# UI, Management & Security Hardening Implementation Plan

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

**Goal** — Close every finding from architecture review 07 (`archreview/07-ui-management-security.md`): kill the auth-disabled production artifact, honor per-site artifact deployment with a site-scope check, elide connection/external-system/DB secrets everywhere SMTP/SMS already are, make secured-write expiry real server-side, fix the 30s-vs-5min Ask-timeout mismatch, give the CLI actor-side browse/verify/cert-trust parity, canonicalize role casing, throttle LDAP binds, and freeze the authorization table with a matrix test.

**Architecture** — All fixes stay inside the existing management/presentation seam: the `ManagementActor` (single dispatch switch + `GetRequiredRole` gate + `PipeTo`), the `ManagementAuthenticator` Basic→LDAP flow shared by every CLI surface, the Security component's cookie/JWT/policy layer, and the Blazor Central UI. New primitives are small and testable in isolation (`DisableLoginGuard`, `ConfigSecretScrubber`, `LoginThrottle`, `PollGate`), wired at the existing composition points. Message-contract changes are strictly additive (optional trailing record params, new enum members) per the repo's contract-evolution rule; every spec-affecting change updates `docs/requirements/Component-Security.md` / `Component-ManagementService.md` / `Component-CentralUI.md` / `Component-CLI.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 + Blazor Server (bUnit), EF Core (ConfigurationDatabase), System.CommandLine (CLI). Build: `dotnet build ZB.MOM.WW.ScadaBridge.slnx`. Test per-project: `dotnet test tests/<project>` — **CLI tests must be run directly** (`dotnet test tests/ZB.MOM.WW.ScadaBridge.CLI.Tests`) until Task 33 lands.

---

## Parallelization (incomplete tasks) — 2026-07-09

T1–T3 complete. **Ready now** (`blockedBy` ⊆ {1,2,3}): **{4, 6, 8, 9, 10, 12, 14, 19, 21, 26, 28, 29, 30, 31, 32, 33}**.

**This plan's defining constraint:** 15 tasks edit **`ManagementActor.cs`** (T5, T7, T8, T9, T10, T11, T12, T15, T16, T17, T22, T23, T28, T30, T31) — they **serialize into one lane**, and that lane is shared initiative-wide with PLAN-05 T13 and PLAN-06 (T2/T3/T11/T20/T22).

**Concurrent lanes:**
- **Global ManagementActor lane (one at a time):** ready members = **{8, 9, 10, 12, 28, 30, 31}**. **Start with T10** (GetRequiredRoles refactor) — it unblocks T11, T13, T22, T23.
- **Free / file-disjoint (run as concurrent implementers):** T4 (`ArtifactDeploymentService.cs` — coordinate later with PLAN-06 T10), T6 (`ConfigSecretScrubber.cs`), T14 (`SecuredWriteRepository`), T19 (`ManagementEndpoints`/options), T21 (Commons Browse/Verify/Cert message records), T26 (Security `LoginThrottle`), and **T29 ↔ T32** (both edit `AlarmSummary.razor` — serialize the pair).
- **Skip:** **T33** — CLI.Tests is already in the slnx via PLAN-08 T1 (done); mark `skipped-duplicate` in the `.tasks.json`, don't execute.

See [00-MASTER-TRACKER.md § Parallelization Map](00-MASTER-TRACKER.md#parallelization-map--incomplete-tasks-2026-07-09) for the initiative-wide mutex table.

## Findings Coverage

| Finding | Severity | Disposition |
|---|---|---|
| C1 — `DisableLogin: true` in production deploy artifact, no environment guard | Critical | Tasks 1, 2, 3 |
| C2 — `deploy artifacts --site-id` silently ignored; no site-scope check | High | Tasks 4, 5 |
| C3 — DataConnection/ExternalSystem/DB-connection secrets leak in List/Get + audit | High | Tasks 6, 7, 8, 9 |
| S1 — 30s server Ask vs 5-min bundle/deploy operations; import keeps applying after 504 | High | Tasks 19, 20. Client-supplied import-id idempotency: **Deferred** — Transport apply-path change, plan 05's domain (coordinate; single-flight in Task 30 also narrows the double-apply window) |
| S2 — Pending secured writes never expire; "Expired" status fictional | High | Tasks 14, 15, 16, 18 |
| S3 — Poll timers can overlap their own refreshes | Medium | Task 29 |
| S4 — 200 MB bodies buffered as strings, ~4 copies resident | Medium | Task 30 (single-flight + copy elimination). Streaming multipart upload: **Deferred** — transport re-plumb of the JSON envelope; bounded risk after Task 30 |
| S5 — Fire-and-forget SignalR sends swallow failures | Low | Task 31 |
| P1 — Full LDAP bind per request, no throttle/lockout (password-spray oracle) | Medium | Tasks 26, 27 |
| P2 — Unbounded list handlers materialize whole tables | Medium | Task 28 (ListTemplates/ListInstances additive paging). QueryDeployments/ExportBundle internals: **Deferred** — bounded by fleet size today; needs repo-level projections, low risk |
| P3 — `ListSecuredWrites` hard-caps at 200, no paging | Medium | Tasks 14, 17, 18 |
| P4 — Alarm Summary re-sorts/filters per render | Low | Task 32 |
| C4 — Browse/Verify/Cert commands doc'd as actor commands but UI-only (no CLI parity) | Medium | Tasks 21, 22, 23, 24, 25 |
| C5 — Role-casing asymmetry (UI ordinal vs actor ignore-case), acknowledged deferral | Medium | Task 12 |
| C6 — Area role gate: code says Designer, three docs say Admin, UI page says Deployer | Medium | Task 11 |
| C7 — `InstanceName` passed into slot commented "InstanceId filter" | Low | Task 31 (verified: site `instance_id` column stores the instance **UniqueName** — `InstanceActor.LogLifecycleEvent` passes `_instanceUniqueName` — so behavior is correct; comment/xmldoc are wrong) |
| C8 — Stale CLAUDE.md note on SecuredWrite `SourceNode` | Low | Task 31 |
| UA1 — Secured-write lifecycle (no expiry, no paging, history ungated, dual-role hazard) | UA | Tasks 10, 14–18; dual-role deployment hazard restated in docs in Task 15 |
| UA2 — ManagementService test depth (~150 commands, no authz matrix test) | UA | Tasks 13, 24 (+ handler tests added by every task above) |
| UA3 — CLI.Tests not in slnx | UA | Task 33 |
| UA4 — No single tracking list for deferred follow-ups | UA | **Deferred to plan 08**, which owns the consolidated 23-item deferred-work inventory |
| UA5 — Login endpoints lack throttling; user-enumeration check | UA | Tasks 26, 27. Enumeration check **verified safe, no change**: `LdapAuthFailureMessages` maps `UserNotFound` and `BadCredentials` to the identical "Invalid username or password." |
| UA6 — Accessibility debt on sortable headers/custom grids | UA | Task 32 (AlarmSummary, the cited file). Fleet-wide grid a11y sweep: **Deferred** — uniform low-severity UX debt, log as follow-up |
| UA7 — "Committed" runtime logs under docker topology | UA | **Won't-fix** — verified false premise: `git check-ignore docker/central-node-a/logs` → ignored, `git ls-files docker | grep -i log` → empty. Logs are untracked local output; nothing to scrub |

---

### Task 1: `DisableLoginGuard` — refuse the flag outside Development

**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** 4, 6, 14, 19, 21, 26, 29
**Files:**
- Create: `src/ZB.MOM.WW.ScadaBridge.Security/Auth/DisableLoginGuard.cs`
- Modify: `src/ZB.MOM.WW.ScadaBridge.Security/Auth/AuthDisableLoginOptions.cs` (add `AllowOutsideDevelopment` property)
- Test: `tests/ZB.MOM.WW.ScadaBridge.Security.Tests/DisableLoginGuardTests.cs` (create)

1. Write the failing test:
```csharp
using ZB.MOM.WW.ScadaBridge.Security.Auth;
using Xunit;

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

public class DisableLoginGuardTests
{
    [Theory]
    [InlineData("Production")]
    [InlineData("Staging")]
    [InlineData("")]
    public void Validate_DisableLoginOutsideDevelopment_WithoutAck_Throws(string env)
    {
        var ex = Assert.Throws<InvalidOperationException>(
            () => DisableLoginGuard.Validate(disableLogin: true, environmentName: env, allowOutsideDevelopment: false));
        Assert.Contains("DisableLogin", ex.Message);
        Assert.Contains("Development", ex.Message);
    }

    [Theory]
    [InlineData("Development", false)] // dev env: allowed without ack
    [InlineData("development", false)] // case-insensitive env name
    [InlineData("Production", true)]   // explicit second ack key
    public void Validate_Allowed_DoesNotThrow(string env, bool ack)
        => DisableLoginGuard.Validate(true, env, ack);

    [Fact]
    public void Validate_FlagOff_NeverThrows()
        => DisableLoginGuard.Validate(false, "Production", false);
}
```
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.Security.Tests --filter DisableLoginGuardTests` — expect FAIL (type missing).
3. Implement `DisableLoginGuard.cs`:
```csharp
namespace ZB.MOM.WW.ScadaBridge.Security.Auth;

/// <summary>
/// Startup guard for the dev/test <see cref="AuthDisableLoginOptions.DisableLogin"/> flag.
/// DisableLogin authenticates EVERY request with ALL roles (including Operator+Verifier,
/// nullifying the two-person secured-write control) on a SCADA control surface, so it is
/// refused outside the Development environment unless the operator sets the explicit
/// second acknowledgement key <see cref="AuthDisableLoginOptions.AllowOutsideDevelopment"/>.
/// Called by the Host composition root before AddSecurity(); fail-fast at boot.
/// </summary>
public static class DisableLoginGuard
{
    public static void Validate(bool disableLogin, string environmentName, bool allowOutsideDevelopment)
    {
        if (!disableLogin) return;
        if (string.Equals(environmentName, "Development", StringComparison.OrdinalIgnoreCase)) return;
        if (allowOutsideDevelopment) return;
        throw new InvalidOperationException(
            "ScadaBridge:Security:Auth:DisableLogin=true disables ALL authentication on a SCADA control " +
            $"surface and is refused outside the Development environment (current: '{environmentName}'). " +
            "Remove the flag, run with ASPNETCORE_ENVIRONMENT=Development, or — only if you fully accept an " +
            "unauthenticated deployment — additionally set " +
            "ScadaBridge:Security:Auth:AllowOutsideDevelopment=true.");
    }
}
```
Add to `AuthDisableLoginOptions`:
```csharp
/// <summary>Explicit second acknowledgement key: permits DisableLogin outside Development. Default false.</summary>
public bool AllowOutsideDevelopment { get; set; }
```
4. Run the filter again — expect PASS. Run full project: `dotnet test tests/ZB.MOM.WW.ScadaBridge.Security.Tests`.
5. Commit: `git add src/ZB.MOM.WW.ScadaBridge.Security/Auth tests/ZB.MOM.WW.ScadaBridge.Security.Tests/DisableLoginGuardTests.cs && git commit -m "feat(security): DisableLoginGuard refuses DisableLogin outside Development without explicit ack"`

### Task 2: Wire the guard at the Host composition root + doc

