diff --git a/docs/requirements/Component-TemplateEngine.md b/docs/requirements/Component-TemplateEngine.md
index 9bacc5b2..261dc630 100644
--- a/docs/requirements/Component-TemplateEngine.md
+++ b/docs/requirements/Component-TemplateEngine.md
@@ -214,6 +214,8 @@ Each flattened configuration output includes a **revision hash** (computed from
The override flows into the flattened attribute's `DataSourceReference` and therefore participates in the revision hash — changes to an instance's binding overrides re-deploy as expected.
+The revision hash covers the resolved attributes, alarms, scripts, connections, **and native alarm source bindings** (`ResolvedNativeAlarmSource`: canonical name, connection name, source reference, condition filter, and locked state). Native alarm sources were folded into the hash in migration TemplateEngine-011; the hashable projection is populated null-when-empty and the canonical serializer omits null properties, so every native-source-free configuration keeps its byte-identical pre-migration hash. **Migration note:** native-source-bearing instances will report stale once after upgrade; redeploy clears it — deliberate.
+
### On-Demand Validation
The same validation logic is available to Design users in the Central UI without triggering a deployment. This allows template authors to check their work for errors during authoring.
diff --git a/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/RevisionHashService.cs b/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/RevisionHashService.cs
index e863a123..53fd2427 100644
--- a/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/RevisionHashService.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/RevisionHashService.cs
@@ -18,6 +18,16 @@ namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening;
/// by RevisionHashServiceTests.HashableRecords_PropertiesDeclaredAlphabetically.
/// Collections are explicitly sorted by CanonicalName before hashing.
///
+///
+/// MIGRATION (TemplateEngine-011): native alarm source bindings
+/// () now participate in the hash so
+/// that binding edits flag the instance stale. The hashable property is
+/// populated NULL-WHEN-EMPTY and the serializer omits null properties
+/// (), so every
+/// native-source-free configuration keeps its byte-identical pre-migration
+/// hash — no global staleness storm. Native-source-bearing instances will
+/// report stale once after upgrade; redeploy clears it — deliberate.
+///
///
public class RevisionHashService
{
@@ -90,6 +100,19 @@ public class RevisionHashService
ExecutionTimeoutSeconds = s.ExecutionTimeoutSeconds
})
.ToList(),
+ NativeAlarmSources = configuration.NativeAlarmSources is { Count: > 0 }
+ ? configuration.NativeAlarmSources
+ .OrderBy(n => n.CanonicalName, StringComparer.Ordinal)
+ .Select(n => new HashableNativeAlarmSource
+ {
+ CanonicalName = n.CanonicalName,
+ ConditionFilter = n.ConditionFilter,
+ ConnectionName = n.ConnectionName,
+ IsLocked = n.IsLocked,
+ SourceReference = n.SourceReference
+ })
+ .ToList()
+ : null,
Connections = configuration.Connections is { Count: > 0 }
? new SortedDictionary(
configuration.Connections.ToDictionary(
@@ -138,6 +161,13 @@ public class RevisionHashService
///
public string InstanceUniqueName { get; init; } = string.Empty;
///
+ /// Collection of native alarm source bindings in the configuration.
+ /// Null when the configuration carries no native alarm sources so that
+ /// native-source-free configs hash identically to before this field was
+ /// added (see the MIGRATION note on the class summary).
+ ///
+ public List? NativeAlarmSources { get; init; }
+ ///
/// Collection of scripts in the configuration.
///
public List Scripts { get; init; } = [];
@@ -247,6 +277,30 @@ public class RevisionHashService
public string Protocol { get; init; } = string.Empty;
}
+ private sealed record HashableNativeAlarmSource
+ {
+ ///
+ /// The path-qualified canonical name of the native alarm source binding.
+ ///
+ public string CanonicalName { get; init; } = string.Empty;
+ ///
+ /// Optional condition filter; null = mirror all conditions under the source.
+ ///
+ public string? ConditionFilter { get; init; }
+ ///
+ /// The data connection name that owns the alarm feed.
+ ///
+ public string ConnectionName { get; init; } = string.Empty;
+ ///
+ /// Whether the native alarm source binding is locked.
+ ///
+ public bool IsLocked { get; init; }
+ ///
+ /// The source reference (OPC UA SourceNode/notifier nodeId, or MxAccess object/area).
+ ///
+ public string SourceReference { get; init; } = string.Empty;
+ }
+
private sealed record HashableScript
{
///
diff --git a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/RevisionHashServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/RevisionHashServiceTests.cs
index 93fd1323..98e2da22 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/RevisionHashServiceTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/RevisionHashServiceTests.cs
@@ -367,6 +367,103 @@ public class RevisionHashServiceTests
Assert.Equal(_sut.ComputeHash(config1), _sut.ComputeHash(config2));
}
+ [Fact]
+ public void ComputeHash_NativeAlarmSourceChange_ChangesHash()
+ {
+ // TemplateEngine-011: native alarm source bindings are part of the
+ // deployment package, so an edit to a source's binding (here the
+ // SourceReference) MUST change the revision hash and flag the instance
+ // stale. Before this migration native sources were absent from the hash
+ // and this edit went undetected.
+ var baseSource = new ResolvedNativeAlarmSource
+ {
+ CanonicalName = "Boiler.Alarms",
+ ConnectionName = "plc1",
+ SourceReference = "ns=2;s=Boiler",
+ ConditionFilter = null,
+ IsLocked = false
+ };
+ var editedSource = baseSource with { SourceReference = "ns=2;s=Boiler2" };
+
+ var configBefore = new FlattenedConfiguration
+ {
+ InstanceUniqueName = "Instance1",
+ TemplateId = 1,
+ SiteId = 1,
+ NativeAlarmSources = [baseSource]
+ };
+ var configAfter = configBefore with { NativeAlarmSources = [editedSource] };
+
+ Assert.NotEqual(_sut.ComputeHash(configBefore), _sut.ComputeHash(configAfter));
+ }
+
+ [Fact]
+ public void ComputeHash_NativeAlarmSourceIsLockedChange_ChangesHash()
+ {
+ // TemplateEngine-011: IsLocked participates in the hash too.
+ var baseSource = new ResolvedNativeAlarmSource
+ {
+ CanonicalName = "Boiler.Alarms",
+ ConnectionName = "plc1",
+ SourceReference = "ns=2;s=Boiler",
+ IsLocked = false
+ };
+ var editedSource = baseSource with { IsLocked = true };
+
+ var configBefore = new FlattenedConfiguration
+ {
+ InstanceUniqueName = "Instance1",
+ TemplateId = 1,
+ SiteId = 1,
+ NativeAlarmSources = [baseSource]
+ };
+ var configAfter = configBefore with { NativeAlarmSources = [editedSource] };
+
+ Assert.NotEqual(_sut.ComputeHash(configBefore), _sut.ComputeHash(configAfter));
+ }
+
+ [Fact]
+ public void ComputeHash_NativeAlarmSourceOrder_DoesNotAffectHash()
+ {
+ // TemplateEngine-011: sources are sorted by CanonicalName before hashing,
+ // mirroring attributes/alarms/scripts — authoring order is irrelevant.
+ var a = new ResolvedNativeAlarmSource
+ {
+ CanonicalName = "A", ConnectionName = "plc1", SourceReference = "ns=2;s=A"
+ };
+ var b = new ResolvedNativeAlarmSource
+ {
+ CanonicalName = "B", ConnectionName = "plc1", SourceReference = "ns=2;s=B"
+ };
+
+ var config1 = new FlattenedConfiguration
+ {
+ InstanceUniqueName = "Instance1",
+ TemplateId = 1,
+ SiteId = 1,
+ NativeAlarmSources = [a, b]
+ };
+ var config2 = config1 with { NativeAlarmSources = [b, a] };
+
+ Assert.Equal(_sut.ComputeHash(config1), _sut.ComputeHash(config2));
+ }
+
+ [Fact]
+ public void ComputeHash_NoNativeAlarmSources_HashUnchangedFromBaseline()
+ {
+ // TemplateEngine-011 migration surgicality: adding the (null-when-empty)
+ // NativeAlarmSources property to the hashable projection must NOT change
+ // the hash of any native-source-free configuration. The literal below is
+ // the pre-migration hash of CreateConfig("Instance1", "25.0"); pinning it
+ // proves that only instances that HAVE native sources flip stale — there
+ // is no global staleness storm.
+ var config = CreateConfig("Instance1", "25.0");
+
+ Assert.Equal(
+ "sha256:e937bcfa34a146ca6bdb9bda27bdceb204e440a4a6110ca646f7750f37b166cb",
+ _sut.ComputeHash(config));
+ }
+
private static FlattenedConfiguration CreateConfig(string instanceName, string tempValue)
{
return new FlattenedConfiguration