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:
+79
-22
@@ -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 />
|
||||
|
||||
Reference in New Issue
Block a user