Files
ScadaBridge/archreview/plans/PLAN-R2-05-templates-deployment-transport.md
T
Joseph Doherty 5bbd7689fa docs(archreview): round-2 re-review (2026-07-12) + 8 fix plans (86 tasks)
Re-ran all 8 domain reviews at HEAD 8c888f13 against the b910f5eb baseline:
every round-1 finding source-verified (168 fixed, 0 regressions, 0 false
claims); 56 new findings (1 Critical / 4 High / 15 Medium / 36 Low),
concentrated in post-baseline code (anti-entropy resync, KPI rollup
backfill, live alarm stream) and seams the fixes exposed.

Headliners: S&F resync predicate inversion can wipe the delivering node's
buffer (02-N1 Critical); resync snapshot exceeds the Akka remoting frame
size (02-N2); failover drill kills the one node keep-oldest can't survive
(01-N1); unbounded rollup backfill per failover (04-R1); live production
API key in untracked test.txt (08-NF1).

Adds PLAN-R2-01..08 + .tasks.json manifests and the Round-2 board,
P0 list, cross-plan mutexes, and wave order in 00-MASTER-TRACKER.
2026-07-12 23:52:10 -04:00

38 KiB
Raw Blame History

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 (N1N6) 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/<project> — 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:
[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);
}
  1. Run: dotnet test tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests --filter FullyQualifiedName~ScriptCompilerTests → expect FAIL (second violation/error missing from the message).
  2. 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:
        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):
[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);
}
  1. 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).
  2. 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.
  3. 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).
  4. 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:
[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
}
  1. 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).
  2. Implement — wrap the T1-joined factory in the surface-keyed cache:
    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# (("}"""):
[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"));
}
  1. Run: dotnet test tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests --filter FullyQualifiedName~ValidationServiceTests → expect FAIL (the gated run still emits the syntax error).
  2. 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.
  3. Run the filter, then the full TemplateEngine project (dotnet test tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests) → expect PASS, no regressions.
  4. 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):
[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
}
  1. Run: dotnet test tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests --filter FullyQualifiedName~BundleImporterApplyTests → expect FAIL (no ApiMethod notification — names.Count == 0 short-circuits).
  2. Implement — one condition at :1802 plus the comment that justified the old behaviour:
                .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):
[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<SemanticValidationException>(
        () => 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.
}
  1. Run: dotnet test tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests --filter FullyQualifiedName~SemanticValidatorImportTests → expect FAIL (both forbidden-API cases import/preview clean).
  2. 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):
        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.
  1. Run the filter, then dotnet test tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests --filter FullyQualifiedName~BundleImporterPreviewTests (preview parity) → expect PASS.
  2. 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:
