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
ParentTemplateIdinheritance 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 useParent.*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.
-
Schema + entities. Add the new fields. Empty migration. EF mappings. No behavior changes. Existing data unaffected.
-
Composition flow change. Modify
TemplateService.AddCompositionAsyncto derive on compose for new compositions. Existing data still has direct compositions and continues to work. Two modes coexist during the cutover. -
Migration. EF Core migration script that walks existing compositions and creates the derived templates retroactively. After this all compositions are derived.
-
Inherit/override resolution. Update
FlatteningServiceto merge inherited and overridden fields. Tests for the override semantics. -
Lock semantics. Wire
LockedInDerivedthroughTemplateServiceupdate paths. Tests. -
Template tree UI. Hide derived templates from the main listing; surface them through the parent's Compositions tab.
-
Derived TemplateEdit UI. Banner, inherited/override badges, readonly-when-locked, override/revert actions.
-
Base TemplateEdit UI. Add the LockedInDerived toggle column.
-
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 viaParentTemplateId, 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
IntakeSensorandOutletSensor) produces two derived templates:Pump.IntakeSensorandPump.OutletSensor, both inheriting fromSensor. -
Inheritance updates flow downward: if a base attribute changes value later and the derivative has
IsInherited = truefor that attribute, the derived value updates. Once overridden (IsInherited = false), changes to the base no longer affect that field. -
Subsequent
LockedInDerivedafter overrides exist: surface as a validation error at deploy time; do not force-revert silently.