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
@@ -86,6 +86,7 @@ public class NativeAlarmActor : ReceiveActor
/// a logging failure never affects the mirror.</param>
/// <param name="persistFlushInterval">How long native-alarm transitions coalesce before a
/// batched SQLite persist flush (P4). Defaults to 100 ms.</param>
/// <param name="rehydrationRead">Optional prestarted persisted-state read; null reads from storage.</param>
public NativeAlarmActor(
ResolvedNativeAlarmSource source,
string instanceName,
@@ -96,8 +97,10 @@ public class NativeAlarmActor : ReceiveActor
ILogger logger,
AlarmKind nativeKind = AlarmKind.NativeOpcUa,
IServiceProvider? serviceProvider = null,
TimeSpan? persistFlushInterval = null)
TimeSpan? persistFlushInterval = null,
Task<IReadOnlyList<NativeAlarmRow>>? rehydrationRead = null)
{
_rehydrationRead = rehydrationRead;
_source = source;
_instanceName = instanceName;
_instanceActor = instanceActor;
@@ -124,7 +127,7 @@ public class NativeAlarmActor : ReceiveActor
// Rehydrate last-known state from SQLite (non-blocking), then subscribe.
// A fresh source snapshot will reconcile/replace this once it arrives.
_storage.GetNativeAlarmsAsync(_instanceName, _source.CanonicalName)
(_rehydrationRead ?? _storage.GetNativeAlarmsAsync(_instanceName, _source.CanonicalName))
.PipeTo(Self, Self, rows => new RehydrationCompleted(rows));
SendSubscribe();
@@ -149,32 +152,48 @@ public class NativeAlarmActor : ReceiveActor
base.PostStop();
}
private readonly Task<IReadOnlyList<NativeAlarmRow>>? _rehydrationRead;
private string? _subscribeCorrelation;
private DateTimeOffset _subscribeDeadline;
private int _subscribeDelayMs;
private bool _subscribeSucceeded;
private void SendSubscribe()
{
_dclManager.Tell(new SubscribeAlarmsRequest(
CorrelationId: Guid.NewGuid().ToString("N"),
InstanceUniqueName: _instanceName,
ConnectionName: _source.ConnectionName,
SourceReference: _source.SourceReference,
ConditionFilter: _source.ConditionFilter,
Timestamp: DateTimeOffset.UtcNow));
// Arm a response-timeout retry so a LOST subscribe response is re-sent, not
// only a failure reply (S4). Cancelled on a Success reply; a failure reply
// re-arms it at 1× (overriding this 2× response-timeout arm). 2× interval so
// a slow-but-arriving response is not pre-empted by a redundant re-send.
if (_subscribeSucceeded) return;
if (_subscribeCorrelation is not null && DateTimeOffset.UtcNow < _subscribeDeadline)
{
_retryTimer?.Cancel();
_retryTimer = Context.System.Scheduler.ScheduleTellOnceCancelable(
TimeSpan.FromMilliseconds(Math.Max(1, (_subscribeDeadline - DateTimeOffset.UtcNow).TotalMilliseconds)),
Self, RetrySubscribe.Instance, Self);
return;
}
_subscribeDelayMs = _subscribeDelayMs == 0
? Math.Clamp(_options.NativeAlarmRetryIntervalMs, 1, 60000) : Math.Min(60000, _subscribeDelayMs * 2);
_subscribeCorrelation = Guid.NewGuid().ToString("N");
var responseTimeout = TimeSpan.FromMilliseconds(Math.Min(120000, _subscribeDelayMs * 2));
_subscribeDeadline = DateTimeOffset.UtcNow + responseTimeout;
_dclManager.Tell(new SubscribeAlarmsRequest(_subscribeCorrelation, _instanceName,
_source.ConnectionName, _source.SourceReference, _source.ConditionFilter, DateTimeOffset.UtcNow));
_retryTimer?.Cancel();
_retryTimer = Context.System.Scheduler.ScheduleTellOnceCancelable(
TimeSpan.FromMilliseconds(_options.NativeAlarmRetryIntervalMs * 2),
_retryTimer = Context.System.Scheduler.ScheduleTellOnceCancelable(responseTimeout,
Self, RetrySubscribe.Instance, Self);
}
private readonly HashSet<string> _liveTouched = new();
private bool _authoritativeSnapshotReceived;
private bool _rehydrationCompleted;
private void HandleRehydration(RehydrationCompleted msg)
{
if (_authoritativeSnapshotReceived || _rehydrationCompleted) return;
_rehydrationCompleted = true;
foreach (var row in msg.Rows)
{
// A live transition that arrived before the read completed wins.
if (_alarms.ContainsKey(row.SourceReference))
if (_alarms.ContainsKey(row.SourceReference) || _liveTouched.Contains(row.SourceReference))
{
continue;
}
@@ -227,8 +246,12 @@ public class NativeAlarmActor : ReceiveActor
// event (it is not a live transition).
Emit(t, t.Condition, logSiteEvent: false);
}
_liveTouched.Clear();
}
private bool _snapshotInProgress;
private readonly Dictionary<string, NativeAlarmTransition> _liveDuringSnapshot = new();
private void HandleTransition(NativeAlarmTransitionUpdate update)
{
var t = update.Transition;
@@ -237,15 +260,18 @@ public class NativeAlarmActor : ReceiveActor
switch (t.Kind)
{
case AlarmTransitionKind.SnapshotStart:
if (t.SnapshotId is null || t.SnapshotId == _lastCompletedSnapshotId || t.SnapshotId == _snapshotId)
if (t.SnapshotId is not null && (t.SnapshotId == _lastCompletedSnapshotId || t.SnapshotId == _snapshotId))
return;
_snapshotId = t.SnapshotId;
_snapshotInProgress = true;
_snapshotBuffer.Clear();
_liveDuringSnapshot.Clear();
break;
case AlarmTransitionKind.Snapshot:
if (t.SnapshotId != _snapshotId)
return;
_snapshotInProgress = true;
_snapshotBuffer[t.SourceReference] = t;
break;
@@ -270,6 +296,16 @@ public class NativeAlarmActor : ReceiveActor
/// </summary>
private void ApplySnapshotSwap()
{
foreach (var (source, live) in _liveDuringSnapshot)
{
if (_snapshotBuffer.TryGetValue(source, out var snapshot) && snapshot.TransitionTime > live.TransitionTime) continue;
if (!live.Condition.Active && live.Condition.Acknowledged) _snapshotBuffer.Remove(source);
else _snapshotBuffer[source] = live;
}
_liveDuringSnapshot.Clear();
_snapshotInProgress = false;
_authoritativeSnapshotReceived = true;
_liveTouched.Clear();
foreach (var (sourceRef, prior) in _alarms)
{
if (!_snapshotBuffer.ContainsKey(sourceRef))
@@ -304,12 +340,18 @@ public class NativeAlarmActor : ReceiveActor
private void ApplyLiveTransition(NativeAlarmTransition t)
{
if (!_authoritativeSnapshotReceived && !_rehydrationCompleted) _liveTouched.Add(t.SourceReference);
// Retain timestamp ordering for a clear removed from the visible mirror
// while a refresh is still assembling.
if (_liveDuringSnapshot.TryGetValue(t.SourceReference, out var recent) && t.TransitionTime < recent.TransitionTime)
return;
// Ignore stale (out-of-order) transitions for a condition we already hold.
if (_alarms.TryGetValue(t.SourceReference, out var existing) && t.TransitionTime < existing.TransitionTime)
{
return;
}
if (_snapshotInProgress) _liveDuringSnapshot[t.SourceReference] = t;
_alarms[t.SourceReference] = t;
MarkDirtyUpsert(t);
Emit(t, t.Condition);
@@ -333,6 +375,8 @@ public class NativeAlarmActor : ReceiveActor
// An interrupted refresh is not a snapshot. Never merge its partial rows
// into the next connection's replay.
_snapshotBuffer.Clear();
_liveDuringSnapshot.Clear();
_snapshotInProgress = false;
_snapshotId = null;
// Keep last-known conditions (uncertain) rather than clearing them; the
// reconnect snapshot reconciles state. No emit — avoids a flap.
@@ -343,8 +387,12 @@ public class NativeAlarmActor : ReceiveActor
private void HandleSubscribeResponse(SubscribeAlarmsResponse resp)
{
if (resp.CorrelationId != _subscribeCorrelation) return;
_subscribeCorrelation = null;
if (resp.Success)
{
_subscribeSucceeded = true;
_subscribeDelayMs = 0;
// Success — cancel the response-timeout retry armed in SendSubscribe.
_retryTimer?.Cancel();
_logger.LogInformation("Native alarm subscription established for {Source} on {Instance}",
@@ -358,7 +406,7 @@ public class NativeAlarmActor : ReceiveActor
_retryTimer?.Cancel();
_retryTimer = Context.System.Scheduler.ScheduleTellOnceCancelable(
TimeSpan.FromMilliseconds(_options.NativeAlarmRetryIntervalMs),
TimeSpan.FromMilliseconds(_subscribeDelayMs),
Self, RetrySubscribe.Instance, Self);
}