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
@@ -849,7 +849,7 @@ public class AuditEndpointsTests
/// </summary>
private static async Task<(HttpClient Client, IAuditLogRepository Repo, IHost Host)> BuildHostWithTreeAsync(
string[] roles,
IReadOnlyList<ExecutionTreeNode>? treeNodes = null)
IReadOnlyList<ExecutionTreeNode>? treeNodes = null, string[]? permittedSites = null)
{
var repo = Substitute.For<IAuditLogRepository>();
@@ -858,7 +858,7 @@ public class AuditEndpointsTests
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
var returnNodes = treeNodes ?? Array.Empty<ExecutionTreeNode>();
repo.GetExecutionTreeAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
repo.GetExecutionTreeAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>(), Arg.Any<IReadOnlyList<string>?>())
.Returns(Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(returnNodes));
var ldap = Substitute.For<ILdapAuthService>();
@@ -867,7 +867,7 @@ public class AuditEndpointsTests
var roleMapper = Substitute.For<RoleMapper>(Substitute.For<ISecurityRepository>());
roleMapper.MapGroupsToRolesAsync(Arg.Any<IReadOnlyList<string>>(), Arg.Any<CancellationToken>())
.Returns(new RoleMappingResult(roles, Array.Empty<string>(), IsSystemWideDeployment: true));
.Returns(new RoleMappingResult(roles, permittedSites ?? Array.Empty<string>(), IsSystemWideDeployment: permittedSites is null));
var hostBuilder = new HostBuilder()
.ConfigureWebHost(web =>
@@ -907,6 +907,20 @@ public class AuditEndpointsTests
FirstOccurredAtUtc: new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
LastOccurredAtUtc: new DateTime(2026, 5, 20, 10, 1, 0, DateTimeKind.Utc));
[Fact]
public async Task Tree_ScopedViewer_ThreadsPermittedSitesToRepository()
{
var execution = Guid.NewGuid();
var (client, repo, host) = await BuildHostWithTreeAsync(new[] { "Viewer" }, permittedSites: new[] { "42" });
using (host)
{
var response = await client.SendAsync(Get($"/api/audit/tree?executionId={execution:D}"));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
await repo.Received().GetExecutionTreeAsync(execution, Arg.Any<CancellationToken>(),
Arg.Is<IReadOnlyList<string>>(sites => sites.SequenceEqual(new[] { "42" })));
}
}
[Fact]
public async Task Tree_ValidExecutionId_ReturnsJsonArray()
{
@@ -281,6 +281,18 @@ public class FlowDebugRunnerTests
await _router.DidNotReceiveWithAnyArgs().RouteToSetAttributesAsync(default!, default!, default);
}
[Fact]
public async Task Replay_ThreadsScopeAndRetainsTruncatedRootWithoutRows()
{
var repo = Substitute.For<IAuditLogRepository>(); var root = Guid.NewGuid(); var child = Guid.NewGuid();
repo.GetTraceEventsAsync(child, Arg.Any<CancellationToken>(), Arg.Is<IReadOnlyList<string>>(s => s.SequenceEqual(new[] { "42" })))
.Returns(new ZB.MOM.WW.ScadaBridge.Commons.Types.Audit.TraceEventBatch(root, Array.Empty<AuditEvent>(), true));
_services.AddSingleton(repo);
var result = await Runner().ReplayAsync(_services.BuildServiceProvider(), new FlowDebugReplayCommand(child), CancellationToken.None,
new AuthenticatedUser("scoped", "Scoped", new[] { "Viewer" }, new[] { "42" }));
Assert.True(result.Completed); Assert.True(result.Truncated); Assert.Equal(root, result.RootExecutionId);
}
[Fact]
public async Task Replay_ReturnsRepositoryRows_WithRootFromTheFirstRow()
{
@@ -295,13 +307,13 @@ public class FlowDebugRunnerTests
repo.GetTraceEventsAsync(child, Arg.Any<CancellationToken>()).Returns(rows);
_services.AddSingleton(repo);
var result = await Runner().ReplayAsync(_services.BuildServiceProvider(), new FlowDebugReplayCommand(child), CancellationToken.None);
var result = await Runner().ReplayAsync(_services.BuildServiceProvider(), new FlowDebugReplayCommand(child), CancellationToken.None, Deployer);
Assert.True(result.Completed);
Assert.Equal(root, result.RootExecutionId);
Assert.Equal(rows, result.Events);
var empty = await Runner().ReplayAsync(_services.BuildServiceProvider(), new FlowDebugReplayCommand(Guid.NewGuid()), CancellationToken.None);
var empty = await Runner().ReplayAsync(_services.BuildServiceProvider(), new FlowDebugReplayCommand(Guid.NewGuid()), CancellationToken.None, Deployer);
Assert.False(empty.Completed);
Assert.Equal(FlowDebugRunner.NotFoundCode, empty.ErrorCode);
}
@@ -147,4 +147,10 @@ public class ManagementEndpointsTests
=> Assert.Equal(TimeSpan.FromMinutes(10), ManagementEndpoints.ResolveAskTimeout(
new ManagementServiceOptions { LongRunningCommandTimeout = TimeSpan.FromMinutes(10) },
typeof(ImportBundleCommand)));
[Fact]
public void BulkAsk_LeavesGraceBeyondOverallSchedulingDeadline()
{
var options = new ManagementServiceOptions { LongRunningCommandTimeout = TimeSpan.FromMinutes(2) };
Assert.Equal(TimeSpan.FromMinutes(3), ManagementEndpoints.ResolveAskTimeout(options, typeof(MgmtDeploySiteCommand)));
}
}