R0.6: fail-closed server interface-version gate at connect

Turns the previously-discarded GetInterfaceVersion result into a connect-time
version pin. The native buffers carried in the WCF/MDAS body (and in the 2023 R2
gRPC bytes fields) are framed per native interface version; parsing them against
an unexpected version risks silent misinterpretation, so we throw rather than
best-effort parse.

- HistorianServerVersionGate + HistorianServiceInterface: evidence-based
  supported versions discovered from a live Historian 2020 server (product
  20.0.000) via the wcf-probe command — History=11, Retrieval=4, Transaction=2.
  Status' GetInterfaceVersion returns 0, so Status is reachability-only.
- HistorianClientOptions.VerifyServerInterfaceVersion (default true) — bypass
  knob for bringing up a server whose reported integers aren't yet captured
  (e.g. a 2023 R2 gRPC endpoint carrying the same proven 2020 buffers).
- Wired into both transports' connect paths: WCF history (auth-chain helper) +
  retrieval (read orchestrator), and gRPC history + retrieval.
- Mismatch throws ProtocolEvidenceMissingException naming reported/expected
  version and the bypass knob.

10 new unit tests (198 total green). Verified the gate does not regress the
proven WCF read path: a live read against the local 2020 server reaches past the
gate (Retr=4 matches) — the only live failures are a pre-existing environmental
read timeout (OperationCanceledException), identical with and without this change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-06-19 14:48:52 -04:00
parent a530ae0f10
commit 6b892b69ba
6 changed files with 218 additions and 6 deletions
@@ -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);