# 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/`. ## 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](00-MASTER-TRACKER.md#parallelization-map--incomplete-tasks-2026-07-09) 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 | — | 1–8 | | 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`): ```csharp [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); } ``` 2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests --filter FullyQualifiedName~InheritanceImportTests` → expect **FAIL** (ParentTemplateId null). 3. Implement `ResolveInheritanceEdgesAsync(IReadOnlyList 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). 4. 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`. 5. 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. 6. 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~InheritanceImportTests` → **FAIL** (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: "})` — 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:** 9–13, 15–18, 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~TemplateScriptFidelityTests` → **FAIL**. 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:** 9–13, 15–18, 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 LockedInDerived` → **FAIL** (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:** 9–13, 15–18, 21, 22 **Files:** - Modify: `src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntityDtos.cs` (new `TemplateNativeAlarmSourceDto`; `TemplateDto` gains trailing `IReadOnlyList 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` (`CompareTemplate` — `DiffChildren` over native sources) - Test (new): `tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/NativeAlarmSourceImportTests.cs`; extend `EntitySerializerTests` 1. Failing tests: ```csharp [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." 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). ``` 2. Run `--filter NativeAlarmSourceImportTests` → **FAIL**. 3. 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)`. 4. 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). 5. 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:** 9–13, 15–18, 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): ```csharp [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() ``` 2. Run `dotnet test tests/ZB.MOM.WW.ScadaBridge.Transport.Tests --filter ArtifactDiffTests` → **FAIL**. 3. 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 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. 4. Run Transport unit + integration suites → **PASS**. 5. 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:** 1–4, 9–13, 15–18, 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' `AreaId`s) - 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:** 9–18, 21, 22 (test-only; depends on Tasks 3–5, 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). ```csharp private static void AssertRoundTripEqual(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"); } ``` 2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests --filter RoundTripEquivalenceTests` — expect **PASS** if Tasks 1–7 are complete (any failure here is a residual fidelity hole: fix it in the importer/exporter, not the test). 3. 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:** 1–8, 15–18, 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: ```csharp [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` ``` 2. `dotnet test tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests --filter Flatten_SameTemplateComposedTwice` → **FAIL** (`y2.z.Val` missing). 3. Implement: convert the shared `visited` set into a **recursion-path** set in all four methods. Pattern (identical in each): ```csharp // 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(); 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 ResolveComposed…Recursive(n, $"{prefix}.{n.InstanceName}", …, path); } } finally { foreach (var id in pushed) path.Remove(id); } ``` (Rename the parameter `visited` → `path` 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:** 1–8, 15–18, 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 CollisionDetectorTests` → **FAIL**. 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:** 1–8, 15–18, 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: ```csharp [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. ``` 2. Run `--filter RevisionHashServiceTests` → **FAIL**. 3. Implement: `HashableNativeAlarmSource` with alphabetical properties (`CanonicalName`, `ConditionFilter`, `ConnectionName`, `IsLocked`, `SourceReference`); on `HashableConfiguration` add `public List? 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.* 4. 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:** 1–8, 15–18, 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 DiffServiceTests` → **FAIL**. 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:** 1–8, 15–18, 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: ```csharp [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. ``` 2. Run TemplateEngine + ManagementService test filters → **FAIL**. 3. 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). 4. 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:** 9–13, 15–18, 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: ```csharp // Commons/Messages/Scripting/ScriptArtifactsChanged.cs /// 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). public sealed record ScriptArtifactsChanged( string ArtifactKind, // ScriptArtifactKinds.ApiMethod | SharedScript | Template IReadOnlyList 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 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:** 1–14, 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: ```csharp [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); } ``` 2. Run `--filter ScriptCompilerTests` → **FAIL**. 3. Implement: static `ConcurrentDictionary` 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. 4. 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:** 1–15, 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 DeploymentComparisonTests` → **FAIL**. 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:** 1–16, 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.LogError`s (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:** 1–16, 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:** 9–18, 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: ```csharp [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. ``` 2. Run `--filter SemanticValidatorImportTests` → **FAIL**. 3. 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. 4. Run → **PASS**; full Transport integration suite. Update `docs/requirements/Component-Transport.md` blocker-row section (template-script name findings are advisory; ApiMethod findings block). 5. 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:** 9–18, 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:** 1–20, 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: ```csharp [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 f = x => x; f.Invoke(3);")] // delegate Invoke stays allowed — see step 3 public void FindViolations_Allows(string code) => Assert.Empty(ScriptTrustValidator.FindViolations(code)); ``` 2. Run `dotnet test tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests --filter ScriptTrustValidatorTests` → **FAIL**. 3. 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. 4. 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). 5. 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:** 1–21 **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 `` 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 LineDifferTests` → **FAIL** (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:** 9–18, 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? folderNameById`, `IReadOnlyDictionary? 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 names* → `CompareTemplate(dto, existing, folderMap, templateMap)` classifies **Identical** (today: `"" != "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 `` 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 3–7), 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.