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
@@ -0,0 +1,93 @@
namespace AVEVA.Historian.Client;
/// <summary>
/// Identifies a versioned native Historian service interface whose reported interface
/// version is validated at connect time by <see cref="HistorianServerVersionGate"/>.
/// </summary>
internal enum HistorianServiceInterface
{
History,
Retrieval,
Status,
Transaction
}
/// <summary>
/// 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 <c>bytes</c> 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 <see cref="ProtocolEvidenceMissingException"/>
/// 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 <c>wcf-probe</c> command:
/// <list type="bullet">
/// <item>History (<c>Hist</c>) interface version = 11</item>
/// <item>Retrieval (<c>Retr</c>) interface version = 4</item>
/// <item>Transaction (<c>Trx</c>) interface version = 2</item>
/// </list>
/// The Status (<c>Stat</c>) service's <c>GetInterfaceVersion</c> 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 <see cref="HistorianClientOptions.VerifyServerInterfaceVersion"/> set to
/// <see langword="false"/>.
/// </summary>
internal static class HistorianServerVersionGate
{
public const uint HistoryInterfaceVersion = 11;
public const uint RetrievalInterfaceVersion = 4;
public const uint TransactionInterfaceVersion = 2;
/// <summary>
/// True when the service interface reports a meaningful version that should be matched.
/// Status is reachability-only (its <c>GetInterfaceVersion</c> returns 0).
/// </summary>
public static bool IsValueGated(HistorianServiceInterface service) => service switch
{
HistorianServiceInterface.History => true,
HistorianServiceInterface.Retrieval => true,
HistorianServiceInterface.Transaction => true,
HistorianServiceInterface.Status => false,
_ => false
};
/// <summary>The interface version this SDK's serializers target for a value-gated service.</summary>
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.")
};
/// <summary>
/// Throws <see cref="ProtocolEvidenceMissingException"/> when version verification is enabled
/// and the server's reported interface version differs from the version this SDK targets.
/// No-op when <see cref="HistorianClientOptions.VerifyServerInterfaceVersion"/> is
/// <see langword="false"/>, when the service is not value-gated (Status), or on a match.
/// </summary>
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");
}
}