Files
ScadaBridge/archreview/plans/PLAN-R2-07-ui-management-security.md
T
Joseph Doherty 5bbd7689fa docs(archreview): round-2 re-review (2026-07-12) + 8 fix plans (86 tasks)
Re-ran all 8 domain reviews at HEAD 8c888f13 against the b910f5eb baseline:
every round-1 finding source-verified (168 fixed, 0 regressions, 0 false
claims); 56 new findings (1 Critical / 4 High / 15 Medium / 36 Low),
concentrated in post-baseline code (anti-entropy resync, KPI rollup
backfill, live alarm stream) and seams the fixes exposed.

Headliners: S&F resync predicate inversion can wipe the delivering node's
buffer (02-N1 Critical); resync snapshot exceeds the Akka remoting frame
size (02-N2); failover drill kills the one node keep-oldest can't survive
(01-N1); unbounded rollup backfill per failover (04-R1); live production
API key in untracked test.txt (08-NF1).

Adds PLAN-R2-01..08 + .tasks.json manifests and the Round-2 board,
P0 list, cross-plan mutexes, and wave order in 00-MASTER-TRACKER.
2026-07-12 23:52:10 -04:00

67 KiB
Raw Blame History

PLAN-R2-07 — UI, Management & Security Round-2 Hardening Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.

Goal — Close every NEW (round-2) finding from architecture review 07 round 2 (archreview/07-ui-management-security.md, dated 2026-07-12): throttle the last unthrottled LDAP-bind surface (the debug-stream hub) and fix the doc that falsely claims coverage is complete (N1); make LoginThrottle keys survive the Traefik topology via trusted-proxy ForwardedHeaders and document the username-lockout-DoS trade-off (N2); enforce LDAP-mapping site scope on all four secured-write handlers, where the spec already promises it (N3); stop the Alarm Summary poll from displaying cross-site data after a site switch (N4) and from regressing fresher live state (N5); un-stick IsLive when a live-cache aggregator dies (N6); adopt the house disposal guard on the live callback (N7); amortize LoginThrottle.Prune (N8); and freeze the secret-scrubber's fragment coverage with a lock-in test (N9). Round-1 partials with documented deferrals (S1 import idempotency, S4 streaming multipart, P2 QueryDeployments/ExportBundle internals, UA6 fleet-wide grid a11y) stay deferred — coverage rows only, no tasks.

Architecture — All fixes stay inside the seams round 1 built: ManagementAuthenticator is already the shared Basic→LDAP+throttle flow, so N1 is deleting the hub's bespoke bind and delegating; LoginThrottle and SecurityOptions already live in the Security component, so N2 adds one small pure setup class (ForwardedHeadersSetup) wired at the Host composition root ahead of the middleware pipeline; N3 reuses the existing EnforceSiteScope/EnforceSiteScopeForIdentifier helpers (ManagementActor.cs:495-536) exactly as C2's fix did for DeployArtifacts; N4/N5/N7 mirror guards that already exist elsewhere in the same file or in DebugView.razor; N6 adds a deathwatch hook to the (already reference-counted, lock-guarded) SiteAlarmLiveCacheService. Message-contract and repository changes are strictly additive (optional trailing params). Every spec-affecting change updates docs/requirements/Component-Security.md / Component-CentralUI.md / Component-Communication.md in the same task as the code.

Tech Stack — C#/.NET 10, Akka.NET (TestKit.Xunit2 + NSubstitute for actor tests), ASP.NET Core minimal endpoints + SignalR + Blazor Server (bUnit), EF Core (ConfigurationDatabase). Build: dotnet build ZB.MOM.WW.ScadaBridge.slnx. Test per-project: dotnet test tests/<project> (CLI.Tests is in the slnx since PLAN-08 T1 — no special handling).


Parallelization & mutex rules

ManagementActor single-writer lane (initiative-wide rule, same as PLAN-07): every task that edits src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs serializes into ONE lane — among themselves and against any other plan's ManagementActor task. In this round-2 wave only PLAN-R2-07 touches the file (Tasks 4 and 6 — run T4 → T6, never concurrently), but the rule stands: check the round-2 master tracker before starting either, and if another plan later gains a ManagementActor task, this lane serializes with it.

SiteAlarmLiveCacheService.cs mutex with PLAN-R2-02: PLAN-R2-02 also modifies src/ZB.MOM.WW.ScadaBridge.Communication/SiteAlarmLiveCacheService.cs (delta coalescing). Task 10 (N6) and R2-02's coalescing task must NOT run concurrently — whichever plan's task starts first finishes and lands its commit before the other begins, and the second task rebases on the first's committed state. Coordinate through the round-2 tracker.

AlarmSummary.razor internal lane: Tasks 7, 8, 9 all edit the same file/methods — run strictly 7 → 8 → 9.

Concurrent lanes at plan start:

  • Free / file-disjoint: T1 (DebugStreamHub.cs), T2 (Security/ForwardedHeadersSetup.cs + Host/Program.cs), T5 (ISecuredWriteRepository + EF repo), T7 (first of the AlarmSummary lane), T10 (subject to the R2-02 mutex), T11 (LoginThrottle.cs), T12 (test-only + ConfigSecretScrubber.cs xmldoc).
  • Ordering constraints: 3←2; 6←(4,5); 8←7; 9←8. T4 and T6 share the ManagementActor lane (T4 first).

Task 1: Route the DebugStreamHub LDAP bind through ManagementAuthenticator (throttled) + fix the false doc claim

Classification: high-risk Estimated implement time: ~5 min Parallelizable with: 2, 4, 5, 7, 10, 11, 12 Files:

  • Modify: src/ZB.MOM.WW.ScadaBridge.ManagementService/DebugStreamHub.cs (lines 78146: replace the bespoke bypass + Basic-decode + LDAP-bind + role-map block in OnConnectedAsync)
  • Modify: docs/requirements/Component-Security.md (line 208, "Scope — every LDAP-bind surface" bullet)
  • Test: tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/DebugStreamHubTests.cs (extend — currently only tests the static IsInstanceAccessAllowed)

The hub currently uses ManagementAuthenticator only for the DisableLogin bypass (TryDisableLoginUser, :82) and then runs its own Basic decode + ILdapAuthService.AuthenticateAsync (:119-120) + RoleMapper (:129-130) — so the LoginThrottle never sees hub connection attempts, and hub failures don't feed the shared lockout. ManagementAuthenticator.AuthenticateAsync (ManagementAuthenticator.cs:70-146) already does all of it — bypass, decode, IsLockedOut refusal, bind, RecordFailure/RecordSuccess, role resolution, and system-wide PermittedSiteIds normalization. Delegate to it.

  1. Write the failing tests. Harness: a real DefaultHttpContext whose RequestServices carries IOptions<AuthDisableLoginOptions> (off), IOptions<SecurityOptions> (defaults), a FakeTimeProvider-style clock as TimeProvider, a singleton LoginThrottle, and a substituted ILdapAuthService — mirror ManagementAuthenticatorTests.BasicAuthContext exactly. Wrap it for SignalR with a minimal HubCallerContext fake:
private sealed class TestHubCallerContext : HubCallerContext
{
    private readonly FeatureCollection _features = new();
    public TestHubCallerContext(HttpContext http) =>
        _features.Set<IHttpContextFeature>(new TestHttpContextFeature { HttpContext = http });
    public bool Aborted { get; private set; }
    public override string ConnectionId => "test-conn";
    public override string? UserIdentifier => null;
    public override System.Security.Claims.ClaimsPrincipal? User => null;
    public override IDictionary<object, object?> Items { get; } = new Dictionary<object, object?>();
    public override IFeatureCollection Features => _features;
    public override CancellationToken ConnectionAborted => CancellationToken.None;
    public override void Abort() => Aborted = true;

    private sealed class TestHttpContextFeature : IHttpContextFeature { public HttpContext? HttpContext { get; set; } }
}

Build the hub with real light-weight collaborators (DebugStreamService needs CommunicationService — construct it directly: new CommunicationService(Options.Create(new CommunicationOptions()), NullLogger<CommunicationService>.Instance) — plus a new SiteStreamGrpcClientFactory(NullLoggerFactory.Instance) and a bare ServiceCollection().BuildServiceProvider(); IHubContext<DebugStreamHub> is Substitute.For), assign hub.Context = testContext, set context.Connection.RemoteIpAddress = IPAddress.Parse("10.0.0.1") and a Basic header for alice:wrong. Tests:

