# Code Review — TemplateEngine | Field | Value | |-------|-------| | Module | `src/ScadaLink.TemplateEngine` | | Design doc | `docs/requirements/Component-TemplateEngine.md` | | Status | Reviewed | | Last reviewed | 2026-05-28 | | Reviewer | claude-agent | | Commit reviewed | `1eb6e97` | | Open findings | 6 | ## 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 gaps** — `RevisionHashService` 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). ## Checklist coverage _Re-review (2026-05-17, `39d737e`):_ | # | Category | Examined | Notes | |---|----------|----------|-------| | 1 | Correctness & logic bugs | ✓ | Prior bugs (001–005, 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` 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` 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/ScadaLink.TemplateEngine/Flattening/FlatteningService.cs:211`, `src/ScadaLink.TemplateEngine/Flattening/FlatteningService.cs:535`, `src/ScadaLink.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 ``): 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/ScadaLink.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 ``): 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 (`ScadaLink.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/ScadaLink.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: ```csharp var granularityError = LockEnforcer.ValidateAttributeOverride(existing, proposed); if (granularityError != null && existing.IsLocked) return Result.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 ``): 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/ScadaLink.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 ``): 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/ScadaLink.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 ``): 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/ScadaLink.TemplateEngine/Validation/ScriptCompiler.cs:21`, `src/ScadaLink.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/ScadaLink.TemplateEngine/Validation/ScriptCompiler.cs:54`, `src/ScadaLink.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/ScadaLink.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/ScadaLink.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/ScadaLink.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/ScadaLink.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/ScadaLink.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/ScadaLink.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/ScadaLink.TemplateEngine/**`): renaming the enum touches `ScadaLink.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 `ScadaLink.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/ScadaLink.TemplateEngine/CycleDetector.cs:30`, `src/ScadaLink.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/ScadaLink.TemplateEngine/TemplateService.cs:109`, `src/ScadaLink.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/ScadaLink.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: ```csharp 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/ScadaLink.TemplateEngine/Flattening/FlatteningService.cs:750` | **Description** `ResolveComposedScriptsRecursive` assigns each composed script a `ScriptScope`: ```csharp 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 | Open | | Location | `src/ScadaLink.TemplateEngine/Flattening/RevisionHashService.cs:128`, `src/ScadaLink.TemplateEngine/Flattening/RevisionHashService.cs:156`, `src/ScadaLink.TemplateEngine/Flattening/RevisionHashService.cs:42`, `src/ScadaLink.TemplateEngine/Flattening/DiffService.cs:110`, `src/ScadaLink.TemplateEngine/Flattening/DiffService.cs:118` | **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` 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** _Unresolved._ ### TemplateEngine-018 — `DiffService` reports no entries for added/removed/changed connections | | | |--|--| | Severity | Medium | | Category | Correctness & logic bugs | | Status | Open | | Location | `src/ScadaLink.TemplateEngine/Flattening/DiffService.cs:19` | **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 | Open | | Location | `src/ScadaLink.TemplateEngine/TemplateResolver.cs:117`, `src/ScadaLink.TemplateEngine/TemplateResolver.cs:123` | **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`: ```csharp 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`: ```csharp 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 | Open | | Location | `src/ScadaLink.TemplateEngine/TemplateService.cs:77`, `src/ScadaLink.TemplateEngine/TemplateService.cs:256`, `src/ScadaLink.TemplateEngine/TemplateService.cs:407`, `src/ScadaLink.TemplateEngine/TemplateService.cs:556`, `src/ScadaLink.TemplateEngine/TemplateService.cs:734`, `src/ScadaLink.TemplateEngine/SharedScriptService.cs:71` | **Description** `IAuditService.LogAsync` takes a `string entityId` argument and `TemplateService .CreateTemplateAsync`, `AddAttributeAsync`, `AddAlarmAsync`, `AddScriptAsync`, `AddCompositionAsync`, and `SharedScriptService.CreateSharedScriptAsync` all hard-code it to `"0"`: ```csharp 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 (`AddInstanceAsync` → `SaveChangesAsync` → `LogAsync(... instance.Id ...)`, lines 90–94) 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 `AddXxxAsync` → `SaveChangesAsync` → `LogAsync(... 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 | Open | | Location | `src/ScadaLink.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 130–142. Add a regression test `MoveTemplate_NameCollisionAtDestination_Fails`. **Resolution** _Unresolved._ ### TemplateEngine-022 — `LockEnforcer.ValidateLockChange` enforces "once-locked-stays-locked" for `IsLocked` but not for `LockedInDerived` | | | |--|--| | Severity | Low | | Category | Documentation & comments | | Status | Open | | Location | `src/ScadaLink.TemplateEngine/LockEnforcer.cs:109`, `src/ScadaLink.TemplateEngine/TemplateService.cs:323`, `src/ScadaLink.TemplateEngine/TemplateService.cs:476`, `src/ScadaLink.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** _Unresolved._