Files
lmxopcua/docs/plans/2026-05-29-adminui-followups.md
T
Joseph Doherty b76561a780 docs(adminui): implementation plan + task persistence for deferred follow-ups
19 tasks across WS1 (driver collection editors), WS2 (typed resilience
form), WS3 (editable DB-backed LDAP role map, global), WS4 (cleanup).
2026-05-29 08:59:55 -04:00

46 KiB
Raw Blame History

AdminUI Deferred Follow-ups Implementation Plan

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

Goal: Complete the real deferred follow-ups behind the AdminUI's "Phase C.2 / follow-up" notes — driver collection editors (tags/devices/endpoints), a typed resilience-override form, and editable DB-backed LDAP→role mapping — then strip the now-obsolete notes and stale source comments.

Architecture: A generic modal-per-row CollectionEditor<TRow> Blazor component edits in-memory lists that the existing driver-page Save already serializes into DriverInstance.DriverConfig. The resilience textarea becomes a typed form bound to a nullable ResilienceFormModel that emits the same override JSON the runtime parser reads. LDAP role mapping moves to the already-existing LdapGroupRoleMapping DB entity (global rows only), wired into the cookie-login path with the appsettings map as a fallback baseline.

Tech Stack: .NET 10, Blazor Server (InteractiveServer), EF Core (OtOpcUaConfigDbContext), xUnit + Shouldly, Bootstrap 5 markup. No bUnit — logic is tested through plain mapping/merge methods + EF in-memory.

Branch: feat/adminui-followups (design doc: docs/plans/2026-05-29-adminui-followups-design.md).

Key facts (verified during planning):

  • Driver tag/device lists live in DriverConfig JSON and ARE the runtime source of truth (driver factories deserialize and poll them). The canonical Tag table is orthogonal.
  • Driver tag/device contracts are immutable positional records → editors bind to mutable row VMs that map to/from the record.
  • Resilience override JSON shape (parser DriverResilienceOptionsParser, case-insensitive keys): { "bulkheadMaxConcurrent": int?, "bulkheadMaxQueue": int?, "recycleIntervalSeconds": int?, "capabilityPolicies": { "<Capability>": { "timeoutSeconds": int?, "retryCount": int?, "breakerFailureThreshold": int? } } }. Capabilities: Read, Write, Discover, Subscribe, Probe, AlarmSubscribe, AlarmAcknowledge, HistoryRead.
  • LDAP login: AuthEndpoints.LoginAsync builds role claims from result.Roles (which LdapAuthService computes from appsettings GroupToRole). ILdapGroupRoleMappingService (CRUD over LdapGroupRoleMapping, AdminRole enum: ConfigViewer/ConfigEditor/FleetAdmin) exists but is not registered in DI and not wired into login.
  • Auth policies live in src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs (AddAuthorization, fallback RequireAuthenticatedUser, existing DriverOperator policy).
  • Test projects: tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests (per-driver *FormSerializationTests.cs), tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests (RoleMapperTests.cs, AuthEndpointsIntegrationTests.cs), tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests (LdapGroupRoleMappingServiceTests.cs).

Global conventions for every task:

  • Run dotnet build ZB.MOM.WW.OtOpcUa.slnx after edits; it must be clean.
  • Commit at the end of each task with the message shown.
  • IReadOnlyList<...> collection round-trip tests use the page's _jsonOpts (camelCase, UnmappedMemberHandling.Skip).

WS1 — Driver collection editors

Task 1: Generic CollectionEditor<TRow> shared component

Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 9, Task 11, Task 12, Task 14, Task 16

Files:

  • Create: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/CollectionEditor.razor

This is a presentation component; no unit test (no bUnit). It is exercised by the Modbus proof (Task 2) and /run (Task 19). Keep ALL logic in the component trivial; per-driver mapping/validation is tested in Tasks 28.

Step 1: Write the component

@* Generic modal-per-row list editor. The parent owns the List<TRow> (a MUTABLE row VM,
   because driver contracts are immutable records). This renders a read-only table with
   Add/Edit/Delete and a modal that edits a CLONED working copy — commit on Save, discard
   on Cancel. NewRow builds a default VM; Clone copies one for the working copy; Validate
   (optional) returns an error string to block commit or null to allow. *@
@typeparam TRow

<section class="panel rise mt-3" style="@_styleDelay">
    <div class="panel-head d-flex align-items-center">
        <span>@Title (@Items.Count)</span>
        <button type="button" class="btn btn-sm btn-outline-primary ms-auto" @onclick="Add">+ Add @ItemNoun</button>
    </div>
    @if (Items.Count == 0)
    {
        <div style="padding:1rem" class="text-muted">No @ItemNoun.ToLowerInvariant() rows.</div>
    }
    else
    {
        <div class="table-wrap">
            <table class="data-table">
                <thead>@HeaderTemplate</thead>
                <tbody>
                    @for (var i = 0; i < Items.Count; i++)
                    {
                        var idx = i;
                        <tr>
                            @RowTemplate(Items[idx])
                            <td class="text-end" style="white-space:nowrap">
                                <button type="button" class="btn btn-sm btn-link p-0 me-2" @onclick="() => Edit(idx)">Edit</button>
                                <button type="button" class="btn btn-sm btn-link p-0 text-danger" @onclick="() => Delete(idx)">Delete</button>
                            </td>
                        </tr>
                    }
                </tbody>
            </table>
        </div>
    }
</section>

@if (_modalOpen && _working is not null)
{
    <div class="modal-backdrop fade show" style="display:block"></div>
    <div class="modal fade show" tabindex="-1" role="dialog" style="display:block">
        <div class="modal-dialog modal-lg" role="document">
            <div class="modal-content">
                <div class="modal-header">
                    <h5 class="modal-title">@(_editIndex is null ? $"Add {ItemNoun}" : $"Edit {ItemNoun}")</h5>
                    <button type="button" class="btn-close" aria-label="Close" @onclick="Cancel"></button>
                </div>
                <div class="modal-body">
                    @EditTemplate(_working)
                    @if (!string.IsNullOrEmpty(_validationError))
                    {
                        <div class="text-danger small mt-2">@_validationError</div>
                    }
                </div>
                <div class="modal-footer">
                    <button type="button" class="btn btn-outline-secondary" @onclick="Cancel">Cancel</button>
                    <button type="button" class="btn btn-primary" @onclick="Commit">Save</button>
                </div>
            </div>
        </div>
    </div>
}

@code {
    [Parameter, EditorRequired] public List<TRow> Items { get; set; } = default!;
    [Parameter] public EventCallback ItemsChanged { get; set; }
    [Parameter] public string Title { get; set; } = "Items";
    [Parameter] public string ItemNoun { get; set; } = "row";
    [Parameter] public string AnimationDelay { get; set; } = ".18s";
    [Parameter, EditorRequired] public RenderFragment HeaderTemplate { get; set; } = default!;
    [Parameter, EditorRequired] public RenderFragment<TRow> RowTemplate { get; set; } = default!;
    [Parameter, EditorRequired] public RenderFragment<TRow> EditTemplate { get; set; } = default!;
    [Parameter, EditorRequired] public Func<TRow> NewRow { get; set; } = default!;
    [Parameter, EditorRequired] public Func<TRow, TRow> Clone { get; set; } = default!;
    [Parameter] public Func<TRow, IReadOnlyList<TRow>, int?, string?>? Validate { get; set; }

    private string _styleDelay => $"animation-delay:{AnimationDelay}";
    private bool _modalOpen;
    private int? _editIndex;
    private TRow? _working;
    private string? _validationError;

    private void Add()
    {
        _editIndex = null;
        _working = NewRow();
        _validationError = null;
        _modalOpen = true;
    }

    private void Edit(int index)
    {
        _editIndex = index;
        _working = Clone(Items[index]);
        _validationError = null;
        _modalOpen = true;
    }

    private async Task Delete(int index)
    {
        Items.RemoveAt(index);
        await ItemsChanged.InvokeAsync();
    }

    private void Cancel()
    {
        _modalOpen = false;
        _working = default;
        _editIndex = null;
        _validationError = null;
    }

    private async Task Commit()
    {
        if (_working is null) return;
        _validationError = Validate?.Invoke(_working, Items, _editIndex);
        if (_validationError is not null) return;

        if (_editIndex is int i) Items[i] = _working;
        else Items.Add(_working);

        _modalOpen = false;
        _working = default;
        _editIndex = null;
        await ItemsChanged.InvokeAsync();
    }
}

Step 2: Build

Run: dotnet build src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj Expected: Build succeeded, 0 errors.

Step 3: Commit

git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/CollectionEditor.razor
git commit -m "feat(adminui): add generic CollectionEditor<TRow> modal list editor"

Task 2: Wire Modbus tag editor (proof) + round-trip test

Classification: standard Estimated implement time: ~5 min Parallelizable with: none (reference task — Tasks 3-8 mirror it; do this first)

Files:

  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/ModbusDriverPage.razor
  • Reference (read for field names): src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusDriverOptions.cs:283 (ModbusTagDefinition)
  • Test: tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ModbusDriverPageFormSerializationTests.cs

ModbusTagDefinition(string Name, ModbusRegion Region, ushort Address, ModbusDataType DataType, bool Writable=true, ModbusByteOrder ByteOrder=BigEndian, byte BitIndex=0, ushort StringLength=0, ModbusStringByteOrder StringByteOrder=HighByteFirst, bool WriteIdempotent=false, int? ArrayCount=null, double? Deadband=null, byte? UnitId=null, bool CoalesceProhibited=false).

Step 1: Add a mutable row VM + mapping inside ModbusDriverPage.razor @code

Add this nested class beside FormModel:

// Mutable VM for the modal editor — ModbusTagDefinition is an immutable record.
public sealed class ModbusTagRow
{
    public string Name { get; set; } = "";
    public ModbusRegion Region { get; set; } = ModbusRegion.HoldingRegister;
    public int Address { get; set; }
    public ModbusDataType DataType { get; set; } = ModbusDataType.Int16;
    public bool Writable { get; set; } = true;
    public ModbusByteOrder ByteOrder { get; set; } = ModbusByteOrder.BigEndian;
    public int BitIndex { get; set; }
    public int StringLength { get; set; }
    public bool WriteIdempotent { get; set; }

    public ModbusTagRow Clone() => (ModbusTagRow)MemberwiseClone();

    public static ModbusTagRow FromDefinition(ModbusTagDefinition d) => new()
    {
        Name = d.Name, Region = d.Region, Address = d.Address, DataType = d.DataType,
        Writable = d.Writable, ByteOrder = d.ByteOrder, BitIndex = d.BitIndex,
        StringLength = d.StringLength, WriteIdempotent = d.WriteIdempotent,
    };

    public ModbusTagDefinition ToDefinition() => new(
        Name: Name.Trim(), Region: Region, Address: (ushort)Math.Clamp(Address, 0, 65535),
        DataType: DataType, Writable: Writable, ByteOrder: ByteOrder,
        BitIndex: (byte)Math.Clamp(BitIndex, 0, 255), StringLength: (ushort)Math.Clamp(StringLength, 0, 65535),
        WriteIdempotent: WriteIdempotent);