[Fact]
public async Task OnConnectedAsync_FailedBinds_FeedTheSharedLoginThrottle()
{
    // 5 failed hub connects for alice@10.0.0.1 must arm the same lockout the
    // /management and /auth surfaces consult (arch-review R2 N1).
    for (var i = 0; i < 5; i++) await ConnectAsync("alice", "wrong"); // each Aborted
    Assert.True(_throttle.IsLockedOut("alice", "10.0.0.1"));
}

[Fact]
public async Task OnConnectedAsync_LockedOutKey_AbortsWithoutContactingLdap()
{
    for (var i = 0; i < 5; i++) _throttle.RecordFailure("alice", "10.0.0.1");
    _ldap.ClearReceivedCalls();
    var ctx = await ConnectAsync("alice", "whatever");
    Assert.True(ctx.Aborted);
    await _ldap.DidNotReceiveWithAnyArgs().AuthenticateAsync(default!, default!, default);
}

(ConnectAsync = build hub + context, await hub.OnConnectedAsync(), return the fake context.) 2. Run: dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter DebugStreamHub — new tests FAIL (the hub binds directly; the throttle never locks and LDAP is contacted while "locked"). 3. Implement — in OnConnectedAsync, delete lines 78137 (the TryDisableLoginUser block, the Basic-decode block, the direct ILdapAuthService bind, and the RoleMapper call) and replace with:

// Authenticate via the SAME shared Basic→LDAP flow as POST /management and the
// audit REST (arch-review R2 N1): ManagementAuthenticator applies the DisableLogin
// bypass, the LoginThrottle lockout check BEFORE the bind, and RecordFailure/
// RecordSuccess bookkeeping — so hub connection attempts can no longer be used as
// an unthrottled LDAP-bind oracle, and hub failures feed the shared lockout.
var outcome = await ManagementAuthenticator.AuthenticateAsync(httpContext);
if (outcome.User is null)
{
    _logger.LogWarning("DebugStreamHub connection rejected: authentication failed or throttled");
    Context.Abort();
    return;
}
var user = outcome.User;

// Role check — Deployer role required (unchanged policy).
if (!user.Roles.Contains(Roles.Deployer))
{
    _logger.LogWarning("DebugStreamHub connection rejected: {Username} lacks Deployer role", user.Username);
    Context.Abort();
    return;
}

// Persist the resolved identity on the connection so per-instance site-scope
// enforcement can be applied to SubscribeInstance calls. PermittedSiteIds is the
// authenticator's normalized form (empty == system-wide), same as every other surface.
Context.Items[RolesKey] = user.Roles;
Context.Items[PermittedSiteIdsKey] = user.PermittedSiteIds;

_logger.LogInformation("DebugStreamHub connection established for {Username}", user.Username);
await base.OnConnectedAsync();

Drop the now-unused using System.Text; / ZB.MOM.WW.Auth.Abstractions.Ldap imports if nothing else uses them. 4. Run the filter — PASS. Run the full project: dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests (the five IsInstanceAccessAllowed tests and ManagementAuthenticatorTests must stay green). 5. Fix Component-Security.md:208: the claim was false while the hub bound directly — with this change it becomes true. Reword the bullet so it describes the mechanism rather than asserting completeness by fiat, e.g.: "…and the HTTP-Basic management/CLI surfaces fronted by ManagementAuthenticatorPOST /management, the audit REST endpoints, and the debug-stream hub (DebugStreamHub.OnConnectedAsync delegates its whole credential path to ManagementAuthenticator.AuthenticateAsync, closing the round-2 N1 gap where the hub bound LDAP directly and bypassed the throttle). No bind happens outside these paths." 6. Commit: git add src/ZB.MOM.WW.ScadaBridge.ManagementService/DebugStreamHub.cs tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/DebugStreamHubTests.cs docs/requirements/Component-Security.md && git commit -m "fix(security): throttle DebugStreamHub LDAP bind via ManagementAuthenticator + correct coverage doc (plan R2-07 T1)"

Task 2: ForwardedHeadersSetup — trusted-proxy client-IP resolution for the throttle keys

Classification: high-risk Estimated implement time: ~5 min Parallelizable with: 1, 4, 5, 7, 10, 11, 12 Files:

  • Create: src/ZB.MOM.WW.ScadaBridge.Security/ForwardedHeadersSetup.cs (options class + pure builder)
  • Modify: src/ZB.MOM.WW.ScadaBridge.Host/Program.cs (central pipeline — insert immediately before app.UseWebSockets() at line 340)
  • Test: tests/ZB.MOM.WW.ScadaBridge.Security.Tests/ForwardedHeadersSetupTests.cs (create)

Every throttle surface keys on context.Connection.RemoteIpAddress (ManagementAuthenticator.cs:111, AuthEndpoints.cs:39, :126), and the repo has zero UseForwardedHeaders hits — behind Traefik every client shares the proxy IP and the key degenerates to username|<proxy-ip> (N2). Fix: honor X-Forwarded-For from the trusted proxy only.

  1. Failing tests:
using Microsoft.AspNetCore.HttpOverrides;
using ZB.MOM.WW.ScadaBridge.Security;
using Xunit;

namespace ZB.MOM.WW.ScadaBridge.Security.Tests;

public class ForwardedHeadersSetupTests
{
    [Fact]
    public void Build_Disabled_ReturnsNull()
        => Assert.Null(ForwardedHeadersSetup.Build(new ForwardedHeadersSecurityOptions { Enabled = false }));

    [Fact]
    public void Build_Enabled_ParsesProxiesAndNetworks_AndClearsDefaults()
    {
        var built = ForwardedHeadersSetup.Build(new ForwardedHeadersSecurityOptions
        {
            Enabled = true,
            KnownProxies = ["10.5.0.9"],
            KnownNetworks = ["172.16.0.0/12"],
        })!;
        Assert.Equal(ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto, built.ForwardedHeaders);
        Assert.Equal(1, built.ForwardLimit);
        Assert.Single(built.KnownProxies);                       // ONLY the configured proxy —
        Assert.DoesNotContain(System.Net.IPAddress.IPv6Loopback, // the loopback default is cleared
            built.KnownProxies);
        Assert.Single(built.KnownNetworks);
    }

    [Theory]
    [InlineData("not-an-ip", "172.16.0.0/12")]
    [InlineData("10.5.0.9", "not-a-cidr")]
    public void Build_MalformedEntry_ThrowsAtStartup(string proxy, string network)
        => Assert.ThrowsAny<Exception>(() => ForwardedHeadersSetup.Build(
            new ForwardedHeadersSecurityOptions { Enabled = true, KnownProxies = [proxy], KnownNetworks = [network] }));
}
  1. Run: dotnet test tests/ZB.MOM.WW.ScadaBridge.Security.Tests --filter ForwardedHeadersSetup — FAIL (types missing).
  2. Implement ForwardedHeadersSetup.cs:
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.HttpOverrides;
using System.Net;

namespace ZB.MOM.WW.ScadaBridge.Security;

/// <summary>
/// Config shape for trusted-proxy forwarded-header handling
/// (<c>ScadaBridge:Security:ForwardedHeaders</c>). Disabled by default: a deployment
/// with no reverse proxy must NOT honor X-Forwarded-For (a client could spoof its
/// throttle key). The documented Traefik topologies enable it with the docker
/// network range so LoginThrottle keys on the REAL client IP (arch-review R2 N2).
/// </summary>
public sealed class ForwardedHeadersSecurityOptions
{
    public const string SectionName = "ScadaBridge:Security:ForwardedHeaders";
    public bool Enabled { get; set; }
    /// <summary>Exact proxy IPs allowed to supply X-Forwarded-For.</summary>
    public string[] KnownProxies { get; set; } = [];
    /// <summary>CIDR networks allowed to supply X-Forwarded-For (e.g. "172.16.0.0/12").</summary>
    public string[] KnownNetworks { get; set; } = [];
}

/// <summary>
/// Builds the <see cref="ForwardedHeadersOptions"/> for <c>app.UseForwardedHeaders</c>.
/// Pure and fail-fast: malformed entries throw at startup rather than silently
/// trusting nothing/everything. Defaults (loopback) are CLEARED — only the
/// explicitly configured proxies/networks are trusted, and ForwardLimit=1 means
/// only the entry the trusted proxy itself appended is honored.
/// </summary>
public static class ForwardedHeadersSetup
{
    public static ForwardedHeadersOptions? Build(ForwardedHeadersSecurityOptions options)
    {
        if (!options.Enabled) return null;
        var built = new ForwardedHeadersOptions
        {
            ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto,
            ForwardLimit = 1,
        };
        built.KnownProxies.Clear();
        built.KnownNetworks.Clear();
        foreach (var proxy in options.KnownProxies)
            built.KnownProxies.Add(IPAddress.Parse(proxy));
        foreach (var network in options.KnownNetworks)
        {
            var parts = network.Split('/');
            built.KnownNetworks.Add(new(IPAddress.Parse(parts[0]), int.Parse(parts[1])));
        }
        return built;
    }
}

