# 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(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 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 ExpandLeaves(IEnumerable 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` 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.