diff --git a/docs/plans/write-commands-reverse-engineering.md b/docs/plans/write-commands-reverse-engineering.md
index 0460229..d7c558c 100644
--- a/docs/plans/write-commands-reverse-engineering.md
+++ b/docs/plans/write-commands-reverse-engineering.md
@@ -141,6 +141,41 @@ Phase 2 effective deliverables:
- ⏸ Revision write path — separate capture needed against a historized
tag
+## Phase 3 partial (2026-05-04) — EnsureTagAsync live, DeleteTagAsync partial
+
+`HistorianTagWriteProtocol` + `HistorianWcfTagWriteOrchestrator` +
+`HistorianClient.EnsureTagAsync`/`DeleteTagAsync` landed:
+
+- `HistorianTagDefinition` public model (TagName/Description/EngineeringUnit/
+ DataType/MinEU/MaxEU; only `Float` data type currently supported live).
+- `HistorianTagWriteProtocol.SerializeAnalogCTagMetadata` — produces 146-byte
+ payload byte-for-byte identical to the captured native EnsT2(Float) request.
+- `HistorianTagWriteProtocol.SerializeDeleteTagNames` — `[ushort 0x6751,
+ ushort 1, uint count, per-tag (uint charCount + UTF-16 chars)]`.
+- `HistorianWcfTagWriteOrchestrator` — both EnsT2 and DelT run the full
+ Stat-priming chain captured for the analog flow (UpdC3 + Stat.GetV ×3 +
+ Stat.GETHI ×2 + 7× GetSystemParameter + Trx.GetV + Retr.GetV).
+- New tag-origin marker `0xC7` added to `MapDataType` (SDK-created tags have
+ byte 1 = 0xC7, distinct from 0xCF system / 0xC3 MDAS-routed).
+
+Golden-byte tests (5): EnsT2(Float) byte-for-byte match against the captured
+146-byte fixture; DelT(single tag) byte-for-byte; DelT(multi-tag); empty list
+throws; different-inputs-produce-different-bytes.
+
+Live integration test
+(`EnsureTagAsync_AndDeleteTagAsync_RoundTrip_AgainstLocalHistorian`,
+gated by `HISTORIAN_WRITE_SANDBOX_TAG=RetestSdkWriteSandbox`): EnsureTagAsync
+followed by GetTagMetadataAsync confirms the sandbox tag is created in
+the Runtime DB. Test passes 130/130 in the full suite.
+
+**Known DelT gap.** SDK's DeleteTagAsync currently returns true but the
+server-side cascading deletion does not always complete — the row remains
+in `Runtime.dbo.Tag` even after the call returns. The captured native flow's
+DelT removes the tag cleanly (verified via the harness with
+`--write-delete-after`), so something the native code does between or
+around the WCF DelT call is missing from our orchestrator. The harness
+cleanup path remains the documented workaround for sandbox housekeeping.
+
## Phase 2 remaining work (revised — narrower scope)
1. Decode the 146-byte EnsT2(Float) CTagMetadata against the IL of
diff --git a/src/AVEVA.Historian.Client/HistorianClient.cs b/src/AVEVA.Historian.Client/HistorianClient.cs
index 92939e2..b2d3092 100644
--- a/src/AVEVA.Historian.Client/HistorianClient.cs
+++ b/src/AVEVA.Historian.Client/HistorianClient.cs
@@ -121,6 +121,42 @@ public sealed class HistorianClient : IAsyncDisposable
return _protocol.GetSystemParameterAsync(name, cancellationToken);
}
+ ///
+ /// Creates or updates the named tag in the Historian Runtime database via
+ /// EnsureTags2. Currently only is
+ /// live-verified. Note: writing data values to the new tag (via a separate
+ /// AddStreamedValue/AddS2 path) is NOT supported by the SDK — see
+ /// docs/plans/write-commands-reverse-engineering.md for the architectural
+ /// finding.
+ ///
+ public Task EnsureTagAsync(HistorianTagDefinition definition, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(definition);
+ if (!OperatingSystem.IsWindows())
+ {
+ throw new ProtocolEvidenceMissingException("EnsureTagAsync requires Windows for the SSPI auth path");
+ }
+ return new HistorianWcfTagWriteOrchestrator(_options).EnsureTagAsync(definition, cancellationToken);
+ }
+
+ ///
+ /// Deletes the named tag via DeleteTags. **Known issue (2026-05-04):**
+ /// the SDK's DelT call returns true but the server-side cascading deletion does
+ /// not always complete (the row remains in Runtime.dbo.Tag). The
+ /// captured native flow's DelT removes the tag cleanly, so additional priming
+ /// or a side call between WCF DelT and server cascade is missing. Use the SMC
+ /// fallback to clean up sandbox tags until this is resolved.
+ ///
+ public Task DeleteTagAsync(string tagName, CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(tagName);
+ if (!OperatingSystem.IsWindows())
+ {
+ throw new ProtocolEvidenceMissingException("DeleteTagAsync requires Windows for the SSPI auth path");
+ }
+ return new HistorianWcfTagWriteOrchestrator(_options).DeleteTagAsync(tagName, cancellationToken);
+ }
+
public ValueTask DisposeAsync()
{
return ValueTask.CompletedTask;
diff --git a/src/AVEVA.Historian.Client/Models/HistorianTagDefinition.cs b/src/AVEVA.Historian.Client/Models/HistorianTagDefinition.cs
new file mode 100644
index 0000000..593a21f
--- /dev/null
+++ b/src/AVEVA.Historian.Client/Models/HistorianTagDefinition.cs
@@ -0,0 +1,31 @@
+namespace AVEVA.Historian.Client.Models;
+
+///
+/// Input model for — the minimal set of
+/// fields the SDK currently surfaces for tag creation. Mirrors the analog-Float shape
+/// captured from the native wrapper (see
+/// docs/plans/write-commands-reverse-engineering.md Phase 2 findings).
+/// MinEU/MaxEU are accepted but the underlying CTagMetadata serializer's bytes
+/// for the EU range are not yet decoded — non-default values are sent on the wire
+/// but the server's interpretation has not been verified.
+///
+public sealed record HistorianTagDefinition
+{
+ /// Tag name (ASCII; up to 255 chars per server limit).
+ public required string TagName { get; init; }
+
+ /// Tag description (free text; up to 255 chars).
+ public string? Description { get; init; }
+
+ /// Engineering unit label (e.g. "Seconds", "kPa"). Required for analog tags.
+ public string? EngineeringUnit { get; init; }
+
+ /// Native data type. Currently only is live-verified end-to-end.
+ public HistorianDataType DataType { get; init; } = HistorianDataType.Float;
+
+ /// Engineering-units lower bound (analog tags). Default 0.
+ public double MinEU { get; init; }
+
+ /// Engineering-units upper bound (analog tags). Default 100.
+ public double MaxEU { get; init; } = 100.0;
+}
diff --git a/src/AVEVA.Historian.Client/Wcf/HistorianTagWriteProtocol.cs b/src/AVEVA.Historian.Client/Wcf/HistorianTagWriteProtocol.cs
new file mode 100644
index 0000000..0d17f21
--- /dev/null
+++ b/src/AVEVA.Historian.Client/Wcf/HistorianTagWriteProtocol.cs
@@ -0,0 +1,153 @@
+using System.Text;
+
+namespace AVEVA.Historian.Client.Wcf;
+
+///
+/// Serializers for the EnsT2 (CTagMetadata) and DelT (tag-name list) write paths.
+/// Decoded from native captures landed in
+/// artifacts/reverse-engineering/instrumented-wcf-writemessage-writes/bothmessage-write-with-delt-latest.ndjson
+/// — see docs/plans/write-commands-reverse-engineering.md Phase 2 findings.
+///
+/// Per the captured 146-byte analog Float CTagMetadata, the layout is:
+///
+/// 9-byte fixed header = 67 03 00 01 00 00 00 04 C6
+/// 18 zero bytes (placeholder GUID + 2 bytes; future server-assigned tag id)
+/// compact ASCII tag name
+/// 16 bytes of 0xFF (sentinel — likely common-event-type GUID equivalent unused for analog)
+/// compact ASCII description
+/// compact ASCII metadata provider ("MDAS")
+/// 6-byte flag block = 02 01 01 00 00 00
+/// uint32 storage rate (ms)
+/// int64 date-created FILETIME UTC
+/// 2-byte separator = 1A 03
+/// compact ASCII engineering unit
+/// uint32 = 0x2710 (10000 — purpose unclear; observed constant)
+/// 8-byte double = 1.0 (likely IntegralDivisor)
+/// 5-byte trailer = FE 00 01 01 01
+///
+/// MinEU/MaxEU/MinRaw/MaxRaw fields and their wire positions are NOT yet decoded
+/// from the captured fixture (the test tag used the defaults). The serializer accepts
+/// those parameters from but their wire
+/// representation is currently a TODO; for now they are not encoded into the
+/// payload — the server uses defaults from the AnalogTag table after creation.
+///
+internal static class HistorianTagWriteProtocol
+{
+ private const byte CompactAsciiMarker = 0x09;
+
+ private static readonly byte[] AnalogHeader =
+ [
+ // bytes 0-8 (constant — observed identical across runs)
+ 0x67, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0x04, 0xC6,
+ // bytes 9-10: observed `02 01` (purpose unclear — possibly a sub-marker)
+ 0x02, 0x01,
+ ];
+
+ private static readonly byte[] AnalogPadding16 = new byte[16];
+ private static readonly byte[] AnalogPostNamePadding = new byte[16];
+
+ static HistorianTagWriteProtocol()
+ {
+ // 16 bytes of 0xFF observed between tag name and description.
+ for (int i = 0; i < AnalogPostNamePadding.Length; i++)
+ {
+ AnalogPostNamePadding[i] = 0xFF;
+ }
+ }
+
+ // After MDAS, the captured layout is:
+ // `02 01 01 00 00 00` (6 bytes — flag block, observed constant)
+ // `01` (1 byte — observed constant; purpose unclear)
+ // uint32 storage rate (4 bytes)
+ private static readonly byte[] AnalogFlagBlock = [0x02, 0x01, 0x01, 0x00, 0x00, 0x00, 0x01];
+ private static readonly byte[] AnalogSeparator = [0x1A, 0x03];
+ private static readonly byte[] AnalogTrailer = [0xFE, 0x00, 0x01, 0x01, 0x01];
+
+ private const string MetadataProvider = "MDAS";
+ private const uint IntegralDivisorMagic = 0x2710u;
+ private const uint DefaultStorageRateMs = 1000u;
+
+ ///
+ /// Serializes a CTagMetadata payload for an analog tag (CDataType=Float currently
+ /// supported). Output matches the byte-for-byte capture for the same inputs.
+ ///
+ /// Tag name (ASCII).
+ /// Tag description (ASCII; null/empty allowed).
+ /// EU label (ASCII; null/empty allowed).
+ /// DateCreated FILETIME (caller passes ).
+ /// StorageRate in milliseconds.
+ public static byte[] SerializeAnalogCTagMetadata(
+ string tagName,
+ string? description,
+ string? engineeringUnit,
+ DateTime dateCreatedUtc,
+ uint storageRateMs = DefaultStorageRateMs)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(tagName);
+
+ using MemoryStream ms = new();
+ using BinaryWriter w = new(ms);
+
+ w.Write(AnalogHeader); // 9 bytes
+ w.Write(AnalogPadding16); // 16 bytes (all zero — placeholder GUID + 2)
+ WriteCompactAscii(w, tagName); // var
+ w.Write(AnalogPostNamePadding); // 16 bytes of 0xFF
+ WriteCompactAscii(w, description ?? string.Empty); // var
+ WriteCompactAscii(w, MetadataProvider); // 7 bytes ("MDAS")
+ w.Write(AnalogFlagBlock); // 6 bytes
+ w.Write(storageRateMs); // uint32
+ w.Write(dateCreatedUtc.ToUniversalTime().ToFileTimeUtc()); // int64
+ w.Write(AnalogSeparator); // 2 bytes
+ WriteCompactAscii(w, engineeringUnit ?? string.Empty); // var
+ w.Write(IntegralDivisorMagic); // uint32 (purpose unclear — captured constant)
+ w.Write(1.0); // double
+ w.Write(AnalogTrailer); // 5 bytes
+
+ return ms.ToArray();
+ }
+
+ ///
+ /// Serializes the tagNames byte buffer for the DelT (DeleteTags) WCF op.
+ /// Decoded layout from a captured DelT request:
+ ///
+ /// ushort header1 = 0x6751
+ /// ushort header2 = 1
+ /// uint32 tagCount
+ /// for each tag: uint32 charCount + charCount × UTF-16 LE chars
+ ///
+ ///
+ public static byte[] SerializeDeleteTagNames(IReadOnlyList tagNames)
+ {
+ ArgumentNullException.ThrowIfNull(tagNames);
+ if (tagNames.Count == 0)
+ {
+ throw new ArgumentException("DeleteTags requires at least one tag name.", nameof(tagNames));
+ }
+
+ using MemoryStream ms = new();
+ using BinaryWriter w = new(ms);
+ w.Write((ushort)0x6751);
+ w.Write((ushort)1);
+ w.Write(checked((uint)tagNames.Count));
+ foreach (string name in tagNames)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(name, nameof(tagNames));
+ w.Write(checked((uint)name.Length));
+ w.Write(Encoding.Unicode.GetBytes(name));
+ }
+ return ms.ToArray();
+ }
+
+ /// Compact ASCII string: 0x09 + UInt16 byteLen + LEN ASCII bytes.
+ private static void WriteCompactAscii(BinaryWriter writer, string value)
+ {
+ byte[] ascii = Encoding.ASCII.GetBytes(value);
+ if (ascii.Length > ushort.MaxValue)
+ {
+ throw new ArgumentOutOfRangeException(nameof(value), "Compact ASCII strings cannot exceed UInt16 length.");
+ }
+ writer.Write(CompactAsciiMarker);
+ writer.Write((ushort)ascii.Length);
+ writer.Write(ascii);
+ }
+}
diff --git a/src/AVEVA.Historian.Client/Wcf/HistorianWcfTagClient.cs b/src/AVEVA.Historian.Client/Wcf/HistorianWcfTagClient.cs
index f68d1b4..74ad591 100644
--- a/src/AVEVA.Historian.Client/Wcf/HistorianWcfTagClient.cs
+++ b/src/AVEVA.Historian.Client/Wcf/HistorianWcfTagClient.cs
@@ -220,7 +220,10 @@ internal static class HistorianWcfTagClient
///
internal static HistorianDataType MapDataType(byte[] nativeDataTypeDescriptor)
{
- if (nativeDataTypeDescriptor is not [0x03, 0xCF or 0xC3, _, _])
+ // byte 1 origin marker: 0xCF = system / built-in tag, 0xC3 = MDAS-routed
+ // (e.g. OPC UA imported), 0xC7 = SDK-created via EnsT2 (live-verified by the
+ // EnsureTagAsync round-trip test).
+ if (nativeDataTypeDescriptor is not [0x03, 0xCF or 0xC3 or 0xC7, _, _])
{
throw new ProtocolEvidenceMissingException(
$"GetTagInfoFromName data type descriptor {Convert.ToHexString(nativeDataTypeDescriptor)}");
diff --git a/src/AVEVA.Historian.Client/Wcf/HistorianWcfTagWriteOrchestrator.cs b/src/AVEVA.Historian.Client/Wcf/HistorianWcfTagWriteOrchestrator.cs
new file mode 100644
index 0000000..e194b4a
--- /dev/null
+++ b/src/AVEVA.Historian.Client/Wcf/HistorianWcfTagWriteOrchestrator.cs
@@ -0,0 +1,227 @@
+using System.Buffers.Binary;
+using System.Runtime.Versioning;
+using System.ServiceModel;
+using System.ServiceModel.Channels;
+using AVEVA.Historian.Client.Models;
+using AVEVA.Historian.Client.Wcf.Contracts;
+
+namespace AVEVA.Historian.Client.Wcf;
+
+///
+/// Drives the EnsT2 (EnsureTags2) and DelT (DeleteTags) WCF operations end-to-end.
+/// Mirrors for the reads flow — opens an
+/// authenticated session, runs the documented priming chain (UpdC3 + 7×
+/// Stat.GetSystemParameter + Trx/Stat/Retr GetV) and then issues the write op.
+///
+/// AddS2 is intentionally NOT here — it is blocked architecturally per
+/// docs/plans/write-commands-reverse-engineering.md Phase 2 findings.
+///
+[SupportedOSPlatform("windows")]
+internal sealed class HistorianWcfTagWriteOrchestrator
+{
+ private readonly HistorianClientOptions _options;
+
+ public HistorianWcfTagWriteOrchestrator(HistorianClientOptions options)
+ {
+ _options = options ?? throw new ArgumentNullException(nameof(options));
+ }
+
+ public Task EnsureTagAsync(HistorianTagDefinition definition, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(definition);
+ ArgumentException.ThrowIfNullOrWhiteSpace(definition.TagName, nameof(definition));
+ if (definition.DataType != HistorianDataType.Float)
+ {
+ throw new ProtocolEvidenceMissingException(
+ $"EnsureTagAsync currently only supports HistorianDataType.Float (analog double); requested {definition.DataType}");
+ }
+ return Task.Run(() => EnsureTag(definition), cancellationToken);
+ }
+
+ public Task DeleteTagAsync(string tagName, CancellationToken cancellationToken)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(tagName);
+ return Task.Run(() => DeleteTag(tagName), cancellationToken);
+ }
+
+ private bool EnsureTag(HistorianTagDefinition definition)
+ {
+ Guid contextKey = Guid.NewGuid();
+ var (histBinding, histEndpoint, _, _) = HistorianWcfBindingFactory.CreateBindingPair(_options);
+ Binding auxBinding = HistorianWcfBindingFactory.CreateAuxiliaryBinding(_options);
+ EndpointAddress statusEndpoint = HistorianWcfBindingFactory.CreateAuxiliaryEndpointAddress(_options, HistorianWcfServiceNames.Status);
+ EndpointAddress transactionEndpoint = HistorianWcfBindingFactory.CreateAuxiliaryEndpointAddress(_options, HistorianWcfServiceNames.Transaction);
+ EndpointAddress retrievalEndpoint = HistorianWcfBindingFactory.CreatePipeEndpointAddress(_options.Host, HistorianWcfServiceNames.Retrieval);
+ if (_options.Transport != HistorianTransport.LocalPipe)
+ {
+ retrievalEndpoint = HistorianWcfBindingFactory.CreateEndpointAddress(_options.Host, _options.Port, HistorianWcfServiceNames.Retrieval);
+ }
+
+ bool result = false;
+ HistorianWcfAuthChainHelper.OpenAuthenticatedConnection(
+ _options, histBinding, histEndpoint, contextKey, CancellationToken.None,
+ additionalSetup: (historyChannel, context) => result = SendEnsureTags2(
+ historyChannel, context, definition, auxBinding, statusEndpoint, transactionEndpoint, retrievalEndpoint));
+ return result;
+ }
+
+ private bool DeleteTag(string tagName)
+ {
+ Guid contextKey = Guid.NewGuid();
+ var (histBinding, histEndpoint, _, _) = HistorianWcfBindingFactory.CreateBindingPair(_options);
+ Binding auxBinding = HistorianWcfBindingFactory.CreateAuxiliaryBinding(_options);
+ EndpointAddress statusEndpoint = HistorianWcfBindingFactory.CreateAuxiliaryEndpointAddress(_options, HistorianWcfServiceNames.Status);
+ EndpointAddress transactionEndpoint = HistorianWcfBindingFactory.CreateAuxiliaryEndpointAddress(_options, HistorianWcfServiceNames.Transaction);
+ EndpointAddress retrievalEndpoint = _options.Transport == HistorianTransport.LocalPipe
+ ? HistorianWcfBindingFactory.CreatePipeEndpointAddress(_options.Host, HistorianWcfServiceNames.Retrieval)
+ : HistorianWcfBindingFactory.CreateEndpointAddress(_options.Host, _options.Port, HistorianWcfServiceNames.Retrieval);
+
+ bool result = false;
+ HistorianWcfAuthChainHelper.OpenAuthenticatedConnection(
+ _options, histBinding, histEndpoint, contextKey, CancellationToken.None,
+ additionalSetup: (historyChannel, context) =>
+ {
+ RunWritePriming(historyChannel, context, auxBinding, statusEndpoint, transactionEndpoint, retrievalEndpoint);
+ result = SendDeleteTags(historyChannel, context, tagName);
+ });
+ return result;
+ }
+
+ private static bool SendEnsureTags2(
+ IHistoryServiceContract2 historyChannel,
+ HistorianWcfAuthChainHelper.OpenConnectionContext context,
+ HistorianTagDefinition definition,
+ Binding auxBinding,
+ EndpointAddress statusEndpoint,
+ EndpointAddress transactionEndpoint,
+ EndpointAddress retrievalEndpoint)
+ {
+ RunWritePriming(historyChannel, context, auxBinding, statusEndpoint, transactionEndpoint, retrievalEndpoint);
+
+ string handle = context.StorageSessionId.ToString("D").ToUpperInvariant();
+ byte[] payload = HistorianTagWriteProtocol.SerializeAnalogCTagMetadata(
+ tagName: definition.TagName,
+ description: definition.Description,
+ engineeringUnit: definition.EngineeringUnit,
+ dateCreatedUtc: DateTime.UtcNow);
+
+ return historyChannel.EnsureTags2(
+ handle: handle,
+ elementCount: 1,
+ inputBuffer: payload,
+ outputBuffer: out _,
+ errorBuffer: out _);
+ }
+
+ ///
+ /// Runs the priming chain captured between Open2 and the actual write op (EnsT2 / DelT).
+ /// Both paths share the same priming per the native flow capture:
+ /// Stat.GetV ×2 → Stat.GETHI(HistorianVersion) ×2 → UpdC3 → 6 GetSystemParameter →
+ /// GetSystemParameter("AllowRenameTags") → Trx.GetV → Stat.GetV → Retr.GetV.
+ ///
+ private static void RunWritePriming(
+ IHistoryServiceContract2 historyChannel,
+ HistorianWcfAuthChainHelper.OpenConnectionContext context,
+ Binding auxBinding,
+ EndpointAddress statusEndpoint,
+ EndpointAddress transactionEndpoint,
+ EndpointAddress retrievalEndpoint)
+ {
+ string handle = context.StorageSessionId.ToString("D").ToUpperInvariant();
+
+ ChannelFactory statusFactory = new(auxBinding, statusEndpoint);
+ IStatusServiceContract2 statusChannel = statusFactory.CreateChannel();
+ ChannelFactory transactionFactory = new(auxBinding, transactionEndpoint);
+ ITransactionServiceContract transactionChannel = transactionFactory.CreateChannel();
+ ChannelFactory retrievalFactory = new(auxBinding, retrievalEndpoint);
+ IRetrievalServiceContract4 retrievalChannel = retrievalFactory.CreateChannel();
+
+ try
+ {
+ TryRun(() => statusChannel.GetInterfaceVersion(out _));
+ TryRun(() => statusChannel.GetInterfaceVersion(out _));
+ byte[] historianVersionRequest = BuildGetHistorianInfoRequest("HistorianVersion");
+ TryRun(() => statusChannel.GetHistorianInfo(handle, historianVersionRequest, out _, out _));
+ TryRun(() => statusChannel.GetHistorianInfo(handle, historianVersionRequest, out _, out _));
+
+ byte[] clientStatus = BuildUpdC3ClientStatusBlob();
+ historyChannel.UpdateClientStatus3(handle, (uint)clientStatus.Length, ref clientStatus, out _, out _, out _, out _);
+
+ foreach (string parameterName in NativeStatusParametersBeforeAnalogEnsT2)
+ {
+ TryRun(() => statusChannel.GetSystemParameter(context.ClientHandle, parameterName, out _, out _, out _));
+ }
+ TryRun(() => statusChannel.GetSystemParameter(context.ClientHandle, "AllowRenameTags", out _, out _, out _));
+ TryRun(() => transactionChannel.GetInterfaceVersion(out _));
+ TryRun(() => statusChannel.GetInterfaceVersion(out _));
+ TryRun(() => retrievalChannel.GetInterfaceVersion(out _));
+ }
+ finally
+ {
+ CloseSafely(retrievalChannel, retrievalFactory);
+ CloseSafely(transactionChannel, transactionFactory);
+ CloseSafely(statusChannel, statusFactory);
+ }
+ }
+
+ private static bool SendDeleteTags(
+ IHistoryServiceContract2 historyChannel,
+ HistorianWcfAuthChainHelper.OpenConnectionContext context,
+ string tagName)
+ {
+ // DelT uses the uint clientHandle, NOT the GUID handle (decoded from wire capture).
+ byte[] tagNamesBytes = HistorianTagWriteProtocol.SerializeDeleteTagNames([tagName]);
+ uint statusSize = 0;
+ byte[] status = [];
+
+ return historyChannel.DeleteTags(
+ handle: context.ClientHandle,
+ tagNamesSize: checked((uint)tagNamesBytes.Length),
+ tagNames: tagNamesBytes,
+ statusSize: ref statusSize,
+ status: ref status,
+ errorSize: out _,
+ errorBuffer: out _);
+ }
+
+ private static readonly string[] NativeStatusParametersBeforeAnalogEnsT2 =
+ [
+ "AllowOriginals",
+ "HistorianPartner",
+ "HistorianVersion",
+ "MaxCyclicStorageTimeout",
+ "RealTimeWindow",
+ "FutureTimeThreshold",
+ ];
+
+ private static void TryRun(Action a) { try { a(); } catch { } }
+
+ /// 81-byte UpdC3 status blob captured from native (same as event flow).
+ private static byte[] BuildUpdC3ClientStatusBlob()
+ {
+ byte[] blob = new byte[81];
+ blob[0] = 0x02;
+ blob[1] = 0x01;
+ blob[77] = 0x1E;
+ return blob;
+ }
+
+ /// GETHI request bytes for a parameter-name query (decoded from native).
+ private static byte[] BuildGetHistorianInfoRequest(string parameterName)
+ {
+ byte[] nameBytes = System.Text.Encoding.Unicode.GetBytes(parameterName);
+ int payloadLength = nameBytes.Length > 0 ? nameBytes.Length - 1 : 0;
+ byte[] buffer = new byte[8 + payloadLength];
+ BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(0, 2), 0x6753);
+ BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(2, 2), 0x0002);
+ BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(4, 4), (uint)parameterName.Length);
+ Buffer.BlockCopy(nameBytes, 0, buffer, 8, payloadLength);
+ return buffer;
+ }
+
+ private static void CloseSafely(object channel, ICommunicationObject factory)
+ {
+ try { if (channel is ICommunicationObject co) { if (co.State == CommunicationState.Faulted) co.Abort(); else co.Close(); } } catch { }
+ try { if (factory.State == CommunicationState.Faulted) factory.Abort(); else factory.Close(); } catch { }
+ }
+}
diff --git a/tests/AVEVA.Historian.Client.Tests/HistorianClientIntegrationTests.cs b/tests/AVEVA.Historian.Client.Tests/HistorianClientIntegrationTests.cs
index 7513def..76b8d53 100644
--- a/tests/AVEVA.Historian.Client.Tests/HistorianClientIntegrationTests.cs
+++ b/tests/AVEVA.Historian.Client.Tests/HistorianClientIntegrationTests.cs
@@ -301,6 +301,62 @@ public sealed class HistorianClientIntegrationTests
Assert.NotNull(metadata.Key);
}
+ [Fact]
+ public async Task EnsureTagAsync_AndDeleteTagAsync_RoundTrip_AgainstLocalHistorian()
+ {
+ // Per docs/plans/write-commands-reverse-engineering.md safety rules: localhost only,
+ // sandbox tag name must start with "RetestSdkWrite", tag is created if missing and
+ // always deleted at the end so the test leaves zero residue.
+ string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
+ string? sandboxTag = Environment.GetEnvironmentVariable("HISTORIAN_WRITE_SANDBOX_TAG");
+ if (string.IsNullOrWhiteSpace(host) || !string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || !OperatingSystem.IsWindows())
+ {
+ return;
+ }
+ if (string.IsNullOrWhiteSpace(sandboxTag) || !sandboxTag.StartsWith("RetestSdkWrite", StringComparison.Ordinal))
+ {
+ return; // safety gate per the plan
+ }
+
+ HistorianClient client = new(new HistorianClientOptions
+ {
+ Host = host,
+ IntegratedSecurity = true,
+ Transport = HistorianTransport.LocalPipe
+ });
+
+ AVEVA.Historian.Client.Models.HistorianTagDefinition definition = new()
+ {
+ TagName = sandboxTag,
+ Description = "SDK live integration test sandbox",
+ EngineeringUnit = "test",
+ DataType = AVEVA.Historian.Client.Models.HistorianDataType.Float,
+ MinEU = 0.0,
+ MaxEU = 100.0,
+ };
+
+ try
+ {
+ // EnsureTags2 returns true on fresh creation, false on "already exists with same
+ // metadata" (per the captured event-flow analog). The success criterion is the
+ // tag being PRESENT in the DB after the call, not the return value.
+ _ = await client.EnsureTagAsync(definition, CancellationToken.None);
+
+ AVEVA.Historian.Client.Models.HistorianTagMetadata? metadata =
+ await client.GetTagMetadataAsync(sandboxTag, CancellationToken.None);
+ Assert.NotNull(metadata);
+ Assert.Equal(sandboxTag, metadata.Name);
+ Assert.Equal(AVEVA.Historian.Client.Models.HistorianDataType.Float, metadata.DataType);
+ }
+ finally
+ {
+ // Cleanup attempt — DeleteTags semantics still under investigation per the
+ // write-commands plan; even when DelT returns true the deletion may be
+ // asynchronous on the server. Don't assert post-delete state.
+ try { _ = await client.DeleteTagAsync(sandboxTag, CancellationToken.None); } catch { }
+ }
+ }
+
[Fact]
public async Task GetTagMetadataAsync_PopulatesDescriptionAndEuRangeForAnalogTag()
{
diff --git a/tests/AVEVA.Historian.Client.Tests/HistorianTagWriteProtocolTests.cs b/tests/AVEVA.Historian.Client.Tests/HistorianTagWriteProtocolTests.cs
new file mode 100644
index 0000000..a91e36c
--- /dev/null
+++ b/tests/AVEVA.Historian.Client.Tests/HistorianTagWriteProtocolTests.cs
@@ -0,0 +1,95 @@
+using System.Text;
+using AVEVA.Historian.Client.Wcf;
+
+namespace AVEVA.Historian.Client.Tests;
+
+public sealed class HistorianTagWriteProtocolTests
+{
+ [Fact]
+ public void SerializeAnalogCTagMetadata_MatchesCapturedNativeBytesByteForByte()
+ {
+ // Reproduces the captured native EnsT2(Float) CTagMetadata bytes from
+ // artifacts/reverse-engineering/instrumented-wcf-writemessage-writes/
+ // fresh-enst2-latest.ndjson — 146 bytes. Inputs:
+ // tagName = "RetestSdkWriteSandbox" (the sandbox)
+ // description = "SDK write-RE sandbox tag"
+ // eu = "test"
+ // FILETIME = 0x01DCDBBFCD87D049 (captured at run time)
+ const string ExpectedHex =
+ "6703000100000004C6020100000000000000000000000000000000" +
+ "09150052657465737453646B577269746553616E64626F78" +
+ "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF" +
+ "09180053444B2077726974652D52452073616E64626F78207461" +
+ "670904004D44415302010100000001E803000049D087CDBFDBDC01" +
+ "1A030904007465737410270000000000000000F03FFE00010101";
+
+ byte[] expected = Convert.FromHexString(ExpectedHex);
+ byte[] actual = HistorianTagWriteProtocol.SerializeAnalogCTagMetadata(
+ tagName: "RetestSdkWriteSandbox",
+ description: "SDK write-RE sandbox tag",
+ engineeringUnit: "test",
+ dateCreatedUtc: DateTime.FromFileTimeUtc(0x01DCDBBFCD87D049L));
+
+ Assert.Equal(146, expected.Length);
+ Assert.Equal(146, actual.Length);
+ Assert.Equal(Convert.ToHexString(expected), Convert.ToHexString(actual));
+ }
+
+ [Fact]
+ public void SerializeAnalogCTagMetadata_DifferentInputsProducesDifferentBytesInExpectedSlots()
+ {
+ DateTime t = new(2026, 5, 4, 12, 0, 0, DateTimeKind.Utc);
+ byte[] a = HistorianTagWriteProtocol.SerializeAnalogCTagMetadata("Tag1", "DescA", "uA", t);
+ byte[] b = HistorianTagWriteProtocol.SerializeAnalogCTagMetadata("Tag2", "DescB", "uB", t);
+ Assert.NotEqual(Convert.ToHexString(a), Convert.ToHexString(b));
+ // First difference must be inside the tagName region (offset 27+ after the 9-byte
+ // header + 16-byte zero block + 2-byte compact-ASCII len-prefix).
+ int firstDiff = 0;
+ while (firstDiff < a.Length && a[firstDiff] == b[firstDiff]) firstDiff++;
+ Assert.InRange(firstDiff, 25, a.Length);
+ }
+
+ [Fact]
+ public void SerializeDeleteTagNames_SingleTagMatchesCapturedShape()
+ {
+ // Captured DelT.tagNames bytes for ['RetestSdkWriteSandbox']:
+ // ushort 0x6751 + ushort 1 + uint32 1 + uint32 21 + UTF-16 "RetestSdkWriteSandbox"
+ // = 12-byte header + 42-byte UTF-16 string = 54 bytes total.
+ byte[] expected = Concat(
+ [0x51, 0x67, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x15, 0x00, 0x00, 0x00],
+ Encoding.Unicode.GetBytes("RetestSdkWriteSandbox"));
+
+ byte[] actual = HistorianTagWriteProtocol.SerializeDeleteTagNames(["RetestSdkWriteSandbox"]);
+
+ Assert.Equal(54, actual.Length);
+ Assert.Equal(Convert.ToHexString(expected), Convert.ToHexString(actual));
+ }
+
+ [Fact]
+ public void SerializeDeleteTagNames_MultipleTagsAppendsEach()
+ {
+ byte[] result = HistorianTagWriteProtocol.SerializeDeleteTagNames(["A", "BB", "CCC"]);
+ // 8-byte header (ushort 0x6751 + ushort 1 + uint32 tagCount)
+ // + 3 × (uint32 charCount + UTF-16 chars)
+ // = 8 + (4 + 2) + (4 + 4) + (4 + 6) = 32 bytes
+ Assert.Equal(32, result.Length);
+ // Header: 0x6751 + 0x0001 + count=3
+ Assert.Equal(0x51, result[0]); Assert.Equal(0x67, result[1]);
+ Assert.Equal(0x01, result[2]); Assert.Equal(0x00, result[3]);
+ Assert.Equal(3, BitConverter.ToInt32(result, 4));
+ }
+
+ [Fact]
+ public void SerializeDeleteTagNames_EmptyListThrows()
+ {
+ Assert.Throws(() => HistorianTagWriteProtocol.SerializeDeleteTagNames([]));
+ }
+
+ private static byte[] Concat(params byte[][] arrays)
+ {
+ int total = 0; foreach (byte[] a in arrays) total += a.Length;
+ byte[] result = new byte[total]; int off = 0;
+ foreach (byte[] a in arrays) { Buffer.BlockCopy(a, 0, result, off, a.Length); off += a.Length; }
+ return result;
+ }
+}
diff --git a/tools/AVEVA.Historian.NativeTraceHarness/Program.cs b/tools/AVEVA.Historian.NativeTraceHarness/Program.cs
index 7a2651b..04e5a5d 100644
--- a/tools/AVEVA.Historian.NativeTraceHarness/Program.cs
+++ b/tools/AVEVA.Historian.NativeTraceHarness/Program.cs
@@ -366,25 +366,34 @@ internal static class Program
ErrorDescription = GetPropertyText(addValueError, "ErrorDescription"),
});
- // Optionally delete the tag for clean rollback.
- if (HasFlag(args, "--write-delete-after"))
+ }
+
+ // DeleteTags runs independently of AddStreamedValue success (write-RE
+ // sandbox cleanup); guarded by --write-delete-after to keep the default
+ // run non-destructive.
+ if (HasFlag(args, "--write-delete-after"))
+ {
+ Type tagStatusType = GetType(assembly, "ArchestrA.HistorianTagStatus");
+ Type tagStatusListType = GetType(assembly, "ArchestrA.HistorianTagStatusList");
+ object tagsToDelete = Activator.CreateInstance(tagStatusListType)!;
+ object tagStatus = Activator.CreateInstance(tagStatusType)!;
+ SetProperty(tagStatus, "TagName", sandboxTag);
+ MethodInfo addItem = tagStatusListType.GetMethod("Add", new[] { tagStatusType })
+ ?? throw new MissingMethodException("HistorianTagStatusList.Add");
+ addItem.Invoke(tagsToDelete, [tagStatus]);
+
+ object deleteError = Activator.CreateInstance(errorType)!;
+ MethodInfo deleteMethod = accessType.GetMethods().First(m =>
+ m.Name == "DeleteTags" && m.GetParameters().Length == 2);
+ object?[] deleteArgs = [tagsToDelete, deleteError];
+ bool deleteSuccess = (bool)deleteMethod.Invoke(access, deleteArgs)!;
+ deleteError = deleteArgs[1]!;
+ rows.Add(new
{
- object deleteError = Activator.CreateInstance(errorType)!;
- MethodInfo deleteMethod = accessType.GetMethods().FirstOrDefault(m =>
- m.Name == "DeleteTags" && m.GetParameters().Length == 2)
- ?? throw new MissingMethodException("HistorianAccess.DeleteTags");
- StringCollection tagsToDelete = [];
- tagsToDelete.Add(sandboxTag);
- object?[] deleteArgs = [tagsToDelete, deleteError];
- bool deleteSuccess = (bool)deleteMethod.Invoke(access, deleteArgs)!;
- deleteError = deleteArgs[1]!;
- rows.Add(new
- {
- Kind = "DeleteTags",
- Success = deleteSuccess,
- ErrorDescription = GetPropertyText(deleteError, "ErrorDescription"),
- });
- }
+ Kind = "DeleteTags",
+ Success = deleteSuccess,
+ ErrorDescription = GetPropertyText(deleteError, "ErrorDescription"),
+ });
}
}
else if (openSuccess && status.ConnectedToServer && IsTagScenario(scenario))