(KnownNetworks element type: use whatever the installed ASP.NET Core surface expects — Microsoft.AspNetCore.HttpOverrides.IPNetwork historically, System.Net.IPNetwork on newer TFMs; the collection initializer new(...) resolves either. Match the compiler.) 4. Wire in Host/Program.cs — insert immediately before app.UseWebSockets(); (line 340), first middleware in the central pipeline so everything downstream (auth endpoints, ManagementAuthenticator, hub) reads the substituted Connection.RemoteIpAddress:

// Trusted-proxy forwarded headers (arch-review R2 N2): behind Traefik every client
// shares the proxy's IP, collapsing the LoginThrottle's {username}|{ip} keys onto
// one address (per-IP isolation gone + cheap username-lockout DoS). When enabled,
// X-Forwarded-For from the CONFIGURED proxies only is honored. MUST run first —
// everything downstream keys on Connection.RemoteIpAddress.
var forwardedOptions = builder.Configuration
    .GetSection(ZB.MOM.WW.ScadaBridge.Security.ForwardedHeadersSecurityOptions.SectionName)
    .Get<ZB.MOM.WW.ScadaBridge.Security.ForwardedHeadersSecurityOptions>() ?? new();
if (ZB.MOM.WW.ScadaBridge.Security.ForwardedHeadersSetup.Build(forwardedOptions) is { } fho)
    app.UseForwardedHeaders(fho);
  1. Run the filter — PASS; dotnet build ZB.MOM.WW.ScadaBridge.slnx — clean; dotnet test tests/ZB.MOM.WW.ScadaBridge.Security.Tests — no regressions.
  2. Commit: git add src/ZB.MOM.WW.ScadaBridge.Security/ForwardedHeadersSetup.cs src/ZB.MOM.WW.ScadaBridge.Host/Program.cs tests/ZB.MOM.WW.ScadaBridge.Security.Tests/ForwardedHeadersSetupTests.cs && git commit -m "fix(security): trusted-proxy ForwardedHeaders so LoginThrottle keys on the real client IP (plan R2-07 T2)"

Task 3: Ship the ForwardedHeaders config to the Traefik topologies + document the lockout-DoS trade-off

Classification: high-risk Estimated implement time: ~4 min Parallelizable with: 1, 4, 5, 7, 10, 11, 12 (waits on Task 2) Files:

  • Modify: docker/central-node-a/appsettings.Central.json, docker/central-node-b/appsettings.Central.json (inside the existing "Security" object)
  • Modify: docker-env2/central-node-a/appsettings.Central.json, docker-env2/central-node-b/appsettings.Central.json (same)
  • Modify: deploy/wonder-app-vd03/appsettings.Central.json (explicit disabled block + comment; no proxy fronts that host)
  • Modify: docs/requirements/Component-Security.md (§Login throttling — key derivation + new trade-off paragraph)
  • Modify: tests/ZB.MOM.WW.ScadaBridge.Security.Tests/LoginThrottleTests.cs (comment only, line 37)
  1. Add to both docker central nodes' "Security" section (site nodes don't serve the UI/management surface — untouched):
"ForwardedHeaders": {
  "_comment": "Traefik fronts this node on the external scadabridge-net docker network. Trust X-Forwarded-For from the docker bridge address pool so LoginThrottle keys on the real client IP (arch-review R2 N2). Narrow to the Traefik container IP if the network is pinned.",
  "Enabled": true,
  "KnownNetworks": [ "172.16.0.0/12" ]
}

Same block in both docker-env2 central nodes. In deploy/wonder-app-vd03/appsettings.Central.json add the block with "Enabled": false and a comment: "No reverse proxy fronts this host today. If one is introduced, set Enabled=true and list ONLY the proxy's IP in KnownProxies — never enable without it, or clients can spoof their throttle key." 2. Validate all five JSONs: for f in docker/central-node-{a,b}/appsettings.Central.json docker-env2/central-node-{a,b}/appsettings.Central.json deploy/wonder-app-vd03/appsettings.Central.json; do python3 -c "import json,sys;json.load(open(sys.argv[1]))" "$f" || echo "BAD $f"; done 3. Component-Security.md §Login throttling — update the "Key and window" bullet: the IP half of the key is the forwarded-header-resolved client IP when ScadaBridge:Security:ForwardedHeaders names a trusted proxy, otherwise the raw connection peer. Then append a new bullet documenting the trade-off (this text is the deliverable — keep the substance):

  • Proxy topologies and the username-lockout DoS. The key is deliberately {username}|{IP}, not {username} alone: keying on username alone would let any network-adjacent actor lock any operator out of a SCADA control surface with five wrong passwords, repeatable indefinitely. That isolation only exists when the throttle sees real client IPs — behind a proxy without ForwardedHeaders trust, all clients collapse onto the proxy's IP and the DoS returns. Mitigations: (a) the shipped Traefik topologies enable trusted-proxy ForwardedHeaders (so per-IP isolation is real in the documented deployment); (b) the lockout window is short by default (LoginLockoutMinutes = 5) and never touches the directory account itself; (c) residual risk — an attacker spraying from their own IP locking out a victim username at that IP only — is the throttle working as designed. A success-path bypass ("a correct password unlocks") was considered and rejected: it would let an attacker who has the password bypass the lockout entirely, and it converts the throttle into a password oracle during the lockout window.
  1. LoginThrottleTests.cs:37 — extend the // per username+IP key comment: // per username+IP key — real isolation in production requires the trusted-proxy ForwardedHeaders config (R2 N2); without it all clients share the proxy IP.
  2. dotnet test tests/ZB.MOM.WW.ScadaBridge.Security.Tests --filter LoginThrottle — still green (comment-only). Optional smoke after the next bash docker/deploy.sh: docker logs scadabridge-central-a shows no ForwardedHeaders startup fault, and a failed CLI login from the host still 401s (not 429) on the first attempt.
  3. Commit: git add docker docker-env2 deploy/wonder-app-vd03/appsettings.Central.json docs/requirements/Component-Security.md tests/ZB.MOM.WW.ScadaBridge.Security.Tests/LoginThrottleTests.cs && git commit -m "fix(security): ship trusted-proxy ForwardedHeaders config for Traefik topologies + document lockout-DoS trade-off (plan R2-07 T3)"

Task 4: Enforce site scope on secured-write submit / approve / reject

Classification: high-risk Estimated implement time: ~5 min Parallelizable with: 1, 2, 3, 5, 7, 10, 11, 12 (ManagementActor.cs — single-writer lane; run before Task 6) Files:

  • Modify: src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs (HandleSubmitSecuredWrite :1243-1283, HandleApproveSecuredWrite :1285-1440, HandleRejectSecuredWrite :1442-1479)
  • Test: tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/SecuredWriteHandlerTests.cs

None of the four secured-write handlers checks user.PermittedSiteIds (verified: zero EnforceSiteScope occurrences in :1243-1510), while Component-Security.md:141 says scoping "is layered on at the LDAP-mapping level" — a Verifier scoped to site 3 can today approve a device write to any site (N3, the same class C2 closed for DeployArtifacts). Ruling: code moves, not the doc — enforce.

  1. Add a scoped-envelope helper next to the existing Envelope helper (SecuredWriteHandlerTests.cs:151-153):
private static ManagementEnvelope ScopedEnvelope(object command, string username, string[] permittedSiteIds, params string[] roles) =>
    new(new AuthenticatedUser(username, username, roles, permittedSiteIds), command, Guid.NewGuid().ToString("N"));

Write the failing negative (and positive) tests — every security task needs the out-of-scope rejection asserted:

[Fact]
public void Submit_SiteScopedOperator_OutOfScopeSite_Unauthorized_NoRowInserted()
{
    SeedSiteWithConnection(1, "SITE1", "Gw1", "MxGateway");
    var actor = CreateActor();
    actor.Tell(ScopedEnvelope(
        new SubmitSecuredWriteCommand("SITE1", "Gw1", "Tag.A", "true", "Boolean", "go"),
        "alice", ["3"], "Operator"));
    ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
    _securedWriteRepo.DidNotReceiveWithAnyArgs().AddAsync(default!, default);
}

[Fact]
public void Submit_SiteScopedOperator_InScopeSite_Succeeds()
{
    SeedSiteWithConnection(1, "SITE1", "Gw1", "MxGateway");
    var actor = CreateActor();
    actor.Tell(ScopedEnvelope(
        new SubmitSecuredWriteCommand("SITE1", "Gw1", "Tag.A", "true", "Boolean", "go"),
        "alice", ["1"], "Operator"));
    ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
}

