diff --git a/docs/plans/revision-write-path.md b/docs/plans/revision-write-path.md
index f25e2e7..85de6e6 100644
--- a/docs/plans/revision-write-path.md
+++ b/docs/plans/revision-write-path.md
@@ -134,6 +134,51 @@ path is implementable. If it fails with a server-side cache error,
try `RTag2` first. If it still fails, the path is genuinely blocked
server-side.
+### SDK-direct probe results (2026-05-05)
+
+`HistorianWcfRevisionOrchestrator` wires up the priming chain + a probe
+of `ITransactionServiceContract2.AddNonStreamValuesBegin2(string handle, out string transactionId, out byte[] errorBuffer)`.
+Live test against `localhost`:
+
+- ✅ `OpenSucceeded: True` — Hist auth chain + Open2 still work end-to-end
+- ✅ Trx channel opens, `Trx.GetV` returns interface version 2
+- ✅ Wire path is recognized — server processes the call (no
+ `ActionNotSupportedException` after switching from the abbreviated
+ `AddNonS2B` to the default action name)
+- ❌ Server returns structured error `04 33 00 00 00` =
+ type 4 (CustomError) + code 51 (`UnknownClient`) for all four handle
+ formats tried (contextKey GUID upper, storageSessionId upper, contextKey
+ lower, ClientHandle as string)
+- ❌ Adding the full priming chain (Stat.GetV ×2, Stat.GETHI ×2, UpdC3,
+ 6× Stat.GetSystemParameter, AllowRenameTags, Trx.GetV, Stat.GetV,
+ Retr.GetV) doesn't change the result — Trx still rejects with
+ `UnknownClient`
+
+`ITransactionServiceContract2` exposes only `GetV`, `ForwardSnapshot*`,
+and `AddNonStreamValues*`. There is no `ValidateClient`, `RegisterClient`,
+or `Open` on Trx. So the client-with-Trx registration must happen via
+some cross-service side effect we haven't identified.
+
+**Important takeaway:** the wire path works at the WCF protocol layer.
+We're past the "is this even reachable" question. The remaining gap is
+finding what populates Trx's session table — likely:
+
+1. `RTag2` on /Hist with a tag whose registration cascades to Trx
+2. Some `IStorageServiceContract` op that we haven't tried
+3. An aspect of the C++ HistorianClient initialization that doesn't
+ show up in the IL we've inspected (e.g., the
+ `aahClientCommon.CClientCommon` calls during InitializeProxy)
+
+A future session that wants to push further should:
+1. Add `RTag2` for the sandbox tag and retry Begin2 — quick experiment
+2. If that fails, try sending the IStorageServiceContract.AddT or
+ similar to "introduce" the tag to Trx
+3. If that fails, do an IL walk of `aahClientCommon.CClientCommon`
+ methods called between Open2 and AddNonStreamValuesBegin in a
+ working native scenario (using a system tag the wrapper would
+ accept — or capturing actual on-wire bytes via the IL-rewrite
+ instrumentation if possible)
+
## Decision
Do **not** add public `WriteRevisionsAsync` / `BeginRevisionAsync` to
diff --git a/src/AVEVA.Historian.Client/Wcf/Contracts/ITransactionServiceContract.cs b/src/AVEVA.Historian.Client/Wcf/Contracts/ITransactionServiceContract.cs
index b7d35be..3b67f79 100644
--- a/src/AVEVA.Historian.Client/Wcf/Contracts/ITransactionServiceContract.cs
+++ b/src/AVEVA.Historian.Client/Wcf/Contracts/ITransactionServiceContract.cs
@@ -1,3 +1,4 @@
+using System.Runtime.InteropServices;
using System.ServiceModel;
namespace AVEVA.Historian.Client.Wcf.Contracts;
@@ -26,3 +27,40 @@ internal interface ITransactionServiceContract
[OperationContract]
uint AddNonStreamValuesEnd(uint handle, string transactionId, bool commit);
}
+
+///
+/// V2 surface — discovered by inspecting CHistoryConnectionWCF.AddNonStreamValuesBegin's
+/// IL (token 0x06004051), which calls
+/// ITransactionServiceContract2::AddNonStreamValuesBegin2(string, ref string, ref byte[])
+/// before falling back to V1. The V2 ops use the GUID-string handle pattern matching
+/// other V2 ops on /Hist (EnsT2, AddS2, RTag2) plus an out-byte[] errorBuffer.
+///
+[ServiceContract(Name = HistorianWcfServiceNames.Transaction, Namespace = HistorianWcfServiceNames.Namespace)]
+internal interface ITransactionServiceContract2
+{
+ [OperationContract(Name = "GetV")]
+ uint GetInterfaceVersion(out uint version);
+
+ [OperationContract]
+ [return: MarshalAs(UnmanagedType.U1)]
+ bool AddNonStreamValuesBegin2(
+ string handle,
+ out string transactionId,
+ out byte[] errorBuffer);
+
+ [OperationContract]
+ [return: MarshalAs(UnmanagedType.U1)]
+ bool AddNonStreamValues2(
+ string handle,
+ string transactionId,
+ [MessageParameter(Name = "pBuf")] byte[] buffer,
+ out byte[] errorBuffer);
+
+ [OperationContract]
+ [return: MarshalAs(UnmanagedType.U1)]
+ bool AddNonStreamValuesEnd2(
+ string handle,
+ string transactionId,
+ [MarshalAs(UnmanagedType.U1)] bool commit,
+ out byte[] errorBuffer);
+}
diff --git a/src/AVEVA.Historian.Client/Wcf/HistorianWcfRevisionOrchestrator.cs b/src/AVEVA.Historian.Client/Wcf/HistorianWcfRevisionOrchestrator.cs
new file mode 100644
index 0000000..48a7c40
--- /dev/null
+++ b/src/AVEVA.Historian.Client/Wcf/HistorianWcfRevisionOrchestrator.cs
@@ -0,0 +1,232 @@
+using System.Buffers.Binary;
+using System.ServiceModel;
+using System.ServiceModel.Channels;
+using AVEVA.Historian.Client.Wcf.Contracts;
+
+namespace AVEVA.Historian.Client.Wcf;
+
+///
+/// Drives the AddNonStreamValuesBegin / AddNonStreamValues / AddNonStreamValuesEnd
+/// WCF op group on the /Trx service end-to-end. The native AVEVA wrapper's
+/// equivalent surface (HistorianAccess.AddRevisionValues*) is gated by the
+/// C++ HistorianClient's per-connection cache and rejects all writes from a
+/// managed client with err 129 TagNotFoundInCache. This SDK orchestrator
+/// bypasses the wrapper entirely — talks WCF directly — to test whether the SERVER
+/// gates on the same condition.
+///
+/// Live behavior is unverified. The first iteration is probe-only: open the auth
+/// chain, drive the standard write priming, call AddNonStreamValuesBegin and
+/// surface whatever the server returns.
+///
+internal sealed class HistorianWcfRevisionOrchestrator
+{
+ private readonly HistorianClientOptions _options;
+
+ public HistorianWcfRevisionOrchestrator(HistorianClientOptions options)
+ {
+ _options = options ?? throw new ArgumentNullException(nameof(options));
+ }
+
+ public Task ProbeBeginAsync(CancellationToken cancellationToken)
+ => Task.Run(() => ProbeBegin(cancellationToken), cancellationToken);
+
+ private HistorianRevisionProbeResult ProbeBegin(CancellationToken cancellationToken)
+ {
+ Guid contextKey = Guid.NewGuid();
+ var (histBinding, histEndpoint, _, _) = HistorianWcfBindingFactory.CreateBindingPair(_options);
+ Binding auxBinding = HistorianWcfBindingFactory.CreateAuxiliaryBinding(_options);
+ EndpointAddress transactionEndpoint = HistorianWcfBindingFactory.CreateAuxiliaryEndpointAddress(_options, HistorianWcfServiceNames.Transaction);
+
+ HistorianRevisionProbeResult result = new();
+
+ HistorianWcfAuthChainHelper.OpenAuthenticatedConnection(
+ _options, histBinding, histEndpoint, contextKey, cancellationToken,
+ connectionMode: HistorianWcfAuthChainHelper.NativeIntegratedWriteEnabledConnectionMode,
+ additionalSetup: (historyChannel, context) =>
+ {
+ result.OpenSucceeded = true;
+ result.ClientHandle = context.ClientHandle;
+ result.StorageSessionId = context.StorageSessionId;
+
+ // Run the same priming chain that EnsT2/DelT use — without it, the Trx
+ // service rejects calls with err 51 UnknownClient because the client
+ // hasn't registered itself across the auxiliary services.
+ EndpointAddress statusEndpoint = HistorianWcfBindingFactory.CreateAuxiliaryEndpointAddress(_options, HistorianWcfServiceNames.Status);
+ EndpointAddress retrievalEndpoint = HistorianWcfBindingFactory.CreateAuxiliaryEndpointAddress(_options, HistorianWcfServiceNames.Retrieval);
+ RunPrimingChain(historyChannel, context, auxBinding, statusEndpoint, transactionEndpoint, retrievalEndpoint);
+
+ ChannelFactory trxFactory = new(auxBinding, transactionEndpoint);
+ HistorianWcfClientCredentialsHelper.Configure(trxFactory, _options);
+ ITransactionServiceContract2 trxChannel = trxFactory.CreateChannel();
+ ICommunicationObject trxCo = (ICommunicationObject)trxChannel;
+ try
+ {
+ // Get interface version first to register the client in the Trx service's
+ // session table (matches the cross-service GetV priming pattern used by
+ // RunWritePriming for EnsT2/DelT).
+ try
+ {
+ uint trxRc = trxChannel.GetInterfaceVersion(out uint trxVersion);
+ result.TrxInterfaceVersionReturnCode = trxRc;
+ result.TrxInterfaceVersion = trxVersion;
+ }
+ catch (Exception ex)
+ {
+ result.TrxInterfaceVersionException = $"{ex.GetType().Name}: {ex.Message}";
+ }
+
+ // Probe V2 AddNonStreamValuesBegin2. Try BOTH possible handle formats —
+ // the server returns 0433000000 (UnknownClient = 51) when the wrong one
+ // is sent. Capture which one (if any) is recognized.
+ foreach ((string label, string handle) in new[]
+ {
+ ("contextKey", contextKey.ToString("D").ToUpperInvariant()),
+ ("storageSessionId", context.StorageSessionId.ToString("D").ToUpperInvariant()),
+ ("contextKey-lower", contextKey.ToString("D")),
+ ("clientHandle-as-string", context.ClientHandle.ToString()),
+ })
+ {
+ try
+ {
+ string? transactionId = null;
+ byte[]? errorBuffer = null;
+ bool ok = trxChannel.AddNonStreamValuesBegin2(handle, out transactionId, out errorBuffer);
+ result.BeginAttempts.Add(new HistorianRevisionBeginAttempt
+ {
+ HandleLabel = label,
+ HandleSent = handle,
+ Succeeded = ok,
+ TransactionId = transactionId,
+ ErrorHex = errorBuffer is null || errorBuffer.Length == 0 ? null : Convert.ToHexString(errorBuffer),
+ });
+ if (ok && !string.IsNullOrEmpty(transactionId))
+ {
+ result.BeginSucceeded = true;
+ result.BeginTransactionId = transactionId;
+ break;
+ }
+ }
+ catch (Exception ex)
+ {
+ result.BeginAttempts.Add(new HistorianRevisionBeginAttempt
+ {
+ HandleLabel = label,
+ HandleSent = handle,
+ Exception = $"{ex.GetType().Name}: {ex.Message}",
+ });
+ }
+ }
+ }
+ finally
+ {
+ try { if (trxCo.State == CommunicationState.Faulted) trxCo.Abort(); else trxCo.Close(); } catch { try { trxCo.Abort(); } catch { } }
+ try { if (trxFactory.State == CommunicationState.Faulted) trxFactory.Abort(); else trxFactory.Close(); } catch { try { trxFactory.Abort(); } catch { } }
+ }
+ });
+
+ return result;
+ }
+
+ ///
+ /// Mirrors HistorianWcfTagWriteOrchestrator.RunWritePriming. The cross-service GetV
+ /// calls + UpdC3 register the client in each aux service's session table so that
+ /// subsequent ops (like AddNonStreamValuesBegin2 on /Trx) recognize the handle.
+ ///
+ private static void RunPrimingChain(
+ IHistoryServiceContract2 historyChannel,
+ HistorianWcfAuthChainHelper.OpenConnectionContext context,
+ Binding auxBinding,
+ EndpointAddress statusEndpoint,
+ EndpointAddress transactionEndpoint,
+ EndpointAddress retrievalEndpoint)
+ {
+ string handle = context.StorageSessionId.ToString("D").ToUpperInvariant();
+
+ ChannelFactory statusFactory = new(auxBinding, statusEndpoint);
+ IStatusServiceContract2 statusChannel = statusFactory.CreateChannel();
+ ChannelFactory transactionFactory = new(auxBinding, transactionEndpoint);
+ ITransactionServiceContract transactionChannel = transactionFactory.CreateChannel();
+ ChannelFactory retrievalFactory = new(auxBinding, retrievalEndpoint);
+ IRetrievalServiceContract4 retrievalChannel = retrievalFactory.CreateChannel();
+
+ try
+ {
+ TryRun(() => statusChannel.GetInterfaceVersion(out _));
+ TryRun(() => statusChannel.GetInterfaceVersion(out _));
+ byte[] historianVersionRequest = BuildGetHistorianInfoRequest("HistorianVersion");
+ TryRun(() => statusChannel.GetHistorianInfo(handle, historianVersionRequest, out _, out _));
+ TryRun(() => statusChannel.GetHistorianInfo(handle, historianVersionRequest, out _, out _));
+
+ byte[] clientStatus = BuildUpdC3ClientStatusBlob();
+ TryRun(() => historyChannel.UpdateClientStatus3(handle, (uint)clientStatus.Length, ref clientStatus, out _, out _, out _, out _));
+
+ foreach (string parameterName in new[] { "AllowOriginals", "HistorianPartner", "HistorianVersion", "MaxCyclicStorageTimeout", "RealTimeWindow", "FutureTimeThreshold", "AllowRenameTags" })
+ {
+ TryRun(() => statusChannel.GetSystemParameter(context.ClientHandle, parameterName, out _, out _, out _));
+ }
+ TryRun(() => transactionChannel.GetInterfaceVersion(out _));
+ TryRun(() => statusChannel.GetInterfaceVersion(out _));
+ TryRun(() => retrievalChannel.GetInterfaceVersion(out _));
+ }
+ finally
+ {
+ CloseSafely(retrievalChannel, retrievalFactory);
+ CloseSafely(transactionChannel, transactionFactory);
+ CloseSafely(statusChannel, statusFactory);
+ }
+ }
+
+ private static byte[] BuildUpdC3ClientStatusBlob()
+ {
+ byte[] blob = new byte[81];
+ blob[0] = 0x02;
+ blob[1] = 0x01;
+ blob[77] = 0x1E;
+ return blob;
+ }
+
+ private static byte[] BuildGetHistorianInfoRequest(string parameterName)
+ {
+ byte[] nameBytes = System.Text.Encoding.Unicode.GetBytes(parameterName);
+ int payloadLength = nameBytes.Length > 0 ? nameBytes.Length - 1 : 0;
+ byte[] buffer = new byte[8 + payloadLength];
+ BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(0, 2), 0x6753);
+ BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(2, 2), 0x0002);
+ BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(4, 4), (uint)parameterName.Length);
+ Buffer.BlockCopy(nameBytes, 0, buffer, 8, payloadLength);
+ return buffer;
+ }
+
+ private static void TryRun(Action a) { try { a(); } catch { } }
+
+ private static void CloseSafely(object channel, ICommunicationObject factory)
+ {
+ try { if (channel is ICommunicationObject co) { if (co.State == CommunicationState.Faulted) co.Abort(); else co.Close(); } } catch { }
+ try { if (factory.State == CommunicationState.Faulted) factory.Abort(); else factory.Close(); } catch { }
+ }
+}
+
+internal sealed class HistorianRevisionProbeResult
+{
+ public bool OpenSucceeded { get; set; }
+ public uint ClientHandle { get; set; }
+ public Guid StorageSessionId { get; set; }
+ public uint? TrxInterfaceVersionReturnCode { get; set; }
+ public uint? TrxInterfaceVersion { get; set; }
+ public string? TrxInterfaceVersionException { get; set; }
+ public string? BeginTransactionId { get; set; }
+ public bool BeginSucceeded { get; set; }
+ public string? BeginErrorHex { get; set; }
+ public string? BeginException { get; set; }
+ public List BeginAttempts { get; } = new();
+}
+
+internal sealed class HistorianRevisionBeginAttempt
+{
+ public string HandleLabel { get; set; } = "";
+ public string HandleSent { get; set; } = "";
+ public bool Succeeded { get; set; }
+ public string? TransactionId { get; set; }
+ public string? ErrorHex { get; set; }
+ public string? Exception { get; set; }
+}
diff --git a/tests/AVEVA.Historian.Client.Tests/HistorianWcfRevisionProbeTests.cs b/tests/AVEVA.Historian.Client.Tests/HistorianWcfRevisionProbeTests.cs
new file mode 100644
index 0000000..a10ac71
--- /dev/null
+++ b/tests/AVEVA.Historian.Client.Tests/HistorianWcfRevisionProbeTests.cs
@@ -0,0 +1,60 @@
+using System.Runtime.Versioning;
+using AVEVA.Historian.Client;
+using AVEVA.Historian.Client.Models;
+using AVEVA.Historian.Client.Wcf;
+using Xunit.Abstractions;
+
+namespace AVEVA.Historian.Client.Tests;
+
+///
+/// Probes the SDK-direct WCF revision-write path (D2 new path). Calls
+/// AddNonStreamValuesBegin through
+/// against the live local Historian and surfaces what the server returns. The
+/// underlying native wrapper is gated client-side by err 129 TagNotFoundInCache;
+/// this test bypasses the wrapper entirely and asks the SERVER directly. Gated on
+/// HISTORIAN_HOST=localhost; skips otherwise.
+///
+[SupportedOSPlatform("windows")]
+public sealed class HistorianWcfRevisionProbeTests
+{
+ private readonly ITestOutputHelper _output;
+
+ public HistorianWcfRevisionProbeTests(ITestOutputHelper output)
+ {
+ _output = output;
+ }
+
+ [Fact]
+ public async Task AddNonStreamValuesBegin_ProbeReturnsServerResult()
+ {
+ string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
+ if (string.IsNullOrWhiteSpace(host) || !string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || !OperatingSystem.IsWindows())
+ {
+ return;
+ }
+
+ HistorianClientOptions options = new()
+ {
+ Host = host,
+ IntegratedSecurity = true,
+ Transport = HistorianTransport.LocalPipe,
+ };
+
+ HistorianWcfRevisionOrchestrator orchestrator = new(options);
+ HistorianRevisionProbeResult result = await orchestrator.ProbeBeginAsync(CancellationToken.None);
+
+ _output.WriteLine($"OpenSucceeded: {result.OpenSucceeded}");
+ _output.WriteLine($"ClientHandle: {result.ClientHandle}");
+ _output.WriteLine($"StorageSessionId: {result.StorageSessionId}");
+ _output.WriteLine($"TrxInterfaceVersion: {result.TrxInterfaceVersion} (rc={result.TrxInterfaceVersionReturnCode}) ex={result.TrxInterfaceVersionException}");
+ _output.WriteLine($"BeginSucceeded: {result.BeginSucceeded}");
+ _output.WriteLine($"BeginTransactionId: {result.BeginTransactionId}");
+ foreach (HistorianRevisionBeginAttempt attempt in result.BeginAttempts)
+ {
+ _output.WriteLine($" attempt[{attempt.HandleLabel}] handle={attempt.HandleSent} ok={attempt.Succeeded} tx={attempt.TransactionId} err={attempt.ErrorHex} ex={attempt.Exception}");
+ }
+
+ Assert.True(result.OpenSucceeded, "Auth chain failed; revision probe never reached the Trx endpoint.");
+ // Don't assert BeginSucceeded — we're surfacing whatever the server says, not requiring success.
+ }
+}