Files
ScadaBridge/code-reviews/TemplateEngine/findings.md
T
Joseph Doherty d39089f4ed docs(code-review): full review at 4307c381 — 18 modules, 67 findings recorded + remediation tracked
Full per-module re-review of the 16 stale modules (last seen 1eb6e97 / 2026-05-28)
plus first-ever reviews of KpiHistory (#26) and ScriptAnalysis (#25), at HEAD 4307c381.

67 new findings (0 Critical, 6 High, 27 Medium, 34 Low). Remediation in commit
fd618cf1 closed 5 of the 6 Highs and ~33 Medium/Low; the rest are Deferred/Won't Fix
with rationale. Remaining pending (4) are all InboundAPI's Database-helper findings
(IA-026 High .. IA-029), left to the active feat/ipsen-movein effort per owner decision.

Highlights: caught a central-only-delivery security drift (SMTP creds broadcast to
sites — DM-025/SR-031), a never-committed 'Resolved' fix (SiteEventLogging-016 → -024),
an unguarded KPI recorder tick (KH-001), a trust-analyzer fallback weakening (SA-001),
and a native-alarm subscribe-path leak (DCL-023). ScriptAnalysis verdict: trust boundary
is semantically sound (symbol-based) in the production cluster config.

README regenerated; regen-readme.py --check passes (4 pending / 567 total).
2026-06-20 18:02:32 -04:00

78 KiB
Raw Blame History

Code Review — TemplateEngine

Field Value
Module src/ZB.MOM.WW.ScadaBridge.TemplateEngine
Design doc docs/requirements/Component-TemplateEngine.md
Status Reviewed
Last reviewed 2026-06-20
Reviewer claude-agent
Commit reviewed 4307c381
Open findings 0

Summary

The Template Engine is a pure central-side modeling library: stateless services over ITemplateEngineRepository plus four static helper classes (collision, cycle, lock, resolver). It has no Akka actors and no direct concurrency, so the Akka and thread-safety categories produce nothing of substance. The code is generally well-structured and the cascade-based composition model (derived templates owned by composition slots) is consistently applied. However the review surfaced several real correctness gaps. The most serious are in flattening: composed alarms and scripts nested below the first level are silently dropped, derived templates omit base alarms entirely (breaking per-slot alarm override), and the alarm-on-trigger-script resolution step is an empty placeholder so that whole validation rule is dead. Validation has two security-relevant weaknesses — the forbidden-API scan is a naive substring match and the brace-balance "compile" check mispredicts on verbatim / interpolated / raw string literals. Several documented behaviours (collision check on create, optimistic concurrency on instance state) are claimed but not implemented. Themes: validation that is weaker than the design promises, and asymmetric handling of attributes vs. alarms vs. scripts throughout the resolve/flatten/derive paths.

Re-review 2026-05-17 (commit 39d737e)

Re-reviewed the whole module against all ten checklist categories at commit 39d737e. All fourteen prior findings remain closed — the batch-4 fixes (bc88a36/804697f and predecessors) hold up: the recursive composition walk, the per-slot alarm override mechanism, the code-region-aware delimiter scanner, and the single-source deletion-constraint logic are all correctly in place. Two new Medium findings surfaced, both in the composition-cascade path and both affecting nested (depth ≥ 2) compositions specifically — the same blind spot that produced TemplateEngine-001. TemplateEngine-015: RenameCompositionAsync renames only the directly slot-owned derived template, leaving cascaded inner derived templates with a stale dotted-path name. TemplateEngine-016: FlatteningService hard-codes ScriptScope.ParentPath to the empty string for every composed script regardless of nesting depth, so a script two or more levels deep cannot resolve Parent.X references to its real parent module. Both are limited-impact (nested compositions are the less common case and there is design-time visibility) but represent genuine drift from the recursive-nesting design promise.

Re-review 2026-05-28 (commit 1eb6e97)

Re-reviewed the whole module against all ten checklist categories at commit 1eb6e97. All sixteen prior findings remain closed. Six new findings surfaced, clustered in three themes:

  1. Revision-hash / diff coverage gapsRevisionHashService and DiffService both omit Attributes.Description, Alarms.Description, and the entire Connections map. A change that only edits an attribute/alarm description, or a data-connection endpoint, will deploy a new flattened configuration but be invisible to staleness detection and the diff view — the very gap the revision hash was introduced to close (TemplateEngine-017, TemplateEngine-018). Severity Medium/High.

  2. TemplateEngine-013 fix only partially applied — the 0-as-no-parent sentinel was removed from CycleDetector but TemplateResolver .BuildInheritanceChain still uses currentId != 0 / ParentTemplateId ?? 0. A template with a real Id of 0 is treated as "no template" and silently excluded from its own inheritance chain, so every flatten/resolve through that template loses its members. The fix from adb5e75 did not propagate into the resolver (TemplateEngine-019). Severity Medium.

  3. Audit log integrity / drift — every Create audit entry in TemplateService and SharedScriptService is written with EntityId = "0" before SaveChangesAsync populates the real key, so the audit trail loses the link back to the created row (TemplateEngine-020); MoveTemplateAsync never validates folder-acyclicity / sibling-name-uniqueness even though TemplateFolderService.MoveFolderAsync does (TemplateEngine-021); and the advertised IS NOT_locked & not_LockedInDerived & not_IsInherited self-reference loop is intact, but LockEnforcer.ValidateLockChange permits downgrading a LockedInDerived flag on a base template — there is no equivalent of the once-locked-stays-locked rule for the LockedInDerived flag (TemplateEngine-022). Severity Low/Medium.

Themes: hash/diff drift from the deployment payload, asymmetric application of the duplicate-Id / null-sentinel fix from the last batch, and audit-write ordering inconsistency between TemplateService (logs then saves) and InstanceService (saves then logs).

Re-review 2026-06-20 (commit 4307c381) — full review

Re-reviewed the whole module against all ten checklist categories at commit 4307c381. The large delta since 1eb6e97 (722 commits, including the ScadaLink → ZB.MOM.WW.ScadaBridge rename, the Script Analysis #25 extraction, native-alarm-source authoring/flatten, the read-only inheritance-resolve service, List-attribute semantics, and Transport line-diff) holds up well. All 22 prior findings remain closed and verified: the deploy gate now delegates to the authoritative ScriptTrustValidator + RoslynScriptCompiler (real semantic compile, no residual substring scan or hand-rolled brace counter in the security path — ScriptCompiler and ValidationService.CheckExpressionSyntax both call the shared analyzer); the recursive composition walk, per-slot alarm override, duplicate-Id-tolerant BuildLookup, int?-parent walk, and the revision-hash / diff Description+Connections+ElementDataType coverage are all in place and test-guarded. The new TemplateInheritanceResolver (M9-T26a) mirrors the flattener's inheritance precedence exactly (including HiLo per-setpoint merge), verified by Resolve_AgreesWithFlatteningService_* tests. Three new findings surfaced — all lock/audit asymmetries where a newer feature did not pick up an invariant an older sibling already enforces: native-alarm-source instance overrides bypass the lock at flatten time (the unfixed-for-native sibling of the closed TemplateEngine-008; ResolvedNativeAlarmSource carries no IsLocked flag), the four TemplateFolderService mutators stage their audit row after the final SaveChangesAsync so the row is never persisted, and a dead/misnamed SemanticValidator.ParseParameterDefinitions legacy branch returns types not names.

# Category Examined Notes
1 Correctness & logic bugs New: native-source instance override applies with no lock check (TemplateEngine-023). Flattening inherit/compose/override precedence + recursion verified correct for attrs/alarms/scripts/native-sources; HiLo per-setpoint merge consistent with resolver.
2 Akka.NET conventions No actors. AddTemplateEngineActors is still an empty placeholder. Nothing to assess.
3 Concurrency & thread safety Services stateless/scoped; static helpers hold no mutable state. No new findings.
4 Error handling & resilience Result<T> used consistently; Flatten wraps in try/catch; JSON parse failures fall back safely. No store-and-forward/failover surface.
5 Security Deploy gate is now authoritative via Script Analysis #25 (ScriptTrustValidator.FindViolations + RoslynScriptCompiler.Compile) — alias/using static/global:: bypasses caught by symbol resolution; no residual substring scan in the security path. No new findings.
6 Performance & resource management TemplateEngine-009 N+1 fix holds (Compositions navigation reused). JsonDocument/streams disposed. No new leaks.
7 Design-document adherence Native-alarm-source lock rule ("Lock applies to the entire source") not enforced in flattening (TemplateEngine-023). Otherwise the doc matches: revision-hash/diff payload coverage, semantic validation set, acyclicity, derived-naming all align.
8 Code organization & conventions New: TemplateFolderService Create/Rename/Move/Reorder log after the final save → audit row never persisted (TemplateEngine-024), asymmetric with TemplateService (which always saves after LogAsync).
9 Testing coverage Strong overall (inheritance-resolver agrees-with-flattener tests, hash determinism test). Gaps: no test for a locked native source rejecting an instance override; folder tests mock IAuditService so the never-saved audit row is invisible; ParseParameterDefinitions legacy branch untested.
10 Documentation & comments New: ParseParameterDefinitions is documented as returning parameter NAMES but its legacy-array branch returns the type field (TemplateEngine-025). XML docs otherwise accurate and thorough.

Checklist coverage

Re-review (2026-05-17, 39d737e):

# Category Examined Notes
1 Correctness & logic bugs Prior bugs (001005, 013) all resolved and verified. Re-review 2026-05-17 found two new nested-composition defects: rename does not cascade (TemplateEngine-015), composed-script ParentPath always empty (TemplateEngine-016).
2 Akka.NET conventions No actors in this module (AddTemplateEngineActors is an empty placeholder). Nothing to assess.
3 Concurrency & thread safety Services are stateless, scoped per request; static helpers hold no mutable state. Design says template editing is last-write-wins; that is honoured. See TemplateEngine-010 re: a doc claim of optimistic concurrency that is not implemented.
4 Error handling & resilience Result<T> used consistently; repository nulls guarded. FlatteningService wraps in try/catch. No store-and-forward or failover surface in this module.
5 Security No auth checks in-module (delegated to callers per design). Script trust-model enforcement is weak — see TemplateEngine-006 and TemplateEngine-007.
6 Performance & resource management GetAllTemplatesAsync reloaded on most member edits; one genuine N+1 in TemplateDeletionService (TemplateEngine-009). No IDisposable leaks (JsonDocument/streams disposed).
7 Design-document adherence Drift found: recursive composition not fully implemented in flattening; DataType enum naming differs from doc; optimistic-concurrency claim.
8 Code organization & conventions POCO entities in Commons, repo interfaces in Commons, Options pattern N/A (no options here). Duplicate deletion logic (TemplateEngine-014).
9 Testing coverage Tests exist for every file, but the dead/placeholder paths (TemplateEngine-004, 005) and deep nesting (TemplateEngine-001) are not exercised.
10 Documentation & comments Mostly accurate; a misleading converter comment (TemplateEngine-011) and a stale enum/doc mismatch (TemplateEngine-012).

Re-review (2026-05-28, 1eb6e97):

# Category Examined Notes
1 Correctness & logic bugs New: TemplateResolver.BuildInheritanceChain still uses the 0-as-no-parent sentinel that was removed from CycleDetector in adb5e75 (TemplateEngine-019). TemplateService.MoveTemplateAsync performs no folder-acyclicity or sibling-name-uniqueness check (TemplateEngine-021).
2 Akka.NET conventions No actors. AddTemplateEngineActors is still an empty placeholder. Nothing to assess.
3 Concurrency & thread safety Services remain stateless, scoped per request. No new findings.
4 Error handling & resilience Result<T> used consistently. MoveTemplateAsync is missing target-folder validation found elsewhere — see TemplateEngine-021.
5 Security No new findings. Forbidden-API limitations still tracked under the closed TemplateEngine-006 (resolved as advisory).
6 Performance & resource management MergeHiLoConfig / PrefixTriggerAttribute allocate a MemoryStream + Utf8JsonWriter + Encoding.UTF8.GetString per call — fine for the per-flatten frequency, no finding. No new resource leaks.
7 Design-document adherence New drift: RevisionHashService and DiffService both omit Description fields and the Connections map from the deployable payload (TemplateEngine-017, TemplateEngine-018), so the revision hash and diff do not reflect every committed deployment input.
8 Code organization & conventions Audit-write ordering asymmetric: TemplateService.Create* and SharedScriptService.CreateSharedScriptAsync log with EntityId = "0" before SaveChangesAsync, while InstanceService.CreateInstanceAsync saves first then logs with the real Id (TemplateEngine-020).
9 Testing coverage New finding paths exercised in part — RevisionHashServiceTests does not assert that Description / Connections changes change the hash; no test for BuildInheritanceChain with a real Id of 0; no test for MoveTemplateAsync rejecting a target folder.
10 Documentation & comments New: LockEnforcer.ValidateLockChange is documented as enforcing the once-locked-stays-locked rule but has no equivalent for LockedInDerived (TemplateEngine-022).

Findings

TemplateEngine-001 — Deeply nested composed members are dropped during flattening

Severity High
Category Correctness & logic bugs
Status Resolved
Location src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/FlatteningService.cs:211, src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/FlatteningService.cs:535, src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/FlatteningService.cs:609

Description

The design doc states composition supports "recursive nesting of feature modules" and that nested paths extend as [Outer].[Inner].[Member]. ResolveComposedAttributes only descends one level of nesting: it resolves the directly-composed module, then its immediate child compositions, and stops. A module composed three or more levels deep contributes no attributes to the flattened configuration. ResolveComposedAlarms and ResolveComposedScripts are worse — they handle only the first (direct) level and do not descend at all, so any alarm or script in a nested composed module is dropped entirely. CollisionDetector and TemplateResolver recurse fully, so collision detection and the authoring UI will show members that the deployed configuration silently lacks.

Recommendation

Replace the hand-unrolled one/two-level loops with a single recursive walk (carrying the accumulated path prefix) for attributes, alarms, and scripts, matching the recursion already in TemplateResolver.AddComposedMembers and CollisionDetector.CollectComposedMembers.

Resolution

Resolved 2026-05-16 (commit <pending>): replaced the hand-unrolled one/two-level composition loops in ResolveComposedAttributes, ResolveComposedAlarms, and ResolveComposedScripts with single recursive walks (*Recursive helpers) carrying the accumulated path prefix and a visited set, so composed members at arbitrary nesting depth are resolved. Regression tests: Flatten_ThreeLevelComposition_AttributesAlarmsScriptsAllResolved, Flatten_NestedComposedAlarm_TriggerAttributePrefixed.

TemplateEngine-002 — Derived templates omit all base alarms; composed alarms cannot be overridden per slot

Severity High
Category Correctness & logic bugs
Status Resolved
Location src/ZB.MOM.WW.ScadaBridge.TemplateEngine/TemplateService.cs:799

Description

BuildDerivedTemplate copies the base template's Attributes and Scripts into the new derived template as IsInherited = true placeholder rows so they can be overridden per composition slot, but there is no loop for Alarms. The derived template therefore has zero alarm rows. The TemplateAlarm entity also has no IsInherited or LockedInDerived fields (unlike TemplateAttribute / TemplateScript), so even if a copy loop were added there is no mechanism to mark a copied alarm as inherited or to override one. The design's Override Granularity section explicitly requires composed alarm fields (Priority, Trigger thresholds, Description, On-Trigger Script) to be overridable. As written, a composed module's alarms cannot be tuned for the slot they are used in.

Recommendation

Add an alarm copy loop to BuildDerivedTemplate and add IsInherited / LockedInDerived fields to TemplateAlarm, mirroring TemplateAttribute. Update UpdateAlarmAsync to honour them as UpdateAttributeAsync / UpdateScriptAsync already do.

Resolution

Resolved 2026-05-16 (commit <pending>): implemented the per-slot alarm override mechanism as a coordinated Commons + ConfigurationDatabase + TemplateEngine change, mirroring the existing attribute/script override design. Added IsInherited / LockedInDerived to the TemplateAlarm POCO (ZB.MOM.WW.ScadaBridge.Commons) and an EF migration AddDerivedAlarmFields adding two bit NOT NULL DEFAULT 0 columns to TemplateAlarms. BuildDerivedTemplate now copies base alarms as IsInherited = true placeholder rows. FlatteningService.ResolveInheritedAlarms skips IsInherited placeholder rows so they no longer shadow the live base alarm, and ValidateLockedInDerived now rejects a derived override of a LockedInDerived base alarm. UpdateAlarmAsync honours the base LockedInDerived lock and persists IsInherited / LockedInDerived, exactly as UpdateAttributeAsync / UpdateScriptAsync do. Regression tests: Flatten_InheritedAlarmOnDerived_BaseValueWins, Flatten_OverriddenAlarmOnDerived_DerivedValueWins, Flatten_LockedInDerivedAlarmOverride_Fails, AddComposition_CopiesAlarmsAsInherited, UpdateAlarm_LockedInDerivedBase_RejectsOnDerived, UpdateAlarm_DerivedOverride_PersistsIsInheritedFalse.

TemplateEngine-003 — UpdateAttributeAsync lets a non-locked attribute change its fixed DataType / DataSourceReference

Severity High
Category Correctness & logic bugs
Status Resolved
Location src/ZB.MOM.WW.ScadaBridge.TemplateEngine/TemplateService.cs:285

Description

LockEnforcer.ValidateAttributeOverride correctly rejects a change to DataType or DataSourceReference (both "fixed by the defining level" per the design). But the caller only honours that error when the attribute is already locked:

var granularityError = LockEnforcer.ValidateAttributeOverride(existing, proposed);
if (granularityError != null && existing.IsLocked)
    return Result<TemplateAttribute>.Failure(granularityError);

Lines 293-294 then unconditionally apply existing.DataType = proposed.DataType and existing.DataSourceReference = proposed.DataSourceReference. For the common case of an unlocked attribute, the fixed-field guard is dead and both fields are silently mutable, violating the override-granularity rule. (The lock-error branch of the same helper is also redundant — a locked attribute already returns earlier inside the helper.)

Recommendation

Remove the && existing.IsLocked condition so the granularity error is always returned, and stop assigning DataType / DataSourceReference from proposed in the apply block.

Resolution

Resolved 2026-05-16 (commit <pending>): removed the && existing.IsLocked guard in UpdateAttributeAsync so the fixed-field granularity error is always honoured, and removed the unconditional existing.DataType / existing.DataSourceReference assignments from the apply block. Regression tests: UpdateAttribute_UnlockedAttribute_DataTypeChangeRejected, UpdateAttribute_UnlockedAttribute_DataSourceReferenceChangeRejected.

TemplateEngine-004 — Alarm on-trigger script references are never resolved (empty placeholder)

Severity High
Category Correctness & logic bugs
Status Resolved
Location src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/FlatteningService.cs:695

Description

ResolveAlarmScriptReferences is invoked as Step 7 of Flatten but its body is empty — only a comment describing what it should do. Consequently every ResolvedAlarm.OnTriggerScriptCanonicalName stays null. This has two downstream effects: (1) SemanticValidator's "on-trigger script must exist" check (SemanticValidator.cs:209) can never fire, so the design-mandated validation of alarm on-trigger script references is silently absent; (2) RevisionHashService and DiffService both hash/compare OnTriggerScriptCanonicalName, so a change to which script an alarm triggers never affects the revision hash and is invisible to the diff — a real staleness-detection gap.

Recommendation

Implement the resolution: map each alarm's OnTriggerScriptId (set on TemplateAlarm) to the canonical name of the corresponding resolved script, accounting for composition prefixes. If the design intends scripts to be referenced by name within scope, document and implement that consistently.

Resolution

Resolved 2026-05-16 (commit <pending>): implemented ResolveAlarmScriptReferences. Alarm resolution now records each resolved alarm's OnTriggerScriptId keyed by canonical name, and script resolution records each resolved TemplateScript.Id keyed by its canonical name (both honour composition path prefixes). Step 7 joins the two maps to set ResolvedAlarm.OnTriggerScriptCanonicalName, so the revision hash, diff, and SemanticValidator on-trigger-script-exists check now all see the reference. Regression tests: Flatten_AlarmOnTriggerScript_ResolvedToCanonicalName, Flatten_ComposedAlarmOnTriggerScript_ResolvedWithPrefix.

TemplateEngine-005 — Collision validation is skipped when creating a child template

Severity High
Category Correctness & logic bugs
Status Resolved
Location src/ZB.MOM.WW.ScadaBridge.TemplateEngine/TemplateService.cs:56

Description

CreateTemplateAsync contains a block guarded by if (parentTemplateId.HasValue) that loads GetAllTemplatesAsync and then does nothing but hold a comment — it never runs a collision check. A child template created with a parent inherits the parent's members; if the child is later given members (via AddAttributeAsync etc.) those calls do run CollisionDetector, but the create path itself performs no naming-collision validation and UpdateTemplateAsync only validates collisions on a name change. The design states naming collisions are design-time errors that must block a save. The dead block is also confusing and allocates an unused full-table read.

Recommendation

Either run a real collision check on the to-be-created template (including its inherited members) or delete the dead block and its unused query. If create-time collisions are genuinely impossible because a fresh template has no members, document that explicitly instead of leaving a no-op.

Resolution

Resolved 2026-05-16 (commit <pending>): deleted the dead if (parentTemplateId.HasValue) block and its unused GetAllTemplatesAsync read in CreateTemplateAsync. A create-time collision check on a child is a guaranteed no-op — a freshly created template has no members of its own, the parent's members were already collision-validated on every member-mutating call, and a new child cannot be an ancestor of its parent. Replaced the no-op with an explanatory comment documenting that collision detection is enforced on AddAttribute/AddAlarm/AddScript/AddComposition and on rename. Regression test: CreateTemplate_WithParent_DoesNotRunDeadCollisionQuery.

TemplateEngine-006 — Forbidden-API enforcement is a naive substring scan (bypassable and false-positive prone)

Severity Medium
Category Security
Status Resolved
Location src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/ScriptCompiler.cs:21, src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/ValidationService.cs:318

Description

ScriptCompiler.ForbiddenPatterns is checked with code.Contains(pattern). This is both under- and over-inclusive against the script trust model:

  • Bypass: using System.IO; followed by File.ReadAllText(...) contains no System.IO. token; using static System.IO.File;, namespace aliases, and global::System.IO.File all evade the literal patterns.
  • False positive: a string literal, comment, or attribute name containing the text System.IO. is flagged as a forbidden API even though it is inert.

The same patterns are reused for trigger-expression validation (CheckExpressionSyntax), inheriting the same weakness. The file comment acknowledges this is interim until Roslyn is wired in, but the trust model is security-relevant and the gap should be tracked.

Recommendation

Defer real enforcement to the Roslyn-based compiler (semantic symbol analysis of referenced types/namespaces) rather than text matching. Until then, document the limitation prominently and treat the substring scan as advisory, not authoritative.

Resolution

Resolved 2026-05-16 (commit pending): fixed the false-positive half and documented the (deferred) bypass half. Added CSharpDelimiterScanner.ContainsInCode, a code-region-aware substring search that blanks out string/char-literal/comment spans before matching, so the inert text System.IO. inside a string or comment is no longer flagged. ScriptCompiler.TryCompile and ValidationService.CheckExpressionSyntax now use it. The bypass half (namespace aliases, using static, global::) genuinely requires Roslyn semantic symbol analysis, which the TemplateEngine project deliberately does not reference — that authoritative check is deferred to the real script compiler / Site Runtime sandbox. The limitation is now documented prominently as a SECURITY LIMITATION note in the ScriptCompiler class summary and the ForbiddenPatterns doc, and the scan is explicitly labelled advisory. Regression tests: TryCompile_ForbiddenApiTextInsideStringLiteral_NotFlagged, TryCompile_ForbiddenApiTextInsideComment_NotFlagged, TryCompile_ForbiddenApiInRealCode_StillFlagged.

TemplateEngine-007 — Brace-balance "compilation" misjudges verbatim / interpolated / raw strings

Severity Medium
Category Correctness & logic bugs
Status Resolved
Location src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/ScriptCompiler.cs:54, src/ZB.MOM.WW.ScadaBridge.TemplateEngine/SharedScriptService.cs:124

Description

ScriptCompiler.TryCompile tracks string state with a single inString flag toggled on " and an escaped-quote check of code[i-1] != '\\'. It does not understand verbatim strings (@"..." where "" is the escape and \ is literal), interpolated strings ($"{...}" whose braces are code, not text), raw string literals ("""..."""), or char literals. A script with a verbatim string containing a brace, an interpolated string, or a '}' char literal will be wrongly rejected as having mismatched braces — blocking a valid script from deployment. SharedScriptService.ValidateSyntax is even cruder: it counts braces/brackets/parens with no string or comment awareness at all, so any string literal containing one of those characters produces a false syntax error.

Recommendation

Once the Roslyn compiler is available, parse with CSharpSyntaxTree.ParseText and inspect diagnostics instead of hand-rolling a tokenizer. If an interim check must remain, at minimum handle verbatim/interpolated/char literals or scope the check down to something that cannot false-positive.

Resolution

Resolved 2026-05-16 (commit pending): replaced both hand-rolled string trackers with CSharpDelimiterScanner, a single string-/comment-aware scanner that correctly skips regular strings (with \ escapes), verbatim strings (@"...", "" escape), interpolated strings ($"..." / $@"...", interpolation holes {...} treated as code, {{/}} as escaped braces), C# 11 raw string literals ("""..."""), char literals, and line/block comments while tracking {}/[]/() depth. ScriptCompiler .TryCompile and SharedScriptService.ValidateSyntax now delegate to it, so a valid script containing a delimiter inside a literal/comment is no longer falsely rejected; genuine mismatches are still caught. Regression tests in ScriptCompilerTests (TryCompile_VerbatimStringWithBrace_*, _VerbatimStringWithEscapedQuote_*, _InterpolatedStringWithBraces_*, _RawStringLiteralWithBraces_*, _CharLiteralWithBrace_*, _GenuineMismatchedBraces_StillDetected) and SharedScriptServiceTests.ValidateSyntax_DelimiterInsideStringOrComment_ReturnsNull.

TemplateEngine-008 — SetAlarmOverrideAsync accepts overrides for unknown / composed alarms with no validation

Severity Medium
Category Error handling & resilience
Status Resolved
Location src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Services/InstanceService.cs:178

Description

SetAlarmOverrideAsync looks up the alarm by name among the template's direct alarms only. When the lookup returns null — which is the case for every composed (path-qualified) alarm as well as for a genuinely non-existent name — the method skips the lock check and proceeds to persist the override. This means: (1) an override can be created for an alarm that does not exist (a silent dead record), and (2) a composed alarm that is IsLocked at the template level can be overridden, bypassing the lock rule. SetAttributeOverrideAsync by contrast rejects unknown attribute names. The inline comment acknowledges the gap but the behaviour is inconsistent and risky.

Recommendation

Resolve the full effective alarm set (via the resolver / flattening) so composed alarms are found, reject overrides whose canonical name is not in that set, and apply the lock check to composed alarms too.

Resolution

Resolved 2026-05-16 (commit pending): SetAlarmOverrideAsync now resolves the instance template's full effective alarm set via TemplateResolver.ResolveAllMembers (loaded from GetAllTemplatesAsync) instead of looking up only the template's direct alarms. An override whose canonical name is absent from that set is rejected with a "does not exist" failure (mirroring SetAttributeOverrideAsync); the IsLocked check now also applies to composed (path-qualified) and inherited alarms, closing the lock-bypass. Regression tests: SetAlarmOverride_NonExistentAlarm_ReturnsFailure, SetAlarmOverride_ComposedLockedAlarm_ReturnsFailure, SetAlarmOverride_ComposedUnlockedAlarm_ReturnsSuccess, SetAlarmOverride_DirectLockedAlarm_ReturnsFailure.

TemplateEngine-009 — N+1 query in TemplateDeletionService.CanDeleteTemplateAsync

Severity Medium
Category Performance & resource management
Status Resolved
Location src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Services/TemplateDeletionService.cs:75

Description

Check 3 ("other templates compose it directly") loads all templates and then issues a separate GetCompositionsByTemplateIdAsync call inside a loop over every template — one round-trip per template in the database. The composition information needed is already reachable via t.Compositions on the templates returned by GetAllTemplatesAsync (which TemplateService.DeleteTemplateAsync uses for the equivalent check at line 162). The loop scales linearly with the template count on every delete-precheck and every actual delete.

Recommendation

Use the Compositions navigation already loaded by GetAllTemplatesAsync, or add a single repository call that returns all compositions, rather than querying per template.

Resolution

Resolved 2026-05-16 (commit pending): CanDeleteTemplateAsync Check 3 now reads the Compositions navigation already loaded by GetAllTemplatesAsync (a single SelectMany) instead of issuing one GetCompositionsByTemplateIdAsync round-trip per template — the same source TemplateService.DeleteTemplateAsync uses for the equivalent check. The per-delete cost no longer scales with template count. Regression test: CanDeleteTemplate_DoesNotIssuePerTemplateCompositionQuery (verifies GetCompositionsByTemplateIdAsync is never called); the existing CanDeleteTemplate_ComposedByOthers_ReturnsFailure and CanDeleteTemplate_MultipleConstraints_AllErrorsReported tests were updated to seed the Compositions navigation, matching how EF's GetAllTemplatesAsync loads it.

TemplateEngine-010 — InstanceService documents optimistic concurrency that is not implemented

Severity Low — re-triaged from Medium: this is a stale XML comment, not a behavioural defect. The code matches the design (last-write-wins); only the doc string was wrong.
Category Documentation & comments
Status Resolved
Location src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Services/InstanceService.cs:9

Description

The class summary states instances support "Enabled/disabled state with optimistic concurrency". EnableAsync, DisableAsync, AssignToAreaAsync and the override/binding mutators all perform a plain read-modify-write with no version token, RowVersion, or concurrency check. Two concurrent enable/disable requests last-writer-wins with no detection. Either the doc is stale (the design's optimistic-concurrency decision applies to deployment status records, not instance state) or a concurrency token was intended and is missing.

Recommendation

If last-write-wins is acceptable for instance state, correct the XML doc. If optimistic concurrency is required, add a concurrency token to Instance and surface a conflict result.

Re-triage

Verified against the design: docs/requirements/Component-TemplateEngine.md states "Concurrent editing uses last-write-wins — no pessimistic locking or conflict detection." The system's optimistic-concurrency decision (per CLAUDE.md) applies to deployment status records, not instance state. The code is therefore correct — a plain read-modify-write is the intended behaviour — and the only defect is the stale "with optimistic concurrency" phrase in the class XML summary. Re-triaged from Medium (Error handling) to Low (Documentation): doc-only fix, no behaviour change.

Resolution

Resolved 2026-05-16 (commit pending): corrected the InstanceService class XML summary — replaced "Enabled/disabled state with optimistic concurrency" with an explicit statement that instance-state edits are last-write-wins (no version token / conflict detection), citing the design decision and noting that optimistic concurrency in the system applies to deployment status records, not instance state. No code or behaviour change; no regression test (documentation-only).

TemplateEngine-011 — SortedPropertiesConverterFactory is dead code with a misleading comment

Severity Low
Category Documentation & comments
Status Resolved
Location src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/RevisionHashService.cs:136

Description

SortedPropertiesConverterFactory.CanConvert always returns false and CreateConverter always returns null, so the factory registered in CanonicalJsonOptions does nothing. The class comment claims it "ensures properties are serialized in alphabetical order for deterministic output", and the options comment says "Ensure consistent ordering" — both are false. Determinism actually relies entirely on the Hashable* records being hand-declared with alphabetically-ordered properties (plus camelCase). That works today but is fragile: a future contributor adding a property out of alphabetical order silently changes every revision hash, and the dead converter gives false confidence that ordering is enforced programmatically.

Recommendation

Either implement the converter to genuinely sort properties, or delete it and replace the comments with an explicit note that determinism depends on the manual property ordering of the Hashable* records (ideally enforced by a test).

Resolution

Resolved 2026-05-16 (commit pending commit): deleted the dead SortedPropertiesConverterFactory (and removed it from CanonicalJsonOptions), and replaced the misleading "sorted keys / consistent ordering" comments with an explicit DETERMINISM CONTRACT note on the RevisionHashService class summary — System.Text.Json serializes properties in CLR declaration order and does not sort, so stable hashes rely on the private Hashable* records declaring their properties alphabetically (collections are explicitly sorted by CanonicalName). That manual ordering is now guarded by a regression test: RevisionHashServiceTests.HashableRecords_PropertiesDeclaredAlphabetically (reflects over the nested Hashable* types and asserts ordinal-alphabetical property declaration order), so adding a property out of order now fails the build's test gate instead of silently changing every revision hash.

TemplateEngine-012 — DataType enum naming diverges from the design doc

Severity Low
Category Design-document adherence
Status Deferred
Location src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/SemanticValidator.cs:18

Description

The design doc (Attribute section) lists data types as "Boolean, Integer, Float, String". The actual DataType enum is Boolean, Int32, Float, Double, DateTime, Binary. SemanticValidator.NumericDataTypes correctly hard-codes the real names (Int32, Float, Double), so the code is internally consistent, but the design doc is stale — it omits Double, DateTime, Binary and calls the integer type "Integer". This makes the doc an unreliable reference for which trigger-operand types are numeric.

Recommendation

Update docs/requirements/Component-TemplateEngine.md to list the actual enum members, or rename the enum to match the doc if "Integer" is the intended canonical name.

Re-triage

Verified against the source: the DataType enum is declared in src/ZB.MOM.WW.ScadaBridge.Commons/Types/Enums/DataType.cs (Boolean, Int32, Float, Double, String, DateTime, Binary) — not in the TemplateEngine module — and is consumed across modules (TemplateAttribute entity, management command contracts). The only in-module file the finding cites, SemanticValidator.cs:18, is confirmed correct: NumericDataTypes already hard-codes the real enum names. Both remediation options in the recommendation therefore land outside this module's resolution boundary (src/ZB.MOM.WW.ScadaBridge.TemplateEngine/**): renaming the enum touches ZB.MOM.WW.ScadaBridge.Commons (and every consumer of DataType), and the alternative — updating the design doc — touches docs/requirements/Component-TemplateEngine.md. There is no in-module code defect to fix. Re-triaged from Open to Deferred: the fix is a one-line design-doc correction (list the actual seven enum members instead of "Boolean, Integer, Float, String") that must be made by an agent owning the docs / Commons scope.

Resolution

Deferred 2026-05-16 (no commit): no in-module fix possible — see Re-triage. The TemplateEngine code is correct as-is. FLAGGED for the docs owner: correct the Attribute data-type list in docs/requirements/Component-TemplateEngine.md to match ZB.MOM.WW.ScadaBridge.Commons DataType (Boolean, Int32, Float, Double, String, DateTime, Binary). Renaming the enum is not recommended (cross-module churn for no behavioural gain); the doc is the authoritative thing to fix.

TemplateEngine-013 — ToDictionary(t => t.Id) throws on duplicate IDs; cycle detectors overload Id 0 as a sentinel

Severity Low
Category Correctness & logic bugs
Status Resolved
Location src/ZB.MOM.WW.ScadaBridge.TemplateEngine/CycleDetector.cs:30, src/ZB.MOM.WW.ScadaBridge.TemplateEngine/CycleDetector.cs:38

Description

Across the static helpers, allTemplates.ToDictionary(t => t.Id) is used freely; if the caller ever passes a list containing two templates with the same Id (e.g. a not-yet-saved template assigned Id == 0, or duplicated input) the call throws an unhandled ArgumentException rather than returning a Result failure. Separately, CycleDetector uses 0 as the "no parent" sentinel (currentId != 0, ParentTemplateId ?? 0) and DetectInheritanceCycle / DetectCrossGraphCycle ignore a proposed parent/composed id of 0. EF identity keys start at 1 so this is currently benign, but the overload is fragile — an in-memory or test template with Id == 0 would be treated as "no template" and cycle checks would be silently skipped.

Recommendation

Guard the dictionary builds (or use a grouping/ToLookup) and validate input, and use int?/-1 rather than 0 as the no-parent sentinel so a real id of 0 is never special.

Resolution

Resolved 2026-05-16 (commit pending commit): added CycleDetector.BuildLookup, a duplicate-tolerant Id-keyed lookup (Dictionary + TryAdd, first occurrence wins) that replaces every allTemplates.ToDictionary(t => t.Id) in the static helpers — CycleDetector (all three methods), TemplateResolver.ResolveAllMembers, and CollisionDetector.DetectCollisions — so a list containing two templates with the same Id (e.g. not-yet-saved templates carrying Id 0) no longer throws an unhandled ArgumentException. Separately, the 0-as-"no-parent" sentinel was removed: DetectInheritanceCycle now walks the parent chain via the int? ParentTemplateId (HasValue gates continuation), and DetectCrossGraphCycle gates the proposed parent/composed edges on HasValue rather than != 0, so a template with a real Id of 0 is treated like any other node and cycles through it are detected. Regression tests: CycleDetectorTests.DetectInheritanceCycle_DuplicateIdsInList_DoesNotThrow, DetectCompositionCycle_DuplicateIdsInList_DoesNotThrow, DetectCrossGraphCycle_DuplicateIdsInList_DoesNotThrow, DetectInheritanceCycle_RealIdZero_StillDetectsCycle, DetectInheritanceCycle_ParentChainThroughIdZero_DetectsCycle.

TemplateEngine-014 — Template-deletion constraint logic is duplicated and divergent

Severity Low
Category Code organization & conventions
Status Resolved
Location src/ZB.MOM.WW.ScadaBridge.TemplateEngine/TemplateService.cs:109, src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Services/TemplateDeletionService.cs:27

Description

TemplateService.DeleteTemplateAsync and TemplateDeletionService.CanDeleteTemplateAsync both implement the "can this template be deleted" rules (instances, child templates, derived templates, composing templates). The two implementations have already drifted: TemplateService reads composing templates from the in-memory t.Compositions navigation while TemplateDeletionService issues per-template GetCompositionsByTemplateIdAsync calls (see TemplateEngine-009), they format error messages differently, and TemplateService returns on the first failing category while TemplateDeletionService accumulates all of them. A future rule change must be made in two places or behaviour will diverge further.

Recommendation

Make TemplateService.DeleteTemplateAsync delegate to TemplateDeletionService (or vice versa) so the constraint logic lives in exactly one place.

Resolution

Resolved 2026-05-16 (commit pending commit): TemplateService.DeleteTemplateAsync no longer re-implements the deletion-constraint rules — it now delegates the constraint check and the delete to TemplateDeletionService.DeleteTemplateAsync (the surviving single implementation, which already accumulates every blocking reason rather than returning on the first failing category). TemplateService retains only its audit-logging side effect: after a successful delete it writes the Delete audit entry and calls SaveChangesAsync (the deletion service is unaware of auditing and persists the delete itself, so the audit entry needs its own save). The constraint logic now lives in exactly one place, so a future rule change cannot drift between two implementations. Behavioural change: DeleteTemplateAsync now reports all blocking reasons and uses TemplateDeletionService's phrasing — the affected TemplateServiceTests delete tests were updated to the unified messages, and a regression test DeleteTemplate_MultipleConstraints_ReportsAllNotJustFirst verifies all three constraint categories are surfaced together.

TemplateEngine-015 — RenameCompositionAsync does not cascade-rename nested derived templates

Severity Medium
Category Correctness & logic bugs
Status Resolved
Location src/ZB.MOM.WW.ScadaBridge.TemplateEngine/TemplateService.cs:680

Description

AddCompositionAsync builds a cascade of derived templates whose names follow a dotted path: composing $Sensor (which itself composes $Probe as Probe1) into $Pump as TempSensor produces $Pump.TempSensor and the nested $Pump.TempSensor.Probe1 (see CreateCascadedCompositionAsync and the AddComposition_CascadesChildCompositions test). RenameCompositionAsync, however, renames only the directly slot-owned derived template:

var derived = await _repository.GetTemplateByIdAsync(composition.ComposedTemplateId, ...);
if (derived != null && derived.IsDerived && derived.OwnerCompositionId == compositionId)
{
    var newDerivedName = $"{owner.Name}.{newInstanceName}";
    ...
    derived.Name = newDerivedName;
    await _repository.UpdateTemplateAsync(derived, ...);
}

There is no recursion into derived.Compositions. After renaming the TempSensor slot to MainSensor, the parent derived becomes $Pump.MainSensor but the cascaded child stays $Pump.TempSensor.Probe1 — its name no longer reflects the slot path it lives under, breaking the dotted-path naming invariant the cascade otherwise maintains. DeleteCompositionAsync correctly recurses (CascadeDeleteDerivedAsync), so rename is the asymmetric outlier. The RenameComposition_RenamesSlotAndDerivedTemplate test only exercises a single-level derived, so the gap is untested. The stale name also breaks the AddComposition_DerivedNameCollision_Fails / cascade-name pre-check on any subsequent compose that walks the now-inconsistent name tree.

Recommendation

Recurse over derived.Compositions (mirroring CascadeDeleteDerivedAsync), re-deriving each cascaded child's name from the renamed parent ($"{parentDerivedName}.{childComposition.InstanceName}"), and run the existing same-name collision pre-check across every name the cascade will produce — not just the top-level one. Add a regression test covering a two-level cascade rename.

Resolution

Resolved 2026-05-17 (commit pending): RenameCompositionAsync now recurses into derived.Compositions via a new CollectCascadeRenamesAsync helper (mirroring CascadeDeleteDerivedAsync), re-deriving each cascaded inner derived template's name from its renamed parent and slot instance name, and runs the same-name collision pre-check across every name in the cascade before any row mutates. Regression tests: RenameComposition_CascadesRenameToNestedDerivedTemplates, RenameComposition_NestedCascadeNameCollision_Fails.

TemplateEngine-016 — Composed-script ScriptScope.ParentPath is always empty, breaking Parent.X resolution for nested modules

Severity Medium
Category Correctness & logic bugs
Status Resolved
Location src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/FlatteningService.cs:750

Description

ResolveComposedScriptsRecursive assigns each composed script a ScriptScope:

Scope = new Commons.Types.Scripts.ScriptScope(SelfPath: prefix, ParentPath: "")

prefix is the accumulated path-qualified module path (Outer at depth 1, Outer.Inner at depth 2, etc.), so SelfPath is correct. ParentPath, however, is hard-coded to the empty string at every depth. Per ScriptScope's own XML doc, ParentPath is "computed at flattening time and seeded into the script's globals … so Attributes["X"] / Parent.X can prepend the right path-prefix." For a script directly composed at depth 1 the parent is the root and "" is correct, but for a script in a nested module (Outer.Inner.Foo) the parent module is Outer — yet ParentPath is still "". A nested composed script that references Parent.X will therefore resolve the reference against the root flat namespace instead of its actual parent module, reading the wrong attribute (or failing to find one). This is the same depth-≥2 nesting blind spot as TemplateEngine-001; the recursive walk was added there but the Scope construction was not updated to carry the parent path. ResolveComposedScripts for direct (root-template) scripts leaves Scope at the default ScriptScope.Root, which is correct.

Recommendation

Thread the parent module path through ResolveComposedScriptsRecursive (the caller already knows it — it is the prefix of the enclosing recursion frame, or "" for a depth-1 composition) and set ParentPath to that value, so SelfPath = "Outer.Inner" pairs with ParentPath = "Outer". Add a flattening test asserting the Scope of a two-level composed script.

Resolution

Resolved 2026-05-17 (commit pending): threaded a parentPath parameter through ResolveComposedScriptsRecursive — the top-level caller passes "" (a depth-1 composition's parent is the root template) and each nested call passes the enclosing module's prefix — and the ScriptScope now sets ParentPath to that value instead of a hard-coded "", so a depth-2 script's SelfPath = "Outer.Inner" pairs with ParentPath = "Outer" and Parent.X resolves against the real parent module. Regression test: Flatten_NestedComposedScript_ScopeCarriesCorrectParentPath.

TemplateEngine-017 — Revision hash and diff both ignore Description and Connections, defeating staleness detection for real deployment changes

Severity High
Category Design-document adherence
Status Resolved
Location src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/RevisionHashService.cs:128, src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/RevisionHashService.cs:156, src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/RevisionHashService.cs:42, src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/DiffService.cs:110, src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/DiffService.cs:118

Resolution — Added Description to HashableAttribute and HashableAlarm (placed alphabetically per the determinism contract) and introduced a HashableConnection projection plus a SortedDictionary<string, HashableConnection> Connections field on HashableConfiguration that captures protocol, primary/backup JSON, and failover retry count for every deployed connection. DiffService.AttributesEqual and AlarmsEqual now compare Description, and a new public ConnectionsEqual helper covers connection-endpoint drift so callers can detect the change in the same shape used by the other entity comparators. Regression tests ComputeHash_AttributeDescriptionEdit_ChangesHash, ComputeHash_AlarmDescriptionEdit_ChangesHash, ComputeHash_ConnectionEndpointEdit_ChangesHash, and ConnectionsEqual_EndpointEdit_ReturnsFalse lock the behaviour in.

Description

The design states the revision hash is "computed from the resolved content" and backs both staleness detection and diff correlation. The Hashable* records, however, omit fields that are part of the deployed FlattenedConfiguration:

  • HashableAttribute skips ResolvedAttribute.Description and the resolved connection name/protocol (BoundDataConnectionName/BoundDataConnectionProtocol).
  • HashableAlarm skips ResolvedAlarm.Description.
  • The top-level HashableConfiguration skips the entire Connections map — the ConnectionConfig per connection name carries the protocol, the primary endpoint JSON, the backup endpoint JSON, and the failover retry count, all of which travel in the deployment package.

The same gaps exist in DiffService.AttributesEqual, AlarmsEqual, and there is no entry for Connections at all. Concrete consequences:

  1. A Design user edits an attribute's Description (an authoring-time concern) → the flattened payload changes → no hash change, no diff entry.
  2. A Deployment user edits the primary endpoint JSON of a data connection bound to an instance → the deployment package now ships a different ConnectionConfig → no hash change, no diff entry, so the staleness indicator says the instance is up to date and the diff view shows no pending change. The site quietly receives different connection credentials/host on the next redeploy.

The Description case is mostly cosmetic. The Connections case is a deployment correctness gap — staleness detection is the mechanism that tells operators "this instance has drifted from its template and needs redeployment", and a connection-endpoint change is exactly the kind of drift it must catch.

Recommendation

Add Description to HashableAttribute and HashableAlarm (alphabetically placed, per the determinism contract) and to AttributesEqual / AlarmsEqual. Add a HashableConnections : SortedDictionary<string, HashableConnection> field (or equivalent) to HashableConfiguration that includes Protocol, ConfigurationJson, BackupConfigurationJson, and FailoverRetryCount, and mirror it in DiffService. Add tests: Hash_DescriptionEditChangesHash, Hash_ConnectionEndpointEditChangesHash, Diff_ConnectionEndpointEdit_ProducesEntry.

Resolution

Resolved (commit pending): RevisionHashService now folds Description into the HashableAttribute / HashableAlarm projections (alphabetical placement preserved) and adds a sorted Connections map of HashableConnection (Protocol, ConfigurationJson, BackupConfigurationJson, FailoverRetryCount) on HashableConfiguration. DiffService.AttributesEqual / AlarmsEqual compare Description, and a public ConnectionsEqual helper covers connection drift in the same shape as the other entity comparators. Regression tests: ComputeHash_AttributeDescriptionEdit_ChangesHash, ComputeHash_AlarmDescriptionEdit_ChangesHash, ComputeHash_ConnectionEndpointEdit_ChangesHash, ConnectionsEqual_EndpointEdit_ReturnsFalse. The diff-shape extension that surfaces added/removed/changed connections in the UI remains tracked under TemplateEngine-018.

TemplateEngine-018 — DiffService reports no entries for added/removed/changed connections

Severity Medium
Category Correctness & logic bugs
Status Resolved
Location src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/DiffService.cs:19

Resolution (2026-05-28): Added DiffService.ComputeConnectionsDiff(oldConfig, newConfig), which mirrors the existing attribute/alarm/script ComputeEntityDiff shape over the FlattenedConfiguration.Connections map and emits DiffEntry<ConnectionConfig> Added / Removed / Changed entries keyed by connection name (delegating equality to the existing ConnectionsEqual helper). Kept the new diff as a parallel method on DiffService rather than extending ConfigurationDiff (which lives in ZB.MOM.WW.ScadaBridge.Commons, outside this fix's scope); the public-record extension and Central UI plumbing are a paired Commons follow-up. Regression tests: ComputeConnectionsDiff_NewBindingAdded_ReportedAsAdded, ComputeConnectionsDiff_BindingCleared_ReportedAsRemoved, ComputeConnectionsDiff_EndpointEdit_ReportedAsChanged, ComputeConnectionsDiff_IdenticalConnections_NoEntries.

Description

DiffService.ComputeDiff returns a ConfigurationDiff with AttributeChanges, AlarmChanges, and ScriptChanges only. The FlattenedConfiguration it diffs also carries a Connections dictionary (per-attribute connection bindings collapsed to one connection-config-per-name during flattening — see FlatteningService:99-118), and this dictionary materially affects what the site receives at deploy time. A connection added to or removed from the flattened configuration (e.g., an instance gains its first data-sourced attribute, or its last binding is cleared) produces no diff entry. Operators inspecting the diff view to decide whether to redeploy see "no changes" when the site will in fact receive a structurally different deployment package.

This is the diff-view counterpart of TemplateEngine-017's hash gap; they are separable because the ConfigurationDiff data shape would have to be extended even after the hash is fixed.

Recommendation

Add ConnectionChanges (or equivalent) to ConfigurationDiff in Commons (Types/Flattening/ConfigurationDiff.cs), populate it in DiffService.ComputeDiff via a new ComputeEntityDiff over Connections.Keys, and add a ConnectionsEqual helper. Update the Central UI diff display to render the new section. Add regression tests: Diff_NewConnectionBinding_ReportedAsAdded, Diff_ClearedBinding_ReportedAsRemoved, Diff_EndpointEdit_ReportedAsChanged.

Resolution

Unresolved.

TemplateEngine-019 — TemplateResolver.BuildInheritanceChain still uses the 0-as-no-parent sentinel that was removed from CycleDetector

Severity Medium
Category Correctness & logic bugs
Status Resolved
Location src/ZB.MOM.WW.ScadaBridge.TemplateEngine/TemplateResolver.cs:117, src/ZB.MOM.WW.ScadaBridge.TemplateEngine/TemplateResolver.cs:123

Resolution (2026-05-28): BuildInheritanceChain now walks the parent chain via the int? ParentTemplateId directly — only a missing (null) value means "no parent", so a real template Id of 0 walks the chain like any other node (matching the duplicate-tolerant BuildLookup and the TemplateEngine-013 CycleDetector fix). Regression tests: BuildInheritanceChain_RealIdZero_IsTreatedAsParentReferenceNotAsNoParent, BuildInheritanceChain_ParentChainThroughIdZero_DoesNotTruncateChainAtZero, and the end-to-end ResolveAllMembers_TemplateWithRealIdZero_StillResolvesItsMembers.

Description

TemplateEngine-013 removed the 0-as-no-parent sentinel from CycleDetector (adb5e75) — ParentTemplateId is int?, so a missing value means "no parent" and a real Id of 0 must walk the chain like any other node. The fix did not propagate into TemplateResolver.BuildInheritanceChain:

var currentId = templateId;
...
while (currentId != 0 && lookup.TryGetValue(currentId, out var current))
{
    ...
    currentId = current.ParentTemplateId ?? 0;
}

The seeded currentId = templateId is treated as "no template" when templateId == 0, so ResolveAllMembers(0, ...) returns an empty chain even when a template with Id 0 exists. Walking up, current.ParentTemplateId ?? 0 then currentId != 0 collapses a real parent of Id 0 onto the "no parent" exit, silently truncating the chain. The chain is the input to every flatten/resolve/validate path through FlatteningService, TemplateService .ResolveTemplateMembersAsync, and InstanceService.SetAlarmOverrideAsync — a template with a real Id of 0 (which EF identity sequences avoid in production but which any in-memory test or import-staging path can produce) silently loses its inheritance contribution. The duplicate-tolerant BuildLookup added in adb5e75 is used here, so the test gap is one half of the same fix.

Recommendation

Switch the walk to the int? form, mirroring CycleDetector .DetectInheritanceCycle:

int? currentId = templateId;
while (currentId.HasValue && lookup.TryGetValue(currentId.Value, out var current))
{
    if (!visited.Add(currentId.Value)) break;
    chain.Add(current);
    currentId = current.ParentTemplateId;
}

Add regression test TemplateResolverTests.BuildInheritanceChain_RealIdZero_StillResolves.

Resolution

Unresolved.

TemplateEngine-020 — Create* audit entries are written with EntityId = "0" before SaveChangesAsync populates the real key

Severity Medium
Category Code organization & conventions
Status Resolved
Location src/ZB.MOM.WW.ScadaBridge.TemplateEngine/TemplateService.cs:77, src/ZB.MOM.WW.ScadaBridge.TemplateEngine/TemplateService.cs:256, src/ZB.MOM.WW.ScadaBridge.TemplateEngine/TemplateService.cs:407, src/ZB.MOM.WW.ScadaBridge.TemplateEngine/TemplateService.cs:556, src/ZB.MOM.WW.ScadaBridge.TemplateEngine/TemplateService.cs:734, src/ZB.MOM.WW.ScadaBridge.TemplateEngine/SharedScriptService.cs:71

Resolution (2026-05-28): Every Create* path in TemplateService (CreateTemplateAsync, AddAttributeAsync, AddAlarmAsync, AddScriptAsync, AddCompositionAsync) and SharedScriptService.CreateSharedScriptAsync now follows the InstanceService.CreateInstanceAsync shape — save the entity first so EF Core populates the auto-generated key, then log the audit row with the real entity.Id, then save the audit row. AddCompositionAsync already saved the composition row inside CreateCascadedCompositionAsync before returning, so only its LogAsync call needed to switch from "0" to composition.Id.ToString(). Regression tests assert the captured audit entityId equals the post-save id (not "0"): CreateTemplate_AuditRowCarriesRealTemplateIdNotLiteralZero, AddAttribute_AuditRowCarriesRealAttributeIdNotLiteralZero, AddAlarm_AuditRowCarriesRealAlarmIdNotLiteralZero, AddScript_AuditRowCarriesRealScriptIdNotLiteralZero, and CreateSharedScript_AuditRowCarriesRealScriptIdNotLiteralZero.

Description

IAuditService.LogAsync takes a string entityId argument and TemplateService .CreateTemplateAsync, AddAttributeAsync, AddAlarmAsync, AddScriptAsync, AddCompositionAsync, and SharedScriptService.CreateSharedScriptAsync all hard-code it to "0":

await _repository.AddTemplateAsync(template, cancellationToken);
await _auditService.LogAsync(user, "Create", "Template", "0", name, template, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);

EF Core populates template.Id only when SaveChangesAsync runs, but the audit row is written and queued in the change tracker before the save with a literal "0". The single save then commits the audit row with EntityId = "0" and the new template/attribute/alarm/script with its real Id. Every "Create" entry in the audit trail therefore loses the link back to the row it describes — searching the audit log by entity id of a created row finds nothing, only the subsequent Update/Delete rows are findable.

Note that InstanceService.CreateInstanceAsync uses the opposite order (AddInstanceAsyncSaveChangesAsyncLogAsync(... instance.Id ...), lines 9094) and gets the real Id. The asymmetry is the smoking gun: half the module audits Create correctly, half does not.

A separate consideration: writing the audit row in the same SaveChangesAsync as the entity is correct (it gives transactional all-or-nothing) — the fix is to save the entity first, then log, then save the audit row (two-phase, like InstanceService and TemplateService.DeleteTemplateAsync already do).

Recommendation

For every Create* path in TemplateService and SharedScriptService, swap the order to AddXxxAsyncSaveChangesAsyncLogAsync(... newId .ToString() ...)SaveChangesAsync, matching InstanceService .CreateInstanceAsync and TemplateService.DeleteTemplateAsync. Add regression tests that assert the EntityId recorded on the audit row matches the created row's Id.

Resolution

Unresolved.

TemplateEngine-021 — MoveTemplateAsync skips folder cycle and sibling-name-collision validation

Severity Medium
Category Correctness & logic bugs
Status Resolved
Location src/ZB.MOM.WW.ScadaBridge.TemplateEngine/TemplateService.cs:173

Description

TemplateService.MoveTemplateAsync validates only that the target folder exists, then unconditionally assigns template.FolderId = newFolderId. TemplateFolderService.MoveFolderAsync (the sibling for folder-to-folder moves) by contrast validates:

  • the target folder is not the folder being moved (self-parent);
  • the target folder is not a descendant of the folder being moved (cycle);
  • no sibling at the destination has the same name (case-insensitive).

The first two are folder-graph concerns and don't apply to template moves, but the third does — two templates with the same name in the same folder is the authoring-time scenario the design's "naming collisions are design-time errors" rule was meant to cover. Today, two templates named "Pump" can be moved into the same folder with no error, breaking any UI that locates a template by (FolderId, Name) and producing a worse user experience than the folder-rename path which does check.

Separately, the design doc states folders carry "no semantic meaning for template resolution, flattening, validation, or inheritance" — so this is strictly a UI-organization invariant, but it is documented elsewhere (TemplateFolderService enforces it for folders) and the asymmetry is surprising.

Recommendation

After resolving the target folder, run a sibling-name-uniqueness check across templates with the same FolderId == newFolderId and the same Name (case-insensitive), mirroring TemplateFolderService.MoveFolderAsync lines 130142. Add a regression test MoveTemplate_NameCollisionAtDestination_Fails.

Resolution (2026-05-28):

Resolved (commit pending): MoveTemplateAsync now loads GetAllTemplatesAsync on any FolderId-changing move and rejects the move if another template at the destination shares the moved template's name (case-insensitive), mirroring TemplateFolderService.MoveFolderAsync's sibling-name uniqueness check; the FolderId is not written when the check fails. Cycle detection is deliberately not added — a template move changes only FolderId and never touches ParentTemplateId, and templates have no folder-children navigation, so no inheritance- or folder-graph cycle is reachable through this path (the finding's own description states this; only the sibling-name check applies). Regression tests: MoveTemplate_NameCollisionAtDestination_Fails (case- insensitive collision rejected, FolderId untouched, UpdateTemplateAsync never called) and MoveTemplate_NoCollisionAtDestination_Succeeds (same-named template in a different folder is not a collision).

TemplateEngine-022 — LockEnforcer.ValidateLockChange enforces "once-locked-stays-locked" for IsLocked but not for LockedInDerived

Severity Low
Category Documentation & comments
Status Resolved
Location src/ZB.MOM.WW.ScadaBridge.TemplateEngine/LockEnforcer.cs:109, src/ZB.MOM.WW.ScadaBridge.TemplateEngine/TemplateService.cs:323, src/ZB.MOM.WW.ScadaBridge.TemplateEngine/TemplateService.cs:476, src/ZB.MOM.WW.ScadaBridge.TemplateEngine/TemplateService.cs:623

Description

LockEnforcer.ValidateLockChange documents and enforces the rule that an already-locked member cannot be unlocked downstream (originalIsLocked && !proposedIsLocked → error). The class-level XML doc describes locking as covering both fields:

Locking rules: ... Once locked, a member stays locked — it cannot be unlocked downstream.

But the LockedInDerived field has no equivalent guard. UpdateAttributeAsync, UpdateAlarmAsync, and UpdateScriptAsync all let the proposed LockedInDerived flag flip in either direction on a base-template member. This is a subtle correctness gap with two failure modes:

  1. A base template originally marked an attribute LockedInDerived = true to protect derived templates from overriding it. A subsequent edit can clear the flag while leaving existing derived-template overrides intact — those overrides become legal retroactively even though the design intent was that they were always blocked.
  2. The XML doc on LockEnforcer and the class summary on TemplateService describe a one-way ratchet that the code does not implement for one of the two lock flags. A reader of the documentation cannot tell which rules are actually enforced.

The defect is "Low" because the design doc for the Template Engine itself does not explicitly call out a once-locked-stays-locked rule for LockedInDerived. The most likely fix is therefore to (a) correct the LockEnforcer XML doc to describe only IsLocked, or (b) add the equivalent guard for LockedInDerived and a regression test. The choice is a design question — pick one and align the code and docs.

Recommendation

Decide the policy. If LockedInDerived is intended to be once-set-stays-set like IsLocked, extend ValidateLockChange (or add a sibling ValidateLockedInDerivedChange) and reject the downgrade in UpdateAttributeAsync / UpdateAlarmAsync / UpdateScriptAsync. If it is intended to be mutable, update the LockEnforcer summary to scope the rule to IsLocked only. Either way, add a test pinning the chosen behaviour.

Resolution (2026-05-28):

Resolved (commit pending): chose option (b) — LockedInDerived is now a one-way ratchet on base templates, matching the design intent that an existing block on derived overrides cannot be retroactively re-allowed. Added a sibling LockEnforcer.ValidateLockedInDerivedChange(originalLockedInDerived, proposedLockedInDerived, memberName) and wired it into UpdateAttributeAsync, UpdateAlarmAsync, and UpdateScriptAsync (only when the owning template is not derived — derived rows never carry an authoritative LockedInDerived, they inherit the base's value). The LockEnforcer class XML summary now explicitly extends the once-locked-stays-locked rule to both IsLocked and LockedInDerived so the documentation matches the enforced behaviour. Regression tests: LockEnforcerTests.ValidateLockedInDerivedChange_* (true→ false rejected, false→true / true→true / false→false accepted) and TemplateServiceTests.UpdateAttribute_LockedInDerivedDowngrade_OnBase_Rejected (end-to-end on the attribute update path).

TemplateEngine-023 — Instance native-alarm-source override bypasses the source lock during flattening

Severity Medium
Category Correctness & logic bugs
Status Deferred
Location src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/FlatteningService.cs:775, src/ZB.MOM.WW.ScadaBridge.Commons/Types/Flattening/FlattenedConfiguration.cs:137

Description

ApplyInstanceNativeAlarmSourceOverrides applies a per-instance native-alarm-source override unconditionally — it looks the binding up by canonical name and, if found, overlays the override's non-null fields with no lock check:

foreach (var ovr in overrides)
{
    if (!sources.TryGetValue(ovr.SourceCanonicalName, out var existing))
        continue; // Cannot add new bindings via overrides.

    sources[ovr.SourceCanonicalName] = existing with { ConnectionName = ..., ... };
}

Contrast ApplyInstanceOverrides (attributes, :309if (existing.IsLocked) continue;) and ApplyInstanceAlarmOverrides (alarms, :337if (existing.IsLocked) continue;). The design (Component-TemplateEngine.md Override Granularity, Native alarm sources: "Lock applies to the entire source") requires the same treatment. The root cause is in Commons: ResolvedNativeAlarmSource (FlattenedConfiguration.cs:137) carries no IsLocked field, so the flattener has nothing to gate on even if it wanted to — unlike ResolvedAttribute/ResolvedAlarm, which both expose IsLocked. The inheritance-side ResolveInheritedNativeAlarmSources does track a base lock (via the lockedNames set) so a derived template cannot override a base-locked source, but that lock state is dropped on the way out and the instance layer is unguarded.

This is the same defect class as the closed TemplateEngine-008 (which fixed the identical instance-override lock-bypass for alarms), never applied to native alarm sources because they were introduced after that finding. The companion gap lives in ManagementService.HandleSetInstanceNativeAlarmSourceOverride (ManagementActor.cs:868), which — unlike InstanceService.SetAlarmOverrideAsync / SetAttributeOverrideAsync — performs neither an existence check (an override for an unknown source name is silently persisted as a dead record) nor a lock check before writing the override row straight through the repository, bypassing InstanceService entirely. Blast radius is limited (native alarms are a read-only mirror — no write-back to the source), but the documented lock invariant is not enforced anywhere on this path.

Recommendation

Add an IsLocked field to ResolvedNativeAlarmSource (Commons) and carry it through ResolveInheritedNativeAlarmSources / ResolveComposedNativeAlarmSources; then gate ApplyInstanceNativeAlarmSourceOverrides on if (existing.IsLocked) continue;, mirroring the attribute/alarm paths. Add the matching existence + lock guard to HandleSetInstanceNativeAlarmSourceOverride (resolve the effective native-source set via TemplateResolver/flattening as SetAlarmOverrideAsync does). Add a flattening regression test (Flatten_LockedNativeSource_InstanceOverrideIgnored) and a management test rejecting an override of a locked / unknown source.

Resolution

Deferred 2026-06-20: the native-alarm-source override lock-bypass fix spans Commons (ResolvedNativeAlarmSource has no IsLocked field to gate on) and ManagementService (the override handler skips the existence/lock checks), and the blast radius is limited (native alarms are read-only mirrors, no write-back). Recorded as a cross-module follow-up.

TemplateEngine-024 — TemplateFolderService mutators stage their audit row after the final save, so it is never persisted

Severity Medium
Category Code organization & conventions
Status Resolved
Location src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Services/TemplateFolderService.cs:58, src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Services/TemplateFolderService.cs:91, src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Services/TemplateFolderService.cs:154, src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Services/TemplateFolderService.cs:204

Description

IAuditService.LogAsync only stages the audit row in the EF change tracker — the implementation (ConfigurationDatabase/Services/AuditService.cs:61) ends with AddAsync(entry) and explicitly documents "caller is responsible for calling SaveChangesAsync to ensure atomicity." Four of the five TemplateFolderService mutators call LogAsync after their final SaveChangesAsync and never save again:

  • CreateFolderAsync (:56-58): AddFolderAsyncSaveChangesAsyncLogAsync (no save).
  • RenameFolderAsync (:89-91): UpdateFolderAsyncSaveChangesAsyncLogAsync (no save).
  • MoveFolderAsync (:152-154): UpdateFolderAsyncSaveChangesAsyncLogAsync (no save).
  • ReorderFolderAsync (:201-204): Update×2 → SaveChangesAsyncLogAsync (no save).

ManagementActor.ProcessCommand (ManagementActor.cs:130) wraps each command in a DI scope, dispatches, and disposes the scope with no outer SaveChangesAsync; EF Core does not flush on dispose. The folder operation itself persists (it was saved before the LogAsync), but the staged TemplateFolder audit row is silently discarded when the scoped DbContext is disposed. Every folder Create / Rename / Move / Reorder therefore leaves no audit trail, even though _auditService.LogAsync(...) is called and reads as if it does. (DeleteFolderAsync has the same shape but the lost row is the Delete record specifically.) This is asymmetric with TemplateService, where every mutator ends with LogAsyncSaveChangesAsync (e.g. :225-226). The folder tests mock IAuditService and the repository, so they never exercise the real change-tracker lifecycle and the gap is invisible to them.

Recommendation

Add a final await _repository.SaveChangesAsync(cancellationToken); after the LogAsync in all five folder mutators (matching TemplateService), or — cleaner — reorder each to mutate → log → single SaveChangesAsync so the entity change and its audit row commit atomically. Add a test that uses a real (in-memory) AuditService + DbContext and asserts an AuditLogEntry row exists after a folder create/rename/move/reorder.

Resolution

Resolved 2026-06-20 (commit fd618cf1): added await _repository.SaveChangesAsync(...) after LogAsync in all five TemplateFolderService mutators (Create/Rename/Move/Reorder/Delete), so folder-mutation audit rows are now persisted (previously staged then discarded with the scope). Matches the TemplateService two-save pattern. Tests added.

TemplateEngine-025 — SemanticValidator.ParseParameterDefinitions legacy-array branch returns types, not names; method is dead production code

Severity Low
Category Documentation & comments
Status Resolved
Location src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/SemanticValidator.cs:573

Description

ParseParameterDefinitions is documented and named as returning the declared parameter names:

/// ... returns the declared parameter names.
internal static List<string> ParseParameterDefinitions(string? parameterDefinitionsJson)

Its JSON-Schema branch correctly returns props.EnumerateObject().Select(p => p.Name), but its legacy flat-array branch returns the type field:

else if (doc.RootElement.ValueKind == JsonValueKind.Array)
{
    return doc.RootElement.EnumerateArray()
        .Select(e => e.TryGetProperty("type", out var t) ? t.GetString() ?? "unknown" : "unknown")
        .ToList();
}

This is a copy-paste from the sibling ParseParameterTypes (:491, which legitimately returns types) — so for legacy [{name,type}] definitions this method returns the wrong field (types instead of names). The method has no non-test caller in src/ (only its own SemanticValidatorTests), and the test exercises only the JSON-Schema branch, so the buggy legacy branch is both dead and untested. The live param-count/type validation path uses ParseParameterTypes, not this method, so there is no behavioural impact today — but the method is internal (callable from the rest of the module) and silently returns mislabelled data for one input shape.

Recommendation

Delete ParseParameterDefinitions (and its tests) as dead code, or — if it is meant to back a future feature — fix the legacy branch to return e.TryGetProperty("name", ...) and add a legacy-array test asserting names are returned.

Resolution

Resolved 2026-06-20 (commit fd618cf1): deleted the dead, misnamed SemanticValidator.ParseParameterDefinitions (no production caller per grep; its only references were two tests, which asserted the buggy type-not-name output). Tests removed.