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
@@ -943,10 +943,20 @@ VALUES
/// The two walkers must stay in step on window slack, depth cap and the DISTINCT-edges trick,
/// so there is exactly one copy of each.
/// </summary>
private static string ScopedAuditSource(IReadOnlyList<string>? sites) => sites is null
? "dbo.AuditLog" : "(SELECT * FROM dbo.AuditLog WHERE SourceSiteId IS NULL OR SourceSiteId IN (SELECT [value] FROM OPENJSON(@scopeSites)))";
private static void AddSiteScopeParameter(DbCommand command, IReadOnlyList<string>? sites)
{
if (sites is null) return;
var parameter = command.CreateParameter(); parameter.ParameterName = "@scopeSites";
parameter.Value = System.Text.Json.JsonSerializer.Serialize(sites); command.Parameters.Add(parameter);
}
private static async Task<ChainAnchor> ResolveChainAnchorAsync(
DbConnection conn,
Guid executionId,
CancellationToken ct)
CancellationToken ct, IReadOnlyList<string>? permittedSiteIds = null)
{
// --- Phase 1: walk up to the root ---------------------------------
// Climb ParentExecutionId until a node has no parent (root) or the
@@ -960,13 +970,15 @@ VALUES
await using (var upCmd = conn.CreateCommand())
{
upCmd.CommandText =
"SELECT TOP 1 ParentExecutionId FROM dbo.AuditLog " +
$"SELECT TOP 1 ParentExecutionId FROM {ScopedAuditSource(permittedSiteIds)} source " +
"WHERE ExecutionId = @cur AND ParentExecutionId IS NOT NULL;";
var pCur = upCmd.CreateParameter();
pCur.ParameterName = "@cur";
pCur.Value = rootExecutionId;
upCmd.Parameters.Add(pCur);
AddSiteScopeParameter(upCmd, permittedSiteIds);
var result = await upCmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
parent = result is null or DBNull ? null : (Guid)result;
}
@@ -990,12 +1002,13 @@ VALUES
await using (var winCmd = conn.CreateCommand())
{
winCmd.CommandText =
"SELECT MIN(OccurredAtUtc) FROM dbo.AuditLog WHERE ExecutionId = @root;";
$"SELECT MIN(OccurredAtUtc) FROM {ScopedAuditSource(permittedSiteIds)} source WHERE ExecutionId = @root;";
var pWinRoot = winCmd.CreateParameter();
pWinRoot.ParameterName = "@root";
pWinRoot.Value = rootExecutionId;
winCmd.Parameters.Add(pWinRoot);
AddSiteScopeParameter(winCmd, permittedSiteIds);
var winResult = await winCmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
rootFirstOccurred = winResult is null or DBNull ? null : (DateTime)winResult;
}
@@ -1026,10 +1039,10 @@ VALUES
/// ParentExecutionId of its child's edge. No separate
/// union-back CTE is needed.
/// </remarks>
private static string ChainCtePrefix(string edgesWindowPredicate) => $@"
private static string ChainCtePrefix(string edgesWindowPredicate, IReadOnlyList<string>? permittedSiteIds = null) => $@"
WITH Edges AS (
SELECT DISTINCT ExecutionId, ParentExecutionId
FROM dbo.AuditLog
FROM {ScopedAuditSource(permittedSiteIds)} scoped
WHERE ExecutionId IS NOT NULL
{edgesWindowPredicate}
),
@@ -1076,7 +1089,7 @@ VALUES
/// <inheritdoc />
public async Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId,
CancellationToken ct = default)
CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null)
{
var conn = _context.Database.GetDbConnection();
var openedHere = false;
@@ -1089,7 +1102,7 @@ VALUES
try
{
// --- Phase 1 + window: shared with GetTraceEventsAsync (arch-review F9) ---
var anchor = await ResolveChainAnchorAsync(conn, executionId, ct).ConfigureAwait(false);
var anchor = await ResolveChainAnchorAsync(conn, executionId, ct, permittedSiteIds).ConfigureAwait(false);
// --- Phase 2: walk down from the root via a recursive CTE ---------
// The Edges/Chain prefix is ChainCtePrefix (see its remarks for the
@@ -1103,7 +1116,7 @@ VALUES
var nodes = new List<ExecutionTreeNode>();
await using (var downCmd = conn.CreateCommand())
{
downCmd.CommandText = ChainCtePrefix(anchor.EdgesWindowPredicate) + $@",
downCmd.CommandText = ChainCtePrefix(anchor.EdgesWindowPredicate, permittedSiteIds) + $@",
ChainIds AS (
SELECT ExecutionId FROM Chain
UNION
@@ -1125,21 +1138,22 @@ VALUES
MIN(a.ParentExecutionId) AS [ParentExecutionId],
COUNT(a.EventId) AS [RowCount],
(SELECT STRING_AGG(d.Category, ',')
FROM (SELECT DISTINCT a2.Category FROM dbo.AuditLog a2
FROM (SELECT DISTINCT a2.Category FROM {ScopedAuditSource(permittedSiteIds)} a2
WHERE a2.ExecutionId = ids.ExecutionId) d) AS [Channels],
(SELECT STRING_AGG(d.Status, ',')
FROM (SELECT DISTINCT a2.Status FROM dbo.AuditLog a2
FROM (SELECT DISTINCT a2.Status FROM {ScopedAuditSource(permittedSiteIds)} a2
WHERE a2.ExecutionId = ids.ExecutionId) d) AS [Statuses],
MIN(a.SourceSiteId) AS [SourceSiteId],
MIN(JSON_VALUE(a.DetailsJson,'$.sourceInstanceId')) AS [SourceInstanceId],
MIN(a.OccurredAtUtc) AS [FirstOccurredAtUtc],
MAX(a.OccurredAtUtc) AS [LastOccurredAtUtc]
FROM ChainIds ids
LEFT JOIN dbo.AuditLog a ON a.ExecutionId = ids.ExecutionId
LEFT JOIN {ScopedAuditSource(permittedSiteIds)} a ON a.ExecutionId = ids.ExecutionId
GROUP BY ids.ExecutionId
OPTION (MAXRECURSION {ExecutionChainMaxDepth});";
AddChainParameters(downCmd, anchor);
AddSiteScopeParameter(downCmd, permittedSiteIds);
await using var reader = await downCmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
while (await reader.ReadAsync(ct).ConfigureAwait(false))
@@ -1181,7 +1195,7 @@ VALUES
/// <inheritdoc />
public async Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(
Guid executionId,
CancellationToken ct = default)
CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null)
{
// Flow Debugger Replay (design 2026-09-05 §7.1): same walk as GetExecutionTreeAsync
// — up to the root, then the Edges/Chain CTE down — but projecting the chain's
@@ -1197,24 +1211,28 @@ VALUES
}
var chainIds = new List<Guid>();
var rootId = executionId;
var chainTruncated = false;
try
{
var anchor = await ResolveChainAnchorAsync(conn, executionId, ct).ConfigureAwait(false);
var anchor = await ResolveChainAnchorAsync(conn, executionId, ct, permittedSiteIds).ConfigureAwait(false);
rootId = anchor.RootExecutionId;
await using (var idsCmd = conn.CreateCommand())
{
idsCmd.CommandText = ChainCtePrefix(anchor.EdgesWindowPredicate) + $@"
SELECT ExecutionId FROM Chain
idsCmd.CommandText = ChainCtePrefix(anchor.EdgesWindowPredicate, permittedSiteIds) + $@"
SELECT TOP (2049) ExecutionId FROM (SELECT ExecutionId FROM Chain
UNION
SELECT ParentExecutionId FROM Chain WHERE ParentExecutionId IS NOT NULL
SELECT ParentExecutionId FROM Chain WHERE ParentExecutionId IS NOT NULL) ids
OPTION (MAXRECURSION {ExecutionChainMaxDepth});";
AddChainParameters(idsCmd, anchor);
AddSiteScopeParameter(idsCmd, permittedSiteIds);
await using var reader = await idsCmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
while (await reader.ReadAsync(ct).ConfigureAwait(false))
{
if (!reader.IsDBNull(0)) chainIds.Add(reader.GetGuid(0));
if (!reader.IsDBNull(0)) { if (chainIds.Count == 2048) { chainTruncated = true; break; } chainIds.Add(reader.GetGuid(0)); }
}
}
}
@@ -1231,9 +1249,10 @@ VALUES
return Array.Empty<AuditEvent>();
}
var rows = await _context.Set<AuditLogRow>()
var query = _context.Set<AuditLogRow>()
.AsNoTracking()
.Where(r => r.ExecutionId != null && chainIds.Contains(r.ExecutionId.Value))
.Where(r => permittedSiteIds == null || r.SourceSiteId == null || permittedSiteIds.Contains(r.SourceSiteId))
// Outbound-calls plan (2026-09-06, D6): the replay set is the trace channel plus the
// three outbound channels (the script's API / DB / notification calls already sit in
// the tree under the run's execution id) plus the inbound row. SecuredWrite, Cluster
@@ -1245,11 +1264,49 @@ VALUES
|| r.Channel == AuditChannel.Notification
|| (r.Channel == AuditChannel.ApiInbound && r.Kind == AuditKind.InboundRequest))
.OrderBy(r => r.OccurredAtUtc)
.ThenBy(r => r.EventId)
.ToListAsync(ct)
.ConfigureAwait(false);
.ThenBy(r => r.EventId);
return rows.Select(RowToCanonical).ToList();
const int maxRows = 500;
const int maxBytes = 1024 * 1024 - 4096; // Reserve envelope/array metadata.
var events = new List<AuditEvent>();
var bytes = 0;
var truncated = chainTruncated;
// Small keyset pages also bound EF execution-strategy buffering. Reading
// all 501 candidate rows in one query could buffer hundreds of MiB before
// the consumer reaches its byte cutoff. Each page projects only details
// within the remaining budget; oversized events are omitted whole.
const int pageSize = 8;
DateTime? afterTime = null;
Guid afterId = default;
while (true)
{
var remaining = maxBytes - bytes;
var pageQuery = afterTime is { } cursor
? query.Where(r => r.OccurredAtUtc > cursor || (r.OccurredAtUtc == cursor && r.EventId.CompareTo(afterId) > 0))
: query;
var page = await pageQuery.OrderBy(r => r.OccurredAtUtc).ThenBy(r => r.EventId)
.Take(Math.Min(pageSize, maxRows + 1 - events.Count)).Select(r => new {
Row = new AuditLogRow {
EventId = r.EventId, OccurredAtUtc = r.OccurredAtUtc, Actor = r.Actor, Action = r.Action,
Outcome = r.Outcome, Channel = r.Channel, Target = r.Target, SourceNode = r.SourceNode,
CorrelationId = r.CorrelationId,
DetailsJson = r.DetailsJson != null && r.DetailsJson.Length <= remaining ? r.DetailsJson : null
}, DetailLength = r.DetailsJson == null ? 0 : r.DetailsJson.Length
}).ToListAsync(ct);
if (page.Count == 0) break;
foreach (var item in page)
{
if (item.DetailLength > remaining || events.Count >= maxRows)
return new TraceEventBatch(rootId, events, true);
var evt = RowToCanonical(item.Row);
var size = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(evt).Length;
if (size > maxBytes - bytes) return new TraceEventBatch(rootId, events, true);
events.Add(evt); bytes += size;
afterTime = item.Row.OccurredAtUtc; afterId = item.Row.EventId;
}
if (page.Count < pageSize) break;
}
return new TraceEventBatch(rootId, events, truncated);
}
/// <inheritdoc />