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
@@ -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>`