    public static string? ValidateRow(ModbusTagRow row, IReadOnlyList<ModbusTagRow> all, int? editIndex)
    {
        if (string.IsNullOrWhiteSpace(row.Name)) return "Name is required.";
        for (var i = 0; i < all.Count; i++)
            if (i != editIndex && string.Equals(all[i].Name, row.Name, StringComparison.OrdinalIgnoreCase))
                return $"Duplicate tag name '{row.Name}'.";
        return null;
    }
}

Step 2: Replace the _tags field + load/save plumbing

Replace:

    private IReadOnlyList<ModbusTagDefinition> _tags = [];
    private string _tagsJson = "[]";

with:

    private List<ModbusTagRow> _tags = [];

In OnInitializedAsync, replace _tags = opts.Tags; with:

                _tags = opts.Tags.Select(ModbusTagRow.FromDefinition).ToList();

and delete the line _tagsJson = System.Text.Json.JsonSerializer.Serialize(_tags, _jsonOpts); (both the one inside the else block context and the trailing one before _loaded = true;). After this change there is no _tagsJson.

In SubmitAsync and SerializeCurrentConfig, replace _form.ToOptions(_tags) with:

_form.ToOptions(_tags.Select(r => r.ToDefinition()).ToList())

Step 3: Replace the read-only Tags section markup

Replace the whole @* Tags — read-only JSON view *@ <section>…</section> block (lines ~276-285) with:

            <CollectionEditor TRow="ModbusTagRow" Items="_tags" Title="Tags" ItemNoun="tag"
                              NewRow="@(() => new ModbusTagRow())" Clone="@(r => r.Clone())"
                              Validate="ModbusTagRow.ValidateRow">
                <HeaderTemplate>
                    <tr><th>Name</th><th>Region</th><th>Address</th><th>Type</th><th>Writable</th><th></th></tr>
                </HeaderTemplate>
                <RowTemplate Context="t">
                    <td class="mono">@t.Name</td><td>@t.Region</td><td class="mono">@t.Address</td>
                    <td>@t.DataType</td><td>@(t.Writable ? "yes" : "no")</td>
                </RowTemplate>
                <EditTemplate Context="t">
                    <div class="row g-3">
                        <div class="col-md-6"><label class="form-label">Name</label>
                            <input class="form-control form-control-sm" @bind="t.Name" /></div>
                        <div class="col-md-3"><label class="form-label">Region</label>
                            <select class="form-select form-select-sm" @bind="t.Region">
                                @foreach (var e in Enum.GetValues<ModbusRegion>()) { <option value="@e">@e</option> }
                            </select></div>
                        <div class="col-md-3"><label class="form-label">Address</label>
                            <input type="number" class="form-control form-control-sm" @bind="t.Address" /></div>
                        <div class="col-md-3"><label class="form-label">Data type</label>
                            <select class="form-select form-select-sm" @bind="t.DataType">
                                @foreach (var e in Enum.GetValues<ModbusDataType>()) { <option value="@e">@e</option> }
                            </select></div>
                        <div class="col-md-3"><label class="form-label">Byte order</label>
                            <select class="form-select form-select-sm" @bind="t.ByteOrder">
                                @foreach (var e in Enum.GetValues<ModbusByteOrder>()) { <option value="@e">@e</option> }
                            </select></div>
                        <div class="col-md-2"><label class="form-label">Bit index</label>
                            <input type="number" class="form-control form-control-sm" @bind="t.BitIndex" /></div>
                        <div class="col-md-2"><label class="form-label">String len</label>
                            <input type="number" class="form-control form-control-sm" @bind="t.StringLength" /></div>
                        <div class="col-md-2"><div class="form-check form-switch mt-4">
                            <input type="checkbox" class="form-check-input" @bind="t.Writable" id="tagWritable" />
                            <label class="form-check-label" for="tagWritable">Writable</label></div></div>
                    </div>
                </EditTemplate>
            </CollectionEditor>

(Leave the existing address-picker UI intact — it stays useful for copying an address into the modal.)

Step 4: Write the failing test

Add to ModbusDriverPageFormSerializationTests.cs:

[Fact]
public void TagRow_round_trips_through_definition()
{
    var row = new ModbusDriverPage.ModbusTagRow
    {
        Name = "Pump1_Speed", Region = ModbusRegion.HoldingRegister, Address = 40001,
        DataType = ModbusDataType.Int16, Writable = true,
    };
    var def = row.ToDefinition();
    var back = ModbusDriverPage.ModbusTagRow.FromDefinition(def);

    back.Name.ShouldBe("Pump1_Speed");
    back.Address.ShouldBe(40001);
    back.DataType.ShouldBe(ModbusDataType.Int16);
    back.Writable.ShouldBeTrue();
}

[Fact]
public void Tag_list_survives_options_serialize_round_trip()
{
    var tags = new List<ModbusTagDefinition>
    {
        new("A", ModbusRegion.HoldingRegister, 1, ModbusDataType.Int16),
        new("B", ModbusRegion.Coil, 2, ModbusDataType.Bool),
    };
    var opts = new ModbusDriverPage.FormModel().ToOptions(tags);
    var json = JsonSerializer.Serialize(opts, TestJsonOpts);   // mirror page _jsonOpts (camelCase, Skip)
    var back = JsonSerializer.Deserialize<ModbusDriverOptions>(json, TestJsonOpts)!;
    back.Tags.Count.ShouldBe(2);
    back.Tags[0].Name.ShouldBe("A");
}

[Fact]
public void ValidateRow_rejects_duplicate_name()
{
    var rows = new List<ModbusDriverPage.ModbusTagRow> { new() { Name = "A" } };
    ModbusDriverPage.ModbusTagRow.ValidateRow(new() { Name = "A" }, rows, null)
        .ShouldNotBeNull();
}

