3 Commits

Author SHA1 Message Date
Joseph Doherty 5615f3d0c7 feat(templates): phase 1 — derived-template schema (additive)
Phase 1 of the design at
docs/plans/2026-05-12-derive-on-compose-design.md.

Additive schema only — no behavior changes. Existing data and code
paths continue to work; subsequent phases will start writing the
new fields.

Template gains:
  IsDerived            true when this row was auto-created to back
                       a composition slot
  OwnerCompositionId   back-ref to the owning TemplateComposition
                       (plain int, not an EF nav property — managed
                       by TemplateService for cascade-delete)

TemplateAttribute / TemplateScript each gain:
  IsInherited          row copied from base and not yet overridden;
                       changes to the base flow downward
  LockedInDerived      on a base, blocks derived from overriding;
                       enforced at the service layer in later phases

EF Core migration AddDerivedTemplateFields adds four columns:
  Templates.IsDerived              bit NOT NULL DEFAULT 0
  Templates.OwnerCompositionId     int NULL
  TemplateAttributes.IsInherited   bit NOT NULL DEFAULT 0
  TemplateAttributes.LockedInDerived bit NOT NULL DEFAULT 0
  TemplateScripts.IsInherited      bit NOT NULL DEFAULT 0
  TemplateScripts.LockedInDerived  bit NOT NULL DEFAULT 0

Existing rows get the defaults. Tests across SiteRuntime / TemplateEngine
/ CentralUI suites stay green (129 / 199 / 159).

