Item 3: the gRPC read-back against the live 2023 R2 server proves the SDK-sent
event PERSISTS (independently read back from event history), resolving the older
WCF/M2 caveat ("server accepts AddS2 but the local dev box does not persist to
v_AlarmEventHistory2") as an environment artifact, not an SDK gap. Also retire the
stale "fresh native capture (SendEvent gRPC framing)" next-step note — SendEvent
over gRPC is now shipped + live-validated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC
Add SendEventAsync to the write surface: M2 event-send, now on both transports.
WCF runs Open2 event-mode -> CM_EVENT registration -> AddS2; gRPC runs v8 Event
OpenConnection -> registration -> HistoryService.AddStreamValues. Both carry the
same "OS" event VTQ buffer (no distinct event RPC, not the "ON" historical buffer);
gRPC path live-validated end-to-end. Only original events with string properties.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC
Tested the binary-dive "use deleteFromServer=true" hypothesis directly against
the native client (local 2020 WCF box, Capture-DeleteTagExtendedProperties.ps1
cross-session sync trick). Result: the native DeleteTagExtendedPropertiesByName
with deleteFromServer=true returns Success=true, but the property is re-fetchable
and re-deletable across repeated fresh sessions — it is NEVER durably removed.
So the native client itself only performs an optimistic client-side cache delete
the server does not durably honor (the HCAL cache-sync model the decompile shows).
This supersedes the earlier "code=1, prop survives" note (that was the same-session
sync-gate failure; with proper cross-session sync it returns Success yet still does
not durably delete). A managed DeleteTagExtendedPropertiesAsync would return a
misleading success, so it correctly stays unshipped. Handoff item 7 updated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC
Captured the native 2023 R2 client's gRPC event send (new capture-send-event
harness scenario): it rides HistoryService.AddStreamValues with the SAME "OS"
(0x534F) storage-sample buffer the WCF path already uses (HistorianEventWrite-
Protocol) — confirming "no distinct RPC", and that it is NOT the historical
write's "ON" buffer. Diffed the write-enabled vs read-only v8 Event open: byte-
identical apart from per-session crypto, so the existing OpenSession event path
is reused unchanged.
So SendEvent-over-gRPC was pure assembly of proven parts:
- HistorianGrpcEventWriteOrchestrator = v8 Event open + CM_EVENT registration
(UpdC3/RegisterTags/EnsureTags) + AddStreamValues(OS buffer).
- HistorianClient.SendEventAsync now routes to it for RemoteGrpc (WCF otherwise).
Live-validated end-to-end against the 2023 R2 server: pure-managed SDK send →
AddStreamValues BSuccess=true → the event reads back from the server (markers
confirmed in returned event rows). The native gRPC RegisterTags(24B) +
EnsureTags(86B) byte-match our serializers (new GrpcEventSendProtocolTests
golden, closing the 83-vs-86 EnsureTags question). Gated live test
SendEventAsync_OverGrpc_AcceptsEvent (opt-in HISTORIAN_GRPC_EVENT_SEND=1).
331 offline tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC
Mined the full decompiled stock 2023 R2 managed client as the oracle for every
still-pending gRPC item. Governing fact: ArchestrA.HistorianAccess is a C++/CLI
shim into native HistorianClient; the managed Grpc*Client wrappers have zero
call sites, so buffer-building/dispatch for the pending items is native (absent
from the binaries). Sharpened verdicts into handoff.md:
- Items 4/5/6 + OpenStorageConnection: hard-confirmed walled, with real reasons
(SQL gated out client-side via IsManagedHistorian; no Revision RPC in the gRPC
contract; LoadBlocks response is a native blob behind the storage console handle).
- Items 3 (SendEvent) and 7 (DeleteTEP): moved from vague to precise, LOCAL-box
capturable targets (PackToVtq btValues / DeleteTagExtendedPropertiesByName
BtInput with deleteFromServer=true).
Also correct the HistorianGrpcRevisionProbe doc comment: it probes the
non-streamed ORIGINAL-insert path (AddNonStreamValues), a distinct capability
from revision EDITS (native AddRevisionValues trio, no gRPC RPC) — the prior
comment conflated them.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC
Bring the doc header current with the merged event-row parser fix: bump
"Last updated" to 2026-06-23 and correct the Build And Test known-good
result from 321/321 to the actual 328/328 (build clean 0/0), noting the
+7 are the HistorianEventRowProtocolTests golden + gated-capture coverage.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC
Docs-only. Updates handoff.md item 1 to reflect that all four next-session angles for the
gRPC event zero-rows are tested and ruled out (transport, metadata/cert, topology, data
store) and that the parse path is verified against the provided client with a latent
multi-row bug fixed. Corrects two historical event-row-layout skeletons that described
0x1E as a per-row marker (it is a one-time buffer header field; rows are markerless).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC
Updated the live-blocker item (1) to reflect that all four next-session angles for the
gRPC event zero-rows are now tested and ruled out (transport, metadata/cert, topology,
data store) and that the parse path is verified against the provided client with a latent
multi-row bug fixed. Corrected the two historical event-row-layout skeletons that wrongly
described 0x1E as a per-row marker: it is a one-time buffer header field and rows are
markerless (which is why the old parser returned only the first row of any multi-row
buffer, on WCF too).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC
Three steps verifying the gRPC event read against the provided 2023 R2 client:
- Decompiled the stock managed client (Archestra.Historian.GrpcClient, HistorianAccess):
confirms no hidden client-side difference. The stock client is gRPC-Web/HTTP-1.1 (same
transport as ours), m_metadata is gzip-only, the ClientInterceptor is a no-op, and it
presents no TLS client cert. The event-query orchestration lives in the native C++ core.
- Captured decrypted HTTP/1.1 frames of a native capture-event (50 rows) vs our SDK (0
rows) through a TLS-terminating tee proxy. Found the native splits services across 5
connections and runs the event query on a dedicated RetrievalService connection; tested
replicating that (HISTORIAN_GRPC_EVENT_SPLIT_CHANNEL) -> still 0 rows, so the server
correlates by session handle, not connection. Topology is not the gate.
- Verified the parse path against the provided client's real result buffer (50 events) and
fixed a latent bug: the event-row buffer is version + rowCount + a one-time 0x1E header
field then MARKERLESS rows; the parser wrongly treated 0x1E as a per-row marker and
decoded only the first row of any multi-row buffer. This also affected the shipped WCF
event read (identical v9 header). Fixed to a 10-byte header + markerless rows, accepting
version 9 (WCF) and 11 (gRPC). The real 50-row buffer now decodes to exactly 50 events.
Net: every client-side angle for the gRPC zero-rows is exhausted (payload, transport,
metadata/cert, topology, data store) -> the gate is a server-internal per-connection
retrieval working-set. The parse path is now verified against real 2023 R2 event data on
both transports, and WCF event reads now correctly return all rows of a multi-row buffer.
gRPC event-row retrieval stays auth-solved / parse-verified / retrieval-server-gated.
328 offline tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC
Used the provided stock client as an oracle to verify the event read path. The
capture-event harness returns 50 real events, and the instrument-grpc-nonstream rewrite
captured the exact GetNextEventQueryResultBuffer.result buffer (63,192 bytes, version
0x0B=11, rowCount 50 = 25 Alarm.Set + 25 Alarm.Clear). Feeding that real buffer through
HistorianEventRowProtocol.Parse exposed a latent parser bug.
The real buffer layout is: version(2) + rowCount(4) + headerField(4, =0x1E) followed by
MARKERLESS rows (rowFormat(2)=7 + filetime(8) + 8x u16 slots + compact-ascii type +
propCount + props). The parser wrongly treated the one-time 0x1E field as a per-row
marker and re-consumed [marker+format] for every row, so it decoded only the FIRST row
of any multi-row buffer and stopped. This is not gRPC-specific: the captured WCF v9
buffer has the identical 0900 <rowCount> 1E000000 0700 header, so the shipped WCF event
read had the same latent multi-row truncation.
Fix: read a 10-byte buffer header (skip the 0x1E field once) and parse markerless rows;
accept container version 9 (WCF) and 11 (gRPC), mirroring the interface-version gate that
accepts History 11 and 12.
Verified: the real 50-row buffer now decodes to exactly 50 events, ending cleanly at
end-of-buffer (Parse_RealStockClientCapture_DecodesAllEvents, gated on
HISTORIAN_EVENT_CAPTURE_NDJSON so it skips without the gitignored capture), plus a
synthetic v11 golden test. 328 offline tests pass.
The parse path is now verified against the provided client's real event data on both
transports; the only remaining gap for gRPC events is the server delivering rows to our
connection (the documented retrieval-server-gate).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC
Pursued hypothesis #3 (connection-frame capture). Built a TLS-terminating tee proxy
(artifacts/.../httpcap/, gitignored: self-signed server cert, forwards through the
loopback tunnel, logs decrypted HTTP/1.1 + gRPC-Web both directions) and ran a native
capture-event (returns 50 rows) and our SDK diagnostic (0 rows) through the SAME
proxy/upstream for a clean A/B.
Findings:
- The stock client is gRPC-Web/HTTP-1.1 (alpn empty), and clientCert=none on every
connection — confirming (with the decompile) that hypothesis #2 (TLS client cert) is
moot: the native presents no client cert.
- Connection topology differs: the native opens 5 TLS connections, one per service, and
runs the event query (StartEventQuery/GetNext/EndEventQuery) on a DEDICATED
RetrievalService connection, separate from the HistoryService connection that opened and
registered the session. Our SDK collapses every service onto one connection. (Matches the
decompile: the stock client has a separate GrpcClientBase per service.)
- Framing differs benignly: native uses content-length + Expect: 100-continue; SDK uses
transfer-encoding: chunked. The server accepts both (StartEventQuery returns a valid
handle), so framing is not the gate. No hidden header on either side.
Tested the topology hypothesis with a new env-gated switch
(HISTORIAN_GRPC_EVENT_SPLIT_CHANNEL=1): run StartEventQuery/GetNext/EndEventQuery on a
dedicated RetrievalService connection (no re-handshake, reusing the session handle —
mirroring native conn4), registration staying on the main connection. Result: still
0B00000000001E000000 (0 rows), QH=1063. Splitting the event query onto its own connection
does not make rows flow — the server correlates by session handle, not connection, so
topology is not the row-scoping gate.
Every angle is now exhausted (payload, transport, metadata/interceptor/cert, topology,
data store). The gate is a server-internal per-connection retrieval working-set in the
native HistorianClient C++ core, unreachable from a pure-managed client. Conclusion
unchanged: auth-solved / retrieval-server-gated; ReadEventsAsync over gRPC keeps the
no-row throw, event reads use WCF. 56 offline gRPC/event tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC
Closed the blind spot the zero-rows conclusion had leaned on: prior cycles used wire
capture (instrument-grpc-nonstream hooks only byte[] params), blind to gRPC metadata,
interceptors, channel options. Read the stock managed source directly
(histsdk-2023r2-analysis/decompiled/Archestra.Historian.GrpcClient + HistorianAccess;
the pure-managed assemblies decompile cleanly though mixed-mode aahClientManaged crashes
ILSpy).
Findings:
- GrpcClientBase.InitializeBase uses GrpcWebHandler (GrpcWebMode, HttpVersion 1.1) — the
stock client speaks gRPC-Web over HTTP/1.1, the SAME transport as our SDK. This corrects
the premise of hypothesis #1: there was never a native Grpc.Core HTTP/2 path to differ
from; the stock client returning 50 rows is itself gRPC-Web. The HTTP/2 disproof's
conclusion stands and is reinforced (identical transport on both sides).
- m_metadata on every RPC (incl. StartEventQuery/GetNextEventQueryResultBuffer) is only
grpc-internal-encoding-request: gzip — exactly our header set. The ClientInterceptor is
a no-op (empty LogCall). So the "invisible per-connection metadata/header" blind spot is
confirmed empty — no hidden client-side identity the byte[] capture missed.
- CreateEventQuery/StartQuery/MoveNext are not in managed code; the managed
GrpcRetrievalClient.StartEventQuery is a thin one-RPC stub. The query logic lives in the
native C++/CLI HistorianClient core — consistent with the working-set being native/server-side.
Every client-controllable layer is now confirmed identical by reading the stock source,
not just by wire match: request bytes, transport, channel options, gRPC metadata,
interceptor. The remaining difference is below the managed surface / server-side.
Conclusion unchanged: gRPC event-row retrieval is auth-solved / retrieval-server-gated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC
Pursued the two server-side/connection hypotheses from grpc-event-query-capture.md
for the gRPC event zero-rows blocker:
- #1 transport (DISPROVEN): native HTTP/2 (no GrpcWebHandler) returns the byte-identical
version-11 rowCount-0 terminal as gRPC-Web; the full v8 chain (ExchangeKey auth,
CM_EVENT registration, StartEventQuery) runs end-to-end over both. Added
HistorianGrpcChannelFactory.CreateHttp2 + HISTORIAN_GRPC_EVENT_HTTP2 switch.
- #4 SQL ground truth (ANSWERED): the event store has no per-connection column; the
rich Events view is served live by the Historian engine via the INSQL OLE DB
provider. Same engine + same window: 71,332 events via INSQL vs 0 via gRPC for our
connection. The data is global/unscoped — the gate is the gRPC RetrievalService's
per-connection in-process execution state, not data scoping or transport.
Three independent angles now exhausted (client payload byte-identical, transport
HTTP/2 == gRPC-Web, data store global). gRPC event-row retrieval stands documented as
auth-solved / retrieval-server-gated; ReadEventsAsync over gRPC keeps the no-row throw
and event reads use WCF. 326 offline tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC
Pursued the server-side SQL angle for the gRPC event zero-rows. Built a read-only
SOCKS5-relay + Microsoft.Data.SqlClient probe (gitignored, artifacts/.../sqlschema/)
and dumped the live Runtime event schema. Findings:
- No per-connection / per-client / per-session column exists anywhere in the event
store. The only scoping-like columns on Events/EventHistory/snapshots are event
content (Source_* origin, User_* acker, Provider_NodeName, SourceServer replication).
- The rich Events view is not a relational table — it is served live by the Historian
engine via the INSQL OLE DB provider (linked servers INSQL/INSQLD; encrypted remote
view). The SQL EventHistory base table holds only 168 rows / 1 tag.
- Decisive: for the SAME -90d..now window the gRPC StartEventQuery diagnostic returned
0 rows, the Events view via INSQL returns 71,332 events (most recent Alarm.Set firing
seconds before the probe). Same engine, same window — INSQL serves the data, gRPC
withholds it from our connection.
So there is nothing in the data to scope by: the zero-row gate is the gRPC
RetrievalService's per-connection in-process execution state, not data scoping or
transport (the same class of wall as DeleteTagExtendedProperties). Combined with the
transport disproof, three independent angles are now exhausted — client payload
(byte-identical), transport (HTTP/2 == gRPC-Web), and data store (global, unscoped).
gRPC event-row retrieval stands documented as auth-solved / retrieval-server-gated;
ReadEventsAsync over gRPC keeps the no-row throw and event reads use WCF.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC
Tested grpc-event-query-capture.md's leading next-session hypothesis — that the
native client's Grpc.Core HTTP/2 transport (vs our Grpc.Net.Client + GrpcWebHandler
gRPC-Web) is why event reads return zero rows. Added HistorianGrpcChannelFactory
.CreateHttp2 (plain HTTP/2 over SocketsHttpHandler, no gRPC-Web wrap) and an
HISTORIAN_GRPC_EVENT_HTTP2 switch on the event orchestrator (event path only; reads
stay gRPC-Web).
Live side-by-side against the event-bearing 2023 R2 server, everything else held
constant: the full v8 chain (ExchangeKey auth, CM_EVENT RegisterTags/EnsureTags=True,
StartEventQuery with a valid handle) runs end-to-end over BOTH native HTTP/2 and
gRPC-Web, and the server returns the byte-identical version-11 rowCount-0 terminal
(0B00000000001E000000) on both transports. Transport choice makes no difference —
the leading hypothesis is disproven and the zero-row scoping sits above the gRPC
transport layer.
Also confirmed the native capture-event harness queries a 30-day historical window
(returns 50 rows), so the native read is connection-scoped historical retrieval,
not a live subscription.
CreateHttp2 + the env switch + the EventChannelMode diagnostic are retained for
further connection-level probing. 44 offline tests pass; orchestrator stays on the
no-row throw.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC
Records the row-retrieval pickup now that the v8 ExchangeKey auth is solved and the
gap is proven connection-level (not client payload):
- grpc-event-query-capture.md: a "NEXT SESSION — the server-side / connection angle"
section — what's already proven (don't redo), the in-place tooling, and ordered,
testable hypotheses (HTTP/2 vs gRPC-Web transport [leading], TLS client cert,
HTTP/2 frame capture, SQL event-store scoping).
- handoff.md item 1: updated to "v8 auth solved; row retrieval connection-gated",
pointing at the NEXT SESSION section; the "to move any item" summary updated.
Doc-only; sanitization scan clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC
Extends the instrument-grpc rewrite to log string (strHandle) + uint (uiHandle /
queryRequestType) params, not just byte[], and captures our SDK's live v8
openParameters for a byte-diff against the native.
Result of the exhaustive comparison (all live-confirmed via the opt-in
EventReadDiagnostic test):
- StartEventQuery request: byte-identical to the native (v6 layout)
- v8 OpenConnection openParameters: byte-identical to the native (302B) once
ClientNodeName matches — every control byte/ConnectionType/token/ShardId
- handle usage identical: ExchangeKey->contextKey, registration->storage GUID
(strHandle), query->client uint (uiHandle); handles valid (RTag/EnsT=True)
- queryRequestType=3, registration order, gzip metadata header — all match
- window has events (native returns 50 now); eventCount not it
Every observable client-side byte matches the native, yet the server scopes 0
events to our connection. The event RPCs succeed over our transport and return a
valid EMPTY result (not a transport error), so this is a connection/server-level
difference (session affinity tied to the native Grpc.Core HTTP/2 connection or a
connection identity used to scope events) — invisible to and unfixable by client
payload matching. Needs server-side insight, not more wire RE.
Added opt-in diagnostics (RegistrationDiag, LastResultBufferHex,
LastEventOpenRequestHex). 326/326 offline; gated test still pins the no-row throw.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC
Continues closing the event-row gap after the v8 ExchangeKey/RC4 auth breakthrough.
- HistorianGrpcHandshake: the v8 EVENT path skips StorageService.ValidateClientCredential
(the native event connection authenticates purely via ExchangeKey + the RC4 token;
running the Negotiate loop establishes a different session scope).
- HistorianGrpcEventOrchestrator.RegisterCmEventTag: simplified to the exact native
gRPC event sequence (UpdateClientStatus -> RegisterTags -> EnsureTags -> GetHistorianInfo
-> GetSystemParameter x7), dropping the 2020-WCF-era cross-service GetV probes and
params-before-register that the gRPC event flow does not use. eventCount 5 -> 100.
- Opt-in diagnostics (RegistrationDiag, LastResultBufferHex/LastErrorBufferHex; gated
EventReadDiagnostic test) for the continued investigation.
STATUS: auth + StartEventQuery + registration all succeed live (RTag/EnsT=True, valid
query handle), but GetNext returns version-11 rowCount-0 while the native returns 50 for
a BYTE-IDENTICAL request. Every observable wire element matches the native. The remaining
unknown is the string/uint HANDLE field VALUES the native uses per event RPC — the
instrument-grpc capture logged only byte[] params, not the handle fields. Next: extend
the IL rewrite to log strHandle/uiHandle/queryRequestType, re-capture, and match.
326/326 offline; gated test still pins the no-row throw.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC
Implements the reverse-engineered v8 credential token in pure managed code and
wires the full event-connection auth chain. Live result: the v8 OpenConnection
now AUTHENTICATES against the 2023 R2 server (past the 132/171 AuthenticationFailed
wall) — the crypto is solved.
- HistorianNativeHandshake.DeriveExchangeKeyClientKey: client key = SHA256(ECDH
shared secret) via ECDiffieHellman.DeriveKeyFromHash(SHA256), matching the native
ECDiffieHellmanCng{Hash,SHA256}.DeriveKeyMaterial.
- BuildExchangeKeyCredentialToken + Rc4: token = RC4(password-UTF16LE, key=MD5(clientKey)).
Reproduces a live-captured token EXACTLY (verified offline) — the native
HistorianCrypto.NRC4_V2.aahCryptV2 scheme (MD5-keyed RC4). Pure managed; nothing
AVEVA shipped. RC4 pinned by the standard test vector.
- OpenSession(eventConnection:true): ExchangeKey -> derive client key -> token ->
v8 OpenConnection with ConnectionType=Event + the token. Orchestrator re-armed.
- HistorianAddTagsProtocol.SerializeCmEventEnsureTagsGrpc: the 86-byte native gRPC
CM_EVENT EnsureTags (8-byte header + ...2f27 event-type GUID), replacing the
2020 WCF 83-byte CTagMetadata on the gRPC event registration.
Goldens: RC4 standard vector + token construction. 326/326 offline.
KNOWN REMAINING: the event query still returns zero rows (GetNext yields a 10-byte
zero-row buffer). Auth + StartEventQuery succeed; the query-layer detail (vs the
native row-returning capture) is the last step. Gated test still pins the no-row
throw; opt-in diagnostic (HISTORIAN_GRPC_EVENT_DIAG) surfaces the journey.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC
Loaded dnlib in PowerShell (ILSpy crashes on the mixed-mode assembly) and scanned
the IL to recover the entire v8 token construction:
- <Module>::CHistoryConnectionGrpc.GetClientKey drives the ECDH: ECDiffieHellmanCng
{KeyDerivationFunction=Hash, HashAlgorithm=SHA256, KeySize=256} -> ExchangeKey ->
CngKey.Import(serverPub, EccPublicBlob) -> DeriveKeyMaterial = SHA256(shared secret),
the 32-byte client key.
- aahClientCommon.CClientBase.ConfigureOpenConnection (the lone GetClientKey caller)
builds the 26-byte token via HistorianCrypto.NRC4_V2.aahCryptV2 = a custom MD5-keyed
RC4 stream cipher with a version prefix:
* body/HashData = MD5 (verified by the round constants 0xd76aa478... + shifts 7/12/17/22)
* prepare_key = RC4 KSA from a 16-byte MD5 key
* enc_buffer = MD5 -> key, then rc4encrypt; enc prepends PrefixV2/InnerPrefixV2
(the constant 0x8e token marker)
So token = prefix + RC4(plaintext, key=MD5(keyMaterial)), keyMaterial tied to the
SHA256(ECDH secret) client key. 100% reproducible in pure managed code (RC4+MD5).
Remaining (next cycle): read ConfigureOpenConnection's exact key/plaintext/prefix bytes,
implement aahCryptV2 managed-side, set the v8 token, live-test. Frida CNG + dnlib are
the RE path; nothing AVEVA is shipped.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC
Frida-hooked Windows CNG (scripts/frida/aahclientmanaged-cng-exchangekey.js) during
a real native ExchangeKey to recover the token derivation:
- The ECDH + KDF are standard CNG driven by managed System.Security.Cryptography
.ECDiffieHellmanCng: NCryptSecretAgreement (P-256) -> NCryptDeriveKey(KDF=HASH,
SHA256, 32 bytes). So the derived key = SHA256(ECDH shared secret).
- "ECK1" is the standard CNG BCRYPT_ECCPUBLIC_BLOB magic (P-256), confirming our
BuildExchangeKeyClientHello wire format.
- The 26-byte token (constant 0x8e marker) is a custom construction over the
derived key: a 528-candidate offline cracker (HMAC/SHA/AES-GCM/CBC/CTR over the
derived key x request slices x creds) found no match, and it matches none of the
traced hash digests. It is built in aahClientManaged's C++/CLI <Module> code
between the DeriveKeyMaterial call and the openParameters assembly.
Next: ILSpy cannot decompile the mixed-mode assembly (crashes, exit 70); use dnlib
(IL-level) to dump the <Module> method referencing DeriveKeyMaterial and read the
post-derive token construction. 2 of 3 layers cleared (key exchange + client key);
the 3rd (token) is localized, pending dnlib extraction. Orchestrator stays on v6.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC
Records that the pure-managed P-256 ExchangeKey works (cleared the v8 client-key
check; error advanced to 132/171 AuthenticationFailed). The remaining layer is the
26-byte credential-token KDF, which requires recovering the native key derivation.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC
Implements HistoryService.ExchangeKey as a pure-managed P-256 ECDH key exchange
and wires it ahead of the v8 Event OpenConnection.
- HistorianNativeHandshake.BuildExchangeKeyClientHello / DeriveExchangeKeySecret:
.NET ECDiffieHellman (nistP256); wire format "ECK1" + u32(32) + X(32) + Y(32),
decoded from the live capture. No native AVEVA dependency.
- HistorianGrpcHandshake.OpenSession(eventConnection: true): runs ExchangeKey on
the context-key handle before the v8 OpenConnection.
- Guardrail HistorianGrpcHandshakeRoutingTests scoped to the token-loop closure:
still pins that the Negotiate token loop routes to ValidateClientCredential (not
ExchangeKey), while allowing the legitimate ExchangeKey call in OpenSession.
Live result: ExchangeKey succeeds (server accepts our public key) and the v8
OpenConnection error advances from 132/34 "Failed to get client key" to 132/171
AuthenticationFailed — the ECDH cleared the client-key layer. The remaining
blocker is the 26-byte v8 credential token, which must be derived from the ECDH
shared secret (token KDF, not yet recovered). Orchestrator stays on v6 (set
eventConnection: true to re-arm once the KDF lands). 323/323 offline.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC
Records the full v8 openParameters byte map, the ECDH ExchangeKey finding, and
the Path A live result: the v8 OpenConnection on a ValidateClientCredential
session is rejected with native 132/34 "EstablishConnection Failed to get client
key". The v8 path requires the client key established by HistoryService.ExchangeKey
(ECDH), so the next route is Path B — implement ExchangeKey ("ECK1" + 64-byte
P-256 point) via .NET ECDiffieHellman, then reissue the v8 open.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC
Builds the native 2023 R2 version-8 OpenConnection format, which (unlike v6)
carries a ConnectionType byte (Event vs Process) — required because the 2023 R2
server returns event rows only on an Event connection.
- HistorianOpen2Protocol.SerializeNativeOpenConnectionVersion8: reproduces the
302-byte v8 layout decoded from a live capture (version 8, markers, client-key
GUID, username HString, length-prefixed credential token, ClientType /
ConnectionType / flag / constant word / compact metadata / two empty strings;
the tail reuses WriteClientCommonInfo). Golden-tested
(Version8EventSerializerReproducesCapturedNativeStructure).
- HistorianNativeHandshake.BuildEventOpenConnectionVersion8Request: ConnectionType=
Event, zeroed credential token (mirroring how v6 zeros its credential block and
relies on the separate ValidateClientCredential handshake).
- HistorianGrpcHandshake.OpenSession: optional eventConnection switch; the
OpenConnection failure path now decodes the native error (type/code/ASCII).
Path A (reuse ValidateClientCredential + zeroed token) was live-tested and
DISPROVEN: the server parses the v8 buffer but rejects it at the auth check with
native 132/34 "EstablishConnection Failed to get client key" — the v8 path looks
up the client key in the registry HistoryService.ExchangeKey (ECDH) populates,
not the one ValidateClientCredential does. The event orchestrator is therefore
reverted to the v6 session (gated test still pins the no-row throw). The v8
serializer/builder are retained for Path B (implement ExchangeKey). 323/323
offline tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC
Item 1 is no longer capture-gated (the capture is done, merged 8ad160b). Update it
to: the capture-event run read 50 events from the stock client; the v6 request is
shipped; the remaining gate is the native v8 Event-type OpenConnection (a
ConnectionType field the SDK's v6 Open2 format lacks), a scoped RE+impl follow-on.
Points at docs/reverse-engineering/grpc-event-query-capture.md. Also corrected the
"to move any item" summary so item 1 no longer reads as needing a fresh capture.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC
Records the 2026-06-22 capture of the stock 2023 R2 gRPC event read and the
diagnosis of why row retrieval is gated:
1. The working StartEventQuery request is version 6 (vs the SDK's v5) — shipped in
the companion code commit.
2. Rows additionally require an EVENT-type connection. Decoding the captured
OpenConnection.openParameters (native format v8) shows a ConnectionType byte
(Event=01 / Process=02) right after ClientType — a field the SDK's v6 Open2
format does not have (it writes ClientType then ConnectionMode back-to-back).
So the v6 buffer the SDK sends (accepted for reads) cannot mark the connection
as Event, and the 2023 R2 server returns event rows only on an Event
connection. The native client also used the ExchangeKey cert auth path.
Conclusion: making event rows flow over gRPC requires the SDK to emit the native
v8 OpenConnection format with ConnectionType=Event (a larger RE+implementation
effort), scoped as a follow-on. v6 is retained as the captured-correct request.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC
Captured the stock 2023 R2 client doing a gRPC event read (50 rows flowed) to
resolve the open "gRPC event ROW retrieval returns zero rows" item. Two captured
differences from our SDK's path; this lands the first (necessary) one plus the
capture tooling.
- HistorianEventQueryProtocol.CreateStartEventQueryAttempts: add a `version`
parameter (default 5 = the 2020 WCF format, unchanged). The gRPC event
orchestrator now opts into version 6 — the leading `06` plus a 5-byte trailing
zero pad — which is the envelope the stock 2023 R2 client sends. The two
buffers are otherwise byte-identical (filter block, UTC string, metadata
namespace). Golden test Version6EmptyFilterMatchesCapturedGrpcEnvelope pins it.
- Grpc2023CaptureHarness: new `capture-event` scenario drives HistorianAccess
over an Event-type gRPC connection (CreateEventQuery -> EventQueryArgs ->
StartQuery -> MoveNext) so the wide-net instrument-grpc-nonstream rewrite dumps
StartEventQuery.requestBuffer + the row result. Hostname defaults sanitized to
HISTORIAN_GRPC_HOST / "localhost" (removed hardcoded server name).
NECESSARY BUT NOT SUFFICIENT: live validation shows v6 alone does not make rows
flow — the read also requires an Event-type connection, which our SDK's v6 Open2
format cannot express (see the companion docs commit). The gated
ReadEventsAsync_OverGrpc_* test correctly still pins the no-row throw. 322/322
offline tests pass; WCF event path unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC
Live SQL ground truth (user-authorized one-time read via SOCKS->SQL relay)
disproves the gate on the open gRPC event-row item. The live 2023 R2 server
IS event-bearing — Runtime.dbo.Events holds 19,356 rows in the last 30 days
(90,944 in 365) — yet the empty-filter gRPC event query still returns zero rows
and long-polls to the deadline over that same window.
So GetNextEventQueryResultBuffer returning nothing is NOT "no events on the
server"; the empty-filter request shape (filter / namespace / event-tag
registration) doesn't match existing rows. The remaining work is a fresh native
gRPC event-query capture of the stock client, not access to a different server.
- handoff.md: rewrite open-item #1 with the SQL numbers + capture-gated framing;
update the "to move any item" summary to match.
- HistorianGrpcIntegrationTests: correct the event-read test comment (drop the
false "idle dev box holds no events" rationale; document 19k-events-yet-zero-rows).
No behavior change (test edit is comment-only). Sanitization scan clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC
The handoff doc was anchored at 2026-05-04 (read-path blocker era). Add a
"Current Status (2026-06-22)" section at the top summarizing the full shipped
read/write/config/client-side surface across WCF + gRPC, the 8 remaining gated
items (event-row retrieval, active-SF magnitude, SendEvent capture, SQL wall,
R4.2 edits, ReadBlocks, the disproven DelTep gRPC probe, deferred-by-design),
and what would unblock each. Reframe the old "Active Blocker" as a historical
read-path record; fix the stale 55/55 test count to 321/321 offline.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC