Files
ScadaBridge/archreview/plans/PLAN-07-ui-management-security.md
T
Joseph Doherty 3a21e77c03 docs(archreview): add cross-plan parallelization map for incomplete tasks + sync stale PLAN-04 status
Corrects the Status Board (PLAN-04 24/26, not 0), and adds a Parallelization Map to
00-MASTER-TRACKER plus a per-plan Parallelization block to PLAN-04/05/06/07/08. Derives
ready sets from the live .tasks.json blockedBy graphs and the Files metadata, identifies
the initiative-wide serialization mutexes (ManagementActor.cs single-writer, EF migration
snapshot, Host/Program.cs, ArtifactDeploymentService.cs, SiteStorageService.cs,
NotificationOutboxActor.cs, AlarmSummary.razor), and recommends the safe concurrent
subagent lanes per plan. Flags PLAN-07 T33 as a dedupe-skip (CLI.Tests already in slnx).
2026-07-09 09:53:05 -04:00

90 KiB
Raw Blame History

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

T1T3 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 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 UniqueNameInstanceActor.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, 1418; 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

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:
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);
}
  1. Run: dotnet test tests/ZB.MOM.WW.ScadaBridge.Security.Tests --filter DisableLoginGuardTests — expect FAIL (type missing).
  2. Implement DisableLoginGuard.cs:
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:

/// <summary>Explicit second acknowledgement key: permits DisableLogin outside Development. Default false.</summary>
public bool AllowOutsideDevelopment { get; set; }
  1. Run the filter again — expect PASS. Run full project: dotnet test tests/ZB.MOM.WW.ScadaBridge.Security.Tests.
  2. 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 130138, 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:
// 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)));
  1. 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.
  2. dotnet build ZB.MOM.WW.ScadaBridge.slnx — expect clean.
  3. 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.
  4. 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 3942 "Auth" block; line 44 _comment_Security)
  • Modify: deploy/wonder-app-vd03/RUNBOOK.md (add a login note)
  1. Delete the block:
      "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 227345)
  • 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):
[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);
}
  1. Run: dotnet test tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests --filter DeployToSiteAsync — expect FAIL (no such method).
  2. 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:
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 713, 1518, 22, 23, 30) Files:

  • Modify: src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs (HandleDeployArtifacts lines 20062013; 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):
[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);
}
  1. Run: dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter DeployArtifacts — expect FAIL.
  2. Implement:
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:
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);
    }
}
  1. Run: dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter ConfigSecretScrubberTests — FAIL.
  2. Implement with System.Text.Json.Nodes.JsonNode recursion:
/// <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.
}
  1. 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 14011456: 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"):
[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:

/// <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:
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 15871625)
  • 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:
/// <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 25262559)
  • Modify: docs/requirements/Component-ManagementService.md
  • Test: same file as Tasks 78
  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 97242: 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:
[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));
}
  1. Run: dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter ListSecuredWrites — FAIL (currently ungated).
  2. Implement:
    • Rename GetRequiredRoleinternal 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:
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).
  1. PASS + full ManagementService.Tests + Security.Tests runs.
  2. 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:
[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 */ }
  1. dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter CreateArea — Deployer case FAILS today.
  2. 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.
  3. 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 19041944 — do not run concurrently with other ManagementActor tasks) Files:

  • Modify: src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs (lines 19041944: 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:
[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
}
  1. dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter RoleMapping — FAIL.
  2. Replace ValidateMappingRole with:
// 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):
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));
    }
}
  1. 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.
  2. 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.
  3. 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:
[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));
}
  1. Run: dotnet test tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests --filter "TryMarkExpired|CountAsync_Filters" — FAIL.
  2. Add to the interface (xmldoc mirrors TryMarkApprovedAsync's CAS wording — this is the multi-node-safe expiry primitive):
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):
[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 */ }
  1. Run: dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter SecuredWrite — new tests FAIL.
  2. 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:
/// <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.)
  1. 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):
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 1833 ResolveAskTimeout; line 101 call site)
  • Test: tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementEndpointsTests.cs
  1. Failing tests:
[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)));
  1. Run: dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter ResolveAskTimeout — FAIL.
  2. Implement:
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:
"ManagementService": {
  "CommandTimeout": "00:00:30",
  "LongRunningCommandTimeout": "00:05:00"
}
  1. Extend the doc's configuration table row with LongRunningCommandTimeout (TimeSpan, default 5 min, applied to ImportBundle/PreviewBundle/ExportBundle/MgmtDeployArtifacts/MgmtDeployInstance).
  2. 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).
  3. 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.:
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 204207 becomes true)
  • Test: tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs
  1. Failing tests:
[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 */ }
  1. Run --filter BrowseNode — FAIL (falls through to NotSupportedException today).
  2. Implement three handlers following HandleQueryEventLogs's shape:
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 HandleSearchAddressSpaceSearchAddressSpaceAsync, HandleVerifyEndpointVerifyEndpointAsync; 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):
[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 */ }
  1. Run: dotnet test tests/ZB.MOM.WW.ScadaBridge.Security.Tests --filter LoginThrottleTests — FAIL.
  2. 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.
  3. 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 101115)
  • 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):
[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 */ }
  1. Run: dotnet test tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests --filter ManagementAuthenticator — FAIL.
  2. Implement in AuthenticateAsync after username/password extraction, before the bind:
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, 713, 1517, 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 1115 (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:
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());
    }
}
  1. Run: dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter PollGateTests — FAIL.
  2. Implement:
/// <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 ~29022905, 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:
/// <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 204221)
  • 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):
_ = 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 UniqueNameInstanceActor.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 176179)
  • 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 13); 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~SecuredWritesTests7/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 @injects 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.