If TestJsonOpts isn't already present in the test file, add a private static matching the page: new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip }.

Step 5: Run tests

Run: dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests --filter "FullyQualifiedName~ModbusDriverPage" Expected: PASS (3 new tests green).

Step 6: Build the AdminUI project (Razor compile)

Run: dotnet build src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj Expected: 0 errors.

Step 7: Commit

git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/ModbusDriverPage.razor tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ModbusDriverPageFormSerializationTests.cs
git commit -m "feat(adminui): editable Modbus tag list via CollectionEditor"

Tasks 3-8: Roll out editors to remaining drivers (mirror Task 2)

Each task follows the exact Task 2 pattern for its driver page: (a) read the driver's contract record(s), (b) add a mutable row VM (<Driver><Tag|Device|Endpoint>Row) with Clone(), FromX/ToX, and a ValidateRow (required key + duplicate check), (c) change the _tags/_devices/_endpoints field to List<VM>, fix load (…Select(VM.FromX).ToList()) and save (…Select(r => r.ToX()).ToList()), (d) replace each read-only <pre> section with a <CollectionEditor>, (e) add round-trip + validate tests to the driver's existing *FormSerializationTests.cs, (f) build + test + commit.

Classification: standard · Estimated implement time: ~5 min each · Parallelizable with: each other and Tasks 9, 11, 12, 14, 16 (distinct files). All depend on Task 1; use Task 2 as the template.

Task Driver page (…/Components/Pages/Clusters/Drivers/) Contract(s) to read Editors Test file
3 AbCipDriverPage.razor Driver.AbCip.Contracts/AbCipDriverOptions.cs (device + tag records) Devices, Tags AbCipDriverPageFormSerializationTests.cs
4 AbLegacyDriverPage.razor Driver.AbLegacy.Contracts/AbLegacyDriverOptions.cs Devices, Tags AbLegacyDriverPageFormSerializationTests.cs
5 TwinCATDriverPage.razor Driver.TwinCAT*/…Options.cs Devices, Tags TwinCATDriverPageFormSerializationTests.cs
6 FocasDriverPage.razor Driver.FOCAS*/…Options.cs (device has series enum) Devices, Tags FocasDriverPageFormSerializationTests.cs
7 S7DriverPage.razor Driver.S7*/…Options.cs (tag record) Tags S7DriverPageFormSerializationTests.cs
8 OpcUaClientDriverPage.razor OpcUaClientDriverOptions.EndpointUrls (List<string>) Endpoint URLs OpcUaClientDriverPageFormSerializationTests.cs

Notes:

  • Task 8 endpoint rows: VM is sealed class EndpointUrlRow { public string Url { get; set; } = ""; public EndpointUrlRow Clone() => (EndpointUrlRow)MemberwiseClone(); }; map to/from string; validate non-empty + well-formed opc.tcp://. Leave UnsMappingTable's read-only JSON as-is (out of scope; only the endpoint list note is removed in Task 18).
  • Device deletes: keep a Validate/confirm note that tags referencing the device by host address may break — surface it in the modal helper text; no cascade logic.
  • Commit message per task: feat(adminui): editable <driver> tag/device list via CollectionEditor.

WS2 — Resilience typed form

Task 9: ResilienceFormModel (FromJson/ToJson) + tests

Classification: standard Estimated implement time: ~5 min Parallelizable with: Tasks 1-8, 11, 12, 14, 16

Files:

  • Create: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/ResilienceFormModel.cs
  • Test: tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ResilienceFormModelTests.cs
  • Reference: src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverResilienceOptionsParser.cs (JSON shape, case-insensitive)

Step 1: Write the model

using System.Text.Json;
using System.Text.Json.Serialization;

namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers;

/// <summary>
///     Mutable, all-nullable form model for the driver resilience override. Binds the typed
///     fields in DriverResilienceSection; null/blank = "use the driver's tier default", so a
///     blank form serializes back to null (preserving DriverInstance.ResilienceConfig = null).
///     Emits / reads the exact override JSON shape DriverResilienceOptionsParser consumes.
/// </summary>
public sealed class ResilienceFormModel
{
    public static readonly string[] Capabilities =
        ["Read", "Write", "Discover", "Subscribe", "Probe", "AlarmSubscribe", "AlarmAcknowledge", "HistoryRead"];

    public int? BulkheadMaxConcurrent { get; set; }
    public int? BulkheadMaxQueue { get; set; }
    public int? RecycleIntervalSeconds { get; set; }

    // capability name -> (timeout, retry, breaker), each nullable.
    public Dictionary<string, CapabilityRow> Policies { get; set; } =
        Capabilities.ToDictionary(c => c, _ => new CapabilityRow());

    public sealed class CapabilityRow
    {
        public int? TimeoutSeconds { get; set; }
        public int? RetryCount { get; set; }
        public int? BreakerFailureThreshold { get; set; }
        public bool IsEmpty => TimeoutSeconds is null && RetryCount is null && BreakerFailureThreshold is null;
    }

    private static readonly JsonSerializerOptions ReadOpts = new() { PropertyNameCaseInsensitive = true };
    private static readonly JsonSerializerOptions WriteOpts = new()
    {
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
        DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
    };