**Classification:** high-risk
**Estimated implement time:** ~4 min
**Parallelizable with:** none (waits on Task 1)
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.Host/Program.cs` (~lines 130–138, immediately after `disableLogin` is read)
- Modify: `docs/requirements/Component-Security.md` §Dev Disable-Login Flag (~line 27)
- Test: `tests/ZB.MOM.WW.ScadaBridge.Host.Tests/` — inspect harness; if no config-composition test seam exists, verification is the Security.Tests guard suite (Task 1) + build + step 4 smoke

1. In `Program.cs`, after line 137 (`GetValue<bool>(...DisableLogin)`), insert:
```csharp
// Fail-fast guard: DisableLogin outside Development requires the explicit
// AllowOutsideDevelopment acknowledgement key. See DisableLoginGuard.
ZB.MOM.WW.ScadaBridge.Security.Auth.DisableLoginGuard.Validate(
    disableLogin,
    builder.Environment.EnvironmentName,
    builder.Configuration
        .GetSection(ZB.MOM.WW.ScadaBridge.Security.Auth.AuthDisableLoginOptions.SectionName)
        .GetValue<bool>(nameof(ZB.MOM.WW.ScadaBridge.Security.Auth.AuthDisableLoginOptions.AllowOutsideDevelopment)));
```
2. Update `Component-Security.md` §Dev Disable-Login Flag: document the guard, the `AllowOutsideDevelopment` ack key, and fix the stale "all four roles" → "all six roles (Administrator, Designer, Deployer, Viewer, Operator, Verifier)" while in the section.
3. `dotnet build ZB.MOM.WW.ScadaBridge.slnx` — expect clean.
4. Smoke (docker cluster is unaffected — `docker/docker-compose.yml` sets `ASPNETCORE_ENVIRONMENT: Development` and both central nodes have `DisableLogin: false`): optionally run the Host locally with `ASPNETCORE_ENVIRONMENT=Production` and `ScadaBridge__Security__Auth__DisableLogin=true`, assert it exits with the guard message.
5. Commit: `git add src/ZB.MOM.WW.ScadaBridge.Host/Program.cs docs/requirements/Component-Security.md && git commit -m "feat(host): fail-fast DisableLogin environment guard at composition root"`

### Task 3: Scrub `deploy/wonder-app-vd03` — remove `DisableLogin: true`

**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** 1, 4, 6, 14, 19, 21, 26, 29
**Files:**
- Modify: `deploy/wonder-app-vd03/appsettings.Central.json` (lines 39–42 `"Auth"` block; line 44 `_comment_Security`)
- Modify: `deploy/wonder-app-vd03/RUNBOOK.md` (add a login note)

1. Delete the block:
```json
      "Auth": {
        "DisableLogin": true,
        "User": "multi-role"
      }
```
(and the now-trailing comma on `"RequireHttpsCookie": false`'s following line structure — validate JSON).
2. Append to `_comment_Security`: "Login is REQUIRED on this host: authenticate as one of the GLAuth test users (e.g. multi-role/password) or repoint to corporate AD. DisableLogin is refused outside Development by a startup guard."
3. In `RUNBOOK.md`, add a short "Authentication" note: the UI (:8080) and management API (:8085) now require credentials; CLI calls need `--username/--password`; the previous unauthenticated posture was removed (arch-review C1).
4. Validate: `python3 -c "import json;json.load(open('deploy/wonder-app-vd03/appsettings.Central.json'))"` — expect no error.
5. Commit: `git add deploy/wonder-app-vd03 && git commit -m "fix(deploy): remove DisableLogin=true from wonder-app-vd03 production artifact (arch-review C1)"`
6. Note for the operator relaying this plan: the user-memory file `wonder-app-vd03-host-access.md` records "DisableLogin=true makes :8085 /management unauthenticated" — that note becomes stale on the next redeploy of this artifact.

### Task 4: `ArtifactDeploymentService.DeployToSiteAsync`

**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** 1, 3, 6, 14, 19, 21, 26, 29
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.DeploymentManager/ArtifactDeploymentService.cs` (refactor `DeployToAllSitesAsync` ~lines 227–345)
- Test: `tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/` — extend the existing `ArtifactDeploymentService*` test file (locate with `grep -rl ArtifactDeploymentService tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests`)

1. Write failing tests (mirror the existing harness's mocks for `ISiteRepository`/`CommunicationService`):
```csharp
[Fact]
public async Task DeployToSiteAsync_DeploysOnlyToTargetSite()
{
    // harness: two sites (Id 1 "SITE1", Id 2 "SITE2") in the site repo mock
    var result = await _service.DeployToSiteAsync(1, "tester");
    Assert.True(result.IsSuccess);
    Assert.Single(result.Value.SiteResults);
    Assert.Equal("SITE1", result.Value.SiteResults[0].SiteId);
    await _communicationService.Received(1).DeployArtifactsAsync(
        "SITE1", Arg.Any<DeployArtifactsCommand>(), Arg.Any<CancellationToken>());
    await _communicationService.DidNotReceive().DeployArtifactsAsync(
        "SITE2", Arg.Any<DeployArtifactsCommand>(), Arg.Any<CancellationToken>());
}

[Fact]
public async Task DeployToSiteAsync_UnknownSite_ReturnsFailure()
{
    var result = await _service.DeployToSiteAsync(999, "tester");
    Assert.False(result.IsSuccess);
    Assert.Contains("999", result.Error);
}
```
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests --filter DeployToSiteAsync` — expect FAIL (no such method).
3. Implement: extract the body of `DeployToAllSitesAsync` after the `sites` fetch into `private async Task<Result<ArtifactDeploymentSummary>> DeployCoreAsync(IReadOnlyList<Site> sites, string user, CancellationToken ct)` (everything from `deploymentId` creation down, unchanged). Then:
```csharp
public async Task<Result<ArtifactDeploymentSummary>> DeployToAllSitesAsync(
    string user, CancellationToken cancellationToken = default)
{
    var sites = await _siteRepo.GetAllSitesAsync(cancellationToken);
    if (sites.Count == 0)
        return Result<ArtifactDeploymentSummary>.Failure("No sites configured.");
    return await DeployCoreAsync(sites, user, cancellationToken);
}

/// <summary>Deploys system-wide artifacts to a single site (management-API --site-id path).</summary>
public async Task<Result<ArtifactDeploymentSummary>> DeployToSiteAsync(
    int siteId, string user, CancellationToken cancellationToken = default)
{
    var site = await _siteRepo.GetSiteByIdAsync(siteId, cancellationToken);
    if (site is null)
        return Result<ArtifactDeploymentSummary>.Failure($"Site with ID {siteId} not found.");
    return await DeployCoreAsync([site], user, cancellationToken);
}
```
(Match the actual `ISiteRepository.GetSiteByIdAsync` signature — check whether it takes a CancellationToken.)
4. Run the filter — expect PASS. Run `dotnet test tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests` (no regression in the all-sites tests).
5. Commit: `git add src/ZB.MOM.WW.ScadaBridge.DeploymentManager tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests && git commit -m "feat(deployment): DeployToSiteAsync for single-site artifact deployment"`

### Task 5: `HandleDeployArtifacts` honors `SiteId` + site-scope enforcement + docs

**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** none (waits on Task 4; touches ManagementActor.cs — serialize with 7–13, 15–18, 22, 23, 30)
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs` (`HandleDeployArtifacts` lines 2006–2013; dispatch arm ~line where `MgmtDeployArtifactsCommand cmd =>` appears, currently passes `user.Username` — pass `user`)
- Modify: `docs/requirements/Component-ManagementService.md` (deployment commands section), `docs/requirements/Component-CLI.md` + `src/ZB.MOM.WW.ScadaBridge.CLI/README.md` (`--site-id` semantics: now honored, and fleet-wide requires system-wide deployment rights)
- Test: `tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs`

1. Write failing tests (using the existing `Envelope`/`ScopedEnvelope` helpers; register a substituted `ArtifactDeploymentService` in `_services` — if it is a concrete class, extract-or-virtualize is out of scope: register the real class with mocked dependencies exactly as the DeploymentManager tests construct it, or Substitute if members are virtual; follow whatever `SecuredWriteHandlerTests` does for `CommunicationService`):
```csharp
[Fact]
public void DeployArtifacts_WithSiteId_CallsSingleSiteDeploy()
{
    var actor = CreateActor();
    actor.Tell(Envelope(new MgmtDeployArtifactsCommand(SiteId: 7), "Deployer"));
    ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
    _artifactService.Received(1).DeployToSiteAsync(7, "testuser", Arg.Any<CancellationToken>());
}

[Fact]
public void DeployArtifacts_SiteScopedUser_OutOfScopeSite_Unauthorized()
{
    var actor = CreateActor();
    actor.Tell(ScopedEnvelope(new MgmtDeployArtifactsCommand(SiteId: 7), ["3"], "Deployer"));
    ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
}

[Fact]
public void DeployArtifacts_SiteScopedUser_FleetWide_Unauthorized()
{
    var actor = CreateActor();
    actor.Tell(ScopedEnvelope(new MgmtDeployArtifactsCommand(SiteId: null), ["3"], "Deployer"));
    var resp = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
    Assert.Contains("fleet-wide", resp.Message, StringComparison.OrdinalIgnoreCase);
}
```
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter DeployArtifacts` — expect FAIL.
3. Implement:
```csharp
private static async Task<object?> HandleDeployArtifacts(
    IServiceProvider sp, MgmtDeployArtifactsCommand cmd, AuthenticatedUser user)
{
    // A site-scoped Deployer may not trigger a FLEET-WIDE artifact deployment:
    // EnforceSiteScope(null) is a no-op by design, so the fleet-wide case needs
    // its own guard (arch-review C2 authorization gap).
    if (cmd.SiteId is null
        && user.PermittedSiteIds.Length > 0
        && !user.Roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase))
    {
        throw new SiteScopeViolationException(
            "Site-scoped users cannot deploy artifacts fleet-wide; specify a target site id.");
    }
    EnforceSiteScope(user, cmd.SiteId);

    var svc = sp.GetRequiredService<ArtifactDeploymentService>();
    var result = cmd.SiteId is int siteId
        ? await svc.DeployToSiteAsync(siteId, user.Username)
        : await svc.DeployToAllSitesAsync(user.Username);
    return result.IsSuccess
        ? result.Value
        : throw new ManagementCommandException(result.Error);
}
```
Change the dispatch arm from `HandleDeployArtifacts(sp, cmd, user.Username)` to `HandleDeployArtifacts(sp, cmd, user)`. (Check `SiteScopeViolationException`'s ctor; match `EnforceSiteScope`'s usage.)
4. Run the filter — expect PASS; run the full project.
5. Update the three docs (step Files list) — `--site-id` now deploys to exactly that site; fleet-wide requires system-wide Deployer or Administrator.
6. Commit: `git add src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests docs/requirements/Component-ManagementService.md docs/requirements/Component-CLI.md src/ZB.MOM.WW.ScadaBridge.CLI/README.md && git commit -m "fix(management): honor MgmtDeployArtifactsCommand.SiteId + enforce site scope (arch-review C2)"`

### Task 6: `ConfigSecretScrubber` — JSON secret elision + sentinel merge

**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** 1, 3, 4, 14, 19, 21, 26, 29
**Files:**
- Create: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ConfigSecretScrubber.cs`
- Test: `tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ConfigSecretScrubberTests.cs` (create)

