diff --git a/docs/plans/2026-05-12-derive-on-compose-design.md b/docs/plans/2026-05-12-derive-on-compose-design.md new file mode 100644 index 0000000..04e2383 --- /dev/null +++ b/docs/plans/2026-05-12-derive-on-compose-design.md @@ -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.