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
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-tepcreates 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 — soDeleteTagExtendedPropertiesByNamepasses the client gate andDelTepreaches 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 forGetTgByNm); - the SDK first primes the session with
GetTgByNm(tag identity, returns 140 bytes of tag info) andGetTepByNm(returns the property), keeping the Retrieval prime channel open across theDelTepcall; - 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>)andAddTagExtendedPropertyAsync(tag, name, value).HistorianTagExtendedPropertyProtocol.SerializeAddRequest(the inBuff serializer; lives beside the R1.5 read parser); orchestrator path inHistorianWcfTagWriteOrchestrator.- Golden
WcfTagExtendedPropertyWriteProtocolTests(pins the server-accepted buffer + layout); gated live testAddTagExtendedPropertiesAsync_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.