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