# 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 2–8.

**Step 1: Write the component**

```razor
@* 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**

```bash
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`:

```csharp
// 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:
```csharp
    private IReadOnlyList<ModbusTagDefinition> _tags = [];
    private string _tagsJson = "[]";
```
with:
```csharp
    private List<ModbusTagRow> _tags = [];
```

In `OnInitializedAsync`, replace `_tags = opts.Tags;` with:
```csharp
                _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:
```csharp
_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:

```razor
            <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`:

```csharp
[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**

```bash
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&lt;string&gt;) | 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**

```csharp
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**

```csharp
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 tests** — `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests --filter "FullyQualifiedName~ResilienceFormModel"` → PASS.

**Step 4: Commit**

```bash
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):

```razor
@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: Build** — `dotnet build src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj` → 0 errors.

**Step 3: Commit**

```bash
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**

```csharp
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**

```csharp
[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: Run** — `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests --filter "FullyQualifiedName~RoleMapper"` → PASS.

**Step 4: Commit**

```bash
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):

```csharp
services.AddScoped<ILdapGroupRoleMappingService, LdapGroupRoleMappingService>();
```

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

**Step 2: Build** the solution → 0 errors.

**Step 3: Commit**

```bash
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:
```csharp
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:
```csharp
        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`:
```csharp
        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**

```bash
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:
```csharp
            o.AddPolicy("FleetAdmin", policy => policy.RequireRole("FleetAdmin"));
```

**Step 2: Build** → 0 errors.

**Step 3: Commit** — `git 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):

```razor
@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**

```bash
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: Run** — `dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests --filter "FullyQualifiedName~LdapGroupRoleMapping"` → PASS.

**Step 3: Commit** — `git 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.
