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
@@ -54,6 +54,17 @@ public class FlatteningService
try
{
// Step 0: Validate LockedInDerived isn't violated by any chain.
var lockError = ValidateLockedInDerived(templateChain);
if (lockError != null)
return Result<FlattenedConfiguration>.Failure(lockError);
foreach (var composedChain in composedTemplateChains.Values)
{
lockError = ValidateLockedInDerived(composedChain);
if (lockError != null)
return Result<FlattenedConfiguration>.Failure(lockError);
}
// Step 1: Resolve attributes from inheritance chain (most-derived-first wins for same name)
var attributes = ResolveInheritedAttributes(templateChain);
@@ -124,7 +135,10 @@ public class FlatteningService
{
var result = new Dictionary<string, ResolvedAttribute>(StringComparer.Ordinal);
// Walk from base (last) to most-derived (first) so derived values win
// Walk from base (last) to most-derived (first) so derived values win.
// IsInherited rows on a derived template are placeholders that should
// not shadow the live base value; they only contribute a row when the
// base lacks one.
for (int i = templateChain.Count - 1; i >= 0; i--)
{
var template = templateChain[i];
@@ -132,9 +146,13 @@ public class FlatteningService
foreach (var attr in template.Attributes)
{
// If a parent defined this attribute as locked, derived cannot change the value
if (result.TryGetValue(attr.Name, out var existing) && existing.IsLocked)
continue;
if (result.TryGetValue(attr.Name, out var existing))
{
if (existing.IsLocked)
continue;
if (attr.IsInherited)
continue;
}
result[attr.Name] = new ResolvedAttribute
{
@@ -152,6 +170,42 @@ public class FlatteningService
return result;
}
/// <summary>
/// Reports any LockedInDerived violations across the chain — i.e., a base
/// attribute/script marked LockedInDerived that a downstream derived
/// template overrides (IsInherited=false). Returns null on success or an
/// error message describing the first offending entries.
/// </summary>
private static string? ValidateLockedInDerived(IReadOnlyList<Template> templateChain)
{
var attrLocks = new Dictionary<string, Template>(StringComparer.Ordinal);
var scriptLocks = new Dictionary<string, Template>(StringComparer.Ordinal);
var errors = new List<string>();
for (int i = templateChain.Count - 1; i >= 0; i--)
{
var template = templateChain[i];
foreach (var attr in template.Attributes)
{
if (attr.LockedInDerived)
attrLocks[attr.Name] = template;
else if (!attr.IsInherited && attrLocks.TryGetValue(attr.Name, out var lockingTemplate) && lockingTemplate.Id != template.Id)
errors.Add($"Attribute '{attr.Name}' is LockedInDerived by base template '{lockingTemplate.Name}' and cannot be overridden by '{template.Name}'.");
}
foreach (var script in template.Scripts)
{
if (script.LockedInDerived)
scriptLocks[script.Name] = template;
else if (!script.IsInherited && scriptLocks.TryGetValue(script.Name, out var lockingTemplate) && lockingTemplate.Id != template.Id)
errors.Add($"Script '{script.Name}' is LockedInDerived by base template '{lockingTemplate.Name}' and cannot be overridden by '{template.Name}'.");
}
}
return errors.Count == 0 ? null : string.Join(" ", errors);
}
private static void ResolveComposedAttributes(
IReadOnlyList<Template> templateChain,
IReadOnlyDictionary<int, IReadOnlyList<TemplateComposition>> compositionMap,
@@ -343,8 +397,13 @@ public class FlatteningService
foreach (var script in template.Scripts)
{
if (result.TryGetValue(script.Name, out var existing) && existing.IsLocked)
continue;
if (result.TryGetValue(script.Name, out var existing))
{
if (existing.IsLocked)
continue;
if (script.IsInherited)
continue;
}
result[script.Name] = new ResolvedScript
{