Files
histsdk/docs/reverse-engineering/wcf-add-tag-extended-properties.md
T
Joseph Doherty c1b1b3d23b 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
2026-06-21 11:26:21 -04:00

7.4 KiB

Extended-property write over 2020 WCF — AddTEx (HCAL R1.11)

Status: Add DONE + live-verified (2026-06-21). Delete (DelTep) deferred — see below. HistorianClient.AddTagExtendedPropertiesAsync / AddTagExtendedPropertyAsync writes user-defined extended properties onto an existing tag via the 2020 WCF AddTEx (AddTagExtendedProperties) op, and they read back via the R1.5 GetTagExtendedPropertiesAsync path. Verified end-to-end from the pure-managed .NET 10 client against the local 2020 Historian (create tag → add property → read back → delete tag).

The op

bool AddTagExtendedProperties(string handle, byte[] inBuff, out byte[] errorBuffer)   // AddTEx

On IHistoryServiceContract2 (History service). Requires a write-enabled connection (Open2 mode 0x401) and the uppercase storage-session GUID handle — the SDK reuses the write orchestrator's open + priming chain (the same one used by EnsT2/DelT). The tag is referenced by name inside inBuff; no extra per-connection tag registration was needed (the server resolves it).

The inBuff — the exact inverse of the R1.5 read response

The native AddTagExtendedProperties(TagExtendedPropertyGroupList, out err) packs its groups into the AddTEx inBuff with the same framing the R1.5 GetTepByNm response uses, so the write serializer is the inverse of HistorianTagExtendedPropertyProtocol.ParseResponse:

uint32 groupCount (= 1)
byte 0x01                         (group marker)
0x09 + uint16 byteLen + ASCII tagName      (compact-ASCII string)
uint32 propertyCount
repeated propertyCount times:
    byte 0x02                     (property marker)
    0x09 + uint16 byteLen + ASCII propertyName
    0x43 + uint16 payloadLen + uint16 charCount + UTF-16LE value   (VT_BSTR variant; payloadLen = 2 + charCount*2)
byte 0x01                         (group trailer)
byte 0x00                         (buffer terminator)

⚠️ The trailing 0x01 0x00 matters: the group trailer is 0x01 (as in the read parser) plus a final 0x00 buffer terminator. Omitting the 0x00 makes inBuff one byte short and the server throws SErrorException in aahClientAccessPoint::CHistStorage::AddTagExtendedProperties (AddTEx returns false). The read parser tolerates the extra byte because it only consumes one trailing byte per group.

Only the string (0x43 VT_BSTR) value variant is evidence-backed (matching the read path). The raw instrument capture mangles the final byte with MDAS chunk markers, so the golden fixture pins the clean byte[] the SDK handed the channel (dumped via AVEVA_HISTORIAN_TEP_DUMP) — the exact buffer the live server accepted.

Delete (DelTep) — wire format captured + serializer proven; live delete server-blocked

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

  • HistorianClient.AddTagExtendedPropertiesAsync(tag, IReadOnlyList<HistorianTagExtendedProperty>) and AddTagExtendedPropertyAsync(tag, name, value).
  • HistorianTagExtendedPropertyProtocol.SerializeAddRequest (the inBuff serializer; lives beside the R1.5 read parser); orchestrator path in HistorianWcfTagWriteOrchestrator.
  • Golden WcfTagExtendedPropertyWriteProtocolTests (pins the server-accepted buffer + layout); gated live test AddTagExtendedPropertiesAsync_AgainstLocalHistorian_WritesAndReadsBack.

Capture / decode tooling

scripts/Capture-AddTagExtendedProperties.ps1 (native-harness add-tep scenario + instrument-wcf-{write,read}message; sandbox-guarded create→add→[optional delete]) and scripts/decode-add-tep-capture.py.