fix(transport): warn on import of instance overrides targeting locked template members — inert rows no longer silent (plan R2-05 T7)

This commit is contained in:
Joseph Doherty
2026-07-13 10:04:43 -04:00
parent b02c15bbca
commit db623c1435
2 changed files with 273 additions and 2 deletions
@@ -684,8 +684,9 @@ public sealed class BundleImporter : IBundleImporter
// its new name, but at preview time no resolution has been chosen yet, so
// the in-bundle name is the original DTO name — which is what an instance
// references. (Explicit rename remap is a D-wave apply-time concern.)
var targetTemplates = await _templateRepo.GetAllTemplatesAsync(ct).ConfigureAwait(false);
var targetTemplateNames = new HashSet<string>(StringComparer.Ordinal);
foreach (var t in await _templateRepo.GetAllTemplatesAsync(ct).ConfigureAwait(false))
foreach (var t in targetTemplates)
{
targetTemplateNames.Add(t.Name);
}
@@ -883,6 +884,22 @@ public sealed class BundleImporter : IBundleImporter
}
}
// N4: advisory warning rows for instance overrides that target a LOCKED
// template member — the flattener drops them, so surface the inert row in the
// wizard (Kind: Warning, non-blocking). resolutionMap is null at preview time,
// so every instance is scanned.
foreach (var (instance, message) in CollectLockedOverrideWarnings(content, resolutionMap: null, targetTemplates))
{
blockers.Add(new ImportPreviewItem(
EntityType: "Instance",
Name: instance,
ExistingVersion: null,
IncomingVersion: null,
Kind: ConflictKind.Warning,
FieldDiffJson: null,
BlockerReason: message));
}
return blockers;
}
@@ -1075,6 +1092,85 @@ public sealed class BundleImporter : IBundleImporter
=> resolutionMap != null
&& ResolveOrDefault(resolutionMap, entityType, name).Action == ResolutionAction.Skip;
/// <summary>
/// Best-effort scan for instance overrides that target a LOCKED template member.
/// The flattener drops such overrides (an override on a locked attribute / alarm /
/// native-alarm-source never takes effect), so the bundle carries an inert row —
/// this surfaces an advisory warning so the operator learns the config won't apply,
/// WITHOUT blocking the import or changing what gets written (bundle fidelity keeps
/// the row; the flattener stays the behavioural authority).
/// <para>
/// Matches locked members by DIRECT name only — own + inherited placeholder rows
/// both carry the <c>IsLocked</c> flag, so a lock on either matches. Overrides
/// addressing composed path-qualified members (<c>y1.z.Val</c>) or derived-shadow
/// locks (<c>LockedInDerived</c> on a base the placeholder row doesn't reflect) may
/// not match a direct row — an unmatched name emits NO warning (never a false
/// positive; the ManagementActor and flattener remain the enforcement points). The
/// instance's template is resolved from the bundle DTO (the version about to be
/// written) first, else the pre-existing target template.
/// </para>
/// </summary>
private static IReadOnlyList<(string Instance, string Message)> CollectLockedOverrideWarnings(
BundleContentDto content,
Dictionary<(string, string), ImportResolution>? resolutionMap,
IReadOnlyList<Template> targetTemplates)
{
var warnings = new List<(string, string)>();
var bundleTemplates = new Dictionary<string, TemplateDto>(StringComparer.Ordinal);
foreach (var t in content.Templates) bundleTemplates[t.Name] = t;
var targetByName = new Dictionary<string, Template>(StringComparer.Ordinal);
foreach (var t in targetTemplates) targetByName[t.Name] = t;
foreach (var inst in content.Instances)
{
if (IsSkipResolution(resolutionMap, "Instance", inst.UniqueName)) continue;
if (string.IsNullOrEmpty(inst.TemplateName)) continue;
HashSet<string> lockedAttrs;
HashSet<string> lockedAlarms;
HashSet<string> lockedSources;
if (bundleTemplates.TryGetValue(inst.TemplateName, out var dto))
{
lockedAttrs = new(dto.Attributes.Where(a => a.IsLocked).Select(a => a.Name), StringComparer.Ordinal);
lockedAlarms = new(dto.Alarms.Where(a => a.IsLocked).Select(a => a.Name), StringComparer.Ordinal);
lockedSources = new(dto.NativeAlarmSources.Where(s => s.IsLocked).Select(s => s.Name), StringComparer.Ordinal);
}
else if (targetByName.TryGetValue(inst.TemplateName, out var tgt))
{
lockedAttrs = new(tgt.Attributes.Where(a => a.IsLocked).Select(a => a.Name), StringComparer.Ordinal);
lockedAlarms = new(tgt.Alarms.Where(a => a.IsLocked).Select(a => a.Name), StringComparer.Ordinal);
lockedSources = new(tgt.NativeAlarmSources.Where(s => s.IsLocked).Select(s => s.Name), StringComparer.Ordinal);
}
else
{
// Template unresolvable — a separate blocker/validation covers that.
continue;
}
foreach (var o in inst.AttributeOverrides)
{
if (lockedAttrs.Contains(o.AttributeName))
warnings.Add((inst.UniqueName, LockedOverrideMessage(inst.UniqueName, "attribute", o.AttributeName)));
}
foreach (var o in inst.AlarmOverrides)
{
if (lockedAlarms.Contains(o.AlarmCanonicalName))
warnings.Add((inst.UniqueName, LockedOverrideMessage(inst.UniqueName, "alarm", o.AlarmCanonicalName)));
}
foreach (var o in inst.NativeAlarmSourceOverrides)
{
if (lockedSources.Contains(o.SourceCanonicalName))
warnings.Add((inst.UniqueName, LockedOverrideMessage(inst.UniqueName, "native-alarm-source", o.SourceCanonicalName)));
}
}
return warnings;
}
private static string LockedOverrideMessage(string unique, string kind, string name) =>
$"Instance '{unique}' {kind} override '{name}' targets a LOCKED template member — " +
"the flattener ignores it, so this part of the bundle's instance config will never take effect.";
/// <summary>
/// Names that look like PascalCase references but are never user-defined
/// SharedScripts or ExternalSystems. Filters the false-positive noise the
@@ -1242,7 +1338,7 @@ public sealed class BundleImporter : IBundleImporter
if (validationWarnings.Count > 0)
{
_logger?.LogWarning(
"Bundle import {BundleImportId}: {Count} advisory template-script reference warning(s): {Warnings}",
"Bundle import {BundleImportId}: {Count} advisory import warning(s): {Warnings}",
bundleImportId, validationWarnings.Count, string.Join("; ", validationWarnings));
}
@@ -4375,6 +4471,15 @@ public sealed class BundleImporter : IBundleImporter
// (Pass 0 emits only errors), so nothing surfacable is dropped.
if (errors.Count > 0) return (errors, warnings);
// N4: advisory warnings for instance overrides that target a LOCKED template
// member. Best-effort, non-blocking (rides ImportResult.Warnings); the row is
// still written (bundle fidelity) — the flattener is the behavioural authority.
var lockScanTemplates = await _templateRepo.GetAllTemplatesAsync(ct).ConfigureAwait(false);
foreach (var (_, message) in CollectLockedOverrideWarnings(content, resolutionMap, lockScanTemplates))
{
warnings.Add(message);
}
// ---- Pass 1: minimal name-resolution scan ----
// Build the known-resolvable set. For in-bundle entries, EXCLUDE the