# Hosts per-driver rows + AbCip nested-struct + Galaxy hygiene — Implementation Plan

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

**Goal:** Ship three disjoint backlog items — a `/hosts` cluster-grouped Driver Instances section (#8),
AbCip nested-struct member expansion (#6), and Galaxy stale-comment hygiene + reconcile (#3/#13/#10).

**Architecture:** Reuse the existing driver-health DPS pipeline (add only an AdminUI-internal store
`GetAll()` + a pure view-model builder) so no Commons/interface/EF change is needed. AbCip stops discarding
the nested template id the member block already carries. Galaxy comments rewritten to shipped reality.

**Tech Stack:** .NET 10, Blazor Server (AdminUI), EF Core (ConfigDB read), xUnit + Shouldly. No bUnit.

**Base:** branch `feat/hosts-rows-abcip-nested-hygiene` off master `f59680fa`; design committed `fec08915`.

**Execution note:** implementers dispatched **serially** on this branch (avoids the shared-tree git-race
lesson); per-task classification-driven review. T3 depends on T2.

---

### Task 1: AbCip nested-struct template-id threading

**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (serial dispatch)

**Files:**
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipTemplateCache.cs:57` (`AbCipUdtMember` record)
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/CipTemplateObjectDecoder.cs:85-96`
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs:~1179` (the nested recursion call)
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipFetchUdtShapeTests.cs` (or a new
  `AbCipNestedTemplateTests.cs` in the same project)

**Context:** Top-level controller-discovered UDT members are addressable (`4e141402`/`4a7b0fde`), but a
nested struct member's sub-shape can't be fetched because the prior fix passed `templateInstanceId: null`
when recursing. The member-info `u16` actually carries the nested template id (bit 15 = struct flag, lower
12 bits = template instance id — see the decoder's own remarks block, lines 26-27). Stop discarding it.

**Step 1: Extend `AbCipUdtMember` (optional trailing param — preserves existing 4-arg call sites)**

```csharp
/// <summary>One member of a Logix UDT.</summary>
/// <param name="Name">Member name.</param>
/// <param name="Offset">Byte offset from the struct start.</param>
/// <param name="DataType">Member CIP data type (Structure for a nested UDT).</param>
/// <param name="ArrayLength">Element count (1 for a scalar member).</param>
/// <param name="NestedTemplateId">For a <see cref="AbCipDataType.Structure"/> member, the nested UDT's
///     template instance id decoded from the member-info low 12 bits; <c>null</c> for a scalar member.</param>
public sealed record AbCipUdtMember(
    string Name,
    int Offset,
    AbCipDataType DataType,
    int ArrayLength,
    uint? NestedTemplateId = null);
```

**Step 2: Decode the nested template id in `CipTemplateObjectDecoder.Decode` (struct members only)**

In the member loop (after computing `isStruct`/`typeCode`, ~line 85), use the **full 12-bit mask** (not the
byte-cast `typeCode`) for the nested id, and thread it into the member:

```csharp
var isStruct = (info & MemberInfoStructFlag) != 0;
var typeCode = (byte)(info & MemberInfoTypeCodeMask);
var dataType = isStruct
    ? AbCipDataType.Structure
    : (CipSymbolObjectDecoder.MapTypeCode(typeCode) ?? AbCipDataType.Structure);
// For a struct member the low 12 bits are the nested UDT's template instance id (same encoding as
// the Symbol Object), NOT a primitive type code — capture it so the nested shape can be fetched.
var nestedTemplateId = isStruct ? (uint?)(info & MemberInfoTypeCodeMask) : null;

var memberName = i < memberNames.Length ? memberNames[i] : $"<member_{i}>";
members.Add(new AbCipUdtMember(
    Name: memberName,
    Offset: offset,
    DataType: dataType,
    ArrayLength: arraySize == 0 ? 1 : arraySize,
    NestedTemplateId: nestedTemplateId));
```

**Step 3: Thread the id at the recursion site (`AbCipDriver.cs:~1179`)**

Change `templateInstanceId: null` → `templateInstanceId: member.NestedTemplateId`:

```csharp
var nested = await ResolveDiscoveredUdtShapeAsync(
    deviceHostAddress, member.Name, templateInstanceId: member.NestedTemplateId, cancellationToken)
    .ConfigureAwait(false);
```

`ResolveDiscoveredUdtShapeAsync` still consults the name-keyed seeded shapes (the `SeedDiscoveredUdtShapeForTest`
seam) first, then falls back to `FetchUdtShapeAsync(deviceHostAddress, id, ct)` — which reads `@udt/{id}` and
decodes — when an id is now present. Deep nesting works recursively (each level decodes its own members'
`NestedTemplateId`), bounded by the existing `MaxUdtDepth` + `visited` cycle guard.

**Step 4: Tests (offline, via `FakeTemplateReader`)**

- **Decoder test:** build a minimal Template Object blob (header + one struct member with
  `info = 0x8000 | 0x123`, plus a scalar member) using the same byte layout the existing tests use; assert
  the struct member decodes `DataType == Structure` AND `NestedTemplateId == 0x123u`, and the scalar member
  decodes `NestedTemplateId == null`.
- **Threading test:** drive the discovery/fan-out path for a top-level UDT whose member is a nested struct;
  configure the `FakeTemplateReader` to return the nested template's blob for the nested id; assert the nested
  struct's leaf members become addressable (emitted) — i.e. the nested id now drives a real fetch rather than
  yielding `null`. Reuse the existing discovery test harness pattern in this project; do NOT use
  `SeedDiscoveredUdtShapeForTest` for the nested shape (that would bypass what we're testing).

**Step 5: Run + commit**

```bash
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests --filter "FullyQualifiedName~Udt" -v minimal
git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipTemplateCache.cs \
        src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/CipTemplateObjectDecoder.cs \
        src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs \
        tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/<test-file>.cs
git commit -m "feat(abcip): thread nested-struct template id so nested UDT members are addressable (#6)"
```

---

### Task 2: `/hosts` driver-snapshot store enumeration + pure grouping view-model

**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (serial; T3 depends on this)

**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/IDriverStatusSnapshotStore.cs`
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/InMemoryDriverStatusSnapshotStore.cs`
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hosts/HostsDriverView.cs` (pure builder + view-model records)
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Hosts/HostsDriverViewTests.cs` (confirm the AdminUI
  test project path; create the file)

**Step 1: Add `GetAll()` to the store interface**

```csharp
/// <summary>Returns a point-in-time snapshot of every driver instance's last-known health.</summary>
IReadOnlyCollection<DriverHealthChanged> GetAll();
```

**Step 2: Implement in `InMemoryDriverStatusSnapshotStore`**

```csharp
/// <inheritdoc />
public IReadOnlyCollection<DriverHealthChanged> GetAll() => _byInstance.Values.ToArray();
```

**Step 3: Pure grouping view-model `HostsDriverView`**

Define plain input/output records (no EF types) so it unit-tests offline. Group by `ClusterId`, union of
cluster ids from nodes + snapshots; enrich each snapshot with `Name`/`DriverType` by joining instances on
`DriverInstanceId`; unknown driver (snapshot with no matching instance) → null Name/DriverType fallback.

```csharp
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Hosts;

using ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers;

/// <summary>A cluster node as needed for the Hosts driver view (subset of the ConfigDB ClusterNode row).</summary>
public sealed record HostsNodeInfo(string ClusterId, string NodeId, string Host, int OpcUaPort);

/// <summary>A driver instance as needed for enrichment (subset of the ConfigDB DriverInstance row).</summary>
public sealed record HostsDriverInstanceInfo(string DriverInstanceId, string ClusterId, string Name, string DriverType);

/// <summary>One driver row in a cluster group: live health enriched with the configured name + type.</summary>
public sealed record HostsDriverRow(
    string DriverInstanceId,
    string? Name,
    string? DriverType,
    string State,
    DateTime? LastSuccessfulReadUtc,
    string? LastError,
    int ErrorCount5Min,
    DateTime PublishedUtc);

/// <summary>One cluster group: the cluster's nodes + the cluster's live driver rows.</summary>
public sealed record HostsClusterGroup(
    string ClusterId,
    IReadOnlyList<HostsNodeInfo> Nodes,
    IReadOnlyList<HostsDriverRow> Drivers);

/// <summary>Pure builder for the Hosts page Driver-Instances section — DB-agnostic + unit-testable.</summary>
public static class HostsDriverView
{
    /// <summary>Builds the cluster-grouped driver view from live snapshots + cached ConfigDB rows.</summary>
    /// <param name="snapshots">Live driver-health snapshots from the snapshot store.</param>
    /// <param name="nodes">ConfigDB cluster nodes (for the per-cluster node list).</param>
    /// <param name="instances">ConfigDB driver instances (for Name/DriverType enrichment).</param>
    /// <returns>Cluster groups ordered by ClusterId; drivers ordered by Name ?? DriverInstanceId.</returns>
    public static IReadOnlyList<HostsClusterGroup> Build(
        IEnumerable<DriverHealthChanged> snapshots,
        IEnumerable<HostsNodeInfo> nodes,
        IEnumerable<HostsDriverInstanceInfo> instances)
    {
        var snapList = snapshots?.ToList() ?? new();
        var nodeList = nodes?.ToList() ?? new();
        var instById = (instances ?? Enumerable.Empty<HostsDriverInstanceInfo>())
            .GroupBy(i => i.DriverInstanceId)
            .ToDictionary(g => g.Key, g => g.First());

        var clusterIds = snapList.Select(s => s.ClusterId)
            .Concat(nodeList.Select(n => n.ClusterId))
            .Where(id => !string.IsNullOrEmpty(id))
            .Distinct(StringComparer.OrdinalIgnoreCase)
            .OrderBy(id => id, StringComparer.OrdinalIgnoreCase);

        var groups = new List<HostsClusterGroup>();
        foreach (var clusterId in clusterIds)
        {
            var clusterNodes = nodeList
                .Where(n => string.Equals(n.ClusterId, clusterId, StringComparison.OrdinalIgnoreCase))
                .OrderBy(n => n.NodeId, StringComparer.OrdinalIgnoreCase)
                .ToList();

            var drivers = snapList
                .Where(s => string.Equals(s.ClusterId, clusterId, StringComparison.OrdinalIgnoreCase))
                .Select(s =>
                {
                    instById.TryGetValue(s.DriverInstanceId, out var inst);
                    return new HostsDriverRow(
                        s.DriverInstanceId, inst?.Name, inst?.DriverType, s.State,
                        s.LastSuccessfulReadUtc, s.LastError, s.ErrorCount5Min, s.PublishedUtc);
                })
                .OrderBy(d => d.Name ?? d.DriverInstanceId, StringComparer.OrdinalIgnoreCase)
                .ToList();

            groups.Add(new HostsClusterGroup(clusterId, clusterNodes, drivers));
        }
        return groups;
    }
}
```

**Step 4: Tests (xUnit + Shouldly)** in `HostsDriverViewTests.cs`:
- two clusters with nodes + snapshots → two groups, correct nodes + drivers per cluster;
- enrichment: a snapshot whose `DriverInstanceId` matches an instance → Name + DriverType filled;
- unknown-driver fallback: a snapshot with no matching instance → row present with null Name/DriverType;
- a cluster with nodes but no snapshots → group present, empty Drivers;
- empty inputs → empty list;
- driver ordering by Name then DriverInstanceId.

**Step 5: Run + commit**

```bash
dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests --filter "FullyQualifiedName~HostsDriverView" -v minimal
git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/IDriverStatusSnapshotStore.cs \
        src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/InMemoryDriverStatusSnapshotStore.cs \
        src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hosts/HostsDriverView.cs \
        tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Hosts/HostsDriverViewTests.cs
git commit -m "feat(adminui): driver-snapshot GetAll() + pure Hosts driver-view builder (#8)"
```

---

### Task 3: `/hosts` "Driver Instances" Razor section

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

**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Hosts.razor`

**Context:** Add a new section below the existing Members section. Keep the Akka member rows untouched.
Pattern after `DriverStatusPanel` for the live subscription (in-process store read, marshal via InvokeAsync —
dodges the Traefik self-hub trap) and after `Fleet.razor` for the ConfigDB read.

**Step 1: Injects + usings** (top of Hosts.razor)

```razor
@using ZB.MOM.WW.OtOpcUa.AdminUI.Hosts
@using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs
@using ZB.MOM.WW.OtOpcUa.Configuration
@using Microsoft.EntityFrameworkCore
@inject IDriverStatusSnapshotStore DriverStore
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
```
(Confirm the ConfigDbContext type name + namespace from `Fleet.razor`.)

**Step 2: State + lifecycle (in `@code`)**

- Fields: `IReadOnlyList<HostsClusterGroup>? _driverGroups;` plus cached
  `List<HostsNodeInfo> _nodes`, `List<HostsDriverInstanceInfo> _instances`.
- `OnInitializedAsync`: `await LoadConfigAsync();` then `RebuildDriverGroups();` then
  `DriverStore.SnapshotChanged += OnSnapshotChanged;`
- `LoadConfigAsync()`: `await using var db = await DbFactory.CreateDbContextAsync();` read `ClusterNodes`
  → `HostsNodeInfo(ClusterId, NodeId, Host, OpcUaPort)`; read `DriverInstances` →
  `HostsDriverInstanceInfo(DriverInstanceId, ClusterId, Name, DriverType)`. (Confirm DbSet + property names.)
- `RebuildDriverGroups()`: `_driverGroups = HostsDriverView.Build(DriverStore.GetAll(), _nodes, _instances);`
- `OnSnapshotChanged(DriverHealthChanged _)`: `InvokeAsync(() => { RebuildDriverGroups(); StateHasChanged(); });`
  (cheap — reuses cached config; a brand-new driver shows by id until the 5 s timer/Refresh re-reads config).
- Extend the existing `Refresh`/timer + `RefreshAsync` to also `await LoadConfigAsync(); RebuildDriverGroups();`
  so configured names stay current. (The timer callback is sync — make a small async path or call
  `LoadConfigAsync()` fire-and-forget-with-InvokeAsync; keep it simple and correct.)
- `Dispose()`: add `DriverStore.SnapshotChanged -= OnSnapshotChanged;` alongside the existing `_timer?.Dispose()`.

**Step 3: Markup — new section below the Members `</section>`**

A panel "Driver instances" with a small notice that rows are **cluster-scoped** (health is per driver
instance across the cluster, not per Akka member). For each `HostsClusterGroup`: a sub-head
`Cluster <ClusterId>` + node chips (`<NodeId> (<Host>:<OpcUaPort>)`) + a table of drivers:
columns Driver (Name ?? DriverInstanceId, with the id in `.mono` small if a name exists), Type, Status
(chip via the mapping below), Last read, Last error, Errors/5 min. Handle `_driverGroups is null` (Loading)
and empty (a notice "No driver instances reporting yet").

**Step 4: Local chip mapping (mirror `DriverStatusPanel.ChipClass`)**

```csharp
private static string DriverChipClass(string? state) => state switch
{
    "Healthy"      => "chip-ok",
    "Degraded"     => "chip-warn",
    "Connecting"   => "chip-warn",
    "Reconnecting" => "chip-warn",
    "Faulted"      => "chip-bad",
    _              => "chip-idle",
};
```

**Step 5: Build + commit** (no bUnit — proven live in Task 6)

```bash
dotnet build src/Server/ZB.MOM.WW.OtOpcUa.AdminUI -v minimal
git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Hosts.razor
git commit -m "feat(adminui): /hosts cluster-grouped Driver Instances section (#8)"
```
Also update the page's stale top-of-file comment (lines 2-5: "there are no per-driver host rows yet") to
reflect the shipped section.

---

### Task 4: Galaxy stale-comment hygiene

**Classification:** trivial
**Estimated implement time:** ~4 min
**Parallelizable with:** none (serial)

**Files:**
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs` (~lines 52, 92, 669)
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriverFactoryExtensions.cs` (~line 19)
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Health/HostStatusAggregator.cs` (~line 21)

**Comment-only.** Verify each claim against the current code BEFORE rewriting (e.g. confirm `_ownedMxSession`
is built in `InitializeAsync`, the probe-watcher membership refresh after `DiscoverAsync` is wired,
`HostStatusAggregator.OnHostStatusChanged` is re-raised by the driver). Rewrite to shipped reality:
- Drop "PR 4.W" / "PR 4.4 supplies the production implementation; until then…" / "legacy-host backend
  handles reads in production" — Galaxy is the standard Equipment-kind driver now; reads ARE supported in
  production; the legacy Galaxy.Host/Proxy/Shared were retired in PR 7.2.
- `GalaxyDriverFactoryExtensions`: drop "PR 4.W will add a server-side `Galaxy:Backend` switch … parity
  testing (Phase 5)" — never landed and won't (only `GalaxyMxGateway` exists; the legacy `Galaxy` proxy
  type is retired). State the shipped reality (the distinct type name is historical; no backend switch).
- Convert any genuinely-still-future note to a real `TODO`, else delete the forward-ref.

Do NOT change any code — comments/XML-doc only.

**Build + commit:**
```bash
dotnet build src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy -v minimal
git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs \
        src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriverFactoryExtensions.cs \
        src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Health/HostStatusAggregator.cs
git commit -m "docs(galaxy): rewrite stale PR-4.W/legacy-host forward-ref comments to shipped reality (#13)"
```

---

### Task 5: Reconcile stillpending + memory (never-staged for stillpending)

**Classification:** trivial
**Estimated implement time:** ~3 min
**Parallelizable with:** none

**Files:**
- Modify: `stillpending.md` (**never staged** — local working copy only)
- Modify: `/Users/dohertj2/.claude/projects/-Users-dohertj2-Desktop-OtOpcUa/memory/MEMORY.md`
- Modify: `/Users/dohertj2/.claude/projects/-Users-dohertj2-Desktop-OtOpcUa/memory/project_stillpending_backlog.md`

- `stillpending.md`: mark item #10 SHIPPED (the `ctx`-receiver guard is in `ScriptAnalysisService.cs:224`,
  `70e1bde9`); strike #8 (this phase) + #6 (this phase) SHIPPED with the commit refs once known; note the
  AbCip finding (id was already in the member block, not a new query) + the Hosts cluster-scoped framing +
  the deferred per-member-nesting follow-up.
- Memory: update `project_stillpending_backlog.md` + the `MEMORY.md` one-liner. Keep `MEMORY.md` entries
  short (the index is already near its size cap).

**Commit (memory files only — stillpending.md stays unstaged):**
```bash
git add docs/plans/2026-06-18-hosts-rows-abcip-nested-hygiene.md.tasks.json
git commit -m "docs(plans): mark hosts/abcip/hygiene tasks complete + reconcile"
```
(Memory files live outside the repo; they are written, not committed.)

---

### Task 6: Build + tests + live `/run` + finish

**Classification:** small
**Estimated implement time:** ~5 min (+ live-verify)
**Parallelizable with:** none

**Step 1: Full build + the three affected test projects**
```bash
dotnet build ZB.MOM.WW.OtOpcUa.slnx -v minimal
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests -v minimal
dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests -v minimal
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests -v minimal
```
Expected: build clean; all green (AbCip + AdminUI + Galaxy). Note any pre-existing unrelated failures.

**Step 2: Component A live `/run` (docker-dev)** — rebuild **BOTH** central-1 AND central-2 (the `:9200`
AdminUI round-robins across both; a half-deploy serves stale code). Deploy a Modbus driver, open
`http://localhost:9200/hosts`, confirm the Driver Instances section lists the deployed driver(s) grouped by
cluster with a live status chip; drive a Reconnect from a driver page and confirm the chip updates. Record
the result honestly (login is disabled on the local rig — drive it yourself; do not defer to user sign-in).

**Step 3: Finish** — superpowers-extended-cc:finishing-a-development-branch → merge to master + push.
