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

644 lines
61 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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](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 | — | 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`):
```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<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).
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: <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~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:** 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 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:** 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` (`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.<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).
```
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:** 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):
```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<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 '<entity> 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:** 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' `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:** 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).
```csharp
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");
}
```
2. 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).
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:** 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:
```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<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 `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:** 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 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:** 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:
```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<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.*
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:** 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 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:** 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:
```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:** 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:
```csharp
// 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:
```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<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.
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:** 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 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:** 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.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:** 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:
```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:** 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:
```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<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));
```
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:** 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 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:** 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 names*`CompareTemplate(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.