diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx
index 85b032b2..fd2ce393 100644
--- a/ZB.MOM.WW.OtOpcUa.slnx
+++ b/ZB.MOM.WW.OtOpcUa.slnx
@@ -40,6 +40,7 @@
+
@@ -109,6 +110,7 @@
+
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..4f9ec0ad
--- /dev/null
+++ b/docs/plans/2026-10-07-native-ab-transport-gate-results.md
@@ -0,0 +1,21 @@
+# Native AB transport gate results
+
+## ab_server suites (T17)
+
+_(pending)_
+
+## Differential vs ab_server (T18)
+
+_(pending)_
+
+## AdminUI /run (T19)
+
+_(pending)_
+
+## Real press (T21)
+
+_(pending)_
+
+## Latency (T21)
+
+_(pending)_
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/CipReply.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipReply.cs
new file mode 100644
index 00000000..b43f9059
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipReply.cs
@@ -0,0 +1,22 @@
+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);
+ }
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ICipChannel.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ICipChannel.cs
new file mode 100644
index 00000000..42e0b9e1
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ICipChannel.cs
@@ -0,0 +1,13 @@
+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);
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Wire/CipReader.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Wire/CipReader.cs
new file mode 100644
index 00000000..e83d48b0
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Wire/CipReader.cs
@@ -0,0 +1,26 @@
+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);
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Wire/CipWriter.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Wire/CipWriter.cs
new file mode 100644
index 00000000..3489b4bb
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Wire/CipWriter.cs
@@ -0,0 +1,29 @@
+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();
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.csproj
new file mode 100644
index 00000000..2f48e07a
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.csproj
@@ -0,0 +1,19 @@
+
+
+ net10.0
+ enable
+ enable
+ latest
+ true
+ true
+ $(NoWarn);CS1591
+ ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport
+ ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport
+
+
+
+
+
+
+
+
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj
index 50b2cae4..13e4c766 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj
@@ -14,6 +14,7 @@
+
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj
index 167eb613..b4fe82f3 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj
@@ -14,6 +14,7 @@
+
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/AbStatusCodesTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/AbStatusCodesTests.cs
new file mode 100644
index 00000000..8cadaa43
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/AbStatusCodesTests.cs
@@ -0,0 +1,53 @@
+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);
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Wire/CipReplyTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Wire/CipReplyTests.cs
new file mode 100644
index 00000000..41026c2b
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Wire/CipReplyTests.cs
@@ -0,0 +1,43 @@
+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_reply_with_one_extended_word()
+ {
+ var r = CipReply.Parse(new byte[] { 0xCC, 0x00, 0xFF, 0x01, 0x07, 0x21 });
+ r.ExtendedStatus.ToArray().ShouldBe(new ushort[] { 0x2107 });
+ r.Status.ShouldBe(AbStatus.TypeMismatch);
+ }
+
+ [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);
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.csproj b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.csproj
new file mode 100644
index 00000000..f3153044
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.csproj
@@ -0,0 +1,26 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+ true
+ ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+