# Historian within-timestamp paging (#400) + AbCip/TwinCAT UDT member-paths — Implementation Plan

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

**Goal:** Close two `stillpending.md` §2 items — page raw history *within* a single oversized
tie-cluster timestamp instead of failing loudly (#400), and make AbCip + TwinCAT UDT/struct
members individually discoverable + readable.

**Architecture:** Three disjoint workstreams in one branch (parallelizable implementers).
WS-A adds a pure `HistoryPaging.SliceTieCluster` decision + a bounded within-timestamp
over-fetch in `ServeRawPaged` + a `MaxTieClusterOverfetch` knob. WS-B (AbCip) fans out
controller-discovered UDTs into atomic-member Variables by fetching the cached UDT shape
(mirroring the existing pre-declared fan-out). WS-B (TwinCAT) extracts a pure recursive
symbol-leaf expander and feeds the real ADS symbol tree through it.

**Tech Stack:** .NET 10, OPC UA Foundation UA-.NETStandard, xUnit + Shouldly, libplctag
(AbCip), TwinCAT.Ads. No EF migration, no Commons wire/proto change, no bUnit.

**Design:** `docs/plans/2026-06-17-historian-paging-udt-member-paths-design.md` (committed
`ad66ecc9`). Branch `feat/stillpending-historian-paging-udt-members` off master `c402872c`.

**Hard rules (every task):** stage by explicit path, never `git add .`; never stage
`sql_login.txt` / `src/Server/.../pki/` / `pending.md` / `current.md` /
`docker-dev/docker-compose.yml` / `stillpending.md`; never echo/commit secrets; no
force-push; no `--no-verify`; **NO EF migration, NO Commons wire/proto change, NO bUnit**.
Run build/test with `dangerouslyDisableSandbox: true` (the sandbox blocks the rig + some
local resources). Each implementer commits only the paths in its own `Files:` block.

---

### Task 1 (WS-A): #400 within-timestamp tie-cluster paging

**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 2, Task 3

**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/HistoryPaging.cs` (add `SliceTieCluster`)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs:1881-1892` (replace the stuck-branch loud-fail; add a `MaxTieClusterOverfetch` property near the `HistorianDataSource` property at `:156-179`)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ServerHistorianOptions.cs` (add `MaxTieClusterOverfetch` option + a `Validate()` warning when ≤ 0)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs:199-202` (wire the option onto the node manager at `StartAsync`, mirroring `SetHistorianDataSource`)
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/HistoryPagingTests.cs` (pure `SliceTieCluster` tests)
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadPagingTests.cs` (oversized-tie-cluster integration test; reuse the existing `SeriesHistorianDataSource` fake + `ReadRaw`/`MakeSeries` harness)

**Context.** `ServeRawPaged` (`OtOpcUaNodeManager.cs:1812-1928`) synthesises Raw paging over a
single-shot `(start,end,cap)` backend. The tie-safe cursor is `HistoryContinuationState(Tagname,
NextStartUtc=T, EndUtc, BoundarySkipCount=skip, NumValuesPerNode)`. The "stuck" branch at
`:1881-1892` fires when an inbound resume returns a FULL backend page that the boundary-tie trim
empties entirely — i.e. more ties share `T` than `cap`. Today it logs + returns
`BadHistoryOperationUnsupported`. We replace that with a bounded within-timestamp over-fetch.

The backend (`HistorianDataSource.ReadRawAsync:405`) treats `(start,end)` inclusive both ends and
honors an **explicit** cap; **`cap==0` falls back to `MaxValuesPerRead`** (`:431`), so the
over-fetch MUST pass an explicit cap. The existing `SeriesHistorianDataSource` test fake already
filters `>= start && <= end` then `Take(cap)`, so `(T,T,bigCap)` returns the whole T-cluster.

**Step 1: Write the failing pure-helper tests.** In `HistoryPagingTests.cs` add `SliceTieCluster`
tests. The helper signature:

```csharp
// In HistoryPaging.cs — pure, no SDK.
// Given a fully-fetched tie cluster at boundary T, decide which slice to serve this page and the
// next cursor. clusterCount = total ties at T; skip = ties already emitted on prior pages;
// cap = client NumValuesPerNode (> 0 on this path); endUtc = window upper bound (inclusive).
public static void SliceTieCluster(
    int clusterCount, int skip, uint cap, DateTime boundaryT, DateTime endUtc,
    out int sliceStart, out int sliceCount,
    out DateTime? nextStartUtc, out int nextSkip)
{
    sliceStart = Math.Min(skip, clusterCount);
    sliceCount = Math.Min((int)cap, clusterCount - sliceStart);
    var emitted = sliceStart + sliceCount;            // ties emitted through this page
    if (emitted < clusterCount)                       // more ties remain at T → stay in the cluster
    {
        nextStartUtc = boundaryT;
        nextSkip = emitted;
    }
    else                                              // cluster drained → advance one tick past T
    {
        var next = boundaryT.AddTicks(1);
        if (next <= endUtc) { nextStartUtc = next; nextSkip = 0; }
        else { nextStartUtc = null; nextSkip = 0; }   // T was the window end → terminal
    }
}
```

Tests (each `[Fact]`, Shouldly):
- mid-cluster: `clusterCount=10, skip=2, cap=3` → `sliceStart=2, sliceCount=3, nextStartUtc=T, nextSkip=5`.
- last full slice that exactly drains: `clusterCount=6, skip=3, cap=3` → drained → `nextStartUtc=T+1tick, nextSkip=0` (T < endUtc).
- short final slice but window remains: `clusterCount=5, skip=3, cap=10` → `sliceCount=2`, drained → `nextStartUtc=T+1tick` (emit CP even though the slice is short).
- drained at window end: same but `endUtc == T` → `nextStartUtc=null` (terminal).
- self-heal: `skip >= clusterCount` (e.g. `clusterCount=4, skip=4`) → `sliceCount=0`, drained → advance/terminal.

Run: `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests --filter FullyQualifiedName~HistoryPagingTests` — expected FAIL (method missing).

**Step 2: Implement `SliceTieCluster`** (code above) in `HistoryPaging.cs`. Rerun → PASS.

**Step 3: Add the `MaxTieClusterOverfetch` option + node-manager property + Host wiring.**
- `ServerHistorianOptions.cs`: `public int MaxTieClusterOverfetch { get; init; } = 65536;` with XML doc explaining it bounds the within-timestamp over-fetch; in `Validate()` (when `Enabled`) add a warning if `MaxTieClusterOverfetch <= 0`.
- `OtOpcUaNodeManager.cs`: near the `HistorianDataSource` property (`:156-179`) add
  `public int MaxTieClusterOverfetch { get; set; } = 65536;` (plain settable, default matches the option).
- `OtOpcUaServerHostedService.cs:199-202`: after `_server.SetHistorianDataSource(...)`, set the node-manager value from the bound `ServerHistorianOptions` (inject/resolve the options the same way the historian source is resolved; if the server exposes the node manager via `_server.NodeManager`, set `MaxTieClusterOverfetch` there — match the existing accessor pattern). Keep the default if options are absent.

**Step 4: Write the failing integration test** in `NodeManagerHistoryReadPagingTests.cs`. Add a
`MakeSeriesWithTie` helper (or inline) producing e.g. 3 distinct-timestamp samples, then a tie
cluster of 5 samples all at one `SourceTimestampUtc`, then 2 more distinct — total 10, with the
5-way tie larger than a `cap=2`. Drive the full `do/while` resume loop (as the existing
`Raw_pages_full_series_across_continuation_points` test does) and assert the union is all 10
samples in order, no dup/skip, terminating. Add a second test: set `nm.MaxTieClusterOverfetch = 3`
and a 5-way tie → the resume hits the cluster-exceeds-bound backstop → that page returns
`BadHistoryOperationUnsupported` (today's loud behavior preserved for absurd clusters).
Run → FAIL (stuck branch still loud-fails the first test).

**Step 5: Rewrite the stuck branch** in `ServeRawPaged` (`:1881-1892`). Replace the loud-fail with:

```csharp
if (inboundCp is { Length: > 0 } && backendFull && samples.Count == 0)
{
    // Oversized tie cluster at `startUtc` (= boundary T). Over-fetch the whole cluster with an
    // explicit bounded cap (cap==0 would fall back to the backend's MaxValuesPerRead), then serve
    // cap-at-a-time and advance past T when drained. A cluster beyond the safety bound preserves the
    // historical loud fail. See docs/Historian.md + HistoryPaging.SliceTieCluster.
    var overfetchCap = (uint)(MaxTieClusterOverfetch + 1);   // +1 to detect overflow
    var cluster = HistorianDataSource
        .ReadRawAsync(tagname, startUtc, startUtc, overfetchCap, CancellationToken.None)
        .GetAwaiter().GetResult().Samples;

    if (cluster.Count > MaxTieClusterOverfetch)
    {
        // unchanged loud fail (keep the existing Utils.LogError text) → BadHistoryOperationUnsupported
        ...
        return;
    }

    HistoryPaging.SliceTieCluster(cluster.Count, boundarySkip, numValuesPerNode, startUtc, endUtc,
        out var sliceStart, out var sliceCount, out var nextStart, out var nextSkip);
    var sliceList = new List<DataValueSnapshot>(sliceCount);
    for (var k = sliceStart; k < sliceStart + sliceCount; k++) sliceList.Add(cluster[k]);

    byte[]? clusterCp = null;
    if (nextStart is DateTime ns)
        clusterCp = _historyContinuationStore.Save(session,
            new HistoryContinuationState(tagname, ns, endUtc, nextSkip, numValuesPerNode));

    results[handle.Index] = new SdkHistoryReadResult
    {
        StatusCode = sliceList.Count == 0 ? StatusCodes.GoodNoData : StatusCodes.Good,
        HistoryData = new ExtensionObject(ToHistoryDataFromSamples(sliceList)),
        ContinuationPoint = clusterCp,
    };
    errors[handle.Index] = ServiceResult.Good;
    return;
}
```

Rerun Step-4 tests → PASS. Then run the FULL paging suite to confirm no regression:
`dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests --filter FullyQualifiedName~NodeManagerHistoryRead`.

**Step 6: Commit.**
```bash
git add src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/HistoryPaging.cs \
        src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs \
        src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ServerHistorianOptions.cs \
        src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs \
        tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/HistoryPagingTests.cs \
        tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadPagingTests.cs
git commit -m "feat(historian): page within oversized tie clusters (#400) instead of loud-failing"
```

**Acceptance:** oversized-tie Raw read pages out the full cluster + the rest of the window with no
dup/skip and terminates; cluster > `MaxTieClusterOverfetch` still loud-fails; existing paging
tests green; no contract/wire/proto/EF change.

---

### Task 2 (WS-B AbCip): controller-discovered UDT member expansion

**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 1, Task 3

**Files:**
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs:977-1011` (the controller-discovered branch in `DiscoverAsync`; reference the pre-declared fan-out at `:949-971`)
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverDiscoveryTests.cs`

**Context.** `DiscoverAsync` (`:929-1013`) already fans out **pre-declared** UDT tags into a member
sub-folder with one Variable per atomic member (`:949-971`), sourcing members from
`tag.Members`. But the **controller-discovered** branch (`:981-1011`) emits a discovered Structure
tag as a SINGLE Variable whose `DriverDataType` is the `Structure → String` placeholder
(`AbCipDataTypeExtensions.cs:33`) — never expanded, because `AbCipDiscoveredTag` carries no
`Members`. The driver already has the machinery to get them: `FetchUdtShapeAsync(deviceHostAddress,
templateInstanceId, ct)` (`:103-120`) reads + caches an `AbCipUdtShape` (member list with
name/offset/atomic type) via the CIP Template Object reader, and `AbCipTemplateCache` holds it.

**Fix.** In the discovered-tag loop, when `discovered.DataType == AbCipDataType.Structure`, fetch
the UDT shape and fan out its atomic members into a member sub-folder under the Discovered folder —
mirroring the pre-declared fan-out at `:949-971`. Atomic members only; a nested-struct member
recurses (bounded depth, e.g. const `MaxUdtDepth = 8`); a member type with no atomic
`DriverDataType` (or an unresolvable shape) is skipped (never mis-emitted). Dedup member full-names
within a device folder. A discovered Structure with no resolvable shape falls back to the **current**
single-Variable behavior (no regression).

> **Member-read resolution.** Pre-declared member tags are registered in `_tagsByName` at init
> (`:197-218`) so member reads resolve. For discovered members, equipment-tag reads resolve via the
> `EquipmentTagRefResolver` over the authored TagConfig (FullName = `Parent.Member`), and the read
> planner (`AbCipUdtReadPlanner`) groups members of one parent. Confirm a discovered member-path read
> resolves to the member's atomic type in a test; if resolution needs the member def registered,
> register it the same way `:197-218` does (mirror, don't invent). Keep the bare-container read at
> `:515-522` returning `BadNotSupported` (unchanged — members are the addressable surface).

**Step 1: Failing test** in `AbCipDriverDiscoveryTests.cs`. Use the existing fake enumerator +
fake template reader pattern (see `AbCipFetchUdtShapeTests.cs` / `AbCipDriverDiscoveryTests.cs` for
how the fake `IAbCipTemplateReader` / `IAbCipTagEnumerator` are wired). Enumerate one discovered
Structure tag (`Motor1`, type Structure) whose template shape has atomic members `Speed` (Real) +
`Running` (Bool) + a nested struct `Status` with member `Code` (DInt). Build the address space via a
capturing `IAddressSpaceBuilder` test double (reuse the one the existing discovery tests use). Assert:
the Discovered folder contains a `Motor1` sub-folder with member Variables `Motor1.Speed` (Float32),
`Motor1.Running` (Boolean), and `Motor1.Status.Code` (Int32) — NOT a single `Motor1` String node.
Assert the depth cap drops members deeper than `MaxUdtDepth`. Run → FAIL.

**Step 2: Implement** the discovered-UDT fan-out in `AbCipDriver.cs:981-1011` (mirror `:949-971`;
add a small recursive local function over `AbCipUdtShape.Members` with a depth guard + a
visited-set/dedup). Rerun → PASS.

**Step 3:** Run the full AbCip suite to confirm no discovery/array regression:
`dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests`.

**Step 4: Commit.**
```bash
git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs \
        tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverDiscoveryTests.cs
git commit -m "feat(abcip): expand controller-discovered UDTs into addressable member variables"
```

**Acceptance:** a browsed UDT surfaces its atomic members (incl. nested-struct leaves up to the depth
cap) as individually addressable Variables with correct atomic types; unresolvable shapes degrade to
the prior single-Variable behavior; member-path read resolves; AbCip suite green; no proto/EF change.

---

### Task 3 (WS-B TwinCAT): discovered struct/UDT member expansion

**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 1, Task 2

**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATSymbolExpander.cs` (pure recursion helper + a minimal symbol-node abstraction)
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs:319-372` (`BrowseSymbolsAsync` adapts `ISymbol`→the abstraction + feeds the expander; `MapSymbolType` stays)
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATSymbolExpanderTests.cs` (new, pure)

**Context.** `AdsTwinCATClient.BrowseSymbolsAsync` (`:319-339`) iterates `loader.Symbols` and emits
one `TwinCATDiscoveredSymbol(InstancePath, mappedType, readOnly, arrayLength)` per top-level symbol;
`MapSymbolType` returns `null` for a Struct (`:360+`), and the driver drops null-typed symbols, so
UDT/FB instances + their members never surface. **The real `ISymbol`/`SymbolLoaderFactory` is
non-injectable** (the `FakeTwinCATClient` operates one level ABOVE — it yields ready-made
`TwinCATDiscoveredSymbol`s, see `FakeTwinCATClient.BrowseSymbolsAsync`), so the recursion must live in
a **pure helper** over a minimal abstraction to be unit-testable, then adapt the real `ISymbol` to it
(same discipline as the bit-RMW driver-level RMW).

**Step 1: Failing pure-expander tests** in `TwinCATSymbolExpanderTests.cs`. Define the abstraction +
helper in the new `TwinCATSymbolExpander.cs`:

```csharp
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;

/// Minimal view over an ADS symbol the expander needs (adapter wraps the real ISymbol).
public interface ITwinCATSymbolNode
{
    string InstancePath { get; }
    bool IsStruct { get; }                                   // Category == Struct (has members)
    (TwinCATDataType? Type, int? ArrayLength) Mapped { get; } // null Type ⇒ unsupported atomic leaf
    IReadOnlyList<ITwinCATSymbolNode> Children { get; }       // struct members (empty for atomics)
    bool ReadOnly { get; }
}

public static class TwinCATSymbolExpander
{
    public const int MaxDepth = 8;

    // Walk roots → atomic leaves. A struct recurses its Children; an atomic leaf with a non-null
    // mapped Type is yielded; unsupported leaves (null Type) and depth-exceeded nodes are dropped.
    // Dedup by InstancePath (neutralizes Flat-vs-tree double-listing). Yields TwinCATDiscoveredSymbol.
    public static IEnumerable<TwinCATDiscoveredSymbol> ExpandLeaves(IEnumerable<ITwinCATSymbolNode> roots);
}
```

Tests (fake `ITwinCATSymbolNode` tree): a struct `MAIN.Motor1` with atomic members `Speed`(Real)
+ `Running`(Bool) and a nested struct `Status` with `Code`(DInt) → yields exactly
`MAIN.Motor1.Speed`, `MAIN.Motor1.Running`, `MAIN.Motor1.Status.Code` (full InstancePaths, mapped
atomic types); a top-level atomic `MAIN.Counter`(DInt) → yields itself; an unsupported leaf (null
mapped Type, e.g. a pointer) → dropped; depth beyond `MaxDepth` → dropped; duplicate InstancePath →
emitted once. Run → FAIL.

**Step 2: Implement** `TwinCATSymbolExpander` (recursive, depth-guarded, `HashSet<string>` dedup).
Rerun → PASS.

**Step 3: Wire the real client.** In `AdsTwinCATClient.BrowseSymbolsAsync` (`:319-339`), add a private
adapter `sealed class AdsSymbolNode : ITwinCATSymbolNode` wrapping `ISymbol` (`InstancePath`;
`IsStruct` = `DataType?.Category == DataTypeCategory.Struct`; `Mapped` = `MapSymbolType(DataType)`;
`Children` = `symbol.SubSymbols.Select(s => new AdsSymbolNode(s))`; `ReadOnly` = `!IsSymbolWritable`).
Replace the per-symbol emit with `foreach (var ds in TwinCATSymbolExpander.ExpandLeaves(
loader.Symbols.Select(s => new AdsSymbolNode(s)))) { ct.ThrowIfCancellationRequested(); yield return ds; }`.
Keep the cancellation semantics (throw, not yield-break). This block is operator-gated for live (no
local ADS); the pure expander is the unit proof. Build the driver:
`dotnet build src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT`.

**Step 4:** Run the full TwinCAT suite (confirm the existing browser/array tests still pass — the
fake-driven `DiscoverAsync` tests are unaffected since the fake yields `TwinCATDiscoveredSymbol`s
directly): `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests`.

**Step 5: Commit.**
```bash
git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATSymbolExpander.cs \
        src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs \
        tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATSymbolExpanderTests.cs
git commit -m "feat(twincat): expand discovered struct/UDT symbols into addressable member leaves"
```

**Acceptance:** the pure expander yields atomic leaves of a struct tree (incl. nested, depth-capped,
deduped), drops unsupported leaves; the real `BrowseSymbolsAsync` feeds the ADS symbol tree through
it; TwinCAT suite green; no contract/proto/EF change. The pre-declared-Structure-tag reject at
`TwinCATDriverFactoryExtensions.cs:89` stays (its message points to discovery — now satisfied).

---

### Task 4: Docs + plan-record §2 clear

**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** none (after T1–T3)

**Files:**
- Modify: `docs/Historian.md` (the "Paging limitation — oversized tie clusters" block at `:144-153`)
- Modify: `docs/drivers/AbCip.md` (UDT member-path discovery section)
- Modify: `docs/drivers/TwinCAT.md` (discovered struct/UDT member section)

**Steps.**
1. `docs/Historian.md`: rewrite the paging-limitation block — the server now pages *within* a single
   timestamp via a bounded over-fetch (`ServerHistorian:MaxTieClusterOverfetch`, default 65536);
   a cluster beyond the bound still fails `BadHistoryOperationUnsupported` (the remedy is a larger
   `MaxTieClusterOverfetch` or `NumValuesPerNode`). Note the within-cluster page may be short yet
   still carry a continuation point.
2. `docs/drivers/AbCip.md`: document that controller-discovered UDTs now surface atomic members
   (incl. nested, depth-capped) as addressable Variables; the bare container stays `BadNotSupported`;
   member writes deferred.
3. `docs/drivers/TwinCAT.md`: document discovered struct/UDT member expansion (atomic leaves,
   depth cap, unsupported leaves dropped); pre-declared Structure tags still rejected → use discovery.
4. **Do NOT stage `stillpending.md`** — the §2 lines are cleared via this plan record only.

**Commit.**
```bash
git add docs/Historian.md docs/drivers/AbCip.md docs/drivers/TwinCAT.md
git commit -m "docs: within-timestamp tie-cluster paging + AbCip/TwinCAT UDT member discovery"
```

**Acceptance:** docs match the shipped behavior; no overclaim (member writes/whole-UDT explicitly
deferred); `stillpending.md` untouched.

---

### Task 5: Full build + tests + final integration review

**Classification:** standard
**Estimated implement time:** ~4 min (build/test) + review
**Parallelizable with:** none

**Steps.**
1. `dotnet build ZB.MOM.WW.OtOpcUa.slnx` — expect 0 errors.
2. `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests` ·
   `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests` ·
   `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests` — all green.
3. Dispatch the **final integration reviewer** over the whole branch diff
   (`git diff c402872c..HEAD`). Checks: (a) `SliceTieCluster` advance-past-T is lossless + the
   over-fetch passes an explicit cap (never 0); (b) the `MaxTieClusterOverfetch` backstop preserves
   the loud fail; (c) AbCip discovered-member fan-out mirrors the pre-declared path + degrades on
   unresolvable shape + reads resolve; (d) the TwinCAT expander dedups + depth-caps + drops
   unsupported leaves, and the adapter maps `ISymbol` faithfully; (e) no Commons/wire/proto/EF
   change, no bUnit; (f) no never-stage files staged.
4. Apply any actionable findings via a FRESH implementer (no SendMessage in this env), re-review.

**Commit** any fixes by explicit path.

---

### Task 6: Live `/run` best-effort + finish branch + memory

**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none

**Steps.**
1. **WS-A** is unit+integration-proven (the oversized-tie integration test is the canonical proof; a
   live anomaly needs the AVEVA historian on `10.100.0.48` — infra-gated). Record honestly.
2. **WS-B AbCip** best-effort: a local `ab_server` ControlLogix (`--plc=ControlLogix`) has only
   partial UDT support; attempt a member-discovery smoke and record the result (likely unit-proven).
3. **WS-B TwinCAT** is operator-gated (Windows-only ADS) — unit-proven via the expander.
4. Use **superpowers-extended-cc:finishing-a-development-branch**: verify tests, then **merge to
   master + push** (the user's standing finish choice — no re-ask), delete the branch.
5. Update `.tasks.json` with final state + live results + any review follow-ups.
6. Update memory: `project_stillpending_backlog.md` (top summary line + paragraph; drop #400 +
   AbCip/TwinCAT UDT member-paths from REMAINING) and `MEMORY.md` line 27.

**Acceptance:** `master = origin/master` at the merge commit; tests green; memory + bookkeeping current.

---

## Dependency graph

`{T1 ∥ T2 ∥ T3} → T4 → T5 → T6` — T1/T2/T3 touch disjoint projects (OpcUaServer/Runtime/Host vs
AbCip vs TwinCAT) ⇒ dispatch their implementers concurrently.
