# Historian within-timestamp paging (#400) + AbCip/TwinCAT UDT member-paths — Design > **Status:** Approved 2026-06-17. Branch `feat/stillpending-historian-paging-udt-members` off master `c402872c`. > Next step: writing-plans → subagent-driven-development → finish (merge to master + push). ## Goal Bundle two `stillpending.md` §2 backlog items into one phase. They touch **disjoint projects** (server/historian vs. two driver projects), so their implementers run concurrently. - **WS-A — #400 within-timestamp tie-cluster paging.** Retire the loud `BadHistoryOperationUnsupported` fail when a single `SourceTimestamp` carries more raw ties than `NumValuesPerNode`; page *within* the timestamp instead. - **WS-B — AbCip + TwinCAT UDT member-paths.** Make individual UDT/struct members individually addressable (discover + read) — what both §2 lines explicitly point to (`stillpending.md:50` *"Must address individual member paths"*, `:51` *"discover UDT members individually"*). --- ## WS-A — #400: within-timestamp tie-cluster paging ### Problem `HistoryRead-Raw` is paged server-side over a single-shot backend that only accepts `(start, end, cap)` — it cannot skip/offset. The tie-safe resume cursor (`HistoryContinuationState`) is a `(NextStartUtc=T, BoundarySkipCount=skip)` pair. When **more samples share one `SourceTimestamp` than `NumValuesPerNode`** (an oversized tie cluster), the cursor cannot advance past T: every resume re-reads the same first `cap` ties, the boundary-tie trim empties the page, and `ServeRawPaged` (`OtOpcUaNodeManager.cs:1881-1892`) fails that node **loudly** with `BadHistoryOperationUnsupported`. The operator's only remedy today is to re-issue with a bigger `NumValuesPerNode`. For a single tag's raw history this is a data anomaly (raw samples normally carry strictly increasing distinct timestamps) — hence it was deferred as task #400. ### Grounding facts (verified against the code) - The backend (`HistorianDataSource.ReadRawAsync:405`) queries `StartDateTime = startTime` / `EndDateTime = endTime` (inclusive both ends, `RetrievalMode.Full`) and honors an **explicit** `maxValues` cap. So a degenerate `(T, T, bounded)` read returns the *whole* cluster at exactly T. - **`cap == 0` is NOT truly unlimited at the backend** — it falls back to `_config.MaxValuesPerRead` (`HistorianDataSource.cs:431-432,441`). So the over-fetch must pass an **explicit** large cap, never rely on `0 = unlimited`. - The pure paging helpers already live in `HistoryPaging.cs` (`IsFullPage`, `ComputeResumeCursor`, `TrimBoundaryDuplicates`) — all static + SDK-free, unit-tested directly. The new decision logic joins them there. - Inclusive-start resume + boundary-tie trim are already load-bearing invariants of the current tie-safe cursor; the over-fetch inherits them and adds no new assumption beyond *stable backend ordering within a single timestamp* (which the existing skip-count cursor already relies on). - The config knobs live in `ServerHistorianOptions.cs` (`AddServerHistorian` DI mirror). ### Approaches considered - **A — within-timestamp over-fetch + cap-sliced serve (CHOSEN).** On detecting the stuck cluster, issue a dedicated degenerate read `(T, T, MaxTieClusterOverfetch + 1)` to pull the whole cluster at T, then serve it `cap` samples per page by advancing the existing `BoundarySkipCount` *within* the cluster. When the cluster drains, advance the cursor to `T.AddTicks(1)` with skip 0 (safe: all T-ties emitted; no DateTime tick exists strictly between T and T+1). Re-reading `(T,T,bounded)` each page is idempotent over immutable history. `MaxTieClusterOverfetch` (new `ServerHistorianOptions` field) bounds the over-fetch; a cluster larger than the bound preserves **today's** loud `BadHistoryOperationUnsupported` as the ultimate backstop. **No contract / backend / sidecar change.** The slice + next-cursor decision goes in a new pure `HistoryPaging.SliceTieCluster(...)` (unit-testable without a server/session/SDK); the node-manager owns only the `(T,T,bounded)` I/O. - **B — backend skip/offset parameter (REJECTED).** Native within-timestamp offset via a `(start, end, cap, skip)` backend surface. Requires an `IHistorianDataSource.ReadRawAsync` contract change **and** a Wonderware sidecar wire-protocol change — infra-gated and off-limits (no wire/proto change). - **C — buffer the cluster in the continuation store (REJECTED).** Over-fetch once, stash the whole cluster in the continuation-point state, serve slices from the buffer. Avoids re-reads (O(N) vs A's O(N²/cap) transfer) but holds the whole cluster in the session-bound, capped, oldest-evicted store — more state, more memory under many concurrent pathological reads, more eviction edge-cases. A re-reads instead; the re-read cost only bites on a documented anomaly path, and steady-state memory stays one page (plus the transient bounded over-fetch). ### Architecture (Approach A) The change is **localized to the existing "stuck" branch** of `ServeRawPaged` (`OtOpcUaNodeManager.cs:1881-1892`). Today that branch fails the node; instead it: ``` // stuck: inboundCp present, backend page full, boundary-trim emptied the page cluster = ReadRawAsync(tagname, T, T, MaxTieClusterOverfetch + 1) // whole cluster at T if cluster.Count > MaxTieClusterOverfetch: log + BadHistoryOperationUnsupported // preserve today's loud backstop for absurd clusters return (sliceStart, sliceCount, nextCursor) = HistoryPaging.SliceTieCluster( // PURE clusterCount: cluster.Count, skip: boundarySkip, cap: numValuesPerNode, boundaryT: T, endUtc: endUtc) serve cluster[sliceStart .. sliceStart+sliceCount) // Good (or GoodNoData if empty + drained + no window left) emit continuation point iff nextCursor is not null ``` `SliceTieCluster` decides: - **more ties remain at T** (`skip + sliceCount < clusterCount`) → next cursor `(T, skip + sliceCount)` (stay within the cluster). - **cluster drained** (`skip + sliceCount >= clusterCount`) → next cursor `(T.AddTicks(1), 0)` **iff** `T.AddTicks(1) <= endUtc` (there may be window left after T); else terminal (no continuation). A drained slice may be **short** (< cap) yet still emit a CP — the one legitimate exception to "short page ⇒ terminal", because we *know* there may be un-read window past T. Documented. A **fresh** read that lands on an oversized cluster needs **no change**: it returns the first `cap` ties, `ComputeResumeCursor` stores `(T, cap)`, and the *next* (resume) read hits the stuck branch — which now over-fetches. The `(T, N)`/`skip >= clusterCount` degenerate self-heals (slice empty → drained branch → advance past T). ### Testing (WS-A) - Pure `HistoryPaging.SliceTieCluster` unit tests: mid-cluster slice, last (short) slice + advance-past-T, exact-cap-boundary, `skip >= clusterCount` self-heal, `T == endUtc` terminal, drained-with-window-left emits CP. - `ServeRawPaged` integration vs a fake `IHistorianDataSource` returning an oversized cluster (cluster bigger than cap at one timestamp): assert the full cluster is paged out across N resumes with no duplicates/drops, then the window past T is read, then terminal — and that `> MaxTieClusterOverfetch` still loud-fails. - Regression: the existing small-tie-cluster + normal Raw paging tests stay green. --- ## WS-B — AbCip + TwinCAT UDT member-paths ### Problem Both §2 lines point to the **same feature** — make individual UDT members addressable: - **AbCip** (`stillpending.md:50`): bare-name read of a member-bearing UDT returns `BadNotSupported` (`AbCipDriver.cs:515-522`); UDT type maps to a `String` placeholder (`AbCipDataTypeExtensions.cs:33`). *"Must address individual member paths."* - **TwinCAT** (`stillpending.md:51`): pre-declared Structure tags are **rejected** at parse (`TwinCATDriverFactoryExtensions.cs:89-100`, by design — the error tells the operator to *"discover UDT members individually"*); discovered UDTs/FBs are **silently skipped** (`MapSymbolType` → `null` → `DiscoverAsync` drops, `AdsTwinCATClient.cs:349/364`). **Both path parsers already handle member paths.** `AbCipTagPath.TryParse` / `TwinCATSymbolPath.TryParse` structurally parse `Motor.Speed.Setpoint` and reassemble the exact native name libplctag (`ToLibplctagName`) / ADS (`ToAdsSymbolName`) read — proven by the just-shipped bit-RMW work (which read/wrote `parent.bit` through these same surfaces). So the **read path for a member reference already works**; the gap is purely **discovery** — neither enumerator walks members: - `LibplctagTagEnumerator.EnumerateAsync` (`:28-49`) emits one `AbCipDiscoveredTag` per top-level `@tags` entry; a UDT instance surfaces as a bare Structure tag with `Members` metadata, never expanded into addressable member tags. - `AdsTwinCATClient.BrowseSymbolsAsync` (`:319-339`) emits one `TwinCATDiscoveredSymbol` per top-level symbol and drops Structure symbols (`MapSymbolType` → null). ### Approaches considered - **A — discovery-time member expansion, read (CHOSEN).** When discovery hits a struct/UDT, walk its members and emit each **atomic leaf** as an individually-addressable discovered tag carrying its concrete member path + resolved atomic data type. The read path already serves it. Minimal, symmetric across both drivers, fully fake-testable. - **AbCip:** expand from the already-decoded `AbCipUdtShape.Members` (each carries name + offset + atomic type) the template cache (`AbCipTemplateCache`) holds. Emit `AbCipDiscoveredTag` per atomic member with reference `Parent.Member` and the member's `DriverDataType`. (Whole-UDT grouped reads via `AbCipUdtReadPlanner` are an existing efficiency path and stay; member tags are the addressable surface.) - **TwinCAT:** recurse `ISymbol.SubSymbols` in `BrowseSymbolsAsync`; for each atomic leaf yield a `TwinCATDiscoveredSymbol(InstancePath, mappedAtomicType, readOnly, arrayLength)`. A struct sub-symbol recurses; an unsupported leaf (nested-UDT-array / pointer / FB internal) is dropped (null), never mis-reported — same discipline as the existing multi-dim-array drop. - **B — runtime member resolution, no discovery change (REJECTED).** Operator hand-types member tags; AbCip resolver walks the template at read time. No browse (worse UX) and more resolver complexity for AbCip; A is cleaner. - **C — whole-UDT structured read (REJECTED).** Return the container as one OPC UA structured value — needs a structured OPC UA DataType + encoder + the retirement of the String placeholder; large surface. Defer. ### Architecture (Approach A) - **AbCip:** add member expansion to the discovery emit. Where a discovered tag is a `Structure` with decoded `Members`, additionally emit one `AbCipDiscoveredTag` per atomic member (reference `Name.Member`, atomic `DriverDataType` from `AbCipDataTypeExtensions`/the template member type). Bounded recursion depth for nested structs (config/const cap, e.g. 8). The bare Structure container keeps returning `BadNotSupported` on direct read (unchanged) — members are the addressable surface. - **TwinCAT:** in `BrowseSymbolsAsync`, when a symbol's `IDataType.Category == Struct`, recurse `symbol.SubSymbols` and yield atomic leaves (full `InstancePath`, `MapSymbolType`-resolved type). Bounded recursion depth. The pre-declared-Structure-tag reject at `TwinCATDriverFactoryExtensions.cs:89` stays (its message already points to discovery) — but now discovery actually surfaces the members it points to. - **No** `IDriver` / wire / proto / EF change — both edits are driver-internal discovery. ### Named deferrals (not silent) - **Member writes** — read-only members this slice (writes via the atomic path may "just work" but add round-trip risk; deferred + documented). - Whole-UDT (container) read/write — stays unsupported; address members. - Arrays-of-UDT, UDT-typed array members, pointers/references, FB internals beyond plain struct members — dropped in discovery (never mis-reported), deferred. - Nested-struct recursion beyond the bounded depth cap. ### Testing (WS-B) - **AbCip:** fake template reader / enumerator yields a UDT with atomic + nested members → assert discovery emits per-member tags with the right reference + atomic type; assert a member-path read resolves + reads (existing fake runtime); assert the bare container still `BadNotSupported`; depth-cap honored. - **TwinCAT:** `FakeTwinCATClient` symbol tree with a struct + nested struct + an unsupported leaf → assert `BrowseSymbolsAsync` yields the atomic leaves (full paths, mapped types), drops the unsupported leaf, honors the depth cap; member read via `ToAdsSymbolName` works. - **No bUnit.** --- ## Live `/run` - **WS-A (#400):** **unit + integration-proven.** A real oversized-tie anomaly needs the AVEVA Historian on `10.100.0.48` (infra-gated — same gate as all of Phase C) plus an actual same-timestamp tie cluster. The fake-source integration test is the canonical proof; a live `/run` is best-effort/operator-gated. - **WS-B AbCip:** **best-effort** against a local `ab_server` ControlLogix (`--plc=ControlLogix`) — UDT support in `ab_server` is partial, so likely unit-proven; attempt a member round-trip and record honestly. - **WS-B TwinCAT:** **operator-gated** (Windows-only ADS runtime; no local target) — unit-proven only. ## Task slicing (subagent-driven) Disjoint projects ⇒ WS-A and WS-B implementers run **concurrently**. - **T1 (WS-A)** — `HistoryPaging.SliceTieCluster` pure helper + `MaxTieClusterOverfetch` option + `ServeRawPaged` stuck-branch rewrite + tests. `standard`. ∥ T2/T3. - **T2 (WS-B AbCip)** — discovery member expansion + member-read verification + tests. `standard`. ∥ T1/T3. - **T3 (WS-B TwinCAT)** — `BrowseSymbolsAsync` sub-symbol recursion + tests. `standard`. ∥ T1/T2. - **T4** — Docs (`docs/Historian.md` paging-limitation section → resolved; `docs/drivers/AbCip.md` + `docs/drivers/TwinCAT.md` UDT member-path sections) + clear the §2 lines **via the plan record only** (do not stage `stillpending.md`). `small`. - **T5** — Full build + OpcUaServer + AbCip + TwinCAT tests + final integration review. `standard`. - **T6** — Live `/run` best-effort (WS-A fake-integration; WS-B AbCip ab_server best-effort, TwinCAT operator-gated) + finish branch (merge to master + push) + memory. `standard`. Dependency graph: `{T1 ∥ T2 ∥ T3} → T4 → T5 → T6`. ## Hard rules (carried from prior phases) 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 contract change** (all edits are driver-internal or server-internal — `HistoryPaging`/`OtOpcUaNodeManager`/`ServerHistorianOptions` and the two drivers' discovery; no `IDriver`/`IHistorianDataSource`/`WriteResult` change); **NO bUnit**; `dangerouslyDisableSandbox` for all build/test/rig commands.