Files
scadalink-design/docs/plans/2026-05-12-derive-on-compose-status.md
Joseph Doherty 8b8b85c839 docs(templates): record phase 2+3 completion in status doc
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.
2026-05-12 08:31:20 -04:00

10 KiB
Raw Blame History

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.md for 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: 03a8c4aPhase 3 complete, data migration in place.

Phases 13 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 passing
  • tests/ScadaLink.SiteRuntime.Tests — 129 passing
  • tests/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.AddCompositionAsync builds a derived template ("<parent>.<slot>"), copies base attributes/scripts with IsInherited=true, then composes the derived (not the base). Sets OwnerCompositionId back-ref after the composition's Id is known.
  • Composing a derived template is rejected — only bases can be composed.
  • DeleteCompositionAsync cascade-deletes the slot-owned derived template (IsDerived=true and OwnerCompositionId==compositionId).
  • DeleteTemplateAsync blocks 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.CanDeleteTemplateAsync mirrors 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 TemplateComposition whose target is IsDerived=0, inserts a derived template, copies attributes/scripts with IsInherited=1, then repoints ComposedTemplateId.
  • Idempotent (only touches non-derived targets), so re-runs are safe.
  • Down() reverses by repointing compositions to ParentTemplateId and 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)
  • src/ScadaLink.Commons/Entities/Templates/TemplateAttribute.cs
    • Added IsInherited: bool
    • Added LockedInDerived: bool
  • 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.cs regenerated.

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.ParentTemplateId chains in ResolveInheritedScripts. 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). If IsInherited = false, the derived's own value is the override and wins.
  • Treat LockedInDerived as an enforcement check: if a derived row has IsInherited = false (override) AND the base row has LockedInDerived = 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 via ParentTemplateId.
  • 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 = false for 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 _templates to exclude t.IsDerived by 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 TemplateEdit should link each composition row to the derived template's edit page (the existing link likely points to the base — needs to follow composition.ComposedTemplateId which 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 the OwnerCompositionId. Always one — no picker.
  • If the open template is a base (!IsDerived): suppress Parent.* assistance entirely. Add a SCADA008 hint diagnostic on Parent.* 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:

  1. Read this file. Read the design doc 2026-05-12-derive-on-compose-design.md for context.
  2. Run git log --oneline -15 to confirm the branch is at 03a8c4a or later.
  3. Check test status: dotnet test across the three suites named above.
  4. Ask the user which phase to tackle next (or proceed from phase 4 if the user has explicitly said "continue").
  5. Each phase is its own commit.

Pre-existing capability worth knowing

The script-scope editor work (commits 3ed05f00139c9c) 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.