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).
61 KiB
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.csmutex: 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 editsFlatteningService.csit is not concurrent with T9.
Cross-plan cautions: T13 shares the ManagementActor.cs global single-writer with PLAN-06/07; T25 (docs) later touches CLAUDE.md. See 00-MASTER-TRACKER.md § Parallelization Map for the full mutex table.
Findings Coverage
| Report finding | Severity | Task(s) |
|---|---|---|
| Bundle import drops template inheritance edges | Critical | 1, 2 |
Imported scripts lose MinTimeBetweenRuns/ExecutionTimeoutSeconds; LockedInDerived not in DTOs |
High | 3, 4 |
Template NativeAlarmSources not transported (overrides dangle) |
High | 5 |
| Flattener drops grandchild members on repeated composition; CollisionDetector blind | High | 9, 10 |
Revision hash and diff omit NativeAlarmSources |
High | 11, 12 |
| Import leaves stale compiled Inbound API handlers | High | 14 (contract + Transport publisher; consumer → plan 06) |
| Locked native alarm sources overridable at instance level | High | 13 |
| Post-success audit failure flips Success→Failed | Medium | 17 |
Deleting NotDeployed instance requires live site round-trip |
Medium | 18 |
| Import blocker heuristic hard-blocks on false positives | Medium | 19 |
OperationLockManager single-node in-memory |
Medium | 25 (documented invariant); active-node gating deferred to plans 01/07 (cluster routing ownership) |
| Every validation run compiles every script (CPU + assembly leak) | High (perf) | 15, 16 |
LineDiffer O((N+M)²) memory, no input cap |
Medium (perf) | 22 |
Templates with folder/parent/composition never diff Identical |
Medium (perf) | 23 |
LoadAsync O(bundle) + unbounded session count |
Low (perf) | 24 (session cap); manifest-peek ReadManifestAsync Deferred — already doc-acknowledged (Component-Transport.md:90), pure optimisation |
| Import apply is one long EF transaction | Low (perf) | Deferred — Task 16 removes the dominant cost (Roslyn in StaleInstanceProbe); restructuring the single-transaction rollback contract is high-risk for low residual gain |
Deny-list gaps: Environment.Exit/FailFast/GetEnvironmentVariable |
Medium (sec) | 21 |
System.Data provider namespaces unbounded network hole |
Medium (sec) | 21 |
| Reflection-gateway member list incomplete | Medium (sec) | 21 |
| Transport import bypasses script trust gate | Medium (sec) | 20 |
| Instance alarm overrides implement less than spec | Medium | 25 (doc aligned to implementation — adding DescriptionOverride/OnTriggerScriptOverride columns is a feature, not a fix; decision recorded) |
ArtifactDiff equality predicates drift from sync predicates |
Medium | 6 |
Spec advertises unimplemented scripts/ bundle dir; MaxBundleEntryCount = 4 |
Low | 25 |
TryCompile surfaces only first violation/error |
Low | 24 |
| RevisionHash determinism contract fragile | Low | 11 (guard test extended; migration note) |
| Underdeveloped 1: template fidelity shipped behind site/instance wave | — | 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(addResolveInheritanceEdgesAsync; call it fromApplyAsyncafter line 1044ResolveCompositionEdgesAsync; extend Overwrite branch ~1466-1484) - Test (new):
tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/InheritanceImportTests.cs
- Write failing test (follow the fixture pattern of
Import/SiteInstanceImportTests.cs— source context →BundleExporter.ExportAsync→ target context →LoadAsync/PreviewAsync/ApplyAsync):
[Fact]
public async Task Import_DerivedTemplate_WiresParentTemplateId()
{
// Source: base "Pump" + derived "Pump.WaterPump" (ParentTemplateId -> Pump)
// with one inherited-from-base attribute on Pump.
var bundle = await ExportTemplatesFromSourceAsync("Pump", "Pump.WaterPump");
await ImportIntoEmptyTargetAsync(bundle, ResolutionAction.Add);
var target = await TargetTemplateRepo.GetAllTemplatesAsync();
var basePump = target.Single(t => t.Name == "Pump");
var derived = target.Single(t => t.Name == "Pump.WaterPump");
Assert.Equal(basePump.Id, derived.ParentTemplateId); // FAILS today: null
}
[Fact]
public async Task Import_Overwrite_UpdatesParentEdge_AndRenamedBaseIsHonoured()
{
// Bundle base renamed to "Pump2" via ResolutionAction.Rename; derived's
// BaseTemplateName ("Pump") must resolve through the rename map to Pump2.
...
Assert.Equal(pump2.Id, derived.ParentTemplateId);
}
- Run:
dotnet test tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests --filter FullyQualifiedName~InheritanceImportTests→ expect FAIL (ParentTemplateId null). - Implement
ResolveInheritanceEdgesAsync(IReadOnlyList<TemplateDto> dtos, Dictionary<(string,string), ImportResolution> resolutionMap, string user, CancellationToken ct)mirroringResolveCompositionEdgesAsync(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→ setParentTemplateId = 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 setResolveCompositionEdgesAsyncbuilds). SetParentTemplateId. - Unresolvable base → leave null and emit a
BundleImportBaseTemplateUnresolvedaudit row (mirrorBundleImportAlarmScriptUnresolved), never throw. - Call from
ApplyAsyncimmediately afterResolveCompositionEdgesAsync(line 1044).
- For each non-Skip template DTO: compute the template's persisted name (Rename →
- 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. - Update
docs/requirements/Component-Transport.mdapply-pass list (the second-pass rewire section) to name the inheritance pass alongside alarm-script and composition rewires. - 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 inApplyAsync) - Test:
tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/InheritanceImportTests.cs(extend)
- Write failing test: craft a bundle whose templates form an inheritance cycle against a pre-existing target template (target
Ahas parentB; bundle overwritesBwithBaseTemplateName: "A"). AssertApplyAsyncthrowsSemanticValidationExceptionand the transaction rolled back (targetB.ParentTemplateIdunchanged). Add the composition-cycle twin (bundle composition edge closing a loop through a target template). - Run:
dotnet test tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests --filter FullyQualifiedName~InheritanceImportTests→ FAIL (cycle persists today; importer performs no acyclicity check). - 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, throwSemanticValidationException(new[] {"Import would create a template inheritance/composition cycle: <path>"})— the existing catch path rolls back and reports. - Run test → PASS. Commit:
git commit -m "fix(transport): reject bundle imports that would persist an inheritance/composition cycle"(with the touched paths ingit 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;SyncTemplateScriptsAsyncchangedpredicate + copies ~1836-1851 and the add branch ~1870-1877) - Test (new):
tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/TemplateScriptFidelityTests.cs
- Failing test: export a template whose script has
MinTimeBetweenRuns = TimeSpan.FromSeconds(30)andExecutionTimeoutSeconds = 42; import as Add into empty target, assert both fields on the persistedTemplateScript. Second test: Overwrite a target whose script differs only inExecutionTimeoutSeconds→ assert field updated and aTemplateScriptUpdatedaudit row emitted. dotnet test tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests --filter FullyQualifiedName~TemplateScriptFidelityTests→ FAIL.- Implement: add
MinTimeBetweenRuns = s.MinTimeBetweenRuns, ExecutionTimeoutSeconds = s.ExecutionTimeoutSecondsto theTemplateScriptinitializers inBuildTemplateand theSyncTemplateScriptsAsyncadd branch; add|| current.MinTimeBetweenRuns != scriptDto.MinTimeBetweenRuns || current.ExecutionTimeoutSeconds != scriptDto.ExecutionTimeoutSecondsto thechangedpredicate and copy both fields in the update block. - 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— trailingbool 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, threeSyncTemplate*Asyncpredicates/copies) - Test:
tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Serialization/EntitySerializerTests.cs(extend),tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/TemplateScriptFidelityTests.cs(extend)
- Failing unit test in
EntitySerializerTests: entity withLockedInDerived = trueon an attribute, alarm, and script →ToBundleContentDTOs carry it;FromBundleContentround-trips it. Failing integration test: import as Add → persisted flags true; old-form JSON (no field) still deserializes withfalse(backward-compat assertion — deserialize a hand-written DTO JSON blob missing the property). - Run both test projects with
--filter LockedInDerived→ FAIL (compile errors first — that counts; fix signatures then re-run for behavioural red). - Implement: trailing optional parameter on each record (mirrors the
ExecutionTimeoutSeconds = nullprecedent — additive, noschemaVersionbump needed); populate at export; consume inBuildTemplate,FromBundleContent, and all three sync helpers (changedpredicate + copy). - 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(newTemplateNativeAlarmSourceDto;TemplateDtogains trailingIReadOnlyList<TemplateNativeAlarmSourceDto> NativeAlarmSourcesdefaulting empty) - Modify:
src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntitySerializer.cs(export +FromBundleContent) - Modify:
src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs(BuildTemplate; newSyncTemplateNativeAlarmSourcesAsynccalled from the Overwrite branch at ~1482) - Modify:
src/ZB.MOM.WW.ScadaBridge.Transport/Import/ArtifactDiff.cs(CompareTemplate—DiffChildrenover native sources) - Test (new):
tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/NativeAlarmSourceImportTests.cs; extendEntitySerializerTests
- Failing tests:
[Fact] public async Task Import_Add_CarriesTemplateNativeAlarmSources()
// template with one source (all fields set incl. IsLocked, IsInherited, LockedInDerived)
// → export → import Add → persisted TemplateNativeAlarmSource field-equal.
[Fact] public async Task Import_Overwrite_SyncsNativeAlarmSources()
// target source differs in ConditionFilter; bundle also drops one source and adds one
// → assert update/add/delete applied + audit rows (TemplateNativeAlarmSource{Added,Updated,Deleted}).
[Fact] public async Task Preview_DiffsNativeAlarmSources()
// changed source shows a "NativeAlarmSources.<name>" FieldChange; identical → no change row.
[Fact] public async Task Import_InstanceOverride_NoLongerDangles()
// export template-with-source + instance override of it → import → flatten target instance
// → resolved config contains the source with the override applied (today: silently dropped).
- Run
--filter NativeAlarmSourceImportTests→ FAIL. - Implement:
- DTO:
record TemplateNativeAlarmSourceDto(string Name, string? Description, string ConnectionName, string SourceReference, string? ConditionFilter, bool IsLocked, bool IsInherited, bool LockedInDerived). - Export in
EntitySerializer.ToBundleContent(includeIsInheritedplaceholder rows — the collision detector depends on them) and consume inFromBundleContent. BuildTemplate: copy the collection. NewSyncTemplateNativeAlarmSourcesAsyncmirrorsSyncTemplateAttributesAsync(name-keyed add/update/delete; repository hasDeleteTemplateNativeAlarmSourceAsync-style members — verify exact name onITemplateEngineRepositoryand add if missing).ArtifactDiff.CompareTemplate:DiffChildren(existing.NativeAlarmSources, incoming.NativeAlarmSources, e => e.Name, i => i.Name, NativeAlarmSourcesEqual, "NativeAlarmSources", changes).
- DTO:
- Run → PASS; run full Transport suites. Update
docs/requirements/Component-Transport.mdtransported-entity table +CLAUDE.mdcomponent #24 blurb (template native alarm sources now travel). - 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(replaceAttributesEqual/AlarmsEqual/ScriptsEqualat 646-666;CompareTemplatepasses a script-name resolver for the alarm on-trigger ref) - Modify:
src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs(three syncchangedpredicates delegate to the same class) - Test:
tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Import/ArtifactDiffTests.cs(extend)
- Failing tests (today's drift, both directions):
[Fact] public void CompareTemplate_AttributeElementDataTypeChange_IsModified() // omitted today → false Identical
[Fact] public void CompareTemplate_ScriptExecutionTimeoutChange_IsModified() // omitted today
[Fact] public void CompareTemplate_AlarmOnTriggerScriptChange_IsModified() // omitted today
[Fact] public void CompareTemplate_LockedInDerivedChange_IsModified()
- Run
dotnet test tests/ZB.MOM.WW.ScadaBridge.Transport.Tests --filter ArtifactDiffTests→ FAIL. - Implement
TemplateChildEquality: one static method per entity comparing the complete writable field list (attributes: Value/DataType/ElementDataType/IsLocked/LockedInDerived/Description/DataSourceReference; alarms: +OnTriggerScriptNamevia aFunc<int?, string?> scriptNameByIdresolver built fromexisting.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." PointArtifactDiffand the three (four after Task 5) sync predicates at it. - Run Transport unit + integration suites → PASS.
- 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 gainsareaNameByIdfor exported instances; exporter hasScadaBridgeDbContext— loadAreasfor the instances'AreaIds) - Modify:
src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntitySerializer.cs(~247-256: replace theAreaName: nullTODO with the lookup) - Test:
tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/SiteInstanceImportTests.cs(extend) or newAreaTransportTests.cs
- Failing test: source instance assigned to Area "Line-1" under its site → export → import into target with
--create-missingsite mapping → assert target instance'sAreaIdresolves to an Area named "Line-1" under the target site (the importer'sGetOrCreatemachinery atBundleImporter.cs:3168is already built and tested — only the exporter feeds it null). - Run → FAIL (imported
AreaIdis null). - Implement: extend the export aggregate with the instances' area id→name map (single query over
Areasfiltered to referenced ids) and emitAreaName: areaNameById.TryGetValue(inst.AreaId ?? -1, out var an) ? an : null. Delete the TODO comment. - 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). - 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
- 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 howLockedInDerived, cadence/timeout, and native sources were lost).
private static void AssertRoundTripEqual<T>(T source, T imported, params string[] excluded)
{
foreach (var p in typeof(T).GetProperties().Where(p => p.CanWrite
&& !excluded.Contains(p.Name)
&& p.PropertyType.IsValueType || p.PropertyType == typeof(string)))
Assert.True(Equals(p.GetValue(source), p.GetValue(imported)),
$"{typeof(T).Name}.{p.Name} did not survive export→import");
}
- 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). - 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 fourResolveComposed*Recursivemethods: 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)
- Failing test:
[Fact]
public void Flatten_SameTemplateComposedTwice_ResolvesNestedMembersUnderBothSlots()
{
// X composes Y as "y1" and "y2"; Y composes Z as "z"; Z has attribute "Val",
// alarm "Alm", script "Run", native source "Src".
var result = Flatten(x);
Assert.Contains(result.Attributes, a => a.CanonicalName == "y1.z.Val");
Assert.Contains(result.Attributes, a => a.CanonicalName == "y2.z.Val"); // FAILS today
// + same assertions for Alarms / Scripts / NativeAlarmSources
}
[Fact]
public void Flatten_CompositionCycle_StillTerminates() // regression guard for the cycle role of `visited`
dotnet test tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests --filter Flatten_SameTemplateComposedTwice→ FAIL (y2.z.Valmissing).- Implement: convert the shared
visitedset into a recursion-path set in all four methods. Pattern (identical in each):
// Guard on the recursion PATH, not globally: composition is by-slot, so a template
// legitimately appears twice under different prefixes; only a template already on
// the CURRENT path is a cycle.
var pushed = new List<int>();
foreach (var composedTemplate in composedChain)
if (path.Add(composedTemplate.Id)) pushed.Add(composedTemplate.Id);
try
{
foreach (var composedTemplate in composedChain)
{
if (!compositionMap.TryGetValue(composedTemplate.Id, out var nested)) continue;
foreach (var n in nested)
if (!path.Contains(n.ComposedTemplateId)) // cycle guard
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)
- Failing test: X composes Y twice (
y1,y2); Y composes Z; X also declares a direct attribute namedy2.z.Val(path-collision). Assert the detector reports the collision (today the second slot's subtree is never collected, so it cannot). - Run
--filter CollisionDetectorTests→ FAIL. - Implement the same path-set conversion as Task 9:
visitedbecomes the current recursion path — addtemplate.Idon entry, remove infinally; the early-return then only fires on genuine cycles. - 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(newHashableNativeAlarmSource;HashableConfigurationgains the property in alphabetical position) - Test:
tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/RevisionHashServiceTests.cs(extend, incl. the alphabetical-guard test)
- Failing tests:
[Fact] public void ComputeHash_NativeAlarmSourceChange_ChangesHash() // FAILS today
[Fact] public void ComputeHash_NoNativeAlarmSources_HashUnchangedFromBaseline()
// pin a literal known-good hash for a native-source-free config so the migration
// is surgical: only instances that HAVE native sources flip stale.
- Run
--filter RevisionHashServiceTests→ FAIL. - Implement:
HashableNativeAlarmSourcewith alphabetical properties (CanonicalName,ConditionFilter,ConnectionName,IsLocked,SourceReference); onHashableConfigurationaddpublic List<HashableNativeAlarmSource>? NativeAlarmSources { get; init; }in alphabetical slot (betweenInstanceUniqueNameandScripts), populated null-when-empty soWhenWritingNullkeeps every native-source-free config's hash byte-identical (no global staleness storm — the migration only flags the instances the fix is about). Extend theHashableRecords_PropertiesDeclaredAlphabeticallyguard to the new record. Add a migration note to the class doc-comment anddocs/requirements/Component-TemplateEngine.md(revision-hash section): native-source-bearing instances will report stale once after upgrade; redeploy clears it — deliberate. - 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 zeroNativeAlarmreferences) - Modify: the
ConfigurationDifftype in Commons (src/ZB.MOM.WW.ScadaBridge.Commons/Types/Flattening/— additive section) - Test:
tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/DiffServiceTests.cs(extend)
- Failing test: two flattened configs differing only in one native source's
SourceReference→ diff reports one changed native source; add/remove cases too. - Run
--filter DiffServiceTests→ FAIL. - Implement following the exact pattern DiffService uses for Alarms (keyed on
CanonicalName, field-compareConnectionName/SourceReference/ConditionFilter/IsLocked). AdditiveNativeAlarmSourcescollection onConfigurationDiff(message-contract rule: additive only). - Run → PASS; also run
tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests(DeploymentComparisonTestsconsume 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(ResolvedNativeAlarmSourcegainspublic bool IsLocked { get; init; }— additive) - Modify:
src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/FlatteningService.cs(ResolveInheritedNativeAlarmSources~670-710 setsIsLocked;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)
- Failing tests:
[Fact] public void Flatten_LockedNativeAlarmSource_IgnoresInstanceOverride()
// template source IsLocked=true + instance override repointing ConnectionName
// → resolved source keeps template values, Source stays "Template". FAILS today.
[Fact] public async Task SetNativeAlarmSourceOverride_OnLockedSource_Throws()
// ManagementActor handler rejects with ManagementCommandException. FAILS today.
- Run TemplateEngine + ManagementService test filters → FAIL.
- Implement:
ResolveInheritedNativeAlarmSources: setIsLocked = binding.IsLocked || lockedNames.Contains(binding.Name)on the resolved record (keeplockedNamesderived-shadow logic).ApplyInstanceNativeAlarmSourceOverrides: after theTryGetValue,if (existing.IsLocked) continue;— exactly mirroring the attribute (:309) and alarm (:337) paths.HandleSetInstanceNativeAlarmSourceOverride: resolveIFlatteningPipelinefromsp(ManagementService already references DeploymentManager), flatten the instance, find the source bycmd.SourceCanonicalName; throwManagementCommandException($"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).
- 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 depIScriptArtifactChangeBus?; publish aftertx.CommitAsyncat ~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
- Failing test: apply a bundle that Overwrites an
ApiMethod, aSharedScript, and aTemplate; a recording fakeIScriptArtifactChangeBushanded to the importer must receive, after commit, oneScriptArtifactsChangedper kind with the post-resolution (renamed) names. Second test: a failed apply (semantic validation throw) publishes nothing. - Run → FAIL (types don't exist — compile-red, then behavioural red).
- Implement the contract:
// Commons/Messages/Scripting/ScriptArtifactsChanged.cs
/// <summary>Advisory, at-least-once, published only AFTER the mutating transaction
/// commits. Consumers must invalidate any compiled/cached artifact keyed by these
/// names, and must ALSO self-heal without the notification (content-hash checks) —
/// this bus is in-process per node; cross-node propagation is the consumer's
/// responsibility (see docs/plans/2026-07-08-script-artifact-invalidation-contract.md).</summary>
public sealed record ScriptArtifactsChanged(
string ArtifactKind, // ScriptArtifactKinds.ApiMethod | SharedScript | Template
IReadOnlyList<string> Names, // post-resolution (renamed) names
string Source, // "BundleImport" | "Management" | "FailoverActivation"
DateTimeOffset OccurredAtUtc);
public static class ScriptArtifactKinds { public const string ApiMethod = "ApiMethod"; /* … */ }
// Commons/Interfaces/IScriptArtifactChangeBus.cs
public interface IScriptArtifactChangeBus
{
void Publish(ScriptArtifactsChanged notification);
IDisposable Subscribe(Action<ScriptArtifactsChanged> handler);
}
InProcessScriptArtifactChangeBus: lock-free copy-on-write handler list; Publish swallows-and-logs handler exceptions (a bad consumer must never fail an import). BundleImporter collects overwritten/renamed names during apply and publishes once per kind after tx.CommitAsync (never inside the transaction — a notification for a rolled-back write is worse than a missed one).
4. Write the contract doc: semantics above + the explicit handoff table: plan 06 implements (a) the InboundScriptExecutor subscription that evicts _scriptHandlers and _knownBadMethods by name, (b) publishing from the ManagementActor UpdateApiMethod/shared-script paths (closes the project-memory gotchas), (c) cross-node/failover freshness (revision-keyed handler cache). Plan 05 ships the seam + the Transport publisher only.
5. Run → PASS; dotnet build ZB.MOM.WW.ScadaBridge.slnx. Commit: git commit -m "feat(commons/transport): ScriptArtifactsChanged invalidation contract + post-commit publish from bundle import (consumer lands in plan 06)"
Task 15: Cache script-compile verdicts by code hash
Classification: standard Estimated implement time: ~4 min Parallelizable with: 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(TryCompileconsults the cache) - Test:
tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/ScriptCompilerTests.cs(extend)
- Failing test:
[Fact]
public void TryCompile_SameCodeTwice_SecondCallIsCacheHit()
{
ScriptCompileVerdictCache.Clear();
var c = new ScriptCompiler();
c.TryCompile("return 1;", "A");
var hitsBefore = ScriptCompileVerdictCache.Hits;
var r = c.TryCompile("return 1;", "B"); // same code, different name
Assert.True(r.IsSuccess);
Assert.Equal(hitsBefore + 1, ScriptCompileVerdictCache.Hits);
}
- Run
--filter ScriptCompilerTests→ FAIL. - Implement: static
ConcurrentDictionary<string,(bool ok, string? error)>keyedConvert.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). ExposeHits/Count/Clear()for tests and diagnostics.TryCompilekeys 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. - 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 passesvalidateScripts: false) - Modify:
src/ZB.MOM.WW.ScadaBridge.DeploymentManager/StaleInstanceProbe.cs(~34 passesvalidateScripts: false) - Test:
tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploymentComparisonTests.cs,DeploymentServiceTests.cs(extend)
- Failing test: with a template script that does not compile,
GetDeploymentComparisonAsyncmust still return a comparison (staleness needs the hash, not a compile) — todayFlattenAndValidateAsyncfails and the comparison errors out; and a mock-pipeline assertion that the comparison/probe paths requestvalidateScripts: falsewhileDeployInstanceAsynckeepstrue. - Run
--filter DeploymentComparisonTests→ FAIL. - Implement:
FlatteningPipelineskipsValidationService.ValidateScriptCompilation(only that validator — semantic/structural validation stays) whenvalidateScriptsis false. Deploy path unchanged (truedefault). This removes 100% of Roslyn work (and the non-collectibleInteractiveAssemblyLoaderloads) from the Deployments page sweep and fromBundleImporter.ComputeStaleInstanceIdsAsync's per-instance probing. - Run DeploymentManager + Transport integration suites → PASS. Update
docs/requirements/Component-DeploymentManager.md(comparison path is hash-only by design; deploy gate remains authoritative). - 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)
- Failing test: audit-service mock throws on the
"Deploy"LogAsync; site responds Success → assert result is Success and the persisted record'sStatusstaysSuccess(today the catch at :343 flips it toFailed— the exact divergence the surrounding comments prevent, and a violation of "audit-write failure NEVER aborts the user-facing action"). - Run → FAIL.
- Implement: wrap the
LogAsyncat :330-332 in its own try/catch that only_logger.LogErrors (mirrorTryLogLifecycleTimeoutAsyncat :1034). Same guard for any other post-terminal-status audit writes in the lifecycle verbs (audit-only; scanDisableInstanceAsync/EnableInstanceAsyncfor the same shape and guard if present). - 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)
- Failing test: instance in
NotDeployed, communication-service mock configured to throw (site unreachable) →DeleteInstanceAsyncmust succeed and remove the record without ever calling the communication service (assertVerify(..., Times.Never)); Transport-imported instances (alwaysNotDeployed) become deletable against uncommissioned sites. - Run → FAIL.
- 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"withnew { CommandId = (string?)null, LocalOnly = true }, return success. MatchesStateTransitionValidator.CanDelete's documented rationale (:40-49) — the two components currently disagree about the same design sentence. - 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)
- Failing tests:
[Fact] public async Task Apply_TemplateScriptWithLocalPascalCaseMethod_DoesNotHardBlock()
// script: "decimal ComputeRate(decimal x) => x * 2; return ComputeRate(Attributes[\"A\"]);"
// → ApplyAsync succeeds (today: SemanticValidationException on 'ComputeRate').
[Fact] public async Task Apply_TemplateScriptWithRegexAndStringBuilder_DoesNotHardBlock()
[Fact] public async Task Apply_ApiMethodWithUnresolvedReference_StillHardBlocks()
// ApiMethod script calling a genuinely-missing ExternalSystem keeps throwing.
- Run
--filter SemanticValidatorImportTests→ FAIL. - 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), collectLocalFunctionStatementSyntax/MethodDeclarationSyntaxidentifier names, and exclude them fromreferencedfor 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 inDetectBlockersAsyncso preview and apply agree.
- Locally-declared names: before the candidate loop, Roslyn-parse each scanned script (
- Run → PASS; full Transport integration suite. Update
docs/requirements/Component-Transport.mdblocker-row section (template-script name findings are advisory; ApiMethod findings block). - 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(RunSemanticValidationAsyncPass 2, ~3694+;DetectBlockersAsyncfor preview parity) - Test:
tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/SemanticValidatorImportTests.cs(extend)
- Failing test: bundle whose template script (and, second case, ApiMethod script) contains
System.Diagnostics.Process.Start("cmd")→ApplyAsyncthrowsSemanticValidationExceptionnaming the forbidden API; preview surfaces the same as a blocker row. Today both import clean — the fifth write path with no trust gate. - Run → FAIL.
- 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 toZB.MOM.WW.ScadaBridge.Transport.csprojif 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. - Run → PASS. Update
docs/requirements/Component-ScriptAnalysis.mdcall-site list (Transport import is now the fifth delegating call site) andComponent-Transport.md. - 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)
- Failing tests:
[Theory]
[InlineData("Environment.Exit(0);")]
[InlineData("Environment.FailFast(\"x\");")]
[InlineData("var s = Environment.GetEnvironmentVariable(\"SCADABRIDGE_API_KEY\");")]
[InlineData("var c = new Microsoft.Data.SqlClient.SqlConnection(\"Server=attacker\");")]
public void FindViolations_Flags(string code) => Assert.NotEmpty(ScriptTrustValidator.FindViolations(code));
[Theory] // syntactic gateway closure (matters in TPA-degraded fallback mode)
[InlineData("typeof(string).Assembly.GetTypes()")]
[InlineData("asm.EntryPoint")]
public void SyntacticPass_Flags(string code) ...
[Theory] // must NOT regress legitimate scripts
[InlineData("await Task.Delay(1);")]
[InlineData("Func<int,int> f = x => x; f.Invoke(3);")] // delegate Invoke stays allowed — see step 3
public void FindViolations_Allows(string code) => Assert.Empty(ScriptTrustValidator.FindViolations(code));
- Run
dotnet test tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests --filter ScriptTrustValidatorTests→ FAIL. - Implement:
ForbiddenScopes+="System.Environment"(whole type — no legitimate script use;Environment.NewLinecallers can use"\n"),"System.GC", and the ADO.NET provider namespaces:"Microsoft.Data","System.Data.SqlClient","System.Data.Odbc","System.Data.OleDb"(scripts reachDbConnectiononly via theDatabasehelper, whoseSystem.Data.Commonabstract types stay allowed — closes the arbitrary-hostnew SqlConnection(...)channel theSystem.Netdeny was meant to cover).ReflectionGatewayMembers+="GetTypes","EntryPoint","DeclaredMethods","DeclaredMembers","DeclaredConstructors","DynamicInvoke". Deliberately exclude"Invoke": the syntactic pass rejects regardless of receiver, anddelegate.Invoke()is legitimate;MethodInfo.Invokeis already caught semantically (System.Reflectionscope) — record this rationale in the code comment.
- 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). - Update
docs/requirements/Component-ScriptAnalysis.mddeny-list table + theSystem.Dataposture 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)
- Failing test: two 10,000-line inputs with zero common lines →
Diffreturns within a small time budget withTruncated = trueand 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). - Run
--filter LineDifferTests→ FAIL (or pathological slowness — treat as fail). - Implement:
private const int MaxInputLines = 4000;at the top ofDiff:if (aLines.Length + bLines.Length > MaxInputLines) return SummaryOnlyResult(aLines.Length, bLines.Length);producing the same shape themaxLinesoutput 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. - 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(CompareTemplategains optionalIReadOnlyDictionary<int,string>? folderNameById,IReadOnlyDictionary<int,string>? templateNameById;FolderNameOf/BaseTemplateNameOf/CompositionTargetNameOfat 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)
- 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). - Run → FAIL.
- 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 fromPreviewAsync. - 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:
- Session cap:
TransportOptions.MaxConcurrentImportSessions = 8. Failing test: 9thAddthrowsInvalidOperationException("Too many concurrent import sessions …"); removing one admits a new one. Implement the count check inBundleSessionStore.Add(:38). Bounds the N×~200 MB decrypted-content pinning. - Full error lists: failing test: script with two forbidden APIs → failure message contains both. Implement:
string.Join("; ", violations)/string.Join("; ", errors)instead ofviolations[0]/errors[0](coordinate with Task 15's cached-error format — store the joined string). - 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 atSemanticValidator.cs:43-56(return-shape permitting; if the method returns bool, thread a warnings collector the wayValidationServicealready aggregates warnings). - 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):
- Component-Transport.md: (a) remove the
scripts/bundle-directory section (:45-47) or mark it explicitly "not implemented —MaxBundleEntryCountdefault 4 would reject it"; (b) transported-entity list gains templateNativeAlarmSources,LockedInDerived, script cadence/timeout, realAreaName; (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. - 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 (
InstanceAlarmOverridecarries onlyTriggerConfigurationOverride/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. - Component-DeploymentManager.md:
NotDeployeddelete is central-side only (Task 18); post-success audit isolation (Task 17); add the explicitOperationLockManagerinvariant 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). - Component-ScriptAnalysis.md: deny-list additions +
System.Dataprovider posture + Transport import as the fifth delegating call site (Tasks 20, 21). - 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.mdchange is needed (no wire-relationship/stack change) — verify and note in the commit message. git diffreview, 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
ScriptArtifactsChangedmessage,IScriptArtifactChangeBusseam, in-process bus, contract doc, and the Transport post-commit publisher. Plan 06 implements the consumers:InboundScriptExecutoreviction 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
OperationLockManagerresidual risk) belongs to the cluster-routing/management-surface owners; Task 25 records the invariant only. - Plan 08: also flagged the
AreaName: nullhalf-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.