Files
ScadaBridge/archreview/plans/PLAN-05-templates-deployment-transport.md
T
Joseph Doherty 3a21e77c03 docs(archreview): add cross-plan parallelization map for incomplete tasks + sync stale PLAN-04 status
Corrects the Status Board (PLAN-04 24/26, not 0), and adds a Parallelization Map to
00-MASTER-TRACKER plus a per-plan Parallelization block to PLAN-04/05/06/07/08. Derives
ready sets from the live .tasks.json blockedBy graphs and the Files metadata, identifies
the initiative-wide serialization mutexes (ManagementActor.cs single-writer, EF migration
snapshot, Host/Program.cs, ArtifactDeploymentService.cs, SiteStorageService.cs,
NotificationOutboxActor.cs, AlarmSummary.razor), and recommends the safe concurrent
subagent lanes per plan. Flags PLAN-07 T33 as a dedupe-skip (CLI.Tests already in slnx).
2026-07-09 09:53:05 -04:00

61 KiB
Raw Blame History

Templates, Deployment & Transport Fix Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.

Goal: Close every finding in archreview/05-templates-deployment-transport.md — the Transport import silent-data-loss family (inheritance, script fields, native alarm sources, AreaName), the flattener/collision repeated-composition bug, revision-hash staleness blindness, native-alarm lock enforcement, Roslyn compile cost/leak on read paths, deployment audit/delete defects, script-trust hardening, and the cross-cutting script-artifact invalidation contract.

Architecture: The Transport import apply path (BundleImporter) re-implements DTO→entity mapping by hand and drifted from the DTO layer; fixes restore field-for-field fidelity, add the missing inheritance/native-source passes, and single-source the diff/sync equality predicates so they cannot drift again, guarded structurally by a per-entity round-trip equivalence test suite. TemplateEngine fixes convert the shared cycle-guard visited set to a recursion-path set (composition is by-slot, not by-template), extend the revision hash/diff to the native-alarm member class, and thread IsLocked through ResolvedNativeAlarmSource. A new Commons seam (IScriptArtifactChangeBus + ScriptArtifactsChanged) defines the "script artifact changed → invalidate everywhere" contract; Transport publishes post-commit, plan 06 consumes it in the Inbound API.

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>.

Parallelization (incomplete tasks) — 2026-07-09

Only T1 is complete. Ready now (all blockedBy done): {2, 7, 9, 13, 14, 15, 16, 21, 22}.

Concurrent lanes (dispatch as separate implementers):

  • Free / file-disjoint: T7 (Export/DependencyResolver), T9 (FlatteningService.cs), T15 (TemplateEngine/Validation compile-cache), T16 (DeploymentManager pipeline), T21 (ScriptAnalysis/ScriptTrustPolicy), T22 (Transport/LineDiffer). Up to ~6 at once.
  • Transport/Import/BundleImporter.cs mutex: T2 ↔ T14 — one at a time (the downstream import chain T3→T4→T5→T6→T8 and T19→T20→T23 also serialize through this file).
  • Global ManagementActor lane: T13 (ManagementActor.cs + FlatteningService.cs) — runs in the initiative-wide ManagementActor mutex, and because it also edits FlatteningService.cs it is not concurrent with T9.

Cross-plan cautions: T13 shares the ManagementActor.cs global single-writer with PLAN-06/07; T25 (docs) later touches CLAUDE.md. See 00-MASTER-TRACKER.md § Parallelization Map for the full mutex table.

Findings Coverage

Report finding Severity Task(s)
Bundle import drops template inheritance edges Critical 1, 2
Imported scripts lose MinTimeBetweenRuns/ExecutionTimeoutSeconds; LockedInDerived not in DTOs High 3, 4
Template NativeAlarmSources not transported (overrides dangle) High 5
Flattener drops grandchild members on repeated composition; CollisionDetector blind High 9, 10
Revision hash and diff omit NativeAlarmSources High 11, 12
Import leaves stale compiled Inbound API handlers High 14 (contract + Transport publisher; consumer → plan 06)
Locked native alarm sources overridable at instance level High 13
Post-success audit failure flips Success→Failed Medium 17
Deleting NotDeployed instance requires live site round-trip Medium 18
Import blocker heuristic hard-blocks on false positives Medium 19
OperationLockManager single-node in-memory Medium 25 (documented invariant); active-node gating deferred to plans 01/07 (cluster routing ownership)
Every validation run compiles every script (CPU + assembly leak) High (perf) 15, 16
LineDiffer O((N+M)²) memory, no input cap Medium (perf) 22
Templates with folder/parent/composition never diff Identical Medium (perf) 23
LoadAsync O(bundle) + unbounded session count Low (perf) 24 (session cap); manifest-peek ReadManifestAsync Deferred — already doc-acknowledged (Component-Transport.md:90), pure optimisation
Import apply is one long EF transaction Low (perf) Deferred — Task 16 removes the dominant cost (Roslyn in StaleInstanceProbe); restructuring the single-transaction rollback contract is high-risk for low residual gain
Deny-list gaps: Environment.Exit/FailFast/GetEnvironmentVariable Medium (sec) 21
System.Data provider namespaces unbounded network hole Medium (sec) 21
Reflection-gateway member list incomplete Medium (sec) 21
Transport import bypasses script trust gate Medium (sec) 20
Instance alarm overrides implement less than spec Medium 25 (doc aligned to implementation — adding DescriptionOverride/OnTriggerScriptOverride columns is a feature, not a fix; decision recorded)
ArtifactDiff equality predicates drift from sync predicates Medium 6
Spec advertises unimplemented scripts/ bundle dir; MaxBundleEntryCount = 4 Low 25
TryCompile surfaces only first violation/error Low 24
RevisionHash determinism contract fragile Low 11 (guard test extended; migration note)
Underdeveloped 1: template fidelity shipped behind site/instance wave 18
Underdeveloped 2: no derived-template import test 1, 8
Underdeveloped 3: no repeated-composition flattening test 9, 10
Underdeveloped 4: deferred-but-load-bearing (rename call-site rewriting, Transport-012 UI, ReadManifestAsync) 25 documents the rename limitation; Transport-012 UI + ReadManifestAsync Deferred (acknowledged backlog, not correctness)
Underdeveloped 5: preview→apply window has no optimistic concurrency Deferred — version fields are documented as reserved (ArtifactDiff.cs:33-37); 30-min TTL window accepted, recorded in Task 25 doc sweep
Underdeveloped 6: three hand-maintained compile-surface mirrors Deferred — reflection/representative-script guard tests already exist; source-generator is an improvement, not a defect
Underdeveloped 7: SemanticValidator leaf-name fallback silently accepts wrong-child calls 24 (emit warning)

Task 1: Wire template inheritance edges in the import apply path

Classification: high-risk (Critical data-loss fix on the import data contract) Estimated implement time: ~5 min Parallelizable with: 9, 10, 11, 12, 13, 15, 16, 17, 18, 21, 22 Files:

  • Modify: src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs (add ResolveInheritanceEdgesAsync; call it from ApplyAsync after line 1044 ResolveCompositionEdgesAsync; extend Overwrite branch ~1466-1484)
  • Test (new): tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/InheritanceImportTests.cs
  1. Write failing test (follow the fixture pattern of Import/SiteInstanceImportTests.cs — source context → BundleExporter.ExportAsync → target context → LoadAsync/PreviewAsync/ApplyAsync):
