diff --git a/tests/AVEVA.Historian.Client.Tests/HandshakeReuseSpikeTests.cs b/tests/AVEVA.Historian.Client.Tests/HandshakeReuseSpikeTests.cs
new file mode 100644
index 0000000..0aba687
--- /dev/null
+++ b/tests/AVEVA.Historian.Client.Tests/HandshakeReuseSpikeTests.cs
@@ -0,0 +1,168 @@
+using System.Diagnostics;
+using AVEVA.Historian.Client.Grpc;
+using AVEVA.Historian.Client.Models;
+using Xunit.Abstractions;
+
+namespace AVEVA.Historian.Client.Tests;
+
+///
+/// SPIKE (pending.md A1): does the 2023 R2 historian honor REUSING one authenticated session across
+/// many operations — the precondition for handshake amortization? Env-gated exactly like
+/// (silent skip without HISTORIAN_GRPC_HOST +
+/// HISTORIAN_TEST_TAG + HISTORIAN_USER). READ-ONLY — no writes, safe against live data. Reuse
+/// validity is ASSERTED; latency numbers are LOGGED only (no flaky perf gates).
+///
+public sealed class HandshakeReuseSpikeTests
+{
+ private const int ReuseOps = 5;
+ private readonly ITestOutputHelper _output;
+
+ public HandshakeReuseSpikeTests(ITestOutputHelper output) => _output = output;
+
+ // (1) REUSE VALIDITY: one session, N reads on the same clientHandle. If the server rejects handle
+ // reuse, read #2+ throws -> RED finding. All succeed -> GREEN finding.
+ [Fact]
+ public void ReusedSession_RunsManyReads_AllSucceed()
+ {
+ if (!TryGetEnv(out string host, out string tag)) return;
+ HistorianClientOptions options = BuildOptions(host);
+ (DateTime startUtc, DateTime endUtc) = LastSevenDays();
+
+ using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options);
+
+ var swHandshake = Stopwatch.StartNew();
+ HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(connection, options, CancellationToken.None);
+ swHandshake.Stop();
+ _output.WriteLine($"open-session (handshake) = {swHandshake.ElapsedMilliseconds} ms");
+
+ var orchestrator = new HistorianGrpcReadOrchestrator(options);
+ for (int i = 0; i < ReuseOps; i++)
+ {
+ var sw = Stopwatch.StartNew();
+ List rows = orchestrator.RunRawQueryOnSession(
+ connection, session.ClientHandle, tag, startUtc, endUtc, maxValues: 8, CancellationToken.None);
+ sw.Stop();
+ _output.WriteLine($"reused-read[{i}] = {sw.ElapsedMilliseconds} ms, rows={rows.Count}");
+ Assert.NotEmpty(rows);
+ Assert.All(rows, r => Assert.Equal(tag, r.TagName));
+ }
+ }
+
+ // (2) WIN MAGNITUDE: full per-call path (fresh Create+handshake+query) vs amortized (one handshake,
+ // N reused reads). Logged only — quantifies whether amortization is worth the refactor.
+ [Fact]
+ public async Task ReusedSession_VsPerCallPath_LogsLatencyDelta()
+ {
+ if (!TryGetEnv(out string host, out string tag)) return;
+ HistorianClientOptions options = BuildOptions(host);
+ (DateTime startUtc, DateTime endUtc) = LastSevenDays();
+
+ HistorianClient client = new(options);
+ var swPerCall = Stopwatch.StartNew();
+ for (int i = 0; i < ReuseOps; i++)
+ {
+ await foreach (HistorianSample _ in client.ReadRawAsync(tag, startUtc, endUtc, 8, CancellationToken.None)) { }
+ }
+ swPerCall.Stop();
+
+ using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options);
+ HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(connection, options, CancellationToken.None);
+ var orchestrator = new HistorianGrpcReadOrchestrator(options);
+ var swAmortized = Stopwatch.StartNew();
+ for (int i = 0; i < ReuseOps; i++)
+ {
+ orchestrator.RunRawQueryOnSession(connection, session.ClientHandle, tag, startUtc, endUtc, 8, CancellationToken.None);
+ }
+ swAmortized.Stop();
+
+ _output.WriteLine($"per-call ({ReuseOps} ops) = {swPerCall.ElapsedMilliseconds} ms");
+ _output.WriteLine($"amortized ({ReuseOps} ops) = {swAmortized.ElapsedMilliseconds} ms");
+ _output.WriteLine($"saving over {ReuseOps} ops = {swPerCall.ElapsedMilliseconds - swAmortized.ElapsedMilliseconds} ms");
+ }
+
+ // (3) EXPIRY SWEEP: reuse after idle gaps. Default bounded [0s, 30s]; longer tiers opt-in via
+ // HISTORIAN_REUSE_IDLE_SECONDS="0,30,120,600". Rethrows at the tier where reuse first breaks.
+ [Fact]
+ public void ReusedSession_IdleSweep_SurfacesExpiryTier()
+ {
+ if (!TryGetEnv(out string host, out string tag)) return;
+ HistorianClientOptions options = BuildOptions(host);
+ (DateTime startUtc, DateTime endUtc) = LastSevenDays();
+
+ using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options);
+ HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(connection, options, CancellationToken.None);
+ var orchestrator = new HistorianGrpcReadOrchestrator(options);
+
+ foreach (int delaySec in ParseIdleSweep())
+ {
+ if (delaySec > 0) Thread.Sleep(TimeSpan.FromSeconds(delaySec));
+ try
+ {
+ List rows = orchestrator.RunRawQueryOnSession(
+ connection, session.ClientHandle, tag, startUtc, endUtc, 8, CancellationToken.None);
+ _output.WriteLine($"idle {delaySec,4}s -> OK (rows={rows.Count})");
+ }
+ catch (Exception ex)
+ {
+ _output.WriteLine($"idle {delaySec,4}s -> BROKE ({ex.GetType().Name})");
+ throw;
+ }
+ }
+ }
+
+ // --- helpers ---
+
+ private static bool TryGetEnv(out string host, out string tag)
+ {
+ host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST") ?? "";
+ tag = Environment.GetEnvironmentVariable("HISTORIAN_TEST_TAG") ?? "";
+ return !string.IsNullOrWhiteSpace(host)
+ && !string.IsNullOrWhiteSpace(tag)
+ && !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("HISTORIAN_USER"));
+ }
+
+ private static (DateTime StartUtc, DateTime EndUtc) LastSevenDays()
+ {
+ DateTime end = DateTime.UtcNow;
+ return (end - TimeSpan.FromDays(7), end);
+ }
+
+ private static int[] ParseIdleSweep()
+ {
+ string? raw = Environment.GetEnvironmentVariable("HISTORIAN_REUSE_IDLE_SECONDS");
+ if (string.IsNullOrWhiteSpace(raw)) return [0, 30];
+ return raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+ .Select(s => int.Parse(s, System.Globalization.CultureInfo.InvariantCulture)).ToArray();
+ }
+
+ private static HistorianClientOptions BuildOptions(string host)
+ {
+ string? user = Environment.GetEnvironmentVariable("HISTORIAN_USER");
+ string? password = Environment.GetEnvironmentVariable("HISTORIAN_PASSWORD");
+ bool explicitCreds = !string.IsNullOrEmpty(user);
+ int port = int.TryParse(Environment.GetEnvironmentVariable("HISTORIAN_GRPC_PORT"), out int parsed)
+ ? parsed
+ : HistorianClientOptions.DefaultGrpcPort;
+ bool tls = string.Equals(Environment.GetEnvironmentVariable("HISTORIAN_GRPC_TLS"), "true", StringComparison.OrdinalIgnoreCase);
+ // Optional per-call deadline override (seconds) for slow/remote boxes — heavier aggregate
+ // modes over a tunnelled link can exceed the 30s default. Falls back to the SDK default.
+ TimeSpan timeout = int.TryParse(Environment.GetEnvironmentVariable("HISTORIAN_GRPC_TIMEOUT"), out int secs) && secs > 0
+ ? TimeSpan.FromSeconds(secs)
+ : new HistorianClientOptions { Host = host }.RequestTimeout;
+
+ return new HistorianClientOptions
+ {
+ Host = host,
+ Port = port,
+ Transport = HistorianTransport.RemoteGrpc,
+ GrpcUseTls = tls,
+ AllowUntrustedServerCertificate = tls,
+ ServerDnsIdentity = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_DNSID"),
+ IntegratedSecurity = !explicitCreds,
+ UserName = user ?? string.Empty,
+ Password = password ?? string.Empty,
+ RequestTimeout = timeout,
+ Compression = true // the stock client always advertises grpc gzip request encoding
+ };
+ }
+}