feat(alarms): thread an additive AckTime through the native-alarm mirror

MES alarm-status API §6.4 (docs/plans/2026-06-30-mes-alarm-status-api.md,
Phase 1 task 1). MES needs a real AckDT for a triggered alarm, and the mirror
carried acked-vs-unacked but never WHEN. AckTime now rides the whole path:
DCL transition -> AlarmStateChanged -> gRPC AlarmStateUpdate -> site SQLite.

Stamping rule, identical on both protocols: non-null ONLY while the condition
is active AND acknowledged. That single predicate yields all three required
behaviours -- null while unacked, cleared on re-raise (a re-raise arrives
unacknowledged), and no phantom ack on a return-to-normal. The last one is
load-bearing for MxGateway, which maps INACTIVE to Acknowledged = true; without
the active check every clear would claim an ack the system never observed.

Provenance is honest, never fabricated:
  - OPC UA A&C supplies a TRUE ack instant, so we now select it:
    AcknowledgeableConditionType/AckedState/TransitionTime at SelectClause
    index 18, APPENDED so the positional reads at 0-17 keep their meaning.
    Servers that omit the field fall back to the event's own Time.
  - MxAccess Gateway supplies none, so the ack transition's own timestamp is
    used -- accurate to when the system SAW the ack. An ACTIVE_ACKED
    re-subscribe snapshot restores one from LastTransitionTimestamp rather
    than dropping it.
The decision lives in pure mappers (Opc/Mx AlarmMapper.DeriveAckTime), so it is
unit-tested with no live server or gateway.

Additive-only throughout: init-only property on AlarmStateChanged, trailing
optional positional on NativeAlarmTransition (all 14-arg call sites untouched),
proto field 24 (never reusing a number) regenerated via docker/regen-proto.sh
sitestream with the csproj diff verified empty.

Persistence rides native_alarm_state's existing metadata_json blob rather than
a new column -- deliberately. That table is RegisterReplicated in
SiteLocalDbSetup and LocalDb builds its CDC triggers from the column list at
registration time, so an additive JSON property changes no schema, no triggers
and no replication contract; metadata_json is exactly the extension point UA4
introduced for this. Rows written before the field deserialize it as null.

Tests: 4 OPC UA + 6 MxGateway mapper cases, 3 NativeAlarmActor (emit,
failover rehydrate, pre-AckTime row), 1 proto round-trip incl. the null case,
4 Commons additive/back-compat. The OPC UA SelectClause count lock-in moves
18 -> 19 with an index-18 assertion -- intended, the clause is appended, which
is precisely what that guard exists to make visible.
This commit is contained in:
Joseph Doherty
2026-08-01 13:12:04 -04:00
parent 6dc5d94cb9
commit 01bcca992c
19 changed files with 775 additions and 113 deletions
@@ -210,7 +210,12 @@ public class NativeAlarmActor : ReceiveActor
var t = new NativeAlarmTransition(
row.SourceReference, string.Empty, meta.AlarmTypeName, AlarmTransitionKind.Snapshot,
condition, meta.Category, string.Empty, meta.Message, string.Empty, string.Empty,
null, row.LastTransitionAt, meta.CurrentValue, meta.LimitValue);
null, row.LastTransitionAt, meta.CurrentValue, meta.LimitValue,
// MES alarm-status API §6.4: restore the persisted ack instant so an
// acknowledged condition keeps its AckTime across a restart/failover
// instead of reappearing as if it had never been acknowledged. Null on
// pre-AckTime metadata rows (absent JSON property deserializes to null).
AckTime: meta.AckTime);
_alarms[row.SourceReference] = t;
// Rehydration replays last-known state on (re)start — surface it
// upward for the DebugView but do NOT re-log it as a fresh operational
@@ -417,6 +422,12 @@ public class NativeAlarmActor : ReceiveActor
CurrentValue = t.CurrentValue,
LimitValue = t.LimitValue,
NativeSourceCanonicalName = _source.CanonicalName,
// MES alarm-status API §6.4: carried verbatim from the transition — the DCL
// adapter already decided whether an ack instant applies (null while unacked,
// cleared on re-raise), so the mirror never invents or suppresses one. A
// synthesised return-to-normal keeps the last known ack time so the final
// event still reports how the condition ended.
AckTime = t.AckTime,
};
_instanceActor.Tell(change);
@@ -496,7 +507,7 @@ public class NativeAlarmActor : ReceiveActor
t.SourceReference,
JsonSerializer.Serialize(t.Condition),
(string?)JsonSerializer.Serialize(new NativeAlarmMetadata(
t.AlarmTypeName, t.Category, t.Message, t.CurrentValue, t.LimitValue)),
t.AlarmTypeName, t.Category, t.Message, t.CurrentValue, t.LimitValue, t.AckTime)),
t.TransitionTime))
.ToList();
_dirtyUpserts.Clear();
@@ -546,7 +557,20 @@ public class NativeAlarmActor : ReceiveActor
/// Persisted display metadata for a native alarm condition (UA4). Serialized into the
/// <c>metadata_json</c> column so a rehydrated condition renders fully (type/category/message/
/// current+limit values) before the first source snapshot re-supplies it.
///
/// <para>
/// <b>Why AckTime rides here rather than in a new column (MES alarm-status API §6.4).</b>
/// This JSON blob is the established extension point for per-condition fields that must
/// survive a restart — it is exactly what UA4 added <c>metadata_json</c> for. Adding a
/// physical column to <c>native_alarm_state</c> instead would mean altering a table that
/// is <c>RegisterReplicated</c> in <c>SiteLocalDbSetup</c>, whose CDC triggers are built
/// from the column list at registration time; an additive JSON property changes no
/// schema, no triggers and no replication contract. Absent on rows written before this
/// change — <c>System.Text.Json</c> leaves the missing property at <c>null</c>, which is
/// the correct "ack time unknown" value.
/// </para>
/// </summary>
private sealed record NativeAlarmMetadata(
string AlarmTypeName, string Category, string Message, string CurrentValue, string LimitValue);
string AlarmTypeName, string Category, string Message, string CurrentValue, string LimitValue,
DateTimeOffset? AckTime = null);
}