1. Failing tests:
```csharp
public class ConfigSecretScrubberTests
{
    [Fact]
    public void Scrub_RedactsPasswordFields_AtAnyDepth()
    {
        var json = """{"Endpoint":"opc.tcp://x","UserIdentity":{"Username":"u","Password":"p"},"CertificatePassword":"cp"}""";
        var (scrubbed, had) = ConfigSecretScrubber.Scrub(json);
        Assert.True(had);
        Assert.DoesNotContain("\"p\"", scrubbed);
        Assert.DoesNotContain("cp", scrubbed);
        Assert.Contains(ConfigSecretScrubber.Sentinel, scrubbed);
        Assert.Contains("opc.tcp://x", scrubbed); // non-secrets untouched
    }

    [Fact]
    public void Scrub_NoSecrets_ReturnsFalseFlag()
    {
        var (_, had) = ConfigSecretScrubber.Scrub("""{"Endpoint":"opc.tcp://x"}""");
        Assert.False(had);
    }

    [Theory]
    [InlineData(null)]
    [InlineData("")]
    [InlineData("not-json{")]
    public void Scrub_NullEmptyOrMalformed_PassesThrough(string? input)
    {
        var (scrubbed, had) = ConfigSecretScrubber.Scrub(input);
        Assert.Equal(input, scrubbed);
        Assert.False(had);
    }

    [Fact]
    public void MergeSentinels_RestoresStoredSecrets_KeepsEdits()
    {
        var stored = """{"Endpoint":"old","UserIdentity":{"Password":"real"}}""";
        var incoming = $$"""{"Endpoint":"new","UserIdentity":{"Password":"{{ConfigSecretScrubber.Sentinel}}"}}""";
        var merged = ConfigSecretScrubber.MergeSentinels(incoming, stored)!;
        Assert.Contains("\"new\"", merged);
        Assert.Contains("\"real\"", merged);
        Assert.DoesNotContain(ConfigSecretScrubber.Sentinel, merged);
    }
}
```
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter ConfigSecretScrubberTests` — FAIL.
3. Implement with `System.Text.Json.Nodes.JsonNode` recursion:
```csharp
/// <summary>
/// Elides secret-bearing values inside opaque connection-config JSON blobs before
/// they leave the management boundary (responses AND audit afterState), mirroring
/// the SmtpConfigPublicShape/SmsConfigPublicShape pattern for structured entities.
/// A property is secret-bearing when its name contains one of the fragments below
/// (case-insensitive) and its value is a JSON string. MergeSentinels lets a client
/// round-trip a scrubbed config through update without wiping stored secrets.
/// </summary>
internal static class ConfigSecretScrubber
{
    public const string Sentinel = "***REDACTED***";
    private static readonly string[] SecretNameFragments =
        ["password", "secret", "token", "apikey", "credential", "passphrase"];
    // Scrub: parse (return input unchanged on parse failure), walk objects/arrays,
    // replace matching string values with Sentinel, return (json, hadSecrets).
    // MergeSentinels: walk incoming; wherever value == Sentinel, substitute the value
    // at the same path in storedJson (if absent, leave Sentinel — update will store it
    // verbatim, which is inert). Null/empty inputs pass through.
}
```
4. Run — PASS. 5. Commit: `git add src/ZB.MOM.WW.ScadaBridge.ManagementService/ConfigSecretScrubber.cs tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ConfigSecretScrubberTests.cs && git commit -m "feat(management): ConfigSecretScrubber for connection-config secret elision"`

### Task 7: DataConnection List/Get/audit secret elision + sentinel-preserving update

**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** none (ManagementActor.cs; waits on Task 6)
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs` (lines 1401–1456: `HandleListDataConnections`, `HandleGetDataConnection`, `HandleCreateDataConnection`, `HandleUpdateDataConnection`)
- Modify: `docs/requirements/Component-ManagementService.md` (data-connection command responses are secret-elided; update merges the redaction sentinel)
- Test: `tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs` (or a new `SecretProjectionTests.cs`)

1. Failing tests (harness: `ISiteRepository` substitute returning a `DataConnection` whose `PrimaryConfiguration` contains `"Password":"opc-secret"`):
```csharp
[Fact]
public void GetDataConnection_ResponseOmitsPasswords()
{
    var actor = CreateActor();
    actor.Tell(Envelope(new GetDataConnectionCommand(1), "Viewer"));
    var resp = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
    Assert.DoesNotContain("opc-secret", resp.JsonData);
    Assert.Contains("hasSecrets", resp.JsonData); // camelCase serializer
}

[Fact]
public void UpdateDataConnection_SentinelConfig_PreservesStoredSecret()
{
    // stored config has Password "opc-secret"; incoming config carries the sentinel
    var actor = CreateActor();
    var incoming = """{"Endpoint":"opc.tcp://new","UserIdentity":{"Password":"***REDACTED***"}}""";
    actor.Tell(Envelope(new UpdateDataConnectionCommand(1, "c1", "OpcUa", incoming, null, 3), "Designer"));
    ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
    await _siteRepo.Received(1).UpdateDataConnectionAsync(
        Arg.Is<DataConnection>(c => c.PrimaryConfiguration!.Contains("opc-secret")
                                 && !c.PrimaryConfiguration.Contains("***REDACTED***")));
}

[Fact]
public void UpdateDataConnection_AuditAfterState_OmitsPasswords()
{
    var actor = CreateActor();
    actor.Tell(Envelope(new UpdateDataConnectionCommand(1, "c1", "OpcUa", "{\"Password\":\"newpw\"}", null, 3), "Designer"));
    ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
    // AuditAsync serializes afterState via IAuditService — assert the captured object graph has no "newpw"
    _auditService.Received().LogAsync(Arg.Any<string>(), "Update", "DataConnection",
        Arg.Any<string>(), Arg.Any<string>(),
        Arg.Is<object>(o => !ManagementActor.SerializeResult(o).Contains("newpw")), Arg.Any<CancellationToken>());
}
```
(Match `UpdateDataConnectionCommand`'s and `IAuditService.LogAsync`'s real signatures — read them first.)
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter DataConnection` — new tests FAIL.
3. Implement in `ManagementActor.cs`:
```csharp
/// <summary>Secret-elided projection of a DataConnection (arch-review C3): the raw
/// PrimaryConfiguration/BackupConfiguration JSON can embed OPC UA user + certificate
/// passwords, and List/Get are readable by any authenticated user. Mirrors
/// SmtpConfigPublicShape. Non-config fields are projected verbatim.</summary>
private static object DataConnectionPublicShape(DataConnection c)
{
    var (primary, s1) = ConfigSecretScrubber.Scrub(c.PrimaryConfiguration);
    var (backup, s2) = ConfigSecretScrubber.Scrub(c.BackupConfiguration);
    return new
    {
        c.Id, c.Name, c.Protocol, c.SiteId, c.FailoverRetryCount,
        PrimaryConfiguration = primary, BackupConfiguration = backup,
        HasSecrets = s1 || s2,
    };
    // NOTE: project every additional simple property present on the entity
    // (read Commons/Entities .../DataConnection.cs and include all non-config fields).
}
```
- `HandleListDataConnections`: `return (await ...).Select(DataConnectionPublicShape).ToList();` (both branches).
- `HandleGetDataConnection`: `return conn is null ? null : DataConnectionPublicShape(conn);` (after the scope check).
- `HandleCreateDataConnection`: audit + return `DataConnectionPublicShape(conn)`.
- `HandleUpdateDataConnection`: before assignment, `cmd = cmd with { }` is unnecessary — merge directly:
```csharp
conn.PrimaryConfiguration = ConfigSecretScrubber.MergeSentinels(cmd.PrimaryConfiguration, conn.PrimaryConfiguration);
conn.BackupConfiguration  = ConfigSecretScrubber.MergeSentinels(cmd.BackupConfiguration,  conn.BackupConfiguration);
```
(assign Name/Protocol/FailoverRetryCount as today); audit + return the public shape.
4. Run filter — PASS; full project run. Note: the Central UI data-connection editor talks to repositories directly (not the actor), so UI round-tripping is unaffected; CLI round-trips are protected by the sentinel merge.
5. Update `Component-ManagementService.md`. Commit: `git add -A src/ZB.MOM.WW.ScadaBridge.ManagementService tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests docs/requirements/Component-ManagementService.md && git commit -m "fix(management): elide DataConnection config secrets from responses and audit (arch-review C3)"`

### Task 8: ExternalSystem `AuthConfiguration` elision + preserve-if-null update

**Classification:** high-risk
**Estimated implement time:** ~4 min
**Parallelizable with:** none (ManagementActor.cs)
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs` (lines 1587–1625)
- Modify: `docs/requirements/Component-ManagementService.md`
- Test: `tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/` (same file as Task 7)

1. Failing tests: `ListExternalSystems`/`GetExternalSystem` response must not contain the mocked `AuthConfiguration` value (e.g. `"apiKey":"es-secret"`), must contain `hasAuthConfiguration`; `UpdateExternalSystem` with `AuthConfiguration = null` must preserve the stored value; Create/Update audit afterState must not contain the secret. Same test pattern as Task 7.
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter ExternalSystem` — FAIL.
3. Implement `ExternalSystemPublicShape`:
```csharp
/// <summary>Secret-elided ExternalSystemDefinition projection: AuthConfiguration
/// (API keys / Basic credentials) never leaves this boundary. Mirrors SmtpConfigPublicShape.</summary>
private static object ExternalSystemPublicShape(ExternalSystemDefinition d) => new
{
    d.Id, d.Name, d.EndpointUrl, d.AuthType,
    HasAuthConfiguration = !string.IsNullOrEmpty(d.AuthConfiguration),
};
```
List → `.Select(ExternalSystemPublicShape)`; Get → projected-or-null; Create/Update → audit + return projection; Update gains preserve-if-null: `if (cmd.AuthConfiguration is not null) def.AuthConfiguration = cmd.AuthConfiguration;` (comment mirrors the SMTP preserve-if-null rationale).
4. PASS + full run. 5. Doc + commit: `git commit -m "fix(management): elide ExternalSystem AuthConfiguration from responses and audit (arch-review C3)"`

### Task 9: DatabaseConnection `ConnectionString` elision + preserve-if-null update

**Classification:** high-risk
**Estimated implement time:** ~4 min
**Parallelizable with:** none (ManagementActor.cs)
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs` (lines 2526–2559)
- Modify: `docs/requirements/Component-ManagementService.md`
- Test: same file as Tasks 7–8

1. Failing tests: List/Get response contains `id`/`name`/`hasConnectionString` but not the mocked connection string (`"Server=db;Password=sql-secret"`); Update with `ConnectionString = null` preserves stored value. Check `UpdateDatabaseConnectionDefCommand.ConnectionString` nullability first — if non-nullable, make it `string?` (additive JSON-wire-compatible).
2. `dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter DatabaseConnection` — FAIL.
3. Implement `private static object DatabaseConnectionPublicShape(DatabaseConnectionDefinition d) => new { d.Id, d.Name, HasConnectionString = !string.IsNullOrEmpty(d.ConnectionString) };` and apply to List/Get/Create/Update returns (audit shape at :2544/:2557 already `{Id, Name}` — leave). Update handler: `if (cmd.ConnectionString is not null) def.ConnectionString = cmd.ConnectionString;`
4. PASS + full run. CLI note: `db-connection` list/get output shape changes (no connection string) — update `Component-CLI.md`/CLI README sample output if shown.
5. Commit: `git commit -m "fix(management): elide DatabaseConnection ConnectionString from List/Get (arch-review C3)"`

