Files
ScadaBridge/code-reviews/TemplateEngine/findings.md
T
Joseph Doherty 819f1b4665 fix(validation): close Theme 3 — 11 input-validation / unbounded-input findings
Each finding is a focused validation guard or upper bound at a trust boundary.
Highlights:
- Commons-015: EncryptionMetadata ctor now validates Algorithm (AES-256-GCM
  only), Kdf (PBKDF2-SHA256 only), Iterations ([100k, 10M]), non-null Salt/IV.
- Transport-004: new BundleUnlockRateLimiter (sliding-window, per-key,
  singleton) wired into BundleImporter.LoadAsync; over-budget callers see
  BundleUnlockRateLimitedException. Per-bundle 3-strike + per-window cap.
- ESG-022: ExternalSystemClient.InvokeHttpAsync allow-lists the documented
  GET/POST/PUT/PATCH/DELETE set (case-insensitive); unknown verbs throw.
- SEL-015: SiteEventLogger queue now bounded (10k cap, DropOldest); dropped
  events fault their Task and increment FailedWriteCount so the drop is
  observable instead of an unbounded memory growth.
- SEL-017: EventLogQueryService clamps caller-supplied PageSize to a new
  MaxQueryPageSize cap (default 500) so int.MaxValue can't OOM the host.
- SEL-020: LogEventAsync rejects severities outside {Info, Warning, Error}
  (matches SQLite BINARY-collation query filter).
- InboundAPI-020: ContentType "json" check now case-insensitive
  (application/JSON no longer slips through as not-json).
- InboundAPI-024: _knownBadMethods capped at 1000 entries (drops new entries
  once full); per-request DB lookup remains the correctness path.
- SR-025: HandleSetStaticAttribute validates the attribute name against the
  deployed config; unknown names now return Success=false instead of
  leaking orphan override rows into the SQLite store.
- TE-021: MoveTemplateAsync runs the sibling-name-collision check at the
  destination, mirroring TemplateFolderService.MoveFolderAsync.
- TE-022: LockEnforcer's once-locked-stays-locked rule now also covers
  LockedInDerived (was previously only IsLocked).

New regression tests across 8 test projects (EncryptionMetadata, rate
limiter, ESG client allow-list, SEL bounded channel / PageSize clamp /
severity validation, InboundAPI ContentType + bad-methods cap, SiteRT
unknown-attribute, TemplateEngine MoveTemplate + LockedInDerived).
Build clean; affected suites all green. README regenerated: 93 open (was 104).

Note: a separate manual re-run was needed for the SiteEventLogging hunk
because its initial subagent's source edits never landed on disk despite
reporting success (file-collision-style failure mode).
2026-05-28 06:58:25 -04:00

1188 lines
62 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 | 4 |
## 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 (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/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 `<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/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 `<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
(`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<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/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 `<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/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 `<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/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 | Resolved |
| 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` |
**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 | 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 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 `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 | Resolved |
| 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
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/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 (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).