docs(templates): design for derive-on-compose specialization
Aveva-style composition: composing $Sensor into $Pump creates a derived template Pump.TempSensor that inherits from $Sensor and can override values, override script bodies, add new fields, with LockedInDerived on the base preventing specific overrides. Schema sketch: Template gains IsDerived + OwnerCompositionId; TemplateAttribute/Script gain IsInherited + LockedInDerived. TemplateComposition.ComposedTemplateId pivots to point at the derived template (the base is reachable via derived.ParentTemplateId). Phased rollout (9 phases), starting from additive schema, then flow change for new compositions, then EF Core migration of existing data, then resolution, lock semantics, tree UI, derived template edit UI, base template lock-toggle UI, editor metadata simplification (multi-parent picker becomes mostly obsolete — derived templates always have a single owner). Open questions captured at the end for review before phase 1.
This commit is contained in:
272
docs/plans/2026-05-12-derive-on-compose-design.md
Normal file
272
docs/plans/2026-05-12-derive-on-compose-design.md
Normal file
@@ -0,0 +1,272 @@
|
||||
# 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:
|
||||
|
||||
```csharp
|
||||
public bool IsDerived { get; set; } // hides from main tree
|
||||
public int? OwnerCompositionId { get; set; } // back-ref to composition
|
||||
```
|
||||
|
||||
`TemplateAttribute` gains:
|
||||
|
||||
```csharp
|
||||
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:
|
||||
|
||||
```sql
|
||||
-- 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.
|
||||
|
||||
## Open questions
|
||||
|
||||
- **Derived-template names**: `Pump.TempSensor` vs `Pump_TempSensor` vs
|
||||
`${parentName}::${instanceName}`? Visible only on the edit page since the
|
||||
tree hides derived templates, but appears in audit logs and error messages.
|
||||
Default: dot-separated mirrors the canonical path format already used in
|
||||
flattening (`TempSensor.Temperature`). Pick this unless the dot is
|
||||
problematic for any existing pipeline.
|
||||
|
||||
- **Re-composing the same base in two slots on the same parent**: e.g., Pump
|
||||
composes Sensor twice as `IntakeSensor` and `OutletSensor`. Two derived
|
||||
templates: `Pump.IntakeSensor` and `Pump.OutletSensor`. Both inherit from
|
||||
`Sensor`. Confirmed OK.
|
||||
|
||||
- **Composition order / dependency**: if the user composes A inside B and
|
||||
later edits A's base — does B's derived template auto-pick up the changes?
|
||||
Answer per inheritance: yes for `IsInherited = true` fields, no for
|
||||
overrides. Aveva-consistent.
|
||||
|
||||
- **Cascade-delete confirmation**: deleting a base template that has
|
||||
derivatives — block with a clear error listing the derivatives, force user
|
||||
to delete them first? Aveva blocks. Likely we should too.
|
||||
|
||||
- **Validation**: if base adds an attribute, derived inherits it on next load.
|
||||
But what about derived overriding a field that base subsequently locks via
|
||||
`LockedInDerived`? Force-revert the override on next deploy? Surface as a
|
||||
validation error? Probably the latter.
|
||||
Reference in New Issue
Block a user