29 KiB
Code Review — TemplateEngine
| Field | Value |
|---|---|
| Module | src/ScadaLink.TemplateEngine |
| Design doc | docs/requirements/Component-TemplateEngine.md |
| Status | Reviewed |
| Last reviewed | 2026-05-16 |
| Reviewer | claude-agent |
| Commit reviewed | 9c60592 |
| 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.
Checklist coverage
| # | Category | Examined | Notes |
|---|---|---|---|
| 1 | Correctness & logic bugs | ✓ | Multiple real bugs: deep composed-member loss, derived alarms omitted, granularity bypass, no-op create-time collision block. |
| 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). |
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:
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 byFile.ReadAllText(...)contains noSystem.IO.token;using static System.IO.File;, namespace aliases, andglobal::System.IO.Fileall 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 | Open |
| 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
Unresolved.
TemplateEngine-012 — DataType enum naming diverges from the design doc
| Severity | Low |
| Category | Design-document adherence |
| Status | Open |
| 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.
Resolution
Unresolved.
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 | Open |
| 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
Unresolved.
TemplateEngine-014 — Template-deletion constraint logic is duplicated and divergent
| Severity | Low |
| Category | Code organization & conventions |
| Status | Open |
| 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
Unresolved.