R1.2 GetRuntimeParameter + string-handle wall RESOLVED (handle-format bug)
Execute HCAL roadmap R1.2 (GetRuntimeParameterAsync) end-to-end, and in doing so
discover that the "string-handle wall" blocking R1.1/R1.4/R1.5/R1.6 was a handle
FORMAT bug, not a missing native session/filter registration.
R1.2 (shipped, live-verified):
- Captured native GetRuntimeParameter -> WCF op aa/Stat/GETRP (string-handle op,
GETHI's shape), via scripts/Capture-RuntimeParam.ps1 + instrument-wcf-{write,read}message.
- HistorianRuntimeParameterProtocol serializes pRequestBuff (54 67 01 00 + uint
nameCount + per-name uint charCount + UTF-16) and parses pResponseBuff (version +
uint resultCount + CRetVariant 0x43 VT_BSTR + uint16 len + uint16 charCount + UTF-16).
- IStatusServiceContract2.GetRuntimeParameter (GETRP) op; HistorianWcfStatusClient
passes the Open2 storage-session GUID as the string handle, UPPERCASE.
- Public HistorianClient.GetRuntimeParameterAsync(name) via the dialect.
- Golden WcfRuntimeParameterProtocolTests + gated live test; returns HistorianVersion.
String-handle wall RESOLVED (proven, public APIs deferred):
- The Open2 storage GUID works as the string handle when sent UPPERCASE
(ToString("D").ToUpperInvariant()); earlier "blocked" probes used lowercase.
- Live-probed GETHI (R1.4) -> returns data; ExeC (R1.1) -> Retr.GetV prime -> ExeC ->
GetR returns a BinaryFormatter-serialized .NET DataTable. Gated
StringHandleProbeDiagnosticTests + scripts/Capture-ExecSql.ps1 + exec-sql harness scenario.
- Docs flipped: wcf-string-handle-wall.md RESOLVED banner; roadmap R1.1/R1.4 reachable,
R1.5/R1.6 likely; wcf-status-localhost.md GETRP section.
- R1.1/R1.4 public APIs NOT shipped: ExeC needs a GetR paging loop + a BinaryFormatter-
stream parser (BinaryFormatter is removed from .NET 10); GETHI full-info struct needs
its own capture.
223 unit tests pass; gated live tests green against the local 2020 Historian.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC
This commit is contained in:
+19
-15
@@ -39,8 +39,12 @@ HCAL replacement, built on the **2023 R2 gRPC transport**. Derived from
|
||||
> reachable). The reachable **`uint`-handle** items are now **DONE**: ~~R1.8/R1.9 StartQuery
|
||||
> summary/state modes~~ (resolved = existing `ReadAggregateAsync`) and ~~R1.7 event filters~~
|
||||
> (✅ 2026-06-20 — `ReadEventsAsync(…, HistorianEventFilter)`, live-honored). M2 event send is
|
||||
> also done (✅ WCF `AddS2`). Everything string-handle still waits on one RE target: the native
|
||||
> session/filter registration.
|
||||
> also done (✅ WCF `AddS2`). **R1.2 `GetRuntimeParameterAsync` is 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 is a strong lead
|
||||
> that the GETHI/ExeC failures are (at least partly) a handle-*format* issue rather than only a
|
||||
> missing native registration. **Cheap high-value follow-up: retry GETHI/ExeC with the uppercased
|
||||
> storage GUID** before assuming the registration wall (see `wcf-string-handle-wall.md` §Update).
|
||||
|
||||
## Guiding principles
|
||||
|
||||
@@ -86,25 +90,25 @@ read/browse/status surface is Windows-free and the gRPC stack is the default pat
|
||||
### 1a. Trivial (XS–S each, no new payload format)
|
||||
| ID | Capability | gRPC op | Notes |
|
||||
|---|---|---|---|
|
||||
| ~~R1.1~~ | ~~`ExecuteSqlCommandAsync`~~ | `Retrieval.ExecuteSqlCommand` | ⚠ **Blocked on 2020 WCF.** Live-probed 2026-06-20: `ExeC` returns native error type 4 / code **51 (InvalidParameter)** for every handle variant — same unmapped *native session/filter registration* prerequisite that blocks `StartTagQuery`/`QueryTag` (see `implementation-status.md` lines ~982, ~1404). Needs that registration RE'd, or a 2023 R2 gRPC server. Do not wire via guessed calls. |
|
||||
| R1.2 | `GetRuntimeParameterAsync` | `Status.GetRuntimeParameter` | mirror `GetSystemParameter` |
|
||||
| R1.1 | `ExecuteSqlCommandAsync` | `Retrieval.ExecuteSqlCommand` (`ExeC`+`GetR`) | ✅ **REACHABLE (2026-06-20, live-probed).** The earlier "code 51 blocked" verdict was a handle-**format** bug — `ExeC` succeeds with the Open2 storage GUID sent **uppercase** (`ToString("D").ToUpperInvariant()`). Chain: `Retr.GetV` prime → `ExeC(handle, sqlString, option=0, ref queryHandle)` → `GetR(handle, queryHandle, ref sequence)` returns the result as a **BinaryFormatter-serialized .NET DataTable**. Proven by `StringHandleProbeDiagnosticTests` + `scripts/Capture-ExecSql.ps1`. **Public API not yet shipped** — needs a `GetR` continuation loop + a custom BinaryFormatter-stream parser (BinaryFormatter is removed from .NET 10, so a DataTable can't just be deserialized). |
|
||||
| ~~R1.2~~ | ~~`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. |
|
||||
| ~~R1.3~~ | ~~`GetServerTimeZoneAsync`~~ | `Status.GetSystemTimeZoneName` | ⚠ **gRPC/2023R2-only.** Verified 2026-06-20: over **2020 WCF** this op is a stub (rc=0, empty value) in the `GetServerTime` family — not shippable here. Build+verify only against a live 2023 R2 server. See `docs/reverse-engineering/wcf-status-localhost.md`. |
|
||||
|
||||
> ⛔ **String-handle wall (2026-06-20).** R1.4/R1.5/R1.6 (and R1.1) are **all blocked on 2020
|
||||
> WCF** for the *same* reason: their ops take a **`string` GUID handle** and require an unmapped
|
||||
> native session/filter registration. Probed live — GETHI returns code 1 for the exact native
|
||||
> request shape across 5 handle formats + Stat.GetV priming; ExeC returns code 51. The proven
|
||||
> surface uses **`uint`-handle** ops only. **One RE target — the native string-handle session
|
||||
> registration — unblocks this whole sub-milestone.** Full analysis:
|
||||
> `docs/reverse-engineering/wcf-string-handle-wall.md`. R1.8/R1.9 (StartQuery summary/state modes)
|
||||
> are `uint`-handle and remain reachable on 2020 WCF.
|
||||
> ✅ **String-handle "wall" RESOLVED (2026-06-20) — it was a handle-FORMAT bug.** R1.4/R1.5/R1.6
|
||||
> (and R1.1) take a **`string` GUID 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. R1.5/R1.6
|
||||
> (GetTepByNm family) + QTB/QTG are very likely reachable the same way (not yet individually
|
||||
> re-probed). Full analysis: `docs/reverse-engineering/wcf-string-handle-wall.md` (RESOLVED banner).
|
||||
> R1.8/R1.9 (StartQuery summary/state modes) are `uint`-handle and were already reachable.
|
||||
|
||||
### 1b. Bounded (decode one `bytes` payload; S–M each)
|
||||
| ID | Capability | gRPC op | Payload to decode | Depends |
|
||||
|---|---|---|---|---|
|
||||
| ~~R1.4~~ | `GetHistorianInfoAsync` | `Status.GetHistorianInfo` | ⛔ **string-handle wall** — GETHI returns code 1 on 2020 WCF (all handle/priming variants). GETHI buffer incl. `EventStorageMode`@514. | string-handle RE |
|
||||
| ~~R1.5~~ | Extended-property **read** | `Retrieval.GetTagExtendedPropertiesFromName` | ⛔ **string-handle wall** (GetTepByNm takes `string handle`). TEP result buffer. | string-handle RE |
|
||||
| ~~R1.6~~ | Localized-property **read** | `Retrieval.GetTagLocalizedPropertiesFromName` | ⛔ **string-handle wall** (same family). | string-handle RE |
|
||||
| R1.4 | `GetHistorianInfoAsync` | `Status.GetHistorianInfo` (`GETHI`) | ✅ **REACHABLE (2026-06-20, live-probed)** via the uppercase storage GUID — `GETHI` returns data (`StringHandleProbeDiagnosticTests`). The version-keyed request returns `uint charCount + UTF-16`; the full info struct (incl. `EventStorageMode`@514) needs its own request capture. **Public API not yet shipped.** | uppercase string handle |
|
||||
| R1.5 | Extended-property **read** | `Retrieval.GetTagExtendedPropertiesFromName` | 🟡 **Likely reachable** via uppercase string handle (GetTepByNm family) — not yet individually re-probed. TEP result buffer. | uppercase string handle |
|
||||
| R1.6 | Localized-property **read** | `Retrieval.GetTagLocalizedPropertiesFromName` | 🟡 **Likely reachable** (same family) — not yet re-probed. | uppercase string handle |
|
||||
| ~~R1.7~~ | 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.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`](r1.8-r1.9-summary-queries.md) | — |
|
||||
|
||||
@@ -48,3 +48,26 @@ Interpretation:
|
||||
- **`GetServerTimeZoneAsync` (roadmap R1.3) is NOT a trivial WCF op on 2020** — it
|
||||
is a stub returning empty. Do not ship it over the 2020 WCF transport. Deliver
|
||||
it only against a live 2023 R2 gRPC server. Reclassified in `docs/plans/hcal-roadmap.md`.
|
||||
|
||||
## GETRP / GetRuntimeParameter (roadmap R1.2) — DONE, live-verified 2026-06-20
|
||||
|
||||
Captured the native `HistorianAccess.GetRuntimeParameter(List<string>, out List<object>)`
|
||||
WCF traffic with `scripts/Capture-RuntimeParam.ps1` (instrument-wcf-{write,read}message).
|
||||
Findings:
|
||||
|
||||
- The WCF op is **`aa/Stat/GETRP`** — `bool GETRP(string handle, byte[] pRequestBuff,
|
||||
out byte[] pResponseBuff, out byte[] errorBuffer)`, i.e. the **same string-handle +
|
||||
request/response-buffer shape as GETHI**, *not* the simple `GetSystemParameter(uint, string)`
|
||||
shape the roadmap originally assumed.
|
||||
- The `string handle` is the **Open2 storage-session GUID** (the value
|
||||
`ParseOpenConnectionResponse` reads from `outBuff[5..21]`), sent **UPPERCASE, dash-separated,
|
||||
no braces** (`ToString("D").ToUpperInvariant()`).
|
||||
- Unlike GETHI (which the earlier probe found blocked), **GETRP succeeds from the pure-managed
|
||||
client** with that handle: `GetRuntimeParameter("HistorianVersion")` → `20,0,000,000`.
|
||||
- `pRequestBuff` = `54 67 01 00` (sig+version) + uint nameCount + per name(uint charCount +
|
||||
UTF-16LE). `pResponseBuff` = version(1) + uint resultCount + CRetVariant(`0x43` VT_BSTR +
|
||||
uint16 payloadLen + uint16 charCount + UTF-16LE).
|
||||
|
||||
Shipped as `HistorianClient.GetRuntimeParameterAsync(name)`. See
|
||||
`HistorianRuntimeParameterProtocol`, golden `WcfRuntimeParameterProtocolTests`, and the
|
||||
handle-format lead in `wcf-string-handle-wall.md` §Update (retry GETHI/ExeC uppercased).
|
||||
|
||||
@@ -1,10 +1,35 @@
|
||||
# The 2020 WCF string-handle wall (2026-06-20)
|
||||
|
||||
> ## ✅✅ RESOLVED (2026-06-20): the "wall" was a handle-FORMAT bug, not a registration wall.
|
||||
>
|
||||
> The string-handle ops are reachable from the pure-managed client after all. The Open2
|
||||
> storage-session GUID must be passed as the `string handle` **UPPERCASE, dash-separated,
|
||||
> no braces** — `storageSessionId.ToString("D").ToUpperInvariant()`. The earlier probes that
|
||||
> "proved" the wall passed the GUID in .NET's default **lowercase** `ToString("D")`, which the
|
||||
> server's session table does not match. Live-verified end-to-end against the local 2020 server:
|
||||
> - **GETRP** (R1.2) → returns the runtime `HistorianVersion` (shipped).
|
||||
> - **GETHI** (R1.4) → `returned=True`, returns the version buffer (`0C000000` + UTF-16 "20,0,000,000").
|
||||
> - **ExeC** (R1.1) → `returned=True`, `Retr.GetV` prime + `ExeC("SELECT 1 AS ProbeValue", option=0)`
|
||||
> yields `queryHandle`, then `GetR(handle, queryHandle, sequence=0)` returns a 1232-byte result =
|
||||
> a **BinaryFormatter-serialized .NET DataTable** (stream header `…System.Data, Version=4.0.0.0…`).
|
||||
>
|
||||
> Probes: gated `StringHandleProbeDiagnosticTests` (GETHI + ExeC). Captures:
|
||||
> `scripts/Capture-RuntimeParam.ps1`, `scripts/Capture-ExecSql.ps1`. The handle for ExeC/GetR is the
|
||||
> **same** Open2 storage-session GUID (confirmed = `outBuff[5..21]`). The original analysis below is
|
||||
> retained for history; treat its "blocked" conclusions as **superseded** — the only missing piece
|
||||
> was the uppercase format. R1.5/R1.6 (GetTepByNm family) and QTB/QTG are very likely reachable the
|
||||
> same way but have not yet been individually re-probed.
|
||||
|
||||
---
|
||||
|
||||
Live-probing the local **Historian 2020** (WCF, port 32568) for HCAL roadmap M1
|
||||
surfaced a clean structural boundary on what the pure-managed client can call. It
|
||||
explains why R1.1/R1.4/R1.5 all fail and identifies the single RE target that
|
||||
unblocks the rest of the M1 read surface.
|
||||
|
||||
> ⚠️ **Superseded — see the RESOLVED banner above.** The boundary below is real *only* when the
|
||||
> handle is sent lowercase. With the uppercased storage GUID the string-handle ops succeed.
|
||||
|
||||
## The dichotomy
|
||||
|
||||
Retrieval/Status/History ops split by the **type of their first (handle) parameter**:
|
||||
@@ -56,3 +81,34 @@ once and the whole family unlocks. Until then, the alternatives are:
|
||||
|
||||
Do **not** ship any string-handle op via guessed calls (project discipline:
|
||||
"leave them throwing until evidence supports an implementation").
|
||||
|
||||
## ⚠️ Update (2026-06-20): GETRP punches through — the wall is not absolute
|
||||
|
||||
Roadmap **R1.2 `GetRuntimeParameterAsync`** turned out to be a **`string`-handle op**
|
||||
(`aa/Stat/GETRP(string handle, byte[] pRequestBuff) → (bool, byte[] pResponseBuff,
|
||||
byte[] errorBuffer)`) — the **same shape as GETHI**, and in the same native session it
|
||||
uses the **same handle GUID** as GETHI (confirmed: the GUID equals the Open2 `outBuff`
|
||||
storage-session id at `[5..21]`, the value the managed `ParseOpenConnectionResponse`
|
||||
already extracts as `StorageSessionId`).
|
||||
|
||||
Yet GETRP **works from the pure-managed client** — live-verified, returns the runtime
|
||||
`HistorianVersion` value `20,0,000,000`. The only material difference from the failed
|
||||
GETHI probe is the **handle string format**: the native client sends the GUID
|
||||
**UPPERCASE, dash-separated, no braces** (format example
|
||||
`XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX`, all hex upper), i.e.
|
||||
`storageSessionId.ToString("D").ToUpperInvariant()`. `.NET Guid.ToString("D")` is
|
||||
lowercase, so a probe that passed the GUID without upcasing would not byte-match what
|
||||
the server's session table is keyed on.
|
||||
|
||||
**Implication / open lead (not yet retested):** the GETHI/ExeC/QTB/QTG family failures
|
||||
may be (at least partly) a **handle-format** issue, not (only) a missing native
|
||||
registration step. The highest-value cheap follow-up is to **re-probe GETHI and ExeC
|
||||
with the uppercased storage-session GUID** before assuming the registration wall. If
|
||||
they also return data, the "wall" collapses to a formatting bug and R1.4/R1.5/R1.6/R1.1
|
||||
may be reachable without any new RE. This has **not** been done yet — do not reclassify
|
||||
those items until it is. GETRP is shipped because it was directly captured + live-verified
|
||||
end-to-end; the rest remain `ProtocolEvidenceMissingException`/unprobed until tested.
|
||||
|
||||
See `HistorianRuntimeParameterProtocol`, `IStatusServiceContract2.GetRuntimeParameter`,
|
||||
golden `WcfRuntimeParameterProtocolTests`, and capture tooling
|
||||
`scripts/Capture-RuntimeParam.ps1` + `scripts/decode-runtime-param-capture.py`.
|
||||
|
||||
Reference in New Issue
Block a user