Resolve all 33 medium-severity architecture review findings

Second slice of the 2026-09-09 whole-system review remediation
(docs/plans/2026-09-09-arch-review.md §10), on top of the High slice 146a1323.

Runtime/devices: SR-2 alarm on-trigger integration scope; SR-3 correlated NativeAlarmActor
subscribe retry; SR-4 immutable audited DB target; SR-5 ordered InstanceActor stop/flush;
SR-12 rehydration fence; SR-13 S&F gate recheck per delivery; DC-4 no OPC UA security
downgrade; DC-5 alarm snapshot for every new subscriber; ORCH-1 sync-dispose joins the pump.

Management/UI: MG-2 site scope on audit tree + Flow Debugger Replay; MG-3 stale-probe memo
removed; MG-4 server-owned bulk-deploy deadline; UI-5 sandbox runs in a killable child
process (new CentralUI.SandboxWorker project); UI-14 bounded sandbox output/IPC.

Edge/audit: EI-1 relay audit ownership; EI-2 SkipBodyCapture suppresses error text; EI-3/EI-11
error-field redaction + caps; EI-4 single relay deadline, pre-execution-only retry; EI-5
authenticated relay provenance; EI-10 body cap before audit read.

Persistence/contracts: DL-1 global Instance.UniqueName unique index + conflict translation;
DL-2 DataConnection protocol config encrypted at rest (migration
20260909203046_EnforceGlobalInstanceNamesAndProtectProtocolConfiguration — coordinated
central upgrade, old binaries cannot read protected rows); DL-3 bounded Replay paging;
DL-6 cross-site Overwrite of a deployed instance rejected; XC-4 proto descriptor contract test.

Config/hygiene: XC-1 central listener isolation by local port; XC-2/XC-3 inert SMTP + cron
keys removed from rig settings; CM-2 env2 bootstrap guard enabled; DC-6 deploy site exit code;
DC-7 notifier retries only explicit standby rejection; XC-5 ClusterClient purged from docs.

Gates recorded in the report: build 0/0, 9 397 passed / 0 failed, rig redeployed on all
eight nodes with the migration applied. 46 Low findings remain open.
This commit is contained in:
Joseph Doherty
2026-09-09 22:05:30 -04:00
parent 146a13238f
commit 9a87d7a328
136 changed files with 5441 additions and 648 deletions
@@ -224,17 +224,37 @@ public class ScadaBridgeDbContext : DbContext, IDataProtectionKeyContext
public override int SaveChanges(bool acceptAllChangesOnSuccess)
{
GuardSecretWritesHaveAKeyRing();
return base.SaveChanges(acceptAllChangesOnSuccess);
try { return base.SaveChanges(acceptAllChangesOnSuccess); }
catch (DbUpdateException ex) when (IsInstanceNameConflict(ex))
{ throw DetachInstanceNameConflict(ex); }
}
/// <inheritdoc />
public override Task<int> SaveChangesAsync(
public override async Task<int> SaveChangesAsync(
bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default)
{
GuardSecretWritesHaveAKeyRing();
return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
try { return await base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken); }
catch (DbUpdateException ex) when (IsInstanceNameConflict(ex))
{ throw DetachInstanceNameConflict(ex); }
}
private static InstanceNameConflictException DetachInstanceNameConflict(DbUpdateException error)
{
foreach (var entry in error.Entries) entry.State = EntityState.Detached;
return new InstanceNameConflictException(error);
}
private static bool IsInstanceNameConflict(DbUpdateException error)
=> error.InnerException is Microsoft.Data.SqlClient.SqlException sql
? sql.Number is 2601 or 2627 && sql.Message.Contains("IX_Instances_UniqueName", StringComparison.Ordinal)
: error.InnerException?.GetType().Name == "SqliteException"
&& error.InnerException.Message.Contains("UNIQUE constraint failed: Instances.UniqueName", StringComparison.Ordinal);
internal IDataProtector DataConnectionProtector => (_dataProtectionProvider
?? throw new InvalidOperationException("Data Protection is required to migrate protocol credentials."))
.CreateProtector(DataConnectionSecretConverter.Purpose);
/// <summary>True when this context was constructed with a real Data Protection provider.</summary>
internal bool HasSecretEncryptionProvider => _dataProtectionProvider is not null;
@@ -262,6 +282,8 @@ public class ScadaBridgeDbContext : DbContext, IDataProtectionKeyContext
SmsConfiguration => nameof(SmsConfiguration.AuthToken),
ExternalSystemDefinition => nameof(ExternalSystemDefinition.AuthConfiguration),
DatabaseConnectionDefinition => nameof(DatabaseConnectionDefinition.ConnectionString),
DataConnection connection when connection.PrimaryConfiguration is not null => nameof(DataConnection.PrimaryConfiguration),
DataConnection => nameof(DataConnection.BackupConfiguration),
_ => null
};
if (secretProperty is null)
@@ -280,14 +302,13 @@ public class ScadaBridgeDbContext : DbContext, IDataProtectionKeyContext
}
/// <summary>
/// Model cache key factory that folds <see cref="HasSecretEncryptionProvider"/> into the
/// cache key, so a write-capable (provider-bearing) context and a schema-only context do
/// not share a cached model.
/// Model cache key factory that folds the Data Protection provider identity into the
/// cache key, so different key rings and schema-only contexts never share converter instances.
/// </summary>
private sealed class SecretAwareModelCacheKeyFactory : IModelCacheKeyFactory
{
/// <summary>
/// Creates a model cache key that includes the Data Protection provider state.
/// Creates a model cache key that includes the Data Protection provider identity.
/// </summary>
/// <param name="context">The database context.</param>
/// <param name="designTime">Whether the model is being created at design-time.</param>
@@ -295,7 +316,7 @@ public class ScadaBridgeDbContext : DbContext, IDataProtectionKeyContext
public object Create(DbContext context, bool designTime)
=> (context.GetType(),
designTime,
(context as ScadaBridgeDbContext)?.HasSecretEncryptionProvider ?? false);
(context as ScadaBridgeDbContext)?._dataProtectionProvider);
/// <summary>
/// Creates a model cache key for run-time contexts.
@@ -350,6 +371,13 @@ public class ScadaBridgeDbContext : DbContext, IDataProtectionKeyContext
modelBuilder.Entity<DatabaseConnectionDefinition>()
.Property(d => d.ConnectionString)
.HasConversion(converter);
IDataProtector protocolProtector = _dataProtectionProvider is { } protocolProvider
? protocolProvider.CreateProtector(DataConnectionSecretConverter.Purpose)
: SchemaOnlyDataProtector.Instance;
var protocolConverter = new DataConnectionSecretConverter(protocolProtector);
modelBuilder.Entity<DataConnection>().Property(d => d.PrimaryConfiguration).HasConversion(protocolConverter);
modelBuilder.Entity<DataConnection>().Property(d => d.BackupConfiguration).HasConversion(protocolConverter);
}
/// <summary>