Files
scadalink-design/docs/plans/2026-05-12-derive-on-compose-design.md

11 KiB

Derive-on-compose template specialization

Goal

Match Aveva System Platform's composition model: composing template $Sensor into template $Pump no longer references $Sensor directly. Instead the system creates a derived template that inherits from $Sensor, then the composition references the derived template. The derived template lives under the owning parent and can:

  • override attribute default values
  • override script bodies
  • add new attributes / scripts the base doesn't have
  • be prevented from overriding fields the base marks as locked

This is the user-selected approach (Option C "Always-derive") from the brainstorming session, with all four customization scopes enabled.

Why

  • Per-composition customization is a real SCADA use case (Pump's TempSensor needs different alarm thresholds from Motor's TempSensor).
  • Single parent always at design time: removes the multi-parent picker we just added.
  • Industry-standard mental model for users coming from Aveva / Wonderware.

Non-goals

  • Replacing the existing ParentTemplateId inheritance chain — we reuse it.
  • Versioning of base templates separately from derived (out of scope; can layer later).
  • Cross-template attribute references (already covered by Children/Parent).

Data model changes

Template gains:

public bool IsDerived { get; set; }                 // hides from main tree
public int? OwnerCompositionId { get; set; }        // back-ref to composition

TemplateAttribute gains:

public bool IsInherited { get; set; }               // value came from base
public bool LockedInDerived { get; set; }           // base marks "no override"

TemplateScript gains the same IsInherited / LockedInDerived pair.

TemplateComposition is unchanged in shape — ComposedTemplateId now points at the derived template, not the base. The base is reachable via derived.ParentTemplateId.

Why a separate IsDerived flag rather than just "has a parent and is composed once": explicit marker keeps the tree-view filtering trivial and signals intent independent of current composition state.

Why OwnerCompositionId instead of inferring from TemplateComposition back-pointers: O(1) lookup for cascade-delete and forbid-direct-edit paths.

Lifecycle

Compose "$Sensor" into "$Pump" as instance "TempSensor":
  1. Create new template { Name: "Pump.TempSensor",  ParentTemplateId: $Sensor.Id,
                           IsDerived: true,           Description: from $Sensor }
  2. Copy $Sensor.Attributes into the new template marked IsInherited=true
  3. Copy $Sensor.Scripts    into the new template marked IsInherited=true
  4. Create TemplateComposition { TemplateId: $Pump.Id,
                                   ComposedTemplateId: newTemplate.Id,
                                   InstanceName: "TempSensor" }
  5. Set newTemplate.OwnerCompositionId = the new composition's Id

Delete composition or owning parent → cascade-delete the derived template.

Rename composition InstanceName → rename the derived template (Pump.NewName).

Edit base attribute that is IsInherited=true on derivatives → the derivatives pick up the change if they haven't overridden that field. Override sets IsInherited=false.

Lock semantics

Existing IsLocked on TemplateAttribute already exists with the meaning "this attribute on this template is locked for editing." Add a second flag LockedInDerived meaning "derived templates may not override the value inherited from this attribute." These compose:

State on base What derived can do
neither flag set Override value freely
LockedInDerived only Cannot override; inherited value is final
IsLocked only Base itself can't be edited; derived can still override
both Locked everywhere

Flattening implications

FlatteningService.ResolveInheritedScripts already walks a template chain via ParentTemplateId. That logic already handles "child overrides parent; parent's IsLocked blocks override." We extend the same with LockedInDerived for both attributes and scripts.

ResolveComposedScripts walks compositions → composed templates. Today the prefix is the InstanceName. With derived templates the prefix is still the InstanceName (the derived template's name Pump.TempSensor doesn't show up in canonical paths — paths use the slot name, not the template name).

The ResolvedScript.Scope we landed for Phase 2 of the previous design still applies: SelfPath = "TempSensor", ParentPath = "". No change.

UI changes

Template tree

