docs(plan): historian within-timestamp paging (#400) + AbCip/TwinCAT UDT member-paths implementation plan + tasks

This commit is contained in:
Joseph Doherty
2026-06-17 20:00:05 -04:00
parent ad66ecc97e
commit d0a0661f6a
2 changed files with 430 additions and 0 deletions
@@ -0,0 +1,411 @@
# 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.
@@ -0,0 +1,19 @@
{
"planPath": "docs/plans/2026-06-17-historian-paging-udt-member-paths.md",
"designPath": "docs/plans/2026-06-17-historian-paging-udt-member-paths-design.md",
"designCommit": "ad66ecc9",
"baseMaster": "c402872c",
"branch": "feat/stillpending-historian-paging-udt-members",
"scope": "Bundle two stillpending.md §2 items. WS-A (#400): page Raw history WITHIN an oversized tie-cluster timestamp via a bounded within-timestamp over-fetch (HistoryPaging.SliceTieCluster pure helper + MaxTieClusterOverfetch knob + ServeRawPaged stuck-branch rewrite) instead of the loud BadHistoryOperationUnsupported. WS-B AbCip: fan out controller-discovered UDTs into atomic-member Variables via the cached UDT shape (mirror the pre-declared fan-out). WS-B TwinCAT: pure TwinCATSymbolExpander recursion (over a minimal ITwinCATSymbolNode abstraction) fed by the real ADS ISymbol tree in BrowseSymbolsAsync. NO Commons/wire/proto/EF change; NO bUnit. Live: WS-A unit+integration-proven (historian infra-gated); AbCip best-effort (ab_server partial UDT); TwinCAT operator-gated.",
"dependencyGraph": "{T1 ∥ T2 ∥ T3} → T4 → T5 → T6 (T1 OpcUaServer/Runtime/Host ∥ T2 AbCip ∥ T3 TwinCAT touch disjoint projects)",
"tasks": [
{"id": 1, "subject": "WS-A #400: HistoryPaging.SliceTieCluster + MaxTieClusterOverfetch option + ServeRawPaged within-timestamp over-fetch + tests", "classification": "standard", "parallelizableWith": [2, 3], "status": "pending"},
{"id": 2, "subject": "WS-B AbCip: controller-discovered UDT member expansion (fetch shape + fan out atomic members, bounded depth) + tests", "classification": "standard", "parallelizableWith": [1, 3], "status": "pending"},
{"id": 3, "subject": "WS-B TwinCAT: pure TwinCATSymbolExpander recursion + real BrowseSymbolsAsync ISymbol adapter + tests", "classification": "standard", "parallelizableWith": [1, 2], "status": "pending"},
{"id": 4, "subject": "Docs (Historian.md paging-limitation→resolved + AbCip.md/TwinCAT.md UDT member sections) + §2 clear via plan record only", "classification": "small", "status": "pending", "blockedBy": [1, 2, 3]},
{"id": 5, "subject": "Full build + OpcUaServer/AbCip/TwinCAT tests + final integration review", "classification": "standard", "status": "pending", "blockedBy": [4]},
{"id": 6, "subject": "Live /run best-effort + finish branch (merge to master + push) + memory", "classification": "standard", "status": "pending", "blockedBy": [5]}
],
"executionState": "PLANNED — ready for subagent-driven execution.",
"lastUpdated": "2026-06-17"
}