[Fact]
public async Task Import_DerivedTemplate_WiresParentTemplateId()
{
    // Source: base "Pump" + derived "Pump.WaterPump" (ParentTemplateId -> Pump)
    // with one inherited-from-base attribute on Pump.
    var bundle = await ExportTemplatesFromSourceAsync("Pump", "Pump.WaterPump");
    await ImportIntoEmptyTargetAsync(bundle, ResolutionAction.Add);

    var target = await TargetTemplateRepo.GetAllTemplatesAsync();
    var basePump = target.Single(t => t.Name == "Pump");
    var derived = target.Single(t => t.Name == "Pump.WaterPump");
    Assert.Equal(basePump.Id, derived.ParentTemplateId); // FAILS today: null
}

[Fact]
public async Task Import_Overwrite_UpdatesParentEdge_AndRenamedBaseIsHonoured()
{
    // Bundle base renamed to "Pump2" via ResolutionAction.Rename; derived's
    // BaseTemplateName ("Pump") must resolve through the rename map to Pump2.
    ...
    Assert.Equal(pump2.Id, derived.ParentTemplateId);
}
  1. Run: dotnet test tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests --filter FullyQualifiedName~InheritanceImportTests → expect FAIL (ParentTemplateId null).
  2. Implement ResolveInheritanceEdgesAsync(IReadOnlyList<TemplateDto> dtos, Dictionary<(string,string), ImportResolution> resolutionMap, string user, CancellationToken ct) mirroring ResolveCompositionEdgesAsync (line 1993):
    • For each non-Skip template DTO: compute the template's persisted name (Rename → RenameTo), load the tracked entity by name.
    • If dto.BaseTemplateName is null → set ParentTemplateId = null (an Overwrite from a root-template bundle must clear a stale edge).
    • Else resolve the base name through the resolution map (ResolveOrDefault(resolutionMap, "Template", dto.BaseTemplateName); Rename → RenameTo), then look up by name across staged-imported + pre-existing target templates (same lookup set ResolveCompositionEdgesAsync builds). Set ParentTemplateId.
    • Unresolvable base → leave null and emit a BundleImportBaseTemplateUnresolved audit row (mirror BundleImportAlarmScriptUnresolved), never throw.
    • Call from ApplyAsync immediately after ResolveCompositionEdgesAsync (line 1044).
  3. Run test → expect PASS. Run the whole Transport suites: dotnet test tests/ZB.MOM.WW.ScadaBridge.Transport.Tests && dotnet test tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.
  4. Update docs/requirements/Component-Transport.md apply-pass list (the second-pass rewire section) to name the inheritance pass alongside alarm-script and composition rewires.
  5. Commit: git add src/ZB.MOM.WW.ScadaBridge.Transport tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests docs/requirements/Component-Transport.md && git commit -m "fix(transport): wire template inheritance edges on bundle import (C3) — derived templates no longer land as roots"

Task 2: Import-time acyclicity check over the merged template graph

Classification: standard Estimated implement time: ~4 min Parallelizable with: 9, 10, 11, 12, 13, 15, 16, 17, 18, 21, 22 Files:

  • Modify: src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs (after the Task-1 inheritance pass in ApplyAsync)
  • Test: tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/InheritanceImportTests.cs (extend)
  1. Write failing test: craft a bundle whose templates form an inheritance cycle against a pre-existing target template (target A has parent B; bundle overwrites B with BaseTemplateName: "A"). Assert ApplyAsync throws SemanticValidationException and the transaction rolled back (target B.ParentTemplateId unchanged). Add the composition-cycle twin (bundle composition edge closing a loop through a target template).
  2. Run: dotnet test tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests --filter FullyQualifiedName~InheritanceImportTestsFAIL (cycle persists today; importer performs no acyclicity check).
  3. Implement: after both rewire passes, load all templates + compositions from the tracked context and run TemplateEngine CycleDetector.DetectInheritanceCycle / DetectCompositionCycle (Transport already references TemplateEngine). On a detected cycle, throw SemanticValidationException(new[] {"Import would create a template inheritance/composition cycle: <path>"}) — the existing catch path rolls back and reports.
  4. Run test → PASS. Commit: git commit -m "fix(transport): reject bundle imports that would persist an inheritance/composition cycle" (with the touched paths in git add).

Task 3: Carry MinTimeBetweenRuns / ExecutionTimeoutSeconds through import

Classification: small Estimated implement time: ~3 min Parallelizable with: 913, 1518, 21, 22 Files:

  • Modify: src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs (BuildTemplate ~1550-1560; SyncTemplateScriptsAsync changed predicate + copies ~1836-1851 and the add branch ~1870-1877)
  • Test (new): tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/TemplateScriptFidelityTests.cs
  1. Failing test: export a template whose script has MinTimeBetweenRuns = TimeSpan.FromSeconds(30) and ExecutionTimeoutSeconds = 42; import as Add into empty target, assert both fields on the persisted TemplateScript. Second test: Overwrite a target whose script differs only in ExecutionTimeoutSeconds → assert field updated and a TemplateScriptUpdated audit row emitted.
  2. dotnet test tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests --filter FullyQualifiedName~TemplateScriptFidelityTestsFAIL.
  3. Implement: add MinTimeBetweenRuns = s.MinTimeBetweenRuns, ExecutionTimeoutSeconds = s.ExecutionTimeoutSeconds to the TemplateScript initializers in BuildTemplate and the SyncTemplateScriptsAsync add branch; add || current.MinTimeBetweenRuns != scriptDto.MinTimeBetweenRuns || current.ExecutionTimeoutSeconds != scriptDto.ExecutionTimeoutSeconds to the changed predicate and copy both fields in the update block.
  4. Run → PASS. Commit: git commit -m "fix(transport): carry script cadence/timeout fields through bundle import (was silently reset to defaults)"

Task 4: Add LockedInDerived to template child DTOs and apply paths

Classification: high-risk (bundle data contract change — must stay additive) Estimated implement time: ~5 min Parallelizable with: 913, 1518, 21, 22 Files:

  • Modify: src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntityDtos.cs (TemplateAttributeDto, TemplateAlarmDto, TemplateScriptDto — trailing bool LockedInDerived = false)
  • Modify: src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntitySerializer.cs (export ~55-90; FromBundleContent ~330-360)
  • Modify: src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs (BuildTemplate, three SyncTemplate*Async predicates/copies)
  • Test: tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Serialization/EntitySerializerTests.cs (extend), tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/TemplateScriptFidelityTests.cs (extend)
  1. Failing unit test in EntitySerializerTests: entity with LockedInDerived = true on an attribute, alarm, and script → ToBundleContent DTOs carry it; FromBundleContent round-trips it. Failing integration test: import as Add → persisted flags true; old-form JSON (no field) still deserializes with false (backward-compat assertion — deserialize a hand-written DTO JSON blob missing the property).
  2. Run both test projects with --filter LockedInDerivedFAIL (compile errors first — that counts; fix signatures then re-run for behavioural red).
  3. Implement: trailing optional parameter on each record (mirrors the ExecutionTimeoutSeconds = null precedent — additive, no schemaVersion bump needed); populate at export; consume in BuildTemplate, FromBundleContent, and all three sync helpers (changed predicate + copy).
  4. Run → PASS. Commit: git commit -m "fix(transport): transport LockedInDerived on template attributes/alarms/scripts (additive DTO fields)"

