Files
histsdk/docs/reverse-engineering/implementation-status.md
T
dohertj2 c95824a65d Initial commit: managed .NET 10 AVEVA Historian SDK + reverse-engineering toolkit
Full read-only SDK (src/AVEVA.Historian.Client) implementing the CLAUDE.md required
surface against AVEVA Historian's binary WCF protocol — no native AVEVA runtime
dependency. All operations live-verified against a local Historian:

- ProbeAsync, ReadRawAsync, ReadAggregateAsync, ReadAtTimeAsync, ReadEventsAsync
- BrowseTagNamesAsync, GetTagMetadataAsync (17 native data-type codes mapped)
- GetConnectionStatusAsync, GetStoreForwardStatusAsync, GetSystemParameterAsync
- 108/108 unit + integration tests pass

Includes the reverse-engineering toolkit (tools/AVEVA.Historian.ReverseEngineering)
used to decode the protocol: WCF probes, IL inspection via dnlib, and IL-rewrite
instrumentation (instrument-wcf-{write,read}message etc.) plus the .NET Framework
trace harness (tools/AVEVA.Historian.NativeTraceHarness) for parity testing.

Sanitized handoff evidence under docs/reverse-engineering/. Native AVEVA binaries
(current/, aveva-install-x64/, aveva-install-x86/) are gitignored — fetch separately
from the AVEVA installer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 06:31:48 -04:00

88 KiB
Raw Blame History

Implementation Status

