diff --git a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcReadOrchestrator.cs b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcReadOrchestrator.cs
index d37241c..72d2d5d 100644
--- a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcReadOrchestrator.cs
+++ b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcReadOrchestrator.cs
@@ -163,7 +163,9 @@ internal sealed class HistorianGrpcReadOrchestrator
Guid contextKey = Guid.NewGuid();
var historyClient = new GrpcHistory.HistoryService.HistoryServiceClient(connection.Channel);
- historyClient.GetInterfaceVersion(new GrpcHistory.GetInterfaceVersionRequest(), connection.Metadata, Deadline(), cancellationToken);
+ GrpcHistory.GetInterfaceVersionResponse historyVersion = historyClient.GetInterfaceVersion(
+ new GrpcHistory.GetInterfaceVersionRequest(), connection.Metadata, Deadline(), cancellationToken);
+ HistorianServerVersionGate.Validate(HistorianServiceInterface.History, historyVersion.UiVersion, _options);
HistorianNativeHandshake.RunTokenRounds(
(handle, wrapped, _) =>
@@ -210,7 +212,9 @@ internal sealed class HistorianGrpcReadOrchestrator
CancellationToken cancellationToken)
{
var retrievalClient = new GrpcRetrieval.RetrievalService.RetrievalServiceClient(connection.Channel);
- retrievalClient.GetRetrievalInterfaceVersion(new GrpcRetrieval.GetRetrievalInterfaceVersionRequest(), null, Deadline(), cancellationToken);
+ GrpcRetrieval.GetRetrievalInterfaceVersionResponse retrievalVersion = retrievalClient.GetRetrievalInterfaceVersion(
+ new GrpcRetrieval.GetRetrievalInterfaceVersionRequest(), null, Deadline(), cancellationToken);
+ HistorianServerVersionGate.Validate(HistorianServiceInterface.Retrieval, retrievalVersion.UiVersion, _options);
byte[] requestBuffer = HistorianDataQueryProtocol.SerializeFullHistoryRequest(request);
uint queryHandle = StartQuery(retrievalClient, clientHandle, requestBuffer, "raw", cancellationToken);
@@ -260,7 +264,9 @@ internal sealed class HistorianGrpcReadOrchestrator
CancellationToken cancellationToken)
{
var retrievalClient = new GrpcRetrieval.RetrievalService.RetrievalServiceClient(connection.Channel);
- retrievalClient.GetRetrievalInterfaceVersion(new GrpcRetrieval.GetRetrievalInterfaceVersionRequest(), null, Deadline(), cancellationToken);
+ GrpcRetrieval.GetRetrievalInterfaceVersionResponse retrievalVersion = retrievalClient.GetRetrievalInterfaceVersion(
+ new GrpcRetrieval.GetRetrievalInterfaceVersionRequest(), null, Deadline(), cancellationToken);
+ HistorianServerVersionGate.Validate(HistorianServiceInterface.Retrieval, retrievalVersion.UiVersion, _options);
HistorianDataQueryRequest request = HistorianWcfReadOrchestrator.BuildAggregateQueryRequest(tag, startUtc, endUtc, mode, interval);
byte[] requestBuffer = HistorianDataQueryProtocol.SerializeFullHistoryRequest(request);
diff --git a/src/AVEVA.Historian.Client/HistorianClientOptions.cs b/src/AVEVA.Historian.Client/HistorianClientOptions.cs
index afd7174..23fc53e 100644
--- a/src/AVEVA.Historian.Client/HistorianClientOptions.cs
+++ b/src/AVEVA.Historian.Client/HistorianClientOptions.cs
@@ -62,4 +62,16 @@ public sealed class HistorianClientOptions
/// true the server certificate chain is not validated. Default false.
///
public bool GrpcUseTls { get; init; }
+
+ ///
+ /// When true (default) the SDK verifies, at connect time, that the Historian server
+ /// reports the native interface versions its byte serializers were built against
+ /// (History=11, Retrieval=4, Transaction=2 — evidence from a live AVEVA Historian 2020
+ /// server). A mismatch throws rather than
+ /// risk misparsing version-framed native buffers. Set false only when you have
+ /// independently confirmed wire compatibility with a different server version — e.g.
+ /// when bringing up a 2023 R2 gRPC server whose reported interface integers have not yet
+ /// been captured. See .
+ ///
+ public bool VerifyServerInterfaceVersion { get; init; } = true;
}
diff --git a/src/AVEVA.Historian.Client/HistorianServerVersionGate.cs b/src/AVEVA.Historian.Client/HistorianServerVersionGate.cs
new file mode 100644
index 0000000..aa05f3c
--- /dev/null
+++ b/src/AVEVA.Historian.Client/HistorianServerVersionGate.cs
@@ -0,0 +1,93 @@
+namespace AVEVA.Historian.Client;
+
+///
+/// Identifies a versioned native Historian service interface whose reported interface
+/// version is validated at connect time by .
+///
+internal enum HistorianServiceInterface
+{
+ History,
+ Retrieval,
+ Status,
+ Transaction
+}
+
+///
+/// Fail-closed check (roadmap item R0.6) that a Historian server reports the native
+/// interface version this SDK's byte serializers were built against.
+///
+/// The opaque native buffers carried inside the WCF/MDAS message body — and, on 2023 R2,
+/// inside the gRPC bytes fields — are framed per native interface version. Parsing
+/// them against an unexpected version risks silent misinterpretation, so per the
+/// "version-pin, fail closed" principle this throws
+/// rather than best-effort parsing.
+///
+/// Supported versions are evidence-based, discovered from a live AVEVA Historian 2020
+/// server (product 20.0.000) via the reverse-engineering wcf-probe command:
+///
+/// - History (Hist) interface version = 11
+/// - Retrieval (Retr) interface version = 4
+/// - Transaction (Trx) interface version = 2
+///
+/// The Status (Stat) service's GetInterfaceVersion returns 0 (not a real
+/// version), so the Status interface is validated for reachability only, never value.
+///
+/// A 2023 R2 gRPC server may report different integers even though it carries the same
+/// proven 2020 native buffers; until those integers are captured, point such a server at
+/// this gate with set to
+/// .
+///
+internal static class HistorianServerVersionGate
+{
+ public const uint HistoryInterfaceVersion = 11;
+ public const uint RetrievalInterfaceVersion = 4;
+ public const uint TransactionInterfaceVersion = 2;
+
+ ///
+ /// True when the service interface reports a meaningful version that should be matched.
+ /// Status is reachability-only (its GetInterfaceVersion returns 0).
+ ///
+ public static bool IsValueGated(HistorianServiceInterface service) => service switch
+ {
+ HistorianServiceInterface.History => true,
+ HistorianServiceInterface.Retrieval => true,
+ HistorianServiceInterface.Transaction => true,
+ HistorianServiceInterface.Status => false,
+ _ => false
+ };
+
+ /// The interface version this SDK's serializers target for a value-gated service.
+ public static uint ExpectedVersion(HistorianServiceInterface service) => service switch
+ {
+ HistorianServiceInterface.History => HistoryInterfaceVersion,
+ HistorianServiceInterface.Retrieval => RetrievalInterfaceVersion,
+ HistorianServiceInterface.Transaction => TransactionInterfaceVersion,
+ _ => throw new ArgumentOutOfRangeException(nameof(service), service, "Service interface is not value-gated.")
+ };
+
+ ///
+ /// Throws when version verification is enabled
+ /// and the server's reported interface version differs from the version this SDK targets.
+ /// No-op when is
+ /// , when the service is not value-gated (Status), or on a match.
+ ///
+ public static void Validate(HistorianServiceInterface service, uint reportedVersion, HistorianClientOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ if (!options.VerifyServerInterfaceVersion || !IsValueGated(service))
+ {
+ return;
+ }
+
+ uint expected = ExpectedVersion(service);
+ if (reportedVersion == expected)
+ {
+ return;
+ }
+
+ throw new ProtocolEvidenceMissingException(
+ $"{service} interface version {reportedVersion} (this SDK's serializers target version {expected}); " +
+ $"set {nameof(HistorianClientOptions)}.{nameof(HistorianClientOptions.VerifyServerInterfaceVersion)}=false to bypass at your own risk");
+ }
+}
diff --git a/src/AVEVA.Historian.Client/Wcf/HistorianWcfAuthChainHelper.cs b/src/AVEVA.Historian.Client/Wcf/HistorianWcfAuthChainHelper.cs
index ed0a52e..5031b24 100644
--- a/src/AVEVA.Historian.Client/Wcf/HistorianWcfAuthChainHelper.cs
+++ b/src/AVEVA.Historian.Client/Wcf/HistorianWcfAuthChainHelper.cs
@@ -45,7 +45,8 @@ internal static class HistorianWcfAuthChainHelper
ICommunicationObject historyChannelCo = (ICommunicationObject)historyChannel;
try
{
- historyChannel.GetInterfaceVersion(out _);
+ historyChannel.GetInterfaceVersion(out uint historyVersion);
+ HistorianServerVersionGate.Validate(HistorianServiceInterface.History, historyVersion, options);
RunValClRounds(historyChannel, contextKey, options, cancellationToken);
byte[] open2Request = HistorianNativeHandshake.BuildOpenConnection3Request(options.Host, contextKey, connectionMode);
diff --git a/src/AVEVA.Historian.Client/Wcf/HistorianWcfReadOrchestrator.cs b/src/AVEVA.Historian.Client/Wcf/HistorianWcfReadOrchestrator.cs
index 7cb44be..2638cb6 100644
--- a/src/AVEVA.Historian.Client/Wcf/HistorianWcfReadOrchestrator.cs
+++ b/src/AVEVA.Historian.Client/Wcf/HistorianWcfReadOrchestrator.cs
@@ -187,7 +187,8 @@ internal sealed class HistorianWcfReadOrchestrator
ICommunicationObject retrievalChannelCo = (ICommunicationObject)retrievalChannel;
try
{
- retrievalChannel.GetInterfaceVersion(out _);
+ retrievalChannel.GetInterfaceVersion(out uint retrievalVersion);
+ HistorianServerVersionGate.Validate(HistorianServiceInterface.Retrieval, retrievalVersion, _options);
uint isAllowedReturn = retrievalChannel.IsOriginalAllowed(clientHandle, out bool isAllowed);
if (isAllowedReturn != 0 || !isAllowed)
@@ -289,7 +290,8 @@ internal sealed class HistorianWcfReadOrchestrator
ICommunicationObject retrievalChannelCo = (ICommunicationObject)retrievalChannel;
try
{
- retrievalChannel.GetInterfaceVersion(out _);
+ retrievalChannel.GetInterfaceVersion(out uint retrievalVersion);
+ HistorianServerVersionGate.Validate(HistorianServiceInterface.Retrieval, retrievalVersion, _options);
uint isAllowedReturn = retrievalChannel.IsOriginalAllowed(clientHandle, out bool isAllowed);
if (isAllowedReturn != 0 || !isAllowed)
diff --git a/tests/AVEVA.Historian.Client.Tests/HistorianServerVersionGateTests.cs b/tests/AVEVA.Historian.Client.Tests/HistorianServerVersionGateTests.cs
new file mode 100644
index 0000000..9c3a98e
--- /dev/null
+++ b/tests/AVEVA.Historian.Client.Tests/HistorianServerVersionGateTests.cs
@@ -0,0 +1,98 @@
+namespace AVEVA.Historian.Client.Tests;
+
+///
+/// Unit coverage for the R0.6 connect-time interface-version gate. The supported versions
+/// (History=11, Retrieval=4, Transaction=2) are evidence-based, captured from a live AVEVA
+/// Historian 2020 server via the reverse-engineering wcf-probe command.
+///
+public sealed class HistorianServerVersionGateTests
+{
+ private static HistorianClientOptions Options(bool verify = true) => new()
+ {
+ Host = "histserver",
+ IntegratedSecurity = true,
+ VerifyServerInterfaceVersion = verify
+ };
+
+ [Fact]
+ public void VerifyServerInterfaceVersion_DefaultsToTrue()
+ {
+ Assert.True(new HistorianClientOptions { Host = "h" }.VerifyServerInterfaceVersion);
+ }
+
+ [Fact]
+ public void Validate_MatchingVersion_DoesNotThrow()
+ {
+ // Each value-gated service accepts exactly the version this SDK targets.
+ HistorianServerVersionGate.Validate(HistorianServiceInterface.History, HistorianServerVersionGate.HistoryInterfaceVersion, Options());
+ HistorianServerVersionGate.Validate(HistorianServiceInterface.Retrieval, HistorianServerVersionGate.RetrievalInterfaceVersion, Options());
+ HistorianServerVersionGate.Validate(HistorianServiceInterface.Transaction, HistorianServerVersionGate.TransactionInterfaceVersion, Options());
+ }
+
+ [Fact]
+ public void Validate_MismatchedVersion_ThrowsProtocolEvidenceMissing()
+ {
+ // (service, wrong version) cases — one below and one above each expected value.
+ (HistorianServiceInterface Service, uint Version)[] cases =
+ [
+ (HistorianServiceInterface.History, 10u),
+ (HistorianServiceInterface.History, 12u),
+ (HistorianServiceInterface.Retrieval, 3u),
+ (HistorianServiceInterface.Retrieval, 5u),
+ (HistorianServiceInterface.Transaction, 1u),
+ ];
+
+ foreach ((HistorianServiceInterface service, uint version) in cases)
+ {
+ ProtocolEvidenceMissingException ex = Assert.Throws(
+ () => HistorianServerVersionGate.Validate(service, version, Options()));
+
+ // The message must name the reported version, the expected version, and the bypass knob.
+ Assert.Contains(version.ToString(System.Globalization.CultureInfo.InvariantCulture), ex.Operation);
+ Assert.Contains(HistorianServerVersionGate.ExpectedVersion(service).ToString(System.Globalization.CultureInfo.InvariantCulture), ex.Operation);
+ Assert.Contains(nameof(HistorianClientOptions.VerifyServerInterfaceVersion), ex.Operation);
+ }
+ }
+
+ [Fact]
+ public void Validate_VerificationDisabled_NeverThrows()
+ {
+ // A wildly wrong version is tolerated when the operator opts out (e.g. bringing up a
+ // 2023 R2 gRPC server whose reported integers have not yet been captured).
+ HistorianServerVersionGate.Validate(HistorianServiceInterface.History, 999u, Options(verify: false));
+ HistorianServerVersionGate.Validate(HistorianServiceInterface.Retrieval, 0u, Options(verify: false));
+ }
+
+ [Theory]
+ [InlineData(0u)]
+ [InlineData(7u)]
+ [InlineData(999u)]
+ public void Validate_StatusService_IsReachabilityOnly_NeverThrows(uint anyVersion)
+ {
+ // Status' GetInterfaceVersion returns 0 on the live server; it is not value-gated.
+ HistorianServerVersionGate.Validate(HistorianServiceInterface.Status, anyVersion, Options());
+ Assert.False(HistorianServerVersionGate.IsValueGated(HistorianServiceInterface.Status));
+ }
+
+ [Fact]
+ public void IsValueGated_HistoryRetrievalTransaction_AreGated()
+ {
+ Assert.True(HistorianServerVersionGate.IsValueGated(HistorianServiceInterface.History));
+ Assert.True(HistorianServerVersionGate.IsValueGated(HistorianServiceInterface.Retrieval));
+ Assert.True(HistorianServerVersionGate.IsValueGated(HistorianServiceInterface.Transaction));
+ }
+
+ [Fact]
+ public void ExpectedVersion_Status_Throws()
+ {
+ Assert.Throws(
+ () => HistorianServerVersionGate.ExpectedVersion(HistorianServiceInterface.Status));
+ }
+
+ [Fact]
+ public void Validate_NullOptions_Throws()
+ {
+ Assert.Throws(
+ () => HistorianServerVersionGate.Validate(HistorianServiceInterface.History, 11u, null!));
+ }
+}