Phase 1 → 3 marked done; remaining work is phases 4-9. Sanity script now
targets the post-Phase-3 commit (03a8c4a) and notes the pre-existing
NU1608 build error in IntegrationTests / Host.Tests so future sessions
don't chase a phantom regression.
10 KiB
Derive-on-compose: implementation status + remaining plan
For Claude resuming after compaction: This is the single starting point. Read it in full. Then read the companion design doc
2026-05-12-derive-on-compose-design.mdfor the architectural rationale. Do NOT make code changes until you've also confirmed which phase the user wants next.
Where we are
Branch: feature/templates-folder-hierarchy.
Last commit on this feature: 03a8c4a — Phase 3 complete, data
migration in place.
Phases 1–3 done. Compose now derives; existing compositions migrate
on next dotnet ef database update. Everything from Phase 4 onward is
still pending.
All test suites currently green:
tests/ScadaLink.CentralUI.Tests— 159 passingtests/ScadaLink.SiteRuntime.Tests— 129 passingtests/ScadaLink.TemplateEngine.Tests— 205 passing (+6 phase 2 tests)
Design decisions already made (from the brainstorm)
User picked the full Aveva model with all four customization scopes:
- Naming: dot-separated →
Pump.TempSensor - Delete base with derivatives: block with a list of the dependents
- Migration of existing compositions: auto-migrate all on the EF Core migration step in Phase 3
- Tree UX: derived templates hidden by default; toggle to reveal
- Customization scope: override attribute values, override script bodies, add new attrs/scripts per slot, lock fields against override
Done — Phase 1: Additive schema
Commits: 6854843 (design doc) + a968cef (decisions recorded) + 5615f3d.
Done — Phase 2: Compose flow change
Commit: fa86750.
TemplateService.AddCompositionAsyncbuilds a derived template ("<parent>.<slot>"), copies base attributes/scripts withIsInherited=true, then composes the derived (not the base). SetsOwnerCompositionIdback-ref after the composition's Id is known.- Composing a derived template is rejected — only bases can be composed.
DeleteCompositionAsynccascade-deletes the slot-owned derived template (IsDerived=trueandOwnerCompositionId==compositionId).DeleteTemplateAsyncblocks direct deletion of derived templates and splits the inheritor check into regular children vs. derivatives — the derivative branch labels each by'OwnerName' (as 'SlotName').TemplateDeletionService.CanDeleteTemplateAsyncmirrors the same derivative-aware checks.
Done — Phase 3: Migration of existing compositions
Commit: 03a8c4a. Migration 20260512122746_MigrateCompositionsToDerived.
- Pre-flight aborts with a descriptive error if any
<parent>.<slot>derived name would collide. - Cursor-walks every
TemplateCompositionwhose target isIsDerived=0, inserts a derived template, copies attributes/scripts withIsInherited=1, then repointsComposedTemplateId. - Idempotent (only touches non-derived targets), so re-runs are safe.
Down()reverses by repointing compositions toParentTemplateIdand dropping the derived templates.
The migration was NOT verified against a live SQL Server in this
session — run bash docker/deploy.sh (or dotnet ef database update)
once with seeded test data to confirm shape.
Files touched in 5615f3d:
src/ScadaLink.Commons/Entities/Templates/Template.cs- Added
IsDerived: bool - Added
OwnerCompositionId: int?(plain int — not an EF nav prop)
- Added
src/ScadaLink.Commons/Entities/Templates/TemplateAttribute.cs- Added
IsInherited: bool - Added
LockedInDerived: bool
- Added
src/ScadaLink.Commons/Entities/Templates/TemplateScript.cs- Same two fields
src/ScadaLink.ConfigurationDatabase/Migrations/20260512121446_AddDerivedTemplateFields.cs- EF Core migration. Six new columns, all NOT NULL DEFAULT 0 (or nullable int). No data transform — existing rows get defaults.
ScadaLinkDbContextModelSnapshot.csregenerated.
No behavior changes. New fields are never read or written yet.
Remaining — phases 4 through 9
Phase 4: Inherit/override resolution in flattening
src/ScadaLink.TemplateEngine/Flattening/FlatteningService.cs:
- The service currently walks
Template.ParentTemplateIdchains inResolveInheritedScripts. That same chain now naturally includes the base→derived link. No special handling needed for the chain itself. - BUT when a derived template's attribute is
IsInherited = true, the effective value comes from the base (resolved through the chain). IfIsInherited = false, the derived's own value is the override and wins. - Treat
LockedInDerivedas an enforcement check: if a derived row hasIsInherited = false(override) AND the base row hasLockedInDerived = true, emit a flattening validation error.
Tests: round-trip flattening with overridden + inherited combinations.
Phase 5: Lock semantics enforcement
In TemplateService.UpdateAttributeAsync and UpdateScriptAsync:
- If the target template has
IsDerived = true, look up the base viaParentTemplateId. - If the same-named attribute on the base has
LockedInDerived = true, reject the update with a clear error: "This attribute is locked by the base template '$Sensor' and cannot be overridden." - If the update is being applied on a base template AND there are
derivatives that have
IsInherited = falsefor this field (overrides), the caller may want to know — surface a warning result. Out of scope for now; flag as TODO.
Tests: lock-blocked updates fail with the expected message.
Phase 6: Template tree UI — hide derived
src/ScadaLink.CentralUI/Components/Pages/Design/Templates.razor:
- Filter
_templatesto excludet.IsDerivedby default. - Add a "Show derived templates" checkbox at the top.
- When shown, render derived templates indented under their base
(
t.ParentTemplateId == base.Id && t.IsDerived). - Compositions tab on
TemplateEditshould link each composition row to the derived template's edit page (the existing link likely points to the base — needs to followcomposition.ComposedTemplateIdwhich after phase 3 is the derived).
Phase 7: Derived TemplateEdit UI
src/ScadaLink.CentralUI/Components/Pages/Design/TemplateEdit.razor:
- Top banner when
_selectedTemplate.IsDerived: "Derived from[base.Name]— composed inside[parent.Name]as[composition.InstanceName]." - Attributes table: new column showing Inherited or Overridden badge.
- Locked-from-base attributes (base has
LockedInDerived = true) render readonly with a 🔒 icon and tooltip "Locked by base — cannot override." - Editing an inherited row flips it to override (
IsInherited = false). - "Revert to base" button per row when
IsInherited = false— clears the override; row reverts to the base value (IsInherited = true, value cleared). - Adding a new attribute/script: creates a row with
IsInherited = false(it's not from the base). - Removing an inherited row should be blocked (the row exists because it's on the base; can't remove it on the derived). Removing an own-added row deletes normally.
Phase 8: Base TemplateEdit UI — lock toggle
Same TemplateEdit.razor, when editing a base template:
- New column on Attributes table: a 🔒 toggle representing
LockedInDerived. - Same on Scripts table.
- Tooltip on the toggle: "Lock this against per-slot override in derived templates."
When a base template is open and the user toggles LockedInDerived = true
while derived templates exist with overrides, surface a warning toast:
"N derived templates currently override this. Existing overrides won't be
silently reverted but deploy validation will flag them."
Phase 9: Editor metadata simplification
Now that derived templates have a single owner, BuildParentContextsAsync
in TemplateEdit.razor simplifies:
- If the open template
IsDerived: parent = the single template that owns theOwnerCompositionId. Always one — no picker. - If the open template is a base (
!IsDerived): suppressParent.*assistance entirely. Add a SCADA008 hint diagnostic onParent.*use: "Parent access on a base template is ambiguous — override this script in a derived template instead."
Remove the multi-parent select dropdown that was added in commit 0139c9c
(or keep it but guard with a check that should never fire after phase 3).
GetTemplatesComposingAsync (added in 0139c9c) is still useful elsewhere —
keep it.
How to resume
After compaction, the future session should:
- Read this file. Read the design doc
2026-05-12-derive-on-compose-design.mdfor context. - Run
git log --oneline -15to confirm the branch is at03a8c4aor later. - Check test status:
dotnet testacross the three suites named above. - Ask the user which phase to tackle next (or proceed from phase 4 if the user has explicitly said "continue").
- Each phase is its own commit.
Pre-existing capability worth knowing
The script-scope editor work (commits 3ed05f0 → 0139c9c) is already in
place. Scripts on derived templates will automatically benefit from the
single-parent context simplification in phase 9. The runtime accessors
(Attributes / Children / Parent / Scope) defined there continue to
work unchanged — the canonical-name paths they resolve are stable across
the derivation change.
The multi-parent picker introduced in 0139c9c becomes mostly dormant
after phase 3 (no template should be composed by multiple parents anymore
through the new flow). Plan to remove it in phase 9.
Quick sanity script (run before phase 4)
git status --short # should be clean
git log --oneline -5 # top should include 03a8c4a
dotnet build src/ScadaLink.TemplateEngine src/ScadaLink.ConfigurationDatabase
dotnet test tests/ScadaLink.TemplateEngine.Tests/ScadaLink.TemplateEngine.Tests.csproj
Note: the full dotnet build of the solution fails with NU1608 in
ScadaLink.IntegrationTests and ScadaLink.Host.Tests due to a
pre-existing Microsoft.CodeAnalysis.Common 4.13 vs 5.0 mismatch — not
related to the derive-on-compose work. Build the three suites listed in
"Where we are" individually.