diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Protocol/AlarmCapableProtocols.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Protocol/AlarmCapableProtocols.cs
new file mode 100644
index 00000000..e7bfc151
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Protocol/AlarmCapableProtocols.cs
@@ -0,0 +1,31 @@
+namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
+
+///
+/// Single source of truth for which data-connection protocol strings produce an
+/// adapter that implements (i.e. can
+/// mirror native alarms).
+///
+/// The set MUST stay in sync with the protocols registered against an
+/// alarm-subscribable adapter in the DCL DataConnectionFactory: today the
+/// "OpcUa" adapter (OpcUaDataConnection) and the "MxGateway" adapter
+/// (MxGatewayDataConnection) both implement
+/// . The runtime decision is made in
+/// DataConnectionActor via _adapter is IAlarmSubscribableConnection;
+/// this central-side helper lets the deploy pipeline and Central UI gate
+/// native-alarm-source bindings against the same notion without instantiating an
+/// adapter. Adding a new alarm-capable protocol = register the adapter in the
+/// factory AND add its protocol string here.
+///
+public static class AlarmCapableProtocols
+{
+ ///
+ /// Determines whether a data connection's protocol string resolves to an
+ /// alarm-capable adapter (one implementing ).
+ /// Case-insensitive; null/blank is not alarm-capable.
+ ///
+ /// The data connection protocol string (e.g. "OpcUa").
+ /// true when the protocol's adapter can subscribe native alarms; otherwise false.
+ public static bool IsAlarmCapable(string? protocol) =>
+ string.Equals(protocol, "OpcUa", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(protocol, "MxGateway", StringComparison.OrdinalIgnoreCase);
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/FlatteningPipeline.cs b/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/FlatteningPipeline.cs
index 6cf5be21..39ccbf68 100644
--- a/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/FlatteningPipeline.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/FlatteningPipeline.cs
@@ -1,4 +1,5 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
+using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
@@ -111,8 +112,18 @@ public class FlatteningPipeline : IFlatteningPipeline
ReturnDefinition = s.ReturnDefinition
}).ToList();
+ // Compute the alarm-capable connection-name set so the semantic validator
+ // can gate native-alarm-source bindings. "Alarm-capable" matches the DCL
+ // runtime decision (DataConnectionActor: _adapter is IAlarmSubscribableConnection),
+ // mapped from the protocol string via the shared AlarmCapableProtocols helper.
+ var alarmCapableConnectionNames = dataConnections.Values
+ .Where(c => AlarmCapableProtocols.IsAlarmCapable(c.Protocol))
+ .Select(c => c.Name)
+ .ToHashSet(StringComparer.Ordinal);
+
// Validate
- var validation = _validationService.Validate(config, resolvedSharedScripts);
+ var validation = _validationService.Validate(
+ config, resolvedSharedScripts, alarmCapableConnectionNames);
// Compute revision hash
var hash = _revisionHashService.ComputeHash(config);
diff --git a/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/ValidationService.cs b/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/ValidationService.cs
index 039dbeb1..7093f506 100644
--- a/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/ValidationService.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/ValidationService.cs
@@ -45,8 +45,18 @@ public class ValidationService
///
/// The flattened configuration to validate.
/// Optional list of shared scripts for validation context.
+ ///
+ /// Optional set of site data-connection names whose protocol resolves to an
+ /// alarm-capable adapter (see
+ /// ). When supplied,
+ /// the semantic validator gates every native-alarm-source binding against it.
+ /// null skips the capability check (its absence makes the check inert).
+ ///
/// A merged aggregating all pipeline stage outcomes.
- public ValidationResult Validate(FlattenedConfiguration configuration, IReadOnlyList? sharedScripts = null)
+ public ValidationResult Validate(
+ FlattenedConfiguration configuration,
+ IReadOnlyList? sharedScripts = null,
+ IReadOnlySet? alarmCapableConnectionNames = null)
{
ArgumentNullException.ThrowIfNull(configuration);
@@ -59,7 +69,7 @@ public class ValidationService
ValidateScriptTriggerReferences(configuration),
ValidateExpressionTriggers(configuration),
ValidateConnectionBindingCompleteness(configuration),
- _semanticValidator.Validate(configuration, sharedScripts)
+ _semanticValidator.Validate(configuration, sharedScripts, alarmCapableConnectionNames)
};
return ValidationResult.Merge(results.ToArray());
diff --git a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/FlatteningPipelineNativeAlarmCapabilityTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/FlatteningPipelineNativeAlarmCapabilityTests.cs
new file mode 100644
index 00000000..b0d6a4de
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/FlatteningPipelineNativeAlarmCapabilityTests.cs
@@ -0,0 +1,96 @@
+using NSubstitute;
+using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
+using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
+using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
+using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
+using ZB.MOM.WW.ScadaBridge.DeploymentManager;
+using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening;
+using ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation;
+
+namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests;
+
+///
+/// M2.1 (#22): proves the FlatteningPipeline actually computes the alarm-capable
+/// connection set from the loaded site data connections and threads it through
+/// ValidationService → SemanticValidator. Before the fix the pipeline loaded the
+/// connections but never passed the capable set, so the native-alarm-source
+/// capability check (built but inert) never ran in production — a source bound to
+/// a non-alarm-capable connection deployed silently.
+///
+public class FlatteningPipelineNativeAlarmCapabilityTests
+{
+ private const int InstanceId = 1;
+ private const int TemplateId = 10;
+ private const int SiteId = 100;
+
+ private readonly ITemplateEngineRepository _templateRepo = Substitute.For();
+ private readonly ISiteRepository _siteRepo = Substitute.For();
+ private readonly FlatteningPipeline _sut;
+
+ public FlatteningPipelineNativeAlarmCapabilityTests()
+ {
+ _sut = new FlatteningPipeline(
+ _templateRepo,
+ _siteRepo,
+ new FlatteningService(),
+ new ValidationService(),
+ new RevisionHashService());
+ }
+
+ ///
+ /// Seeds a single-template chain whose only template carries one native alarm
+ /// source bound to , and a site that owns a
+ /// single data connection of .
+ ///
+ private void Arrange(string connectionName, string connectionProtocol, string boundConnectionName)
+ {
+ var template = new Template("Tank") { Id = TemplateId };
+ template.NativeAlarmSources.Add(new TemplateNativeAlarmSource("BoilerAlarms")
+ {
+ ConnectionName = boundConnectionName,
+ SourceReference = "ns=2;s=Boiler",
+ });
+
+ var instance = new Instance("Tank-01") { Id = InstanceId, TemplateId = TemplateId, SiteId = SiteId };
+
+ _templateRepo.GetInstanceByIdAsync(InstanceId, Arg.Any()).Returns(instance);
+ _templateRepo.GetTemplateWithChildrenAsync(TemplateId, Arg.Any()).Returns(template);
+ _templateRepo.GetCompositionsByTemplateIdAsync(TemplateId, Arg.Any())
+ .Returns([]);
+ _templateRepo.GetAllSharedScriptsAsync(Arg.Any())
+ .Returns([]);
+
+ var connection = new DataConnection(connectionName, connectionProtocol, SiteId) { Id = 7 };
+ _siteRepo.GetDataConnectionsBySiteIdAsync(SiteId, Arg.Any())
+ .Returns([connection]);
+ }
+
+ [Fact]
+ public async Task FlattenAndValidate_NativeAlarmSourceOnNonAlarmCapableConnection_ReportsCapabilityError()
+ {
+ // A "Modbus" connection is NOT alarm-capable (no IAlarmSubscribableConnection adapter).
+ Arrange(connectionName: "PlantBus", connectionProtocol: "Modbus", boundConnectionName: "PlantBus");
+
+ var result = await _sut.FlattenAndValidateAsync(InstanceId);
+
+ Assert.True(result.IsSuccess);
+ Assert.Contains(result.Value.Validation.Errors,
+ e => e.Category == ValidationCategory.NativeAlarmSourceInvalid
+ && e.Message.Contains("alarm-capable"));
+ }
+
+ [Theory]
+ [InlineData("OpcUa")]
+ [InlineData("MxGateway")]
+ public async Task FlattenAndValidate_NativeAlarmSourceOnAlarmCapableConnection_NoCapabilityError(string protocol)
+ {
+ Arrange(connectionName: "Boiler", connectionProtocol: protocol, boundConnectionName: "Boiler");
+
+ var result = await _sut.FlattenAndValidateAsync(InstanceId);
+
+ Assert.True(result.IsSuccess);
+ Assert.DoesNotContain(result.Value.Validation.Errors,
+ e => e.Category == ValidationCategory.NativeAlarmSourceInvalid);
+ }
+}