# Contained Names for Composition-Derived Templates — Design **Date:** 2026-05-18 **Status:** Implemented ## Context When a template composes another template, the `MigrateCompositionsToDerived` migration materializes the composition slot as its own *derived* `Template` row. Each derived template was named `OwnerTemplate.Name + "." + Composition. InstanceName` — e.g. the `Pump` slot inside `Motor Controller` became a template literally named **`Motor Controller.Pump`**. The dotted name exists only to satisfy the global `UNIQUE` index on `Template.Name`: there is already a standalone `Pump` template, so the derived one could not also be called `Pump`. This is the opposite of how AVEVA System Platform models containment — there, an object has a **contained name** (`Pump`, unique only within its container) and a **hierarchical name** (`MotorController.Pump`) that is *derived*, not stored. This design moves ScadaLink to that model. ### Decisions taken during brainstorming - **Adopt the contained-name model.** A derived template's stored `Name` is its contained name (the composition's `InstanceName`); the qualified path is computed, not stored. - **Keep derived templates materialized.** The `MigrateCompositionsToDerived` design is not revisited — the contained-name fix works with derived templates as-is. - **`QualifiedName` is computed**, not a denormalized column — a stored column would need cascade maintenance on every rename up the chain. Composition chains are 1–3 deep, so walking them on read is cheap. ### Audit findings (read-only, completed before this design) - No code resolves a template *by name*: there is no `GetTemplateByNameAsync`; the Management API and CLI are entirely ID-based. - The template tree already hides derived templates (`Where(t => !t.IsDerived)`). - Flattening, member canonical names, and collision detection use `composition.InstanceName` — never `Template.Name`. - The flattened/deployed artifact carries no template name; deployment keys on ID + revision hash. The dotted name never reaches sites. - `TemplateComposition` already has a DB `UNIQUE (TemplateId, InstanceName)` index — the contained-name uniqueness backstop already exists. - Every other `Template.Name` use is either dotted-name construction (`TemplateService` + the old migration), a dotted-name uniqueness pre-check, or reading `.Name` for display after an ID lookup. ## Design ### 1. Schema - The `UNIQUE` index on `Template.Name` becomes a **filtered** unique index: `UNIQUE ... WHERE [IsDerived] = 0`. User-authored (base) templates stay globally unique; derived templates do not need to be. - Derived templates' uniqueness is enforced entirely by the existing `UNIQUE (TemplateId, InstanceName)` index on `TemplateComposition` — a slot name is unique within its owner. - A new EF Core migration: - Rewrites every derived row: `Name = OwnerComposition.InstanceName`. - Replaces the `Template.Name` index with the filtered version. - `TemplateConfiguration`: `HasIndex(t => t.Name).IsUnique()` gains `.HasFilter("[IsDerived] = 0")`. ### 2. Qualified name A new pure helper resolves the hierarchical name on demand: ``` QualifiedName(template, byId, compById): if not template.IsDerived: return template.Name comp = compById[template.OwnerCompositionId] owner = byId[comp.TemplateId] return QualifiedName(owner, …) + "." + comp.InstanceName ``` It lives in a small static class in `ScadaLink.TemplateEngine` (e.g. `TemplateNaming`), takes lookups the callers already have, and is null-safe (a missing link falls back to the stored `Name`). ### 3. TemplateService The composition create / rename / cascade paths currently build `$"{owner.Name}.{instanceName}"` for the derived `Template.Name`. They change to store the **contained name** (`instanceName`) directly: - `CreateCascadedCompositionAsync` — derived `Name = instanceName`. - Nested cascade (`EnumerateCascadeNames`, cascade-rename) — each level stores its own `InstanceName`, not the accumulated path. - The dotted-name global collision pre-check (`allTemplates.Any(t => t.Name == newName)`) is removed — uniqueness is the in-owner `InstanceName` check that already runs (and the DB index behind it). ### 4. UI - `TemplateEdit`: the page title shows the contained `Name`; the qualified path is shown as a breadcrumb / subtitle (the existing "— composed inside X as Y" line already carries this information and becomes the canonical presentation). - "inherits X", cycle-detection messages, and deletion-blocked messages use `QualifiedName` when the referenced template is derived, so a bare `TempSensor` is never ambiguous in a message. ### 5. Out of scope - Un-materializing derived templates (treating a composition purely as a slot). - Any change to member/attribute canonical names — they already use `InstanceName`. ## Testing & verification - **`TemplateNaming` unit tests** — qualified name for a base template, a one-level derived template, and a nested derived template; null-safe fallback. - **`TemplateService` tests** — composition create and rename store the contained name; two derived templates with the same contained name under different owners are allowed; a duplicate slot name on one owner is rejected. - **Migration** — applied to the dev cluster; existing derived rows (`Motor Controller.Pump`, `Tank Monitor.DrivePump.TempSensor`, …) collapse to contained names; verified in the browser. ## Affected files - `src/ScadaLink.ConfigurationDatabase/Configurations/TemplateConfiguration.cs` - `src/ScadaLink.ConfigurationDatabase/Migrations/*` (new migration) - `src/ScadaLink.TemplateEngine/TemplateService.cs` - `src/ScadaLink.TemplateEngine/TemplateNaming.cs` (new) - `src/ScadaLink.CentralUI/Components/Pages/Design/TemplateEdit.razor` - Docs: `docs/requirements/Component-TemplateEngine.md`