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

981 lines
67 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:
```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 78137 (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) |