Completed

  • Production SDK targets net10.0 and has no AVEVA binary references.

  • Public API now includes the intended parity surface:

    • TCP probe
    • raw, aggregate, at-time, and block history reads
    • event reads
    • tag browse and metadata calls
    • connection, store-forward, and system-parameter status calls
    • write-back intentionally remains out of scope for this read-only SDK pass
  • Internal protocol scaffolding exists:

    • HistorianConnection
    • HistorianFrameReader
    • HistorianFrameWriter
    • Historian2020ProtocolDialect
  • Evidence-backed WCF scaffolding exists:

    • WCF service names for Hist, Retr, Storage, Stat, and Trx
    • contract interfaces for history, history extensions, retrieval, retrieval extensions, and status
    • application/x-mdas message encoder wrapper
    • Net.TCP binding and endpoint factory for port 32568
    • bounded wcf-start-query harness options (--max-attempts and --timeout-seconds) so negative query probes do not have to run the full matrix
    • live local and remote GetV evidence for /Hist, /Retr, /Stat, and /Trx
    • live GetV evidence for /HistCert using MDAS over WCF Net.TCP transport security
    • ValidateClientCredential token wrapping and the native NTLM negotiate VERSION flag adjustment as isolated, tested protocol primitives
  • ProbeAsync now uses fully managed WCF/MDAS GetV calls instead of a raw TCP socket check.

  • Managed Hist.OpenConnection reaches server logic, but the older scalar operation expects the native password/session packet.

  • Managed Hist.Open2 now reaches server logic with a version-1 byte buffer. Empty credentials return custom native error 171 (AuthenticationFailed), confirming the basic byte-buffer framing and UTF-16 string encoding.

  • Managed integrated Windows Open2 succeeds when the same version-1 buffer is sent to /Hist-Integrated with WCF Windows transport security. /Hist with that binding fails before the operation call because the upgrade is not supported on the plain history endpoint.

  • The same managed integrated Windows Open2 flow succeeds remotely against <historian-host>:32568/Hist-Integrated; the returned handle is accepted by Retr.IsOriginalAllowed. The stored artifact redacts the session output buffer and transient handle value.

  • The native aahClientManaged.dll path is confirmed to open successfully with integrated Windows auth after polling GetConnectionStatus until pending clears.

  • The native integrated read path is confirmed to reach query execution: a deliberately missing tag returns native TagNotFound (127) instead of connection/authentication failure.

  • scripts\Find-GalaxyHistorizedTags.ps1 queries the local Galaxy Repository (localhost, database ZB, Windows auth) for non-array dynamic attributes with HistoryExtension.

  • OtOpcUaParityTest_001.Counter is confirmed as a live native read fixture candidate. A 1440-minute native wrapper read returned one row with timestamp 2026-04-30T11:00:29.4340342Z, Value = 0, Quality = 133, OpcQuality = 192, and QualityDetail = 248.

  • A Frida attach pass can see aahClientManaged.dll and install hooks at candidate mixed-mode RVAs, but those base + RVA hooks do not fire during the successful read. The next capture approach must intercept CLR/WCF managed byte-array calls rather than raw method RVAs. See frida-aahclientmanaged-hook-pass.md.

  • tools\AVEVA.Historian.NativeTraceHarness is a reverse-engineering-only .NET Framework harness for native integrated reads. It records sanitized reflection snapshots around OpenConnection, StartQuery, and MoveNext. The latest run confirms StartQuery assigned queryHandle = 1 and returned tag key 238, value 0, quality 133, OPC quality 192, and quality detail 248 for OtOpcUaParityTest_001.Counter. It now supports fixed UTC windows plus configurable retrieval mode and resolution; latest artifacts cover Full, Cyclic, Interpolated, and TimeWeightedAverage.

  • The native trace harness also supports --scenario event with an event connection. The latest event artifact confirms EventQuery.StartQuery succeeds and returns three sanitized local-dev event rows for a seven-day window.

  • scripts\Attach-NativeTraceHarnessWinsockCapture.ps1 and scripts\frida\aahclientmanaged-winsock.js can attach before native OpenConnection and hook Winsock plus common file/pipe APIs. Localhost, 127.0.0.1, and the machine LAN IP all completed successful native reads without observed connect/send/recv, CreateFile/ReadFile/WriteFile, or NtCreateFile/NtReadFile/NtWriteFile payload events. This is negative evidence that the installed local Historian path uses an in-process/local optimization rather than the remote Net.TCP transport path.

  • An expanded Frida method pass added decompiled QueryColumnSelector and HistorianClient.StartDataQuery/GetNextRow MethodDef RVAs. Hooks installed where Frida allowed them, but no enter/leave events fired during successful native reads. MethodDef RVAs from the mixed-mode assembly are not the actual CLR/native dispatch targets.

  • The native trace harness can now dump prepared CLR runtime method pointers for mixed-mode <Module> methods such as HistorianClient.StartDataQuery, CRetrievalConnectionWCF.StartQuery2, HistorianClient.GetNextRow, and HistorianClient.StartEventQuery. The corrected artifacts show these pointers are process-specific CLR/JIT entry addresses outside the loaded aahClientManaged.dll image, not stable DLL RVAs.

  • scripts\Attach-NativeTraceHarnessRuntimePointerCapture.ps1 automates a same-process Frida pass against those runtime pointers. It successfully generated a pre-StartQuery pointer snapshot and installed 37 absolute hooks while the native direct history read was paused. The read then succeeded, but no hook enter/leave callbacks fired. This is negative evidence for using MethodHandle.GetFunctionPointer() addresses as direct Frida hook targets.

  • scripts\Attach-NativeTraceHarnessAahClientExportCapture.ps1 and scripts\frida\aahclient-exports.js attempted to hook the procedural mdas_* exports from aahClient.dll. A successful local direct history read did not load aahClient.dll, and dumpbin /dependents confirms aahClientManaged.dll does not import aahClient.dll or aahClientCommon.dll. A separate -DumpLoadedModules run showed only aahClientManaged.dll among the current AVEVA DLLs. The exported mdas_* ABI is likely a separate native client surface, not the active C++/CLI wrapper boundary used by this harness.

  • scripts\Attach-NativeTraceHarnessSystemBoundaryCapture.ps1 and scripts\frida\aahclientmanaged-system-boundary.js now hook file I/O, Nt*File, NtDeviceIoControlFile, DNS, exported Winsock calls, WSAIoctl, mswsock extension exports, Secur32, Crypt32, and NetAPI. Local direct and same-machine remote-IP reads produce no boundary callbacks beyond hook installation. A Debian relay run confirms the relay TCP connection is owned by the harness PID, but the same exported user-mode hooks still see no connect/send/recv/device-control callbacks before the security reset.

  • scripts\Run-PktmonDebianRelayCapture.ps1 records pktmon metadata for the Debian relay path without retaining raw payload bytes. The latest history run captured TCP packet metadata for <relay-host>:32568, converted the ETL to a text report, deleted the ETL, and wrote a summary confirming PayloadBytesCaptured = false and RawEtlDeleted = true.

  • Focused ildasm excerpts now confirm ArchestrA.HistoryQuery.StartQuery has an IL call at IL_02d2 to HistorianClient.StartDataQuery token 060055E4, and HistoryQuery.MoveNext has an IL call at IL_0054 to HistorianClient.GetNextRow<class DataQueryResultRow> token 0600588D. IL-rewrite instrumentation is now validated: a dnlib-written wrapper copy can run in a disposable DLL folder, and an instrumented <Module>.Query.StartDataQuery captured a 251-byte native DataQueryRequest buffer during a successful local direct read.

  • The captured full-history buffer corrected the managed HistorianDataQueryProtocol serializer. It now matches the native wrapper byte-for-byte for the deterministic OtOpcUaParityTest_001.Counter fixture. The buffer hash is 3581ef3b42b59b46503d1aa0127fa60fe4b40943e419aeab99e47e4683888851.

  • The same instrumentation path now captures the native DataQueryResultRow* memory after GetNextRow. The first full-history row confirms tag key, timestamp, quality, OPC quality, quality detail, and percent-good offsets; the artifact hash is 2c2cb06988187c1bd7793a52a71f33599542a69d5e83885c583de8bf3df5d43b.

  • The read-boundary instrumentation now logs GetNextRow arg.1 as an explicit queryHandle scalar before dumping arg.2 row memory. A combined local read captured StartDataQuery.Request SHA 543ea11af87607044067a0274b1da423cef2acbb7b4f4fab137af023a7153d7f, GetNextRow.QueryHandle = 1, and GetNextRow.DataQueryResultRow SHA 702f5248cf8319e3e02da33678ed97dfaa43666bddb88c42101d5290990a4198 in the same session.

  • A follow-up combined IL instrumentation pass correlated the native open, query, WCF retrieval, and row-read handles. HistorianClient.OpenConnection wrote legacy handle 2, and Query.StartDataQuery read the same value as its ClientHandleCandidate immediately before entering the common retrieval layer. The successful CRetrievalConnectionWCF.StartQuery2 call then used a different transient WCF retrieval client handle. This proves the missing read parity state is the native client/session mapping below the legacy ClientApp handle, not the 251-byte DataQueryRequest payload.

  • Instrumenting aahClientCommon.CServerClient.GetHandle token 060017F9 produced no records during a successful local history read, even while the surrounding open/query/WCF hooks fired. Direct metadata-token scanning also found no IL references to that accessor. The transient WCF retrieval handle is not obtained through GetHandle on this path.

  • Instrumenting aahClientCommon.CRetrieval.StartQuery2, aahClientCommon.CSrvRetrievalConnection.StartQuery, and CRetrievalConsoleClient.StartQuery also produced no records in the successful WCF read path. The active path is instead aahClientCommon.CClientCommon.StartQuery token 06002E86.

  • The CClientCommon.StartQuery instrumentation captured the missing handle boundary: legacy handle 2 enters Query.StartDataQuery, then CClientCommon.StartQuery calls a CClient vtable function at IL offset 0x01A3. The returned transient value exactly equals the client handle used by CRetrievalConnectionWCF.StartQuery2 and GetNextQueryResultBuffer2. The WCF server query handle returned by StartQuery2 is copied back through the queryHandle pointer, while the public managed HistoryQuery.queryHandle remains the wrapper value 1. Sanitized evidence is in cclientcommon-startquery-correlation-latest.json.

  • CClientBase.OpenConnection confirms that same vtable offset 24 is the handle accessor: the initial value is 0, then the secondary open branch succeeds and the post-open value exactly matches the later CClientCommon.StartQuery and WCF StartQuery2 client handle. The primary open branch did not emit records on this local integrated path. Sanitized evidence is in cclientbase-open-correlation-latest.json; the next target is the secondary open vtable call at IL offset 0x06D4.

  • The secondary open branch resolves to CHistoryConnectionWCF.OpenConnection3 token 06004059. It calls IHistoryServiceContract2.OpenConnection2 with a 1346-byte request and gets a 42-byte response with empty error. The deserialized response initializes the vtable offset 24 handle later used by CClientCommon.StartQuery and /Retr.StartQuery2. In the captured 42-byte response, byte 0 is 0x03 and the transient /Retr client handle is UInt32 little-endian at offset 1. CClientInfo.DeserializeOpenConnectionOutParams token 06004008 confirms the response layout: byte protocol version, uint32 client handle, 16 session bytes, one FILETIME, and for version 3 an additional FILETIME. The observed five-byte tail is preserved as opaque trailing data until a caller or native field assignment is identified. CClientInfo.SerializeOpenConnectionInParams4 token 06004003 confirms the observed request starts with protocol byte 6, a 16-byte client key, and a boolean content selector. The observed selector is 0, so the active content branch is SerializeOpenConnectionInParams2Content token 06004005, whose field order matches the existing native version-3 content serializer: host string, UInt16 secret length plus secret bytes, client type, connection mode, metadata namespace, two strings, and client common info. CMetadataNamespace.Save uses compact/scrambled empty strings in this envelope, so the empty metadata namespace is 10 bytes rather than the older 13-byte plain layout. With that correction, managed replay with the observed 1026-byte zero credential block reaches server credential validation instead of packet-version rejection. Both /Hist-Integrated Windows transport and /HistCert certificate transport now return native error 171 with an extended message saying the server context could not be found. That makes the leading 16-byte OpenConnection3 key the next evidence target. A same-run memory-correlation pass now proves request bytes 1..16 exactly match CClientInfo +1240, and request byte 17 matches CClientInfo +1608. CClientInfo.SetContextKey token 0600386E copies its GUID argument to the same CClientInfo +1240 field, but no direct IL caller exists. The actual source is now narrowed to CClientBase.ConfigureOpenConnection token 0600388C: it calls native CClientContext.AuthenticateClient token 06005DCB on CClientBase +2112, then copies 16 bytes from CClientBase +2176 (CClientContext +64, the GetContextKey location) to CClientBase +1480. Since this aligns with CClientInfo +1240, the prefix is the context key that native AuthenticateClient validates before OpenConnection3, not an arbitrary fresh managed replay GUID. Runtime instrumentation around AuthenticateClient confirms the field equals the generated client key before authentication, changes during the native auth path, then matches the copied CClientInfo field and the OpenConnection3 request prefix. A direct managed Hist-Integrated.ValidateClient2 probe reaches the service but fails with native error type 4/code 51 before ExchangeKey. Sanitized IL instrumentation of CHistoryConnectionWCF.ValidateClient (06004044) and CHistoryConnectionWCF.GetClientKey (06004041) was present in an isolated wrapper copy and the logger smoke test passed, but a successful native integrated history read emitted no ValidateClient2 or ExchangeKey records. That negative result rules out the obvious managed WCF auth methods for this path. Native disassembly of CClientContext.AuthenticateClient (06005DCB, RVA 0x298BA0) shows it uses Secur32 AcquireCredentialsHandleW with package Negotiate, creates a context GUID at CClientContext +64, and loops through CHistoryConnectionWCF.ValidateClientCredential / WCF ValCl. Instrumenting ValCl confirms two successful native rounds: a 69-byte client input to 239-byte server output, then a 93-byte client input to a one-byte terminal output. The client input envelope is one round byte, a 4-byte little-endian token length, then an NTLMSSP token; the first native input prefix is 01400000004e544c4d5353500001000000b7b218e209000900370000000f000f. System-boundary Frida evidence now confirms that these wrapped payloads come directly from SSPI: native calls AcquireCredentialsHandleW with package Negotiate, then two InitializeSecurityContextW calls for NT SERVICE\aahClientAccessPoint. The raw SSPI output token lengths are 64 and 88 bytes, matching the 69- and 93-byte ValCl bodies after the 5-byte AVEVA wrapper is added. The first call returns SEC_I_CONTINUE_NEEDED (0x90312) and the second returns success. Native disassembly of CClientContext.AuthenticateClient now maps the loop directly: it is native-only, calls UuidCreate, stores the context GUID at CClientContext +64, calls AcquireCredentialsHandleW, and enters a loop at VA 0x180298DE0. The internal helper at VA 0x180298F30 calls InitializeSecurityContextW, uses request flags 0x2081C on the first round and 0x81C on later rounds, then writes the ValCl stream envelope as round byte, UInt32 token length, and token bytes. The outer loop copies CClientContext +64 as the context key and calls the connection's ValidateClientCredential virtual method; in the captured local read, CClientContext +0x50 is zero, so the normal connection-object virtual path at VA 0x180298E95 is selected. A stable IL-side memory window around CClientContext.AuthenticateClient shows the embedded CClientContext mutates only four regions in the first 256 bytes during successful auth: pointer-sized fields at offsets +8, +16, and +24, plus the context key at +64..+79. Dereferencing the +8 target shows SSPI/security-package metadata strings such as Schannel, Microsoft Kerberos V1.0, TSSSP, System.Core, and Default TLS SSP. The +16 target is partially decoded as pointer-rich native state: nested targets at offsets 0 and 8 have the same pointer/count shape and no readable ASCII or UTF-16 Historian payload strings, while the nested target at offset 64 is all zero. The +24 value is not safe to treat as a directly readable buffer. This narrows the missing managed replay state to native CClientContext object state and server acceptance of that client context key, rather than the SSPI token bytes alone. A managed NegotiateAuthentication probe can reproduce that first wrapped input exactly after setting the NTLM VERSION negotiate flag, but standalone ValCl still fails with native error type 4/code 1 on both /HistCert and /Hist-Integrated. That means the active blocker is not the first token envelope; it is native connection/session prerequisite state around proxy initialization or server-side context registration. A follow-up instrumented local read shows the native path constructs CHistoryConnectionWCF, then calls GetInterfaceVersion before auth. The first GetInterfaceVersion InitializeProxy path succeeds and SetManagedPtr sets the ready flag to 1; by both successful ValCl rounds, COperation.Start2 succeeds with error type/code 0, the existing proxy is not faulted, and the reconnect flag is 0. Branch instrumentation now shows the local native path uses connection mode 1, enters CWcfConfig.ConfigurePipeProxy, builds the uncompressed local /Hist named-pipe endpoint shape, and does not use ConfigureTcpProxy. Static IL inspection of ConfigurePipeProxy confirms this path builds a NetNamedPipeBinding with Security.Mode = None, TransactionFlow = false, native-sized MaxBufferSize/ReaderQuotas.MaxArrayLength, and the aahMDASEncoder.ClientBinding wrapper. It then creates the channel through the static ChannelFactory<T>.CreateChannel(binding, endpoint) helper and sets IContextChannel.OperationTimeout from the native timeout-minutes argument. Managed pipe probes using the same static-factory channel shape, with and without eager channel open, still fail in the same way. A managed named-pipe ValCl replay reaches GetInterfaceVersion version 11 and reproduces the first wrapped token hash, but is still rejected at round 0 with native error type 4/code 1; explicitly running the managed calls under the current Windows token does not change that result. Native handle summaries show uppercase GUID text, and managed lowercase-handle replay still fails, so handle casing is not the mismatch. Skipping GetInterfaceVersion and avoiding explicit WCF channel open in the managed TCP probe also returns native error type 4/code 1, so the mismatch is not just transport or token bytes. Because native GetInterfaceVersion creates its proxy while impersonating the stored Windows identity, the managed pipe probe was extended to create/open the channel inside the same current-token impersonation scope; that still fails at ValCl round 0, with and without also wrapping the operation calls. A local System Platform log check shows the managed pipe ValCl failures correspond to aahClientAccessPoint warnings: ValidateClientCredential caught exception: System.NullReferenceException at HistoryService.ValidateClientCredential line 1593. Enabling named-pipe transport security is rejected as a binding mismatch, which confirms the native uncompressed pipe binding shape. A new direct .NET Framework WCF probe using the same aa/Hist contract, MDAS content type, uncompressed named pipe, and SSPI-generated first token also fails with native error type 4/code 1 and the same server log exception. That rules out a simple .NET 10 WCF regression: the success condition belongs to AVEVA's mixed/native wrapper state, not a plain full-framework WCF client. Static IL inspection of COperation.Start2 shows it is only a local gate: it checks operation-priority and bandwidth controls and can set local gate-failure error codes 243 or 150, but it does not call a WCF service operation. Static inspection of the local aahClientAccessPoint.exe service now maps the receiving side of this failure. HistoryService.ValidateClientCredential parses the WCF handle as a GUID, allocates a CServerBuffer, copies the ValCl byte array into that buffer, and calls native CServerNode.ProcessServerToken. That native method parses exactly the AVEVA token envelope already observed on the client side: one round byte, a 4-byte little-endian token length, then SSPI token bytes. On the first round, ProcessServerToken calls helper 0x0050FFC0, which locks the server context collection and inserts or refreshes a keyed native context object. It then calls helper 0x00517AB0 to look up that object. If no context object is returned, the server sets custom error code 0x29 and ValCl fails before any successful context registration. With a context object, helper 0x00505C00 calls Secur32 AcceptSecurityContext through the service import table at 0x005A0340, treating both success and SEC_I_CONTINUE_NEEDED (0x90312) as valid protocol progress. Only after the terminal round does HistoryService.ValidateClientCredential add the context GUID to its managed context-handle collection. This confirms the remaining managed replay gap is before or inside server ProcessServerToken context setup/lookup, not OpenConnection3 itself. A tighter IL window confirms the line number reported in System Platform logs is the catch/log path, not the exact null-reference instruction. The normal path is: Guid.TryParse on the WCF handle at IL 0x012A, CServerBuffer allocation through a vtable call at 0x0183, byte-array pointer/length copy into buffer offsets +72/+76, and CServerNode.ProcessServerToken at 0x01DC. Only when the native call returns success with continue == false does IL 0x0311 add the parsed context GUID to m_contextHandles; when continue == true, the method returns the server token without final handle registration. This keeps the runtime server helper probe as the most direct remaining evidence target. Additional server disassembly and string decoding identify the native object as aahClientAccessPoint::CServerContext. The first-round setup helper uses the server lock at CServerNode +0xE80 and keyed context map at CServerNode +0xE98, logs Adding ServerContext 0x%p, constructs a 0x3c-byte context object through helper 0x00505100, and inserts the new node through a red-black-tree insertion helper at 0x0042F590. The lookup helper reads the same map and returns the context object from map node offsets +0x20/+0x24; a null result is what drives the 0x29 custom error path. The credential helper calls AcquireCredentialsHandleW with the UTF-16 package string Negotiate. The token-processing helper is logged as aahClientAccessPoint::CServerContext::ProcessClientToken; its failure log string still says InitializeSecurityContext, but the import actually called by this server helper is Secur32 AcceptSecurityContext. HistoryService.ValidateIntegratedCredentials is a separate server path: its first instructions read ServiceSecurityContext.Current.WindowsIdentity. That explains the earlier OpenConnection2/OpenConnection3 null-reference failures on bindings where ServiceSecurityContext.Current is absent. Those errors are evidence of selecting the wrong integrated-credential path, not a user/password validation result. A focused object-window capture now shows the successful native GetInterfaceVersion path populates the history proxy managed-pointer slot at CHistoryConnectionWCF +608 and the ready flag at +669; between GetInterfaceVersion completion and ValCl entry, a second managed-pointer slot at +616 is populated for the binding wrapper. Across both successful ValCl rounds the parent CHistoryConnectionWCF object window is stable, but the +608 history proxy target mutates at bytes 96..101; the +616 binding target and +640 Windows identity target remain stable. This points at native-managed proxy wrapper state as the next concrete evidence target. Static IL inspection of CHistoryConnectionWCF.Initialize narrows the meaning of the AVEVA log line that says Initialize: DataSourceId(). It is client-side connection/proxy setup, not a separate WCF Initialize operation: the method logs Initialize, retrieves or creates the managed /Hist proxy and binding through InitializeProxy<IHistoryServiceContract2>, stores those pointers at the same +608/+616 managed-pointer slots seen at runtime, then does the same for the /Trx proxy. The history proxy initializer has only the previously mapped WCF setup branches: ConfigurePipeProxy at IL 0x0098 and ConfigureTcpProxy at IL 0x038E, followed by SetProxyString. The decompiled HistoryServiceContract interfaces contain no Initialize operation contract. This makes the log line supporting evidence for local proxy setup state, but not a missing service call that a managed replay can simply add before ValCl. A focused server-side Frida probe was added at scripts\frida\aahclientaccesspoint-valcl-context.js with runner scripts\Capture-AahClientAccessPointValClContext.ps1. It hooks ProcessServerToken, the first-round context setup and lookup helpers, and the AcceptSecurityContext wrapper while logging only sanitized pointer, GUID, round, length, and return-value metadata. The script writes a .frida.log sidecar.

    An elevated PowerShell session (Admin, High Mandatory Label, SeDebugPrivilege enabled, SeImpersonatePrivilege enabled) ran both scenarios on 2026-05-03 and Frida attach was still rejected with the CLI message Failed to attach: process with pid <pid> either refused to load frida-agent, or terminated during injection. Direct frida.attach(<pid>) from the Python API reveals the actual exception class is frida.ProcessNotRespondingError, which means the agent injection handshake did not complete in time, not that the OS refused the DLL load. The original suspected cause (mitigation policy MicrosoftSignedOnly / BlockNonMicrosoftBinaries) is now disproven: Get-ProcessMitigation -Id <pid> reports every category OFF for this process, including BinarySignature.MicrosoftSignedOnly, DynamicCode.BlockDynamicCode, Cfg.Enable, ImageLoad.BlockRemoteImageLoads, ExtensionPoint.DisableExtensionPoints, and UserShadowStack.*. Process access from the elevated token also succeeds at PROCESS_ALL_ACCESS, including PROCESS_VM_OPERATION, PROCESS_VM_WRITE, and PROCESS_CREATE_THREAD, so the DACL is not blocking injection. Cross-bitness Frida (64-bit Python attaching to a fresh C:\Windows\SysWOW64\cmd.exe) attaches and runs scripts cleanly, so the WOW64 path itself is not broken. Defender real-time protection, behavior monitoring, and on-access protection are all OFF, no third-party AV/EDR product is registered with SecurityCenter2, no EDR-style filter driver is active, no frida modules appear in the target's loaded module list before or after a failed attempt, no IFEO debugger entry exists for aahClientAccessPoint.exe, and AppInit_DLLs is empty in both 64-bit and WOW64 hives. Attach attempts with realm='native', realm='emulated', and persist_timeout=30 all fail identically. The remaining likely cause is service-internal: aahClientAccessPoint.exe runs 152 threads, many in EventPairLow ALPC/SCM waits, and Frida's in-memory manual-mapper agent does not get a cooperative thread for its RPC bootstrap. This is consistent with ProcessNotRespondingError rather than a load-time rejection. The NativeRead probe still completed end-to-end with the canonical fixture row (TagKey=238, Value=0, Quality=133, OpcQuality=192, QualityDetail=248) but emitted no server-side helper events. The ManagedValCl probe ran the .NET Framework named-pipe ValCl path against net.pipe://localhost/Hist, reproduced the canonical first wrapped NTLM envelope (raw outgoing 64 bytes, wrapped 69 bytes, wrapped prefix 01400000004e544c4d5353500001000000b7b218e209000900370000000f000f), and again returned ServerSuccess=false, ServerOutputLength=0, ErrorLength=5 with NativeError {Type:4, Code:1, Name:Failure} — matching prior managed named-pipe ValCl failures and confirming the failure shape is reproducible but providing no new server-side helper visibility. None of the five diagnostic questions (whether 0x0050FFC0 ran, whether 0x00517AB0 returned a context, whether AcquireCredentialsHandleW succeeded, whether AcceptSecurityContext was reached, whether failures are pre- or post-context-map insertion) can be answered from these captures.

    ETW SSPI tracing on 2026-05-03 produced server-helper-boundary evidence without injection. A logman trace session capturing the LsaSrv {199FE037-2B82-40A9-82AC-E1D46C792B99}, LSA {CC85922F-DB41-11D2-9244-006008269001}, Microsoft-Windows-NTLM {AC43300D-5FCC-4800-8E99-1BD3F85F0320}, NTLM Security Protocol {C92CF544-91B3-4DC0-8E11-C580339A0BF8}, and Security: NTLM Authentication {5BBB6C18-AA45-49B1-A15F-085F7ED0AA90} providers at level 0xFF and keywords 0xFFFFFFFFFFFFFFFF recorded:

    Run Total events aahClientAccessPoint events lsass events
    NativeRead (success) 5610 10 4330
    ManagedValCl (fail) 133 0 121

    Successful native server-side activity is a 47-millisecond burst of legacy MOF events (Ids 30, 34, 35, 40, 84, 10, 12, 16, 17, 86) inside PID aahClientAccessPoint. The failing managed run produces zero events from the server PID at all — the server never reaches any SSPI helper invocation. lsass activity is also 35x lower in the failing run, consistent with auth never completing the first round end-to-end. This pins the previously logged HistoryService.ValidateClientCredential caught System.NullReferenceException at line 1593 to a code path before CServerNode.ProcessServerToken at IL 0x01DC.

    Static IL inspection of the full HistoryService.ValidateClientCredential body (token 0x06000774 in mixed-mode aahClientAccessPoint.exe, 779 instructions, dnlib output preserved at artifacts/reverse-engineering/server-historyservice-validateclientcredential-il-latest.json) enumerates every NRE-capable instruction reached on the straight-line happy path before the ProcessServerToken call:

    • Prologue (IL 0x0000..0x0129). Constructs a std::wstring, case-insensitively compares two wide strings via _wcsicmp (0x00A0), and calls CServerNode.LogHistorianMessage(this, _, CServerClient*, _, _, _, _) at 0x00ED. The third argument is a CServerClient*. If that pointer is derived from OperationContext.Current or related WCF context state and is null for the calling binding shape, the call site itself is an NRE candidate before Guid.TryParse is reached.
    • Guid parse and recover (IL 0x012A..0x015E). Guid.TryParse on arg.1 at 0x012A. False branch raises custom error code 28 via SError.SetToCustomError and returns. True branch calls <Module>::PtrToStringChars at 0x0150 then <Module>::GuidFromString at 0x0159 to recover a native _GUID for ProcessServerToken. Guid.TryParse cannot NRE on a non-null string; the recovery path only NREs if the string is null, which a successful TryParse rules out.
    • Allocator vtable chain (IL 0x0160..0x0183). Loads &g_ClientAccessPoint + 2328, dereferences once at 0x0178 to get the allocator pointer pAllocator, then derefs *pAllocator at 0x017E (vtable pointer) and *(vtable + 40) at 0x0182 (slot for the allocator method), and calli at 0x0183 returns CServerBuffer*. NRE candidates: 0x017E if the field at g_ClientAccessPoint + 2328 is null/uninitialised, or 0x0182 if the vtable is malformed. Both target a process-wide static, so they would fail identically for the successful native path; rule out unless the field is initialised lazily per-binding.
    • Allocator-null branch (IL 0x0189..0x01A3). brtrue.s on the returned CServerBuffer*. Null path raises custom error code 204 ("No more buffer") and returns false. So a null allocator result is handled and not the NRE source.
    • Byte-array copy (IL 0x01A8..0x01C6). ldelema System.Byte on arg.2 at 0x01AA and ldlen on arg.2 at 0x01B2. Both NRE if the WCF-deserialised inputBuffer parameter is null. The two pointer/length values are then stind.i4'd into *(CServerBuffer + 72) and *(CServerBuffer + 76), matching the documented +72/+76 offsets.
    • ProcessServerToken call (IL 0x01CA..0x01DC). Loads &(g_ClientAccessPoint + 2144) as the CServerNode* this, the parsed _GUID, the CServerBuffer*, a ref bool continue, and a ref SError, then calls ProcessServerToken (token 0x0600064F).

    The IL slice does not include exception-handler metadata (current dnlib-method output covers instructions only), so the precise source-line 1593 reported by the System Platform log catch handler cannot be mapped to one specific instruction from static IL alone. The structural narrowing is: the throw must happen at a ldelema, ldlen, ldind, or calli instruction reached before 0x01DC. The shortlist is 0x00ED (LogHistorianMessage with CServerClient*), 0x017E and 0x0182 (allocator vtable derefs), and 0x01AA / 0x01B2 (byte-array deref). All other instructions in the window either operate on managed strings already proven non-null or on static-field addresses.

    Differential analysis against the successful native path narrows this further. g_ClientAccessPoint is a process-wide singleton, so the +2328 field has the same value for both runs; the vtable chain is therefore unlikely to be the differentiator. The native wrapper's successful local read uses the same Security.Mode = None uncompressed pipe binding the managed probe uses, so ServiceSecurityContext.Current.WindowsIdentity is identical in both paths and the prologue CServerClient* derivation is also unlikely to be the differentiator. The remaining structural difference is the WCF parameter binding: if the managed probe's SOAP body schema causes WCF to deserialise inputBuffer as null even though a 69-byte wrapped token is on the wire, ldelema at 0x01AA would NRE. The managed probe sends 69 bytes per its sanitised log, but the schema expectation on the server side has not been verified end-to-end.

    The next concrete evidence target is therefore not more static IL, but a SOAP-body comparison: dump the actual <inputBuffer> element the .NET Framework probe writes versus the wire shape the native wrapper writes for /Hist-Integrated.ValCl. If the schemas differ, the WCF service deserialises arg.2 as null and the IL window decisively fails at 0x01AA. If the schemas match, the throw is earlier (the prologue's CServerClient* log argument becomes the prime suspect) and runtime confirmation needs PsExec SYSTEM injection or a signed Detours stub at IL 0x00ED.

    SOAP-body comparison on 2026-05-04 resolved this. Enabling <system.serviceModel><diagnostics><messageLogging logEntireMessage="true" logMessagesAtTransportLevel="true" .../> in AVEVA.Historian.NetFxWcfProbe.exe.config and capturing the on-the-wire SOAP body for the failing aa/Hist/ValCl request produced:

    <ValCl xmlns="aa">
      <handle>...GUID...</handle>
      <inputBuffer>AUAAAABOVExNU1NQAAEAAAC3...</inputBuffer>
    </ValCl>
    

    The 69-byte wrapped NTLM token IS on the wire as base64 inside <inputBuffer>. The matching server response, however, used <outBuff b:nil="true" .../> rather than the expected <outputBuffer .../> shape, exposing a parameter-name mismatch. ildasm against aahClientAccessPoint.exe confirmed the actual server contract is

    ValidateClientCredential(string handle,
                             uint8[] inBuff,
                             [out] uint8[]& outBuff,
                             [out] uint8[]& errorBuffer)
    

    not inputBuffer / outputBuffer. WCF builds the request body element name from the C# parameter name, so the probe sent <inputBuffer> and the server's WCF deserialiser ignored that unknown element, leaving arg.2 = inBuff = null. IL 0x01AA ldelema System.Byte then NREs, which the C++/CLI catch handler at HistoryService.cpp line 1593 maps to native error Type=4 Code=1 Failure with an empty error buffer.

    Adding [MessageParameter(Name = "inBuff")] and [MessageParameter(Name = "outBuff")] to the probe's ValidateClientCredential declaration and re-running unblocks the request: round 0 returns ServerSuccess=true, ServerOutputLength=239, ServerContinue=true, with ServerOutputPrefixHex 014e544c4d535350000200... (a 0x01 continue byte followed by NTLMSSP type-2 challenge). This matches the previously documented native-success "69-byte client input to 239-byte server output" exactly. Round 1 then sends the 88/93-byte NTLMSSP type-3 token and the server returns Type=129 Code=0x80090308 (SEC_E_INVALID_TOKEN) with a 100-byte error buffer whose ASCII payload includes aahClientAccessPoint::CServerContext::ProcessClientToken and InitializeSecurityContext. That is a real SSPI-level rejection inside AcceptSecurityContext, not the previous parameter-binding NRE — the original blocker is gone and the next layer of failure is exposed.

    The same fix is now applied to the production SDK contracts IHistoryServiceContract2.ValidateClientCredential and IStorageServiceContract.ValidateClientCredential via [MessageParameter(Name = "inBuff" | "outBuff")], preserving the conventional C# parameter names while making WCF emit the server-correct element names. ildasm against aahClientAccessPoint also revealed several other operations the SDK does not yet need (EnsT InBuff/OutBuff, EnsT2 InBuff/OutBuff, RTag2 pInBuff/outBuff, ExKey inBuff/OutBuff, StJb strJobid, GtJb strJobid/jobstatus) carry the same parameter-naming mismatch with their current SDK declarations. Those are intentionally left alone for this read-only pass; audit them when their flows become required.

    The next concrete evidence target is now SEC_E_INVALID_TOKEN on ValCl round 1: native traces showed InitializeSecurityContextW with request flags 0x2081C for the first round and 0x81C for later rounds. The .NET Framework probe uses default flags through the SSPI wrapper. Replicating those exact flags (and confirming the Negotiate target name matches the wrapper's NT SERVICE\aahClientAccessPoint) is the next testable hypothesis.

    Native SSPI flag replication on 2026-05-04 resolved this. Decoding the documented native flags:

    • 0x2081C (round 0) = ISC_REQ_IDENTIFY (0x20000) | ISC_REQ_CONNECTION (0x800) | ISC_REQ_CONFIDENTIALITY (0x10) | ISC_REQ_SEQUENCE_DETECT (0x8) | ISC_REQ_REPLAY_DETECT (0x4)
    • 0x81C (round 1+) = same minus ISC_REQ_IDENTIFY

    The probe's SspiClient previously used ISC_REQ_ALLOCATE_MEMORY | ISC_REQ_CONFIDENTIALITY | ISC_REQ_INTEGRITY | ISC_REQ_CONNECTION = 0x10910, which is missing ISC_REQ_REPLAY_DETECT, ISC_REQ_SEQUENCE_DETECT, and round-0 ISC_REQ_IDENTIFY. The REPLAY/SEQUENCE pair gates NTLM MIC (Message Integrity Code) generation in the type-3 response message; without them the type-3 message has no MIC and the server's AcceptSecurityContext rejects it with SEC_E_INVALID_TOKEN.

    Adding ISC_REQ_REPLAY_DETECT, ISC_REQ_SEQUENCE_DETECT, and round-0-only ISC_REQ_IDENTIFY (keeping ISC_REQ_ALLOCATE_MEMORY for buffer convenience and tracking the round count internally in SspiClient) reproduces the documented native ValCl sequence byte-for-byte from a fully managed client:

    Round Outgoing wrapped Server output ServerContinue NativeError
    0 69 bytes 239 bytes (NTLM type-2 challenge) true none
    1 93 bytes 1 byte (0x00 terminal) false none

    FinalServerSuccess: true, FinalNativeError: null. This matches the previously documented "successful native two ValCl rounds: 69-byte client input to 239-byte server output, then 93-byte client input to one-byte terminal output" exactly.

    The long-standing managed ValCl blocker is therefore resolved. The chain that successful native reads use is now reproducible from a managed client end-to-end:

    1. Hist-Integrated.GetV → version 11
    2. Hist-Integrated.ValCl round 0 (69 → 239 bytes) ✓
    3. Hist-Integrated.ValCl round 1 (93 → 1 byte terminal) ✓

    The next steps in the chain — OpenConnection3 (with the now-known context key), Retr.IsOriginalAllowed, and Retr.StartQuery2 — should now be exercisable without server-side helper failures, because the prerequisite native context-map registration that ProcessServerToken performs has finally been completed by a managed client.

    The production SDK currently has no SSPI client (only the wrap/unwrap helpers in HistorianWcfAuthenticationProtocol). When the SDK auth flow is wired up for the production read path, it must use the same native-equivalent flags. .NET 10's System.Net.Security.NegotiateAuthentication does not expose ISC_REQ_* directly, so the SDK will likely need to P/Invoke InitializeSecurityContextW (or equivalent) to set IDENTIFY + REPLAY_DETECT + SEQUENCE_DETECT exactly. Sample reference implementation in tools/AVEVA.Historian.NetFxWcfProbe/Program.cs SspiClient.

    End-to-end chain verification on 2026-05-04. With the WCF parameter fix and SSPI flag fix in place, the .NET Framework probe was extended to chain Hist.Open2 (replaying the captured 1346-byte v6 request with the leading 16 context-key bytes spliced to match the managed ValCl GUID), then Retr.IsOriginalAllowed, then Retr.StartQuery2 (replaying the captured 251-byte OtOpcUaParityTest_001.Counter DataQueryRequest). The result:

    Step Outcome
    Hist.GetV version 11
    Hist.ValCl round 0 239-byte server response, NTLM type-2 challenge
    Hist.ValCl round 1 1-byte terminal response
    Hist.Open2 42 bytes, version 0x03, transient /Retr client handle decoded
    Retr.GetV version 4
    Retr.IsOriginalAllowed(handle) return code 0, isAllowed = true
    Retr.StartQuery2(handle, 1, 251 bytes, ...) Success=true, response 31 bytes, QueryHandlePresent=true, no error

    The 31-byte StartQuery2 response SHA-256 4c062b5ce8181308f0f46bfd8c6088acb52e6ade94401651b7d3ccc8952edfb5 is byte-for-byte identical to the previously captured native success response (recorded in the existing Wcf.StartQuery2 block of Current Hard Blocker and in instrumented-openconnection3-correlation/capture.ndjson). The full AVEVA Historian native wire protocol chain through StartQuery2 is now reproducible end-to-end from a fully managed client.

    This required one additional contract fix: IRetrievalServiceContract2 also had the parameter-name mismatch class of bug. The actual server contract uses pRequestBuff / pResponseBuff / errSize / err on StartQuery2 (and pResultBuff / errSize / err on GetNextQueryResultBuffer2, errSize / err on EndQuery2); the SDK declared them as requestBuffer / responseBuffer / errorSize / errorBuffer. [MessageParameter(Name = ...)] attributes added to src/AVEVA.Historian.Client/Wcf/Contracts/IRetrievalServiceContract2.cs.

    Replay-only details: the Open2 body was constructed by reading the captured 1346-byte v6 native request from artifacts/reverse-engineering/openconnection3-request-replay.bin and overwriting bytes 1..16 with the new managed-side context-key GUID; the 251-byte data query was loaded as-is from artifacts/reverse-engineering/startdataquery-request-replay.bin. Both inputs and the captured native fields they contain (machine name, process name, etc.) are local to the dev host. The probe's stdout JSON only echoes lengths, SHAs, version bytes, and prefix hex; it does not echo identity payloads.

    The next concrete step is the production-SDK pass to wire the managed auth chain: implement an SSPI client that emits the native flags, replace the captured-replay Open2 payload with a schema-driven serialiser using HistorianOpen2Protocol.SerializeNativeOpenConnection3Version6 (already in the SDK), and chain ValCl → Open2 → /Retr.StartQuery2 → /Retr.GetNextQueryResultBuffer2 for the canonical read fixture. The reverse-engineered protocol is now fully understood end-to-end for the read path; remaining work is plumbing.

    Production SDK plumbing landed on 2026-05-04. The full managed read path is now wired and verified end-to-end against the live local Historian:

    • src/AVEVA.Historian.Client/AVEVA.Historian.Client.csproj — added System.ServiceModel.NetNamedPipe 10.0.652802 package.
    • src/AVEVA.Historian.Client/Wcf/HistorianWcfBindingFactory.cs — added CreateMdasNetNamedPipeBinding (Security.Mode = None + MDAS encoder wrapper) and CreatePipeEndpointAddress. Marked [SupportedOSPlatform("windows")] since named pipes are Windows-only.
    • src/AVEVA.Historian.Client/Wcf/HistorianSspiClient.cs (new) — P/Invoke InitializeSecurityContextW / AcquireCredentialsHandleW / DeleteSecurityContext / FreeCredentialsHandle with internal round counter and the canonical native flag bitmasks (0x2081C round 0 / 0x81C later, plus ALLOCATE_MEMORY for buffer convenience). Constants exposed as internal for test verification.
    • src/AVEVA.Historian.Client/Wcf/HistorianDataQueryProtocol.cs — added TryParseGetNextQueryResultBufferRows for the raw/Full row layout: 6-byte buffer header (UInt16 version=9, UInt32 rowCount) followed by rowCount self-describing rows of UInt32 tagKey + UInt32 tagNameChars + tagName UTF-16 + UInt32 sampleBlockCount + Int64 startUtcFileTime + UInt32 quality + UInt32 qualityDetail + UInt32 opcQuality + Double numericValue + Double percentGood + 1-byte marker + 34 trailing bytes. The 5-byte error/terminal 04 1E 00 00 00 (type 4, code 30 = "no more data") is recognised as the "stop looping" signal.
    • src/AVEVA.Historian.Client/Wcf/HistorianWcfReadOrchestrator.cs (new) — chains Hist.GetV → Hist.ValCl × N → Hist.Open2 → /Retr channel → Retr.GetV → Retr.IsOriginalAllowed → Retr.StartQuery2 → loop Retr.GetNextQueryResultBuffer2. Builds the OpenConnection3 v6 request through HistorianOpen2Protocol.SerializeNativeOpenConnection3Version6 with the documented native constants (ClientType=4, ConnectionMode=0x402, FormatVersion=4, HcalVersion=17, DataSourceId=ClientDllVersion="2020.406.2652.2", ClientCommonInfo.ClientVersion=999_999, ShardId=Guid.Empty) and a 1026-byte zero credential block.
    • src/AVEVA.Historian.Client/HistorianClientOptions.cs — added Transport (defaults to LocalPipe) and TargetSpn (defaults to NT SERVICE\aahClientAccessPoint).
    • src/AVEVA.Historian.Client/HistorianTransport.cs (new) — enum LocalPipe / RemoteTcpIntegrated / RemoteTcpCertificate; only LocalPipe is implemented in this pass.
    • src/AVEVA.Historian.Client/Models/HistorianSample.cs — added PercentGood positional property.
    • src/AVEVA.Historian.Client/Protocol/Historian2020ProtocolDialect.cs — constructor now takes HistorianClientOptions; ReadRawAsync delegates to HistorianWcfReadOrchestrator on Windows + Transport.LocalPipe, throws ProtocolEvidenceMissingException otherwise.
    • tests/AVEVA.Historian.Client.Tests/ — new HistorianSspiClientTests (5 flag-selection tests), WcfBindingFactoryTests (3), WcfDataQueryResultBufferTests (5 golden-byte parser tests using the captured 570-byte fixture). HistorianClientIntegrationTests.ReadRawAsync_AgainstLocalHistorian_ReturnsAtLeastOneRow (gated on HISTORIAN_HOST=localhost + HISTORIAN_TEST_TAG) exercises the full managed chain against the live local Historian.

    Test results with HISTORIAN_HOST=localhost and HISTORIAN_TEST_TAG=OtOpcUaParityTest_001.Counter: 69/69 pass, including the live read. Without the env vars, the integration test skips cleanly and 64/64 pass.

    The reverse-engineering phase for the read path is complete. The production SDK now reads history end-to-end against the live local Historian using only managed code — no aahClientManaged.dll or aahClient.dll loaded in the consuming process. Aggregate (Interpolated / TimeWeightedAverage) read modes, remote TCP transport, explicit username/password auth, event reads, and other contracts (EnsT, RTag2, ExKey, StJb, GtJb — all of which ildasm shows have the same parameter-naming mismatch as the resolved ValCl / StartQuery2 operations) remain follow-up work but no longer face protocol-discovery blockers — only the parameter-rename audit + per-mode row-layout decoding + transport-binding additions.

    All listed follow-up work landed on 2026-05-04. The SDK now supports: ReadRawAsync, ReadAggregateAsync, ReadAtTimeAsync, and ReadEventsAsync — all verified against the live local Historian (72/72 tests pass with the integration env vars set). Specifically:

    • [MessageParameter] audit (Phase B2): ildasm against aahClientAccessPoint.exe was used to verify server parameter names for every operation across IHistoryServiceContract, IHistoryServiceContract2, IRetrievalServiceContract, IRetrievalServiceContract3, and IRetrievalServiceContract4. [MessageParameter(Name = ...)] attributes were applied to ~30 parameter-name mismatches: EnsT, EnsT2, RTag2, ExKey, AddTEx, DelTep, StJb, GtJb, AddS2, UpdC3, DelT, VldC2, OpenConnection, AddTags, RegisterTags, AddStreamValues, ValidateClient, UpdateClientStatus, SetClientTimeOut, CloseConnection, StartQuery, GetNextQueryResultBuffer, ExecuteSqlCommand, GetRecordSetByteStream, StartTagQuery, QueryTag, StartEventQuery, GetNextEventQueryResultBuffer, EndEventQuery, GetShardTagidsByTagnameAndSource. Build clean, 72/72 tests pass.

    • Aggregate row parser (Phase B4): HistorianDataQueryProtocol.TryParseGetNextQueryResultBufferAggregateRows parses the same wire shape as raw rows but interprets FILETIME #1 (at row offset 8 + tagNameLen*2 + 4) as the interval EndTimeUtc and the FILETIME at trailer offset 2 (row offset 8 + tagNameLen*2 + 43) as StartTimeUtc — derived from native struct evidence (+0x28 = EndDateTime, +0x150 = StartDateTime) and the captured raw fixture where Start == End. The orientation was verified by the live ReadAggregateAsync test against OtOpcUaParityTest_001.Counter returning consistent TimeWeightedAverage rows.

    • Aggregate + at-time wiring (Phase B5): HistorianWcfReadOrchestrator.ReadAggregateAsync and ReadAtTimeAsync chain Hist.GetV → ValCl × N → Hist.Open2 → Retr.IsOriginalAllowed → Retr.StartQuery2 → loop Retr.GetNextQueryResultBuffer2. The aggregate request maps the public RetrievalMode enum to the documented HistorianDataQueryRequest.QueryType values (Full → 2, Interpolated → 3, TimeWeightedAverage → 5, Cyclic → 4); other modes throw ProtocolEvidenceMissingException until they have a fixture- backed mapping. ReadAtTimeAsync issues a one-tick Interpolated window per requested timestamp and folds each aggregate result into a HistorianSample.

    • Event flow (Phase B6+B7+B8): HistorianWcfEventOrchestrator mirrors the read orchestrator but targets IRetrievalServiceContract4.StartEventQuery and GetNextEventQueryResultBuffer. The chain reaches StartEventQuery successfully — a real victory, since the previous probe attempts failed at this exact call site. GetNextEventQueryResultBuffer then returns native error type=4 code=85 (0x55), which is a NEW server response (not the canonical code=30 "no more data"). The orchestrator treats any 5-byte type=4 error buffer as a soft terminal and surfaces the full code via the LastErrorBufferDescription diagnostic. Likely investigation targets for code 85: the existing notes describe a native CreateDefaultEventTag step that calls RegisterTags2 to register a synthetic CM_EVENT tag before any event read can return rows; we currently skip that prerequisite. Event-row WCF wire format also remains undecoded (only native struct snapshots are captured), so even if rows came back we'd need a fresh capture to parse them. Both of these are documented as open follow-ups.

    • Remote TCP transport (Phase B1 + B9): HistorianWcfBindingFactory.CreateBindingPair(options) selects binding + endpoint pairs by HistorianTransport: LocalPipenet.pipe://host/Hist + /Retr (existing pipe binding); RemoteTcpIntegratednet.tcp://host:port/Hist-Integrated (NetTcpBinding + Windows transport security) for the auth chain plus plain MDAS Net.TCP /Retr for queries (per existing evidence that /Retr rejects Windows transport security); RemoteTcpCertificate/HistCert over MDAS+certificate + plain /Retr. The orchestrators now consume the binding pair transport-agnostically. Untested against a live remote Historian on this host, but the auth chain is ready to fire.

    • Explicit username/password auth (Phase B3): HistorianSspiClient has a second constructor overload that builds a SEC_WINNT_AUTH_IDENTITY Unicode struct and passes it as pAuthData to AcquireCredentialsHandleW. The auth chain helper picks the constructor based on HistorianClientOptions.IntegratedSecurity: when false and UserName is set, it parses DOMAIN\user (or treats the value as a bare user) and forwards it with Password. Untested against a live remote Historian; reserved for the explicit-creds production path.

    Verified test results with HISTORIAN_HOST=localhost and HISTORIAN_TEST_TAG=OtOpcUaParityTest_001.Counter: 72/72 pass (15-second total), including the four live integration tests (ProbeAsync, BrowseTagNamesAsync, GetTagMetadataAsync, ReadRawAsync_AgainstLocalHistorian, ReadAggregateAsync_AgainstLocalHistorian, ReadAtTimeAsync_AgainstLocalHistorian, ReadEventsAsync_AgainstLocalHistorian). Without env vars, all tests skip cleanly to 72/72.

    Remaining open work (no protocol discovery — pure plumbing or fresh capture):

    • Decode the event-row WCF wire format (no captured fixture yet). Most direct path: instrument the native trace harness to capture Wcf.GetNextEventQueryResultBuffer.ResultBytes while running the successful native event read, base-64-encode into the existing capture.ndjson, then write a parser using the same approach as the data-query parser.
    • Investigate code 85 (0x55) terminal from GetNextEventQueryResultBuffer. Most likely the missing RegisterTags2(CM_EVENT) prerequisite. See wcf-register-event-tag-latest.json for the prior probe attempts and the documented ConvertEventTagToTagMetadata GUID values (353b8145-5df0-4d46-a253-871aef49b321 event tag id, 5f59ae42-3bb6-4760-91a5-ab0be01f2f27 event type id).
    • Verify remote TCP transports against an actual remote Historian. Both RemoteTcpIntegrated (use /Hist-Integrated) and RemoteTcpCertificate (use /HistCert) are wired but unverified on this host.
    • Verify explicit username/password against a live Historian with a non-current user account.
    • Add RetrievalMode mappings beyond the four currently supported (Cyclic, Delta, BestFit, MinimumWithTime, MaximumWithTime, Integral, Slope, Counter, ValueState, RoundTrip, StartBound, EndBound) when corresponding native QueryType constants are documented.
    • Decode the trailing 24 bytes of each row body (after the EndTime/Quality/NumericValue/PercentGood/marker/StartTime stack); they're zero in the captured fixture but vary in some rows (e.g. trailer bytes at row+125..+132 differ across the 4 captured rows for the same tag), suggesting a per-sample value or source identifier we haven't decoded.

    Event-flow follow-up investigation on 2026-05-04. The event orchestrator now uses ConnectionMode = 0x501 (Event) so the chain reaches Retr.GetNextEventQueryResultBuffer. The server returns native error type=4 code=85 (0x55) with zero rows. SQL ground truth (SELECT * FROM Runtime.dbo.Events) confirms 5+ events ARE available in the test window — they're just not flowing because the session is not subscribed.

    Attempted fix: call IHistoryServiceContract2.RegisterTags2 with a count=1 + 16-byte CM_EVENT GUID body before StartEventQuery. Tested with both storageSessionId and contextKey as the handle parameter, in both upper- and lower-case GUID-D format. Every variant returned native error code=51 (InvalidParameter). Reverted.

    Per existing notes (lines 673728 in this doc), the actual native prerequisite is IHistoryServiceContract.AddTags (AddT) with a CTagMetadata payload (version=1 byte + optional-mask=2 bytes + data-type-byte=10 + tag-id=16 bytes + compact UTF-16 strings), NOT RegisterTags2. Documented CM_EVENT identity: tag id 353b8145-5df0-4d46-a253-871aef49b321, event type id 5f59ae42-3bb6-4760-91a5-ab0be01f2f27, CDataType=10, storage type 2.

    The remaining concrete next step for live event reads:

    1. Instrument Wcf.AddT.Request on a running native event harness to capture the exact CTagMetadata wire bytes. The existing reverse-engineering CLI has IL-rewrite instrumentation that captures other WCF request/response bodies — extend the same approach to AddT.
    2. Wire the captured payload into HistorianWcfEventOrchestrator as the additionalSetup callback for the event chain.
    3. Once AddT(CTagMetadata) succeeds, capture the resulting Wcf.GetNextEventQueryResultBuffer.ResultBytes and write a parser similar to the data-query row parser.

    Until step 1 lands, ReadEventsAsync reaches the chain layer successfully but returns empty results. The diagnostic helper EventChainDiagnosticTests.EventOrchestrator_DiagnosticDump_AgainstLocalHistorian surfaces LastResultBufferLength and LastErrorBufferDescription via ITestOutputHelper for iteration.

    Raw ETL files contain SSPI tokens, machine names, and identity metadata; they stay under artifacts/reverse-engineering/etw-sspi-{nativeread,managedvalcl}-*.etl and are not committed.

    Therefore "rerun from elevated PowerShell" is no longer a viable next step on this host. Practical alternative evidence paths for the server helper layer:

    • SYSTEM-level injection from an interactive PsExec session (PsExec64 -accepteula -s -i frida -p <pid> -l <script>). Requires user consent because it spawns a SYSTEM-token shell.
    • Signed instrumentation DLL: build a Detours-based agent with a code signature trusted by the local mitigation policy, then LoadLibraryA via a signed loader.
    • ETW SSPI provider trace (Microsoft-Windows-Security-SSPICli, Microsoft-Windows-Security-Auditing, Microsoft-Windows-Security-Negotiate, Microsoft-Windows-Security-Kerberos) filtered to PID <pid>. Captures AcceptSecurityContext calls and return statuses without injection, at the cost of helper-call granularity.
    • Inspect/temporarily relax the mitigation policy on this dev host (Get-ProcessMitigation -Name aahClientAccessPoint.exe). This is a user-consent-required change to a shared service binary.

    Until one of those produces server-side helper evidence, the active blocker remains "missing native wrapper/proxy/session state that lets the server accept the client-generated context key during ValCl before OpenConnection3."

    Static IL inspection now confirms both managed auth helper methods use the same handle source: CHistoryConnectionWCF.GetClientKey converts its contextKey argument to 36-character GUID text before ExchangeKey, and CHistoryConnectionWCF.ValidateClientCredential converts its contextKey argument the same way before ValCl. A managed named-pipe probe was extended to call ExchangeKey with that same handle style before replaying ValCl. It reaches GetInterfaceVersion version 11, but ExchangeKey itself fails with native error type 4/code 1 across default, static-channel, and lazy static-channel variants. That rules out a simple preceding ExchangeKey call as the missing registration step for the successful native ValCl path.

    Static IL inspection also maps the client-side integrated identity state: CHistoryConnectionWCF.SetIntegratedSecurity(true) sets flag CHistoryConnection +540, captures WindowsIdentity.GetCurrent(), and stores it with CServiceUtility.SetManagedPtr<WindowsIdentity> through the tuple at offsets +640, +600, and +664. GetInterfaceVersion reads the same tuple and calls WindowsIdentity.Impersonate() before proxy setup. Because this is client-side wrapper state and direct managed probes already run under the current Windows token, the remaining gap is the native wrapper/proxy state that makes the server accept the ValCl context key around GetInterfaceVersion proxy setup and CClientContext.AuthenticateClient, not normal .NET WCF binding setup or COperation.Start2. Raw request bytes contain local identity metadata and stay under ignored artifacts; sanitized hashes are in openconnection3-correlation-latest.json.

  • A TimeWeightedAverage aggregate request fixture is also captured and covered by a byte-for-byte serializer test. It confirms query type 5, double resolution ticks in the first resolution field, and scaled resolution ticks in the later field. The artifact hash is 954874bf851bdea6333b8a8159f036e19b124b7a5febefb0cb9c9a8564b20981.

  • An Interpolated request fixture is captured and covered by a byte-for-byte serializer test. It confirms query type 3 and the same resolution encoding as aggregate requests. The artifact hash is fc3a2fcc28d1926d2bd1de477e306cb0930e80a3327be6309b6e834e2951ca26.

  • TimeWeightedAverage DataQueryResultRow* memory is captured. Aggregate rows use offset 0x28 for managed EndDateTime and offset 0x150 for managed StartDateTime, unlike the initial raw-row assumption where the timestamp at 0x28 was sufficient for both bounds.

  • Interpolated DataQueryResultRow* memory is captured and follows the same time-bound offsets as aggregate rows in the one-minute-resolution fixture.

  • StartEventQuery request bytes are captured from the native wrapper and now back a byte-for-byte managed serializer test. The successful local event capture returned three event rows and emitted a 65-byte request with SHA-256 6b955b02087047a3199a8c74f3eee85c3b49aaa29b05de12eff2dd536f2da0d5.

  • Native event row memory is captured through the same IL-rewrite path. Three sanitized EventQueryResultRow* snapshots confirm managed EventTime is a direct FILETIME at offset 0x18; ReceivedTime and event property/string payload storage remain unresolved.

  • StartTagQuery request bytes are captured from the native wrapper's WCF path and now back a byte-for-byte managed serializer test. The deterministic OData tag filter request is 92 bytes with SHA-256 af1dbcdd3eb0ad91a18882c22252aa74aff82998e96a39b63415ab4792a962ac; the successful response is 8 bytes with SHA-256 db49223a2cf9616171322e5325816a7a579582ebdce91c2f89df8df7aa8aac01 and parses as uint32 queryHandle, uint32 tagCount. Local native execution succeeds and returns tag name OtOpcUaParityTest_001.Counter plus metadata summary TagKey = 238, TagDataType = 4, TagStorageType = 3, and EngineeringUnit = None. Wildcard tag browse is a separate StartLikeTagNameSearch / GetLikeTagnames path and has not been captured yet.

  • TagQuery.GetTagInfo response bytes are captured from the native wrapper before Load<CTagMetadata,SByteStream<SCrtMemFile>> deserializes them. The deterministic one-tag response is 106 bytes with SHA-256 77b2bf720d8888f08a1499a8162e706c2cef567a1f6d74d7e92efe0cd3e3e34b and now backs a managed parser test for tag count, native data-type descriptor, type GUID, tag key, compact ASCII tag name, metadata provider, storage type, deadband type, and interpolation type. The post-deserialization CTagMetadata vector is also captured with 224-byte elements and hash 6df4a5a837d06df9332391eb7350af17804137b87541dad8556ca04d015e2995.

  • The separate wildcard browse path is now proven through fully managed WCF: StartLikeTagNameSearch with filter OtOpcUaParityTest% returns 0, and GetLikeTagnames returns one 66-byte buffer with SHA-256 2d450a55f392aed0026e9a957fefa3b116aab6ec81912c5d824c6b9a1ff5a4a1. The response parser is covered by a unit test and confirms layout uint32 count, then repeated uint32 UTF-16 char length plus UTF-16LE tag name bytes.

  • HistorianClient.BrowseTagNamesAsync now uses the fully managed WCF tag browse path instead of the evidence-missing guard. Public * wildcards are normalized to the Historian LIKE wildcard %. Explicit username/password Open2 for tag browse remains guarded because the successful evidence is integrated Windows auth.

  • HistorianClient.GetTagMetadataAsync now uses the fully managed WCF direct metadata path instead of the evidence-missing guard. It opens the same /Hist-Integrated session and calls /Retr GetTagInfoFromName, then parses the observed 98-byte tag metadata record. The current data-type map is intentionally conservative: descriptor 03 C3 00 31 maps to HistorianDataType.Int4, and unknown descriptors still raise ProtocolEvidenceMissingException until fixture-backed.

  • Passwordless SSH to the Debian relay host works for the configured test account, and the host can reach the Windows Historian on <historian-host>:32568.

  • scripts\Run-DebianHistorianRelayCapture.ps1 starts a temporary Python relay on the Debian box and forwards <relay-host>:32568 to <historian-host>:32568. The native Windows client then emits observable remote Net.TCP/WCF traffic when pointed at <relay-host>.

  • The relay capture confirms:

    • initial WCF Net.TCP preamble bytes beginning with 00 01 00 01 02 02 ... net.tcp:/
    • server preamble acknowledgement 0A
    • TLS-style 16 03 03 ... exchange on one connection
    • NTLMSSP type 1/2/3 messages on subsequent connections
    • server rejection/reset before OpenConnection reaches connected state
  • A fully managed wcf-cert-probe now reproduces the /HistCert transport shape directly. Local HistCert.GetV returns interface version 11, and remote <historian-host>:32568/HistCert also returns version 11 when the client endpoint identity is set to DNS localhost, matching the certificate claim.

  • A fully managed remote wcf-probe also confirms the plain service endpoints on <historian-host>:32568: /Hist version 11, /Retr version 4, /Stat version 0, and /Trx version 2. The certificate DNS identity issue is isolated to /HistCert.

  • A fully managed remote wcf-tag-info probe confirms the same integrated session handle works for scalar retrieval metadata calls. For OtOpcUaParityTest_001.Counter, GetTagTypeFromName returns type 1, IsManualTag returns false, and legacy GetTagInfoFromName returns 238 with no metadata buffer. Initial byte-buffer probes for GetTgByNm and GetTg also return 238 with zero output bytes across tag-name and tag-id encodings, so the remaining metadata gap is likely the exact WCF contract signature or a richer request envelope rather than the session handle.

  • The relay path provides transport evidence but not query buffers yet, because the relayed authentication/session is rejected before a query can start.

  • Server-side ArchestrA logs for the relayed run confirm the native client initialized against Server(<relay-host>) from AVEVA.Historian.NativeTraceHarness(20.0.000) and selected security type Transport with Certificate. This means the relay failure is identity/security state related; rewriting only the plaintext Net.TCP preamble host is not sufficient to make a relay transparent.

  • Forcing the private HistorianConnectionArgs.directConnection field to true lets the native trace harness complete a successful read even with ServerName = <relay-host>, and the Debian relay records no traffic. That makes DirectConnection a native/local parity aid, not a way to capture the remote protocol.

  • The same relay path has now been run for native event connections with endpoint-host rewriting enabled. Event mode opens the same initial /HistCert Net.TCP preamble using application/ssl-tls, then retries /Hist-Integrated with application/negotiate and NTLMSSP type 1/2/3 messages. The server still returns a 13-byte rejection/reset before the harness reaches connected state. Setting --direct-connection does not bypass this event path; it emits the same relay traffic and still fails to connect. This differs from history direct mode, which completed locally and avoided the relay.

  • The same trace harness attempted WCF diagnostics/message logging, but no .svclog file was produced. Ordinary WCF config tracing is not sufficient to capture the native wrapper's byte-array calls in this process.

  • The Open2 serializer and observed five-byte native error decoder are shared production internals with unit coverage. The reverse-engineering CLI reports native error type, code, and known name beside the sanitized base64 buffer.

  • A reverse-engineering-only WCF capture server exists under tools\AVEVA.Historian.WcfCaptureServer.

  • Uncaptured protocol operations throw ProtocolEvidenceMissingException instead of pretending to implement proprietary frames.

  • Reverse-engineering CLI exists under tools\AVEVA.Historian.ReverseEngineering. It can:

    • inventory PE exports
    • list mixed-mode MethodDef tokens/RVAs from aahClientManaged.dll
    • list direct IL references to a metadata token
    • hash native DLLs
    • write capture manifests
    • emit timestamp markers for capture notes
    • probe WCF/MDAS endpoints
    • attempt scalar Open and byte-buffer Open2 session opens
    • probe Stat WCF operations and optional system-parameter calls
    • probe Retr.StartEventQuery with reconstructed event request buffers
  • Unit tests cover confirmed binary facts:

    • FILETIME UTC conversion
    • UTF-16 null-terminated strings
    • enum numeric compatibility
    • frame container round trips
    • legacy Hist.Open2 version-1 buffer layout
    • native OpenConnection3 version-6 request prefix and content branch layout
    • first-pass DataQueryRequest.Save field order for empty metadata and auto-summary defaults
    • observed native error buffer decoding
    • native OpenConnection3 version-3 response parsing from the decoded CClientInfo.DeserializeOpenConnectionOutParams layout
    • Windows SYSTEMTIME byte parsing for status evidence
    • native StartTagQuery request/response layout and first GetTagInfo response stream fields
    • GetLikeTagnames browse response buffer layout
    • public browse wildcard normalization
    • evidence guardrails

Current Hard Blocker

The 2020 client transport is now known to be WCF Net.TCP using a custom MDAS message encoder, not just an opaque raw socket frame format. The Hist.Open2 legacy version-1 buffer is decoded far enough to make /Hist-Integrated return success under fully managed integrated Windows auth. The 32-byte response is decoded for the legacy path as:

  • uint32 client handle
  • Guid storage session id
  • int64 connect time FILETIME UTC
  • uint32 server status

The decoded handle is accepted by Retr.IsOriginalAllowed, while hard-coded handle 1 is rejected. That confirms the session handle is usable for at least some retrieval contract calls.

The native wrapper WCF read boundary is now captured from a successful local read. Reverse-only IL instrumentation of CRetrievalConnectionWCF.StartQuery2 and CRetrievalConnectionWCF.GetNextQueryResultBuffer2 shows:

  • StartQuery2 succeeds with clientHandle = 256524568, queryRequestType = 1, request size 251, response size 31, response SHA-256 4c062b5ce8181308f0f46bfd8c6088acb52e6ade94401651b7d3ccc8952edfb5, server query handle 2967, and zero error size.
  • GetNextQueryResultBuffer2 succeeds with the same client handle and server query handle, result size 570, result SHA-256 f4126e0610a4c63b3dff0e41e8d15e51d75fdbc736f3ec490e7c66d1bb31638d, and a five-byte terminal/error buffer SHA-256 7db15e1972ced8a44dae4d75f7a1f0cd74858c7d0deb8b3522d6a05904778bf7.
  • The public HistoryQuery.queryHandle = 1 is a client-side wrapper handle. The server WCF query handle on /Retr is a separate value (2967 in this run).

Direct managed Retr.StartQuery2 probes remain negative even with byte-matched request serialization, so the remaining gap is no longer the data-query request payload. The latest correlation pass shows the native read path starts with legacy native handle 2, then the common retrieval/session layer supplies the separate /Retr WCF client handle accepted by StartQuery2. Instrumenting CHistoryConnectionWCF.OpenConnection2, CServerClient.GetHandle, aahClientCommon.CRetrieval.StartQuery2, CSrvRetrievalConnection.StartQuery, and CRetrievalConsoleClient.StartQuery did not emit records in this path. The current evidence target is the CClient object reached by CClientCommon.StartQuery: the vtable method at offset 24 returns the /Retr WCF client handle, and CHistoryConnectionWCF.OpenConnection3 creates it by calling IHistoryServiceContract2.OpenConnection2. The 1346-byte OpenConnection3 request is now decoded far enough to show the remaining prerequisite: reproduce CClientContext.AuthenticateClient so the managed client can register/validate the client-generated context key with the server before sending OpenConnection3. The first ValCl token and local named-pipe setup are now mapped; next inspect or instrument the native GetInterfaceVersion and wrapper/proxy side effects that make the same token accepted when sent by the wrapper. Current negative probe evidence:

  • exact native QueryColumnSelector.SelectNonSummaryColumns state 0x00008182000782FF
  • corrected packed CQTIFlags value for ordinary full reads (0x01FF)
  • query type values 0..14
  • option "" and "NoOption"
  • selector flags 0xFFFF, 0x3FFFF, and ulong.MaxValue
  • empty MDS/storage redundant endpoints
  • local-machine MDS/storage redundant endpoint variants
  • corrected NoFilter filter text
  • corrected empty metadata namespace and default auto-summary byte blocks
  • Windows transport security on /Retr, which fails before the operation with ProtocolException: The requested upgrade is not supported; /Retr expects the plain MDAS Net.TCP binding even when /Hist-Integrated uses Windows transport security for Open2.
  • remote <historian-host> WCF start-query parity: all 22 reconstructed request variants open /Hist-Integrated successfully and pass Retr.IsOriginalAllowed, but StartQuery2 returns false with zero response/error sizes; legacy StartQuery returns code 238 for each. The legacy call also returns zero response size and no response buffer, so code 238 is not a discarded response payload. The stored artifact redacts transient handle values.
  • a bounded current-session replay of the first byte-matched full-history candidate again opened /Hist-Integrated, passed Retr.IsOriginalAllowed, and returned StartQuery2 = false with zero response/error bytes. The legacy StartQuery operation now faults with a server null-reference instead of returning a useful response. This reinforces that the blocker is the native session/client-handle state from OpenConnection3, not the 251-byte DataQueryRequest payload.

The Galaxy DB query path also confirmed alternate historized tags, including TestMachine_001.TestHistoryValue. Native direct local reads succeed for that tag, while native non-direct local history reads now report an SEHException from DataQueryResponse.Load after successful open/connect. Native event queries still succeed. This means the current local server remains useful for direct native value parity and event flow evidence, but it is not yet a clean oracle for WCF StartQuery2 history response bytes. Running the native harness executable without its WCF diagnostics .config does not change the non-direct history failure, so message logging is not the cause.

Event request serialization has one stronger fact set than history now: EventQueryRequest.Save<SByteStream<SCrtMemFile>> writes version 5, start/end FILETIME, event count, skip count, order, query type, the empty filter block, result buffer size 0x10000, timezone UTC, empty metadata namespace, and empty selected-property count. The managed wcf-start-event-query probe serializes that 65-byte request and records the SHA-256 in docs\reverse-engineering\wcf-start-event-query-attempts-latest.json. The WCF queryRequestType for event starts is now confirmed as 3. CRetrievalConnectionWCF.StartEventQuery also contains a version dispatch: server interface versions <= 2 use /Retr.StartQuery2, while newer versions use /Retr.StartEventQuery. The latest direct managed probe tries both operations with the same 65-byte request.

Event Open2 setup is also partially mapped. SetConnectionMode(Event, integratedSecurity: true) yields connection mode 1281 (0x501), while the process read-only integrated mode is 1026 (0x402). The latest managed probe tries both modes and three handle candidates: returned Open2 handle, clientHandlerArray[1] == 1, and native eventTagHandle == 10000000. All currently return false with empty error buffers through both StartEventQuery and StartQuery2.

Native event open performs one extra setup step that the managed probe does not yet reproduce: CreateDefaultEventTag creates CM_EVENT with description AnE Event, engineering unit NONE, and data type 10; it then calls AddTagInternal, which reaches HistorianClient.AddHistorianTag. The immediate aahClientCommon.CClient.RegisterTag continuation is client-local state: it inserts the tag id into local sets, marks the cached tag info as registered, and flips synchronization flags. The RTag2 WCF edge now has one negative probe: its input buffer is a serialized GUID vector (uint32 count followed by 16-byte GUIDs), but wcf-register-event-tag failed for empty, zero-GUID, and event-handle-shaped vectors across Open2-derived GUID handle candidates. First-16 handle candidates return native error 51; the bytes 4-19 .NET GUID candidate reaches deeper and returns native error 1. The richer AddHistorianTag / CTagMetadata server path remains the leading candidate for why native event queries start successfully while direct managed event-start calls do not.

The default event tag metadata path is now partially concrete: ConvertEventTagToTagMetadata maps CM_EVENT to event tag id 353b8145-5df0-4d46-a253-871aef49b321, common event type id 5f59ae42-3bb6-4760-91a5-ab0be01f2f27, CDataType = 5, and storage type 2. CTagMetadata.Save writes a one-byte version, two-byte optional-field mask, the data-type byte, tag id, and compact UTF-16 strings. The current managed wcf-add-event-tag probe uses these facts to try two payload variants through IHistoryServiceContract.AddTags (AddT), but both return AddReturnCode = 4 with no output buffer and event query start remains false. This is useful negative evidence: the current approximation is not the missing server state.

Two capture routes were also ruled out for this event setup. First, a fake WCF server bound to an alternate port saw only startup and no native calls while the native event harness still queried the real local Historian, so the native WCF endpoint is not redirected simply by changing HistorianConnectionArgs.TcpPort. Second, an expanded Frida pass installed hooks for CreateDefaultEventTag, AddTagInternal, AddHistorianTag, ConvertEventTagToTagMetadata, and CTagMetadata.Save before open; the event query succeeded, but no hook enter/leave callbacks fired. MethodDef RVAs are not viable runtime interception points for this mixed-mode path. Third, the native harness now has a --pre-load-sleep-seconds option so Frida can attach before aahClientManaged.dll is loaded. A preload Winsock/IPC pass for the successful local event scenario (winsock-event-preload-localhost-latest.ndjson) still recorded only hook installation plus the native success snapshot, with no Historian-port socket traffic and no tracked named-pipe/file payloads. The local event success path is therefore below or outside the current Winsock/pipe hook set.

The following are not yet known:

  • native version-3/version-4 open buffer differences
  • exact native client/session field that maps legacy handle 2 to the /Retr WCF clientHandle consumed by successful native StartQuery2.
  • why direct managed Retr.StartQuery2 fails with handles decoded from managed Open2 even though the native wrapper's WCF call succeeds with a handle from the same operation family.
  • whether GetNextQueryResultBuffer2's five-byte error buffer on a successful row batch is a terminal-status marker, a warning, or a harmless native error scratch buffer.
  • query result buffer layouts
  • event default tag registration (CM_EVENT) and result buffer layout
  • broader wildcard browse behavior for multi-batch and empty-result cases
  • non-Open2 native error buffer variants
  • write request/result buffer layouts

New tag-query evidence narrows the metadata path. The latest combined IL instrumentation shows successful native CRetrievalConnectionWCF.StartTagQuery uses a 4-byte WCF request (51 67 01 00) and a native retrieval-session GUID string, not the 92-byte filter-bearing request shape. Direct managed replay of both shapes with numeric, GUID, hex, base64, fresh-GUID, and captured-GUID handle candidates failed with native errors 51 or 1. This means StartTagQuery depends on earlier native session/filter registration. Do not wire metadata through guessed StartTagQuery; the SDK now uses the proven GetTagInfoFromName direct WCF metadata path. Instrument the earlier metadata-layer registration call only if StartTagQuery pagination becomes necessary later.

Until these buffers are decoded and fixture-backed under fixtures\protocol\2020, the SDK cannot truthfully perform real Historian reads or writes.

Next Capture Pass

Run these first:

dotnet run --project tools\AVEVA.Historian.ReverseEngineering -- manifest
dotnet run --project tools\AVEVA.Historian.ReverseEngineering -- exports current\aahClient.dll
dotnet run --project tools\AVEVA.Historian.ReverseEngineering -- mark connect-process
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\Test-AahClientManagedOpen.ps1 -IntegratedSecurity
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\Test-AahClientManagedReadIntegrated.ps1
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\Find-GalaxyHistorizedTags.ps1 -Limit 25
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\Attach-AahClientManagedFridaCapture.ps1 -TagName OtOpcUaParityTest_001.Counter
.\tools\AVEVA.Historian.NativeTraceHarness\bin\Debug\net481\AVEVA.Historian.NativeTraceHarness.exe --tag OtOpcUaParityTest_001.Counter --lookback-minutes 1440 --max-rows 1
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\Attach-NativeTraceHarnessWinsockCapture.ps1 -ServerName <historian-host> -Scenario history -RetrievalMode Full -TagName OtOpcUaParityTest_001.Counter -LookbackMinutes 1440 -MaxRows 1
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\Run-DebianHistorianRelayCapture.ps1 -SshUser <relay-user> -SshHost <relay-host> -TargetHost <historian-host> -OutputPath .\docs\reverse-engineering\debian-relay-history-latest.ndjson -HarnessOutputPath .\docs\reverse-engineering\native-trace-harness-via-debian-relay-latest.json
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\Run-DebianHistorianRelayCapture.ps1 -SshUser <relay-user> -SshHost <relay-host> -TargetHost <historian-host> -OutputPath .\docs\reverse-engineering\debian-relay-history-direct-latest.ndjson -HarnessOutputPath .\docs\reverse-engineering\native-trace-harness-via-debian-relay-direct-latest.json -HarnessExtraArgs @("--direct-connection")

Then execute the matching native SDK scenario while recording API Monitor, Detours-style lower-boundary hooks, or a CLR profiler/managed method rewrite. Do not use MethodDef RVAs as Frida hook targets, and do not treat MethodHandle.GetFunctionPointer() as sufficient by itself; the latest same-process runtime-pointer Frida pass installed hooks but saw no callbacks during a successful direct history read. Also do not assume aahClient.dll exports are on the wrapper path; the latest export-hook pass did not observe that DLL being loaded. Windows built-in packet capture produced empty PCAPNG files for local loopback on this machine, and classic WCF diagnostics did not produce message logs in the native harness process. A remote Historian server target would be better for packet/Winsock capture because the installed local server path appears to bypass observable client-process network and pipe IO. Add only sanitized decoded buffers or fixture bytes to the repo.

Current Frida evidence is now broad enough that repeating export-level hooks is unlikely to pay off. The next practical evidence path is one of:

  • API Monitor/Detours with deeper mixed-mode or WCF-native call interception.
  • CLR profiler/IL instrumentation around the C++/CLI wrapper's managed entry and WCF contract calls.
  • ETW/WFP/kernel-level network tracing for the relay path, then correlate to the sanitized relay transcript and harness state.

The pktmon path now covers metadata/timing. It still cannot provide query bytes because the relayed session fails before ConnectedToServer = true; it is a correlation aid, not the protocol parser input.

The local WCF capture server cannot currently intercept the native localhost path: the native wrapper ignores TcpPort for local machine targets and still uses the installed Historian endpoint. See native-open-capture-server.md.

Additional confirmed status evidence is in wcf-status-localhost.md: the Stat endpoint is reachable, but WCF GetServerTime is a native no-op stub and handle-dependent status calls return failures with handle 0.