gRPC 2023 R2: fix auth handshake op routing + accept History v12

First live-verified gRPC read against a real 2023 R2 Historian. The handshake
previously failed at round 0 (cred-independent) because the SSPI/Negotiate token
loop was routed to HistoryService.ExchangeKey. ExchangeKey is a separate
key-exchange/cert-path op, not the Negotiate loop — the token loop belongs on
StorageService.ValidateClientCredential, which kept the 2020 inBuff/outBuff token
framing the SDK's WrapValidateClientCredentialToken/TryRead helpers already build.

Captured + diffed against the recovered 2023 R2 protobuf contract and the
decompiled stock client; routing the loop to ValidateClientCredential completes
the full chain (ValidateClientCredential x N -> OpenConnection -> StartQuery ->
GetNextQueryResultBuffer) and returns rows.

- HistorianGrpcReadOrchestrator: token loop now calls
  StorageService.ValidateClientCredential(Handle, InBuff); corrected the op-map
  doc comment (was asserting the wrong ExchangeKey mapping).
- HistorianServerVersionGate: accept History interface version 12 alongside 11.
  Live server reports History=12, Retrieval=4, Storage=4; the buffers are
  byte-identical (a live read returns rows), so 12 is buffer-compatible. Retrieval
  stays pinned at 4 (matches). New AcceptedVersions() supports multi-version gates.
- New HistorianGrpcHandshakeRoutingTests: IL-level structural guardrail that
  disassembles the orchestrator (incl. lambda closures) and asserts the handshake
  invokes ValidateClientCredential and never ExchangeKey — fails if the regression
  returns.
- Updated gate tests + CLAUDE.md gRPC op-map.

240 unit tests pass on the full stack; 210 on this branch's base. The byte
payloads remain the proven 2020 protocol.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC
This commit is contained in:
Joseph Doherty
2026-06-21 12:34:04 -04:00
parent 362fcb0ef4
commit 22e9c5e5f8
6 changed files with 215 additions and 15 deletions
@@ -43,6 +43,15 @@ internal static class HistorianServerVersionGate
public const uint RetrievalInterfaceVersion = 4;
public const uint TransactionInterfaceVersion = 2;
/// <summary>
/// The 2023 R2 gRPC HistoryService reports interface version 12. It is buffer-compatible with
/// the 2020 version 11 — the OpenConnection3 v6 / token / DataQueryRequest / row buffers are
/// byte-identical — confirmed by a live end-to-end gRPC read against a real 2023 R2 server
/// (2026-06-21). So both 11 and 12 are accepted for History. (Retrieval reported 4, matching
/// the 2020 value, so it needs no widening.)
/// </summary>
public const uint HistoryInterfaceVersionGrpc2023R2 = 12;
/// <summary>
/// True when the service interface reports a meaningful version that should be matched.
/// Status is reachability-only (its <c>GetInterfaceVersion</c> returns 0).
@@ -56,7 +65,7 @@ internal static class HistorianServerVersionGate
_ => false
};
/// <summary>The interface version this SDK's serializers target for a value-gated service.</summary>
/// <summary>The canonical interface version this SDK's serializers target for a value-gated service.</summary>
public static uint ExpectedVersion(HistorianServiceInterface service) => service switch
{
HistorianServiceInterface.History => HistoryInterfaceVersion,
@@ -65,6 +74,18 @@ internal static class HistorianServerVersionGate
_ => throw new ArgumentOutOfRangeException(nameof(service), service, "Service interface is not value-gated.")
};
/// <summary>
/// All interface versions accepted for a value-gated service. Usually a single value, but
/// History accepts both the 2020 value (11) and the buffer-compatible 2023 R2 gRPC value (12).
/// </summary>
public static uint[] AcceptedVersions(HistorianServiceInterface service) => service switch
{
HistorianServiceInterface.History => [HistoryInterfaceVersion, HistoryInterfaceVersionGrpc2023R2],
HistorianServiceInterface.Retrieval => [RetrievalInterfaceVersion],
HistorianServiceInterface.Transaction => [TransactionInterfaceVersion],
_ => throw new ArgumentOutOfRangeException(nameof(service), service, "Service interface is not value-gated.")
};
/// <summary>
/// Throws <see cref="ProtocolEvidenceMissingException"/> when version verification is enabled
/// and the server's reported interface version differs from the version this SDK targets.
@@ -80,14 +101,15 @@ internal static class HistorianServerVersionGate
return;
}
uint expected = ExpectedVersion(service);
if (reportedVersion == expected)
uint[] accepted = AcceptedVersions(service);
if (Array.IndexOf(accepted, reportedVersion) >= 0)
{
return;
}
string acceptedList = string.Join(", ", accepted);
throw new ProtocolEvidenceMissingException(
$"{service} interface version {reportedVersion} (this SDK's serializers target version {expected}); " +
$"{service} interface version {reportedVersion} (this SDK's serializers target version {acceptedList}); " +
$"set {nameof(HistorianClientOptions)}.{nameof(HistorianClientOptions.VerifyServerInterfaceVersion)}=false to bypass at your own risk");
}
}