[Fact]
public void Approve_SiteScopedVerifier_OutOfScopeSite_Unauthorized_NeverRelays()
{
    SeedSiteWithConnection(1, "SITE1", "Gw1", "MxGateway");
    _securedWriteRepo.GetAsync(42, Arg.Any<CancellationToken>()).Returns(new PendingSecuredWrite
    {
        Id = 42, SiteId = "SITE1", ConnectionName = "Gw1", TagPath = "Tag.A",
        ValueJson = "true", ValueType = "Boolean", Status = "Pending",
        OperatorUser = "alice", SubmittedAtUtc = DateTime.UtcNow,
    });
    var actor = CreateActor();
    actor.Tell(ScopedEnvelope(new ApproveSecuredWriteCommand(42, "ok"), "bob", ["3"], "Verifier"));
    ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
    _securedWriteRepo.DidNotReceiveWithAnyArgs().TryMarkApprovedAsync(default, default!, default, default, default);
    Assert.Equal(0, _comms.CallCount); // the device write is NEVER relayed
}

[Fact]
public void Reject_SiteScopedVerifier_OutOfScopeSite_Unauthorized() { /* same seed, RejectSecuredWriteCommand(42, "no") → ManagementUnauthorized; UpdateAsync not received */ }

[Fact]
public void Approve_ScopedAdministrator_BypassesSiteScope() { /* ScopedEnvelope(..., "carol", ["3"], "Verifier", "Administrator") on the SITE1 row → NOT ManagementUnauthorized (EnforceSiteScope admin bypass, ManagementActor.cs:499) */ }
  1. Run: dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter SecuredWrite — new tests FAIL (no scope check exists).
  2. Implement — three insertions, all before any state mutation or audit emission:
    • HandleSubmitSecuredWrite: the site entity is already loaded at :1247-1248, so use the cheap overload right after it:
// Site scope (arch-review R2 N3): an LDAP-mapping-scoped Operator may only
// submit device writes to their permitted sites — same rule C2 applies to
// DeployArtifacts, on a higher-consequence path. Checked before the row exists.
EnforceSiteScope(user, site.Id);
  • HandleApproveSecuredWrite: after the row load + Pending check (:1289-1294), before the TTL/self-approval/CAS chain:
// Site scope (arch-review R2 N3): the approving Verifier must be permitted on
// the row's target site — checked BEFORE the TTL/self-approval/CAS chain so an
// out-of-scope verifier can neither consume the Pending transition nor relay.
await EnforceSiteScopeForIdentifier(sp, user, row.SiteId);
  • HandleRejectSecuredWrite: same line after :1447-1451, against entity.SiteId.
  1. Run the filter — PASS. Run the full project (dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests) — the existing submit/approve/reject tests use the system-wide Envelope helper (PermittedSiteIds empty), so EnforceSiteScope no-ops and they stay green. RequiredRoleMatrixTests is untouched: no role-set changes, only in-handler scope enforcement (the frozen table freezes roles, not scope — see its header comment "Do not regenerate it mechanically"). If it fails, STOP: something else changed.
  2. Commit: git add src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/SecuredWriteHandlerTests.cs && git commit -m "fix(security): enforce LDAP-mapping site scope on secured-write submit/approve/reject (plan R2-07 T4)"

Task 5: ISecuredWriteRepository — additive permitted-sites filter for scoped listing

Classification: standard Estimated implement time: ~4 min Parallelizable with: 1, 2, 3, 4, 7, 10, 11, 12 Files:

  • Modify: src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/ISecuredWriteRepository.cs (QueryAsync :51, CountAsync :112)
  • Modify: src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/SecuredWriteRepository.cs (QueryAsync :55, CountAsync :131)
  • Test: tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/SecuredWriteRepositoryTests.cs (extend)

Task 6 needs to scope the unfiltered list for a site-scoped caller at the query level (so rows and totalCount are both scoped, and paging stays correct). Give both query methods an additive optional filter.

  1. Failing tests (mirror the existing SecuredWriteRepositoryTests seeding — rows across site identifiers "SITE1", "SITE2", "SITE3"):
[Fact]
public async Task QueryAsync_PermittedSiteIds_ReturnsOnlyPermittedRows()
{
    // seed 2 rows on SITE1, 1 on SITE2, 1 on SITE3 (existing seeding helper)
    var rows = await _repo.QueryAsync(status: null, siteId: null, skip: 0, take: 100,
        permittedSiteIds: new[] { "SITE1", "SITE3" });
    Assert.Equal(3, rows.Count);
    Assert.DoesNotContain(rows, r => r.SiteId == "SITE2");
}

[Fact]
public async Task CountAsync_PermittedSiteIds_CountsOnlyPermittedRows()
{
    var count = await _repo.CountAsync(status: null, siteId: null,
        permittedSiteIds: new[] { "SITE1", "SITE3" });
    Assert.Equal(3, count);
}

[Fact]
public async Task QueryAsync_NullPermittedSiteIds_IsUnfiltered_BackCompat()
{ /* null → all 4 rows, exactly today's behavior */ }
  1. Run: dotnet test tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests --filter SecuredWriteRepository — FAIL (no such parameter).
  2. Implement — additive optional parameter before the CancellationToken on both members (interface + impl):
Task<IReadOnlyList<PendingSecuredWrite>> QueryAsync(
    string? status, string? siteId, int skip, int take,
    IReadOnlyCollection<string>? permittedSiteIds = null, CancellationToken ct = default);

Task<int> CountAsync(
    string? status, string? siteId,
    IReadOnlyCollection<string>? permittedSiteIds = null, CancellationToken ct = default);

xmldoc: "permittedSiteIds — when non-null, only rows whose SiteId (site identifier) is in the set match; null matches every site. Applied IN ADDITION to siteId. Used by the ManagementActor to constrain an unfiltered list to a site-scoped caller's permitted sites at the query level (arch-review R2 N3), so paging and totals stay correct." EF impl, in both methods' filter composition:

if (permittedSiteIds is not null)
    query = query.Where(w => permittedSiteIds.Contains(w.SiteId));
  1. Run the filter — PASS; full project run (existing positional callers compile unchanged — the parameter is trailing-optional).
  2. Commit: git add src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/ISecuredWriteRepository.cs src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/SecuredWriteRepository.cs tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/SecuredWriteRepositoryTests.cs && git commit -m "fix(security): additive permitted-sites filter on secured-write query/count (plan R2-07 T5)"

Task 6: Scope-filter HandleListSecuredWrites + amend Component-Security.md:141 + matrix verification

Classification: high-risk Estimated implement time: ~5 min Parallelizable with: none (ManagementActor.cs — single-writer lane; waits on Tasks 4 and 5) Files:

  • Modify: src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs (HandleListSecuredWrites :1481-1509; dispatch arm :470)
  • Modify: docs/requirements/Component-Security.md (line 141 — the "any site scoping is layered on at the LDAP-mapping level" sentence)
  • Test: tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/SecuredWriteHandlerTests.cs
  1. Failing tests:
[Fact]
public void List_SiteScopedReader_ExplicitOutOfScopeSiteFilter_Unauthorized()
{
    SeedSiteWithConnection(1, "SITE1", "Gw1", "MxGateway");
    var actor = CreateActor();
    actor.Tell(ScopedEnvelope(new ListSecuredWritesCommand(null, "SITE1"), "bob", ["3"], "Verifier"));
    ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
}

[Fact]
public void List_SiteScopedReader_Unfiltered_QueriesOnlyPermittedIdentifiers()
{
    _siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(new List<Site>
    {
        new("Site1", "SITE1") { Id = 1 },
        new("Site3", "SITE3") { Id = 3 },
    });
    _securedWriteRepo.QueryAsync(null, null, 0, Arg.Any<int>(),
            Arg.Any<IReadOnlyCollection<string>?>(), Arg.Any<CancellationToken>())
        .Returns(new List<PendingSecuredWrite>());
    var actor = CreateActor();
    actor.Tell(ScopedEnvelope(new ListSecuredWritesCommand(null, null), "bob", ["3"], "Verifier"));
    ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
    _securedWriteRepo.Received(1).QueryAsync(null, null, 0, Arg.Any<int>(),
        Arg.Is<IReadOnlyCollection<string>?>(s => s != null && s.Single() == "SITE3"),
        Arg.Any<CancellationToken>());
}

[Fact]
public void List_SystemWideReader_PassesNullPermittedFilter() { /* Envelope(...) → QueryAsync received with permittedSiteIds == null */ }