Task 5: Transport template NativeAlarmSources end-to-end

Classification: high-risk (new bundle payload class + new sync pass) Estimated implement time: ~5 min (DTO+export+apply) — if it runs long, split diff wiring into its own commit Parallelizable with: 913, 1518, 21, 22 Files:

  • Modify: src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntityDtos.cs (new TemplateNativeAlarmSourceDto; TemplateDto gains trailing IReadOnlyList<TemplateNativeAlarmSourceDto> NativeAlarmSources defaulting empty)
  • Modify: src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntitySerializer.cs (export + FromBundleContent)
  • Modify: src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs (BuildTemplate; new SyncTemplateNativeAlarmSourcesAsync called from the Overwrite branch at ~1482)
  • Modify: src/ZB.MOM.WW.ScadaBridge.Transport/Import/ArtifactDiff.cs (CompareTemplateDiffChildren over native sources)
  • Test (new): tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/NativeAlarmSourceImportTests.cs; extend EntitySerializerTests
  1. Failing tests:
[Fact] public async Task Import_Add_CarriesTemplateNativeAlarmSources()
// template with one source (all fields set incl. IsLocked, IsInherited, LockedInDerived)
// → export → import Add → persisted TemplateNativeAlarmSource field-equal.

[Fact] public async Task Import_Overwrite_SyncsNativeAlarmSources()
// target source differs in ConditionFilter; bundle also drops one source and adds one
// → assert update/add/delete applied + audit rows (TemplateNativeAlarmSource{Added,Updated,Deleted}).

[Fact] public async Task Preview_DiffsNativeAlarmSources()
// changed source shows a "NativeAlarmSources.<name>" FieldChange; identical → no change row.

[Fact] public async Task Import_InstanceOverride_NoLongerDangles()
// export template-with-source + instance override of it → import → flatten target instance
// → resolved config contains the source with the override applied (today: silently dropped).
  1. Run --filter NativeAlarmSourceImportTestsFAIL.
  2. Implement:
    • DTO: record TemplateNativeAlarmSourceDto(string Name, string? Description, string ConnectionName, string SourceReference, string? ConditionFilter, bool IsLocked, bool IsInherited, bool LockedInDerived).
    • Export in EntitySerializer.ToBundleContent (include IsInherited placeholder rows — the collision detector depends on them) and consume in FromBundleContent.
    • BuildTemplate: copy the collection. New SyncTemplateNativeAlarmSourcesAsync mirrors SyncTemplateAttributesAsync (name-keyed add/update/delete; repository has DeleteTemplateNativeAlarmSourceAsync-style members — verify exact name on ITemplateEngineRepository and add if missing).
    • ArtifactDiff.CompareTemplate: DiffChildren(existing.NativeAlarmSources, incoming.NativeAlarmSources, e => e.Name, i => i.Name, NativeAlarmSourcesEqual, "NativeAlarmSources", changes).
  3. Run → PASS; run full Transport suites. Update docs/requirements/Component-Transport.md transported-entity table + CLAUDE.md component #24 blurb (template native alarm sources now travel).
  4. Commit: git commit -m "fix(transport): transport template NativeAlarmSources — imports no longer amputate native alarm mirrors nor dangle instance overrides"

Task 6: Single-source per-entity field comparers for diff + sync

