From 43c25874983e00b5cb5709a531390ee7deb31dee Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 25 Jun 2026 14:38:27 -0400 Subject: [PATCH 01/15] feat(write): un-gate UInt1/Int8/UInt8 tag-create + historical-value encoders GetAnalogDataTypeCode gains UInt1=0x08/Int8=0x19/UInt8=0x39 (read-side MapDataType already maps these); EncodeNativeValue gains 1-byte UInt1 + 8-byte LE Int8/UInt8 (mirrors the captured Double value layout). Value API stays double; 2^53 exact-magnitude ceiling documented. Golden tests + live round-trip follow. Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii --- .../Wcf/HistorianHistoricalWriteProtocol.cs | 26 ++++++++++++++++--- .../Wcf/HistorianTagWriteProtocol.cs | 15 +++++++---- 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/src/AVEVA.Historian.Client/Wcf/HistorianHistoricalWriteProtocol.cs b/src/AVEVA.Historian.Client/Wcf/HistorianHistoricalWriteProtocol.cs index f485d0c..c252c48 100644 --- a/src/AVEVA.Historian.Client/Wcf/HistorianHistoricalWriteProtocol.cs +++ b/src/AVEVA.Historian.Client/Wcf/HistorianHistoricalWriteProtocol.cs @@ -28,10 +28,12 @@ namespace AVEVA.Historian.Client.Wcf; /// +0x26 UInt32 0 // value high dword (constant zero) /// +0x2A value bytes, native width by tag type: /// Float → Float32(4) · Double → Float64(8) · Int2 → Int16(2) -/// Int4 → Int32(4) · UInt4 → UInt32(4) +/// Int4 → Int32(4) · UInt4 → UInt32(4) · UInt1 → UInt8(1) +/// Int8 → Int64(8) · UInt8 → UInt64(8) /// /// -/// Captured for the five analog types EnsureTagAsync supports (Float/Double/Int2/Int4/UInt4). +/// Live-captured for Float/Double/Int2/Int4/UInt4; UInt1/Int8/UInt8 mirror the captured +/// Double value layout (8 LE bytes for Int8/UInt8, 1 byte for UInt1) pending live round-trip. /// Other tag types have no captured value encoding and are rejected. /// internal static class HistorianHistoricalWriteProtocol @@ -53,6 +55,8 @@ internal static class HistorianHistoricalWriteProtocol /// is the tag's declared analog type (selects the value width), and /// is the storage/received timestamp the orchestrator stamps. /// Throws for tag types without a captured encoding. + /// Int8/UInt8 carry exact magnitude only up to 2^53 — the value API is ; + /// full 64-bit range is a separate follow-on. /// public static byte[] SerializeAddStreamValuesBuffer( Guid tagGuid, @@ -117,10 +121,26 @@ internal static class HistorianHistoricalWriteProtocol BinaryPrimitives.WriteUInt32LittleEndian(b, checked((uint)value)); return b; } + case HistorianDataType.UInt1: + { + return [checked((byte)value)]; + } + case HistorianDataType.Int8: + { + byte[] b = new byte[8]; + BinaryPrimitives.WriteInt64LittleEndian(b, checked((long)value)); + return b; + } + case HistorianDataType.UInt8: + { + byte[] b = new byte[8]; + BinaryPrimitives.WriteUInt64LittleEndian(b, checked((ulong)value)); + return b; + } default: throw new ProtocolEvidenceMissingException( $"AddHistoricalValuesAsync has no captured value encoding for tag data type '{dataType}'. " + - "Captured types: Float, Double, Int2, Int4, UInt4."); + "Captured types: Float, Double, Int2, Int4, UInt4, UInt1, Int8, UInt8."); } } diff --git a/src/AVEVA.Historian.Client/Wcf/HistorianTagWriteProtocol.cs b/src/AVEVA.Historian.Client/Wcf/HistorianTagWriteProtocol.cs index a45faf6..f5e2aaa 100644 --- a/src/AVEVA.Historian.Client/Wcf/HistorianTagWriteProtocol.cs +++ b/src/AVEVA.Historian.Client/Wcf/HistorianTagWriteProtocol.cs @@ -58,9 +58,10 @@ internal static class HistorianTagWriteProtocol ]; /// - /// Native CDataType wire codes per data type — captured 2026-05-04 by probing - /// every type via instrument-wcf-writemessage. Matches the codes already documented - /// in MapDataType for the read path. + /// Native CDataType wire codes per data type — captured by probing every type via + /// instrument-wcf-writemessage. Matches the codes already documented in + /// MapDataType for the read path; UInt1/Int8/UInt8 + /// reuse the same read-side codes (0x08/0x19/0x39). /// public static byte GetAnalogDataTypeCode(Models.HistorianDataType dataType) => dataType switch { @@ -70,8 +71,11 @@ internal static class HistorianTagWriteProtocol Models.HistorianDataType.UInt4 => 0x11, Models.HistorianDataType.Int2 => 0x29, Models.HistorianDataType.Int4 => 0x31, + Models.HistorianDataType.UInt1 => 0x08, + Models.HistorianDataType.Int8 => 0x19, + Models.HistorianDataType.UInt8 => 0x39, _ => throw new ProtocolEvidenceMissingException( - $"EnsureTagAsync data type {dataType} has no captured CTagMetadata wire code; supported: Float, Double, UInt2, UInt4, Int2, Int4."), + $"EnsureTagAsync data type {dataType} has no captured CTagMetadata wire code; supported: Float, Double, UInt2, UInt4, Int2, Int4, UInt1, Int8, UInt8."), }; private static readonly byte[] AnalogPadding16 = new byte[16]; @@ -118,7 +122,8 @@ internal static class HistorianTagWriteProtocol /// /// Serializes a CTagMetadata payload for an analog tag. Live-verified for Float, - /// Double, Int2, Int4, UInt4 — see for the + /// Double, Int2, Int4, UInt4; UInt1/Int8/UInt8 supported via the read-side type + /// codes (pending live round-trip) — see for the /// type-code mapping. Output matches the byte-for-byte capture for the same inputs. /// When MinEU/MaxEU/MinRaw/MaxRaw are all defaults (0/100/0/100) emits the compact /// `1A 03` scaling marker; otherwise emits `1F` + 4 doubles in order. From 79bb1d9e06d951335164631db3e12d235db86da6 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 25 Jun 2026 14:49:21 -0400 Subject: [PATCH 02/15] test(write): golden-pin UInt1/Int8/UInt8 tag-create + value buffers New InlineData rows derived from the captured Double baselines (type-code / value bytes swapped). Negative-gate tests retained for still-gated types. Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii --- .../HistorianTagWriteProtocolTests.cs | 22 +++++++++++++++++++ .../WcfHistoricalWriteProtocolTests.cs | 7 ++++++ 2 files changed, 29 insertions(+) diff --git a/tests/AVEVA.Historian.Client.Tests/HistorianTagWriteProtocolTests.cs b/tests/AVEVA.Historian.Client.Tests/HistorianTagWriteProtocolTests.cs index bee045f..dba3736 100644 --- a/tests/AVEVA.Historian.Client.Tests/HistorianTagWriteProtocolTests.cs +++ b/tests/AVEVA.Historian.Client.Tests/HistorianTagWriteProtocolTests.cs @@ -62,6 +62,28 @@ public sealed class HistorianTagWriteProtocolTests + "09120052657465737453646B5772697465496E7432" + "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF" + "09180053444B2077726974652D52452073616E64626F78207461670904004D44415302010100000001E8030000549B0E36EDDBDC011A030904007465737410270000000000000000F03FFE00")] + // UInt1/Int8/UInt8 (H1 un-gate): prefix is the captured Double buffer; only the data-type code byte differs. + [InlineData( + AVEVA.Historian.Client.Models.HistorianDataType.Int8, + "RetestSdkWriteDouble", 0x01dcdbed24988f3aL, + "4E6703000100000004C6021900000000000000000000000000000000" + + "09140052657465737453646B5772697465446F75626C65" + + "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF" + + "09180053444B2077726974652D52452073616E64626F78207461670904004D44415302010100000001E80300003A8F9824EDDBDC011A030904007465737410270000000000000000F03FFE00")] + [InlineData( + AVEVA.Historian.Client.Models.HistorianDataType.UInt8, + "RetestSdkWriteDouble", 0x01dcdbed24988f3aL, + "4E6703000100000004C6023900000000000000000000000000000000" + + "09140052657465737453646B5772697465446F75626C65" + + "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF" + + "09180053444B2077726974652D52452073616E64626F78207461670904004D44415302010100000001E80300003A8F9824EDDBDC011A030904007465737410270000000000000000F03FFE00")] + [InlineData( + AVEVA.Historian.Client.Models.HistorianDataType.UInt1, + "RetestSdkWriteDouble", 0x01dcdbed24988f3aL, + "4E6703000100000004C6020800000000000000000000000000000000" + + "09140052657465737453646B5772697465446F75626C65" + + "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF" + + "09180053444B2077726974652D52452073616E64626F78207461670904004D44415302010100000001E80300003A8F9824EDDBDC011A030904007465737410270000000000000000F03FFE00")] public void SerializeAnalogCTagMetadata_PerDataType_MatchesCapturedNativeBytes( AVEVA.Historian.Client.Models.HistorianDataType dataType, string tagName, diff --git a/tests/AVEVA.Historian.Client.Tests/WcfHistoricalWriteProtocolTests.cs b/tests/AVEVA.Historian.Client.Tests/WcfHistoricalWriteProtocolTests.cs index 3e142d5..98b902c 100644 --- a/tests/AVEVA.Historian.Client.Tests/WcfHistoricalWriteProtocolTests.cs +++ b/tests/AVEVA.Historian.Client.Tests/WcfHistoricalWriteProtocolTests.cs @@ -21,6 +21,13 @@ public sealed class WcfHistoricalWriteProtocolTests "4f4e0100380000002e00" + "ca9735f7f841b244b56f9c14ccfeac32" + "b09bc72fd701dd01" + "c000" + "c0100100" + "104b59f3e701dd01" + "0000000039300000")] [InlineData(HistorianDataType.UInt4, 305419896d, "4f4e0100380000002e00" + "e7ae22d8e4cc65439ebd8bcb09402974" + "602d6663d701dd01" + "c000" + "c0100100" + "498af726e801dd01" + "0000000078563412")] + // UInt1/Int8/UInt8 (H1 un-gate): prefix is the captured Double buffer; only the data-type code / value bytes differ. Live round-trip confirms (gateway HistorianTypeRoundTripTests). + [InlineData(HistorianDataType.Int8, 100000d, + "4f4e01003c0000003200" + "7f970bb0b5a7344bb7bf6899ff06d027" + "c07d5f23d701dd01" + "c000" + "c0100100" + "08eff1e6e701dd01" + "00000000a086010000000000")] + [InlineData(HistorianDataType.UInt8, 100001d, + "4f4e01003c0000003200" + "7f970bb0b5a7344bb7bf6899ff06d027" + "c07d5f23d701dd01" + "c000" + "c0100100" + "08eff1e6e701dd01" + "00000000a186010000000000")] + [InlineData(HistorianDataType.UInt1, 7d, + "4f4e0100" + "35000000" + "2b00" + "7f970bb0b5a7344bb7bf6899ff06d027" + "c07d5f23d701dd01" + "c000" + "c0100100" + "08eff1e6e701dd01" + "00000000" + "07")] public void SerializeAddStreamValuesBuffer_MatchesCapturedNativeBuffer(HistorianDataType dataType, double value, string capturedHex) { byte[] captured = Convert.FromHexString(capturedHex); From dea9107b4b571d7c98585d44bf28b526f9d62585 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 25 Jun 2026 14:58:45 -0400 Subject: [PATCH 03/15] docs(re): capture + record 2023 R2 gRPC interface-version integers (C3a) Env-gated live evidence test reads History/Retrieval/Transaction/Status GetInterfaceVersion over gRPC; integers recorded in grpc-interface-versions.md. Stale not-yet-captured comment fixed; gate XML-doc notes live confirmation. Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii --- .../grpc-interface-versions.md | 44 ++++++++ .../HistorianServerVersionGate.cs | 8 +- .../GrpcInterfaceVersionEvidenceTests.cs | 102 ++++++++++++++++++ .../HistorianServerVersionGateTests.cs | 5 +- 4 files changed, 155 insertions(+), 4 deletions(-) create mode 100644 docs/reverse-engineering/grpc-interface-versions.md create mode 100644 tests/AVEVA.Historian.Client.Tests/GrpcInterfaceVersionEvidenceTests.cs diff --git a/docs/reverse-engineering/grpc-interface-versions.md b/docs/reverse-engineering/grpc-interface-versions.md new file mode 100644 index 0000000..5bf1b89 --- /dev/null +++ b/docs/reverse-engineering/grpc-interface-versions.md @@ -0,0 +1,44 @@ +# 2023 R2 gRPC Interface-Version Integers (C3a) + +**Captured:** 2026-06-25 +**Transport:** 2023 R2 gRPC (h2c, unauthenticated `GetInterfaceVersion` RPCs — no credentials required) +**Status:** LIVE — integers captured from a real AVEVA Historian 2023 R2 server. + +## Captured Values + +| Service | UiVersion / Version | UiError / Error | Notes | +|---------------|---------------------|-----------------|----------------------------------------------------| +| History | 12 | 0 | Matches `HistoryInterfaceVersionGrpc2023R2 = 12` | +| Retrieval | 4 | 0 | Matches `RetrievalInterfaceVersion = 4` (unchanged from 2020) | +| Transaction | 2 | 0 | Matches `TransactionInterfaceVersion = 2` (unchanged from 2020) | +| Status | 4 | 0 | Reachability-only; version integer is not version-gated (see note) | + +> **Field-name note:** The History, Retrieval, and Status proto responses use `UiError`/`UiVersion` fields. +> The Transaction response uses `Error`/`Version` (different naming convention in the proto). Both are +> captured correctly; the table uses a unified column header for readability. + +> **Status note:** `StatusService.GetStatusInterfaceVersion` returned UiVersion=4, UiError=0 on the live +> 2023 R2 server. Status is classified as reachability-only — its version integer carries no semantic +> meaning for the SDK's byte serializers — so its UiVersion is not gated and not asserted in tests. + +## Evidence Test + +`tests/AVEVA.Historian.Client.Tests/GrpcInterfaceVersionEvidenceTests.cs` — +`GrpcInterfaceVersions_LiveServer_MatchAcceptedSet` reads these four RPCs live and asserts: +- `history.UiError == 0` and `history.UiVersion ∈ {11, 12}` +- `retrieval.UiError == 0` and `retrieval.UiVersion == 4` +- `transaction.Error == 0` and `transaction.Version == 2` +- `status.UiError == 0` (version not asserted) + +The test skips silently when `HISTORIAN_GRPC_HOST` is absent. + +## Gap Closed + +This document closes the **C3a** gap: "2023 R2 gRPC server-version integers not yet captured." +Prior to this capture, the `HistorianServerVersionGate` accepted History=12, Retrieval=4, and +Transaction=2 on the basis that they were inferred/expected-to-be-unchanged. All four integers are +now confirmed from a live 2023 R2 server over the gRPC transport; no widening of `AcceptedVersions` +is required (all captured values were already accepted). + +The 2020 WCF baseline (History=11, Retrieval=4, Transaction=2) was captured earlier via the +`wcf-probe` command and is documented in `wcf-probe-remote-latest.json` and `wcf-contract-evidence.md`. diff --git a/src/AVEVA.Historian.Client/HistorianServerVersionGate.cs b/src/AVEVA.Historian.Client/HistorianServerVersionGate.cs index 96930be..cc69813 100644 --- a/src/AVEVA.Historian.Client/HistorianServerVersionGate.cs +++ b/src/AVEVA.Historian.Client/HistorianServerVersionGate.cs @@ -48,8 +48,12 @@ internal static class HistorianServerVersionGate /// 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.) + /// (2026-06-21). So both 11 and 12 are accepted for History. + /// + /// Retrieval=4, Transaction=2, and Status UiError=0 are now confirmed captured live over the + /// 2023 R2 gRPC transport (2026-06-25, unauthenticated GetInterfaceVersion RPCs); see + /// docs/reverse-engineering/grpc-interface-versions.md. All captured values were already + /// accepted — no widening of was required. /// public const uint HistoryInterfaceVersionGrpc2023R2 = 12; diff --git a/tests/AVEVA.Historian.Client.Tests/GrpcInterfaceVersionEvidenceTests.cs b/tests/AVEVA.Historian.Client.Tests/GrpcInterfaceVersionEvidenceTests.cs new file mode 100644 index 0000000..2d0d854 --- /dev/null +++ b/tests/AVEVA.Historian.Client.Tests/GrpcInterfaceVersionEvidenceTests.cs @@ -0,0 +1,102 @@ +using AVEVA.Historian.Client.Grpc; +using GrpcHistory = ArchestrA.Grpc.Contract.History; +using GrpcRetrieval = ArchestrA.Grpc.Contract.Retrieval; +using GrpcStatus = ArchestrA.Grpc.Contract.Status; +using GrpcTransaction = ArchestrA.Grpc.Contract.Transaction; +using Xunit.Abstractions; + +namespace AVEVA.Historian.Client.Tests; + +/// +/// Live evidence test (C3a): reads the four unauthenticated GetInterfaceVersion RPCs from a +/// real 2023 R2 Historian over gRPC and asserts the accepted version set. These RPCs run before any +/// credential exchange, so no HISTORIAN_USER / HISTORIAN_PASSWORD is required — only a reachable host. +/// +/// Skips silently when HISTORIAN_GRPC_HOST is absent (offline / CI). The captured integers +/// are recorded in docs/reverse-engineering/grpc-interface-versions.md and close the C3a +/// "2023 R2 gRPC server-version integers not yet captured" gap. +/// +public sealed class GrpcInterfaceVersionEvidenceTests +{ + private readonly ITestOutputHelper _output; + + public GrpcInterfaceVersionEvidenceTests(ITestOutputHelper output) => _output = output; + + [Fact] + public void GrpcInterfaceVersions_LiveServer_MatchAcceptedSet() + { + string host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST") ?? ""; + if (string.IsNullOrWhiteSpace(host)) + { + _output.WriteLine("SKIP: HISTORIAN_GRPC_HOST is not set — no live historian available."); + return; + } + + HistorianClientOptions options = BuildOptions(host); + + using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options); + DateTime deadline = DateTime.UtcNow.Add(TimeSpan.FromSeconds(10)); + + GrpcHistory.GetInterfaceVersionResponse history = + new GrpcHistory.HistoryService.HistoryServiceClient(connection.Channel) + .GetInterfaceVersion(new GrpcHistory.GetInterfaceVersionRequest(), connection.Metadata, deadline, default); + + GrpcRetrieval.GetRetrievalInterfaceVersionResponse retrieval = + new GrpcRetrieval.RetrievalService.RetrievalServiceClient(connection.Channel) + .GetRetrievalInterfaceVersion(new GrpcRetrieval.GetRetrievalInterfaceVersionRequest(), connection.Metadata, deadline, default); + + GrpcStatus.GetStatusInterfaceVersionResponse status = + new GrpcStatus.StatusService.StatusServiceClient(connection.Channel) + .GetStatusInterfaceVersion(new GrpcStatus.GetStatusInterfaceVersionRequest(), connection.Metadata, deadline, default); + + GrpcTransaction.GetTransactionInterfaceVersionResponse transaction = + new GrpcTransaction.TransactionService.TransactionServiceClient(connection.Channel) + .GetTransactionInterfaceVersion(new GrpcTransaction.GetTransactionInterfaceVersionRequest(), connection.Metadata, deadline, default); + + _output.WriteLine($"History UiVersion={history.UiVersion} UiError={history.UiError}"); + _output.WriteLine($"Retrieval UiVersion={retrieval.UiVersion} UiError={retrieval.UiError}"); + _output.WriteLine($"Status UiVersion={status.UiVersion} UiError={status.UiError}"); + // Note: Transaction response fields are named Error/Version (not UiError/UiVersion) per the proto. + _output.WriteLine($"Transaction Version={transaction.Version} Error={transaction.Error}"); + + // History: accepted set is {11 (2020 WCF), 12 (2023 R2 gRPC)}. + Assert.Equal(0u, history.UiError); + Assert.Contains(history.UiVersion, new uint[] { 11u, 12u }); + + // Retrieval: 4 on both 2020 WCF and 2023 R2 gRPC. + Assert.Equal(0u, retrieval.UiError); + Assert.Equal(4u, retrieval.UiVersion); + + // Transaction: 2 (confirmed by live gRPC capture — see grpc-interface-versions.md). + // NOTE: the Transaction response proto uses Error/Version (not UiError/UiVersion). + Assert.Equal(0u, transaction.Error); + Assert.Equal(2u, transaction.Version); + + // Status: reachability-only — UiError must be 0; UiVersion is intentionally 0 by design. + Assert.Equal(0u, status.UiError); + } + + 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); + + 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, + }; + } +} diff --git a/tests/AVEVA.Historian.Client.Tests/HistorianServerVersionGateTests.cs b/tests/AVEVA.Historian.Client.Tests/HistorianServerVersionGateTests.cs index cbc7539..61051ae 100644 --- a/tests/AVEVA.Historian.Client.Tests/HistorianServerVersionGateTests.cs +++ b/tests/AVEVA.Historian.Client.Tests/HistorianServerVersionGateTests.cs @@ -71,8 +71,9 @@ public sealed class HistorianServerVersionGateTests [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). + // A wildly wrong version is tolerated when the operator opts out. The 2023 R2 gRPC + // integers are now captured live (see docs/reverse-engineering/grpc-interface-versions.md) + // and all accepted; this opt-out is a safety valve for some future, not-yet-captured value. HistorianServerVersionGate.Validate(HistorianServiceInterface.History, 999u, Options(verify: false)); HistorianServerVersionGate.Validate(HistorianServiceInterface.Retrieval, 0u, Options(verify: false)); } From aa56d2d81be37152fd1b370ebfee1b8a6f3a7b70 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 25 Jun 2026 15:03:22 -0400 Subject: [PATCH 04/15] docs(re): correct Status interface-version comments (4 on gRPC, not 0) Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii --- docs/reverse-engineering/grpc-interface-versions.md | 5 +++-- src/AVEVA.Historian.Client/HistorianServerVersionGate.cs | 8 +++++--- .../GrpcInterfaceVersionEvidenceTests.cs | 3 ++- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/reverse-engineering/grpc-interface-versions.md b/docs/reverse-engineering/grpc-interface-versions.md index 5bf1b89..a2f91bb 100644 --- a/docs/reverse-engineering/grpc-interface-versions.md +++ b/docs/reverse-engineering/grpc-interface-versions.md @@ -18,8 +18,9 @@ > captured correctly; the table uses a unified column header for readability. > **Status note:** `StatusService.GetStatusInterfaceVersion` returned UiVersion=4, UiError=0 on the live -> 2023 R2 server. Status is classified as reachability-only — its version integer carries no semantic -> meaning for the SDK's byte serializers — so its UiVersion is not gated and not asserted in tests. +> 2023 R2 server. This differs from the historical 0 observed on 2020 WCF — both are reachability-only. +> Status is classified as reachability-only: its version integer carries no semantic meaning for the +> SDK's byte serializers, so its UiVersion is not gated and not asserted in tests. ## Evidence Test diff --git a/src/AVEVA.Historian.Client/HistorianServerVersionGate.cs b/src/AVEVA.Historian.Client/HistorianServerVersionGate.cs index cc69813..8e35bcd 100644 --- a/src/AVEVA.Historian.Client/HistorianServerVersionGate.cs +++ b/src/AVEVA.Historian.Client/HistorianServerVersionGate.cs @@ -29,8 +29,9 @@ internal enum HistorianServiceInterface /// 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. +/// The Status (Stat) service's GetInterfaceVersion is not a real version (0 on +/// 2020 WCF, 4 on 2023 R2 gRPC) — it carries no meaning for the byte serializers either way — so +/// the Status interface is validated for reachability only, never value. /// /// A 2023 R2 gRPC server reports History interface version 12 even though it carries the /// same proven 2020 native buffers. That value is captured and accepted (see @@ -59,7 +60,8 @@ internal static class HistorianServerVersionGate /// /// True when the service interface reports a meaningful version that should be matched. - /// Status is reachability-only (its GetInterfaceVersion returns 0). + /// Status is reachability-only (its GetInterfaceVersion is not a real version — + /// 0 on 2020 WCF, 4 on 2023 R2 gRPC). /// public static bool IsValueGated(HistorianServiceInterface service) => service switch { diff --git a/tests/AVEVA.Historian.Client.Tests/GrpcInterfaceVersionEvidenceTests.cs b/tests/AVEVA.Historian.Client.Tests/GrpcInterfaceVersionEvidenceTests.cs index 2d0d854..b90554a 100644 --- a/tests/AVEVA.Historian.Client.Tests/GrpcInterfaceVersionEvidenceTests.cs +++ b/tests/AVEVA.Historian.Client.Tests/GrpcInterfaceVersionEvidenceTests.cs @@ -72,7 +72,8 @@ public sealed class GrpcInterfaceVersionEvidenceTests Assert.Equal(0u, transaction.Error); Assert.Equal(2u, transaction.Version); - // Status: reachability-only — UiError must be 0; UiVersion is intentionally 0 by design. + // Status: reachability-only — assert UiError==0 only; UiVersion is not value-gated + // (observed 4 on 2023 R2 gRPC, 0 on 2020 WCF). Assert.Equal(0u, status.UiError); } From 95b924cdbe9348c26e170c31fd19bbdbd06e87ca Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 25 Jun 2026 15:27:04 -0400 Subject: [PATCH 05/15] =?UTF-8?q?fix(write):=20re-gate=20UInt1=20=E2=80=94?= =?UTF-8?q?=20historian=20creates=20degenerate=20UInt1=20analog=20tags?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live evidence: EnsureTags(UInt1) returns success but the server stores a degenerate tag (descriptor type byte 0x00, no GUID/name), so GetTagInfo truncates and writes fail. Not client-fixable on the analog path. Int8/UInt8 stay GREEN (live-proven). UInt1 reverts to ProtocolEvidenceMissingException (fail-closed); golden rows removed; negative-gate tests added. Removed the one-off capture diagnostic. Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii --- .../Wcf/HistorianHistoricalWriteProtocol.cs | 14 ++++++-------- .../Wcf/HistorianTagWriteProtocol.cs | 17 ++++++++++------- .../HistorianTagWriteProtocolTests.cs | 12 ++++-------- .../WcfHistoricalWriteProtocolTests.cs | 8 +++++--- 4 files changed, 25 insertions(+), 26 deletions(-) diff --git a/src/AVEVA.Historian.Client/Wcf/HistorianHistoricalWriteProtocol.cs b/src/AVEVA.Historian.Client/Wcf/HistorianHistoricalWriteProtocol.cs index c252c48..9fbbcef 100644 --- a/src/AVEVA.Historian.Client/Wcf/HistorianHistoricalWriteProtocol.cs +++ b/src/AVEVA.Historian.Client/Wcf/HistorianHistoricalWriteProtocol.cs @@ -32,8 +32,9 @@ namespace AVEVA.Historian.Client.Wcf; /// Int8 → Int64(8) · UInt8 → UInt64(8) /// /// -/// Live-captured for Float/Double/Int2/Int4/UInt4; UInt1/Int8/UInt8 mirror the captured -/// Double value layout (8 LE bytes for Int8/UInt8, 1 byte for UInt1) pending live round-trip. +/// Live-captured for Float/Double/Int2/Int4/UInt4; Int8/UInt8 mirror the captured +/// Double value layout (8 LE bytes) pending live round-trip. UInt1 is re-gated: the +/// historian accepts EnsureTags(UInt1) but stores a degenerate tag — writes would fail. /// Other tag types have no captured value encoding and are rejected. /// internal static class HistorianHistoricalWriteProtocol @@ -54,7 +55,8 @@ internal static class HistorianHistoricalWriteProtocol /// is the per-tag GUID (from the gRPC tag-info read), /// is the tag's declared analog type (selects the value width), and /// is the storage/received timestamp the orchestrator stamps. - /// Throws for tag types without a captured encoding. + /// Throws for tag types without a captured encoding + /// (including UInt1, which is re-gated — the historian creates a degenerate UInt1 analog tag). /// Int8/UInt8 carry exact magnitude only up to 2^53 — the value API is ; /// full 64-bit range is a separate follow-on. /// @@ -121,10 +123,6 @@ internal static class HistorianHistoricalWriteProtocol BinaryPrimitives.WriteUInt32LittleEndian(b, checked((uint)value)); return b; } - case HistorianDataType.UInt1: - { - return [checked((byte)value)]; - } case HistorianDataType.Int8: { byte[] b = new byte[8]; @@ -140,7 +138,7 @@ internal static class HistorianHistoricalWriteProtocol default: throw new ProtocolEvidenceMissingException( $"AddHistoricalValuesAsync has no captured value encoding for tag data type '{dataType}'. " + - "Captured types: Float, Double, Int2, Int4, UInt4, UInt1, Int8, UInt8."); + "Captured types: Float, Double, Int2, Int4, UInt4, Int8, UInt8."); } } diff --git a/src/AVEVA.Historian.Client/Wcf/HistorianTagWriteProtocol.cs b/src/AVEVA.Historian.Client/Wcf/HistorianTagWriteProtocol.cs index f5e2aaa..852d25d 100644 --- a/src/AVEVA.Historian.Client/Wcf/HistorianTagWriteProtocol.cs +++ b/src/AVEVA.Historian.Client/Wcf/HistorianTagWriteProtocol.cs @@ -60,8 +60,11 @@ internal static class HistorianTagWriteProtocol /// /// Native CDataType wire codes per data type — captured by probing every type via /// instrument-wcf-writemessage. Matches the codes already documented in - /// MapDataType for the read path; UInt1/Int8/UInt8 - /// reuse the same read-side codes (0x08/0x19/0x39). + /// MapDataType for the read path; Int8/UInt8 + /// reuse the same read-side codes (0x19/0x39). + /// UInt1 is excluded: live evidence shows the historian accepts EnsureTags(UInt1) + /// with success but stores a degenerate tag (null type descriptor) that subsequent + /// GetTagInfo cannot parse — not client-fixable on the analog path. /// public static byte GetAnalogDataTypeCode(Models.HistorianDataType dataType) => dataType switch { @@ -71,11 +74,10 @@ internal static class HistorianTagWriteProtocol Models.HistorianDataType.UInt4 => 0x11, Models.HistorianDataType.Int2 => 0x29, Models.HistorianDataType.Int4 => 0x31, - Models.HistorianDataType.UInt1 => 0x08, Models.HistorianDataType.Int8 => 0x19, Models.HistorianDataType.UInt8 => 0x39, _ => throw new ProtocolEvidenceMissingException( - $"EnsureTagAsync data type {dataType} has no captured CTagMetadata wire code; supported: Float, Double, UInt2, UInt4, Int2, Int4, UInt1, Int8, UInt8."), + $"EnsureTagAsync data type {dataType} has no captured CTagMetadata wire code; supported: Float, Double, UInt2, UInt4, Int2, Int4, Int8, UInt8."), }; private static readonly byte[] AnalogPadding16 = new byte[16]; @@ -122,9 +124,10 @@ internal static class HistorianTagWriteProtocol /// /// Serializes a CTagMetadata payload for an analog tag. Live-verified for Float, - /// Double, Int2, Int4, UInt4; UInt1/Int8/UInt8 supported via the read-side type - /// codes (pending live round-trip) — see for the - /// type-code mapping. Output matches the byte-for-byte capture for the same inputs. + /// Double, Int2, Int4, UInt4; Int8/UInt8 supported via the read-side type codes + /// (pending live round-trip); UInt1 re-gated (historian creates a degenerate tag) — + /// see for the type-code mapping. Output matches + /// the byte-for-byte capture for the same inputs. /// When MinEU/MaxEU/MinRaw/MaxRaw are all defaults (0/100/0/100) emits the compact /// `1A 03` scaling marker; otherwise emits `1F` + 4 doubles in order. /// diff --git a/tests/AVEVA.Historian.Client.Tests/HistorianTagWriteProtocolTests.cs b/tests/AVEVA.Historian.Client.Tests/HistorianTagWriteProtocolTests.cs index dba3736..141c7c0 100644 --- a/tests/AVEVA.Historian.Client.Tests/HistorianTagWriteProtocolTests.cs +++ b/tests/AVEVA.Historian.Client.Tests/HistorianTagWriteProtocolTests.cs @@ -62,7 +62,7 @@ public sealed class HistorianTagWriteProtocolTests + "09120052657465737453646B5772697465496E7432" + "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF" + "09180053444B2077726974652D52452073616E64626F78207461670904004D44415302010100000001E8030000549B0E36EDDBDC011A030904007465737410270000000000000000F03FFE00")] - // UInt1/Int8/UInt8 (H1 un-gate): prefix is the captured Double buffer; only the data-type code byte differs. + // Int8/UInt8 (H1 un-gate): prefix is the captured Double buffer; only the data-type code byte differs. [InlineData( AVEVA.Historian.Client.Models.HistorianDataType.Int8, "RetestSdkWriteDouble", 0x01dcdbed24988f3aL, @@ -77,13 +77,6 @@ public sealed class HistorianTagWriteProtocolTests + "09140052657465737453646B5772697465446F75626C65" + "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF" + "09180053444B2077726974652D52452073616E64626F78207461670904004D44415302010100000001E80300003A8F9824EDDBDC011A030904007465737410270000000000000000F03FFE00")] - [InlineData( - AVEVA.Historian.Client.Models.HistorianDataType.UInt1, - "RetestSdkWriteDouble", 0x01dcdbed24988f3aL, - "4E6703000100000004C6020800000000000000000000000000000000" - + "09140052657465737453646B5772697465446F75626C65" - + "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF" - + "09180053444B2077726974652D52452073616E64626F78207461670904004D44415302010100000001E80300003A8F9824EDDBDC011A030904007465737410270000000000000000F03FFE00")] public void SerializeAnalogCTagMetadata_PerDataType_MatchesCapturedNativeBytes( AVEVA.Historian.Client.Models.HistorianDataType dataType, string tagName, @@ -271,6 +264,9 @@ public sealed class HistorianTagWriteProtocolTests () => HistorianTagWriteProtocol.GetAnalogDataTypeCode(AVEVA.Historian.Client.Models.HistorianDataType.SingleByteString)); Assert.Throws( () => HistorianTagWriteProtocol.GetAnalogDataTypeCode(AVEVA.Historian.Client.Models.HistorianDataType.Int1)); + // UInt1 re-gated: historian creates a degenerate UInt1 analog tag (null type descriptor) — see pending notes. + Assert.Throws( + () => HistorianTagWriteProtocol.GetAnalogDataTypeCode(AVEVA.Historian.Client.Models.HistorianDataType.UInt1)); } [Fact] diff --git a/tests/AVEVA.Historian.Client.Tests/WcfHistoricalWriteProtocolTests.cs b/tests/AVEVA.Historian.Client.Tests/WcfHistoricalWriteProtocolTests.cs index 98b902c..0a68062 100644 --- a/tests/AVEVA.Historian.Client.Tests/WcfHistoricalWriteProtocolTests.cs +++ b/tests/AVEVA.Historian.Client.Tests/WcfHistoricalWriteProtocolTests.cs @@ -21,13 +21,11 @@ public sealed class WcfHistoricalWriteProtocolTests "4f4e0100380000002e00" + "ca9735f7f841b244b56f9c14ccfeac32" + "b09bc72fd701dd01" + "c000" + "c0100100" + "104b59f3e701dd01" + "0000000039300000")] [InlineData(HistorianDataType.UInt4, 305419896d, "4f4e0100380000002e00" + "e7ae22d8e4cc65439ebd8bcb09402974" + "602d6663d701dd01" + "c000" + "c0100100" + "498af726e801dd01" + "0000000078563412")] - // UInt1/Int8/UInt8 (H1 un-gate): prefix is the captured Double buffer; only the data-type code / value bytes differ. Live round-trip confirms (gateway HistorianTypeRoundTripTests). + // Int8/UInt8 (H1 un-gate): prefix is the captured Double buffer; only the value bytes differ. Live round-trip confirms (gateway HistorianTypeRoundTripTests). [InlineData(HistorianDataType.Int8, 100000d, "4f4e01003c0000003200" + "7f970bb0b5a7344bb7bf6899ff06d027" + "c07d5f23d701dd01" + "c000" + "c0100100" + "08eff1e6e701dd01" + "00000000a086010000000000")] [InlineData(HistorianDataType.UInt8, 100001d, "4f4e01003c0000003200" + "7f970bb0b5a7344bb7bf6899ff06d027" + "c07d5f23d701dd01" + "c000" + "c0100100" + "08eff1e6e701dd01" + "00000000a186010000000000")] - [InlineData(HistorianDataType.UInt1, 7d, - "4f4e0100" + "35000000" + "2b00" + "7f970bb0b5a7344bb7bf6899ff06d027" + "c07d5f23d701dd01" + "c000" + "c0100100" + "08eff1e6e701dd01" + "00000000" + "07")] public void SerializeAddStreamValuesBuffer_MatchesCapturedNativeBuffer(HistorianDataType dataType, double value, string capturedHex) { byte[] captured = Convert.FromHexString(capturedHex); @@ -54,6 +52,10 @@ public sealed class WcfHistoricalWriteProtocolTests Assert.Throws(() => HistorianHistoricalWriteProtocol.SerializeAddStreamValuesBuffer( Guid.NewGuid(), HistorianDataType.SingleByteString, DateTime.UtcNow, 1.0, DateTime.UtcNow)); + // UInt1 re-gated: historian creates a degenerate UInt1 analog tag (null type descriptor) — see pending notes. + Assert.Throws(() => + HistorianHistoricalWriteProtocol.SerializeAddStreamValuesBuffer( + Guid.NewGuid(), HistorianDataType.UInt1, DateTime.UtcNow, 1.0, DateTime.UtcNow)); } [Fact] From aa8ca2f6ad011d8cd07e881e57869f910449ab49 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 25 Jun 2026 15:34:34 -0400 Subject: [PATCH 06/15] docs: Int8/UInt8 analog writes supported (live-proven); UInt1 server-degenerate EnsureTagAsync + AddHistoricalValues now cover Int8/UInt8 (codes 0x19/0x39, native LE int64/uint64 value). UInt1 documented as not-supported: the server stores a degenerate analog tag (GetTagInfo stub, type byte 0x00). Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii --- CLAUDE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 943d032..42d037c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,9 +18,9 @@ Reads (the original required surface, all working live as of 2026-05-04): Writes (added 2026-05-04 by explicit user request — do not extend further without one): -- `EnsureTagAsync` for analog types: Float, Double, Int2, Int4, UInt4 (live-verified end-to-end). Other types (SingleByteString/DoubleByteString/Int1/Int8/UInt8) fail at native AddTag — likely require a different path and are intentionally not supported. `MinEU`/`MaxEU`/`MinRaw`/`MaxRaw` all round-trip into the DB. By default `ApplyScaling=false` and the server mirrors MinRaw→MinEU and sets `AnalogTag.Scaling=0`; set `ApplyScaling=true` on the definition to persist distinct raw bounds with `AnalogTag.Scaling=1`. The wire encoding is the trailer's second byte (`FE 00` vs `FE 01`). +- `EnsureTagAsync` for analog types: Float, Double, Int2, Int4, UInt4, **Int8, UInt8** (all live-verified end-to-end; `Int8`/`UInt8` added 2026-06-25 — same analog `CTagMetadata` layout, type codes `0x19`/`0x39`). **`UInt1` is NOT supported**: the server accepts `EnsureTags(UInt1)` but stores a *degenerate* analog tag (`GetTagInfosFromName` returns a 31-byte stub — descriptor type byte `0x00`, no GUID), so the write fails on the `GetTagInfo` path; re-gated fail-closed. SingleByteString/DoubleByteString and special/event types require a different (non-analog) path and are intentionally not supported. `MinEU`/`MaxEU`/`MinRaw`/`MaxRaw` all round-trip into the DB. By default `ApplyScaling=false` and the server mirrors MinRaw→MinEU and sets `AnalogTag.Scaling=0`; set `ApplyScaling=true` on the definition to persist distinct raw bounds with `AnalogTag.Scaling=1`. The wire encoding is the trailer's second byte (`FE 00` vs `FE 01`). - `DeleteTagAsync` -- `AddHistoricalValuesAsync` (added 2026-06-21 by explicit user request — M3 historical/backfill writes). **gRPC-only** (`HistorianTransport.RemoteGrpc`); non-gRPC transports throw `ProtocolEvidenceMissingException`. Reverse-engineered by capturing the native 2023 R2 client: the historical write rides `HistoryService.AddStreamValues` with an "ON" storage-sample buffer (`HistorianHistoricalWriteProtocol`, golden-tested), NOT the TransactionService `AddNonStreamValues` path the static decompile suggested. Orchestrator (`HistorianGrpcHistoricalWriteOrchestrator`): write-enabled session → `GetTagInfosFromName` (resolves the per-tag GUID = the tag-info `TypeId`, and maps the data type via `MapDataType`) → `AddStreamValues`. Tag must pre-exist (`EnsureTagAsync`). Supports all five analog types `EnsureTagAsync` does — **Float, Double, Int2, Int4, UInt4** (all captured live + golden-tested + write/read-back validated). The 4-byte value descriptor is constant (`C0 10 01 00`); the value is `u32(0) + native-width value` (float32 / double64 / int16 / int32 / uint32) selected by the tag's declared type. Other tag types throw `ProtocolEvidenceMissingException`. Live-validated end-to-end against the 2023 R2 server. The D2/`AddS2` cache gate (err 129) does NOT block the primed 2023 R2 client. See `docs/plans/revision-write-path.md` §"R3.1 CAPTURED". +- `AddHistoricalValuesAsync` (added 2026-06-21 by explicit user request — M3 historical/backfill writes). **gRPC-only** (`HistorianTransport.RemoteGrpc`); non-gRPC transports throw `ProtocolEvidenceMissingException`. Reverse-engineered by capturing the native 2023 R2 client: the historical write rides `HistoryService.AddStreamValues` with an "ON" storage-sample buffer (`HistorianHistoricalWriteProtocol`, golden-tested), NOT the TransactionService `AddNonStreamValues` path the static decompile suggested. Orchestrator (`HistorianGrpcHistoricalWriteOrchestrator`): write-enabled session → `GetTagInfosFromName` (resolves the per-tag GUID = the tag-info `TypeId`, and maps the data type via `MapDataType`) → `AddStreamValues`. Tag must pre-exist (`EnsureTagAsync`). Supports the analog types `EnsureTagAsync` does — **Float, Double, Int2, Int4, UInt4, Int8, UInt8** (all captured live + golden-tested + write/read-back validated; `Int8`/`UInt8` added 2026-06-25, value = native-width LE int64/uint64). The 4-byte value descriptor is constant (`C0 10 01 00`); the value is `u32(0) + native-width value` (float32 / double64 / int16 / int32 / uint32 / int64 / uint64) selected by the tag's declared type. Other tag types throw `ProtocolEvidenceMissingException` (incl. `UInt1` — server-degenerate, see `EnsureTagAsync` above). Live-validated end-to-end against the 2023 R2 server. The D2/`AddS2` cache gate (err 129) does NOT block the primed 2023 R2 client. See `docs/plans/revision-write-path.md` §"R3.1 CAPTURED". - `SendEventAsync` (M2 event-send; added by explicit user request). Appends a single `HistorianEvent` to the built-in `CM_EVENT` tag, readable back via `ReadEventsAsync` / `v_AlarmEventHistory2`. Works on **both transports**, routed by `HistorianClientOptions.Transport`: WCF runs Open2 event-mode (`0x501`) → CM_EVENT registration (RTag2 + EnsT2) → `AddS2` (`AddStreamValues2`); gRPC (`RemoteGrpc`, `HistorianGrpcEventWriteOrchestrator`, added 2026-06-23) runs the v8 Event `OpenConnection` (ExchangeKey ECDH) → CM_EVENT registration → `HistoryService.AddStreamValues`. **Both carry the same `"OS"` (0x534F) event VTQ buffer** (`HistorianEventWriteProtocol`, the managed `PackToVtq` equivalent) — there is NO distinct event-send RPC and it is NOT the historical write's `"ON"` buffer (captured live from the native 2023 R2 client; the write-enabled Event open is byte-identical to the read-only one). The gRPC path is **live-validated end-to-end** (send → `BSuccess` → event reads back from the server). Only **original events** (`RevisionVersion = 0`) with **string-valued properties** have a captured encoding; revision/update/delete events and non-string property values throw `ProtocolEvidenceMissingException`. Registration buffers are golden-tested against the live capture (`GrpcEventSendProtocolTests`); gated live test `SendEventAsync_OverGrpc_AcceptsEvent` (opt-in `HISTORIAN_GRPC_EVENT_SEND=1`). `AddS2` (streaming process-sample writes for user tags) remains architecturally blocked — the server cache only ingests from configured IOServers/ApplicationServer pipelines. Do not add streaming write-samples support. (`AddHistoricalValuesAsync` is the distinct *non-streamed original/backfill* path and is supported.) From 100b44a36541ce34195371844eb31f720a3f4eaf Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 25 Jun 2026 15:42:50 -0400 Subject: [PATCH 07/15] docs(write): drop UInt1 from value-layout table; mark Int8/UInt8 live-proven MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the stale UInt1 → UInt8(1) entry from the EncodeNativeValue layout table (UInt1 is re-gated; the prose already said so). Int8/UInt8 layout note updated from "pending" to live-proven. Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii --- .../Wcf/HistorianHistoricalWriteProtocol.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/AVEVA.Historian.Client/Wcf/HistorianHistoricalWriteProtocol.cs b/src/AVEVA.Historian.Client/Wcf/HistorianHistoricalWriteProtocol.cs index 9fbbcef..b5639be 100644 --- a/src/AVEVA.Historian.Client/Wcf/HistorianHistoricalWriteProtocol.cs +++ b/src/AVEVA.Historian.Client/Wcf/HistorianHistoricalWriteProtocol.cs @@ -28,13 +28,13 @@ namespace AVEVA.Historian.Client.Wcf; /// +0x26 UInt32 0 // value high dword (constant zero) /// +0x2A value bytes, native width by tag type: /// Float → Float32(4) · Double → Float64(8) · Int2 → Int16(2) -/// Int4 → Int32(4) · UInt4 → UInt32(4) · UInt1 → UInt8(1) +/// Int4 → Int32(4) · UInt4 → UInt32(4) /// Int8 → Int64(8) · UInt8 → UInt64(8) /// /// /// Live-captured for Float/Double/Int2/Int4/UInt4; Int8/UInt8 mirror the captured -/// Double value layout (8 LE bytes) pending live round-trip. UInt1 is re-gated: the -/// historian accepts EnsureTags(UInt1) but stores a degenerate tag — writes would fail. +/// Double value layout (8 LE bytes) and are live-proven (2026-06-25). UInt1 is re-gated: +/// the historian accepts EnsureTags(UInt1) but stores a degenerate tag — writes would fail. /// Other tag types have no captured value encoding and are rejected. /// internal static class HistorianHistoricalWriteProtocol From 85f3bd4b4e6ae24b7b9a6fe296dec291d529bc34 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 25 Jun 2026 16:37:15 -0400 Subject: [PATCH 08/15] test(c2): env-gated WCF event-read diagnostic spike (RemoteTcpIntegrated) Drives HistorianWcfEventOrchestrator over RemoteTcpIntegrated and dumps the native chain (UpdC3/RTag2/EnsT2 return codes, result-buffer length, row count) to settle whether WCF event reads return rows on an event-bearing 2023 R2 box. Windows-only, gated by HISTORIAN_WCF_EVENT_HOST, never fails the suite, inert off Windows. Sanitized: counts + return codes + buffer lengths + sha256 only. Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii --- .../WcfEventReadSpikeTests.cs | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 tests/AVEVA.Historian.Client.Tests/WcfEventReadSpikeTests.cs diff --git a/tests/AVEVA.Historian.Client.Tests/WcfEventReadSpikeTests.cs b/tests/AVEVA.Historian.Client.Tests/WcfEventReadSpikeTests.cs new file mode 100644 index 0000000..3db9017 --- /dev/null +++ b/tests/AVEVA.Historian.Client.Tests/WcfEventReadSpikeTests.cs @@ -0,0 +1,105 @@ +using System.Runtime.Versioning; +using AVEVA.Historian.Client.Models; +using AVEVA.Historian.Client.Wcf; +using Xunit.Abstractions; + +namespace AVEVA.Historian.Client.Tests; + +/// +/// C2 live diagnostic spike (pending.md C2): does the managed WCF event-read path return rows +/// against an event-bearing 2023 R2 historian? gRPC event reads are a proven server-side dead-end +/// (docs/reverse-engineering/grpc-event-query-capture.md); the WCF transport is C2's only listed +/// unblock but is itself unproven (the orchestrator documents native code 76 / 85 on this server). +/// +/// Drives directly over RemoteTcpIntegrated and dumps the +/// full native chain. It NEVER fails the suite (skip+log diagnostic) and is inert off Windows — the +/// GREEN/RED call is by reading the printed "events observed" count + native return codes. +/// +/// Gated by HISTORIAN_WCF_EVENT_HOST (independent of the gRPC live vars so it never runs by +/// accident). Optional: HISTORIAN_WCF_EVENT_PORT (default 32568), HISTORIAN_WCF_EVENT_USER +/// + HISTORIAN_WCF_EVENT_PASSWORD (absent => IntegratedSecurity), HISTORIAN_WCF_EVENT_SPN +/// (Kerberos SPN override; the default is the LocalPipe identity and will not authenticate remotely), +/// HISTORIAN_WCF_EVENT_DAYS (lookback window, default 90 — the live event store held 71,332 +/// events in -90d). +/// +/// Run from the Windows capture rig over VPN: +/// dotnet test --filter "FullyQualifiedName~WcfEventReadSpike" -l "console;verbosity=detailed" +/// +[SupportedOSPlatform("windows")] +public sealed class WcfEventReadSpikeTests +{ + private readonly ITestOutputHelper _output; + + public WcfEventReadSpikeTests(ITestOutputHelper output) => _output = output; + + [Fact] + public async Task WcfEventRead_DiagnosticDump_AgainstRemoteHistorian() + { + string? host = Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_HOST"); + if (string.IsNullOrWhiteSpace(host) || !OperatingSystem.IsWindows()) + { + return; // inert without the dedicated gate / off Windows + } + + int port = int.TryParse(Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_PORT"), out int p) + ? p : HistorianClientOptions.DefaultPort; // 32568 + int days = int.TryParse(Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_DAYS"), out int d) + ? d : 90; + string? user = Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_USER"); + string? password = Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_PASSWORD"); + string? spn = Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_SPN"); + bool integrated = string.IsNullOrEmpty(user); + + HistorianClientOptions options = new() + { + Host = host, + Port = port, + Transport = HistorianTransport.RemoteTcpIntegrated, + IntegratedSecurity = integrated, + UserName = user ?? string.Empty, + Password = password ?? string.Empty, + TargetSpn = string.IsNullOrWhiteSpace(spn) ? "NT SERVICE\\aahClientAccessPoint" : spn, + }; + + HistorianWcfEventOrchestrator orchestrator = new(options); + DateTime endUtc = DateTime.UtcNow; + DateTime startUtc = endUtc - TimeSpan.FromDays(days); + + int observed = 0; + bool hasFirstEvent = false; + string outcome = "completed"; + try + { + await foreach (HistorianEvent evt in orchestrator.ReadEventsAsync(startUtc, endUtc, filter: null, CancellationToken.None)) + { + observed++; + hasFirstEvent = true; + _ = evt; // event identity intentionally NOT logged (sanitized) + } + } + catch (Exception ex) + { + outcome = $"threw {ex.GetType().Name}"; // message omitted — may carry host/credential text + } + + // Sanitized diagnostic dump — counts, native return codes, buffer lengths, sha256 ONLY. + _output.WriteLine($"[C2 WCF spike] outcome: {outcome}"); + _output.WriteLine($"[C2 WCF spike] events observed: {observed}"); + _output.WriteLine($"[C2 WCF spike] hasFirstEvent: {hasFirstEvent}"); + _output.WriteLine($"[C2 WCF spike] LastUpdC3ReturnCode: {HistorianWcfEventOrchestrator.LastUpdC3ReturnCode}"); + _output.WriteLine($"[C2 WCF spike] LastRTag2ReturnCode: {HistorianWcfEventOrchestrator.LastRTag2ReturnCode}"); + _output.WriteLine($"[C2 WCF spike] LastAddReturnCode(EnsT2): {HistorianWcfEventOrchestrator.LastAddReturnCode}"); + _output.WriteLine($"[C2 WCF spike] LastAddOutputLength: {HistorianWcfEventOrchestrator.LastAddOutputLength}"); + _output.WriteLine($"[C2 WCF spike] LastEnsT2PayloadSha256: {HistorianWcfEventOrchestrator.LastEnsT2PayloadSha256}"); + _output.WriteLine($"[C2 WCF spike] LastResultBufferLength: {orchestrator.LastResultBufferLength}"); + // Contract-safe: LastErrorBufferDescription is DescribeNativeError's STRUCTURED formatting of the + // 5-byte native error buffer ("type=N code=M (0xHEX)" / "") — never freeform server text, + // FQDN, SPN, or credentials. The code value (e.g. 76 / 85) is the RED-case signal this spike exists + // to capture, so it is dumped in full rather than reduced to a length. + _output.WriteLine($"[C2 WCF spike] LastErrorBufferDescription: {orchestrator.LastErrorBufferDescription}"); + _output.WriteLine($"[C2 WCF spike] window days: {days}"); + + // Diagnostic: NEVER fails the suite. GREEN = observed > 0; RED = observed == 0 (read the output). + Assert.True(observed >= 0, "diagnostic always passes; the GREEN/RED signal is the printed count"); + } +} From f1c57f714961f36e817d50b60ab9e2da5f472b38 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 25 Jun 2026 16:58:56 -0400 Subject: [PATCH 09/15] =?UTF-8?q?docs(c2):=20record=20WCF=20event-read=20s?= =?UTF-8?q?pike=20live=20result=20(RED=20=E2=80=94=20transport=20not=20ser?= =?UTF-8?q?ved=20on=202023=20R2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WCF net.tcp (RemoteTcpIntegrated) against the live 2023 R2 historian is reset at the socket-write/framing layer before any auth — both the event spike and a basic Probe/ReadRaw throw the identical CommunicationException/SocketException ("forcibly closed by the remote host"). The 2023 R2 box does not serve the legacy WCF transport; C2's "route via WCF" unblock is moot on this server class. Sanitized: counts + native return codes + buffer lengths only. Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii --- .../wcf-event-read-spike-results.md | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 docs/reverse-engineering/wcf-event-read-spike-results.md diff --git a/docs/reverse-engineering/wcf-event-read-spike-results.md b/docs/reverse-engineering/wcf-event-read-spike-results.md new file mode 100644 index 0000000..16c610d --- /dev/null +++ b/docs/reverse-engineering/wcf-event-read-spike-results.md @@ -0,0 +1,63 @@ +# WCF event-read spike — live result (2026-06-25): WCF transport not served on 2023 R2 + +Settles the open question behind **C2** ("event reads over gRPC are gated; the only listed unblock is +*route event reads via WCF*"). The gRPC event-read path is a proven server-side dead-end +(`grpc-event-query-capture.md`: auth fully solved, every client-controllable layer byte-matched to the +stock client, yet the server scopes 0 rows to our connection). This spike tested the **WCF** leg. + +## What was run + +A Windows-only, env-gated diagnostic (`tests/AVEVA.Historian.Client.Tests/WcfEventReadSpikeTests.cs`, +gated by `HISTORIAN_WCF_EVENT_HOST`) drove `HistorianWcfEventOrchestrator.ReadEventsAsync` directly +over `RemoteTcpIntegrated` (WCF `net.tcp`, port 32568) against the **live 2023 R2 historian**, with a +−90d window (the engine holds tens of thousands of events in that range), run from the native Windows +capture rig over VPN. Auth supplied as explicit domain credentials (consumed by the app-level +`ValidateClientCredential` SSPI rounds). + +## Result — RED (transport not served), sanitized + +Event spike: + +| field | value | +|---|---| +| outcome | `THREW System.ServiceModel.CommunicationException` ("The socket connection was aborted") | +| inner | `System.Net.Sockets.SocketException` — "An existing connection was forcibly closed by the remote host" | +| events observed | 0 | +| LastUpdC3ReturnCode / LastRTag2ReturnCode / LastAddReturnCode(EnsT2) | 0 / 0 / 0 | +| LastEnsT2PayloadSha256 | empty | +| LastResultBufferLength | 0 | + +All native return codes are `0` and the EnsT2 payload sha256 is empty: the chain failed at the **first +WCF call** (`GetInterfaceVersion`), *before* any auth token round or CM_EVENT registration ran. + +Corroboration — a basic (non-event) `RemoteTcpIntegrated` `ProbeAsync` + `ReadRawAsync` (the committed +`RemoteTcpIntegrationTests`) throws the **identical** exception, with the stack landing in +`System.ServiceModel.Channels.SocketConnection.WriteAsync` — i.e. the failure is **transport-wide**, not +event-specific, and not auth-specific (it never reaches auth). + +Phase 0 (reachability) had confirmed TCP 32568 is **open** (the connect succeeds). So the port accepts a +socket, but the moment the SDK writes its `net.tcp` binary-SOAP framing the server **resets the +connection** (RST at the socket-write layer). + +## Conclusion + +The **2023 R2 historian does not serve the legacy WCF NetTcp transport.** A raw RST at the first socket +write — before any security negotiation, SOAP fault, or auth exchange — is the signature of a listener +that does not speak `net.tcp` binary SOAP, not of an auth/SPN problem or event-row scoping. (The earlier +WCF event-chain native return codes 76/85 documented in `HistorianWcfEventOrchestrator` were only ever +observed against a **2020** historian; against 2023 R2 there is no WCF endpoint to reach at all.) + +Therefore **C2's "route event reads via WCF" unblock is moot on 2023 R2** — there is no WCF endpoint to +route to. Event reads are unavailable on the 2023 R2 historian over **both** transports: + +- **gRPC** — auth-solved but retrieval-server-gated (server scopes 0 rows to our connection; + `grpc-event-query-capture.md`). +- **WCF (`net.tcp`)** — transport not served on 2023 R2 (connection reset at framing). + +The WCF event-read managed path would only ever apply to a legacy **2020** historian, which the gateway +does not target (the gateway runs `RemoteGrpc` against 2023 R2). The only remaining theoretical unblock +is server-side (AVEVA exposing event-row retrieval to a managed gRPC connection) — not client-fixable. + +**C2 is closed won't-fix** for the gateway's target (2023 R2). `ReadEventsAsync` over gRPC keeps its +honest no-row throw; the gating messages are corrected so they no longer point operators at the WCF +transport as a live fallback on 2023 R2. From 64c9793b91ed757d2a1ab3c81f9c547d325002c2 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 25 Jun 2026 17:00:50 -0400 Subject: [PATCH 10/15] =?UTF-8?q?docs(c2):=20correct=20event-read=20gating?= =?UTF-8?q?=20messages=20=E2=80=94=20WCF=20not=20served=20on=202023=20R2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gRPC ReadEvents throws no longer advise "use the WCF transport for event reads": that path is moot on 2023 R2 (net.tcp is reset at the framing layer, live-disproven 2026-06-25). Messages now state event reads are auth-solved but server-gated over gRPC and have no WCF fallback on 2023 R2, citing the two evidence docs. WCF orchestrator remarks scoped to legacy 2020 historians; row layout noted as decoded. String/comment only; throw behavior unchanged. Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii --- .../Grpc/HistorianGrpcEventOrchestrator.cs | 38 +++++++++++-------- .../Wcf/HistorianWcfEventOrchestrator.cs | 10 +++-- 2 files changed, 30 insertions(+), 18 deletions(-) diff --git a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcEventOrchestrator.cs b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcEventOrchestrator.cs index 1720728..74e8568 100644 --- a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcEventOrchestrator.cs +++ b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcEventOrchestrator.cs @@ -34,13 +34,16 @@ namespace AVEVA.Historian.Client.Grpc; /// (the read path proved the front-door session is sufficient over gRPC). /// /// -/// Live status (2026-06-22): the chain runs end-to-end and StartEventQuery succeeds, but -/// GetNextEventQueryResultBuffer long-polls when the query has no rows — it blocks to the -/// call deadline instead of returning the synchronous 5-byte code-85 terminal the 2020 WCF op returns. -/// A poll-deadline expiry is therefore treated as the no-data terminal (see the loop). The idle dev box -/// holds no events, so row-level retrieval is not yet live-verified; verifying parsed rows over -/// gRPC awaits an event-bearing 2023 R2 server. This is tooled + completes cleanly, NOT proven to -/// return rows. +/// Live status — server-gated (settled 2026-06-25): the chain runs end-to-end and +/// StartEventQuery succeeds, but GetNextEventQueryResultBuffer long-polls to the +/// no-data terminal (instead of the synchronous 5-byte code-85 terminal the 2020 WCF op returns); a +/// poll-deadline expiry is treated as that terminal (see the loop). This is not an empty-box +/// artifact: the live 2023 R2 server holds tens of thousands of events yet scopes 0 rows to a +/// managed connection. Every client-controllable layer was byte-matched to the stock client that returns +/// rows (see docs/reverse-engineering/grpc-event-query-capture.md) — the gate is a server-internal +/// per-connection retrieval working-set, not client-fixable. The legacy WCF transport is not a +/// fallback on 2023 R2 (docs/reverse-engineering/wcf-event-read-spike-results.md). Tooled + +/// completes cleanly, but proven NOT to return rows over a managed connection. /// /// internal sealed class HistorianGrpcEventOrchestrator @@ -101,9 +104,11 @@ internal sealed class HistorianGrpcEventOrchestrator { throw new ProtocolEvidenceMissingException( $"ReadEvents over gRPC did not return rows within {OverallBudget.TotalSeconds:0}s: StartEventQuery " + - "succeeds but the CM_EVENT registration replay stalls and GetNextEventQueryResultBuffer long-polls " + - "(no synchronous code-85 terminal over gRPC). Row-level retrieval is not yet verified over gRPC " + - "(the dev box holds no events) — use the WCF transport for event reads."); + "succeeds but GetNextEventQueryResultBuffer long-polls to the no-data terminal. Event-row retrieval " + + "over gRPC is auth-solved but server-gated — the 2023 R2 server scopes 0 rows to a managed connection " + + "(see docs/reverse-engineering/grpc-event-query-capture.md). The legacy WCF transport is NOT a fallback " + + "on 2023 R2 (live-disproven 2026-06-25: net.tcp is reset at the framing layer — see " + + "docs/reverse-engineering/wcf-event-read-spike-results.md), so there is no event-read path on a 2023 R2 historian."); } foreach (HistorianEvent evt in events) @@ -169,16 +174,19 @@ internal sealed class HistorianGrpcEventOrchestrator // reaches the no-data terminal with ZERO rows (the gRPC server long-polls GetNext rather than // returning the WCF code-85 terminal), we cannot distinguish "genuinely no events in range" // from "the CM_EVENT registration replay didn't fully land over gRPC" — so we refuse to return - // a possibly-false empty list and surface the unverified state instead. An event-bearing 2023 R2 - // server will return rows here and exercise the parse path; flip this once that is captured. + // a possibly-false empty list and surface the gated state instead. Proven server-gated: the live + // 2023 R2 server holds tens of thousands of events yet scopes 0 to a managed gRPC connection + // (grpc-event-query-capture.md); WCF is not a 2023 R2 fallback (wcf-event-read-spike-results.md). if (events.Count == 0) { throw new ProtocolEvidenceMissingException( "ReadEvents over gRPC: the chain completes and StartEventQuery succeeds, but " + "GetNextEventQueryResultBuffer returns no rows (it long-polls to the no-data terminal " + - $"after the CM_EVENT registration replay; last={LastErrorBufferDescription}). Row-level " + - "retrieval is not yet verified over gRPC (the dev box holds no events) — use the WCF " + - "transport for event reads until a capture against an event-bearing 2023 R2 server confirms it."); + $"after the CM_EVENT registration replay; last={LastErrorBufferDescription}). Event-row retrieval " + + "over gRPC is auth-solved but server-gated — the 2023 R2 server scopes 0 rows to a managed connection " + + "(see docs/reverse-engineering/grpc-event-query-capture.md). The legacy WCF transport is NOT a fallback " + + "on 2023 R2 (live-disproven 2026-06-25: net.tcp is reset at the framing layer — see " + + "docs/reverse-engineering/wcf-event-read-spike-results.md)."); } return events; diff --git a/src/AVEVA.Historian.Client/Wcf/HistorianWcfEventOrchestrator.cs b/src/AVEVA.Historian.Client/Wcf/HistorianWcfEventOrchestrator.cs index 779a5a2..c8f81a5 100644 --- a/src/AVEVA.Historian.Client/Wcf/HistorianWcfEventOrchestrator.cs +++ b/src/AVEVA.Historian.Client/Wcf/HistorianWcfEventOrchestrator.cs @@ -9,9 +9,13 @@ namespace AVEVA.Historian.Client.Wcf; /// /// Mirrors HistorianWcfReadOrchestrator but targets IRetrievalServiceContract4 for the event flow. -/// Event row buffer layout is undecoded as of this pass — when StartEventQuery succeeds, this -/// orchestrator returns an empty enumeration but logs the row-buffer length via the -/// diagnostic so a follow-up capture can decode the wire shape. +/// Applies to legacy 2020-era WCF (net.tcp) historians only. The event row-buffer layout is now +/// decoded (; verified against real captured rows). Note: a +/// 2023 R2 historian does NOT serve this WCF transport at all — net.tcp is reset at the framing +/// layer before any auth (live-disproven 2026-06-25; see +/// docs/reverse-engineering/wcf-event-read-spike-results.md), so this orchestrator is not a +/// fallback for 2023 R2 deployments. The native return codes 76/85 noted below were 2020-historian +/// observations. /// internal sealed class HistorianWcfEventOrchestrator { From 7992e43908375021c5f4bbec2023d30ee9a227df Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 25 Jun 2026 20:18:23 -0400 Subject: [PATCH 11/15] test(c2): make WCF spike transport-selectable (integrated|certificate) + opt-in verbose The first live run used the wrong port (32568 direct vs the 42568 WCF tunnel) and hardcoded RemoteTcpIntegrated; via the tunnel the error advanced from socket-RST to ProtocolException (binding/security mismatch). Add HISTORIAN_WCF_EVENT_TRANSPORT (certificate), _DNSID, _ALLOW_UNTRUSTED, and an opt-in _VERBOSE for live binding diagnosis. Default output stays sanitized; still Windows-only, never fails the suite. Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii --- .../WcfEventReadSpikeTests.cs | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/tests/AVEVA.Historian.Client.Tests/WcfEventReadSpikeTests.cs b/tests/AVEVA.Historian.Client.Tests/WcfEventReadSpikeTests.cs index 3db9017..6a06d31 100644 --- a/tests/AVEVA.Historian.Client.Tests/WcfEventReadSpikeTests.cs +++ b/tests/AVEVA.Historian.Client.Tests/WcfEventReadSpikeTests.cs @@ -48,17 +48,27 @@ public sealed class WcfEventReadSpikeTests string? user = Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_USER"); string? password = Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_PASSWORD"); string? spn = Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_SPN"); - bool integrated = string.IsNullOrEmpty(user); + string? dnsId = Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_DNSID"); + bool certificate = string.Equals( + Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_TRANSPORT"), "certificate", StringComparison.OrdinalIgnoreCase); + bool allowUntrusted = string.Equals( + Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_ALLOW_UNTRUSTED"), "1", StringComparison.Ordinal); + // Certificate transport carries no Windows transport credential (cert-validated TLS channel); + // integrated derives one from the logged-in identity unless an explicit DOMAIN\user is supplied. + // App-level ValidateClientCredential still uses UserName/Password (or the process identity). + bool integrated = !certificate && string.IsNullOrEmpty(user); HistorianClientOptions options = new() { Host = host, Port = port, - Transport = HistorianTransport.RemoteTcpIntegrated, + Transport = certificate ? HistorianTransport.RemoteTcpCertificate : HistorianTransport.RemoteTcpIntegrated, IntegratedSecurity = integrated, UserName = user ?? string.Empty, Password = password ?? string.Empty, TargetSpn = string.IsNullOrWhiteSpace(spn) ? "NT SERVICE\\aahClientAccessPoint" : spn, + ServerDnsIdentity = string.IsNullOrWhiteSpace(dnsId) ? null : dnsId, + AllowUntrustedServerCertificate = allowUntrusted, }; HistorianWcfEventOrchestrator orchestrator = new(options); @@ -79,10 +89,16 @@ public sealed class WcfEventReadSpikeTests } catch (Exception ex) { - outcome = $"threw {ex.GetType().Name}"; // message omitted — may carry host/credential text + // Default: type name only (sanitized). Opt into messages for live binding/protocol debugging + // via HISTORIAN_WCF_EVENT_VERBOSE=1 — binding/protocol errors may carry the endpoint host + // (already known to the operator) but never credentials; still off by default. + outcome = string.Equals(Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_VERBOSE"), "1", StringComparison.Ordinal) + ? $"threw {ex.GetType().FullName} :: {ex.Message} | inner={ex.InnerException?.GetType().FullName} :: {ex.InnerException?.Message}" + : $"threw {ex.GetType().Name}"; } // Sanitized diagnostic dump — counts, native return codes, buffer lengths, sha256 ONLY. + _output.WriteLine($"[C2 WCF spike] transport: {options.Transport} (integratedSec={integrated}, allowUntrusted={allowUntrusted})"); _output.WriteLine($"[C2 WCF spike] outcome: {outcome}"); _output.WriteLine($"[C2 WCF spike] events observed: {observed}"); _output.WriteLine($"[C2 WCF spike] hasFirstEvent: {hasFirstEvent}"); From 954b9cc9cc97c786704b600f5a4aac639d2b6baf Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 25 Jun 2026 20:35:46 -0400 Subject: [PATCH 12/15] feat(wcf): add ConnectViaAddress (WCF Via) for tunneled historian access + wire into C2 spike When the historian is reached through a port-forward whose local port differs from the server's real service port, WCF's server-side AddressFilter rejects the message (To = tunnel port != server port). ConnectViaAddress lets the channel connect to the tunnel while addressing the SOAP To the real Host/Port endpoint. Applied in HistorianWcfClientCredentialsHelper.Configure (the critical event factories already call it). The C2 spike reads HISTORIAN_WCF_EVENT_VIA. Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii --- src/AVEVA.Historian.Client/HistorianClientOptions.cs | 11 +++++++++++ .../Wcf/HistorianWcfClientCredentialsHelper.cs | 9 +++++++++ .../WcfEventReadSpikeTests.cs | 2 ++ 3 files changed, 22 insertions(+) diff --git a/src/AVEVA.Historian.Client/HistorianClientOptions.cs b/src/AVEVA.Historian.Client/HistorianClientOptions.cs index 23fc53e..29908b4 100644 --- a/src/AVEVA.Historian.Client/HistorianClientOptions.cs +++ b/src/AVEVA.Historian.Client/HistorianClientOptions.cs @@ -53,6 +53,17 @@ public sealed class HistorianClientOptions /// public string? ServerDnsIdentity { get; init; } + /// + /// Optional WCF "Via" address (e.g. net.tcp://host:42568). When set, the SDK's WCF + /// channel factories connect to this address while still addressing the SOAP message + /// To the logical endpoint built from /. Use this when + /// the Historian is reached through a port-forwarding tunnel or proxy whose local port differs + /// from the server's real service port: point / at the + /// server's real endpoint (so the server's WCF AddressFilter matches) and set this to the tunnel + /// endpoint. Has no effect on the gRPC transport. Default null (connect == address). + /// + public string? ConnectViaAddress { get; init; } + /// /// For : when true the channel uses TLS /// (https://); when false it uses plaintext (http://). Matches the stock diff --git a/src/AVEVA.Historian.Client/Wcf/HistorianWcfClientCredentialsHelper.cs b/src/AVEVA.Historian.Client/Wcf/HistorianWcfClientCredentialsHelper.cs index 8759671..b8689f5 100644 --- a/src/AVEVA.Historian.Client/Wcf/HistorianWcfClientCredentialsHelper.cs +++ b/src/AVEVA.Historian.Client/Wcf/HistorianWcfClientCredentialsHelper.cs @@ -2,6 +2,7 @@ using System.IdentityModel.Selectors; using System.IdentityModel.Tokens; using System.Security.Cryptography.X509Certificates; using System.ServiceModel; +using System.ServiceModel.Description; using System.ServiceModel.Security; namespace AVEVA.Historian.Client.Wcf; @@ -28,6 +29,14 @@ internal static class HistorianWcfClientCredentialsHelper RevocationMode = X509RevocationMode.NoCheck, }; } + + // Tunnel/proxy support: connect to the Via address while still addressing the message To the + // logical endpoint (Host/Port). Lets a port-forward whose local port differs from the server's + // real service port satisfy the server-side WCF AddressFilter (which checks the To header). + if (!string.IsNullOrWhiteSpace(options.ConnectViaAddress)) + { + factory.Endpoint.EndpointBehaviors.Add(new ClientViaBehavior(new Uri(options.ConnectViaAddress))); + } } private sealed class AcceptAnyCertificateValidator : X509CertificateValidator diff --git a/tests/AVEVA.Historian.Client.Tests/WcfEventReadSpikeTests.cs b/tests/AVEVA.Historian.Client.Tests/WcfEventReadSpikeTests.cs index 6a06d31..9e22ce8 100644 --- a/tests/AVEVA.Historian.Client.Tests/WcfEventReadSpikeTests.cs +++ b/tests/AVEVA.Historian.Client.Tests/WcfEventReadSpikeTests.cs @@ -49,6 +49,7 @@ public sealed class WcfEventReadSpikeTests string? password = Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_PASSWORD"); string? spn = Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_SPN"); string? dnsId = Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_DNSID"); + string? via = Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_VIA"); bool certificate = string.Equals( Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_TRANSPORT"), "certificate", StringComparison.OrdinalIgnoreCase); bool allowUntrusted = string.Equals( @@ -69,6 +70,7 @@ public sealed class WcfEventReadSpikeTests TargetSpn = string.IsNullOrWhiteSpace(spn) ? "NT SERVICE\\aahClientAccessPoint" : spn, ServerDnsIdentity = string.IsNullOrWhiteSpace(dnsId) ? null : dnsId, AllowUntrustedServerCertificate = allowUntrusted, + ConnectViaAddress = string.IsNullOrWhiteSpace(via) ? null : via, }; HistorianWcfEventOrchestrator orchestrator = new(options); From 8777c0b816545276e0eae812689a769d08cabe9f Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 25 Jun 2026 20:37:38 -0400 Subject: [PATCH 13/15] =?UTF-8?q?fix(wcf):=20set=20Via=20via=20CreateChann?= =?UTF-8?q?el(address,=20via)=20=E2=80=94=20ClientViaBehavior=20absent=20i?= =?UTF-8?q?n=20.NET=20WCF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ClientViaBehavior is a .NET Framework type not present in the System.ServiceModel client libraries. Use the portable ChannelFactory.CreateChannel(EndpointAddress, Uri) overload instead, via a CreateChannel helper applied at the history-open and retrieval-query sites (the critical event path). Fixes the build break in 954b9cc. Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii --- .../Wcf/HistorianWcfAuthChainHelper.cs | 2 +- .../HistorianWcfClientCredentialsHelper.cs | 25 +++++++++++++------ .../Wcf/HistorianWcfEventOrchestrator.cs | 2 +- 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/src/AVEVA.Historian.Client/Wcf/HistorianWcfAuthChainHelper.cs b/src/AVEVA.Historian.Client/Wcf/HistorianWcfAuthChainHelper.cs index 5031b24..934d5ed 100644 --- a/src/AVEVA.Historian.Client/Wcf/HistorianWcfAuthChainHelper.cs +++ b/src/AVEVA.Historian.Client/Wcf/HistorianWcfAuthChainHelper.cs @@ -41,7 +41,7 @@ internal static class HistorianWcfAuthChainHelper try { - IHistoryServiceContract2 historyChannel = historyFactory.CreateChannel(); + IHistoryServiceContract2 historyChannel = HistorianWcfClientCredentialsHelper.CreateChannel(historyFactory, options); ICommunicationObject historyChannelCo = (ICommunicationObject)historyChannel; try { diff --git a/src/AVEVA.Historian.Client/Wcf/HistorianWcfClientCredentialsHelper.cs b/src/AVEVA.Historian.Client/Wcf/HistorianWcfClientCredentialsHelper.cs index b8689f5..83bec42 100644 --- a/src/AVEVA.Historian.Client/Wcf/HistorianWcfClientCredentialsHelper.cs +++ b/src/AVEVA.Historian.Client/Wcf/HistorianWcfClientCredentialsHelper.cs @@ -2,7 +2,6 @@ using System.IdentityModel.Selectors; using System.IdentityModel.Tokens; using System.Security.Cryptography.X509Certificates; using System.ServiceModel; -using System.ServiceModel.Description; using System.ServiceModel.Security; namespace AVEVA.Historian.Client.Wcf; @@ -29,14 +28,24 @@ internal static class HistorianWcfClientCredentialsHelper RevocationMode = X509RevocationMode.NoCheck, }; } + } - // Tunnel/proxy support: connect to the Via address while still addressing the message To the - // logical endpoint (Host/Port). Lets a port-forward whose local port differs from the server's - // real service port satisfy the server-side WCF AddressFilter (which checks the To header). - if (!string.IsNullOrWhiteSpace(options.ConnectViaAddress)) - { - factory.Endpoint.EndpointBehaviors.Add(new ClientViaBehavior(new Uri(options.ConnectViaAddress))); - } + /// + /// Creates a channel from , honoring + /// when set: the channel connects to + /// the Via address while still addressing the SOAP message To the factory's logical endpoint. + /// This lets a port-forward whose local port differs from the server's real service port satisfy the + /// server-side WCF AddressFilter (which validates the To header). Use this in place of + /// factory.CreateChannel() at every WCF event/read channel-creation site. + /// + public static TChannel CreateChannel(ChannelFactory factory, HistorianClientOptions options) + { + ArgumentNullException.ThrowIfNull(factory); + ArgumentNullException.ThrowIfNull(options); + + return string.IsNullOrWhiteSpace(options.ConnectViaAddress) + ? factory.CreateChannel() + : factory.CreateChannel(factory.Endpoint.Address, new Uri(options.ConnectViaAddress)); } private sealed class AcceptAnyCertificateValidator : X509CertificateValidator diff --git a/src/AVEVA.Historian.Client/Wcf/HistorianWcfEventOrchestrator.cs b/src/AVEVA.Historian.Client/Wcf/HistorianWcfEventOrchestrator.cs index c8f81a5..d1aaca5 100644 --- a/src/AVEVA.Historian.Client/Wcf/HistorianWcfEventOrchestrator.cs +++ b/src/AVEVA.Historian.Client/Wcf/HistorianWcfEventOrchestrator.cs @@ -167,7 +167,7 @@ internal sealed class HistorianWcfEventOrchestrator try { - IRetrievalServiceContract4 channel = factory.CreateChannel(); + IRetrievalServiceContract4 channel = HistorianWcfClientCredentialsHelper.CreateChannel(factory, _options); ICommunicationObject channelCo = (ICommunicationObject)channel; try { From de8d5e91ce88685c54e7b8a68e204640828ba5e8 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 26 Jun 2026 04:38:58 -0400 Subject: [PATCH 14/15] feat(wcf): EventReadConnectionModeOverride + cross-platform/bounded C2 spike MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live investigation (direct from a VPN host to the 2023 R2 historian's real WCF port) showed the certificate transport + NegotiateAuthentication auth work cross-platform, and that the event-read chain needs the 0x501 event connection mode for CM_EVENT RegisterTags to succeed (0x402/0x401 fail). Even with registration succeeding over a window that has events, StartEventQuery returns a 0-row header and long-polls — the same server-side per-connection row gate proven for gRPC. Adds: EventReadConnectionModeOverride (diagnostic), and spike knobs — cross-platform cert gate, version-check bypass, per-call timeout, overall budget with phase-diagnostic dump, connection-mode override. Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii --- .../HistorianClientOptions.cs | 8 +++ .../Wcf/HistorianWcfEventOrchestrator.cs | 2 +- .../WcfEventReadSpikeTests.cs | 59 ++++++++++++++++--- 3 files changed, 59 insertions(+), 10 deletions(-) diff --git a/src/AVEVA.Historian.Client/HistorianClientOptions.cs b/src/AVEVA.Historian.Client/HistorianClientOptions.cs index 29908b4..c197027 100644 --- a/src/AVEVA.Historian.Client/HistorianClientOptions.cs +++ b/src/AVEVA.Historian.Client/HistorianClientOptions.cs @@ -64,6 +64,14 @@ public sealed class HistorianClientOptions /// public string? ConnectViaAddress { get; init; } + /// + /// Diagnostic override for the native OpenConnection mode the WCF event-read chain uses (default + /// 0x402, read-only process). Set to e.g. 0x501 (event) or 0x401 (write-enabled) + /// to probe whether CM_EVENT registration / event-row retrieval needs a different connection type on a + /// 2023 R2 server. Null = the default read-only process mode. Intended for protocol investigation. + /// + public uint? EventReadConnectionModeOverride { get; init; } + /// /// For : when true the channel uses TLS /// (https://); when false it uses plaintext (http://). Matches the stock diff --git a/src/AVEVA.Historian.Client/Wcf/HistorianWcfEventOrchestrator.cs b/src/AVEVA.Historian.Client/Wcf/HistorianWcfEventOrchestrator.cs index d1aaca5..7ff341e 100644 --- a/src/AVEVA.Historian.Client/Wcf/HistorianWcfEventOrchestrator.cs +++ b/src/AVEVA.Historian.Client/Wcf/HistorianWcfEventOrchestrator.cs @@ -147,7 +147,7 @@ internal sealed class HistorianWcfEventOrchestrator EndpointAddress transactionEndpoint = HistorianWcfBindingFactory.CreateAuxiliaryEndpointAddress(_options, HistorianWcfServiceNames.Transaction); uint clientHandle = HistorianWcfAuthChainHelper.OpenAuthenticatedConnection( _options, histBinding, histEndpoint, contextKey, cancellationToken, - connectionMode: HistorianWcfAuthChainHelper.NativeIntegratedReadOnlyConnectionMode, + connectionMode: _options.EventReadConnectionModeOverride ?? HistorianWcfAuthChainHelper.NativeIntegratedReadOnlyConnectionMode, additionalSetup: (historyChannel, context) => AddCmEventTagViaAddT(historyChannel, context, auxBinding, statusEndpoint, transactionEndpoint, retrBinding, retrEndpoint)); return RunEventQuery(retrBinding, retrEndpoint, clientHandle, startUtc, endUtc, filter, cancellationToken); diff --git a/tests/AVEVA.Historian.Client.Tests/WcfEventReadSpikeTests.cs b/tests/AVEVA.Historian.Client.Tests/WcfEventReadSpikeTests.cs index 9e22ce8..1b74b48 100644 --- a/tests/AVEVA.Historian.Client.Tests/WcfEventReadSpikeTests.cs +++ b/tests/AVEVA.Historian.Client.Tests/WcfEventReadSpikeTests.cs @@ -36,9 +36,14 @@ public sealed class WcfEventReadSpikeTests public async Task WcfEventRead_DiagnosticDump_AgainstRemoteHistorian() { string? host = Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_HOST"); - if (string.IsNullOrWhiteSpace(host) || !OperatingSystem.IsWindows()) + bool certificate = string.Equals( + Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_TRANSPORT"), "certificate", StringComparison.OrdinalIgnoreCase); + // The certificate (TLS) transport and NegotiateAuthentication-based app auth are cross-platform; + // the integrated (Windows SSPI transport-security) transport is Windows-only. Skip when the gate + // is unconfigured, or when an integrated run is requested off Windows. + if (string.IsNullOrWhiteSpace(host) || (!certificate && !OperatingSystem.IsWindows())) { - return; // inert without the dedicated gate / off Windows + return; } int port = int.TryParse(Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_PORT"), out int p) @@ -50,8 +55,17 @@ public sealed class WcfEventReadSpikeTests string? spn = Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_SPN"); string? dnsId = Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_DNSID"); string? via = Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_VIA"); - bool certificate = string.Equals( - Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_TRANSPORT"), "certificate", StringComparison.OrdinalIgnoreCase); + bool bypassVersion = string.Equals( + Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_NOVERSIONCHECK"), "1", StringComparison.Ordinal); + int timeoutSec = int.TryParse(Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_TIMEOUT_SEC"), out int ts) && ts > 0 + ? ts : 30; + string? connModeRaw = Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_CONNMODE"); + uint? connModeOverride = null; + if (!string.IsNullOrWhiteSpace(connModeRaw)) + { + string hex = connModeRaw.StartsWith("0x", StringComparison.OrdinalIgnoreCase) ? connModeRaw[2..] : connModeRaw; + if (uint.TryParse(hex, System.Globalization.NumberStyles.HexNumber, null, out uint cm)) { connModeOverride = cm; } + } bool allowUntrusted = string.Equals( Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_ALLOW_UNTRUSTED"), "1", StringComparison.Ordinal); // Certificate transport carries no Windows transport credential (cert-validated TLS channel); @@ -71,22 +85,49 @@ public sealed class WcfEventReadSpikeTests ServerDnsIdentity = string.IsNullOrWhiteSpace(dnsId) ? null : dnsId, AllowUntrustedServerCertificate = allowUntrusted, ConnectViaAddress = string.IsNullOrWhiteSpace(via) ? null : via, + VerifyServerInterfaceVersion = !bypassVersion, + ConnectTimeout = TimeSpan.FromSeconds(timeoutSec), + RequestTimeout = TimeSpan.FromSeconds(timeoutSec), + EventReadConnectionModeOverride = connModeOverride, }; HistorianWcfEventOrchestrator orchestrator = new(options); DateTime endUtc = DateTime.UtcNow; DateTime startUtc = endUtc - TimeSpan.FromDays(days); + int budgetSec = int.TryParse(Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_BUDGET_SEC"), out int bgs) && bgs > 0 + ? bgs : 60; + int observed = 0; bool hasFirstEvent = false; string outcome = "completed"; try { - await foreach (HistorianEvent evt in orchestrator.ReadEventsAsync(startUtc, endUtc, filter: null, CancellationToken.None)) + // Race the read against an overall budget. The chain is synchronous WCF, so a stuck call + // can't be token-cancelled — but the orchestrator's static/instance diagnostics are set as it + // progresses, so on a budget timeout we still dump them to see which phase (auth / CM_EVENT + // registration / query) is hanging. The abandoned task keeps running in the background harness. + using var budget = new CancellationTokenSource(TimeSpan.FromSeconds(budgetSec)); + Task readTask = Task.Run(async () => { - observed++; - hasFirstEvent = true; - _ = evt; // event identity intentionally NOT logged (sanitized) + int n = 0; + await foreach (HistorianEvent evt in orchestrator.ReadEventsAsync(startUtc, endUtc, filter: null, budget.Token)) + { + n++; + _ = evt; // event identity intentionally NOT logged (sanitized) + } + return n; + }); + + Task finished = await Task.WhenAny(readTask, Task.Delay(TimeSpan.FromSeconds(budgetSec + 5))); + if (finished == readTask) + { + observed = await readTask; + hasFirstEvent = observed > 0; + } + else + { + outcome = $"TIMED OUT after {budgetSec}s (chain still running — see return codes for phase reached)"; } } catch (Exception ex) @@ -100,7 +141,7 @@ public sealed class WcfEventReadSpikeTests } // Sanitized diagnostic dump — counts, native return codes, buffer lengths, sha256 ONLY. - _output.WriteLine($"[C2 WCF spike] transport: {options.Transport} (integratedSec={integrated}, allowUntrusted={allowUntrusted})"); + _output.WriteLine($"[C2 WCF spike] transport: {options.Transport} (integratedSec={integrated}, allowUntrusted={allowUntrusted}, connMode={(connModeOverride.HasValue ? "0x" + connModeOverride.Value.ToString("X") : "default-0x402")})"); _output.WriteLine($"[C2 WCF spike] outcome: {outcome}"); _output.WriteLine($"[C2 WCF spike] events observed: {observed}"); _output.WriteLine($"[C2 WCF spike] hasFirstEvent: {hasFirstEvent}"); From f2297315b9e2a4e49d642c98ea505c9500775519 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 26 Jun 2026 04:41:21 -0400 Subject: [PATCH 15/15] =?UTF-8?q?docs(c2):=20correct=20the=20WCF=20finding?= =?UTF-8?q?=20=E2=80=94=20transport+auth=20viable,=20row-retrieval=20serve?= =?UTF-8?q?r-gated?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Overturns the earlier wrong "WCF not served on 2023 R2" conclusion (that was a test error: wrong port/transport for the reverse tunnel). Corrected: the cert (TLS) transport + NegotiateAuthentication auth reach the 2023 R2 historian cross-platform; the 0x501 event connection mode makes CM_EVENT RegisterTags succeed; yet StartEventQuery returns a 0-row buffer + long-polls over a window that has events. Registration and window ruled out -> the same server-side per-connection row gate as gRPC. Event reads stay server-gated over BOTH transports; not client-fixable. Evidence doc rewritten; gRPC + WCF orchestrator gating messages corrected. Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii --- .../wcf-event-read-spike-results.md | 100 ++++++++++-------- .../Grpc/HistorianGrpcEventOrchestrator.cs | 27 ++--- .../Wcf/HistorianWcfEventOrchestrator.cs | 16 +-- 3 files changed, 80 insertions(+), 63 deletions(-) diff --git a/docs/reverse-engineering/wcf-event-read-spike-results.md b/docs/reverse-engineering/wcf-event-read-spike-results.md index 16c610d..1f4ad98 100644 --- a/docs/reverse-engineering/wcf-event-read-spike-results.md +++ b/docs/reverse-engineering/wcf-event-read-spike-results.md @@ -1,63 +1,75 @@ -# WCF event-read spike — live result (2026-06-25): WCF transport not served on 2023 R2 +# WCF event-read spike — live result (2026-06-25/26): transport+auth viable, row-retrieval server-gated Settles the open question behind **C2** ("event reads over gRPC are gated; the only listed unblock is *route event reads via WCF*"). The gRPC event-read path is a proven server-side dead-end (`grpc-event-query-capture.md`: auth fully solved, every client-controllable layer byte-matched to the -stock client, yet the server scopes 0 rows to our connection). This spike tested the **WCF** leg. +stock client, yet the server scopes 0 rows to our connection). This spike resolved the **WCF** leg. + +> **Correction to an earlier draft of this doc.** A first pass concluded "the 2023 R2 historian does not +> serve the legacy WCF transport (connection reset at framing)." **That was a test error, not a server +> fact.** It connected to the historian's real WCF port `32568` *directly* and used the Windows-integrated +> transport. In this environment the historian is reached through a **reverse SSH tunnel** (local +> `42568` → historian `32568`), and integrated/Kerberos auth does not work through that tunnel. The +> socket-RST was the tunnel/transport mismatch, not an absent listener. Corrected below. ## What was run -A Windows-only, env-gated diagnostic (`tests/AVEVA.Historian.Client.Tests/WcfEventReadSpikeTests.cs`, -gated by `HISTORIAN_WCF_EVENT_HOST`) drove `HistorianWcfEventOrchestrator.ReadEventsAsync` directly -over `RemoteTcpIntegrated` (WCF `net.tcp`, port 32568) against the **live 2023 R2 historian**, with a -−90d window (the engine holds tens of thousands of events in that range), run from the native Windows -capture rig over VPN. Auth supplied as explicit domain credentials (consumed by the app-level -`ValidateClientCredential` SSPI rounds). +A Windows-only-by-default, env-gated diagnostic (`tests/AVEVA.Historian.Client.Tests/WcfEventReadSpikeTests.cs`) +drives `HistorianWcfEventOrchestrator.ReadEventsAsync` directly. The decisive run was **cross-platform, +direct** (no tunnel): from the VPN-holding host straight to the historian's real WCF endpoint +`net.tcp://:32568/HistCert`, using the **certificate transport** (`RemoteTcpCertificate`, +TLS, `AllowUntrustedServerCertificate`) and `NegotiateAuthentication` (cross-platform, explicit domain +credentials). The SDK's interface-version gate was bypassed (`VerifyServerInterfaceVersion=false`) — +the 2023 R2 WCF **History interface reports version 13** (this SDK's serializers target 11/12). -## Result — RED (transport not served), sanitized +## Result — transport+auth viable; row-retrieval server-gated (sanitized) -Event spike: +Progression of the live errors as the addressing/transport was corrected: -| field | value | +| attempt | error | |---|---| -| outcome | `THREW System.ServiceModel.CommunicationException` ("The socket connection was aborted") | -| inner | `System.Net.Sockets.SocketException` — "An existing connection was forcibly closed by the remote host" | -| events observed | 0 | -| LastUpdC3ReturnCode / LastRTag2ReturnCode / LastAddReturnCode(EnsT2) | 0 / 0 / 0 | -| LastEnsT2PayloadSha256 | empty | -| LastResultBufferLength | 0 | +| direct `:32568`, integrated | `SocketException` "forcibly closed" (wrong port + transport for the tunnel) | +| tunnel `:42568`, integrated | `ProtocolException` at the security UpgradeResponse (integrated can't negotiate through the tunnel) | +| tunnel `:42568`, certificate | reached the WCF dispatcher → `AddressFilter` mismatch (tunnel rewrites the port) | +| **direct `:32568`, certificate, cross-platform** | **past auth** → `ProtocolEvidenceMissingException`: History interface version **13** | +| + `VerifyServerInterfaceVersion=false` | **full chain runs**; query returns a 10-byte **0-row** header, then `GetNext` long-polls | -All native return codes are `0` and the EnsT2 payload sha256 is empty: the chain failed at the **first -WCF call** (`GetInterfaceVersion`), *before* any auth token round or CM_EVENT registration ran. +Connection-mode experiment (certificate transport, direct, version-bypassed, a 1-day window that holds +events), comparing the native OpenConnection mode used for the event-read chain: -Corroboration — a basic (non-event) `RemoteTcpIntegrated` `ProbeAsync` + `ReadRawAsync` (the committed -`RemoteTcpIntegrationTests`) throws the **identical** exception, with the stack landing in -`System.ServiceModel.Channels.SocketConnection.WriteAsync` — i.e. the failure is **transport-wide**, not -event-specific, and not auth-specific (it never reaches auth). - -Phase 0 (reachability) had confirmed TCP 32568 is **open** (the connect succeeds). So the port accepts a -socket, but the moment the SDK writes its `net.tcp` binary-SOAP framing the server **resets the -connection** (RST at the socket-write layer). +| connMode | RegisterTags (RTag2) | EnsureTags (EnsT2) | result buffer | events | +|---|---|---|---|---| +| `0x501` (event) | **0 — success** | 1 (benign-false, as in the 2020 flow) | 10 bytes (0-row header) | **0** | +| `0x401` (write) | 1 (fail) | 1 | 10 bytes | 0 | +| `0x402` (read-only, default) | 1 (fail) | 1 | 10 bytes | 0 | ## Conclusion -The **2023 R2 historian does not serve the legacy WCF NetTcp transport.** A raw RST at the first socket -write — before any security negotiation, SOAP fault, or auth exchange — is the signature of a listener -that does not speak `net.tcp` binary SOAP, not of an auth/SPN problem or event-row scoping. (The earlier -WCF event-chain native return codes 76/85 documented in `HistorianWcfEventOrchestrator` were only ever -observed against a **2020** historian; against 2023 R2 there is no WCF endpoint to reach at all.) +1. **WCF transport + auth ARE viable on 2023 R2.** The certificate (TLS) transport negotiates and the + `NegotiateAuthentication` app-level handshake authenticates — **cross-platform** (proven from a + non-Windows VPN host). The earlier "WCF not served" conclusion was wrong. (Integrated/Windows + transport security is not usable through the reverse tunnel — `net.tcp` Kerberos does not tunnel.) +2. **The event-read chain needs the `0x501` event connection mode.** With it, CM_EVENT `RegisterTags` + **succeeds** (it fails on `0x402`/`0x401`). `EnsureTags` returns false, but that is documented as + benign in the 2020 flow that *did* return rows. +3. **Row retrieval is server-gated — same as gRPC.** Even with auth solved and `RegisterTags` succeeding, + over a window that holds events, `StartEventQuery` succeeds but `GetNextEventQueryResultBuffer` returns + a **0-row** header (10 bytes) and long-polls. Registration and window are ruled out as the cause; the + server simply does not scope event rows to a managed connection. This is the **identical** server-side + per-connection retrieval working-set gate proven for gRPC in `grpc-event-query-capture.md`. -Therefore **C2's "route event reads via WCF" unblock is moot on 2023 R2** — there is no WCF endpoint to -route to. Event reads are unavailable on the 2023 R2 historian over **both** transports: +**Therefore event reads do not return rows on the 2023 R2 historian over either transport** — gRPC +(retrieval-server-gated) and WCF (transport+auth work, but the same server-side row gate). The only +remaining theoretical unblock is server-side (AVEVA exposing event-row retrieval to a managed +connection) — not client-fixable. **C2 stays closed won't-fix**, for this (corrected) reason. -- **gRPC** — auth-solved but retrieval-server-gated (server scopes 0 rows to our connection; - `grpc-event-query-capture.md`). -- **WCF (`net.tcp`)** — transport not served on 2023 R2 (connection reset at framing). +## SDK additions from this investigation (retained, build-clean, golden where applicable) -The WCF event-read managed path would only ever apply to a legacy **2020** historian, which the gateway -does not target (the gateway runs `RemoteGrpc` against 2023 R2). The only remaining theoretical unblock -is server-side (AVEVA exposing event-row retrieval to a managed gRPC connection) — not client-fixable. - -**C2 is closed won't-fix** for the gateway's target (2023 R2). `ReadEventsAsync` over gRPC keeps its -honest no-row throw; the gating messages are corrected so they no longer point operators at the WCF -transport as a live fallback on 2023 R2. +- `HistorianClientOptions.ConnectViaAddress` — WCF `Via` (connect to a tunnel/proxy while addressing the + SOAP `To` the real endpoint), so a port-forward whose local port differs from the server's real port + satisfies the server-side WCF AddressFilter. +- `HistorianClientOptions.EventReadConnectionModeOverride` — diagnostic override of the event-read + OpenConnection mode (the `0x501` finding above). +- The C2 spike is now transport-selectable (integrated|certificate), cross-platform for the cert + transport, bounded (per-call timeout + overall budget with a phase-diagnostic dump), and version-gate + bypassable. Output stays sanitized (counts, native return codes, buffer lengths, sha256). diff --git a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcEventOrchestrator.cs b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcEventOrchestrator.cs index 74e8568..c1464a6 100644 --- a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcEventOrchestrator.cs +++ b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcEventOrchestrator.cs @@ -104,11 +104,12 @@ internal sealed class HistorianGrpcEventOrchestrator { throw new ProtocolEvidenceMissingException( $"ReadEvents over gRPC did not return rows within {OverallBudget.TotalSeconds:0}s: StartEventQuery " + - "succeeds but GetNextEventQueryResultBuffer long-polls to the no-data terminal. Event-row retrieval " + - "over gRPC is auth-solved but server-gated — the 2023 R2 server scopes 0 rows to a managed connection " + - "(see docs/reverse-engineering/grpc-event-query-capture.md). The legacy WCF transport is NOT a fallback " + - "on 2023 R2 (live-disproven 2026-06-25: net.tcp is reset at the framing layer — see " + - "docs/reverse-engineering/wcf-event-read-spike-results.md), so there is no event-read path on a 2023 R2 historian."); + "succeeds but GetNextEventQueryResultBuffer long-polls to the no-data terminal. Event-row retrieval is " + + "auth-solved but SERVER-GATED on 2023 R2 over both transports — the server scopes 0 rows to a managed " + + "connection (gRPC: docs/reverse-engineering/grpc-event-query-capture.md). The WCF transport reaches the " + + "2023 R2 historian (certificate transport + auth work, CM_EVENT registration succeeds on the 0x501 event " + + "connection) but hits the SAME server-side row gate — 0-row buffer + long-poll (see " + + "docs/reverse-engineering/wcf-event-read-spike-results.md). Not client-fixable on either transport."); } foreach (HistorianEvent evt in events) @@ -175,18 +176,20 @@ internal sealed class HistorianGrpcEventOrchestrator // returning the WCF code-85 terminal), we cannot distinguish "genuinely no events in range" // from "the CM_EVENT registration replay didn't fully land over gRPC" — so we refuse to return // a possibly-false empty list and surface the gated state instead. Proven server-gated: the live - // 2023 R2 server holds tens of thousands of events yet scopes 0 to a managed gRPC connection - // (grpc-event-query-capture.md); WCF is not a 2023 R2 fallback (wcf-event-read-spike-results.md). + // 2023 R2 server holds tens of thousands of events yet scopes 0 to a managed connection + // (grpc-event-query-capture.md). WCF reaches the same historian (cert transport + auth work, + // CM_EVENT registers on the 0x501 event connection) but hits the SAME row gate — not a fallback + // (wcf-event-read-spike-results.md). if (events.Count == 0) { throw new ProtocolEvidenceMissingException( "ReadEvents over gRPC: the chain completes and StartEventQuery succeeds, but " + "GetNextEventQueryResultBuffer returns no rows (it long-polls to the no-data terminal " + - $"after the CM_EVENT registration replay; last={LastErrorBufferDescription}). Event-row retrieval " + - "over gRPC is auth-solved but server-gated — the 2023 R2 server scopes 0 rows to a managed connection " + - "(see docs/reverse-engineering/grpc-event-query-capture.md). The legacy WCF transport is NOT a fallback " + - "on 2023 R2 (live-disproven 2026-06-25: net.tcp is reset at the framing layer — see " + - "docs/reverse-engineering/wcf-event-read-spike-results.md)."); + $"after the CM_EVENT registration replay; last={LastErrorBufferDescription}). Event-row retrieval is " + + "auth-solved but SERVER-GATED on 2023 R2 over both transports — the server scopes 0 rows to a managed " + + "connection (gRPC: docs/reverse-engineering/grpc-event-query-capture.md; WCF reaches the historian and " + + "registers on the 0x501 event connection yet hits the same row gate: " + + "docs/reverse-engineering/wcf-event-read-spike-results.md). Not client-fixable on either transport."); } return events; diff --git a/src/AVEVA.Historian.Client/Wcf/HistorianWcfEventOrchestrator.cs b/src/AVEVA.Historian.Client/Wcf/HistorianWcfEventOrchestrator.cs index 7ff341e..f6ef4dc 100644 --- a/src/AVEVA.Historian.Client/Wcf/HistorianWcfEventOrchestrator.cs +++ b/src/AVEVA.Historian.Client/Wcf/HistorianWcfEventOrchestrator.cs @@ -8,13 +8,15 @@ using AVEVA.Historian.Client.Wcf.Contracts; namespace AVEVA.Historian.Client.Wcf; /// -/// Mirrors HistorianWcfReadOrchestrator but targets IRetrievalServiceContract4 for the event flow. -/// Applies to legacy 2020-era WCF (net.tcp) historians only. The event row-buffer layout is now -/// decoded (; verified against real captured rows). Note: a -/// 2023 R2 historian does NOT serve this WCF transport at all — net.tcp is reset at the framing -/// layer before any auth (live-disproven 2026-06-25; see -/// docs/reverse-engineering/wcf-event-read-spike-results.md), so this orchestrator is not a -/// fallback for 2023 R2 deployments. The native return codes 76/85 noted below were 2020-historian +/// Mirrors HistorianWcfReadOrchestrator but targets IRetrievalServiceContract4 for the event flow. The +/// event row-buffer layout is decoded (; verified against real +/// captured rows). A 2023 R2 historian does serve this transport via the certificate +/// (TLS) endpoint (the cert transport + NegotiateAuthentication auth work cross-platform; the +/// integrated/Windows transport does not tunnel). With the 0x501 event connection mode CM_EVENT +/// registration succeeds — but StartEventQuery still returns a 0-row buffer and long-polls: event +/// rows are server-gated per connection on 2023 R2, the same wall as the gRPC path, and not +/// client-fixable (see docs/reverse-engineering/wcf-event-read-spike-results.md and +/// grpc-event-query-capture.md). The native return codes 76/85 noted below were 2020-historian /// observations. /// internal sealed class HistorianWcfEventOrchestrator