Live-probed both R1.3 and R1.4 against a real 2023 R2 server over the gRPC StatusService; implemented the one that carries an evidence-backed value. R1.3 GetServerTimeZoneAsync — SHIPPED: - StatusService.GetSystemTimeZoneName(uiHandle) returns the real server zone over RemoteGrpc (the 2020 WCF op is a client-side stub returning empty). - HistorianGrpcStatusClient.GetSystemTimeZoneNameAsync -> dialect routing -> public HistorianClient.GetServerTimeZoneAsync. Non-gRPC transports fail closed with ProtocolEvidenceMissingException (no empty-string lie). - Golden message-shape unit test + non-gRPC guardrail unit test + gated live test. 271 unit tests pass. R1.4 GetHistorianInfoAsync (EventStorageMode) — bounded out on gRPC too: - gRPC GetHistorianInfo is the same named-value query as 2020 WCF (only HistorianVersion resolves); EventStorageMode + 7 variants fail on both GetHistorianInfo and GetSystemParameter. The 518-byte struct is filled by a native vtable+648 HCAL call, not the gRPC op (per the 2023 R2 decompile), so the field is never on the wire. Not shipped on any transport. Closes the roadmap's open "build against a live 2023 R2 server" caveat. Also correct the stale M3 roadmap section: D2 already proved Transaction.AddNonStreamValues* rides the storage-engine pipe (STransactPipeClient2 -> aaStorageEngine), not WCF — same wall as R4.2 — so M3-over-WCF is blocked, not "the path that is NOT the gated cache push". Docs: hcal-roadmap.md, wcf-historian-info.md, wcf-status-localhost.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC
30 KiB
HCAL modern-.NET client — implementation roadmap
Ordered, actionable plan to grow histsdk from "reads + basic config" into a broad
HCAL replacement, built on the 2023 R2 gRPC transport. Derived from
hcal-capability-matrix.md; event details in
histevents.md.
Move to the repo's
docs/plans/when execution starts. Each work item lands as: a protocol serializer/parser + golden-byte unit test + an env-gated live integration test against the local Historian.
Progress (updated 2026-06-21)
- ✅ R0.6 version gate —
HistorianServerVersionGate+HistorianClientOptions.VerifyServerInterfaceVersion; fail-closed on connect, wired into both WCF and gRPC paths. Supported versions are evidence-based (Hist=11/12, Retr=4, Trx=2; Status reachability-only), captured from the live server. History 12 (2023 R2 gRPC) accepted alongside 11 (buffer-compatible). - ✅ CW-1 capture pipeline —
ProtocolCaptureSanitizer+ProtocolFixtureWriter+capture-tag-infoCLI command; produces sanitizedfixtures/protocol/<op>/golden files. 11 unit tests. First fixture:get-tag-info/analog-*.json. - ✅ gRPC auth handshake (read chain) — LIVE-VERIFIED 2026-06-21 against a real 2023 R2
server:
ReadRawAsyncoverRemoteGrpcreturns rows. Token loop routes toStorageService.ValidateClientCredential. Shared handshake extracted toGrpc/HistorianGrpcHandshakefor reuse by the status/browse/metadata paths. - ✅ R0.4 Probe over gRPC —
Grpc/HistorianGrpcProbe(History/Retrieval/StatusGetInterfaceVersion);ProbeAsyncroutes over gRPC whenTransport==RemoteGrpc. LIVE-VERIFIED 2026-06-21 (no credentials required — runs before the auth loop). - ✅ R0.3 System parameter over gRPC —
Grpc/HistorianGrpcStatusClient.GetSystemParameterAsync(StatusService.GetSystemParameter); routed in the dialect. Built + unit-tested + LIVE-VERIFIED 2026-06-21 against a real 2023 R2 server (returnedHistorianVersion). Code path is the proven handshake + a single string-in/string-out RPC. - ✅ R0.2 Tag metadata over gRPC —
Grpc/HistorianGrpcTagClient.GetTagMetadataAsync(RetrievalService.GetTagInfosFromName, the plural string-handle op).GetTagMetadataAsyncroutes over gRPC whenTransport==RemoteGrpc. RequestbtTagNames=uint count + per-name(uint charCount + UTF-16LE)(golden-byte unit-tested); responsebtTagInfos=uint count + CTagMetadatarecords (reusesParseGetTagInfoResponse); string handle = uppercase Open2 storage GUID. The 2020 WCF string-handle wall does not apply on the gRPC front door (as predicted). LIVE-VERIFIED 2026-06-21 —GetTagMetadataAsyncreturned the requested tag + a valid data type. - ✅ R0.1 Browse over gRPC — DONE, LIVE-VERIFIED 2026-06-21.
HistorianClient.BrowseTagNamesAsyncroutes over gRPC viaGrpc/HistorianGrpcTagClient.BrowseTagNamesAsync: StartTagQuery(OData filter) → paged QueryTag (btRequest=u16 0x6752 + u16 1 + u16 queryType + u32 startIndex + u32 count) → EndTagQuery; response =u32 count + per-name(u32 charCount + UTF-16LE) + trailer. The SDK glob filter is translated byGlobToODataFilter(Pre*→startswith,*suf→endswith,*mid*→contains, exact→eq). The QueryTag packet-id0x6752was recovered from a.rdatapacket-descriptor table ({0x6751,1}=StartTagQuery,{0x6752,1}=QueryTag) — no Ghidra needed. Golden-byte + glob unit tests + gated live test. Full finding:docs/reverse-engineering/grpc-tag-query-odata.md.
✅ Milestone 0 (gRPC parity) is COMPLETE — probe, system-param, metadata, and browse all run over
RemoteGrpcand are live-verified against a real 2023 R2 server, alongside the read chain.
ℹ️ Auth note (2026-06-21, resolved): an apparent NTLM round-1
SEC_E_LOGON_DENIEDblocker turned out to be a test-harness credential-parsing bug, not a server/account/SDK issue — the gitignored creds file stores quoted values ("nam\user","pass"), and the env-setup must strip surrounding quotes before exportingHISTORIAN_USER/HISTORIAN_PASSWORD. With quotes stripped, the domain account authenticates and the full read + system-param + probe chain passes live. The round-failure diagnostic added during the hunt is kept (HistorianNativeHandshake.DescribeErrordecodes the native error + hex/ASCII preview).
⚠️ Live-verification constraint: the local Historian is 2020 (WCF, port 32568) — the 2023 R2 gRPC endpoint (32565) is absent. M0's gRPC routing (R0.1–R0.4) can be built and golden-byte/unit-tested here but cannot be live-verified without an actual 2023 R2 server. Treat gRPC ops as unverified until then; the byte payloads remain the proven 2020 protocol.
🔬 M1a re-classification (2026-06-20). Two "trivial" items were live-probed against the 2020 WCF server and found not deliverable here, both for evidence-backed reasons:
- R1.3
GetServerTimeZoneAsync—Status.GetSystemTimeZoneNameis a client-side stub on 2020 (rc=0, empty value), same family asGetServerTime. gRPC/2023R2-only.- R1.1
ExecuteSqlCommandAsync—ExeCreturns native error 51 (InvalidParameter); the contract-3 string-handle ops require an unmapped native session/filter registration step (theStartTagQuerywall).Takeaway: the M1a "cheap surface" is cheap only on the 2023 R2 gRPC front door. On 2020 WCF the boundary is the handle type (see the string-handle wall note under §1b and
docs/reverse-engineering/wcf-string-handle-wall.md):uint-handle ops work,string-handle ops are blocked. GETHI/GetTepByNm were probed and confirmed blocked (not, as first guessed, reachable). The reachableuint-handle items are now DONE:R1.8/R1.9 StartQuery summary/state modes(resolved = existingReadAggregateAsync) andR1.7 event filters(✅ 2026-06-20 —ReadEventsAsync(…, HistorianEventFilter), live-honored). M2 event send is also done (✅ WCFAddS2). R1.2GetRuntimeParameterAsyncis also done (✅ 2026-06-20,aa/Stat/GETRP, live-verified) — notably a string-handle op that punches through the wall using the Open2 storage-session GUID as an uppercase string handle, which proved the GETHI/ExeC failures were a handle-format issue rather than a missing native registration. Follow-up done: R1.1ExecuteSqlCommandAsyncshipped; R1.5 extended-property read shipped (R1.6 collapsed into it — no distinct localized op). R1.4GetHistorianInfobounded out on 2020 WCF — GETHI there is a named-value query (onlyHistorianVersion);EventStorageModeis 2023R2-gRPC-only (seewcf-historian-info.md). Net: the reachable 2020-WCF M1 read surface is complete; what remains is config writes (M1c — gated on an explicit user request) and the gRPC/2023R2-only items (R1.3 timezone, R1.4 EventStorageMode — need a live 2023 R2 server).Update 2026-06-21 (live 2023 R2 gRPC probe — both closed): R1.3 SHIPPED on gRPC —
GetServerTimeZoneAsyncreturns a real zone ("Eastern Daylight Time") viaStatusService.GetSystemTimeZoneName; non-gRPC path fails closed (ProtocolEvidenceMissingException). R1.4 bounded out on gRPC too —GetHistorianInfois named-value-only on the gRPC wire as well,EventStorageModeresolves under no name on eitherGetHistorianInfoorGetSystemParameter, and the 518-byte struct is C++-HCAL-internal (filled via native vtable+648, not the gRPC op). So no gRPC/2023R2-specific reads remain open — the entire M1 read surface (2020 WCF + 2023 R2 gRPC) is now closed.
Guiding principles
- gRPC-first. New ops go on the
RemoteGrpctransport (clean protobuf envelope); the innerbytesblob is the only thing to RE. Keep WCF as the legacy/Windows path. - Two tests per op, always. A golden-byte test (deterministic, no server) and a
gated live test (
HISTORIAN_GRPC_HOST/HISTORIAN_HOST). No op is "done" without both. - Version-pin, fail closed. Read server version at connect; gate every byte
serializer on it; throw
ProtocolEvidenceMissingExceptionon mismatch — never best-effort parse. - Capture once, encode forever. For CAPTURE-tier items, instrument one native call,
save a sanitized fixture under
fixtures/protocol/, then implement against the fixture. - Ship per milestone. Each milestone is independently releasable.
Effort: S ≈ days · M ≈ ~1 week · L ≈ weeks. Estimates are incremental on histsdk's existing infra (auth chain, transport, frame primitives, test harness).
Milestone 0 — Foundation: full gRPC parity for the DONE surface (M)
Goal: everything already working over WCF also works over RemoteGrpc, so the whole
read/browse/status surface is Windows-free and the gRPC stack is the default path.
| ID | Work | gRPC op | Files | Verify | Effort |
|---|---|---|---|---|---|
| R0.1 | Route browse over gRPC | Retrieval.StartTagQuery/QueryTag or GetTagInfosFromName |
Grpc/HistorianGrpcReadOrchestrator (+ new …GrpcBrowseClient), Historian2020ProtocolDialect |
browse tags live over gRPC | S |
| R0.2 | Route tag metadata over gRPC | Retrieval.GetTagInfosFromName |
dialect + grpc client | metadata matches WCF result | S |
| R0.3 | Route status/system-param over gRPC | Status.GetSystemParameter, Status.GetHistorianConsoleStatus |
new Grpc/HistorianGrpcStatusClient |
system param + conn status live | S |
| R0.4 | Probe over gRPC | *.GetInterfaceVersion |
grpc clients | ProbeAsync Windows-free |
XS |
| R0.5 | Capture harness for gRPC payloads | n/a | reuse instrument-wcf-* tooling (same byte blobs) + add a grpc-call-dump helper |
dump any request/response bytes to a fixture |
S |
| R0.6 | Version gate | server version at connect | HistorianClientOptions, orchestrators |
mismatched version → throws | S |
Acceptance: the entire Phase-0 capability set runs end-to-end over RemoteGrpc
(incl. Linux), no WCF on the path. 188+ unit tests green; live gRPC integration suite green.
Milestone 1 — Cheap surface completion (TRIVIAL/BOUNDED) (M–L total)
Goal: knock out the remaining read/config surface. Order = ascending payload difficulty.
1a. Trivial (XS–S each, no new payload format)
| ID | Capability | gRPC op | Notes |
|---|---|---|---|
ExecuteSqlCommandAsync |
Retrieval.ExecuteSqlCommand (ExeC+GetR) |
✅ DONE (2026-06-20), live-verified. ExecuteSqlCommandAsync(sql) → HistorianSqlResult (columns + typed rows). String-handle op via the uppercase storage GUID. Chain: Retr.GetV prime → ExeC(handle, sql, option=0, ref queryHandle) → GetR loop (note: GetR returns false even on success — the stream is in pResultBuff regardless; false = final page). GetR's pResultBuff is an NRBF-serialized DataTable (SerializationFormat.Xml: members XmlSchema + XmlDiffGram). BinaryFormatter is gone from .NET 10, so it's decoded read-only with System.Formats.Nrbf + XDocument (no BinaryFormatter). Shipped: HistorianSqlResult/HistorianSqlColumn/HistorianSqlExecuteOption, HistorianSqlResultProtocol, HistorianWcfSqlClient, golden WcfSqlResultProtocolTests, gated live tests. See docs/reverse-engineering/wcf-exec-sql.md. |
|
GetRuntimeParameterAsync |
Status.GetRuntimeParameter (aa/Stat/GETRP) |
✅ DONE (2026-06-20), live-verified. Captured (scripts/Capture-RuntimeParam.ps1): GETRP is a string-handle op (GETHI's shape), but reachable from the managed client using the Open2 storage-session GUID as an uppercase string handle (ToString("D").ToUpperInvariant()). Returns HistorianVersion = 20,0,000,000 live. pRequestBuff = 54 67 01 00 + uint nameCount + per-name(uint charCount + UTF-16); pResponseBuff = version + uint resultCount + CRetVariant(0x43 VT_BSTR + uint16 len + uint16 charCount + UTF-16). Single string-valued param only (multi-name framing inferred, not captured). Shipped: HistorianClient.GetRuntimeParameterAsync(name); golden WcfRuntimeParameterProtocolTests. Note: GETRP punching through the string-handle wall with the uppercase storage GUID is a strong lead that GETHI/ExeC may be a handle-format issue — see wcf-string-handle-wall.md §Update. |
|
GetServerTimeZoneAsync |
Status.GetSystemTimeZoneName |
✅ DONE on gRPC (2026-06-21), LIVE-VERIFIED against the real 2023 R2 server — returns "Eastern Daylight Time". HistorianClient.GetServerTimeZoneAsync routes over RemoteGrpc (HistorianGrpcStatusClient.GetSystemTimeZoneNameAsync, uiHandle-in/string-out, no buffer). The 2020 WCF op stays a client-side stub (rc=0, empty), so the non-gRPC path throws ProtocolEvidenceMissingException (fail-closed) rather than return an empty string. Golden message-shape + non-gRPC guardrail unit tests + gated live test. (2020-only routes — per-block HistoryBlock.TimeZoneOffset, SQL via R1.1 — remain DST-specific and are not this op.) |
✅ String-handle "wall" RESOLVED (2026-06-20) — it was a handle-FORMAT bug. R1.4/R1.5/R1.6 (and R1.1) take a
stringGUID handle; the earlier "code 1/51 blocked" verdict came from passing the Open2 storage GUID in .NET's default lowercase. Sent uppercase (storageSessionId.ToString("D").ToUpperInvariant()) the same handle works: GETRP (R1.2, shipped), GETHI (R1.4) and ExeC (R1.1) are all live-verified reachable, and R1.5GetTepByNmis now shipped + live-verified (GetTagExtendedPropertiesAsync). R1.6 has no distinct op (collapses into R1.5). Note:QTB(StartTagQuery) does not punch through — it fails server-side (CMdServer::StartActiveTagnamesQueryover theaahMetadataServerpipe), independent of handle format, so the index-based property/query paths stay blocked here. Full analysis:docs/reverse-engineering/wcf-string-handle-wall.md(RESOLVED banner) anddocs/reverse-engineering/wcf-tag-extended-properties.md. R1.8/R1.9 (StartQuery summary/state modes) areuint-handle and were already reachable.
1b. Bounded (decode one bytes payload; S–M each)
| ID | Capability | gRPC op | Payload to decode | Depends |
|---|---|---|---|---|
GetHistorianInfoAsync |
Status.GetHistorianInfo (GETHI) |
⛔ BOUNDED OUT — now confirmed on the 2023 R2 gRPC front door too (2026-06-21, live-probed). The motivating field EventStorageMode is not on the wire on either transport. Live gRPC probe against the real 2023 R2 server: GetHistorianInfo is a named-value query exactly like 2020 WCF — only HistorianVersion resolves (→ "23,1,000,000" + 02 00 01 00 trailer); EventStorageMode + 7 name variants fail (success=false) on both GetHistorianInfo and GetSystemParameter. The 518-byte HISTORIAN_INFO struct (mode@514) is the C++ HCAL in-memory model (managed HistorianAccess.GetHistorianInfo fills it via a native vtable+648 call, not the gRPC op — verified in the 2023 R2 decompile), derived outside the wire. The only wire-reachable field (version) is already shipped (ProbeAsync/GetSystemParameterAsync/GetRuntimeParameterAsync), so a struct API would be hollow + misleading. Closes the prior "build against a live 2023 R2 server" caveat — done, and there is nothing to ship. See docs/reverse-engineering/wcf-historian-info.md. |
uppercase string handle | |
| Extended-property read | Retrieval.GetTagExtendedPropertiesFromName (GetTepByNm) |
✅ DONE (2026-06-20), live-verified. GetTagExtendedPropertiesAsync(tag) → name/value pairs. String-handle op via the uppercase storage GUID; name-based path (GetTagExtendedPropertiesByName, not the QTB-gated TagQuery path). Request tagNames = uint count + per-name(uint charCount+UTF-16); response = uint tagCount + per-tag(marker + compact-ASCII name + uint propCount + per-prop(marker + compact-ASCII name + 0x43 VT_BSTR value) + trailer). Sequence-paged. Shipped: HistorianTagExtendedPropertyProtocol, golden WcfTagExtendedPropertyProtocolTests, gated live test. See docs/reverse-engineering/wcf-tag-extended-properties.md. |
uppercase string handle | |
| Localized-property read | (no op) | ⛔ No distinct op on 2020 — collapses into R1.5. There is no GetTagLocalizedPropertiesFromName/GetTlpByNm or GetTagLocalizedPropertiesByName in current/aahClientManaged.dll; the only "localized" surfaces are error-message/UI-text localization. Extended properties (R1.5) are the user-defined tag-property read surface. Closed, not throwing. |
— | |
| Event filters | filter bytes in Retrieval.StartEventQuery |
✅ DONE (2026-06-20), live-honored. ReadEventsAsync(start, end, HistorianEventFilter). The filter rides StartEventQuery's pRequestBuff (captured via EventQuery.AddEventFilter + instrument-wcf-writemessage; Equal vs Contains diffed to isolate the op). Filter block: ushort 0 + uint filterCount + uint condCount + uint nameLen + name(UTF-16) + uint 1 + ushort op + uint 1 + value(0x09-len-0x00 compact-ASCII) + byte 0. REAL, not inert (a non-matching predicate returns 0 events; matching returns the subset). Single string-valued predicate only; multi-filter (OR) / multi-condition (AND via AddEventFilterCondition) framing not yet fully captured. See HistorianEventFilter, golden WcfEventQueryProtocolTests. |
— | |
| R1.8 | Analog-summary query | Retrieval.StartQuery (summary mode) |
summary row layout — uint-handle, reachable. Scoped + decode targets located (CAnalogSummaryValue.UnpackFromValueBuffer, fields Min/Max/First/Last/ValueCount/Integral/…). Plan: r1.8-r1.9-summary-queries.md |
— |
| R1.9 | State-summary query | Retrieval.StartQuery (state mode) |
state-summary row layout — uint-handle, reachable. Scoped (CStateSummaryStruct: MinContained/MaxContained/TotalContained/PartialStart/PartialEnd/StateEntryCount). Plan: r1.8-r1.9-summary-queries.md |
— |
1c. Bounded config writes (S–M each)
| ID | Capability | gRPC op | Payload | Notes |
|---|---|---|---|---|
| R1.10 | RenameTagsAsync |
History rename op | rename request buffer | AllowRenameTags already probed |
| Extended-property write | History.AddTagExtendedProperties (AddTEx) |
✅ Add DONE (2026-06-21), live-verified. AddTagExtendedPropertiesAsync/AddTagExtendedPropertyAsync (write mode, uppercase handle). inBuff = exact inverse of the R1.5 read framing (uint32 groupCount + 0x01 + compact-ASCII tag + uint32 propCount + per prop[0x02 + compact-ASCII name + 0x43 VT_BSTR value] + 0x01 trailer + 0x00 terminator); the trailing 0x00 is required or the server throws. Golden WcfTagExtendedPropertyWriteProtocolTests + gated live write/read-back test. Delete (DelTep): wire format CAPTURED + serializer golden-proven (2026-06-21), but live delete is server-blocked and NOT shipped. Captured via a two-session trick (add in Run A → fresh-session read-sync → delete in Run B, past the native err-229 client gate); inBuff = same group framing as Add but property-name-only and a 0x00 group trailer. A decisive experiment shows SDK-added properties ARE deletable (the native client deletes one), so SDK-add is complete; the SDK's own DelTep is rejected (SErrorException in CHistStorage::DeleteTagExtendedProperties) despite matching mode/handle/inBuff + GetTgByNm/GetTepByNm prime + open channel + 60s retries. Root cause: the native multiplexes services over ONE connection (per-connection working set), which the SDK's per-service WCF channels don't reproduce — needs transport-level multiplexing. See docs/reverse-engineering/wcf-add-tag-extended-properties.md §Delete. |
||
| Localized-property write | (no op) | ⛔ No distinct op on 2020 — closed (mirror of R1.6). A symbol sweep of current/*.dll finds no AddTagLocalizedProperties / DeleteTagLocalizedProperties / any *LocalizedPropert* / TagLocalized*; only UI/error-text localization (GetLocalizedText/GetLocalizedMessage/LocalizedResourcesDir). Localized properties are a 2023 R2/gRPC concept. Closed, not throwing. See docs/reverse-engineering/wcf-tag-extended-properties.md §R1.12. |
2026-06-21 | |
| Non-analog tag create (string/discrete) | History.EnsureTags |
distinct CTagMetadata variant | ⛔ GATED — bounded out (2026-06-21, live-probed). Native AddTag rejects every non-analog type client-side (ErrorCode=ValidationFailed / "Transaction validation failed", before any WCF op): SingleByteString, DoubleByteString, and Int1 all fail; Float (control) succeeds. The native HistorianDataType enum has no Discrete/Boolean and no Int8/UInt8 (SDK-only extensions); HistorianTag has no TagType setter (type is data-type-derived). So no non-analog wire request is ever emitted → nothing to capture/implement. String/discrete create goes via a different subsystem (config editor / SQL), not this client's AddTag. EnsureTagAsync stays analog-only. See docs/reverse-engineering/wcf-non-analog-tag-create.md. |
Acceptance: read + browse + metadata + system/status + property R/W + summaries + event-filtered reads + rename all live-verified over gRPC.
Milestone 2 — Event sending (CAPTURE) (S–M) ← headline gap
Goal: SendEventAsync(HistorianEvent). Path fully mapped in histevents.md; one capture away.
✅ DONE (2026-06-20) —
HistorianClient.SendEventAsync(HistorianEvent)shipped and live-accepted over 2020 WCF. The headline assumption — that event delivery would ride the non-WCF storage-engine pipe (and so be blocked like revision writes) — was disproved by capture: a nativeAddStreamedValue(HistorianEvent)leaves over WCF asAddS2(IHistoryServiceContract2.AddStreamValues2). CM_EVENT is a built-in registered tag, so the129 TagNotFoundInCachegate that blocksAddS2for user tags does not apply to events. The full managed chain (Open2 event-mode 0x501 → CM_EVENT RTag2/EnsT2 → AddS2) is accepted by the server (AddS2returns success, empty error buffer). See the event-send field map under §"Event-send wire format" inhistevents.mdandHistorianEventWriteProtocol.⚠️ Persistence caveat (environment, not SDK): on the local dev Historian, accepted events are not persisted to the queryable store (
v_AlarmEventHistory2latest stays at the pre-test date; count only ages down). The native client exhibits the identical behaviour (itsAddS2also returns success but nothing lands), so this is the box's event-ingestion pipeline not being active — not an SDK protocol gap. The SDK emits byte-equivalentAddS2(golden-tested). Full send→store→read-back round-trip awaits a Historian with an active event storage pipeline.
| ID | Work | Status |
|---|---|---|
| R2.1 | Capture the event value blob | ✅ scripts/Capture-EventSend.ps1 (event-send harness scenario + instrument-wcf-{write,read}message); two captures diffed to separate constant framing from value fields. Decisive finding: event-send = WCF AddS2, not storage pipe. |
| R2.2 | HistorianEventWriteProtocol |
✅ Serializes the AddS2 pBuf (storage sample buffer wrapping the event VTQ): "OS" sig + sampleCount + length fields + CM_EVENT tag id + EventTime FILETIME + OpcQuality + opaque descriptor + event Id + ReceivedTime FILETIME + Namespace + EventType + version + typed property bag (string props reuse the read parser's 0x43 encoding). Golden-byte test pins capture A. |
| R2.3 | Event write orchestrator | ✅ HistorianWcfEventOrchestrator.SendEventAsync: Open2 (0x501) → reuse CM_EVENT RTag2/EnsT2 registration → AddStreamValues2(handle, pBuf, out err) on the same /Hist channel + storage-session handle. |
| R2.4 | Public API | ✅ HistorianClient.SendEventAsync(HistorianEvent). Original events only (RevisionVersion=0) with string-valued properties; other property types + revision/update/delete throw ProtocolEvidenceMissingException until captured. |
| R2.5 | Round-trip test | ✅ Golden-byte on R2.2 + gated live test SendEventAsync_AgainstLocalHistorian_AcceptedByServer (asserts server acceptance; SQL read-back best-effort given the persistence caveat). |
Acceptance: an event sent from histsdk is accepted by the historian over WCF with a
byte-correct AddS2 (✅). Appears-and-reads-back is environment-gated on event persistence (see caveat).
Milestone 3 — Historical / non-streamed value writes (BOUNDED) (M)
Goal: insert original historical VTQs (backfill), the path that is NOT the gated cache push.
⛔ BLOCKED on 2020 WCF — re-confirmed by the D2 probe (2026-05-05), see
revision-write-path.md. The premise above ("the path that is NOT the gated cache push") was disproved: R3.1's op (Transaction.AddNonStreamValuesBegin/AddNonStreamValues/End) is the sameITransactionServiceContract2.AddNonStreamValuesBegin2D2 probed, and over WCF it returns04 33 00 00 00=UnknownClient (51)for every handle format and the full priming chain (Stat/Hist/Retr/Trx GetV + UpdC3 + 6× GetSystemParameter + RTag2). Root cause (IL-walk:CClient.TransactionBegin→CHistStorageConnection.StartTransaction→CStorageEngineConsoleClient.StartTransaction): the real transaction rides a shared-memory + named-pipe channel (STransactPipeClient2+SCrtMemFile) toaaStorageEngine.exe, separate from WCF. The WCF Trx op is a server-side relay that requires a pre-existing storage-engine pipe session, which no WCF op can establish. So M3 over 2020 WCF is unimplementable as a pure-managed SDK — same architectural wall as R4.2 (revisions) and theAddS2cache gate.Only remaining lever: the 2023 R2 gRPC front door (HCAL-native, no legacy storage-engine pipe). Whether the gRPC services expose a non-streamed/revision write that bypasses the pipe is untested — it needs the live 2023 R2 server + a native gRPC capture of the write op, then decode/implement. Treat as on-demand (no current demand signal); the WCF path is closed.
| ID | Work | gRPC op | Status |
|---|---|---|---|
| R3.1 | Decode non-streamed VTQ packet | Transaction.AddNonStreamValuesBegin/AddNonStreamValues/End |
⛔ WCF blocked (storage-engine pipe — D2). gRPC: untested |
| R3.2 | AddHistoricalValuesAsync |
batched begin→values→end | ⛔ gated on R3.1 |
| R3.3 | Ingest-permission validation | confirm the target accepts original-data insert (distinct from AddS2 cache wall) |
⛔ proven to share the same gate, not distinct |
Acceptance: historical points inserted and read back. WCF path closed (D2); would require the gRPC write path (live 2023 R2 server + capture) to reopen.
Milestone 4 — HARD subsystems (deferred / optional) (L each)
Only if the use case demands them. Each is a real subsystem, not an op.
| ID | Capability | Approach | Risk |
|---|---|---|---|
| R4.1 | Store-and-forward | Pragmatic local queue (durable outbox + replay on reconnect) rather than bit-faithful SF cache + Forward*Snapshot. Faithful SF = decode SF cache format + snapshot framing + recovery log |
high; consider "good enough" |
| R4.2 | Revision / edit writes | AddRevisionValue(s) go via the non-WCF storage-engine pipe (STransactPipeClient2) — separate transport RE |
high |
| R4.3 | Real store-forward status | duplex push (SetStoreForwardEvent) or a decoded pull endpoint — see store-forward plan |
medium |
| R4.4 | Multi-historian / redundancy | client-side orchestration over N single-historian sessions (failover, ReSyncTags, partner watchdog) — build last | medium |
Won't-do from the client (GATED)
- Streaming process-sample writes (
AddStreamedValue(HistorianDataValue)/AddS2): runtime cache only ingests from configured IOServer/AppServer pipelines. Confirm your ingestion architecture instead of pursuing this.
Cross-cutting workstreams (run alongside all milestones)
- CW-1 Capture tooling (enables R0.5, R1.x, R2.1): one reusable "call op → dump
request/response
bytes→ sanitized fixture" path. Highest leverage — do first. - CW-2 Version compatibility: matrix of tested Historian versions; serializers keyed by version; CI gate.
- CW-3 Cross-platform CI: run the gRPC suite on Linux/macOS (transport is portable; explicit-cred auth path).
- CW-4 Fixtures discipline: every new op ships a
fixtures/protocol/<op>/golden file; sanitize hostnames/tags/GUIDs before commit. - CW-5 Public API shape: keep the modern surface (async,
IAsyncEnumerable, cancellation, options record, DI-friendly) consistent as the surface grows.
Sequencing (critical path)
CW-1 capture tooling ─┐
M0 gRPC parity ───────┼─→ M1 cheap surface ─→ M2 event send ─→ M3 historical writes ─→ (M4 optional)
R0.6 version gate ────┘
Recommended first sprint: CW-1 + M0 (R0.1–R0.6) → a fully Windows-free, version-safe gRPC client at today's capability. Second sprint: M1a + M2 (cheap wins + the headline event-send). M3/M4 as demand dictates.
Status 2026-06-21: sprints 1 + 2 are complete (M0 gRPC parity, the reachable M1 surface, and M2 event-send all shipped + live-verified; remaining M1 items are evidence-bounded-out). The reachable surface on the available 2020 WCF infrastructure is exhausted — every remaining roadmap item is now either (a) blocked by the storage-engine-pipe architecture (M3-WCF, R4.2), (b) gRPC/2023R2-only and needs the live 2023 R2 server for a native capture (R1.3 timezone, R1.4 EventStorageMode, M3/revisions over gRPC), or (c) a HARD deferred subsystem (M4). No further work lands without one of: a live-2023R2 capture session, or a customer-demand trigger.
One-glance status
| Milestone | Tier | Effort | Value | When |
|---|---|---|---|---|
| M0 gRPC parity + capture tooling | foundation | M | unblocks everything, Windows-free | ✅ done |
| M1 cheap surface | TRIVIAL/BOUNDED | M–L | most remaining read/config | ✅ done (reachable surface; rest bounded out) |
| M2 event send | CAPTURE | S–M | headline write capability | ✅ done |
| M3 historical writes | BOUNDED | M | backfill | ⛔ WCF blocked (D2); gRPC = on-demand + live 2023R2 |
| M4 SF / revisions / redundancy | HARD | L×N | parity completeness | defer (R4.2 = same pipe wall) |