### Task 10: `GetRequiredRoles` any-of refactor + gate `ListSecuredWrites`

**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** none (ManagementActor.cs)
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs` (lines 97–242: `HandleEnvelope` + `GetRequiredRole`)
- Modify: `src/ZB.MOM.WW.ScadaBridge.Security/AuthorizationPolicies.cs` (new `RequireSecuredWriteAccess` policy)
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Operations/SecuredWrites.razor` (page `@attribute [Authorize(Policy = ...)]` — check current policy first and align)
- Modify: `docs/requirements/Component-ManagementService.md` §Authorization, `docs/requirements/Component-CentralUI.md` §Secured Writes
- Test: `tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs`, `tests/ZB.MOM.WW.ScadaBridge.Security.Tests/SecurityTests.cs` (policy registration)

1. Failing tests:
```csharp
[Fact]
public void ListSecuredWrites_ViewerOnly_Unauthorized()
{
    var actor = CreateActor();
    actor.Tell(Envelope(new ListSecuredWritesCommand(null, null), "Viewer"));
    ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
}

[Theory]
[InlineData("Operator")]
[InlineData("Verifier")]
[InlineData("Administrator")]
public void ListSecuredWrites_SecuredWriteRoles_Allowed(string role)
{
    var actor = CreateActor(); // ISecuredWriteRepository substitute returns empty list
    actor.Tell(Envelope(new ListSecuredWritesCommand(null, null), role));
    ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
}
```
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter ListSecuredWrites` — FAIL (currently ungated).
3. Implement:
   - Rename `GetRequiredRole` → `internal static IReadOnlyList<string>? GetRequiredRoles(object command)` (internal for Task 13's matrix test). Wrap the existing arms with static readonly arrays: `private static readonly string[] AdminOnly = [Roles.Administrator];`, `DesignerOnly`, `DeployerOnly`, `OperatorOnly`, `VerifierOnly`, and new `SecuredWriteReaders = [Roles.Operator, Roles.Verifier, Roles.Administrator];`. Add arm: `ListSecuredWritesCommand => SecuredWriteReaders,` with a comment (tag values on the history table are process-sensitive — arch-review UA1).
   - `HandleEnvelope`:
```csharp
var requiredRoles = GetRequiredRoles(envelope.Command);
if (requiredRoles is not null
    && !requiredRoles.Any(r => user.Roles.Contains(r, StringComparer.OrdinalIgnoreCase)))
{
    sender.Tell(new ManagementUnauthorized(correlationId,
        $"One of roles [{string.Join(", ", requiredRoles)}] required for {envelope.Command.GetType().Name}"));
    return;
}
```
(The existing `CreateSiteCommand_WithDesignRole_ReturnsUnauthorized` asserts `Contains("Administrator")` — still passes.)
   - `AuthorizationPolicies.cs`: add `RequireSecuredWriteAccess` = `RequireClaim(JwtTokenService.RoleClaimType, Roles.Operator, Roles.Verifier, Roles.Administrator)` and apply to the SecuredWrites page attribute (check the page's current attribute; per Component-CentralUI it may already be operator-gated — align page, service, and actor).
4. PASS + full ManagementService.Tests + Security.Tests runs.
5. Docs, then commit: `git commit -m "feat(management): any-of role gates; restrict ListSecuredWrites to Operator/Verifier/Admin (arch-review UA1)"`

### Task 11: Reconcile the area-management role gate (code ⇄ three docs)

**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** none (ManagementActor.cs; waits on Task 10)
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs` (Area command arms, lines 186/207)
- Modify: `docs/requirements/Component-ManagementService.md` §Authorization (~line 213), `docs/requirements/Component-Security.md` §Administrator (~line 88), `docs/requirements/Component-CentralUI.md` §Area Management (~line 106)
- Test: `tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs`

**Design decision** (record in the commit message): the Central UI creates areas inside the Deployment Topology workflow (`Topology.razor` is `RequireDeployment`), and the actor gates them Designer — the three docs' "Admin" assignment is stale. Reconcile on **any-of [Designer, Deployer]** (covers both real surfaces; Administrator is not implicitly included in the actor gate, matching current behavior for other Designer commands), and update all three docs to match.

1. Failing test:
```csharp
[Theory]
[InlineData("Designer")]
[InlineData("Deployer")]
public void CreateArea_DesignerOrDeployer_Allowed(string role) { /* Envelope(new CreateAreaCommand(...), role) → not ManagementUnauthorized */ }

[Fact]
public void CreateArea_ViewerOnly_Unauthorized() { /* → ManagementUnauthorized */ }
```
2. `dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter CreateArea` — Deployer case FAILS today.
3. Move `CreateAreaCommand or UpdateAreaCommand or DeleteAreaCommand` out of the Designer arm into a new arm `=> AreaManagers` where `private static readonly string[] AreaManagers = [Roles.Designer, Roles.Deployer];` with a comment citing the reconciliation. Update the three docs: area management = Designer or Deployer (management API), Central UI exposes it inside the Deployment Topology workflow (`RequireDeployment`); remove "area management" from the Admin lists.
4. PASS. 5. Commit: `git commit -m "fix(management): reconcile area role gate to Designer|Deployer across actor and all three docs (arch-review C6)"`

### Task 12: Canonicalize role-mapping casing at the write path

**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** 14, 19, 21, 26, 29 (ManagementActor.cs region 1904–1944 — do not run concurrently with other ManagementActor tasks)
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs` (lines 1904–1944: `ValidateMappingRole`, `HandleCreateRoleMapping`, `HandleUpdateRoleMapping`)
- Modify: `docs/requirements/Component-Security.md` (role-mapping section: stored casing is canonicalized)
- Test: `tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/RoleMappingValidationTests.cs`

1. Failing test:
```csharp
[Fact]
public void CreateRoleMapping_LowercaseRole_StoresCanonicalCasing()
{
    var actor = CreateActor();
    actor.Tell(Envelope(new CreateRoleMappingCommand("ldap-group", "deployer"), "Administrator"));
    ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
    await _securityRepo.Received(1).AddMappingAsync(
        Arg.Is<LdapGroupMapping>(m => m.Role == "Deployer")); // canonical, not verbatim
}
```
2. `dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter RoleMapping` — FAIL.
3. Replace `ValidateMappingRole` with:
```csharp
// The stored Role must be CANONICAL casing (Roles.All): ASP.NET RequireClaim policies
// compare ordinal case-sensitively while the actor compares OrdinalIgnoreCase, so a
// row stored as "deployer" passes every actor gate but fails every UI policy — a
// CLI-works/UI-403s split-brain. Canonicalizing at this single write path closes the
// asymmetry (arch-review C5; closes the deferral formerly documented here).
private static string CanonicalizeMappingRole(string role)
{
    var canonical = Roles.All.FirstOrDefault(r => string.Equals(r, role, StringComparison.OrdinalIgnoreCase));
    return canonical ?? throw new ManagementCommandException(
        $"Role '{role}' is not a recognized role. Valid roles are: {string.Join(", ", Roles.All)}.");
}
```
Use `var role = CanonicalizeMappingRole(cmd.Role);` in both create/update handlers and store `role`. Keep the invalid-role rejection tests green (message unchanged).
4. PASS + full RoleMappingValidationTests run. 5. Doc + commit: `git commit -m "fix(management): canonicalize role-mapping casing at write path, closing UI/actor case split (arch-review C5)"`

### Task 13: `GetRequiredRoles` matrix test — freeze the authorization table

**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (waits on Tasks 10, 11, 22, 23 — final gate table)
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ZB.MOM.WW.ScadaBridge.ManagementService.csproj` (add `<ItemGroup><InternalsVisibleTo Include="ZB.MOM.WW.ScadaBridge.ManagementService.Tests" /></ItemGroup>`)
- Create: `tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/RequiredRoleMatrixTests.cs`

1. Write the test (it IS the deliverable — no implementation change beyond the csproj line):
```csharp
using System.Runtime.CompilerServices;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Security;

public class RequiredRoleMatrixTests
{
    // FROZEN authorization table: every registered management command MUST have an
    // entry here. A new command without one fails EveryRegisteredCommand_IsInTheFrozenTable,
    // forcing a deliberate authz decision (arch-review UA2; would have caught C2/C4).
    private static readonly Dictionary<string, string[]?> Expected = new(StringComparer.OrdinalIgnoreCase)
    {
        ["CreateSite"] = [Roles.Administrator],
        ["CreateTemplate"] = [Roles.Designer],
        ["MgmtDeployArtifacts"] = [Roles.Deployer],
        ["SubmitSecuredWrite"] = [Roles.Operator],
        ["ApproveSecuredWrite"] = [Roles.Verifier],
        ["ListSecuredWrites"] = [Roles.Operator, Roles.Verifier, Roles.Administrator],
        ["CreateArea"] = [Roles.Designer, Roles.Deployer],
        ["ListTemplates"] = null, // read-only: any authenticated user
        // ... generate the FULL ~150-entry table: dump
        //     ManagementCommandRegistry-registered names with their current
        //     GetRequiredRoles(...) result once (scratch console or temp test),
        //     eyeball it against Component-ManagementService.md §Authorization,
        //     then paste it here verbatim and never regenerate mechanically.
    };

    public static IEnumerable<object[]> AllRegisteredCommands()
        => typeof(ManagementEnvelope).Assembly.GetTypes()
            .Where(t => t.Namespace == typeof(ManagementEnvelope).Namespace
                        && t.Name.EndsWith("Command", StringComparison.Ordinal) && !t.IsAbstract)
            .Select(t => new object[] { t });

    [Theory, MemberData(nameof(AllRegisteredCommands))]
    public void EveryRegisteredCommand_IsInTheFrozenTable(Type commandType)
    {
        var name = commandType.Name[..^"Command".Length];
        Assert.True(Expected.ContainsKey(name),
            $"Command '{name}' has no entry in the frozen authorization table — add one deliberately.");
        var instance = RuntimeHelpers.GetUninitializedObject(commandType);
        var actual = ManagementActor.GetRequiredRoles(instance);
        var expected = Expected[name];
        if (expected is null) Assert.Null(actual);
        else Assert.Equal(expected.OrderBy(x => x), actual!.OrderBy(x => x));
    }
}
```
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter RequiredRoleMatrixTests` — FAIL until the table is complete and `InternalsVisibleTo` + `internal` visibility (Task 10) are in place.
3. Fill the table to green. Any surprise found while filling (a command gated differently than `Component-ManagementService.md` documents) gets fixed in this task — doc or gate, whichever is wrong — and noted in the commit message.
4. Commit: `git add src/ZB.MOM.WW.ScadaBridge.ManagementService/ZB.MOM.WW.ScadaBridge.ManagementService.csproj tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/RequiredRoleMatrixTests.cs && git commit -m "test(management): frozen GetRequiredRoles matrix over every registered command (arch-review UA2)"`

### Task 14: SecuredWrite repository — `TryMarkExpiredAsync` + `CountAsync`

**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** 1, 3, 4, 6, 19, 21, 26, 29
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/ISecuredWriteRepository.cs`
- Modify: `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/SecuredWriteRepository.cs`
- Test: `tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/` — extend the existing SecuredWriteRepository test file (locate via `grep -rl SecuredWriteRepository tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests`; mirror its harness — in-memory/SQLite or MSSQL fixture)

