feat(instance): native-alarm-source-override CSV bulk import (deferred #12) + doc fixes

Closes the operator parity gap the deferred-work register tracked as #12: native
alarm source overrides could only be set one-at-a-time, while attribute overrides
had a CSV bulk path. Adds an all-or-nothing CSV import for native sources.

- Commons: extract the RFC-4180 line splitter into a shared CsvLineSplitter
  (refactor OverrideCsvParser onto it — no behavior change, pinned by its tests);
  new NativeAlarmSourceOverrideCsvParser (header SourceName,Connection,
  SourceReference,Filter; blank = inherited).
- Commons: NativeAlarmSourceOverrideEntry + bulk SetInstanceNativeAlarmSource-
  OverridesCommand (auto-registered via the reflection command registry).
- ManagementService: Deployer-gated handler — flattens once, validates every
  source resolves + is unlocked + no duplicates up front, then upserts the whole
  batch under a single SaveChanges (true all-or-nothing txn). Added to the frozen
  authorization matrix; dispatch-coverage guard passes.
- CLI: `instance native-alarm-source import --instance-id --file` (parity with
  `instance import-overrides`) + README.
- Tests: native parser (Commons), CLI parse/entry mapping, 3 bulk-handler tests
  (happy path single-commit, locked-source reject, unresolved-source reject).

Also corrects a stale XML-doc line in ScriptRuntimeContext (WaitForAttribute
quality-gated mode is shipped, not "planned") and updates the deferred-work
register: marks #7/#13/#15/#16/#20 verified-resolved, #12 as CLI/API-shipped with
only the Central UI upload affordance still pending.

Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
Joseph Doherty
2026-07-10 08:52:12 -04:00
parent 5a878b78d4
commit f480a56737
13 changed files with 748 additions and 106 deletions
@@ -270,6 +270,7 @@ public class ManagementActor : ReceiveActor
or SetConnectionBindingsCommand or SetInstanceOverridesCommand or SetInstanceAreaCommand
or SetInstanceAlarmOverrideCommand or DeleteInstanceAlarmOverrideCommand
or SetInstanceNativeAlarmSourceOverrideCommand or DeleteInstanceNativeAlarmSourceOverrideCommand
or SetInstanceNativeAlarmSourceOverridesCommand
or GetDeploymentDiffCommand
or MgmtDeployArtifactsCommand
or QueryDeploymentsCommand
@@ -347,6 +348,7 @@ public class ManagementActor : ReceiveActor
DeleteInstanceAlarmOverrideCommand cmd => await HandleDeleteInstanceAlarmOverride(sp, cmd, user),
ListInstanceAlarmOverridesCommand cmd => await HandleListInstanceAlarmOverrides(sp, cmd, user),
SetInstanceNativeAlarmSourceOverrideCommand cmd => await HandleSetInstanceNativeAlarmSourceOverride(sp, cmd, user),
SetInstanceNativeAlarmSourceOverridesCommand cmd => await HandleSetInstanceNativeAlarmSourceOverrides(sp, cmd, user),
DeleteInstanceNativeAlarmSourceOverrideCommand cmd => await HandleDeleteInstanceNativeAlarmSourceOverride(sp, cmd, user),
ListInstanceNativeAlarmSourceOverridesCommand cmd => await HandleListInstanceNativeAlarmSourceOverrides(sp, cmd, user),
@@ -998,6 +1000,78 @@ public class ManagementActor : ReceiveActor
return existing;
}
private static async Task<object?> HandleSetInstanceNativeAlarmSourceOverrides(
IServiceProvider sp, SetInstanceNativeAlarmSourceOverridesCommand cmd, AuthenticatedUser user)
{
await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId);
var repo = sp.GetRequiredService<ITemplateEngineRepository>();
// All-or-nothing (parity with HandleSetInstanceOverrides). Flatten the
// instance ONCE (script compilation skipped — a lock/resolve check does not
// need it) and validate EVERY requested source up front: a source that does
// not resolve would create a dead override the flattener silently drops, and a
// template-locked source cannot be overridden. Only once the whole batch is
// known valid do we upsert — a mid-batch reject never leaves partial state.
var pipeline = sp.GetRequiredService<IFlatteningPipeline>();
var flattenResult = await pipeline.FlattenAndValidateAsync(
cmd.InstanceId, default, validateScripts: false);
if (flattenResult.IsFailure)
throw new ManagementCommandException(
$"Cannot set native alarm source overrides: the instance could not be flattened ({flattenResult.Error}).");
var resolvedByName = flattenResult.Value.Configuration.NativeAlarmSources
.ToDictionary(s => s.CanonicalName, StringComparer.Ordinal);
// Reject a batch that names the same source twice — the intended override
// would be ambiguous, and the last-writer-wins upsert would silently discard
// the earlier row.
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var entry in cmd.Overrides)
{
if (!resolvedByName.TryGetValue(entry.SourceCanonicalName, out var resolvedSource))
throw new ManagementCommandException(
$"Native alarm source '{entry.SourceCanonicalName}' does not resolve for this instance " +
"and cannot be overridden. No overrides were applied.");
if (resolvedSource.IsLocked)
throw new ManagementCommandException(
$"Native alarm source '{entry.SourceCanonicalName}' is locked at the template level and " +
"cannot be overridden. No overrides were applied.");
if (!seen.Add(entry.SourceCanonicalName))
throw new ManagementCommandException(
$"Native alarm source '{entry.SourceCanonicalName}' appears more than once in the batch. " +
"No overrides were applied.");
}
var results = new List<InstanceNativeAlarmSourceOverride>();
foreach (var entry in cmd.Overrides)
{
var existing = await repo.GetNativeAlarmSourceOverrideAsync(cmd.InstanceId, entry.SourceCanonicalName);
if (existing == null)
{
var ovr = new InstanceNativeAlarmSourceOverride(entry.SourceCanonicalName)
{
InstanceId = cmd.InstanceId,
ConnectionNameOverride = entry.ConnectionNameOverride,
SourceReferenceOverride = entry.SourceReferenceOverride,
ConditionFilterOverride = entry.ConditionFilterOverride
};
await repo.AddInstanceNativeAlarmSourceOverrideAsync(ovr);
results.Add(ovr);
}
else
{
existing.ConnectionNameOverride = entry.ConnectionNameOverride;
existing.SourceReferenceOverride = entry.SourceReferenceOverride;
existing.ConditionFilterOverride = entry.ConditionFilterOverride;
await repo.UpdateInstanceNativeAlarmSourceOverrideAsync(existing);
results.Add(existing);
}
}
await repo.SaveChangesAsync();
return results;
}
private static async Task<object?> HandleDeleteInstanceNativeAlarmSourceOverride(
IServiceProvider sp, DeleteInstanceNativeAlarmSourceOverrideCommand cmd, AuthenticatedUser user)
{