Files
lmxopcua/docs/plans/2026-06-17-historian-paging-udt-member-paths.md
T

412 lines
24 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.
# 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 T1T3)
**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.