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

1029 lines
46 KiB
Markdown
Raw Blame History

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