(Match the real ListSecuredWritesCommand parameter order — read SecuredWriteCommands.cs:63: (Status, SiteId, Skip, Take).) 2. Run: dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter List_SiteScoped — FAIL. 3. Implement — change the handler signature to take the user and the dispatch arm at :470 from HandleListSecuredWrites(sp, cmd) to HandleListSecuredWrites(sp, cmd, user):

private static async Task<object?> HandleListSecuredWrites(
    IServiceProvider sp, ListSecuredWritesCommand cmd, AuthenticatedUser user)
{
    // Site scoping (arch-review R2 N3): an explicit SiteId filter is scope-checked
    // like every other identifier-keyed command; an UNFILTERED list from a
    // site-scoped non-admin caller is constrained to their permitted sites at the
    // query level (rows AND totalCount both scoped — see ISecuredWriteRepository).
    IReadOnlyCollection<string>? permittedIdentifiers = null;
    if (cmd.SiteId is not null)
    {
        await EnforceSiteScopeForIdentifier(sp, user, cmd.SiteId);
    }
    else if (user.PermittedSiteIds.Length > 0
        && !user.Roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase))
    {
        var siteRepo = sp.GetRequiredService<ISiteRepository>();
        var permitted = new HashSet<string>(user.PermittedSiteIds);
        permittedIdentifiers = (await siteRepo.GetAllSitesAsync())
            .Where(s => permitted.Contains(s.Id.ToString()))
            .Select(s => s.SiteIdentifier)
            .ToList();
    }

    var repo = sp.GetRequiredService<ISecuredWriteRepository>();
    var take = Math.Clamp(cmd.Take, 1, 500);
    var skip = Math.Max(0, cmd.Skip);
    var rows = await repo.QueryAsync(cmd.Status, cmd.SiteId, skip, take, permittedIdentifiers);
    var totalCount = await repo.CountAsync(cmd.Status, cmd.SiteId, permittedIdentifiers);
    // ... (opportunistic expiry sweep unchanged)
  1. Run the filter — PASS. Then the lock-in suites — the frozen table needs no regeneration (role sets untouched; per its header, the table freezes the GetRequiredRoles switch, which this task does not edit) and the dispatch arm still reaches a handler: dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter "RequiredRoleMatrixTests|DispatchCoverageTests" — green. If RequiredRoleMatrixTests DOES fail, follow its documented update procedure (hand-author the changed entry against the switch + cross-check Component-ManagementService.md §Authorization — never regenerate mechanically). Full project run.
  2. Amend Component-Security.md:141 — replace "Both are coarse global roles like the others; any site scoping is layered on at the LDAP-mapping level." with: "Both are coarse global roles like the others. Site scoping from the LDAP mapping is enforced server-side on every secured-write command (arch-review R2 N3): submit checks the target site, approve/reject check the row's site (before the TTL/self-approval/CAS chain), and the list constrains a scoped caller to their permitted sites — mirroring the deployment commands' EnforceSiteScope rule. Administrators and system-wide principals (empty permitted-site set) are unrestricted."
  3. Commit: git add src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/SecuredWriteHandlerTests.cs docs/requirements/Component-Security.md && git commit -m "fix(security): scope-filter secured-write listing + align spec with enforced site scoping (plan R2-07 T6)"

Task 7: AlarmSummary.RefreshAsync stale-site guard — never display cross-site data

Classification: standard Estimated implement time: ~4 min Parallelizable with: 1, 2, 3, 4, 5, 10, 11, 12 (AlarmSummary.razor lane — run before Tasks 8, 9) Files:

  • Modify: src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/AlarmSummary.razor (RefreshAsync :283-308)
  • Test: tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/AlarmSummaryRenderTests.cs

