# 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 78–146: 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:
```csharp
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:
```csharp
[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 78–137 (the `TryDisableLoginUser` block, the Basic-decode block, the direct `ILdapAuthService` bind, and the `RoleMapper` call) and replace with:
```csharp
// 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 `ManagementAuthenticator` — `POST /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:
```csharp
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] }));
}
```
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.Security.Tests --filter ForwardedHeadersSetup` — FAIL (types missing).
3. Implement `ForwardedHeadersSetup.cs`:
```csharp
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`:
```csharp
// 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);
```
5. Run the filter — PASS; `dotnet build ZB.MOM.WW.ScadaBridge.slnx` — clean; `dotnet test tests/ZB.MOM.WW.ScadaBridge.Security.Tests` — no regressions.
6. 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):
```json
"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.
4. `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`.
5. `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.
6. 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`):
```csharp
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:
```csharp
[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) */ }
```
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter SecuredWrite` — new tests FAIL (no scope check exists).
3. 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:
```csharp
// 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:
```csharp
// 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`.
4. 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.
5. 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"`):
```csharp
[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 */ }
```
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests --filter SecuredWriteRepository` — FAIL (no such parameter).
3. Implement — additive optional parameter before the `CancellationToken` on both members (interface + impl):
```csharp
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:
```csharp
if (permittedSiteIds is not null)
    query = query.Where(w => permittedSiteIds.Contains(w.SiteId));
```
4. Run the filter — PASS; full project run (existing positional callers compile unchanged — the parameter is trailing-optional).
5. 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:
```csharp
[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)`:
```csharp
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)
```
4. 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.
5. 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."
6. 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):
```csharp
[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);
    });
}
```
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter AlarmSummary` — new test FAILS (StaleSite1 overwrites the page).
3. Implement — one guard in `RefreshAsync`, immediately after the await at :293:
```csharp
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):
```csharp
[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']"));
    });
}
```
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter AlarmSummary` — FAILS (the poll rebuilds Zeta over Gamma).
3. Implement — in `RefreshAsync`, after Task 7's stale-site guard, replace the unconditional assignments:
```csharp
// _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):
```csharp
// 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));
}
```
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter AlarmSummary` — FAILS (the late callback re-applies the empty snapshot: zero rows).
3. Implement — mirror `DebugView.razor`:
```csharp
// 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();
}
```
4. Run the filter — PASS (including `DisposingComponent_UnsubscribesFromTheLiveCache`).
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): 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 `ActorSystem` — `SiteAlarmLiveCacheServiceTests` — and the aggregator is a real child at `/user/site-alarm-aggregator-{siteId}-{guid}`):
```csharp
[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
}
```
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.Communication.Tests --filter SiteAlarmLiveCache` — first test FAILS (`IsLive` stays true after the stop).
3. Implement:
   - In `StartAggregatorAsync`, inside the lock right after `entry.Actor = system.ActorOf(props, ...)` (:238), capture and watch:
```csharp
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:
```csharp
/// <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".
4. 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).
5. 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…"
6. 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`

`RecordFailure` → `Prune` 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):
```csharp
[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
}
```
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.Security.Tests --filter LoginThrottle` — FAIL (compile: `TrackedKeyCount`/`PruneEveryNFailures` missing).
3. Implement:
```csharp
/// <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:
```csharp
// 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);
}
```
4. Run the filter — PASS, and ALL seven pre-existing throttle tests must stay green unchanged (they assert lockout semantics, which never depended on pruning).
5. 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:
```csharp
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.
```
4. Run the filter — PASS; full `dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests` run.
5. 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.cs` — **Task 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) |
