# 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)` 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`.