Next: phase 2 — wire AddCompositionAsync to derive on compose for
new compositions. Old data still flows the direct-reference path
until phase 3's migration script.
2026-05-12 08:16:24 -04:00
Joseph Doherty a968cefbc2 docs(templates): record derive-on-compose decisions (naming, migration, tree UX) 2026-05-12 08:13:11 -04:00
Joseph Doherty 68548432b3 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.
2026-05-12 08:12:12 -04:00
7 changed files with 1718 additions and 0 deletions
@@ -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.
## 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.
@@ -12,6 +12,21 @@ public class Template
public ICollection<TemplateScript> Scripts { get; set; } = new List<TemplateScript>(); public ICollection<TemplateScript> Scripts { get; set; } = new List<TemplateScript>();
public ICollection<TemplateComposition> Compositions { get; set; } = new List<TemplateComposition>(); public ICollection<TemplateComposition> Compositions { get; set; } = new List<TemplateComposition>();
/// <summary>
/// True when this template was auto-derived to back a TemplateComposition
/// slot. Derived templates inherit from a base (see <see cref="ParentTemplateId"/>),
/// are owned by their composition row (see <see cref="OwnerCompositionId"/>),
/// and are hidden from the main template tree by default.
/// </summary>
public bool IsDerived { get; set; }
/// <summary>
/// Back-reference to the <see cref="TemplateComposition"/> that owns this
/// derived template. Non-null only when <see cref="IsDerived"/>; cascade-
/// delete when the composition is removed. Always null on base templates.
/// </summary>
public int? OwnerCompositionId { get; set; }
public Template(string name) public Template(string name)
{ {
Name = name ?? throw new ArgumentNullException(nameof(name)); Name = name ?? throw new ArgumentNullException(nameof(name));
@@ -13,6 +13,21 @@ public class TemplateAttribute
public string? Description { get; set; } public string? Description { get; set; }
public string? DataSourceReference { get; set; } public string? DataSourceReference { get; set; }
/// <summary>
/// True when this row was copied from the base template and has not been
/// overridden on the derived template. Changes to the base flow downward
/// for inherited rows; an explicit override flips this to false.
/// Always false on base (non-derived) templates.
/// </summary>
public bool IsInherited { get; set; }
/// <summary>
/// Set on a base attribute. When true, derived templates may not override
/// the value — the row is rendered readonly with a 🔒 in the derived UI,
/// and any attempt to update it through the API is rejected.
/// </summary>
public bool LockedInDerived { get; set; }
public TemplateAttribute(string name) public TemplateAttribute(string name)
{ {
Name = name ?? throw new ArgumentNullException(nameof(name)); Name = name ?? throw new ArgumentNullException(nameof(name));
@@ -13,6 +13,21 @@ public class TemplateScript
public string? ReturnDefinition { get; set; } public string? ReturnDefinition { get; set; }
public TimeSpan? MinTimeBetweenRuns { get; set; } public TimeSpan? MinTimeBetweenRuns { get; set; }
/// <summary>
/// True when this row was copied from the base template and has not been
/// overridden on the derived template. Changes to the base flow downward
/// for inherited rows; an explicit override flips this to false.
/// Always false on base (non-derived) templates.
/// </summary>
public bool IsInherited { get; set; }
/// <summary>
/// Set on a base script. When true, derived templates may not override
/// the script body — the row is rendered readonly with a 🔒 in the derived
/// UI, and any attempt to update it through the API is rejected.
/// </summary>
public bool LockedInDerived { get; set; }
public TemplateScript(string name, string code) public TemplateScript(string name, string code)
{ {
Name = name ?? throw new ArgumentNullException(nameof(name)); Name = name ?? throw new ArgumentNullException(nameof(name));
@@ -0,0 +1,83 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ScadaLink.ConfigurationDatabase.Migrations
{
/// <inheritdoc />
public partial class AddDerivedTemplateFields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsInherited",
table: "TemplateScripts",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "LockedInDerived",
table: "TemplateScripts",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "IsDerived",
table: "Templates",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<int>(
name: "OwnerCompositionId",
table: "Templates",
type: "int",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "IsInherited",
table: "TemplateAttributes",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "LockedInDerived",
table: "TemplateAttributes",
type: "bit",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsInherited",
table: "TemplateScripts");
migrationBuilder.DropColumn(
name: "LockedInDerived",
table: "TemplateScripts");
migrationBuilder.DropColumn(
name: "IsDerived",
table: "Templates");
migrationBuilder.DropColumn(
name: "OwnerCompositionId",
table: "Templates");
migrationBuilder.DropColumn(
name: "IsInherited",
table: "TemplateAttributes");
migrationBuilder.DropColumn(
name: "LockedInDerived",
table: "TemplateAttributes");
}
}
}
@@ -848,11 +848,17 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
b.Property<int?>("FolderId") b.Property<int?>("FolderId")
.HasColumnType("int"); .HasColumnType("int");
b.Property<bool>("IsDerived")
.HasColumnType("bit");
b.Property<string>("Name") b.Property<string>("Name")
.IsRequired() .IsRequired()
.HasMaxLength(200) .HasMaxLength(200)
.HasColumnType("nvarchar(200)"); .HasColumnType("nvarchar(200)");
b.Property<int?>("OwnerCompositionId")
.HasColumnType("int");
b.Property<int?>("ParentTemplateId") b.Property<int?>("ParentTemplateId")
.HasColumnType("int"); .HasColumnType("int");
@@ -935,9 +941,15 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
.HasMaxLength(2000) .HasMaxLength(2000)
.HasColumnType("nvarchar(2000)"); .HasColumnType("nvarchar(2000)");
b.Property<bool>("IsInherited")
.HasColumnType("bit");
b.Property<bool>("IsLocked") b.Property<bool>("IsLocked")
.HasColumnType("bit"); .HasColumnType("bit");
b.Property<bool>("LockedInDerived")
.HasColumnType("bit");
b.Property<string>("Name") b.Property<string>("Name")
.IsRequired() .IsRequired()
.HasMaxLength(200) .HasMaxLength(200)
@@ -1027,9 +1039,15 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
.IsRequired() .IsRequired()
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
b.Property<bool>("IsInherited")
.HasColumnType("bit");
b.Property<bool>("IsLocked") b.Property<bool>("IsLocked")
.HasColumnType("bit"); .HasColumnType("bit");
b.Property<bool>("LockedInDerived")
.HasColumnType("bit");
b.Property<TimeSpan?>("MinTimeBetweenRuns") b.Property<TimeSpan?>("MinTimeBetweenRuns")
.HasColumnType("time"); .HasColumnType("time");