f93b7b99bb
Re-applies the full 10-category checklist to every src/ project — including
first-time reviews of the four newer components (AuditLog, NotificationOutbox,
SiteCallAudit, Transport) — so the code-reviews/ index reflects today's
codebase rather than the 2026-05-16 baseline. 172 new Open findings (0
Critical, 18 High, 62 Medium, 92 Low); 481 findings total across 23 modules.
regen-readme.py now derives each module's Last reviewed + Commit from its
findings.md header instead of hard-coding 2026-05-16 / 9c60592, so future
single-module re-reviews show their own date in the Module Status table.
1149 lines
59 KiB
Markdown
1149 lines
59 KiB
Markdown
# 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<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 | 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<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**
|
||
|
||
_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._
|
||
|