(default|configured)` on every init, e.g.
+`transport=Libplctag (default)`. CLI: `--transport default|native|libplctag` (default `default` = libplctag for
+every `--plc-type`). See
+[AbCip.md §Transport](AbCip.md#transport-transport-option) for the shared behaviour (one pooled connection
+per device, Forward Open throttle, routing).
+
+Native routing: SLC500/MicroLogix/PLC-5 are direct; `logixpccc` uses UnconnectedSend and its SLC framing is
+**unverified**.
+
+### Release note — native PCCC transport, opt-in (final review I3)
+
+- **The default is unchanged: libplctag.** A driver config with no `transport` key runs `Libplctag`, for
+ every PLC family, exactly as before this release. The upgrade restart moves nothing. (An interim build on
+ this branch defaulted SLC 500 / MicroLogix to Native; that was reverted before merge.)
+- **`"transport": "Native"` opts a driver into the managed PCCC-over-EtherNet/IP client** (`Driver.Ab.Transport`).
+ It takes effect when the driver is constructed — a deploy that changes `DriverConfig`, or a process start —
+ not on `ReinitializeAsync`. The AdminUI **Transport** dropdown authors it; remove the key (or pick
+ "Default (libplctag)") to go back.
+- **Known limits of Native** (why it is opt-in):
+ - ⚠️ **Wire evidence is narrow and simulator-only** — see [Wire evidence](#wire-evidence-native-pccc). Only
+ N-file reads and writes and one F8 read have touched a wire (`ab_server`). The **default probe address
+ `S:0`** and the **B, T, C, R, S, L, ST, A, O and I file types** — and the T/C/R sub-element offsets
+ (`.PRE`/`.ACC`/`.LEN`/`.POS`) — are encoded from the PCCC file-type table and are **unverified natively**.
+ No real SLC, MicroLogix or PLC-5 has been exercised. Opt in per driver after checking it against the real
+ controller.
+ - ⚠️ **Status-bit writes are refused** — `T4:0.DN`, `T4:0.TT`, `T4:0.EN`, `C5:0.CU`, `C5:0.DN`, `R6:0.EN`
+ and the rest return **`BadNotSupported`**; nothing is written to the PLC; the handle is kept and health is
+ unchanged. libplctag performs a masked bit write here; a native read-modify-write of the control word would
+ race the PLC updating TT/DN. Reads of status bits are unaffected.
+ - **PLC-5 native is N-file reads only**; every other PLC-5 operation is refused with `BadNotSupported`.
+ `LogixPccc` framing is unverified. Explicit Native on either logs a Warning at init.
+ - **ST (string) byte order is unverified** (no capture); string values may be byte-swapped.
+ - Unlike AbCip, the native AbLegacy runtime has **no element-type check**, so a mis-authored same-width
+ type reads as reinterpreted data (as on libplctag), not `BadTypeMismatch`.
+
+### Wire evidence (native PCCC)
+
+Everything below is against the `ab_server` simulator; no real PCCC controller has been exercised.
+
+| What | Evidence |
+|---|---|
+| N-file read (`N7:0`) | libplctag captures `slc500-read-n7`, `micrologix-read-n7`, `plc5-read-n7` (codec byte parity) + native `AbLegacyReadSmokeTests` read on all three profiles |
+| N-file write (`N7:5`) | capture `slc500-write-n7` + native `AbLegacyReadSmokeTests` write/read round trip (SLC 500) |
+| F-file read (`F8`) | one capture, `slc500-read-f8` (codec parity only; no native F write on a wire) |
+| S (incl. the default probe `S:0`), B, T, C, R, L, ST, A, O, I | **none** — encoded from the PCCC file-type table |
+| T/C/R sub-elements (`.PRE`/`.ACC`/`.LEN`/`.POS`), 6-byte T/C/R element layout | **none** |
+
+### Native known gaps
+
+- ⚠️ **Simulator-only, and narrow.** Only N-file reads/writes and one F8 read are on a wire (`ab_server`); see
+ [Wire evidence](#wire-evidence-native-pccc). No real SLC/MicroLogix/PLC-5 has been exercised; the real-press
+ gate (Task 21) was read-only and AbCip-only.
+- **PLC-5 native is N-file reads only.** Every other file type and all PLC-5 writes are refused before the
+ wire and reach the client as `BadNotSupported` (handle kept, health unchanged; #571 closed). Native is opt-in
+ for every family; an explicit Native on PLC-5 / `logixpccc` logs a Warning at init.
+- Status-bit sub-element writes (e.g. `T4:0.DN`, `C5:0.DN`) are Unsupported natively (they would clobber the
+ control word); nothing goes on the wire. Sub-element arrays are Unsupported natively. L-file bit indexes
+ above 15 are allowed.
+- **ST (string) file layout is uncaptured**, so the string byte order is unverified natively.
+- Capture recipe for golden frames: `scripts/ab-capture/capture.sh` (see
+ [AbLegacy-Test-Fixture.md](AbLegacy-Test-Fixture.md)). Follow-up issues: #564 to #577.
+
## Testing
- **Unit tests** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/` cover the driver, the PCCC address parser, and the host-address parser via fake tag runtimes.
@@ -109,14 +190,16 @@ An AB Legacy tag becomes a **1-D OPC UA array node** when its `TagConfig` JSON c
`"isArray": true` and `"arrayLength": N` (N ≥ 1, capped at 256). The canonical rule:
`isArray: true` + `arrayLength >= 1` → array; `isArray: false` (any length) → scalar.
-**Read mechanism** — the driver issues a **PCCC multi-element file read** via libplctag.
-The tag's file address (e.g. `N7:0`) is extended by `N` element count so libplctag fetches
+**Read mechanism** — the driver issues a **PCCC multi-element file read** (libplctag by default; the opt-in
+native transport issues the same typed read, chunked under the PCCC payload limit).
+The tag's file address (e.g. `N7:0`) is extended by `N` element count so the transport fetches
`N` consecutive PCCC file elements in one EIP/PCCC request. The response is decoded into
N values of the declared `AbLegacyDataType`. Maximum element count is 256 (PCCC payload
limit).
This closes the previously-noted limitation ("single-element addressing today") for the
-array read path. Array *writes* remain a follow-up.
+array read path. The opt-in native transport also writes multi-element arrays (chunked); on libplctag
+array *writes* remain a follow-up.
**Unit test coverage** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/` covers
the multi-element read path via the fake tag runtime. The AB Legacy integration fixture is
@@ -124,7 +207,7 @@ down during normal dev.
**Live-verify** — integration-fixture-gated (requires the AB Legacy Docker fixture).
-**Deferrals** — array *writes*, multi-dimensional arrays, per-element historization.
+**Deferrals** — array *writes* on libplctag (the opt-in native transport writes them), multi-dimensional arrays, per-element historization.
`arrayLength > 256` is rejected at init with an explicit error (PCCC payload cap).
See [Uns.md §Array tags](../Uns.md#array-tags-1-d) for the cross-driver coverage matrix
diff --git a/docs/drivers/AbServer-Test-Fixture.md b/docs/drivers/AbServer-Test-Fixture.md
index 55c2f476..74a071e8 100644
--- a/docs/drivers/AbServer-Test-Fixture.md
+++ b/docs/drivers/AbServer-Test-Fixture.md
@@ -33,6 +33,29 @@ quirk. UDT / alarm / quirk behavior is verified only by unit tests with
- **Endpoint override**: `AB_SERVER_ENDPOINT=host:port` points the
fixture at a real PLC instead of the local container.
+## Per-transport suites and capture recipe
+
+Every integration test in `AbCip.IntegrationTests` now runs **once per transport** (`TransportMatrix`:
+`Libplctag` and `Native`). Run one compose profile at a time and name it with `AB_SERVER_COMPOSE_PROFILE`
+(all profiles bind `:44818`, so set it to the running profile). On the 2026-10-07 run native passed everything
+libplctag passed on all seven profiles; on `micro800` libplctag failed on the empty-path defect described in
+[AbCip.md](AbCip.md#known-gaps) and native passed. That defect is now fixed: a re-run of the `micro800`
+smoke test passes on both transports. Evidence:
+[native-ab-transport-gate-results](../plans/2026-10-07-native-ab-transport-gate-results.md).
+
+- Write-capable tests (the Logix Emulate ALMD test) require `AB_SERVER_ALLOW_WRITES=1` and refuse while
+ `AB_DIFF_ENDPOINT` is set.
+- **Differential harness** (`AbCip.IntegrationTests/Differential`, `LiveIntegration`) compares libplctag and
+ native on browse, template and read parity against one endpoint. It is read-only by construction, aborts on
+ the first connection failure, never retries, and caps the native phase at one Forward Open. Env:
+ `AB_DIFF_ENDPOINT`, `AB_DIFF_PATH`, `AB_DIFF_FAMILY`, `AB_DIFF_TAGS`, `AB_DIFF_MAX_TAGS` (at most 500),
+ `AB_DIFF_MAX_TEMPLATES`, `AB_DIFF_PACE_MS` (default 20), `AB_DIFF_COUNT_OPENS`. Always run with
+ `--filter "FullyQualifiedName~Differential"` and `AB_SERVER_ENDPOINT=127.0.0.1:1`.
+- ab_server serves no UDTs and refuses 1-byte writes, so template parity is vacuous there and BOOL/SINT
+ parity is weak evidence. Native writes remain simulator-only.
+- **Golden-frame capture:** `scripts/ab-capture/capture.sh` (capture client in `scripts/ab-capture/client`);
+ fixtures live in `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/` with a README.
+
## What it actually covers
- Read path: driver → libplctag → CIP-over-EtherNet/IP → simulator → back.
@@ -93,14 +116,14 @@ Only the `_S`-suffix naming classifier (PR 12, `SecurityClassification.ViewOnly`
forced on safety tags) runs. Actual safety-partition write rejection — what
happens when a non-safety write lands on a real `1756-L8xS` — is not exercised.
-### 5. CompactLogix narrow ConnectionSize cap
+### 5. CompactLogix connection size and the regular-open fallback
-CompactLogix profile `Notes`: *"ab_server lacks the narrower limit itself."*
-
-Driver-side `AbCipPlcFamilyProfile` caps `ConnectionSize` at the CompactLogix
-value per PR 10, but `ab_server` accepts whatever the client asks for — the
-cap's correctness is trusted from its unit test, never stressed against a
-simulator that rejects oversized requests.
+Since T21 the CompactLogix profile defaults to a **Large** Forward Open (4002), matching what libplctag
+opens for `plc=compactlogix`. The old 504 default was cosmetic under libplctag, which never received it,
+and cost the native transport ~3× the round trips per poll. A pre-v20 CompactLogix (1769-L3x) or a bridge that refuses
+the Large open is handled by `CipConnection`'s fallback (a regular open at min(size, 504) in the same
+throttled attempt). That fallback is pinned by unit tests against a fake target only: `ab_server` accepts
+any size, so a refusing controller is never exercised here.
### 6. BOOL-within-DINT read-modify-write (#181)
@@ -145,7 +168,7 @@ skip cleanly + the ab_server Docker fixture runs as usual.
| `@tags` Symbol Object walk with `Program:` scope | partial | **yes** | yes |
| Add-On Instructions | no | **yes** | yes |
| GuardLogix safety-partition write rejection | no | **yes** (Emulate 5580) | yes |
-| CompactLogix narrow ConnectionSize enforcement | no | **yes** (5370 firmware) | yes |
+| CompactLogix Large-open refusal → regular-open fallback | no | **yes** (5370 firmware) | yes |
| EtherNet/IP embedded-switch behaviour | no | no | yes |
| Redundant chassis failover (1756-RM) | no | no | yes |
| Motion control timing | no | no | yes |
@@ -185,7 +208,7 @@ project is authored.
| "Do ALMD alarms raise + clear?" | no | yes | **yes** | yes |
| "Is Micro800 unconnected-only enforced wire-side?" | partial (--plc=Micro800 boots, but wire rejection untested) | partial | yes | yes (required) |
| "Does GuardLogix reject non-safety writes on safety tags?" | no | no | yes (Emulate 5580) | yes |
-| "Does CompactLogix refuse oversized ConnectionSize?" | no | partial | yes (5370 firmware) | yes |
+| "Does CompactLogix refuse a Large Forward Open?" | no | partial | yes (5370 firmware) | yes |
| "Does BOOL-in-DINT RMW race against concurrent writers?" | no | yes | partial | yes (stress) |
| "Does EtherNet/IP embedded-switch behave correctly?" | no | no | no | yes (required) |
| "Does redundant-chassis failover work?" | no | no | no | yes (required) |
diff --git a/docs/plans/2026-10-07-native-ab-transport-design.md b/docs/plans/2026-10-07-native-ab-transport-design.md
new file mode 100644
index 00000000..23d180a7
--- /dev/null
+++ b/docs/plans/2026-10-07-native-ab-transport-design.md
@@ -0,0 +1,265 @@
+# Native .NET transport for AbCip + AbLegacy (replace libplctag) — design
+
+**Date:** 2026-10-07 · **Branch:** `feat/native-ab-transport` · **Status:** design, awaiting review
+
+## 1. Goal
+
+Replace the `libplctag` NuGet (managed wrapper + native `plctag` binary, pinned `1.5.2`) under the
+AbCip and AbLegacy drivers with a fully managed EtherNet/IP + CIP + PCCC client owned by this repo.
+libplctag is kept, behind a switch, as a fallback until the native path is proven.
+
+### Why (all four apply)
+
+1. **Native-binary pain** — RID-specific packaging, container/ARM images, an unmanaged dependency in
+ an otherwise all-.NET 10 server.
+2. **Correctness and control** — wrapper defects we cannot fix: `ElementSize` returns `null` after a
+ read (`68cf3ae8`), there is no `ConnectionSize` property (the value is plumbed and dropped),
+ status codes are opaque, and libplctag never checks the CIP type the controller returns (`e3d6b76e`
+ could only add a width check).
+3. **Performance** — one native tag handle per `(device, tag)`; no control over request packing or
+ scheduling.
+4. **Policy** — no native dependencies; libplctag's LGPL/MPL licensing.
+
+### Non-goals
+
+- No change to the driver surface above the transport seams: the address space, tag-config JSON,
+ the poll engine, the RMW serialization, the backoff, and the decoders all stay.
+- No new PLC families. No CIP Class-1 (implicit I/O) connections. No Omron (`omron-njnx` stays an
+ unmapped libplctag value only while the fallback exists).
+- Whole-UDT **writes** stay unsupported, as they are today.
+
+## 2. Decisions (all taken at the recommended option)
+
+| # | Question | Decision |
+|---|---|---|
+| D1 | Build or buy | **Build** an in-repo managed stack. Existing managed CIP libraries fail the licensing/control goals. |
+| D2 | Cutover | **Side-by-side behind a per-driver `Transport` switch** (`Native` \| `Libplctag`), the same dark-switch discipline as `MeshTransport` / `ConfigSource`. |
+| D3 | Proof standard | **Flip per evidence tier** (§8). Native becomes default for AbCip once reads/browse/templates pass the real presses read-only; AbCip writes and all of AbLegacy flip on simulator proof, recorded as a known gap. libplctag stays as the fallback until a bench controller exists. |
+| D4 | Where the stack lives | A new shared project, **`ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport`**, referenced by both drivers (precedent: `Mqtt.Transport`, `OpcUaClient.Session`). Pure protocol: no OtOpcUa, no OPC UA, no logging-framework dependency beyond `Microsoft.Extensions.Logging.Abstractions`. |
+| D5 | Connection model | **One CIP connection per device** (`host:port` + CIP path), shared by every tag runtime on that device. libplctag already shares internally; the native stack makes it explicit. |
+| D6 | Request packing | **Transparent coalescing inside the connection** into Multiple Service Packet (0x0A) requests, within the negotiated connection size, when `AllowPacking`. The `IAbCipTagRuntime` interface does not change. |
+| D7 | Status neutrality | A **driver-owned `AbStatus` enum + `AbTransportException`** replace libplctag's `Status` / `LibPlcTagException` in the mappers, probes and `AbCipDriver`. The libplctag adapter translates into them. |
+| D8 | Decoder reuse | The native enumerator and template reader **emit the same byte layout libplctag's `@tags` / `@udt/{id}` pseudo-tags return**, so `CipSymbolObjectDecoder` / `CipTemplateObjectDecoder` and their real-capture tests are shared unchanged, and the two transports can be diffed byte for byte. |
+| D9 | Primary correctness evidence | **Differential testing**: the same reads through both transports against the same target (`ab_server`, then the real presses read-only), compared on raw buffer and decoded value. |
+
+## 3. Architecture
+
+```
+AbCipDriver / AbLegacyDriver (unchanged except factory selection + AbStatus)
+ │ IAbCipTagFactory / IAbCipTagEnumeratorFactory / IAbCipTemplateReaderFactory / IAbLegacyTagFactory
+ ├── Libplctag* adapters (existing; now translate Status → AbStatus) ← fallback
+ └── Native* adapters (new, in each driver project)
+ │
+ ▼
+ ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport (new, pure managed)
+ Eip/ EncapsulationClient — TCP framing, RegisterSession/UnRegisterSession,
+ ListIdentity, SendRRData (UCMM), SendUnitData (connected)
+ Cip/ CipPath (port segments from "1,0", ANSI symbolic 0x91, member/element
+ segments 0x28/0x29/0x2A), CipService, CipGeneralStatus + extended status
+ Cip/ ConnectionManager — Forward Open (0x54) / Large Forward Open (0x5B) /
+ Forward Close (0x4E), sequence counts, connection-size negotiation,
+ UCMM-only mode (Micro800)
+ Cip/ RequestScheduler — per-connection queue, MSP coalescing, in-flight bound,
+ per-request timeout + cancellation
+ Logix/ Read Tag (0x4C) / Read Tag Fragmented (0x52) / Write Tag (0x4D) /
+ Write Tag Fragmented (0x53); Logix type codes incl. struct handle (0xA0)
+ Logix/ SymbolLister (Get Instance Attribute List 0x55 on class 0x6B, paged)
+ Logix/ TemplateReader (Get Attribute List 0x03 + Read 0x4C on class 0x6C, chunked)
+ Pccc/ Execute PCCC (0x4B, class 0x67) with requestor id; SLC/MicroLogix protected
+ typed logical read/write (0x0F/0xA2, 0x0F/0xAA); PLC-5 typed read/write
+ (0x0F/0x68, 0x0F/0x67); PCCC STS/EXT STS
+ DeviceSessionPool — one session+connection per device key, ref-counted
+```
+
+### 3.1 Units and their contracts
+
+- **`EncapsulationClient`**: owns one TCP socket. It sends and receives 24-byte-header encapsulation
+ frames and correlates replies by sender context. It knows nothing about CIP. Testable against canned
+ byte streams.
+- **`CipPath`**: a pure encoder. It turns `"1,0"` (and `"1,0,2,10.0.0.5"` style routes) into port
+ segments, and turns a Logix tag name such as `Program:P.Arr[3].Member.5` into symbolic, element and
+ member segments. `AbCipTagPath` already parses names; `CipPath` encodes them. Golden-byte unit tests.
+- **`ConnectionManager`**: opens and closes the connected session for a device.
+ - **Connection size.** It honours `ConnectionSize` (the field libplctag drops today). Above 511
+ bytes it uses Large Forward Open. If the target rejects a large open (status 0x01, extended 0x0109
+ or 0x0111), it falls back to a regular Forward Open at the size the target advertises.
+ - **Micro800** (`SupportsConnectedMessaging = false`) runs UCMM-only: no Forward Open.
+- **`RequestScheduler`**: one queue per connection.
+ - **Coalescing.** Requests that arrive within one scheduling turn are packed into an MSP until the
+ next service would overflow the connection size. A reply is fanned back to each waiter with its own
+ per-service status.
+ - **Oversize requests.** A request too large for one frame goes through the fragmented service loop.
+ - **Bounds.** At most one in-flight connected request per connection by default (`MaxInFlight`,
+ default 1), the conservative choice for a production press.
+- **`DeviceSessionPool`**: keyed by `(host, port, cipPath, familyAttribute)`. A runtime acquires a
+ lease on first `InitializeAsync` and releases it on `Dispose`. The last release sends Forward Close
+ and UnRegisterSession.
+
+### 3.2 Native adapters (in the driver projects)
+
+- **`NativeAbCipTagRuntime : IAbCipTagRuntime`**
+ - **Reads.** `InitializeAsync` acquires the device lease and resolves the encoded path once.
+ `ReadAsync` issues Read Tag with `ElementCount`. The reply's **CIP type code + struct handle** is
+ kept with the buffer.
+ - **Decoding.** `Decode*` reads the managed buffer, little-endian. Strings use the Logix
+ `LEN(u32) + DATA[n]` layout with the stride taken from the reply. BOOL arrays are 32-bit packed.
+ - **Size and status.** `GetSize()` is the real reply length, so the stride fallback in
+ `ResolveArrayStride` stops being necessary. `GetStatus()` returns `AbStatus`.
+ - **Writes.** `WriteAsync` issues Write Tag with the type taken from the last read, or from the
+ authored type for atomics. A struct-typed write (STRING) needs the struct handle, so a write with
+ no prior read performs one read first.
+- **`NativeAbCipTagEnumerator`** reads controller-scope and `Program:X` listings through
+ `SymbolLister`, and emits the concatenated entry bytes in the `@tags` layout.
+- **`NativeAbCipTemplateReader`** emits the 14-byte header that libplctag synthesizes, then the raw
+ template bytes (D8).
+- **`NativeAbLegacyTagRuntime : IAbLegacyTagRuntime`**
+ - **Addressing.** Encodes `AbLegacyAddress` (file type, file number, element, sub-element) into PCCC
+ typed-logical addressing, or PLC-5 system addressing for `plc5`.
+ - **Arrays.** Multi-element reads use the element count.
+ - **Bits.** Bit-in-word writes stay at the driver level (`WriteBitInWordAsync`), as today.
+
+### 3.3 The switch
+
+- `AbCipDriverOptions.Transport` and `AbLegacyDriverOptions.Transport` take values
+ `Native | Libplctag`, are serialized as **strings** (`JsonStringEnumConverter`, per the
+ driver-enum-serialization trap), and are absent ⇒ the current default.
+- `Ab*DriverFactoryExtensions.CreateInstance` picks the factory set from the option. Test-injected
+ factories still win.
+- The **probe** (`Ab*DriverProbe`, i.e. Test Connect) honours the same option. A probe that tests a
+ different transport than the driver runs would be a lie.
+- **AdminUI**: a `Transport` dropdown in the AbCip and AbLegacy driver-config modals under the `/raw`
+ tree. It needs live `/run` verification on docker-dev, since there is no bUnit.
+- Default sequence: `Libplctag` at merge, flipped per §8, and the fallback stays selectable.
+
+## 4. Data flow (a poll read)
+
+1. The driver poll calls `runtime.ReadAsync(ct)` on N runtimes for one device. The concurrency
+ window is unchanged.
+2. Each runtime enqueues a Read Tag request on the device's `RequestScheduler`.
+3. The scheduler packs as many as fit into one MSP, sends it via SendUnitData on the shared
+ connection, awaits the reply, splits it by service, and completes each waiter with
+ `(status, typeCode, structHandle, payload)`.
+4. The runtime stores the payload. The driver calls `Decode*` exactly as it does today.
+
+The result: N round-trips through N libplctag handles become ⌈N / fit⌉ round-trips through one
+connection. That is the performance win, with no driver change.
+
+## 5. Error handling and status
+
+- **`AbStatus`** (driver-owned) carries the values the mappers and probes use today: `Ok`,
+ `Pending`, `Timeout`, `NotFound`, `NotAllowed`, `OutOfBounds`, `TooLarge`, `TooSmall`,
+ `Unsupported`, `BadConnection`, `BadReply`, `Partial`, `Abort`, plus a new `TypeMismatch`.
+- **CIP general status mapping (native):**
+ - `0x00` ⇒ `Ok`
+ - `0x04` / `0x05` (path segment / destination unknown) ⇒ `NotFound`
+ - `0x06` ⇒ `Partial` (continue fragmenting)
+ - `0x08` ⇒ `Unsupported`
+ - `0x0F` ⇒ `NotAllowed`
+ - `0x13` ⇒ `TooSmall`
+ - `0x15` ⇒ `TooLarge`
+ - `0xFF` ⇒ by extended status (`0x2105` ⇒ `OutOfBounds`, `0x2107` ⇒ `TypeMismatch`)
+ - socket, encapsulation or timeout faults ⇒ `BadConnection` / `Timeout`
+- **PCCC mapping.** STS/EXT STS map the same way: address-does-not-exist ⇒ `NotFound`, illegal
+ command/format ⇒ `Unsupported`, and so on.
+- **`AbTransportException(AbStatus, string detail)`** is thrown by both transports.
+ `AbCipDriver.cs:1188`'s "the controller answered, so don't throttle the device" check
+ (`IsReachableStatus`) keys on it. **That preserves 05/STAB-8 semantics.**
+- **Type check, native only.** The reply's CIP type code is compared with the authored type.
+ - A mismatch ⇒ `BadTypeMismatch`. This now also catches **same-width** mistakes (DINT authored,
+ REAL returned) that `e3d6b76e` documents as undetectable.
+ - ⚠️ **This is client-visible.** Tags authored wrong from the legacy Kepware export that read Good
+ garbage under libplctag will read Bad under native. That is the fix working, but it must be
+ in the release note and in `docs/drivers/AbCip.md`.
+- **Press safety.**
+ - **Reconnect.** A dropped connection is re-opened through the driver's existing `device.Backoff`,
+ never in a tight loop.
+ - **One open at a time.** `ConnectionManager` enforces at most one Forward Open in flight per
+ device, plus a minimum interval between opens (default 1 s).
+ - **Pinned by a test.** A regression test asserts a bounded Forward Open rate against a refusing
+ target. ignitionoee recorded **~15 Forward Opens/s** at a press from a misconfigured client.
+ - **Close.** Forward Close on dispose. The connection timeout multiplier and RPI are set so a dead
+ client's connection expires on the PLC within seconds, not minutes.
+
+## 6. Packaging
+
+- New project `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/` and its test project
+ `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/`, both added to
+ `ZB.MOM.WW.OtOpcUa.slnx`.
+- While the fallback exists, both drivers keep the `libplctag` package. Libplctag code is confined
+ to the `Libplctag*.cs` adapters. After phase 2 no other file has `using libplctag`, and a
+ source-scan guard test enforces that.
+- On deletion (phase 6) the package, its `Directory.Packages.props` pin, the adapters and the
+ `Libplctag*` create-param members (`LibplctagGateway`; `LibplctagPlcAttribute` is renamed
+ `PlcAttribute`) go.
+
+## 7. Testing
+
+1. **Golden-frame unit tests** (`Ab.Transport.Tests`).
+ - **Encode** every frame type: RegisterSession, Forward Open / Large / Close, MSP, Read / Write
+ (Fragmented), 0x55 listing, template attribute reads, Execute PCCC.
+ - **Decode** every reply type, including error replies.
+ - **Fixtures** are byte captures taken once from libplctag talking to `ab_server` (pcap, stored as
+ hex), plus the **existing real-press captures** in `CipSymbolObjectWireCaptureTests` and
+ `CipTemplateObjectWireCaptureTests`.
+ - ⚠️ A capture from libplctag ⇄ `ab_server` proves wire parity with libplctag, not correctness. The
+ real-press captures are the independent reference (the #468 lesson: verify a fixture with the
+ reference client).
+2. **Scheduler and connection tests.** These run against an in-process fake encapsulation endpoint.
+ They cover:
+ - MSP packing boundaries at connection size −1, exact and +1
+ - per-service error fan-out
+ - cancellation mid-MSP
+ - the Forward Open rate bound
+ - large-open fallback
+ - lease ref-counting and Forward Close on last release
+3. **Integration suites run on both transports.** The existing `AbCip.IntegrationTests` and
+ `AbLegacy.IntegrationTests` against `ab_server` (controllogix / compactlogix / micro800 / guardlogix;
+ slc500 / micrologix / plc5) are parameterized over `Transport`. A native run must be green on every
+ test the libplctag run is green on.
+4. **Differential harness.** A `LiveIntegration`-category test, env-gated like the HistorianGateway
+ live suite and skipped when the env vars are absent. It reads the same tag set through both
+ transports and asserts equal raw buffers and equal decoded values:
+ - against `ab_server`;
+ - against a real press, **read-only**. It runs browse (`@tags` + every program listing), every
+ template, and a sample of scalar, array, STRING and UDT-member reads.
+5. **AdminUI `/run`.** Author `Transport: Native` on a docker-dev AbCip driver pointed at the
+ `ab_server` fixture, deploy, read, then flip it back.
+
+## 8. Phased delivery and gates
+
+| Phase | Delivers | Gate |
+|---|---|---|
+| **P0 Seam** | `AbStatus` + `AbTransportException`; mappers, probes, `AbCipDriver` and `AbLegacyDriver` decoupled from libplctag types; the 9 unit-test files that build `LibPlcTagException` / `Status` fakes (`AbCipDriverWriteTests`, `AbCipDriverProbeTests`, `AbCipDriverReadTests`, `AbCipStatusMapperTests`, `AbCipDriverCodeReviewRegressionTests`, `AbCipUnknownTagReadTests`, `AbLegacyEvictOnFailureTests`, `AbLegacyReadWriteTests`, `AbLegacyHostAndStatusTests`) moved onto `AbStatus` / `AbTransportException`, with the libplctag translation itself tested in the adapters; the `Transport` option (string enum) + factory selection, including a `--transport` flag on `otopcua-abcip-cli` / `otopcua-ablegacy-cli` (whose help text names libplctag); the source-scan guard | Existing suites green; behaviour identical (default `Libplctag`) |
+| **P1 Transport core** | Encapsulation, `CipPath`, `ConnectionManager`, `RequestScheduler` (MSP + fragmentation), `DeviceSessionPool` | Golden-frame + scheduler tests |
+| **P2 AbCip native** | `NativeAbCipTagRuntime`, enumerator, template reader; CIP type check | AbCip integration suite green on both transports vs `ab_server` |
+| **P3 AbLegacy native** | PCCC layer + `NativeAbLegacyTagRuntime` | AbLegacy integration suite green on both transports vs `ab_server` (slc500 / micrologix / plc5) |
+| **P4 Switch surface** | AdminUI dropdown, probe honours the switch, docs (`AbCip.md`, `AbLegacy.md`, fixture docs) | docker-dev `/run` |
+| **P5 Real-hardware gate + flip** | Differential run against the CR presses read-only (VPN from the Mac); a perf comparison (round-trips + p50/p95 poll latency for one press's tag set, native vs libplctag); then **default ⇒ `Native` for both drivers** | AbCip: diff clean on browse / templates / reads, no worse latency, Forward Open count during the run = 1. AbLegacy: simulator proof only, **recorded as a known gap** |
+| **P6 Delete libplctag** *(conditional)* | Remove the package, adapters and `Libplctag*` members | **Blocked until a bench controller** proves writes (CompactLogix or Micro800) and PCCC (SLC / MicroLogix). Until then libplctag stays selectable |
+
+### Real-hardware constraints (from `~/Desktop/ignitionoee`)
+
+- **Reachable targets:** 20 × CompactLogix **5069-L320ERS2 rev 34.11** presses at CR
+ (Z-35162..Z-35181; Z-35162 = `10.209.70.160:44818`), reachable from the Mac over VPN. This is
+ the same rig `cc876bdf` verified against.
+- **They are production machines and strictly read-only.** P5 performs **no writes**, opens a
+ single connection per press, and touches one press at a time.
+- **No legacy PCCC hardware is reachable.** The CR presses were confirmed non-MicroLogix by
+ ListIdentity. The Delmia inventory lists PLC-5/04s at Warsaw with no addresses, and as far as I
+ know a PLC-5/04 has no built-in Ethernet port. There is no ControlLogix and no Micro800 either. So
+ routed paths beyond `1,0` and Micro800 UCMM-only mode are simulator-proven only.
+
+## 9. Known gaps and risks
+
+- **AbCip writes are simulator-proven only.** Mitigation: Write Tag shares framing with the
+ read-proven path. The risk sits in the encoding details (STRING struct handle, type codes), which
+ have golden tests and run against `ab_server`. libplctag stays selectable.
+- **AbLegacy is simulator-proven only**, against `ab_server`'s PCCC emulation, which shares
+ libplctag's view of the protocol. This is the weakest evidence in the plan, stated plainly.
+- **`ab_server` patches** (`0001` symbol listing, `0002` CIP 0x04 for not-found) mean the simulator is
+ already partly ours. A native/libplctag disagreement against it must be checked against the real
+ press before blaming either side.
+- **The same-width type check changes client-visible quality** (§5).
+- **Behaviour libplctag hides that we now own:** connection keep-alive, sequence-count wrap, the
+ session-registration lifetime, and the TCP reconnect after an idle teardown by a NAT or firewall.
+ Each one gets a scheduler/connection test.
diff --git a/docs/plans/2026-10-07-native-ab-transport-gate-results.md b/docs/plans/2026-10-07-native-ab-transport-gate-results.md
new file mode 100644
index 00000000..5d81422c
--- /dev/null
+++ b/docs/plans/2026-10-07-native-ab-transport-gate-results.md
@@ -0,0 +1,479 @@
+# Native AB transport gate results
+
+## ab_server suites (T17)
+
+Every ab_server integration test now runs once per transport (`TransportMatrix` in each integration
+project). Run 2026-10-07 against `10.100.0.35`, one compose profile at a time
+(AbCip `/opt/otopcua-abcip`, `AB_SERVER_COMPOSE_PROFILE=`; AbLegacy `~/otopcua-ablegacy`,
+`AB_LEGACY_COMPOSE_PROFILE=
`); every stack was brought down afterwards.
+
+- **Image:** `otopcua-ab-server:libplctag-release` (`sha256:19e814747d85…`, built 2026-08-01; ab_server from
+ libplctag `v2.7.0` + patches 0001/0002). Host compose files md5-identical to the repo's.
+- **Commit:** fixes `82d011c7` + `2171fc8c`, test matrix `8c68694d`.
+
+Counts are pass / fail / skip for the transport-parameterised rows only. Each AbCip run also passed the
+6 transport-independent `AbServerProfileTests`. The AbCip skips are the 3 Emulate-tier tests
+(`AB_SERVER_PROFILE=emulate` only). The AbLegacy skips are `Slc500_write_then_read…`, which skips itself
+on both transports when the slc500 port (`:44819`) is down.
+
+| Profile | Libplctag | Native | Native ⊇ Libplctag |
+|---|---|---|---|
+| controllogix | 1 / 0 / 3 | 1 / 0 / 3 | yes |
+| compactlogix | 1 / 0 / 3 | 1 / 0 / 3 | yes |
+| micro800 | 0 / 1 / 3 | 1 / 0 / 3 | yes (libplctag fails, see below) |
+| guardlogix | 1 / 0 / 3 | 1 / 0 / 3 | yes |
+| slc500 | 2 / 0 / 0 | 2 / 0 / 0 | yes |
+| micrologix | 1 / 0 / 1 | 1 / 0 / 1 | yes |
+| plc5 | 1 / 0 / 1 | 1 / 0 / 1 | yes |
+
+**Native-only failures found and fixed:**
+
+- `82d011c7` (controllogix): RegisterSession carried a non-zero sender context, and ab_server rejects that
+ with encapsulation status 1. Every native session failed.
+- `2171fc8c` (slc500): ab_server answers Execute PCCC with a fixed requestor id
+ (`07 3D F3 45 43 50 21`), so the requestor-echo check rejected every native PCCC reply. Native now sends
+ libplctag's identity.
+
+**Skips added:** none. The PLC-5 integration test only reads N7, so the PLC-5 Unsupported paths
+(non-N reads, all writes) and status-bit sub-element writes are never exercised by the suite.
+
+**micro800:** the smoke test used to send `/1,0` for every family, and both transports failed against
+`--plc=Micro800`. It now uses an empty path for Micro800. With the empty path, native passes. libplctag
+still fails with BadCommunicationError, because the driver's libplctag runtime passes `Path = ""` and
+libplctag answers that with `ErrorBadData`. A `null` path works (checked with the capture client). That
+defect is pre-existing and libplctag-side; it is recorded, not fixed, here.
+
+## Differential vs ab_server (T18)
+
+Run 2026-10-07 on branch `feat/native-ab-transport`, harness
+`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/`, against `ab_server`
+(`controllogix` profile only, `/opt/otopcua-abcip` on 10.100.0.35):
+
+```bash
+AB_DIFF_ENDPOINT=10.100.0.35:44818 AB_DIFF_COUNT_OPENS=1 \
+ dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests \
+ --filter "FullyQualifiedName~Differential" --logger "console;verbosity=detailed"
+```
+
+Target as the harness resolved it: `10.100.0.35:44818`, path `1,0`, family `CompactLogix`
+(the defaults; libplctag attribute `compactlogix`, connection size 504).
+
+**Result: 7/7 passed, 0 unexpected divergences, 0 expected divergences.**
+
+| Check | Result |
+|---|---|
+| Browse parity (`@tags` + program listings) | 9 tags via libplctag, 9 via native, identical as multisets |
+| Template parity | vacuous: ab_server serves no UDTs, so 0 template ids. Task 21's press is the first real exercise |
+| Read parity | 9/9 tags equal on status, raw buffer and decoded value |
+| Native Forward Opens over the run (`AB_DIFF_COUNT_OPENS=1`) | **1** |
+| TCP connections to the target before the native phase | 0 (baseline 0). libplctag's session closed within 1 ms of the last handle being disposed |
+| Read-only guard (offline, runs without the endpoint) | pass |
+| Comparison falsifiability (offline) | pass |
+
+| Tag | Type | libplctag status | native status | Equal? |
+|---|---|---|---|---|
+| `Motor_Run` | Bool | Ok | Ok | yes |
+| `Line1_Count` | DInt | Ok | Ok | yes |
+| `Pump_1_Speed` | Real | Ok | Ok | yes |
+| `TestString` | String | Ok | Ok | yes |
+| `TestSINT` | SInt | Ok | Ok | yes |
+| `TestBOOL` | Bool | Ok | Ok | yes |
+| `TestREAL` | Real | Ok | Ok | yes |
+| `TestDINT` | DInt | Ok | Ok | yes |
+| `TestArray` | DInt[16] | Ok | Ok | yes |
+
+**The values were made non-zero first.** An all-zero simulator would make raw and value equality
+vacuous. Before the run, the simulator was seeded through a throwaway libplctag scratch program
+outside the repo. The harness itself never writes. Seeded values:
+
+- `TestDINT` = −123456789
+- `TestREAL` = 3.14159
+- `Pump_1_Speed` = 1234.5
+- `Line1_Count` = 987654
+- `TestArray[i]` = (i−8)·100003+7, which includes negatives
+- `TestString` = `"Hello T18"`
+
+ab_server refuses 1-byte writes (`ErrorBadParam`), so `TestBOOL`, `TestSINT` and `Motor_Run` stay 0.
+Their parity is therefore weaker evidence.
+
+**Wire capture of the same run.** tcpdump ran in the ab_server container's network namespace
+(`nicolaka/netshoot`), and the capture was decoded with tshark:
+
+| TCP stream | Session | RegisterSession | Forward Open | Forward Close | Requests | Window (s, relative) |
+|---|---|---|---|---|---|---|
+| 12 | libplctag | 1 | 1 (Large, 0x5B) | 1 | 1× 0x55 listing, 18× 0x52 Read Fragmented | 0.243 → 0.336 |
+| 13 | native | 1 | 1 (0x54) | 1 | 1× 0x55 listing, 18× 0x52 Read Fragmented | 0.349 → 0.452 |
+
+- Exactly two sessions, with no overlap: a 13 ms gap between them.
+- Zero write services on the wire: no 0x4D, 0x53 or 0x10.
+- The 12 earlier sub-10 ms TCP connections are the `AbServerFact`/`AbServerTheory` reachability probes. xUnit runs them during discovery for the *other* suites, even under the filter. They target `AB_SERVER_ENDPOINT` (default 10.100.0.35), never `AB_DIFF_ENDPOINT`.
+
+**Explicit-list path.** `AB_DIFF_TAGS="TestDINT,NoSuchTag,TestArray,TestString"`: 4/4 equal.
+`NoSuchTag` returned `NotFound` under both transports, and there was still exactly one native Forward Open.
+
+**Transport fixes found by the differential: none.** One harness defect was found and fixed before
+this result. libplctag closes a session as soon as its last handle is destroyed. The first version of the
+libplctag phase created, read and disposed one handle at a time, so it opened a fresh session (and a
+fresh Forward Open) per tag: 9+ sessions, which the netstat sampling showed. The libplctag phase now holds an anchor handle
+on `@tags` for its whole duration, which mirrors the native anchor lease. That leaves the one-session
+capture above.
+
+### Review round 1 re-run (press-safety fixes)
+
+Re-run 2026-10-07 after the review-r1 fixes, with the default pacing. `AB_SERVER_ENDPOINT=127.0.0.1:1`
+kept the sibling suites' discovery probes off the network:
+
+```bash
+AB_SERVER_ENDPOINT=127.0.0.1:1 AB_DIFF_ENDPOINT=10.100.0.35:44818 AB_DIFF_COUNT_OPENS=1 \
+ dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests \
+ --filter "FullyQualifiedName~Differential" --logger "console;verbosity=detailed"
+```
+
+**Result: 12/12 passed — 7 live tests and 5 offline safety tests — with 0 unexpected and 0 expected divergences.**
+
+| Check | Result |
+|---|---|
+| Settings | pace 20 ms, max tags 200, max templates 50 |
+| Browse | 9 = 9 |
+| Selection (array round-robin first) | 1 array + 8 scalars. ab_server has no UDTs, so there were no UDT members and the coverage check had nothing to require |
+| Reads | 9/9 equal |
+| Templates | 0 ids, not truncated. Not vacuous by the new rule: ab_server's `STRING` is not a structure, so browse found no structured tags |
+| Native Forward Opens | **1** |
+| TCP connections to the target before native | 0 (baseline 0) |
+
+**Wire capture of this run** (tcpdump in the ab_server container's namespace, decoded with tshark):
+
+| TCP stream | Session | Forward Open | Forward Close | Requests | Window (s) |
+|---|---|---|---|---|---|
+| 0 | libplctag | 1 (Large, 0x5B) | 1 | 1× 0x55, 18× 0x52 | 0.000 → 0.487 |
+| 1 | native | 1 (0x54) | 1 | 1× 0x55, 18× 0x52 | 0.510 → 0.984 |
+
+- No discovery probes.
+- Zero write services: no 0x4D, 0x53, 0x4B or 0x10.
+- The two sessions are 23 ms apart and do not overlap.
+
+**The Forward Opens differ between the transports, and that is expected.** libplctag always uses a *Large*
+Forward Open. Native uses a regular one because the `CompactLogix` profile's connection size (504) is at most 511. Task 21 will
+see the same split on the press.
+
+**What changed for press safety (review r1):**
+
+- **Stop on the first connection-level failure, on either transport.** That covers a refused connect,
+ RegisterSession or Forward Open, a timeout, and a lost session. Any status other than
+ NotFound/NotAllowed/OutOfBounds/TooLarge/TooSmall/Unsupported/TypeMismatch aborts the whole run with the
+ failing operation and status. Nothing is retried.
+- **The native phase is capped at one open attempt.** It sets the new process-wide
+ `CipConnection.OpenAttemptBudget`, so a reconnect cannot reach the wire at all, even from inside an
+ enumerator that swallows a per-program failure.
+- **The run also aborts the moment `ForwardOpenCount` passes 1.** This is checked after every browse entry,
+ template and tag.
+- **Offline pin:** against a loopback listener that accepts and hangs up, the native phase aborts at browse after exactly
+ **one** TCP connect. Removing the harness abort turns the test red. Removing both the abort and the budget
+ turns the connect count red.
+- **Pacing:** `AB_DIFF_PACE_MS` (default 20) is applied before every harness-issued request: browse,
+ each template read, each tag's create-read and read. Listing *pages* inside the enumerators are not
+ paced.
+- **Volume caps:** `AB_DIFF_MAX_TEMPLATES` defaults to 50. `AB_DIFF_MAX_TAGS` and `AB_DIFF_MAX_TEMPLATES` must be at most 500;
+ a higher value fails fast instead of being clamped.
+- **Selection:** the default selection takes arrays (up to 20), UDT members (up to 20) and scalars round-robin, so any cap
+ of 3 or more exercises all three kinds. The reads test fails if browse offered arrays or UDT members
+ but the selection holds none.
+- **Template parity** fails when browse found structured tags but no template id was compared, and treats an
+ undecodable shape on either side as a mismatch.
+- **Write-capable integration tests** now require `AB_SERVER_ALLOW_WRITES=1` and refuse while
+ `AB_DIFF_ENDPOINT` is set. Those tests are the Logix Emulate ALMD test and the AbLegacy SLC500 N7:5 round trip.
+ The ab_server controllogix matrix is read-only and unaffected: 15 passed and 11 skipped with
+ `AB_SERVER_COMPOSE_PROFILE=controllogix`.
+
+### Latency harness proven on ab_server (T21 Step 1)
+
+`Differential/PollLatencyComparisonTests.cs` (+ `PollLatencyRun.cs`) ran 2026-10-07 against ab_server
+(`controllogix` image, host port 44828 because an unrelated outbound socket held 44818 on the docker host),
+under the same `--filter "FullyQualifiedName~Differential"` invocation as the differential. The mechanics work:
+the timed phase reuses the differential's browse, the anchors, the open budget and the barriers; **1** native Forward Open
+in the latency phase; 30 passes per transport, 0 tag failures; native packs 100 concurrent reads into **6** requests per pass.
+ab_server has 7 atomic scalars, so the 100-tag set was **repeated cyclically** (reported in the output).
+
+| Family | libplctag p50 / p95 ms | native p50 / p95 ms | native requests/pass | Criterion |
+|---|---|---|---|---|
+| CompactLogix (504, the press default) | 7.7 / 10.9 | 22.1 / 25.3 | 6 | **FAIL** |
+| ControlLogix (4002) | 7.3 / 9.1 | 7.7 / 9.2 | 2 | pass |
+
+The CompactLogix result was a real finding, not a harness fault. Native opened a 504-byte connection there, while libplctag
+opened a Large one (see the T18 capture above), so native needed 3× the round trips per pass.
+
+**Fixed:** the CompactLogix profile now defaults to 4002, so native opens Large like libplctag. A refusal still falls back to
+a regular 504 open. Re-run 2026-10-07 23:30–23:32Z against the `compactlogix` compose profile on :44818 (3 distinct atomic
+tags, repeated to 100), with a capture in the container's network namespace:
+
+| Run | libplctag p50 / p95 ms | native p50 / p95 ms | native req/pass | Criterion |
+|---|---|---|---|---|
+| 1 | — | — | — | aborted in the latency libplctag phase (see below) |
+| 2 | 7.6 / 9.6 | 7.5 / 10.8 | 2 | FAIL (limit 10.6) |
+| 3 | 7.8 / 9.3 | 8.1 / 9.4 | 2 | PASS (limit 10.2) |
+| 4 | 7.7 / 10.0 | 7.8 / 11.7 | 2 | FAIL (limit 11.1) |
+| 5 | — | — | — | aborted in the latency libplctag phase |
+
+The structural gap is gone: p50s are equal, and both transports now send 2 requests per pass. At ~10 ms the p95 margin is about
+1 ms, which is within this network path's jitter, so the ab_server verdict varies from run to run.
+
+Capture: every session opened with **0x5B (Large Forward Open)** and was accepted (0xDB, status 0). That covers 15 sessions:
+four per complete invocation and three in the aborted run 5. Each session had one Forward Close (0x4E to Connection Manager 6/1).
+There were **zero** 0x4D/0x53/0x4B. Both aborts (runs 1 and 5) were the simulator: libplctag's ~2 KB packed request was split
+across TCP segments (1448 + 606), and ab_server answered with FIN. The harness aborted on the first connection-level failure,
+as designed. A real controller reassembles TCP; ab_server does not.
+
+The T21 press result is recorded under `### Pass 3 — 2026-10-08 02:27–02:30 UTC (HEAD `8e2dbc67`, after the final-review fixes; same settings as pass 2) — with packet capture
+
+**17/17 passed.** Browse 3162 = 3162 tags; 20 UDT templates identical; 200 tags read with 0 unexpected divergences
+(6 live); native 1 Forward Open, Forward Closes sent/acked 1/1; latency native p95 105.1 ms ≤ libplctag p95 197.3 ms
+×1.10 = 217.1 ms → PASS. Raw output: [pass 3](2026-10-07-native-ab-transport-t21/t21-pass3.txt).
+
+Packet capture (`tcpdump -i utun4 host 10.209.70.160 and port 44818`, analysed with TCP sequence reassembly):
+
+| Session | Order | Window (s) | CIP services seen (requests) | Forward Close target |
+|---|---|---|---|---|
+| libplctag differential | 1 | 0.0 – 82.5 | 0x03, 0x4C, 0x52, 0x55, 0x5B ×1, 0x4E ×1 | Connection Manager 6/1 |
+| native differential | 2 | 82.5 – 126.3 | 0x03, 0x4C, 0x52, 0x55, 0x5B ×1, 0x4E ×1 (+ UnRegisterSession) | Connection Manager 6/1 |
+| libplctag latency | 3 | 126.5 – 152.4 | 0x0A (MSP), 0x52, 0x5B ×1, 0x4E ×1 | Connection Manager 6/1 |
+| native latency | 4 | 152.4 – 176.5 | 0x0A (MSP), 0x52, 0x5B ×1, 0x4E ×1 (+ UnRegisterSession) | Connection Manager 6/1 |
+
+- **Zero write-class services** (no 0x4D Write Tag, 0x53 Write Tag Fragmented, 0x4B Execute PCCC, and no 0x4E to
+ anything but the Connection Manager).
+- **Four sessions, strictly sequential** — each starts after the previous one's last packet; never two at once.
+- Every session opened exactly one Large Forward Open and closed it with one Forward Close.
+
+## Latency (T21)` and is not filled in here.
+
+## AdminUI /run (T19)
+
+Run 2026-10-07 on the local docker-dev rig (OrbStack), against the ab_server `controllogix` fixture
+on `10.100.0.35:44818`. Every container ran the same freshly built image
+(`otopcua-host:dev` `sha256:91bffbb0…`, checked per container because `:9200` round-robins). The
+modal was driven with the Chrome tools against `http://localhost:9200/raw`.
+
+**Authoring (UI).**
+- Created the AbCip driver `t19-abcip` (`DRV-8ea0f0a8cfde`) in the MAIN cluster.
+- Configure → Transport = "Native (managed)" → Save. `DriverInstance.DriverConfig` then held
+ `"transport":"Native"`, a string.
+- Reopened Configure. The dropdown showed "Native (managed)". Changed only Timeout from 2 to 3 and
+ saved. The row then held `"transport":"Native"` and `"timeout":"00:00:03"`, so the save-reverts-transport
+ defect is fixed live, not only in unit tests.
+- Created device `Device1plc1` with host `ab://10.100.0.35:44818/1,0` (ControlLogix). Test Connect
+ returned `OK · 62 ms`.
+- Added the raw tag `TestDINT` (Int32, read-only, tag path `TestDINT`, DInt).
+- Seeded `TestDINT = 4242` on the simulator so a non-zero value proves the read path.
+- Raw namespace: `ns=2`, the shared `s=OtOpcUa` root plus RawPath ids. UNS uses `ns=3`.
+- RawPath: `t19-abcip/Device1plc1/TestDINT`.
+
+**Leg 1: Native.** The deploy sealed with `acks=6`. Both central nodes logged:
+```
+AbCip driver DRV-8ea0f0a8cfde initializing: transport=Native tagFactory=NativeAbCipTagFactory
+```
+CLI read on central-1 (`:4840`) and central-2 (`:4841`):
+```
+Node: ns=2;s=t19-abcip/Device1plc1/TestDINT
+Value: 4242
+Status: 0x00000000
+```
+The simulator value was then changed to 5150 through the libplctag CLI. The native-served node
+followed it (`Value: 5150`, `Status: 0x00000000`, source time `23:02:09.60Z`).
+
+**Leg 2: inverted gate (Native, dead port).**
+- Device host changed to `ab://10.100.0.35:44819/1,0`. Nothing listens on 44819
+ (`ss -ltn` count 0).
+- The node went `BadNoCommunication` (value retained). A CLI read failed with
+ `ServiceResultException: [80050000]` (BadCommunicationError). The CLI `read` throws on a Bad
+ status rather than printing it.
+- The central-1 log showed the native error text, raised from `NativeAbCipTagRuntime.ReadAsync`:
+ ```
+ ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.AbTransportException: TCP connect to 10.100.0.35:44819 failed: ConnectionRefused
+ ---> System.Net.Sockets.SocketException (111): Connection refused
+ ```
+- For contrast, the same dead port under libplctag logged different wording:
+ ```
+ ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.AbTransportException: ErrorTimeout
+ ---> libplctag.LibPlcTagException: ErrorTimeout
+ ```
+ So the switch changes behaviour and is not inert.
+
+**Leg 3: back to libplctag.**
+- Device restored to port 44818. Configure → Transport = "libplctag (default)" → Save. The row held
+ `"transport":"Libplctag"`.
+- The redeploy sealed with `acks=6`. Both centrals logged
+ `initializing: transport=Libplctag tagFactory=LibplctagTagFactory`.
+- CLI reads on `:4840` and `:4841`: `Value: 5150`, `Status: 0x00000000`.
+
+**Fix found by the gate.** The first deploy logged no transport line. `AbCipDriverFactoryExtensions.Register`
+took no `ILoggerFactory`, and `DriverFactoryBootstrap` registered AbCip without one, so every production
+AbCip driver ran on `NullLogger`. That hid the new line and every existing AbCip warning or error. AbCip
+now takes the host logger factory the same way AbLegacy and Modbus do. `AbCipTransportInitLogTests` is
+falsified by reverting that wiring: 2/2 tests fail.
+
+**Cleanup.**
+- The `t19-abcip` driver, device and tag rows were deleted and the change redeployed (sealed,
+ `acks=6`). Both centrals logged `stopped driver child DRV-8ea0f0a8cfde`.
+- The rig has no test driver pointing at the fixture.
+- The fixture was brought down (`docker compose --profile controllogix down`).
+- The simulator's `TestDINT` was left at 5150 until teardown. The container is gone, so the value
+ does not persist.
+
+## Real press (T21)
+
+Target: **Z-35162**, production CompactLogix 5069-L320ERS2, `10.209.70.160:44818`, path `1,0`, family CompactLogix
+(native connection size 4002, Large Forward Open). Read-only. User approved the press and each time window; VPN up;
+no other client on this Mac connected to the press. Driven from the Mac over VPN (≈44 ms RTT).
+
+### Pass 1 — 2026-10-07 23:35–23:36 UTC (HEAD `c306c6f6`; 25 tags, 20 templates, pace 50 ms, latency 100×30)
+
+12/14 passed. **Safety held**: packet capture (`tcpdump` on `utun4`) shows 4 sequential sessions, each one Large
+Forward Open (0x5B) and one Forward Close (0x4E, Connection Manager 6/1, acked); services seen only 0x03, 0x0A, 0x4C,
+0x52, 0x55, 0x5B, 0x4E; **zero writes** (no 0x4D/0x53/0x4B). Browse parity and all 20 UDT template shapes identical;
+native Forward Opens = 1.
+
+Failures, and what they led to:
+- **Read parity, 2 tags** — live analog values moved between the sequential libplctag and native phases
+ (`WW_M4ActualTemp` 384.887 → 385.138). Harness defect: it could not tell drift from divergence. Fixed by a volatility
+ probe (`82a0c298`, tightened element-wise in `43b81b66`): a value that moves between two libplctag reads is
+ compared by status, length, type, shape and **every element that held still**.
+- **Latency** — native p95 142.8 ms vs libplctag 99.1 ms ×1.10 = 109.1 ms (FAIL), medians native 93.7 / libplctag
+ 95.2 ms; three native outliers in 30 passes. Criterion unchanged; pass count raised to 50 and per-pass GC/RTT
+ diagnostics added.
+- **Test host crashed at exit** — `A callback was made on a garbage collected delegate … callback_func_ex`. Root cause
+ is in libplctag.NET 1.5.2: a failed tag create leaves its native callback registered and `Dispose()` doesn't release
+ it. **Production-reachable** on the default transport (a nonexistent tag name crashes the server node at
+ stop/restart). Fixed in the adapters (`6cd68762`, hardened `6ae62873`); Gitea #578.
+- Found before pass 1, on ab_server: native CompactLogix opened a 504-byte connection (the family default libplctag
+ never honoured) and needed ~3× the round-trips. Native default changed to 4002 with the existing Large→regular
+ fallback (`c306c6f6`).
+
+### Pass 2 — 2026-10-08 00:21–00:24 UTC (HEAD `df776170`, fresh build; 200 tags, 20 templates, pace 50 ms, latency 100×50)
+
+**17/17 passed.**
+
+| Check | Result |
+|---|---|
+| Browse parity | libplctag 3162 tags, native 3162 — identical sets |
+| UDT templates | 20 compared (of 74 found, capped) — identical shapes |
+| Read parity | 200 tags: **0 unexpected**, 0 expected (TypeMismatch) divergences; 6 live tags (below) |
+| Native Forward Opens | 1 per native phase |
+| Native Forward Closes sent/acked | 1/1 per native phase; 0 connections left |
+| Sessions | libplctag and native never connected at the same time |
+| Writes | none — read-only source guard passes; `grep -c WriteAsync` = 0 over every Differential file |
+| libplctag failed-create orphans | 15 destroyed by the #578 fix, 0 rooted — no crash at exit |
+
+⚠️ **Pass 2 has no independent packet capture** (pass 3 below re-ran it with one): the operator's `tcpdump` had stopped at 23:45 UTC, before pass 2 began,
+and this was not noticed until afterwards. The read-only evidence for pass 2 is the source guard and the harness's
+own Forward Open/Close counters; pass 1's capture is the wire-level proof for the same code paths.
+
+Live tags (value moved between the two libplctag reads; every element that held still matched native):
+
+```
+- live: WW_M4ActualTemp (lib 4 byte(s) 0CCDC243 = 389.60193; lib probe 4 byte(s) 7084C243 = 389.03467; native 4 byte(s) 4C47C243 = 388.557)
+- live: Program:EM01_PowerUnit.__fout9FB1561FA00D73F5 (lib 20 byte(s) 0050C348218986402189A640DF76D9C021898640 = [400000,4.2042394,5.2042394,-6.7957606,4.2042394]; lib probe 20 byte(s) 0050C348E74B8640E74BA64019B4D9C0E74B8640 = [400000,4.1967654,5.1967654,-6.8032346,4.1967654]; native 20 byte(s) 0050C348BB328640BB32A64045CDD9C0BB328640 = [400000,4.1936927,5.1936927,-6.8063073,4.1936927])
+- live: Program:EM02_Clamp.__fout2F67CD35C2302EE0 (lib 16 byte(s) BFB2BF379A9919BF00D68349CDCCCCBE = [2.285221E-05,-0.6,1080000,-0.4]; lib probe 16 byte(s) 5119313A9A9919BF00D68349CDCCCCBE = [0.00067557866,-0.6,1080000,-0.4]; native 16 byte(s) 1562C2B99A9919BF00D68349CDCCCCBE = [-0.0003707564,-0.6,1080000,-0.4])
+- live: Program:ES03_EM01_Zone1.__fout25DE96F3D0E2B7BB (lib 16 byte(s) 00E14CC0CDDA75BF000000C08F2D2BC0 = [-3.201233,-0.9603699,-2,-2.6746557]; lib probe 16 byte(s) 009464C09A2589BF000000C0A84932C0 = [-3.5715332,-1.07146,-2,-2.7857456]; native 16 byte(s) 00A03AC034F35FBF000000C0A8B325C0 = [-2.9160156,-0.87480474,-2,-2.5890903])
+- live: Program:ES03_EM01_Zone1.__fout92848E133B44AF24 (lib 32 byte(s) 40EDA94160F0D043000090C10022A14980256C41C656D143006A4F400000A8C1 = [21.240845,417.87793,-18,1320000,14.759155,418.67792,3.2408447,-21]; lib probe 32 byte(s) 6017AD412089D043000090C10022A14940D1654186EFD04300BB68400000A8C1 = [21.636414,417.0713,-18,1320000,14.363586,417.87128,3.6364136,-21]; native 32 byte(s) C047A841BE97CF43000090C10022A14980706F4124FECF43003E42400000A8C1 = [21.035034,415.1855,-18,1320000,14.964966,415.98547,3.0350342,-21])
+- live: Program:ES03_EM01_Zone1.__fout6DC7745628658A9D (lib 8 byte(s) 00824E40CD34A53F = [3.2266846,1.2906739]; lib probe 8 byte(s) 008A6440CDD4B63F = [3.5709229,1.4283692]; native 8 byte(s) 000D3940670A943F = [2.8914185,1.1565675])
+```
+
+Test summary:
+
+```
+ Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.Template_parity_fails_when_vacuous_or_undecodable [26 ms]
+ Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.A_failing_endpoint_aborts_the_native_phase_after_exactly_one_connect [2 s]
+ Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.The_default_selection_keeps_arrays_and_udt_members_at_any_cap [17 ms]
+ Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.Volume_knobs_fail_fast_above_the_ceiling_instead_of_clamping [4 ms]
+ Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.A_failing_endpoint_aborts_the_libplctag_phase_before_any_tag_is_read [6 s]
+ Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.PollLatencyComparisonTests.Tag_pick_takes_atomic_scalars_only_and_repeats_a_short_set_visibly [15 ms]
+ Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.PollLatencyComparisonTests.Native_poll_p95_is_within_ten_percent_of_libplctag [3 m 2 s]
+ Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_native_side_closes_its_one_connection_with_exactly_one_forward_close [< 1 ms]
+ Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.No_failed_libplctag_tag_is_left_with_a_live_native_callback [< 1 ms]
+ Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.A_live_value_is_compared_structurally_and_a_stable_one_by_value [3 ms]
+ Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_harness_is_read_only_by_construction [36 ms]
+ Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Browse_listings_decode_to_identical_tag_sets_through_both_enumerators [6 ms]
+ Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_comparison_flags_every_divergence_and_excuses_only_a_native_type_mismatch [< 1 ms]
+ Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Every_template_decodes_to_an_identical_shape_through_both_readers [< 1 ms]
+ Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_native_side_makes_exactly_one_forward_open_over_the_whole_run [< 1 ms]
+ Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Every_selected_tag_reads_identically_through_both_transports [3 ms]
+ Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Libplctag_and_native_are_never_connected_at_the_same_time [< 1 ms]
+Total tests: 17
+ Passed: 17
+```
+
+Full raw `dotnet test` output: [pass 1](2026-10-07-native-ab-transport-t21/t21-pass1.txt), [pass 2](2026-10-07-native-ab-transport-t21/t21-pass2.txt).
+
+## Latency (T21)
+
+Pass 2 (100 atomic tags × 50 passes per transport, 200 ms gap, all reads in a pass issued concurrently):
+
+| Transport | Passes | p50 ms | p95 ms | min ms | max ms | CIP requests per pass (median) | Tag failures |
+|---|---|---|---|---|---|---|---|
+| libplctag | 50 | 107.0 | 139.5 | 57.9 | 164.8 | n/a | 0 |
+| native | 50 | 100.3 | 105.8 | 94.8 | 107.5 | 2 | 0 |
+
+**Criterion: native p95 105.8 ms ≤ libplctag p95 139.5 ms × 1.10 = 153.4 ms → PASS.** Native was faster at the median
+and much steadier (max 107.5 ms vs 164.8 ms), packing 95 reads into 2 CIP requests per pass. Five pad/config tags that
+libplctag refused at setup were removed from both sets (`Local:*:I.Pad1`, `Local:5:C.CfgSize`, `Local:5:C.CfgIDNum`).
+
+Pass 1 (30 passes) failed this criterion on three native outliers (p95 142.8 vs limit 109.1; medians native 93.7 /
+libplctag 95.2) — see above.
+
+Per-pass detail (pass 2):
+
+```
+| Pass | libplctag ms | libplctag GC 0/1/2 | native ms | native GC 0/1/2 | native requests | native RTT min/mean/max ms |
+|---|---|---|---|---|---|---|
+| 1 | 104.8 | 0/0/0 | 100.3 | 0/0/0 | 2 | 44.9/49.6/54.4 |
+| 2 | 57.9 | 0/0/0 | 100.5 | 0/0/0 | 2 | 47.0/50.1/53.3 |
+| 3 | 139.5 | 0/0/0 | 105.8 | 0/0/0 | 2 | 45.3/52.9/60.4 |
+| 4 | 104.5 | 0/0/0 | 100.6 | 1/0/0 | 2 | 47.3/50.2/53.2 |
+| 5 | 108.0 | 0/0/0 | 105.3 | 0/0/0 | 2 | 44.5/52.6/60.6 |
+| 6 | 104.4 | 0/0/0 | 105.6 | 0/0/0 | 2 | 44.2/52.7/61.2 |
+| 7 | 60.8 | 0/0/0 | 98.7 | 0/0/0 | 2 | 44.3/49.3/54.2 |
+| 8 | 59.6 | 0/0/0 | 102.6 | 0/0/0 | 2 | 47.4/51.2/55.0 |
+| 9 | 109.2 | 0/0/0 | 97.0 | 0/0/0 | 2 | 45.9/48.4/50.9 |
+| 10 | 109.7 | 0/0/0 | 99.3 | 0/0/0 | 2 | 45.9/49.6/53.3 |
+| 11 | 105.9 | 0/0/0 | 103.0 | 0/0/0 | 2 | 46.2/51.4/56.7 |
+| 12 | 107.7 | 0/0/0 | 95.1 | 0/0/0 | 2 | 43.3/47.5/51.7 |
+| 13 | 104.7 | 0/0/0 | 104.0 | 0/0/0 | 2 | 45.7/52.0/58.2 |
+| 14 | 106.8 | 0/0/0 | 96.5 | 0/0/0 | 2 | 43.6/48.2/52.7 |
+| 15 | 107.2 | 0/0/0 | 102.4 | 0/0/0 | 2 | 46.1/51.1/56.2 |
+| 16 | 105.5 | 0/0/0 | 97.4 | 0/0/0 | 2 | 44.7/48.6/52.6 |
+| 17 | 105.9 | 0/0/0 | 107.5 | 0/0/0 | 2 | 51.0/53.7/56.4 |
+| 18 | 103.2 | 0/0/0 | 96.6 | 0/0/0 | 2 | 43.0/48.2/53.5 |
+| 19 | 106.5 | 0/0/0 | 102.1 | 0/0/0 | 2 | 45.5/51.0/56.4 |
+| 20 | 106.8 | 0/0/0 | 99.6 | 0/0/0 | 2 | 44.6/49.7/54.9 |
+| 21 | 122.8 | 0/0/0 | 104.5 | 0/0/0 | 2 | 52.1/52.2/52.3 |
+| 22 | 109.8 | 0/0/0 | 104.3 | 0/0/0 | 2 | 45.8/52.1/58.5 |
+| 23 | 104.3 | 0/0/0 | 98.6 | 0/0/0 | 2 | 45.4/49.2/53.0 |
+| 24 | 109.7 | 0/0/0 | 98.8 | 0/0/0 | 2 | 45.9/49.3/52.8 |
+| 25 | 155.1 | 0/0/0 | 101.0 | 0/0/0 | 2 | 45.5/50.4/55.4 |
+| 26 | 164.8 | 0/0/0 | 102.9 | 0/0/0 | 2 | 43.9/51.4/58.8 |
+| 27 | 120.4 | 0/0/0 | 101.4 | 0/0/0 | 2 | 48.2/50.7/53.1 |
+| 28 | 108.6 | 0/0/0 | 101.7 | 0/0/0 | 2 | 46.9/50.8/54.7 |
+| 29 | 106.0 | 0/0/0 | 98.5 | 0/0/0 | 2 | 45.5/49.2/52.9 |
+| 30 | 102.9 | 0/0/0 | 94.9 | 0/0/0 | 2 | 43.6/47.4/51.2 |
+| 31 | 111.3 | 0/0/0 | 99.9 | 0/0/0 | 2 | 45.6/49.9/54.2 |
+| 32 | 103.3 | 0/0/0 | 96.4 | 0/0/0 | 2 | 43.6/48.1/52.6 |
+| 33 | 105.8 | 0/0/0 | 104.6 | 0/0/0 | 2 | 46.4/52.2/58.0 |
+| 34 | 109.3 | 0/0/0 | 95.8 | 0/0/0 | 2 | 43.2/47.8/52.4 |
+| 35 | 110.2 | 0/0/0 | 102.1 | 0/0/0 | 2 | 46.3/51.0/55.7 |
+| 36 | 108.0 | 0/0/0 | 101.7 | 0/0/0 | 2 | 47.5/50.8/54.0 |
+| 37 | 107.6 | 0/0/0 | 96.7 | 0/0/0 | 2 | 45.0/48.3/51.5 |
+| 38 | 108.9 | 0/0/0 | 96.5 | 0/0/0 | 2 | 44.7/48.2/51.6 |
+| 39 | 105.2 | 0/0/0 | 98.2 | 0/0/0 | 2 | 45.6/49.0/52.5 |
+| 40 | 106.7 | 0/0/0 | 99.4 | 0/0/0 | 2 | 44.4/49.6/54.9 |
+| 41 | 107.0 | 0/0/0 | 101.8 | 0/0/0 | 2 | 46.9/50.8/54.8 |
+| 42 | 108.6 | 0/0/0 | 106.7 | 0/0/0 | 2 | 49.2/53.3/57.4 |
+| 43 | 107.0 | 0/0/0 | 102.6 | 0/0/0 | 2 | 49.9/51.2/52.6 |
+| 44 | 107.7 | 0/0/0 | 100.3 | 0/0/0 | 2 | 47.4/50.1/52.7 |
+| 45 | 109.5 | 0/0/0 | 102.6 | 0/0/0 | 2 | 45.8/51.2/56.7 |
+| 46 | 107.6 | 0/0/0 | 104.0 | 0/0/0 | 2 | 48.4/52.0/55.5 |
+| 47 | 115.6 | 0/0/0 | 99.0 | 0/0/0 | 2 | 45.9/49.4/52.9 |
+| 48 | 106.0 | 0/0/0 | 97.8 | 0/0/0 | 2 | 44.9/48.8/52.8 |
+| 49 | 104.5 | 0/0/0 | 98.4 | 0/0/0 | 2 | 46.1/49.1/52.2 |
+| 50 | 112.5 | 0/0/0 | 94.8 | 1/1/1 | 2 | 43.0/47.3/51.7 |
+
+```
+
diff --git a/docs/plans/2026-10-07-native-ab-transport-t21/t21-pass1.txt b/docs/plans/2026-10-07-native-ab-transport-t21/t21-pass1.txt
new file mode 100644
index 00000000..1eff5ca3
--- /dev/null
+++ b/docs/plans/2026-10-07-native-ab-transport-t21/t21-pass1.txt
@@ -0,0 +1,307 @@
+
+Test run for /Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/bin/Debug/net10.0/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.dll (.NETCoreApp,Version=v10.0)
+VSTest version 18.0.1 (arm64)
+
+Starting test execution, please wait...
+A total of 1 test files matched the specified pattern.
+/Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/bin/Debug/net10.0/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.dll
+[xUnit.net 00:00:00.00] xUnit.net VSTest Adapter v3.0.2+dd36e86129 (64-bit .NET 10.0.5)
+[xUnit.net 00:00:00.33] Discovering: ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
+[xUnit.net 00:00:00.46] Discovered: ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
+[xUnit.net 00:00:00.57] Starting: ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
+ Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.Template_parity_fails_when_vacuous_or_undecodable [11 ms]
+ Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.A_failing_endpoint_aborts_the_native_phase_after_exactly_one_connect [2 s]
+ Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.The_default_selection_keeps_arrays_and_udt_members_at_any_cap [7 ms]
+ Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.Volume_knobs_fail_fast_above_the_ceiling_instead_of_clamping [2 ms]
+ Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.A_failing_endpoint_aborts_the_libplctag_phase_before_any_tag_is_read [6 s]
+ Standard Output Messages:
+ libplctag connects before the abort: 1
+
+
+ Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.PollLatencyComparisonTests.Tag_pick_takes_atomic_scalars_only_and_repeats_a_short_set_visibly [13 ms]
+[xUnit.net 00:01:15.30] ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.PollLatencyComparisonTests.Native_poll_p95_is_within_ten_percent_of_libplctag [FAIL]
+[xUnit.net 00:01:15.30] Shouldly.ShouldAssertException : natP95
+[xUnit.net 00:01:15.30] should be less than or equal to
+[xUnit.net 00:01:15.30] 109.06115000000001d
+[xUnit.net 00:01:15.30] but was
+[xUnit.net 00:01:15.30] 142.765d
+[xUnit.net 00:01:15.30]
+[xUnit.net 00:01:15.30] Additional Info:
+[xUnit.net 00:01:15.30] native p95 must be ≤ libplctag p95 × 1.10
+[xUnit.net 00:01:15.30] Stack Trace:
+[xUnit.net 00:01:15.30] /Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/PollLatencyComparisonTests.cs(74,0): at ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.PollLatencyComparisonTests.Native_poll_p95_is_within_ten_percent_of_libplctag()
+[xUnit.net 00:01:15.30] at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
+[xUnit.net 00:01:15.30] at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
+[xUnit.net 00:01:15.30] at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task, ConfigureAwaitOptions options)
+[xUnit.net 00:01:15.30] --- End of stack trace from previous location ---
+[xUnit.net 00:01:15.30] at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
+[xUnit.net 00:01:15.30] at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
+[xUnit.net 00:01:15.30] at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task, ConfigureAwaitOptions options)
+[xUnit.net 00:01:15.30] Output:
+[xUnit.net 00:01:15.30] ## Latency — 10.209.70.160:44818 path '1,0' family CompactLogix (compactlogix, connection size 4002), max tags 25, max templates 20, pace 50 ms
+[xUnit.net 00:01:15.30] Tags per pass: 95 (100 distinct); passes per transport: 30; gap between passes: 200 ms.
+[xUnit.net 00:01:15.30] Types: SInt 6, DInt 8, Bool 17, Real 7, Int 57
+[xUnit.net 00:01:15.30] Dropped (libplctag refused at setup, removed from both sets): Local:1:I.Pad1, Local:2:I.Pad1, Local:3:I.Pad1, Local:5:C.CfgSize, Local:5:C.CfgIDNum
+[xUnit.net 00:01:15.30] Native requests spent on setup (one read per tag): 95; native Forward Opens: 1.
+[xUnit.net 00:01:15.30]
+[xUnit.net 00:01:15.30] | Transport | Passes | p50 ms | p95 ms | min ms | max ms | CIP requests per pass (median) | Tag failures |
+[xUnit.net 00:01:15.30] |---|---|---|---|---|---|---|---|
+[xUnit.net 00:01:15.30] | libplctag | 30 | 95.2 | 99.1 | 90.8 | 179.2 | n/a | 0 |
+[xUnit.net 00:01:15.30] | native | 30 | 93.7 | 142.8 | 88.8 | 146.1 | 2 | 0 |
+[xUnit.net 00:01:15.30]
+[xUnit.net 00:01:15.30] Criterion: native p95 142.8 ms ≤ libplctag p95 99.1 ms × 1.10 = 109.1 ms → **FAIL**
+[xUnit.net 00:01:15.30]
+[xUnit.net 00:01:15.30] | Pass | libplctag ms | native ms | native requests |
+[xUnit.net 00:01:15.30] |---|---|---|---|
+[xUnit.net 00:01:15.30] | 1 | 95.0 | 95.4 | 2 |
+[xUnit.net 00:01:15.30] | 2 | 94.4 | 92.4 | 2 |
+[xUnit.net 00:01:15.30] | 3 | 95.8 | 96.8 | 2 |
+[xUnit.net 00:01:15.30] | 4 | 95.0 | 94.7 | 2 |
+[xUnit.net 00:01:15.30] | 5 | 98.8 | 93.7 | 2 |
+[xUnit.net 00:01:15.30] | 6 | 92.4 | 101.0 | 2 |
+[xUnit.net 00:01:15.30] | 7 | 94.1 | 94.1 | 2 |
+[xUnit.net 00:01:15.30] | 8 | 97.0 | 96.7 | 2 |
+[xUnit.net 00:01:15.30] | 9 | 93.4 | 91.2 | 2 |
+[xUnit.net 00:01:15.30] | 10 | 97.7 | 91.0 | 2 |
+[xUnit.net 00:01:15.30] | 11 | 94.6 | 91.1 | 2 |
+[xUnit.net 00:01:15.30] | 12 | 96.5 | 92.0 | 2 |
+[xUnit.net 00:01:15.30] | 13 | 94.1 | 97.6 | 2 |
+[xUnit.net 00:01:15.30] | 14 | 96.1 | 94.0 | 2 |
+[xUnit.net 00:01:15.30] | 15 | 90.8 | 93.3 | 2 |
+[xUnit.net 00:01:15.30] | 16 | 95.6 | 93.7 | 2 |
+[xUnit.net 00:01:15.30] | 17 | 93.9 | 100.6 | 2 |
+[xUnit.net 00:01:15.30] | 18 | 92.4 | 121.6 | 2 |
+[xUnit.net 00:01:15.30] | 19 | 97.5 | 92.7 | 2 |
+[xUnit.net 00:01:15.30] | 20 | 95.2 | 93.8 | 2 |
+[xUnit.net 00:01:15.30] | 21 | 96.8 | 90.4 | 2 |
+[xUnit.net 00:01:15.30] | 22 | 98.6 | 99.1 | 2 |
+[xUnit.net 00:01:15.30] | 23 | 92.1 | 146.1 | 2 |
+[xUnit.net 00:01:15.30] | 24 | 99.1 | 142.8 | 2 |
+[xUnit.net 00:01:15.30] | 25 | 96.6 | 91.7 | 2 |
+[xUnit.net 00:01:15.30] | 26 | 96.9 | 91.4 | 2 |
+[xUnit.net 00:01:15.30] | 27 | 179.2 | 92.0 | 2 |
+[xUnit.net 00:01:15.30] | 28 | 98.5 | 91.7 | 2 |
+[xUnit.net 00:01:15.30] | 29 | 95.0 | 88.8 | 2 |
+[xUnit.net 00:01:15.30] | 30 | 95.0 | 93.4 | 2 |
+ Failed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.PollLatencyComparisonTests.Native_poll_p95_is_within_ten_percent_of_libplctag [1 m 6 s]
+ Error Message:
+ Shouldly.ShouldAssertException : natP95
+ should be less than or equal to
+109.06115000000001d
+ but was
+142.765d
+
+Additional Info:
+ native p95 must be ≤ libplctag p95 × 1.10
+ Stack Trace:
+ at ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.PollLatencyComparisonTests.Native_poll_p95_is_within_ten_percent_of_libplctag() in /Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/PollLatencyComparisonTests.cs:line 74
+ at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
+ at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
+ at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task, ConfigureAwaitOptions options)
+--- End of stack trace from previous location ---
+ at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
+ at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
+ at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task, ConfigureAwaitOptions options)
+ Standard Output Messages:
+ ## Latency — 10.209.70.160:44818 path '1,0' family CompactLogix (compactlogix, connection size 4002), max tags 25, max templates 20, pace 50 ms
+ Tags per pass: 95 (100 distinct); passes per transport: 30; gap between passes: 200 ms.
+ Types: SInt 6, DInt 8, Bool 17, Real 7, Int 57
+ Dropped (libplctag refused at setup, removed from both sets): Local:1:I.Pad1, Local:2:I.Pad1, Local:3:I.Pad1, Local:5:C.CfgSize, Local:5:C.CfgIDNum
+ Native requests spent on setup (one read per tag): 95; native Forward Opens: 1.
+
+ | Transport | Passes | p50 ms | p95 ms | min ms | max ms | CIP requests per pass (median) | Tag failures |
+ |---|---|---|---|---|---|---|---|
+ | libplctag | 30 | 95.2 | 99.1 | 90.8 | 179.2 | n/a | 0 |
+ | native | 30 | 93.7 | 142.8 | 88.8 | 146.1 | 2 | 0 |
+
+ Criterion: native p95 142.8 ms ≤ libplctag p95 99.1 ms × 1.10 = 109.1 ms → **FAIL**
+
+ | Pass | libplctag ms | native ms | native requests |
+ |---|---|---|---|
+ | 1 | 95.0 | 95.4 | 2 |
+ | 2 | 94.4 | 92.4 | 2 |
+ | 3 | 95.8 | 96.8 | 2 |
+ | 4 | 95.0 | 94.7 | 2 |
+ | 5 | 98.8 | 93.7 | 2 |
+ | 6 | 92.4 | 101.0 | 2 |
+ | 7 | 94.1 | 94.1 | 2 |
+ | 8 | 97.0 | 96.7 | 2 |
+ | 9 | 93.4 | 91.2 | 2 |
+ | 10 | 97.7 | 91.0 | 2 |
+ | 11 | 94.6 | 91.1 | 2 |
+ | 12 | 96.5 | 92.0 | 2 |
+ | 13 | 94.1 | 97.6 | 2 |
+ | 14 | 96.1 | 94.0 | 2 |
+ | 15 | 90.8 | 93.3 | 2 |
+ | 16 | 95.6 | 93.7 | 2 |
+ | 17 | 93.9 | 100.6 | 2 |
+ | 18 | 92.4 | 121.6 | 2 |
+ | 19 | 97.5 | 92.7 | 2 |
+ | 20 | 95.2 | 93.8 | 2 |
+ | 21 | 96.8 | 90.4 | 2 |
+ | 22 | 98.6 | 99.1 | 2 |
+ | 23 | 92.1 | 146.1 | 2 |
+ | 24 | 99.1 | 142.8 | 2 |
+ | 25 | 96.6 | 91.7 | 2 |
+ | 26 | 96.9 | 91.4 | 2 |
+ | 27 | 179.2 | 92.0 | 2 |
+ | 28 | 98.5 | 91.7 | 2 |
+ | 29 | 95.0 | 88.8 | 2 |
+ | 30 | 95.0 | 93.4 | 2 |
+
+
+[xUnit.net 00:01:15.35] ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Every_selected_tag_reads_identically_through_both_transports [FAIL]
+[xUnit.net 00:01:15.35] Shouldly.ShouldAssertException : unexpected
+[xUnit.net 00:01:15.35] should be empty but had
+[xUnit.net 00:01:15.35] 2
+[xUnit.net 00:01:15.35] items and was
+[xUnit.net 00:01:15.35] ["WW_M4ActualTemp: **NO: raw** (lib 4 byte(s) 8471C043 = 384.88684; native 4 byte(s) A491C043 = 385.13782)", "Program:EM01_PowerUnit.__fout9FB1561FA00D73F5: **NO: raw** (lib 20 byte(s) 0050C3481CE387401CE3A740E41CD8C01CE38740 = [400000,4.2464733,5.2464733,-6.7535267,4.2464733]; native 20 byte(s) 0050C34811DD874011DDA740EF22D8C011DD8740 = [400000,4.2457356,5.2457356,-6.7542644,4.2457356])"]
+[xUnit.net 00:01:15.35] Stack Trace:
+[xUnit.net 00:01:15.35] /Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/TransportDifferentialTests.cs(123,0): at ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Every_selected_tag_reads_identically_through_both_transports()
+[xUnit.net 00:01:15.35] at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
+[xUnit.net 00:01:15.35] at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
+[xUnit.net 00:01:15.35] at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task, ConfigureAwaitOptions options)
+[xUnit.net 00:01:15.35] --- End of stack trace from previous location ---
+[xUnit.net 00:01:15.35] at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
+[xUnit.net 00:01:15.35] at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
+[xUnit.net 00:01:15.35] at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task, ConfigureAwaitOptions options)
+[xUnit.net 00:01:15.35] Output:
+[xUnit.net 00:01:15.35] ## Reads — 25 tag(s), 10.209.70.160:44818 path '1,0' family CompactLogix (compactlogix, connection size 4002), max tags 25, max templates 20, pace 50 ms
+[xUnit.net 00:01:15.35] Selection by origin: array 9, udt member 8, scalar 8
+[xUnit.net 00:01:15.35] | Tag | Type | libplctag status | native status | Equal? |
+[xUnit.net 00:01:15.35] |---|---|---|---|---|
+[xUnit.net 00:01:15.35] | `Program:Recipes._monitorID` | DInt[255] | Ok | Ok | yes |
+[xUnit.net 00:01:15.35] | `Local:1:I.InfoBits` | SInt | Ok | Ok | yes |
+[xUnit.net 00:01:15.35] | `__CONTAINER` | DInt | Ok | Ok | yes |
+[xUnit.net 00:01:15.35] | `Program:EM01_PowerUnit._VacuumCalibration_mA` | Real[10] | Ok | Ok | yes |
+[xUnit.net 00:01:15.35] | `Local:1:I.RunMode` | Bool | Ok | Ok | yes |
+[xUnit.net 00:01:15.35] | `WW_M4ActualTemp` | Real | Ok | Ok | **NO: raw** |
+[xUnit.net 00:01:15.35] | `Program:EM01_PowerUnit._VacuumCalibration_PSIA` | Real[10] | Ok | Ok | yes |
+[xUnit.net 00:01:15.35] | `Local:1:I.ConnectionFaulted` | Bool | Ok | Ok | yes |
+[xUnit.net 00:01:15.35] | `WW_P1_Status_Msg` | DInt | Ok | Ok | yes |
+[xUnit.net 00:01:15.35] | `Program:EM01_PowerUnit.__fout9FB1561FA00D73F5` | Real[5] | Ok | Ok | **NO: raw** |
+[xUnit.net 00:01:15.35] | `Local:1:I.DiagnosticActive` | Bool | Ok | Ok | yes |
+[xUnit.net 00:01:15.35] | `DI_CycleStart_PB` | Bool | Ok | Ok | yes |
+[xUnit.net 00:01:15.35] | `Program:EM01_PowerUnit.__foutA0A97D0B7D734500` | Real[2] | Ok | Ok | yes |
+[xUnit.net 00:01:15.35] | `Local:1:I.DiagnosticSequenceCount` | SInt | Ok | Ok | yes |
+[xUnit.net 00:01:15.35] | `test` | DInt | Ok | Ok | yes |
+[xUnit.net 00:01:15.35] | `Program:EM01_PowerUnit.__fout2A759FA1596561EC` | Real[2] | Ok | Ok | yes |
+[xUnit.net 00:01:15.35] | `Local:1:I.Pad1` | Int | NotFound | NotFound | yes |
+[xUnit.net 00:01:15.35] | `UN01_EventsPTR` | DInt | Ok | Ok | yes |
+[xUnit.net 00:01:15.35] | `Program:EM01_PowerUnit._SEQ_EP` | DInt[32] | Ok | Ok | yes |
+[xUnit.net 00:01:15.35] | `Local:2:I.InfoBits` | SInt | Ok | Ok | yes |
+[xUnit.net 00:01:15.35] | `Sts_Vacuum_Rerun` | Bool | Ok | Ok | yes |
+[xUnit.net 00:01:15.35] | `Program:EM01_PowerUnit.__fout02198332D12EF5E3` | Real[3] | Ok | Ok | yes |
+[xUnit.net 00:01:15.35] | `Local:2:I.RunMode` | Bool | Ok | Ok | yes |
+[xUnit.net 00:01:15.35] | `Ons_VacuumRerun` | Bool | Ok | Ok | yes |
+[xUnit.net 00:01:15.35] | `Program:EM02_Clamp._SEQ_EP` | DInt[32] | Ok | Ok | yes |
+[xUnit.net 00:01:15.35]
+[xUnit.net 00:01:15.35] Unexpected divergences: 2; expected divergences: 0.
+[xUnit.net 00:01:15.35] - UNEXPECTED: WW_M4ActualTemp: **NO: raw** (lib 4 byte(s) 8471C043 = 384.88684; native 4 byte(s) A491C043 = 385.13782)
+[xUnit.net 00:01:15.35] - UNEXPECTED: Program:EM01_PowerUnit.__fout9FB1561FA00D73F5: **NO: raw** (lib 20 byte(s) 0050C3481CE387401CE3A740E41CD8C01CE38740 = [400000,4.2464733,5.2464733,-6.7535267,4.2464733]; native 20 byte(s) 0050C34811DD874011DDA740EF22D8C011DD8740 = [400000,4.2457356,5.2457356,-6.7542644,4.2457356])
+[xUnit.net 00:01:15.35] Finished: ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
+Process terminated.
+A callback was made on a garbage collected delegate of type 'libplctag.NativeImport!libplctag.NativeImport.plctag+callback_func_ex::Invoke'.
+ Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_harness_is_read_only_by_construction [31 ms]
+ Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Browse_listings_decode_to_identical_tag_sets_through_both_enumerators [6 ms]
+ Standard Output Messages:
+ ## Browse — 10.209.70.160:44818 path '1,0' family CompactLogix (compactlogix, connection size 4002), max tags 25, max templates 20, pace 50 ms
+ libplctag: 3162 tag(s); native: 3162 tag(s)
+
+
+ Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_comparison_flags_every_divergence_and_excuses_only_a_native_type_mismatch [< 1 ms]
+ Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Every_template_decodes_to_an_identical_shape_through_both_readers [< 1 ms]
+ Standard Output Messages:
+ ## Templates — 20 id(s); truncated by AB_DIFF_MAX_TEMPLATES: libplctag 74, native 74
+ | Template | libplctag | native | Equal? |
+ |---|---|---|---|
+ | 283 | Recipe (5 members, 1680 B) | Recipe (5 members, 1680 B) | yes |
+ | 295 | UDT_PartStatus (5 members, 4 B) | UDT_PartStatus (5 members, 4 B) | yes |
+ | 344 | Recipe_Clamp (7 members, 420 B) | Recipe_Clamp (7 members, 420 B) | yes |
+ | 491 | ForceSegmentEnableCheck (3 members, 4 B) | ForceSegmentEnableCheck (3 members, 4 B) | yes |
+ | 527 | Recipe_PowerUnit (6 members, 24 B) | Recipe_PowerUnit (6 members, 24 B) | yes |
+ | 622 | UDT_TemperatureProfile_Segment (11 members, 40 B) | UDT_TemperatureProfile_Segment (11 members, 40 B) | yes |
+ | 676 | AB:5000_DO8_Diag:I:0 (14 members, 36 B) | AB:5000_DO8_Diag:I:0 (14 members, 36 B) | yes |
+ | 700 | AB:5000_AI4CJ:I:0 (12 members, 68 B) | AB:5000_AI4CJ:I:0 (12 members, 68 B) | yes |
+ | 706 | raM_UDT_ItfAD_5000Digital_Sts (11 members, 320 B) | raM_UDT_ItfAD_5000Digital_Sts (11 members, 320 B) | yes |
+ | 715 | TwoHand_Control (8 members, 16 B) | TwoHand_Control (8 members, 16 B) | yes |
+ | 752 | STR0016 (2 members, 20 B) | STR0016 (2 members, 20 B) | yes |
+ | 842 | STR0032 (2 members, 36 B) | STR0032 (2 members, 36 B) | yes |
+ | 862 | raM_UDT_ItfAD_5000Analog_Set (3 members, 4 B) | raM_UDT_ItfAD_5000Analog_Set (3 members, 4 B) | yes |
+ | 1129 | raM_Tec_LimitWithModulo (38 members, 120 B) | raM_Tec_LimitWithModulo (38 members, 120 B) | yes |
+ | 1144 | PartStatus (8 members, 12 B) | PartStatus (8 members, 12 B) | yes |
+ | 1166 | AB:5000_DI16:C:0 (18 members, 72 B) | AB:5000_DI16:C:0 (18 members, 72 B) | yes |
+ | 1192 | raM_UDT_ItfAD_5000Digital_Cmd (5 members, 4 B) | raM_UDT_ItfAD_5000Digital_Cmd (5 members, 4 B) | yes |
+ | 1206 | raM_Dvc_5000AI4_VCRT (77 members, 180 B) | raM_Dvc_5000AI4_VCRT (77 members, 180 B) | yes |
+ | 1289 | TimeString (12 members, 376 B) | TimeString (12 members, 376 B) | yes |
+ | 1313 | raM_Dvc_5000AO4 (73 members, 164 B) | raM_Dvc_5000AO4 (73 members, 164 B) | yes |
+
+
+ Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_native_side_makes_exactly_one_forward_open_over_the_whole_run [< 1 ms]
+ Standard Output Messages:
+ Native Forward Opens over the run: 1.
+
+
+ Failed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Every_selected_tag_reads_identically_through_both_transports [2 ms]
+ Error Message:
+ Shouldly.ShouldAssertException : unexpected
+ should be empty but had
+2
+ items and was
+["WW_M4ActualTemp: **NO: raw** (lib 4 byte(s) 8471C043 = 384.88684; native 4 byte(s) A491C043 = 385.13782)", "Program:EM01_PowerUnit.__fout9FB1561FA00D73F5: **NO: raw** (lib 20 byte(s) 0050C3481CE387401CE3A740E41CD8C01CE38740 = [400000,4.2464733,5.2464733,-6.7535267,4.2464733]; native 20 byte(s) 0050C34811DD874011DDA740EF22D8C011DD8740 = [400000,4.2457356,5.2457356,-6.7542644,4.2457356])"]
+ Stack Trace:
+ at ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Every_selected_tag_reads_identically_through_both_transports() in /Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/TransportDifferentialTests.cs:line 123
+ at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
+ at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
+ at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task, ConfigureAwaitOptions options)
+--- End of stack trace from previous location ---
+ at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
+ at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
+ at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task, ConfigureAwaitOptions options)
+ Standard Output Messages:
+ ## Reads — 25 tag(s), 10.209.70.160:44818 path '1,0' family CompactLogix (compactlogix, connection size 4002), max tags 25, max templates 20, pace 50 ms
+ Selection by origin: array 9, udt member 8, scalar 8
+ | Tag | Type | libplctag status | native status | Equal? |
+ |---|---|---|---|---|
+ | `Program:Recipes._monitorID` | DInt[255] | Ok | Ok | yes |
+ | `Local:1:I.InfoBits` | SInt | Ok | Ok | yes |
+ | `__CONTAINER` | DInt | Ok | Ok | yes |
+ | `Program:EM01_PowerUnit._VacuumCalibration_mA` | Real[10] | Ok | Ok | yes |
+ | `Local:1:I.RunMode` | Bool | Ok | Ok | yes |
+ | `WW_M4ActualTemp` | Real | Ok | Ok | **NO: raw** |
+ | `Program:EM01_PowerUnit._VacuumCalibration_PSIA` | Real[10] | Ok | Ok | yes |
+ | `Local:1:I.ConnectionFaulted` | Bool | Ok | Ok | yes |
+ | `WW_P1_Status_Msg` | DInt | Ok | Ok | yes |
+ | `Program:EM01_PowerUnit.__fout9FB1561FA00D73F5` | Real[5] | Ok | Ok | **NO: raw** |
+ | `Local:1:I.DiagnosticActive` | Bool | Ok | Ok | yes |
+ | `DI_CycleStart_PB` | Bool | Ok | Ok | yes |
+ | `Program:EM01_PowerUnit.__foutA0A97D0B7D734500` | Real[2] | Ok | Ok | yes |
+ | `Local:1:I.DiagnosticSequenceCount` | SInt | Ok | Ok | yes |
+ | `test` | DInt | Ok | Ok | yes |
+ | `Program:EM01_PowerUnit.__fout2A759FA1596561EC` | Real[2] | Ok | Ok | yes |
+ | `Local:1:I.Pad1` | Int | NotFound | NotFound | yes |
+ | `UN01_EventsPTR` | DInt | Ok | Ok | yes |
+ | `Program:EM01_PowerUnit._SEQ_EP` | DInt[32] | Ok | Ok | yes |
+ | `Local:2:I.InfoBits` | SInt | Ok | Ok | yes |
+ | `Sts_Vacuum_Rerun` | Bool | Ok | Ok | yes |
+ | `Program:EM01_PowerUnit.__fout02198332D12EF5E3` | Real[3] | Ok | Ok | yes |
+ | `Local:2:I.RunMode` | Bool | Ok | Ok | yes |
+ | `Ons_VacuumRerun` | Bool | Ok | Ok | yes |
+ | `Program:EM02_Clamp._SEQ_EP` | DInt[32] | Ok | Ok | yes |
+
+ Unexpected divergences: 2; expected divergences: 0.
+ - UNEXPECTED: WW_M4ActualTemp: **NO: raw** (lib 4 byte(s) 8471C043 = 384.88684; native 4 byte(s) A491C043 = 385.13782)
+ - UNEXPECTED: Program:EM01_PowerUnit.__fout9FB1561FA00D73F5: **NO: raw** (lib 20 byte(s) 0050C3481CE387401CE3A740E41CD8C01CE38740 = [400000,4.2464733,5.2464733,-6.7535267,4.2464733]; native 20 byte(s) 0050C34811DD874011DDA740EF22D8C011DD8740 = [400000,4.2457356,5.2457356,-6.7542644,4.2457356])
+
+
+ Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Libplctag_and_native_are_never_connected_at_the_same_time [< 1 ms]
+ Standard Output Messages:
+ TCP connections to the target: baseline 0, before native 0, libplctag drain wait 5284 ms.
+
+
+
+Test Run Failed.
+Total tests: 14
+ Passed: 12
+ Failed: 2
+ Total time: 1.2601 Minutes
diff --git a/docs/plans/2026-10-07-native-ab-transport-t21/t21-pass2.txt b/docs/plans/2026-10-07-native-ab-transport-t21/t21-pass2.txt
new file mode 100644
index 00000000..37b35275
--- /dev/null
+++ b/docs/plans/2026-10-07-native-ab-transport-t21/t21-pass2.txt
@@ -0,0 +1,369 @@
+
+Test run for /Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/bin/Debug/net10.0/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.dll (.NETCoreApp,Version=v10.0)
+VSTest version 18.0.1 (arm64)
+
+Starting test execution, please wait...
+A total of 1 test files matched the specified pattern.
+/Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/bin/Debug/net10.0/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.dll
+[xUnit.net 00:00:00.00] xUnit.net VSTest Adapter v3.0.2+dd36e86129 (64-bit .NET 10.0.5)
+[xUnit.net 00:00:00.46] Discovering: ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
+[xUnit.net 00:00:00.70] Discovered: ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
+[xUnit.net 00:00:00.93] Starting: ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
+ Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.Template_parity_fails_when_vacuous_or_undecodable [26 ms]
+ Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.A_failing_endpoint_aborts_the_native_phase_after_exactly_one_connect [2 s]
+ Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.The_default_selection_keeps_arrays_and_udt_members_at_any_cap [17 ms]
+ Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.Volume_knobs_fail_fast_above_the_ceiling_instead_of_clamping [4 ms]
+ Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.A_failing_endpoint_aborts_the_libplctag_phase_before_any_tag_is_read [6 s]
+ Standard Output Messages:
+ libplctag connects before the abort: 1
+
+
+ Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.PollLatencyComparisonTests.Tag_pick_takes_atomic_scalars_only_and_repeats_a_short_set_visibly [15 ms]
+ Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.PollLatencyComparisonTests.Native_poll_p95_is_within_ten_percent_of_libplctag [3 m 2 s]
+ Standard Output Messages:
+ ## Latency — 10.209.70.160:44818 path '1,0' family CompactLogix (compactlogix, connection size 4002), max tags 200, max templates 20, pace 50 ms
+ Tags per pass: 95 (100 distinct); passes per transport: 50; gap between passes: 200 ms.
+ Types: SInt 6, DInt 8, Bool 17, Real 7, Int 57
+ Dropped (libplctag refused at setup, removed from both sets): Local:1:I.Pad1, Local:2:I.Pad1, Local:3:I.Pad1, Local:5:C.CfgSize, Local:5:C.CfgIDNum
+ Native requests spent on setup (one read per tag): 95; native Forward Opens: 1; native Forward Closes sent/acked: 1/1; connections after the native phase: 0.
+ libplctag orphans destroyed so far: 15; rooted (unreleasable): 0.
+
+ | Transport | Passes | p50 ms | p95 ms | min ms | max ms | CIP requests per pass (median) | Tag failures |
+ |---|---|---|---|---|---|---|---|
+ | libplctag | 50 | 107.0 | 139.5 | 57.9 | 164.8 | n/a | 0 |
+ | native | 50 | 100.3 | 105.8 | 94.8 | 107.5 | 2 | 0 |
+
+ Criterion: native p95 105.8 ms ≤ libplctag p95 139.5 ms × 1.10 = 153.4 ms → PASS
+
+ | Pass | libplctag ms | libplctag GC 0/1/2 | native ms | native GC 0/1/2 | native requests | native RTT min/mean/max ms |
+ |---|---|---|---|---|---|---|
+ | 1 | 104.8 | 0/0/0 | 100.3 | 0/0/0 | 2 | 44.9/49.6/54.4 |
+ | 2 | 57.9 | 0/0/0 | 100.5 | 0/0/0 | 2 | 47.0/50.1/53.3 |
+ | 3 | 139.5 | 0/0/0 | 105.8 | 0/0/0 | 2 | 45.3/52.9/60.4 |
+ | 4 | 104.5 | 0/0/0 | 100.6 | 1/0/0 | 2 | 47.3/50.2/53.2 |
+ | 5 | 108.0 | 0/0/0 | 105.3 | 0/0/0 | 2 | 44.5/52.6/60.6 |
+ | 6 | 104.4 | 0/0/0 | 105.6 | 0/0/0 | 2 | 44.2/52.7/61.2 |
+ | 7 | 60.8 | 0/0/0 | 98.7 | 0/0/0 | 2 | 44.3/49.3/54.2 |
+ | 8 | 59.6 | 0/0/0 | 102.6 | 0/0/0 | 2 | 47.4/51.2/55.0 |
+ | 9 | 109.2 | 0/0/0 | 97.0 | 0/0/0 | 2 | 45.9/48.4/50.9 |
+ | 10 | 109.7 | 0/0/0 | 99.3 | 0/0/0 | 2 | 45.9/49.6/53.3 |
+ | 11 | 105.9 | 0/0/0 | 103.0 | 0/0/0 | 2 | 46.2/51.4/56.7 |
+ | 12 | 107.7 | 0/0/0 | 95.1 | 0/0/0 | 2 | 43.3/47.5/51.7 |
+ | 13 | 104.7 | 0/0/0 | 104.0 | 0/0/0 | 2 | 45.7/52.0/58.2 |
+ | 14 | 106.8 | 0/0/0 | 96.5 | 0/0/0 | 2 | 43.6/48.2/52.7 |
+ | 15 | 107.2 | 0/0/0 | 102.4 | 0/0/0 | 2 | 46.1/51.1/56.2 |
+ | 16 | 105.5 | 0/0/0 | 97.4 | 0/0/0 | 2 | 44.7/48.6/52.6 |
+ | 17 | 105.9 | 0/0/0 | 107.5 | 0/0/0 | 2 | 51.0/53.7/56.4 |
+ | 18 | 103.2 | 0/0/0 | 96.6 | 0/0/0 | 2 | 43.0/48.2/53.5 |
+ | 19 | 106.5 | 0/0/0 | 102.1 | 0/0/0 | 2 | 45.5/51.0/56.4 |
+ | 20 | 106.8 | 0/0/0 | 99.6 | 0/0/0 | 2 | 44.6/49.7/54.9 |
+ | 21 | 122.8 | 0/0/0 | 104.5 | 0/0/0 | 2 | 52.1/52.2/52.3 |
+ | 22 | 109.8 | 0/0/0 | 104.3 | 0/0/0 | 2 | 45.8/52.1/58.5 |
+ | 23 | 104.3 | 0/0/0 | 98.6 | 0/0/0 | 2 | 45.4/49.2/53.0 |
+ | 24 | 109.7 | 0/0/0 | 98.8 | 0/0/0 | 2 | 45.9/49.3/52.8 |
+ | 25 | 155.1 | 0/0/0 | 101.0 | 0/0/0 | 2 | 45.5/50.4/55.4 |
+ | 26 | 164.8 | 0/0/0 | 102.9 | 0/0/0 | 2 | 43.9/51.4/58.8 |
+ | 27 | 120.4 | 0/0/0 | 101.4 | 0/0/0 | 2 | 48.2/50.7/53.1 |
+ | 28 | 108.6 | 0/0/0 | 101.7 | 0/0/0 | 2 | 46.9/50.8/54.7 |
+ | 29 | 106.0 | 0/0/0 | 98.5 | 0/0/0 | 2 | 45.5/49.2/52.9 |
+ | 30 | 102.9 | 0/0/0 | 94.9 | 0/0/0 | 2 | 43.6/47.4/51.2 |
+ | 31 | 111.3 | 0/0/0 | 99.9 | 0/0/0 | 2 | 45.6/49.9/54.2 |
+ | 32 | 103.3 | 0/0/0 | 96.4 | 0/0/0 | 2 | 43.6/48.1/52.6 |
+ | 33 | 105.8 | 0/0/0 | 104.6 | 0/0/0 | 2 | 46.4/52.2/58.0 |
+ | 34 | 109.3 | 0/0/0 | 95.8 | 0/0/0 | 2 | 43.2/47.8/52.4 |
+ | 35 | 110.2 | 0/0/0 | 102.1 | 0/0/0 | 2 | 46.3/51.0/55.7 |
+ | 36 | 108.0 | 0/0/0 | 101.7 | 0/0/0 | 2 | 47.5/50.8/54.0 |
+ | 37 | 107.6 | 0/0/0 | 96.7 | 0/0/0 | 2 | 45.0/48.3/51.5 |
+ | 38 | 108.9 | 0/0/0 | 96.5 | 0/0/0 | 2 | 44.7/48.2/51.6 |
+ | 39 | 105.2 | 0/0/0 | 98.2 | 0/0/0 | 2 | 45.6/49.0/52.5 |
+ | 40 | 106.7 | 0/0/0 | 99.4 | 0/0/0 | 2 | 44.4/49.6/54.9 |
+ | 41 | 107.0 | 0/0/0 | 101.8 | 0/0/0 | 2 | 46.9/50.8/54.8 |
+ | 42 | 108.6 | 0/0/0 | 106.7 | 0/0/0 | 2 | 49.2/53.3/57.4 |
+ | 43 | 107.0 | 0/0/0 | 102.6 | 0/0/0 | 2 | 49.9/51.2/52.6 |
+ | 44 | 107.7 | 0/0/0 | 100.3 | 0/0/0 | 2 | 47.4/50.1/52.7 |
+ | 45 | 109.5 | 0/0/0 | 102.6 | 0/0/0 | 2 | 45.8/51.2/56.7 |
+ | 46 | 107.6 | 0/0/0 | 104.0 | 0/0/0 | 2 | 48.4/52.0/55.5 |
+ | 47 | 115.6 | 0/0/0 | 99.0 | 0/0/0 | 2 | 45.9/49.4/52.9 |
+ | 48 | 106.0 | 0/0/0 | 97.8 | 0/0/0 | 2 | 44.9/48.8/52.8 |
+ | 49 | 104.5 | 0/0/0 | 98.4 | 0/0/0 | 2 | 46.1/49.1/52.2 |
+ | 50 | 112.5 | 0/0/0 | 94.8 | 1/1/1 | 2 | 43.0/47.3/51.7 |
+
+
+ Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_native_side_closes_its_one_connection_with_exactly_one_forward_close [< 1 ms]
+ Standard Output Messages:
+ Native Forward Closes sent/acked: 1/1; connections after the native phase: 0.
+
+
+ Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.No_failed_libplctag_tag_is_left_with_a_live_native_callback [< 1 ms]
+ Standard Output Messages:
+ libplctag orphans destroyed so far: 15; rooted (unreleasable): 0.
+
+
+ Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.A_live_value_is_compared_structurally_and_a_stable_one_by_value [3 ms]
+ Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_harness_is_read_only_by_construction [36 ms]
+ Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Browse_listings_decode_to_identical_tag_sets_through_both_enumerators [6 ms]
+ Standard Output Messages:
+ ## Browse — 10.209.70.160:44818 path '1,0' family CompactLogix (compactlogix, connection size 4002), max tags 200, max templates 20, pace 50 ms
+ libplctag: 3162 tag(s); native: 3162 tag(s)
+
+
+ Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_comparison_flags_every_divergence_and_excuses_only_a_native_type_mismatch [< 1 ms]
+ Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Every_template_decodes_to_an_identical_shape_through_both_readers [< 1 ms]
+ Standard Output Messages:
+ ## Templates — 20 id(s); truncated by AB_DIFF_MAX_TEMPLATES: libplctag 74, native 74
+ | Template | libplctag | native | Equal? |
+ |---|---|---|---|
+ | 283 | Recipe (5 members, 1680 B) | Recipe (5 members, 1680 B) | yes |
+ | 295 | UDT_PartStatus (5 members, 4 B) | UDT_PartStatus (5 members, 4 B) | yes |
+ | 344 | Recipe_Clamp (7 members, 420 B) | Recipe_Clamp (7 members, 420 B) | yes |
+ | 491 | ForceSegmentEnableCheck (3 members, 4 B) | ForceSegmentEnableCheck (3 members, 4 B) | yes |
+ | 527 | Recipe_PowerUnit (6 members, 24 B) | Recipe_PowerUnit (6 members, 24 B) | yes |
+ | 622 | UDT_TemperatureProfile_Segment (11 members, 40 B) | UDT_TemperatureProfile_Segment (11 members, 40 B) | yes |
+ | 676 | AB:5000_DO8_Diag:I:0 (14 members, 36 B) | AB:5000_DO8_Diag:I:0 (14 members, 36 B) | yes |
+ | 700 | AB:5000_AI4CJ:I:0 (12 members, 68 B) | AB:5000_AI4CJ:I:0 (12 members, 68 B) | yes |
+ | 706 | raM_UDT_ItfAD_5000Digital_Sts (11 members, 320 B) | raM_UDT_ItfAD_5000Digital_Sts (11 members, 320 B) | yes |
+ | 715 | TwoHand_Control (8 members, 16 B) | TwoHand_Control (8 members, 16 B) | yes |
+ | 752 | STR0016 (2 members, 20 B) | STR0016 (2 members, 20 B) | yes |
+ | 842 | STR0032 (2 members, 36 B) | STR0032 (2 members, 36 B) | yes |
+ | 862 | raM_UDT_ItfAD_5000Analog_Set (3 members, 4 B) | raM_UDT_ItfAD_5000Analog_Set (3 members, 4 B) | yes |
+ | 1129 | raM_Tec_LimitWithModulo (38 members, 120 B) | raM_Tec_LimitWithModulo (38 members, 120 B) | yes |
+ | 1144 | PartStatus (8 members, 12 B) | PartStatus (8 members, 12 B) | yes |
+ | 1166 | AB:5000_DI16:C:0 (18 members, 72 B) | AB:5000_DI16:C:0 (18 members, 72 B) | yes |
+ | 1192 | raM_UDT_ItfAD_5000Digital_Cmd (5 members, 4 B) | raM_UDT_ItfAD_5000Digital_Cmd (5 members, 4 B) | yes |
+ | 1206 | raM_Dvc_5000AI4_VCRT (77 members, 180 B) | raM_Dvc_5000AI4_VCRT (77 members, 180 B) | yes |
+ | 1289 | TimeString (12 members, 376 B) | TimeString (12 members, 376 B) | yes |
+ | 1313 | raM_Dvc_5000AO4 (73 members, 164 B) | raM_Dvc_5000AO4 (73 members, 164 B) | yes |
+
+
+ Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_native_side_makes_exactly_one_forward_open_over_the_whole_run [< 1 ms]
+ Standard Output Messages:
+ Native Forward Opens over the run: 1.
+
+
+ Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Every_selected_tag_reads_identically_through_both_transports [3 ms]
+ Standard Output Messages:
+ ## Reads — 200 tag(s), 10.209.70.160:44818 path '1,0' family CompactLogix (compactlogix, connection size 4002), max tags 200, max templates 20, pace 50 ms
+ Selection by origin: array 20, udt member 20, scalar 160
+ | Tag | Type | libplctag status | native status | libplctag probe | Equal? |
+ |---|---|---|---|---|---|
+ | `Program:Recipes._monitorID` | DInt[255] | Ok | Ok | stable | yes |
+ | `Local:1:I.InfoBits` | SInt | Ok | Ok | stable | yes |
+ | `__CONTAINER` | DInt | Ok | Ok | stable | yes |
+ | `Program:EM01_PowerUnit._VacuumCalibration_mA` | Real[10] | Ok | Ok | stable | yes |
+ | `Local:1:I.RunMode` | Bool | Ok | Ok | stable | yes |
+ | `WW_M4ActualTemp` | Real | Ok | Ok | **live** | live |
+ | `Program:EM01_PowerUnit._VacuumCalibration_PSIA` | Real[10] | Ok | Ok | stable | yes |
+ | `Local:1:I.ConnectionFaulted` | Bool | Ok | Ok | stable | yes |
+ | `WW_P1_Status_Msg` | DInt | Ok | Ok | stable | yes |
+ | `Program:EM01_PowerUnit.__fout9FB1561FA00D73F5` | Real[5] | Ok | Ok | **live** | live |
+ | `Local:1:I.DiagnosticActive` | Bool | Ok | Ok | stable | yes |
+ | `DI_CycleStart_PB` | Bool | Ok | Ok | stable | yes |
+ | `Program:EM01_PowerUnit.__foutA0A97D0B7D734500` | Real[2] | Ok | Ok | stable | yes |
+ | `Local:1:I.DiagnosticSequenceCount` | SInt | Ok | Ok | stable | yes |
+ | `test` | DInt | Ok | Ok | stable | yes |
+ | `Program:EM01_PowerUnit.__fout2A759FA1596561EC` | Real[2] | Ok | Ok | stable | yes |
+ | `Local:1:I.Pad1` | Int | NotFound | NotFound | NotFound | yes |
+ | `UN01_EventsPTR` | DInt | Ok | Ok | stable | yes |
+ | `Program:EM01_PowerUnit._SEQ_EP` | DInt[32] | Ok | Ok | stable | yes |
+ | `Local:2:I.InfoBits` | SInt | Ok | Ok | stable | yes |
+ | `Sts_Vacuum_Rerun` | Bool | Ok | Ok | stable | yes |
+ | `Program:EM01_PowerUnit.__fout02198332D12EF5E3` | Real[3] | Ok | Ok | stable | yes |
+ | `Local:2:I.RunMode` | Bool | Ok | Ok | stable | yes |
+ | `Ons_VacuumRerun` | Bool | Ok | Ok | stable | yes |
+ | `Program:EM02_Clamp._SEQ_EP` | DInt[32] | Ok | Ok | stable | yes |
+ | `Local:2:I.ConnectionFaulted` | Bool | Ok | Ok | stable | yes |
+ | `test2` | DInt | Ok | Ok | stable | yes |
+ | `Program:EM02_Clamp.__fout79B2713C88B35A28` | Real[3] | Ok | Ok | stable | yes |
+ | `Local:2:I.DiagnosticActive` | Bool | Ok | Ok | stable | yes |
+ | `_Val_PressurePVx` | Real | Ok | Ok | stable | yes |
+ | `Program:EM02_Clamp.__fout2F67CD35C2302EE0` | Real[4] | Ok | Ok | **live** | live |
+ | `Local:2:I.DiagnosticSequenceCount` | SInt | Ok | Ok | stable | yes |
+ | `C_LimitType_Outside` | DInt | Ok | Ok | stable | yes |
+ | `Program:ES03_EM01_Zone1.__fout25DE96F3D0E2B7BB` | Real[4] | Ok | Ok | **live** | live |
+ | `Local:2:I.Pad1` | Int | NotFound | NotFound | NotFound | yes |
+ | `Par_TargetTemperature_Tolerance` | Real | Ok | Ok | stable | yes |
+ | `Program:ES03_EM01_Zone1.__fout92848E133B44AF24` | Real[8] | Ok | Ok | **live** | live |
+ | `Local:3:I.InfoBits` | SInt | Ok | Ok | stable | yes |
+ | `Cfg_RapidHeatup_Tolerancex` | DInt | Ok | Ok | stable | yes |
+ | `Program:ES03_EM01_Zone1.__fout6DC7745628658A9D` | Real[2] | Ok | Ok | **live** | live |
+ | `Local:3:I.RunMode` | Bool | Ok | Ok | stable | yes |
+ | `Cfg_RapidHeatup_Tolerance` | Real | Ok | Ok | stable | yes |
+ | `Program:ES03_EM01_Zone1.__fout7C6F52924950A71D` | Real[2] | Ok | Ok | stable | yes |
+ | `Local:3:I.ConnectionFaulted` | Bool | Ok | Ok | stable | yes |
+ | `test_stop` | Bool | Ok | Ok | stable | yes |
+ | `Program:ES03_EM01_Zone1.__foutCBB7A04F2668DCD0` | Real[2] | Ok | Ok | stable | yes |
+ | `Local:3:I.DiagnosticActive` | Bool | Ok | Ok | stable | yes |
+ | `Sts_ScreenLock` | Bool | Ok | Ok | stable | yes |
+ | `Program:ES03_TemperatureControl._DisableStates` | DInt[32] | Ok | Ok | stable | yes |
+ | `Local:3:I.DiagnosticSequenceCount` | SInt | Ok | Ok | stable | yes |
+ | `xSts_ClearResult` | Bool | Ok | Ok | stable | yes |
+ | `Program:ES03_TemperatureControl._ModeNames` | String[32] | Ok | Ok | stable | yes |
+ | `Local:3:I.Pad1` | Int | NotFound | NotFound | NotFound | yes |
+ | `WS2_CLOSE_Delay_Setpoint` | Int | Ok | Ok | stable | yes |
+ | `Program:ES03_TemperatureControl._ModeTransitions` | DInt[32] | Ok | Ok | stable | yes |
+ | `Local:5:C.CfgSize` | DInt | NotFound | NotFound | NotFound | yes |
+ | `WS2_Force_AlarmDelay_Setpoint` | Int | Ok | Ok | stable | yes |
+ | `Program:ES03_TemperatureControl._SEQ_UP` | DInt[32] | Ok | Ok | stable | yes |
+ | `Local:5:C.CfgIDNum` | DInt | NotFound | NotFound | NotFound | yes |
+ | `WS2_Force_Red_Setpoint` | Real | Ok | Ok | stable | yes |
+ | `C_LimitType_Inside` | DInt | Ok | Ok | stable | yes |
+ | `xxCfg_WaterValve_MinTime` | Real | Ok | Ok | stable | yes |
+ | `WS2_Force_StartDelay_Setpoint` | Int | Ok | Ok | stable | yes |
+ | `WS2_Force_Yellow` | Real | Ok | Ok | stable | yes |
+ | `WS2_F_E10_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
+ | `WW_Green` | Bool | Ok | Ok | stable | yes |
+ | `WW_Down` | Bool | Ok | Ok | stable | yes |
+ | `WS2_F_E1_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
+ | `WS2_F_E2_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
+ | `WS2_F_E3_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
+ | `WS2_F_E4_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
+ | `WS2_F_E5_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
+ | `WS2_F_E6_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
+ | `WS2_F_E7_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
+ | `WS2_F_E8_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
+ | `WS2_F_E9_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
+ | `WS2_HighMold_Red_Setpoint` | Int | Ok | Ok | stable | yes |
+ | `WS2_LowMold_Red_Setpoint` | Int | Ok | Ok | stable | yes |
+ | `WS2_Mold_AlarmDelay_Setpoint` | Int | Ok | Ok | stable | yes |
+ | `WS2_Mold_StartDelay_Setpoint` | Int | Ok | Ok | stable | yes |
+ | `WS2_PH_E10_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
+ | `WS2_PH_E1_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
+ | `WS2_PH_E2_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
+ | `WS2_PH_E3_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
+ | `WS2_PH_E4_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
+ | `WS2_PH_E5_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
+ | `WS2_PH_E6_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
+ | `WS2_PH_E7_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
+ | `WS2_PH_E8_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
+ | `WS2_PH_E9_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
+ | `WS2_POSITION_SP` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLDPLATEN_Z1Z3_SEG1` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLDPLATEN_Z1Z3_SEG10` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLDPLATEN_Z1Z3_SEG2` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLDPLATEN_Z1Z3_SEG3` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLDPLATEN_Z1Z3_SEG4` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLDPLATEN_Z1Z3_SEG5` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLDPLATEN_Z1Z3_SEG6` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLDPLATEN_Z1Z3_SEG7` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLDPLATEN_Z1Z3_SEG8` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLDPLATEN_Z1Z3_SEG9` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLDPLATEN_Z2Z4_SEG1` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLDPLATEN_Z2Z4_SEG10` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLDPLATEN_Z2Z4_SEG2` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLDPLATEN_Z2Z4_SEG3` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLDPLATEN_Z2Z4_SEG4` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLDPLATEN_Z2Z4_SEG5` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLDPLATEN_Z2Z4_SEG6` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLDPLATEN_Z2Z4_SEG7` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLDPLATEN_Z2Z4_SEG8` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLDPLATEN_Z2Z4_SEG9` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLD_MOLD_TEMP_SEG1` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLD_MOLD_TEMP_SEG10` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLD_MOLD_TEMP_SEG2` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLD_MOLD_TEMP_SEG3` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLD_MOLD_TEMP_SEG4` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLD_MOLD_TEMP_SEG5` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLD_MOLD_TEMP_SEG6` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLD_MOLD_TEMP_SEG7` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLD_MOLD_TEMP_SEG8` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLD_MOLD_TEMP_SEG9` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLD_Time_Setpoint_SEG1` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLD_Time_Setpoint_SEG10` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLD_Time_Setpoint_SEG2` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLD_Time_Setpoint_SEG3` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLD_Time_Setpoint_SEG4` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLD_Time_Setpoint_SEG5` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLD_Time_Setpoint_SEG6` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLD_Time_Setpoint_SEG7` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLD_Time_Setpoint_SEG8` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLD_Time_Setpoint_SEG9` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_RAMP_SEG1` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_RAMP_SEG10` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_RAMP_SEG2` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_RAMP_SEG3` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_RAMP_SEG4` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_RAMP_SEG5` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_RAMP_SEG6` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_RAMP_SEG7` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_RAMP_SEG8` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_RAMP_SEG9` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_SP_SEG1` | Real | Ok | Ok | stable | yes |
+ | `WS2_PSI_SP_SEG10` | Real | Ok | Ok | stable | yes |
+ | `WS2_PSI_SP_SEG2` | Real | Ok | Ok | stable | yes |
+ | `WS2_PSI_SP_SEG3` | Real | Ok | Ok | stable | yes |
+ | `WS2_PSI_SP_SEG4` | Real | Ok | Ok | stable | yes |
+ | `WS2_PSI_SP_SEG5` | Real | Ok | Ok | stable | yes |
+ | `WS2_PSI_SP_SEG6` | Real | Ok | Ok | stable | yes |
+ | `WS2_PSI_SP_SEG7` | Real | Ok | Ok | stable | yes |
+ | `WS2_PSI_SP_SEG8` | Real | Ok | Ok | stable | yes |
+ | `WS2_PSI_SP_SEG9` | Real | Ok | Ok | stable | yes |
+ | `WS2_SPEED_SP` | Real | Ok | Ok | stable | yes |
+ | `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG1` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG10` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG2` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG3` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG4` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG5` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG6` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG7` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG8` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG9` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG1` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG10` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG2` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG3` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG4` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG5` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG6` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG7` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG8` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG9` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMP_HOLD_Time_Setpoint_SEG1` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMP_HOLD_Time_Setpoint_SEG10` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMP_HOLD_Time_Setpoint_SEG2` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMP_HOLD_Time_Setpoint_SEG3` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMP_HOLD_Time_Setpoint_SEG4` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMP_HOLD_Time_Setpoint_SEG5` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMP_HOLD_Time_Setpoint_SEG6` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMP_HOLD_Time_Setpoint_SEG7` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMP_HOLD_Time_Setpoint_SEG8` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMP_HOLD_Time_Setpoint_SEG9` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMP_MOLD_HOLD_TEMP_SEG1` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMP_MOLD_HOLD_TEMP_SEG10` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMP_MOLD_HOLD_TEMP_SEG2` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMP_MOLD_HOLD_TEMP_SEG3` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMP_MOLD_HOLD_TEMP_SEG4` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMP_MOLD_HOLD_TEMP_SEG5` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMP_MOLD_HOLD_TEMP_SEG6` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMP_MOLD_HOLD_TEMP_SEG7` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMP_MOLD_HOLD_TEMP_SEG8` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMP_MOLD_HOLD_TEMP_SEG9` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMP_RAMP_SEG1` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMP_RAMP_SEG10` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMP_RAMP_SEG2` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMP_RAMP_SEG3` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMP_RAMP_SEG4` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMP_RAMP_SEG5` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMP_RAMP_SEG6` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMP_RAMP_SEG7` | Int | Ok | Ok | stable | yes |
+
+ Unexpected divergences: 0; expected divergences: 0; live tags (value moved between the two libplctag reads; compared by status, length, type code, shape and every element that held still): 6 of 200.
+ - live: WW_M4ActualTemp (lib 4 byte(s) 0CCDC243 = 389.60193; lib probe 4 byte(s) 7084C243 = 389.03467; native 4 byte(s) 4C47C243 = 388.557)
+ - live: Program:EM01_PowerUnit.__fout9FB1561FA00D73F5 (lib 20 byte(s) 0050C348218986402189A640DF76D9C021898640 = [400000,4.2042394,5.2042394,-6.7957606,4.2042394]; lib probe 20 byte(s) 0050C348E74B8640E74BA64019B4D9C0E74B8640 = [400000,4.1967654,5.1967654,-6.8032346,4.1967654]; native 20 byte(s) 0050C348BB328640BB32A64045CDD9C0BB328640 = [400000,4.1936927,5.1936927,-6.8063073,4.1936927])
+ - live: Program:EM02_Clamp.__fout2F67CD35C2302EE0 (lib 16 byte(s) BFB2BF379A9919BF00D68349CDCCCCBE = [2.285221E-05,-0.6,1080000,-0.4]; lib probe 16 byte(s) 5119313A9A9919BF00D68349CDCCCCBE = [0.00067557866,-0.6,1080000,-0.4]; native 16 byte(s) 1562C2B99A9919BF00D68349CDCCCCBE = [-0.0003707564,-0.6,1080000,-0.4])
+ - live: Program:ES03_EM01_Zone1.__fout25DE96F3D0E2B7BB (lib 16 byte(s) 00E14CC0CDDA75BF000000C08F2D2BC0 = [-3.201233,-0.9603699,-2,-2.6746557]; lib probe 16 byte(s) 009464C09A2589BF000000C0A84932C0 = [-3.5715332,-1.07146,-2,-2.7857456]; native 16 byte(s) 00A03AC034F35FBF000000C0A8B325C0 = [-2.9160156,-0.87480474,-2,-2.5890903])
+ - live: Program:ES03_EM01_Zone1.__fout92848E133B44AF24 (lib 32 byte(s) 40EDA94160F0D043000090C10022A14980256C41C656D143006A4F400000A8C1 = [21.240845,417.87793,-18,1320000,14.759155,418.67792,3.2408447,-21]; lib probe 32 byte(s) 6017AD412089D043000090C10022A14940D1654186EFD04300BB68400000A8C1 = [21.636414,417.0713,-18,1320000,14.363586,417.87128,3.6364136,-21]; native 32 byte(s) C047A841BE97CF43000090C10022A14980706F4124FECF43003E42400000A8C1 = [21.035034,415.1855,-18,1320000,14.964966,415.98547,3.0350342,-21])
+ - live: Program:ES03_EM01_Zone1.__fout6DC7745628658A9D (lib 8 byte(s) 00824E40CD34A53F = [3.2266846,1.2906739]; lib probe 8 byte(s) 008A6440CDD4B63F = [3.5709229,1.4283692]; native 8 byte(s) 000D3940670A943F = [2.8914185,1.1565675])
+
+
+[xUnit.net 00:03:12.61] Finished: ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
+ Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Libplctag_and_native_are_never_connected_at_the_same_time [< 1 ms]
+ Standard Output Messages:
+ TCP connections to the target: baseline 0, before native 0, libplctag drain wait 1 ms.
+
+
+
+Test Run Successful.
+Total tests: 17
+ Passed: 17
+ Total time: 3.2143 Minutes
diff --git a/docs/plans/2026-10-07-native-ab-transport-t21/t21-pass3.txt b/docs/plans/2026-10-07-native-ab-transport-t21/t21-pass3.txt
new file mode 100644
index 00000000..d77aa0f2
--- /dev/null
+++ b/docs/plans/2026-10-07-native-ab-transport-t21/t21-pass3.txt
@@ -0,0 +1,369 @@
+
+Test run for /Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/bin/Debug/net10.0/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.dll (.NETCoreApp,Version=v10.0)
+VSTest version 18.0.1 (arm64)
+
+Starting test execution, please wait...
+A total of 1 test files matched the specified pattern.
+/Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/bin/Debug/net10.0/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.dll
+[xUnit.net 00:00:00.00] xUnit.net VSTest Adapter v3.0.2+dd36e86129 (64-bit .NET 10.0.5)
+[xUnit.net 00:00:00.35] Discovering: ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
+[xUnit.net 00:00:00.49] Discovered: ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
+[xUnit.net 00:00:00.60] Starting: ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
+ Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.Template_parity_fails_when_vacuous_or_undecodable [9 ms]
+ Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.A_failing_endpoint_aborts_the_native_phase_after_exactly_one_connect [2 s]
+ Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.The_default_selection_keeps_arrays_and_udt_members_at_any_cap [7 ms]
+ Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.Volume_knobs_fail_fast_above_the_ceiling_instead_of_clamping [2 ms]
+ Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.A_failing_endpoint_aborts_the_libplctag_phase_before_any_tag_is_read [6 s]
+ Standard Output Messages:
+ libplctag connects before the abort: 1
+
+
+ Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.PollLatencyComparisonTests.Tag_pick_takes_atomic_scalars_only_and_repeats_a_short_set_visibly [13 ms]
+ Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.PollLatencyComparisonTests.Native_poll_p95_is_within_ten_percent_of_libplctag [2 m 56 s]
+ Standard Output Messages:
+ ## Latency — 10.209.70.160:44818 path '1,0' family CompactLogix (compactlogix, connection size 4002), max tags 200, max templates 20, pace 50 ms
+ Tags per pass: 95 (100 distinct); passes per transport: 50; gap between passes: 200 ms.
+ Types: SInt 6, DInt 8, Bool 17, Real 7, Int 57
+ Dropped (libplctag refused at setup, removed from both sets): Local:1:I.Pad1, Local:2:I.Pad1, Local:3:I.Pad1, Local:5:C.CfgSize, Local:5:C.CfgIDNum
+ Native requests spent on setup (one read per tag): 95; native Forward Opens: 1; native Forward Closes sent/acked: 1/1; connections after the native phase: 0.
+ libplctag orphans destroyed so far: 15; rooted (unreleasable): 0.
+
+ | Transport | Passes | p50 ms | p95 ms | min ms | max ms | CIP requests per pass (median) | Tag failures |
+ |---|---|---|---|---|---|---|---|
+ | libplctag | 50 | 101.8 | 197.3 | 54.6 | 219.2 | n/a | 0 |
+ | native | 50 | 99.4 | 105.1 | 76.0 | 139.3 | 2 | 0 |
+
+ Criterion: native p95 105.1 ms ≤ libplctag p95 197.3 ms × 1.10 = 217.1 ms → PASS
+
+ | Pass | libplctag ms | libplctag GC 0/1/2 | native ms | native GC 0/1/2 | native requests | native RTT min/mean/max ms |
+ |---|---|---|---|---|---|---|
+ | 1 | 99.9 | 0/0/0 | 99.6 | 0/0/0 | 2 | 45.0/49.4/53.8 |
+ | 2 | 101.7 | 0/0/0 | 101.3 | 0/0/0 | 2 | 48.1/50.5/53.0 |
+ | 3 | 100.0 | 0/0/0 | 99.6 | 0/0/0 | 2 | 45.7/49.7/53.7 |
+ | 4 | 102.4 | 0/0/0 | 98.9 | 1/0/0 | 2 | 48.2/49.4/50.5 |
+ | 5 | 105.8 | 0/0/0 | 97.1 | 0/0/0 | 2 | 44.8/48.5/52.2 |
+ | 6 | 103.5 | 0/0/0 | 99.8 | 0/0/0 | 2 | 45.3/49.8/54.3 |
+ | 7 | 100.5 | 0/0/0 | 105.1 | 0/0/0 | 2 | 51.2/52.5/53.8 |
+ | 8 | 105.7 | 0/0/0 | 94.4 | 0/0/0 | 2 | 43.3/47.1/50.9 |
+ | 9 | 99.8 | 0/0/0 | 99.1 | 0/0/0 | 2 | 47.0/49.5/51.9 |
+ | 10 | 102.8 | 0/0/0 | 98.1 | 0/0/0 | 2 | 48.4/49.0/49.5 |
+ | 11 | 54.6 | 0/0/0 | 98.7 | 0/0/0 | 2 | 44.0/49.3/54.5 |
+ | 12 | 99.8 | 0/0/0 | 105.2 | 0/0/0 | 2 | 44.6/52.5/60.5 |
+ | 13 | 101.5 | 0/0/0 | 94.9 | 0/0/0 | 2 | 43.7/47.4/51.0 |
+ | 14 | 109.8 | 0/0/0 | 98.9 | 0/0/0 | 2 | 46.9/49.4/51.8 |
+ | 15 | 106.7 | 0/0/0 | 96.3 | 0/0/0 | 2 | 43.2/48.0/52.8 |
+ | 16 | 165.0 | 0/0/0 | 99.4 | 0/0/0 | 2 | 46.6/49.6/52.5 |
+ | 17 | 197.3 | 0/0/0 | 101.3 | 0/0/0 | 2 | 45.7/50.6/55.4 |
+ | 18 | 71.0 | 0/0/0 | 98.8 | 0/0/0 | 2 | 45.3/49.3/53.4 |
+ | 19 | 219.2 | 0/0/0 | 101.0 | 0/0/0 | 2 | 48.0/50.4/52.8 |
+ | 20 | 168.8 | 0/0/0 | 100.5 | 0/0/0 | 2 | 46.2/50.1/54.1 |
+ | 21 | 95.3 | 0/0/0 | 98.3 | 0/0/0 | 2 | 45.7/49.0/52.4 |
+ | 22 | 133.9 | 0/0/0 | 98.9 | 0/0/0 | 2 | 46.4/49.4/52.3 |
+ | 23 | 163.0 | 0/0/0 | 97.2 | 0/0/0 | 2 | 44.9/48.5/52.1 |
+ | 24 | 184.5 | 0/0/0 | 101.4 | 0/0/0 | 2 | 49.1/50.6/52.2 |
+ | 25 | 130.9 | 0/0/0 | 97.2 | 0/0/0 | 2 | 43.7/48.5/53.3 |
+ | 26 | 76.8 | 0/0/0 | 103.2 | 0/0/0 | 2 | 47.3/51.5/55.8 |
+ | 27 | 99.6 | 0/0/0 | 76.0 | 0/0/0 | 1 | 75.8/75.8/75.8 |
+ | 28 | 113.8 | 0/0/0 | 100.3 | 0/0/0 | 2 | 46.4/50.1/53.7 |
+ | 29 | 100.9 | 0/0/0 | 96.0 | 0/0/0 | 2 | 44.2/47.9/51.6 |
+ | 30 | 106.0 | 0/0/0 | 103.0 | 0/0/0 | 2 | 49.9/51.4/53.0 |
+ | 31 | 107.3 | 0/0/0 | 103.7 | 0/0/0 | 2 | 47.4/51.8/56.1 |
+ | 32 | 100.9 | 0/0/0 | 102.4 | 0/0/0 | 2 | 47.4/51.1/54.8 |
+ | 33 | 99.9 | 0/0/0 | 98.6 | 0/0/0 | 2 | 46.0/49.2/52.4 |
+ | 34 | 101.4 | 0/0/0 | 98.7 | 0/0/0 | 2 | 46.7/49.3/51.8 |
+ | 35 | 97.8 | 0/0/0 | 98.9 | 0/0/0 | 2 | 46.3/49.4/52.5 |
+ | 36 | 98.7 | 0/0/0 | 98.5 | 0/0/0 | 2 | 45.2/49.2/53.2 |
+ | 37 | 101.2 | 0/0/0 | 100.1 | 0/0/0 | 2 | 45.2/49.9/54.7 |
+ | 38 | 102.6 | 0/0/0 | 100.0 | 0/0/0 | 2 | 44.0/49.9/55.8 |
+ | 39 | 103.5 | 0/0/0 | 97.7 | 0/0/0 | 2 | 45.0/48.8/52.5 |
+ | 40 | 104.5 | 0/0/0 | 99.6 | 0/0/0 | 2 | 46.0/49.7/53.5 |
+ | 41 | 103.0 | 0/0/0 | 99.4 | 0/0/0 | 2 | 45.4/49.6/53.9 |
+ | 42 | 101.8 | 0/0/0 | 98.7 | 0/0/0 | 2 | 46.0/49.2/52.4 |
+ | 43 | 101.8 | 0/0/0 | 139.3 | 0/0/0 | 2 | 45.6/69.6/93.6 |
+ | 44 | 109.4 | 0/0/0 | 96.6 | 0/0/0 | 2 | 43.2/48.2/53.3 |
+ | 45 | 101.6 | 0/0/0 | 96.6 | 0/0/0 | 2 | 43.2/48.2/53.2 |
+ | 46 | 101.6 | 0/0/0 | 102.8 | 0/0/0 | 2 | 47.9/51.3/54.8 |
+ | 47 | 204.5 | 0/0/0 | 101.0 | 0/0/0 | 2 | 47.3/50.4/53.5 |
+ | 48 | 108.0 | 0/0/0 | 100.0 | 0/0/0 | 2 | 47.4/49.9/52.4 |
+ | 49 | 99.7 | 0/0/0 | 101.3 | 0/0/0 | 2 | 48.3/50.6/52.8 |
+ | 50 | 99.7 | 0/0/0 | 99.4 | 1/1/1 | 2 | 46.0/49.6/53.2 |
+
+
+ Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_native_side_closes_its_one_connection_with_exactly_one_forward_close [< 1 ms]
+ Standard Output Messages:
+ Native Forward Closes sent/acked: 1/1; connections after the native phase: 0.
+
+
+ Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.No_failed_libplctag_tag_is_left_with_a_live_native_callback [< 1 ms]
+ Standard Output Messages:
+ libplctag orphans destroyed so far: 15; rooted (unreleasable): 0.
+
+
+ Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.A_live_value_is_compared_structurally_and_a_stable_one_by_value [3 ms]
+ Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_harness_is_read_only_by_construction [36 ms]
+ Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Browse_listings_decode_to_identical_tag_sets_through_both_enumerators [6 ms]
+ Standard Output Messages:
+ ## Browse — 10.209.70.160:44818 path '1,0' family CompactLogix (compactlogix, connection size 4002), max tags 200, max templates 20, pace 50 ms
+ libplctag: 3162 tag(s); native: 3162 tag(s)
+
+
+ Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_comparison_flags_every_divergence_and_excuses_only_a_native_type_mismatch [< 1 ms]
+ Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Every_template_decodes_to_an_identical_shape_through_both_readers [< 1 ms]
+ Standard Output Messages:
+ ## Templates — 20 id(s); truncated by AB_DIFF_MAX_TEMPLATES: libplctag 74, native 74
+ | Template | libplctag | native | Equal? |
+ |---|---|---|---|
+ | 283 | Recipe (5 members, 1680 B) | Recipe (5 members, 1680 B) | yes |
+ | 295 | UDT_PartStatus (5 members, 4 B) | UDT_PartStatus (5 members, 4 B) | yes |
+ | 344 | Recipe_Clamp (7 members, 420 B) | Recipe_Clamp (7 members, 420 B) | yes |
+ | 491 | ForceSegmentEnableCheck (3 members, 4 B) | ForceSegmentEnableCheck (3 members, 4 B) | yes |
+ | 527 | Recipe_PowerUnit (6 members, 24 B) | Recipe_PowerUnit (6 members, 24 B) | yes |
+ | 622 | UDT_TemperatureProfile_Segment (11 members, 40 B) | UDT_TemperatureProfile_Segment (11 members, 40 B) | yes |
+ | 676 | AB:5000_DO8_Diag:I:0 (14 members, 36 B) | AB:5000_DO8_Diag:I:0 (14 members, 36 B) | yes |
+ | 700 | AB:5000_AI4CJ:I:0 (12 members, 68 B) | AB:5000_AI4CJ:I:0 (12 members, 68 B) | yes |
+ | 706 | raM_UDT_ItfAD_5000Digital_Sts (11 members, 320 B) | raM_UDT_ItfAD_5000Digital_Sts (11 members, 320 B) | yes |
+ | 715 | TwoHand_Control (8 members, 16 B) | TwoHand_Control (8 members, 16 B) | yes |
+ | 752 | STR0016 (2 members, 20 B) | STR0016 (2 members, 20 B) | yes |
+ | 842 | STR0032 (2 members, 36 B) | STR0032 (2 members, 36 B) | yes |
+ | 862 | raM_UDT_ItfAD_5000Analog_Set (3 members, 4 B) | raM_UDT_ItfAD_5000Analog_Set (3 members, 4 B) | yes |
+ | 1129 | raM_Tec_LimitWithModulo (38 members, 120 B) | raM_Tec_LimitWithModulo (38 members, 120 B) | yes |
+ | 1144 | PartStatus (8 members, 12 B) | PartStatus (8 members, 12 B) | yes |
+ | 1166 | AB:5000_DI16:C:0 (18 members, 72 B) | AB:5000_DI16:C:0 (18 members, 72 B) | yes |
+ | 1192 | raM_UDT_ItfAD_5000Digital_Cmd (5 members, 4 B) | raM_UDT_ItfAD_5000Digital_Cmd (5 members, 4 B) | yes |
+ | 1206 | raM_Dvc_5000AI4_VCRT (77 members, 180 B) | raM_Dvc_5000AI4_VCRT (77 members, 180 B) | yes |
+ | 1289 | TimeString (12 members, 376 B) | TimeString (12 members, 376 B) | yes |
+ | 1313 | raM_Dvc_5000AO4 (73 members, 164 B) | raM_Dvc_5000AO4 (73 members, 164 B) | yes |
+
+
+ Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_native_side_makes_exactly_one_forward_open_over_the_whole_run [< 1 ms]
+ Standard Output Messages:
+ Native Forward Opens over the run: 1.
+
+
+ Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Every_selected_tag_reads_identically_through_both_transports [3 ms]
+ Standard Output Messages:
+ ## Reads — 200 tag(s), 10.209.70.160:44818 path '1,0' family CompactLogix (compactlogix, connection size 4002), max tags 200, max templates 20, pace 50 ms
+ Selection by origin: array 20, udt member 20, scalar 160
+ | Tag | Type | libplctag status | native status | libplctag probe | Equal? |
+ |---|---|---|---|---|---|
+ | `Program:Recipes._monitorID` | DInt[255] | Ok | Ok | stable | yes |
+ | `Local:1:I.InfoBits` | SInt | Ok | Ok | stable | yes |
+ | `__CONTAINER` | DInt | Ok | Ok | stable | yes |
+ | `Program:EM01_PowerUnit._VacuumCalibration_mA` | Real[10] | Ok | Ok | stable | yes |
+ | `Local:1:I.RunMode` | Bool | Ok | Ok | stable | yes |
+ | `WW_M4ActualTemp` | Real | Ok | Ok | **live** | live |
+ | `Program:EM01_PowerUnit._VacuumCalibration_PSIA` | Real[10] | Ok | Ok | stable | yes |
+ | `Local:1:I.ConnectionFaulted` | Bool | Ok | Ok | stable | yes |
+ | `WW_P1_Status_Msg` | DInt | Ok | Ok | stable | yes |
+ | `Program:EM01_PowerUnit.__fout9FB1561FA00D73F5` | Real[5] | Ok | Ok | **live** | live |
+ | `Local:1:I.DiagnosticActive` | Bool | Ok | Ok | stable | yes |
+ | `DI_CycleStart_PB` | Bool | Ok | Ok | stable | yes |
+ | `Program:EM01_PowerUnit.__foutA0A97D0B7D734500` | Real[2] | Ok | Ok | stable | yes |
+ | `Local:1:I.DiagnosticSequenceCount` | SInt | Ok | Ok | stable | yes |
+ | `test` | DInt | Ok | Ok | stable | yes |
+ | `Program:EM01_PowerUnit.__fout2A759FA1596561EC` | Real[2] | Ok | Ok | stable | yes |
+ | `Local:1:I.Pad1` | Int | NotFound | NotFound | NotFound | yes |
+ | `UN01_EventsPTR` | DInt | Ok | Ok | stable | yes |
+ | `Program:EM01_PowerUnit._SEQ_EP` | DInt[32] | Ok | Ok | stable | yes |
+ | `Local:2:I.InfoBits` | SInt | Ok | Ok | stable | yes |
+ | `Sts_Vacuum_Rerun` | Bool | Ok | Ok | stable | yes |
+ | `Program:EM01_PowerUnit.__fout02198332D12EF5E3` | Real[3] | Ok | Ok | stable | yes |
+ | `Local:2:I.RunMode` | Bool | Ok | Ok | stable | yes |
+ | `Ons_VacuumRerun` | Bool | Ok | Ok | stable | yes |
+ | `Program:EM02_Clamp._SEQ_EP` | DInt[32] | Ok | Ok | stable | yes |
+ | `Local:2:I.ConnectionFaulted` | Bool | Ok | Ok | stable | yes |
+ | `test2` | DInt | Ok | Ok | stable | yes |
+ | `Program:EM02_Clamp.__fout79B2713C88B35A28` | Real[3] | Ok | Ok | stable | yes |
+ | `Local:2:I.DiagnosticActive` | Bool | Ok | Ok | stable | yes |
+ | `_Val_PressurePVx` | Real | Ok | Ok | stable | yes |
+ | `Program:EM02_Clamp.__fout2F67CD35C2302EE0` | Real[4] | Ok | Ok | **live** | live |
+ | `Local:2:I.DiagnosticSequenceCount` | SInt | Ok | Ok | stable | yes |
+ | `C_LimitType_Outside` | DInt | Ok | Ok | stable | yes |
+ | `Program:ES03_EM01_Zone1.__fout25DE96F3D0E2B7BB` | Real[4] | Ok | Ok | **live** | live |
+ | `Local:2:I.Pad1` | Int | NotFound | NotFound | NotFound | yes |
+ | `Par_TargetTemperature_Tolerance` | Real | Ok | Ok | stable | yes |
+ | `Program:ES03_EM01_Zone1.__fout92848E133B44AF24` | Real[8] | Ok | Ok | **live** | live |
+ | `Local:3:I.InfoBits` | SInt | Ok | Ok | stable | yes |
+ | `Cfg_RapidHeatup_Tolerancex` | DInt | Ok | Ok | stable | yes |
+ | `Program:ES03_EM01_Zone1.__fout6DC7745628658A9D` | Real[2] | Ok | Ok | **live** | live |
+ | `Local:3:I.RunMode` | Bool | Ok | Ok | stable | yes |
+ | `Cfg_RapidHeatup_Tolerance` | Real | Ok | Ok | stable | yes |
+ | `Program:ES03_EM01_Zone1.__fout7C6F52924950A71D` | Real[2] | Ok | Ok | stable | yes |
+ | `Local:3:I.ConnectionFaulted` | Bool | Ok | Ok | stable | yes |
+ | `test_stop` | Bool | Ok | Ok | stable | yes |
+ | `Program:ES03_EM01_Zone1.__foutCBB7A04F2668DCD0` | Real[2] | Ok | Ok | stable | yes |
+ | `Local:3:I.DiagnosticActive` | Bool | Ok | Ok | stable | yes |
+ | `Sts_ScreenLock` | Bool | Ok | Ok | stable | yes |
+ | `Program:ES03_TemperatureControl._DisableStates` | DInt[32] | Ok | Ok | stable | yes |
+ | `Local:3:I.DiagnosticSequenceCount` | SInt | Ok | Ok | stable | yes |
+ | `xSts_ClearResult` | Bool | Ok | Ok | stable | yes |
+ | `Program:ES03_TemperatureControl._ModeNames` | String[32] | Ok | Ok | stable | yes |
+ | `Local:3:I.Pad1` | Int | NotFound | NotFound | NotFound | yes |
+ | `WS2_CLOSE_Delay_Setpoint` | Int | Ok | Ok | stable | yes |
+ | `Program:ES03_TemperatureControl._ModeTransitions` | DInt[32] | Ok | Ok | stable | yes |
+ | `Local:5:C.CfgSize` | DInt | NotFound | NotFound | NotFound | yes |
+ | `WS2_Force_AlarmDelay_Setpoint` | Int | Ok | Ok | stable | yes |
+ | `Program:ES03_TemperatureControl._SEQ_UP` | DInt[32] | Ok | Ok | stable | yes |
+ | `Local:5:C.CfgIDNum` | DInt | NotFound | NotFound | NotFound | yes |
+ | `WS2_Force_Red_Setpoint` | Real | Ok | Ok | stable | yes |
+ | `C_LimitType_Inside` | DInt | Ok | Ok | stable | yes |
+ | `xxCfg_WaterValve_MinTime` | Real | Ok | Ok | stable | yes |
+ | `WS2_Force_StartDelay_Setpoint` | Int | Ok | Ok | stable | yes |
+ | `WS2_Force_Yellow` | Real | Ok | Ok | stable | yes |
+ | `WS2_F_E10_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
+ | `WW_Green` | Bool | Ok | Ok | stable | yes |
+ | `WW_Down` | Bool | Ok | Ok | stable | yes |
+ | `WS2_F_E1_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
+ | `WS2_F_E2_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
+ | `WS2_F_E3_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
+ | `WS2_F_E4_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
+ | `WS2_F_E5_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
+ | `WS2_F_E6_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
+ | `WS2_F_E7_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
+ | `WS2_F_E8_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
+ | `WS2_F_E9_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
+ | `WS2_HighMold_Red_Setpoint` | Int | Ok | Ok | stable | yes |
+ | `WS2_LowMold_Red_Setpoint` | Int | Ok | Ok | stable | yes |
+ | `WS2_Mold_AlarmDelay_Setpoint` | Int | Ok | Ok | stable | yes |
+ | `WS2_Mold_StartDelay_Setpoint` | Int | Ok | Ok | stable | yes |
+ | `WS2_PH_E10_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
+ | `WS2_PH_E1_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
+ | `WS2_PH_E2_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
+ | `WS2_PH_E3_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
+ | `WS2_PH_E4_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
+ | `WS2_PH_E5_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
+ | `WS2_PH_E6_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
+ | `WS2_PH_E7_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
+ | `WS2_PH_E8_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
+ | `WS2_PH_E9_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
+ | `WS2_POSITION_SP` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLDPLATEN_Z1Z3_SEG1` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLDPLATEN_Z1Z3_SEG10` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLDPLATEN_Z1Z3_SEG2` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLDPLATEN_Z1Z3_SEG3` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLDPLATEN_Z1Z3_SEG4` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLDPLATEN_Z1Z3_SEG5` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLDPLATEN_Z1Z3_SEG6` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLDPLATEN_Z1Z3_SEG7` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLDPLATEN_Z1Z3_SEG8` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLDPLATEN_Z1Z3_SEG9` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLDPLATEN_Z2Z4_SEG1` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLDPLATEN_Z2Z4_SEG10` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLDPLATEN_Z2Z4_SEG2` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLDPLATEN_Z2Z4_SEG3` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLDPLATEN_Z2Z4_SEG4` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLDPLATEN_Z2Z4_SEG5` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLDPLATEN_Z2Z4_SEG6` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLDPLATEN_Z2Z4_SEG7` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLDPLATEN_Z2Z4_SEG8` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLDPLATEN_Z2Z4_SEG9` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLD_MOLD_TEMP_SEG1` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLD_MOLD_TEMP_SEG10` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLD_MOLD_TEMP_SEG2` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLD_MOLD_TEMP_SEG3` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLD_MOLD_TEMP_SEG4` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLD_MOLD_TEMP_SEG5` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLD_MOLD_TEMP_SEG6` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLD_MOLD_TEMP_SEG7` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLD_MOLD_TEMP_SEG8` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLD_MOLD_TEMP_SEG9` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLD_Time_Setpoint_SEG1` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLD_Time_Setpoint_SEG10` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLD_Time_Setpoint_SEG2` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLD_Time_Setpoint_SEG3` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLD_Time_Setpoint_SEG4` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLD_Time_Setpoint_SEG5` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLD_Time_Setpoint_SEG6` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLD_Time_Setpoint_SEG7` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLD_Time_Setpoint_SEG8` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_HOLD_Time_Setpoint_SEG9` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_RAMP_SEG1` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_RAMP_SEG10` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_RAMP_SEG2` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_RAMP_SEG3` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_RAMP_SEG4` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_RAMP_SEG5` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_RAMP_SEG6` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_RAMP_SEG7` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_RAMP_SEG8` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_RAMP_SEG9` | Int | Ok | Ok | stable | yes |
+ | `WS2_PSI_SP_SEG1` | Real | Ok | Ok | stable | yes |
+ | `WS2_PSI_SP_SEG10` | Real | Ok | Ok | stable | yes |
+ | `WS2_PSI_SP_SEG2` | Real | Ok | Ok | stable | yes |
+ | `WS2_PSI_SP_SEG3` | Real | Ok | Ok | stable | yes |
+ | `WS2_PSI_SP_SEG4` | Real | Ok | Ok | stable | yes |
+ | `WS2_PSI_SP_SEG5` | Real | Ok | Ok | stable | yes |
+ | `WS2_PSI_SP_SEG6` | Real | Ok | Ok | stable | yes |
+ | `WS2_PSI_SP_SEG7` | Real | Ok | Ok | stable | yes |
+ | `WS2_PSI_SP_SEG8` | Real | Ok | Ok | stable | yes |
+ | `WS2_PSI_SP_SEG9` | Real | Ok | Ok | stable | yes |
+ | `WS2_SPEED_SP` | Real | Ok | Ok | stable | yes |
+ | `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG1` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG10` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG2` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG3` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG4` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG5` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG6` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG7` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG8` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG9` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG1` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG10` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG2` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG3` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG4` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG5` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG6` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG7` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG8` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG9` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMP_HOLD_Time_Setpoint_SEG1` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMP_HOLD_Time_Setpoint_SEG10` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMP_HOLD_Time_Setpoint_SEG2` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMP_HOLD_Time_Setpoint_SEG3` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMP_HOLD_Time_Setpoint_SEG4` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMP_HOLD_Time_Setpoint_SEG5` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMP_HOLD_Time_Setpoint_SEG6` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMP_HOLD_Time_Setpoint_SEG7` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMP_HOLD_Time_Setpoint_SEG8` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMP_HOLD_Time_Setpoint_SEG9` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMP_MOLD_HOLD_TEMP_SEG1` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMP_MOLD_HOLD_TEMP_SEG10` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMP_MOLD_HOLD_TEMP_SEG2` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMP_MOLD_HOLD_TEMP_SEG3` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMP_MOLD_HOLD_TEMP_SEG4` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMP_MOLD_HOLD_TEMP_SEG5` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMP_MOLD_HOLD_TEMP_SEG6` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMP_MOLD_HOLD_TEMP_SEG7` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMP_MOLD_HOLD_TEMP_SEG8` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMP_MOLD_HOLD_TEMP_SEG9` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMP_RAMP_SEG1` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMP_RAMP_SEG10` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMP_RAMP_SEG2` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMP_RAMP_SEG3` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMP_RAMP_SEG4` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMP_RAMP_SEG5` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMP_RAMP_SEG6` | Int | Ok | Ok | stable | yes |
+ | `WS2_TEMP_RAMP_SEG7` | Int | Ok | Ok | stable | yes |
+
+ Unexpected divergences: 0; expected divergences: 0; live tags (value moved between the two libplctag reads; compared by status, length, type code, shape and every element that held still): 6 of 200.
+ - live: WW_M4ActualTemp (lib 4 byte(s) 20526943 = 233.3208; lib probe 4 byte(s) 908D6843 = 232.55298; native 4 byte(s) 501F6743 = 231.12231)
+ - live: Program:EM01_PowerUnit.__fout9FB1561FA00D73F5 (lib 20 byte(s) 0050C3487C144C403E0A8640C2F5F9C07C144C40 = [400000,3.1887503,4.1887503,-7.8112497,3.1887503]; lib probe 20 byte(s) 0050C3481C244A400E128540F2EDFAC01C244A40 = [400000,3.158454,4.158454,-7.841546,3.158454]; native 20 byte(s) 0050C34831AA454018D58240E82AFDC031AA4540 = [400000,3.0885127,4.0885124,-7.9114876,3.0885127])
+ - live: Program:EM02_Clamp.__fout2F67CD35C2302EE0 (lib 16 byte(s) 96D41EB99A9919BF00D68349CDCCCCBE = [-0.00015147249,-0.6,1080000,-0.4]; lib probe 16 byte(s) A830FE389A9919BF00D68349CDCCCCBE = [0.00012120727,-0.6,1080000,-0.4]; native 16 byte(s) D34D8FB99A9919BF00D68349CDCCCCBE = [-0.0002733307,-0.6,1080000,-0.4])
+ - live: Program:ES03_EM01_Zone1.__fout25DE96F3D0E2B7BB (lib 16 byte(s) 11E416C300000000000000C000000000 = [-150.89088,0,-2,0]; lib probe 16 byte(s) F1E910C300000000000000C000000000 = [-144.91383,0,-2,0]; native 16 byte(s) 194008C300000000000000C000000000 = [-136.25038,0,-2,0])
+ - live: Program:ES03_EM01_Zone1.__fout92848E133B44AF24 (lib 32 byte(s) 91D52843C4085243000090C10022A14991D504C391D5524391D516430000A8C1 = [168.83424,210.03424,-18,1320000,-132.83424,210.83424,150.83424,-21]; lib probe 32 byte(s) CDD7224398074C43000090C10022A1499AAFFDC265D44C43CDD710430000A8C1 = [162.84297,204.02966,-18,1320000,-126.84297,204.82967,144.84297,-21]; native 32 byte(s) 2D311A4388634343000090C10022A1495A62ECC2553044432D3108430000A8C1 = [154.1921,195.3888,-18,1320000,-118.19209,196.1888,136.1921,-21])
+ - live: Program:ES03_EM01_Zone1.__fout6DC7745628658A9D (lib 8 byte(s) 4DC71643E23E7142 = [150.77852,60.31141]; lib probe 8 byte(s) F5BA104388916742 = [144.7303,57.89212]; native 8 byte(s) 650B0843D5AB5942 = [136.04451,54.417805])
+
+
+[xUnit.net 00:03:06.03] Finished: ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
+ Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Libplctag_and_native_are_never_connected_at_the_same_time [< 1 ms]
+ Standard Output Messages:
+ TCP connections to the target: baseline 0, before native 0, libplctag drain wait 1 ms.
+
+
+
+Test Run Successful.
+Total tests: 17
+ Passed: 17
+ Total time: 3.1046 Minutes
diff --git a/docs/plans/2026-10-07-native-ab-transport.md b/docs/plans/2026-10-07-native-ab-transport.md
new file mode 100644
index 00000000..acf1d7bd
--- /dev/null
+++ b/docs/plans/2026-10-07-native-ab-transport.md
@@ -0,0 +1,2760 @@
+# Native AB Transport (replace libplctag) Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development (recommended) or superpowers-extended-cc:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Put a fully managed EtherNet/IP + CIP + PCCC client under the AbCip and AbLegacy drivers, selectable per driver against the existing libplctag path, and prove it with the integration suites, a differential harness, and a read-only real-press gate.
+
+**Architecture:** Add a new pure-protocol project, `ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport` (encapsulation, CIP paths and services, connection manager, request scheduler with Multiple Service Packet coalescing, PCCC), referenced by both drivers. Each driver gains `Native*` adapters behind its existing runtime/enumerator/template-reader interfaces, plus a `Transport` option (`Libplctag` | `Native`, a string enum) that picks the factory set. libplctag is confined to the `Libplctag*.cs` adapters and translates its `Status` into a driver-owned `AbStatus`.
+
+**Tech Stack:** .NET 10, C#, xUnit v3 + Shouldly, `System.Net.Sockets`, `System.Buffers.Binary.BinaryPrimitives`, the `ab_server` Docker fixtures on `10.100.0.35`, Blazor (AdminUI form).
+
+**Spec:** `docs/plans/2026-10-07-native-ab-transport-design.md` (commit `45b6062d`). Executors read both documents. Where this plan and the spec disagree, the deviations listed under Global Constraints win.
+
+## Global Constraints
+
+- **Wire format:** everything is little-endian. Use `BinaryPrimitives` and the `CipWriter`/`CipReader` helpers from Task 1. No `BitConverter`, because it is endianness-dependent.
+- **The decoders do not change.** `CipSymbolObjectDecoder` and `CipTemplateObjectDecoder` stay byte-for-byte as they are. The native enumerator and template reader must emit the exact buffer layouts those decoders document (spec D8):
+ - The symbol listing entry is `u32 instanceId, u16 symbolType, u16 elementLength, u32×3 dims, u16 nameLen, name` with **no padding**.
+ - The template buffer is a 14-byte header `u16 id, u32 defSizeWords, u32 instanceSize, u16 memberCount, u16 handle`, followed by the raw Read Template bytes.
+- **`AbStatus` is the only status vocabulary above the adapters.** After Task 2/3 no file outside `Libplctag*.cs` may contain `using libplctag`. Task 11 enforces this with a source-scan guard.
+- **`IAbCipTagRuntime.GetStatus()` / `IAbLegacyTagRuntime.GetStatus()` return `(int)AbStatus`** under both transports. A zero value still means success, so every existing `status != 0` check stays correct.
+- **The `Transport` option is serialized as a string** (`"Native"` / `"Libplctag"`). That means `JsonStringEnumConverter` in the AdminUI form, and the existing string DTO + `ParseEnum` in the factory extensions. An absent value means `Libplctag` until Task 22 flips it.
+- **Press safety, for every native code path:**
+ - At most **one Forward Open in flight per device**, and a **minimum 1 s** between Forward Open attempts per device.
+ - Exactly one CIP connection per `(host, port, cipPath, plcAttribute)`.
+ - Forward Close on the last lease release.
+ - **Real presses are touched only in Task 21, read-only, one press at a time.**
+- **One request in flight per connection.** `EncapsulationClient` serializes requests and `RequestScheduler` has `MaxInFlight = 1` hard-wired. *Deviation from spec §3.1, which allowed a configurable `MaxInFlight`; per YAGNI there is no knob in v1.*
+- **One `RequestScheduler` per device connection, never per tag.** It lives in the `DeviceSessionPool` entry beside the `CipConnection` and is exposed as `ConnectionLease.Scheduler`. Per-tag schedulers would make MSP coalescing impossible and race the connected sequence counter. That is review finding B1.
+- **The connected sequence count is assigned inside `EncapsulationClient`'s single-flight gate**, never by a caller (B1).
+- **Caller cancellation never faults the shared connection.** A poll subscription's CTS is cancelled on every OPC UA unsubscribe (`PollGroupEngine.cs:160`). Faulting the socket on that would drop every tag on the press and force a fresh Forward Open. Instead:
+ - Frames are written uncancellably (they are small and bounded by the socket).
+ - Cancellation during the reply wait abandons only the waiter. The stale reply is skipped by context or sequence on the next exchange.
+ - Only a **timeout**, an I/O error or a malformed frame faults the connection. That is review finding B2.
+- **Routing is explicit:** `ConnectionOptions.Routing ∈ { Connected, UnconnectedSend, Direct }`.
+ - `Connected` = Forward Open + SendUnitData.
+ - `UnconnectedSend` = SendRRData wrapping 0x52.
+ - `Direct` = SendRRData carrying the CIP request bare, as libplctag does for SLC/MicroLogix/PLC-5 Execute PCCC even with a `1,0` path.
+ - The **captures decide** each family's mode (B3).
+- **Model tiers:** `mechanical` ⇒ **haiku**, `standard` ⇒ **sonnet**, `frontier` ⇒ **opus**. Each task's metadata carries `model`.
+- **Parallel execution:** tasks in the same wave touch disjoint files and may run concurrently, **each in its own git worktree** off `feat/native-ab-transport`. They merge back serially in task-id order. Only Task 1 edits `ZB.MOM.WW.OtOpcUa.slnx`. No task edits `Directory.Packages.props`.
+- **Build gate for every task:** `dotnet build ZB.MOM.WW.OtOpcUa.slnx` with 0 warnings (`TreatWarningsAsErrors` is on for driver projects), and the task's test command green.
+- **Naming:** test classes follow `Tests`, test methods use sentence-style names (`A_refused_forward_open_is_not_retried_inside_the_minimum_interval`), and test projects use xUnit v3 + Shouldly, matching `ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.csproj`.
+
+**User decisions (already made):**
+- "all of the above". All four motivations apply (native-binary pain, correctness/control, performance, policy).
+- "1, side-by-side behind a switch". There is a per-driver `Transport` switch and libplctag stays as the fallback.
+- "use recommended choices for all choices". The proof standard flips per evidence tier: AbCip reads, browse and templates must pass the real presses read-only; AbCip writes and all of AbLegacy are simulator-proven and recorded as a known gap. libplctag is deleted only after a bench controller exists. The stack lives in a new shared `Driver.Ab.Transport` project, with one connection per device, transparent MSP coalescing, a driver-owned `AbStatus`, reuse of the libplctag-shaped decoder buffers, and differential testing as the primary evidence.
+- "generate implementation plan to use subagents with appropriate models in parallel where safe. use fable subagent to review plans when done".
+
+**Out of plan (blocked, not a task):** spec Phase 6, deleting libplctag. It is blocked until a bench CompactLogix or Micro800 (for writes) and an SLC or MicroLogix (for PCCC) are reachable. Record it in `docs/drivers/AbCip.md` "Known gaps" in Task 20.
+
+---
+
+## Execution waves
+
+| Wave | Tasks (parallel within a wave) | Notes |
+|---|---|---|
+| 0 | T1 | Creates the project, the slnx entries, the shared contracts and the empty gate-results doc. |
+| 1 | T2, T3, T4, T6 | T2/T3 are driver seams (disjoint projects); T4 captures golden frames from the docker host; T6 is the path encoders. |
+| 2 | T5, T7, T8, T11 | T5 needs T4 (capture parity); T7 needs T4+T6; T8 needs T4; T11 needs T2+T3. All disjoint folders. |
+| 3 | T10 | The scheduler (needs T7). |
+| 4 | T9 | Connection + pool (needs T5, T7, T10): the pool entry owns the scheduler. |
+| 5 | T12, T13, T14 | T12/T13 are disjoint AbCip files (need T2, T9); T14 is AbLegacy (needs T3, T8, T9). |
+| 6 | T15, T16 | AbCip switch (needs T12, T13) and AbLegacy switch (needs T14), in disjoint projects. |
+| 7 | T17 | Integration suites on both transports (needs T15, T16). It appends to the gate-results doc. |
+| 8 | T18, T19 | Differential harness (needs T17); AdminUI + `/run` (needs T17, since both append to the gate-results doc, so they are serialized behind T17; T18 and T19 append to **different headed sections** created by T1, which merge cleanly). |
+| 9 | T20 | Docs (needs T19's evidence and the T8/T14 verification status). |
+| 10 | T21 → T22 | Real-press gate (needs T18), then the default flip (needs T21, T17, T19, T20). Serial. |
+
+Dependency list (authoritative; mirrors `.tasks.json` `blockedBy`):
+T2,T3,T4,T6 ← T1 · T5 ← T1,T4 · T7 ← T4,T6 · T8 ← T4 · T11 ← T2,T3 · T10 ← T7 · T9 ← T5,T7,T10 ·
+T12,T13 ← T2,T9 · T14 ← T3,T8,T9 · T15 ← T12,T13 · T16 ← T14 · T17 ← T15,T16 ·
+T18 ← T17 · T19 ← T17 · T20 ← T19 · T21 ← T18 · T22 ← T21,T17,T19,T20
+
+**Model per task:**
+
+| Tier | Model | Tasks |
+|---|---|---|
+| frontier | opus | T4, T9, T10, T12, T18, T21 |
+| standard | sonnet | T1, T2, T3, T5, T6, T7, T8, T13, T14, T15, T16, T17, T19, T20 |
+| mechanical | haiku | T11, T22 |
+
+---
+
+### Task 1: Ab.Transport project skeleton + shared contracts
+
+**Goal:** Create `ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport` and its test project, wired into the solution. It carries the shared vocabulary that every later task codes against: `AbStatus`, `AbTransportException`, `AbStatusCodes`, `CipWriter`/`CipReader`, `CipReply`, and `ICipChannel`.
+
+**Files:**
+- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.csproj`
+- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/AbStatus.cs`
+- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/AbTransportException.cs`
+- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/AbStatusCodes.cs`
+- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Wire/CipWriter.cs`
+- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Wire/CipReader.cs`
+- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipReply.cs`
+- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ICipChannel.cs`
+- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.csproj`
+- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/AbStatusCodesTests.cs`
+- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Wire/CipReplyTests.cs`
+- Create: `docs/plans/2026-10-07-native-ab-transport-gate-results.md` (skeleton with five empty headed sections, `## ab_server suites (T17)`, `## Differential vs ab_server (T18)`, `## AdminUI /run (T19)`, `## Real press (T21)` and `## Latency (T21)`, so later tasks append to separate sections and merge cleanly)
+- Modify: `ZB.MOM.WW.OtOpcUa.slnx` (add both projects next to the AbCip entries, lines 41-44 and 112-115)
+- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj` and `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj` (add a `ProjectReference` to Ab.Transport)
+
+**Acceptance Criteria:**
+- [ ] `dotnet build ZB.MOM.WW.OtOpcUa.slnx` succeeds with 0 warnings and both new projects appear in the slnx.
+- [ ] `AbStatus.Ok == 0`, and every error member is negative.
+- [ ] `AbStatusCodes.FromCip` maps CIP general status as follows. It is aligned with the existing `AbCipStatusMapper.MapCipGeneralStatus`, so that existing mapping does not regress (review finding 13).
+ - `0x00`/`0x0B` → `Ok`
+ - `0x04`/`0x05`/`0x16` → `NotFound`
+ - `0x06` → `Partial`
+ - `0x08` → `Unsupported`
+ - `0x0A` → `OutOfBounds`
+ - `0x0C`/`0x10` → `DeviceStateConflict`
+ - `0x0E`/`0x0F` → `NotAllowed`
+ - `0x13` → `TooSmall`
+ - `0x15` → `TooLarge`
+ - `0xFF` + `0x2105` → `OutOfBounds`
+ - `0xFF` + `0x2107` → `TypeMismatch`
+ - everything else, including `0x1E`, → `BadReply`
+ `0x1E` (embedded service error) is only meaningful on an MSP envelope, and the scheduler (T10) handles it before calling `FromCip`.
+- [ ] `AbStatusCodes.FromPccc` maps STS `0x00→Ok`, `0x10→Unsupported`, `0x20→NotFound`, `0x30→NotAllowed`, `0x40/0x50→DeviceStateConflict`, and `0xF0` maps via EXT STS (`0x06/0x07→NotFound` address problems, else `BadReply`).
+- [ ] `CipReply.Parse` round-trips a reply with 0, 1 and 2 extended-status words.
+- [ ] The driver projects reference Ab.Transport, and Ab.Transport references no OtOpcUa project.
+
+**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests` → all pass.
+
+**Steps:**
+
+- [ ] **Step 1: Create the projects.** The library csproj mirrors the AbCip csproj property group (net10.0, nullable, `TreatWarningsAsErrors`, `GenerateDocumentationFile`, `NoWarn CS1591`). Its only package reference is `Microsoft.Extensions.Logging.Abstractions`. Check it is already pinned in `Directory.Packages.props` with `grep -n Logging.Abstractions Directory.Packages.props`. If it is absent, do **not** add it; the logger is optional and can be dropped from this project.
+
+```xml
+
+
+ net10.0
+ enable
+ enable
+ latest
+ true
+ true
+ $(NoWarn);CS1591
+ ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport
+ ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport
+
+
+
+
+
+
+
+
+```
+
+The test csproj is a copy of `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.csproj` with the RootNamespace and ProjectReference retargeted. Drop the `OTOPCUA0001` NoWarn.
+
+- [ ] **Step 2: Write the failing tests.**
+
+```csharp
+// tests/.../AbStatusCodesTests.cs
+using Shouldly;
+using Xunit;
+namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests;
+
+public sealed class AbStatusCodesTests
+{
+ [Theory]
+ [InlineData((byte)0x00, AbStatus.Ok)]
+ [InlineData((byte)0x04, AbStatus.NotFound)]
+ [InlineData((byte)0x05, AbStatus.NotFound)]
+ [InlineData((byte)0x16, AbStatus.NotFound)]
+ [InlineData((byte)0x06, AbStatus.Partial)]
+ [InlineData((byte)0x08, AbStatus.Unsupported)]
+ [InlineData((byte)0x0F, AbStatus.NotAllowed)]
+ [InlineData((byte)0x10, AbStatus.DeviceStateConflict)]
+ [InlineData((byte)0x13, AbStatus.TooSmall)]
+ [InlineData((byte)0x15, AbStatus.TooLarge)]
+ [InlineData((byte)0x0B, AbStatus.Ok)]
+ [InlineData((byte)0x0A, AbStatus.OutOfBounds)]
+ [InlineData((byte)0x0C, AbStatus.DeviceStateConflict)]
+ [InlineData((byte)0x0E, AbStatus.NotAllowed)]
+ [InlineData((byte)0x1E, AbStatus.BadReply)]
+ [InlineData((byte)0x01, AbStatus.BadReply)]
+ public void Cip_general_status_maps(byte general, AbStatus expected) =>
+ AbStatusCodes.FromCip(general, ReadOnlySpan.Empty).ShouldBe(expected);
+
+ [Theory]
+ [InlineData((ushort)0x2105, AbStatus.OutOfBounds)]
+ [InlineData((ushort)0x2107, AbStatus.TypeMismatch)]
+ [InlineData((ushort)0x9999, AbStatus.BadReply)]
+ public void General_status_FF_maps_by_extended_status(ushort ext, AbStatus expected) =>
+ AbStatusCodes.FromCip(0xFF, [ext]).ShouldBe(expected);
+
+ [Theory]
+ [InlineData((byte)0x00, null, AbStatus.Ok)]
+ [InlineData((byte)0x10, null, AbStatus.Unsupported)]
+ [InlineData((byte)0x20, null, AbStatus.NotFound)]
+ [InlineData((byte)0x30, null, AbStatus.NotAllowed)]
+ [InlineData((byte)0x40, null, AbStatus.DeviceStateConflict)]
+ [InlineData((byte)0xF0, (byte)0x06, AbStatus.NotFound)]
+ [InlineData((byte)0xF0, (byte)0x07, AbStatus.NotFound)]
+ [InlineData((byte)0xF0, (byte)0x0A, AbStatus.BadReply)]
+ public void Pccc_status_maps(byte sts, byte? ext, AbStatus expected) =>
+ AbStatusCodes.FromPccc(sts, ext).ShouldBe(expected);
+
+ [Fact]
+ public void Ok_is_zero_and_every_error_is_negative()
+ {
+ ((int)AbStatus.Ok).ShouldBe(0);
+ foreach (var s in Enum.GetValues().Where(s => s is not AbStatus.Ok and not AbStatus.Pending))
+ ((int)s).ShouldBeLessThan(0);
+ }
+}
+```
+
+```csharp
+// tests/.../Wire/CipReplyTests.cs
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
+namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Wire;
+
+public sealed class CipReplyTests
+{
+ [Fact]
+ public void Parses_success_reply_with_data()
+ {
+ // 0xCC = reply to Read Tag 0x4C; reserved; status 0; ext size 0; data C4 00 2A 00 00 00
+ var r = CipReply.Parse(new byte[] { 0xCC, 0x00, 0x00, 0x00, 0xC4, 0x00, 0x2A, 0x00, 0x00, 0x00 });
+ r.Service.ShouldBe((byte)0x4C);
+ r.GeneralStatus.ShouldBe((byte)0);
+ r.ExtendedStatus.Length.ShouldBe(0);
+ r.Data.ToArray().ShouldBe(new byte[] { 0xC4, 0x00, 0x2A, 0x00, 0x00, 0x00 });
+ }
+
+ [Fact]
+ public void Parses_error_reply_with_two_extended_words()
+ {
+ var r = CipReply.Parse(new byte[] { 0xCC, 0x00, 0xFF, 0x02, 0x05, 0x21, 0x00, 0x00 });
+ r.GeneralStatus.ShouldBe((byte)0xFF);
+ r.ExtendedStatus.ToArray().ShouldBe(new ushort[] { 0x2105, 0x0000 });
+ r.Data.Length.ShouldBe(0);
+ r.Status.ShouldBe(AbStatus.OutOfBounds);
+ }
+
+ [Fact]
+ public void Truncated_reply_throws_BadReply()
+ {
+ var ex = Should.Throw(() => CipReply.Parse(new byte[] { 0xCC, 0x00 }));
+ ex.Status.ShouldBe(AbStatus.BadReply);
+ }
+}
+```
+
+- [ ] **Step 3: Run the tests and confirm they fail.** Run `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests`. Expected: build errors (types are not defined yet).
+
+- [ ] **Step 4: Implement.**
+
+```csharp
+// AbStatus.cs
+namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
+
+/// Transport-neutral status shared by the libplctag and native AB transports.
+/// Zero is success; every error is negative so existing status != 0 checks stay correct.
+public enum AbStatus
+{
+ Ok = 0,
+ Pending = 1,
+ Timeout = -1,
+ NotFound = -2,
+ NotAllowed = -3,
+ OutOfBounds = -4,
+ TooLarge = -5,
+ TooSmall = -6,
+ Unsupported = -7,
+ BadConnection = -8,
+ BadReply = -9,
+ Partial = -10,
+ Abort = -11,
+ TypeMismatch = -12,
+ DeviceStateConflict = -13,
+}
+```
+
+```csharp
+// AbTransportException.cs
+namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
+
+/// Thrown by both AB transports. drives OPC UA status mapping and the
+/// driver's "controller answered, don't throttle the device" classification (05/STAB-8).
+public sealed class AbTransportException(AbStatus status, string message, Exception? inner = null)
+ : Exception(message, inner)
+{
+ public AbStatus Status { get; } = status;
+}
+```
+
+```csharp
+// AbStatusCodes.cs
+namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
+
+public static class AbStatusCodes
+{
+ public static AbStatus FromCip(byte general, ReadOnlySpan extended) => general switch
+ {
+ 0x00 or 0x0B => AbStatus.Ok,
+ 0x04 or 0x05 or 0x16 => AbStatus.NotFound,
+ 0x06 => AbStatus.Partial,
+ 0x08 => AbStatus.Unsupported,
+ 0x0A => AbStatus.OutOfBounds,
+ 0x0C or 0x10 => AbStatus.DeviceStateConflict,
+ 0x0E or 0x0F => AbStatus.NotAllowed,
+ 0x13 => AbStatus.TooSmall,
+ 0x15 => AbStatus.TooLarge,
+ 0xFF when extended.Length > 0 && extended[0] == 0x2105 => AbStatus.OutOfBounds,
+ 0xFF when extended.Length > 0 && extended[0] == 0x2107 => AbStatus.TypeMismatch,
+ _ => AbStatus.BadReply,
+ };
+
+ public static AbStatus FromPccc(byte sts, byte? extSts) => sts switch
+ {
+ 0x00 => AbStatus.Ok,
+ 0x10 => AbStatus.Unsupported,
+ 0x20 => AbStatus.NotFound,
+ 0x30 => AbStatus.NotAllowed,
+ 0x40 or 0x50 => AbStatus.DeviceStateConflict,
+ 0xF0 when extSts is 0x06 or 0x07 => AbStatus.NotFound,
+ _ => AbStatus.BadReply,
+ };
+}
+```
+
+```csharp
+// Wire/CipWriter.cs
+using System.Buffers.Binary;
+namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
+
+/// Growable little-endian writer for CIP / EIP frames.
+public sealed class CipWriter
+{
+ private byte[] _buf = new byte[256];
+ public int Length { get; private set; }
+
+ private Span Take(int n)
+ {
+ if (Length + n > _buf.Length) Array.Resize(ref _buf, Math.Max(_buf.Length * 2, Length + n));
+ var s = _buf.AsSpan(Length, n);
+ Length += n;
+ return s;
+ }
+
+ public CipWriter U8(byte v) { Take(1)[0] = v; return this; }
+ public CipWriter U16(ushort v) { BinaryPrimitives.WriteUInt16LittleEndian(Take(2), v); return this; }
+ public CipWriter U32(uint v) { BinaryPrimitives.WriteUInt32LittleEndian(Take(4), v); return this; }
+ public CipWriter U64(ulong v) { BinaryPrimitives.WriteUInt64LittleEndian(Take(8), v); return this; }
+ public CipWriter Bytes(ReadOnlySpan v) { v.CopyTo(Take(v.Length)); return this; }
+ public CipWriter PadToEven() { if ((Length & 1) == 1) U8(0); return this; }
+
+ /// Overwrite a u16 already written at (length back-patching).
+ public void PatchU16(int offset, ushort v) => BinaryPrimitives.WriteUInt16LittleEndian(_buf.AsSpan(offset, 2), v);
+
+ public byte[] ToArray() => _buf.AsSpan(0, Length).ToArray();
+}
+```
+
+```csharp
+// Wire/CipReader.cs
+using System.Buffers.Binary;
+namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
+
+/// Bounds-checked little-endian reader. Any overrun throws AbTransportException(BadReply).
+public ref struct CipReader(ReadOnlySpan data)
+{
+ private readonly ReadOnlySpan _data = data;
+ public int Position { get; private set; }
+ public readonly int Remaining => _data.Length - Position;
+
+ private ReadOnlySpan Take(int n)
+ {
+ if (n < 0 || Position + n > _data.Length)
+ throw new AbTransportException(AbStatus.BadReply, $"Reply truncated: need {n} bytes at {Position}, have {Remaining}.");
+ var s = _data.Slice(Position, n);
+ Position += n;
+ return s;
+ }
+
+ public byte U8() => Take(1)[0];
+ public ushort U16() => BinaryPrimitives.ReadUInt16LittleEndian(Take(2));
+ public uint U32() => BinaryPrimitives.ReadUInt32LittleEndian(Take(4));
+ public ulong U64() => BinaryPrimitives.ReadUInt64LittleEndian(Take(8));
+ public ReadOnlySpan Bytes(int n) => Take(n);
+ public ReadOnlySpan Rest() => Take(Remaining);
+}
+```
+
+```csharp
+// Cip/CipReply.cs
+using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
+namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
+
+/// A parsed CIP message-router reply: service (request code, 0x80 bit stripped), general
+/// status, extended status words, and the reply data.
+public sealed record CipReply(byte Service, byte GeneralStatus, ushort[] ExtendedStatus, ReadOnlyMemory Data)
+{
+ public AbStatus Status => AbStatusCodes.FromCip(GeneralStatus, ExtendedStatus);
+
+ public static CipReply Parse(ReadOnlyMemory raw)
+ {
+ var r = new CipReader(raw.Span);
+ var service = (byte)(r.U8() & 0x7F);
+ r.U8(); // reserved
+ var general = r.U8();
+ var extWords = r.U8();
+ var ext = new ushort[extWords];
+ for (var i = 0; i < extWords; i++) ext[i] = r.U16();
+ var data = raw[r.Position..];
+ return new CipReply(service, general, ext, data);
+ }
+}
+```
+
+```csharp
+// Cip/ICipChannel.cs
+namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
+
+/// A path to a CIP message router: connected (Forward Open) or unconnected (UCMM). Task 9
+/// implements it; Task 10's scheduler and the native adapters consume it.
+public interface ICipChannel
+{
+ /// Largest CIP request (service byte through end of data) one call may carry.
+ int MaxRequestBytes { get; }
+
+ /// Send one CIP request and return the raw CIP reply bytes (reply-service byte first).
+ /// Throws on transport failure or timeout.
+ Task> SendAsync(ReadOnlyMemory cipRequest, CancellationToken cancellationToken);
+}
+```
+
+Add the two `` lines to the slnx. Add ` ` to both driver csprojs.
+
+- [ ] **Step 5: Run the tests.** Run `dotnet build ZB.MOM.WW.OtOpcUa.slnx && dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests`. Expected: 0 warnings, all pass.
+
+- [ ] **Step 6: Commit.**
+
+```bash
+git add docs/plans/2026-10-07-native-ab-transport-gate-results.md src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests ZB.MOM.WW.OtOpcUa.slnx src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj
+git commit -m "feat(ab-transport): project skeleton with AbStatus, CIP reply parsing and the ICipChannel seam"
+```
+
+---
+
+### Task 2: AbCip seam — decouple the driver from libplctag types
+
+**Goal:** Change every AbCip file other than the `Libplctag*.cs` adapters so it speaks `AbStatus` / `AbTransportException` instead of libplctag's `Status` / `LibPlcTagException`. Behaviour stays identical.
+
+**Files:**
+- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs`, `LibplctagTagEnumerator.cs`, `LibplctagTemplateReader.cs` (wrap libplctag calls; translate)
+- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagStatusTranslation.cs`
+- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipStatusMapper.cs` (replace `MapLibplctagStatus(Status)` / `TryGetStatus(LibPlcTagException)` with `MapStatus(AbStatus)`, `MapStatus(int)`, and `MapException`)
+- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs` (`IsReachableStatus(AbStatus)`, `IsReachableException(AbTransportException)`; construct runtimes via an `IAbCipTagFactory` parameter that defaults to `new LibplctagTagFactory()`)
+- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs:1,1186-1191` (catch `AbTransportException`)
+- Modify (tests): `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverWriteTests.cs`, `AbCipDriverProbeTests.cs`, `AbCipDriverReadTests.cs`, `AbCipStatusMapperTests.cs`, `AbCipDriverCodeReviewRegressionTests.cs` (line 326 uses `libplctag.Status.ErrorBadConnection`), `AbCipUnknownTagReadTests.cs`, `AbCipLoggingTests.cs` (line 91, same), `FakeAbCipTag.cs`. Run `grep -rln "libplctag" tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests --include=*.cs` and migrate **every** hit except `LibplctagStatusTranslationTests.cs` and the mapper parity oracle.
+- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs` (its ` ` must become `MapStatus(int)`. A dangling cref is CS1574, which `TreatWarningsAsErrors` turns into a build error.)
+- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/LibplctagStatusTranslationTests.cs`
+
+**Acceptance Criteria:**
+- [ ] `grep -l 'using libplctag' src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/*.cs` lists only `LibplctagTagRuntime.cs`, `LibplctagTagEnumerator.cs`, `LibplctagTemplateReader.cs` and `LibplctagStatusTranslation.cs`.
+- [ ] `LibplctagTagRuntime.GetStatus()` returns `(int)AbStatus`, and every libplctag call that can throw `LibPlcTagException` rethrows `AbTransportException` with the translated status and the original as `InnerException`.
+- [ ] The translation table is the existing `MapLibplctagStatus` buckets re-expressed in `AbStatus`:
+ - `Ok→Ok`, `Pending→Pending`, `ErrorTimeout→Timeout`
+ - `ErrorNotFound/ErrorNoMatch/ErrorBadDevice→NotFound`
+ - `ErrorNotAllowed→NotAllowed`
+ - `ErrorOutOfBounds→OutOfBounds`, `ErrorTooLarge→TooLarge`, `ErrorTooSmall→TooSmall`
+ - `ErrorUnsupported/ErrorNotImplemented→Unsupported`
+ - `ErrorPartial→Partial`, `ErrorAbort→Abort`
+ - `ErrorBadReply→BadReply`
+ - every other member → `BadConnection`
+- [ ] `AbCipStatusMapper.MapStatus(AbStatus)` produces exactly the OPC UA codes the old mapper produced for the corresponding libplctag members. `TypeMismatch` maps to `BadTypeMismatch` and `DeviceStateConflict` to `BadDeviceFailure`. `Partial` and `BadReply` must still map to `BadCommunicationError`, as `ErrorPartial` / `ErrorBadReply` did.
+- [ ] The full existing `AbCip.Tests` suite is green, with the only changes being test fakes constructing `AbTransportException(AbStatus.X, ...)` instead of `LibPlcTagException("ErrorX")` and setting `Status = (int)AbStatus.X`.
+
+**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests` → all pass, then `grep -l 'using libplctag' src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/*.cs` → exactly the four `Libplctag*` files.
+
+**Steps:**
+
+- [ ] **Step 1: Write the translation test (failing).**
+
+```csharp
+// tests/.../LibplctagStatusTranslationTests.cs
+using libplctag;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
+
+public sealed class LibplctagStatusTranslationTests
+{
+ [Theory]
+ [InlineData(Status.Ok, AbStatus.Ok)]
+ [InlineData(Status.Pending, AbStatus.Pending)]
+ [InlineData(Status.ErrorTimeout, AbStatus.Timeout)]
+ [InlineData(Status.ErrorNotFound, AbStatus.NotFound)]
+ [InlineData(Status.ErrorNoMatch, AbStatus.NotFound)]
+ [InlineData(Status.ErrorBadDevice, AbStatus.NotFound)]
+ [InlineData(Status.ErrorNotAllowed, AbStatus.NotAllowed)]
+ [InlineData(Status.ErrorOutOfBounds, AbStatus.OutOfBounds)]
+ [InlineData(Status.ErrorTooLarge, AbStatus.TooLarge)]
+ [InlineData(Status.ErrorTooSmall, AbStatus.TooSmall)]
+ [InlineData(Status.ErrorUnsupported, AbStatus.Unsupported)]
+ [InlineData(Status.ErrorNotImplemented, AbStatus.Unsupported)]
+ [InlineData(Status.ErrorPartial, AbStatus.Partial)]
+ [InlineData(Status.ErrorAbort, AbStatus.Abort)]
+ [InlineData(Status.ErrorBadReply, AbStatus.BadReply)]
+ [InlineData(Status.ErrorBadGateway, AbStatus.BadConnection)]
+ [InlineData(Status.ErrorWinsock, AbStatus.BadConnection)]
+ public void Translates(Status from, AbStatus to) => LibplctagStatusTranslation.ToAbStatus(from).ShouldBe(to);
+
+ [Fact]
+ public void Exception_message_is_recovered_as_the_enum_name()
+ {
+ LibplctagStatusTranslation.ToAbStatus(new LibPlcTagException("ErrorNotFound")).ShouldBe(AbStatus.NotFound);
+ LibplctagStatusTranslation.ToAbStatus(new LibPlcTagException("gibberish")).ShouldBe(AbStatus.BadConnection);
+ }
+}
+```
+
+Add a matching parity test to `AbCipStatusMapperTests`: for every `Status` member, `MapStatus(ToAbStatus(s))` must equal the OPC UA code the **old** `MapLibplctagStatus(s)` returned. Copy the old switch into the test as an expected-value table **before deleting it from production**. That table is the regression oracle.
+
+- [ ] **Step 2: Run and confirm it fails.** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests --filter LibplctagStatusTranslation` → compile error.
+
+- [ ] **Step 3: Implement the translation and wrap the adapters.**
+
+```csharp
+// LibplctagStatusTranslation.cs
+using libplctag;
+using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
+
+/// The ONLY place libplctag's meets .
+internal static class LibplctagStatusTranslation
+{
+ public static AbStatus ToAbStatus(Status s) => s switch
+ {
+ Status.Ok => AbStatus.Ok,
+ Status.Pending => AbStatus.Pending,
+ Status.ErrorTimeout => AbStatus.Timeout,
+ Status.ErrorNotFound or Status.ErrorNoMatch or Status.ErrorBadDevice => AbStatus.NotFound,
+ Status.ErrorNotAllowed => AbStatus.NotAllowed,
+ Status.ErrorOutOfBounds => AbStatus.OutOfBounds,
+ Status.ErrorTooLarge => AbStatus.TooLarge,
+ Status.ErrorTooSmall => AbStatus.TooSmall,
+ Status.ErrorUnsupported or Status.ErrorNotImplemented => AbStatus.Unsupported,
+ Status.ErrorPartial => AbStatus.Partial,
+ Status.ErrorAbort => AbStatus.Abort,
+ Status.ErrorBadReply => AbStatus.BadReply,
+ _ => AbStatus.BadConnection,
+ };
+
+ /// libplctag builds the exception from Status.ToString() , so the message is the enum name.
+ public static AbStatus ToAbStatus(LibPlcTagException ex) =>
+ Enum.TryParse((ex.Message ?? "").Trim(), ignoreCase: true, out var s) && Enum.IsDefined(s)
+ ? ToAbStatus(s)
+ : AbStatus.BadConnection;
+
+ public static async Task Wrap(Func op)
+ {
+ try { await op().ConfigureAwait(false); }
+ catch (LibPlcTagException ex) { throw new AbTransportException(ToAbStatus(ex), ex.Message, ex); }
+ }
+}
+```
+
+In `LibplctagTagRuntime`: `InitializeAsync/ReadAsync/WriteAsync => LibplctagStatusTranslation.Wrap(() => _tag.XAsync(ct))`, and `GetStatus() => (int)LibplctagStatusTranslation.ToAbStatus(_tag.GetStatus())`. Do the same in the enumerator's `ReadListingAsync` and in `LibplctagTemplateReader.ReadAsync`.
+
+- [ ] **Step 4: Rewrite the mapper and probe and the driver catch.**
+
+`AbCipStatusMapper` (remove `using libplctag`):
+
+```csharp
+public static uint MapStatus(int status) => MapStatus((AbStatus)status);
+
+public static uint MapStatus(AbStatus status) => status switch
+{
+ AbStatus.Ok => Good,
+ AbStatus.Pending => GoodMoreData,
+ AbStatus.Timeout => BadTimeout,
+ AbStatus.NotFound => BadNodeIdUnknown,
+ AbStatus.NotAllowed => BadNotWritable,
+ AbStatus.OutOfBounds or AbStatus.TooLarge or AbStatus.TooSmall => BadOutOfRange,
+ AbStatus.Unsupported => BadNotSupported,
+ AbStatus.TypeMismatch => BadTypeMismatch,
+ AbStatus.DeviceStateConflict => BadDeviceFailure,
+ _ => BadCommunicationError, // BadConnection, BadReply, Partial, Abort
+};
+
+public static uint MapException(Exception ex) =>
+ ex is AbTransportException ate ? MapStatus(ate.Status) : BadCommunicationError;
+```
+
+Rename every caller of `MapLibplctagStatus` to `MapStatus`; `grep -rn MapLibplctagStatus src tests` finds them all. Keep `MapCipGeneralStatus` unchanged.
+
+`AbCipDriverProbe`:
+
+```csharp
+internal static bool IsReachableStatus(AbStatus s) => s is AbStatus.Ok or AbStatus.NotFound;
+internal static bool IsReachableException(AbTransportException ex) => IsReachableStatus(ex.Status);
+```
+
+In the probe body, replace `(Status)rt.GetStatus()` with `(AbStatus)rt.GetStatus()`, `statusCode == Status.Ok` with `== AbStatus.Ok`, and `catch (LibPlcTagException ex)` with `catch (AbTransportException ex)`. Replace `new LibplctagTagRuntime(p)` with `tagFactory.Create(p)`, where `tagFactory` is a new optional parameter (`IAbCipTagFactory? tagFactory = null` ⇒ `new LibplctagTagFactory()`). Task 15 passes the transport-selected factory.
+
+`AbCipDriver.cs:1186`:
+
+```csharp
+if (ex is AbTransportException ate
+ && ate.Status != AbStatus.Ok
+ && AbCipDriverProbe.IsReachableStatus(ate.Status))
+ device.Backoff.RecordSuccess();
+```
+
+Remove `using libplctag;` from `AbCipDriver.cs` and add `using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;`. Rename log template placeholders `{LibplctagStatus}` → `{Status}`, and change the messages from "libplctag status" to "transport status".
+
+- [ ] **Step 5: Migrate the tests.**
+ - In `FakeAbCipTag` and every test that sets `Status =` / `new LibPlcTagException("ErrorX")`: use `(int)AbStatus.X` / `new AbTransportException(AbStatus.X, "X")`.
+ - Map the old names this way: `ErrorNotFound→NotFound`, `ErrorTimeout→Timeout`, `ErrorBadConnection→BadConnection`, `ErrorNotAllowed→NotAllowed`, `ErrorOutOfBounds→OutOfBounds`.
+ - In `AbCipDriverProbeTests` line 174/188, change the theory to iterate `AbStatus` values. "something the wrapper has never said" becomes `new AbTransportException(AbStatus.BadConnection, "...")`, and the expectation is false.
+
+- [ ] **Step 6: Run the suite.** Run `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests`. Expected: all pass, with the same test count as before plus the new translation tests.
+
+- [ ] **Step 7: Commit.**
+
+```bash
+git add -A src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests
+git commit -m "refactor(abcip): confine libplctag to its adapters behind AbStatus/AbTransportException"
+```
+
+---
+
+### Task 3: AbLegacy seam — decouple the driver from libplctag types
+
+**Goal:** Do the same as Task 2 for AbLegacy. Only `LibplctagLegacyTagRuntime.cs` and `LibplctagLegacyStatusTranslation.cs` may reference libplctag.
+
+**Files:**
+- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs`
+- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyStatusTranslation.cs`
+- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyStatusMapper.cs` (`MapStatus(AbStatus|int)`, `MapException`; keep `MapPcccStatus`)
+- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs` (`IsReachableStatus(AbStatus)`, catch `AbTransportException`, optional `IAbLegacyTagFactory` parameter defaulting to `new LibplctagLegacyTagFactory()`)
+- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs` (lines ~318-325: `MapLibplctagStatus`→`MapStatus`; log text "transport status"; any `LibPlcTagException` catch → `AbTransportException`)
+- Modify (tests): `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyEvictOnFailureTests.cs`, `AbLegacyReadWriteTests.cs` (line 73), `AbLegacyHostAndStatusTests.cs`, `AbLegacyBitRmwTests.cs` (line 232), `AbLegacyArrayTests.cs` (line 115), `FakeAbLegacyTag.cs`. Run `grep -rln "libplctag" tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests --include=*.cs` and migrate every hit except the translation test and the parity oracle. Also grep `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy` for `cref=".*MapLibplctagStatus` and fix any match (CS1574).
+- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/LibplctagLegacyStatusTranslationTests.cs`
+
+**Acceptance Criteria:**
+- [ ] `grep -l 'using libplctag' src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/*.cs` lists exactly `LibplctagLegacyTagRuntime.cs` and `LibplctagLegacyStatusTranslation.cs`.
+- [ ] The translation table is identical to Task 2's, applied in the AbLegacy namespace. The table is duplicated, not shared, because each driver owns its own libplctag confinement and Task 22's eventual deletion removes both.
+- [ ] `AbLegacyStatusMapper.MapStatus(AbStatus)` gives exactly the outputs the old `MapLibplctagStatus` gave for the corresponding members, proven by a parity table copied from the old switch before deleting it.
+- [ ] The full `AbLegacy.Tests` suite is green.
+
+**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests` → all pass.
+
+**Steps:**
+
+- [ ] **Step 1: Write the failing tests.** Copy Task 2's `LibplctagStatusTranslationTests` verbatim into `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/LibplctagLegacyStatusTranslationTests.cs`, and change the namespace to `ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests` and the class under test to `LibplctagLegacyStatusTranslation`. Add the mapper parity table to `AbLegacyHostAndStatusTests`, built from the old `AbLegacyStatusMapper.MapLibplctagStatus` switch at `AbLegacyStatusMapper.cs:42-55`.
+- [ ] **Step 2: Run and confirm it fails.** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests --filter LibplctagLegacyStatusTranslation` → compile error.
+- [ ] **Step 3: Implement.** Create `LibplctagLegacyStatusTranslation` with the same code as Task 2's `LibplctagStatusTranslation` (namespace `ZB.MOM.WW.OtOpcUa.Driver.AbLegacy`). Then make these changes:
+ - Wrap `InitializeAsync/ReadAsync/WriteAsync` in `LibplctagLegacyTagRuntime` with `LibplctagLegacyStatusTranslation.Wrap(...)`.
+ - `GetStatus() => (int)LibplctagLegacyStatusTranslation.ToAbStatus(_tag.GetStatus())`.
+ - The mapper becomes:
+
+```csharp
+public static uint MapStatus(int status) => MapStatus((AbStatus)status);
+public static uint MapStatus(AbStatus status) => status switch
+{
+ AbStatus.Ok => Good,
+ AbStatus.Pending => GoodMoreData,
+ AbStatus.Timeout => BadTimeout,
+ AbStatus.NotFound => BadNodeIdUnknown,
+ AbStatus.NotAllowed => BadNotWritable,
+ AbStatus.OutOfBounds or AbStatus.TooLarge or AbStatus.TooSmall => BadOutOfRange,
+ AbStatus.Unsupported => BadNotSupported,
+ AbStatus.TypeMismatch => BadTypeMismatch,
+ AbStatus.DeviceStateConflict => BadDeviceFailure,
+ _ => BadCommunicationError,
+};
+public static uint MapException(Exception ex) =>
+ ex is AbTransportException ate ? MapStatus(ate.Status) : BadCommunicationError;
+```
+
+ If `AbLegacyStatusMapper` does not yet define `BadTypeMismatch`, add `public const uint BadTypeMismatch = 0x80740000u;` (the same value as `AbCipStatusMapper`).
+ - In the probe: `IsReachableStatus(AbStatus s) => s is AbStatus.Ok or AbStatus.NotFound`, and every `Status.*` use moves to `AbStatus.*`.
+- [ ] **Step 4: Migrate the tests.** `FakeAbLegacyTag.Status` values and any `LibPlcTagException` move to `(int)AbStatus.X` / `AbTransportException`.
+- [ ] **Step 5: Run the suite.** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests` → all pass.
+- [ ] **Step 6: Commit.**
+
+```bash
+git add -A src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests
+git commit -m "refactor(ablegacy): confine libplctag to its adapter behind AbStatus/AbTransportException"
+```
+
+---
+
+### Task 4: Capture golden frames (libplctag ⇄ ab_server)
+
+**Goal:** Capture, once, the exact request/reply bytes that libplctag 1.5.2 exchanges with `ab_server` for every operation the native stack must reproduce. Store them as hex fixtures that Tasks 7 and 8 assert parity against.
+
+**Files:**
+- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/README.md` (how each capture was taken: commit, image tag, command)
+- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/*.hex` (one file per exchange, as below)
+- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/CaptureFile.cs` (loader)
+- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/CaptureFileTests.cs`
+- Create: `scripts/ab-capture/capture.sh` (re-runnable recipe)
+
+**Acceptance Criteria:**
+- [ ] Each `.hex` file holds alternating `> ` (client→server) and `< ` (server→client) lines of **encapsulation frames**. Each line is one full EIP frame (the 24-byte header plus data) as uppercase hex with no spaces. Lines starting with `#` are comments.
+- [ ] These captures exist and are non-empty:
+ - `logix-register-session.hex`
+ - `logix-forward-open.hex` (both a Large FO 0x5B and, if libplctag falls back or uses it, a 0x54)
+ - `logix-read-dint.hex`, `logix-read-dint-array16.hex`, `logix-read-string.hex`, `logix-read-bool.hex`
+ - `logix-write-dint.hex`, `logix-write-string.hex`
+ - `logix-read-unknown-tag.hex` (CIP 0x04, via patch 0002)
+ - `logix-tags-listing.hex` (`@tags`, via patch 0001)
+ - `logix-forward-close.hex`
+ - `micro800-read-dint.hex` (UCMM, no Forward Open)
+ - `slc500-read-n7.hex`, `slc500-write-n7.hex`, `slc500-read-f8.hex`, `micrologix-read-n7.hex`, `plc5-read-n7.hex`
+- [ ] `CaptureFileTests` loads every `.hex` file, parses every frame header, and asserts that each frame's encapsulation length field equals its data length.
+- [ ] `Captures/README.md` records, for each file: the libplctag version (1.5.2), the ab_server image (`otopcua-ab-server:libplctag-release`), the compose profile, and the libplctag tag string used.
+- [ ] `Captures/README.md` has a **"Routing observed"** table, one row per family (controllogix, micro800, slc500, micrologix, plc5, plus logixpccc if reachable), with the columns: ForwardOpen yes/no · CIP request wrapped in Unconnected Send yes/no · route bytes · requestor-id bytes (PCCC). Tasks 9, 12 and 14 read their `Routing` choice from this table.
+
+**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter CaptureFile` → pass, and `ls tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/*.hex | wc -l` → ≥ 17.
+
+**Steps:**
+
+- [ ] **Step 1: Bring up each fixture on the docker host, one profile at a time** (they all bind :44818):
+
+```bash
+ssh dohertj2@10.100.0.35 'cd /opt/otopcua-abcip && docker compose --profile controllogix up -d'
+```
+
+If `/opt/otopcua-abcip` is missing or stale, rsync first. Read the fixture's `docs/drivers/AbServer-Test-Fixture.md` for the exact path:
+
+```bash
+rsync -av tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Docker/ dohertj2@10.100.0.35:/opt/otopcua-abcip/
+```
+
+For AbLegacy, the stack lives at **`~/otopcua-ablegacy`** on the docker host, not `/opt` (`infra/README.md:41,68`). It is driven by `AB_LEGACY_COMPOSE_PROFILE= docker compose up -d`, and its source is `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/`. **Read that compose file for the published ports**; do not assume `:44818`. For containers that publish other ports, or run concurrently, capture inside the container's network namespace with the netshoot form in Step 2.
+
+- [ ] **Step 2: Capture server-side** while driving libplctag from the Mac with the existing CLIs (`otopcua-abcip-cli` / `otopcua-ablegacy-cli`; run `dotnet run --project src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli -- --help` for the verbs):
+
+```bash
+ssh dohertj2@10.100.0.35 'sudo timeout 20 tcpdump -i any -U -w /tmp/cap.pcap tcp port 44818' &
+dotnet run --project src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli -- read --gateway 10.100.0.35 --path 1,0 --tag TestDINT --type DInt
+wait; scp dohertj2@10.100.0.35:/tmp/cap.pcap "$CAPDIR/logix-read-dint.pcap" # CAPDIR = a fresh empty dir under the session scratchpad
+```
+
+If the CLI flags differ, use the CLI's own `--help` output. Do not invent flags. If `sudo tcpdump` is unavailable on the host, use `docker run --rm --net=container:otopcua-ab-server-controllogix nicolaka/netshoot tcpdump -U -w - tcp port 44818 > cap.pcap` instead.
+
+- [ ] **Step 3: Convert each pcap into the `.hex` line format.** Use `scripts/ab-capture/capture.sh`, which runs `tshark -r X.pcap -T fields -e tcp.srcport -e tcp.payload -Y 'tcp.len>0'`, re-joins segments into EIP frames using the 24-byte header length field, and emits `>`/`<` by direction (srcport 44818 ⇒ `<`). Install tshark on the Mac with `brew install wireshark` if it is absent.
+
+- [ ] **Step 4: Write the loader and its test.**
+
+```csharp
+// Captures/CaptureFile.cs
+namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Captures;
+
+internal sealed record CapturedFrame(bool FromClient, byte[] Bytes);
+
+internal static class CaptureFile
+{
+ public static string Dir => Path.Combine(AppContext.BaseDirectory, "Captures");
+
+ public static IReadOnlyList Load(string name) =>
+ File.ReadAllLines(Path.Combine(Dir, name))
+ .Where(l => l.Length > 2 && l[0] is '>' or '<')
+ .Select(l => new CapturedFrame(l[0] == '>', Convert.FromHexString(l[2..].Trim())))
+ .ToList();
+
+ /// The CIP payload of a SendRRData / SendUnitData frame (the last CPF item's data,
+ /// minus the 2-byte sequence count for connected data).
+ public static byte[] CipPayload(CapturedFrame f)
+ {
+ var b = f.Bytes;
+ var cmd = BitConverter.ToUInt16(b, 0); // test-only; fixtures are LE on a LE host
+ var pos = 24 + 4 + 2; // header + interface handle + timeout
+ var itemCount = BitConverter.ToUInt16(b, pos); pos += 2;
+ byte[] last = [];
+ for (var i = 0; i < itemCount; i++)
+ {
+ var type = BitConverter.ToUInt16(b, pos);
+ var len = BitConverter.ToUInt16(b, pos + 2);
+ last = b.AsSpan(pos + 4, len).ToArray();
+ if (type == 0x00B1) last = last[2..]; // strip connected sequence count
+ pos += 4 + len;
+ }
+ return cmd is 0x6F or 0x70 ? last : [];
+ }
+}
+```
+
+Add ` ` to the test csproj. `CaptureFileTests` iterates `Directory.GetFiles(CaptureFile.Dir, "*.hex")` and asserts, for every frame, `BitConverter.ToUInt16(bytes, 2) == bytes.Length - 24`.
+
+- [ ] **Step 5: Run.** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter CaptureFile` → pass.
+
+- [ ] **Step 6: Tear down the fixtures** (`docker compose down`) and **commit.**
+
+```bash
+git add tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/*.csproj scripts/ab-capture
+git commit -m "test(ab-transport): golden libplctag<->ab_server frame captures for Logix, Micro800 and PCCC"
+```
+
+> ⚠️ These captures prove **parity with libplctag**, not correctness. The independent reference is the real-press captures already in `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/CipSymbolObjectRealControllerCaptureTests.cs` and `CipTemplateObjectWireCaptureTests.cs`. Tasks 13 and 21 use those.
+
+---
+
+### Task 5: EtherNet/IP encapsulation codec + `EncapsulationClient`
+
+**Goal:** A TCP client that frames EIP encapsulation, registers a session, and sends SendRRData (unconnected) and SendUnitData (connected) requests with at most one outstanding request. It also includes a scriptable in-process fake server for tests.
+
+**Files:**
+- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Eip/EncapsulationHeader.cs`
+- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Eip/EncapsulationCodec.cs`
+- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Eip/EncapsulationClient.cs`
+- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Eip/EncapsulationCodecTests.cs`
+- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Eip/EncapsulationClientTests.cs`
+- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Fakes/FakeEipServer.cs`
+
+**Acceptance Criteria:**
+- [ ] `EncapsulationCodec.RegisterSession()` produces exactly `65 00 04 00 00000000 00000000 <8-byte context> 00000000 01 00 00 00`.
+- [ ] `EncapsulationCodec.SendRRData(session, ctx, cip, timeoutSec)` produces: header with command `0x006F`; data = interface handle `00000000`, timeout `u16`, item count `02 00`, Null Address item `00 00 00 00`, then the Unconnected Data item `B2 00 ` followed by the cip bytes.
+- [ ] `EncapsulationCodec.SendUnitData(session, connId, seq, cip)` produces: command `0x0070`; data = interface handle `00000000`, timeout `00 00`, item count `02 00`, Connected Address item `A1 00 04 00 `, Connected Data item `B1 00 ` followed by the cip bytes.
+- [ ] `EncapsulationCodec.ParseReply` returns the header and the CPF item payload. For a connected reply it strips the sequence count. It throws `AbTransportException(BadReply)` when encapsulation status ≠ 0, the length is wrong, or the expected item is missing.
+- [ ] `EncapsulationClient`:
+ - **Lifecycle.** It connects, registers (and stores the session handle from the reply), and `UnRegisterSession`s on `DisposeAsync`.
+ - **Single flight.** It serializes requests with a `SemaphoreSlim(1,1)`.
+ - **Connected sequence (B1).** `SendUnitDataAsync(otConnectionId, cip, timeout, ct)` takes **no sequence argument**. The client owns the connected sequence counter and increments it **inside the gate**, wrapping 65535 → 1.
+ - **Matching replies.**
+ - Unconnected replies match on the 8-byte sender context.
+ - Connected (0x70) replies match on the echoed sequence count.
+ - Any frame that doesn't match (an earlier abandoned request's late reply) is **skipped** and the client keeps reading. It is not treated as an error.
+ - **Caller cancellation (B2).**
+ - The frame write uses `CancellationToken.None`, so a write is never half-sent.
+ - If the *caller's* token is cancelled during the reply wait, the call throws `OperationCanceledException`. The connection is **not** faulted: `IsConnected` stays true, the gate is released, and the late reply is skipped by the next exchange.
+ - **Faults.**
+ - The *timeout* elapsing (no reply within `timeout`) throws `AbTransportException(Timeout)` and **faults** the connection by closing the socket. A target that has stopped answering cannot be trusted.
+ - A socket or I/O error throws `AbTransportException(BadConnection)` and faults the connection.
+ - `IsConnected` turns false after any fault.
+- [ ] Parity: the bytes `RegisterSession` sends, apart from the sender context, equal the first `>` frame of `Captures/logix-register-session.hex`.
+- [ ] `FakeEipServer` accepts a script of `Func` steps, records every request it receives, and can be told to stall (to test timeouts) or drop the connection.
+
+**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter "FullyQualifiedName~Eip"` → all pass.
+
+**Steps:**
+
+- [ ] **Step 1: Write the codec golden tests (failing).**
+
+```csharp
+// Eip/EncapsulationCodecTests.cs
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Eip;
+using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Captures;
+namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Eip;
+
+public sealed class EncapsulationCodecTests
+{
+ private const ulong Ctx = 0x0807060504030201;
+
+ [Fact]
+ public void RegisterSession_frame_is_byte_exact() =>
+ Convert.ToHexString(EncapsulationCodec.RegisterSession(Ctx)).ShouldBe(
+ "6500" + "0400" + "00000000" + "00000000" + "0102030405060708" + "00000000" + "0100" + "0000");
+
+ [Fact]
+ public void SendRRData_wraps_cip_in_null_address_and_unconnected_data_items()
+ {
+ var frame = EncapsulationCodec.SendRRData(0x11223344, Ctx, new byte[] { 0x4C, 0x02 }, timeoutSeconds: 10);
+ Convert.ToHexString(frame).ShouldBe(
+ "6F00" + "1200" + "44332211" + "00000000" + "0102030405060708" + "00000000" +
+ "00000000" + "0A00" + "0200" + "00000000" + "B2000200" + "4C02");
+ }
+
+ [Fact]
+ public void SendUnitData_wraps_cip_with_connection_id_and_sequence()
+ {
+ var frame = EncapsulationCodec.SendUnitData(0x11223344, Ctx, connectionId: 0xAABBCCDD, sequence: 7, new byte[] { 0x4C, 0x02 });
+ Convert.ToHexString(frame).ShouldBe(
+ "7000" + "1800" + "44332211" + "00000000" + "0102030405060708" + "00000000" +
+ "00000000" + "0000" + "0200" + "A1000400" + "DDCCBBAA" + "B1000400" + "0700" + "4C02");
+ }
+
+ [Fact]
+ public void RegisterSession_matches_libplctag_capture_except_context()
+ {
+ var captured = CaptureFile.Load("logix-register-session.hex").First(f => f.FromClient).Bytes;
+ var ours = EncapsulationCodec.RegisterSession(Ctx);
+ ours.AsSpan(0, 12).ToArray().ShouldBe(captured.AsSpan(0, 12).ToArray()); // cmd,len,session,status
+ ours.AsSpan(20).ToArray().ShouldBe(captured.AsSpan(20).ToArray()); // options + data
+ }
+
+ [Fact]
+ public void Nonzero_encapsulation_status_is_BadReply()
+ {
+ var reply = Convert.FromHexString("6F00" + "0000" + "44332211" + "01000000" + "0102030405060708" + "00000000");
+ Should.Throw(() => EncapsulationCodec.ParseReply(reply, expectConnected: false))
+ .Status.ShouldBe(AbStatus.BadReply);
+ }
+}
+```
+
+- [ ] **Step 2: Run and confirm it fails.** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter FullyQualifiedName~Eip` → compile error.
+
+- [ ] **Step 3: Implement the codec.**
+
+```csharp
+// Eip/EncapsulationHeader.cs
+namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Eip;
+
+public static class EncapCommand
+{
+ public const ushort ListIdentity = 0x0063, RegisterSession = 0x0065, UnRegisterSession = 0x0066,
+ SendRRData = 0x006F, SendUnitData = 0x0070;
+}
+
+public readonly record struct EncapsulationHeader(
+ ushort Command, ushort Length, uint SessionHandle, uint Status, ulong SenderContext, uint Options)
+{
+ public const int Size = 24;
+}
+```
+
+```csharp
+// Eip/EncapsulationCodec.cs
+using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
+namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Eip;
+
+public static class EncapsulationCodec
+{
+ private static CipWriter Header(ushort cmd, uint session, ulong ctx) =>
+ new CipWriter().U16(cmd).U16(0).U32(session).U32(0).U64(ctx).U32(0);
+
+ private static byte[] Finish(CipWriter w)
+ {
+ w.PatchU16(2, (ushort)(w.Length - EncapsulationHeader.Size));
+ return w.ToArray();
+ }
+
+ public static byte[] RegisterSession(ulong ctx) =>
+ Finish(Header(EncapCommand.RegisterSession, 0, ctx).U16(1).U16(0));
+
+ public static byte[] UnRegisterSession(uint session, ulong ctx) =>
+ Finish(Header(EncapCommand.UnRegisterSession, session, ctx));
+
+ public static byte[] SendRRData(uint session, ulong ctx, ReadOnlySpan cip, ushort timeoutSeconds) =>
+ Finish(Header(EncapCommand.SendRRData, session, ctx)
+ .U32(0).U16(timeoutSeconds).U16(2)
+ .U16(0x0000).U16(0)
+ .U16(0x00B2).U16((ushort)cip.Length).Bytes(cip));
+
+ public static byte[] SendUnitData(uint session, ulong ctx, uint connectionId, ushort sequence, ReadOnlySpan cip) =>
+ Finish(Header(EncapCommand.SendUnitData, session, ctx)
+ .U32(0).U16(0).U16(2)
+ .U16(0x00A1).U16(4).U32(connectionId)
+ .U16(0x00B1).U16((ushort)(cip.Length + 2)).U16(sequence).Bytes(cip));
+
+ public static EncapsulationHeader ReadHeader(ReadOnlySpan b)
+ {
+ var r = new CipReader(b);
+ return new EncapsulationHeader(r.U16(), r.U16(), r.U32(), r.U32(), r.U64(), r.U32());
+ }
+
+ /// Parse a reply frame. Returns the header plus the CIP payload of the data item
+ /// (0x00B2 unconnected / 0x00B1 connected, sequence count stripped).
+ public static (EncapsulationHeader Header, ReadOnlyMemory Cip, ushort Sequence) ParseReply(
+ ReadOnlyMemory frame, bool expectConnected)
+ {
+ var h = ReadHeader(frame.Span);
+ if (h.Status != 0)
+ throw new AbTransportException(AbStatus.BadReply, $"Encapsulation status 0x{h.Status:X8} on command 0x{h.Command:X4}.");
+ if (h.Length != frame.Length - EncapsulationHeader.Size)
+ throw new AbTransportException(AbStatus.BadReply, "Encapsulation length mismatch.");
+ if (h.Command is EncapCommand.RegisterSession or EncapCommand.UnRegisterSession)
+ return (h, ReadOnlyMemory.Empty, 0);
+
+ var r = new CipReader(frame.Span[EncapsulationHeader.Size..]);
+ r.U32(); r.U16(); // interface handle, timeout
+ var items = r.U16();
+ var offset = EncapsulationHeader.Size + 8;
+ for (var i = 0; i < items; i++)
+ {
+ var type = r.U16();
+ var len = r.U16();
+ offset += 4;
+ if (type == (expectConnected ? 0x00B1 : 0x00B2))
+ {
+ if (!expectConnected) return (h, frame.Slice(offset, len), 0);
+ var seq = (ushort)(frame.Span[offset] | (frame.Span[offset + 1] << 8));
+ return (h, frame.Slice(offset + 2, len - 2), seq);
+ }
+ r.Bytes(len);
+ offset += len;
+ }
+ throw new AbTransportException(AbStatus.BadReply, "Reply carried no data item.");
+ }
+}
+```
+
+- [ ] **Step 4: Write the client tests against `FakeEipServer` (failing).**
+
+`FakeEipServer` sketch (complete it):
+
+```csharp
+// Fakes/FakeEipServer.cs
+using System.Net;
+using System.Net.Sockets;
+namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes;
+
+/// Loopback EIP endpoint. Each received frame is passed to Handler; a non-null return is
+/// written back. Answers RegisterSession / UnRegisterSession automatically (session 0x11223344).
+internal sealed class FakeEipServer : IAsyncDisposable
+{
+ private readonly TcpListener _listener = new(IPAddress.Loopback, 0);
+ private readonly CancellationTokenSource _cts = new();
+ public int Port => ((IPEndPoint)_listener.LocalEndpoint).Port;
+ public List Received { get; } = [];
+ public int Connections;
+ public Func Handler { get; set; } = _ => null;
+ public bool Stall { get; set; }
+
+ public FakeEipServer() { _listener.Start(); _ = AcceptLoop(); }
+
+ private async Task AcceptLoop()
+ {
+ while (!_cts.IsCancellationRequested)
+ {
+ TcpClient c;
+ try { c = await _listener.AcceptTcpClientAsync(_cts.Token); } catch { return; }
+ Interlocked.Increment(ref Connections);
+ _ = Serve(c);
+ }
+ }
+
+ private async Task Serve(TcpClient c)
+ {
+ using var _ = c;
+ var s = c.GetStream();
+ var head = new byte[24];
+ while (!_cts.IsCancellationRequested)
+ {
+ if (!await ReadExact(s, head)) return;
+ var len = head[2] | (head[3] << 8);
+ var frame = new byte[24 + len];
+ head.CopyTo(frame, 0);
+ if (!await ReadExact(s, frame.AsMemory(24))) return;
+ lock (Received) Received.Add(frame);
+ var cmd = head[0] | (head[1] << 8);
+ byte[]? reply = cmd switch
+ {
+ 0x65 => RegisterReply(frame),
+ 0x66 => null,
+ _ => Stall ? null : Handler(frame),
+ };
+ if (reply is not null) await s.WriteAsync(reply, _cts.Token);
+ }
+ }
+
+ private static byte[] RegisterReply(byte[] req)
+ {
+ var r = (byte[])req.Clone();
+ r[4] = 0x44; r[5] = 0x33; r[6] = 0x22; r[7] = 0x11;
+ return r;
+ }
+
+ private async Task ReadExact(NetworkStream s, Memory buf)
+ {
+ var got = 0;
+ while (got < buf.Length)
+ {
+ int n;
+ try { n = await s.ReadAsync(buf[got..], _cts.Token); } catch { return false; }
+ if (n == 0) return false;
+ got += n;
+ }
+ return true;
+ }
+
+ /// Build a SendRRData reply echoing the request's session + context with the given CIP reply.
+ public static byte[] RRReply(byte[] request, byte[] cipReply)
+ {
+ var session = BitConverter.ToUInt32(request, 4);
+ var ctx = BitConverter.ToUInt64(request, 12);
+ return ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Eip.EncapsulationCodec.SendRRData(session, ctx, cipReply, 0);
+ }
+
+ public async ValueTask DisposeAsync() { _cts.Cancel(); _listener.Stop(); await Task.CompletedTask; }
+}
+```
+
+Client tests to write:
+- `Registers_and_stores_session_handle`: after `ConnectAsync`, `SessionHandle == 0x11223344`.
+- `SendRRData_returns_cip_payload`: the handler replies `RRReply(req, [0xCC,0,0,0,0xC4,0,1,0,0,0])`, and the result equals those 10 bytes.
+- `Concurrent_sends_are_serialized`: fire 5 `SendRRDataAsync` calls concurrently, with a handler that sleeps 20 ms and records the arrival time. Assert the received frames never overlap; each request arrives only after the previous reply was sent.
+- `Stalled_reply_times_out_and_closes_the_socket`: set `Stall = true` and use a 200 ms timeout. Assert `AbTransportException(Timeout)` and `IsConnected == false`.
+- `Caller_cancellation_does_not_fault_the_connection` (B2):
+ - Have the handler delay its reply by 300 ms, and cancel the caller's token at 50 ms.
+ - Assert `OperationCanceledException` and `IsConnected == true`.
+ - Then send a second request whose handler answers immediately. Assert it returns **its own** payload: the stale reply arrives first and is skipped.
+- `Connected_sequence_is_assigned_inside_the_gate`: fire 20 concurrent `SendUnitDataAsync` calls, with a handler that echoes the sequence. Assert the sequences seen on the wire are strictly increasing in arrival order, with no duplicates.
+- `Reply_with_foreign_context_is_skipped`: the handler first writes a reply carrying a different context, then the correct one. The call returns the correct payload.
+- `Dispose_sends_UnRegisterSession`: after `DisposeAsync`, `Received` contains a 0x66 frame.
+
+- [ ] **Step 5: Implement `EncapsulationClient`.**
+
+```csharp
+// Eip/EncapsulationClient.cs
+using System.Net.Sockets;
+namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Eip;
+
+public sealed class EncapsulationClient : IAsyncDisposable
+{
+ private readonly TcpClient _tcp;
+ private readonly NetworkStream _stream;
+ private readonly SemaphoreSlim _gate = new(1, 1);
+ private long _ctx;
+ private ushort _seq; // connected sequence count; only touched under _gate
+ private volatile bool _faulted;
+
+ public uint SessionHandle { get; private set; }
+ public bool IsConnected => !_faulted && _tcp.Connected;
+
+ private EncapsulationClient(TcpClient tcp) { _tcp = tcp; _stream = tcp.GetStream(); }
+
+ public static async Task ConnectAsync(string host, int port, TimeSpan timeout, CancellationToken ct)
+ {
+ var tcp = new TcpClient { NoDelay = true };
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ cts.CancelAfter(timeout);
+ try { await tcp.ConnectAsync(host, port, cts.Token).ConfigureAwait(false); }
+ catch (OperationCanceledException) when (!ct.IsCancellationRequested)
+ { tcp.Dispose(); throw new AbTransportException(AbStatus.Timeout, $"TCP connect to {host}:{port} timed out."); }
+ catch (SocketException ex)
+ { tcp.Dispose(); throw new AbTransportException(AbStatus.BadConnection, $"TCP connect to {host}:{port} failed: {ex.SocketErrorCode}", ex); }
+
+ var c = new EncapsulationClient(tcp);
+ var reply = await c.ExchangeAsync(ctx => EncapsulationCodec.RegisterSession(ctx), timeout, ct).ConfigureAwait(false);
+ c.SessionHandle = EncapsulationCodec.ReadHeader(reply.Span).SessionHandle;
+ return c;
+ }
+
+ public async Task> SendRRDataAsync(ReadOnlyMemory cip, TimeSpan timeout, CancellationToken ct)
+ {
+ var secs = (ushort)Math.Clamp((int)Math.Ceiling(timeout.TotalSeconds), 1, ushort.MaxValue);
+ var frame = await ExchangeAsync(ctx => EncapsulationCodec.SendRRData(SessionHandle, ctx, cip.Span, secs), timeout, ct).ConfigureAwait(false);
+ return EncapsulationCodec.ParseReply(frame, expectConnected: false).Cip;
+ }
+
+ public async Task> SendUnitDataAsync(uint otConnectionId, ReadOnlyMemory cip,
+ TimeSpan timeout, CancellationToken ct)
+ {
+ // The sequence is chosen inside ExchangeAsync's gate (build runs under it), so concurrent
+ // callers can never put sequences on the wire out of order (review B1).
+ var frame = await ExchangeAsync(ctx =>
+ {
+ _seq = _seq == ushort.MaxValue ? (ushort)1 : (ushort)(_seq + 1);
+ return EncapsulationCodec.SendUnitData(SessionHandle, ctx, otConnectionId, _seq, cip.Span);
+ }, timeout, ct).ConfigureAwait(false);
+ return EncapsulationCodec.ParseReply(frame, expectConnected: true).Cip;
+ }
+
+ /// Write one frame, then read frames until the matching reply arrives. Unconnected
+ /// replies match on sender context; connected (0x70) replies match on the echoed sequence.
+ /// Non-matching frames are late replies to abandoned requests and are skipped.
+ /// Caller cancellation abandons the wait WITHOUT faulting the stream (review B2); only the
+ /// timeout, an I/O error or a malformed frame faults it.
+ private async Task> ExchangeAsync(Func build, TimeSpan timeout, CancellationToken ct)
+ {
+ if (_faulted) throw new AbTransportException(AbStatus.BadConnection, "Session is faulted; reconnect.");
+ await _gate.WaitAsync(ct).ConfigureAwait(false);
+ using var timeoutCts = new CancellationTokenSource(timeout);
+ using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutCts.Token);
+ try
+ {
+ var ctx = (ulong)Interlocked.Increment(ref _ctx);
+ var request = build(ctx);
+ var cmd = (ushort)(request[0] | (request[1] << 8));
+ var expectSeq = cmd == EncapCommand.SendUnitData ? _seq : (ushort)0; // set by build() above, under the gate
+ await _stream.WriteAsync(request, CancellationToken.None).ConfigureAwait(false);
+ while (true)
+ {
+ var frame = await ReadFrameAsync(linked.Token).ConfigureAwait(false);
+ var h = EncapsulationCodec.ReadHeader(frame.Span);
+ if (h.Command != cmd) continue;
+ if (cmd == EncapCommand.SendUnitData)
+ {
+ if (EncapsulationCodec.ParseReply(frame, expectConnected: true).Sequence != expectSeq) continue;
+ }
+ else if (h.SenderContext != ctx) continue;
+ return frame;
+ }
+ }
+ catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !ct.IsCancellationRequested)
+ { Fault(); throw new AbTransportException(AbStatus.Timeout, $"No reply within {timeout.TotalMilliseconds:F0} ms."); }
+ catch (OperationCanceledException) { throw; } // caller cancelled: keep the stream; the late reply is skipped later
+ catch (AbTransportException) { Fault(); throw; }
+ catch (Exception ex) when (ex is IOException or SocketException or ObjectDisposedException or EndOfStreamException)
+ { Fault(); throw new AbTransportException(AbStatus.BadConnection, ex.Message, ex); }
+ finally { _gate.Release(); }
+ }
+
+ private async Task> ReadFrameAsync(CancellationToken ct)
+ {
+ var head = new byte[EncapsulationHeader.Size];
+ await _stream.ReadExactlyAsync(head, ct).ConfigureAwait(false);
+ var len = head[2] | (head[3] << 8);
+ var frame = new byte[EncapsulationHeader.Size + len];
+ head.CopyTo(frame, 0);
+ await _stream.ReadExactlyAsync(frame.AsMemory(EncapsulationHeader.Size), ct).ConfigureAwait(false);
+ return frame;
+ }
+
+ private void Fault() { _faulted = true; try { _tcp.Close(); } catch { /* already closed */ } }
+
+ public async ValueTask DisposeAsync()
+ {
+ if (!_faulted && _tcp.Connected)
+ {
+ try
+ {
+ var bytes = EncapsulationCodec.UnRegisterSession(SessionHandle, 0);
+ using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));
+ await _stream.WriteAsync(bytes, cts.Token).ConfigureAwait(false);
+ }
+ catch { /* best effort */ }
+ }
+ _tcp.Dispose();
+ _gate.Dispose();
+ }
+}
+```
+
+> Note: a *caller* cancellation leaves the stream intact. The abandoned reply is skipped when it arrives. Only a timeout or an I/O fault closes the socket, and `CipConnection` (Task 9) then reconnects through the Forward Open throttle. This distinction is press safety: unsubscribing an OPC UA monitored item must not cost the press a new Forward Open.
+
+- [ ] **Step 6: Run.** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter FullyQualifiedName~Eip` → all pass.
+- [ ] **Step 7: Commit.** `git add -A src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Eip tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Eip tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Fakes && git commit -m "feat(ab-transport): EtherNet/IP encapsulation codec and single-flight session client"`
+
+---
+
+### Task 6: CIP path encoding + Logix type codes
+
+**Goal:** Pure encoders for (a) the route from a CIP path string such as `1,0` and (b) the request path for a Logix symbolic tag name. Also provide the Logix atomic type-code table that the native runtime uses for writes and for the type check.
+
+**Files:**
+- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipPath.cs`
+- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/LogixTypeCode.cs`
+- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/CipPathTests.cs`
+- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/LogixTypeCodeTests.cs`
+
+**Acceptance Criteria:**
+- [ ] `CipPath.EncodeRoute("1,0")` → `01 00`. `EncodeRoute("")` → empty. `EncodeRoute("1,0,2,10.0.0.5")` → `01 00 12 08 31 30 2E 30 2E 30 2E 35`. That is port 2 with the extended-link-address flag `0x10 | 2` = `0x12`, length 8, the ASCII text, and no pad because the length is even. A 9-character address gets one pad byte. Port numbers ≥ 15 use the extended-port form `0x0F, u16 port`.
+- [ ] `CipPath.EncodeSymbolic("TestDINT")` → `91 08 54 65 73 74 44 49 4E 54`. An odd-length name gets a pad byte after the name.
+- [ ] `EncodeSymbolic("Program:Main.Arr[3].Val")` → `91 0C "Program:Main"`, then `91 03 "Arr" 00`, then element `28 03`, then `91 03 "Val" 00`.
+- [ ] Element segments use `28 xx` for indices < 256, `29 00 lo hi` for indices < 65536, and `2A 00 b0 b1 b2 b3` above that. A multi-dimensional `[1,2]` emits two element segments.
+- [ ] A trailing purely numeric member (`Tag.5`, the bit selector) is **not** encoded. `EncodeSymbolic` throws `ArgumentException`, and `CipPath.SplitBitSelector("Tag.5")` returns `("Tag", 5)`. Bit selection is the runtime's job (Task 12).
+- [ ] `CipPath.EncodeClassInstance(cls, inst)` uses 8-bit logical segments (`20 cc 24 ii`) when they fit, otherwise 16-bit (`21 00 cc cc` / `25 00 ii ii`).
+- [ ] `CipPath.WithPathSize(segments)` prefixes the size in 16-bit words.
+- [ ] `LogixTypeCode` has these constants: `Bool=0xC1, SInt=0xC2, Int=0xC3, DInt=0xC4, LInt=0xC5, USInt=0xC6, UInt=0xC7, UDInt=0xC8, ULInt=0xC9, Real=0xCA, LReal=0xCB, Struct=0x02A0, BoolArray=0xD3`. `ByteWidth(code)` returns 1/1/2/4/8/1/2/4/8/4/8/4.
+
+**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter "FullyQualifiedName~CipPath|FullyQualifiedName~LogixTypeCode"` → pass.
+
+**Steps:**
+
+- [ ] **Step 1: Write the failing tests** for every acceptance-criteria row above. Each one is an `[InlineData(input, expectedHex)]` theory asserting `Convert.ToHexString(CipPath.EncodeX(input)).ShouldBe(expectedHex)`. For example:
+
+```csharp
+[Theory]
+[InlineData("TestDINT", "9108546573744449 4E54")]
+[InlineData("Program:Main.Arr[3].Val", "910C50726F6772616D3A4D61696E" + "910341727200" + "2803" + "910356616C00")]
+[InlineData("Big[300]", "910342696700" + "29002C01")]
+[InlineData("Grid[1,2]", "910447726964" + "2801" + "2802")]
+public void Encodes_symbolic(string name, string hex) =>
+ Convert.ToHexString(CipPath.EncodeSymbolic(name)).ShouldBe(hex.Replace(" ", ""));
+```
+
+- [ ] **Step 2: Run and confirm it fails.** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter "FullyQualifiedName~CipPath|FullyQualifiedName~LogixTypeCode"` → compile error.
+
+- [ ] **Step 3: Implement.**
+
+```csharp
+// Cip/CipPath.cs
+using System.Globalization;
+using System.Text;
+using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
+namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
+
+public static class CipPath
+{
+ /// Route path from "port,link[,port,link…]". Links that are not a byte value are IP
+ /// addresses, encoded as extended link-address port segments.
+ public static byte[] EncodeRoute(string cipPath)
+ {
+ var w = new CipWriter();
+ if (string.IsNullOrWhiteSpace(cipPath)) return [];
+ var parts = cipPath.Split(',', StringSplitOptions.TrimEntries);
+ if (parts.Length % 2 != 0) throw new ArgumentException($"CIP path '{cipPath}' must be port,link pairs.");
+ for (var i = 0; i < parts.Length; i += 2)
+ {
+ var port = int.Parse(parts[i], CultureInfo.InvariantCulture);
+ var link = parts[i + 1];
+ var isByte = byte.TryParse(link, NumberStyles.None, CultureInfo.InvariantCulture, out var linkByte);
+ if (port < 15)
+ {
+ if (isByte) w.U8((byte)port).U8(linkByte);
+ else { var a = Encoding.ASCII.GetBytes(link); w.U8((byte)(0x10 | port)).U8((byte)a.Length).Bytes(a).PadToEven(); }
+ }
+ else
+ {
+ if (isByte) w.U8(0x0F).U16((ushort)port).U8(linkByte);
+ else { var a = Encoding.ASCII.GetBytes(link); w.U8(0x1F).U8((byte)a.Length).U16((ushort)port).Bytes(a).PadToEven(); }
+ }
+ }
+ return w.ToArray();
+ }
+
+ public static (string Name, int? Bit) SplitBitSelector(string tagName)
+ {
+ var dot = tagName.LastIndexOf('.');
+ if (dot > 0 && dot < tagName.Length - 1 && tagName[(dot + 1)..].All(char.IsAsciiDigit))
+ return (tagName[..dot], int.Parse(tagName[(dot + 1)..], CultureInfo.InvariantCulture));
+ return (tagName, null);
+ }
+
+ public static byte[] EncodeSymbolic(string tagName)
+ {
+ if (SplitBitSelector(tagName).Bit is not null)
+ throw new ArgumentException($"'{tagName}' ends in a bit selector; strip it with SplitBitSelector first.");
+ var w = new CipWriter();
+ foreach (var part in SplitMembers(tagName))
+ {
+ var bracket = part.IndexOf('[');
+ var name = bracket < 0 ? part : part[..bracket];
+ var a = Encoding.ASCII.GetBytes(name);
+ w.U8(0x91).U8((byte)a.Length).Bytes(a).PadToEven();
+ if (bracket >= 0)
+ {
+ foreach (var idxText in part[(bracket + 1)..part.IndexOf(']')].Split(',', StringSplitOptions.TrimEntries))
+ {
+ var idx = uint.Parse(idxText, CultureInfo.InvariantCulture);
+ if (idx < 0x100) w.U8(0x28).U8((byte)idx);
+ else if (idx < 0x10000) w.U8(0x29).U8(0).U16((ushort)idx);
+ else w.U8(0x2A).U8(0).U32(idx);
+ }
+ }
+ }
+ return w.ToArray();
+ }
+
+ /// Split on '.' outside brackets, keeping "Program:X" whole.
+ private static IEnumerable SplitMembers(string name)
+ {
+ var depth = 0; var start = 0;
+ for (var i = 0; i < name.Length; i++)
+ {
+ if (name[i] == '[') depth++;
+ else if (name[i] == ']') depth--;
+ else if (name[i] == '.' && depth == 0) { yield return name[start..i]; start = i + 1; }
+ }
+ yield return name[start..];
+ }
+
+ public static byte[] EncodeClassInstance(ushort cls, uint instance)
+ {
+ var w = new CipWriter();
+ if (cls < 0x100) w.U8(0x20).U8((byte)cls); else w.U8(0x21).U8(0).U16(cls);
+ if (instance < 0x100) w.U8(0x24).U8((byte)instance);
+ else if (instance < 0x10000) w.U8(0x25).U8(0).U16((ushort)instance);
+ else w.U8(0x26).U8(0).U32(instance);
+ return w.ToArray();
+ }
+
+ /// Message Router (class 2, instance 1).
+ public static readonly byte[] MessageRouter = [0x20, 0x02, 0x24, 0x01];
+
+ /// Connection Manager (class 6, instance 1).
+ public static readonly byte[] ConnectionManager = [0x20, 0x06, 0x24, 0x01];
+}
+```
+
+```csharp
+// Logix/LogixTypeCode.cs
+namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Logix;
+
+public static class LogixTypeCode
+{
+ public const ushort Bool = 0xC1, SInt = 0xC2, Int = 0xC3, DInt = 0xC4, LInt = 0xC5, USInt = 0xC6,
+ UInt = 0xC7, UDInt = 0xC8, ULInt = 0xC9, Real = 0xCA, LReal = 0xCB, BoolArray = 0xD3, Struct = 0x02A0;
+
+ public static int ByteWidth(ushort code) => code switch
+ {
+ Bool or SInt or USInt => 1,
+ Int or UInt => 2,
+ DInt or UDInt or Real or BoolArray => 4,
+ LInt or ULInt or LReal => 8,
+ _ => -1,
+ };
+}
+```
+
+Fix the `"9108546573744449 4E54"` spacing in the test by stripping spaces (already done with `.Replace(" ", "")`).
+
+- [ ] **Step 4: Run.** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter "FullyQualifiedName~CipPath|FullyQualifiedName~LogixTypeCode"` → pass.
+- [ ] **Step 5: Commit.** `git commit -am`-style, with explicit paths: `git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipPath.cs src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix && git commit -m "feat(ab-transport): CIP route/symbolic path encoders and Logix type codes"`
+
+---
+
+### Task 7: CIP service codecs (Connection Manager, MSP, Logix Read/Write, symbol + template)
+
+**Goal:** Pure request builders and reply parsers for every CIP service the native AbCip path needs, each proven byte-exact against the Task 4 captures where a capture exists.
+
+**Files:**
+- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ConnectionManagerServices.cs` (Unconnected Send 0x52, Forward Open 0x54, Large Forward Open 0x5B, Forward Close 0x4E)
+- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/MultipleServicePacket.cs` (0x0A)
+- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/LogixServices.cs` (Read Tag 0x4C, Read Tag Fragmented 0x52, Write Tag 0x4D, Write Tag Fragmented 0x53)
+- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/SymbolObjectServices.cs` (Get Instance Attribute List 0x55 on class 0x6B)
+- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/TemplateObjectServices.cs` (Get Attribute List 0x03 + Read 0x4C on class 0x6C)
+- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/ConnectionManagerServicesTests.cs`
+- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/MultipleServicePacketTests.cs`
+- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/LogixServicesTests.cs`
+- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/SymbolObjectServicesTests.cs`
+- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/TemplateObjectServicesTests.cs`
+
+**Acceptance Criteria:**
+- [ ] **Unconnected Send** `0x52` to `ConnectionManager` builds: priority/tick `0x0A`, timeout ticks `0x0E`, embedded request size `u16`, the embedded request, a pad byte if that size is odd, the route path size in words `u8`, reserved `0x00`, then the route. When the route is empty (Micro800), the builder is not used and the embedded request is sent directly.
+- [ ] **Forward Open.** `ForwardOpen(ForwardOpenParams p)` emits:
+ - service `0x54` (or `0x5B` when `p.Large`), path size `02`, `ConnectionManager`, priority/tick `0x0A`, timeout ticks `0x0E`;
+ - O→T id `00000000`, T→O id `p.TOConnectionId`, connection serial `p.ConnectionSerial`, vendor id `p.VendorId`, originator serial `p.OriginatorSerial`;
+ - timeout multiplier `p.TimeoutMultiplier`, 3 reserved bytes;
+ - O→T RPI `u32`, then O→T params: `u16 0x4200 | size` (regular) or `u32 0x42000000 | size` (large);
+ - T→O RPI, then T→O params (same encoding);
+ - transport/trigger `0xA3`, connection path size in words, connection path = `EncodeRoute(cipPath)` + `MessageRouter`.
+- [ ] `ParseForwardOpenReply` returns `(OTConnectionId, TOConnectionId, OTApiMicros, TOApiMicros)` on success. On a refusal it surfaces the general and extended status.
+- [ ] `ShouldFallBackToRegularOpen(CipReply)` is true when the reply is general `0x08` (Service Not Supported — pre-v20 Logix and other targets answer a Large Forward Open this way, and libplctag falls back on it too) **or** general `0x01` with extended `0x0109` (invalid connection size). Task 9 uses it.
+- [ ] `ForwardClose(serial, vendor, origSerial, route)` → `4E 02 20 06 24 01 0A 0E 00 `.
+- [ ] **MSP.** `MultipleServicePacket.Build(IReadOnlyList requests)` → `0A 02 20 02 24 01`, count `u16`, then offsets `u16[count]` measured from the start of the count field, then the requests concatenated. `Parse(reply, count)` returns one `CipReply` per embedded reply. `BuildSize(sizes)` returns the exact byte size of an MSP for the given request sizes, without allocating (Task 10 uses it for packing).
+- [ ] **Read/Write Tag:**
+ - `ReadTag(path, elements)` → `4C `.
+ - `ReadTagFragmented(path, elements, offset)` → `52 … `.
+ - `WriteTag(path, typeCode, structHandle?, elements, data)` → `4D … [ when type == 0x02A0] `.
+ - `WriteTagFragmented` → `53 … [] `.
+ - `ParseReadReply(CipReply)` → `(ushort TypeCode, ushort? StructHandle, ReadOnlyMemory Data)`. For `0x02A0` the next u16 is the handle.
+- [ ] **Symbol listing.** `SymbolObjectServices.GetInstanceAttributeList(programName?, startInstance)` →
+ - `[91 len "Program:X" pad]`, then `20 6B 25 00 `, then attribute count `04 00` and attributes `02 00 07 00 08 00 01 00` (type, element length, array dims, name).
+ - Each reply entry, as the controller sends it, is `u32 instance, u16 type, u16 elemLen, u32×3 dims, u16 nameLen, name` — exactly the entry layout `CipSymbolObjectDecoder` expects. `ParseListingReply` returns `(byte[] Entries, uint LastInstance, bool More)`, where More ⇔ general status `0x06`.
+ - ⚠️ If `Captures/logix-tags-listing.hex` shows libplctag requesting a different attribute set or order, **match the capture** and update this criterion. The decoder layout is the invariant; the attribute list is the means.
+- [ ] **Templates:**
+ - `TemplateObjectServices.GetAttributes(templateId)` → `03 03 20 6C 25 00 04 00 04 00 05 00 02 00 01 00`. That is attributes 4 (definition size, words), 5 (structure size, bytes), 2 (member count) and 1 (handle).
+ - `ParseAttributes` → `(uint DefWords, uint StructBytes, ushort Members, ushort Handle)`; each attribute's reply is `id u16, status u16, value`.
+ - `ReadTemplate(templateId, offset, bytes)` → `4C 03 20 6C 25 00 `, with More ⇔ status `0x06`.
+ - `TemplateObjectServices.BuildDecoderBuffer(id, attrs, rawTemplate)` → the 14-byte header + raw bytes from Global Constraints.
+- [ ] **Capture parity tests** (each extracts the CIP payload via `CaptureFile.CipPayload`):
+ - The ReadTag request in `logix-read-dint.hex` equals our `ReadTag(EncodeSymbolic("TestDINT"), 1)`. If libplctag wrapped it in an MSP or Unconnected Send, unwrap the same way in the test, and document that wrapping in a test comment.
+ - `ParseReadReply` on the captured reply yields `TypeCode == 0xC4` and 4 data bytes.
+ - The captured Forward Open request equals ours when built with the captured values of the serials and connection ids, which are read out of the capture.
+ - The captured `@tags` reply entries decode through `CipSymbolObjectDecoder` (reference the AbCip project from the test project only if that is needed; otherwise assert the entry byte layout directly).
+
+**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter "FullyQualifiedName~Services|FullyQualifiedName~MultipleServicePacket"` → pass.
+
+**Steps:**
+
+- [ ] **Step 1: Write the golden-byte tests (failing)** for each builder above, one `[Fact]` per acceptance row. For example:
+
+```csharp
+[Fact]
+public void Forward_close_frame()
+{
+ var bytes = ConnectionManagerServices.ForwardClose(connectionSerial: 0x1234, vendorId: 0x1337, originatorSerial: 0xCAFEBABE, route: [0x01, 0x00]);
+ Convert.ToHexString(bytes).ShouldBe("4E02200624010A0E" + "3412" + "3713" + "BEBAFECA" + "03" + "00" + "0100" + "20022401");
+}
+
+[Fact]
+public void Msp_offsets_are_measured_from_the_count_field()
+{
+ var a = new byte[] { 0x4C, 0x02, 0x91, 0x01, 0x41, 0x00, 0x01, 0x00 }; // 8 bytes
+ var b = new byte[] { 0x4C, 0x02, 0x91, 0x01, 0x42, 0x00, 0x01, 0x00 };
+ Convert.ToHexString(MultipleServicePacket.Build([a, b])).ShouldBe(
+ "0A02200224010200" + "0600" + "0E00" + Convert.ToHexString(a) + Convert.ToHexString(b));
+ MultipleServicePacket.BuildSize([8, 8]).ShouldBe(6 + 2 + 4 + 16);
+}
+
+[Fact]
+public void Read_reply_with_struct_handle()
+{
+ var reply = CipReply.Parse(Convert.FromHexString("CC000000" + "A002" + "CE0F" + "05000000" + "48656C6C6F"));
+ var (type, handle, data) = LogixServices.ParseReadReply(reply);
+ type.ShouldBe((ushort)0x02A0); handle.ShouldBe((ushort)0x0FCE); data.Length.ShouldBe(9);
+}
+```
+
+- [ ] **Step 2: Run and confirm failure.** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter "FullyQualifiedName~Services|FullyQualifiedName~MultipleServicePacket"` → compile error.
+
+- [ ] **Step 3: Implement.** The core shapes follow; complete every builder named in the acceptance criteria.
+
+```csharp
+// Cip/ConnectionManagerServices.cs
+using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
+namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
+
+public sealed record ForwardOpenParams(
+ bool Large, uint TOConnectionId, ushort ConnectionSerial, ushort VendorId, uint OriginatorSerial,
+ byte TimeoutMultiplier, uint RpiMicros, int ConnectionSize, byte[] Route);
+
+public sealed record ForwardOpenReply(uint OTConnectionId, uint TOConnectionId, uint OTApiMicros, uint TOApiMicros);
+
+public static class ConnectionManagerServices
+{
+ public const byte PriorityTick = 0x0A, TimeoutTicks = 0x0E;
+
+ public static byte[] UnconnectedSend(ReadOnlySpan embedded, ReadOnlySpan route)
+ {
+ var w = new CipWriter().U8(0x52).U8(2).Bytes(CipPath.ConnectionManager)
+ .U8(PriorityTick).U8(TimeoutTicks).U16((ushort)embedded.Length).Bytes(embedded);
+ if ((embedded.Length & 1) == 1) w.U8(0);
+ return w.U8((byte)(route.Length / 2)).U8(0).Bytes(route).ToArray();
+ }
+
+ public static byte[] ForwardOpen(ForwardOpenParams p)
+ {
+ var connPath = new CipWriter().Bytes(p.Route).Bytes(CipPath.MessageRouter).ToArray();
+ var w = new CipWriter().U8(p.Large ? (byte)0x5B : (byte)0x54).U8(2).Bytes(CipPath.ConnectionManager)
+ .U8(PriorityTick).U8(TimeoutTicks)
+ .U32(0).U32(p.TOConnectionId).U16(p.ConnectionSerial).U16(p.VendorId).U32(p.OriginatorSerial)
+ .U8(p.TimeoutMultiplier).U8(0).U8(0).U8(0);
+ WriteSide(w, p);
+ WriteSide(w, p);
+ return w.U8(0xA3).U8((byte)(connPath.Length / 2)).Bytes(connPath).ToArray();
+ }
+
+ private static void WriteSide(CipWriter w, ForwardOpenParams p)
+ {
+ w.U32(p.RpiMicros);
+ if (p.Large) w.U32(0x42000000u | (uint)p.ConnectionSize);
+ else w.U16((ushort)(0x4200 | (p.ConnectionSize & 0x1FF)));
+ }
+
+ public static ForwardOpenReply ParseForwardOpenReply(CipReply reply)
+ {
+ if (reply.GeneralStatus != 0)
+ throw new AbTransportException(reply.Status == AbStatus.BadReply ? AbStatus.BadConnection : reply.Status,
+ $"Forward Open refused: general 0x{reply.GeneralStatus:X2}, extended {string.Join(",", reply.ExtendedStatus.Select(e => $"0x{e:X4}"))}.");
+ var r = new CipReader(reply.Data.Span);
+ var ot = r.U32(); var to = r.U32(); r.U16(); r.U16(); r.U32();
+ return new ForwardOpenReply(ot, to, r.U32(), r.U32());
+ }
+
+ public static byte[] ForwardClose(ushort connectionSerial, ushort vendorId, uint originatorSerial, ReadOnlySpan route)
+ {
+ var connPath = new CipWriter().Bytes(route).Bytes(CipPath.MessageRouter).ToArray();
+ return new CipWriter().U8(0x4E).U8(2).Bytes(CipPath.ConnectionManager).U8(PriorityTick).U8(TimeoutTicks)
+ .U16(connectionSerial).U16(vendorId).U32(originatorSerial)
+ .U8((byte)(connPath.Length / 2)).U8(0).Bytes(connPath).ToArray();
+ }
+}
+```
+
+> The large-open fallback needs the raw statuses, so add:
+> `public static bool ShouldFallBackToRegularOpen(CipReply r) => r.GeneralStatus == 0x08 || (r.GeneralStatus == 0x01 && r.ExtendedStatus.Length > 0 && r.ExtendedStatus[0] == 0x0109);`
+> Test both branches, plus a negative case (`0x01`/`0x0100` "connection in use" must NOT fall back).
+
+```csharp
+// Logix/LogixServices.cs
+using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
+using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
+namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Logix;
+
+public static class LogixServices
+{
+ private static CipWriter Start(byte service, ReadOnlySpan path) =>
+ new CipWriter().U8(service).U8((byte)(path.Length / 2)).Bytes(path);
+
+ public static byte[] ReadTag(ReadOnlySpan path, ushort elements) => Start(0x4C, path).U16(elements).ToArray();
+ public static byte[] ReadTagFragmented(ReadOnlySpan path, ushort elements, uint offset) =>
+ Start(0x52, path).U16(elements).U32(offset).ToArray();
+
+ public static byte[] WriteTag(ReadOnlySpan path, ushort type, ushort? handle, ushort elements, ReadOnlySpan data)
+ {
+ var w = Start(0x4D, path).U16(type);
+ if (type == LogixTypeCode.Struct) w.U16(handle ?? throw new ArgumentException("Struct write needs a handle."));
+ return w.U16(elements).Bytes(data).ToArray();
+ }
+
+ public static byte[] WriteTagFragmented(ReadOnlySpan path, ushort type, ushort? handle, ushort elements, uint offset, ReadOnlySpan data)
+ {
+ var w = Start(0x53, path).U16(type);
+ if (type == LogixTypeCode.Struct) w.U16(handle ?? throw new ArgumentException("Struct write needs a handle."));
+ return w.U16(elements).U32(offset).Bytes(data).ToArray();
+ }
+
+ public static (ushort TypeCode, ushort? StructHandle, ReadOnlyMemory Data) ParseReadReply(CipReply reply)
+ {
+ var r = new CipReader(reply.Data.Span);
+ var type = r.U16();
+ ushort? handle = type == LogixTypeCode.Struct ? r.U16() : null;
+ return (type, handle, reply.Data[r.Position..]);
+ }
+}
+```
+
+Implement `MultipleServicePacket`, `SymbolObjectServices` and `TemplateObjectServices` to the byte layouts in the acceptance criteria, using `CipWriter`/`CipReader` the same way.
+
+- [ ] **Step 4: Write and run the capture-parity tests.** If a parity test fails because libplctag sends something different from what this plan specifies (for example a different timeout-ticks byte, or different symbol attributes), **libplctag ⇄ ab_server is the parity reference for framing constants**: change the constant to match, and record the before/after in the test comment. Never change a layout the existing real-press decoder tests rely on.
+- [ ] **Step 5: Run.** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests` → all pass.
+- [ ] **Step 6: Commit.** `git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix && git commit -m "feat(ab-transport): Connection Manager, MSP, Logix read/write, symbol and template service codecs"`
+
+---
+
+### Task 8: PCCC codec (Execute PCCC, SLC/MicroLogix typed logical, PLC-5 typed)
+
+**Goal:** Pure builders and parsers that turn a PCCC file address into an Execute-PCCC CIP request, for both SLC/MicroLogix/LogixPccc and PLC-5. Byte parity is proven against the Task 4 captures.
+
+**Files:**
+- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Pccc/PcccFileType.cs`
+- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Pccc/PcccAddress.cs`
+- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Pccc/PcccServices.cs`
+- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Pccc/PcccServicesTests.cs`
+- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Pccc/PcccAddressTests.cs`
+
+**Acceptance Criteria:**
+- [ ] `PcccAddress(string FileLetter, int? FileNumber, int Element, int? SubElement)` is the transport-side address. It does not depend on `AbLegacyAddress`; Task 14 maps between the two.
+- [ ] File type codes: `O=0x8B, I=0x8C, S=0x84, B=0x85, T=0x86, C=0x87, R=0x88, N=0x89, F=0x8A, ST=0x8D, A=0x8E, L=0x91`. Implicit file numbers: `O→0, I→1, S→2`. **Verify every code against `slc500-read-n7.hex` / `slc500-read-f8.hex`**; the capture wins if it disagrees.
+- [ ] Sub-element indices: `T/C` → `PRE=1, ACC=2` (word offsets within the 3-word element, where word 0 is the control bits), and `R` → `LEN=1, POS=2`. The bit sub-elements (`EN/TT/DN/CU/CD/OV/UN`) read word 0 and are returned as `(sub=0, bit)`. `PcccAddress.ResolveSubElement` returns `(int word, int? bit)`.
+- [ ] `PcccServices.ExecutePccc(requestorVendor, requestorSerial, pccc)` → `4B 02 20 67 24 01 07 `.
+- [ ] `PcccServices.SlcTypedRead(tns, addr, byteCount)` → PCCC `0F 00 A2 `, where `u8*` is a single byte when < 255, else `FF lo hi`.
+- [ ] `PcccServices.SlcTypedWrite(tns, addr, data)` → `0F 00 AA `.
+- [ ] **Byte-count limit (review finding 12).** `byteCount` is a single byte on the wire. `SlcTypedRead`/`SlcTypedWrite` (and the PLC-5 builders) throw `AbTransportException(TooLarge)` when the requested or supplied byte count exceeds `PcccServices.MaxDataBytes = 236`; a silent `(byte)` wrap would be a corrupt write. Test 236 (ok) and 237 (throws) for both. Task 14 chunks larger arrays.
+- [ ] `PcccServices.Plc5TypedRead(tns, addr, elements)` → `0F 00 68 `.
+ - The system address is a level-mask byte (bit0 = file level present? **derive from `plc5-read-n7.hex`**) followed by level values (`$N7:0` ⇒ data table section, file 7, element 0), each `u8*` encoded.
+ - `Plc5TypedWrite` is `0F 00 67 `.
+ - ⚠️ The PLC-5 framing must be **copied from the capture**. If ab_server's `--plc=PLC/5` path is not exercised by libplctag in the capture, do **not** ship PLC-5 builders at all. Instead expose `PcccServices.Plc5Verified = false`, have the native AbLegacy runtime throw `AbTransportException(Unsupported)` for `plc5`, and record that in the Task 20 docs. Do not use `[Obsolete]`: under `TreatWarningsAsErrors` it is a build error at every call site.
+- [ ] `PcccServices.ParseReply(cipReply)` strips the requestor-id echo (`len u8` + `len-1` bytes), reads `cmd u8 (0x4F), sts u8, tns u16`, then `extSts u8` if `sts == 0xF0`, then the data. Non-zero STS → `AbTransportException(AbStatusCodes.FromPccc(sts, ext))`. A TNS that differs from the request → `BadReply`.
+- [ ] Capture parity: `SlcTypedRead` for `N7:0` / `F8:0` equals the PCCC payload inside the captured Execute PCCC request, with the TNS normalized; `ParseReply` on the captured reply yields the 2 / 4 data bytes.
+
+**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter "FullyQualifiedName~Pccc"` → pass.
+
+**Steps:**
+
+- [ ] **Step 1: Read the captures first.** Decode `slc500-read-n7.hex` by hand (write the decode as comments in `PcccServicesTests`) to fix the exact byte for each field. Check: is the request inside an Unconnected Send? Is it connected via Forward Open? What vendor/serial does libplctag put in the requestor id? This decode is the spec for the steps below.
+- [ ] **Step 2: Write the failing tests:** golden bytes for `ExecutePccc`, `SlcTypedRead(N7:0, 2)`, `SlcTypedRead(F8:3, 4)`, `SlcTypedWrite(N7:0, [0x2A,0x00])`, an element ≥ 255 (`N7:300` ⇒ `FF 2C 01`), and the `T4:0.ACC` sub-element 2. Parse tests for success, `sts=0x10` ⇒ `Unsupported`, `sts=0xF0 ext=0x06` ⇒ `NotFound`, and a TNS mismatch ⇒ `BadReply`. Add the capture-parity facts.
+- [ ] **Step 3: Run and confirm failure.** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter FullyQualifiedName~Pccc` → compile error.
+- [ ] **Step 4: Implement.**
+
+```csharp
+// Pccc/PcccServices.cs (core)
+using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
+using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
+namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Pccc;
+
+public static class PcccServices
+{
+ public static byte[] ExecutePccc(ushort vendor, uint serial, ReadOnlySpan pccc) =>
+ new CipWriter().U8(0x4B).U8(2).U8(0x20).U8(0x67).U8(0x24).U8(0x01)
+ .U8(7).U16(vendor).U32(serial).Bytes(pccc).ToArray();
+
+ private static void Field(CipWriter w, int v)
+ {
+ if (v < 0xFF) w.U8((byte)v); else w.U8(0xFF).U16((ushort)v);
+ }
+
+ public static byte[] SlcTypedRead(ushort tns, PcccAddress a, byte byteCount)
+ {
+ var w = new CipWriter().U8(0x0F).U8(0).U16(tns).U8(0xA2).U8(byteCount);
+ Field(w, a.EffectiveFileNumber); w.U8(a.FileTypeCode); Field(w, a.Element); Field(w, a.ResolveSubElement().Word);
+ return w.ToArray();
+ }
+
+ public static byte[] SlcTypedWrite(ushort tns, PcccAddress a, ReadOnlySpan data)
+ {
+ var w = new CipWriter().U8(0x0F).U8(0).U16(tns).U8(0xAA).U8((byte)data.Length);
+ Field(w, a.EffectiveFileNumber); w.U8(a.FileTypeCode); Field(w, a.Element); Field(w, a.ResolveSubElement().Word);
+ return w.Bytes(data).ToArray();
+ }
+
+ public static ReadOnlyMemory ParseReply(CipReply reply, ushort expectedTns)
+ {
+ if (reply.GeneralStatus != 0)
+ throw new AbTransportException(reply.Status, $"Execute PCCC CIP status 0x{reply.GeneralStatus:X2}.");
+ var span = reply.Data.Span;
+ var r = new CipReader(span);
+ var idLen = r.U8(); r.Bytes(idLen - 1);
+ r.U8(); // cmd | 0x40
+ var sts = r.U8();
+ var tns = r.U16();
+ byte? ext = sts == 0xF0 ? r.U8() : null;
+ if (sts != 0) throw new AbTransportException(AbStatusCodes.FromPccc(sts, ext), $"PCCC STS 0x{sts:X2}{(ext is { } e ? $" EXT 0x{e:X2}" : "")}.");
+ if (tns != expectedTns) throw new AbTransportException(AbStatus.BadReply, $"PCCC TNS {tns} != {expectedTns}.");
+ return reply.Data[r.Position..];
+ }
+}
+```
+
+`PcccAddress` exposes `FileTypeCode` (from the table), `EffectiveFileNumber` (explicit, or the implicit number for O/I/S), and `ResolveSubElement()`. The PLC-5 builders come from the capture decode in Step 1.
+
+- [ ] **Step 5: Run.** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter FullyQualifiedName~Pccc` → pass.
+- [ ] **Step 6: Commit.** `git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Pccc tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Pccc && git commit -m "feat(ab-transport): PCCC Execute-PCCC codec for SLC/MicroLogix typed logical and PLC-5 typed access"`
+
+---
+
+### Task 9: CipConnection + DeviceSessionPool (routing modes, keep-alive, press-safety bounds)
+
+**Goal:** Own one EIP session, one CIP connection (when routed `Connected`) and **one `RequestScheduler`** per device key. Hand out ref-counted leases exposing both, keep idle class-3 connections alive, and rate-limit Forward Opens so that no failure mode can storm a PLC.
+
+**Files:**
+- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/DeviceKey.cs`
+- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ConnectionOptions.cs` (includes the `CipRouting` enum)
+- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipConnection.cs` (implements `ICipChannel`)
+- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ForwardOpenThrottle.cs`
+- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/DeviceSessionPool.cs` (includes `ConnectionLease`)
+- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.csproj` (`InternalsVisibleTo` for `ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests`, `ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests`, `ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests`)
+- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/CipConnectionTests.cs`
+- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/ForwardOpenThrottleTests.cs`
+- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/DeviceSessionPoolTests.cs`
+- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Fakes/FakeCipTarget.cs` (a `FakeEipServer.Handler` that answers Forward Open / Forward Close / Unconnected Send / Direct / connected data, and counts each)
+
+**Acceptance Criteria:**
+- [ ] `DeviceKey(string Host, int Port, string CipPath, string PlcAttribute)` is a record with case-insensitive `Host`/`PlcAttribute` equality.
+- [ ] **`ConnectionOptions`:**
+ - `Routing` (`CipRouting { Connected, UnconnectedSend, Direct }`) and `AllowPacking` (bool).
+ - `Timeout` and `ConnectionSize` (int).
+ - `MinForwardOpenInterval` (default 1 s).
+ - `RpiMicros` (default 2 000 000) and `TimeoutMultiplier` (default 1 ⇒ ×8, so connection expiry = 16 s).
+ - `KeepAliveInterval` (default = RPI × 8 / 2 = 8 s).
+ - `VendorId` (`0x1337`) and `OriginatorSerial` (random per process).
+- [ ] **Routing semantics (B3):**
+ - `Connected`:
+ - On first use: TCP connect, RegisterSession, Forward Open. When `ConnectionSize > 511` that is a Large FO (0x5B); otherwise a regular one.
+ - If `ShouldFallBackToRegularOpen` (T7) is true for the reply, it retries **once**, in the same throttled attempt, with a regular open at `Math.Min(ConnectionSize, 504)`.
+ - Requests go via `SendUnitDataAsync`. The client assigns the sequence (T5).
+ - `MaxRequestBytes = negotiatedSize - 2`.
+ - `UnconnectedSend`: requests go via SendRRData wrapped in Unconnected Send 0x52 with the route. `MaxRequestBytes = 504 - (12 + route.Length)`.
+ - `Direct`: requests go via SendRRData carrying the CIP request bare, even when a CIP path is configured; the path is ignored. `MaxRequestBytes = 504`.
+- [ ] **Reconnect:** when the client is faulted (timeout or I/O, never caller cancellation — see T5), the next `SendAsync` reconnects through the throttle. The failing call itself throws its original exception.
+- [ ] **Keep-alive (review finding 8, spec §9).** In `Connected` mode, a timer sends Get Attribute Single (`0E 03 20 01 24 01 30 01`: Identity, instance 1, attribute 1 = Vendor ID) through the connection whenever no request has been sent for `KeepAliveInterval`.
+ - A keep-alive failure faults the connection like any other.
+ - Keep-alives count towards `RequestsSent`.
+ - Test with fake time: no keep-alive while traffic flows; exactly one after an idle `KeepAliveInterval`.
+- [ ] **No null-race (finding 16).** The open path returns the `(client, connection)` pair it established, and `SendAsync` uses that pair rather than re-reading fields after the lock.
+- [ ] **Clean disposal.** Disposal never disposes a `SemaphoreSlim` that waiters may still be queued on. It sets a `_disposed` flag checked under the lock, and later callers get `AbTransportException(Abort)`.
+- [ ] **`ForwardOpenThrottle`** (per `DeviceKey`; the pool keeps throttles for the process lifetime, so a reconnect after the last release is still limited):
+ - At most 1 open attempt in flight.
+ - An attempt within `MinForwardOpenInterval` of the previous **attempt start** waits for the remainder. If the caller's token fires first, it throws `AbTransportException(BadConnection, "Forward Open throttled")`.
+ - It uses an injected `TimeProvider`.
+- [ ] **Storm test:** against a `FakeCipTarget` refusing every Forward Open, 50 concurrent `SendAsync` callers over 3 s of fake time produce **≤ 4** Forward Open frames.
+- [ ] **`DeviceSessionPool`:**
+ - `Acquire(DeviceKey, ConnectionOptions) → ConnectionLease`. `ConnectionLease : IAsyncDisposable` exposes `ICipChannel Channel` and `RequestScheduler Scheduler`.
+ - **All leases for one key share one `CipConnection` and one `RequestScheduler`** (B1). The scheduler is built as `new RequestScheduler(connection, options.AllowPacking)`. A second `Acquire` with different `ConnectionOptions` for the same key reuses the first entry's options; that is logged once and pinned by a test.
+ - The last lease's dispose disposes the scheduler, sends Forward Close (`Connected` only) and then UnRegisterSession.
+ - A later acquire on the same key builds new instances.
+ - Thread-safe: 100 parallel acquire/dispose tasks pass.
+- [ ] `DeviceSessionPool.Shared` is the process-wide instance; tests build their own.
+- [ ] **Diagnostics for the gates (Tasks 18 and 21):** `internal static int ForwardOpenCount` (one increment per Forward Open frame sent, including the fallback) and `internal static int RequestsSent` (one per channel send, keep-alives included), both via `Interlocked`.
+
+**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter "FullyQualifiedName~CipConnection|FullyQualifiedName~ForwardOpenThrottle|FullyQualifiedName~DeviceSessionPool"` → pass; the storm test passes 20/20 consecutive runs.
+
+**Steps:**
+
+- [ ] **Step 1: Write the failing tests.**
+ - Use `ManualTimeProvider : TimeProvider` in `Fakes/` (about 20 lines: settable `UtcNow`, plus timers fired by `Advance(TimeSpan)`). Check `Directory.Packages.props` for `Microsoft.Extensions.TimeProvider.Testing` first; use `FakeTimeProvider` if it is already pinned, and **never add a package**.
+ - Write these tests:
+ - `Second_attempt_inside_interval_waits`
+ - `Attempt_after_interval_proceeds_immediately`
+ - `Only_one_attempt_in_flight`
+ - `Refusing_target_sees_at_most_one_forward_open_per_interval`
+ - `Large_open_service_not_supported_falls_back_to_regular_open_once`
+ - `Large_open_bad_size_falls_back_to_regular_open_once`
+ - `Connection_in_use_does_not_fall_back`
+ - `Direct_routing_sends_bare_cip_even_with_a_path`
+ - `UnconnectedSend_routing_wraps_in_0x52`
+ - `Connected_routing_never_sends_bare`
+ - `Caller_cancellation_does_not_reconnect` (cancel mid-request, then send again ⇒ `ForwardOpenCount` unchanged)
+ - `Timeout_reconnects_through_the_throttle`
+ - `Idle_connection_sends_one_keepalive_per_interval`
+ - `Traffic_suppresses_keepalive`
+ - `Leases_for_one_key_share_one_scheduler`
+ - `Last_lease_dispose_sends_forward_close_then_unregister`
+ - `Concurrent_acquire_and_release_is_safe`
+ - `Send_after_dispose_throws_Abort`
+- [ ] **Step 2: Run and confirm failure.** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter "FullyQualifiedName~CipConnection|FullyQualifiedName~ForwardOpenThrottle|FullyQualifiedName~DeviceSessionPool"` → compile error.
+- [ ] **Step 3: Implement the throttle.**
+
+```csharp
+// Cip/ForwardOpenThrottle.cs
+namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
+
+/// Press safety: at most one Forward Open attempt in flight per device and a minimum
+/// interval between attempt starts. A misconfigured client once drove ~15 Forward Opens/s at a
+/// production press (ignitionoee vite-press-oee-bind.md, 2026-09-23); this is what prevents it.
+public sealed class ForwardOpenThrottle(TimeSpan minInterval, TimeProvider time)
+{
+ private readonly SemaphoreSlim _inFlight = new(1, 1);
+ private DateTimeOffset? _lastStart;
+
+ public async Task RunAsync(Func> open, CancellationToken ct)
+ {
+ await _inFlight.WaitAsync(ct).ConfigureAwait(false);
+ try
+ {
+ if (_lastStart is { } last)
+ {
+ var wait = last + minInterval - time.GetUtcNow();
+ if (wait > TimeSpan.Zero)
+ {
+ try { await Task.Delay(wait, time, ct).ConfigureAwait(false); }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested)
+ { throw new AbTransportException(AbStatus.BadConnection, "Forward Open throttled; caller deadline reached."); }
+ }
+ }
+ _lastStart = time.GetUtcNow();
+ return await open(ct).ConfigureAwait(false);
+ }
+ finally { _inFlight.Release(); }
+ }
+}
+```
+
+The throttle's semaphore is never disposed: throttles live for the process.
+
+- [ ] **Step 4: Implement `CipConnection`.**
+
+```csharp
+// Cip/CipConnection.cs
+namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
+
+public sealed class CipConnection : ICipChannel, IAsyncDisposable
+{
+ internal static int ForwardOpenCount;
+ internal static int RequestsSent;
+
+ private sealed record Open(EncapsulationClient Client, ForwardOpenReply? Conn);
+
+ private readonly DeviceKey _key;
+ private readonly ConnectionOptions _o;
+ private readonly ForwardOpenThrottle _throttle;
+ private readonly TimeProvider _time;
+ private readonly byte[] _route;
+ private readonly SemaphoreSlim _state = new(1, 1); // never disposed (see DisposeAsync)
+ private readonly ITimer? _keepAlive;
+ private Open? _open;
+ private int _negotiatedSize;
+ private long _lastSendTicks;
+ private bool _disposed;
+ private readonly ushort _connSerial = (ushort)Random.Shared.Next(1, ushort.MaxValue);
+ private readonly uint _toId = (uint)Random.Shared.Next();
+
+ public CipConnection(DeviceKey key, ConnectionOptions o, ForwardOpenThrottle throttle, TimeProvider time)
+ {
+ _key = key; _o = o; _throttle = throttle; _time = time;
+ _route = CipPath.EncodeRoute(key.CipPath);
+ if (o.Routing == CipRouting.Connected)
+ _keepAlive = time.CreateTimer(_ => _ = KeepAliveAsync(), null, o.KeepAliveInterval, o.KeepAliveInterval);
+ }
+
+ public int MaxRequestBytes => _o.Routing switch
+ {
+ CipRouting.Connected => (_negotiatedSize > 0 ? _negotiatedSize : Math.Min(_o.ConnectionSize, 504)) - 2,
+ CipRouting.UnconnectedSend => 504 - (12 + _route.Length),
+ _ => 504,
+ };
+
+ public async Task> SendAsync(ReadOnlyMemory cip, CancellationToken ct)
+ {
+ var open = await EnsureOpenAsync(ct).ConfigureAwait(false);
+ Interlocked.Increment(ref RequestsSent);
+ Interlocked.Exchange(ref _lastSendTicks, _time.GetTimestamp());
+ return _o.Routing switch
+ {
+ CipRouting.Connected => await open.Client.SendUnitDataAsync(open.Conn!.OTConnectionId, cip, _o.Timeout, ct).ConfigureAwait(false),
+ CipRouting.UnconnectedSend => await open.Client.SendRRDataAsync(ConnectionManagerServices.UnconnectedSend(cip.Span, _route), _o.Timeout, ct).ConfigureAwait(false),
+ _ => await open.Client.SendRRDataAsync(cip, _o.Timeout, ct).ConfigureAwait(false),
+ };
+ }
+
+ private async Task KeepAliveAsync()
+ {
+ if (_disposed || _open is not { Client.IsConnected: true }) return;
+ if (_time.GetElapsedTime(Interlocked.Read(ref _lastSendTicks)) < _o.KeepAliveInterval) return;
+ try { await SendAsync(new byte[] { 0x0E, 0x03, 0x20, 0x01, 0x24, 0x01, 0x30, 0x01 }, CancellationToken.None).ConfigureAwait(false); }
+ catch (AbTransportException) { /* the client is now faulted; the next real send reconnects through the throttle */ }
+ }
+
+ private async Task EnsureOpenAsync(CancellationToken ct)
+ {
+ if (_open is { } o && o.Client.IsConnected) return o;
+ await _state.WaitAsync(ct).ConfigureAwait(false);
+ try
+ {
+ if (_disposed) throw new AbTransportException(AbStatus.Abort, "Connection disposed.");
+ if (_open is { } o2 && o2.Client.IsConnected) return o2;
+ return _open = await _throttle.RunAsync(OpenAsync, ct).ConfigureAwait(false);
+ }
+ finally { _state.Release(); }
+ }
+
+ private async Task OpenAsync(CancellationToken ct)
+ {
+ if (_open is not null) { await _open.Client.DisposeAsync().ConfigureAwait(false); _open = null; }
+ var client = await EncapsulationClient.ConnectAsync(_key.Host, _key.Port, _o.Timeout, ct).ConfigureAwait(false);
+ if (_o.Routing != CipRouting.Connected) return new Open(client, null);
+ try
+ {
+ var large = _o.ConnectionSize > 511;
+ _negotiatedSize = _o.ConnectionSize;
+ var reply = await ForwardOpenAsync(client, large, _negotiatedSize, ct).ConfigureAwait(false);
+ if (large && ConnectionManagerServices.ShouldFallBackToRegularOpen(reply))
+ {
+ _negotiatedSize = Math.Min(_o.ConnectionSize, 504);
+ reply = await ForwardOpenAsync(client, false, _negotiatedSize, ct).ConfigureAwait(false);
+ }
+ return new Open(client, ConnectionManagerServices.ParseForwardOpenReply(reply));
+ }
+ catch { await client.DisposeAsync().ConfigureAwait(false); throw; }
+ }
+
+ private async Task ForwardOpenAsync(EncapsulationClient client, bool large, int size, CancellationToken ct)
+ {
+ Interlocked.Increment(ref ForwardOpenCount);
+ var p = new ForwardOpenParams(large, _toId, _connSerial, _o.VendorId, _o.OriginatorSerial, _o.TimeoutMultiplier, _o.RpiMicros, size, _route);
+ return CipReply.Parse(await client.SendRRDataAsync(ConnectionManagerServices.ForwardOpen(p), _o.Timeout, ct).ConfigureAwait(false));
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ await _state.WaitAsync().ConfigureAwait(false);
+ Open? open;
+ try { _disposed = true; open = _open; _open = null; }
+ finally { _state.Release(); }
+ if (_keepAlive is not null) await _keepAlive.DisposeAsync().ConfigureAwait(false);
+ if (open is null) return;
+ if (open.Conn is not null && open.Client.IsConnected)
+ {
+ try
+ {
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(1));
+ await open.Client.SendRRDataAsync(ConnectionManagerServices.ForwardClose(_connSerial, _o.VendorId, _o.OriginatorSerial, _route), TimeSpan.FromSeconds(1), cts.Token).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (ex is AbTransportException or OperationCanceledException) { /* PLC expires it after RPI × multiplier */ }
+ }
+ await open.Client.DisposeAsync().ConfigureAwait(false);
+ }
+}
+```
+
+- [ ] **Step 5: Implement `DeviceSessionPool` + `ConnectionLease`.**
+ - Keep a `Dictionary` under a `lock`, where `Entry = (CipConnection Conn, RequestScheduler Scheduler, ConnectionOptions Options, int RefCount)`.
+ - Keep a separate, never-pruned `Dictionary`.
+ - `Acquire` creates the entry on first use (`new CipConnection(key, o, throttle, time)`, `new RequestScheduler(conn, o.AllowPacking)`) and increments `RefCount`.
+ - `ConnectionLease.DisposeAsync` is idempotent (`Interlocked` flag). It decrements the count and, at zero, removes the entry; **outside the lock** it awaits `Scheduler.DisposeAsync()`, then `Conn.DisposeAsync()`.
+ - The pool constructor takes an optional `TimeProvider` (default `TimeProvider.System`).
+- [ ] **Step 6: Run.** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests` → all pass, then run `Refusing_target_sees_at_most_one_forward_open_per_interval` 20× to check it is not flaky.
+- [ ] **Step 7: Commit.** `git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests && git commit -m "feat(ab-transport): per-device connection with routing modes, shared scheduler, keep-alive and Forward Open throttle"`
+
+---
+
+### Task 10: RequestScheduler — MSP coalescing + fragmentation
+
+**Goal:** A per-channel scheduler that coalesces concurrent CIP requests into Multiple Service Packets within `MaxRequestBytes`, keeps one request in flight, fans each reply back to its own waiter, and runs fragmented read/write loops for payloads that do not fit in one frame.
+
+**Files:**
+- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/RequestScheduler.cs`
+- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/LogixTagClient.cs` (read/write helpers over the scheduler, including the fragment loops)
+- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/RequestSchedulerTests.cs`
+- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/LogixTagClientTests.cs`
+- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Fakes/FakeCipChannel.cs`
+
+**Acceptance Criteria:**
+- [ ] `RequestScheduler(ICipChannel channel, bool allowPacking)` exposes `Task SendAsync(byte[] request, CancellationToken ct)`.
+- [ ] **Packing:** while one request is in flight, new requests queue. When the channel frees, the scheduler takes queued requests in FIFO order while `MultipleServicePacket.BuildSize(sizes) <= channel.MaxRequestBytes`. More than one taken ⇒ one MSP. Exactly one ⇒ sent bare. `allowPacking == false` ⇒ always bare.
+- [ ] **Boundary tests:** with `MaxRequestBytes = M`, two requests whose MSP size is exactly `M` pack into one frame, and two whose MSP size is `M + 1` go as two frames.
+- [ ] **A single request larger than `MaxRequestBytes`** is sent bare. Callers must fragment; `LogixTagClient` does.
+- [ ] **Reply fan-out:** each waiter gets its own embedded `CipReply`, with its own general status, so one failing service in an MSP fails only its own waiter. If the MSP envelope itself fails (non-zero general status other than `0x1E` embedded-service-error), or the channel throws, every waiter in that batch faults with the same `AbTransportException`.
+- [ ] **Cancellation:**
+ - A waiter cancelled while queued is removed and never sent.
+ - A waiter cancelled while its batch is in flight gets `OperationCanceledException`, and the batch's other waiters still complete.
+ - **The channel call always uses the scheduler's own `_stop` token, never a waiter's**, for single sends as well as MSPs (B2). So one caller's cancellation can never abort, or fault, a shared exchange.
+ - Each waiter's `CancellationTokenRegistration` is stored on its `Pending` record and **disposed when the TCS completes** (review finding 10). A test makes 10 000 sends on one long-lived token and asserts no growth: measure with a counting `CancellationTokenSource` subclass, or assert `Pending` registrations are disposed via a test hook.
+- [ ] **Owner:** one scheduler per device connection, created by `DeviceSessionPool` (T9). This task builds only the class and tests it over `FakeCipChannel`.
+- [ ] **`LogixTagClient`:**
+ - `ReadAsync(path, elements)` sends Read Tag.
+ - If the reply status is `0x06` (Partial), it **keeps** that reply's data and continues with Read Tag Fragmented at `offset = bytesReceivedSoFar`, appending each reply's data until a reply has status `0x00`. That avoids double-counting the first chunk (review finding 15).
+ - It returns `(TypeCode, StructHandle, byte[] Data)`. The type code and handle come from the first reply; fragment replies repeat the type header, so strip it from each before appending.
+ - **Reply-size note:** packing bounds only the *request* size, so many large reads packed together can produce a reply that the controller truncates with `0x06` per service. That is correct behaviour (the fragment loop recovers it), not an error.
+ - `WriteAsync(path, type, handle, elements, data)` sends one Write Tag when it fits, otherwise Write Tag Fragmented chunks of `MaxRequestBytes - 12 - path.Length`, aligned down to the element width.
+ - Non-zero final status ⇒ `AbTransportException(reply.Status)`.
+- [ ] `FakeCipChannel` lets a test set `MaxRequestBytes`, records every request, and answers via a delegate. It is reused by Tasks 12 and 13.
+
+**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter "FullyQualifiedName~RequestScheduler|FullyQualifiedName~LogixTagClient"` → pass.
+
+**Steps:**
+
+- [ ] **Step 1: Write `FakeCipChannel` and the failing tests.**
+
+```csharp
+// Fakes/FakeCipChannel.cs
+using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
+namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes;
+
+internal sealed class FakeCipChannel(int maxRequestBytes = 504) : ICipChannel
+{
+ public int MaxRequestBytes { get; set; } = maxRequestBytes;
+ public List Sent { get; } = [];
+ public Func> Respond { get; set; } = r => Task.FromResult(new byte[] { (byte)(r[0] | 0x80), 0, 0, 0 });
+ public TaskCompletionSource? Gate { get; set; }
+
+ public async Task> SendAsync(ReadOnlyMemory req, CancellationToken ct)
+ {
+ lock (Sent) Sent.Add(req.ToArray());
+ if (Gate is { } g) await g.Task.WaitAsync(ct);
+ return await Respond(req.ToArray());
+ }
+}
+```
+
+Tests to write (each with explicit byte sizes):
+- `Requests_queued_behind_an_inflight_one_are_packed_into_one_msp`: hold `Gate` with the first request in flight, enqueue 3 more, release. Assert `Sent.Count == 2` and `Sent[1][0] == 0x0A`.
+- `Msp_exactly_at_limit_packs` / `Msp_one_byte_over_limit_splits`.
+- `Packing_disabled_sends_each_bare`.
+- `Per_service_error_fails_only_its_waiter`: the MSP reply carries replies with status 0 and status 0x04. Waiter 1 gets Ok, waiter 2 gets a `CipReply` with `GeneralStatus == 0x04`.
+- `Channel_failure_faults_every_waiter_in_the_batch`.
+- `Queued_waiter_cancelled_before_send_is_never_sent`.
+- `Fragmented_read_keeps_the_first_partial_and_continues`: the first (Read Tag) reply has status 0x06 with 100 data bytes, and the next two are Read Tag Fragmented replies (0x06 with 100 bytes, then 0x00 with 40). The result is 240 bytes, and the fragmented requests were sent at offsets **100 and 200**.
+- `Waiter_cancellation_never_reaches_the_channel_token`: the fake channel records the token it received; assert it is never a waiter's token.
+- `Cancellation_registrations_are_released`.
+- `Fragmented_write_chunks_align_to_element_width`.
+
+- [ ] **Step 2: Run and confirm failure.** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter "FullyQualifiedName~RequestScheduler|FullyQualifiedName~LogixTagClient"` → compile error.
+
+- [ ] **Step 3: Implement the scheduler.**
+
+```csharp
+// Cip/RequestScheduler.cs
+using System.Threading.Channels;
+namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
+
+public sealed class RequestScheduler : IAsyncDisposable
+{
+ private sealed class Pending(byte[] request)
+ {
+ public byte[] Request { get; } = request;
+ public TaskCompletionSource Tcs { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ public CancellationTokenRegistration Registration { get; set; }
+ }
+
+ private readonly ICipChannel _channel;
+ private readonly bool _allowPacking;
+ private readonly LinkedList _queue = new();
+ private readonly object _lock = new();
+ private readonly SemaphoreSlim _signal = new(0);
+ private readonly CancellationTokenSource _stop = new();
+ private readonly Task _pump;
+
+ public RequestScheduler(ICipChannel channel, bool allowPacking)
+ { _channel = channel; _allowPacking = allowPacking; _pump = Task.Run(PumpAsync); }
+
+ public Task SendAsync(byte[] request, CancellationToken ct)
+ {
+ var p = new Pending(request);
+ LinkedListNode node;
+ lock (_lock) node = _queue.AddLast(p);
+ if (ct.CanBeCanceled)
+ {
+ p.Registration = ct.Register(() =>
+ {
+ lock (_lock) { if (node.List is not null) _queue.Remove(node); }
+ p.Tcs.TrySetCanceled(ct);
+ });
+ // Release the registration as soon as the request completes, however it completes (finding 10).
+ _ = p.Tcs.Task.ContinueWith(static (_, state) => ((Pending)state!).Registration.Dispose(), p,
+ CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default);
+ }
+ _signal.Release();
+ return p.Tcs.Task;
+ }
+
+ private List TakeBatch()
+ {
+ var batch = new List();
+ lock (_lock)
+ {
+ var sizes = new List();
+ while (_queue.First is { } n)
+ {
+ if (batch.Count > 0 && !_allowPacking) break;
+ sizes.Add(n.Value.Request.Length);
+ if (batch.Count > 0 && MultipleServicePacket.BuildSize(sizes) > _channel.MaxRequestBytes) break;
+ batch.Add(n.Value);
+ _queue.RemoveFirst();
+ }
+ }
+ return batch;
+ }
+
+ private async Task PumpAsync()
+ {
+ while (!_stop.IsCancellationRequested)
+ {
+ try { await _signal.WaitAsync(_stop.Token).ConfigureAwait(false); } catch (OperationCanceledException) { return; }
+ var batch = TakeBatch();
+ if (batch.Count == 0) continue;
+ try
+ {
+ if (batch.Count == 1)
+ {
+ var raw = await _channel.SendAsync(batch[0].Request, _stop.Token).ConfigureAwait(false); // never a waiter's token (B2)
+ batch[0].Tcs.TrySetResult(CipReply.Parse(raw));
+ }
+ else
+ {
+ var raw = await _channel.SendAsync(MultipleServicePacket.Build(batch.Select(b => b.Request).ToList()), _stop.Token).ConfigureAwait(false);
+ var envelope = CipReply.Parse(raw);
+ if (envelope.GeneralStatus is not (0x00 or 0x1E))
+ throw new AbTransportException(envelope.Status, $"MSP failed: 0x{envelope.GeneralStatus:X2}.");
+ var replies = MultipleServicePacket.Parse(envelope, batch.Count);
+ for (var i = 0; i < batch.Count; i++) batch[i].Tcs.TrySetResult(replies[i]);
+ }
+ }
+ catch (Exception ex)
+ {
+ foreach (var b in batch) b.Tcs.TrySetException(ex);
+ }
+ // drain remaining signals for items taken in this batch
+ for (var i = 1; i < batch.Count; i++) _signal.Wait(0);
+ }
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ _stop.Cancel();
+ try { await _pump.ConfigureAwait(false); } catch { /* stopping */ }
+ lock (_lock) foreach (var p in _queue) p.Tcs.TrySetCanceled();
+ _signal.Dispose(); _stop.Dispose();
+ }
+}
+```
+
+> The requirement "requests queued **while one is in flight** are packed" falls out of the single pump: the pump is busy awaiting the channel, so requests accumulate until it loops back. Do not add a delay-based batching window.
+
+- [ ] **Step 4: Implement `LogixTagClient`** to the acceptance criteria. It holds a `RequestScheduler` and an `ICipChannel` (for `MaxRequestBytes`), and builds requests with `LogixServices`.
+- [ ] **Step 5: Run.** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests` → all pass, then run the RequestScheduler tests 20× to check they are not flaky.
+- [ ] **Step 6: Commit.** `git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests && git commit -m "feat(ab-transport): MSP-coalescing request scheduler and fragmented Logix read/write"`
+
+---
+
+### Task 11: libplctag-confinement guard
+
+**Goal:** A source-scan test that fails if any production `.cs` file outside the libplctag adapters references `libplctag`, so the eventual deletion stays mechanical.
+
+**Files:**
+- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/LibplctagConfinementGuardTests.cs`
+
+**Acceptance Criteria:**
+- [ ] The test walks up from `AppContext.BaseDirectory` to the directory containing `ZB.MOM.WW.OtOpcUa.slnx`, then scans every `*.cs` under `src/` (excluding `bin`/`obj`) for **code** references: `using libplctag` or `libplctag.(Tag|Status|PlcType|Protocol|LibPlcTagException|NativeImport)`. Prose such as the CLI help string `"via libplctag. Second of six…"` (`AbCip.Cli/Program.cs:9`, `AbLegacy.Cli/Program.cs:8`) must **not** match, or the guard is red from wave 2 until T15/T16 (review finding 6).
+- [ ] Allowed files: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Libplctag*.cs` and `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/Libplctag*.cs` only. Comments and XML docs are ignored by stripping `//` line comments before matching.
+- [ ] The failure message lists each offending file and line.
+- [ ] A second fact, `Prose_mentions_of_libplctag_do_not_trip_the_guard`, runs the regex against the literal `"…via libplctag. Second of six driver CLIs"` and asserts no match.
+- [ ] **Falsifiability check:** temporarily add `using libplctag;` to `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs`, run the test and see it go red, then revert. Record that you did this in the commit message.
+
+**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter LibplctagConfinementGuard` → pass.
+
+**Steps:**
+
+- [ ] **Step 1: Write the test.**
+
+```csharp
+using System.Text.RegularExpressions;
+using Shouldly;
+using Xunit;
+namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests;
+
+public sealed class LibplctagConfinementGuardTests
+{
+ private static readonly Regex Reference = new(
+ @"\busing\s+libplctag\b|\blibplctag\.(Tag|Status|PlcType|Protocol|LibPlcTagException|NativeImport)\b",
+ RegexOptions.Compiled);
+
+ [Fact]
+ public void Only_the_libplctag_adapters_reference_libplctag()
+ {
+ var root = new DirectoryInfo(AppContext.BaseDirectory);
+ while (root is not null && !File.Exists(Path.Combine(root.FullName, "ZB.MOM.WW.OtOpcUa.slnx"))) root = root.Parent;
+ root.ShouldNotBeNull("could not find the repo root");
+
+ var offenders = new List();
+ foreach (var file in Directory.EnumerateFiles(Path.Combine(root.FullName, "src"), "*.cs", SearchOption.AllDirectories))
+ {
+ if (file.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}") ||
+ file.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}")) continue;
+ var name = Path.GetFileName(file);
+ var dir = Path.GetFileName(Path.GetDirectoryName(file)!);
+ var allowed = name.StartsWith("Libplctag", StringComparison.Ordinal)
+ && dir is "ZB.MOM.WW.OtOpcUa.Driver.AbCip" or "ZB.MOM.WW.OtOpcUa.Driver.AbLegacy";
+ if (allowed) continue;
+ var lines = File.ReadAllLines(file);
+ for (var i = 0; i < lines.Length; i++)
+ {
+ var code = lines[i].Split("//")[0];
+ if (Reference.IsMatch(code)) offenders.Add($"{Path.GetRelativePath(root.FullName, file)}:{i + 1}: {lines[i].Trim()}");
+ }
+ }
+ offenders.ShouldBeEmpty(string.Join(Environment.NewLine, offenders));
+ }
+}
+```
+
+- [ ] **Step 2: Run it** (it should pass now that T2 and T3 have merged). Then do the falsifiability check from the acceptance criteria.
+- [ ] **Step 3: Commit.** `git add tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/LibplctagConfinementGuardTests.cs && git commit -m "test(ab-transport): guard libplctag confinement to its adapters (falsified by adding using libplctag to AbCipDriver.cs)"`
+
+---
+
+### Task 12: `NativeAbCipTagRuntime` + factory
+
+**Goal:** A native `IAbCipTagRuntime` that leases the device connection from `DeviceSessionPool.Shared`, reads and writes through `LogixTagClient`, decodes the managed buffer with the same semantics as `LibplctagTagRuntime`, and adds a CIP type check.
+
+**Files:**
+- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagRuntime.cs`
+- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagFactory.cs`
+- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipDevice.cs` (maps `AbCipTagCreateParams` → `DeviceKey` + `ConnectionOptions`; shared with Task 13)
+- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs` (add trailing optionals `AbCipDataType? AuthoredType = null, bool UseConnectedMessaging = true` to `AbCipTagCreateParams`)
+- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs` (pass `UseConnectedMessaging: profile.SupportsConnectedMessaging`)
+- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs` (`EnsureTagRuntimeAsync` passes `AuthoredType: def.DataType`; `DeviceState`'s create-params builder passes `UseConnectedMessaging: Profile.SupportsConnectedMessaging`)
+- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTagRuntimeTests.cs`
+- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.csproj` (add a `ProjectReference` to Ab.Transport and ` `.; source-linking compiles the fake into this assembly, so it stays `internal`)
+
+**Acceptance Criteria:**
+- [ ] `NativeAbCipDevice.Key(p)` = `new DeviceKey(p.Gateway, p.Port > 0 ? p.Port : 44818, p.CipPath, p.LibplctagPlcAttribute)`.
+- [ ] `NativeAbCipDevice.Options(p)`:
+ - `Timeout = p.Timeout`, `ConnectionSize = p.ConnectionSize`, `AllowPacking = p.AllowPacking`.
+ - **`Routing`:** if `p.UseConnectedMessaging` is false, use `UnconnectedSend` when `p.CipPath` is non-empty and `Direct` when it is empty. Otherwise use `Connected`.
+ - `UseConnectedMessaging` is a new trailing optional `bool UseConnectedMessaging = true` on `AbCipTagCreateParams`. It is set at every construction site from `AbCipPlcFamilyProfile.SupportsConnectedMessaging`, so the family profile stays the single authority and string-matching `"micro800"` is not needed (review finding 18). The sites are `AbCipDriver` `DeviceState` (around `AbCipDriver.cs:1690-1700`, where `AllowPacking`/`ConnectionSize` are already merged) and `AbCipDriverProbe`; find any others with `grep -rn "new AbCipTagCreateParams" src`.
+ - Cross-check the result against the Task 4 "Routing observed" table for controllogix and micro800, and record any mismatch in the test comment.
+- [ ] **Initialization and reads:**
+ - `InitializeAsync` acquires the lease from `DeviceSessionPool.Shared` and builds a per-runtime `LogixTagClient` over **the lease's shared `Scheduler` and `Channel`**. It must never construct its own `RequestScheduler`: that would defeat MSP coalescing across tags (B1). It encodes the path once, splitting off a trailing bit selector with `CipPath.SplitBitSelector`; that is stored as the runtime's implicit bit.
+ - It performs **one read** to prove the tag exists. That matches libplctag, whose `InitializeAsync` errors for an unknown tag; the driver's 05/STAB-8 logic depends on create throwing `NotFound`.
+ - `ReadAsync` reads `p.ElementCount` elements, stores the data, `TypeCode` and `StructHandle`, and sets status `Ok`. On `AbTransportException` it stores the exception's status **and rethrows**, matching libplctag's exception-on-failure behaviour. `GetSize()` returns the data length.
+- [ ] **Decode** is byte-identical to `LibplctagTagRuntime` for every `AbCipDataType` (Bool/SInt/USInt/Int/UInt/DInt/UDInt/LInt/ULInt/Real/LReal/String/Dt). Specifically:
+ - Bool with a bit index ⇒ bit `n` of the little-endian buffer.
+ - Bool without ⇒ `byte[0] != 0`.
+ - String ⇒ Logix STRING `u32 LEN` + `DATA`, ASCII, `LEN` clamped to the data length − 4.
+ - `DecodeArray` uses stride = `data.Length / count` when it divides evenly, else the static width (the same `ResolveArrayStride` rule; call `LibplctagTagRuntime.ResolveArrayStride` with `elementSize: null` to share it).
+ - Bool arrays are bit-packed (bit `i` of the buffer).
+- [ ] **Type check:** after a read, if `TypeCode` is atomic and differs from the authored type's Logix code, `GetStatus()` returns `(int)AbStatus.TypeMismatch` and `Decode*` returns null. The mapping is `AbCipDataType` → `LogixTypeCode`, with `Dt → DInt`; `String`/`Structure` are exempt. Exemptions:
+ - (a) A BOOL read with a bit index against a DINT host is **not** a mismatch.
+ - (b) **Authored `Bool` with returned `0xD3`** (the Logix packed BOOL-array type) is **not** a mismatch (review finding 11).
+ - Tests cover both exemptions, plus a positive mismatch.
+- [ ] **Encode/write:**
+ - `EncodeValue` fills a managed write buffer with the same encodings as `LibplctagTagRuntime.EncodeValue`, including the same `NotSupportedException` cases.
+ - `WriteAsync` writes with type = the last read `TypeCode` if known, else the authored type's code.
+ - For String, it uses `Struct` + the last `StructHandle`. If none is known it performs a read first; that read happens inside `WriteAsync`, under the driver's existing per-runtime lock.
+ - The STRING write payload is `u32 len` + DATA padded to the capacity learned from the last read's data length − 4.
+- [ ] `Dispose` releases the lease, waiting for at most 2 s (`lease.DisposeAsync().AsTask().Wait(TimeSpan.FromSeconds(2))`). It must not fire and forget: an immediate re-init would otherwise open a second session to the press while the old Forward Close is still in flight (review finding 22). It never disposes the shared scheduler; the pool does that on the last release.
+- [ ] `NativeAbCipTagFactory : IAbCipTagFactory` creates `NativeAbCipTagRuntime`.
+- [ ] Test seam: `internal static NativeAbCipTagRuntime ForTest(ICipChannel channel, AbCipTagCreateParams p)` builds a runtime over the given channel with a private `RequestScheduler(channel, p.AllowPacking)` (test-only; no pool lease). Production code always goes through the factory and `DeviceSessionPool.Shared`.
+- [ ] **Unit tests** (against `FakeCipChannel` via `ForTest`):
+ - decode parity for each type, using a buffer captured from `LibplctagTagRuntime` semantics (hard-coded bytes);
+ - the TypeMismatch case (REAL returned for a DInt-authored tag);
+ - an unknown tag ⇒ `InitializeAsync` throws `AbTransportException(NotFound)`;
+ - a STRING write sends `Struct` + the handle learned from the prior read;
+ - a 1-element array is read as an array (`IsArray`).
+
+**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests --filter NativeAbCipTagRuntime` → pass.
+
+**Steps:**
+
+- [ ] **Step 1: Write the failing tests**, for example:
+
+```csharp
+[Fact]
+public async Task Real_returned_for_a_DInt_authored_tag_is_TypeMismatch()
+{
+ var ch = new FakeCipChannel { Respond = _ => Task.FromResult(Convert.FromHexString("CC000000" + "CA00" + "0000803F")) };
+ using var rt = NativeAbCipTagRuntime.ForTest(ch, Params("Speed", AbCipDataType.DInt));
+ await rt.InitializeAsync(CancellationToken.None);
+ await rt.ReadAsync(CancellationToken.None);
+ rt.GetStatus().ShouldBe((int)AbStatus.TypeMismatch);
+ rt.DecodeValue(AbCipDataType.DInt, null).ShouldBeNull();
+}
+
+[Fact]
+public async Task Unknown_tag_fails_initialize_with_NotFound()
+{
+ var ch = new FakeCipChannel { Respond = _ => Task.FromResult(Convert.FromHexString("CC000400")) };
+ using var rt = NativeAbCipTagRuntime.ForTest(ch, Params("Nope", AbCipDataType.DInt));
+ (await Should.ThrowAsync(() => rt.InitializeAsync(CancellationToken.None))).Status.ShouldBe(AbStatus.NotFound);
+}
+```
+
+`AbCipTagCreateParams` has no authored-type field. Add an optional `AbCipDataType? AuthoredType = null` parameter at the **end** of the record (it is binary compatible for every existing named-argument caller). Set it at `AbCipDriver.cs` `EnsureTagRuntimeAsync` from `def.DataType`. The libplctag runtime ignores it.
+
+- [ ] **Step 2: Run and confirm failure.** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests --filter NativeAbCipTagRuntime` → compile error.
+- [ ] **Step 3: Implement** to the acceptance criteria. Use `BinaryPrimitives` for every decode, never `BitConverter`.
+- [ ] **Step 4: Run.** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests` → all pass, including every pre-existing test.
+- [ ] **Step 5: Commit.** `git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests && git commit -m "feat(abcip): native tag runtime over the managed CIP transport, with returned-type checking"`
+
+---
+
+### Task 13: Native AbCip symbol enumerator + template reader
+
+**Goal:** Native `IAbCipTagEnumerator` and `IAbCipTemplateReader` implementations whose output buffers feed the **unchanged** decoders.
+
+**Files:**
+- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/SymbolListingEnumerator.cs` (the transport-neutral listing loop moved out of `LibplctagTagEnumerator.cs`)
+- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagEnumerator.cs` (keeps only `LibplctagListingReader` + `LibplctagTagEnumeratorFactory`)
+- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/` files referencing `LibplctagTagEnumerator` (rename to `SymbolListingEnumerator`)
+- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagEnumerator.cs`
+- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTemplateReader.cs`
+- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTagEnumeratorTests.cs`
+- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTemplateReaderTests.cs`
+
+**Acceptance Criteria:**
+- [ ] **Enumerator split.** The current `LibplctagTagEnumerator` class (its listing loop has no libplctag dependency apart from its default constructor) is renamed **`SymbolListingEnumerator`**, keeping its `Func> readListing` constructor seam. The libplctag default reader moves into `LibplctagTagEnumerator.cs` as `LibplctagListingReader`, so the Task 11 guard still allows it. `LibplctagTagEnumeratorFactory` returns `new SymbolListingEnumerator(LibplctagListingReader.ReadAsync)`.
+- [ ] **Native listing reader.** `NativeAbCipTagEnumerator` = `new SymbolListingEnumerator(NativeListingReader.ReadAsync)`, wrapped in a class for the factory. Its reader:
+ - `"@tags"` ⇒ pages `SymbolObjectServices.GetInstanceAttributeList(null, start)` from instance 0, appending entry bytes. While the reply is `More`, it continues from `LastInstance + 1`.
+ - `"Program:X.@tags"` ⇒ the same with `programName: "X"`.
+ - It returns the concatenated bytes.
+- [ ] `NativeAbCipTemplateReader.ReadAsync(params, id)`:
+ - `GetAttributes(id)` gives `defWords`, `structBytes`, `members`, `handle`.
+ - It then sends `ReadTemplate(id, offset, chunk)` repeatedly, with `chunk = Math.Min(remaining, MaxRequestBytes - 16)`, where the total = `defWords * 4 - 23` (the CIP-documented Read Template length), advancing by each reply's data length until the status is 0. Status `0x06` continues.
+ - It returns `TemplateObjectServices.BuildDecoderBuffer(...)`.
+- [ ] Unit tests feed the **real controller captures** through the native path. The fake channel serves, as reply data, the bytes contained in `CipTemplateObjectWireCaptureTests` (rev 34.11 press). The test asserts that `CipTemplateObjectDecoder.Decode(nativeBuffer)` equals `Decode(capturedLibplctagBuffer)`, member by member.
+ - The captured libplctag buffer's header **is** the 14-byte header, so the fake replies are built from that header's fields plus the raw bytes after byte 14.
+ - Do the same for `CipSymbolObjectRealControllerCaptureTests` entries: split them across two pages with a `0x06` status between, and assert the decoded tag list is identical.
+- [ ] Both factories (`NativeAbCipTagEnumeratorFactory`, `NativeAbCipTemplateReaderFactory`) exist and lease from `DeviceSessionPool.Shared` via `NativeAbCipDevice`. They send through **the lease's shared `Scheduler`**, never a private one, and dispose the lease (bounded 2 s wait, as in T12) when the enumeration or read completes.
+
+**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests --filter "FullyQualifiedName~NativeAbCipTagEnumerator|FullyQualifiedName~NativeAbCipTemplateReader|FullyQualifiedName~Discovery"` → pass.
+
+**Steps:**
+
+- [ ] **Step 1: Write the failing tests** that reuse the real-capture byte arrays. Reference them through the existing test classes' fields if those are accessible; otherwise copy the hex into a shared `RealControllerCaptures.cs` and point both old and new tests at it.
+- [ ] **Step 2: Run and confirm failure.** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests --filter "FullyQualifiedName~NativeAbCipTagEnumerator|FullyQualifiedName~NativeAbCipTemplateReader"` → compile error.
+- [ ] **Step 3: Split the enumerator.** Move the class body into `SymbolListingEnumerator.cs` as `SymbolListingEnumerator`, leaving `LibplctagListingReader.ReadAsync` and `LibplctagTagEnumeratorFactory` (which now returns `new SymbolListingEnumerator(LibplctagListingReader.ReadAsync)`) in `LibplctagTagEnumerator.cs`. Then implement the native readers.
+- [ ] **Step 4: Run** the full AbCip.Tests suite → all pass.
+- [ ] **Step 5: Commit.** `git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests && git commit -m "feat(abcip): native symbol listing and template reads feeding the unchanged decoders"`
+
+---
+
+### Task 14: `NativeAbLegacyTagRuntime` + factory
+
+**Goal:** A native `IAbLegacyTagRuntime` over PCCC (Task 8) carried in Execute PCCC through a `DeviceSessionPool` lease.
+
+**Files:**
+- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagRuntime.cs`
+- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagFactory.cs`
+- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/NativeAbLegacyTagRuntimeTests.cs`
+- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests.csproj` (reference Ab.Transport + link `FakeCipChannel.cs`, as in Task 12)
+
+**Acceptance Criteria:**
+- [ ] **Connection:**
+ - `DeviceKey` is built from `AbLegacyTagCreateParams` the same way as Task 12 (default port 44818).
+ - **`Routing` comes from the Task 4 "Routing observed" table (B3), per family:**
+ - Expected: `slc500`/`micrologix`/`plc5` ⇒ **`Direct`** (libplctag sends Execute PCCC bare in SendRRData even though the driver forces `EffectiveCipPath = "1,0"`, `AbLegacyDriver.cs:864`); `logixpccc` ⇒ **`Connected`**.
+ - **If the capture shows otherwise, the capture wins.** Encode the choice in one `static CipRouting RoutingFor(string plcAttribute)` with a comment citing the capture file, and pin it with a test per family.
+ - `ConnectionSize = 504`, `AllowPacking = false` (PCCC requests are never MSP-packed).
+ - Send through the lease's shared `Scheduler`, never a private one (B1).
+- [ ] **Address handling:**
+ - `AbLegacyAddress.TryParse(p.TagName)` ⇒ `PcccAddress(FileLetter, FileNumber, WordNumber, sub)`. The sub-element comes from `ResolveSubElement`. A `BitIndex` is kept for decode only, because reads are word reads.
+ - Byte counts per element: N/A/B/I/O/S = 2, F/L = 4, T/C/R = 6 when no sub-element (the whole element), 2 when a word sub-element is chosen, ST = 84.
+ - Element count = `p.ElementCount`.
+- [ ] **Read and write:**
+ - `ReadAsync` dispatches by `plc` attribute: `slc500`/`micrologix`/`logixpccc` ⇒ `SlcTypedRead`; `plc5` ⇒ `Plc5TypedRead`, or `AbTransportException(Unsupported)` when `PcccServices.Plc5Verified` is false (Task 8).
+ - The TNS increments per request.
+ - **Chunking (review finding 12):** a read or write whose byte count exceeds `PcccServices.MaxDataBytes` (236) is split into consecutive element ranges of at most 236 bytes (118 words, or 59 longs/floats). The element number advances per chunk and the data is concatenated in order. A failure in any chunk fails the whole operation with that chunk's status; a partial write is reported as failed, never as Good. Test a 256-word array read (three chunks: 118 + 118 + 20) and a 130-word write (two chunks).
+ - On failure it stores the status and rethrows.
+ - `InitializeAsync` performs one read, for the same `NotFound`-on-create reason as Task 12.
+- [ ] **Decode** is identical to `LibplctagLegacyTagRuntime`:
+ - Bit with index ⇒ bit of the LE word (`/N` up to 15, or up to 31 for L).
+ - Bit without ⇒ `(word & 1) != 0`.
+ - Int/AnalogInt ⇒ `short`→`int`; Long ⇒ `int`; Float ⇒ `float`.
+ - String ⇒ the SLC ST layout: `u16 LEN` + 82 data bytes **with each byte pair swapped**. ⚠️ Verify this byte order against `slc500` ST data in ab_server. If ab_server does not seed ST, test against a hand-built buffer and flag it in Task 20 docs as unverified.
+ - Timer/Counter/Control element without a sub-element ⇒ `GetInt32(0)` parity (the first 4 bytes, LE).
+ - `DecodeArray` uses the offsets documented in `LibplctagLegacyTagRuntime.DecodeArray`.
+- [ ] **Encode** follows `LibplctagLegacyTagRuntime.EncodeValue` case by case, with the same `NotSupportedException` for bit-with-index. `WriteAsync` ⇒ `SlcTypedWrite` (or PLC-5 / Unsupported).
+- [ ] **Unit tests:**
+ - N7:0 read decode;
+ - F8 read;
+ - B3:0/5 decode;
+ - an N7 array of 5;
+ - T4:0.ACC sub-element addressing (bytes on the wire);
+ - STS 0x20 ⇒ `NotFound` on initialize;
+ - a write of N7:0 = 42 produces the expected PCCC bytes.
+
+**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests --filter NativeAbLegacyTagRuntime` → pass.
+
+**Steps:**
+
+- [ ] **Step 1: Write the failing tests** against `FakeCipChannel`. Build replies with this helper:
+
+```csharp
+private static byte[] PcccReply(ushort tns, byte[] data) =>
+ [0xCB, 0x00, 0x00, 0x00, 0x07, 0x37, 0x13, 0x00, 0x00, 0x00, 0x00, 0x4F, 0x00, (byte)tns, (byte)(tns >> 8), .. data];
+```
+
+ Note the leading `0xCB` (0x4B | 0x80) and the 7-byte requestor-id echo. Adjust it to whatever echo the Task 4 capture shows.
+- [ ] **Step 2: Run and confirm failure.** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests --filter NativeAbLegacyTagRuntime` → compile error.
+- [ ] **Step 3: Implement** to the acceptance criteria.
+- [ ] **Step 4: Run** the full AbLegacy.Tests suite → all pass.
+- [ ] **Step 5: Commit.** `git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests && git commit -m "feat(ablegacy): native PCCC tag runtime over the managed transport"`
+
+---
+
+### Task 15: AbCip `Transport` switch (options, factory, probe, CLI)
+
+**Goal:** One `Transport` option selects the native or libplctag factory set everywhere AbCip creates a transport object: the driver, Test Connect, and the CLI.
+
+**Files:**
+- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipTransport.cs`
+- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipDriverOptions.cs` (add `public AbCipTransport Transport { get; init; } = AbCipTransport.Libplctag;`)
+- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverFactoryExtensions.cs` (DTO `string? Transport`; `ParseEnum(dto.Transport, …, fallback: AbCipTransport.Libplctag)`)
+- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipTransportFactories.cs`
+- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs:145-155` (null factories ⇒ `AbCipTransportFactories.For(options.Transport)`)
+- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs` (use `AbCipTransportFactories.For(opts.Transport).Tags`)
+- Modify: `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/` (`--transport native|libplctag`, default `libplctag`; help text "via libplctag" → "via libplctag or the native transport")
+- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipTransportSwitchTests.cs`
+- Modify: `tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests/` (add a `--transport` parse test)
+
+**Acceptance Criteria:**
+- [ ] `public enum AbCipTransport { Libplctag, Native }`.
+- [ ] `AbCipTransportFactories.For(t)` returns `(IAbCipTagFactory Tags, IAbCipTagEnumeratorFactory Enumerators, IAbCipTemplateReaderFactory Templates)`: the native trio for `Native`, the libplctag trio for `Libplctag`.
+- [ ] Explicitly injected factories (tests) still win over the option.
+- [ ] JSON configs: `"transport": "Native"` ⇒ Native; `"transport": "native"` ⇒ Native (case-insensitive, as with the existing `ParseEnum`); absent ⇒ Libplctag; `"transport": "Bogus"` ⇒ the same failure the existing `ParseEnum` raises for a bad `PlcFamily` (do not invent a new behaviour).
+- [ ] **Reinit does NOT swap transports, by design (review finding 19).** `AbCipDriver.ReinitializeAsync` (`AbCipDriver.cs:401`) is `ShutdownAsync` + `InitializeAsync` over constructor-held factories, so a transport change in the reinit JSON is inert. Do **not** invent factory swapping:
+ - Document in the option's XML doc and in `docs/drivers/AbCip.md` (T20) that changing `transport` takes effect when the driver instance is recreated, which a deploy that changes the driver config does.
+ - Pin the current behaviour with a test, `Reinit_with_a_changed_transport_keeps_the_constructed_transport`, so a future change is deliberate.
+ - Before writing that test, confirm how a deploy applies a changed `DriverConfig`: `grep -rn "ReinitializeAsync\|CreateInstance" src/Server src/Core | grep -i driver`. If deploys recreate the instance, say so in the doc. If they call `ReinitializeAsync`, raise it as a follow-up in the commit message rather than widening this task.
+- [ ] The **probe** deserializes with its own `JsonStringEnumConverter`, so a bad `"transport"` reports "Config JSON is invalid", not the `ParseEnum` message. That is acceptable; assert that exact behaviour in the probe test rather than the driver's.
+- [ ] The probe with `Transport: Native` constructs a `NativeAbCipTagRuntime`. Prove it with a test that uses an unreachable `127.0.0.1:1` endpoint and asserts the failure message comes from the native path ("TCP connect … failed"), not libplctag's.
+- [ ] The CLI `--transport native` reaches `AbCipDriverOptions.Transport`.
+
+**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests --filter AbCipTransportSwitch && dotnet test tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests` → pass.
+
+**Steps:**
+
+- [ ] **Step 1: Write the failing tests** (option parsing, factory selection, injected-wins, probe path).
+- [ ] **Step 2: Run and confirm failure.** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests --filter AbCipTransportSwitch` → compile error.
+- [ ] **Step 3: Implement.**
+
+```csharp
+// AbCipTransportFactories.cs
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
+
+internal static class AbCipTransportFactories
+{
+ public static (IAbCipTagFactory Tags, IAbCipTagEnumeratorFactory Enumerators, IAbCipTemplateReaderFactory Templates)
+ For(AbCipTransport transport) => transport switch
+ {
+ AbCipTransport.Native => (new NativeAbCipTagFactory(), new NativeAbCipTagEnumeratorFactory(), new NativeAbCipTemplateReaderFactory()),
+ _ => (new LibplctagTagFactory(), new LibplctagTagEnumeratorFactory(), new LibplctagTemplateReaderFactory()),
+ };
+}
+```
+
+ In the `AbCipDriver` constructor:
+
+```csharp
+var defaults = AbCipTransportFactories.For(options.Transport);
+_tagFactory = tagFactory ?? defaults.Tags;
+_enumeratorFactory = enumeratorFactory ?? defaults.Enumerators;
+_templateReaderFactory = templateReaderFactory ?? defaults.Templates;
+```
+
+- [ ] **Step 4: Run** the AbCip and CLI test suites → all pass.
+- [ ] **Step 5: Commit.** `git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests && git commit -m "feat(abcip): Transport option selects native or libplctag across driver, probe and CLI"`
+
+---
+
+### Task 16: AbLegacy `Transport` switch (options, factory, probe, CLI)
+
+**Goal:** Do the same as Task 15 for AbLegacy.
+
+**Files:**
+- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyTransport.cs` (`public enum AbLegacyTransport { Libplctag, Native }`)
+- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyDriverOptions.cs` (`Transport` property, default `Libplctag`)
+- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs` (DTO + parse)
+- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs:65-71` (`_tagFactory = tagFactory ?? (options.Transport == AbLegacyTransport.Native ? new NativeAbLegacyTagFactory() : new LibplctagLegacyTagFactory());`)
+- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs`
+- Modify: `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/` (`--transport`)
+- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyTransportSwitchTests.cs`
+- Modify: `tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests/`
+
+**Acceptance Criteria:** the same five criteria as Task 15, applied to AbLegacy types:
+- [ ] `"transport": "Native"` and `"native"` select the native factory, absent selects libplctag, and a bad value fails the way an existing bad enum field does.
+- [ ] An injected factory wins over the option.
+- [ ] The probe with Native uses the native path; prove it with the unreachable-endpoint message test.
+- [ ] The CLI `--transport native` reaches the options.
+- [ ] A reinit with a changed transport keeps the constructed transport (`AbLegacyDriver` reinit has the same Shutdown+Initialize shape), pinned by the same kind of test and documentation as Task 15.
+
+**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests --filter AbLegacyTransportSwitch && dotnet test tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests` → pass.
+
+**Steps:**
+
+- [ ] **Step 1: Write the failing tests:** option parsing (`"Native"`, `"native"`, absent, `"Bogus"`), injected-factory-wins, the probe's native-path message for `127.0.0.1:1`, and the CLI `--transport native` parse.
+- [ ] **Step 2: Run and confirm failure.** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests --filter AbLegacyTransportSwitch` → compile error.
+- [ ] **Step 3: Implement.**
+ - Add the enum, the options property and the DTO field. Parse it with the file's existing enum-parse helper, falling back to `AbLegacyTransport.Libplctag`.
+ - Use the constructor line given under **Files**.
+ - In the probe, build the runtime with `options.Transport == AbLegacyTransport.Native ? new NativeAbLegacyTagFactory() : new LibplctagLegacyTagFactory()` (or the injected factory from Task 3).
+ - Add `--transport native|libplctag` to the CLI, defaulting to `libplctag`, and change its "via libplctag" help text to "via libplctag or the native transport".
+- [ ] **Step 4: Run** the AbLegacy and CLI test suites → all pass.
+- [ ] **Step 5: Commit.** `git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests && git commit -m "feat(ablegacy): Transport option selects native or libplctag across driver, probe and CLI"`
+
+---
+
+### Task 17: AbCip + AbLegacy integration suites on both transports
+
+**Goal:** Every existing `ab_server` integration test runs once per transport, and the native run is green wherever the libplctag run is green, against every compose profile.
+
+**Files:**
+- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbCipReadSmokeTests.cs` and every other test in that project that constructs `AbCipDriverOptions` (`grep -rln "new AbCipDriverOptions" tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests`)
+- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/TransportMatrix.cs`
+- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyReadSmokeTests.cs` and siblings
+- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/TransportMatrix.cs`
+- Modify: `docs/plans/2026-10-07-native-ab-transport-gate-results.md` (created empty by Task 1; this task fills only its `## ab_server suites (T17)` section)
+
+**Acceptance Criteria:**
+- [ ] `TransportMatrix.Profiles` yields `(profile, transport)` for every profile × both transports. The theories switch from `MemberData(nameof(Profiles))` to `MemberData(nameof(TransportMatrix.Profiles), MemberType = typeof(TransportMatrix))`, and options get `Transport = transport`.
+- [ ] **The AbLegacy matrix is its own class** in the AbLegacy integration project, using `AbLegacyTransport`, and it **preserves the existing `AB_LEGACY_COMPOSE_PROFILE` filter** that `AbLegacyReadSmokeTests.Profiles` applies today (`AbLegacyReadSmokeTests.cs:26-35`): it must filter `KnownProfiles.All` by that env var before crossing with transports. The AbCip snippet below does not apply to AbLegacy verbatim (review finding 20).
+- [ ] Run against `10.100.0.35`, one compose profile at a time:
+ - AbCip: `controllogix`, `compactlogix`, `micro800`, `guardlogix`.
+ - AbLegacy: `slc500`, `micrologix`, `plc5`.
+ - **For each profile, the set of passing tests under `Native` ⊇ the set under `Libplctag`.**
+- [ ] Any native-only failure is fixed in the transport task's code (reopen that task's files) and is not skipped. The only allowed exception is the PLC-5 `Unsupported` path from Task 8, marked `Skip = "PLC-5 framing unverified — see Task 8"` **only if** Task 8 set `Plc5Verified = false`.
+- [ ] The results doc records, under its `## ab_server suites (T17)` heading, per profile × transport, the pass/fail/skip counts plus the `ab_server` image tag and the commit.
+
+**Verify:** for each profile P (AbCip stack directory as established in Task 4's `Captures/README.md`; AbLegacy at `~/otopcua-ablegacy` with `AB_LEGACY_COMPOSE_PROFILE=P`): `ssh dohertj2@10.100.0.35 'cd && docker compose --profile P up -d'` then `AB_SERVER_ENDPOINT=10.100.0.35:44818 dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests --logger "trx;LogFileName=P.trx"` → the Native pass set ⊇ the Libplctag pass set (compare the two trx files with `grep -o 'testName="[^"]*" .*outcome="[^"]*"'`).
+
+**Steps:**
+
+- [ ] **Step 1: Add `TransportMatrix` and retarget every theory.**
+
+```csharp
+public static class TransportMatrix
+{
+ public static IEnumerable Profiles =>
+ from p in KnownProfiles.All
+ from t in new[] { AbCipTransport.Libplctag, AbCipTransport.Native }
+ select new object[] { p, t };
+}
+```
+
+- [ ] **Step 2: Run the suites per profile** as in Verify. AbLegacy uses its own Docker dir and its fixture's endpoint env var (read `AbLegacyServerFixture.cs` for the name).
+- [ ] **Step 3: Triage every Native-only failure.** Reproduce it with the CLI (`--transport native` vs `--transport libplctag`), compare against the Task 4 capture, fix the code, add a unit test that pins the fix in the owning task's test file, and re-run.
+- [ ] **Step 4: Write the results table** and tear down the fixtures.
+- [ ] **Step 5: Commit.** `git add tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests docs/plans/2026-10-07-native-ab-transport-gate-results.md && git commit -m "test(ab): run the ab_server integration suites on both transports"`
+
+---
+
+### Task 18: Differential harness (libplctag vs native, same target)
+
+**Goal:** A `LiveIntegration` test that reads the same tags through both transports against one endpoint and asserts equal raw buffers and equal decoded values. It skips cleanly when the endpoint env var is absent.
+
+**Files:**
+- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/TransportDifferentialTests.cs`
+- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/DifferentialTarget.cs`
+- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs` and `NativeAbCipTagRuntime.cs` (add `internal ReadOnlyMemory RawBuffer`)
+- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj` (`InternalsVisibleTo` for `ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests`)
+
+**Acceptance Criteria:**
+- [ ] **Env gating:**
+ - `AB_DIFF_ENDPOINT` (`host:port`), `AB_DIFF_PATH` (default `1,0`), `AB_DIFF_FAMILY` (default `CompactLogix`).
+ - `AB_DIFF_TAGS`: an optional comma list. Absent ⇒ every atomic scalar, the first 20 arrays and the first 20 UDT members from browse.
+ - `AB_DIFF_MAX_TAGS` (default 200).
+ - Absent `AB_DIFF_ENDPOINT` ⇒ every test reports **skipped**, with the reason naming the variable.
+- [ ] **The suite is read-only by construction.** It never calls `WriteAsync` or `EncodeValue`. A guard test reflects over `TransportDifferentialTests` and fails if any method body references `WriteAsync` (scan its source file).
+- [ ] **Browse parity:** `@tags` plus every program listing through both enumerators decode to identical `AbCipDiscoveredTag` sets (order-insensitive). Every `TemplateInstanceId` seen decodes to an identical `AbCipUdtShape` through both template readers.
+- [ ] **Read parity:** for each selected tag, under both transports:
+ - Run `InitializeAsync` + `ReadAsync`.
+ - Assert `GetStatus()` is equal, **except** that native `TypeMismatch` where libplctag says `Ok` is reported as a distinct *expected divergence* (spec §5), listed in the output and not failed.
+ - Assert the raw buffers are equal. To expose the raw bytes, add `internal ReadOnlyMemory RawBuffer` to both runtimes: libplctag's via `_tag.GetBuffer()`, native's via its stored data. Add `InternalsVisibleTo` for the integration test assembly in the AbCip csproj.
+ - Assert the decoded values are equal.
+- [ ] **Connection discipline (review finding 9):**
+ - Before any native work, the harness acquires one `DeviceSessionPool.Shared.Acquire(key, options)` **anchor lease** for the target, and holds it until the run ends. Per-tag native runtimes therefore share that one connection: the pool never reaches refcount 0, so it never sends Forward Close and re-opens between tags.
+ - libplctag runtimes and native runtimes are never alive at the same time. All libplctag reads run first, and their handles are disposed. Then all native reads run. So the press sees at most two sessions over the run, never concurrently.
+ - With `AB_DIFF_COUNT_OPENS=1`, the native side asserts it made exactly **one** Forward Open over the whole run, using `CipConnection.ForwardOpenCount` from Task 9 (reset it at the start of the run).
+- [ ] Output: a markdown table (tag, type, libplctag status, native status, equal?) written to `TestContext.Current.TestOutputHelper`.
+- [ ] Results are appended under the gate-results doc's `## Differential vs ab_server (T18)` heading only.
+- [ ] Run against `ab_server` (`controllogix` profile) with `AB_DIFF_ENDPOINT=10.100.0.35:44818` ⇒ zero unexpected divergences. Append the results to the gate-results doc.
+- [ ] **Ordering:** blocked by Task 17, so any transport fixes found by the ab_server suites land before the differential run.
+
+**Verify:** `AB_DIFF_ENDPOINT=10.100.0.35:44818 dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests --filter "FullyQualifiedName~Differential"` → pass with 0 unexpected divergences; without the env var → all skipped.
+
+**Steps:**
+
+- [ ] **Step 1: Write the harness.** Use the xUnit v3 `Assert.Skip(...)` call (the project is on xunit.v3), or a dynamic-skip attribute pattern matching `AbServerTheoryAttribute`.
+- [ ] **Step 2: Run** against ab_server, fix any divergence (in the owning task's code, with a pinning unit test), and re-run until clean.
+- [ ] **Step 3: Commit.** `git add tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip docs/plans/2026-10-07-native-ab-transport-gate-results.md && git commit -m "test(abcip): read-only differential harness comparing native and libplctag on one target"`
+
+---
+
+### Task 19: AdminUI `Transport` dropdown (AbCip + AbLegacy) + docker-dev `/run`
+
+**Goal:** An operator can pick the transport per driver in the `/raw` driver-config modal, the value round-trips as a string, and a live docker-dev run proves it reaches the driver.
+
+**Files:**
+- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbCipDriverForm.razor` (`FormModel.Transport`, `ToOptions`/`FromOptions`, an `InputSelect` in the advanced section)
+- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbLegacyDriverForm.razor` (same)
+- Modify: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbLegacyDriverPageFormSerializationTests.cs` (add Transport round-trip cases)
+- Create or modify: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbCipDriverPageFormSerializationTests.cs` (exists ⇒ add cases; absent ⇒ create, modelled on the AbLegacy file)
+
+**Acceptance Criteria:**
+- [ ] The dropdown has options "libplctag (default)" ⇒ `Libplctag` and "Native (managed)" ⇒ `Native`, bound with `@bind-Value:after="EmitAsync"` the way the other fields are.
+- [ ] The serialized JSON contains `"transport":"Native"` (a string, not a number). It goes through the existing `_jsonOpts` with `JsonStringEnumConverter` (`AbCipDriverForm.razor:108-113`); verify the AbLegacy form has the same converter and add it if missing.
+- [ ] Loading a config without `transport` shows "libplctag (default)" and re-emits **without** a `transport` key, or with `"Libplctag"`; either is fine, but pick one and test it.
+- [ ] **Live `/run` on docker-dev** (login is disabled; drive it yourself with the Chrome tools against `http://localhost:9200`):
+ 1. Bring up the AbCip `controllogix` fixture on `10.100.0.35`.
+ 2. In `/raw`, create or edit an AbCip driver with a device `ab://10.100.0.35:44818/1,0`, set Transport = Native, and add the raw tag `TestDINT`.
+ 3. Deploy.
+ 4. Read the UNS/raw node with the Client CLI (`dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- read -u opc.tcp://localhost:4840 -n "ns=;s="`) and get `Good`.
+ 5. Confirm from the central node's logs that the native runtime served it. Add one `LogDebug("AbCip transport={Transport}")` at driver init if no log line distinguishes the two.
+ 6. Flip back to libplctag, redeploy, and read `Good` again.
+ 7. Record the output under the gate-results doc's `## AdminUI /run (T19)` heading only.
+- [ ] **Inverted-gate check** (memory: "an unchanged observable proves nothing"): with Transport = Native, point the device at a port where no `ab_server` is listening. The read must go **Bad** and the log must show the *native* error text. That proves the switch changes behaviour and isn't inert.
+
+**Verify:** AdminUI unit tests pass, plus the docker-dev `/run` evidence (CLI output for both transports and the inverted case) is pasted into `docs/plans/2026-10-07-native-ab-transport-gate-results.md`.
+
+**Steps:**
+
+- [ ] **Step 1: Write the failing round-trip test.** In the two `*DriverPageFormSerializationTests.cs` files above, assert `ToOptions(FromOptions(x)).Transport == x.Transport` for both forms and both values, and that the serialized JSON carries `"transport":"Native"` as a string. Run it and confirm a compile error.
+- [ ] **Step 2: Implement both forms.** Add the `Transport` field to each `FormModel` and to `ToOptions`/`FromOptions`, plus the `InputSelect` in the advanced section, and make sure the AbLegacy form's JSON options include `JsonStringEnumConverter`. Run the round-trip test → pass.
+- [ ] **Step 3: Rebuild the docker-dev images** with `docker-dev/deploy.sh`, as `docker-dev/README.md` documents; do not guess flags.
+- [ ] **Step 4: Run the live `/run` sequence** above and paste the evidence.
+- [ ] **Step 5: Commit.** `git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI tests/Server docs/plans/2026-10-07-native-ab-transport-gate-results.md && git commit -m "feat(adminui): Transport dropdown on AbCip/AbLegacy driver forms (live-verified on docker-dev)"`
+
+---
+
+### Task 20: Documentation
+
+**Goal:** Document the native transport, the switch, the known gaps and the client-visible type-check change, in the repo and in the umbrella history.
+
+**Files:**
+- Modify: `docs/drivers/AbCip.md` (sections: "Transport (`Transport` option)", "Native transport behaviour" covering connection sharing, MSP packing, the type check and press-safety limits, and "Known gaps" covering simulator-only writes, PLC-5/Micro800/routed paths, and libplctag deletion blocked on a bench controller)
+- Modify: `docs/drivers/AbLegacy.md` (Transport section, PCCC simulator-only gap, PLC-5 status from Task 8, ST byte-order status from Task 14)
+- Modify: `docs/drivers/AbServer-Test-Fixture.md` and `docs/drivers/AbLegacy-Test-Fixture.md` (suites now run per transport; the capture recipe `scripts/ab-capture/capture.sh`)
+- Modify: `CLAUDE.md` (one short paragraph under a new heading "AB drivers: native transport (`Transport`)" with the switch, the confinement guard, and a pointer to the docs; keep it short, under 15 lines)
+- Modify: `../scadaproj/.claude/skills/sister-project-history/otopcua.md` (append a dated entry), on scadaproj's `main` per the CLAUDE.md procedure
+
+**Acceptance Criteria:**
+- [ ] `docs/drivers/AbCip.md` states explicitly:
+ - The native path reports `BadTypeMismatch` for a same-width mis-authored type that libplctag reads as Good garbage, with an example (DINT authored, REAL returned).
+ - That is the fix working, and it is called out as client-visible.
+- [ ] Every config key added (`transport`) is documented with its values, its default and string serialization.
+- [ ] The scadaproj history entry is committed on `main` and pushed. The OtOpcUa index **row** in `../scadaproj/CLAUDE.md` is **not** touched; the summary does not change, since the driver set and wire contract are the same.
+- [ ] The scadaproj branch is restored afterwards.
+
+**Verify:** `grep -n "Transport" docs/drivers/AbCip.md docs/drivers/AbLegacy.md CLAUDE.md` shows the new sections; `cd ../scadaproj && git log origin/main -1 --format=%s` shows the history commit.
+
+**Steps:**
+
+- [ ] **Step 1: Write the repo docs** listed under **Files**. Take PLC-5 status from Task 8, ST byte-order status from Task 14, and the `/run` evidence from the gate-results doc.
+- [ ] **Step 2: Do the scadaproj edit with the exact CLAUDE.md sequence:**
+
+```bash
+cd ~/Desktop/scadaproj
+ORIG=$(git rev-parse --abbrev-ref HEAD); git stash list
+git checkout main && git pull
+# append to .claude/skills/sister-project-history/otopcua.md
+git commit -am "docs(history): OtOpcUa AbCip/AbLegacy gain a native managed transport behind a Transport switch"
+git push origin main
+git checkout "$ORIG"
+```
+
+- [ ] **Step 3: Commit in OtOpcUa.** `git add docs/drivers CLAUDE.md && git commit -m "docs(ab): native transport, Transport switch, known gaps and the type-check change"`
+
+---
+
+### Task 21: Real-press read-only gate (CR CompactLogix)
+
+**Goal:** Run the Task 18 differential harness and a latency comparison against one production CompactLogix 5069-L320ERS2 press at CR, **read-only**, and record the evidence that decides the AbCip default flip.
+
+> **USER-ORDERED GATE — NON-SKIPPABLE.** This task was requested by the user in the current conversation. It MUST NOT be closed by walking around it, by declaring it "verified inline", or by substituting a cheaper check. Close only after every item in `acceptanceCriteria` has been re-validated independently, with output captured.
+
+**Files:**
+- Modify: `docs/plans/2026-10-07-native-ab-transport-gate-results.md` (append the press results)
+- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/PollLatencyComparisonTests.cs`
+
+**Acceptance Criteria:**
+- [ ] **Preconditions (human):** the VPN to CR is up, the user has OK'd touching the press at this time, and the target is **one** press. The default is Z-35162 at `10.209.70.160:44818`, path `1,0`; any other press must be named by the user.
+- [ ] **Run the differential:**
+ - `AB_DIFF_ENDPOINT=10.209.70.160:44818 AB_DIFF_FAMILY=CompactLogix AB_DIFF_COUNT_OPENS=1 dotnet test …--filter "FullyQualifiedName~Differential"` passes.
+ - Browse parity holds: the same discovered tag set (the 2026-10-07 run saw ~23,412 variables across 80 programs) and identical UDT shapes.
+ - Read parity holds over `AB_DIFF_MAX_TAGS=200`, with zero unexpected divergences. Every `TypeMismatch` expected-divergence is listed by tag name.
+ - Native made exactly **1** Forward Open over the run. That is achievable only because T18's harness holds an anchor lease for the whole run; if the count is higher, the harness is broken, not the press.
+- [ ] **Latency comparison** (`PollLatencyComparisonTests`, env-gated by the same variables):
+ - Read a fixed set of 100 atomic tags 30 times per transport, sequentially; transports are never concurrent. The native side runs under an anchor lease, as in T18.
+ - Native passes issue all 100 reads **concurrently** (`Task.WhenAll`), so the shared scheduler can MSP-pack them. libplctag passes do the same; libplctag packs internally.
+ - Record the CIP round-trips per pass (native: `CipConnection.RequestsSent` from Task 9, delta per pass; libplctag: report n/a) and the p50/p95 wall time per pass.
+ - Pass criterion: native p95 ≤ libplctag p95 × 1.10.
+- [ ] **No writes:**
+ - Evidence: the Task 18 source guard passes.
+ - `grep -c WriteAsync` over both differential test files returns 0.
+- [ ] **Recorded results:** under the `## Real press (T21)` and `## Latency (T21)` headings, the results doc carries the raw `dotnet test` output, the divergence table, the latency table, the Forward Open count, the date/time (UTC) of the run and the press ID.
+- [ ] **Failure handling:** if any criterion fails, the task stays open. Fix the code (with a pinning test), re-run the ab_server suites (Task 17) and the press run. **Do not** relax the criterion.
+
+**Verify:** `AB_DIFF_ENDPOINT=10.209.70.160:44818 AB_DIFF_FAMILY=CompactLogix AB_DIFF_COUNT_OPENS=1 dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests --filter "FullyQualifiedName~Differential" --logger "console;verbosity=detailed"` → passed, 0 unexpected divergences, `ForwardOpenCount=1`, and native p95 ≤ 1.10 × libplctag p95.
+
+**Steps:**
+
+- [ ] **Step 1:** Write `PollLatencyComparisonTests` (skips without the env vars). Run it against ab_server first to prove it works.
+- [ ] **Step 2:** Ask the user to confirm the VPN, the press and the time window. Do not proceed without an explicit yes.
+- [ ] **Step 3:** Run the differential, then the latency comparison, **sequentially**.
+- [ ] **Step 4:** Paste the evidence into the results doc.
+- [ ] **Step 5: Commit.** `git add tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential docs/plans/2026-10-07-native-ab-transport-gate-results.md && git commit -m "test(abcip): real-press read-only gate — native vs libplctag differential and latency (Z-35162)"`
+
+---
+
+### Task 22: Flip the defaults to Native + release note
+
+**Goal:** Make `Native` the default transport for both drivers (spec §8 P5), keeping `Libplctag` selectable, and write the client-visible release note.
+
+**Files:**
+- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipDriverOptions.cs` (default `AbCipTransport.Native`)
+- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverFactoryExtensions.cs` (`fallback: AbCipTransport.Native`)
+- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyDriverOptions.cs`, `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs` (same)
+- Modify: both AdminUI forms (relabel the options as "Native (default)" / "libplctag (fallback)")
+- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipTransportSwitchTests.cs`, `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyTransportSwitchTests.cs` (the absent ⇒ Native expectation)
+- Modify: the CLIs' `--transport` default (`native`)
+- Modify: `docs/drivers/AbCip.md`, `docs/drivers/AbLegacy.md` (the default, and a "Release note" subsection)
+
+**Acceptance Criteria:**
+- [ ] Blocked by Task 21 passing **and** Task 17 green for all AbLegacy profiles.
+- [ ] A config with no `transport` key runs Native in both drivers; prove it with the switch tests.
+- [ ] Existing unit tests that construct `AbCipDriver` with **no** injected factory and expect libplctag behaviour are updated to set `Transport = Libplctag` explicitly. Find them with `grep -rn "new AbCipDriver(" tests | grep -v tagFactory`.
+- [ ] The release note says:
+ - The native transport is the default.
+ - It reports `BadTypeMismatch` for mis-authored same-width types.
+ - Set `"transport": "Libplctag"` to fall back.
+ - AbCip writes and all of AbLegacy are simulator-proven only.
+- [ ] The full `dotnet test ZB.MOM.WW.OtOpcUa.slnx` passes, except suites that need fixtures that are not up; those are listed as skipped.
+
+**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests` → all pass.
+
+**Steps:**
+
+- [ ] **Step 1: Write the failing tests.** Flip the switch tests' absent ⇒ X expectation to `Native` in `AbCipTransportSwitchTests` and `AbLegacyTransportSwitchTests`. Run them and confirm they fail.
+- [ ] **Step 2: Flip the defaults.** Change the four defaults (two options classes, two `fallback:` arguments), relabel the AdminUI options, and change the CLIs' default to `native`. Run the switch tests → pass.
+- [ ] **Step 3: Pin the remaining libplctag tests.** Find every test that builds a driver with no injected factory and relies on libplctag behaviour (`grep -rn "new AbCipDriver(" tests | grep -v tagFactory`, and the same for `AbLegacyDriver`), and set `Transport = Libplctag` on each.
+- [ ] **Step 4: Write the release-note subsection** in both driver docs.
+- [ ] **Step 5: Run the full suite.**
+- [ ] **Step 6: Commit.** `git add src tests docs && git commit -m "feat(ab): native managed transport is the default for AbCip and AbLegacy; libplctag remains as fallback"`
+
+---
+
+## Self-review notes (author)
+
+- **Spec coverage:**
+ - D1–D9 → T1–T16.
+ - §5 status mapping → T1, T2, T3.
+ - §5 press safety → T9 (throttle, storm test), T18 (single connection), T21 (one press, read-only).
+ - §6 packaging → T1, T11.
+ - §7.1 golden frames → T4–T8.
+ - §7.2 scheduler and connection tests → T9, T10.
+ - §7.3 both-transport suites → T17.
+ - §7.4 differential harness → T18, T21.
+ - §7.5 AdminUI `/run` → T19.
+ - §8 P5 flip → T22.
+ - P6 is out of plan, as recorded.
+- **Deviations from the spec:**
+ - `MaxInFlight` is fixed at 1 (see Global Constraints).
+ - `LibplctagTagEnumerator` is renamed `SymbolListingEnumerator` (T13), because its decode loop is transport-neutral.
+ - `AbCipTagCreateParams` gains `AuthoredType` and `UseConnectedMessaging` (T12).
+ - Explicit `CipRouting` modes (T9): the spec assumed only connected-or-UCMM, but libplctag's PCCC framing needs a third, `Direct`.
+ - **Fable review (2026-10-07)** findings B1–B3 and 4–22 are folded in: the scheduler is per connection, caller cancellation is non-faulting, routing modes, the T5←T4 and T19←T17 dependencies, the narrowed guard regex, the 0x08 Large-FO fallback, keep-alive, the anchor lease, registration disposal, the BOOL 0xD3 exemption, PCCC chunking, `FromCip` parity, fixture locations, fragment continuation, the open-pair race, extra test files, the profile-driven routing flag, reinit semantics, the AbLegacy matrix filter, per-task models, and the bounded lease dispose.
diff --git a/docs/plans/2026-10-07-native-ab-transport.md.tasks.json b/docs/plans/2026-10-07-native-ab-transport.md.tasks.json
new file mode 100644
index 00000000..091fed4c
--- /dev/null
+++ b/docs/plans/2026-10-07-native-ab-transport.md.tasks.json
@@ -0,0 +1,216 @@
+{
+ "planPath": "docs/plans/2026-10-07-native-ab-transport.md",
+ "specPath": "docs/plans/2026-10-07-native-ab-transport-design.md",
+ "tasks": [
+ {
+ "id": 1,
+ "subject": "Task 1: Ab.Transport project skeleton + shared contracts",
+ "status": "completed",
+ "description": "**Goal:** Create `ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport` and its test project, wired into the solution. It carries the shared vocabulary that every later task codes against: `AbStatus`, `AbTransportException`, `AbStatusCodes`, `CipWriter`/`CipReader`, `CipReply`, and `ICipChannel`.\n\n**Files:**\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.csproj`\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/AbStatus.cs`\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/AbTransportException.cs`\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/AbStatusCodes.cs`\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Wire/CipWriter.cs`\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Wire/CipReader.cs`\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipReply.cs`\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ICipChannel.cs`\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.csproj`\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/AbStatusCodesTests.cs`\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Wire/CipReplyTests.cs`\n- Create: `docs/plans/2026-10-07-native-ab-transport-gate-results.md` (skeleton with five empty headed sections, `## ab_server suites (T17)`, `## Differential vs ab_server (T18)`, `## AdminUI /run (T19)`, `## Real press (T21)` and `## Latency (T21)`, so later tasks append to separate sections and merge cleanly)\n- Modify: `ZB.MOM.WW.OtOpcUa.slnx` (add both projects next to the AbCip entries, lines 41-44 and 112-115)\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj` and `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj` (add a `ProjectReference` to Ab.Transport)\n\n**Acceptance Criteria:**\n- [ ] `dotnet build ZB.MOM.WW.OtOpcUa.slnx` succeeds with 0 warnings and both new projects appear in the slnx.\n- [ ] `AbStatus.Ok == 0`, and every error member is negative.\n- [ ] `AbStatusCodes.FromCip` maps CIP general status as follows. It is aligned with the existing `AbCipStatusMapper.MapCipGeneralStatus`, so that existing mapping does not regress (review finding 13).\n - `0x00`/`0x0B` \u2192 `Ok`\n - `0x04`/`0x05`/`0x16` \u2192 `NotFound`\n - `0x06` \u2192 `Partial`\n - `0x08` \u2192 `Unsupported`\n - `0x0A` \u2192 `OutOfBounds`\n - `0x0C`/`0x10` \u2192 `DeviceStateConflict`\n - `0x0E`/`0x0F` \u2192 `NotAllowed`\n - `0x13` \u2192 `TooSmall`\n - `0x15` \u2192 `TooLarge`\n - `0xFF` + `0x2105` \u2192 `OutOfBounds`\n - `0xFF` + `0x2107` \u2192 `TypeMismatch`\n - everything else, including `0x1E`, \u2192 `BadReply`\n `0x1E` (embedded service error) is only meaningful on an MSP envelope, and the scheduler (T10) handles it before calling `FromCip`.\n- [ ] `AbStatusCodes.FromPccc` maps STS `0x00\u2192Ok`, `0x10\u2192Unsupported`, `0x20\u2192NotFound`, `0x30\u2192NotAllowed`, `0x40/0x50\u2192DeviceStateConflict`, and `0xF0` maps via EXT STS (`0x06/0x07\u2192NotFound` address problems, else `BadReply`).\n- [ ] `CipReply.Parse` round-trips a reply with 0, 1 and 2 extended-status words.\n- [ ] The driver projects reference Ab.Transport, and Ab.Transport references no OtOpcUa project.\n\n**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests` \u2192 all pass.\n\nSteps + code: docs/plans/2026-10-07-native-ab-transport.md \u00a7 Task 1.\n\n```json:metadata\n{\"files\": [\"ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport\", \"ZB.MOM.WW.OtOpcUa.slnx\", \"docs/plans/2026-10-07-native-ab-transport-gate-results.md\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/AbStatus.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/AbStatusCodes.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/AbTransportException.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipReply.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ICipChannel.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Wire/CipReader.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Wire/CipWriter.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.csproj\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/AbStatusCodesTests.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Wire/CipReplyTests.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.csproj\"], \"verifyCommand\": \"dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests\", \"modelTier\": \"standard\", \"model\": \"sonnet\", \"acceptanceCriteriaCount\": 6}\n```"
+ },
+ {
+ "id": 2,
+ "subject": "Task 2: AbCip seam \u2014 decouple the driver from libplctag type",
+ "status": "completed",
+ "description": "**Goal:** Change every AbCip file other than the `Libplctag*.cs` adapters so it speaks `AbStatus` / `AbTransportException` instead of libplctag's `Status` / `LibPlcTagException`. Behaviour stays identical.\n\n**Files:**\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs`, `LibplctagTagEnumerator.cs`, `LibplctagTemplateReader.cs` (wrap libplctag calls; translate)\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagStatusTranslation.cs`\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipStatusMapper.cs` (replace `MapLibplctagStatus(Status)` / `TryGetStatus(LibPlcTagException)` with `MapStatus(AbStatus)`, `MapStatus(int)`, and `MapException`)\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs` (`IsReachableStatus(AbStatus)`, `IsReachableException(AbTransportException)`; construct runtimes via an `IAbCipTagFactory` parameter that defaults to `new LibplctagTagFactory()`)\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs:1,1186-1191` (catch `AbTransportException`)\n- Modify (tests): `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverWriteTests.cs`, `AbCipDriverProbeTests.cs`, `AbCipDriverReadTests.cs`, `AbCipStatusMapperTests.cs`, `AbCipDriverCodeReviewRegressionTests.cs` (line 326 uses `libplctag.Status.ErrorBadConnection`), `AbCipUnknownTagReadTests.cs`, `AbCipLoggingTests.cs` (line 91, same), `FakeAbCipTag.cs`. Run `grep -rln \"libplctag\" tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests --include=*.cs` and migrate **every** hit except `LibplctagStatusTranslationTests.cs` and the mapper parity oracle.\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs` (its `` must become `MapStatus(int)`. A dangling cref is CS1574, which `TreatWarningsAsErrors` turns into a build error.)\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/LibplctagStatusTranslationTests.cs`\n\n**Acceptance Criteria:**\n- [ ] `grep -l 'using libplctag' src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/*.cs` lists only `LibplctagTagRuntime.cs`, `LibplctagTagEnumerator.cs`, `LibplctagTemplateReader.cs` and `LibplctagStatusTranslation.cs`.\n- [ ] `LibplctagTagRuntime.GetStatus()` returns `(int)AbStatus`, and every libplctag call that can throw `LibPlcTagException` rethrows `AbTransportException` with the translated status and the original as `InnerException`.\n- [ ] The translation table is the existing `MapLibplctagStatus` buckets re-expressed in `AbStatus`:\n - `Ok\u2192Ok`, `Pending\u2192Pending`, `ErrorTimeout\u2192Timeout`\n - `ErrorNotFound/ErrorNoMatch/ErrorBadDevice\u2192NotFound`\n - `ErrorNotAllowed\u2192NotAllowed`\n - `ErrorOutOfBounds\u2192OutOfBounds`, `ErrorTooLarge\u2192TooLarge`, `ErrorTooSmall\u2192TooSmall`\n - `ErrorUnsupported/ErrorNotImplemented\u2192Unsupported`\n - `ErrorPartial\u2192Partial`, `ErrorAbort\u2192Abort`\n - `ErrorBadReply\u2192BadReply`\n - every other member \u2192 `BadConnection`\n- [ ] `AbCipStatusMapper.MapStatus(AbStatus)` produces exactly the OPC UA codes the old mapper produced for the corresponding libplctag members. `TypeMismatch` maps to `BadTypeMismatch` and `DeviceStateConflict` to `BadDeviceFailure`. `Partial` and `BadReply` must still map to `BadCommunicationError`, as `ErrorPartial` / `ErrorBadReply` did.\n- [ ] The full existing `AbCip.Tests` suite is green, with the only changes being test fakes constructing `AbTransportException(AbStatus.X, ...)` instead of `LibPlcTagException(\"ErrorX\")` and setting `Status = (int)AbStatus.X`.\n\n**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests` \u2192 all pass, then `grep -l 'using libplctag' src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/*.cs` \u2192 exactly the four `Libplctag*` files.\n\nSteps + code: docs/plans/2026-10-07-native-ab-transport.md \u00a7 Task 2.\n\n```json:metadata\n{\"files\": [\"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs:1,1186-1191\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipStatusMapper.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagStatusTranslation.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverWriteTests.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/LibplctagStatusTranslationTests.cs\"], \"verifyCommand\": \"dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests && grep -l 'using libplctag' src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/*.cs\", \"modelTier\": \"standard\", \"model\": \"sonnet\", \"acceptanceCriteriaCount\": 5}\n```",
+ "blockedBy": [
+ 1
+ ]
+ },
+ {
+ "id": 3,
+ "subject": "Task 3: AbLegacy seam \u2014 decouple the driver from libplctag t",
+ "status": "completed",
+ "description": "**Goal:** Do the same as Task 2 for AbLegacy. Only `LibplctagLegacyTagRuntime.cs` and `LibplctagLegacyStatusTranslation.cs` may reference libplctag.\n\n**Files:**\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs`\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyStatusTranslation.cs`\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyStatusMapper.cs` (`MapStatus(AbStatus|int)`, `MapException`; keep `MapPcccStatus`)\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs` (`IsReachableStatus(AbStatus)`, catch `AbTransportException`, optional `IAbLegacyTagFactory` parameter defaulting to `new LibplctagLegacyTagFactory()`)\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs` (lines ~318-325: `MapLibplctagStatus`\u2192`MapStatus`; log text \"transport status\"; any `LibPlcTagException` catch \u2192 `AbTransportException`)\n- Modify (tests): `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyEvictOnFailureTests.cs`, `AbLegacyReadWriteTests.cs` (line 73), `AbLegacyHostAndStatusTests.cs`, `AbLegacyBitRmwTests.cs` (line 232), `AbLegacyArrayTests.cs` (line 115), `FakeAbLegacyTag.cs`. Run `grep -rln \"libplctag\" tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests --include=*.cs` and migrate every hit except the translation test and the parity oracle. Also grep `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy` for `cref=\".*MapLibplctagStatus` and fix any match (CS1574).\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/LibplctagLegacyStatusTranslationTests.cs`\n\n**Acceptance Criteria:**\n- [ ] `grep -l 'using libplctag' src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/*.cs` lists exactly `LibplctagLegacyTagRuntime.cs` and `LibplctagLegacyStatusTranslation.cs`.\n- [ ] The translation table is identical to Task 2's, applied in the AbLegacy namespace. The table is duplicated, not shared, because each driver owns its own libplctag confinement and Task 22's eventual deletion removes both.\n- [ ] `AbLegacyStatusMapper.MapStatus(AbStatus)` gives exactly the outputs the old `MapLibplctagStatus` gave for the corresponding members, proven by a parity table copied from the old switch before deleting it.\n- [ ] The full `AbLegacy.Tests` suite is green.\n\n**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests` \u2192 all pass.\n\nSteps + code: docs/plans/2026-10-07-native-ab-transport.md \u00a7 Task 3.\n\n```json:metadata\n{\"files\": [\"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyStatusMapper.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyStatusTranslation.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyEvictOnFailureTests.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/LibplctagLegacyStatusTranslationTests.cs\"], \"verifyCommand\": \"dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests\", \"modelTier\": \"standard\", \"model\": \"sonnet\", \"acceptanceCriteriaCount\": 4}\n```",
+ "blockedBy": [
+ 1
+ ]
+ },
+ {
+ "id": 4,
+ "subject": "Task 4: Capture golden frames (libplctag \u21c4 ab_server)",
+ "status": "completed",
+ "description": "**Goal:** Capture, once, the exact request/reply bytes that libplctag 1.5.2 exchanges with `ab_server` for every operation the native stack must reproduce. Store them as hex fixtures that Tasks 7 and 8 assert parity against.\n\n**Files:**\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/README.md` (how each capture was taken: commit, image tag, command)\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/*.hex` (one file per exchange, as below)\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/CaptureFile.cs` (loader)\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/CaptureFileTests.cs`\n- Create: `scripts/ab-capture/capture.sh` (re-runnable recipe)\n\n**Acceptance Criteria:**\n- [ ] Each `.hex` file holds alternating `> ` (client\u2192server) and `< ` (server\u2192client) lines of **encapsulation frames**. Each line is one full EIP frame (the 24-byte header plus data) as uppercase hex with no spaces. Lines starting with `#` are comments.\n- [ ] These captures exist and are non-empty:\n - `logix-register-session.hex`\n - `logix-forward-open.hex` (both a Large FO 0x5B and, if libplctag falls back or uses it, a 0x54)\n - `logix-read-dint.hex`, `logix-read-dint-array16.hex`, `logix-read-string.hex`, `logix-read-bool.hex`\n - `logix-write-dint.hex`, `logix-write-string.hex`\n - `logix-read-unknown-tag.hex` (CIP 0x04, via patch 0002)\n - `logix-tags-listing.hex` (`@tags`, via patch 0001)\n - `logix-forward-close.hex`\n - `micro800-read-dint.hex` (UCMM, no Forward Open)\n - `slc500-read-n7.hex`, `slc500-write-n7.hex`, `slc500-read-f8.hex`, `micrologix-read-n7.hex`, `plc5-read-n7.hex`\n- [ ] `CaptureFileTests` loads every `.hex` file, parses every frame header, and asserts that each frame's encapsulation length field equals its data length.\n- [ ] `Captures/README.md` records, for each file: the libplctag version (1.5.2), the ab_server image (`otopcua-ab-server:libplctag-release`), the compose profile, and the libplctag tag string used.\n- [ ] `Captures/README.md` has a **\"Routing observed\"** table, one row per family (controllogix, micro800, slc500, micrologix, plc5, plus logixpccc if reachable), with the columns: ForwardOpen yes/no \u00b7 CIP request wrapped in Unconnected Send yes/no \u00b7 route bytes \u00b7 requestor-id bytes (PCCC). Tasks 9, 12 and 14 read their `Routing` choice from this table.\n\n**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter CaptureFile` \u2192 pass, and `ls tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/*.hex | wc -l` \u2192 \u2265 17.\n\nSteps + code: docs/plans/2026-10-07-native-ab-transport.md \u00a7 Task 4.\n\n```json:metadata\n{\"files\": [\"scripts/ab-capture/capture.sh\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/*.hex\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/CaptureFile.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/CaptureFileTests.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/README.md\"], \"verifyCommand\": \"dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter CaptureFile && ls tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/*.hex | wc -l\", \"modelTier\": \"frontier\", \"model\": \"opus\", \"acceptanceCriteriaCount\": 5}\n```",
+ "blockedBy": [
+ 1
+ ]
+ },
+ {
+ "id": 5,
+ "subject": "Task 5: EtherNet/IP encapsulation codec + `EncapsulationClie",
+ "status": "completed",
+ "description": "**Goal:** A TCP client that frames EIP encapsulation, registers a session, and sends SendRRData (unconnected) and SendUnitData (connected) requests with at most one outstanding request. It also includes a scriptable in-process fake server for tests.\n\n**Files:**\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Eip/EncapsulationHeader.cs`\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Eip/EncapsulationCodec.cs`\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Eip/EncapsulationClient.cs`\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Eip/EncapsulationCodecTests.cs`\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Eip/EncapsulationClientTests.cs`\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Fakes/FakeEipServer.cs`\n\n**Acceptance Criteria:**\n- [ ] `EncapsulationCodec.RegisterSession()` produces exactly `65 00 04 00 00000000 00000000 <8-byte context> 00000000 01 00 00 00`.\n- [ ] `EncapsulationCodec.SendRRData(session, ctx, cip, timeoutSec)` produces: header with command `0x006F`; data = interface handle `00000000`, timeout `u16`, item count `02 00`, Null Address item `00 00 00 00`, then the Unconnected Data item `B2 00 ` followed by the cip bytes.\n- [ ] `EncapsulationCodec.SendUnitData(session, connId, seq, cip)` produces: command `0x0070`; data = interface handle `00000000`, timeout `00 00`, item count `02 00`, Connected Address item `A1 00 04 00 `, Connected Data item `B1 00 ` followed by the cip bytes.\n- [ ] `EncapsulationCodec.ParseReply` returns the header and the CPF item payload. For a connected reply it strips the sequence count. It throws `AbTransportException(BadReply)` when encapsulation status \u2260 0, the length is wrong, or the expected item is missing.\n- [ ] `EncapsulationClient`:\n - **Lifecycle.** It connects, registers (and stores the session handle from the reply), and `UnRegisterSession`s on `DisposeAsync`.\n - **Single flight.** It serializes requests with a `SemaphoreSlim(1,1)`.\n - **Connected sequence (B1).** `SendUnitDataAsync(otConnectionId, cip, timeout, ct)` takes **no sequence argument**. The client owns the connected sequence counter and increments it **inside the gate**, wrapping 65535 \u2192 1.\n - **Matching replies.**\n - Unconnected replies match on the 8-byte sender context.\n - Connected (0x70) replies match on the echoed sequence count.\n - Any frame that doesn't match (an earlier abandoned request's late reply) is **skipped** and the client keeps reading. It is not treated as an error.\n - **Caller cancellation (B2).**\n - The frame write uses `CancellationToken.None`, so a write is never half-sent.\n - If the *caller's* token is cancelled during the reply wait, the call throws `OperationCanceledException`. The connection is **not** faulted: `IsConnected` stays true, the gate is released, and the late reply is skipped by the next exchange.\n - **Faults.**\n - The *timeout* elapsing (no reply within `timeout`) throws `AbTransportException(Timeout)` and **faults** the connection by closing the socket. A target that has stopped answering cannot be trusted.\n - A socket or I/O error throws `AbTransportException(BadConnection)` and faults the connection.\n - `IsConnected` turns false after any fault.\n- [ ] Parity: the bytes `RegisterSession` sends, apart from the sender context, equal the first `>` frame of `Captures/logix-register-session.hex`.\n- [ ] `FakeEipServer` accepts a script of `Func` steps, records every request it receives, and can be told to stall (to test timeouts) or drop the connection.\n\n**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter \"FullyQualifiedName~Eip\"` \u2192 all pass.\n\nSteps + code: docs/plans/2026-10-07-native-ab-transport.md \u00a7 Task 5.\n\n```json:metadata\n{\"files\": [\"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Eip/EncapsulationClient.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Eip/EncapsulationCodec.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Eip/EncapsulationHeader.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Eip/EncapsulationClientTests.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Eip/EncapsulationCodecTests.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Fakes/FakeEipServer.cs\"], \"verifyCommand\": \"dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter \\\"FullyQualifiedName~Eip\\\"\", \"modelTier\": \"standard\", \"model\": \"sonnet\", \"acceptanceCriteriaCount\": 7}\n```",
+ "blockedBy": [
+ 1,
+ 4
+ ]
+ },
+ {
+ "id": 6,
+ "subject": "Task 6: CIP path encoding + Logix type codes",
+ "status": "completed",
+ "description": "**Goal:** Pure encoders for (a) the route from a CIP path string such as `1,0` and (b) the request path for a Logix symbolic tag name. Also provide the Logix atomic type-code table that the native runtime uses for writes and for the type check.\n\n**Files:**\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipPath.cs`\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/LogixTypeCode.cs`\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/CipPathTests.cs`\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/LogixTypeCodeTests.cs`\n\n**Acceptance Criteria:**\n- [ ] `CipPath.EncodeRoute(\"1,0\")` \u2192 `01 00`. `EncodeRoute(\"\")` \u2192 empty. `EncodeRoute(\"1,0,2,10.0.0.5\")` \u2192 `01 00 12 08 31 30 2E 30 2E 30 2E 35`. That is port 2 with the extended-link-address flag `0x10 | 2` = `0x12`, length 8, the ASCII text, and no pad because the length is even. A 9-character address gets one pad byte. Port numbers \u2265 15 use the extended-port form `0x0F, u16 port`.\n- [ ] `CipPath.EncodeSymbolic(\"TestDINT\")` \u2192 `91 08 54 65 73 74 44 49 4E 54`. An odd-length name gets a pad byte after the name.\n- [ ] `EncodeSymbolic(\"Program:Main.Arr[3].Val\")` \u2192 `91 0C \"Program:Main\"`, then `91 03 \"Arr\" 00`, then element `28 03`, then `91 03 \"Val\" 00`.\n- [ ] Element segments use `28 xx` for indices < 256, `29 00 lo hi` for indices < 65536, and `2A 00 b0 b1 b2 b3` above that. A multi-dimensional `[1,2]` emits two element segments.\n- [ ] A trailing purely numeric member (`Tag.5`, the bit selector) is **not** encoded. `EncodeSymbolic` throws `ArgumentException`, and `CipPath.SplitBitSelector(\"Tag.5\")` returns `(\"Tag\", 5)`. Bit selection is the runtime's job (Task 12).\n- [ ] `CipPath.EncodeClassInstance(cls, inst)` uses 8-bit logical segments (`20 cc 24 ii`) when they fit, otherwise 16-bit (`21 00 cc cc` / `25 00 ii ii`).\n- [ ] `CipPath.WithPathSize(segments)` prefixes the size in 16-bit words.\n- [ ] `LogixTypeCode` has these constants: `Bool=0xC1, SInt=0xC2, Int=0xC3, DInt=0xC4, LInt=0xC5, USInt=0xC6, UInt=0xC7, UDInt=0xC8, ULInt=0xC9, Real=0xCA, LReal=0xCB, Struct=0x02A0, BoolArray=0xD3`. `ByteWidth(code)` returns 1/1/2/4/8/1/2/4/8/4/8/4.\n\n**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter \"FullyQualifiedName~CipPath|FullyQualifiedName~LogixTypeCode\"` \u2192 pass.\n\nSteps + code: docs/plans/2026-10-07-native-ab-transport.md \u00a7 Task 6.\n\n```json:metadata\n{\"files\": [\"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipPath.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/LogixTypeCode.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/CipPathTests.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/LogixTypeCodeTests.cs\"], \"verifyCommand\": \"dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter \\\"FullyQualifiedName~CipPath|FullyQualifiedName~LogixTypeCode\\\"\", \"modelTier\": \"standard\", \"model\": \"sonnet\", \"acceptanceCriteriaCount\": 8}\n```",
+ "blockedBy": [
+ 1
+ ]
+ },
+ {
+ "id": 7,
+ "subject": "Task 7: CIP service codecs (Connection Manager, MSP, Logix R",
+ "status": "completed",
+ "description": "**Goal:** Pure request builders and reply parsers for every CIP service the native AbCip path needs, each proven byte-exact against the Task 4 captures where a capture exists.\n\n**Files:**\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ConnectionManagerServices.cs` (Unconnected Send 0x52, Forward Open 0x54, Large Forward Open 0x5B, Forward Close 0x4E)\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/MultipleServicePacket.cs` (0x0A)\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/LogixServices.cs` (Read Tag 0x4C, Read Tag Fragmented 0x52, Write Tag 0x4D, Write Tag Fragmented 0x53)\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/SymbolObjectServices.cs` (Get Instance Attribute List 0x55 on class 0x6B)\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/TemplateObjectServices.cs` (Get Attribute List 0x03 + Read 0x4C on class 0x6C)\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/ConnectionManagerServicesTests.cs`\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/MultipleServicePacketTests.cs`\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/LogixServicesTests.cs`\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/SymbolObjectServicesTests.cs`\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/TemplateObjectServicesTests.cs`\n\n**Acceptance Criteria:**\n- [ ] **Unconnected Send** `0x52` to `ConnectionManager` builds: priority/tick `0x0A`, timeout ticks `0x0E`, embedded request size `u16`, the embedded request, a pad byte if that size is odd, the route path size in words `u8`, reserved `0x00`, then the route. When the route is empty (Micro800), the builder is not used and the embedded request is sent directly.\n- [ ] **Forward Open.** `ForwardOpen(ForwardOpenParams p)` emits:\n - service `0x54` (or `0x5B` when `p.Large`), path size `02`, `ConnectionManager`, priority/tick `0x0A`, timeout ticks `0x0E`;\n - O\u2192T id `00000000`, T\u2192O id `p.TOConnectionId`, connection serial `p.ConnectionSerial`, vendor id `p.VendorId`, originator serial `p.OriginatorSerial`;\n - timeout multiplier `p.TimeoutMultiplier`, 3 reserved bytes;\n - O\u2192T RPI `u32`, then O\u2192T params: `u16 0x4200 | size` (regular) or `u32 0x42000000 | size` (large);\n - T\u2192O RPI, then T\u2192O params (same encoding);\n - transport/trigger `0xA3`, connection path size in words, connection path = `EncodeRoute(cipPath)` + `MessageRouter`.\n- [ ] `ParseForwardOpenReply` returns `(OTConnectionId, TOConnectionId, OTApiMicros, TOApiMicros)` on success. On a refusal it surfaces the general and extended status.\n- [ ] `ShouldFallBackToRegularOpen(CipReply)` is true when the reply is general `0x08` (Service Not Supported \u2014 pre-v20 Logix and other targets answer a Large Forward Open this way, and libplctag falls back on it too) **or** general `0x01` with extended `0x0109` (invalid connection size). Task 9 uses it.\n- [ ] `ForwardClose(serial, vendor, origSerial, route)` \u2192 `4E 02 20 06 24 01 0A 0E 00 `.\n- [ ] **MSP.** `MultipleServicePacket.Build(IReadOnlyList requests)` \u2192 `0A 02 20 02 24 01`, count `u16`, then offsets `u16[count]` measured from the start of the count field, then the requests concatenated. `Parse(reply, count)` returns one `CipReply` per embedded reply. `BuildSize(sizes)` returns the exact byte size of an MSP for the given request sizes, without allocating (Task 10 uses it for packing).\n- [ ] **Read/Write Tag:**\n - `ReadTag(path, elements)` \u2192 `4C `.\n - `ReadTagFragmented(path, elements, offset)` \u2192 `52 \u2026 `.\n - `WriteTag(path, typeCode, structHandle?, elements, data)` \u2192 `4D \u2026 [ when type == 0x02A0] `.\n - `WriteTagFragmented` \u2192 `53 \u2026 [] `.\n - `ParseReadReply(CipReply)` \u2192 `(ushort TypeCode, ushort? StructHandle, ReadOnlyMemory Data)`. For `0x02A0` the next u16 is the handle.\n- [ ] **Symbol listing.** `SymbolObjectServices.GetInstanceAttributeList(programName?, startInstance)` \u2192\n - `[91 len \"Program:X\" pad]`, then `20 6B 25 00 `, then attribute count `04 00` and attributes `02 00 07 00 08 00 01 00` (type, element length, array dims, name).\n - Each reply entry, as the controller sends it, is `u32 instance, u16 type, u16 elemLen, u32\u00d73 dims, u16 nameLen, name` \u2014 exactly the entry layout `CipSymbolObjectDecoder` expects. `ParseListingReply` returns `(byte[] Entries, uint LastInstance, bool More)`, where More \u21d4 general status `0x06`.\n - \u26a0\ufe0f If `Captures/logix-tags-listing.hex` shows libplctag requesting a different attribute set or order, **match the capture** and update this criterion. The decoder layout is the invariant; the attribute list is the means.\n- [ ] **Templates:**\n - `TemplateObjectServices.GetAttributes(templateId)` \u2192 `03 03 20 6C 25 00 04 00 04 00 05 00 02 00 01 00`. That is attributes 4 (definition size, words), 5 (structure size, bytes), 2 (member count) and 1 (handle).\n - `ParseAttributes` \u2192 `(uint DefWords, uint StructBytes, ushort Members, ushort Handle)`; each attribute's reply is `id u16, status u16, value`.\n - `ReadTemplate(templateId, offset, bytes)` \u2192 `4C 03 20 6C 25 00 `, with More \u21d4 status `0x06`.\n - `TemplateObjectServices.BuildDecoderBuffer(id, attrs, rawTemplate)` \u2192 the 14-byte header + raw bytes from Global Constraints.\n- [ ] **Capture parity tests** (each extracts the CIP payload via `CaptureFile.CipPayload`):\n - The ReadTag request in `logix-read-dint.hex` equals our `ReadTag(EncodeSymbolic(\"TestDINT\"), 1)`. If libplctag wrapped it in an MSP or Unconnected Send, unwrap the same way in the test, and document that wrapping in a test comment.\n - `ParseReadReply` on the captured reply yields `TypeCode == 0xC4` and 4 data bytes.\n - The captured Forward Open request equals ours when built with the captured values of the serials and connection ids, which are read out of the capture.\n - The captured `@tags` reply entries decode through `CipSymbolObjectDecoder` (reference the AbCip project from the test project only if that is needed; otherwise assert the entry byte layout directly).\n\n**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter \"FullyQualifiedName~Services|FullyQualifiedName~MultipleServicePacket\"` \u2192 pass.\n\nSteps + code: docs/plans/2026-10-07-native-ab-transport.md \u00a7 Task 7.\n\n```json:metadata\n{\"files\": [\"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ConnectionManagerServices.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/MultipleServicePacket.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/LogixServices.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/SymbolObjectServices.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/TemplateObjectServices.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/ConnectionManagerServicesTests.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/MultipleServicePacketTests.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/LogixServicesTests.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/SymbolObjectServicesTests.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/TemplateObjectServicesTests.cs\"], \"verifyCommand\": \"dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter \\\"FullyQualifiedName~Services|FullyQualifiedName~MultipleServicePacket\\\"\", \"modelTier\": \"standard\", \"model\": \"sonnet\", \"acceptanceCriteriaCount\": 10}\n```",
+ "blockedBy": [
+ 4,
+ 6
+ ]
+ },
+ {
+ "id": 8,
+ "subject": "Task 8: PCCC codec (Execute PCCC, SLC/MicroLogix typed logic",
+ "status": "completed",
+ "description": "**Goal:** Pure builders and parsers that turn a PCCC file address into an Execute-PCCC CIP request, for both SLC/MicroLogix/LogixPccc and PLC-5. Byte parity is proven against the Task 4 captures.\n\n**Files:**\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Pccc/PcccFileType.cs`\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Pccc/PcccAddress.cs`\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Pccc/PcccServices.cs`\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Pccc/PcccServicesTests.cs`\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Pccc/PcccAddressTests.cs`\n\n**Acceptance Criteria:**\n- [ ] `PcccAddress(string FileLetter, int? FileNumber, int Element, int? SubElement)` is the transport-side address. It does not depend on `AbLegacyAddress`; Task 14 maps between the two.\n- [ ] File type codes: `O=0x8B, I=0x8C, S=0x84, B=0x85, T=0x86, C=0x87, R=0x88, N=0x89, F=0x8A, ST=0x8D, A=0x8E, L=0x91`. Implicit file numbers: `O\u21920, I\u21921, S\u21922`. **Verify every code against `slc500-read-n7.hex` / `slc500-read-f8.hex`**; the capture wins if it disagrees.\n- [ ] Sub-element indices: `T/C` \u2192 `PRE=1, ACC=2` (word offsets within the 3-word element, where word 0 is the control bits), and `R` \u2192 `LEN=1, POS=2`. The bit sub-elements (`EN/TT/DN/CU/CD/OV/UN`) read word 0 and are returned as `(sub=0, bit)`. `PcccAddress.ResolveSubElement` returns `(int word, int? bit)`.\n- [ ] `PcccServices.ExecutePccc(requestorVendor, requestorSerial, pccc)` \u2192 `4B 02 20 67 24 01 07 `.\n- [ ] `PcccServices.SlcTypedRead(tns, addr, byteCount)` \u2192 PCCC `0F 00 A2 `, where `u8*` is a single byte when < 255, else `FF lo hi`.\n- [ ] `PcccServices.SlcTypedWrite(tns, addr, data)` \u2192 `0F 00 AA `.\n- [ ] **Byte-count limit (review finding 12).** `byteCount` is a single byte on the wire. `SlcTypedRead`/`SlcTypedWrite` (and the PLC-5 builders) throw `AbTransportException(TooLarge)` when the requested or supplied byte count exceeds `PcccServices.MaxDataBytes = 236`; a silent `(byte)` wrap would be a corrupt write. Test 236 (ok) and 237 (throws) for both. Task 14 chunks larger arrays.\n- [ ] `PcccServices.Plc5TypedRead(tns, addr, elements)` \u2192 `0F 00 68 `.\n - The system address is a level-mask byte (bit0 = file level present? **derive from `plc5-read-n7.hex`**) followed by level values (`$N7:0` \u21d2 data table section, file 7, element 0), each `u8*` encoded.\n - `Plc5TypedWrite` is `0F 00 67 `.\n - \u26a0\ufe0f The PLC-5 framing must be **copied from the capture**. If ab_server's `--plc=PLC/5` path is not exercised by libplctag in the capture, do **not** ship PLC-5 builders at all. Instead expose `PcccServices.Plc5Verified = false`, have the native AbLegacy runtime throw `AbTransportException(Unsupported)` for `plc5`, and record that in the Task 20 docs. Do not use `[Obsolete]`: under `TreatWarningsAsErrors` it is a build error at every call site.\n- [ ] `PcccServices.ParseReply(cipReply)` strips the requestor-id echo (`len u8` + `len-1` bytes), reads `cmd u8 (0x4F), sts u8, tns u16`, then `extSts u8` if `sts == 0xF0`, then the data. Non-zero STS \u2192 `AbTransportException(AbStatusCodes.FromPccc(sts, ext))`. A TNS that differs from the request \u2192 `BadReply`.\n- [ ] Capture parity: `SlcTypedRead` for `N7:0` / `F8:0` equals the PCCC payload inside the captured Execute PCCC request, with the TNS normalized; `ParseReply` on the captured reply yields the 2 / 4 data bytes.\n\n**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter \"FullyQualifiedName~Pccc\"` \u2192 pass.\n\nSteps + code: docs/plans/2026-10-07-native-ab-transport.md \u00a7 Task 8.\n\n```json:metadata\n{\"files\": [\"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Pccc/PcccAddress.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Pccc/PcccFileType.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Pccc/PcccServices.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Pccc/PcccAddressTests.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Pccc/PcccServicesTests.cs\"], \"verifyCommand\": \"dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter \\\"FullyQualifiedName~Pccc\\\"\", \"modelTier\": \"standard\", \"model\": \"sonnet\", \"acceptanceCriteriaCount\": 10}\n```",
+ "blockedBy": [
+ 4
+ ]
+ },
+ {
+ "id": 9,
+ "subject": "Task 9: CipConnection + DeviceSessionPool (routing modes, ke",
+ "status": "completed",
+ "description": "**Goal:** Own one EIP session, one CIP connection (when routed `Connected`) and **one `RequestScheduler`** per device key. Hand out ref-counted leases exposing both, keep idle class-3 connections alive, and rate-limit Forward Opens so that no failure mode can storm a PLC.\n\n**Files:**\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/DeviceKey.cs`\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ConnectionOptions.cs` (includes the `CipRouting` enum)\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipConnection.cs` (implements `ICipChannel`)\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ForwardOpenThrottle.cs`\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/DeviceSessionPool.cs` (includes `ConnectionLease`)\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.csproj` (`InternalsVisibleTo` for `ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests`, `ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests`, `ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests`)\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/CipConnectionTests.cs`\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/ForwardOpenThrottleTests.cs`\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/DeviceSessionPoolTests.cs`\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Fakes/FakeCipTarget.cs` (a `FakeEipServer.Handler` that answers Forward Open / Forward Close / Unconnected Send / Direct / connected data, and counts each)\n\n**Acceptance Criteria:**\n- [ ] `DeviceKey(string Host, int Port, string CipPath, string PlcAttribute)` is a record with case-insensitive `Host`/`PlcAttribute` equality.\n- [ ] **`ConnectionOptions`:**\n - `Routing` (`CipRouting { Connected, UnconnectedSend, Direct }`) and `AllowPacking` (bool).\n - `Timeout` and `ConnectionSize` (int).\n - `MinForwardOpenInterval` (default 1 s).\n - `RpiMicros` (default 2 000 000) and `TimeoutMultiplier` (default 1 \u21d2 \u00d78, so connection expiry = 16 s).\n - `KeepAliveInterval` (default = RPI \u00d7 8 / 2 = 8 s).\n - `VendorId` (`0x1337`) and `OriginatorSerial` (random per process).\n- [ ] **Routing semantics (B3):**\n - `Connected`:\n - On first use: TCP connect, RegisterSession, Forward Open. When `ConnectionSize > 511` that is a Large FO (0x5B); otherwise a regular one.\n - If `ShouldFallBackToRegularOpen` (T7) is true for the reply, it retries **once**, in the same throttled attempt, with a regular open at `Math.Min(ConnectionSize, 504)`.\n - Requests go via `SendUnitDataAsync`. The client assigns the sequence (T5).\n - `MaxRequestBytes = negotiatedSize - 2`.\n - `UnconnectedSend`: requests go via SendRRData wrapped in Unconnected Send 0x52 with the route. `MaxRequestBytes = 504 - (12 + route.Length)`.\n - `Direct`: requests go via SendRRData carrying the CIP request bare, even when a CIP path is configured; the path is ignored. `MaxRequestBytes = 504`.\n- [ ] **Reconnect:** when the client is faulted (timeout or I/O, never caller cancellation \u2014 see T5), the next `SendAsync` reconnects through the throttle. The failing call itself throws its original exception.\n- [ ] **Keep-alive (review finding 8, spec \u00a79).** In `Connected` mode, a timer sends Get Attribute Single (`0E 03 20 01 24 01 30 01`: Identity, instance 1, attribute 1 = Vendor ID) through the connection whenever no request has been sent for `KeepAliveInterval`.\n - A keep-alive failure faults the connection like any other.\n - Keep-alives count towards `RequestsSent`.\n - Test with fake time: no keep-alive while traffic flows; exactly one after an idle `KeepAliveInterval`.\n- [ ] **No null-race (finding 16).** The open path returns the `(client, connection)` pair it established, and `SendAsync` uses that pair rather than re-reading fields after the lock.\n- [ ] **Clean disposal.** Disposal never disposes a `SemaphoreSlim` that waiters may still be queued on. It sets a `_disposed` flag checked under the lock, and later callers get `AbTransportException(Abort)`.\n- [ ] **`ForwardOpenThrottle`** (per `DeviceKey`; the pool keeps throttles for the process lifetime, so a reconnect after the last release is still limited):\n - At most 1 open attempt in flight.\n - An attempt within `MinForwardOpenInterval` of the previous **attempt start** waits for the remainder. If the caller's token fires first, it throws `AbTransportException(BadConnection, \"Forward Open throttled\")`.\n - It uses an injected `TimeProvider`.\n- [ ] **Storm test:** against a `FakeCipTarget` refusing every Forward Open, 50 concurrent `SendAsync` callers over 3 s of fake time produce **\u2264 4** Forward Open frames.\n- [ ] **`DeviceSessionPool`:**\n - `Acquire(DeviceKey, ConnectionOptions) \u2192 ConnectionLease`. `ConnectionLease : IAsyncDisposable` exposes `ICipChannel Channel` and `RequestScheduler Scheduler`.\n - **All leases for one key share one `CipConnection` and one `RequestScheduler`** (B1). The scheduler is built as `new RequestScheduler(connection, options.AllowPacking)`. A second `Acquire` with different `ConnectionOptions` for the same key reuses the first entry's options; that is logged once and pinned by a test.\n - The last lease's dispose disposes the scheduler, sends Forward Close (`Connected` only) and then UnRegisterSession.\n - A later acquire on the same key builds new instances.\n - Thread-safe: 100 parallel acquire/dispose tasks pass.\n- [ ] `DeviceSessionPool.Shared` is the process-wide instance; tests build their own.\n- [ ] **Diagnostics for the gates (Tasks 18 and 21):** `internal static int ForwardOpenCount` (one increment per Forward Open frame sent, including the fallback) and `internal static int RequestsSent` (one per channel send, keep-alives included), both via `Interlocked`.\n\n**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter \"FullyQualifiedName~CipConnection|FullyQualifiedName~ForwardOpenThrottle|FullyQualifiedName~DeviceSessionPool\"` \u2192 pass; the storm test passes 20/20 consecutive runs.\n\nSteps + code: docs/plans/2026-10-07-native-ab-transport.md \u00a7 Task 9.\n\n```json:metadata\n{\"files\": [\"ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests\", \"ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests\", \"ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipConnection.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ConnectionOptions.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/DeviceKey.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ForwardOpenThrottle.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/DeviceSessionPool.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.csproj\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/CipConnectionTests.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/ForwardOpenThrottleTests.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/DeviceSessionPoolTests.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Fakes/FakeCipTarget.cs\"], \"verifyCommand\": \"dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter \\\"FullyQualifiedName~CipConnection|FullyQualifiedName~ForwardOpenThrottle|FullyQualifiedName~DeviceSessionPool\\\"\", \"modelTier\": \"frontier\", \"model\": \"opus\", \"acceptanceCriteriaCount\": 12}\n```",
+ "blockedBy": [
+ 5,
+ 7,
+ 10
+ ]
+ },
+ {
+ "id": 10,
+ "subject": "Task 10: RequestScheduler \u2014 MSP coalescing + fragmentation",
+ "status": "completed",
+ "description": "**Goal:** A per-channel scheduler that coalesces concurrent CIP requests into Multiple Service Packets within `MaxRequestBytes`, keeps one request in flight, fans each reply back to its own waiter, and runs fragmented read/write loops for payloads that do not fit in one frame.\n\n**Files:**\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/RequestScheduler.cs`\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/LogixTagClient.cs` (read/write helpers over the scheduler, including the fragment loops)\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/RequestSchedulerTests.cs`\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/LogixTagClientTests.cs`\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Fakes/FakeCipChannel.cs`\n\n**Acceptance Criteria:**\n- [ ] `RequestScheduler(ICipChannel channel, bool allowPacking)` exposes `Task SendAsync(byte[] request, CancellationToken ct)`.\n- [ ] **Packing:** while one request is in flight, new requests queue. When the channel frees, the scheduler takes queued requests in FIFO order while `MultipleServicePacket.BuildSize(sizes) <= channel.MaxRequestBytes`. More than one taken \u21d2 one MSP. Exactly one \u21d2 sent bare. `allowPacking == false` \u21d2 always bare.\n- [ ] **Boundary tests:** with `MaxRequestBytes = M`, two requests whose MSP size is exactly `M` pack into one frame, and two whose MSP size is `M + 1` go as two frames.\n- [ ] **A single request larger than `MaxRequestBytes`** is sent bare. Callers must fragment; `LogixTagClient` does.\n- [ ] **Reply fan-out:** each waiter gets its own embedded `CipReply`, with its own general status, so one failing service in an MSP fails only its own waiter. If the MSP envelope itself fails (non-zero general status other than `0x1E` embedded-service-error), or the channel throws, every waiter in that batch faults with the same `AbTransportException`.\n- [ ] **Cancellation:**\n - A waiter cancelled while queued is removed and never sent.\n - A waiter cancelled while its batch is in flight gets `OperationCanceledException`, and the batch's other waiters still complete.\n - **The channel call always uses the scheduler's own `_stop` token, never a waiter's**, for single sends as well as MSPs (B2). So one caller's cancellation can never abort, or fault, a shared exchange.\n - Each waiter's `CancellationTokenRegistration` is stored on its `Pending` record and **disposed when the TCS completes** (review finding 10). A test makes 10 000 sends on one long-lived token and asserts no growth: measure with a counting `CancellationTokenSource` subclass, or assert `Pending` registrations are disposed via a test hook.\n- [ ] **Owner:** one scheduler per device connection, created by `DeviceSessionPool` (T9). This task builds only the class and tests it over `FakeCipChannel`.\n- [ ] **`LogixTagClient`:**\n - `ReadAsync(path, elements)` sends Read Tag.\n - If the reply status is `0x06` (Partial), it **keeps** that reply's data and continues with Read Tag Fragmented at `offset = bytesReceivedSoFar`, appending each reply's data until a reply has status `0x00`. That avoids double-counting the first chunk (review finding 15).\n - It returns `(TypeCode, StructHandle, byte[] Data)`. The type code and handle come from the first reply; fragment replies repeat the type header, so strip it from each before appending.\n - **Reply-size note:** packing bounds only the *request* size, so many large reads packed together can produce a reply that the controller truncates with `0x06` per service. That is correct behaviour (the fragment loop recovers it), not an error.\n - `WriteAsync(path, type, handle, elements, data)` sends one Write Tag when it fits, otherwise Write Tag Fragmented chunks of `MaxRequestBytes - 12 - path.Length`, aligned down to the element width.\n - Non-zero final status \u21d2 `AbTransportException(reply.Status)`.\n- [ ] `FakeCipChannel` lets a test set `MaxRequestBytes`, records every request, and answers via a delegate. It is reused by Tasks 12 and 13.\n\n**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter \"FullyQualifiedName~RequestScheduler|FullyQualifiedName~LogixTagClient\"` \u2192 pass.\n\nSteps + code: docs/plans/2026-10-07-native-ab-transport.md \u00a7 Task 10.\n\n```json:metadata\n{\"files\": [\"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/RequestScheduler.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/LogixTagClient.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/RequestSchedulerTests.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Fakes/FakeCipChannel.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/LogixTagClientTests.cs\"], \"verifyCommand\": \"dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter \\\"FullyQualifiedName~RequestScheduler|FullyQualifiedName~LogixTagClient\\\"\", \"modelTier\": \"frontier\", \"model\": \"opus\", \"acceptanceCriteriaCount\": 9}\n```",
+ "blockedBy": [
+ 7
+ ]
+ },
+ {
+ "id": 11,
+ "subject": "Task 11: libplctag-confinement guard",
+ "status": "completed",
+ "description": "**Goal:** A source-scan test that fails if any production `.cs` file outside the libplctag adapters references `libplctag`, so the eventual deletion stays mechanical.\n\n**Files:**\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/LibplctagConfinementGuardTests.cs`\n\n**Acceptance Criteria:**\n- [ ] The test walks up from `AppContext.BaseDirectory` to the directory containing `ZB.MOM.WW.OtOpcUa.slnx`, then scans every `*.cs` under `src/` (excluding `bin`/`obj`) for **code** references: `using libplctag` or `libplctag.(Tag|Status|PlcType|Protocol|LibPlcTagException|NativeImport)`. Prose such as the CLI help string `\"via libplctag. Second of six\u2026\"` (`AbCip.Cli/Program.cs:9`, `AbLegacy.Cli/Program.cs:8`) must **not** match, or the guard is red from wave 2 until T15/T16 (review finding 6).\n- [ ] Allowed files: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Libplctag*.cs` and `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/Libplctag*.cs` only. Comments and XML docs are ignored by stripping `//` line comments before matching.\n- [ ] The failure message lists each offending file and line.\n- [ ] A second fact, `Prose_mentions_of_libplctag_do_not_trip_the_guard`, runs the regex against the literal `\"\u2026via libplctag. Second of six driver CLIs\"` and asserts no match.\n- [ ] **Falsifiability check:** temporarily add `using libplctag;` to `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs`, run the test and see it go red, then revert. Record that you did this in the commit message.\n\n**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter LibplctagConfinementGuard` \u2192 pass.\n\nSteps + code: docs/plans/2026-10-07-native-ab-transport.md \u00a7 Task 11.\n\n```json:metadata\n{\"files\": [\"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/LibplctagConfinementGuardTests.cs\"], \"verifyCommand\": \"dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter LibplctagConfinementGuard\", \"modelTier\": \"mechanical\", \"model\": \"haiku\", \"acceptanceCriteriaCount\": 5}\n```",
+ "blockedBy": [
+ 2,
+ 3
+ ]
+ },
+ {
+ "id": 12,
+ "subject": "Task 12: `NativeAbCipTagRuntime` + factory",
+ "status": "completed",
+ "description": "**Goal:** A native `IAbCipTagRuntime` that leases the device connection from `DeviceSessionPool.Shared`, reads and writes through `LogixTagClient`, decodes the managed buffer with the same semantics as `LibplctagTagRuntime`, and adds a CIP type check.\n\n**Files:**\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagRuntime.cs`\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagFactory.cs`\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipDevice.cs` (maps `AbCipTagCreateParams` \u2192 `DeviceKey` + `ConnectionOptions`; shared with Task 13)\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs` (add trailing optionals `AbCipDataType? AuthoredType = null, bool UseConnectedMessaging = true` to `AbCipTagCreateParams`)\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs` (pass `UseConnectedMessaging: profile.SupportsConnectedMessaging`)\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs` (`EnsureTagRuntimeAsync` passes `AuthoredType: def.DataType`; `DeviceState`'s create-params builder passes `UseConnectedMessaging: Profile.SupportsConnectedMessaging`)\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTagRuntimeTests.cs`\n- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.csproj` (add a `ProjectReference` to Ab.Transport and ` `.; source-linking compiles the fake into this assembly, so it stays `internal`)\n\n**Acceptance Criteria:**\n- [ ] `NativeAbCipDevice.Key(p)` = `new DeviceKey(p.Gateway, p.Port > 0 ? p.Port : 44818, p.CipPath, p.LibplctagPlcAttribute)`.\n- [ ] `NativeAbCipDevice.Options(p)`:\n - `Timeout = p.Timeout`, `ConnectionSize = p.ConnectionSize`, `AllowPacking = p.AllowPacking`.\n - **`Routing`:** if `p.UseConnectedMessaging` is false, use `UnconnectedSend` when `p.CipPath` is non-empty and `Direct` when it is empty. Otherwise use `Connected`.\n - `UseConnectedMessaging` is a new trailing optional `bool UseConnectedMessaging = true` on `AbCipTagCreateParams`. It is set at every construction site from `AbCipPlcFamilyProfile.SupportsConnectedMessaging`, so the family profile stays the single authority and string-matching `\"micro800\"` is not needed (review finding 18). The sites are `AbCipDriver` `DeviceState` (around `AbCipDriver.cs:1690-1700`, where `AllowPacking`/`ConnectionSize` are already merged) and `AbCipDriverProbe`; find any others with `grep -rn \"new AbCipTagCreateParams\" src`.\n - Cross-check the result against the Task 4 \"Routing observed\" table for controllogix and micro800, and record any mismatch in the test comment.\n- [ ] **Initialization and reads:**\n - `InitializeAsync` acquires the lease from `DeviceSessionPool.Shared` and builds a per-runtime `LogixTagClient` over **the lease's shared `Scheduler` and `Channel`**. It must never construct its own `RequestScheduler`: that would defeat MSP coalescing across tags (B1). It encodes the path once, splitting off a trailing bit selector with `CipPath.SplitBitSelector`; that is stored as the runtime's implicit bit.\n - It performs **one read** to prove the tag exists. That matches libplctag, whose `InitializeAsync` errors for an unknown tag; the driver's 05/STAB-8 logic depends on create throwing `NotFound`.\n - `ReadAsync` reads `p.ElementCount` elements, stores the data, `TypeCode` and `StructHandle`, and sets status `Ok`. On `AbTransportException` it stores the exception's status **and rethrows**, matching libplctag's exception-on-failure behaviour. `GetSize()` returns the data length.\n- [ ] **Decode** is byte-identical to `LibplctagTagRuntime` for every `AbCipDataType` (Bool/SInt/USInt/Int/UInt/DInt/UDInt/LInt/ULInt/Real/LReal/String/Dt). Specifically:\n - Bool with a bit index \u21d2 bit `n` of the little-endian buffer.\n - Bool without \u21d2 `byte[0] != 0`.\n - String \u21d2 Logix STRING `u32 LEN` + `DATA`, ASCII, `LEN` clamped to the data length \u2212 4.\n - `DecodeArray` uses stride = `data.Length / count` when it divides evenly, else the static width (the same `ResolveArrayStride` rule; call `LibplctagTagRuntime.ResolveArrayStride` with `elementSize: null` to share it).\n - Bool arrays are bit-packed (bit `i` of the buffer).\n- [ ] **Type check:** after a read, if `TypeCode` is atomic and differs from the authored type's Logix code, `GetStatus()` returns `(int)AbStatus.TypeMismatch` and `Decode*` returns null. The mapping is `AbCipDataType` \u2192 `LogixTypeCode`, with `Dt \u2192 DInt`; `String`/`Structure` are exempt. Exemptions:\n - (a) A BOOL read with a bit index against a DINT host is **not** a mismatch.\n - (b) **Authored `Bool` with returned `0xD3`** (the Logix packed BOOL-array type) is **not** a mismatch (review finding 11).\n - Tests cover both exemptions, plus a positive mismatch.\n- [ ] **Encode/write:**\n - `EncodeValue` fills a managed write buffer with the same encodings as `LibplctagTagRuntime.EncodeValue`, including the same `NotSupportedException` cases.\n - `WriteAsync` writes with type = the last read `TypeCode` if known, else the authored type's code.\n - For String, it uses `Struct` + the last `StructHandle`. If none is known it performs a read first; that read happens inside `WriteAsync`, under the driver's existing per-runtime lock.\n - The STRING write payload is `u32 len` + DATA padded to the capacity learned from the last read's data length \u2212 4.\n- [ ] `Dispose` releases the lease, waiting for at most 2 s (`lease.DisposeAsync().AsTask().Wait(TimeSpan.FromSeconds(2))`). It must not fire and forget: an immediate re-init would otherwise open a second session to the press while the old Forward Close is still in flight (review finding 22). It never disposes the shared scheduler; the pool does that on the last release.\n- [ ] `NativeAbCipTagFactory : IAbCipTagFactory` creates `NativeAbCipTagRuntime`.\n- [ ] Test seam: `internal static NativeAbCipTagRuntime ForTest(ICipChannel channel, AbCipTagCreateParams p)` builds a runtime over the given channel with a private `RequestScheduler(channel, p.AllowPacking)` (test-only; no pool lease). Production code always goes through the factory and `DeviceSessionPool.Shared`.\n- [ ] **Unit tests** (against `FakeCipChannel` via `ForTest`):\n - decode parity for each type, using a buffer captured from `LibplctagTagRuntime` semantics (hard-coded bytes);\n - the TypeMismatch case (REAL returned for a DInt-authored tag);\n - an unknown tag \u21d2 `InitializeAsync` throws `AbTransportException(NotFound)`;\n - a STRING write sends `Struct` + the handle learned from the prior read;\n - a 1-element array is read as an array (`IsArray`).\n\n**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests --filter NativeAbCipTagRuntime` \u2192 pass.\n\nSteps + code: docs/plans/2026-10-07-native-ab-transport.md \u00a7 Task 12.\n\n```json:metadata\n{\"files\": [\"ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipDevice.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagFactory.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagRuntime.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTagRuntimeTests.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.csproj\"], \"verifyCommand\": \"dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests --filter NativeAbCipTagRuntime\", \"modelTier\": \"frontier\", \"model\": \"opus\", \"acceptanceCriteriaCount\": 10}\n```",
+ "blockedBy": [
+ 2,
+ 9
+ ]
+ },
+ {
+ "id": 13,
+ "subject": "Task 13: Native AbCip symbol enumerator + template reader",
+ "status": "completed",
+ "description": "**Goal:** Native `IAbCipTagEnumerator` and `IAbCipTemplateReader` implementations whose output buffers feed the **unchanged** decoders.\n\n**Files:**\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/SymbolListingEnumerator.cs` (the transport-neutral listing loop moved out of `LibplctagTagEnumerator.cs`)\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagEnumerator.cs` (keeps only `LibplctagListingReader` + `LibplctagTagEnumeratorFactory`)\n- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/` files referencing `LibplctagTagEnumerator` (rename to `SymbolListingEnumerator`)\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagEnumerator.cs`\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTemplateReader.cs`\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTagEnumeratorTests.cs`\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTemplateReaderTests.cs`\n\n**Acceptance Criteria:**\n- [ ] **Enumerator split.** The current `LibplctagTagEnumerator` class (its listing loop has no libplctag dependency apart from its default constructor) is renamed **`SymbolListingEnumerator`**, keeping its `Func> readListing` constructor seam. The libplctag default reader moves into `LibplctagTagEnumerator.cs` as `LibplctagListingReader`, so the Task 11 guard still allows it. `LibplctagTagEnumeratorFactory` returns `new SymbolListingEnumerator(LibplctagListingReader.ReadAsync)`.\n- [ ] **Native listing reader.** `NativeAbCipTagEnumerator` = `new SymbolListingEnumerator(NativeListingReader.ReadAsync)`, wrapped in a class for the factory. Its reader:\n - `\"@tags\"` \u21d2 pages `SymbolObjectServices.GetInstanceAttributeList(null, start)` from instance 0, appending entry bytes. While the reply is `More`, it continues from `LastInstance + 1`.\n - `\"Program:X.@tags\"` \u21d2 the same with `programName: \"X\"`.\n - It returns the concatenated bytes.\n- [ ] `NativeAbCipTemplateReader.ReadAsync(params, id)`:\n - `GetAttributes(id)` gives `defWords`, `structBytes`, `members`, `handle`.\n - It then sends `ReadTemplate(id, offset, chunk)` repeatedly, with `chunk = Math.Min(remaining, MaxRequestBytes - 16)`, where the total = `defWords * 4 - 23` (the CIP-documented Read Template length), advancing by each reply's data length until the status is 0. Status `0x06` continues.\n - It returns `TemplateObjectServices.BuildDecoderBuffer(...)`.\n- [ ] Unit tests feed the **real controller captures** through the native path. The fake channel serves, as reply data, the bytes contained in `CipTemplateObjectWireCaptureTests` (rev 34.11 press). The test asserts that `CipTemplateObjectDecoder.Decode(nativeBuffer)` equals `Decode(capturedLibplctagBuffer)`, member by member.\n - The captured libplctag buffer's header **is** the 14-byte header, so the fake replies are built from that header's fields plus the raw bytes after byte 14.\n - Do the same for `CipSymbolObjectRealControllerCaptureTests` entries: split them across two pages with a `0x06` status between, and assert the decoded tag list is identical.\n- [ ] Both factories (`NativeAbCipTagEnumeratorFactory`, `NativeAbCipTemplateReaderFactory`) exist and lease from `DeviceSessionPool.Shared` via `NativeAbCipDevice`. They send through **the lease's shared `Scheduler`**, never a private one, and dispose the lease (bounded 2 s wait, as in T12) when the enumeration or read completes.\n\n**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests --filter \"FullyQualifiedName~NativeAbCipTagEnumerator|FullyQualifiedName~NativeAbCipTemplateReader|FullyQualifiedName~Discovery\"` \u2192 pass.\n\nSteps + code: docs/plans/2026-10-07-native-ab-transport.md \u00a7 Task 13.\n\n```json:metadata\n{\"files\": [\"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagEnumerator.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagEnumerator.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTemplateReader.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/SymbolListingEnumerator.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTagEnumeratorTests.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTemplateReaderTests.cs\"], \"verifyCommand\": \"dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests --filter \\\"FullyQualifiedName~NativeAbCipTagEnumerator|FullyQualifiedName~NativeAbCipTemplateReader|FullyQualifiedName~Discovery\\\"\", \"modelTier\": \"standard\", \"model\": \"sonnet\", \"acceptanceCriteriaCount\": 4}\n```",
+ "blockedBy": [
+ 2,
+ 9
+ ]
+ },
+ {
+ "id": 14,
+ "subject": "Task 14: `NativeAbLegacyTagRuntime` + factory",
+ "status": "completed",
+ "description": "**Goal:** A native `IAbLegacyTagRuntime` over PCCC (Task 8) carried in Execute PCCC through a `DeviceSessionPool` lease.\n\n**Files:**\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagRuntime.cs`\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagFactory.cs`\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/NativeAbLegacyTagRuntimeTests.cs`\n- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests.csproj` (reference Ab.Transport + link `FakeCipChannel.cs`, as in Task 12)\n\n**Acceptance Criteria:**\n- [ ] **Connection:**\n - `DeviceKey` is built from `AbLegacyTagCreateParams` the same way as Task 12 (default port 44818).\n - **`Routing` comes from the Task 4 \"Routing observed\" table (B3), per family:**\n - Expected: `slc500`/`micrologix`/`plc5` \u21d2 **`Direct`** (libplctag sends Execute PCCC bare in SendRRData even though the driver forces `EffectiveCipPath = \"1,0\"`, `AbLegacyDriver.cs:864`); `logixpccc` \u21d2 **`Connected`**.\n - **If the capture shows otherwise, the capture wins.** Encode the choice in one `static CipRouting RoutingFor(string plcAttribute)` with a comment citing the capture file, and pin it with a test per family.\n - `ConnectionSize = 504`, `AllowPacking = false` (PCCC requests are never MSP-packed).\n - Send through the lease's shared `Scheduler`, never a private one (B1).\n- [ ] **Address handling:**\n - `AbLegacyAddress.TryParse(p.TagName)` \u21d2 `PcccAddress(FileLetter, FileNumber, WordNumber, sub)`. The sub-element comes from `ResolveSubElement`. A `BitIndex` is kept for decode only, because reads are word reads.\n - Byte counts per element: N/A/B/I/O/S = 2, F/L = 4, T/C/R = 6 when no sub-element (the whole element), 2 when a word sub-element is chosen, ST = 84.\n - Element count = `p.ElementCount`.\n- [ ] **Read and write:**\n - `ReadAsync` dispatches by `plc` attribute: `slc500`/`micrologix`/`logixpccc` \u21d2 `SlcTypedRead`; `plc5` \u21d2 `Plc5TypedRead`, or `AbTransportException(Unsupported)` when `PcccServices.Plc5Verified` is false (Task 8).\n - The TNS increments per request.\n - **Chunking (review finding 12):** a read or write whose byte count exceeds `PcccServices.MaxDataBytes` (236) is split into consecutive element ranges of at most 236 bytes (118 words, or 59 longs/floats). The element number advances per chunk and the data is concatenated in order. A failure in any chunk fails the whole operation with that chunk's status; a partial write is reported as failed, never as Good. Test a 256-word array read (three chunks: 118 + 118 + 20) and a 130-word write (two chunks).\n - On failure it stores the status and rethrows.\n - `InitializeAsync` performs one read, for the same `NotFound`-on-create reason as Task 12.\n- [ ] **Decode** is identical to `LibplctagLegacyTagRuntime`:\n - Bit with index \u21d2 bit of the LE word (`/N` up to 15, or up to 31 for L).\n - Bit without \u21d2 `(word & 1) != 0`.\n - Int/AnalogInt \u21d2 `short`\u2192`int`; Long \u21d2 `int`; Float \u21d2 `float`.\n - String \u21d2 the SLC ST layout: `u16 LEN` + 82 data bytes **with each byte pair swapped**. \u26a0\ufe0f Verify this byte order against `slc500` ST data in ab_server. If ab_server does not seed ST, test against a hand-built buffer and flag it in Task 20 docs as unverified.\n - Timer/Counter/Control element without a sub-element \u21d2 `GetInt32(0)` parity (the first 4 bytes, LE).\n - `DecodeArray` uses the offsets documented in `LibplctagLegacyTagRuntime.DecodeArray`.\n- [ ] **Encode** follows `LibplctagLegacyTagRuntime.EncodeValue` case by case, with the same `NotSupportedException` for bit-with-index. `WriteAsync` \u21d2 `SlcTypedWrite` (or PLC-5 / Unsupported).\n- [ ] **Unit tests:**\n - N7:0 read decode;\n - F8 read;\n - B3:0/5 decode;\n - an N7 array of 5;\n - T4:0.ACC sub-element addressing (bytes on the wire);\n - STS 0x20 \u21d2 `NotFound` on initialize;\n - a write of N7:0 = 42 produces the expected PCCC bytes.\n\n**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests --filter NativeAbLegacyTagRuntime` \u2192 pass.\n\nSteps + code: docs/plans/2026-10-07-native-ab-transport.md \u00a7 Task 14.\n\n```json:metadata\n{\"files\": [\"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagFactory.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagRuntime.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/NativeAbLegacyTagRuntimeTests.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests.csproj\"], \"verifyCommand\": \"dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests --filter NativeAbLegacyTagRuntime\", \"modelTier\": \"standard\", \"model\": \"sonnet\", \"acceptanceCriteriaCount\": 6}\n```",
+ "blockedBy": [
+ 3,
+ 8,
+ 9
+ ]
+ },
+ {
+ "id": 15,
+ "subject": "Task 15: AbCip `Transport` switch (options, factory, probe, ",
+ "status": "completed",
+ "description": "**Goal:** One `Transport` option selects the native or libplctag factory set everywhere AbCip creates a transport object: the driver, Test Connect, and the CLI.\n\n**Files:**\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipTransport.cs`\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipDriverOptions.cs` (add `public AbCipTransport Transport { get; init; } = AbCipTransport.Libplctag;`)\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverFactoryExtensions.cs` (DTO `string? Transport`; `ParseEnum(dto.Transport, \u2026, fallback: AbCipTransport.Libplctag)`)\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipTransportFactories.cs`\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs:145-155` (null factories \u21d2 `AbCipTransportFactories.For(options.Transport)`)\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs` (use `AbCipTransportFactories.For(opts.Transport).Tags`)\n- Modify: `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/` (`--transport native|libplctag`, default `libplctag`; help text \"via libplctag\" \u2192 \"via libplctag or the native transport\")\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipTransportSwitchTests.cs`\n- Modify: `tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests/` (add a `--transport` parse test)\n\n**Acceptance Criteria:**\n- [ ] `public enum AbCipTransport { Libplctag, Native }`.\n- [ ] `AbCipTransportFactories.For(t)` returns `(IAbCipTagFactory Tags, IAbCipTagEnumeratorFactory Enumerators, IAbCipTemplateReaderFactory Templates)`: the native trio for `Native`, the libplctag trio for `Libplctag`.\n- [ ] Explicitly injected factories (tests) still win over the option.\n- [ ] JSON configs: `\"transport\": \"Native\"` \u21d2 Native; `\"transport\": \"native\"` \u21d2 Native (case-insensitive, as with the existing `ParseEnum`); absent \u21d2 Libplctag; `\"transport\": \"Bogus\"` \u21d2 the same failure the existing `ParseEnum` raises for a bad `PlcFamily` (do not invent a new behaviour).\n- [ ] **Reinit does NOT swap transports, by design (review finding 19).** `AbCipDriver.ReinitializeAsync` (`AbCipDriver.cs:401`) is `ShutdownAsync` + `InitializeAsync` over constructor-held factories, so a transport change in the reinit JSON is inert. Do **not** invent factory swapping:\n - Document in the option's XML doc and in `docs/drivers/AbCip.md` (T20) that changing `transport` takes effect when the driver instance is recreated, which a deploy that changes the driver config does.\n - Pin the current behaviour with a test, `Reinit_with_a_changed_transport_keeps_the_constructed_transport`, so a future change is deliberate.\n - Before writing that test, confirm how a deploy applies a changed `DriverConfig`: `grep -rn \"ReinitializeAsync\\|CreateInstance\" src/Server src/Core | grep -i driver`. If deploys recreate the instance, say so in the doc. If they call `ReinitializeAsync`, raise it as a follow-up in the commit message rather than widening this task.\n- [ ] The **probe** deserializes with its own `JsonStringEnumConverter`, so a bad `\"transport\"` reports \"Config JSON is invalid\", not the `ParseEnum` message. That is acceptable; assert that exact behaviour in the probe test rather than the driver's.\n- [ ] The probe with `Transport: Native` constructs a `NativeAbCipTagRuntime`. Prove it with a test that uses an unreachable `127.0.0.1:1` endpoint and asserts the failure message comes from the native path (\"TCP connect \u2026 failed\"), not libplctag's.\n- [ ] The CLI `--transport native` reaches `AbCipDriverOptions.Transport`.\n\n**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests --filter AbCipTransportSwitch && dotnet test tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests` \u2192 pass.\n\nSteps + code: docs/plans/2026-10-07-native-ab-transport.md \u00a7 Task 15.\n\n```json:metadata\n{\"files\": [\"src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipDriverOptions.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipTransport.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs:145-155\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverFactoryExtensions.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipTransportFactories.cs\", \"tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests/\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipTransportSwitchTests.cs\"], \"verifyCommand\": \"dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests --filter AbCipTransportSwitch && dotnet test tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests\", \"modelTier\": \"standard\", \"model\": \"sonnet\", \"acceptanceCriteriaCount\": 8}\n```",
+ "blockedBy": [
+ 12,
+ 13
+ ]
+ },
+ {
+ "id": 16,
+ "subject": "Task 16: AbLegacy `Transport` switch (options, factory, prob",
+ "status": "completed",
+ "description": "**Goal:** Do the same as Task 15 for AbLegacy.\n\n**Files:**\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyTransport.cs` (`public enum AbLegacyTransport { Libplctag, Native }`)\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyDriverOptions.cs` (`Transport` property, default `Libplctag`)\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs` (DTO + parse)\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs:65-71` (`_tagFactory = tagFactory ?? (options.Transport == AbLegacyTransport.Native ? new NativeAbLegacyTagFactory() : new LibplctagLegacyTagFactory());`)\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs`\n- Modify: `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/` (`--transport`)\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyTransportSwitchTests.cs`\n- Modify: `tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests/`\n\n**Acceptance Criteria:** the same five criteria as Task 15, applied to AbLegacy types:\n- [ ] `\"transport\": \"Native\"` and `\"native\"` select the native factory, absent selects libplctag, and a bad value fails the way an existing bad enum field does.\n- [ ] An injected factory wins over the option.\n- [ ] The probe with Native uses the native path; prove it with the unreachable-endpoint message test.\n- [ ] The CLI `--transport native` reaches the options.\n- [ ] A reinit with a changed transport keeps the constructed transport (`AbLegacyDriver` reinit has the same Shutdown+Initialize shape), pinned by the same kind of test and documentation as Task 15.\n\n**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests --filter AbLegacyTransportSwitch && dotnet test tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests` \u2192 pass.\n\nSteps + code: docs/plans/2026-10-07-native-ab-transport.md \u00a7 Task 16.\n\n```json:metadata\n{\"files\": [\"src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyDriverOptions.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyTransport.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs:65-71\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs\", \"tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests/\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyTransportSwitchTests.cs\"], \"verifyCommand\": \"dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests --filter AbLegacyTransportSwitch && dotnet test tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests\", \"modelTier\": \"standard\", \"model\": \"sonnet\", \"acceptanceCriteriaCount\": 6}\n```",
+ "blockedBy": [
+ 14
+ ]
+ },
+ {
+ "id": 17,
+ "subject": "Task 17: AbCip + AbLegacy integration suites on both transpo",
+ "status": "completed",
+ "description": "**Goal:** Every existing `ab_server` integration test runs once per transport, and the native run is green wherever the libplctag run is green, against every compose profile.\n\n**Files:**\n- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbCipReadSmokeTests.cs` and every other test in that project that constructs `AbCipDriverOptions` (`grep -rln \"new AbCipDriverOptions\" tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests`)\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/TransportMatrix.cs`\n- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyReadSmokeTests.cs` and siblings\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/TransportMatrix.cs`\n- Modify: `docs/plans/2026-10-07-native-ab-transport-gate-results.md` (created empty by Task 1; this task fills only its `## ab_server suites (T17)` section)\n\n**Acceptance Criteria:**\n- [ ] `TransportMatrix.Profiles` yields `(profile, transport)` for every profile \u00d7 both transports. The theories switch from `MemberData(nameof(Profiles))` to `MemberData(nameof(TransportMatrix.Profiles), MemberType = typeof(TransportMatrix))`, and options get `Transport = transport`.\n- [ ] **The AbLegacy matrix is its own class** in the AbLegacy integration project, using `AbLegacyTransport`, and it **preserves the existing `AB_LEGACY_COMPOSE_PROFILE` filter** that `AbLegacyReadSmokeTests.Profiles` applies today (`AbLegacyReadSmokeTests.cs:26-35`): it must filter `KnownProfiles.All` by that env var before crossing with transports. The AbCip snippet below does not apply to AbLegacy verbatim (review finding 20).\n- [ ] Run against `10.100.0.35`, one compose profile at a time:\n - AbCip: `controllogix`, `compactlogix`, `micro800`, `guardlogix`.\n - AbLegacy: `slc500`, `micrologix`, `plc5`.\n - **For each profile, the set of passing tests under `Native` \u2287 the set under `Libplctag`.**\n- [ ] Any native-only failure is fixed in the transport task's code (reopen that task's files) and is not skipped. The only allowed exception is the PLC-5 `Unsupported` path from Task 8, marked `Skip = \"PLC-5 framing unverified \u2014 see Task 8\"` **only if** Task 8 set `Plc5Verified = false`.\n- [ ] The results doc records, under its `## ab_server suites (T17)` heading, per profile \u00d7 transport, the pass/fail/skip counts plus the `ab_server` image tag and the commit.\n\n**Verify:** for each profile P (AbCip stack directory as established in Task 4's `Captures/README.md`; AbLegacy at `~/otopcua-ablegacy` with `AB_LEGACY_COMPOSE_PROFILE=P`): `ssh dohertj2@10.100.0.35 'cd && docker compose --profile P up -d'` then `AB_SERVER_ENDPOINT=10.100.0.35:44818 dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests --logger \"trx;LogFileName=P.trx\"` \u2192 the Native pass set \u2287 the Libplctag pass set (compare the two trx files with `grep -o 'testName=\"[^\"]*\" .*outcome=\"[^\"]*\"'`).\n\nSteps + code: docs/plans/2026-10-07-native-ab-transport.md \u00a7 Task 17.\n\n```json:metadata\n{\"files\": [\"docs/plans/2026-10-07-native-ab-transport-gate-results.md\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbCipReadSmokeTests.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/TransportMatrix.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyReadSmokeTests.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/TransportMatrix.cs\"], \"verifyCommand\": \"AB_SERVER_ENDPOINT=10.100.0.35:44818 dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests --logger \\\"trx;LogFileName=P.trx\\\" && grep -o 'testName=\\\"[^\\\"]*\\\" .*outcome=\\\"[^\\\"]*\\\"'\", \"modelTier\": \"standard\", \"model\": \"sonnet\", \"acceptanceCriteriaCount\": 5}\n```",
+ "blockedBy": [
+ 15,
+ 16
+ ]
+ },
+ {
+ "id": 18,
+ "subject": "Task 18: Differential harness (libplctag vs native, same tar",
+ "status": "completed",
+ "description": "**Goal:** A `LiveIntegration` test that reads the same tags through both transports against one endpoint and asserts equal raw buffers and equal decoded values. It skips cleanly when the endpoint env var is absent.\n\n**Files:**\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/TransportDifferentialTests.cs`\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/DifferentialTarget.cs`\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs` and `NativeAbCipTagRuntime.cs` (add `internal ReadOnlyMemory RawBuffer`)\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj` (`InternalsVisibleTo` for `ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests`)\n\n**Acceptance Criteria:**\n- [ ] **Env gating:**\n - `AB_DIFF_ENDPOINT` (`host:port`), `AB_DIFF_PATH` (default `1,0`), `AB_DIFF_FAMILY` (default `CompactLogix`).\n - `AB_DIFF_TAGS`: an optional comma list. Absent \u21d2 every atomic scalar, the first 20 arrays and the first 20 UDT members from browse.\n - `AB_DIFF_MAX_TAGS` (default 200).\n - Absent `AB_DIFF_ENDPOINT` \u21d2 every test reports **skipped**, with the reason naming the variable.\n- [ ] **The suite is read-only by construction.** It never calls `WriteAsync` or `EncodeValue`. A guard test reflects over `TransportDifferentialTests` and fails if any method body references `WriteAsync` (scan its source file).\n- [ ] **Browse parity:** `@tags` plus every program listing through both enumerators decode to identical `AbCipDiscoveredTag` sets (order-insensitive). Every `TemplateInstanceId` seen decodes to an identical `AbCipUdtShape` through both template readers.\n- [ ] **Read parity:** for each selected tag, under both transports:\n - Run `InitializeAsync` + `ReadAsync`.\n - Assert `GetStatus()` is equal, **except** that native `TypeMismatch` where libplctag says `Ok` is reported as a distinct *expected divergence* (spec \u00a75), listed in the output and not failed.\n - Assert the raw buffers are equal. To expose the raw bytes, add `internal ReadOnlyMemory RawBuffer` to both runtimes: libplctag's via `_tag.GetBuffer()`, native's via its stored data. Add `InternalsVisibleTo` for the integration test assembly in the AbCip csproj.\n - Assert the decoded values are equal.\n- [ ] **Connection discipline (review finding 9):**\n - Before any native work, the harness acquires one `DeviceSessionPool.Shared.Acquire(key, options)` **anchor lease** for the target, and holds it until the run ends. Per-tag native runtimes therefore share that one connection: the pool never reaches refcount 0, so it never sends Forward Close and re-opens between tags.\n - libplctag runtimes and native runtimes are never alive at the same time. All libplctag reads run first, and their handles are disposed. Then all native reads run. So the press sees at most two sessions over the run, never concurrently.\n - With `AB_DIFF_COUNT_OPENS=1`, the native side asserts it made exactly **one** Forward Open over the whole run, using `CipConnection.ForwardOpenCount` from Task 9 (reset it at the start of the run).\n- [ ] Output: a markdown table (tag, type, libplctag status, native status, equal?) written to `TestContext.Current.TestOutputHelper`.\n- [ ] Results are appended under the gate-results doc's `## Differential vs ab_server (T18)` heading only.\n- [ ] Run against `ab_server` (`controllogix` profile) with `AB_DIFF_ENDPOINT=10.100.0.35:44818` \u21d2 zero unexpected divergences. Append the results to the gate-results doc.\n- [ ] **Ordering:** blocked by Task 17, so any transport fixes found by the ab_server suites land before the differential run.\n\n**Verify:** `AB_DIFF_ENDPOINT=10.100.0.35:44818 dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests --filter \"FullyQualifiedName~Differential\"` \u2192 pass with 0 unexpected divergences; without the env var \u2192 all skipped.\n\nSteps + code: docs/plans/2026-10-07-native-ab-transport.md \u00a7 Task 18.\n\n```json:metadata\n{\"files\": [\"ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/DifferentialTarget.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/TransportDifferentialTests.cs\"], \"verifyCommand\": \"AB_DIFF_ENDPOINT=10.100.0.35:44818 dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests --filter \\\"FullyQualifiedName~Differential\\\"\", \"modelTier\": \"frontier\", \"model\": \"opus\", \"acceptanceCriteriaCount\": 9}\n```",
+ "blockedBy": [
+ 17
+ ]
+ },
+ {
+ "id": 19,
+ "subject": "Task 19: AdminUI `Transport` dropdown (AbCip + AbLegacy) + d",
+ "status": "completed",
+ "description": "**Goal:** An operator can pick the transport per driver in the `/raw` driver-config modal, the value round-trips as a string, and a live docker-dev run proves it reaches the driver.\n\n**Files:**\n- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbCipDriverForm.razor` (`FormModel.Transport`, `ToOptions`/`FromOptions`, an `InputSelect` in the advanced section)\n- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbLegacyDriverForm.razor` (same)\n- Modify: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbLegacyDriverPageFormSerializationTests.cs` (add Transport round-trip cases)\n- Create or modify: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbCipDriverPageFormSerializationTests.cs` (exists \u21d2 add cases; absent \u21d2 create, modelled on the AbLegacy file)\n\n**Acceptance Criteria:**\n- [ ] The dropdown has options \"libplctag (default)\" \u21d2 `Libplctag` and \"Native (managed)\" \u21d2 `Native`, bound with `@bind-Value:after=\"EmitAsync\"` the way the other fields are.\n- [ ] The serialized JSON contains `\"transport\":\"Native\"` (a string, not a number). It goes through the existing `_jsonOpts` with `JsonStringEnumConverter` (`AbCipDriverForm.razor:108-113`); verify the AbLegacy form has the same converter and add it if missing.\n- [ ] Loading a config without `transport` shows \"libplctag (default)\" and re-emits **without** a `transport` key, or with `\"Libplctag\"`; either is fine, but pick one and test it.\n- [ ] **Live `/run` on docker-dev** (login is disabled; drive it yourself with the Chrome tools against `http://localhost:9200`):\n 1. Bring up the AbCip `controllogix` fixture on `10.100.0.35`.\n 2. In `/raw`, create or edit an AbCip driver with a device `ab://10.100.0.35:44818/1,0`, set Transport = Native, and add the raw tag `TestDINT`.\n 3. Deploy.\n 4. Read the UNS/raw node with the Client CLI (`dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- read -u opc.tcp://localhost:4840 -n \"ns=;s=\"`) and get `Good`.\n 5. Confirm from the central node's logs that the native runtime served it. Add one `LogDebug(\"AbCip transport={Transport}\")` at driver init if no log line distinguishes the two.\n 6. Flip back to libplctag, redeploy, and read `Good` again.\n 7. Record the output under the gate-results doc's `## AdminUI /run (T19)` heading only.\n- [ ] **Inverted-gate check** (memory: \"an unchanged observable proves nothing\"): with Transport = Native, point the device at a port where no `ab_server` is listening. The read must go **Bad** and the log must show the *native* error text. That proves the switch changes behaviour and isn't inert.\n\n**Verify:** AdminUI unit tests pass, plus the docker-dev `/run` evidence (CLI output for both transports and the inverted case) is pasted into `docs/plans/2026-10-07-native-ab-transport-gate-results.md`.\n\nSteps + code: docs/plans/2026-10-07-native-ab-transport.md \u00a7 Task 19.\n\n```json:metadata\n{\"files\": [\"src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbCipDriverForm.razor\", \"src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbLegacyDriverForm.razor\", \"tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbCipDriverPageFormSerializationTests.cs\", \"tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbLegacyDriverPageFormSerializationTests.cs\"], \"verifyCommand\": \"\", \"modelTier\": \"standard\", \"model\": \"sonnet\", \"acceptanceCriteriaCount\": 5}\n```",
+ "blockedBy": [
+ 17
+ ]
+ },
+ {
+ "id": 20,
+ "subject": "Task 20: Documentation",
+ "status": "completed",
+ "description": "**Goal:** Document the native transport, the switch, the known gaps and the client-visible type-check change, in the repo and in the umbrella history.\n\n**Files:**\n- Modify: `docs/drivers/AbCip.md` (sections: \"Transport (`Transport` option)\", \"Native transport behaviour\" covering connection sharing, MSP packing, the type check and press-safety limits, and \"Known gaps\" covering simulator-only writes, PLC-5/Micro800/routed paths, and libplctag deletion blocked on a bench controller)\n- Modify: `docs/drivers/AbLegacy.md` (Transport section, PCCC simulator-only gap, PLC-5 status from Task 8, ST byte-order status from Task 14)\n- Modify: `docs/drivers/AbServer-Test-Fixture.md` and `docs/drivers/AbLegacy-Test-Fixture.md` (suites now run per transport; the capture recipe `scripts/ab-capture/capture.sh`)\n- Modify: `CLAUDE.md` (one short paragraph under a new heading \"AB drivers: native transport (`Transport`)\" with the switch, the confinement guard, and a pointer to the docs; keep it short, under 15 lines)\n- Modify: `../scadaproj/.claude/skills/sister-project-history/otopcua.md` (append a dated entry), on scadaproj's `main` per the CLAUDE.md procedure\n\n**Acceptance Criteria:**\n- [ ] `docs/drivers/AbCip.md` states explicitly:\n - The native path reports `BadTypeMismatch` for a same-width mis-authored type that libplctag reads as Good garbage, with an example (DINT authored, REAL returned).\n - That is the fix working, and it is called out as client-visible.\n- [ ] Every config key added (`transport`) is documented with its values, its default and string serialization.\n- [ ] The scadaproj history entry is committed on `main` and pushed. The OtOpcUa index **row** in `../scadaproj/CLAUDE.md` is **not** touched; the summary does not change, since the driver set and wire contract are the same.\n- [ ] The scadaproj branch is restored afterwards.\n\n**Verify:** `grep -n \"Transport\" docs/drivers/AbCip.md docs/drivers/AbLegacy.md CLAUDE.md` shows the new sections; `cd ../scadaproj && git log origin/main -1 --format=%s` shows the history commit.\n\nSteps + code: docs/plans/2026-10-07-native-ab-transport.md \u00a7 Task 20.\n\n```json:metadata\n{\"files\": [\"../scadaproj/.claude/skills/sister-project-history/otopcua.md\", \"CLAUDE.md\", \"docs/drivers/AbCip.md\", \"docs/drivers/AbLegacy-Test-Fixture.md\", \"docs/drivers/AbLegacy.md\", \"docs/drivers/AbServer-Test-Fixture.md\", \"scripts/ab-capture/capture.sh\"], \"verifyCommand\": \"grep -n \\\"Transport\\\" docs/drivers/AbCip.md docs/drivers/AbLegacy.md CLAUDE.md\", \"modelTier\": \"standard\", \"model\": \"sonnet\", \"acceptanceCriteriaCount\": 4}\n```",
+ "blockedBy": [
+ 19
+ ]
+ },
+ {
+ "id": 21,
+ "subject": "Task 21: Real-press read-only gate (CR CompactLogix)",
+ "status": "completed",
+ "description": "**Goal:** Run the Task 18 differential harness and a latency comparison against one production CompactLogix 5069-L320ERS2 press at CR, **read-only**, and record the evidence that decides the AbCip default flip.\n\n> **USER-ORDERED GATE \u2014 NON-SKIPPABLE.** This task was requested by the user in the current conversation. It MUST NOT be closed by walking around it, by declaring it \"verified inline\", or by substituting a cheaper check. Close only after every item in `acceptanceCriteria` has been re-validated independently, with output captured.\n\n**Files:**\n- Modify: `docs/plans/2026-10-07-native-ab-transport-gate-results.md` (append the press results)\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/PollLatencyComparisonTests.cs`\n\n**Acceptance Criteria:**\n- [ ] **Preconditions (human):** the VPN to CR is up, the user has OK'd touching the press at this time, and the target is **one** press. The default is Z-35162 at `10.209.70.160:44818`, path `1,0`; any other press must be named by the user.\n- [ ] **Run the differential:**\n - `AB_DIFF_ENDPOINT=10.209.70.160:44818 AB_DIFF_FAMILY=CompactLogix AB_DIFF_COUNT_OPENS=1 dotnet test \u2026--filter \"FullyQualifiedName~Differential\"` passes.\n - Browse parity holds: the same discovered tag set (the 2026-10-07 run saw ~23,412 variables across 80 programs) and identical UDT shapes.\n - Read parity holds over `AB_DIFF_MAX_TAGS=200`, with zero unexpected divergences. Every `TypeMismatch` expected-divergence is listed by tag name.\n - Native made exactly **1** Forward Open over the run. That is achievable only because T18's harness holds an anchor lease for the whole run; if the count is higher, the harness is broken, not the press.\n- [ ] **Latency comparison** (`PollLatencyComparisonTests`, env-gated by the same variables):\n - Read a fixed set of 100 atomic tags 30 times per transport, sequentially; transports are never concurrent. The native side runs under an anchor lease, as in T18.\n - Native passes issue all 100 reads **concurrently** (`Task.WhenAll`), so the shared scheduler can MSP-pack them. libplctag passes do the same; libplctag packs internally.\n - Record the CIP round-trips per pass (native: `CipConnection.RequestsSent` from Task 9, delta per pass; libplctag: report n/a) and the p50/p95 wall time per pass.\n - Pass criterion: native p95 \u2264 libplctag p95 \u00d7 1.10.\n- [ ] **No writes:**\n - Evidence: the Task 18 source guard passes.\n - `grep -c WriteAsync` over both differential test files returns 0.\n- [ ] **Recorded results:** under the `## Real press (T21)` and `## Latency (T21)` headings, the results doc carries the raw `dotnet test` output, the divergence table, the latency table, the Forward Open count, the date/time (UTC) of the run and the press ID.\n- [ ] **Failure handling:** if any criterion fails, the task stays open. Fix the code (with a pinning test), re-run the ab_server suites (Task 17) and the press run. **Do not** relax the criterion.\n\n**Verify:** `AB_DIFF_ENDPOINT=10.209.70.160:44818 AB_DIFF_FAMILY=CompactLogix AB_DIFF_COUNT_OPENS=1 dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests --filter \"FullyQualifiedName~Differential\" --logger \"console;verbosity=detailed\"` \u2192 passed, 0 unexpected divergences, `ForwardOpenCount=1`, and native p95 \u2264 1.10 \u00d7 libplctag p95.\n\nSteps + code: docs/plans/2026-10-07-native-ab-transport.md \u00a7 Task 21.\n\n```json:metadata\n{\"files\": [\"docs/plans/2026-10-07-native-ab-transport-gate-results.md\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/PollLatencyComparisonTests.cs\"], \"verifyCommand\": \"AB_DIFF_ENDPOINT=10.209.70.160:44818 AB_DIFF_FAMILY=CompactLogix AB_DIFF_COUNT_OPENS=1 dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests --filter \\\"FullyQualifiedName~Differential\\\" --logger \\\"console;verbosity=detailed\\\"\", \"modelTier\": \"frontier\", \"model\": \"opus\", \"userGate\": true, \"tags\": [\"user-gate\"], \"gateScope\": \"task\", \"failurePolicy\": \"keep-open-and-fix\", \"requireEvidenceTokens\": [[\"libplctag\", \"baseline\"], [\"native\"], [\"ForwardOpenCount=1\", \"ForwardOpenCount: 1\"]], \"acceptanceCriteriaCount\": 6}\n```",
+ "blockedBy": [
+ 18
+ ]
+ },
+ {
+ "id": 22,
+ "subject": "Task 22: Flip the defaults to Native + release note",
+ "status": "completed",
+ "description": "**Goal:** Make `Native` the default transport for both drivers (spec \u00a78 P5), keeping `Libplctag` selectable, and write the client-visible release note.\n\n**Files:**\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipDriverOptions.cs` (default `AbCipTransport.Native`)\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverFactoryExtensions.cs` (`fallback: AbCipTransport.Native`)\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyDriverOptions.cs`, `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs` (same)\n- Modify: both AdminUI forms (relabel the options as \"Native (default)\" / \"libplctag (fallback)\")\n- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipTransportSwitchTests.cs`, `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyTransportSwitchTests.cs` (the absent \u21d2 Native expectation)\n- Modify: the CLIs' `--transport` default (`native`)\n- Modify: `docs/drivers/AbCip.md`, `docs/drivers/AbLegacy.md` (the default, and a \"Release note\" subsection)\n\n**Acceptance Criteria:**\n- [ ] Blocked by Task 21 passing **and** Task 17 green for all AbLegacy profiles.\n- [ ] A config with no `transport` key runs Native in both drivers; prove it with the switch tests.\n- [ ] Existing unit tests that construct `AbCipDriver` with **no** injected factory and expect libplctag behaviour are updated to set `Transport = Libplctag` explicitly. Find them with `grep -rn \"new AbCipDriver(\" tests | grep -v tagFactory`.\n- [ ] The release note says:\n - The native transport is the default.\n - It reports `BadTypeMismatch` for mis-authored same-width types.\n - Set `\"transport\": \"Libplctag\"` to fall back.\n - AbCip writes and all of AbLegacy are simulator-proven only.\n- [ ] The full `dotnet test ZB.MOM.WW.OtOpcUa.slnx` passes, except suites that need fixtures that are not up; those are listed as skipped.\n\n**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests` \u2192 all pass.\n\nSteps + code: docs/plans/2026-10-07-native-ab-transport.md \u00a7 Task 22.\n\n```json:metadata\n{\"files\": [\"docs/drivers/AbCip.md\", \"docs/drivers/AbLegacy.md\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipDriverOptions.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverFactoryExtensions.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyDriverOptions.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipTransportSwitchTests.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyTransportSwitchTests.cs\"], \"verifyCommand\": \"dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests\", \"modelTier\": \"mechanical\", \"model\": \"haiku\", \"acceptanceCriteriaCount\": 5}\n```",
+ "blockedBy": [
+ 21,
+ 17,
+ 19,
+ 20
+ ]
+ }
+ ],
+ "lastUpdated": "2026-10-07T19:16:30.130246+00:00"
+}
\ No newline at end of file
diff --git a/scripts/ab-capture/capture.sh b/scripts/ab-capture/capture.sh
new file mode 100755
index 00000000..31e185d5
--- /dev/null
+++ b/scripts/ab-capture/capture.sh
@@ -0,0 +1,108 @@
+#!/usr/bin/env bash
+# Golden-frame capture recipe for the native AB transport (Task 4 of
+# docs/plans/2026-10-07-native-ab-transport.md).
+#
+# Captures the EtherNet/IP conversation between libplctag (the libplctag 1.5.2
+# NuGet wrapper, native core 2.6.0) and the ab_server simulator fixtures, and
+# converts each pcap into the `.hex` line format consumed by
+# tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures:
+#
+# > server EIP frame, uppercase hex, no spaces>
+# < client EIP frame>
+# # comment
+#
+# Packets are captured INSIDE the simulator container's network namespace
+# (netshoot sidecar), where ab_server always listens on 44818 — so the same
+# filter works for the AbLegacy containers whose host ports are 44819-44821.
+# tshark also runs in the netshoot image on the docker host, so nothing needs
+# installing on the workstation beyond python3.
+#
+# Usage:
+# CAPDIR= scripts/ab-capture/capture.sh run --
+# scripts/ab-capture/capture.sh tohex
+# scripts/ab-capture/capture.sh client
+#
+# `client` builds (once) and runs scripts/ab-capture/client — the console program
+# over libplctag 1.5.2 that took the committed fixtures: one tag, one Initialize +
+# Read/Write, then Dispose + plc_tag_shutdown so the Forward Close reaches the wire.
+# No UnregisterSession is ever sent; the session ends with a TCP RST. Example:
+#
+# CAPDIR=$PWD/caps scripts/ab-capture/capture.sh run otopcua-ab-server-controllogix logix-read-dint -- \
+# scripts/ab-capture/capture.sh client read 10.100.0.35:44818 1,0 ControlLogix TestDINT dint
+#
+# See Captures/README.md for every invocation used.
+#
+# Fixtures (one AbCip profile at a time — they all bind host :44818):
+# ssh dohertj2@10.100.0.35 'cd /opt/otopcua-abcip && docker compose --profile controllogix up -d'
+# ssh dohertj2@10.100.0.35 'cd ~/otopcua-ablegacy && docker compose --profile slc500 up -d'
+set -euo pipefail
+
+DOCKER_HOST_SSH="${DOCKER_HOST_SSH:-dohertj2@10.100.0.35}"
+NETSHOOT="${NETSHOOT:-nicolaka/netshoot:v0.13}"
+REMOTE_DIR="${REMOTE_DIR:-/tmp/abcap}"
+
+cmd="${1:-}"; shift || true
+
+case "$cmd" in
+ client)
+ here="$(cd "$(dirname "$0")" && pwd)"
+ dll="$here/client/bin/Release/net10.0/ab-capture-client.dll"
+ [[ -f "$dll" ]] || dotnet build "$here/client/ab-capture-client.csproj" -c Release -v q >&2
+ exec dotnet "$dll" "$@"
+ ;;
+
+ run)
+ container="$1"; name="$2"; shift 2
+ [[ "${1:-}" == "--" ]] && shift
+ : "${CAPDIR:?set CAPDIR to a fresh empty directory}"
+ sidecar="abcap-$name"
+ ssh "$DOCKER_HOST_SSH" "mkdir -p $REMOTE_DIR && rm -f $REMOTE_DIR/$name.pcap && \
+ docker run -d --rm --name $sidecar --net=container:$container -v $REMOTE_DIR:/cap $NETSHOOT \
+ tcpdump -i eth0 -U -w /cap/$name.pcap tcp port 44818 >/dev/null"
+ sleep 2 # let tcpdump open the interface before the client connects
+ "$@" || echo "client exited non-zero: $?" >&2
+ sleep 1
+ ssh "$DOCKER_HOST_SSH" "docker stop $sidecar >/dev/null"
+ scp -q "$DOCKER_HOST_SSH:$REMOTE_DIR/$name.pcap" "$CAPDIR/$name.pcap"
+ echo "$CAPDIR/$name.pcap"
+ ;;
+
+ tohex)
+ pcap="$1"; out="$2"
+ base="$(basename "$pcap")"
+ ssh "$DOCKER_HOST_SSH" "mkdir -p $REMOTE_DIR"
+ scp -q "$pcap" "$DOCKER_HOST_SSH:$REMOTE_DIR/tohex-$base"
+ # srcport + payload of every data-bearing, non-retransmitted segment, in capture order.
+ ssh "$DOCKER_HOST_SSH" "docker run --rm -v $REMOTE_DIR:/cap $NETSHOOT \
+ tshark -r /cap/tohex-$base -T fields -e tcp.srcport -e tcp.payload \
+ -Y 'tcp.len>0 && !tcp.analysis.retransmission'" \
+ | python3 -I -c '
+import sys
+# Re-join TCP segments into EIP encapsulation frames per direction, using the
+# 24-byte header length field (offset 2, UInt16 LE) to find frame boundaries.
+bufs = {True: b"", False: b""}
+for line in sys.stdin:
+ line = line.strip()
+ if not line:
+ continue
+ port, payload = line.split("\t")
+ from_client = port != "44818" # srcport 44818 => server->client
+ bufs[from_client] += bytes.fromhex(payload.replace(":", ""))
+ b = bufs[from_client]
+ while len(b) >= 24:
+ total = 24 + int.from_bytes(b[2:4], "little")
+ if len(b) < total:
+ break
+ print(("> " if from_client else "< ") + b[:total].hex().upper())
+ b = b[total:]
+ bufs[from_client] = b
+for k, rest in bufs.items():
+ if rest:
+ sys.exit(f"trailing partial frame ({len(rest)} bytes, client={k})")
+' > "$out"
+ echo "$out: $(grep -c '^[<>]' "$out") frames"
+ ;;
+
+ *)
+ sed -n '2,37p' "$0"; exit 2 ;;
+esac
diff --git a/scripts/ab-capture/client/Program.cs b/scripts/ab-capture/client/Program.cs
new file mode 100644
index 00000000..c8a74539
--- /dev/null
+++ b/scripts/ab-capture/client/Program.cs
@@ -0,0 +1,169 @@
+// Capture client for scripts/ab-capture/capture.sh.
+//
+// One libplctag tag, one operation, then Dispose + plc_tag_shutdown so the Forward Close
+// reaches the wire. The tag is built with exactly the properties the drivers set
+// (LibplctagTagRuntime / LibplctagLegacyTagRuntime / LibplctagTagEnumerator): Gateway, Path,
+// PlcType, Protocol=ab_eip, Name, Timeout, and ElementCount when > 1. Nothing else.
+//
+// usage:
+// ab-capture-client version
+// ab-capture-client read [count]
+// ab-capture-client write
+//
+// a libplctag.PlcType name: ControlLogix, Micro800, Slc500, MicroLogix, Plc5, LogixPccc
+// dint | int | real | string | bool | raw (read only: hex dump of the buffer)
+// dintseq (write only): element i (0-based) = ((i+1) << 24) | (i+1), for elements;
+// is ignored but required.
+// env UNCONNECTED=1 sets UseConnectedMessaging=false (ignored by libplctag 2.6.0 for Micro800).
+using libplctag;
+using libplctag.NativeImport;
+
+if (args.Length == 0)
+{
+ Console.Error.WriteLine("usage: see the header of Program.cs");
+ return 2;
+}
+
+if (args[0] == "seed-bool")
+{
+ // ab_server rejects libplctag's BOOL write with CIP 0x03: libplctag sends two data bytes for a
+ // one-byte BOOL and ab_server's "too much data" check refuses it. This seeds a BOOL for a
+ // later libplctag READ capture with one raw UCMM Write Tag carrying exactly one data byte.
+ // usage: seed-bool <0|1> (route is port 1 / slot 0)
+ return await SeedBoolAsync(args[1], int.Parse(args[2]), args[3], args[4] == "1" ? (byte)1 : (byte)0);
+}
+
+if (args[0] == "version")
+{
+ Console.WriteLine($"{plctag.plc_tag_get_int_attribute(0, "version_major", -1)}." +
+ $"{plctag.plc_tag_get_int_attribute(0, "version_minor", -1)}." +
+ $"{plctag.plc_tag_get_int_attribute(0, "version_patch", -1)}");
+ return 0;
+}
+
+var op = args[0];
+var gateway = args[1];
+var path = args[2] == "-" ? null : args[2];
+var plc = Enum.Parse(args[3], ignoreCase: true);
+var name = args[4];
+var type = args[5];
+var count = args.Length > 6 ? int.Parse(args[6]) : 1;
+
+var tag = new Tag
+{
+ Gateway = gateway,
+ Path = path,
+ PlcType = plc,
+ Protocol = Protocol.ab_eip,
+ Name = name,
+ Timeout = TimeSpan.FromSeconds(5),
+};
+if (count > 1) tag.ElementCount = count;
+if (Environment.GetEnvironmentVariable("UNCONNECTED") == "1") tag.UseConnectedMessaging = false;
+
+var exit = 0;
+try
+{
+ await tag.InitializeAsync();
+ if (op == "write")
+ {
+ var value = args[7];
+ switch (type)
+ {
+ case "dint": tag.SetInt32(0, int.Parse(value)); break;
+ case "int": tag.SetInt16(0, short.Parse(value)); break;
+ case "real": tag.SetFloat32(0, float.Parse(value)); break;
+ case "string": tag.SetString(0, value); break;
+ // As LibplctagTagRuntime does: a whole-byte write (SetBit on a Logix BOOL is ErrorBadParam).
+ case "bool": tag.SetInt8(0, value is "1" or "true" ? (sbyte)1 : (sbyte)0); break;
+ case "dintseq":
+ for (var i = 0; i < count; i++) tag.SetInt32(i * 4, ((i + 1) << 24) | (i + 1));
+ break;
+ default: throw new ArgumentException($"unknown write type {type}");
+ }
+ await tag.WriteAsync();
+ Console.WriteLine("write ok");
+ }
+ else
+ {
+ await tag.ReadAsync();
+ Console.WriteLine($"read ok size={tag.GetSize()}");
+ Console.WriteLine(type switch
+ {
+ "dint" => tag.GetInt32(0).ToString(),
+ "int" => tag.GetInt16(0).ToString(),
+ "real" => tag.GetFloat32(0).ToString(),
+ "string" => tag.GetString(0),
+ "bool" => tag.GetBit(0).ToString(),
+ _ => Convert.ToHexString(tag.GetBuffer()),
+ });
+ }
+}
+catch (Exception ex)
+{
+ Console.WriteLine($"EXC {ex.GetType().Name}: {ex.Message}");
+ exit = 1;
+}
+finally
+{
+ tag.Dispose();
+}
+
+plctag.plc_tag_shutdown();
+await Task.Delay(500); // let the Forward Close flush before the process exits
+return exit;
+
+static async Task SeedBoolAsync(string host, int port, string tagName, byte value)
+{
+ using var tcp = new System.Net.Sockets.TcpClient();
+ await tcp.ConnectAsync(host, port);
+ var s = tcp.GetStream();
+
+ // RegisterSession
+ var reg = new byte[28];
+ reg[0] = 0x65; reg[2] = 4; reg[24] = 1;
+ await s.WriteAsync(reg);
+ var regReply = await ReadFrameAsync(s);
+ var session = BitConverter.ToUInt32(regReply, 4);
+
+ // Write Tag (0x4D): ANSI symbol segment, type BOOL 0xC1, count 1, ONE data byte.
+ var name = System.Text.Encoding.ASCII.GetBytes(tagName);
+ var sym = new List { 0x91, (byte)name.Length };
+ sym.AddRange(name);
+ if (sym.Count % 2 == 1) sym.Add(0);
+ var write = new List { 0x4D, (byte)(sym.Count / 2) };
+ write.AddRange(sym);
+ write.AddRange([0xC1, 0x00, 0x01, 0x00, value]);
+
+ // Unconnected Send (0x52) to the Connection Manager, routed to port 1 / slot 0.
+ var ucmm = new List { 0x52, 0x02, 0x20, 0x06, 0x24, 0x01, 0x0A, 0x05 };
+ ucmm.AddRange(BitConverter.GetBytes((ushort)write.Count));
+ ucmm.AddRange(write);
+ if (write.Count % 2 == 1) ucmm.Add(0);
+ ucmm.AddRange([0x01, 0x00, 0x01, 0x00]);
+
+ // SendRRData: interface handle, timeout, 2 items (null address, unconnected data).
+ var data = new List { 0, 0, 0, 0, 0x0A, 0, 2, 0, 0, 0, 0, 0, 0xB2, 0 };
+ data.AddRange(BitConverter.GetBytes((ushort)ucmm.Count));
+ data.AddRange(ucmm);
+ var frame = new byte[24 + data.Count];
+ frame[0] = 0x6F;
+ BitConverter.GetBytes((ushort)data.Count).CopyTo(frame, 2);
+ BitConverter.GetBytes(session).CopyTo(frame, 4);
+ data.CopyTo(frame, 24);
+ await s.WriteAsync(frame);
+
+ var reply = await ReadFrameAsync(s);
+ var cip = reply.AsSpan(24 + 16).ToArray(); // after handle, timeout, count, null item, data item header
+ Console.WriteLine($"seed-bool reply CIP: {Convert.ToHexString(cip)}");
+ return cip.Length >= 3 && cip[0] == 0xCD && cip[2] == 0 ? 0 : 1;
+}
+
+static async Task ReadFrameAsync(System.Net.Sockets.NetworkStream s)
+{
+ var header = new byte[24];
+ await s.ReadExactlyAsync(header);
+ var body = new byte[BitConverter.ToUInt16(header, 2)];
+ await s.ReadExactlyAsync(body);
+ return [.. header, .. body];
+}
diff --git a/scripts/ab-capture/client/ab-capture-client.csproj b/scripts/ab-capture/client/ab-capture-client.csproj
new file mode 100644
index 00000000..8d5476ad
--- /dev/null
+++ b/scripts/ab-capture/client/ab-capture-client.csproj
@@ -0,0 +1,19 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+ false
+ false
+
+
+
+
+
diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/AbCipCliTransport.cs b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/AbCipCliTransport.cs
new file mode 100644
index 00000000..3a66f770
--- /dev/null
+++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/AbCipCliTransport.cs
@@ -0,0 +1,32 @@
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli;
+
+///
+/// The CLI's --transport choice. leaves
+/// null so the driver resolves it through
+/// exactly as an absent transport key in a deployed config does.
+///
+public enum AbCipCliTransport
+{
+ /// Use the driver's default (resolved by ).
+ Default,
+
+ /// The managed native transport.
+ Native,
+
+ /// The libplctag fallback transport.
+ Libplctag,
+}
+
+/// Maps a onto the driver option.
+public static class AbCipCliTransportExtensions
+{
+ /// Returns the configured transport: null for .
+ /// The CLI choice.
+ /// The value for .
+ public static AbCipTransport? ToConfigured(this AbCipCliTransport choice) => choice switch
+ {
+ AbCipCliTransport.Native => AbCipTransport.Native,
+ AbCipCliTransport.Libplctag => AbCipTransport.Libplctag,
+ _ => null,
+ };
+}
diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/AbCipCommandBase.cs b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/AbCipCommandBase.cs
index c69d04b1..e0f61ed0 100644
--- a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/AbCipCommandBase.cs
+++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/AbCipCommandBase.cs
@@ -26,6 +26,11 @@ public abstract class AbCipCommandBase : DriverCommandBase
"ControlLogix / CompactLogix / Micro800 / GuardLogix (default ControlLogix).")]
public AbCipPlcFamily Family { get; init; } = AbCipPlcFamily.ControlLogix;
+ /// Gets the transport stack used to reach the PLC.
+ [CommandOption("transport", Description =
+ "Transport stack: default (= native), native (managed EtherNet/IP + CIP) or libplctag (fallback).")]
+ public AbCipCliTransport Transport { get; init; } = AbCipCliTransport.Default;
+
/// Gets the per-operation timeout in milliseconds.
[CommandOption("timeout-ms", Description = "Per-operation timeout in ms (default 5000).")]
public int TimeoutMs { get; init; } = 5000;
@@ -68,6 +73,7 @@ public abstract class AbCipCommandBase : DriverCommandBase
WriteIdempotent: t.WriteIdempotent,
DeviceName: t.DeviceHostAddress))],
Timeout = Timeout,
+ Transport = Transport.ToConfigured(),
Probe = new AbCipProbeOptions { Enabled = false },
EnableControllerBrowse = false,
EnableAlarmProjection = false,
diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/Program.cs b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/Program.cs
index 2f561e1d..df586a3d 100644
--- a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/Program.cs
+++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/Program.cs
@@ -6,6 +6,6 @@ return await new CliApplicationBuilder()
.SetDescription(
"OtOpcUa AB CIP test-client — ad-hoc probe + Logix symbolic reads/writes + polled " +
"subscriptions against ControlLogix / CompactLogix / Micro800 / GuardLogix families " +
- "via libplctag. Second of six driver CLIs; mirrors otopcua-modbus-cli's shape.")
+ "via libplctag or the native transport. Second of six driver CLIs; mirrors otopcua-modbus-cli's shape.")
.Build()
.RunAsync(args);
diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/AbLegacyCliTransport.cs b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/AbLegacyCliTransport.cs
new file mode 100644
index 00000000..43aec121
--- /dev/null
+++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/AbLegacyCliTransport.cs
@@ -0,0 +1,32 @@
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli;
+
+///
+/// The CLI's --transport choice. leaves
+/// null so the driver resolves it through
+/// exactly as an absent transport key in a deployed config does.
+///
+public enum AbLegacyCliTransport
+{
+ /// Use the driver's default (resolved by ).
+ Default,
+
+ /// The managed native transport.
+ Native,
+
+ /// The libplctag fallback transport.
+ Libplctag,
+}
+
+/// Maps a onto the driver option.
+public static class AbLegacyCliTransportExtensions
+{
+ /// Returns the configured transport: null for .
+ /// The CLI choice.
+ /// The value for .
+ public static AbLegacyTransport? ToConfigured(this AbLegacyCliTransport choice) => choice switch
+ {
+ AbLegacyCliTransport.Native => AbLegacyTransport.Native,
+ AbLegacyCliTransport.Libplctag => AbLegacyTransport.Libplctag,
+ _ => null,
+ };
+}
diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/AbLegacyCommandBase.cs b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/AbLegacyCommandBase.cs
index 49b4dfcd..e96b1a75 100644
--- a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/AbLegacyCommandBase.cs
+++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/AbLegacyCommandBase.cs
@@ -25,6 +25,13 @@ public abstract class AbLegacyCommandBase : DriverCommandBase
"Slc500 / MicroLogix / Plc5 / LogixPccc (default Slc500).")]
public AbLegacyPlcFamily PlcType { get; init; } = AbLegacyPlcFamily.Slc500;
+ /// Gets the transport stack used to reach the PLC.
+ [CommandOption("transport", Description =
+ "Transport stack: default, native or libplctag. default = libplctag for every --plc-type; native is " +
+ "opt-in (wire-proven only for N-file reads/writes and an F8 read; PLC-5 native is N-file reads only, " +
+ "LogixPccc unverified).")]
+ public AbLegacyCliTransport Transport { get; init; } = AbLegacyCliTransport.Default;
+
/// Gets the per-operation timeout in milliseconds.
[CommandOption("timeout-ms", Description = "Per-operation timeout in ms (default 5000).")]
public int TimeoutMs { get; init; } = 5000;
@@ -56,6 +63,7 @@ public abstract class AbLegacyCommandBase : DriverCommandBase
RawTags = [.. tags.Select(t => new RawTagEntry(
t.Name, AbLegacyTagDefinitionFactory.ToTagConfig(t), t.WriteIdempotent, DeviceName: t.DeviceHostAddress))],
Timeout = Timeout,
+ Transport = Transport.ToConfigured(),
Probe = new AbLegacyProbeOptions { Enabled = false },
};
diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Program.cs b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Program.cs
index 7db0c5fb..be23d9e9 100644
--- a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Program.cs
+++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Program.cs
@@ -5,7 +5,7 @@ return await new CliApplicationBuilder()
.SetExecutableName("otopcua-ablegacy-cli")
.SetDescription(
"OtOpcUa AB Legacy test-client — ad-hoc probe + PCCC N/F/B/L-file reads/writes + " +
- "polled subscriptions against SLC 500 / MicroLogix / PLC-5 devices via libplctag. " +
+ "polled subscriptions against SLC 500 / MicroLogix / PLC-5 devices via libplctag or the native transport. " +
"Addresses use PCCC convention: N7:0, F8:0, B3:0/3, L19:0, ST17:0.")
.Build()
.RunAsync(args);
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/AbStatus.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/AbStatus.cs
new file mode 100644
index 00000000..73006ab7
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/AbStatus.cs
@@ -0,0 +1,22 @@
+namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
+
+/// Transport-neutral status shared by the libplctag and native AB transports.
+/// Zero is success; every error is negative so existing status != 0 checks stay correct.
+public enum AbStatus
+{
+ Ok = 0,
+ Pending = 1,
+ Timeout = -1,
+ NotFound = -2,
+ NotAllowed = -3,
+ OutOfBounds = -4,
+ TooLarge = -5,
+ TooSmall = -6,
+ Unsupported = -7,
+ BadConnection = -8,
+ BadReply = -9,
+ Partial = -10,
+ Abort = -11,
+ TypeMismatch = -12,
+ DeviceStateConflict = -13,
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/AbStatusCodes.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/AbStatusCodes.cs
new file mode 100644
index 00000000..cec4866e
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/AbStatusCodes.cs
@@ -0,0 +1,31 @@
+namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
+
+public static class AbStatusCodes
+{
+ public static AbStatus FromCip(byte general, ReadOnlySpan extended) => general switch
+ {
+ 0x00 or 0x0B => AbStatus.Ok,
+ 0x04 or 0x05 or 0x16 => AbStatus.NotFound,
+ 0x06 => AbStatus.Partial,
+ 0x08 => AbStatus.Unsupported,
+ 0x0A => AbStatus.OutOfBounds,
+ 0x0C or 0x10 => AbStatus.DeviceStateConflict,
+ 0x0E or 0x0F => AbStatus.NotAllowed,
+ 0x13 => AbStatus.TooSmall,
+ 0x15 => AbStatus.TooLarge,
+ 0xFF when extended.Length > 0 && extended[0] == 0x2105 => AbStatus.OutOfBounds,
+ 0xFF when extended.Length > 0 && extended[0] == 0x2107 => AbStatus.TypeMismatch,
+ _ => AbStatus.BadReply,
+ };
+
+ public static AbStatus FromPccc(byte sts, byte? extSts) => sts switch
+ {
+ 0x00 => AbStatus.Ok,
+ 0x10 => AbStatus.Unsupported,
+ 0x20 => AbStatus.NotFound,
+ 0x30 => AbStatus.NotAllowed,
+ 0x40 or 0x50 => AbStatus.DeviceStateConflict,
+ 0xF0 when extSts is 0x06 or 0x07 => AbStatus.NotFound,
+ _ => AbStatus.BadReply,
+ };
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/AbTransportException.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/AbTransportException.cs
new file mode 100644
index 00000000..baa6694e
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/AbTransportException.cs
@@ -0,0 +1,9 @@
+namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
+
+/// Thrown by both AB transports. drives OPC UA status mapping and the
+/// driver's "controller answered, don't throttle the device" classification (05/STAB-8).
+public sealed class AbTransportException(AbStatus status, string message, Exception? inner = null)
+ : Exception(message, inner)
+{
+ public AbStatus Status { get; } = status;
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipConnection.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipConnection.cs
new file mode 100644
index 00000000..5004d731
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipConnection.cs
@@ -0,0 +1,412 @@
+using System.Diagnostics;
+using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Eip;
+namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
+
+///
+/// One device's CIP channel: one EtherNet/IP session and, when , one
+/// class-3 connection. Opens lazily on first use, reconnects (through the )
+/// after the session faults, and keeps an idle connection alive.
+///
+///
+/// Reconnect. Only a faulted session (timeout, I/O error, malformed frame) is reopened, and
+/// only by the next send; the failing call throws its own exception. Caller cancellation never
+/// faults the session (B2), so it never causes a reconnect.
+/// Bounded. Every is bounded even if the caller passes no token
+/// (ruling R9-3): opening (including the throttle wait and any wait behind another opener) is bounded by
+/// MinForwardOpenInterval + 4 × Timeout (connect, RegisterSession, Forward Open, fallback Forward
+/// Open), and the exchange by 2 × Timeout (waiting behind one in-flight exchange, then its own).
+/// Exceeding either surfaces as ( ).
+/// Connection identity. Every Forward Open attempt uses a fresh connection serial and T→O id,
+/// so a reopen is never mistaken by the target for a duplicate of a connection it has not yet expired.
+///
+public sealed class CipConnection : ICipChannel, IAsyncDisposable
+{
+ /// Forward Open frames sent by every connection in the process (fallback included). Gate diagnostics.
+ internal static int ForwardOpenCount;
+
+ /// Forward Close requests put on the wire by every connection in the process. Gate diagnostics.
+ internal static int ForwardClosesSent;
+
+ /// Forward Close requests that got a reply within their budget. Gate diagnostics.
+ internal static int ForwardClosesAcked;
+
+ /// Channel sends by every connection in the process (keep-alives included). Gate diagnostics.
+ internal static int RequestsSent;
+
+ // Round-trip statistics of completed exchanges, process-wide (gate diagnostics: T21 latency attribution).
+ // Two Stopwatch reads and four interlocked ops per request; reset by the harness between timed passes.
+ private static long _exchangeCount;
+ private static long _exchangeTotalTicks;
+ private static long _exchangeMinTicks = long.MaxValue;
+ private static long _exchangeMaxTicks;
+
+ /// Round-trip statistics of the exchanges completed since the last .
+ internal readonly record struct ExchangeStats(long Count, double MinMs, double MaxMs, double MeanMs);
+
+ /// Clears the process-wide round-trip statistics. Gate diagnostics.
+ internal static void ResetExchangeStats()
+ {
+ Interlocked.Exchange(ref _exchangeCount, 0);
+ Interlocked.Exchange(ref _exchangeTotalTicks, 0);
+ Interlocked.Exchange(ref _exchangeMinTicks, long.MaxValue);
+ Interlocked.Exchange(ref _exchangeMaxTicks, 0);
+ }
+
+ /// Snapshot of the process-wide round-trip statistics. Gate diagnostics.
+ internal static ExchangeStats ReadExchangeStats()
+ {
+ var count = Interlocked.Read(ref _exchangeCount);
+ if (count == 0) return new ExchangeStats(0, 0, 0, 0);
+ static double Ms(long ticks) => ticks * 1000.0 / Stopwatch.Frequency;
+ return new ExchangeStats(count, Ms(Interlocked.Read(ref _exchangeMinTicks)), Ms(Interlocked.Read(ref _exchangeMaxTicks)),
+ Ms(Interlocked.Read(ref _exchangeTotalTicks)) / count);
+ }
+
+ private static void RecordExchange(long ticks)
+ {
+ Interlocked.Increment(ref _exchangeCount);
+ Interlocked.Add(ref _exchangeTotalTicks, ticks);
+ long seen;
+ while (ticks < (seen = Interlocked.Read(ref _exchangeMinTicks))
+ && Interlocked.CompareExchange(ref _exchangeMinTicks, ticks, seen) != seen) { }
+ while (ticks > (seen = Interlocked.Read(ref _exchangeMaxTicks))
+ && Interlocked.CompareExchange(ref _exchangeMaxTicks, ticks, seen) != seen) { }
+ }
+
+ /// Open attempts (TCP connect + RegisterSession + Forward Open, a fallback open included) started by
+ /// every connection in the process — counted before any I/O, so a refused attempt counts too. Gate diagnostics.
+ internal static int OpenAttempts;
+
+ ///
+ /// Process-wide cap on ; -1 (the default) is unlimited. An attempt beyond
+ /// the cap fails with before any I/O — no TCP connect, no Forward Open. Only the
+ /// read-only differential harness (Task 18/21) sets it, so a production press that refuses or drops the
+ /// connection sees exactly one open attempt instead of one per remaining operation.
+ ///
+ internal static int OpenAttemptBudget = -1;
+
+ /// Get Attribute Single, Identity (class 1) instance 1 attribute 1 (Vendor ID).
+ internal static readonly byte[] KeepAliveRequest = [0x0E, 0x03, 0x20, 0x01, 0x24, 0x01, 0x30, 0x01];
+
+ /// Largest unconnected message a target is guaranteed to accept.
+ private const int UnconnectedMessageLimit = 504;
+
+ private sealed record Open(EncapsulationClient Client, ForwardOpenReply? Conn, ushort ConnectionSerial, int NegotiatedSize);
+
+ private readonly DeviceKey _key;
+ private readonly ConnectionOptions _o;
+ private readonly ForwardOpenThrottle _throttle;
+ private readonly TimeProvider _time;
+ private readonly Task _predecessorClosed;
+ private readonly byte[] _route;
+
+ private readonly object _sync = new();
+ // Cancels only the waits BEFORE an attempt (predecessor close, throttle interval), never an attempt
+ // already on the wire. Never disposed: it has no timer, and late registrations must stay legal.
+ private readonly CancellationTokenSource _disposing = new();
+ private readonly ITimer? _keepAlive;
+ private Task? _pendingOpen; // the one shared open in progress; under _sync
+ private Open? _open; // written under _sync (Volatile); read lock-free via Volatile
+ private int _disposed; // set under _sync; read lock-free via Volatile
+ private int _disposeStarted;
+ private long _lastSendTimestamp;
+ private int _keepAlivesSent;
+ private int _connectionSerial = Random.Shared.Next(1, ushort.MaxValue);
+
+ /// The device; is the route (ignored for Direct).
+ /// Routing, sizes, timeouts and keep-alive settings.
+ /// The device's process-lifetime Forward Open throttle.
+ /// Clock for the throttle, deadlines and keep-alive.
+ /// Completes when the previous connection for the same key has
+ /// finished closing, so two connections to one device never overlap.
+ internal CipConnection(DeviceKey key, ConnectionOptions options, ForwardOpenThrottle throttle, TimeProvider time,
+ Task? predecessorClosed = null)
+ {
+ _key = key ?? throw new ArgumentNullException(nameof(key));
+ _o = options ?? throw new ArgumentNullException(nameof(options));
+ _throttle = throttle ?? throw new ArgumentNullException(nameof(throttle));
+ _time = time ?? throw new ArgumentNullException(nameof(time));
+ _predecessorClosed = predecessorClosed ?? Task.CompletedTask;
+ // Direct ignores the path entirely (B3), so it is not even parsed.
+ _route = options.Routing == CipRouting.Direct ? [] : CipPath.EncodeRoute(key.CipPath);
+ _lastSendTimestamp = time.GetTimestamp();
+ if (options.Routing == CipRouting.Connected)
+ _keepAlive = time.CreateTimer(static s => ((CipConnection)s!).OnKeepAliveTimer(), this,
+ options.KeepAliveInterval, Timeout.InfiniteTimeSpan);
+ }
+
+ /// The options this connection was built with.
+ internal ConnectionOptions Options => _o;
+
+ /// Test hook: keep-alives started (counted synchronously when the timer decides to send).
+ internal int KeepAlivesSent => Volatile.Read(ref _keepAlivesSent);
+
+ private TimeSpan OpenBudget => _o.MinForwardOpenInterval + 4 * _o.Timeout;
+ private TimeSpan ExchangeBudget => 2 * _o.Timeout;
+
+ public int MaxRequestBytes => _o.Routing switch
+ {
+ CipRouting.Connected => (Volatile.Read(ref _open)?.NegotiatedSize ?? Math.Min(_o.ConnectionSize, UnconnectedMessageLimit)) - 2,
+ CipRouting.UnconnectedSend => UnconnectedMessageLimit - (12 + _route.Length),
+ _ => UnconnectedMessageLimit,
+ };
+
+ public async Task> SendAsync(ReadOnlyMemory cipRequest, CancellationToken cancellationToken)
+ {
+ ThrowIfDisposed();
+ var open = await EnsureOpenAsync(cancellationToken).ConfigureAwait(false);
+ return await ExchangeAsync(open, cipRequest, cancellationToken).ConfigureAwait(false);
+ }
+
+ ///