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
@@ -1,4 +1,5 @@
using System.Net;
using Microsoft.Extensions.DependencyInjection;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
@@ -24,6 +25,74 @@ namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests.Middleware;
/// </summary>
public class AuditWriteMiddlewareTests
{
[Theory]
[InlineData(0, 1)]
[InlineData(1, 1)]
[InlineData(2, 0)]
[InlineData(3, 0)]
public async Task EI1_RelayAuditOwnership_OnlyServingPeerSuppressesLocalFailure(int scenario, int localRows)
{
IResult forwarded = scenario switch
{
0 => Results.Json(new { code = "ACTIVE_NODE_UNREACHABLE" }, statusCode: 503),
1 => Results.Json(new { code = "STANDBY_NODE" }, statusCode: 503),
_ => new HttpStandbyRequestForwarder.RelayedResult(scenario == 2 ? 200 : 500, "application/json", "{}"u8.ToArray())
};
var services = new ServiceCollection().AddLogging();
services.AddSingleton<IActiveNodeGate>(new StandbyGate());
services.AddSingleton<IStandbyRequestForwarder>(new ResultForwarder(forwarded));
using var provider = services.BuildServiceProvider();
var writer = new RecordingAuditWriter();
var middleware = CreateMiddleware(async ctx =>
{
var filter = new InboundApiEndpointFilter(NullLogger<InboundApiEndpointFilter>.Instance,
new InboundOptions(new InboundApiOptions()));
var result = await filter.InvokeAsync(EndpointFilterInvocationContext.Create(ctx), _ => throw new InvalidOperationException("Must not execute locally"));
await ((IResult)result!).ExecuteAsync(ctx);
}, writer);
var context = BuildContext("echo", "{}");
context.RequestServices = provider;
context.Response.Body = new MemoryStream();
await middleware.InvokeAsync(context);
Assert.Equal(localRows, writer.Events.Count);
if (localRows > 0) Assert.NotNull(writer.Events[0].ExecutionId);
}
private sealed class StandbyGate : IActiveNodeGate { public bool IsActiveNode => false; }
private sealed class ResultForwarder(IResult result) : IStandbyRequestForwarder
{
public bool IsConfigured => true;
public Task<IResult> ForwardAsync(HttpContext context, CancellationToken cancellationToken) => Task.FromResult(result);
}
private sealed class InboundOptions(InboundApiOptions value) : IOptionsMonitor<InboundApiOptions>
{
public InboundApiOptions CurrentValue => value;
public InboundApiOptions Get(string? name) => value;
public IDisposable? OnChange(Action<InboundApiOptions, string?> listener) => null;
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task EI2_SkipBodyCapture_SuppressesExceptionText(bool escaped)
{
var writer = new RecordingAuditWriter();
var opts = new AuditLogOptions();
opts.PerTargetOverrides["secret"] = new PerTargetRedactionOverride { SkipBodyCapture = true };
var middleware = CreateMiddleware(ctx =>
{
ctx.Items[AuditWriteMiddleware.ErrorDetailItemKey] = "payload SECRET123";
if (escaped) throw new ArgumentException("payload SECRET123");
ctx.Response.StatusCode = 500;
return Task.CompletedTask;
}, writer, opts);
var context = BuildContext("secret", "SECRET123");
if (escaped) await Assert.ThrowsAsync<ArgumentException>(() => middleware.InvokeAsync(context));
else await middleware.InvokeAsync(context);
var row = Assert.Single(writer.Events);
Assert.DoesNotContain("SECRET123", System.Text.Json.JsonSerializer.Serialize(row));
}
/// <summary>
/// Test-only recording <see cref="ICentralAuditWriter"/>. Captures every
/// <see cref="AuditEvent"/> the middleware emits so each test can assert on