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:
@@ -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 (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). |
|
||||
@@ -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 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._
|
||||
|
||||
|
||||
Reference in New Issue
Block a user