RefreshAsync captures siteId at entry (:285), awaits the multi-second fan-out (:293), then unconditionally assigns _rows/_notReporting/_rollup (:294-296) — a site-A poll completing after a switch to site B overwrites B's alarms with A's, on an operator alarm page (N4). The live path already has exactly this guard (OnLiveAlarmsChanged, :374).

  1. Failing bUnit test (the harness's constructor stubs GetSiteAlarmsAsync(Arg.Any<int>, ...); override per-site inside the test):
[Fact]
public void InFlightPollForPreviousSite_DoesNotOverwriteNewSitesRows()
{
    // Site 1's fan-out hangs on a TCS; site 2 answers immediately (arch-review R2 N4).
    var site1Tcs = new TaskCompletionSource<AlarmSummaryResult>();
    _summary.GetSiteAlarmsAsync(1, Arg.Any<CancellationToken>()).Returns(site1Tcs.Task);
    _summary.GetSiteAlarmsAsync(2, Arg.Any<CancellationToken>()).Returns(Task.FromResult(
        new AlarmSummaryResult(
            new List<AlarmSummaryRow>
            {
                new("Site2Instance", new AlarmStateChanged("Site2Instance", "S2-alarm", AlarmState.Active, 100, DateTimeOffset.UtcNow)),
            },
            Array.Empty<string>())));

    var cut = Render<AlarmSummaryPage>();
    cut.Find("[data-test='alarm-summary-site']").Change("1"); // poll in flight, hung
    cut.Find("[data-test='alarm-summary-site']").Change("2"); // site 2 renders
    cut.WaitForAssertion(() => Assert.Equal("Site2Instance", FirstRowInstance(cut)));

    // The stale site-1 fan-out now completes — it must be dropped, not rendered.
    site1Tcs.SetResult(new AlarmSummaryResult(
        new List<AlarmSummaryRow>
        {
            new("StaleSite1", new AlarmStateChanged("StaleSite1", "S1-alarm", AlarmState.Active, 999, DateTimeOffset.UtcNow)),
        },
        new[] { "StaleNotReporting" }));

    cut.WaitForAssertion(() =>
    {
        Assert.Equal("Site2Instance", FirstRowInstance(cut));
        Assert.DoesNotContain("StaleNotReporting", cut.Markup);
    });
}
  1. Run: dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter AlarmSummary — new test FAILS (StaleSite1 overwrites the page).
  2. Implement — one guard in RefreshAsync, immediately after the await at :293:
var result = await AlarmSummaryService.GetSiteAlarmsAsync(siteId);
// Stale-site guard (arch-review R2 N4): the operator may have switched sites
// while this fan-out was in flight — drop the result rather than labeling
// site A's alarms under site B's picker. Mirrors OnLiveAlarmsChanged (:374).
if (_selectedSiteId != siteId)
{
    return;
}

(_loading is reset in the existing finally — correct either way, since a switch re-enters RefreshAsync for the new site.) 4. Run the filter — PASS, plus the pre-existing AlarmSummary tests stay green. 5. Commit: git add src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/AlarmSummary.razor tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/AlarmSummaryRenderTests.cs && git commit -m "fix(ui): drop stale-site poll results on Alarm Summary site switch (plan R2-07 T7)"

Task 8: While live, the poll updates only _notReporting — never regresses live rows

Classification: standard Estimated implement time: ~5 min Parallelizable with: none (AlarmSummary.razor lane; waits on Task 7) Files:

  • Modify: src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/AlarmSummary.razor (RefreshAsync :283-308; reconciliation comment block :337-351)
  • Modify: docs/requirements/Component-CentralUI.md (line 194, the Refresh bullet)
  • Test: tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/AlarmSummaryRenderTests.cs

The 15 s poll always rebuilds _rows from the fan-out even when the cache is live; a poll whose fan-out started before a live delta lands after it and momentarily reverts the delta (N5). The comment at :344-347 ("the poll simply re-affirms the same snapshot") is wrong — fix both.

  1. Failing bUnit test (uses the manual Refresh button, data-test="alarm-summary-refresh", to drive the poll path deterministically — it invokes the same RefreshAsync the timer does):
[Fact]
public void PollWhileLive_UpdatesNotReportingOnly_DoesNotRegressLiveRows()
{
    // Poll snapshot = Zeta/Alpha (constructor stub) + a not-reporting instance.
    _summary.GetSiteAlarmsAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
        .Returns(ci => Task.FromResult(new AlarmSummaryResult(
            new List<AlarmSummaryRow>
            {
                new("Zeta", new AlarmStateChanged("Zeta", "Z-alarm", AlarmState.Active, 900, DateTimeOffset.UtcNow)),
            },
            new[] { "OfflineInstance" })));

    var cut = RenderWithSiteSelected();

    // Go live with a fresher delta: Gamma only (arch-review R2 N5).
    _liveCache.PushAlarms(new List<AlarmStateChanged>
    {
        new("Gamma", "G-alarm", AlarmState.Active, 300, DateTimeOffset.UtcNow),
    });
    cut.WaitForAssertion(() => Assert.Equal("Gamma", FirstRowInstance(cut)));

    // A poll now completes: it owns _notReporting but must NOT rebuild the rows.
    cut.Find("[data-test='alarm-summary-refresh']").Click();

    cut.WaitForAssertion(() =>
    {
        Assert.Contains("OfflineInstance", cut.Markup);            // notReporting refreshed
        Assert.Equal("Gamma", FirstRowInstance(cut));              // live rows NOT regressed
        Assert.Single(cut.FindAll("tr[data-test='alarm-summary-row']"));
    });
}
  1. Run: dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter AlarmSummary — FAILS (the poll rebuilds Zeta over Gamma).
  2. Implement — in RefreshAsync, after Task 7's stale-site guard, replace the unconditional assignments:
// _notReporting is the poll's unique authority — the alarm-only live cache
// cannot compute it — so it is always refreshed.
_notReporting = result.NotReportingInstances;

// While the cache is live, the live deltas own the row set: a poll whose fan-out
// started BEFORE a delta must not land after it and momentarily revert the alarm
// state (arch-review R2 N5). When not live (pre-seed / degraded stream / dead
// aggregator — see R2 N6), the poll remains the full-rebuild safety net.
if (!LiveAlarmCache.IsLive(siteId))
{
    _rows = result.Alarms;
    _rollup = AlarmSummaryService.ComputeRollup(_rows);
}
RecomputeVisibleRows();

Rewrite the stale sentence in the reconciliation comment block (:344-347): the poll is "the authority for _notReporting and the full-rebuild safety net only while the cache is not live; when live it deliberately leaves _rows to the delta path". 4. Run the filter — PASS. Note interplay checks: PollFallbackRenders_WhenCacheNotLiveYet still passes (not live → full rebuild); OnChangedDelta_RebuildsRenderedRowsFromLiveSnapshot still passes (live path untouched). 5. Update Component-CentralUI.md:194: append "…when live, the poll updates only the NotReporting list and leaves the row set to the delta path, so a slow fan-out can never momentarily revert a fresher live delta (R2 N5)." 6. Commit: git add src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/AlarmSummary.razor tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/AlarmSummaryRenderTests.cs docs/requirements/Component-CentralUI.md && git commit -m "fix(ui): Alarm Summary poll defers row rebuilds to the live path while IsLive (plan R2-07 T8)"

Task 9: House disposal guard on the live callback

Classification: small Estimated implement time: ~4 min Parallelizable with: none (AlarmSummary.razor lane; waits on Task 8) Files:

  • Modify: src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/AlarmSummary.razor (OnLiveAlarmsChanged :367-382, Dispose :507-511)
  • Test: tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/AlarmSummaryRenderTests.cs (+ add a CapturedCallback accessor to FakeSiteAlarmLiveCache)

OnLiveAlarmsChanged does _ = InvokeAsync(...) with no _disposed flag; a callback racing Dispose() can fault the discarded task with ObjectDisposedException from StateHasChanged on a torn-down renderer (N7). DebugView.razor established the pattern (_disposed set before teardown + checked before AND inside the marshalled work — :312, :430-433, :606).

  1. Failing test — expose the raw registered callback on the fake so the test can simulate a delta already in flight when Dispose runs (bUnit's renderer stays alive after cut.Instance.Dispose(), so pre-fix the late callback observably rebuilds the rows):
// In FakeSiteAlarmLiveCache:
public Action? CapturedCallback => _onChanged;

[Fact]
public void LiveCallbackRacingDispose_IsDropped_NoRebuildNoThrow()
{
    var cut = RenderWithSiteSelected();
    _liveCache.PushAlarms(new List<AlarmStateChanged>
    {
        new("Gamma", "G-alarm", AlarmState.Active, 300, DateTimeOffset.UtcNow),
    });
    cut.WaitForAssertion(() => Assert.Equal("Gamma", FirstRowInstance(cut)));

    // Snapshot the callback BEFORE disposal — this models a delta thread that
    // already read the delegate when Dispose ran (arch-review R2 N7).
    var inFlight = _liveCache.CapturedCallback!;
    cut.Instance.Dispose();

    _liveCache.PushAlarms(new List<AlarmStateChanged>()); // mutate the fake's snapshot
    var ex = Record.Exception(inFlight);
    Assert.Null(ex);
    // The disposed page must not have re-applied the (now empty) live snapshot.
    Assert.Equal("Gamma", FirstRowInstance(cut));
}
  1. Run: dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter AlarmSummary — FAILS (the late callback re-applies the empty snapshot: zero rows).
  2. Implement — mirror DebugView.razor:
// N7: set BEFORE teardown so a live callback racing Dispose is dropped both
// before the InvokeAsync marshal and inside it (mirrors DebugView.razor).
private volatile bool _disposed;

private void OnLiveAlarmsChanged(int siteId)
{
    if (_disposed) return;
    _ = InvokeAsync(() =>
    {
        if (_disposed || _selectedSiteId != siteId || !LiveAlarmCache.IsLive(siteId))
        {
            return;
        }
        ApplyLiveSnapshot(siteId);
        StateHasChanged();
    });
}

public void Dispose()
{
    _disposed = true;
    StopTimer();
    DisposeLiveSubscription();
}
  1. Run the filter — PASS (including DisposingComponent_UnsubscribesFromTheLiveCache).
  2. Commit: git add src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/AlarmSummary.razor tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/AlarmSummaryRenderTests.cs && git commit -m "fix(ui): disposal guard on Alarm Summary live callback, matching the DebugView pattern (plan R2-07 T9)"

Task 10: Un-stick IsLive — deathwatch resets liveness when the aggregator terminates

Classification: standard Estimated implement time: ~5 min Parallelizable with: 1, 2, 3, 4, 5, 7, 11, 12 — MUTEX: must NOT run concurrently with PLAN-R2-02's SiteAlarmLiveCacheService delta-coalescing task (see "Parallelization & mutex rules"); whichever starts first lands its commit before the other begins Files:

  • Modify: src/ZB.MOM.WW.ScadaBridge.Communication/SiteAlarmLiveCacheService.cs (StartAggregatorAsync :219-243; new nested watcher actor + OnAggregatorTerminated; SiteEntry :416-453)
  • Modify: src/ZB.MOM.WW.ScadaBridge.Communication/ISiteAlarmLiveCache.cs (IsLive xmldoc :53-61)
  • Modify: docs/requirements/Component-CentralUI.md (line 194 — "when a stream is unhealthy" wording gains the aggregator-death case)
  • Test: tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SiteAlarmLiveCacheServiceTests.cs

SiteEntry.HasPublished is set on first publish (:392-393) and never cleared, so IsLive (:132-136) reports true forever — and OnSiteChangedAsync deliberately applies the live snapshot on top of the fresh poll when IsLive (AlarmSummary.razor:277-280), grafting an arbitrarily stale frozen snapshot over correct data if the aggregator died (N6). Fix: deathwatch the aggregator and reset liveness (which also makes the page's poll the authority again, via Task 8's !IsLive branch).

  1. Failing tests (the harness runs a real TestKit ActorSystemSiteAlarmLiveCacheServiceTests — and the aggregator is a real child at /user/site-alarm-aggregator-{siteId}-{guid}):
[Fact]
public async Task Aggregator_Death_Resets_IsLive_And_Clears_The_Snapshot()
{
    var service = CreateService(TimeSpan.FromMinutes(5), out _);
    using var sub = service.Subscribe(SiteId, () => { });
    AwaitAssert(() => Assert.True(service.IsLive(SiteId)), TimeSpan.FromSeconds(10));

    // Kill the aggregator out from under the service (crash-death, NOT a linger stop).
    var aggregator = await Sys.ActorSelection($"/user/site-alarm-aggregator-{SiteId}-*")
        .ResolveOne(TimeSpan.FromSeconds(5));
    Sys.Stop(aggregator);

    AwaitAssert(() =>
    {
        Assert.False(service.IsLive(SiteId));          // sticky no more (R2 N6)
        Assert.Empty(service.GetCurrentAlarms(SiteId)); // frozen snapshot never grafted
    }, TimeSpan.FromSeconds(10));
}

[Fact]
public void Linger_Stop_Does_Not_Resurrect_The_Aggregator()
{
    // Last viewer leaves, linger fires, entry removed: the deathwatch on the
    // deliberately-stopped actor must be a no-op (entry gone), not a restart.
    var service = CreateService(TimeSpan.FromMilliseconds(50), out var factory);
    var sub = service.Subscribe(SiteId, () => { });
    AwaitAssert(() => Assert.True(service.IsLive(SiteId)), TimeSpan.FromSeconds(10));
    sub.Dispose();
    AwaitAssert(() => Assert.False(service.IsLive(SiteId)), TimeSpan.FromSeconds(10));
    var startsAfterStop = factory.GetOrCreateCount;
    Thread.Sleep(300);
    Assert.Equal(startsAfterStop, factory.GetOrCreateCount); // no self-heal restart fired
}
  1. Run: dotnet test tests/ZB.MOM.WW.ScadaBridge.Communication.Tests --filter SiteAlarmLiveCache — first test FAILS (IsLive stays true after the stop).
  2. Implement:
    • In StartAggregatorAsync, inside the lock right after entry.Actor = system.ActorOf(props, ...) (:238), capture and watch:
var aggregator = entry.Actor;
// Deathwatch (arch-review R2 N6): if the aggregator terminates for any reason,
// liveness must reset — a dead feed must never keep IsLive == true, or the page
// grafts a frozen snapshot over fresh poll data for up to 15 s.
system.ActorOf(Props.Create(() => new AggregatorTerminationWatcher(
    aggregator!, () => OnAggregatorTerminated(entry.SiteId, aggregator!))));
  • New members on the service:
/// <summary>Watches one aggregator and fires a callback exactly once on Terminated.</summary>
private sealed class AggregatorTerminationWatcher : ReceiveActor
{
    public AggregatorTerminationWatcher(IActorRef watched, Action onTerminated)
    {
        Context.Watch(watched);
        Receive<Terminated>(_ =>
        {
            onTerminated();
            Context.Stop(Self);
        });
    }
}

/// <summary>
/// Terminated hook (R2 N6). A DELIBERATE stop (linger fired, entry already removed
/// from _sites) is a no-op — the ReferenceEquals guard also ignores a stale watcher
/// firing after a newer aggregator replaced the dead one. A crash-death with live
/// viewers resets liveness (IsLive → false, snapshot cleared, so the page's poll is
/// authoritative again) and arms the existing bounded start-retry so the feature
/// self-heals, mirroring StartAggregatorAsync's transient-failure path.
/// </summary>
private void OnAggregatorTerminated(int siteId, IActorRef deadActor)
{
    lock (_lock)
    {
        if (!_sites.TryGetValue(siteId, out var entry) || !ReferenceEquals(entry.Actor, deadActor))
            return;

        entry.Actor = null;
        entry.HasPublished = false;
        entry.Current = Empty;

        if (entry.Subscribers.Count > 0 && !entry.Starting)
        {
            entry.Starting = true;
            entry.StartRetryTimer?.Dispose();
            entry.StartRetryTimer = new Timer(
                _ => { _ = StartAggregatorAsync(entry); },
                state: null,
                dueTime: _options.LiveAlarmCacheReconcileInterval,
                period: Timeout.InfiniteTimeSpan);
        }
    }
    _logger.LogWarning(
        "Live alarm aggregator for site {SiteId} terminated unexpectedly; liveness reset " +
        "(page falls back to polling) and a restart is armed while viewers remain", siteId);
}
  • ISiteAlarmLiveCache.IsLive xmldoc: replace "true once the site's aggregator has seeded and published at least once" with "true while a living aggregator has seeded and published; reverts to false if the aggregator terminates (R2 N6) — a frozen snapshot is never reported as live".
  1. Run the filter — PASS (all pre-existing lifecycle tests — shared start, linger stop, re-subscribe, cap, self-heal — must stay green; the linger-stop path removes the entry before the actor stops, so the new hook's TryGetValue guard keeps it inert there).
  2. Update Component-CentralUI.md:194: "…when a stream is unhealthy, the aggregator has died (deathwatch resets IsLive), or a site has not yet seeded, the poll keeps the page fresh…"
  3. Commit: git add src/ZB.MOM.WW.ScadaBridge.Communication/SiteAlarmLiveCacheService.cs src/ZB.MOM.WW.ScadaBridge.Communication/ISiteAlarmLiveCache.cs tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SiteAlarmLiveCacheServiceTests.cs docs/requirements/Component-CentralUI.md && git commit -m "fix(sitestream): deathwatch resets live-cache liveness on aggregator termination (plan R2-07 T10)"

Task 11: Amortize LoginThrottle.Prune off the failure hot path

Classification: standard Estimated implement time: ~4 min Parallelizable with: 1, 2, 4, 5, 7, 10, 12 Files:

  • Modify: src/ZB.MOM.WW.ScadaBridge.Security/LoginThrottle.cs (RecordFailure :121, Prune :146-169; new counter field)
  • Test: tests/ZB.MOM.WW.ScadaBridge.Security.Tests/LoginThrottleTests.cs

RecordFailurePrune enumerates all entries (and may OrderBy the whole map) on every failed bind — during the exact spray the throttle exists for, every request pays an O(n≤10k) scan (N8). Amortize: sweep every Nth failure, plus immediately whenever the map exceeds the hard cap (the cap stays the memory-safety invariant; only the expired-entry housekeeping is batched).

  1. Failing test (needs a diagnostics accessor — LoginThrottle already has InternalsVisibleTo for Security.Tests via the csproj):
[Fact]
public void KeySpray_MapStaysBounded_AtTheHardCap()
{
    var (throttle, _) = Create(); // existing helper: throttle over a fake clock
    // Spray far past the cap: the over-cap eviction must run on EVERY write once
    // the cap is exceeded, regardless of the amortized cadence (arch-review R2 N8).
    for (var i = 0; i < LoginThrottle.MaxTrackedKeys + 500; i++)
        throttle.RecordFailure($"user{i}", "10.0.0.1");
    Assert.True(throttle.TrackedKeyCount <= LoginThrottle.MaxTrackedKeys);
}

[Fact]
public void ExpiredEntries_AreEventuallySwept_OnTheAmortizedCadence()
{
    var (throttle, clock) = Create();
    throttle.RecordFailure("stale", "10.0.0.1");
    clock.Advance(TimeSpan.FromMinutes(10)); // window fully expired
    for (var i = 0; i < LoginThrottle.PruneEveryNFailures; i++)
        throttle.RecordFailure($"fresh{i}", "10.0.0.1"); // cadence tick fires within these
    Assert.False(throttle.IsLockedOut("stale", "10.0.0.1"));
    Assert.True(throttle.TrackedKeyCount <= LoginThrottle.PruneEveryNFailures); // "stale" swept
}
  1. Run: dotnet test tests/ZB.MOM.WW.ScadaBridge.Security.Tests --filter LoginThrottle — FAIL (compile: TrackedKeyCount/PruneEveryNFailures missing).
  2. Implement:
/// <summary>Amortization cadence: a full prune sweep runs every Nth failure write
/// (plus immediately whenever the map exceeds <see cref="MaxTrackedKeys"/>), so a
/// spray does not pay an O(n) scan — and a possible full sort — on every failed
/// request (arch-review R2 N8). Lockout SEMANTICS are unaffected: IsLockedOut reads
/// window/lockout expiry directly and never depends on pruning.</summary>
internal const int PruneEveryNFailures = 64;

/// <summary>Diagnostics/test accessor: number of currently tracked keys.</summary>
internal int TrackedKeyCount => _entries.Count;

private int _failureWrites;

Replace the unconditional Prune(now); at :121 with:

// Amortized prune (R2 N8): every Nth failure, or immediately when over the hard
// cap — the cap is the memory-safety invariant and must never wait for the cadence.
if (Interlocked.Increment(ref _failureWrites) % PruneEveryNFailures == 0
    || _entries.Count > MaxTrackedKeys)
{
    Prune(now);
}
  1. Run the filter — PASS, and ALL seven pre-existing throttle tests must stay green unchanged (they assert lockout semantics, which never depended on pruning).
  2. Commit: git add src/ZB.MOM.WW.ScadaBridge.Security/LoginThrottle.cs tests/ZB.MOM.WW.ScadaBridge.Security.Tests/LoginThrottleTests.cs && git commit -m "fix(security): amortize LoginThrottle.Prune off the failed-bind hot path (plan R2-07 T11)"

Task 12: Lock in the scrubber's fragment coverage + document the array-merge limitation

Classification: standard Estimated implement time: ~5 min Parallelizable with: 1, 2, 4, 5, 7, 10, 11 Files:

  • Modify: src/ZB.MOM.WW.ScadaBridge.ManagementService/ConfigSecretScrubber.cs (IsSecretName :21 → internal; MergeSentinels xmldoc :121-126)
  • Test: tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ConfigSecretScrubberTests.cs (extend)

Detection is name-fragment-based (password|secret|token|apikey|credential|passphrase, :18-19). Current config types are fully covered, but a future field named SigningKey or Pin would silently leak (N9). Freeze the coverage the same way RequiredRoleMatrixTests froze the authz table: enumerate every string property on the DataConnections config types and demand each is either fragment-matched (scrubbed) or on a deliberate non-secret allowlist.

  1. Change IsSecretName from private to internal (the csproj already has InternalsVisibleTo for the test project). Write the failing test:
using System.Reflection;
using ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections;

// Frozen non-secret allowlist (arch-review R2 N9), mirroring RequiredRoleMatrixTests:
// every string-typed property on the DataConnections config types must EITHER match
// the scrubber's secret fragments (it gets elided) OR be listed here after a
// deliberate "this is not a secret" decision. A new string property with neither
// fails EveryConfigStringProperty_IsScrubbedOrDeliberatelyNonSecret, so a future
// "SigningKey"/"Pin" cannot silently leak through List/Get/audit.
private static readonly Dictionary<Type, string[]> KnownNonSecretStringProperties = new()
{
    [typeof(OpcUaEndpointConfig)] = ["EndpointUrl", "SubscriptionDisplayName"],
    [typeof(OpcUaUserIdentityConfig)] = ["Username", "CertificatePath"],
    [typeof(MxGatewayEndpointConfig)] = ["Endpoint", "ClientName", "CaFile", "ServerName"],
    [typeof(OpcUaHeartbeatConfig)] = [/* enumerate on first run; likely none */],
    [typeof(OpcUaDeadbandConfig)] = [/* likely none */],
};

[Fact]
public void EveryConfigStringProperty_IsScrubbedOrDeliberatelyNonSecret()
{
    foreach (var (type, allowlist) in KnownNonSecretStringProperties)
    {
        var stringProps = type.GetProperties(BindingFlags.Public | BindingFlags.Instance)
            .Where(p => p.PropertyType == typeof(string))
            .Select(p => p.Name);
        foreach (var name in stringProps)
        {
            var scrubbed = ConfigSecretScrubber.IsSecretName(name);
            var allowlisted = allowlist.Contains(name);
            Assert.True(scrubbed ^ allowlisted,
                $"{type.Name}.{name}: must be EITHER fragment-scrubbed OR explicitly " +
                "allowlisted as non-secret (and never both) — see arch-review R2 N9.");
        }
    }
}

[Theory]
[InlineData("Password")]
[InlineData("CertificatePassword")]
[InlineData("ApiKey")]
public void KnownSecretProperties_MatchTheFragmentList(string name)
    => Assert.True(ConfigSecretScrubber.IsSecretName(name));

Cross-check the allowlist against the actual types before committing (src/ZB.MOM.WW.ScadaBridge.Commons/Types/DataConnections/OpcUaEndpointConfig.cs, OpcUaUserIdentityConfig.cs, MxGatewayEndpointConfig.cs, OpcUaHeartbeatConfig.cs, OpcUaDeadbandConfig.cs); the XOR also locks the secret side (a rename of Password off the fragment list fails the test). 2. Run: dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter ConfigSecretScrubber — FAIL first on compile (IsSecretName private), then green the assertions. 3. Append to the MergeSentinels xmldoc (:121-126) the N9 limitation, so the by-index behavior is a documented contract rather than a surprise:

/// <b>Array limitation (arch-review R2 N9):</b> sentinel restoration inside arrays
/// pairs incoming[i] with stored[i] BY INDEX. A client that reorders an array of
/// credentialed objects while round-tripping sentinels grafts the wrong stored
/// secret into each slot — a silent misconfiguration (never a disclosure). Clients
/// must not reorder arrays that carry sentinels (the CLI round-trip does not);
/// key-based matching is a possible future refinement if a config type ever nests
/// credentialed arrays.
  1. Run the filter — PASS; full dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests run.
  2. Commit: git add src/ZB.MOM.WW.ScadaBridge.ManagementService/ConfigSecretScrubber.cs tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ConfigSecretScrubberTests.cs && git commit -m "test(management): freeze scrubber fragment coverage over DataConnection config types + document array-merge limit (plan R2-07 T12)"

Dependencies on other plans

  • PLAN-R2-02 (Communication / site stream): shares SiteAlarmLiveCacheService.csTask 10 and R2-02's delta-coalescing task are mutually exclusive in time (see the mutex rule at the top). The gRPC/site half of the live-alarm path (stream reconnect, SubscribeSite semantics) belongs to R2-02; Task 10 touches only the central service's liveness bookkeeping.
  • ManagementActor single-writer lane: only this plan (Tasks 4, 6) touches ManagementActor.cs in round 2, but the initiative-wide rule stands — serialize against any other plan's ManagementActor task that may appear.
  • Round-1 documented deferrals stay put: S1's client-supplied import-id idempotency remains plan-05 domain; S4 streaming multipart, P2 QueryDeployments/ExportBundle projections, and the UA6 fleet-wide grid a11y sweep remain in docs/plans/2026-07-08-deferred-work-register.md. No round-2 task may "helpfully" absorb them.

Execution order

P0 (security-correctness first): Task 1 (N1) and Task 2 → 3 (N2) and Task 4 → 6 (N3, with Task 5 landing any time before 6) — these five are the Medium security findings. Then Task 7 (N4, the operator-facing Medium) → 8 → 9 on the AlarmSummary lane; Tasks 10, 11, 12 land any time (10 subject to the R2-02 mutex).

Ordering constraints: 3←2; 6←(4,5); 8←7; 9←8. Serialize {4, 6} (ManagementActor lane) and {7, 8, 9} (AlarmSummary.razor). Everything else is file-disjoint.

Milestone verification: at plan completion run dotnet build ZB.MOM.WW.ScadaBridge.slnx and dotnet test ZB.MOM.WW.ScadaBridge.slnx (targeted filters during tasks; the full sweep only here), then rebuild the docker cluster (bash docker/deploy.sh) for a smoke: a wrong-password CLI login 401s then 429s on the 6th try (throttle through Traefik with the real client IP), secured-write list under multi-role still works, and the Alarm Summary page live-updates then survives a site switch mid-poll.


Findings Coverage

Finding Severity Disposition
N1 — DebugStreamHub binds LDAP with no throttle; Component-Security.md:208 falsely claims complete coverage Medium Task 1 (hub delegates to ManagementAuthenticator.AuthenticateAsync; doc corrected in the same task)
N2 — LoginThrottle keys collapse behind Traefik: no ForwardedHeaders handling anywhere Medium Tasks 2, 3 (trusted-proxy UseForwardedHeaders bound from config; docker/docker-env2/deploy config shipped; lockout-DoS trade-off + mitigation documented; throttle-test assumption noted)
N3 — Secured-write handlers ignore site scope while the spec says scoping is layered on at the LDAP-mapping level Medium Tasks 4, 5, 6 (enforce at submit/approve/reject via existing EnforceSiteScope* helpers; scoped list at the query level; Component-Security.md:141 amended to match; frozen matrix verified — no role changes, no regeneration needed)
N4 — RefreshAsync applies fan-out results without re-checking the selected site (cross-site data after a switch) Medium Task 7 (one-line guard mirroring the live path's :374 check + bUnit regression)
N5 — When live, a slower poll snapshot can regress fresher live state Low Task 8 (poll updates only _notReporting while IsLive; stale comment fixed)
N6 — IsLive is sticky: HasPublished never resets on aggregator death Low Task 10 (deathwatch → liveness reset + snapshot clear + self-heal restart; interface doc updated). Mutex with PLAN-R2-02's coalescing task.
N7 — Live-callback InvokeAsync lacks the house disposal guard Low Task 9 (_disposed set before teardown, checked before and inside the marshal — DebugView pattern)
N8 — LoginThrottle.Prune scans (and may sort) the whole map on every failed bind Low Task 11 (every-Nth-failure cadence + immediate over-cap sweep; semantics unchanged)
N9 — Scrubber array merge is by-index; fragment list has no lock-in against future config fields Low Task 12 (frozen XOR coverage test over the DataConnections config types; array-by-index limitation documented as contract)
Round-1 residue S1 — import idempotency on a client-supplied id High (residual) Remains deferred (round-1 documented deferral; plan-05 / Transport apply-path domain; tracked in the deferred-work register)
Round-1 residue S4 — streaming multipart bundle upload Medium (residual) Remains deferred (bounded by the round-1 TransportGate single-flight; deferred-work register)
Round-1 residue P2 — QueryDeployments/ExportBundle load-all internals Medium (residual) Remains deferred (bounded by fleet size; needs repo-level projections; deferred-work register)
Round-1 residue UA6 — fleet-wide sortable-grid a11y sweep UA (residual) Remains deferred (uniform low-severity UX debt; logged in the AlarmSummary component comment + deferred-work register)
Round-1 residue P1/UA5 — the one missed bind surface Medium (residual) Owned here as N1 → Task 1 (no separate row-work)