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

273 lines
11 KiB
Markdown

# 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.
## 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.