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

24 KiB
Raw Blame History

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:

// 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=3sliceStart=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=10sliceCount=2, drained → nextStartUtc=T+1tick (emit CP even though the slice is short).
  • drained at window end: same but endUtc == TnextStartUtc=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:

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.

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.

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 TwinCATDiscoveredSymbols, 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:

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 TwinCATDiscoveredSymbols directly): dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests.

Step 5: Commit.

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.

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.