diff --git a/docs/requirements/Component-TemplateEngine.md b/docs/requirements/Component-TemplateEngine.md
index b2bd9835..9bacc5b2 100644
--- a/docs/requirements/Component-TemplateEngine.md
+++ b/docs/requirements/Component-TemplateEngine.md
@@ -143,7 +143,7 @@ The `FlatteningService` resolves native alarm sources alongside alarms, emitting
- **Inheritance**: resolution walks the chain base → derived; a derived-level source wins over the base unless the base level locked it.
- **Composition**: a composed module's sources are path-qualified to the canonical name `[ModuleInstanceName].[Name]`, subject to the same naming-collision checks as other members. Because `SourceReference` is a raw connection address (not an attribute path), composition performs **no attribute-reference rewriting** on it.
-- **Instance overrides**: `InstanceNativeAlarmSourceOverride` applies its non-null fields (`ConnectionNameOverride`, `SourceReferenceOverride`, `ConditionFilterOverride`) over the inherited/composed result and sets `Source = Override`.
+- **Instance overrides**: `InstanceNativeAlarmSourceOverride` applies its non-null fields (`ConnectionNameOverride`, `SourceReferenceOverride`, `ConditionFilterOverride`) over the inherited/composed result and sets `Source = Override`. The resolved `ResolvedNativeAlarmSource` carries an `IsLocked` flag; a **locked** source is left untouched by any instance override (the flattener skips it, exactly mirroring the locked-attribute and locked-alarm rules). The lock is enforced at two points: the `FlatteningService` ignores overrides on a locked source, and the `ManagementActor` `SetInstanceNativeAlarmSourceOverride` handler rejects the command outright (and also rejects an override whose canonical name does not resolve, preventing dangling overrides).
## Inheritance Resolve — Authoring View
diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Flattening/FlattenedConfiguration.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Flattening/FlattenedConfiguration.cs
index 74e6d9f9..b9fb2efd 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Flattening/FlattenedConfiguration.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Flattening/FlattenedConfiguration.cs
@@ -146,6 +146,12 @@ public sealed record ResolvedNativeAlarmSource
public string? ConditionFilter { get; init; }
/// Gets the source of this binding: "Template", "Inherited", "Composed", or "Override".
public string Source { get; init; } = "Template";
+ ///
+ /// Gets whether this native alarm source is locked at the template level. A locked
+ /// source cannot be repointed by an instance override — mirrors the attribute/alarm
+ /// lock semantics.
+ ///
+ public bool IsLocked { get; init; }
}
///
diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs
index d209787a..b3164b9d 100644
--- a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs
@@ -885,6 +885,27 @@ public class ManagementActor : ReceiveActor
await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId);
var repo = sp.GetRequiredService();
+ // Enforce template-level locks (and reject dangling overrides) before persisting.
+ // Flatten the instance (script compilation skipped — a lock check does not need it)
+ // and look up the resolved source by canonical name: a locked source cannot be
+ // overridden, and an unresolved name would create a dead override the flattener
+ // silently drops.
+ var pipeline = sp.GetRequiredService();
+ var flattenResult = await pipeline.FlattenAndValidateAsync(
+ cmd.InstanceId, default, validateScripts: false);
+ if (flattenResult.IsFailure)
+ throw new ManagementCommandException(
+ $"Cannot set native alarm source override: the instance could not be flattened ({flattenResult.Error}).");
+
+ var resolvedSource = flattenResult.Value.Configuration.NativeAlarmSources
+ .FirstOrDefault(s => string.Equals(s.CanonicalName, cmd.SourceCanonicalName, StringComparison.Ordinal));
+ if (resolvedSource == null)
+ throw new ManagementCommandException(
+ $"Native alarm source '{cmd.SourceCanonicalName}' does not resolve for this instance and cannot be overridden.");
+ if (resolvedSource.IsLocked)
+ throw new ManagementCommandException(
+ $"Native alarm source '{cmd.SourceCanonicalName}' is locked at the template level and cannot be overridden.");
+
var existing = await repo.GetNativeAlarmSourceOverrideAsync(cmd.InstanceId, cmd.SourceCanonicalName);
if (existing == null)
{
diff --git a/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/FlatteningService.cs b/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/FlatteningService.cs
index 5a685113..a49d64fe 100644
--- a/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/FlatteningService.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/FlatteningService.cs
@@ -718,7 +718,8 @@ public class FlatteningService
ConnectionName = binding.ConnectionName,
SourceReference = binding.SourceReference,
ConditionFilter = binding.ConditionFilter,
- Source = source
+ Source = source,
+ IsLocked = binding.IsLocked || lockedNames.Contains(binding.Name)
};
if (binding.IsLocked)
@@ -811,6 +812,9 @@ public class FlatteningService
if (!sources.TryGetValue(ovr.SourceCanonicalName, out var existing))
continue; // Cannot add new bindings via overrides.
+ if (existing.IsLocked)
+ continue; // Locked at the template level — instance override is ignored.
+
sources[ovr.SourceCanonicalName] = existing with
{
ConnectionName = ovr.ConnectionNameOverride ?? existing.ConnectionName,
diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs
index 256d8426..02a35321 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs
@@ -12,6 +12,8 @@ using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
+using ZB.MOM.WW.ScadaBridge.DeploymentManager;
using ZB.MOM.WW.ScadaBridge.ManagementService;
using ZB.MOM.WW.ScadaBridge.TemplateEngine;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services;
@@ -232,6 +234,39 @@ public class ManagementActorTests : TestKit, IDisposable
Assert.Contains("Pump1", response.JsonData);
}
+ [Fact]
+ public void SetNativeAlarmSourceOverride_OnLockedSource_ReturnsManagementError()
+ {
+ // #05-T13: the ManagementActor must reject an override targeting a source
+ // that is locked at the template level, before persisting anything.
+ var pipeline = Substitute.For();
+ var flattened = new FlattenedConfiguration
+ {
+ NativeAlarmSources = new List
+ {
+ new() { CanonicalName = "Pressure", IsLocked = true, Source = "Template" }
+ }
+ };
+ pipeline.FlattenAndValidateAsync(10, Arg.Any(), Arg.Any())
+ .Returns(Result.Success(
+ new FlatteningPipelineResult(flattened, "hash", ValidationResult.Success())));
+ _services.AddScoped(_ => pipeline);
+
+ var actor = CreateActor();
+ var envelope = Envelope(
+ new SetInstanceNativeAlarmSourceOverrideCommand(10, "Pressure", "OtherOpc", null, null),
+ "Deployer");
+
+ actor.Tell(envelope);
+
+ var response = ExpectMsg(TimeSpan.FromSeconds(5));
+ Assert.Equal(envelope.CorrelationId, response.CorrelationId);
+ Assert.Contains("locked", response.Error, StringComparison.OrdinalIgnoreCase);
+ // The override must NOT be persisted.
+ _templateRepo.DidNotReceive().AddInstanceNativeAlarmSourceOverrideAsync(
+ Arg.Any());
+ }
+
// ========================================================================
// 5. Error handling test -- service throws exception
// ========================================================================
@@ -2479,7 +2514,21 @@ public class ManagementActorTests : TestKit, IDisposable
[Fact]
public void SetInstanceNativeAlarmSourceOverride_WithDeploymentRole_ReturnsSuccess()
{
- // No prior override → Add path.
+ // No prior override → Add path. The source resolves and is UNLOCKED, so the
+ // lock guard (#05-T13) lets the override through.
+ var pipeline = Substitute.For();
+ var flattened = new FlattenedConfiguration
+ {
+ NativeAlarmSources = new List
+ {
+ new() { CanonicalName = "Pressure", IsLocked = false, Source = "Template" }
+ }
+ };
+ pipeline.FlattenAndValidateAsync(1, Arg.Any(), Arg.Any())
+ .Returns(Result.Success(
+ new FlatteningPipelineResult(flattened, "hash", ValidationResult.Success())));
+ _services.AddScoped(_ => pipeline);
+
var actor = CreateActor();
var envelope = Envelope(
new SetInstanceNativeAlarmSourceOverrideCommand(1, "Pressure", "Opc2", "ns=2;s=NEW", null),
diff --git a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/FlatteningServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/FlatteningServiceTests.cs
index 9eb2f31c..803b7bfd 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/FlatteningServiceTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/FlatteningServiceTests.cs
@@ -789,6 +789,32 @@ public class FlatteningServiceTests
Assert.Equal("Override", result.Value.NativeAlarmSources[0].Source);
}
+ [Fact]
+ public void Flatten_LockedNativeAlarmSource_IgnoresInstanceOverride()
+ {
+ // #05-T13: a native alarm source locked at the template level must not be
+ // repointed by an instance override — the resolved source keeps the template
+ // values and its Source stays "Template" (mirrors attribute/alarm lock rules).
+ var template = CreateTemplate(1, "Base");
+ template.NativeAlarmSources.Add(new TemplateNativeAlarmSource("Pressure")
+ { ConnectionName = "Opc", SourceReference = "ns=2;s=DEFAULT", IsLocked = true });
+ var instance = CreateInstance();
+ instance.NativeAlarmSourceOverrides.Add(new InstanceNativeAlarmSourceOverride("Pressure")
+ { ConnectionNameOverride = "OtherOpc", SourceReferenceOverride = "ns=2;s=Tank07" });
+
+ var result = _sut.Flatten(instance, [template],
+ new Dictionary>(),
+ new Dictionary>(),
+ new Dictionary());
+
+ Assert.True(result.IsSuccess);
+ var src = result.Value.NativeAlarmSources[0];
+ Assert.Equal("Opc", src.ConnectionName); // override ignored
+ Assert.Equal("ns=2;s=DEFAULT", src.SourceReference); // override ignored
+ Assert.Equal("Template", src.Source);
+ Assert.True(src.IsLocked);
+ }
+
// ── MV-4: ElementDataType carried through flatten/override ────────────
[Fact]