feat(templates): phase 4+5 — inherit/override resolution + lock enforcement

FlatteningService now treats IsInherited rows as placeholders: when a
derived template carries an inherited attribute or script, the live base
value resolves through the ParentTemplateId chain instead of the
(possibly stale) copy. An IsInherited=false row is a real override and
wins as before.

ValidateLockedInDerived runs once per chain (main + composed) and returns
a flatten-time failure if a derived template overrides a base row that
the base marked LockedInDerived.

TemplateService.Update{Attribute,Script}Async reject mid-flight when a
derived target tries to override a LockedInDerived base member, and now
persist IsInherited/LockedInDerived from the proposed payload so the UI
can flip override state or set base-locks via the same endpoints.
This commit is contained in:
Joseph Doherty
2026-05-12 08:50:49 -04:00
parent 8b8b85c839
commit f599809486
4 changed files with 265 additions and 6 deletions

View File

@@ -264,6 +264,16 @@ public class TemplateService
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
@@ -282,6 +292,10 @@ public class TemplateService
existing.IsLocked = proposed.IsLocked;
existing.DataType = proposed.DataType;
existing.DataSourceReference = proposed.DataSourceReference;
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);
@@ -493,6 +507,16 @@ public class TemplateService
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.");
}
}
// Validate fixed fields
@@ -508,6 +532,10 @@ public class TemplateService
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);