fix(transport): transport LockedInDerived on template attributes/alarms/scripts (additive DTO fields)

Adds a trailing optional `bool LockedInDerived = false` to TemplateAttributeDto,
TemplateAlarmDto and TemplateScriptDto (mirrors the ExecutionTimeoutSeconds
additive precedent — no schemaVersion bump). Populated at export
(ToBundleContent), consumed on import (FromBundleContent, BuildTemplate, and all
three SyncTemplate*Async changed-predicate + copy + new-entity paths, incl. audit
payloads). Old-form bundles that lack the property still deserialize to false.

Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
Joseph Doherty
2026-07-09 16:46:33 -04:00
parent ea9c5709ac
commit bf17f60a04
5 changed files with 194 additions and 9 deletions
@@ -1549,6 +1549,7 @@ public sealed class BundleImporter : IBundleImporter
Description = a.Description,
DataSourceReference = a.DataSourceReference,
ElementDataType = a.ElementDataType,
LockedInDerived = a.LockedInDerived,
});
}
foreach (var al in dto.Alarms)
@@ -1560,6 +1561,7 @@ public sealed class BundleImporter : IBundleImporter
TriggerType = al.TriggerType,
TriggerConfiguration = al.TriggerConfiguration,
IsLocked = al.IsLocked,
LockedInDerived = al.LockedInDerived,
});
}
foreach (var s in dto.Scripts)
@@ -1573,6 +1575,7 @@ public sealed class BundleImporter : IBundleImporter
IsLocked = s.IsLocked,
MinTimeBetweenRuns = s.MinTimeBetweenRuns,
ExecutionTimeoutSeconds = s.ExecutionTimeoutSeconds,
LockedInDerived = s.LockedInDerived,
});
}
return t;
@@ -1635,7 +1638,8 @@ public sealed class BundleImporter : IBundleImporter
current.IsLocked != attrDto.IsLocked ||
!string.Equals(current.Description, attrDto.Description, StringComparison.Ordinal) ||
!string.Equals(current.DataSourceReference, attrDto.DataSourceReference, StringComparison.Ordinal) ||
current.ElementDataType != attrDto.ElementDataType;
current.ElementDataType != attrDto.ElementDataType ||
current.LockedInDerived != attrDto.LockedInDerived;
if (!changed) continue;
current.Value = normalizedValue;
@@ -1644,6 +1648,7 @@ public sealed class BundleImporter : IBundleImporter
current.Description = attrDto.Description;
current.DataSourceReference = attrDto.DataSourceReference;
current.ElementDataType = attrDto.ElementDataType;
current.LockedInDerived = attrDto.LockedInDerived;
await _templateRepo.UpdateTemplateAttributeAsync(current, ct).ConfigureAwait(false);
await _auditService.LogAsync(
user,
@@ -1659,6 +1664,7 @@ public sealed class BundleImporter : IBundleImporter
current.DataType,
current.ElementDataType,
current.IsLocked,
current.LockedInDerived,
current.Description,
current.DataSourceReference,
},
@@ -1674,6 +1680,7 @@ public sealed class BundleImporter : IBundleImporter
Description = attrDto.Description,
DataSourceReference = attrDto.DataSourceReference,
ElementDataType = attrDto.ElementDataType,
LockedInDerived = attrDto.LockedInDerived,
};
ex.Attributes.Add(newAttr);
await _auditService.LogAsync(
@@ -1690,6 +1697,7 @@ public sealed class BundleImporter : IBundleImporter
newAttr.DataType,
newAttr.ElementDataType,
newAttr.IsLocked,
newAttr.LockedInDerived,
newAttr.Description,
newAttr.DataSourceReference,
},
@@ -1740,7 +1748,8 @@ public sealed class BundleImporter : IBundleImporter
current.PriorityLevel != alarmDto.PriorityLevel ||
current.TriggerType != alarmDto.TriggerType ||
!string.Equals(current.TriggerConfiguration, alarmDto.TriggerConfiguration, StringComparison.Ordinal) ||
current.IsLocked != alarmDto.IsLocked;
current.IsLocked != alarmDto.IsLocked ||
current.LockedInDerived != alarmDto.LockedInDerived;
if (!changed)
{
// Always reset the script FK on Overwrite so the post-flush
@@ -1762,6 +1771,7 @@ public sealed class BundleImporter : IBundleImporter
current.TriggerType = alarmDto.TriggerType;
current.TriggerConfiguration = alarmDto.TriggerConfiguration;
current.IsLocked = alarmDto.IsLocked;
current.LockedInDerived = alarmDto.LockedInDerived;
current.OnTriggerScriptId = null; // re-resolved post-flush.
await _templateRepo.UpdateTemplateAlarmAsync(current, ct).ConfigureAwait(false);
await _auditService.LogAsync(
@@ -1779,6 +1789,7 @@ public sealed class BundleImporter : IBundleImporter
current.TriggerType,
current.TriggerConfiguration,
current.IsLocked,
current.LockedInDerived,
OnTriggerScriptName = alarmDto.OnTriggerScriptName,
},
ct).ConfigureAwait(false);
@@ -1792,6 +1803,7 @@ public sealed class BundleImporter : IBundleImporter
TriggerType = alarmDto.TriggerType,
TriggerConfiguration = alarmDto.TriggerConfiguration,
IsLocked = alarmDto.IsLocked,
LockedInDerived = alarmDto.LockedInDerived,
};
ex.Alarms.Add(newAlarm);
await _auditService.LogAsync(
@@ -1809,6 +1821,7 @@ public sealed class BundleImporter : IBundleImporter
newAlarm.TriggerType,
newAlarm.TriggerConfiguration,
newAlarm.IsLocked,
newAlarm.LockedInDerived,
OnTriggerScriptName = alarmDto.OnTriggerScriptName,
},
ct).ConfigureAwait(false);
@@ -1858,7 +1871,8 @@ public sealed class BundleImporter : IBundleImporter
!string.Equals(current.ReturnDefinition, scriptDto.ReturnDefinition, StringComparison.Ordinal) ||
current.IsLocked != scriptDto.IsLocked ||
current.MinTimeBetweenRuns != scriptDto.MinTimeBetweenRuns ||
current.ExecutionTimeoutSeconds != scriptDto.ExecutionTimeoutSeconds;
current.ExecutionTimeoutSeconds != scriptDto.ExecutionTimeoutSeconds ||
current.LockedInDerived != scriptDto.LockedInDerived;
if (!changed) continue;
current.Code = scriptDto.Code;
@@ -1869,6 +1883,7 @@ public sealed class BundleImporter : IBundleImporter
current.IsLocked = scriptDto.IsLocked;
current.MinTimeBetweenRuns = scriptDto.MinTimeBetweenRuns;
current.ExecutionTimeoutSeconds = scriptDto.ExecutionTimeoutSeconds;
current.LockedInDerived = scriptDto.LockedInDerived;
await _templateRepo.UpdateTemplateScriptAsync(current, ct).ConfigureAwait(false);
await _auditService.LogAsync(
user,
@@ -1883,6 +1898,7 @@ public sealed class BundleImporter : IBundleImporter
current.TriggerType,
current.TriggerConfiguration,
current.IsLocked,
current.LockedInDerived,
},
ct).ConfigureAwait(false);
}
@@ -1897,6 +1913,7 @@ public sealed class BundleImporter : IBundleImporter
IsLocked = scriptDto.IsLocked,
MinTimeBetweenRuns = scriptDto.MinTimeBetweenRuns,
ExecutionTimeoutSeconds = scriptDto.ExecutionTimeoutSeconds,
LockedInDerived = scriptDto.LockedInDerived,
};
ex.Scripts.Add(newScript);
await _auditService.LogAsync(
@@ -1912,6 +1929,7 @@ public sealed class BundleImporter : IBundleImporter
newScript.TriggerType,
newScript.TriggerConfiguration,
newScript.IsLocked,
newScript.LockedInDerived,
},
ct).ConfigureAwait(false);
}
@@ -135,7 +135,13 @@ public sealed record TemplateAttributeDto(
bool IsLocked,
string? Description,
string? DataSourceReference,
DataType? ElementDataType = null);
DataType? ElementDataType = null,
// Whether this member is locked against override in derived templates.
// Additive trailing field; false on bundles written before it existed
// (dropped from JSON by WhenWritingNull? — no, bool is a value type, so it
// is always emitted; older bundles simply lack the property and deserialize
// to the default false).
bool LockedInDerived = false);
public sealed record TemplateAlarmDto(
string Name,
@@ -144,7 +150,9 @@ public sealed record TemplateAlarmDto(
AlarmTriggerType TriggerType,
string? TriggerConfiguration,
bool IsLocked,
string? OnTriggerScriptName);
string? OnTriggerScriptName,
// Derived-lock flag; additive trailing field (see TemplateAttributeDto).
bool LockedInDerived = false);
public sealed record TemplateScriptDto(
string Name,
@@ -157,7 +165,9 @@ public sealed record TemplateScriptDto(
TimeSpan? MinTimeBetweenRuns,
// Per-script execution timeout (seconds). Additive trailing field;
// null on bundles written before this field existed.
int? ExecutionTimeoutSeconds = null);
int? ExecutionTimeoutSeconds = null,
// Derived-lock flag; additive trailing field (see TemplateAttributeDto).
bool LockedInDerived = false);
public sealed record TemplateCompositionDto(
string InstanceName,
@@ -61,7 +61,8 @@ public sealed class EntitySerializer
IsLocked: a.IsLocked,
Description: a.Description,
DataSourceReference: a.DataSourceReference,
ElementDataType: a.ElementDataType)).ToList(),
ElementDataType: a.ElementDataType,
LockedInDerived: a.LockedInDerived)).ToList(),
Alarms: t.Alarms.Select(a => new TemplateAlarmDto(
Name: a.Name,
Description: a.Description,
@@ -75,7 +76,8 @@ public sealed class EntitySerializer
// resolve in this aggregate (e.g. corrupt/orphan row), the
// name comes through as null and the importer leaves the
// FK null on the imported alarm.
OnTriggerScriptName: a.OnTriggerScriptId is { } sid && scriptNameById.TryGetValue(sid, out var sn) ? sn : null)).ToList(),
OnTriggerScriptName: a.OnTriggerScriptId is { } sid && scriptNameById.TryGetValue(sid, out var sn) ? sn : null,
LockedInDerived: a.LockedInDerived)).ToList(),
Scripts: t.Scripts.Select(s => new TemplateScriptDto(
Name: s.Name,
Code: s.Code,
@@ -85,7 +87,8 @@ public sealed class EntitySerializer
ReturnDefinition: s.ReturnDefinition,
IsLocked: s.IsLocked,
MinTimeBetweenRuns: s.MinTimeBetweenRuns,
ExecutionTimeoutSeconds: s.ExecutionTimeoutSeconds)).ToList(),
ExecutionTimeoutSeconds: s.ExecutionTimeoutSeconds,
LockedInDerived: s.LockedInDerived)).ToList(),
Compositions: t.Compositions.Select(c => new TemplateCompositionDto(
InstanceName: c.InstanceName,
ComposedTemplateName: templateNameById.TryGetValue(c.ComposedTemplateId, out var cn) ? cn : string.Empty)).ToList());
@@ -318,6 +321,7 @@ public sealed class EntitySerializer
Description = a.Description,
DataSourceReference = a.DataSourceReference,
ElementDataType = a.ElementDataType,
LockedInDerived = a.LockedInDerived,
});
}
foreach (var al in dto.Alarms)
@@ -330,6 +334,7 @@ public sealed class EntitySerializer
TriggerType = al.TriggerType,
TriggerConfiguration = al.TriggerConfiguration,
IsLocked = al.IsLocked,
LockedInDerived = al.LockedInDerived,
});
}
foreach (var s in dto.Scripts)
@@ -344,6 +349,7 @@ public sealed class EntitySerializer
IsLocked = s.IsLocked,
MinTimeBetweenRuns = s.MinTimeBetweenRuns,
ExecutionTimeoutSeconds = s.ExecutionTimeoutSeconds,
LockedInDerived = s.LockedInDerived,
});
}
return t;