docs(templates): derive-on-compose phase status + resume plan

Companion to the design doc — captures current state, the four
decisions already made, what's done (phase 1, commit 5615f3d),
and a full play-by-play for phases 2 through 9 with exact files,
methods, and tests to touch. Written so a future session after
context compaction can pick up cleanly.
This commit is contained in:
Joseph Doherty
2026-05-12 08:18:43 -04:00
parent 5615f3d0c7
commit 91b786eb1c

View File

@@ -0,0 +1,270 @@
# Derive-on-compose: implementation status + remaining plan
> **For Claude resuming after compaction:** This is the single starting point.
> Read it in full. Then read the companion design doc
> `2026-05-12-derive-on-compose-design.md` for the architectural rationale.
> Do NOT make code changes until you've also confirmed which phase the user
> wants next.
## Where we are
**Branch**: `feature/templates-folder-hierarchy`. Already pushed.
**Last commit on this feature**: `5615f3d` — *Phase 1 complete, additive
schema only*.
**All test suites currently green**:
- `tests/ScadaLink.CentralUI.Tests` — 159 passing
- `tests/ScadaLink.SiteRuntime.Tests` — 129 passing
- `tests/ScadaLink.TemplateEngine.Tests` — 199 passing
## Design decisions already made (from the brainstorm)
User picked the **full Aveva model** with all four customization scopes:
- **Naming**: dot-separated → `Pump.TempSensor`
- **Delete base with derivatives**: block with a list of the dependents
- **Migration of existing compositions**: auto-migrate all on the EF Core
migration step in Phase 3
- **Tree UX**: derived templates hidden by default; toggle to reveal
- **Customization scope**: override attribute values, override script bodies,
add new attrs/scripts per slot, lock fields against override
## Done — Phase 1: Additive schema
Commits: `6854843` (design doc) + `a968cef` (decisions recorded) + `5615f3d`.
Files touched in `5615f3d`:
- `src/ScadaLink.Commons/Entities/Templates/Template.cs`
- Added `IsDerived: bool`
- Added `OwnerCompositionId: int?` (plain int — not an EF nav prop)
- `src/ScadaLink.Commons/Entities/Templates/TemplateAttribute.cs`
- Added `IsInherited: bool`
- Added `LockedInDerived: bool`
- `src/ScadaLink.Commons/Entities/Templates/TemplateScript.cs`
- Same two fields
- `src/ScadaLink.ConfigurationDatabase/Migrations/20260512121446_AddDerivedTemplateFields.cs`
- EF Core migration. Six new columns, all NOT NULL DEFAULT 0 (or nullable
int). No data transform — existing rows get defaults.
- `ScadaLinkDbContextModelSnapshot.cs` regenerated.
**No behavior changes**. New fields are never read or written yet.
## Remaining — phases 2 through 9
Each phase ships independently. The cumulative behavior change fully lands at
phase 3.
### Phase 2: Compose flow change
`TemplateService.AddCompositionAsync` currently creates a `TemplateComposition`
that references a base template directly. Change it so NEW compositions:
1. Create a new `Template` entity:
- `Name = $"{parentTemplate.Name}.{instanceName}"`
- `ParentTemplateId = composedTemplateId` (the base)
- `IsDerived = true`
- `FolderId = null` (or inherit from parent? — pick null; derived
templates aren't visible in the tree)
2. Copy the base's `Attributes` into the new template marked
`IsInherited = true`. Same for `Scripts` and `Alarms`.
3. Create the `TemplateComposition` with `ComposedTemplateId = newTemplate.Id`
(the derived, not the base).
4. Set the derived template's `OwnerCompositionId = composition.Id` (back-ref).
Files to touch:
- `src/ScadaLink.TemplateEngine/TemplateService.cs` — extend
`AddCompositionAsync` (currently around line 290+).
- `src/ScadaLink.TemplateEngine/TemplateService.cs` — also extend
`DeleteCompositionAsync` to cascade-delete the derived template.
- `src/ScadaLink.TemplateEngine/TemplateService.cs` — extend `DeleteTemplateAsync`
to block when the template is composed (already happens) AND when
derivatives exist; reword the error.
- Validation: derived template names must remain unique (the base existing
uniqueness index on `Template.Name` covers this — but if `Pump.TempSensor`
collides with a user-created template of that name, the add fails. Add a
guard with a clear error message.)
Tests to add (`tests/ScadaLink.TemplateEngine.Tests/`):
- AddCompositionAsync creates a derived template.
- The derived template inherits attributes / scripts with `IsInherited = true`.
- DeleteCompositionAsync cascades to remove the derived template.
- Deleting a base with derivatives is blocked with a message that lists them.
### Phase 3: Migration of existing compositions
EF Core data migration that runs on next startup. Two paths:
**Option A (simpler) — write the migration as a custom SQL/code step**
In `src/ScadaLink.ConfigurationDatabase/Migrations/`, generate a new migration
called e.g. `MigrateCompositionsToDerived`. In its `Up` method, use raw
`migrationBuilder.Sql(...)` or a delegate to:
```
FOREACH composition IN TemplateComposition WHERE ComposedTemplate.IsDerived = false:
derived := INSERT INTO Templates (
Name = parent.Name || '.' || composition.InstanceName,
ParentTemplateId = composition.ComposedTemplateId,
IsDerived = true,
OwnerCompositionId = composition.Id)
INSERT INTO TemplateAttributes (Name, Value, DataType, IsLocked, Description,
DataSourceReference, IsInherited=true,
LockedInDerived=false, TemplateId=derived.Id)
FROM TemplateAttributes WHERE TemplateId = composition.ComposedTemplateId
same for TemplateScripts and TemplateAlarms
UPDATE TemplateComposition SET ComposedTemplateId = derived.Id WHERE Id = composition.Id
```
Run as raw SQL to keep the migration deterministic across providers (SQL
Server vs SQLite if used in dev).
**Option B — service-layer one-time migration**
Add a startup hook that runs once (controlled by a flag column) and uses
`TemplateService` directly. Cleaner for complex logic but more code.
**Pick Option A** unless the logic doesn't fit comfortably in SQL.
Validation post-migration: every `TemplateComposition.ComposedTemplateId`
should now reference a template with `IsDerived = true`.
Tests: integration test that seeds pre-migration data, runs the migration,
asserts the new shape.
### Phase 4: Inherit/override resolution in flattening
`src/ScadaLink.TemplateEngine/Flattening/FlatteningService.cs`:
- The service currently walks `Template.ParentTemplateId` chains in
`ResolveInheritedScripts`. That same chain now naturally includes the
base→derived link. No special handling needed for the chain itself.
- BUT when a derived template's attribute is `IsInherited = true`, the
effective value comes from the base (resolved through the chain). If
`IsInherited = false`, the derived's own value is the override and wins.
- Treat `LockedInDerived` as an enforcement check: if a derived row has
`IsInherited = false` (override) AND the base row has `LockedInDerived =
true`, emit a flattening validation error.
Tests: round-trip flattening with overridden + inherited combinations.
### Phase 5: Lock semantics enforcement
In `TemplateService.UpdateAttributeAsync` and `UpdateScriptAsync`:
- If the target template has `IsDerived = true`, look up the base via
`ParentTemplateId`.
- If the same-named attribute on the base has `LockedInDerived = true`,
reject the update with a clear error: *"This attribute is locked by the
base template '$Sensor' and cannot be overridden."*
- If the update is being applied on a base template AND there are
derivatives that have `IsInherited = false` for this field (overrides), the
caller may want to know — surface a warning result. Out of scope for now;
flag as TODO.
Tests: lock-blocked updates fail with the expected message.
### Phase 6: Template tree UI — hide derived
`src/ScadaLink.CentralUI/Components/Pages/Design/Templates.razor`:
- Filter `_templates` to exclude `t.IsDerived` by default.
- Add a "Show derived templates" checkbox at the top.
- When shown, render derived templates indented under their base
(`t.ParentTemplateId == base.Id && t.IsDerived`).
- Compositions tab on `TemplateEdit` should link each composition row to the
derived template's edit page (the existing link likely points to the base
— needs to follow `composition.ComposedTemplateId` which after phase 3 is
the derived).
### Phase 7: Derived TemplateEdit UI
`src/ScadaLink.CentralUI/Components/Pages/Design/TemplateEdit.razor`:
- Top banner when `_selectedTemplate.IsDerived`:
*"Derived from `[base.Name]` — composed inside `[parent.Name]` as
`[composition.InstanceName]`."*
- Attributes table: new column showing **Inherited** or **Overridden** badge.
- Locked-from-base attributes (base has `LockedInDerived = true`) render
readonly with a 🔒 icon and tooltip *"Locked by base — cannot override."*
- Editing an inherited row flips it to override (`IsInherited = false`).
- "Revert to base" button per row when `IsInherited = false` — clears the
override; row reverts to the base value (`IsInherited = true`, value
cleared).
- Adding a new attribute/script: creates a row with `IsInherited = false`
(it's not from the base).
- Removing an inherited row should be blocked (the row exists because it's
on the base; can't remove it on the derived). Removing an own-added row
deletes normally.
### Phase 8: Base TemplateEdit UI — lock toggle
Same `TemplateEdit.razor`, when editing a base template:
- New column on Attributes table: a 🔒 toggle representing `LockedInDerived`.
- Same on Scripts table.
- Tooltip on the toggle: *"Lock this against per-slot override in derived
templates."*
When a base template is open and the user toggles `LockedInDerived = true`
while derived templates exist with overrides, surface a warning toast:
*"N derived templates currently override this. Existing overrides won't be
silently reverted but deploy validation will flag them."*
### Phase 9: Editor metadata simplification
Now that derived templates have a single owner, `BuildParentContextsAsync`
in `TemplateEdit.razor` simplifies:
- **If the open template `IsDerived`**: parent = the single template that
owns the `OwnerCompositionId`. Always one — no picker.
- **If the open template is a base** (`!IsDerived`): suppress `Parent.*`
assistance entirely. Add a SCADA008 hint diagnostic on `Parent.*` use:
*"Parent access on a base template is ambiguous — override this script in
a derived template instead."*
Remove the multi-parent `select` dropdown that was added in commit `0139c9c`
(or keep it but guard with a check that should never fire after phase 3).
`GetTemplatesComposingAsync` (added in `0139c9c`) is still useful elsewhere —
keep it.
## How to resume
After compaction, the future session should:
1. Read this file. Read the design doc
`2026-05-12-derive-on-compose-design.md` for context.
2. Run `git log --oneline -15` to confirm the branch is at `5615f3d` or
later.
3. Check test status: `dotnet test` across the three suites named above.
4. Ask the user which phase to tackle next (or proceed from phase 2 if the
user has explicitly said "continue").
5. Each phase is its own commit. Prefer phase 2 + phase 3 together because
phase 2 alone leaves new and existing compositions in different shapes
until phase 3's migration runs.
## Pre-existing capability worth knowing
The script-scope editor work (commits `3ed05f0``0139c9c`) is already in
place. Scripts on derived templates will automatically benefit from the
single-parent context simplification in phase 9. The runtime accessors
(`Attributes` / `Children` / `Parent` / `Scope`) defined there continue to
work unchanged — the canonical-name paths they resolve are stable across
the derivation change.
The multi-parent picker introduced in `0139c9c` becomes mostly dormant
after phase 3 (no template should be composed by multiple parents anymore
through the new flow). Plan to remove it in phase 9.
## Quick sanity script (run before phase 2)
```bash
git status --short # should be clean
git log --oneline -5 # top should include 5615f3d
dotnet build # 0 errors, 0 warnings
dotnet test tests/ScadaLink.TemplateEngine.Tests/ScadaLink.TemplateEngine.Tests.csproj
```