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
index 4f9ec0ad..3fc33956 100644
--- a/docs/plans/2026-10-07-native-ab-transport-gate-results.md
+++ b/docs/plans/2026-10-07-native-ab-transport-gate-results.md
@@ -2,7 +2,46 @@
## ab_server suites (T17)
-_(pending)_
+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=
`); 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 in the T17 `test(ab)` commit that follows them.
+
+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)
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbCipReadSmokeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbCipReadSmokeTests.cs
index 51168dfb..44d142e7 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbCipReadSmokeTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbCipReadSmokeTests.cs
@@ -6,7 +6,7 @@ using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests;
///
-/// End-to-end smoke tests that exercise the real libplctag stack against a running
+/// End-to-end smoke tests that exercise the real transport stack (libplctag and native, via ) against a running
/// ab_server. Skipped when the binary isn't on PATH ().
/// Parametrized over so one test file covers every family
/// (ControlLogix / CompactLogix / Micro800 / GuardLogix).
@@ -15,15 +15,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests;
[Trait("Requires", "AbServer")]
public sealed class AbCipReadSmokeTests
{
- /// Gets the test profiles for the theory tests.
- public static IEnumerable
- [AbServerFact]
- public async Task Nested_struct_member_discovered_and_expanded_via_template_id_fetch()
+ /// The transport stack the driver runs on.
+ [AbServerTheory]
+ [MemberData(nameof(TransportMatrix.Transports), MemberType = typeof(TransportMatrix))]
+ public async Task Nested_struct_member_discovered_and_expanded_via_template_id_fetch(AbCipTransport transport)
{
AbServerProfileGate.SkipUnless(AbServerProfileGate.Emulate);
@@ -58,9 +60,10 @@ public sealed class AbCipEmulateNestedUdtTests
Devices = [new AbCipDeviceOptions($"ab://{endpoint}/1,0")],
EnableControllerBrowse = true,
Timeout = TimeSpan.FromSeconds(10),
+ Transport = transport,
};
- await using var drv = new AbCipDriver(options, driverInstanceId: "emulate-nested-udt-smoke");
+ await using var drv = new AbCipDriver(options, driverInstanceId: $"emulate-nested-udt-smoke-{transport}");
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
// Drive controller discovery — the driver walks @tags, encounters MotorNested1
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Emulate/AbCipEmulateUdtReadTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Emulate/AbCipEmulateUdtReadTests.cs
index a6018c02..16658a70 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Emulate/AbCipEmulateUdtReadTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Emulate/AbCipEmulateUdtReadTests.cs
@@ -38,8 +38,10 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Emulate;
public sealed class AbCipEmulateUdtReadTests
{
/// Verifies that reading a whole UDT decodes each member at its template object offset.
- [AbServerFact]
- public async Task WholeUdt_read_decodes_each_member_at_its_Template_Object_offset()
+ /// The transport stack the driver runs on.
+ [AbServerTheory]
+ [MemberData(nameof(TransportMatrix.Transports), MemberType = typeof(TransportMatrix))]
+ public async Task WholeUdt_read_decodes_each_member_at_its_Template_Object_offset(AbCipTransport transport)
{
AbServerProfileGate.SkipUnless(AbServerProfileGate.Emulate);
@@ -63,9 +65,10 @@ public sealed class AbCipEmulateUdtReadTests
new AbCipStructureMember("Status", AbCipDataType.DInt),
])),
Timeout = TimeSpan.FromSeconds(5),
+ Transport = transport,
};
- await using var drv = new AbCipDriver(options, driverInstanceId: "emulate-udt-smoke");
+ await using var drv = new AbCipDriver(options, driverInstanceId: $"emulate-udt-smoke-{transport}");
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
// Whole-UDT read optimization from task #194: one libplctag read on the
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/TransportMatrix.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/TransportMatrix.cs
new file mode 100644
index 00000000..55b12b85
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/TransportMatrix.cs
@@ -0,0 +1,37 @@
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests;
+
+///
+/// Crosses every ab_server compose profile with both AbCip transports so each integration
+/// test runs once on libplctag and once on the native managed stack. The native run must be green
+/// wherever the libplctag run is.
+///
+///
+/// Every AbCip compose profile binds host :44818, so only one can run at a time. When
+/// AB_SERVER_COMPOSE_PROFILE is set, keeps only that profile, so the
+/// rows match the running container instead of driving, say, Micro800 semantics at a ControlLogix.
+/// Unset, every profile is crossed (the original behaviour).
+///
+public static class TransportMatrix
+{
+ /// Both transports, for tests that are not parameterised by profile.
+ public static IReadOnlyList AllTransports { get; } =
+ [AbCipTransport.Libplctag, AbCipTransport.Native];
+
+ /// Gets (profile, transport) rows for every selected profile × both transports.
+ public static IEnumerable