# Architecture Review 05 — Design-Time Pipeline: Templates, Deployment, Transport, Script Analysis (Round 2) **Date:** 2026-07-12 (round 2; round-1 baseline commit `b910f5eb`, re-review at HEAD `8c888f13`) ## Scope `src/ZB.MOM.WW.ScadaBridge.TemplateEngine`, `src/ZB.MOM.WW.ScadaBridge.DeploymentManager`, `src/ZB.MOM.WW.ScadaBridge.Transport`, `src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis`, their design docs (`docs/requirements/Component-TemplateEngine.md`, `Component-Transport.md`, `Component-ScriptAnalysis.md`, `Component-DeploymentManager.md`), and the test projects. Cross-component seams re-checked: Inbound API compiled-handler cache (`InboundScriptExecutor`) as the consumer of the plan-05/plan-06 invalidation work; `ManagementActor` native-alarm-source override handlers. ## Method 1. Re-read the round-1 report (the "silent data loss in Transport import" report: 1 Critical, 7 High, 10 Medium, 5 Low, 7 underdeveloped areas) and `archreview/plans/PLAN-05-templates-deployment-transport.md` (27 fixed / 6 deferred claimed). 2. Verified every round-1 finding's true disposition in current source — code read directly, plan claims not trusted. All dispositions below carry file:line evidence from HEAD. 3. Fresh sweep of `git diff b910f5eb..HEAD` scoped to the four projects (~1,900 insertions across 36 files; 31 commits) for new findings: stability, performance, security, conventions. **One-line verdict:** Round 1 found a Transport import that silently amputated template inheritance, cadence/timeout, lock flags, and native alarm sources; round 2 finds all 26 actionable findings genuinely fixed in code (0 regressions, 5 recorded-decision deferrals intact), guarded by a reflection round-trip suite that itself caught 5 further bugs — the residue is 1 Medium performance leftover (Expression-trigger Roslyn compiles on read-only paths) and 5 Lows. --- ## Round-1 Finding Disposition Legend: **F** = Fixed (verified in source), **P** = Partially fixed, **D** = Deferred (accepted, recorded), **N/A** = no longer applicable. | # | Round-1 finding | R1 sev | Disposition | Evidence (file:line) | |---|---|---|---|---| | 1 | Bundle import drops template inheritance edges (derived templates land as roots) | Critical | **F** | `ResolveInheritanceEdgesAsync` (`Transport/Import/BundleImporter.cs:2542-2616`), called from `ApplyAsync` at `:1261` after the composition rewire; honours Rename via the resolution map (`:2576-2580`), clears stale edges for root-template bundles (`:2566-2569`), unresolvable base → null edge + `BundleImportBaseTemplateUnresolved` audit row, never throws (`:2593-2612`). Round-trip + rename tests: `tests/.../Transport.IntegrationTests/Import/InheritanceImportTests.cs` | | 1b | (Rec. #1 rider) importer performs no acyclicity check — crafted bundle can persist a cycle | Critical (rider) | **F** | `EnsureNoTemplateGraphCycles` (`BundleImporter.cs:2633-2670`) runs `CycleDetector.DetectInheritanceCycle`/`DetectCompositionCycle` over the merged change-tracker graph, called before any SaveChanges of the staged edges (`:1275-1287`); completeness argument sound — any edge to a pre-existing template forces a tracked `GetAllTemplatesAsync` load, which Includes `Compositions`/`NativeAlarmSources` (`ConfigurationDatabase/Repositories/TemplateEngineRepository.cs:69-79`) | | 2 | Imported scripts lose `MinTimeBetweenRuns`/`ExecutionTimeoutSeconds`; `LockedInDerived` absent from DTOs | High | **F** | DTOs carry all three (`Transport/Serialization/EntityDtos.cs:210, 221, 231-236`); `BuildTemplate` copies them (`BundleImporter.cs:1832, 1844, 1856-1858`); all four `SyncTemplate*Async` overwrite paths copy them (`:1941, 2067, 2169-2171, 2277`); fidelity tests `Import/TemplateScriptFidelityTests.cs` | | 3 | Template `NativeAlarmSources` not transported; carried instance overrides dangle | High | **F** | `TemplateNativeAlarmSourceDto` (`EntityDtos.cs:187-195`) with back-compat empty default (`:168-178`); export + `BuildTemplate` (`BundleImporter.cs:1866-1872`) + `SyncTemplateNativeAlarmSourcesAsync` (`:2237-2330`, called from Overwrite at `:1726`); preview diff via `DiffChildren` (`Import/ArtifactDiff.cs:123-128`); tests `Import/NativeAlarmSourceImportTests.cs` incl. the override-no-longer-dangles case | | 4 | Flattener drops grandchild members on repeated composition; CollisionDetector blind | High | **F** | All four `ResolveComposed*Recursive` methods guard on the recursion **path** (add-on-entry / remove-in-finally): attributes `TemplateEngine/Flattening/FlatteningService.cs:286-307`, alarms `:659-680`, native sources `:782-803`, scripts `:947+`; `CollisionDetector.CollectComposedMembers` same conversion with explicit rationale comment (`CollisionDetector.cs:126-155`) | | 5 | Revision hash and diff omit `NativeAlarmSources` — no staleness signal | High | **F** | `HashableNativeAlarmSource` (`Flattening/RevisionHashService.cs:280-298`), folded null-when-empty so native-source-free hashes stay byte-identical (`:103-114` — surgical migration, only affected instances flip stale once); `DiffService` sweeps native sources (`Flattening/DiffService.cs:47-50, 149`); reflective alphabetical guard auto-covers the new record (`tests/.../RevisionHashServiceTests.cs:103-132`) plus `ComputeHash_NativeAlarmSourceChange_ChangesHash` (`:371`) | | 6 | Bundle import leaves stale compiled Inbound API handlers serving old code | High | **F** | Two-layer fix. Contract + publisher (plan-05 T14): `IScriptArtifactChangeBus` (`Commons/Interfaces/Scripting/IScriptArtifactChangeBus.cs:17`), post-commit publish from `ApplyAsync` (`BundleImporter.cs:1340, 1777-1816`), swallow-and-log. Consumer (plan-06) went stronger than name-eviction: `InboundScriptExecutor` compares cached handler script text against the current DB row and recompiles on mismatch, and `_knownBadMethods` fast-fails only when the *current* text equals the recorded bad text (`InboundAPI/InboundScriptExecutor.cs:370-404`) — content-based self-healing that also covers failover/direct-SQL edits | | 7 | Locked native alarm sources overridable at instance level — lock enforced nowhere | High | **F** | `ResolvedNativeAlarmSource.IsLocked` propagated incl. derived-shadow locks (`FlatteningService.cs:722`); `ApplyInstanceNativeAlarmSourceOverrides` skips locked (`:815`); `ManagementActor` rejects on locked source for both single and batch handlers, and rejects dangling canonical names (`ManagementService/ManagementActor.cs:953-1043`, throws at `:978`, `:1037`) | | 8 | Post-success audit failure flips committed Success deployment to Failed | Medium | **F** | Deploy audit routed through guarded `TryLogAuditAsync` explicitly outside the failure path (`DeploymentManager/DeploymentService.cs:329-336`, rationale comment in place) | | 9 | Deleting a `NotDeployed` instance requires a live site round-trip | Medium | **F** | `DeleteInstanceAsync` short-circuits `NotDeployed` to a central-only record delete + audit with `SiteRoundTripSkipped = true`, no communication-service call (`DeploymentService.cs:567-595`); doc aligned (`Component-DeploymentManager.md`) | | 10 | Import blocker heuristic hard-blocks valid imports, no override | Medium | **F** | Three-part fix: locally-declared functions/methods excluded per-origin (`BundleImporter.cs:792-811`, `CollectLocalDeclarations` Roslyn script-parse at `:921-944`); denylist extended; severity split — template-script findings are `ConflictKind.Warning`/`ImportResult.Warnings` (advisory, deploy gate re-validates), ApiMethod findings stay `Blocker`/hard error (`:819, :842-846`, apply side `:4444-4460`) | | 11 | `OperationLockManager` single-node in-memory; standby-node ops not excluded | Medium | **D** (accepted) | Invariant recorded as a decision block (`Component-DeploymentManager.md:94`); structural active-node gating deferred to the cluster/UI plans (01/07) per PLAN-05 coverage table — matches what shipped | | 12 | Every validation run Roslyn-compiles every script — CPU + non-collectible assembly load on read paths | High (perf) | **F** (residual → new finding N1) | Verdict cache keyed on SHA-256 of code, name-free errors, 4096-entry bound (`TemplateEngine/Validation/ScriptCompileVerdictCache.cs:28-69`; consumed at `ScriptCompiler.cs:42-60`); read-only paths skip the compile stage entirely — `GetDeploymentComparisonAsync` (`DeploymentService.cs:690`) and `StaleInstanceProbe` (`StaleInstanceProbe.cs:37`) pass `validateScripts: false` through `FlatteningPipeline.cs:58, 162-174`. Script *bodies* fully covered; Expression-**trigger** compiles remain on read paths — see N1 | | 13 | `LineDiffer` O((N+M)²) memory, no input cap | Medium (perf) | **F** | `MaxInputLines = 4000` (`Transport/Import/LineDiffer.cs:62`); oversized inputs short-circuit to summary-only before the Myers trace (`:84`, `SummaryOnlyResult` `:119`); documented (`Component-Transport.md:23`) | | 14 | Templates with folder/parent/composition never diff `Identical` (placeholder comparisons) | Medium (perf) | **F** | `CompareTemplate` takes optional `folderNameById`/`templateNameById` maps (`ArtifactDiff.cs:64-65, 75-76`), resolvers fall back to placeholders only when a map misses (`:679-700`); `PreviewAsync` passes the maps it already builds (`BundleImporter.cs:395`) | | 15 | `LoadAsync` O(bundle) even for manifest; unbounded session count | Low (perf) | **P** | Session cap shipped: `MaxConcurrentImportSessions = 8` (`TransportOptions.cs:37`) enforced with evict-expired-first in `BundleSessionStore.Open` (`BundleSessionStore.cs:78-88`). Manifest-peek `ReadManifestAsync` deferred as planned, honestly documented (`Component-Transport.md:92`) | | 16 | Import apply is one long EF transaction | Low (perf) | **D** (accepted) | Per plan: dominant cost (Roslyn in `StaleInstanceProbe`) removed by #12; restructuring the single-transaction rollback contract judged high-risk/low-gain. Transaction shape unchanged (`BundleImporter.cs` apply path) — accepted | | 17 | Deny-list gaps: `Environment.Exit`/`FailFast`/`GetEnvironmentVariable`, `GC` | Medium (sec) | **F** | `ForbiddenScopes` += `System.Environment`, `System.GC` with rationale comments (`ScriptAnalysis/ScriptTrustPolicy.cs:45-52`). Bonus beyond plan: `ScriptTrustValidator` now compiles the analysis with the runtime's `DefaultImports` (`WithUsings`), closing the bare-identifier blind spot where `Environment.Exit(0)` under `using System;` under-resolved (`ScriptTrustValidator.cs:118-132`) | | 18 | `System.Data` allowance is an unbounded network hole (concrete providers) | Medium (sec) | **F** | `Microsoft.Data`, `System.Data.SqlClient`, `System.Data.Odbc`, `System.Data.OleDb` denied; `System.Data.Common` abstractions deliberately kept (Database helper posture documented in the code comment and `Component-ScriptAnalysis.md`) (`ScriptTrustPolicy.cs:53-61`) | | 19 | Reflection-gateway member list incomplete (`GetTypes`, `Invoke`, `EntryPoint`, `Declared*`) | Medium (sec) | **F** | `GetTypes`, `EntryPoint`, `DeclaredMethods/Members/Constructors`, `DynamicInvoke` added; `Invoke` deliberately excluded with recorded rationale (delegate `Invoke` legitimate; `MethodInfo.Invoke` caught semantically) (`ScriptTrustPolicy.cs:105-116`) | | 20 | Transport import bypasses the script trust gate entirely | Medium (sec) | **F** | Pass 0 trust gate at apply (`BundleImporter.cs:4301-4335`) and preview (`:849-905`): `ScriptTrustValidator.FindViolations` over every non-Skip template/shared/ApiMethod body **and** template script + alarm Expression-trigger bodies (`EnumerateTrustGatedScripts` `:957-1001`, `ExtractTriggerExpression` `:1004-1035`); hard error for all kinds; fail-closed on validator throw. `Component-ScriptAnalysis.md:5, 173, 193` names Transport the fifth call site | | 21 | Instance alarm overrides implement less than spec (Description / On-Trigger ref) | Medium (conv) | **F** (doc aligned) | Spec now records the narrower granularity as the decision — only `TriggerConfigurationOverride`/`PriorityLevelOverride` overridable; Description/On-Trigger ref are template-level; adding columns is future feature work (`Component-TemplateEngine.md:113`) | | 22 | `ArtifactDiff` equality predicates drift from sync predicates (both directions) | Medium (conv) | **F** | `TemplateChildEquality` single source of truth — complete writable-field comparers for attributes/alarms/scripts/native sources incl. `ElementDataType`, `LockedInDerived`, on-trigger-script-by-name resolver, cadence/timeout (`Transport/Import/TemplateChildEquality.cs:35-100`); both `ArtifactDiff` (`:118-128`) and all four sync `changed` predicates route through it (`BundleImporter.cs:1931, 2045, 2160, 2268`) | | 23 | Spec advertises unimplemented `scripts/` bundle dir vs `MaxBundleEntryCount = 4` | Low | **F** | Spec corrected: scripts travel inside the content blob, a well-formed bundle has exactly two zip entries, separate `scripts/` dir explicitly not part of the format (`Component-Transport.md:49-51`) | | 24 | `TryCompile` surfaces only the first violation/error | Low | **F** (residual → N2) | `ScriptCompiler.TryCompile` joins ALL violations / ALL compile errors (`ScriptCompiler.cs:46-58`). The trigger-expression path was not covered — see N2 | | 25 | `RevisionHashService` determinism contract fragile | Low | **F** | Guard test enumerates every nested `Hashable*` record **reflectively**, so new records are auto-covered (`RevisionHashServiceTests.cs:103-132`); migration note in the class doc (`RevisionHashService.cs:23`) | | U1 | Template fidelity shipped behind site/instance wave | — | **F** | Tasks 1–8 landed; the reflection round-trip equivalence suite (`tests/.../Transport.IntegrationTests/RoundTripEquivalenceTests.cs`) structurally guards every transported entity type and itself caught 5 further silent-loss bugs, fixed in the importer/exporter (commit `7c74bbe4`: ES retry/methods-on-Add, notification recipients on export, folder `ParentFolderId` on Add) | | U2 | No derived-template import test | — | **F** | `Import/InheritanceImportTests.cs` (Add + Overwrite + renamed-base cases) | | U3 | No repeated-composition flattening/collision test | — | **F** | Tests extended with the `y1.z.*` / `y2.z.*` two-slot cases + cycle-termination regression guard (commits `730bf191`, `874e32a9`) | | U4 | Deferred-but-load-bearing: rename call-site rewriting, Transport-012 UI, `ReadManifestAsync` | — | **D** (accepted) | Rename limitation now explicit and load-bearing in the spec; Transport-012 filter UI documented as deferred with the CLI workaround (`Component-Transport.md:354`); `ReadManifestAsync` deferral documented (`:92`) | | U5 | Preview→apply window has no optimistic concurrency (version fields null) | — | **D** (accepted) | Recorded decision paragraph — resolutions keyed by `(EntityType, Name)`, single-operator workflow accepted for v1, version fields reserved (`Component-Transport.md:138`; `ArtifactDiff.cs:33-37`) | | U6 | Three hand-maintained compile-surface mirrors | — | **D** (accepted) | Guard tests exist; source-generator remains an improvement, not a defect. `ScriptCompileSurface` changes since baseline are XML-doc only (verified via diff) | | U7 | `SemanticValidator` leaf-name fallback silently accepts wrong-child calls | — | **F** | Leaf-name-only matches now emit `ValidationEntry.Warning(CallTargetNotFound, "... matched by composed leaf name only — child path not verified")` (`TemplateEngine/Validation/SemanticValidator.cs:123-131`) | **Disposition counts:** 26 Fixed (verified) · 1 Partially fixed (#15 — remainder is the doc-acknowledged `ReadManifestAsync` deferral) · 5 Deferred (accepted, all recorded as decisions in the design docs) · 0 Not fixed · 0 Regressed · 0 No longer applicable. --- ## New Findings (round 2) Fresh review of the ~1,900 lines added across the four projects since `b910f5eb` (31 commits: the PLAN-05 fix wave, PLAN-07's `DeployToSiteAsync` site-scope slice, PLAN-06's ESG `TimeoutSeconds` pipeline ride-along, PLAN-08's options validators, and a docs/fixdocs sweep). ### Performance #### [Medium] N1 — Expression-trigger validation still Roslyn-compiles on read-only staleness paths, uncached The `validateScripts: false` fix (#12) gates only `ValidateScriptCompilation`; `ValidateExpressionTriggers` runs unconditionally in the `Validate` pipeline (`TemplateEngine/Validation/ValidationService.cs:128-134`), and its `CheckExpressionSyntax` performs a fresh `ScriptTrustValidator.FindViolations` (a full `CSharpCompilation` over the TPA reference set) **plus** `RoslynScriptCompiler.Compile` per Expression-triggered script *and* alarm, with no verdict cache (`ValidationService.cs:531-546`). For an environment that uses Expression triggers, every Deployments-page staleness sweep (`DeploymentService.GetDeploymentComparisonAsync:690`) and every template-overwriting bundle import (`StaleInstanceProbe.cs:37`) still pays N×(trust-compilation + script compile) — the exact hot-path cost class round 1 flagged, surviving in a smaller member class. Fix: either gate `ValidateExpressionTriggers`' syntax check behind the same `validateScriptCompilation` flag (the attribute-reference scan can stay), or cache the verdict — **if the `ScriptCompileVerdictCache` is reused, the key must gain the globals-surface discriminator**: it is currently keyed on code alone (`ScriptCompileVerdictCache.cs:53`), which is sound only while `ScriptCompiler` (always `ScriptCompileSurface`) is its sole writer; a trigger expression valid against `TriggerCompileSurface` is not interchangeable with a script-body verdict. ### Stability & Correctness #### [Low] N2 — `CheckExpressionSyntax` surfaces only the first violation / first compile error `ValidationService.cs:534-544` returns `violations[0]` / `errors[0]`. Task 24 fixed exactly this shape in `ScriptCompiler.TryCompile` (full joined lists) but the trigger-expression twin was missed — an operator fixing a multi-error expression gets one deploy round-trip per error. Same one-line `string.Join` fix. #### [Low] N3 — `PublishScriptArtifactChanges` skips `Add` resolutions The post-commit publisher notifies only `Overwrite`/`Rename` resolutions (`BundleImporter.cs:1798-1803`). Edge case: an ApiMethod is deleted on the target, then re-imported as `Add` under the same name — any node still holding a `_knownBadMethods`/handler entry for that name receives no notification. Mitigated to Low because the plan-06 consumer self-heals by content comparison (`InboundScriptExecutor.cs:370-379`), which is exactly the contract's stated design (bus is advisory, consumers must self-heal); noting it so a future *non*-self-healing subscriber doesn't inherit the gap silently. #### [Low] N4 — Import persists silently-inert instance overrides on locked members `ApplyInstancesAsync` writes `InstanceAttributeOverride` / `InstanceNativeAlarmSourceOverride` rows straight from the bundle (`BundleImporter.cs:4001, 4022`) with none of the lock checks the `ManagementActor` enforces for the same writes (`ManagementActor.cs:898, 978`). No security bypass — the flattener drops locked-member overrides (`FlatteningService.cs:319, 815`) — but the imported rows are dead data and the operator gets no warning that part of the bundle's instance config will never take effect. A `ConflictKind.Warning` row (the Task-19 machinery is right there) would surface it in the wizard. ### Security / Trust Model #### [Low] N5 — Import trust gate does not scan instance-override Expression-trigger bodies `EnumerateTrustGatedScripts` covers template script bodies, template script/alarm trigger expressions, shared scripts, and ApiMethods (`BundleImporter.cs:957-1001`) — but not `InstanceAlarmOverrideDto.TriggerConfigurationOverride`, which can carry an `{"expression": ...}` body when the overridden alarm is Expression-triggered. Not exploitable pre-runtime: imported instances land `NotDeployed`, and the deploy gate's `ValidateExpressionTriggers` runs on the *flattened* config (post-override) with the same authoritative `FindViolations` (`ValidationService.cs:531-539`), so a forbidden expression is rejected before it can execute. This is an import-*review* completeness gap only — the operator learns at deploy time instead of in the wizard, the exact UX the Task-20 gate was built to improve. Cheap add: run `ExtractTriggerExpression` over instance alarm-override configs in the enumerator. ### Conventions #### [Low] N6 — `TransportOptionsValidator` misses the knob its own fix wave added The validator covers ten options including the PBKDF2 floor (`TransportOptionsValidator.cs:25-66`) but not `MaxConcurrentImportSessions` — configured to `0`/negative, `BundleSessionStore.Open`'s `_sessions.Count >= cap` (`BundleSessionStore.cs:78-88`) rejects **every** import forever with "Too many concurrent import sessions (0)". Fail-closed, so no safety issue, but it contradicts the validator's stated purpose ("a zero/negative value would either disable a cap [or] crash the import pipeline") and is a one-line `RequireThat`. --- ## What's genuinely good - **The round-trip reflection guard is the standout of the fix wave.** `RoundTripEquivalenceTests` compares every public writable property of every transported entity across export→import, so the *class* of bug that produced round-1's Critical + two Highs (hand-written DTO↔entity mapping drifting) is now structurally impossible to reintroduce silently — and it proved itself immediately by catching 5 residual fidelity holes the per-field unit tests had all passed over (commit `7c74bbe4`). - **Fixes routinely exceeded their tickets.** The trust gate covers Expression-trigger bodies, not just script bodies (`BundleImporter.cs:957-1001`); the deny-list work also fixed a real analyzer blind spot (`WithUsings(ScriptTrustPolicy.DefaultImports)` so bare `Environment`/`GC` resolve to their forbidden types — `ScriptTrustValidator.cs:118-132`); the Inbound API consumer chose content-based invalidation over name eviction, which also self-heals failover and direct-SQL edits (`InboundScriptExecutor.cs:370-404`); the `ManagementActor` lock fix also rejects dangling canonical names, closing the create-inert-override path at the management surface (`ManagementActor.cs:961-980`). - **Deferrals are honest and recorded where the next reader will look.** The preview→apply concurrency window (`Component-Transport.md:138`), the `OperationLockManager` node-affinity invariant (`Component-DeploymentManager.md:94`), the rename-doesn't-rewrite-call-sites limitation, and the `ReadManifestAsync` cost note are all written as explicit decisions with rationale, not silently dropped. - **The import-time acyclicity guard's completeness reasoning is documented in the code** (`BundleImporter.cs:2633` remarks) and holds up under adversarial reading — the rewire passes' full-load fallback plus `GetAllTemplatesAsync`'s `Compositions` Include guarantee every node of any candidate cycle is tracked. - **Single-sourcing done right:** `TemplateChildEquality` carries the "adding a writable field means adding it here, once" contract in its doc comment, and both consumers verifiably route through it; the revision-hash alphabetical guard enumerates nested records reflectively so new member classes are covered by construction. --- ## Severity tally — NEW findings only | Severity | Count | Findings | |---|---|---| | Critical | 0 | — | | High | 0 | — | | Medium | 1 | N1 (Expression-trigger compiles on read-only paths, uncached) | | Low | 5 | N2 (first-error-only trigger syntax), N3 (publisher skips Add), N4 (inert locked-member overrides on import), N5 (instance-override trigger bodies not import-gated), N6 (session-cap knob unvalidated) | **Prioritized recommendations:** (1) gate or cache the Expression-trigger syntax check on read-only paths — keying any shared cache by globals surface (N1); (2) fold N2/N6 into the next low-severity sweep (two one-liners); (3) extend `EnumerateTrustGatedScripts` to instance alarm-override expressions and emit a wizard warning for locked-member overrides (N4+N5 share the same code region).