1. Failing tests:
```csharp
[Fact]
public async Task TryMarkExpiredAsync_PendingRow_FlipsToExpired_Once()
{
    var id = await _repo.AddAsync(NewPendingWrite());
    Assert.True(await _repo.TryMarkExpiredAsync(id, DateTime.UtcNow));
    Assert.False(await _repo.TryMarkExpiredAsync(id, DateTime.UtcNow)); // CAS: second caller loses
    var row = await _repo.GetAsync(id);
    Assert.Equal("Expired", row!.Status);
    Assert.NotNull(row.DecidedAtUtc);
    Assert.Null(row.VerifierUser); // system decision, no verifier
}

[Fact]
public async Task TryMarkExpiredAsync_DecidedRow_ReturnsFalse()
{
    var id = await _repo.AddAsync(NewPendingWrite());
    await _repo.TryMarkApprovedAsync(id, "v", null, DateTime.UtcNow);
    Assert.False(await _repo.TryMarkExpiredAsync(id, DateTime.UtcNow));
}

[Fact]
public async Task CountAsync_FiltersLikeQueryAsync()
{
    await _repo.AddAsync(NewPendingWrite());
    await _repo.AddAsync(NewPendingWrite());
    Assert.Equal(2, await _repo.CountAsync(status: "Pending", siteId: null));
    Assert.Equal(0, await _repo.CountAsync(status: "Executed", siteId: null));
}
```
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests --filter "TryMarkExpired|CountAsync_Filters"` — FAIL.
3. Add to the interface (xmldoc mirrors `TryMarkApprovedAsync`'s CAS wording — this is the multi-node-safe expiry primitive):
```csharp
Task<bool> TryMarkExpiredAsync(long id, DateTime expiredAtUtc, CancellationToken ct = default);
Task<int> CountAsync(string? status, string? siteId, CancellationToken ct = default);
```
Implement with the same conditional-update pattern `TryMarkApprovedAsync` uses (`WHERE Status = 'Pending'`), setting `Status='Expired'`, `DecidedAtUtc=expiredAtUtc`, leaving `VerifierUser` NULL; `CountAsync` mirrors `QueryAsync`'s filter composition with `CountAsync()`.
4. PASS + full project run. 5. Commit: `git commit -m "feat(config-db): secured-write TryMarkExpiredAsync CAS + CountAsync (arch-review S2/P3)"`

### Task 15: Server-side secured-write TTL — enforce at approve/reject

**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** none (ManagementActor.cs; waits on Task 14)
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementServiceOptions.cs` (add `SecuredWritePendingTtl`, default 24 h)
- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Enums/AuditKind.cs` (additive member `SecuredWriteExpire`)
- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs` (`HandleApproveSecuredWrite` ~1106, `HandleRejectSecuredWrite` ~1255)
- Modify: `docs/requirements/Component-ManagementService.md`, `docs/requirements/Component-Security.md` (two-person section: TTL + restate the dual-role Operator+Verifier deployment-configuration hazard), `CLAUDE.md` Security & Auth bullet (append "pending secured writes expire server-side after a configurable TTL, default 24 h")
- Test: `tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/SecuredWriteHandlerTests.cs`

1. Failing tests (extend the existing `SecuredWriteHandlerTests` harness; register `IOptions<ManagementServiceOptions>` in its service collection):
```csharp
[Fact]
public void Approve_PendingRowOlderThanTtl_ExpiresInsteadOfExecuting()
{
    var row = PendingRow(submittedAtUtc: DateTime.UtcNow.AddHours(-25)); // TTL default 24h
    _securedWriteRepo.GetAsync(1).Returns(row);
    _securedWriteRepo.TryMarkExpiredAsync(1, Arg.Any<DateTime>()).Returns(true);

    var actor = CreateActor();
    actor.Tell(Envelope(new ApproveSecuredWriteCommand(1, null), "Verifier"));

    var resp = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
    Assert.Contains("expired", resp.Error, StringComparison.OrdinalIgnoreCase);
    _securedWriteRepo.DidNotReceive().TryMarkApprovedAsync(
        Arg.Any<long>(), Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>());
    _commService.DidNotReceiveWithAnyArgs().WriteTagAsync(default!, default!); // stale value NEVER relayed
}

[Fact]
public void Approve_FreshPendingRow_StillExecutes() { /* submitted 1h ago → existing approve path unchanged */ }
```
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter SecuredWrite` — new tests FAIL.
3. Implement:
   - Options: `/// <summary>Age after which a Pending secured write can no longer be approved; it is transitioned to Expired at the next decision/list touch. Default 24 hours.</summary> public TimeSpan SecuredWritePendingTtl { get; set; } = TimeSpan.FromHours(24);`
   - `AuditKind`: add `SecuredWriteExpire` (additive enum member — message contracts are additive-only).
   - Shared helper in `ManagementActor`:
```csharp
/// <summary>If the Pending row is older than the configured TTL, CAS it to Expired
/// (multi-node idempotent), audit best-effort, and return true. A stale setpoint
/// must never be approvable days later (arch-review S2).</summary>
private static async Task<bool> TryExpireIfStaleAsync(
    IServiceProvider sp, ISecuredWriteRepository repo, PendingSecuredWrite row)
{
    var ttl = sp.GetService<IOptions<ManagementServiceOptions>>()?.Value.SecuredWritePendingTtl
              ?? TimeSpan.FromHours(24);
    if (ttl <= TimeSpan.Zero || DateTime.UtcNow - row.SubmittedAtUtc <= ttl) return false;
    var expiredAt = DateTime.UtcNow;
    if (await repo.TryMarkExpiredAsync(row.Id, expiredAt))
    {
        row.Status = "Expired";
        row.DecidedAtUtc = expiredAt;
        await EmitSecuredWriteAuditAsync(
            sp, AuditKind.SecuredWriteExpire, AuditStatus.Discarded, row, actor: "system");
    }
    return true;
}
```
   - In `HandleApproveSecuredWrite` and `HandleRejectSecuredWrite`, immediately after the `"Pending"` status check: `if (await TryExpireIfStaleAsync(sp, repo, row)) throw new ManagementCommandException($"Secured write {cmd.Id} expired (pending longer than the configured TTL) and can no longer be approved or rejected.");`
   (Check `AuditStatus` members; if `Discarded` is inappropriate, use the closest terminal status the enum offers and note it.)
4. PASS + full SecuredWriteHandlerTests. 5. Docs + CLAUDE.md, commit: `git commit -m "feat(management): server-side secured-write TTL — stale pending writes expire, never execute (arch-review S2)"`

### Task 16: Opportunistic expiry sweep in `HandleListSecuredWrites`

