code-review: 2026-05-28 baseline re-review of all 23 modules at 1eb6e97

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.
This commit is contained in:
Joseph Doherty
2026-05-28 02:55:47 -04:00
parent 1eb6e972b0
commit f93b7b99bb
25 changed files with 8793 additions and 115 deletions
+369 -3
View File
@@ -5,10 +5,10 @@
| Module | `src/ScadaLink.TemplateEngine` |
| Design doc | `docs/requirements/Component-TemplateEngine.md` |
| Status | Reviewed |
| Last reviewed | 2026-05-17 |
| Last reviewed | 2026-05-28 |
| Reviewer | claude-agent |
| Commit reviewed | `39d737e` |
| Open findings | 0 |
| Commit reviewed | `1eb6e97` |
| Open findings | 6 |
## Summary
@@ -48,8 +48,49 @@ Both are limited-impact (nested compositions are the less common case and there
is design-time visibility) but represent genuine drift from the recursive-nesting
design promise.
#### Re-review 2026-05-28 (commit `1eb6e97`)
Re-reviewed the whole module against all ten checklist categories at commit
`1eb6e97`. All sixteen prior findings remain closed. Six new findings surfaced,
clustered in three themes:
1. **Revision-hash / diff coverage gaps**`RevisionHashService` and
`DiffService` both omit `Attributes.Description`, `Alarms.Description`, and
the entire `Connections` map. A change that only edits an attribute/alarm
description, or a data-connection endpoint, will deploy a new flattened
configuration but be invisible to staleness detection and the diff view —
the very gap the revision hash was introduced to close (TemplateEngine-017,
TemplateEngine-018). Severity Medium/High.
2. **TemplateEngine-013 fix only partially applied** — the `0`-as-no-parent
sentinel was removed from `CycleDetector` but `TemplateResolver
.BuildInheritanceChain` still uses `currentId != 0` / `ParentTemplateId ?? 0`.
A template with a real Id of 0 is treated as "no template" and silently
excluded from its own inheritance chain, so every flatten/resolve through
that template loses its members. The fix from `adb5e75` did not propagate
into the resolver (TemplateEngine-019). Severity Medium.
3. **Audit log integrity / drift** — every `Create` audit entry in
`TemplateService` and `SharedScriptService` is written with `EntityId = "0"`
*before* `SaveChangesAsync` populates the real key, so the audit trail loses
the link back to the created row (TemplateEngine-020); `MoveTemplateAsync`
never validates folder-acyclicity / sibling-name-uniqueness even though
`TemplateFolderService.MoveFolderAsync` does (TemplateEngine-021); and the
advertised `IS NOT_locked & not_LockedInDerived & not_IsInherited`
self-reference loop is intact, but `LockEnforcer.ValidateLockChange` permits
downgrading a `LockedInDerived` flag on a base template — there is no
equivalent of the once-locked-stays-locked rule for the `LockedInDerived`
flag (TemplateEngine-022). Severity Low/Medium.
Themes: hash/diff drift from the deployment payload, asymmetric application of
the duplicate-Id / null-sentinel fix from the last batch, and audit-write
ordering inconsistency between `TemplateService` (logs then saves) and
`InstanceService` (saves then logs).
## Checklist coverage
_Re-review (2026-05-17, `39d737e`):_
| # | Category | Examined | Notes |
|---|----------|----------|-------|
| 1 | Correctness & logic bugs | ✓ | Prior bugs (001005, 013) all resolved and verified. Re-review 2026-05-17 found two new nested-composition defects: rename does not cascade (TemplateEngine-015), composed-script `ParentPath` always empty (TemplateEngine-016). |
@@ -63,6 +104,21 @@ design promise.
| 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
@@ -780,3 +836,313 @@ passes the enclosing module's `prefix` — and the `ScriptScope` now sets
`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 9094) and gets the real Id. The asymmetry is the smoking gun: half the
module audits Create correctly, half does not.
A separate consideration: writing the audit row in the same `SaveChangesAsync`
as the entity is correct (it gives transactional all-or-nothing) — the fix is
to save the entity first, then log, then save the audit row (two-phase, like
`InstanceService` and `TemplateService.DeleteTemplateAsync` already do).
**Recommendation**
For every `Create*` path in `TemplateService` and `SharedScriptService`, swap
the order to `AddXxxAsync``SaveChangesAsync``LogAsync(... newId
.ToString() ...)``SaveChangesAsync`, matching `InstanceService
.CreateInstanceAsync` and `TemplateService.DeleteTemplateAsync`. Add regression
tests that assert the `EntityId` recorded on the audit row matches the
created row's Id.
**Resolution**
_Unresolved._
### TemplateEngine-021 — `MoveTemplateAsync` skips folder cycle and sibling-name-collision validation
| | |
|--|--|
| Severity | Medium |
| Category | Correctness & logic bugs |
| Status | 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
130142. 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._