R1.11 DelTep capture + R1.3/R1.4/R1.12/R1.13 bounded out

DelTep (extended-property delete) — wire format captured + serializer
golden-proven, but live delete is server-blocked and NOT exposed publicly:
- Captured the DelTep inBuff via a cross-session trick (harness add-tep gains
  --tep-skip-add + read-for-sync before --tep-delete; Capture-DeleteTagExtended
  Properties.ps1 / decode-del-tep-capture.py). Layout = same group framing as
  AddTEx but property-name-only (no 0x43 value) + 0x00 group trailer.
- SerializeDeleteRequest + 4 golden tests pin the server-accepted buffer.
- A decisive experiment shows SDK-added properties ARE deletable (the native
  client read-syncs and deletes one), so SDK-add is complete; the SDK's own
  DelTep is rejected by CHistStorage::DeleteTagExtendedProperties even with
  byte-identical inBuff, matching mode/handle, GetTgByNm+GetTepByNm prime, open
  channel, and 60s retries. Root cause: the native multiplexes services over one
  connection (per-connection working set); the SDK's per-service WCF channels
  don't reproduce it. Kept as documented-but-blocked internal orchestrator path;
  no public HistorianClient delete API.

Bounded out with evidence (no code; docs + roadmap + probe):
- R1.12 localized-property write — no op on 2020 (mirror of R1.6); no
  *LocalizedPropert*/TagLocalized* symbol in any current/*.dll.
- R1.13 non-analog tag create — GATED; native AddTag rejects every non-analog
  type client-side (ValidationFailed, before any WCF op): SingleByteString,
  DoubleByteString, Int1 all fail, Float works. No Discrete type in the native
  enum, no TagType setter. No wire request to capture.
- R1.3 timezone + R1.4 EventStorageMode — re-confirmed 2023R2/gRPC-only from
  the Runtime DB schema (no timezone param, no EventStorageMode anywhere) and a
  parameter-op probe (GetSystemParameter + GETRP return null/throw for every
  candidate; only HistorianVersion works).

238 unit tests pass; full solution builds with 0 warnings.

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:
Joseph Doherty
2026-06-21 11:26:21 -04:00
parent 08b950caee
commit c1b1b3d23b
14 changed files with 732 additions and 32 deletions
+5 -5
View File
@@ -92,7 +92,7 @@ read/browse/status surface is Windows-free and the gRPC stack is the default pat
|---|---|---|---|
| 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`. |
| ~~R1.3~~ | ~~`GetServerTimeZoneAsync`~~ | `Status.GetSystemTimeZoneName` | **gRPC/2023R2-only — re-confirmed 2026-06-21 from 3 angles.** (1) native `GetSystemTimeZoneName` is a stub (rc=0, empty) in the `GetServerTime` family; (2) Runtime DB has no timezone `SystemParameter` — the zone exists only as per-block `HistoryBlock.TimeZoneOffset`/`wwTimeZone` (DST-specific, SQL-only) + a `TimeZone` lookup table, `StorageShard.TimeZoneId`=NULL; (3) `GetSystemParameter` + `GETRP` return null/throw for every timezone candidate (only `HistorianVersion` works). The sole 2020 route is a SQL read via `ExecuteSqlCommand` (R1.1) — DST-specific, different mechanism. Build the real op only against a live 2023 R2 server. See `docs/reverse-engineering/wcf-status-localhost.md`. |
> ✅ **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
@@ -110,7 +110,7 @@ read/browse/status surface is Windows-free and the gRPC stack is the default pat
### 1b. Bounded (decode one `bytes` payload; SM each)
| ID | Capability | gRPC op | Payload to decode | Depends |
|---|---|---|---|---|
| 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.4~~ | `GetHistorianInfoAsync` | `Status.GetHistorianInfo` (`GETHI`) | **BOUNDED OUT on 2020 (re-confirmed 2026-06-21).** GETHI is a **named-value** query reachable via the uppercase storage GUID, but **only `HistorianVersion` resolves** — the full 518-byte `HISTORIAN_INFO` struct (incl. `EventStorageMode`@514) is the 2023R2 HCAL-native/gRPC model. `EventStorageMode` has **no 2020 representation at all**: not a `SystemParameter` (only `EventStorageDuration`/`EventStorageLogPath`), not a DB column, and `GETRP`/`GetSystemParameter` return null/throw for it. The only 2020-reachable field (version) is already shipped via `GetSystemParameterAsync`/`GetRuntimeParameterAsync`, so a struct API would be hollow + misleading. Build the real op only against a live 2023 R2 server. See `docs/reverse-engineering/wcf-status-localhost.md`. | uppercase string handle |
| ~~R1.5~~ | 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 |
| ~~R1.6~~ | 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. | — |
| ~~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`. | — |
@@ -121,9 +121,9 @@ read/browse/status surface is Windows-free and the gRPC stack is the default pat
| ID | Capability | gRPC op | Payload | Notes |
|---|---|---|---|---|
| R1.10 | `RenameTagsAsync` | History rename op | rename request buffer | `AllowRenameTags` already probed |
| ~~R1.11~~ | 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) deferred** — native client-side sync gate (err 229) blocks capturing its inBuff. See `docs/reverse-engineering/wcf-add-tag-extended-properties.md`. |
| R1.12 | Localized-property **write** | `History.AddTagLocalizedProperties` / `DeleteTagLocalizedProperties` | localized serialize | |
| R1.13 | Non-analog tag create (string/discrete) | `History.EnsureTags` | distinct CTagMetadata variant | ⚠ native AddTag rejected some types — confirm server path first; may be GATED |
| ~~R1.11~~ | 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. |
| ~~R1.12~~ | 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 |
| ~~R1.13~~ | 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.
@@ -47,14 +47,73 @@ instrument capture mangles the final byte with MDAS chunk markers, so the golden
**clean** byte[] the SDK handed the channel (dumped via `AVEVA_HISTORIAN_TEP_DUMP`) — the exact buffer
the live server accepted.
## Delete (DelTep) — deferred
## Delete (DelTep) — wire format captured + serializer proven; live delete server-blocked
`DeleteTagExtendedProperties` (`DelTep`) is **not shipped yet**. The native
`DeleteTagExtendedPropertiesByName(tag, propertyNames, deleteFromServer, out err)` performs a
**client-side sync check** and returns error **229 ("Tag extended property not synchronized with
server")** when deleting a just-added property — so the `DelTep` request never reached the wire and its
inBuff could not be captured. Capturing it needs a property that is already server-synchronized (add it
in one session, then delete in a later one). Left for a follow-up rather than shipping a guessed buffer.
**Status (2026-06-21): the `DelTep` wire format is captured and decoded, the serializer is
golden-verified against a server-accepted buffer, and SDK-added properties are confirmed deletable —
but the SDK's own delete is rejected server-side and is therefore NOT exposed publicly.** This is a
much deeper result than the earlier "couldn't capture the inBuff" deferral.
### Capturing it: the cross-session trick
The native `DeleteTagExtendedPropertiesByName(tag, propertyNames, deleteFromServer, out err)` performs
a **client-side sync check** and returns error **229 ("Tag extended property not synchronized with
server")** when deleting a *just-added* property — so a same-session add→delete never reaches the wire.
`AddTEx` success does **not** mark the local cache entry as server-synchronized; only a *server fetch*
(`GetTepByNm`) does. So the capture (`scripts/Capture-DeleteTagExtendedProperties.ps1`) runs two
separate harness processes against one instrumented DLL:
- **Run A**: `add-tep` creates the sandbox tag and adds the property (now server-synced).
- **Run B**: a fresh process opens a new connection, fetches the property
(`GetTagExtendedPropertiesByName`, which seeds the local cache as synced), then deletes it — so
`DeleteTagExtendedPropertiesByName` passes the client gate and `DelTep` reaches the wire.
### The inBuff — same group framing as Add, names only
```
uint32 groupCount (= 1)
byte 0x01 (group marker)
0x09 + uint16 byteLen + ASCII tagName
uint32 propertyCount
repeated propertyCount times:
byte 0x02 (property marker) + 0x09 + uint16 byteLen + ASCII propertyName ← NO value variant
byte 0x00 (group trailer) ← 0x00 for delete, vs 0x01 for add
byte 0x00 (buffer terminator)
```
The native `deleteFromServer` argument is **not** in the buffer — it is the client-side flag that
decides whether the wire op fires at all (`true` ⇒ a `DelTep` call). `HistorianTagExtendedProperty
Protocol.SerializeDeleteRequest` produces this exactly; `WcfTagExtendedPropertyWriteProtocolTests`
pins the server-accepted bytes.
### Why the SDK delete is server-blocked
The SDK's `DelTep` is rejected by the server with `SErrorException` in
`aahClientAccessPoint::CHistStorage::DeleteTagExtendedProperties`, even though:
- the **inBuff is byte-identical** to the server-accepted native capture (golden-verified);
- the **Open2 connection mode matches** the native (`0x401`, confirmed from the capture at offset
0x4a4);
- the **handles match** (uppercase storage-session GUID for `DelTep`/`GetTepByNm`, uint client handle
for `GetTgByNm`);
- the SDK first **primes the session** with `GetTgByNm` (tag identity, returns 140 bytes of tag info)
and `GetTepByNm` (returns the property), keeping the Retrieval prime channel **open across** the
`DelTep` call;
- retried with backoff for **60 s** (ruling out a storage-tier sync delay).
A decisive experiment localizes the gap: an **SDK-added** property *is* deletable — the native client
read-syncs and deletes it (`Success:true`). So the SDK's **add is complete**; only the SDK's **delete
session** is the problem. The native client multiplexes Hist/Retr/Stat/Trx over **one connection**
under a single `HistorianAccess` session, so its `GetTepByNm` populates a **per-connection working set**
that the same-connection `DelTep` consults. The SDK uses **separate WCF channels per service** (the
proven read pattern), so the borrowed-GUID Retrieval prime doesn't satisfy that server-side check.
Reproducing it requires transport-level connection multiplexing — a substantial change beyond this op.
The investigated-but-blocked orchestration is kept (internal
`HistorianWcfTagWriteOrchestrator.DeleteTagExtendedPropertiesAsync`, the `PrimeThenDelete` helper) for
follow-up, but `HistorianClient` deliberately exposes **no** public delete to avoid a silently-failing
write API. Capture/decode tooling: `scripts/Capture-DeleteTagExtendedProperties.ps1` +
`scripts/decode-del-tep-capture.py`, harness `add-tep` scenario with `--tep-skip-add` / `--tep-delete`.
## Shipped surface
@@ -0,0 +1,67 @@
# Non-analog tag create over 2020 WCF — GATED (HCAL R1.13)
**Status: ⛔ bounded out (2026-06-21). No non-analog (string / discrete / wide-integer) tag-create
path is reachable on 2020 — the native managed client rejects every non-analog type *client-side*,
before any WCF op, so there is no wire format to capture and nothing to implement against.**
`EnsureTagAsync` stays analog-only (Float, Double, Int2, UInt2, Int4, UInt4); unsupported types throw
`ProtocolEvidenceMissingException` from `HistorianTagWriteProtocol.GetAnalogDataTypeCode`.
## What R1.13 asked for
Create string / discrete (non-analog) tags via `History.EnsureTags`, with a distinct `CTagMetadata`
variant. The roadmap flagged it "⚠ native AddTag rejected some types — confirm server path first;
may be GATED."
## Findings (live-probed against the local 2020 Historian)
1. **No discrete/boolean data type exists.** The native `ArchestrA.HistorianDataType` enum
(`current/aahClientManaged.dll`, dumped via `enum-dump`) has exactly 12 members: `Int1, Int2,
UInt2, Int4, UInt4, Float, Double, SingleByteString, DoubleByteString, Event, Structure`. There is
no `Discrete`/`Boolean`, and no `Int8`/`UInt8`/`UInt1`/`Guid`/`FileTime` (those are SDK-only
extensions in `Models/HistorianDataType`, recovered from the C++ `CDataType` predicate IL — they
are not settable on the managed `HistorianTag`).
2. **Tag type is data-type-derived, not separately settable.** `ArchestrA.HistorianTag`
(`--dump-type-members`) has **no** `TagType` property — only `TagDataType`. It does carry the
discrete/string-shaped fields (`MessageOn`/`MessageOff`, `RolloverValue`, dead-band/interpolation),
and the type exposes `ValidateAnalog*`, `ValidateDiscreteGeneralProperties`, and
`ValidateDiscreteAndStringStorageProperties` — but the analog-vs-discrete-vs-string decision is made
internally from the data type, with no way to request "discrete."
3. **Native AddTag rejects every non-analog type client-side.** Driving the native
`HistorianAccess.AddTag(HistorianTag, …)` (harness `write` scenario, `--write-data-type`) against
the live server:
| Data type | AddTag.Success | ErrorCode | ErrorType |
|---|---|---|---|
| SingleByteString | **false** | ValidationFailed | CustomError |
| DoubleByteString | **false** | ValidationFailed | CustomError |
| Int1 | **false** | ValidationFailed | CustomError |
| Int8 / UInt8 | n/a | *not in the native enum* | — |
| Float (control) | true | Success | — |
The error — `ErrorType=CustomError`, `ErrorCode=ValidationFailed`, `ErrorDescription="Transaction
validation failed"` — is raised by the client's own `Validate*` chain **before any WCF message is
sent** (the wrapper even auto-populates discrete defaults `MessageOn=ON`/`MessageOff=OFF`, then
fails validation). So the native client never emits a non-analog `EnsT2`/`AddTag` request.
## Why it's not deliverable here
Because the native client refuses non-analog types client-side, **no wire request exists to
reverse-engineer** — there is no captured `CTagMetadata` variant for string or discrete tags, and the
SDK does not guess wire bytes. The rejection is not string-specific: `Int1` (a non-string integer
outside the analog set `{Float, Double, Int2, UInt2, Int4, UInt4}`) fails identically, so the boundary
is "the analog set" rather than "strings only." Creating string/discrete tags on 2020 evidently goes
through a different subsystem (e.g. the configuration editor / SQL config path), not this client's
`AddTag`. R1.13 is closed as GATED, consistent with the mission note that these types "fail at native
AddTag — likely require a different path and are intentionally not supported."
## Probe commands (read-only / sandbox-guarded)
```
dotnet run --project tools\AVEVA.Historian.ReverseEngineering -- enum-dump current\aahClientManaged.dll HistorianDataType
dotnet run --project tools\AVEVA.Historian.ReverseEngineering -- dnlib-method current\aahClientManaged.dll HistorianTag.ValidateDataType
# native AddTag probe (sandbox tag must start with RetestSdkWrite; --write-skip-add-value avoids the blocked value path):
dotnet run --project tools\AVEVA.Historian.NativeTraceHarness -- --scenario write --server-name localhost --tcp-port 32568 \
--write-sandbox-tag RetestSdkWriteNADoubleByteString --write-data-type DoubleByteString --write-skip-add-value
```
@@ -71,3 +71,31 @@ Findings:
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).
## R1.3 timezone + R1.4 EventStorageMode — re-confirmed bounded out (2026-06-21)
Both were already classified 2023R2/gRPC-only; re-verified from two *fresh* angles that corroborate it
more strongly than the original op-level probes:
- **Runtime DB schema** (`Runtime.dbo`, the server's own source of truth): the `SystemParameter` table
has **no** timezone parameter and **no `EventStorageMode`** (only `EventStorageDuration` /
`EventStorageLogPath`). The server timezone exists only as **per-block storage artifacts**
(`HistoryBlock.TimeZoneOffset` = e.g. 240 min, `wwTimeZone` = e.g. "Eastern Daylight Time") and a
`TimeZone` reference/lookup table; `StorageShard.TimeZoneId` is NULL. So the timezone is a
DST-specific, SQL-only, OS-derived value, not a clean server-config field exposed by any op.
- **Parameter-op probe** (`StringHandleProbeDiagnosticTests.TimezoneAndStorageMode_ParameterProbe`):
`GetSystemParameter` and `GetRuntimeParameter` (GETRP) were asked for every timezone candidate
(`TimeZone`/`ServerTimeZone`/`SystemTimeZone`/`TimeZoneName`/`SystemTimeZoneName`/`TimeStampRule`/
`ServerTime`) and every storage-mode candidate (`EventStorageMode`/`StorageMode`/`EventStorage`/
`EventStorageDuration`). **All returned null (GetSystemParameter) or threw
`ProtocolEvidenceMissingException` (GETRP — non-string/empty response)**; only the `HistorianVersion`
control returned a value (`20,0,000,000`). Note: `TimeStampRule`/`EventStorageDuration` *do* exist in
the `SystemParameter` table yet `GetSystemParameterAsync` returns null for them — the shipped op only
surfaces a whitelisted subset (a possible future widening, unrelated to R1.3/R1.4).
Conclusion: **R1.3 `GetServerTimeZoneAsync` and R1.4 `GetHistorianInfoAsync` (EventStorageMode) are not
deliverable as server ops on 2020.** The only 2020 route to the timezone is a SQL read of
`HistoryBlock`/`TimeZone` via `ExecuteSqlCommand` (R1.1) — a DST-specific value over a different
mechanism than the roadmap's `Status.GetSystemTimeZoneName`. `EventStorageMode` has no 2020
representation at all (it is a 2023 R2 event-storage-architecture field). Deliver both only against a
live 2023 R2 gRPC server.
@@ -94,6 +94,18 @@ UI-text localization), not tag properties. So R1.6 **collapses into R1.5**: exte
(`GetTepByNm`) are the user-defined tag-property read surface on 2020. R1.6 is closed as
"no separate op," not left throwing.
## R1.12 (localized-property write) — no distinct op on 2020 (mirror of R1.6)
Symmetric to R1.6 on the write side: there is **no** `AddTagLocalizedProperties` /
`DeleteTagLocalizedProperties` (or any `*LocalizedPropert*` / `TagLocalized*`) symbol in **any**
`current/*.dll`. A full symbol sweep of the shipped client DLLs surfaces only `GetLocalizedText`,
`GetLocalizedMessage`, and `LocalizedResourcesDir` — all UI/error-message-text localization, not tag
data. (The sweep does find the real write op `AddTagExtendedProperties` and the whole `AddTag*`
family, so the absence of a localized op is a true negative, not a grep miss.) R1.12 is therefore
**closed as "no separate op"** — the same conclusion as R1.6's read side. Extended-property write
(R1.11 `AddTEx`) is the user-defined tag-property write surface on 2020; localized properties are a
2023 R2 / gRPC-only concept. Not left throwing.
## Shipped surface
- `HistorianClient.GetTagExtendedPropertiesAsync(tag)``IReadOnlyList<HistorianTagExtendedProperty>`