18 Commits

Author SHA1 Message Date
Joseph Doherty 54bcf8a5e0 docs(c2): record 2020-vs-2023R2 positive control for the event-read server gate
Adds the positive control that the prior C2 evidence lacked. The SAME native WCF
event-read client returns real events (5) from a local AVEVA Historian 2020 but 0
from the 2023 R2 server over the identical sequence and window, while both boxes
hold tens of thousands of events in SQL — isolating the zero-rows to the 2023 R2
server, not the client, protocol, or serializers.

- wcf-event-read-spike-results.md: new "2026-06-26 positive control" section
  (2020 vs 2023 R2 A/B from one WCF client; stock-2020-client version-self-block
  caveat; stock-2023R2 gRPC cross-check).
- grpc-event-query-capture.md: re-control note — the 2026-06-22 stock 50-row
  capture did NOT reproduce; the stock 2023 R2 client now also returns 0 rows.
- HistorianGrpcIntegrationTests: correct the stale "capture-gated, NOT
  server-gated" comment to the server-gate conclusion backed by the controls.

Sanitized throughout (counts, native return codes, buffer lengths only).

Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii
2026-06-26 11:08:20 -04:00
dohertj2 f0a1b04b34 Merge pull request 'feat(wcf): C2 spike + ConnectViaAddress/connmode — WCF transport viable, rows server-gated' (#1) from feat/c2-wcf-event-spike into main 2026-06-26 06:48:01 -04:00
Joseph Doherty f2297315b9 docs(c2): correct the WCF finding — transport+auth viable, row-retrieval server-gated
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
2026-06-26 04:41:21 -04:00
Joseph Doherty de8d5e91ce feat(wcf): EventReadConnectionModeOverride + cross-platform/bounded C2 spike
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
2026-06-26 04:38:58 -04:00
Joseph Doherty 8777c0b816 fix(wcf): set Via via CreateChannel(address, via) — ClientViaBehavior absent in .NET WCF
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
2026-06-25 20:37:38 -04:00
Joseph Doherty 954b9cc9cc 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
2026-06-25 20:35:46 -04:00
Joseph Doherty 7992e43908 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
2026-06-25 20:18:23 -04:00
Joseph Doherty 64c9793b91 docs(c2): correct event-read gating messages — WCF not served on 2023 R2
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
2026-06-25 17:11:19 -04:00
Joseph Doherty f1c57f7149 docs(c2): record WCF event-read spike live result (RED — transport not served on 2023 R2)
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
2026-06-25 16:58:56 -04:00
Joseph Doherty 85f3bd4b4e 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
2026-06-25 16:40:21 -04:00
Joseph Doherty 5a7a28872b Merge feat/c1-numeric-writes: Int8/UInt8 live writes + C3a evidence (UInt1 server-blocked)
Int8/UInt8 analog tag-create + historical-value write encoders un-gated and
live-proven; golden-pinned. UInt1 attempted but re-gated — the historian stores a
degenerate UInt1 analog tag. 2023 R2 gRPC interface-version integers captured (C3a).

Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii
2026-06-25 16:07:24 -04:00
Joseph Doherty 100b44a365 docs(write): drop UInt1 from value-layout table; mark Int8/UInt8 live-proven
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
2026-06-25 15:42:50 -04:00
Joseph Doherty aa8ca2f6ad 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
2026-06-25 15:34:34 -04:00
Joseph Doherty 95b924cdbe fix(write): re-gate UInt1 — historian creates degenerate UInt1 analog tags
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
2026-06-25 15:27:04 -04:00
Joseph Doherty aa56d2d81b docs(re): correct Status interface-version comments (4 on gRPC, not 0)
Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii
2026-06-25 15:03:22 -04:00
Joseph Doherty dea9107b4b 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
2026-06-25 14:58:45 -04:00
Joseph Doherty 79bb1d9e06 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
2026-06-25 14:49:21 -04:00
Joseph Doherty 43c2587498 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
2026-06-25 14:38:27 -04:00
18 changed files with 600 additions and 52 deletions
+2 -2
View File
@@ -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.)
@@ -4,6 +4,16 @@ Captured the stock 2023 R2 client performing a **gRPC event read** that returns
the open item "gRPC event ROW retrieval returns zero rows" (handoff §Current Status item 1). This
closes the capture-gate: the working request shape is now known.
> **Re-control 2026-06-26 — this 50-row result did NOT reproduce.** Re-running the stock 2023 R2
> client (same `capture-event` harness, native Event connection) against the same server returned
> **0 rows** over 30d / 90d / 365d / 3yr, even though `Runtime.dbo.Events` held ~71.5k events in the
> window (newest minutes old). So the native gRPC event read is **server-gated**, not merely
> request-shape/"capture" gated — the gate also stops the stock client now. Positive control the same
> day: the same native **WCF** event-read client returns real events (5) from a local AVEVA Historian
> 2020 but 0 from this 2023 R2 server (see `wcf-event-read-spike-results.md` §"2026-06-26 — positive
> control"). The 50-row capture below remains valid as the *captured-correct request format*; treat its
> "returns rows" framing as a point-in-time observation that the server no longer honors.
## How it was captured
`tools/AVEVA.Historian.Grpc2023CaptureHarness` gained a `capture-event` scenario. It loads the
@@ -0,0 +1,45 @@
# 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. 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
`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`.
@@ -0,0 +1,105 @@
# 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 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-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://<historian>: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 — transport+auth viable; row-retrieval server-gated (sanitized)
Progression of the live errors as the addressing/transport was corrected:
| attempt | error |
|---|---|
| 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 |
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:
| 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
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 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.
## SDK additions from this investigation (retained, build-clean, golden where applicable)
- `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).
## 2026-06-26 — positive control: same WCF client, 2020 historian vs 2023 R2
The earlier evidence triangulated the gate but lacked a clean *positive* control — proof that the
native event-query path returns rows for **some** historian, so that the 0-row 2023 R2 result can be
attributed to the server rather than to the client/protocol. This run supplies it, A/B against two
historians from the **same** WCF event-read client (`HistorianWcfEventOrchestrator`, whose wire
protocol is byte-replayed from stock 2020 captures), same 365-day window:
| target historian | transport | RegisterTags (RTag2) | result buffer | events |
|---|---|---|---|---|
| **local AVEVA Historian 2020** | WCF, integrated | **0 — success** | terminal after rows | **5** |
| **2023 R2** (the C2 server) | WCF, certificate | (gate, as documented) | 10-byte 0-row header → long-poll | **0** |
SQL ground truth (`Runtime.dbo.Events`) for the same two boxes: the 2020 historian holds ~51.6k events
over the window, the 2023 R2 holds ~71.5k — both populated. So the **identical native WCF event-read
client returns real events from a 2020 historian and zero from the 2023 R2 server**. That isolates the
zero-rows to the 2023 R2 server: not the client, not the protocol, not our serializers.
Notes / honesty caveats:
- The genuinely-**stock** 2020 client (`aahClientManaged.dll` v2020.0406.2652.2, driven by reflection)
could **not** be run end-to-end here: against the local 2020 historian (services patched to build
3383.3) it self-blocks at `StartEventQuery` with `Invalid InterfaceVersion` (242) — a client-side
build/version gate, and the stock client has no version-bypass. Our client (which *does* bypass the
version check and byte-replays the same native sequence) is the faithful proxy that reaches the rows.
- Cross-check on the gRPC leg the same day: the **stock 2023 R2** client (native Event connection, its
own correct event query) returned **0 rows** over 30d/90d/365d/3yr against the 2023 R2 server; the
2026-06-22 "50 rows" stock capture did not reproduce. Same server-gate, both transports, both clients.
- Output sanitized throughout (counts, native return codes, buffer lengths only — no event identity,
host, or credentials).
@@ -34,13 +34,16 @@ namespace AVEVA.Historian.Client.Grpc;
/// (the read path proved the front-door session is sufficient over gRPC).
/// </para>
/// <para>
/// <b>Live status (2026-06-22):</b> the chain runs end-to-end and <c>StartEventQuery</c> succeeds, but
/// <c>GetNextEventQueryResultBuffer</c> <b>long-polls</b> 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 <b>row-level retrieval is not yet live-verified</b>; verifying parsed rows over
/// gRPC awaits an event-bearing 2023 R2 server. This is tooled + completes cleanly, NOT proven to
/// return rows.
/// <b>Live status — server-gated (settled 2026-06-25):</b> the chain runs end-to-end and
/// <c>StartEventQuery</c> succeeds, but <c>GetNextEventQueryResultBuffer</c> <b>long-polls</b> 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 <b>not</b> an empty-box
/// artifact: the live 2023 R2 server holds tens of thousands of events yet scopes <b>0 rows</b> to a
/// managed connection. Every client-controllable layer was byte-matched to the stock client that returns
/// rows (see <c>docs/reverse-engineering/grpc-event-query-capture.md</c>) — the gate is a server-internal
/// per-connection retrieval working-set, <b>not client-fixable</b>. The legacy WCF transport is not a
/// fallback on 2023 R2 (<c>docs/reverse-engineering/wcf-event-read-spike-results.md</c>). Tooled +
/// completes cleanly, but proven NOT to return rows over a managed connection.
/// </para>
/// </summary>
internal sealed class HistorianGrpcEventOrchestrator
@@ -101,9 +104,12 @@ 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 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)
@@ -169,16 +175,21 @@ 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 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}). 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 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;
@@ -53,6 +53,25 @@ public sealed class HistorianClientOptions
/// </summary>
public string? ServerDnsIdentity { get; init; }
/// <summary>
/// Optional WCF "Via" address (e.g. <c>net.tcp://host:42568</c>). When set, the SDK's WCF
/// channel factories <b>connect</b> to this address while still addressing the SOAP message
/// <c>To</c> the logical endpoint built from <see cref="Host"/>/<see cref="Port"/>. 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 <see cref="Host"/>/<see cref="Port"/> 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).
/// </summary>
public string? ConnectViaAddress { get; init; }
/// <summary>
/// Diagnostic override for the native OpenConnection mode the WCF event-read chain uses (default
/// <c>0x402</c>, read-only process). Set to e.g. <c>0x501</c> (event) or <c>0x401</c> (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.
/// </summary>
public uint? EventReadConnectionModeOverride { get; init; }
/// <summary>
/// For <see cref="HistorianTransport.RemoteGrpc"/>: when true the channel uses TLS
/// (<c>https://</c>); when false it uses plaintext (<c>http://</c>). Matches the stock
@@ -29,8 +29,9 @@ internal enum HistorianServiceInterface
/// <item>Retrieval (<c>Retr</c>) interface version = 4</item>
/// <item>Transaction (<c>Trx</c>) interface version = 2</item>
/// </list>
/// The Status (<c>Stat</c>) service's <c>GetInterfaceVersion</c> returns 0 (not a real
/// version), so the Status interface is validated for reachability only, never value.
/// The Status (<c>Stat</c>) service's <c>GetInterfaceVersion</c> 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
@@ -48,14 +49,19 @@ 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
/// <c>docs/reverse-engineering/grpc-interface-versions.md</c>. All captured values were already
/// accepted — no widening of <see cref="AcceptedVersions"/> was required.
/// </summary>
public const uint HistoryInterfaceVersionGrpc2023R2 = 12;
/// <summary>
/// True when the service interface reports a meaningful version that should be matched.
/// Status is reachability-only (its <c>GetInterfaceVersion</c> returns 0).
/// Status is reachability-only (its <c>GetInterfaceVersion</c> is not a real version —
/// 0 on 2020 WCF, 4 on 2023 R2 gRPC).
/// </summary>
public static bool IsValueGated(HistorianServiceInterface service) => service switch
{
@@ -29,9 +29,12 @@ namespace AVEVA.Historian.Client.Wcf;
/// +0x2A value bytes, native width by tag type:
/// Float → Float32(4) · Double → Float64(8) · Int2 → Int16(2)
/// Int4 → Int32(4) · UInt4 → UInt32(4)
/// Int8 → Int64(8) · UInt8 → UInt64(8)
/// </code>
///
/// Captured for the five analog types <c>EnsureTagAsync</c> supports (Float/Double/Int2/Int4/UInt4).
/// Live-captured for Float/Double/Int2/Int4/UInt4; Int8/UInt8 mirror the captured
/// 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.
/// </remarks>
internal static class HistorianHistoricalWriteProtocol
@@ -52,7 +55,10 @@ internal static class HistorianHistoricalWriteProtocol
/// <paramref name="tagGuid"/> is the per-tag GUID (from the gRPC tag-info read),
/// <paramref name="dataType"/> is the tag's declared analog type (selects the value width), and
/// <paramref name="receivedTimeUtc"/> is the storage/received timestamp the orchestrator stamps.
/// Throws <see cref="ProtocolEvidenceMissingException"/> for tag types without a captured encoding.
/// Throws <see cref="ProtocolEvidenceMissingException"/> 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 <see langword="double"/>;
/// full 64-bit range is a separate follow-on.
/// </summary>
public static byte[] SerializeAddStreamValuesBuffer(
Guid tagGuid,
@@ -117,10 +123,22 @@ internal static class HistorianHistoricalWriteProtocol
BinaryPrimitives.WriteUInt32LittleEndian(b, checked((uint)value));
return b;
}
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, Int8, UInt8.");
}
}
@@ -58,9 +58,13 @@ internal static class HistorianTagWriteProtocol
];
/// <summary>
/// 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 <see cref="HistorianWcfTagClient"/> 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
/// <see cref="HistorianWcfTagClient"/> 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.
/// </summary>
public static byte GetAnalogDataTypeCode(Models.HistorianDataType dataType) => dataType switch
{
@@ -70,8 +74,10 @@ internal static class HistorianTagWriteProtocol
Models.HistorianDataType.UInt4 => 0x11,
Models.HistorianDataType.Int2 => 0x29,
Models.HistorianDataType.Int4 => 0x31,
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, Int8, UInt8."),
};
private static readonly byte[] AnalogPadding16 = new byte[16];
@@ -118,8 +124,10 @@ internal static class HistorianTagWriteProtocol
/// <summary>
/// Serializes a CTagMetadata payload for an analog tag. Live-verified for Float,
/// Double, Int2, Int4, UInt4 — see <see cref="GetAnalogDataTypeCode"/> 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 <see cref="GetAnalogDataTypeCode"/> 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.
/// </summary>
@@ -41,7 +41,7 @@ internal static class HistorianWcfAuthChainHelper
try
{
IHistoryServiceContract2 historyChannel = historyFactory.CreateChannel();
IHistoryServiceContract2 historyChannel = HistorianWcfClientCredentialsHelper.CreateChannel(historyFactory, options);
ICommunicationObject historyChannelCo = (ICommunicationObject)historyChannel;
try
{
@@ -30,6 +30,24 @@ internal static class HistorianWcfClientCredentialsHelper
}
}
/// <summary>
/// Creates a channel from <paramref name="factory"/>, honoring
/// <see cref="HistorianClientOptions.ConnectViaAddress"/> when set: the channel <b>connects</b> to
/// the Via address while still addressing the SOAP message <c>To</c> 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
/// <c>factory.CreateChannel()</c> at every WCF event/read channel-creation site.
/// </summary>
public static TChannel CreateChannel<TChannel>(ChannelFactory<TChannel> 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
{
public static readonly AcceptAnyCertificateValidator Instance = new();
@@ -8,10 +8,16 @@ using AVEVA.Historian.Client.Wcf.Contracts;
namespace AVEVA.Historian.Client.Wcf;
/// <remarks>
/// 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
/// <see cref="LastResultBufferLength"/> diagnostic so a follow-up capture can decode the wire shape.
/// Mirrors HistorianWcfReadOrchestrator but targets IRetrievalServiceContract4 for the event flow. The
/// event row-buffer layout is decoded (<see cref="HistorianEventRowProtocol"/>; verified against real
/// captured rows). A <b>2023 R2</b> historian <i>does</i> serve this transport via the <b>certificate</b>
/// (TLS) endpoint (the cert transport + <c>NegotiateAuthentication</c> auth work cross-platform; the
/// integrated/Windows transport does not tunnel). With the <c>0x501</c> event connection mode CM_EVENT
/// registration succeeds — but <c>StartEventQuery</c> still returns a 0-row buffer and long-polls: event
/// rows are <b>server-gated</b> per connection on 2023 R2, the same wall as the gRPC path, and not
/// client-fixable (see <c>docs/reverse-engineering/wcf-event-read-spike-results.md</c> and
/// <c>grpc-event-query-capture.md</c>). The native return codes 76/85 noted below were 2020-historian
/// observations.
/// </remarks>
internal sealed class HistorianWcfEventOrchestrator
{
@@ -143,7 +149,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);
@@ -163,7 +169,7 @@ internal sealed class HistorianWcfEventOrchestrator
try
{
IRetrievalServiceContract4 channel = factory.CreateChannel();
IRetrievalServiceContract4 channel = HistorianWcfClientCredentialsHelper.CreateChannel(factory, _options);
ICommunicationObject channelCo = (ICommunicationObject)channel;
try
{
@@ -0,0 +1,103 @@
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;
/// <summary>
/// Live evidence test (C3a): reads the four unauthenticated <c>GetInterfaceVersion</c> 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 <c>HISTORIAN_GRPC_HOST</c> is absent (offline / CI). The captured integers
/// are recorded in <c>docs/reverse-engineering/grpc-interface-versions.md</c> and close the C3a
/// "2023 R2 gRPC server-version integers not yet captured" gap.
/// </summary>
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 — assert UiError==0 only; UiVersion is not value-gated
// (observed 4 on 2023 R2 gRPC, 0 on 2020 WCF).
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,
};
}
}
@@ -517,19 +517,26 @@ public sealed class HistorianGrpcIntegrationTests
}
// Plan #2: ReadEvents over gRPC. The chain runs end-to-end and StartEventQuery succeeds
// (no InvalidOperationException), but — confirmed live 2026-06-22 — GetNextEventQueryResultBuffer
// LONG-POLLS and returns zero rows: the gRPC server blocks to the deadline instead of
// returning the synchronous 5-byte code-85 terminal the 2020 WCF op returns, so the orchestrator
// reaches its no-data terminal with zero rows and (rather than assert a possibly-false "no events"
// empty) throws ProtocolEvidenceMissingException.
// (no InvalidOperationException), but GetNextEventQueryResultBuffer LONG-POLLS and returns zero
// rows: the gRPC server blocks to the deadline instead of returning the synchronous 5-byte
// code-85 terminal the 2020 WCF op returns, so the orchestrator reaches its no-data terminal
// with zero rows and (rather than assert a possibly-false "no events" empty) throws
// ProtocolEvidenceMissingException.
//
// IMPORTANT (2026-06-22): the zero rows are NOT "no events on the server". Verified against the
// live 2023 R2 box, which holds 19,356 events in the last 30 days (SQL ground truth via the INSQL
// linked server, Runtime.dbo.Events). The EMPTY-FILTER gRPC event query simply does not match
// them. So the gate here is the empty-filter request shape (filter / namespace / event-tag
// registration), NOT data availability — this is capture-gated (needs a native gRPC event-query
// capture), not server-gated. Flip to asserting parsed rows once that capture lands and the
// request is corrected. The chain stays BOUNDED (no multi-minute hang) via the short
// SERVER-GATED on 2023 R2 (settled 2026-06-26; supersedes a 2026-06-22 draft that read the zero
// rows as a client-side empty-filter/request-shape "capture gate"). Three live controls against
// the same event-bearing 2023 R2 server retire the request-shape theory:
// 1. The STOCK AVEVA 2023 R2 client (aahClientManaged, native Event connection, its OWN correct
// event query — not our empty-filter shape) returns 0 rows over 30d / 90d / 365d / 3yr.
// The 2026-06-22 "50 rows" stock capture did NOT reproduce.
// 2. SQL ground truth (Runtime.dbo.Events) shows the server holds tens of thousands of events
// in those windows (newest minutes old) — so it is emphatically NOT "no events".
// 3. POSITIVE CONTROL: the same native WCF event-read client (protocol byte-replayed from stock
// 2020 captures) returns real events (5) from a local AVEVA Historian 2020 over the identical
// sequence, but 0 (10-byte 0-row header + long-poll) from this 2023 R2 server.
// The protocol works; the 2023 R2 server simply does not scope event rows to a managed connection.
// Not client-fixable (see docs/reverse-engineering/grpc-event-query-capture.md +
// wcf-event-read-spike-results.md). The chain stays BOUNDED (no multi-minute hang) via the short
// registration + poll deadlines. (Set a small HISTORIAN_GRPC_TIMEOUT to keep this snappy.)
HistorianClient client = new(BuildOptions(host));
@@ -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));
}
@@ -62,6 +62,21 @@ public sealed class HistorianTagWriteProtocolTests
+ "09120052657465737453646B5772697465496E7432"
+ "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF"
+ "09180053444B2077726974652D52452073616E64626F78207461670904004D44415302010100000001E8030000549B0E36EDDBDC011A030904007465737410270000000000000000F03FFE00")]
// 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")]
public void SerializeAnalogCTagMetadata_PerDataType_MatchesCapturedNativeBytes(
AVEVA.Historian.Client.Models.HistorianDataType dataType,
string tagName,
@@ -249,6 +264,9 @@ public sealed class HistorianTagWriteProtocolTests
() => HistorianTagWriteProtocol.GetAnalogDataTypeCode(AVEVA.Historian.Client.Models.HistorianDataType.SingleByteString));
Assert.Throws<ProtocolEvidenceMissingException>(
() => 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<ProtocolEvidenceMissingException>(
() => HistorianTagWriteProtocol.GetAnalogDataTypeCode(AVEVA.Historian.Client.Models.HistorianDataType.UInt1));
}
[Fact]
@@ -0,0 +1,164 @@
using System.Runtime.Versioning;
using AVEVA.Historian.Client.Models;
using AVEVA.Historian.Client.Wcf;
using Xunit.Abstractions;
namespace AVEVA.Historian.Client.Tests;
/// <remarks>
/// 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 <see cref="HistorianWcfEventOrchestrator"/> 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 <c>HISTORIAN_WCF_EVENT_HOST</c> (independent of the gRPC live vars so it never runs by
/// accident). Optional: <c>HISTORIAN_WCF_EVENT_PORT</c> (default 32568), <c>HISTORIAN_WCF_EVENT_USER</c>
/// + <c>HISTORIAN_WCF_EVENT_PASSWORD</c> (absent =&gt; IntegratedSecurity), <c>HISTORIAN_WCF_EVENT_SPN</c>
/// (Kerberos SPN override; the default is the LocalPipe identity and will not authenticate remotely),
/// <c>HISTORIAN_WCF_EVENT_DAYS</c> (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"
/// </remarks>
[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");
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;
}
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");
string? dnsId = Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_DNSID");
string? via = Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_VIA");
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);
// 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 = 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,
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
{
// 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<int> readTask = Task.Run(async () =>
{
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)
{
// 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}, 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}");
_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)" / "<short>") — 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");
}
}
@@ -21,6 +21,11 @@ public sealed class WcfHistoricalWriteProtocolTests
"4f4e0100380000002e00" + "ca9735f7f841b244b56f9c14ccfeac32" + "b09bc72fd701dd01" + "c000" + "c0100100" + "104b59f3e701dd01" + "0000000039300000")]
[InlineData(HistorianDataType.UInt4, 305419896d,
"4f4e0100380000002e00" + "e7ae22d8e4cc65439ebd8bcb09402974" + "602d6663d701dd01" + "c000" + "c0100100" + "498af726e801dd01" + "0000000078563412")]
// 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")]
public void SerializeAddStreamValuesBuffer_MatchesCapturedNativeBuffer(HistorianDataType dataType, double value, string capturedHex)
{
byte[] captured = Convert.FromHexString(capturedHex);
@@ -47,6 +52,10 @@ public sealed class WcfHistoricalWriteProtocolTests
Assert.Throws<ProtocolEvidenceMissingException>(() =>
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<ProtocolEvidenceMissingException>(() =>
HistorianHistoricalWriteProtocol.SerializeAddStreamValuesBuffer(
Guid.NewGuid(), HistorianDataType.UInt1, DateTime.UtcNow, 1.0, DateTime.UtcNow));
}
[Fact]