Hide IsDerived templates from the main list. They're reachable via:

  • the Compositions tab on the parent template (click the row → opens the derived template's edit page)
  • a "Show derived templates" toggle on the tree page (off by default)

TemplateEdit for a derived template

Top banner: "Derived from $Sensor — composed inside $Pump as TempSensor."

Attributes table renders three columns of state:

  • Override / Inherited badge per row
  • Locked-from-base attributes render readonly with a 🔒 icon and tooltip "Locked by base — cannot override."

Scripts table same treatment.

Adding a new attribute or script on the derived template is allowed (creates a row with IsInherited = false).

Removing an inherited row reverts it to the base value (the row goes back to inherited state). Removing an own-added row deletes it.

TemplateEdit for a base template

Two extra columns on attribute / script tables:

  • 🔒 toggle for LockedInDerived — "Lock this against per-slot override"

Compositions tab

Today: lists composition rows with InstanceName + ComposedTemplate name. After: each row links to its derived template (not the base). InstanceName becomes the visible label.

Renaming a composition renames the derived template too.

Composition picker (when adding a composition)

Today: pick a template + provide an instance name. After: pick a base template + provide an instance name. The system creates the derived template behind the scenes.

The picker filters out IsDerived templates — you can only compose bases.

Editor metadata implications

The multi-parent picker becomes mostly irrelevant:

  • Derived template: always single parent (the composition it's owned by). Parent.* resolves to that one. No picker.
  • Base template: still has no direct parent (it's a library entry). Parent.* autocompletion is suppressed. Scripts on bases that use Parent.* get a warning "Parent access on a base template is ambiguous — override this script in the derived template instead."

TemplateEdit.BuildParentContextsAsync simplifies to: "if derived, return the single owning parent; else return null."

GetTemplatesComposingAsync repository method still useful (e.g., for "find all uses of this base"), but the editor metadata path doesn't need it.

Migration

One-shot for existing data:

-- pseudo-SQL describing intent
FOREACH composition IN TemplateComposition:
  derived := INSERT INTO Templates (
    Name = parent.Name + "." + composition.InstanceName,
    ParentTemplateId = composition.ComposedTemplateId,
    IsDerived = true,
    OwnerCompositionId = composition.Id
  )
  -- Copy attributes from base, mark IsInherited=true
  INSERT INTO TemplateAttributes
    SELECT @derived.Id, Name, Value, DataType, true, ... FROM base.Attributes
  -- Same for scripts
  UPDATE TemplateComposition SET ComposedTemplateId = derived.Id WHERE Id = composition.Id

EF Core migration in ScadaLink.ConfigurationDatabase/Migrations/.

Rollback strategy: the migration is one-way for new derivations, but old composition data can be reconstructed from IsDerived templates' ParentTemplateId.

Phased rollout

Each phase is independently shippable and reviewable.

  1. Schema + entities. Add the new fields. Empty migration. EF mappings. No behavior changes. Existing data unaffected.

  2. Composition flow change. Modify TemplateService.AddCompositionAsync to derive on compose for new compositions. Existing data still has direct compositions and continues to work. Two modes coexist during the cutover.

  3. Migration. EF Core migration script that walks existing compositions and creates the derived templates retroactively. After this all compositions are derived.

  4. Inherit/override resolution. Update FlatteningService to merge inherited and overridden fields. Tests for the override semantics.

  5. Lock semantics. Wire LockedInDerived through TemplateService update paths. Tests.

  6. Template tree UI. Hide derived templates from the main listing; surface them through the parent's Compositions tab.

  7. Derived TemplateEdit UI. Banner, inherited/override badges, readonly-when-locked, override/revert actions.

  8. Base TemplateEdit UI. Add the LockedInDerived toggle column.

  9. Editor metadata simplification. Replace the multi-parent picker with the single-parent resolver. Base templates suppress Parent.* assistance and warn on use.

Out of scope (for now)

  • Versioning of base templates with explicit "update derived templates to base v2" workflow.
  • Reverse-flow: editing a derived value and asking "promote to base."
  • Multiple inheritance levels for derivation (e.g., $Sensor → $Sensor.Pump → $Sensor.Pump.HighTemp) — the data model supports it via ParentTemplateId, but the UX hasn't been designed.
  • Cross-tenant template libraries.

Decisions

  • Naming: dot-separated (Pump.TempSensor). Matches the canonical-path format used in flattening. Visible in audit logs / error messages.
  • Delete base with derivatives: block the delete and list the derivatives. User must remove or repoint them first.
  • Migration of existing data: EF Core migration on next startup auto-derives every existing composition. After deploy all compositions are derived; no mixed-mode code paths.
  • Tree UX: derived templates hidden by default. "Show derived templates" toggle on the tree page reveals them indented under their base. Always reachable from the parent's Compositions tab.

Confirmed semantics

  • Re-composing the same base on the same parent in two slots (e.g. Pump composes Sensor twice as IntakeSensor and OutletSensor) produces two derived templates: Pump.IntakeSensor and Pump.OutletSensor, both inheriting from Sensor.

  • Inheritance updates flow downward: if a base attribute changes value later and the derivative has IsInherited = true for that attribute, the derived value updates. Once overridden (IsInherited = false), changes to the base no longer affect that field.

  • Subsequent LockedInDerived after overrides exist: surface as a validation error at deploy time; do not force-revert silently.