Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/TemplateService.cs
T
Joseph Doherty 7b0b9c7365 refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)
Solution + 23 src projects + 26 test projects renamed; folders, csproj,
namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated.
ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated.
SQL roles/logins, LDAP domains, CLI command name, and CLI config dir
(~/.scadalink → ~/.scadabridge) also renamed.

Build green; 5 Host.Tests fail awaiting SQL login rename in next commit.
Pre-existing StaleTagMonitor timing flakes unchanged.

Rename script committed at tools/rename-to-scadabridge.sh.
2026-05-28 09:37:45 -04:00

1216 lines
59 KiB
C#

using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine;
/// <summary>
/// Core service for Template Engine operations.
/// Covers CRUD for templates and their members (attributes, alarms, scripts, compositions),
/// inheritance and composition rules, override/locking validation, collision detection, and acyclicity enforcement.
/// </summary>
public class TemplateService
{
private readonly ITemplateEngineRepository _repository;
private readonly IAuditService _auditService;
/// <summary>
/// Initializes a new instance of the TemplateService class.
/// </summary>
/// <param name="repository">Template engine repository for data access.</param>
/// <param name="auditService">Service for audit logging.</param>
public TemplateService(ITemplateEngineRepository repository, IAuditService auditService)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_auditService = auditService ?? throw new ArgumentNullException(nameof(auditService));
}
// ========================================================================
// WP-1: Template CRUD with Inheritance
// ========================================================================
/// <summary>
/// Creates a new template with optional inheritance and folder assignment.
/// </summary>
/// <param name="name">Name of the template.</param>
/// <param name="description">Optional description.</param>
/// <param name="parentTemplateId">Optional ID of the parent template for inheritance.</param>
/// <param name="user">Username of the user creating the template.</param>
/// <param name="folderId">Optional ID of the folder to place the template in.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Result containing the created template or failure message.</returns>
public async Task<Result<Template>> CreateTemplateAsync(
string name,
string? description,
int? parentTemplateId,
string user,
int? folderId = null,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(name))
return Result<Template>.Failure("Template name is required.");
// Validate parent exists if specified
if (parentTemplateId.HasValue)
{
var parent = await _repository.GetTemplateByIdAsync(parentTemplateId.Value, cancellationToken);
if (parent == null)
return Result<Template>.Failure($"Parent template with ID {parentTemplateId.Value} not found.");
}
var template = new Template(name)
{
Description = description,
ParentTemplateId = parentTemplateId,
FolderId = folderId
};
// No collision or acyclicity check is needed here: a freshly created
// template has no members of its own, the parent (validated above to
// exist) was already collision-checked when its members were added,
// and a brand-new child cannot be an ancestor of its parent. Naming
// collisions are enforced on every member-mutating call (AddAttribute,
// AddAlarm, AddScript, AddComposition) and on rename in UpdateTemplate.
// TemplateEngine-020: save the entity first so EF Core populates the
// auto-generated key, then write the audit row with the real
// template.Id, then save the audit row. The pre-fix order logged
// EntityId = "0" because the audit row was queued before
// SaveChangesAsync ran — every Create audit entry lost the link back
// to the row it described.
await _repository.AddTemplateAsync(template, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
await _auditService.LogAsync(user, "Create", "Template", template.Id.ToString(), name, template, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
return Result<Template>.Success(template);
}
/// <summary>
/// Updates a template's name and description. Parent template is immutable after creation.
/// </summary>
/// <param name="templateId">ID of the template to update.</param>
/// <param name="name">New name for the template.</param>
/// <param name="description">New description.</param>
/// <param name="parentTemplateId">Must match the existing parent (cannot be changed).</param>
/// <param name="user">Username of the user updating the template.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Result containing the updated template or failure message.</returns>
public async Task<Result<Template>> UpdateTemplateAsync(
int templateId,
string name,
string? description,
int? parentTemplateId,
string user,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(name))
return Result<Template>.Failure("Template name is required.");
var template = await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
if (template == null)
return Result<Template>.Failure($"Template with ID {templateId} not found.");
// ParentTemplateId is immutable after creation — set once at create time.
// Reject any attempt to change it (null→value, value→null, or value→other).
if (parentTemplateId != template.ParentTemplateId)
{
return Result<Template>.Failure(
"Parent template cannot be changed after creation.");
}
template.Name = name;
template.Description = description;
// Check for naming collisions after the change
var collisionResult = await ValidateCollisionsAsync(template, cancellationToken);
if (collisionResult != null)
return Result<Template>.Failure(collisionResult);
await _repository.UpdateTemplateAsync(template, cancellationToken);
await _auditService.LogAsync(user, "Update", "Template", templateId.ToString(), name, template, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
return Result<Template>.Success(template);
}
/// <summary>
/// Deletes a template if no instances depend on it.
/// </summary>
/// <param name="templateId">ID of the template to delete.</param>
/// <param name="user">Username of the user deleting the template.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Result indicating success or failure.</returns>
public async Task<Result<bool>> DeleteTemplateAsync(
int templateId,
string user,
CancellationToken cancellationToken = default)
{
var template = await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
if (template == null)
return Result<bool>.Failure($"Template with ID {templateId} not found.");
// Deletion-constraint logic (instances / child / derived / composing
// templates) lives in exactly one place — TemplateDeletionService — so a
// future rule change cannot drift between two implementations
// (TemplateEngine-014). TemplateService owns only the audit-logging side
// effect, which the deletion service is unaware of.
var deletionService = new Services.TemplateDeletionService(_repository);
var deleteResult = await deletionService.DeleteTemplateAsync(templateId, cancellationToken);
if (deleteResult.IsFailure)
return deleteResult;
// TemplateDeletionService already persisted the delete; the audit entry
// is added to the change tracker here and needs its own SaveChangesAsync.
await _auditService.LogAsync(user, "Delete", "Template", templateId.ToString(), template.Name, null, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
return Result<bool>.Success(true);
}
/// <summary>
/// Moves a template to a different folder.
/// </summary>
/// <param name="templateId">ID of the template to move.</param>
/// <param name="newFolderId">ID of the target folder, or null to remove from folder.</param>
/// <param name="user">Username of the user moving the template.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Result containing the updated template or failure message.</returns>
public async Task<Result<Template>> MoveTemplateAsync(
int templateId,
int? newFolderId,
string user,
CancellationToken cancellationToken = default)
{
var template = await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
if (template == null)
return Result<Template>.Failure($"Template with ID {templateId} not found.");
if (newFolderId.HasValue)
{
var folder = await _repository.GetFolderByIdAsync(newFolderId.Value, cancellationToken);
if (folder == null)
return Result<Template>.Failure($"Target folder with ID {newFolderId.Value} not found.");
}
// No-op move — skip the collision check (a template moving to its own
// folder cannot collide with itself).
if (template.FolderId != newFolderId)
{
// Sibling-name uniqueness at the destination (TemplateEngine-021),
// mirroring TemplateFolderService.MoveFolderAsync. A template move
// changes only FolderId, so there is no inheritance- or
// composition-graph cycle to detect (templates have no folder-
// children navigation; ParentTemplateId is untouched here). The
// only invariant the move can break is two templates sharing a
// (FolderId, Name) at the destination, which the design's
// naming-collisions-are-design-time-errors rule forbids.
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
var collision = allTemplates.FirstOrDefault(t =>
t.Id != templateId &&
t.FolderId == newFolderId &&
string.Equals(t.Name, template.Name, StringComparison.OrdinalIgnoreCase));
if (collision != null)
{
var location = newFolderId.HasValue ? "the target folder" : "the root";
return Result<Template>.Failure(
$"A template named '{template.Name}' already exists in {location}.");
}
}
template.FolderId = newFolderId;
await _repository.UpdateTemplateAsync(template, cancellationToken);
await _auditService.LogAsync(user, "Move", "Template", template.Id.ToString(), template.Name, template, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
return Result<Template>.Success(template);
}
/// <summary>
/// Gets a template by ID.
/// </summary>
/// <param name="templateId">ID of the template.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The template, or null if not found.</returns>
public async Task<Template?> GetTemplateByIdAsync(int templateId, CancellationToken cancellationToken = default)
{
return await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
}
/// <summary>
/// Gets all templates.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of all templates.</returns>
public async Task<IReadOnlyList<Template>> GetAllTemplatesAsync(CancellationToken cancellationToken = default)
{
return await _repository.GetAllTemplatesAsync(cancellationToken);
}
// ========================================================================
// WP-2: Attribute Definitions with Lock Flags
// ========================================================================
/// <summary>
/// Adds an attribute definition to a template.
/// </summary>
/// <param name="templateId">ID of the template.</param>
/// <param name="attribute">Attribute definition to add.</param>
/// <param name="user">Username of the user adding the attribute.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Result containing the added attribute or failure message.</returns>
public async Task<Result<TemplateAttribute>> AddAttributeAsync(
int templateId,
TemplateAttribute attribute,
string user,
CancellationToken cancellationToken = default)
{
var template = await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
if (template == null)
return Result<TemplateAttribute>.Failure($"Template with ID {templateId} not found.");
// Check for duplicate name at this template level
if (template.Attributes.Any(a => a.Name == attribute.Name))
return Result<TemplateAttribute>.Failure(
$"Attribute '{attribute.Name}' already exists on template '{template.Name}'.");
attribute.TemplateId = templateId;
// If inheriting, validate not trying to add a member that would collide
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
var testTemplate = CloneTemplateWithNewAttribute(template, attribute);
var collisions = CollisionDetector.DetectCollisions(testTemplate, allTemplates);
if (collisions.Count > 0)
return Result<TemplateAttribute>.Failure(string.Join(" ", collisions));
// TemplateEngine-020: save-then-audit so the audit row carries the
// real attribute Id rather than a literal "0".
await _repository.AddTemplateAttributeAsync(attribute, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
await _auditService.LogAsync(user, "Create", "TemplateAttribute", attribute.Id.ToString(), attribute.Name, attribute, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
return Result<TemplateAttribute>.Success(attribute);
}
/// <summary>
/// Updates an attribute definition with lock and inheritance validation.
/// </summary>
/// <param name="attributeId">ID of the attribute to update.</param>
/// <param name="proposed">New attribute definition values.</param>
/// <param name="user">Username of the user updating the attribute.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Result containing the updated attribute or failure message.</returns>
public async Task<Result<TemplateAttribute>> UpdateAttributeAsync(
int attributeId,
TemplateAttribute proposed,
string user,
CancellationToken cancellationToken = default)
{
var existing = await _repository.GetTemplateAttributeByIdAsync(attributeId, cancellationToken);
if (existing == null)
return Result<TemplateAttribute>.Failure($"Attribute with ID {attributeId} not found.");
// Validate override rules if this is an override (parent has same-named attribute)
var template = await _repository.GetTemplateByIdAsync(existing.TemplateId, cancellationToken);
if (template?.ParentTemplateId != null)
{
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
var parentMember = TemplateResolver.FindMemberByCanonicalName(
existing.Name, template.ParentTemplateId.Value, allTemplates);
if (parentMember != null && parentMember.IsLocked)
return Result<TemplateAttribute>.Failure(
$"Attribute '{existing.Name}' is locked in parent and cannot be overridden.");
// Derived templates may not override fields the base marked LockedInDerived.
if (template.IsDerived)
{
var baseTemplate = await _repository.GetTemplateByIdAsync(template.ParentTemplateId.Value, cancellationToken);
var baseAttr = baseTemplate?.Attributes.FirstOrDefault(a => a.Name == existing.Name);
if (baseAttr != null && baseAttr.LockedInDerived)
return Result<TemplateAttribute>.Failure(
$"Attribute '{existing.Name}' is locked by base template '{baseTemplate!.Name}' and cannot be overridden.");
}
}
// Validate lock change rules
var lockError = LockEnforcer.ValidateLockChange(existing.IsLocked, proposed.IsLocked, existing.Name);
if (lockError != null)
return Result<TemplateAttribute>.Failure(lockError);
// LockedInDerived is a one-way ratchet on base templates: once a base
// marks an attribute LockedInDerived it cannot be cleared, otherwise
// derived overrides that were previously blocked would become
// retroactively legal (TemplateEngine-022). Only meaningful on base
// templates — derived rows never carry an authoritative LockedInDerived.
if (template?.IsDerived != true)
{
var lockedInDerivedError = LockEnforcer.ValidateLockedInDerivedChange(
existing.LockedInDerived, proposed.LockedInDerived, existing.Name);
if (lockedInDerivedError != null)
return Result<TemplateAttribute>.Failure(lockedInDerivedError);
}
// Validate fixed-field granularity. DataType and DataSourceReference are
// fixed by the defining level for every attribute — locked or not — so
// the error is always honoured (a locked attribute is already rejected
// earlier inside the helper).
var granularityError = LockEnforcer.ValidateAttributeOverride(existing, proposed);
if (granularityError != null)
return Result<TemplateAttribute>.Failure(granularityError);
// Apply overridable fields. DataType / DataSourceReference are fixed and
// are deliberately not copied from the proposed attribute.
existing.Value = proposed.Value;
existing.Description = proposed.Description;
existing.IsLocked = proposed.IsLocked;
if (template?.IsDerived == true)
existing.IsInherited = proposed.IsInherited;
else
existing.LockedInDerived = proposed.LockedInDerived;
await _repository.UpdateTemplateAttributeAsync(existing, cancellationToken);
await _auditService.LogAsync(user, "Update", "TemplateAttribute", attributeId.ToString(), existing.Name, existing, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
return Result<TemplateAttribute>.Success(existing);
}
/// <summary>
/// Deletes an attribute definition from a template.
/// </summary>
/// <param name="attributeId">ID of the attribute to delete.</param>
/// <param name="user">Username of the user deleting the attribute.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Result indicating success or failure.</returns>
public async Task<Result<bool>> DeleteAttributeAsync(
int attributeId,
string user,
CancellationToken cancellationToken = default)
{
var attribute = await _repository.GetTemplateAttributeByIdAsync(attributeId, cancellationToken);
if (attribute == null)
return Result<bool>.Failure($"Attribute with ID {attributeId} not found.");
// Cannot remove inherited parent members — only direct members
var template = await _repository.GetTemplateByIdAsync(attribute.TemplateId, cancellationToken);
if (template?.ParentTemplateId != null)
{
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
var parentMember = TemplateResolver.FindMemberByCanonicalName(
attribute.Name, template.ParentTemplateId.Value, allTemplates);
if (parentMember != null)
return Result<bool>.Failure(
$"Cannot remove attribute '{attribute.Name}': it is inherited from parent template.");
}
await _repository.DeleteTemplateAttributeAsync(attributeId, cancellationToken);
await _auditService.LogAsync(user, "Delete", "TemplateAttribute", attributeId.ToString(), attribute.Name, null, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
return Result<bool>.Success(true);
}
// ========================================================================
// WP-3: Alarm Definitions
// ========================================================================
/// <summary>
/// Adds an alarm definition to a template.
/// </summary>
/// <param name="templateId">ID of the template.</param>
/// <param name="alarm">Alarm definition to add.</param>
/// <param name="user">Username of the user adding the alarm.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Result containing the added alarm or failure message.</returns>
public async Task<Result<TemplateAlarm>> AddAlarmAsync(
int templateId,
TemplateAlarm alarm,
string user,
CancellationToken cancellationToken = default)
{
var template = await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
if (template == null)
return Result<TemplateAlarm>.Failure($"Template with ID {templateId} not found.");
if (template.Alarms.Any(a => a.Name == alarm.Name))
return Result<TemplateAlarm>.Failure(
$"Alarm '{alarm.Name}' already exists on template '{template.Name}'.");
// Validate priority range
if (alarm.PriorityLevel < 0 || alarm.PriorityLevel > 1000)
return Result<TemplateAlarm>.Failure("Alarm priority must be between 0 and 1000.");
alarm.TemplateId = templateId;
// Check collisions
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
var testTemplate = CloneTemplateWithNewAlarm(template, alarm);
var collisions = CollisionDetector.DetectCollisions(testTemplate, allTemplates);
if (collisions.Count > 0)
return Result<TemplateAlarm>.Failure(string.Join(" ", collisions));
// TemplateEngine-020: save-then-audit so the audit row carries the
// real alarm Id rather than a literal "0".
await _repository.AddTemplateAlarmAsync(alarm, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
await _auditService.LogAsync(user, "Create", "TemplateAlarm", alarm.Id.ToString(), alarm.Name, alarm, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
return Result<TemplateAlarm>.Success(alarm);
}
/// <summary>
/// Updates an alarm definition with lock and inheritance validation.
/// </summary>
/// <param name="alarmId">ID of the alarm to update.</param>
/// <param name="proposed">New alarm definition values.</param>
/// <param name="user">Username of the user updating the alarm.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Result containing the updated alarm or failure message.</returns>
public async Task<Result<TemplateAlarm>> UpdateAlarmAsync(
int alarmId,
TemplateAlarm proposed,
string user,
CancellationToken cancellationToken = default)
{
var existing = await _repository.GetTemplateAlarmByIdAsync(alarmId, cancellationToken);
if (existing == null)
return Result<TemplateAlarm>.Failure($"Alarm with ID {alarmId} not found.");
// Validate priority range
if (proposed.PriorityLevel < 0 || proposed.PriorityLevel > 1000)
return Result<TemplateAlarm>.Failure("Alarm priority must be between 0 and 1000.");
// Validate lock change
var lockError = LockEnforcer.ValidateLockChange(existing.IsLocked, proposed.IsLocked, existing.Name);
if (lockError != null)
return Result<TemplateAlarm>.Failure(lockError);
// Check parent lock
var template = await _repository.GetTemplateByIdAsync(existing.TemplateId, cancellationToken);
if (template?.ParentTemplateId != null)
{
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
var parentMember = TemplateResolver.FindMemberByCanonicalName(
existing.Name, template.ParentTemplateId.Value, allTemplates);
if (parentMember != null && parentMember.IsLocked)
return Result<TemplateAlarm>.Failure(
$"Alarm '{existing.Name}' is locked in parent and cannot be overridden.");
// Derived templates may not override alarms the base marked LockedInDerived.
if (template.IsDerived)
{
var baseTemplate = await _repository.GetTemplateByIdAsync(template.ParentTemplateId.Value, cancellationToken);
var baseAlarm = baseTemplate?.Alarms.FirstOrDefault(a => a.Name == existing.Name);
if (baseAlarm != null && baseAlarm.LockedInDerived)
return Result<TemplateAlarm>.Failure(
$"Alarm '{existing.Name}' is locked by base template '{baseTemplate!.Name}' and cannot be overridden.");
}
}
// One-way LockedInDerived ratchet on base templates (TemplateEngine-022).
if (template?.IsDerived != true)
{
var lockedInDerivedError = LockEnforcer.ValidateLockedInDerivedChange(
existing.LockedInDerived, proposed.LockedInDerived, existing.Name);
if (lockedInDerivedError != null)
return Result<TemplateAlarm>.Failure(lockedInDerivedError);
}
// Validate fixed fields
var overrideError = LockEnforcer.ValidateAlarmOverride(existing, proposed);
if (overrideError != null)
return Result<TemplateAlarm>.Failure(overrideError);
// Apply overridable fields
existing.PriorityLevel = proposed.PriorityLevel;
existing.TriggerConfiguration = proposed.TriggerConfiguration;
existing.Description = proposed.Description;
existing.OnTriggerScriptId = proposed.OnTriggerScriptId;
existing.IsLocked = proposed.IsLocked;
if (template?.IsDerived == true)
existing.IsInherited = proposed.IsInherited;
else
existing.LockedInDerived = proposed.LockedInDerived;
// Name and TriggerType are NOT updated (fixed)
await _repository.UpdateTemplateAlarmAsync(existing, cancellationToken);
await _auditService.LogAsync(user, "Update", "TemplateAlarm", alarmId.ToString(), existing.Name, existing, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
return Result<TemplateAlarm>.Success(existing);
}
/// <summary>
/// Deletes an alarm definition from a template.
/// </summary>
/// <param name="alarmId">ID of the alarm to delete.</param>
/// <param name="user">Username of the user deleting the alarm.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Result indicating success or failure.</returns>
public async Task<Result<bool>> DeleteAlarmAsync(
int alarmId,
string user,
CancellationToken cancellationToken = default)
{
var alarm = await _repository.GetTemplateAlarmByIdAsync(alarmId, cancellationToken);
if (alarm == null)
return Result<bool>.Failure($"Alarm with ID {alarmId} not found.");
var template = await _repository.GetTemplateByIdAsync(alarm.TemplateId, cancellationToken);
if (template?.ParentTemplateId != null)
{
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
var parentMember = TemplateResolver.FindMemberByCanonicalName(
alarm.Name, template.ParentTemplateId.Value, allTemplates);
if (parentMember != null)
return Result<bool>.Failure(
$"Cannot remove alarm '{alarm.Name}': it is inherited from parent template.");
}
await _repository.DeleteTemplateAlarmAsync(alarmId, cancellationToken);
await _auditService.LogAsync(user, "Delete", "TemplateAlarm", alarmId.ToString(), alarm.Name, null, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
return Result<bool>.Success(true);
}
// ========================================================================
// WP-4: Script Definitions
// ========================================================================
/// <summary>
/// Adds a script definition to a template.
/// </summary>
/// <param name="templateId">ID of the template.</param>
/// <param name="script">Script definition to add.</param>
/// <param name="user">Username of the user adding the script.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Result containing the added script or failure message.</returns>
public async Task<Result<TemplateScript>> AddScriptAsync(
int templateId,
TemplateScript script,
string user,
CancellationToken cancellationToken = default)
{
var template = await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
if (template == null)
return Result<TemplateScript>.Failure($"Template with ID {templateId} not found.");
if (template.Scripts.Any(s => s.Name == script.Name))
return Result<TemplateScript>.Failure(
$"Script '{script.Name}' already exists on template '{template.Name}'.");
script.TemplateId = templateId;
// Check collisions
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
var testTemplate = CloneTemplateWithNewScript(template, script);
var collisions = CollisionDetector.DetectCollisions(testTemplate, allTemplates);
if (collisions.Count > 0)
return Result<TemplateScript>.Failure(string.Join(" ", collisions));
// TemplateEngine-020: save-then-audit so the audit row carries the
// real script Id rather than a literal "0".
await _repository.AddTemplateScriptAsync(script, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
await _auditService.LogAsync(user, "Create", "TemplateScript", script.Id.ToString(), script.Name, script, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
return Result<TemplateScript>.Success(script);
}
/// <summary>
/// Updates a script definition with lock and inheritance validation.
/// </summary>
/// <param name="scriptId">ID of the script to update.</param>
/// <param name="proposed">New script definition values.</param>
/// <param name="user">Username of the user updating the script.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Result containing the updated script or failure message.</returns>
public async Task<Result<TemplateScript>> UpdateScriptAsync(
int scriptId,
TemplateScript proposed,
string user,
CancellationToken cancellationToken = default)
{
var existing = await _repository.GetTemplateScriptByIdAsync(scriptId, cancellationToken);
if (existing == null)
return Result<TemplateScript>.Failure($"Script with ID {scriptId} not found.");
// Validate lock change
var lockError = LockEnforcer.ValidateLockChange(existing.IsLocked, proposed.IsLocked, existing.Name);
if (lockError != null)
return Result<TemplateScript>.Failure(lockError);
// Check parent lock; the LockedInDerived ratchet is enforced after we
// know whether the owning template is derived.
var template = await _repository.GetTemplateByIdAsync(existing.TemplateId, cancellationToken);
if (template?.ParentTemplateId != null)
{
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
var parentMember = TemplateResolver.FindMemberByCanonicalName(
existing.Name, template.ParentTemplateId.Value, allTemplates);
if (parentMember != null && parentMember.IsLocked)
return Result<TemplateScript>.Failure(
$"Script '{existing.Name}' is locked in parent and cannot be overridden.");
// Derived templates may not override scripts the base marked LockedInDerived.
if (template.IsDerived)
{
var baseTemplate = await _repository.GetTemplateByIdAsync(template.ParentTemplateId.Value, cancellationToken);
var baseScript = baseTemplate?.Scripts.FirstOrDefault(s => s.Name == existing.Name);
if (baseScript != null && baseScript.LockedInDerived)
return Result<TemplateScript>.Failure(
$"Script '{existing.Name}' is locked by base template '{baseTemplate!.Name}' and cannot be overridden.");
}
}
// One-way LockedInDerived ratchet on base templates (TemplateEngine-022).
if (template?.IsDerived != true)
{
var lockedInDerivedError = LockEnforcer.ValidateLockedInDerivedChange(
existing.LockedInDerived, proposed.LockedInDerived, existing.Name);
if (lockedInDerivedError != null)
return Result<TemplateScript>.Failure(lockedInDerivedError);
}
// Validate fixed fields
var overrideError = LockEnforcer.ValidateScriptOverride(existing, proposed);
if (overrideError != null)
return Result<TemplateScript>.Failure(overrideError);
// Apply overridable fields
existing.Code = proposed.Code;
existing.TriggerType = proposed.TriggerType;
existing.TriggerConfiguration = proposed.TriggerConfiguration;
existing.MinTimeBetweenRuns = proposed.MinTimeBetweenRuns;
existing.ParameterDefinitions = proposed.ParameterDefinitions;
existing.ReturnDefinition = proposed.ReturnDefinition;
existing.IsLocked = proposed.IsLocked;
if (template?.IsDerived == true)
existing.IsInherited = proposed.IsInherited;
else
existing.LockedInDerived = proposed.LockedInDerived;
// Name is NOT updated (fixed)
await _repository.UpdateTemplateScriptAsync(existing, cancellationToken);
await _auditService.LogAsync(user, "Update", "TemplateScript", scriptId.ToString(), existing.Name, existing, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
return Result<TemplateScript>.Success(existing);
}
/// <summary>
/// Deletes a script definition from a template.
/// </summary>
/// <param name="scriptId">ID of the script to delete.</param>
/// <param name="user">Username of the user deleting the script.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Result indicating success or failure.</returns>
public async Task<Result<bool>> DeleteScriptAsync(
int scriptId,
string user,
CancellationToken cancellationToken = default)
{
var script = await _repository.GetTemplateScriptByIdAsync(scriptId, cancellationToken);
if (script == null)
return Result<bool>.Failure($"Script with ID {scriptId} not found.");
var template = await _repository.GetTemplateByIdAsync(script.TemplateId, cancellationToken);
if (template?.ParentTemplateId != null)
{
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
var parentMember = TemplateResolver.FindMemberByCanonicalName(
script.Name, template.ParentTemplateId.Value, allTemplates);
if (parentMember != null)
return Result<bool>.Failure(
$"Cannot remove script '{script.Name}': it is inherited from parent template.");
}
await _repository.DeleteTemplateScriptAsync(scriptId, cancellationToken);
await _auditService.LogAsync(user, "Delete", "TemplateScript", scriptId.ToString(), script.Name, null, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
return Result<bool>.Success(true);
}
// ========================================================================
// WP-6: Composition with Recursive Nesting
// ========================================================================
/// <summary>
/// Adds a composition (template slot) to a template with acyclicity and collision validation.
/// </summary>
/// <param name="templateId">ID of the parent template.</param>
/// <param name="composedTemplateId">ID of the template to compose.</param>
/// <param name="instanceName">Name for the composition slot.</param>
/// <param name="user">Username of the user adding the composition.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Result containing the added composition or failure message.</returns>
public async Task<Result<TemplateComposition>> AddCompositionAsync(
int templateId,
int composedTemplateId,
string instanceName,
string user,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(instanceName))
return Result<TemplateComposition>.Failure("Instance name is required for composition.");
var template = await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
if (template == null)
return Result<TemplateComposition>.Failure($"Template with ID {templateId} not found.");
var baseTemplate = await _repository.GetTemplateByIdAsync(composedTemplateId, cancellationToken);
if (baseTemplate == null)
return Result<TemplateComposition>.Failure($"Composed template with ID {composedTemplateId} not found.");
// Only base templates can be composed; derived templates are slot-owned.
if (baseTemplate.IsDerived)
return Result<TemplateComposition>.Failure(
$"Cannot compose template '{baseTemplate.Name}': it is a derived template. Compose its base instead.");
// Check for duplicate instance name
if (template.Compositions.Any(c => c.InstanceName == instanceName))
return Result<TemplateComposition>.Failure(
$"Composition instance name '{instanceName}' already exists on template '{template.Name}'.");
// Acyclicity is checked against the base, not the to-be-created derived template —
// the derived inherits from the base, so a base→base cycle is the meaningful check.
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
var cycleError = CycleDetector.DetectCompositionCycle(templateId, composedTemplateId, allTemplates);
if (cycleError != null)
return Result<TemplateComposition>.Failure(cycleError);
var crossCycleError = CycleDetector.DetectCrossGraphCycle(templateId, null, composedTemplateId, allTemplates);
if (crossCycleError != null)
return Result<TemplateComposition>.Failure(crossCycleError);
var probeComposition = new TemplateComposition(instanceName)
{
TemplateId = templateId,
ComposedTemplateId = composedTemplateId
};
var testTemplate = CloneTemplateWithNewComposition(template, probeComposition);
var collisions = CollisionDetector.DetectCollisions(testTemplate, allTemplates);
if (collisions.Count > 0)
return Result<TemplateComposition>.Failure(string.Join(" ", collisions));
// No global name pre-check: derived templates store their contained
// (slot) name, which need only be unique within the owner — and that is
// already enforced above and by the (TemplateId, InstanceName) index.
// TemplateEngine-020: CreateCascadedCompositionAsync already saves the
// composition row, so composition.Id is populated by the time control
// returns here — the audit row therefore carries the real Id instead
// of the pre-fix literal "0".
var composition = await CreateCascadedCompositionAsync(template, baseTemplate, instanceName, user, cancellationToken);
await _auditService.LogAsync(user, "Create", "TemplateComposition", composition.Id.ToString(), instanceName, composition, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
return Result<TemplateComposition>.Success(composition);
}
/// <summary>
/// Creates a derived template under <paramref name="outerTemplate"/> that
/// wraps <paramref name="source"/>, then recursively cascades the source's
/// own compositions so the slot graph is replicated under the new derived.
/// Used both for the user-initiated top-level compose and the recursive
/// children — neither path re-validates (caller pre-flights).
/// </summary>
private async Task<TemplateComposition> CreateCascadedCompositionAsync(
Template outerTemplate,
Template source,
string instanceName,
string user,
CancellationToken cancellationToken)
{
// A derived template stores its contained name — the slot's instance
// name, unique within its owner. The qualified path is computed on read
// (see TemplateNaming.QualifiedName).
var derived = BuildDerivedTemplate(source, instanceName);
await _repository.AddTemplateAsync(derived, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
var composition = new TemplateComposition(instanceName)
{
TemplateId = outerTemplate.Id,
ComposedTemplateId = derived.Id
};
await _repository.AddTemplateCompositionAsync(composition, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
derived.OwnerCompositionId = composition.Id;
await _repository.UpdateTemplateAsync(derived, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
// Cascade — replicate each of the source's compositions onto the new
// derived. The child's ParentTemplateId points at the source-side
// child (so override chains stay intact across nesting).
foreach (var childComp in source.Compositions.ToList())
{
var childSource = await _repository.GetTemplateByIdAsync(childComp.ComposedTemplateId, cancellationToken);
if (childSource == null) continue;
await CreateCascadedCompositionAsync(derived, childSource, childComp.InstanceName, user, cancellationToken);
}
return composition;
}
/// <summary>
/// Renames a composition slot while preserving the composed template and nested slots.
/// </summary>
/// <param name="compositionId">ID of the composition to rename.</param>
/// <param name="newInstanceName">New slot name.</param>
/// <param name="user">Username of the user renaming the composition.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Result containing the updated composition or failure message.</returns>
public async Task<Result<TemplateComposition>> RenameCompositionAsync(
int compositionId,
string newInstanceName,
string user,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(newInstanceName))
return Result<TemplateComposition>.Failure("Slot name is required.");
var composition = await _repository.GetTemplateCompositionByIdAsync(compositionId, cancellationToken);
if (composition == null)
return Result<TemplateComposition>.Failure($"Composition with ID {compositionId} not found.");
if (composition.InstanceName == newInstanceName) return Result<TemplateComposition>.Success(composition);
var owner = await _repository.GetTemplateByIdAsync(composition.TemplateId, cancellationToken);
if (owner == null)
return Result<TemplateComposition>.Failure($"Owning template with ID {composition.TemplateId} not found.");
if (owner.Compositions.Any(c => c.Id != compositionId && c.InstanceName == newInstanceName))
return Result<TemplateComposition>.Failure(
$"Slot name '{newInstanceName}' already exists on '{owner.Name}'.");
// The derived template stores the slot's contained name, so renaming
// the slot renames just that one template. Nested derived templates
// keep their own contained names — an ancestor slot rename never
// touches them, and the qualified path is recomputed on read.
var derived = await _repository.GetTemplateByIdAsync(composition.ComposedTemplateId, cancellationToken);
if (derived != null && derived.IsDerived && derived.OwnerCompositionId == compositionId)
{
derived.Name = newInstanceName;
await _repository.UpdateTemplateAsync(derived, cancellationToken);
}
composition.InstanceName = newInstanceName;
await _repository.UpdateTemplateCompositionAsync(composition, cancellationToken);
await _auditService.LogAsync(user, "Update", "TemplateComposition", compositionId.ToString(), newInstanceName, composition, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
return Result<TemplateComposition>.Success(composition);
}
/// <summary>
/// Deletes a composition (template slot) and cascades deletion of slot-owned derived templates.
/// </summary>
/// <param name="compositionId">ID of the composition to delete.</param>
/// <param name="user">Username of the user deleting the composition.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Result indicating success or failure.</returns>
public async Task<Result<bool>> DeleteCompositionAsync(
int compositionId,
string user,
CancellationToken cancellationToken = default)
{
var composition = await _repository.GetTemplateCompositionByIdAsync(compositionId, cancellationToken);
if (composition == null)
return Result<bool>.Failure($"Composition with ID {compositionId} not found.");
// Identify the slot-owned derived template (post Phase-3 migration this is the
// typical case; pre-migration the composition may still point at a base).
var composedTemplate = await _repository.GetTemplateByIdAsync(composition.ComposedTemplateId, cancellationToken);
await _repository.DeleteTemplateCompositionAsync(compositionId, cancellationToken);
await _auditService.LogAsync(user, "Delete", "TemplateComposition", compositionId.ToString(), composition.InstanceName, null, cancellationToken);
if (composedTemplate != null && composedTemplate.IsDerived && composedTemplate.OwnerCompositionId == compositionId)
{
await CascadeDeleteDerivedAsync(composedTemplate, user, cancellationToken);
}
await _repository.SaveChangesAsync(cancellationToken);
return Result<bool>.Success(true);
}
/// <summary>
/// Recursively deletes a derived template along with the cascade of inner
/// derived templates the compose flow created. Each composition row on the
/// derived has its slot-owned child template removed first, then the row,
/// then the derived itself.
/// </summary>
private async Task CascadeDeleteDerivedAsync(Template derived, string user, CancellationToken cancellationToken)
{
foreach (var child in derived.Compositions.ToList())
{
var childDerived = await _repository.GetTemplateByIdAsync(child.ComposedTemplateId, cancellationToken);
await _repository.DeleteTemplateCompositionAsync(child.Id, cancellationToken);
await _auditService.LogAsync(user, "Delete", "TemplateComposition", child.Id.ToString(), child.InstanceName, null, cancellationToken);
if (childDerived != null && childDerived.IsDerived && childDerived.OwnerCompositionId == child.Id)
await CascadeDeleteDerivedAsync(childDerived, user, cancellationToken);
}
await _repository.DeleteTemplateAsync(derived.Id, cancellationToken);
await _auditService.LogAsync(user, "Delete", "Template", derived.Id.ToString(), derived.Name, null, cancellationToken);
}
private static Template BuildDerivedTemplate(Template baseTemplate, string derivedName)
{
var derived = new Template(derivedName)
{
Description = baseTemplate.Description,
ParentTemplateId = baseTemplate.Id,
IsDerived = true,
};
foreach (var attr in baseTemplate.Attributes)
{
derived.Attributes.Add(new TemplateAttribute(attr.Name)
{
Value = attr.Value,
DataType = attr.DataType,
IsLocked = attr.IsLocked,
Description = attr.Description,
DataSourceReference = attr.DataSourceReference,
IsInherited = true,
LockedInDerived = false,
});
}
foreach (var alarm in baseTemplate.Alarms)
{
derived.Alarms.Add(new TemplateAlarm(alarm.Name)
{
Description = alarm.Description,
PriorityLevel = alarm.PriorityLevel,
IsLocked = alarm.IsLocked,
TriggerType = alarm.TriggerType,
TriggerConfiguration = alarm.TriggerConfiguration,
OnTriggerScriptId = alarm.OnTriggerScriptId,
IsInherited = true,
LockedInDerived = false,
});
}
foreach (var script in baseTemplate.Scripts)
{
derived.Scripts.Add(new TemplateScript(script.Name, script.Code)
{
IsLocked = script.IsLocked,
TriggerType = script.TriggerType,
TriggerConfiguration = script.TriggerConfiguration,
ParameterDefinitions = script.ParameterDefinitions,
ReturnDefinition = script.ReturnDefinition,
MinTimeBetweenRuns = script.MinTimeBetweenRuns,
IsInherited = true,
LockedInDerived = false,
});
}
return derived;
}
// ========================================================================
// WP-7: Path-Qualified Canonical Naming (via TemplateResolver)
// ========================================================================
/// <summary>
/// Resolves all effective members for a template using canonical (path-qualified) names.
/// </summary>
/// <param name="templateId">ID of the template to resolve.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of resolved template members with canonical names.</returns>
public async Task<IReadOnlyList<TemplateResolver.ResolvedTemplateMember>> ResolveTemplateMembersAsync(
int templateId,
CancellationToken cancellationToken = default)
{
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
return TemplateResolver.ResolveAllMembers(templateId, allTemplates);
}
// ========================================================================
// WP-8/9/10/11: Override validation (integrated into Update* methods above)
// ========================================================================
/// <summary>
/// Validates whether overriding a member by canonical name is allowed.
/// Used for composition overrides (WP-11) and inheritance overrides (WP-10).
/// </summary>
/// <param name="templateId">ID of the template.</param>
/// <param name="canonicalName">Canonical name of the member to validate.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Result indicating whether override is allowed.</returns>
public async Task<Result<bool>> ValidateOverrideAsync(
int templateId,
string canonicalName,
CancellationToken cancellationToken = default)
{
var template = await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
if (template == null)
return Result<bool>.Failure($"Template with ID {templateId} not found.");
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
var members = TemplateResolver.ResolveAllMembers(templateId, allTemplates);
var member = members.FirstOrDefault(m => m.CanonicalName == canonicalName);
if (member == null)
return Result<bool>.Failure($"No member found with canonical name '{canonicalName}'.");
if (member.IsLocked)
return Result<bool>.Failure($"Member '{canonicalName}' is locked and cannot be overridden.");
return Result<bool>.Success(true);
}
// ========================================================================
// WP-12: Naming Collision Detection
// ========================================================================
/// <summary>
/// Checks a template for naming collisions across all its members.
/// </summary>
/// <param name="templateId">ID of the template to check.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of collision error messages, or empty if none found.</returns>
public async Task<IReadOnlyList<string>> DetectCollisionsAsync(
int templateId,
CancellationToken cancellationToken = default)
{
var template = await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
if (template == null)
return new[] { $"Template with ID {templateId} not found." };
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
return CollisionDetector.DetectCollisions(template, allTemplates);
}
// ========================================================================
// WP-13: Graph Acyclicity
// ========================================================================
/// <summary>
/// Validates that a proposed inheritance or composition change does not create a cycle.
/// </summary>
/// <param name="templateId">ID of the template being modified.</param>
/// <param name="proposedParentId">Proposed parent template ID for inheritance.</param>
/// <param name="proposedComposedTemplateId">Proposed composed template ID for composition.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Result indicating whether the change would create a cycle.</returns>
public async Task<Result<bool>> ValidateAcyclicityAsync(
int templateId,
int? proposedParentId,
int? proposedComposedTemplateId,
CancellationToken cancellationToken = default)
{
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
if (proposedParentId.HasValue)
{
var inheritanceCycle = CycleDetector.DetectInheritanceCycle(templateId, proposedParentId.Value, allTemplates);
if (inheritanceCycle != null)
return Result<bool>.Failure(inheritanceCycle);
}
if (proposedComposedTemplateId.HasValue)
{
var compositionCycle = CycleDetector.DetectCompositionCycle(templateId, proposedComposedTemplateId.Value, allTemplates);
if (compositionCycle != null)
return Result<bool>.Failure(compositionCycle);
}
var crossCycle = CycleDetector.DetectCrossGraphCycle(templateId, proposedParentId, proposedComposedTemplateId, allTemplates);
if (crossCycle != null)
return Result<bool>.Failure(crossCycle);
return Result<bool>.Success(true);
}
// ========================================================================
// Helper methods
// ========================================================================
private async Task<string?> ValidateCollisionsAsync(Template template, CancellationToken cancellationToken)
{
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
var collisions = CollisionDetector.DetectCollisions(template, allTemplates);
if (collisions.Count > 0)
return string.Join(" ", collisions);
return null;
}
private static Template CloneTemplateWithNewAttribute(Template original, TemplateAttribute newAttr)
{
var clone = new Template(original.Name)
{
Id = original.Id,
ParentTemplateId = original.ParentTemplateId,
Description = original.Description,
};
foreach (var a in original.Attributes) clone.Attributes.Add(a);
clone.Attributes.Add(newAttr);
foreach (var a in original.Alarms) clone.Alarms.Add(a);
foreach (var s in original.Scripts) clone.Scripts.Add(s);
foreach (var c in original.Compositions) clone.Compositions.Add(c);
return clone;
}
private static Template CloneTemplateWithNewAlarm(Template original, TemplateAlarm newAlarm)
{
var clone = new Template(original.Name)
{
Id = original.Id,
ParentTemplateId = original.ParentTemplateId,
Description = original.Description,
};
foreach (var a in original.Attributes) clone.Attributes.Add(a);
foreach (var a in original.Alarms) clone.Alarms.Add(a);
clone.Alarms.Add(newAlarm);
foreach (var s in original.Scripts) clone.Scripts.Add(s);
foreach (var c in original.Compositions) clone.Compositions.Add(c);
return clone;
}
private static Template CloneTemplateWithNewScript(Template original, TemplateScript newScript)
{
var clone = new Template(original.Name)
{
Id = original.Id,
ParentTemplateId = original.ParentTemplateId,
Description = original.Description,
};
foreach (var a in original.Attributes) clone.Attributes.Add(a);
foreach (var a in original.Alarms) clone.Alarms.Add(a);
foreach (var s in original.Scripts) clone.Scripts.Add(s);
clone.Scripts.Add(newScript);
foreach (var c in original.Compositions) clone.Compositions.Add(c);
return clone;
}
private static Template CloneTemplateWithNewComposition(Template original, TemplateComposition newComp)
{
var clone = new Template(original.Name)
{
Id = original.Id,
ParentTemplateId = original.ParentTemplateId,
Description = original.Description,
};
foreach (var a in original.Attributes) clone.Attributes.Add(a);
foreach (var a in original.Alarms) clone.Alarms.Add(a);
foreach (var s in original.Scripts) clone.Scripts.Add(s);
foreach (var c in original.Compositions) clone.Compositions.Add(c);
clone.Compositions.Add(newComp);
return clone;
}
}