    public static ResilienceFormModel FromJson(string? json)
    {
        var model = new ResilienceFormModel();
        if (string.IsNullOrWhiteSpace(json)) return model;

        Shape? shape;
        try { shape = JsonSerializer.Deserialize<Shape>(json, ReadOpts); }
        catch (JsonException) { return model; }   // malformed -> empty form; raw view (Task 10) shows the text
        if (shape is null) return model;

        model.BulkheadMaxConcurrent = shape.BulkheadMaxConcurrent;
        model.BulkheadMaxQueue = shape.BulkheadMaxQueue;
        model.RecycleIntervalSeconds = shape.RecycleIntervalSeconds;
        if (shape.CapabilityPolicies is not null)
            foreach (var (cap, p) in shape.CapabilityPolicies)
                if (model.Policies.TryGetValue(cap, out var row))
                {
                    row.TimeoutSeconds = p.TimeoutSeconds;
                    row.RetryCount = p.RetryCount;
                    row.BreakerFailureThreshold = p.BreakerFailureThreshold;
                }
        return model;
    }

    /// <summary>Emit only the non-null overrides; returns null when nothing is overridden.</summary>
    public string? ToJson()
    {
        var caps = Policies
            .Where(kv => !kv.Value.IsEmpty)
            .ToDictionary(kv => kv.Key, kv => new PolicyShape
            {
                TimeoutSeconds = kv.Value.TimeoutSeconds,
                RetryCount = kv.Value.RetryCount,
                BreakerFailureThreshold = kv.Value.BreakerFailureThreshold,
            });

        var hasAny = BulkheadMaxConcurrent is not null || BulkheadMaxQueue is not null
                     || RecycleIntervalSeconds is not null || caps.Count > 0;
        if (!hasAny) return null;

        var shape = new Shape
        {
            BulkheadMaxConcurrent = BulkheadMaxConcurrent,
            BulkheadMaxQueue = BulkheadMaxQueue,
            RecycleIntervalSeconds = RecycleIntervalSeconds,
            CapabilityPolicies = caps.Count > 0 ? caps : null,
        };
        return JsonSerializer.Serialize(shape, WriteOpts);
    }

    private sealed class Shape
    {
        public int? BulkheadMaxConcurrent { get; set; }
        public int? BulkheadMaxQueue { get; set; }
        public int? RecycleIntervalSeconds { get; set; }
        public Dictionary<string, PolicyShape>? CapabilityPolicies { get; set; }
    }

    private sealed class PolicyShape
    {
        public int? TimeoutSeconds { get; set; }
        public int? RetryCount { get; set; }
        public int? BreakerFailureThreshold { get; set; }
    }
}

Step 2: Write the failing tests

using System.Text.Json;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Resilience;

public class ResilienceFormModelTests
{
    [Fact]
    public void Blank_form_serializes_to_null()
        => new ResilienceFormModel().ToJson().ShouldBeNull();

    [Fact]
    public void Partial_override_round_trips()
    {
        var m = new ResilienceFormModel { BulkheadMaxConcurrent = 16 };
        m.Policies["Read"].TimeoutSeconds = 5;
        m.Policies["Read"].RetryCount = 5;

        var json = m.ToJson();
        json.ShouldNotBeNull();

        var back = ResilienceFormModel.FromJson(json);
        back.BulkheadMaxConcurrent.ShouldBe(16);
        back.Policies["Read"].TimeoutSeconds.ShouldBe(5);
        back.Policies["Write"].IsEmpty.ShouldBeTrue();
    }

    [Fact]
    public void Emitted_json_is_consumable_by_the_runtime_parser()
    {
        var m = new ResilienceFormModel { BulkheadMaxConcurrent = 16 };
        m.Policies["Read"].TimeoutSeconds = 7;

        var opts = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.B, m.ToJson(), out var diag);
        diag.ShouldBeNull();
        opts.BulkheadMaxConcurrent.ShouldBe(16);
        opts.Resolve(DriverCapability.Read).TimeoutSeconds.ShouldBe(7);
        // untouched capability keeps the tier-B default retry count
        opts.Resolve(DriverCapability.Write).RetryCount.ShouldBe(0);
    }
}

Step 3: Run testsdotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests --filter "FullyQualifiedName~ResilienceFormModel" → PASS.

Step 4: Commit

git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/ResilienceFormModel.cs tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ResilienceFormModelTests.cs
git commit -m "feat(adminui): typed resilience override form model + tests"

Task 10: Rewrite DriverResilienceSection.razor to the typed form

Classification: standard Estimated implement time: ~5 min Parallelizable with: none (depends on Task 9)

Files:

  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverResilienceSection.razor

The component keeps its [Parameter] string? ResilienceConfig + ResilienceConfigChanged contract (so all 9 driver pages need NO change). Internally it parses to a ResilienceFormModel, binds typed fields, and on any change re-emits ToJson(). A collapsible "raw JSON" <details> keeps the escape hatch.

Step 1: Replace the file body (drop the stale "matches legacy DriverEdit" comment):

@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers

<section class="panel rise mt-3" style="animation-delay:.14s">
    <div class="panel-head">Resilience overrides (optional)</div>
    <div style="padding:1rem">
        <p class="form-text mb-3">Blank fields use the driver type's stability-tier defaults
            (see <span class="mono">docs/v2/driver-stability.md</span>). Set only what you need to override.</p>

        <div class="row g-3">
            <div class="col-md-4"><label class="form-label">Bulkhead max concurrent</label>
                <input type="number" class="form-control form-control-sm" @bind="_m.BulkheadMaxConcurrent" @bind:after="EmitAsync" placeholder="tier default" /></div>
            <div class="col-md-4"><label class="form-label">Bulkhead max queue</label>
                <input type="number" class="form-control form-control-sm" @bind="_m.BulkheadMaxQueue" @bind:after="EmitAsync" placeholder="tier default" /></div>
            <div class="col-md-4"><label class="form-label">Recycle interval (s, Tier C only)</label>
                <input type="number" class="form-control form-control-sm" @bind="_m.RecycleIntervalSeconds" @bind:after="EmitAsync" placeholder="none" /></div>
        </div>

        <div class="table-wrap mt-3">
            <table class="data-table">
                <thead><tr><th>Capability</th><th>Timeout (s)</th><th>Retries</th><th>Breaker threshold</th></tr></thead>
                <tbody>
                    @foreach (var cap in ResilienceFormModel.Capabilities)
                    {
                        var row = _m.Policies[cap];
                        <tr>
                            <td class="mono">@cap</td>
                            <td><input type="number" class="form-control form-control-sm" @bind="row.TimeoutSeconds" @bind:after="EmitAsync" placeholder="default" /></td>
                            <td><input type="number" class="form-control form-control-sm" @bind="row.RetryCount" @bind:after="EmitAsync" placeholder="default" /></td>
                            <td><input type="number" class="form-control form-control-sm" @bind="row.BreakerFailureThreshold" @bind:after="EmitAsync" placeholder="default" /></td>
                        </tr>
                    }
                </tbody>
            </table>
        </div>

        <details class="mt-3">
            <summary class="small text-muted">Raw JSON (advanced)</summary>
            <pre class="form-control form-control-sm mono mt-2" style="white-space:pre-wrap;min-height:3rem;">@(_m.ToJson() ?? "(null — all tier defaults)")</pre>
        </details>
    </div>
</section>

@code {
    [Parameter] public string? ResilienceConfig { get; set; }
    [Parameter] public EventCallback<string?> ResilienceConfigChanged { get; set; }

    private ResilienceFormModel _m = new();
    private string? _lastParsed;

    protected override void OnParametersSet()
    {
        // Re-parse only when the inbound value actually changed (avoid clobbering edits on re-render).
        if (!string.Equals(_lastParsed, ResilienceConfig, StringComparison.Ordinal))
        {
            _m = ResilienceFormModel.FromJson(ResilienceConfig);
            _lastParsed = ResilienceConfig;
        }
    }

    private async Task EmitAsync()
    {
        var json = _m.ToJson();
        _lastParsed = json;
        ResilienceConfig = json;
        await ResilienceConfigChanged.InvokeAsync(json);
    }
}

Step 2: Builddotnet build src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj → 0 errors.

Step 3: Commit

git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverResilienceSection.razor
git commit -m "feat(adminui): typed resilience override form replaces JSON textarea"

WS3 — Editable DB-backed LDAP→role map (global)

Task 11: RoleMapper DB-merge overload + tests

Classification: small Estimated implement time: ~4 min Parallelizable with: Tasks 1-10, 12, 14, 16

Files:

  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/RoleMapper.cs
  • Test: tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/RoleMapperTests.cs