**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** none (ManagementActor.cs; waits on Task 15)
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs` (`HandleListSecuredWrites` ~1280)
- Test: `tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/SecuredWriteHandlerTests.cs`

1. Failing test: repo returns one fresh Pending + one 25h-old Pending; `ListSecuredWritesCommand` result marks the old one `Expired` and `TryMarkExpiredAsync` was called exactly once (for the stale row only).
2. Run `--filter SecuredWrite` — FAIL.
3. In `HandleListSecuredWrites`, before returning: iterate the queried rows, `await TryExpireIfStaleAsync(sp, repo, row)` for each `Pending` row (helper self-filters by age; CAS makes the multi-node double-run harmless — the ManagementActor runs on every central node by design, so the sweep is deliberately lazy/opportunistic rather than a new timer singleton; document this in a comment).
4. PASS. 5. Commit: `git commit -m "feat(management): opportunistic expiry sweep on secured-write list (arch-review S2)"`

### Task 17: Secured-write list paging — command, handler, CLI

**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (ManagementActor.cs; waits on Task 14)
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/SecuredWriteCommands.cs` (line 59 `ListSecuredWritesCommand`, line 83 `SecuredWriteListResult`)
- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs` (`HandleListSecuredWrites`)
- Modify: CLI secured-write list command (locate: `grep -rl ListSecuredWritesCommand src/ZB.MOM.WW.ScadaBridge.CLI`) — add `--skip/--take`
- Modify: `docs/requirements/Component-ManagementService.md`, `docs/requirements/Component-CLI.md`
- Test: `tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/SecuredWriteHandlerTests.cs`, `tests/ZB.MOM.WW.ScadaBridge.CLI.Tests` (parse test)

1. Failing test: `ListSecuredWritesCommand(null, null, Skip: 10, Take: 25)` → repo `QueryAsync(null, null, 10, 25)` received; result JSON contains `totalCount` sourced from `CountAsync`.
2. Run `--filter SecuredWrite` — FAIL (record has no such params).
3. Implement (additive, defaults preserve today's wire behavior):
```csharp
public record ListSecuredWritesCommand(string? Status, string? SiteId, int Skip = 0, int Take = 200);
public record SecuredWriteListResult(IReadOnlyList<SecuredWriteDto> Items, int TotalCount);
```
Handler: clamp `var take = Math.Clamp(cmd.Take, 1, 500); var skip = Math.Max(0, cmd.Skip);`, `QueryAsync(cmd.Status, cmd.SiteId, skip, take)` + `CountAsync(cmd.Status, cmd.SiteId)`. Update the one UI construction site (`SecuredWriteService`) for the new result shape (compile will point at it). CLI: add the two int options.
4. PASS; `dotnet test tests/ZB.MOM.WW.ScadaBridge.CLI.Tests` (direct — not in slnx yet).
5. Commit: `git commit -m "feat(management): secured-write list paging with TotalCount (arch-review P3)"`

### Task 18: SecuredWrites page — history pager + age in the approve dialog + docs

**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** 19, 21, 26, 28, 29 (waits on Task 17)
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Operations/SecuredWrites.razor` (RefreshListsAsync ~line 303; confirm dialog ~line 342; history table)
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/ISecuredWriteService.cs` + `SecuredWriteService.cs` (paging params + TotalCount pass-through)
- Modify: `docs/requirements/Component-CentralUI.md` §Secured Writes (Expired is real + TTL + paging)
- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/` (bUnit — follow an existing page test's auth/service-fake setup)

1. Failing bUnit test: render the page with a fake `ISecuredWriteService` returning 60 terminal rows + `TotalCount = 120`; assert a "Next" pager button exists and clicking it requests the second page (`ListAsync(..., skip: 50, ...)` received). Second test: the approve confirmation dialog for a row submitted 26 hours ago renders text matching `Submitted .* ago` (age surfaced next to the value — the reviewer's stale-setpoint scenario).
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter SecuredWrites` — FAIL.
3. Implement: page-size 50 history pager (`_historyPage`, Prev/Next buttons disabled at bounds using TotalCount); dialog gains a `Submitted {FormatAge(row.SubmittedAtUtc)} ago ({row.SubmittedAtUtc:u})` line. Pending list stays unpaged (bounded by TTL now).
4. PASS. 5. Doc + commit: `git commit -m "feat(ui): secured-write history paging + submission age in approve dialog (arch-review S2/P3)"`

### Task 19: Per-command long-running Ask timeout

**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** 1, 3, 4, 6, 14, 21, 26, 29
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementServiceOptions.cs` (add `LongRunningCommandTimeout`, default 5 min)
- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementEndpoints.cs` (lines 18–33 `ResolveAskTimeout`; line 101 call site)
- Test: `tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementEndpointsTests.cs`

1. Failing tests:
```csharp
[Theory]
[InlineData(typeof(ImportBundleCommand))]
[InlineData(typeof(ExportBundleCommand))]
[InlineData(typeof(PreviewBundleCommand))]
[InlineData(typeof(MgmtDeployArtifactsCommand))]
[InlineData(typeof(MgmtDeployInstanceCommand))]
public void ResolveAskTimeout_LongRunningCommand_UsesLongTimeout(Type cmd)
    => Assert.Equal(TimeSpan.FromMinutes(5), ManagementEndpoints.ResolveAskTimeout(null, cmd));

[Fact]
public void ResolveAskTimeout_OrdinaryCommand_UsesDefault()
    => Assert.Equal(TimeSpan.FromSeconds(30), ManagementEndpoints.ResolveAskTimeout(null, typeof(ListTemplatesCommand)));

[Fact]
public void ResolveAskTimeout_ConfiguredLongTimeout_Honored()
    => Assert.Equal(TimeSpan.FromMinutes(10), ManagementEndpoints.ResolveAskTimeout(
        new ManagementServiceOptions { LongRunningCommandTimeout = TimeSpan.FromMinutes(10) },
        typeof(ImportBundleCommand)));
```
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter ResolveAskTimeout` — FAIL.
3. Implement:
```csharp
private static readonly TimeSpan DefaultLongRunningTimeout = TimeSpan.FromMinutes(5);

/// <summary>Transport/deploy commands legitimately run for minutes (the CLI already
/// uses a 5-minute client timeout for bundles); a 30 s server-side Ask 504s while the
/// piped ProcessCommand keeps applying — the caller then retries and double-applies
/// (arch-review S1). These command types get LongRunningCommandTimeout instead.</summary>
private static readonly HashSet<Type> LongRunningCommands =
[
    typeof(ImportBundleCommand), typeof(PreviewBundleCommand), typeof(ExportBundleCommand),
    typeof(MgmtDeployArtifactsCommand), typeof(MgmtDeployInstanceCommand),
];

public static TimeSpan ResolveAskTimeout(ManagementServiceOptions? options, Type commandType)
    => LongRunningCommands.Contains(commandType)
        ? (options is { LongRunningCommandTimeout.Ticks: > 0 } o ? o.LongRunningCommandTimeout : DefaultLongRunningTimeout)
        : ResolveAskTimeout(options);
```
Call site: `ResolveAskTimeout(options, command.GetType())`. Keep the existing single-arg overload (used by other callers/tests).
4. PASS + full ManagementEndpointsTests. 5. Commit: `git commit -m "fix(management): 5-min Ask timeout for transport/deploy commands (arch-review S1)"`

### Task 20: Ship `ManagementService` timeout config + doc

**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** 18, 21, 26, 28, 29 (waits on Task 19)
**Files:**
- Modify: `docker/central-node-a/appsettings.Central.json`, `docker/central-node-b/appsettings.Central.json`, the two `docker-env2` central-node appsettings (glob `docker-env2/central-node-*/appsettings.Central.json`), `deploy/wonder-app-vd03/appsettings.Central.json`
- Modify: `docs/requirements/Component-ManagementService.md` §Configuration (~line 247)

1. Add under the `"ScadaBridge"` object in each central-node config:
```json
"ManagementService": {
  "CommandTimeout": "00:00:30",
  "LongRunningCommandTimeout": "00:05:00"
}
```
2. Extend the doc's configuration table row with `LongRunningCommandTimeout` (TimeSpan, default 5 min, applied to ImportBundle/PreviewBundle/ExportBundle/MgmtDeployArtifacts/MgmtDeployInstance).
3. Validate each edited JSON (`python3 -c ...` as in Task 3). Cluster-runtime change: rebuild with `bash docker/deploy.sh` when convenient (config-only; note in commit).
4. Commit: `git commit -m "chore(deploy): ship ManagementService command-timeout config to all central-node topologies (arch-review S1)"`

### Task 21: Browse/Verify/Cert commands gain optional `SiteIdentifier`

**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** 1, 3, 4, 6, 14, 19, 26, 29
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/BrowseCommands.cs` (`BrowseNodeCommand` :24, `SearchAddressSpaceCommand` :62), `VerifyEndpointCommands.cs` (`VerifyEndpointCommand` :102), `CertTrustCommands.cs` (`TrustServerCertCommand` :192, `RemoveServerCertCommand` :199, `ListServerCertsCommand` :206)
- Test: `tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/` (serialization round-trip)

1. Failing test (Commons.Tests): deserialize legacy JSON without the field → `SiteIdentifier == null`; round-trip with the field set survives. (Use `System.Text.Json` with `PropertyNameCaseInsensitive = true`, matching `ManagementEndpoints.ParseCommand`.)
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.Commons.Tests --filter SiteIdentifier` — FAIL.
3. Append an optional trailing positional param to each of the six records, e.g.:
```csharp
public record BrowseNodeCommand(
    string ConnectionName, string? ParentNodeId, string? ContinuationToken,
    string? SiteIdentifier = null);
```
xmldoc: "Target site identifier — REQUIRED when the command is invoked via the management HTTP API/CLI (the actor must route it to a site); ignored on the site side, where routing already happened. Additive per the message-contract evolution rules." Existing positional construction sites (UI services, Communication) compile unchanged.
4. PASS + `dotnet build ZB.MOM.WW.ScadaBridge.slnx`. 5. Commit: `git commit -m "feat(commons): additive SiteIdentifier on browse/verify/cert-trust commands for management-API routing (arch-review C4)"`

### Task 22: Actor handlers — BrowseNode / SearchAddressSpace / VerifyEndpoint

**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** none (ManagementActor.cs; waits on Tasks 10, 21)
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs` (dispatch switch ~:398 "Remote Queries" region; `GetRequiredRoles` Designer arm; new handlers near `HandleQueryEventLogs` ~:2753)
- Modify: `docs/requirements/Component-ManagementService.md` §Remote Queries (note the commands are now actor-dispatched — the doc's existing description at lines 204–207 becomes true)
- Test: `tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs`

1. Failing tests:
```csharp
[Fact]
public void BrowseNode_WithoutSiteIdentifier_ReturnsError()
{
    var actor = CreateActor();
    actor.Tell(Envelope(new BrowseNodeCommand("conn", null, null), "Designer"));
    var resp = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
    Assert.Contains("SiteIdentifier", resp.Error);
}

[Fact]
public void BrowseNode_Designer_RelaysToSite()
{
    var actor = CreateActor(); // CommunicationService fake per SecuredWriteHandlerTests' pattern
    actor.Tell(Envelope(new BrowseNodeCommand("conn", null, null, "SITE1"), "Designer"));
    ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
    _commService.Received(1).BrowseNodeAsync("SITE1", Arg.Any<BrowseNodeCommand>(), Arg.Any<CancellationToken>());
}

[Fact]
public void BrowseNode_ViewerOnly_Unauthorized() { /* → ManagementUnauthorized */ }
```
2. Run `--filter BrowseNode` — FAIL (falls through to NotSupportedException today).
3. Implement three handlers following `HandleQueryEventLogs`'s shape:
```csharp
private static async Task<object?> HandleBrowseNode(IServiceProvider sp, BrowseNodeCommand cmd, AuthenticatedUser user)
{
    var site = cmd.SiteIdentifier
        ?? throw new ManagementCommandException("SiteIdentifier is required when browsing via the management API.");
    await EnforceSiteScopeForIdentifier(sp, user, site);
    var comm = sp.GetRequiredService<CommunicationService>();
    return await comm.BrowseNodeAsync(site, cmd);
}
```
(same for `HandleSearchAddressSpace` → `SearchAddressSpaceAsync`, `HandleVerifyEndpoint` → `VerifyEndpointAsync`; match the real `CommunicationService` signatures at :366/:388/:410). Dispatch arms in the Remote Queries region; `GetRequiredRoles`: add the three commands to the Designer arm (doc line 204/206 already says Design). Update the Task 13 matrix table if it already exists.
4. PASS + full run. 5. Doc + commit: `git commit -m "feat(management): actor dispatch for BrowseNode/SearchAddressSpace/VerifyEndpoint — CLI parity (arch-review C4)"`

### Task 23: Actor handlers — TrustServerCert / RemoveServerCert / ListServerCerts (Admin)

**Classification:** high-risk
**Estimated implement time:** ~4 min
**Parallelizable with:** none (ManagementActor.cs; waits on Tasks 10, 21)
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs` (dispatch + `GetRequiredRoles` Admin arm + three handlers)
- Modify: `docs/requirements/Component-ManagementService.md` (line 218's Admin gating is now enforced at the actor)
- Test: `tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs`

1. Failing tests mirroring Task 22: Designer-only caller gets `ManagementUnauthorized` for `TrustServerCertCommand`; Admin with `SiteIdentifier` relays via `CommunicationService.TrustServerCertAsync`; missing `SiteIdentifier` → curated error.
2. Run `--filter ServerCert` — FAIL.
3. Implement three handlers (delegate to `TrustServerCertAsync`/`RemoveServerCertAsync`/`ListServerCertsAsync` at CommunicationService :435/:477/:456); `GetRequiredRoles`: add the three cert commands to the Administrator arm. Comment: "Mutates the site's trusted-peer PKI store — Admin, matching the Admin-gated UI cert page. Site-side reconcile semantics are owned by the site CertStoreActor (plan 03); this handler only relays." 
4. PASS + full run. 5. Doc + commit: `git commit -m "feat(management): Admin-gated actor dispatch for cert-trust commands (arch-review C4)"`

### Task 24: Dispatch-coverage test — every registered command reaches a handler

**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** 18, 20, 25, 26, 28, 29 (waits on Tasks 22, 23)
**Files:**
- Create: `tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/DispatchCoverageTests.cs`

1. Write the test: for every `ManagementCommandRegistry`-registered type (except a documented exclusion list: `ResolveRolesCommand` — intentionally undispatched, see ManagementActor :418), send an envelope carrying `RuntimeHelpers.GetUninitializedObject(type)` with ALL roles to an actor whose `IServiceProvider` is a substitute wired so `CreateScope()` yields a scoped provider that throws a `SentinelException` from `GetService` (mock `IServiceScopeFactory`/`IServiceScope`). Capture the exception MapFault logs via a recording `ILogger<ManagementActor>` (small test logger that stores `LogError` exceptions keyed by correlation id). Assert: for no command is the captured exception a `NotSupportedException` (which is the "no dispatch arm" signature). Commands that succeed without touching the provider (e.g. `GetHealthSummaryCommand`) simply produce no captured exception — also a pass.
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter DispatchCoverageTests` — should PASS immediately after Tasks 22/23; if it fails, the failure list IS the remaining C4-style gap — add the missing arms or (with justification) the exclusion entry.
3. Commit: `git commit -m "test(management): dispatch-coverage test — every registered command has a handler (arch-review UA2)"`

### Task 25: CLI parity — browse / search / verify-endpoint / certs commands

**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** 18, 20, 24, 26, 28, 29 (waits on Tasks 22, 23)
**Files:**
- Create: `src/ZB.MOM.WW.ScadaBridge.CLI/Commands/ConnectionBrowseCommands.cs`
- Modify: CLI `Program.cs` (register the group), `src/ZB.MOM.WW.ScadaBridge.CLI/README.md`, `docs/requirements/Component-CLI.md`
- Test: `tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/` (new `ConnectionBrowseCommandsTests.cs`)

1. Failing parse tests (mirror an existing CLI.Tests command-parse test): `data-connection browse --site SITE1 --connection conn1 --node-id ns=2;s=X` parses and builds a `BrowseNodeCommand` with `SiteIdentifier == "SITE1"`; same for `search` (`--query --max-depth --max-results`), `verify-endpoint` (`--protocol --config-file <path>` reading ConfigJson from the file), `certs list`, `certs trust --der-file <path> --thumbprint <tp>` (base64-encodes the file), `certs remove --thumbprint`.
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.CLI.Tests --filter ConnectionBrowse` — FAIL.
3. Implement the command group with `CommandHelpers.ExecuteCommandAsync` like `SiteCommands.BuildDeployArtifacts`; wire under the existing `data-connection` group. Update README + Component-CLI.md command tables.
4. PASS: `dotnet test tests/ZB.MOM.WW.ScadaBridge.CLI.Tests` (direct — remember the slnx gotcha until Task 33).
5. Commit: `git commit -m "feat(cli): browse/search/verify-endpoint/cert-trust commands — actor parity (arch-review C4)"`

### Task 26: `LoginThrottle` — fixed-window LDAP-bind failure lockout

**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** 1, 3, 4, 6, 14, 19, 21, 29
**Files:**
- Create: `src/ZB.MOM.WW.ScadaBridge.Security/LoginThrottle.cs`
- Modify: `src/ZB.MOM.WW.ScadaBridge.Security/SecurityOptions.cs` (three additive settings + validator rules)
- Modify: `src/ZB.MOM.WW.ScadaBridge.Security/ServiceCollectionExtensions.cs` (`services.AddSingleton<LoginThrottle>();` in `AddSecurity`)
- Test: `tests/ZB.MOM.WW.ScadaBridge.Security.Tests/LoginThrottleTests.cs` (create)

1. Failing tests (drive time with a fake `TimeProvider` — the codebase already injects `TimeProvider` for `CookieSessionValidator`; reuse that pattern):
```csharp
[Fact]
public void FiveFailuresInWindow_LocksOut_ThenWindowExpiryUnlocks()
{
    var clock = new FakeTimeProvider(); // Microsoft.Extensions.TimeProvider.Testing, already used by Security.Tests — verify, else hand-roll
    var throttle = new LoginThrottle(clock, Options.Create(new SecurityOptions()));
    for (var i = 0; i < 5; i++) throttle.RecordFailure("alice", "10.0.0.1");
    Assert.True(throttle.IsLockedOut("alice", "10.0.0.1"));
    Assert.False(throttle.IsLockedOut("alice", "10.0.0.2"));  // per username+IP key
    Assert.False(throttle.IsLockedOut("bob", "10.0.0.1"));
    clock.Advance(TimeSpan.FromMinutes(6));                    // lockout window passed
    Assert.False(throttle.IsLockedOut("alice", "10.0.0.1"));
}

[Fact]
public void SuccessResets() { /* 4 failures + RecordSuccess → not locked after a 5th failure */ }
```
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.Security.Tests --filter LoginThrottleTests` — FAIL.
3. Implement: `ConcurrentDictionary<string, (int Count, DateTimeOffset WindowStart, DateTimeOffset? LockedUntil)>` keyed `{username.ToLowerInvariant()}|{ip}`; fixed window; opportunistic pruning of expired entries on write; bounded (drop-oldest above e.g. 10 000 keys to cap memory under spray). `SecurityOptions` additive: `MaxLoginFailuresPerWindow = 5`, `LoginFailureWindowMinutes = 5`, `LoginLockoutMinutes = 5` (xmldoc: "0 disables throttling"); extend `SecurityOptionsValidator` (non-negative). Register singleton in `AddSecurity`.
4. PASS + full Security.Tests. 5. Commit: `git commit -m "feat(security): LoginThrottle — per-username+IP LDAP-bind failure lockout (arch-review P1)"`

### Task 27: Wire the throttle — ManagementAuthenticator + auth endpoints + doc

**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** none (waits on Task 26)
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementAuthenticator.cs` (`AuthenticateAsync`, lines 101–115)
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Auth/AuthEndpoints.cs` (`/auth/login` + `/auth/token`, before the LDAP bind)
- Modify: `docs/requirements/Component-Security.md` (new "Login throttling" subsection: scope = every LDAP-bind surface — `/auth/login`, `/auth/token`, `POST /management`, audit REST, debug hub, the last three via ManagementAuthenticator)
- Test: `tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementAuthenticatorTests.cs`

1. Failing test (existing harness builds a `DefaultHttpContext` with `RequestServices`; add `LoginThrottle` + real `TimeProvider` to its service collection):
```csharp
[Fact]
public async Task AuthenticateAsync_LockedOutKey_Returns429WithoutLdapBind()
{
    var throttle = GetThrottleFromServices();
    for (var i = 0; i < 5; i++) throttle.RecordFailure("alice", "127.0.0.1");
    var context = BasicAuthContext("alice", "wrong"); // helper: sets Authorization + Connection.RemoteIpAddress
    var outcome = await ManagementAuthenticator.AuthenticateAsync(context);
    Assert.Null(outcome.User);
    // execute outcome.Failure and assert 429 + code AUTH_THROTTLED
    await _ldapAuth.DidNotReceiveWithAnyArgs().AuthenticateAsync(default!, default!, default);
}

[Fact]
public async Task AuthenticateAsync_FailedBind_RecordsFailure() { /* bind fails → next 4 failures lock out */ }
```
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter ManagementAuthenticator` — FAIL.
3. Implement in `AuthenticateAsync` after username/password extraction, before the bind:
```csharp
var throttle = context.RequestServices.GetRequiredService<LoginThrottle>();
var remoteIp = context.Connection.RemoteIpAddress?.ToString();
if (throttle.IsLockedOut(username, remoteIp))
{
    return new AuthOutcome(null, Results.Json(
        new { error = "Too many failed authentication attempts. Try again later.", code = "AUTH_THROTTLED" },
        statusCode: 429));
}
```
After the bind: `if (!authResult.Succeeded) { throttle.RecordFailure(username, remoteIp); ...existing 401... }` else `throttle.RecordSuccess(username, remoteIp);`. Same three lines in `/auth/login` (locked → `Redirect("/login?error=Too+many+failed+attempts.+Try+again+later.")`) and `/auth/token` (429 JSON). The DisableLogin bypass path stays above the throttle (dev only, guarded by Task 1).
4. PASS + full ManagementService.Tests + CentralUI.Tests (auth endpoint tests if present).
5. Doc + commit: `git commit -m "feat(security): wire LoginThrottle into management Basic-Auth and /auth/login|token — password-spray guard (arch-review P1/UA5)"`

### Task 28: Additive paging for ListTemplates / ListInstances

**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** 18, 20, 24, 25, 29 (touches ManagementActor.cs — serialize with 5, 7–13, 15–17, 22, 23, 30)
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/` (the file declaring `ListTemplatesCommand` / `ListInstancesCommand` — locate via grep; add `int Skip = 0, int? Take = null`)
- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs` (`HandleListTemplates` :507, `HandleListInstances` :718)
- Modify: CLI `template list` / `instance list` commands (add `--skip/--take`), `docs/requirements/Component-CLI.md`, `Component-ManagementService.md`
- Test: `tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs`, `tests/ZB.MOM.WW.ScadaBridge.CLI.Tests`

1. Failing test: repo returns 30 templates; `ListTemplatesCommand(Skip: 10, Take: 5)` yields exactly items 11–15 (post-sort order preserved); `ListInstancesCommand` paging applies AFTER the site-scope filter (scoped user with 3 permitted-site instances and `Take: 2` sees 2 of *their* instances, never an out-of-scope one).
2. Run `--filter Paging` — FAIL.
3. Implement: `Take = null` ⇒ unlimited (today's behavior, so existing callers/UI are untouched); apply `.Skip(Math.Max(0, cmd.Skip)).Take(cmd.Take is > 0 and <= 1000 ? cmd.Take.Value : int.MaxValue)` after any in-memory scope filtering. CLI options pass through. Note in `Component-ManagementService.md` that `QueryDeployments`/`ExportBundle` full-table internals remain a logged scale follow-up (arch-review P2, deferred).
4. PASS (both test projects — CLI directly). 5. Commit: `git commit -m "feat(management): additive Skip/Take paging on template/instance lists (arch-review P2)"`

### Task 29: `PollGate` — non-reentrant poll timers on Health + Alarm Summary

**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** 1, 3, 4, 6, 14, 19, 21, 26
**Files:**
- Create: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/PollGate.cs`
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/AlarmSummary.razor` (`StartTimer` ~line 280), `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/Health.razor` (timer ~line 521)
- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/PollGateTests.cs` (create)

1. Failing tests:
```csharp
public class PollGateTests
{
    [Fact]
    public void TryEnter_SecondCallWhileHeld_ReturnsFalse()
    {
        var gate = new PollGate();
        Assert.True(gate.TryEnter());
        Assert.False(gate.TryEnter());
        gate.Exit();
        Assert.True(gate.TryEnter());
    }
}
```
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter PollGateTests` — FAIL.
3. Implement:
```csharp
/// <summary>Reentrancy guard for poll timers: a fixed-period Timer whose refresh outlives
/// the period stacks overlapping fan-outs against an already-degraded site (arch-review S3).
/// Callers TryEnter at tick start and skip the tick when the previous refresh is in flight.</summary>
public sealed class PollGate
{
    private int _inFlight;
    public bool TryEnter() => Interlocked.CompareExchange(ref _inFlight, 1, 0) == 0;
    public void Exit() => Volatile.Write(ref _inFlight, 0);
}
```
Wire both timers: `if (!_pollGate.TryEnter()) return;` at the top of the callback lambda, `try { await RefreshAsync(); StateHasChanged(); } finally { _pollGate.Exit(); }` inside the `InvokeAsync`.
4. PASS + `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests` (no page-test regressions).
5. Commit: `git commit -m "fix(ui): reentrancy guard on Health/AlarmSummary poll timers (arch-review S3)"`

### Task 30: Transport single-flight + base64 copy elimination

**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (ManagementActor.cs)
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs` (`HandleExportBundle` ~2902–2905, `HandlePreviewBundle`, `HandleImportBundle` ~2949)
- Test: `tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/` (new `TransportGateTests.cs`)

1. Failing test: substitute `IBundleImporter` whose `LoadAsync` awaits a `TaskCompletionSource`; send two `ImportBundleCommand` envelopes; assert `LoadAsync` received exactly 1 call while the TCS is unset, then complete it and assert the second proceeds (both eventually answer). 
2. Run `--filter TransportGate` — FAIL.
3. Implement:
```csharp
/// <summary>Single-flight gate for transport bundle commands: a 200 MB import holds
/// ~4 concurrent copies (base64 string, byte[], MemoryStream, JSON envelope), so two
/// concurrent imports on one central node are a realistic OOM path (arch-review S4).
/// Commands queue here instead of running concurrently on the thread pool.</summary>
private static readonly SemaphoreSlim TransportGate = new(1, 1);
```
Wrap the bodies of the three handlers in `await TransportGate.WaitAsync(); try { ... } finally { TransportGate.Release(); }`. In `HandleExportBundle`, replace `var bytes = ms.ToArray(); ... Convert.ToBase64String(bytes)` with `var base64 = Convert.ToBase64String(ms.GetBuffer().AsSpan(0, (int)ms.Length)); return new ExportBundleResult(base64, (int)ms.Length);` (one whole-artifact copy eliminated). Leave a comment that streaming multipart (like the audit export path, `AuditEndpoints.cs:183+`) is the deferred long-term shape.
4. PASS + full ManagementService.Tests + `dotnet test tests/ZB.MOM.WW.ScadaBridge.Transport.Tests` (no behavioral change expected).
5. Commit: `git commit -m "fix(management): single-flight transport bundle commands + drop redundant export copy (arch-review S4)"`

### Task 31: Low cleanup A — hub send fault logging (S5), event-log filter comment (C7), stale CLAUDE.md note (C8)

**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** 32, 33 (and any non-overlapping task)
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/DebugStreamHub.cs` (~lines 204–221)
- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs` (line 2762 comment), `src/ZB.MOM.WW.ScadaBridge.Commons/Messages/RemoteQuery/EventLogQueryRequest.cs` (xmldoc for `InstanceId`)
- Modify: `CLAUDE.md` (Security & Auth section, the SecuredWrite `SourceNode` parenthetical)
- Test: `tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/DebugStreamHubTests.cs`

1. **S5**: change the fire-and-forget send to log failures (keep fire-and-forget semantics — deliberate):
```csharp
_ = hubClients.Client(connectionId).SendAsync(method, payload).ContinueWith(
    t => logger.LogWarning(t.Exception?.GetBaseException(),
        "Debug stream push to connection {ConnectionId} failed", connectionId),
    CancellationToken.None, TaskContinuationOptions.OnlyOnFaulted, TaskScheduler.Default);
```
(adapt to the hub's actual logger access; add a bUnit-free unit test in `DebugStreamHubTests` if the harness exposes the push path — otherwise verify by build + existing hub tests green).
2. **C7** (verified: the site event-log `instance_id` column stores the instance **UniqueName** — `InstanceActor.LogLifecycleEvent` passes `_instanceUniqueName`, `EventLogQueryService.cs:107-111` matches on it): change the `ManagementActor.cs:2762` comment to `cmd.InstanceName, // instance unique-name filter — the site event log's instance_id column stores UniqueName (see InstanceActor.LogLifecycleEvent)`; add matching `<param>` xmldoc on `EventLogQueryRequest.InstanceId`.
3. **C8** (verified: `EmitSecuredWriteAuditAsync` routes through `ICentralAuditWriter` which stamps `SourceNode` — confirm at `ManagementActor.cs:999-1013` before editing): replace the CLAUDE.md parenthetical "(SecuredWrite audit rows currently leave `SourceNode` NULL — a logged follow-up.)" with "(SecuredWrite audit rows stamp `SourceNode` via `ICentralAuditWriter`/`INodeIdentityProvider`.)".
4. `dotnet build ZB.MOM.WW.ScadaBridge.slnx` + `dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests`.
5. Commit: `git commit -m "chore(management): log debug-stream push failures; fix event-log filter docs; refresh stale SourceNode note (arch-review S5/C7/C8)"`

### Task 32: Low cleanup B — Alarm Summary memoization (P4) + sortable-header a11y (UA6)

**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** 31, 33
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/AlarmSummary.razor` (render body ~line 170; header markup ~lines 176–179)
- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/` (extend the existing AlarmSummary page test if present, else create `AlarmSummaryRenderTests.cs`)

1. Failing bUnit test: rendered sortable `<th>` elements carry `tabindex="0"` and `aria-sort` (`ascending`/`descending` on the active column, `none` otherwise); pressing Enter on a header changes the sort (assert row order flips, mirroring the click test).
2. Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter AlarmSummary` — FAIL.
3. Implement: (a) **P4** — replace the in-render `FilteredRows().ToList()` with a cached `_visibleRows` field recomputed in `RefreshAsync` and in each filter/sort setter (one `RecomputeVisibleRows()` helper); (b) **UA6** — sortable `<th>`: add `tabindex="0"`, `aria-sort="@AriaSortFor(col)"`, `@onkeydown` handler toggling sort on `Enter`/`Space` (`@onkeydown:preventDefault` for Space). Leave a note that the same pattern applies to the other custom grids (deferred fleet-wide sweep, logged).
4. PASS. 5. Commit: `git commit -m "chore(ui): memoize AlarmSummary rows + keyboard/aria-sort on sortable headers (arch-review P4/UA6)"`

### Task 33: Add CLI.Tests to the slnx + retire the gotcha

**Classification:** trivial
**Estimated implement time:** ~3 min
**Parallelizable with:** 31, 32
**Files:**
- Modify: `ZB.MOM.WW.ScadaBridge.slnx` (tests ItemGroup, after line 59)
- Modify: `CLAUDE.md` (Editing Rules bullet about CLI.Tests exclusion; CLI Quick Reference untouched)

1. Add: `<Project Path="tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/ZB.MOM.WW.ScadaBridge.CLI.Tests.csproj" />`
2. Verify: `dotnet build ZB.MOM.WW.ScadaBridge.slnx` then `dotnet test ZB.MOM.WW.ScadaBridge.slnx --filter FullyQualifiedName~CLI.Tests` — CLI tests now discovered and green. If the solution build breaks (an undocumented reason for the historical exclusion), revert and record the reason in CLAUDE.md instead — do not leave the solution red.
3. Update the CLAUDE.md Editing Rules bullet: CLI.Tests is now in the slnx; the "silently skipped" gotcha is retired (keep one sentence of history so old memory notes make sense). The user-memory note `cli-tests-not-in-slnx.md` becomes stale — flag it in your final report.
4. Commit: `git add ZB.MOM.WW.ScadaBridge.slnx CLAUDE.md && git commit -m "chore(build): include CLI.Tests in the solution — retire the silent-skip gotcha (arch-review UA3)"`

---

## Dependencies on other plans

- **Plan 01 (Cluster/Host/Failover):** also flags the `DisableLogin` artifact — **this plan owns the fix** (Tasks 1–3); plan 01 should not duplicate. General active-node/`/health/active` semantics stay with plan 01.
- **Plan 03 (Site Runtime & DCL):** cert-trust **site-side reconcile** (central persistence of trust, PKI-store consistency on failover) is plan 03's; Tasks 21/23 here only add the central relay + Admin gate and do not change site `CertStoreActor` behavior.
- **Plan 05 (Templates/Deployment/Transport):** bundle-import **idempotency on a client-supplied import id** (the other half of S1) belongs to the Transport apply path plan 05 is rewriting — coordinate so `HandleImportBundle` merges cleanly (this plan touches it only to add the Task 30 single-flight gate).
- **Plan 08 (Conventions/Tests):** UA4 (single deferred-work tracking list) is deferred to plan 08's consolidated inventory; Task 33 (slnx) is also listed in the overall report's P2-13 housekeeping — this plan does it, plan 08 should skip it.

## Execution order

**P0 (do first, in order):** Task 1 → 2 → 3 (Critical C1 — the auth-disabled artifact). Then the security-correctness wave: 4 → 5 (C2), 6 → 7 → 8 → 9 (C3), 10 (gate refactor) → 11, 12.

**Ordering constraints:** 2←1; 5←4; 7←6; 11←10; 13←(10,11,22,23); 15←14; 16←15; 17←14; 18←17; 20←19; 22←(10,21); 23←(10,21); 24←(22,23); 25←(22,23); 27←26.

**Serialize every task that edits `ManagementActor.cs`** (5, 7, 8, 9, 10, 11, 12, 15, 16, 17, 22, 23, 28, 30, 31) — do not run these concurrently even where logically independent. Tasks 1/3/4/6/14/19/21/26/29 are safe to parallelize early; 31/32/33 can land any time.

**Milestone verification:** after the P0 wave and again at plan completion, run `dotnet build ZB.MOM.WW.ScadaBridge.slnx`, `dotnet test ZB.MOM.WW.ScadaBridge.slnx`, **plus** `dotnet test tests/ZB.MOM.WW.ScadaBridge.CLI.Tests` (until Task 33), and rebuild the docker cluster (`bash docker/deploy.sh`) for a CLI smoke (`--username multi-role --password password`: `deploy artifacts --site-id`, `data-connection get` secret elision, secured-write list gating, a browse call).

---

## Post-plan known issues

- **✅ RESOLVED 2026-07-09 — SecuredWrites bUnit render tests fixed (`IBrowseService` now registered in the test harness).** Fix applied: `SecuredWritesTests` now registers a `Substitute.For<IBrowseService>()` singleton in its constructor (mirroring the sibling `NodeBrowserDialogSearchTests`), so the embedded `NodeBrowserDialog` resolves its injected service at render time. `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter FullyQualifiedName~SecuredWritesTests` → **7/7 passed, 0 failed**. Original report retained below for history.

  ~~Three `CentralUI.Tests/Components/SecuredWritesTests` tests fail at render:~~
  - `Operator_SeesSubmitForm`
  - `Operator_Submit_CallsServiceWithEnteredValues`
  - `Verifier_OwnSubmission_HasApproveAndRejectDisabled`

  Each throws `System.InvalidOperationException: Cannot provide a value for property 'BrowseService' on type 'ZB.MOM.WW.ScadaBridge.CentralUI.Components.Dialogs.NodeBrowserDialog'. There is no registered service of type 'ZB.MOM.WW.ScadaBridge.CentralUI.Services.IBrowseService'.`

  **Root cause:** the Secured Writes page (`src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Operations/SecuredWrites.razor`) renders `Components/Dialogs/NodeBrowserDialog.razor`, which `@inject`s `IBrowseService`. `SecuredWritesTests` registers the page's own collaborators (`_service`, `_siteRepo`, `_dialog`, auth) but not a fake `IBrowseService`, so rendering the embedded dialog throws. This is a **test-harness DI gap**, not a product defect — the page works at runtime where `IBrowseService` is registered.

  **Not a PLAN-03 regression:** discovered during PLAN-03's milestone `dotnet test ZB.MOM.WW.ScadaBridge.slnx` but the failing files were last touched outside that session (`1a7e7351` page/test, `9cff87fe` dialog); PLAN-03 touches only Site Runtime / DCL. Cross-referenced in `00-MASTER-TRACKER.md` → "Pre-existing failure discovered during Wave 1 PLAN-03 execution".

  **Fix (PLAN-07):** register a stub `IBrowseService` in the `SecuredWritesTests` `Services` collection before `RenderPage()` (mirror how `_service`/`_dialog` are added), or add it to the shared bUnit fixture so any page embedding `NodeBrowserDialog` renders. Then re-run `dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter FullyQualifiedName~SecuredWritesTests` → green.
