diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/EfAlarmConditionStateStore.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/EfAlarmConditionStateStore.cs
new file mode 100644
index 00000000..baf697fb
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/EfAlarmConditionStateStore.cs
@@ -0,0 +1,238 @@
+using System.Collections.Immutable;
+using System.Text.Json;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+using ZB.MOM.WW.OtOpcUa.Configuration;
+using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
+using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
+
+namespace ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
+
+///
+/// Production-side backed by the
+/// table in the central config DB. Unlike the narrower
+/// (which only persists AckedState for the actor's
+/// 3-state enum), this store maps the full Part 9 —
+/// Enabled / Acked / Confirmed / Shelving + the ack/confirm audit trail + operator
+/// comments.
+///
+///
+///
+/// ActiveState is NOT persisted — the entity has no Active column. On
+/// it is restored as ;
+/// the engine re-derives it from the live predicate on startup (Phase 7 decision #14).
+///
+///
+/// LastTransitionUtc ↔ UpdatedAtUtc: the table has no dedicated transition
+/// column, so LastTransitionUtc is written into the row-write
+/// on save and read back from it on load.
+///
+///
+/// LastActiveUtc / LastClearedUtc are transient — they have no columns and
+/// default to null on load (they re-derive from the predicate alongside Active).
+///
+///
+/// serializes to/from
+/// via System.Text.Json. An empty list
+/// round-trips as "[]" (matching the entity default + ).
+///
+///
+public sealed class EfAlarmConditionStateStore : IAlarmStateStore
+{
+ private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
+
+ private readonly IDbContextFactory _dbFactory;
+ private readonly ILogger _logger;
+
+ /// Initializes a new instance of the .
+ /// The factory for creating config database contexts.
+ /// The logger instance.
+ public EfAlarmConditionStateStore(
+ IDbContextFactory dbFactory,
+ ILogger logger)
+ {
+ _dbFactory = dbFactory;
+ _logger = logger;
+ }
+
+ ///
+ public async Task LoadAsync(string alarmId, CancellationToken ct)
+ {
+ using var db = await _dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false);
+ var row = await db.ScriptedAlarmStates.AsNoTracking()
+ .FirstOrDefaultAsync(r => r.ScriptedAlarmId == alarmId, ct)
+ .ConfigureAwait(false);
+ return row is null ? null : MapToState(row);
+ }
+
+ ///
+ public async Task> LoadAllAsync(CancellationToken ct)
+ {
+ using var db = await _dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false);
+ var rows = await db.ScriptedAlarmStates.AsNoTracking()
+ .ToListAsync(ct)
+ .ConfigureAwait(false);
+ return rows.Select(MapToState).ToArray();
+ }
+
+ ///
+ public async Task SaveAsync(AlarmConditionState state, CancellationToken ct)
+ {
+ using var db = await _dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false);
+ var row = await db.ScriptedAlarmStates
+ .FirstOrDefaultAsync(r => r.ScriptedAlarmId == state.AlarmId, ct)
+ .ConfigureAwait(false);
+
+ if (row is null)
+ {
+ row = new ScriptedAlarmState
+ {
+ ScriptedAlarmId = state.AlarmId,
+ EnabledState = MapEnabledToColumn(state.Enabled),
+ AckedState = MapAckedToColumn(state.Acked),
+ ConfirmedState = MapConfirmedToColumn(state.Confirmed),
+ ShelvingState = MapShelvingToColumn(state.Shelving.Kind),
+ };
+ ApplyState(row, state);
+ db.ScriptedAlarmStates.Add(row);
+ }
+ else
+ {
+ ApplyState(row, state);
+ }
+
+ try
+ {
+ await db.SaveChangesAsync(ct).ConfigureAwait(false);
+ }
+ catch (DbUpdateConcurrencyException ex)
+ {
+ // Two writers racing to save the same alarm is benign — last writer wins on
+ // UpdatedAtUtc and the next transition writes again. Log + drop so a race never
+ // crashes the engine.
+ _logger.LogDebug(ex,
+ "EfAlarmConditionStateStore: concurrency conflict for {AlarmId}; dropping save",
+ state.AlarmId);
+ }
+ }
+
+ ///
+ public async Task RemoveAsync(string alarmId, CancellationToken ct)
+ {
+ using var db = await _dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false);
+ var row = await db.ScriptedAlarmStates
+ .FirstOrDefaultAsync(r => r.ScriptedAlarmId == alarmId, ct)
+ .ConfigureAwait(false);
+ if (row is null) return;
+
+ db.ScriptedAlarmStates.Remove(row);
+ await db.SaveChangesAsync(ct).ConfigureAwait(false);
+ }
+
+ private static void ApplyState(ScriptedAlarmState row, AlarmConditionState state)
+ {
+ row.EnabledState = MapEnabledToColumn(state.Enabled);
+ row.AckedState = MapAckedToColumn(state.Acked);
+ row.ConfirmedState = MapConfirmedToColumn(state.Confirmed);
+ row.ShelvingState = MapShelvingToColumn(state.Shelving.Kind);
+ row.ShelvingExpiresUtc = state.Shelving.UnshelveAtUtc;
+ row.LastAckUser = state.LastAckUser;
+ row.LastAckComment = state.LastAckComment;
+ row.LastAckUtc = state.LastAckUtc;
+ row.LastConfirmUser = state.LastConfirmUser;
+ row.LastConfirmComment = state.LastConfirmComment;
+ row.LastConfirmUtc = state.LastConfirmUtc;
+ row.CommentsJson = SerializeComments(state.Comments);
+ // No dedicated transition column — persist LastTransitionUtc into UpdatedAtUtc.
+ row.UpdatedAtUtc = state.LastTransitionUtc;
+ }
+
+ private static AlarmConditionState MapToState(ScriptedAlarmState row) => new(
+ AlarmId: row.ScriptedAlarmId,
+ Enabled: string.Equals(row.EnabledState, "Disabled", StringComparison.Ordinal)
+ ? AlarmEnabledState.Disabled
+ : AlarmEnabledState.Enabled,
+ // Active is not persisted — the engine re-derives it from the predicate at startup.
+ Active: AlarmActiveState.Inactive,
+ Acked: string.Equals(row.AckedState, "Acknowledged", StringComparison.Ordinal)
+ ? AlarmAckedState.Acknowledged
+ : AlarmAckedState.Unacknowledged,
+ Confirmed: string.Equals(row.ConfirmedState, "Confirmed", StringComparison.Ordinal)
+ ? AlarmConfirmedState.Confirmed
+ : AlarmConfirmedState.Unconfirmed,
+ Shelving: new ShelvingState(MapShelvingFromColumn(row.ShelvingState), row.ShelvingExpiresUtc),
+ // No transition column — UpdatedAtUtc carries the last transition timestamp.
+ LastTransitionUtc: row.UpdatedAtUtc,
+ // LastActiveUtc / LastClearedUtc have no columns — they re-derive with Active, so null on load.
+ LastActiveUtc: null,
+ LastClearedUtc: null,
+ LastAckUtc: row.LastAckUtc,
+ LastAckUser: row.LastAckUser,
+ LastAckComment: row.LastAckComment,
+ LastConfirmUtc: row.LastConfirmUtc,
+ LastConfirmUser: row.LastConfirmUser,
+ LastConfirmComment: row.LastConfirmComment,
+ Comments: DeserializeComments(row.CommentsJson));
+
+ private static string MapEnabledToColumn(AlarmEnabledState enabled)
+ => enabled == AlarmEnabledState.Enabled ? "Enabled" : "Disabled";
+
+ private static string MapAckedToColumn(AlarmAckedState acked)
+ => acked == AlarmAckedState.Acknowledged ? "Acknowledged" : "Unacknowledged";
+
+ private static string MapConfirmedToColumn(AlarmConfirmedState confirmed)
+ => confirmed == AlarmConfirmedState.Confirmed ? "Confirmed" : "Unconfirmed";
+
+ private static string MapShelvingToColumn(ShelvingKind kind) => kind switch
+ {
+ ShelvingKind.OneShot => "OneShotShelved",
+ ShelvingKind.Timed => "TimedShelved",
+ _ => "Unshelved",
+ };
+
+ private static ShelvingKind MapShelvingFromColumn(string column) => column switch
+ {
+ "OneShotShelved" => ShelvingKind.OneShot,
+ "TimedShelved" => ShelvingKind.Timed,
+ _ => ShelvingKind.Unshelved,
+ };
+
+ private static string SerializeComments(ImmutableList comments)
+ {
+ if (comments.IsEmpty) return "[]";
+ var dtos = comments.Select(c => new CommentDto
+ {
+ TimestampUtc = c.TimestampUtc,
+ User = c.User,
+ Kind = c.Kind,
+ Text = c.Text,
+ });
+ return JsonSerializer.Serialize(dtos, JsonOptions);
+ }
+
+ private static ImmutableList DeserializeComments(string? json)
+ {
+ if (string.IsNullOrWhiteSpace(json)) return ImmutableList.Empty;
+ var dtos = JsonSerializer.Deserialize>(json, JsonOptions);
+ if (dtos is null || dtos.Count == 0) return ImmutableList.Empty;
+ return dtos
+ .Select(d => new AlarmComment(d.TimestampUtc, d.User ?? string.Empty, d.Kind ?? string.Empty, d.Text ?? string.Empty))
+ .ToImmutableList();
+ }
+
+ /// Stable on-disk shape for a persisted in CommentsJson.
+ private sealed class CommentDto
+ {
+ /// When the comment was recorded (UTC).
+ public DateTime TimestampUtc { get; set; }
+
+ /// Identity of the actor that wrote the comment.
+ public string? User { get; set; }
+
+ /// Human-readable classification of the comment (Acknowledge, Confirm, …).
+ public string? Kind { get; set; }
+
+ /// Operator-supplied or engine-generated comment text.
+ public string? Text { get; set; }
+ }
+}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/EfAlarmConditionStateStoreTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/EfAlarmConditionStateStoreTests.cs
new file mode 100644
index 00000000..00e89ef0
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/EfAlarmConditionStateStoreTests.cs
@@ -0,0 +1,244 @@
+using System.Collections.Immutable;
+using Microsoft.Extensions.Logging.Abstractions;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
+using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
+using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
+
+namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.ScriptedAlarms;
+
+///
+/// Round-trip + upsert + lifecycle coverage for ,
+/// the EF-backed over the ScriptedAlarmState table.
+///
+public sealed class EfAlarmConditionStateStoreTests : RuntimeActorTestBase
+{
+ private static EfAlarmConditionStateStore NewStore(out Microsoft.EntityFrameworkCore.IDbContextFactory db)
+ {
+ db = NewInMemoryDbFactory();
+ return new EfAlarmConditionStateStore(db, NullLogger.Instance);
+ }
+
+ private static AlarmConditionState Sample(
+ string alarmId,
+ AlarmActiveState active = AlarmActiveState.Inactive,
+ ShelvingState? shelving = null,
+ ImmutableList? comments = null)
+ {
+ var t = new DateTime(2026, 06, 10, 12, 00, 00, DateTimeKind.Utc);
+ return new AlarmConditionState(
+ AlarmId: alarmId,
+ Enabled: AlarmEnabledState.Disabled,
+ Active: active,
+ Acked: AlarmAckedState.Unacknowledged,
+ Confirmed: AlarmConfirmedState.Unconfirmed,
+ Shelving: shelving ?? new ShelvingState(ShelvingKind.Timed, t.AddMinutes(30)),
+ LastTransitionUtc: t,
+ LastActiveUtc: t.AddMinutes(-5),
+ LastClearedUtc: t.AddMinutes(-1),
+ LastAckUtc: t.AddMinutes(-2),
+ LastAckUser: "jane",
+ LastAckComment: "ack-comment",
+ LastConfirmUtc: t.AddMinutes(-3),
+ LastConfirmUser: "bob",
+ LastConfirmComment: "confirm-comment",
+ Comments: comments ?? ImmutableList.Empty);
+ }
+
+ /// Save then load round-trips every persisted operator-state + audit field.
+ [Fact]
+ public async Task SaveAsync_then_LoadAsync_round_trips_persisted_fields()
+ {
+ var store = NewStore(out _);
+ var state = Sample("alarm-1");
+
+ await store.SaveAsync(state, CancellationToken.None);
+ var loaded = await store.LoadAsync("alarm-1", CancellationToken.None);
+
+ loaded.ShouldNotBeNull();
+ loaded.AlarmId.ShouldBe("alarm-1");
+ loaded.Enabled.ShouldBe(AlarmEnabledState.Disabled);
+ loaded.Acked.ShouldBe(AlarmAckedState.Unacknowledged);
+ loaded.Confirmed.ShouldBe(AlarmConfirmedState.Unconfirmed);
+ loaded.Shelving.Kind.ShouldBe(ShelvingKind.Timed);
+ loaded.Shelving.UnshelveAtUtc.ShouldBe(state.Shelving.UnshelveAtUtc);
+ loaded.LastTransitionUtc.ShouldBe(state.LastTransitionUtc);
+ loaded.LastAckUtc.ShouldBe(state.LastAckUtc);
+ loaded.LastAckUser.ShouldBe("jane");
+ loaded.LastAckComment.ShouldBe("ack-comment");
+ loaded.LastConfirmUtc.ShouldBe(state.LastConfirmUtc);
+ loaded.LastConfirmUser.ShouldBe("bob");
+ loaded.LastConfirmComment.ShouldBe("confirm-comment");
+ }
+
+ /// Loading an id that was never saved returns null.
+ [Fact]
+ public async Task LoadAsync_for_unknown_id_returns_null()
+ {
+ var store = NewStore(out _);
+
+ var loaded = await store.LoadAsync("never-saved", CancellationToken.None);
+
+ loaded.ShouldBeNull();
+ }
+
+ /// Active is not persisted — a saved Active state loads back as Inactive.
+ [Fact]
+ public async Task Active_is_ignored_on_load_and_defaults_to_Inactive()
+ {
+ var store = NewStore(out _);
+ await store.SaveAsync(Sample("alarm-active", active: AlarmActiveState.Active), CancellationToken.None);
+
+ var loaded = await store.LoadAsync("alarm-active", CancellationToken.None);
+
+ loaded.ShouldNotBeNull();
+ loaded.Active.ShouldBe(AlarmActiveState.Inactive);
+ }
+
+ /// LastActiveUtc / LastClearedUtc have no columns and default to null on load.
+ [Fact]
+ public async Task Derived_timestamps_default_to_null_on_load()
+ {
+ var store = NewStore(out _);
+ await store.SaveAsync(Sample("alarm-derived"), CancellationToken.None);
+
+ var loaded = await store.LoadAsync("alarm-derived", CancellationToken.None);
+
+ loaded.ShouldNotBeNull();
+ loaded.LastActiveUtc.ShouldBeNull();
+ loaded.LastClearedUtc.ShouldBeNull();
+ }
+
+ /// Unshelved round-trips with a null UnshelveAtUtc.
+ [Fact]
+ public async Task Unshelved_round_trips()
+ {
+ var store = NewStore(out _);
+ await store.SaveAsync(
+ Sample("alarm-unshelved", shelving: ShelvingState.Unshelved),
+ CancellationToken.None);
+
+ var loaded = await store.LoadAsync("alarm-unshelved", CancellationToken.None);
+
+ loaded.ShouldNotBeNull();
+ loaded.Shelving.Kind.ShouldBe(ShelvingKind.Unshelved);
+ loaded.Shelving.UnshelveAtUtc.ShouldBeNull();
+ }
+
+ /// OneShot shelving round-trips.
+ [Fact]
+ public async Task OneShot_shelving_round_trips()
+ {
+ var store = NewStore(out _);
+ await store.SaveAsync(
+ Sample("alarm-oneshot", shelving: new ShelvingState(ShelvingKind.OneShot, null)),
+ CancellationToken.None);
+
+ var loaded = await store.LoadAsync("alarm-oneshot", CancellationToken.None);
+
+ loaded.ShouldNotBeNull();
+ loaded.Shelving.Kind.ShouldBe(ShelvingKind.OneShot);
+ loaded.Shelving.UnshelveAtUtc.ShouldBeNull();
+ }
+
+ /// The append-only comment trail round-trips all four fields per comment.
+ [Fact]
+ public async Task Comments_round_trip_all_fields()
+ {
+ var store = NewStore(out _);
+ var c1 = new AlarmComment(
+ new DateTime(2026, 06, 10, 10, 00, 00, DateTimeKind.Utc), "jane", "Acknowledge", "checking pump");
+ var c2 = new AlarmComment(
+ new DateTime(2026, 06, 10, 11, 30, 00, DateTimeKind.Utc), "bob", "Confirm", "all clear");
+ var comments = ImmutableList.Create(c1, c2);
+
+ await store.SaveAsync(Sample("alarm-comments", comments: comments), CancellationToken.None);
+ var loaded = await store.LoadAsync("alarm-comments", CancellationToken.None);
+
+ loaded.ShouldNotBeNull();
+ loaded.Comments.Count.ShouldBe(2);
+ loaded.Comments[0].TimestampUtc.ShouldBe(c1.TimestampUtc);
+ loaded.Comments[0].User.ShouldBe("jane");
+ loaded.Comments[0].Kind.ShouldBe("Acknowledge");
+ loaded.Comments[0].Text.ShouldBe("checking pump");
+ loaded.Comments[1].TimestampUtc.ShouldBe(c2.TimestampUtc);
+ loaded.Comments[1].User.ShouldBe("bob");
+ loaded.Comments[1].Kind.ShouldBe("Confirm");
+ loaded.Comments[1].Text.ShouldBe("all clear");
+ }
+
+ /// An empty comment list round-trips as an empty list.
+ [Fact]
+ public async Task Empty_comments_round_trip()
+ {
+ var store = NewStore(out _);
+ await store.SaveAsync(
+ Sample("alarm-empty-comments", comments: ImmutableList.Empty),
+ CancellationToken.None);
+
+ var loaded = await store.LoadAsync("alarm-empty-comments", CancellationToken.None);
+
+ loaded.ShouldNotBeNull();
+ loaded.Comments.ShouldBeEmpty();
+ }
+
+ /// Saving the same id twice is an upsert — one row, latest values win.
+ [Fact]
+ public async Task SaveAsync_twice_upserts_latest()
+ {
+ var store = NewStore(out var db);
+ var first = Sample("alarm-upsert") with { LastAckUser = "first-user", Enabled = AlarmEnabledState.Enabled };
+ var second = Sample("alarm-upsert") with { LastAckUser = "second-user", Enabled = AlarmEnabledState.Disabled };
+
+ await store.SaveAsync(first, CancellationToken.None);
+ await store.SaveAsync(second, CancellationToken.None);
+
+ using (var ctx = db.CreateDbContext())
+ {
+ ctx.ScriptedAlarmStates.Count(r => r.ScriptedAlarmId == "alarm-upsert").ShouldBe(1);
+ }
+
+ var loaded = await store.LoadAsync("alarm-upsert", CancellationToken.None);
+ loaded.ShouldNotBeNull();
+ loaded.LastAckUser.ShouldBe("second-user");
+ loaded.Enabled.ShouldBe(AlarmEnabledState.Disabled);
+ }
+
+ /// LoadAllAsync returns every saved state.
+ [Fact]
+ public async Task LoadAllAsync_returns_all_saved_states()
+ {
+ var store = NewStore(out _);
+ await store.SaveAsync(Sample("a-1"), CancellationToken.None);
+ await store.SaveAsync(Sample("a-2"), CancellationToken.None);
+ await store.SaveAsync(Sample("a-3"), CancellationToken.None);
+
+ var all = await store.LoadAllAsync(CancellationToken.None);
+
+ all.Select(s => s.AlarmId).OrderBy(x => x).ShouldBe(new[] { "a-1", "a-2", "a-3" });
+ }
+
+ /// RemoveAsync deletes a row so a subsequent load returns null; others survive.
+ [Fact]
+ public async Task RemoveAsync_deletes_one_and_LoadAsync_returns_null()
+ {
+ var store = NewStore(out _);
+ await store.SaveAsync(Sample("keep"), CancellationToken.None);
+ await store.SaveAsync(Sample("drop"), CancellationToken.None);
+
+ await store.RemoveAsync("drop", CancellationToken.None);
+
+ (await store.LoadAsync("drop", CancellationToken.None)).ShouldBeNull();
+ (await store.LoadAsync("keep", CancellationToken.None)).ShouldNotBeNull();
+ }
+
+ /// Removing an unknown id is a no-op (does not throw).
+ [Fact]
+ public async Task RemoveAsync_for_unknown_id_is_noop()
+ {
+ var store = NewStore(out _);
+
+ await Should.NotThrowAsync(() => store.RemoveAsync("ghost", CancellationToken.None));
+ }
+}