Semantics: final roles = the appsettings/dev baseline (result.Roles) the system-wide DB grants. Additive (a group's DB grant adds to, never removes, the baseline). This preserves DevStubMode's FleetAdmin and the empty-DB/DB-down case (baseline only). Cluster-scoped rows are ignored (global-only).

Step 1: Add the overload

using ZB.MOM.WW.OtOpcUa.Configuration.Entities;

// …existing Map(groups, dict) stays…

/// <summary>
///     Merge the appsettings-derived baseline roles with system-wide DB grants. DB rows are
///     additive; cluster-scoped rows (IsSystemWide == false) are ignored under the global model.
/// </summary>
/// <param name="baselineRoles">Roles already resolved from appsettings (or the dev stub).</param>
/// <param name="dbRows">LdapGroupRoleMapping rows for the user's groups (from GetByGroupsAsync).</param>
public static IReadOnlyList<string> Merge(
    IReadOnlyCollection<string> baselineRoles,
    IReadOnlyCollection<LdapGroupRoleMapping> dbRows)
{
    var roles = new HashSet<string>(baselineRoles, StringComparer.OrdinalIgnoreCase);
    foreach (var row in dbRows)
        if (row.IsSystemWide)
            roles.Add(row.Role.ToString());
    return [.. roles];
}

Step 2: Write the failing tests

[Fact]
public void Merge_unions_baseline_and_systemwide_db_roles()
{
    var rows = new[]
    {
        new LdapGroupRoleMapping { LdapGroup = "g1", Role = AdminRole.FleetAdmin, IsSystemWide = true },
        new LdapGroupRoleMapping { LdapGroup = "g2", Role = AdminRole.ConfigEditor, IsSystemWide = false, ClusterId = "SITE-A" },
    };
    var result = RoleMapper.Merge(["ConfigViewer"], rows);
    result.ShouldContain("ConfigViewer");
    result.ShouldContain("FleetAdmin");
    result.ShouldNotContain("ConfigEditor");   // cluster-scoped row ignored (global-only)
}

[Fact]
public void Merge_with_no_db_rows_returns_baseline()
    => RoleMapper.Merge(["FleetAdmin"], []).ShouldBe(["FleetAdmin"]);

(Add using ZB.MOM.WW.OtOpcUa.Configuration.Entities; and …Enums;.)

Step 3: Rundotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests --filter "FullyQualifiedName~RoleMapper" → PASS.

Step 4: Commit

git add src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/RoleMapper.cs tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/RoleMapperTests.cs
git commit -m "feat(security): RoleMapper.Merge — additive DB-backed role grants"

Task 12: Register ILdapGroupRoleMappingService in DI

Classification: small Estimated implement time: ~4 min Parallelizable with: Tasks 1-11, 14, 16

Files:

  • Modify: the composition root(s) where OtOpcUaConfigDbContext/config services are registered. Find with: grep -rIn "AddDbContextFactory<OtOpcUaConfigDbContext>\|AddOtOpcUa\|AddConfigurationServices" src/Server --include='*.cs' | grep -v /bin/ Likely an AddConfiguration…/ServiceCollectionExtensions in ZB.MOM.WW.OtOpcUa.Configuration or the AdminUI/Host Program.cs.

Step 1: Register the scoped service next to the config DbContext registration (both the Admin UI process and the auth/login host need it):

services.AddScoped<ILdapGroupRoleMappingService, LdapGroupRoleMappingService>();

(Add using ZB.MOM.WW.OtOpcUa.Configuration.Services;.)

Step 2: Build the solution → 0 errors.

Step 3: Commit

git commit -am "chore(di): register ILdapGroupRoleMappingService"

Task 13: Wire DB merge into AuthEndpoints.LoginAsync

Classification: high-risk (auth path) Estimated implement time: ~5 min Parallelizable with: none (depends on Tasks 11, 12)

Files:

  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.Security/Endpoints/AuthEndpoints.cs
  • Test: tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AuthEndpointsIntegrationTests.cs (keep green; add a DB-backed-role case if the fixture supports it).

Step 1: Inject the service and merge before building claims. Change the LoginAsync signature to add the service, and replace the role-claim loop.

Signature:

private static async Task<IResult> LoginAsync(
    HttpContext http,
    ILdapAuthService ldap,
    ILdapGroupRoleMappingService roleMappings,
    CancellationToken ct)

After the if (!result.Success) block and before building claims, compute merged roles:

        IReadOnlyList<string> roles = result.Roles;
        try
        {
            var dbRows = await roleMappings.GetByGroupsAsync(result.Groups, ct);
            roles = RoleMapper.Merge(result.Roles, dbRows);
        }
        catch (Exception ex)
        {
            // DB hiccup must never block sign-in — fall back to the appsettings baseline.
            http.RequestServices.GetService<ILoggerFactory>()?
                .CreateLogger("ZB.MOM.WW.OtOpcUa.Security.AuthEndpoints")
                .LogWarning(ex, "DB role-map lookup failed for {User}; using appsettings baseline", username);
        }

Then change the claim loop to iterate roles instead of result.Roles:

        foreach (var role in roles)
            claims.Add(new Claim(ClaimTypes.Role, role));

(Add using Microsoft.Extensions.DependencyInjection; and using Microsoft.Extensions.Logging; and using ZB.MOM.WW.OtOpcUa.Configuration.Services;.)

Confirm LdapAuthResult exposes Groups (it does: positional record (Success, DisplayName, Username, Groups, Roles, Error)).

Step 2: Build the solution → 0 errors.

Step 3: Run the security tests

Run: dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests Expected: all PASS (existing login/logout integration tests still green — the minimal-API handler resolves the new scoped service from the test host's DI; if the test host doesn't register it, register it in that fixture).

Step 4: Commit

git add src/Server/ZB.MOM.WW.OtOpcUa.Security/Endpoints/AuthEndpoints.cs tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AuthEndpointsIntegrationTests.cs
git commit -m "feat(security): merge DB-backed LDAP role grants into login claims"

Task 14: Add FleetAdmin authorization policy

Classification: trivial Estimated implement time: ~2 min Parallelizable with: Tasks 1-13, 16

Files:

  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs:90 (inside the AddAuthorization lambda, beside the DriverOperator policy)

Step 1: Add:

            o.AddPolicy("FleetAdmin", policy => policy.RequireRole("FleetAdmin"));

Step 2: Build → 0 errors.

Step 3: Commitgit commit -am "feat(security): add FleetAdmin authorization policy"


Task 15: Rewrite RoleGrants.razor as global CRUD (FleetAdmin-gated)

Classification: standard Estimated implement time: ~5 min Parallelizable with: none (depends on Tasks 12, 14)

Files:

  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/RoleGrants.razor

Page keeps the LDAP-binding card (from IOptionsSnapshot<LdapOptions>) and shows the appsettings GroupToRole map read-only as "fallback (appsettings)". Adds a DB-backed editable table over ILdapGroupRoleMappingService (system-wide rows only). Gate the whole page to FleetAdmin.

Step 1: Replace the file. Drop the stale "deferred" comment + notice. Set @attribute [Authorize(Policy = "FleetAdmin")]. Inject IDbContextFactory? No — inject ILdapGroupRoleMappingService directly.

Key @code shape (full markup mirrors existing cards/tables + a small add-row form + a CollectionEditor-free inline table with Delete; add is a 2-field form: group + AdminRole):

@page "/role-grants"
@attribute [Microsoft.AspNetCore.Authorization.Authorize(Policy = "FleetAdmin")]
@rendermode RenderMode.InteractiveServer
@using Microsoft.Extensions.Options
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
@using ZB.MOM.WW.OtOpcUa.Configuration.Services
@using ZB.MOM.WW.OtOpcUa.Security.Ldap
@inject IOptionsSnapshot<LdapOptions> Ldap
@inject ILdapGroupRoleMappingService RoleMappings

@code {
    private LdapOptions? _options;
    private IReadOnlyList<LdapGroupRoleMapping> _rows = [];
    private string _newGroup = "";
    private AdminRole _newRole = AdminRole.ConfigViewer;
    private string? _error;

    protected override async Task OnInitializedAsync()
    {
        _options = Ldap.Value;
        await ReloadAsync();
    }

    private async Task ReloadAsync()
        => _rows = (await RoleMappings.ListAllAsync(default)).Where(r => r.IsSystemWide).ToList();

    private async Task AddAsync()
    {
        _error = null;
        if (string.IsNullOrWhiteSpace(_newGroup)) { _error = "LDAP group is required."; return; }
        try
        {
            await RoleMappings.CreateAsync(new LdapGroupRoleMapping
            {
                LdapGroup = _newGroup.Trim(), Role = _newRole, IsSystemWide = true, ClusterId = null,
            }, default);
            _newGroup = "";
            await ReloadAsync();
        }
        catch (Exception ex) { _error = ex.Message; }   // e.g. duplicate (group) unique-index violation
    }

    private async Task DeleteAsync(Guid id)
    {
        _error = null;
        try { await RoleMappings.DeleteAsync(id, default); await ReloadAsync(); }
        catch (Exception ex) { _error = ex.Message; }
    }
}

Markup: keep the existing "LDAP binding" card; add a panel "Group → role (database)" with an add-row form (<input @bind="_newGroup">, <select @bind="_newRole"> over Enum.GetValues<AdminRole>(), <button @onclick="AddAsync">Add</button>), a table of _rows (LdapGroup, Role, Delete button), and _error display; and a separate read-only panel "Fallback (appsettings)" rendering _options.GroupToRole as today (label it as fallback used when a group has no database row).

Step 2: Build the AdminUI project → 0 errors. Confirm the page resolves ILdapGroupRoleMappingService (registered in Task 12).

Step 3: Commit

git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/RoleGrants.razor
git commit -m "feat(adminui): editable DB-backed LDAP role map (global, FleetAdmin-gated)"

Task 16: Extend LdapGroupRoleMappingServiceTests for global CRUD

Classification: small Estimated implement time: ~4 min Parallelizable with: Tasks 1-14

Files:

  • Modify: tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/LdapGroupRoleMappingServiceTests.cs

Step 1: Add tests using the file's existing in-memory EF setup pattern:

  • CreateAsync a system-wide row → ListAllAsync returns it.
  • CreateAsync with IsSystemWide=true + non-null ClusterId → throws InvalidLdapGroupRoleMappingException.
  • DeleteAsync removes the row.

(If a uniqueness test against a relational provider is needed, follow the existing fixture; in-memory EF won't enforce the unique index, so duplicate-rejection is asserted via the SQL fixture if the file already uses one — otherwise leave duplicate handling to the page's catch.)

Step 2: Rundotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests --filter "FullyQualifiedName~LdapGroupRoleMapping" → PASS.

Step 3: Commitgit commit -am "test(config): global LdapGroupRoleMapping CRUD"


WS4 — Cleanup (runs last, after features land)

Task 17: Delete stale source comments

Classification: trivial Estimated implement time: ~3 min Parallelizable with: Task 18 Depends on: Tasks 2-8, 10, 15 (features must exist first)

Files (delete/clean the stale comment only — verify each is truly obsolete before editing):

  • src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/FleetStatusHub.cs:10-12 — remove "passive channel / until the bridge lands" (bridge FleetStatusSignalRBridge is wired).
  • src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs:~15-23 — remove the "follow-up F15 / 47 .razor files" paragraph; keep an accurate one-liner.
  • src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverIdentitySection.razor:1 — drop "generic DriverEdit page … (Phase 4)"; keep "shared identity section".
  • src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/DriverEditRouter.razor:2-5 and DriverTypePicker.razor:1-3 — remove TODO(3.3/3.4) + "falls back to legacy DriverEdit" references (legacy file is gone; verify the router's fallback branch and clean it if it points nowhere).

Step 1: Edit each comment. Step 2: dotnet build src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj → 0 errors. Step 3: git commit -am "docs(adminui): remove stale follow-up source comments (F15/F16/Phase4/TODO)"


Task 18: Strip now-true rendered notes

Classification: trivial Estimated implement time: ~3 min Parallelizable with: Task 17 Depends on: Tasks 2-8, 15

Files (remove the rendered note paragraph; the feature it described now exists):

  • Per-driver pages — the "list-editor coming in a follow-up phase" <p class="form-text"> notes (Modbus/AbCip/AbLegacy/TwinCAT/S7/FOCAS, replaced by the editor in WS1; remove any leftover note line).
  • OpcUaClientDriverPage.razor — the "Endpoint URLs list — read-only JSON view (full list-editor is a follow-up)" note.
  • RoleGrants.razor — already removed in Task 15 (verify none remains).
  • DriverResilienceSection.razor — already removed in Task 10 (verify).

Confirm clean: grep -rIn -iE "follow-up phase|list-editor|coming in a follow|read-only JSON view" src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components returns nothing in rendered markup.

Step 1: Edit. Step 2: Build AdminUI → 0 errors. Step 3: git commit -am "docs(adminui): strip rendered follow-up notes now that editors ship"


Task 19: Full verification

Classification: standard Estimated implement time: ~5 min (plus /run) Depends on: all tasks

Step 1: dotnet build ZB.MOM.WW.OtOpcUa.slnx → 0 errors. Step 2: dotnet test ZB.MOM.WW.OtOpcUa.slnx → all green (note any pre-existing unrelated failures). Step 3: /run the AdminUI and manually verify: add/edit/delete a Modbus tag and one device-driver's device; set a resilience override + confirm raw-JSON reflects it; add + delete a RoleGrants row (as FleetAdmin) and confirm a non-FleetAdmin is blocked. Step 4: Use superpowers-extended-cc:finishing-a-development-branch to decide merge/PR.


Task dependency summary

  • Task 1 → blocks 2,3,4,5,6,7,8
  • Task 9 → blocks 10
  • Tasks 11,12 → block 13
  • Tasks 12,14 → block 15
  • Tasks 2-8,10,15 → block 17,18
  • All → block 19
  • Parallel pools: {1,9,11,12,14,16} can start immediately; {2-8} after 1; {3-8} parallel with each other.