[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()
  1. Run: dotnet test tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests --filter FullyQualifiedName~SiteInstanceImportTests → expect FAIL.
  2. Implement one shared best-effort scan, CollectLockedOverrideWarnings(BundleContentDto content, Dictionary<(string,string), ImportResolution>? resolutionMap, IReadOnlyList<Template> targetTemplates):
    • For each non-Skip instance, resolve its template by dto.TemplateName — bundle DTO first (the version about to be written), else the pre-existing target template (GetAllTemplatesAsync already Includes the child collections).
    • Locked-member name sets from that template's rows: attributes/alarms/native sources with IsLocked == true (bundle DTOs carry IsLocked; derived templates carry inherited placeholder rows, so own + inherited members match by name). Check AttributeOverrides (→ FlatteningService.cs:319 drop), AlarmOverrides (→ :337), and NativeAlarmSourceOverrides (→ :815) — the alarm loop is the same pattern the report's two cited collections use, included for completeness.
    • Warning text: Instance '{unique}' {kind} override '{name}' targets a LOCKED template member — the flattener ignores it, so this part of the bundle's instance config will never take effect.
    • Best-effort, documented: overrides addressing composed path-qualified members (y1.z.Val) or derived-shadow locks (LockedInDerived on a base the placeholder row doesn't reflect) may not match a direct row — an unmatched name emits NO warning (never a false positive; the ManagementActor and flattener remain the enforcement points, ManagementActor.cs:898, :978).
    • Wire it twice: RunSemanticValidationAsync appends to warnings (they ride validationWarningsImportResult.Warnings at :1359 — generalize the "advisory template-script reference warning(s)" log wording at :1206-1208 to "advisory import warning(s)"); DetectBlockersAsync maps each to an ImportPreviewItem with EntityType: "Instance", Kind: ConflictKind.Warning — the Task-19 machinery, so the wizard renders it with the existing Warning badge, no UI change.
    • Do NOT block the import and do NOT change what PopulateInstanceChildren writes (:3999-4029) — bundle fidelity keeps the row; the flattener stays the behavioural authority (binding scope ruling: no flattener change).
  3. Run the filter + --filter FullyQualifiedName~BundleImporterPreviewTests → expect PASS.
  4. Commit: git add src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/SiteInstanceImportTests.cs && git commit -m "fix(transport): warn on import of instance overrides targeting locked template members — inert rows no longer silent (plan R2-05 T7)"

Task 8: Validate MaxConcurrentImportSessions at startup (N6)

Classification: small Estimated implement time: ~2 min Parallelizable with: 1, 2, 3, 4, 5, 6, 7 (file-disjoint with everything) Files:

  • Modify: src/ZB.MOM.WW.ScadaBridge.Transport/TransportOptionsValidator.cs (one RequireThat in Validate :25-66)
  • Test: tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/TransportOptionsValidatorTests.cs (extend — mirror ZeroMaxBundleSizeMb_IsRejected :34)
  1. Write failing tests:
[Fact]
public void ZeroMaxConcurrentImportSessions_IsRejected()
{
    var result = Validate(new TransportOptions { MaxConcurrentImportSessions = 0 });
    Assert.True(result.Failed);
    Assert.Contains("MaxConcurrentImportSessions", result.FailureMessage);   // FAILS today: validates clean
}

[Fact]
public void NegativeMaxConcurrentImportSessions_IsRejected()
  1. Run: dotnet test tests/ZB.MOM.WW.ScadaBridge.Transport.Tests --filter FullyQualifiedName~TransportOptionsValidatorTests → expect FAIL.
  2. Implement — the knob its own fix wave added (TransportOptions.cs:37, enforced at BundleSessionStore.cs:78-88 where a 0/negative cap rejects every import forever with "Too many concurrent import sessions (0)"):
        builder.RequireThat(options.MaxConcurrentImportSessions > 0,
            $"ScadaBridge:Transport:MaxConcurrentImportSessions must be positive " +
            $"(was {options.MaxConcurrentImportSessions}); a zero/negative cap rejects every import session forever.");
  1. Run the filter → expect PASS (DefaultOptions_AreValid must stay green — the default is 8).
  2. Commit: git add src/ZB.MOM.WW.ScadaBridge.Transport/TransportOptionsValidator.cs tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/TransportOptionsValidatorTests.cs && git commit -m "fix(transport): validate MaxConcurrentImportSessions at startup — a zero cap no longer bricks imports silently (plan R2-05 T8)"

Task 9: Design-doc sync sweep

Classification: small (docs only) Estimated implement time: ~4 min Parallelizable with: none (run last) Files:

  • Modify: docs/requirements/Component-Transport.md, docs/requirements/Component-ScriptAnalysis.md, docs/requirements/Component-TemplateEngine.md, /Users/dohertj2/Desktop/ScadaBridge/CLAUDE.md (component #24 blurb + Transport bullet)

No tests; concrete edits, then a stale-cross-reference sweep per repo convention:

  1. Component-Transport.md: (a) trust-gate section — the gated-surface list gains instance alarm-override trigger expressions (T6), same hard severity, with the severity-decision sentence (semantic verdict authoritative; the advisory split remains name-heuristic-only); (b) import-warning section — locked-member override warnings (T7): advisory ConflictKind.Warning at preview + ImportResult.Warnings at apply, best-effort by direct member name, rows still written, flattener remains the behavioural authority; (c) change-bus paragraph — publisher now notifies every non-Skip resolution including Add (T5), and note the subscriber side lives with the Inbound API (PLAN-R2-06 owns the subscriber wiring + the stale Host comment); (d) options table — MaxConcurrentImportSessions is now startup-validated (T8). Leave the ReadManifestAsync deferral note (:92) untouched — still deferred.
  2. Component-ScriptAnalysis.md: fifth-call-site (Transport) surface list gains instance alarm-override trigger expressions; note the verdict cache is keyed by (globals surface, code hash) so script-body and trigger-expression verdicts never cross (T2/T3).
  3. Component-TemplateEngine.md: validation-pipeline section — validateScriptCompilation: false now also skips the Expression-trigger syntax/compile check on read-only staleness/comparison paths (blank + attribute-reference checks still run; deploy gate authoritative, T4); trigger syntax findings report all violations/errors (T1).
  4. CLAUDE.md: component #24 blurb / Transport Key-Design-Decisions bullet — extend the script-trust-gate sentence: Expression-trigger gating covers template scripts, alarms, and instance alarm-override expressions. No ../scadaproj/CLAUDE.md change (no wire-relationship/stack change) — verify and note in the commit message.
  5. git diff review, then commit: git add docs/requirements/Component-Transport.md docs/requirements/Component-ScriptAnalysis.md docs/requirements/Component-TemplateEngine.md CLAUDE.md && git commit -m "docs: sync Transport/ScriptAnalysis/TemplateEngine specs + CLAUDE.md with the round-2 fix wave (plan R2-05 T9)"

Dependencies on other plans

  • PLAN-R2-06 (Edge Integrations round 2): owns the subscriber side of the change bus (Inbound API wiring + the wrong Host comment). T5's publisher fix is independent — the shipped plan-06 consumer self-heals by content comparison (InboundScriptExecutor.cs:370-404), so no cross-plan blockedBy exists in either direction; the two halves land coherently in any order. R2-06 should read T5's updated publisher doc-comment (Adds now publish) when wiring any non-self-healing subscriber.
  • No other cross-plan edges. N1's gating rides the validateScriptCompilation flag PLAN-05 T16 already threads from DeploymentManager — no DeploymentManager file is touched.

Execution order

P0 (start immediately, in parallel): Task 1 (TemplateEngine lane opener), Task 5 (BundleImporter lane opener), Task 8 (free). Lane A (serialize — shared files): 1 → 2 → 3 → 4. Lane B (serialize — BundleImporter.cs mutex): 5 → 6 → 7. Last: 9 (docs sweep), then terminal verification: dotnet build ZB.MOM.WW.ScadaBridge.slnx + dotnet test tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests && dotnet test tests/ZB.MOM.WW.ScadaBridge.Transport.Tests && dotnet test tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests && dotnet test tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests (DeploymentManager consumes the Validate flag) before declaring the plan done.

Findings Coverage

Report finding Severity Task(s)
N1 — Expression-trigger validation Roslyn-compiles on read-only staleness paths, uncached; any shared cache must key by globals surface Medium (perf) 2 (surface-discriminated cache key), 3 (cache trigger verdicts), 4 (skip on comparison/probe paths via the existing validateScriptCompilation flag)
N2 — CheckExpressionSyntax surfaces only the first violation / first compile error Low 1 (the T24 string.Join fix mirrored onto the trigger twin)
N3 — PublishScriptArtifactChanges skips Add resolutions Low 5 (publisher publishes every non-Skip resolution); subscriber side (Inbound API wiring + wrong Host comment) → PLAN-R2-06 — publisher fix is independent, no cross-plan blockedBy
N4 — Import persists silently-inert instance overrides on locked members Low 7 (ConflictKind.Warning preview rows + ImportResult.Warnings at apply; best-effort direct-member match; rows still written, flattener behaviour unchanged per ruling)
N5 — Import trust gate misses InstanceAlarmOverrideDto.TriggerConfigurationOverride expression bodies Low (sec) 6 (enumerator covers instance overrides; hard error — trust-gate severity is uniform, the advisory split is name-heuristic-only; deploy gate stays authoritative backstop; negative tests included)
N6 — TransportOptionsValidator misses MaxConcurrentImportSessions Low (conv) 8 (one RequireThat > 0)
Docs drift from the above 9
Accepted deferral — #11 OperationLockManager single-node in-memory Medium (R1) Deferred (unchanged) — invariant recorded Component-DeploymentManager.md:94; structural active-node gating owned by plans 01/07
Accepted deferral — #15 residue: LoadAsync manifest-peek (ReadManifestAsync) Low (perf, R1) Deferred (unchanged) — doc-acknowledged Component-Transport.md:92; pure optimisation (session cap already shipped, and N6/T8 now validates it)
Accepted deferral — #16 import apply is one long EF transaction Low (perf, R1) Deferred (unchanged) — dominant cost removed by #12/N1; restructuring the rollback contract stays high-risk/low-gain
Accepted deferral — U4: rename call-site rewriting, Transport-012 filter UI Deferred (unchanged) — recorded limitations, Component-Transport.md:354
Accepted deferral — U5: preview→apply optimistic concurrency window Deferred (unchanged) — recorded decision, Component-Transport.md:138; version fields reserved (ArtifactDiff.cs:33-37)
Accepted deferral — U6: three hand-maintained compile-surface mirrors Deferred (unchanged) — guard tests exist; source-generator is an improvement, not a defect