Classification: standard Estimated implement time: ~5 min Parallelizable with: 913, 1518, 21, 22 Files:

  • Create: src/ZB.MOM.WW.ScadaBridge.Transport/Import/TemplateChildEquality.cs
  • Modify: src/ZB.MOM.WW.ScadaBridge.Transport/Import/ArtifactDiff.cs (replace AttributesEqual/AlarmsEqual/ScriptsEqual at 646-666; CompareTemplate passes a script-name resolver for the alarm on-trigger ref)
  • Modify: src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs (three sync changed predicates delegate to the same class)
  • Test: tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Import/ArtifactDiffTests.cs (extend)
  1. Failing tests (today's drift, both directions):
[Fact] public void CompareTemplate_AttributeElementDataTypeChange_IsModified()   // omitted today → false Identical
[Fact] public void CompareTemplate_ScriptExecutionTimeoutChange_IsModified()     // omitted today
[Fact] public void CompareTemplate_AlarmOnTriggerScriptChange_IsModified()       // omitted today
[Fact] public void CompareTemplate_LockedInDerivedChange_IsModified()
  1. Run dotnet test tests/ZB.MOM.WW.ScadaBridge.Transport.Tests --filter ArtifactDiffTestsFAIL.
  2. Implement TemplateChildEquality: one static method per entity comparing the complete writable field list (attributes: Value/DataType/ElementDataType/IsLocked/LockedInDerived/Description/DataSourceReference; alarms: + OnTriggerScriptName via a Func<int?, string?> scriptNameById resolver built from existing.Scripts; scripts: + MinTimeBetweenRuns/ExecutionTimeoutSeconds/LockedInDerived; native sources per Task 5). XML-doc each with: "This is the single source of truth for ' changed'; ArtifactDiff and SyncTemplate*Async must both call it." Point ArtifactDiff and the three (four after Task 5) sync predicates at it.
  3. Run Transport unit + integration suites → PASS.
  4. Commit: git commit -m "refactor(transport): single-source template child equality — diff and overwrite-sync can no longer drift"

Task 7: Export real AreaName (finish the half-shipped Area transport)

Classification: standard Estimated implement time: ~4 min Parallelizable with: 14, 913, 1518, 21, 22 Files:

  • Modify: src/ZB.MOM.WW.ScadaBridge.Transport/Export/DependencyResolver.cs / ResolvedExport.cs (aggregate gains areaNameById for exported instances; exporter has ScadaBridgeDbContext — load Areas for the instances' AreaIds)
  • Modify: src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntitySerializer.cs (~247-256: replace the AreaName: null TODO with the lookup)
  • Test: tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/SiteInstanceImportTests.cs (extend) or new AreaTransportTests.cs
  1. Failing test: source instance assigned to Area "Line-1" under its site → export → import into target with --create-missing site mapping → assert target instance's AreaId resolves to an Area named "Line-1" under the target site (the importer's GetOrCreate machinery at BundleImporter.cs:3168 is already built and tested — only the exporter feeds it null).
  2. Run → FAIL (imported AreaId is null).
  3. Implement: extend the export aggregate with the instances' area id→name map (single query over Areas filtered to referenced ids) and emit AreaName: areaNameById.TryGetValue(inst.AreaId ?? -1, out var an) ? an : null. Delete the TODO comment.
  4. Run → PASS. Update docs/requirements/Component-Transport.md (Area-by-name now actually travels; remove any "importer leaves AreaId null" caveat). CLAUDE.md #24 already claims this — now true, no edit needed (verify wording).
  5. Commit: git commit -m "fix(transport): export instance AreaName — Area-by-name reconciliation was half-shipped (exporter hardcoded null)"

Task 8: Round-trip export→import equivalence test suite (Theme-3 structural guard)

Classification: standard Estimated implement time: ~5 min Parallelizable with: 918, 21, 22 (test-only; depends on Tasks 35, 7 landing first) Files:

  • Create: tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/RoundTripEquivalenceTests.cs
  1. For every transported entity type (Template + attribute/alarm/script/native-source/composition children, SharedScript, ExternalSystem + methods, DatabaseConnection, NotificationList, SmtpConfig, SmsConfig, ApiMethod, TemplateFolder, Site, DataConnection, Instance + override children + bindings), seed a source DB with a maximally populated entity (every writable property non-default), export, import into a fresh target, reload, and compare by reflection: every public writable property of the entity must be equal, except an explicit per-type exclusion list (Id, FK id columns remapped by design, timestamps). The reflection sweep is the point — a future entity property that no DTO carries fails the test instead of silently vanishing (exactly how LockedInDerived, cadence/timeout, and native sources were lost).
private static void AssertRoundTripEqual<T>(T source, T imported, params string[] excluded)
{
    foreach (var p in typeof(T).GetProperties().Where(p => p.CanWrite
        && !excluded.Contains(p.Name)
        && p.PropertyType.IsValueType || p.PropertyType == typeof(string)))
        Assert.True(Equals(p.GetValue(source), p.GetValue(imported)),
            $"{typeof(T).Name}.{p.Name} did not survive export→import");
}
  1. Run: dotnet test tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests --filter RoundTripEquivalenceTests — expect PASS if Tasks 17 are complete (any failure here is a residual fidelity hole: fix it in the importer/exporter, not the test).
  2. Commit: git commit -m "test(transport): per-entity reflection round-trip equivalence suite — structural guard against silent import data loss"

Task 9: Fix repeated-composition member loss in FlatteningService

Classification: standard Estimated implement time: ~5 min Parallelizable with: 18, 1518, 21, 22 Files:

  • Modify: src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/FlatteningService.cs (the four ResolveComposed*Recursive methods: attributes ~285-297, alarms ~648-660, native sources ~761-772, scripts ~913-924)
  • Test: tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/FlatteningServiceTests.cs (extend)
  1. Failing test:
[Fact]
public void Flatten_SameTemplateComposedTwice_ResolvesNestedMembersUnderBothSlots()
{
    // X composes Y as "y1" and "y2"; Y composes Z as "z"; Z has attribute "Val",
    // alarm "Alm", script "Run", native source "Src".
    var result = Flatten(x);
    Assert.Contains(result.Attributes, a => a.CanonicalName == "y1.z.Val");
    Assert.Contains(result.Attributes, a => a.CanonicalName == "y2.z.Val"); // FAILS today
    // + same assertions for Alarms / Scripts / NativeAlarmSources
}

[Fact]
public void Flatten_CompositionCycle_StillTerminates() // regression guard for the cycle role of `visited`
  1. dotnet test tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests --filter Flatten_SameTemplateComposedTwiceFAIL (y2.z.Val missing).
  2. Implement: convert the shared visited set into a recursion-path set in all four methods. Pattern (identical in each):
// Guard on the recursion PATH, not globally: composition is by-slot, so a template
// legitimately appears twice under different prefixes; only a template already on
// the CURRENT path is a cycle.
var pushed = new List<int>();
foreach (var composedTemplate in composedChain)
    if (path.Add(composedTemplate.Id)) pushed.Add(composedTemplate.Id);
try
{
    foreach (var composedTemplate in composedChain)
    {
        if (!compositionMap.TryGetValue(composedTemplate.Id, out var nested)) continue;
        foreach (var n in nested)
            if (!path.Contains(n.ComposedTemplateId))   // cycle guard
                ResolveComposedRecursive(n, $"{prefix}.{n.InstanceName}", , path);
    }
}
finally { foreach (var id in pushed) path.Remove(id); }

(Rename the parameter visitedpath so the semantics are explicit.) 4. Run the full TemplateEngine test project → PASS, no regressions. 5. Commit: git commit -m "fix(template-engine): repeated composition no longer drops nested members — cycle guard keyed on recursion path, not global template id"

Task 10: Fix CollisionDetector blindness under repeated composition

Classification: small Estimated implement time: ~3 min Parallelizable with: 18, 1518, 21, 22 (different file from Task 9) Files:

  • Modify: src/ZB.MOM.WW.ScadaBridge.TemplateEngine/CollisionDetector.cs (CollectComposedMembers ~119-144)
  • Test: tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/CollisionDetectorTests.cs (extend)
  1. Failing test: X composes Y twice (y1, y2); Y composes Z; X also declares a direct attribute named y2.z.Val (path-collision). Assert the detector reports the collision (today the second slot's subtree is never collected, so it cannot).
  2. Run --filter CollisionDetectorTestsFAIL.
  3. Implement the same path-set conversion as Task 9: visited becomes the current recursion path — add template.Id on entry, remove in finally; the early-return then only fires on genuine cycles.
  4. Run → PASS. Commit: git commit -m "fix(template-engine): collision detection sees all slots of a repeatedly-composed template"

Task 11: Add NativeAlarmSources to RevisionHashService (planned hash migration)

Classification: high-risk (revision-hash change ⇒ staleness flags flip for native-source-bearing instances) Estimated implement time: ~4 min Parallelizable with: 18, 1518, 21, 22 — after Task 13 (hash includes IsLocked) Files:

  • Modify: src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/RevisionHashService.cs (new HashableNativeAlarmSource; HashableConfiguration gains the property in alphabetical position)
  • Test: tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/RevisionHashServiceTests.cs (extend, incl. the alphabetical-guard test)
  1. Failing tests:
[Fact] public void ComputeHash_NativeAlarmSourceChange_ChangesHash()          // FAILS today
[Fact] public void ComputeHash_NoNativeAlarmSources_HashUnchangedFromBaseline()
// pin a literal known-good hash for a native-source-free config so the migration
// is surgical: only instances that HAVE native sources flip stale.
  1. Run --filter RevisionHashServiceTestsFAIL.
  2. Implement: HashableNativeAlarmSource with alphabetical properties (CanonicalName, ConditionFilter, ConnectionName, IsLocked, SourceReference); on HashableConfiguration add public List<HashableNativeAlarmSource>? NativeAlarmSources { get; init; } in alphabetical slot (between InstanceUniqueName and Scripts), populated null-when-empty so WhenWritingNull keeps every native-source-free config's hash byte-identical (no global staleness storm — the migration only flags the instances the fix is about). Extend the HashableRecords_PropertiesDeclaredAlphabetically guard to the new record. Add a migration note to the class doc-comment and docs/requirements/Component-TemplateEngine.md (revision-hash section): native-source-bearing instances will report stale once after upgrade; redeploy clears it — deliberate.
  3. Run → PASS. Commit: git commit -m "fix(template-engine): native alarm sources participate in the revision hash — staleness detection restored (deliberate one-time stale flip for affected instances)"

Task 12: Add NativeAlarmSources to DiffService

Classification: small Estimated implement time: ~4 min Parallelizable with: 18, 1518, 21, 22 Files:

  • Modify: src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/DiffService.cs (add added/removed/changed sweep — file currently has zero NativeAlarm references)
  • Modify: the ConfigurationDiff type in Commons (src/ZB.MOM.WW.ScadaBridge.Commons/Types/Flattening/ — additive section)
  • Test: tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/DiffServiceTests.cs (extend)
  1. Failing test: two flattened configs differing only in one native source's SourceReference → diff reports one changed native source; add/remove cases too.
  2. Run --filter DiffServiceTestsFAIL.
  3. Implement following the exact pattern DiffService uses for Alarms (keyed on CanonicalName, field-compare ConnectionName/SourceReference/ConditionFilter/IsLocked). Additive NativeAlarmSources collection on ConfigurationDiff (message-contract rule: additive only).
  4. Run → PASS; also run tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests (DeploymentComparisonTests consume the diff). Commit: git commit -m "fix(template-engine): DiffService covers native alarm sources — Deployments diff view no longer blind to native-alarm edits"

Task 13: Enforce locks on native alarm source overrides

Classification: standard Estimated implement time: ~5 min Parallelizable with: 18, 1518, 21, 22 Files:

  • Modify: src/ZB.MOM.WW.ScadaBridge.Commons/Types/Flattening/FlattenedConfiguration.cs (ResolvedNativeAlarmSource gains public bool IsLocked { get; init; } — additive)
  • Modify: src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/FlatteningService.cs (ResolveInheritedNativeAlarmSources ~670-710 sets IsLocked; ApplyInstanceNativeAlarmSourceOverrides ~775-792 adds the lock check)
  • Modify: src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs (HandleSetInstanceNativeAlarmSourceOverride ~882-909)
  • Test: tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/FlatteningServiceTests.cs, ManagementService test project (mirror an existing handler test)
  1. Failing tests:
[Fact] public void Flatten_LockedNativeAlarmSource_IgnoresInstanceOverride()
// template source IsLocked=true + instance override repointing ConnectionName
// → resolved source keeps template values, Source stays "Template". FAILS today.

[Fact] public async Task SetNativeAlarmSourceOverride_OnLockedSource_Throws()
// ManagementActor handler rejects with ManagementCommandException. FAILS today.
  1. Run TemplateEngine + ManagementService test filters → FAIL.
  2. Implement:
    • ResolveInheritedNativeAlarmSources: set IsLocked = binding.IsLocked || lockedNames.Contains(binding.Name) on the resolved record (keep lockedNames derived-shadow logic).
    • ApplyInstanceNativeAlarmSourceOverrides: after the TryGetValue, if (existing.IsLocked) continue; — exactly mirroring the attribute (:309) and alarm (:337) paths.
    • HandleSetInstanceNativeAlarmSourceOverride: resolve IFlatteningPipeline from sp (ManagementService already references DeploymentManager), flatten the instance, find the source by cmd.SourceCanonicalName; throw ManagementCommandException($"Native alarm source '{…}' is locked at the template level and cannot be overridden.") when locked, and also reject when the canonical name doesn't resolve at all (prevents creating dangling overrides — matching the flattener's cannot-add rule).
  3. Run → PASS. Update docs/requirements/Component-TemplateEngine.md §Native-alarm override rules if wording needs the enforcement point named. Commit: git commit -m "fix(security): enforce template locks on native alarm source overrides at flatten and management-command level"

Task 14: Script-artifact invalidation contract — Commons seam + Transport publisher (plan-06 handoff)

Classification: high-risk (cross-component contract) Estimated implement time: ~5 min Parallelizable with: 913, 1518, 21, 22 Files:

  • Create: src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Scripting/ScriptArtifactsChanged.cs
  • Create: src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/IScriptArtifactChangeBus.cs
  • Create: src/ZB.MOM.WW.ScadaBridge.Host/Services/InProcessScriptArtifactChangeBus.cs (+ singleton registration in the Host's central-role service wiring)
  • Modify: src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs (optional ctor dep IScriptArtifactChangeBus?; publish after tx.CommitAsync at ~1098)
  • Create: docs/plans/2026-07-08-script-artifact-invalidation-contract.md
  • Test: tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/BundleImporterApplyTests.cs (extend), new Host unit test for the bus
  1. Failing test: apply a bundle that Overwrites an ApiMethod, a SharedScript, and a Template; a recording fake IScriptArtifactChangeBus handed to the importer must receive, after commit, one ScriptArtifactsChanged per kind with the post-resolution (renamed) names. Second test: a failed apply (semantic validation throw) publishes nothing.
  2. Run → FAIL (types don't exist — compile-red, then behavioural red).
  3. Implement the contract:
// Commons/Messages/Scripting/ScriptArtifactsChanged.cs
/// <summary>Advisory, at-least-once, published only AFTER the mutating transaction
/// commits. Consumers must invalidate any compiled/cached artifact keyed by these
/// names, and must ALSO self-heal without the notification (content-hash checks) —
/// this bus is in-process per node; cross-node propagation is the consumer's
/// responsibility (see docs/plans/2026-07-08-script-artifact-invalidation-contract.md).</summary>
public sealed record ScriptArtifactsChanged(
    string ArtifactKind,               // ScriptArtifactKinds.ApiMethod | SharedScript | Template
    IReadOnlyList<string> Names,       // post-resolution (renamed) names
    string Source,                     // "BundleImport" | "Management" | "FailoverActivation"
    DateTimeOffset OccurredAtUtc);
public static class ScriptArtifactKinds { public const string ApiMethod = "ApiMethod"; /* … */ }

// Commons/Interfaces/IScriptArtifactChangeBus.cs
public interface IScriptArtifactChangeBus
{
    void Publish(ScriptArtifactsChanged notification);
    IDisposable Subscribe(Action<ScriptArtifactsChanged> handler);
}

InProcessScriptArtifactChangeBus: lock-free copy-on-write handler list; Publish swallows-and-logs handler exceptions (a bad consumer must never fail an import). BundleImporter collects overwritten/renamed names during apply and publishes once per kind after tx.CommitAsync (never inside the transaction — a notification for a rolled-back write is worse than a missed one). 4. Write the contract doc: semantics above + the explicit handoff table: plan 06 implements (a) the InboundScriptExecutor subscription that evicts _scriptHandlers and _knownBadMethods by name, (b) publishing from the ManagementActor UpdateApiMethod/shared-script paths (closes the project-memory gotchas), (c) cross-node/failover freshness (revision-keyed handler cache). Plan 05 ships the seam + the Transport publisher only. 5. Run → PASS; dotnet build ZB.MOM.WW.ScadaBridge.slnx. Commit: git commit -m "feat(commons/transport): ScriptArtifactsChanged invalidation contract + post-commit publish from bundle import (consumer lands in plan 06)"

Task 15: Cache script-compile verdicts by code hash

Classification: standard Estimated implement time: ~4 min Parallelizable with: 114, 17, 18, 21, 22 Files:

  • Create: src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/ScriptCompileVerdictCache.cs
  • Modify: src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/ScriptCompiler.cs (TryCompile consults the cache)
  • Test: tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/ScriptCompilerTests.cs (extend)
  1. Failing test:
[Fact]
public void TryCompile_SameCodeTwice_SecondCallIsCacheHit()
{
    ScriptCompileVerdictCache.Clear();
    var c = new ScriptCompiler();
    c.TryCompile("return 1;", "A");
    var hitsBefore = ScriptCompileVerdictCache.Hits;
    var r = c.TryCompile("return 1;", "B");   // same code, different name
    Assert.True(r.IsSuccess);
    Assert.Equal(hitsBefore + 1, ScriptCompileVerdictCache.Hits);
}
  1. Run --filter ScriptCompilerTestsFAIL.
  2. Implement: static ConcurrentDictionary<string,(bool ok, string? error)> keyed Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(code))) — the verdict is a pure function of code + policy, so a process-wide cache is sound; the policy is compile-time static. Bound at 4096 entries (on overflow, Clear() — simple and safe). Expose Hits/Count/Clear() for tests and diagnostics. TryCompile keys the cache on code only; the script name is formatted into the returned message at read time (store the name-free error, format on return). This removes both the repeat-CPU and the repeat assembly load: an unchanged script compiles exactly once per process lifetime.
  3. Run → PASS; run the full TemplateEngine test project. Commit: git commit -m "perf(template-engine): cache script compile verdicts by code hash — unchanged scripts compile once per process"

Task 16: Skip script compilation on read-only staleness/comparison paths

Classification: standard Estimated implement time: ~5 min Parallelizable with: 115, 21, 22 Files:

  • Modify: IFlatteningPipeline + src/ZB.MOM.WW.ScadaBridge.DeploymentManager/FlatteningPipeline.cs (FlattenAndValidateAsync(int instanceId, bool validateScripts = true, CancellationToken ct = default) — additive default param)
  • Modify: src/ZB.MOM.WW.ScadaBridge.DeploymentManager/DeploymentService.cs (GetDeploymentComparisonAsync ~652 passes validateScripts: false)
  • Modify: src/ZB.MOM.WW.ScadaBridge.DeploymentManager/StaleInstanceProbe.cs (~34 passes validateScripts: false)
  • Test: tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploymentComparisonTests.cs, DeploymentServiceTests.cs (extend)
  1. Failing test: with a template script that does not compile, GetDeploymentComparisonAsync must still return a comparison (staleness needs the hash, not a compile) — today FlattenAndValidateAsync fails and the comparison errors out; and a mock-pipeline assertion that the comparison/probe paths request validateScripts: false while DeployInstanceAsync keeps true.
  2. Run --filter DeploymentComparisonTestsFAIL.
  3. Implement: FlatteningPipeline skips ValidationService.ValidateScriptCompilation (only that validator — semantic/structural validation stays) when validateScripts is false. Deploy path unchanged (true default). This removes 100% of Roslyn work (and the non-collectible InteractiveAssemblyLoader loads) from the Deployments page sweep and from BundleImporter.ComputeStaleInstanceIdsAsync's per-instance probing.
  4. Run DeploymentManager + Transport integration suites → PASS. Update docs/requirements/Component-DeploymentManager.md (comparison path is hash-only by design; deploy gate remains authoritative).
  5. Commit: git commit -m "perf(deployment): staleness/comparison paths no longer Roslyn-compile every script — deploy gate remains the authoritative compile"

Task 17: Isolate the post-success deployment audit write

Classification: small Estimated implement time: ~3 min Parallelizable with: 116, 21, 22 (same file as 16/18 — coordinate order, regions don't overlap) Files:

  • Modify: src/ZB.MOM.WW.ScadaBridge.DeploymentManager/DeploymentService.cs (~330-332)
  • Test: tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploymentServiceTests.cs (extend)
  1. Failing test: audit-service mock throws on the "Deploy" LogAsync; site responds Success → assert result is Success and the persisted record's Status stays Success (today the catch at :343 flips it to Failed — the exact divergence the surrounding comments prevent, and a violation of "audit-write failure NEVER aborts the user-facing action").
  2. Run → FAIL.
  3. Implement: wrap the LogAsync at :330-332 in its own try/catch that only _logger.LogErrors (mirror TryLogLifecycleTimeoutAsync at :1034). Same guard for any other post-terminal-status audit writes in the lifecycle verbs (audit-only; scan DisableInstanceAsync/EnableInstanceAsync for the same shape and guard if present).
  4. Run → PASS. Commit: git commit -m "fix(deployment): audit-write fault can no longer flip a committed Success deployment to Failed"

Task 18: Delete NotDeployed instances without a site round-trip

Classification: small Estimated implement time: ~4 min Parallelizable with: 116, 21, 22 Files:

  • Modify: src/ZB.MOM.WW.ScadaBridge.DeploymentManager/DeploymentService.cs (DeleteInstanceAsync ~546-585)
  • Test: tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploymentServiceTests.cs (extend)
  1. Failing test: instance in NotDeployed, communication-service mock configured to throw (site unreachable) → DeleteInstanceAsync must succeed and remove the record without ever calling the communication service (assert Verify(..., Times.Never)); Transport-imported instances (always NotDeployed) become deletable against uncommissioned sites.
  2. Run → FAIL.
  3. Implement: after the lock acquisition, if (instance.State == InstanceState.NotDeployed) → skip the site command entirely, delete the central record (reuse the existing guarded delete block), audit "Delete" with new { CommandId = (string?)null, LocalOnly = true }, return success. Matches StateTransitionValidator.CanDelete's documented rationale (:40-49) — the two components currently disagree about the same design sentence.
  4. Run → PASS. Update the matching sentence in docs/requirements/Component-DeploymentManager.md. Commit: git commit -m "fix(deployment): delete of a NotDeployed instance is central-side only — no site round-trip, unreachable sites can't block cleanup"

Task 19: Import blocker heuristic — local-declaration exclusion, denylist additions, warning severity for template scripts

Classification: standard Estimated implement time: ~5 min Parallelizable with: 918, 21, 22 Files:

  • Modify: src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs (RunSemanticValidationAsync ~3680-3692; DetectBlockersAsync ~751-818; KnownNonReferenceNames ~853-874; CollectCallIdentifiers)
  • Test: tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/SemanticValidatorImportTests.cs (extend)
  1. Failing tests:
[Fact] public async Task Apply_TemplateScriptWithLocalPascalCaseMethod_DoesNotHardBlock()
// script: "decimal ComputeRate(decimal x) => x * 2; return ComputeRate(Attributes[\"A\"]);"
// → ApplyAsync succeeds (today: SemanticValidationException on 'ComputeRate').

[Fact] public async Task Apply_TemplateScriptWithRegexAndStringBuilder_DoesNotHardBlock()

[Fact] public async Task Apply_ApiMethodWithUnresolvedReference_StillHardBlocks()
// ApiMethod script calling a genuinely-missing ExternalSystem keeps throwing.
  1. Run --filter SemanticValidatorImportTestsFAIL.
  2. Implement, three edges:
    • Locally-declared names: before the candidate loop, Roslyn-parse each scanned script (CSharpSyntaxTree.ParseText(code, new CSharpParseOptions(kind: SourceCodeKind.Script)) — Transport gets it transitively; add the package ref if not), collect LocalFunctionStatementSyntax/MethodDeclarationSyntax identifier names, and exclude them from referenced for that script's contributions.
    • Denylist additions: add to KnownNonReferenceNames: Regex, Match, Matches, IsMatch, Replace, Split, StringBuilder, Append, AppendLine, Parse, TryParse, Format, Join, Abs, Round, Min, Max, Floor, Ceiling, Pow, Sqrt, Json, Serialize, Deserialize, Where, Select, First, FirstOrDefault, Any, All, Count, Sum, Average, OrderBy, OrderByDescending, GroupBy, Distinct, Add, Remove, Clear, Contains, ContainsKey, TryGetValue, StartsWith, EndsWith, Substring, Trim, ToUpper, ToLower, HAVING, VALUES, DELETE, DISTINCT, LIMIT (comment: the list will still drift — that is why template-script findings are warnings, below).
    • Severity split: track which candidate names came from template scripts vs ApiMethod scripts. Template-script findings become warnings — logged + appended to the ImportResult/preview blocker list with a non-blocking flag (the deploy gate re-validates authoritatively at deploy time, ScriptCompiler.TryCompile); ApiMethod findings remain hard errors (SemanticValidationException) because no downstream design-time gate exists for them. Apply the identical split in DetectBlockersAsync so preview and apply agree.
  3. Run → PASS; full Transport integration suite. Update docs/requirements/Component-Transport.md blocker-row section (template-script name findings are advisory; ApiMethod findings block).
  4. Commit: git commit -m "fix(transport): blocker heuristic no longer hard-blocks valid imports — local declarations excluded, denylist extended, template-script findings downgraded to warnings"

Task 20: Run the script trust gate on bundle import

Classification: standard Estimated implement time: ~4 min Parallelizable with: 918, 21, 22 (same BundleImporter region as Task 19 — do after 19) Files:

  • Modify: src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs (RunSemanticValidationAsync Pass 2, ~3694+; DetectBlockersAsync for preview parity)
  • Test: tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/SemanticValidatorImportTests.cs (extend)
  1. Failing test: bundle whose template script (and, second case, ApiMethod script) contains System.Diagnostics.Process.Start("cmd")ApplyAsync throws SemanticValidationException naming the forbidden API; preview surfaces the same as a blocker row. Today both import clean — the fifth write path with no trust gate.
  2. Run → FAIL.
  3. Implement: in Pass 2, for every non-Skip template script, shared script, and ApiMethod script body, call ScriptTrustValidator.FindViolations(code) (ScriptAnalysis is referenced via TemplateEngine; add the direct project reference to ZB.MOM.WW.ScadaBridge.Transport.csproj if needed). Any violation → hard error for all three kinds (trust violations are not the false-positive-prone name heuristic — the semantic verdict is authoritative, same severity everywhere). The bodies are already in memory; Task 15's verdict cache keeps repeat cost nil.
  4. Run → PASS. Update docs/requirements/Component-ScriptAnalysis.md call-site list (Transport import is now the fifth delegating call site) and Component-Transport.md.
  5. Commit: git commit -m "fix(security): bundle import runs the script trust gate — forbidden-API scripts rejected at import review, not at runtime"

Task 21: Harden the ScriptTrustPolicy deny-list

Classification: high-risk (security policy; false-positive blast radius on existing deployed scripts) Estimated implement time: ~5 min Parallelizable with: 120, 22 Files:

  • Modify: src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ScriptTrustPolicy.cs (ForbiddenScopes :36-45, ReflectionGatewayMembers :66-88)
  • Test: tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/ScriptTrustValidatorTests.cs (extend)
  1. Failing tests:
[Theory]
[InlineData("Environment.Exit(0);")]
[InlineData("Environment.FailFast(\"x\");")]
[InlineData("var s = Environment.GetEnvironmentVariable(\"SCADABRIDGE_API_KEY\");")]
[InlineData("var c = new Microsoft.Data.SqlClient.SqlConnection(\"Server=attacker\");")]
public void FindViolations_Flags(string code) => Assert.NotEmpty(ScriptTrustValidator.FindViolations(code));

[Theory] // syntactic gateway closure (matters in TPA-degraded fallback mode)
[InlineData("typeof(string).Assembly.GetTypes()")]
[InlineData("asm.EntryPoint")]
public void SyntacticPass_Flags(string code) ...

[Theory] // must NOT regress legitimate scripts
[InlineData("await Task.Delay(1);")]
[InlineData("Func<int,int> f = x => x; f.Invoke(3);")]  // delegate Invoke stays allowed — see step 3
public void FindViolations_Allows(string code) => Assert.Empty(ScriptTrustValidator.FindViolations(code));
  1. Run dotnet test tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests --filter ScriptTrustValidatorTestsFAIL.
  2. Implement:
    • ForbiddenScopes += "System.Environment" (whole type — no legitimate script use; Environment.NewLine callers can use "\n"), "System.GC", and the ADO.NET provider namespaces: "Microsoft.Data", "System.Data.SqlClient", "System.Data.Odbc", "System.Data.OleDb" (scripts reach DbConnection only via the Database helper, whose System.Data.Common abstract types stay allowed — closes the arbitrary-host new SqlConnection(...) channel the System.Net deny was meant to cover).
    • ReflectionGatewayMembers += "GetTypes", "EntryPoint", "DeclaredMethods", "DeclaredMembers", "DeclaredConstructors", "DynamicInvoke". Deliberately exclude "Invoke": the syntactic pass rejects regardless of receiver, and delegate.Invoke() is legitimate; MethodInfo.Invoke is already caught semantically (System.Reflection scope) — record this rationale in the code comment.
  3. Run → PASS; also run the SiteRuntime/InboundAPI representative-script suites if they exercise the surface (dotnet test tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests --filter Script — any newly-flagged legitimate pattern is a deliberate decision to surface, not silently absorb).
  4. Update docs/requirements/Component-ScriptAnalysis.md deny-list table + the System.Data posture paragraph. Commit: git commit -m "fix(security): deny Environment/GC and ADO.NET provider namespaces; close reflection-gateway list (GetTypes/EntryPoint/Declared*/DynamicInvoke)"

Task 22: Bound LineDiffer input size

Classification: small Estimated implement time: ~3 min Parallelizable with: 121 Files:

  • Modify: src/ZB.MOM.WW.ScadaBridge.Transport/Import/LineDiffer.cs
  • Test: tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Import/LineDifferTests.cs (extend)
  1. Failing test: two 10,000-line inputs with zero common lines → Diff returns within a small time budget with Truncated = true and a summary-only result (hunk list empty or <N lines> placeholders), and never materializes the Myers trace. Assert e.g. total runtime < 2s (today: ~20k rounds × 160 KB V-snapshots ≈ multi-GB transient allocation — the assertion red-lines as timeout/OOM).
  2. Run --filter LineDifferTestsFAIL (or pathological slowness — treat as fail).
  3. Implement: private const int MaxInputLines = 4000; at the top of Diff: if (aLines.Length + bLines.Length > MaxInputLines) return SummaryOnlyResult(aLines.Length, bLines.Length); producing the same shape the maxLines output cap already emits (Truncated = true, add/remove totals = full line counts, no hunks). The 400-line output cap stays; this bounds the algorithm input so a bloated or crafted bundle cannot OOM the active central node from the import preview.
  4. Run → PASS. Commit: git commit -m "fix(transport): cap LineDiffer input size — oversized script diffs degrade to summary instead of O((N+M)^2) memory"

Task 23: Fix ArtifactDiff placeholder-identity comparisons

Classification: small Estimated implement time: ~4 min Parallelizable with: 918, 21, 22 (touches ArtifactDiff + preview call site — after Tasks 5/6) Files:

  • Modify: src/ZB.MOM.WW.ScadaBridge.Transport/Import/ArtifactDiff.cs (CompareTemplate gains optional IReadOnlyDictionary<int,string>? folderNameById, IReadOnlyDictionary<int,string>? templateNameById; FolderNameOf/BaseTemplateNameOf/CompositionTargetNameOf at 674-693 use them)
  • Modify: src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs (PreviewAsync ~379-388 passes the maps it already builds at ~365-366)
  • Test: tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Import/ArtifactDiffTests.cs (extend)
  1. Failing test: existing template with FolderId, ParentTemplateId, and one composition; incoming DTO carries the matching namesCompareTemplate(dto, existing, folderMap, templateMap) classifies Identical (today: "<id:5>" != "Pump" ⇒ spurious Modified on every re-import, defeating the wizard's auto-skip contract, Component-Transport.md:222).
  2. Run → FAIL.
  3. Implement: resolve real names through the maps, falling back to the <id:N> placeholder only when a map is absent/misses (keeps existing unit tests valid). Wire the two maps through from PreviewAsync.
  4. Run Transport unit + integration suites → PASS. Commit: git commit -m "fix(transport): template diffs resolve folder/base/composition names — unchanged structured templates classify Identical again"

Task 24: Low-severity cleanup batch

Classification: small Estimated implement time: ~5 min Parallelizable with: none (touches files owned by Tasks 15 and 19 — run after both) Files:

  • Modify: src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleSessionStore.cs + src/ZB.MOM.WW.ScadaBridge.Transport/TransportOptions.cs (session cap)
  • Modify: src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/ScriptCompiler.cs (:42, :47 — full error lists)
  • Modify: src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/SemanticValidator.cs (~43-56 — leaf-fallback warning)
  • Tests: BundleSessionStoreTests.cs, ScriptCompilerTests.cs, SemanticValidatorTests (Validation folder)

Three concrete edits, each TDD'd:

  1. Session cap: TransportOptions.MaxConcurrentImportSessions = 8. Failing test: 9th Add throws InvalidOperationException("Too many concurrent import sessions …"); removing one admits a new one. Implement the count check in BundleSessionStore.Add (:38). Bounds the N×~200 MB decrypted-content pinning.
  2. Full error lists: failing test: script with two forbidden APIs → failure message contains both. Implement: string.Join("; ", violations) / string.Join("; ", errors) instead of violations[0]/errors[0] (coordinate with Task 15's cached-error format — store the joined string).
  3. Leaf-fallback warning: failing test: composed script call resolved only by leaf-name fallback yields a ValidationEntry.Warning(ValidationCategory.…, "call target '…' matched by leaf name only — child path not verified") instead of silent acceptance. Implement in the fallback branch at SemanticValidator.cs:43-56 (return-shape permitting; if the method returns bool, thread a warnings collector the way ValidationService already aggregates warnings).
  4. Run all three filters → PASS. Commit: git commit -m "chore: low-severity sweep — import session cap, full compile-error lists, leaf-name-fallback validation warning"

Task 25: Design-doc + CLAUDE.md sync sweep and deferred-decision ledger

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

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

No tests; concrete edits (several were partially made inside their behavior tasks — this pass verifies and completes them, then does a stale-cross-reference sweep per repo convention):

  1. Component-Transport.md: (a) remove the scripts/ bundle-directory section (:45-47) or mark it explicitly "not implemented — MaxBundleEntryCount default 4 would reject it"; (b) transported-entity list gains template NativeAlarmSources, LockedInDerived, script cadence/timeout, real AreaName; (c) blocker section documents the warning/error severity split (Task 19) and the LineDiffer input cap (Task 22); (d) make the rename call-site limitation explicit and load-bearing: a Rename resolution does not rewrite call sites in importing scripts — the target DB can end up with scripts calling the old name; Pass-1 registers both names so import passes, but deploy-time validation on the target is the real gate; (e) note the accepted preview→apply window (no optimistic concurrency; version fields reserved — Underdeveloped 5) as a recorded decision.
  2. Component-TemplateEngine.md: align the alarm-override granularity sentence (:113) to the implementation — only Trigger Definition thresholds and Priority Level are instance-overridable; Description and On-Trigger Script reference are not (InstanceAlarmOverride carries only TriggerConfigurationOverride/PriorityLevelOverride); record it as the decision (adding the two override columns is future feature work, not spec debt). Add the revision-hash native-source migration note (Task 11) if not already present.
  3. Component-DeploymentManager.md: NotDeployed delete is central-side only (Task 18); post-success audit isolation (Task 17); add the explicit OperationLockManager invariant paragraph: locks are per-node in-memory; mutual exclusion assumes management traffic reaches only the active central node (Traefik). Direct standby-port mutations are unsupported — structural rejection of ops on the non-active node is owned by the cluster/UI plans (01/07).
  4. Component-ScriptAnalysis.md: deny-list additions + System.Data provider posture + Transport import as the fifth delegating call site (Tasks 20, 21).
  5. CLAUDE.md: component #24 blurb — add template native-alarm-source transport and confirm the Area-by-name claim is now true; adjust the "Transport (#24, M8)" Key Design Decisions bullet likewise. Per the umbrella-index rule, no ../scadaproj/CLAUDE.md change is needed (no wire-relationship/stack change) — verify and note in the commit message.
  6. git diff review, then commit: git commit -m "docs: sync Transport/TemplateEngine/DeploymentManager/ScriptAnalysis specs + CLAUDE.md with the arch-review fix wave (plan 05)"

Dependencies on other plans

  • Plan 06 (Edge Integrations) — explicit handoff: Task 14 ships the ScriptArtifactsChanged message, IScriptArtifactChangeBus seam, in-process bus, contract doc, and the Transport post-commit publisher. Plan 06 implements the consumers: InboundScriptExecutor eviction of _scriptHandlers/_knownBadMethods, publishing from the ManagementActor ApiMethod/shared-script update paths, and cross-node/failover handler freshness. Plan 06 should not start its consumer work until Task 14's contract doc is committed.
  • Plans 01/07: structural rejection of management mutations on the non-active central node (the OperationLockManager residual risk) belongs to the cluster-routing/management-surface owners; Task 25 records the invariant only.
  • Plan 08: also flagged the AreaName: null half-shipped feature — the fix is owned here (Task 7); plan 08 should not duplicate it.
  • Plan 03: runtime script execution/containment is untouched by this plan (reference only).

Execution order

P0 (start immediately, in parallel): Task 1 → 2 (the Critical, sequential), Task 14 (unblocks plan 06), Task 9 → 10 (flattener correctness), Task 11 (after 13), Task 13. Wave 2 (BundleImporter chain — same file, serialize): 3 → 4 → 5 → 6 → 19 → 20 → 23, with 7 in parallel (exporter files) and 15/16/17/18/21/22 in parallel (disjoint projects). Wave 3: 8 (equivalence suite — after 37), 12, 24. Last: 25 (doc sweep), then a full dotnet build ZB.MOM.WW.ScadaBridge.slnx && dotnet test ZB.MOM.WW.ScadaBridge.slnx plus the direct dotnet test tests/ZB.MOM.WW.ScadaBridge.CLI.Tests run (slnx-exclusion gotcha) before declaring the plan done.