# PLAN-R2-05 — Templates, Deployment & Transport Round-2 Fix Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task. **Goal:** Close the six NEW findings (N1–N6) in the round-2 report `archreview/05-templates-deployment-transport.md` (2026-07-12, re-review at HEAD `8c888f13`): the Expression-trigger Roslyn-compile leftover on read-only staleness paths (N1 — the sole Medium, the surviving member class of round-1 #12), the first-error-only trigger syntax message (N2 — the trigger twin of the T24 fix), the change-bus publisher skipping `Add` resolutions (N3), silently-inert locked-member instance overrides on import (N4), the instance-alarm-override gap in the import trust gate (N5), and the unvalidated `MaxConcurrentImportSessions` knob (N6). All round-1 accepted deferrals (incl. the #15 `ReadManifestAsync` residue) stay deferred — coverage rows only, no tasks. **Architecture:** Two independent lanes. **TemplateEngine lane (N1+N2):** `ValidationService.ValidateExpressionTriggers` today runs `ScriptTrustValidator.FindViolations` + `RoslynScriptCompiler.Compile` per Expression-triggered script/alarm unconditionally and uncached (`ValidationService.cs:131, 531-544`) — the fix mirrors round-1 #12 exactly: memoise the verdict in `ScriptCompileVerdictCache` (whose key must first gain a **globals-surface discriminator** — it is keyed on code alone at `ScriptCompileVerdictCache.cs:53`, sound only while `ScriptCompiler`/`ScriptCompileSurface` is its sole writer; a `TriggerCompileSurface` verdict is not interchangeable), then gate the syntax/compile stage behind the same `validateScriptCompilation` flag that already carries `false` from `GetDeploymentComparisonAsync` and `StaleInstanceProbe` through `FlatteningPipeline` — the blank-expression check and attribute-reference scan stay unconditional, and the deploy gate keeps the default (`true`). **Transport lane (N3+N4+N5):** three surgical `BundleImporter` changes — publish `ScriptArtifactsChanged` for non-Skip resolutions including `Add` (`:1798-1803`); extend `EnumerateTrustGatedScripts` (`:957-1001`, the single enumerator both the apply-time Pass-0 gate at `:4309` and the preview gate at `:849` share, so one change covers both) to `InstanceAlarmOverrideDto.TriggerConfigurationOverride` expression bodies; and emit best-effort warnings (`ConflictKind.Warning` at preview + `ImportResult.Warnings` at apply) when an instance override targets a locked template member, WITHOUT changing what gets written or how the flattener resolves it. N6 is a one-line `RequireThat` in `TransportOptionsValidator`. **Tech Stack:** C#/.NET, EF Core (in-memory + MSSQL integration fixtures), Roslyn (`Microsoft.CodeAnalysis.CSharp.Scripting`), xUnit. Build: `dotnet build ZB.MOM.WW.ScadaBridge.slnx`. Test per project: `dotnet test tests/` — targeted filters only per task; no full-suite runs until the plan's terminal verification. ## Parallelization **Concurrent lanes (dispatch as separate implementers):** - **TemplateEngine lane (serialize — shared `ValidationService.cs` + `ScriptCompilerTests.cs`):** T1 → T2 → T3 → T4. - **Transport `BundleImporter.cs` mutex (serialize):** T5 → T6 → T7. - **Free / file-disjoint:** T8 (`TransportOptionsValidator.cs`) runs concurrently with everything. - **Last:** T9 (docs sweep, blockedBy all). **Cross-plan cautions:** T5 is the **publisher half** of N3 only — the subscriber side (Inbound API wiring + the wrong Host comment) is owned by **PLAN-R2-06**. The publisher fix is independent and self-contained (the plan-06 consumer already self-heals by content comparison, `InboundScriptExecutor.cs:370-404`), so there is **no blockedBy across plans** — either plan can land first; the coverage table records the split so both halves land coherently. --- ### Task 1: Full violation/error lists in the trigger-expression syntax check (N2) **Classification:** small **Estimated implement time:** ~3 min **Parallelizable with:** 5, 6, 7, 8 **Files:** - Modify: `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/ValidationService.cs` (`CheckExpressionSyntax` :536, :541) - Test: `tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/ScriptCompilerTests.cs` (extend — the existing `CheckExpressionSyntax_*` facts live here, :134-148) 1. Write failing tests: ```csharp [Fact] public void CheckExpressionSyntax_MultipleForbiddenApis_ReportsAll() { var error = ValidationService.CheckExpressionSyntax( "System.IO.File.Exists(\"x\") && System.Diagnostics.Process.GetProcesses().Length > 0"); Assert.NotNull(error); Assert.Contains("System.IO", error); // FAILS today: only violations[0] is surfaced Assert.Contains("Process", error); } [Fact] public void CheckExpressionSyntax_MultipleCompileErrors_ReportsAll() { var error = ValidationService.CheckExpressionSyntax("NoSuchThingA > 1 && NoSuchThingB < 2"); Assert.NotNull(error); Assert.Contains("NoSuchThingA", error); // FAILS today: only errors[0] is surfaced Assert.Contains("NoSuchThingB", error); } ``` 2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests --filter FullyQualifiedName~ScriptCompilerTests` → expect **FAIL** (second violation/error missing from the message). 3. Implement — the exact `string.Join` shape Task 24 gave `ScriptCompiler.TryCompile` (`ScriptCompiler.cs:50, 55`), applied to the trigger twin at `ValidationService.cs:534-544`: ```csharp var violations = ScriptTrustValidator.FindViolations(expression); if (violations.Count > 0) return $"uses forbidden API: {string.Join("; ", violations)}"; var errors = RoslynScriptCompiler.Compile(expression, typeof(TriggerCompileSurface)); if (errors.Count > 0) return $"is not a valid expression: {string.Join("; ", errors)}"; ``` An operator fixing a multi-error expression sees every finding in one deploy round-trip, matching the "Report ALL violations, not just the first" comment `ScriptCompiler.cs:46-47` already carries. 4. Run → expect **PASS**. Re-run the whole filter to confirm the existing `CheckExpressionSyntax_*` facts still pass. 5. Commit: `git add src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/ValidationService.cs tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/ScriptCompilerTests.cs && git commit -m "fix(template-engine): trigger-expression syntax check reports ALL violations/compile errors, not just the first (plan R2-05 T1)"` ### Task 2: Add a globals-surface discriminator to the verdict-cache key (N1 part 1) **Classification:** high-risk (keying of a cache that stores script-trust verdicts — a cross-surface verdict reuse would return a stale "clean" for code never vetted against that surface) **Estimated implement time:** ~4 min **Parallelizable with:** 5, 6, 7, 8 (blockedBy 1 — shared `ScriptCompilerTests.cs`) **Files:** - Modify: `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/ScriptCompileVerdictCache.cs` (`GetOrAdd` :51-69 gains a leading `string surface` parameter; key at :53 becomes `surface + ":" + hash`) - Modify: `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/ScriptCompiler.cs` (:42 — pass `nameof(ScriptCompileSurface)`) - Test: `tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/ScriptCompilerTests.cs` (extend) 1. Write failing test (compile-red first — the signature changes — then behavioural red): ```csharp [Fact] public void GetOrAdd_SameCodeDifferentSurface_ComputesSeparateVerdicts() { ScriptCompileVerdictCache.Clear(); var a = ScriptCompileVerdictCache.GetOrAdd("SurfaceA", "return 1;", () => (true, null)); var hitsAfterA = ScriptCompileVerdictCache.Hits; var b = ScriptCompileVerdictCache.GetOrAdd("SurfaceB", "return 1;", () => (false, "err")); Assert.True(a.Ok); Assert.False(b.Ok); // code-only key would return SurfaceA's verdict Assert.Equal(hitsAfterA, ScriptCompileVerdictCache.Hits); // no false cross-surface hit Assert.Equal(2, ScriptCompileVerdictCache.Count); } ``` 2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests --filter FullyQualifiedName~ScriptCompilerTests` → expect **FAIL** (compile error on the new parameter — that counts; fix the signature, then the behavioural assertions go red under a code-only key). 3. Implement: - `GetOrAdd(string surface, string code, Func<(bool Ok, string? Error)> factory)`; key = `surface + ":" + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(code)))`. Update the class doc-comment: *the verdict is a pure function of code + policy + globals surface, so the surface is part of the key — a trigger expression valid against `TriggerCompileSurface` is NOT interchangeable with a `ScriptCompileSurface` script-body verdict* (the exact hazard the round-2 report calls out for N1). - `ScriptCompiler.TryCompile` passes `nameof(ScriptCompileSurface)` at :42. No behaviour change for script bodies — same single writer, now under an explicit key segment. 4. Run the filter → expect **PASS** (including the pre-existing `TryCompile_SameCodeTwice_SecondCallIsCacheHit` cache facts at :157-171 — they exercise one surface and must be unaffected). 5. Commit: `git add src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/ScriptCompileVerdictCache.cs src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/ScriptCompiler.cs tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/ScriptCompilerTests.cs && git commit -m "fix(template-engine): verdict-cache key gains the globals-surface discriminator — cross-surface verdict reuse structurally impossible (plan R2-05 T2)"` ### Task 3: Cache Expression-trigger verdicts under the trigger surface key (N1 part 2) **Classification:** high-risk (trust-gate path — the trigger syntax check is a forbidden-API verdict; a caching bug here weakens the gate) **Estimated implement time:** ~4 min **Parallelizable with:** 5, 6, 7, 8 (blockedBy 2 — same files) **Files:** - Modify: `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/ValidationService.cs` (`CheckExpressionSyntax` :531-544 routes through `ScriptCompileVerdictCache`) - Test: `tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/ScriptCompilerTests.cs` (extend) 1. Write failing test plus the cross-surface lock-in negative: ```csharp [Fact] public void CheckExpressionSyntax_SameExpressionTwice_SecondCallIsCacheHit() { ScriptCompileVerdictCache.Clear(); ValidationService.CheckExpressionSyntax("Attributes[\"Temp\"] != null"); var hitsBefore = ScriptCompileVerdictCache.Hits; var error = ValidationService.CheckExpressionSyntax("Attributes[\"Temp\"] != null"); Assert.Null(error); Assert.Equal(hitsBefore + 1, ScriptCompileVerdictCache.Hits); // FAILS today: no cache use } [Fact] public void CheckExpressionSyntax_ScriptSurfaceVerdict_NotReusedForTriggerSurface() { // "Notify != null" resolves on ScriptCompileSurface (Notify is a script global, // ScriptCompileSurface.cs:53) but NOT on TriggerCompileSurface — a shared // code-only cache entry would wrongly report the trigger expression clean. ScriptCompileVerdictCache.Clear(); Assert.True(new ScriptCompiler().TryCompile("Notify != null", "S").IsSuccess); // warms the SCRIPT-surface entry var error = ValidationService.CheckExpressionSyntax("Notify != null"); Assert.NotNull(error); // green today (no cache), and MUST STAY green after T3 — the T2 key makes it structural } ``` 2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests --filter FullyQualifiedName~ScriptCompilerTests` → expect **FAIL** (the cache-hit fact; the cross-surface fact is the lock-in guard that must never flip). 3. Implement — wrap the T1-joined factory in the surface-keyed cache: ```csharp internal static string? CheckExpressionSyntax(string expression) { // Memoise the verdict under the TRIGGER surface key (see ScriptCompileVerdictCache): // the verdict is a pure function of expression + policy + TriggerCompileSurface, and // it is the exact hot-path cost N1 flagged — every staleness sweep / import probe of // an Expression-triggered config re-ran a full trust compilation + script compile. var (_, error) = ScriptCompileVerdictCache.GetOrAdd(nameof(TriggerCompileSurface), expression, () => { var violations = ScriptTrustValidator.FindViolations(expression); if (violations.Count > 0) return (false, $"uses forbidden API: {string.Join("; ", violations)}"); var errors = RoslynScriptCompiler.Compile(expression, typeof(TriggerCompileSurface)); if (errors.Count > 0) return (false, $"is not a valid expression: {string.Join("; ", errors)}"); return (true, (string?)null); }); return error; } ``` The stored error is already entity-name-free — `CheckExpressionTrigger` formats the entity name in at :449-451, matching the cache's name-free contract. 4. Run the filter → expect **PASS** (both new facts + all pre-existing `CheckExpressionSyntax_*`/`TryCompile_*` facts). 5. Commit: `git add src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/ValidationService.cs tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/ScriptCompilerTests.cs && git commit -m "perf(template-engine): cache Expression-trigger compile verdicts under the trigger-surface key — unchanged expressions compile once per process (plan R2-05 T3)"` ### Task 4: Skip the trigger syntax check on read-only staleness/comparison paths (N1 part 3) **Classification:** standard (read paths only; the deploy gate keeps the authoritative compile by default — the same shape round-1 #12/T16 shipped for script bodies) **Estimated implement time:** ~4 min **Parallelizable with:** 5, 6, 7, 8 (blockedBy 3 — same files) **Files:** - Modify: `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/ValidationService.cs` (`Validate` :131 threads the flag; `ValidateExpressionTriggers` :370-400 + `CheckExpressionTrigger` :420-463 gain the gate; `validateScriptCompilation` doc-comment :98-105 updated) - Test: `tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/ValidationServiceTests.cs` (extend) 1. Write failing tests (build the config the way the file's existing facts do — a `FlattenedConfiguration` with one script whose `TriggerType = "Expression"` and `TriggerConfiguration = """{"expression":"this is not C# (("}"""`): ```csharp [Fact] public void Validate_SkipCompilation_SkipsExpressionTriggerSyntaxCheck() { var config = ConfigWithExpressionTriggerScript("this is not C# (("); var gated = new ValidationService().Validate(config, validateScriptCompilation: false); Assert.DoesNotContain(gated.Errors, e => e.Message.Contains("failed validation")); // FAILS today var full = new ValidationService().Validate(config); Assert.Contains(full.Errors, e => e.Message.Contains("failed validation")); // deploy gate unchanged } [Fact] public void Validate_SkipCompilation_StillChecksExpressionAttributeReferences() { // expression "Attributes[\"Ghost\"] != null" referencing a missing attribute: // the reference scan is cheap string work and MUST survive the gate. var config = ConfigWithExpressionTriggerScript("Attributes[\"Ghost\"] != null"); var gated = new ValidationService().Validate(config, validateScriptCompilation: false); Assert.Contains(gated.Errors, e => e.Message.Contains("Ghost")); } ``` 2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests --filter FullyQualifiedName~ValidationServiceTests` → expect **FAIL** (the gated run still emits the syntax error). 3. Implement: - `ValidateExpressionTriggers(FlattenedConfiguration configuration, bool validateExpressionSyntax = true)` — additive default parameter, so every existing direct caller/test stays source-compatible. - `Validate` passes `validateScriptCompilation` through at :131 — the same flag `FlatteningPipeline` already threads `false` into from `GetDeploymentComparisonAsync` (`DeploymentService.cs:690`) and `StaleInstanceProbe` (`StaleInstanceProbe.cs:37`), so **no DeploymentManager/Transport change is needed**: both read paths stop paying the per-expression trust-compilation + compile the moment this lands. - Thread the flag into `CheckExpressionTrigger`; when `false`, skip ONLY the `CheckExpressionSyntax` call (:446-452) — the blank-expression warning/error (:432-444) and the attribute-reference scan (:454-462) still run, per the report's "the attribute-reference scan can stay". - Extend the `validateScriptCompilation` doc-comment (:98-105): the flag now gates both the script-body compile stage AND the Expression-trigger syntax/compile check; read-only callers skip both, the deploy gate runs both. 4. Run the filter, then the full TemplateEngine project (`dotnet test tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests`) → expect **PASS**, no regressions. 5. Commit: `git add src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/ValidationService.cs tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/ValidationServiceTests.cs && git commit -m "perf(template-engine): read-only staleness/comparison paths skip Expression-trigger compiles — deploy gate remains the authoritative check (plan R2-05 T4)"` ### Task 5: Publish `ScriptArtifactsChanged` for `Add` resolutions too (N3) **Classification:** standard **Estimated implement time:** ~3 min **Parallelizable with:** 1, 2, 3, 4, 8 **Files:** - Modify: `src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs` (`PublishScriptArtifactChanges` filter :1802 + the doc-comment :1768-1776) - Test: `tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/BundleImporterApplyTests.cs` (extend — the `RecordingScriptArtifactChangeBus` fixture and the `#05-T14` publish fact at :1336-1455 are already there) 1. Write failing test alongside `ApplyAsync_publishes_ScriptArtifactsChanged_per_kind_after_commit` (:1339): ```csharp [Fact] public async Task ApplyAsync_publishes_ScriptArtifactsChanged_for_Add_resolutions() { // ApiMethod imported as Add into a target where the name does not exist. // Delete-then-reimport-as-Add is the N3 edge: a node can still hold a // _knownBadMethods / compiled-handler entry under that very name, so Adds // must notify too (over-approximation is safe per the bus contract). var result = await ApplyBundleWithApiMethodAsync(action: ResolutionAction.Add); var notification = Assert.Single(_artifactBus.Received, n => n.ArtifactKind == ScriptArtifactKinds.ApiMethod); Assert.Contains("DelmiaRecipeDownload", notification.Names); // FAILS today: Adds are filtered out } ``` 2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests --filter FullyQualifiedName~BundleImporterApplyTests` → expect **FAIL** (no ApiMethod notification — `names.Count == 0` short-circuits). 3. Implement — one condition at :1802 plus the comment that justified the old behaviour: ```csharp .Where(r => string.Equals(r.EntityType, entityType, StringComparison.Ordinal) && r.Action is not ResolutionAction.Skip) ``` Rewrite the doc-comment (:1768-1776): *publishes for every non-Skip resolution — Overwrite, Rename, AND Add. An Add is not "nothing cached yet": an artifact deleted on the target and re-imported as Add under the same name can still have a stale compiled-handler/`_knownBadMethods` entry on any node. Over-approximation stays safe (the bus is advisory, at-least-once; consumers self-heal), which is the contract's stated design.* Keep the Rename → `RenameTo` name projection at :1803 as-is. 4. Run the filter → expect **PASS** (including the existing per-kind Overwrite fact and the failed-apply-publishes-nothing fact — the post-commit placement at :1340 is untouched). 5. Commit: `git add src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/BundleImporterApplyTests.cs && git commit -m "fix(transport): script-artifact change publisher covers Add resolutions — delete-then-reimport no longer starves non-self-healing subscribers (plan R2-05 T5)"` ### Task 6: Trust-gate instance alarm-override trigger expressions (N5) **Classification:** high-risk (script trust gate — 5th `ScriptTrustValidator` call-site completeness; security-adjacent) **Estimated implement time:** ~5 min **Parallelizable with:** 1, 2, 3, 4, 8 (blockedBy 5 — `BundleImporter.cs` mutex) **Files:** - Modify: `src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs` (`EnumerateTrustGatedScripts` :957-1001 gains the instance pass; `ExtractTriggerExpression` :1009-1033 refactored to expose a JSON-only `ExtractExpressionBody`) - Test: `tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/SemanticValidatorImportTests.cs` (extend — sits beside the template twin `Apply_AlarmExpressionTriggerWithForbiddenApi_HardBlocks` at :371) **Severity decision (recorded here, per the review convention):** instance-override trigger findings are **hard errors**, matching every other trust-gate surface. The advisory-vs-hard severity split is a property of the *name-resolution heuristic* only (false-positive-prone, so template findings are warnings — `:819, :842-846`); the trust gate's semantic verdict is authoritative with no false-positive channel, and Task 20 fixed its severity as *"a HARD error for all kinds"* (`:4301-4308`) — template script bodies and template trigger expressions already hard-block today. An instance override is the same executable surface (it *replaces* the template's trigger configuration in the flattened config and compiles/executes at the site), so it takes the same severity. The deploy gate (`ValidateExpressionTriggers` over the flattened, post-override config) stays the authoritative backstop — this closes the import-*review* completeness gap so the operator learns in the wizard, not at deploy time. 1. Write failing tests (negative tests required for a trust-gate change): ```csharp [Fact] public async Task Apply_InstanceAlarmOverrideExpressionWithForbiddenApi_HardBlocks() { // Bundle: template with an Expression-triggered alarm + an instance whose // InstanceAlarmOverrideDto.TriggerConfigurationOverride carries // {"expression":"System.Diagnostics.Process.GetProcesses().Length > 0"}. var ex = await Assert.ThrowsAsync( () => ApplyBundleAsync(bundle)); // FAILS today: imports clean Assert.Contains(ex.Errors, e => e.Contains("Process") && e.Contains("trigger override")); // Rollback contract: nothing persisted (mirror the template-twin's assertions). } [Fact] public async Task Preview_InstanceAlarmOverrideExpressionWithForbiddenApi_SurfacesBlocker() { var preview = await PreviewBundleAsync(bundle); Assert.Contains(preview.Blockers, b => b.Kind == ConflictKind.Blocker && b.EntityType == "Instance" && b.BlockerReason!.Contains("trust violation")); // FAILS today } [Fact] public async Task Apply_InstanceAlarmOverrideExpression_Clean_Imports() { // {"expression":"Attributes[\"Temp\"] != null"} → import succeeds, override row persisted verbatim. } ``` 2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests --filter FullyQualifiedName~SemanticValidatorImportTests` → expect **FAIL** (both forbidden-API cases import/preview clean). 3. Implement: - Split `ExtractTriggerExpression` (:1009-1033): the trigger-type guard stays in `ExtractTriggerExpression`, the JSON `{"expression":"…"}` parse moves to a new `private static string? ExtractExpressionBody(string? triggerConfigJson)` it delegates to. - Append the instance pass to `EnumerateTrustGatedScripts` after the ApiMethod loop (:993-1000): ```csharp foreach (var i in content.Instances) { if (IsSkipResolution(resolutionMap, "Instance", i.UniqueName)) continue; foreach (var o in i.AlarmOverrides) { // The DTO carries no trigger type (the type lives on the template // alarm), so gate ANY override config carrying an {"expression":...} // string body: if the overridden alarm is not Expression-triggered // the body never executes, but vetting it anyway is fail-safe — the // structured (HiLo/threshold) configs have no "expression" key, so // there is no false-positive channel. var expr = ExtractExpressionBody(o.TriggerConfigurationOverride); if (!string.IsNullOrEmpty(expr)) { yield return ("Instance", i.UniqueName, $"{o.AlarmCanonicalName} (alarm trigger override expression)", expr); } } } ``` - No gate-side change needed: the apply-time Pass 0 (:4309) and the preview gate (:849) both iterate this enumerator, so one edit covers both, including the fail-closed validator-throw handling each already has. Update the enumerator's doc-comment (:946-956) to name instance alarm-override expressions in the covered-surface list. 4. Run the filter, then `dotnet test tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests --filter FullyQualifiedName~BundleImporterPreviewTests` (preview parity) → expect **PASS**. 5. Commit: `git add src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/SemanticValidatorImportTests.cs && git commit -m "fix(security): import trust gate covers instance alarm-override trigger expressions — 5th call site is surface-complete (plan R2-05 T6)"` ### Task 7: Warn when import persists overrides on locked template members (N4) **Classification:** standard (warning-only UX; no write-path or flattener behaviour change — the flattener already drops locked-member overrides, `FlatteningService.cs:319, :337, :815`) **Estimated implement time:** ~5 min **Parallelizable with:** 1, 2, 3, 4, 8 (blockedBy 6 — `BundleImporter.cs` mutex) **Files:** - Modify: `src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs` (new best-effort lock scan called from `RunSemanticValidationAsync` (:4293, appending to `warnings`) and from `DetectBlockersAsync` (:670, emitting `ConflictKind.Warning` rows); generalize the apply-side warning log wording at :1204-1208) - Test: `tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/SiteInstanceImportTests.cs` (extend) 1. Write failing tests: ```csharp [Fact] public async Task Import_OverrideOnLockedAttribute_EmitsWarning_ImportStillSucceeds() { // Template attribute "SetPoint" IsLocked=true; instance carries an // InstanceAttributeOverrideDto for "SetPoint". var result = await ApplyBundleAsync(bundle); Assert.Contains(result.Warnings, w => w.Contains("SetPoint") && w.Contains("locked")); // FAILS today: silent // Behaviour unchanged: the row IS still written (bundle fidelity) — the // flattener is what ignores it; assert the persisted override row exists. } [Fact] public async Task Preview_OverrideOnLockedNativeAlarmSource_ShowsWarningRow() { var preview = await PreviewBundleAsync(bundle); Assert.Contains(preview.Blockers, b => b.Kind == ConflictKind.Warning && b.EntityType == "Instance" && b.BlockerReason!.Contains("locked")); // FAILS today } [Fact] public async Task Import_OverrideOnUnlockedAttribute_NoLockWarning() ``` 2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests --filter FullyQualifiedName~SiteInstanceImportTests` → expect **FAIL**. 3. Implement one shared best-effort scan, `CollectLockedOverrideWarnings(BundleContentDto content, Dictionary<(string,string), ImportResolution>? resolutionMap, IReadOnlyList