diff --git a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcSqlClient.cs b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcSqlClient.cs
index b5bb36d..d48992a 100644
--- a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcSqlClient.cs
+++ b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcSqlClient.cs
@@ -21,10 +21,14 @@ namespace AVEVA.Historian.Client.Grpc;
/// System.IndexOutOfRangeException ... at aahClientAccessPoint.CSrvDbConnection.ExecuteSqlCommand.
/// This is a server-side CSrvDbConnection (SQL DB-connection) precondition that the pure
/// managed gRPC session does not establish — the same class of wall as
-/// StorageService.OpenStorageConnection (whose real precondition is the front-door
-/// HistoryService.RegisterTags family). Priming Retr.GetV does not clear it. The request
-/// framing here is the captured/expected shape; the op stays bounded behind
-/// until the DB-connection registration is reproduced.
+/// StorageService.OpenStorageConnection. Priming Retr.GetV does not clear it, and
+/// a HistoryService.RegisterTags prime does NOT clear it either (tried live 2026-06-22 on
+/// both read-only 0x402 and write-enabled 0x401 sessions: RegisterTags itself
+/// returned false and ExecuteSqlCommand faulted with the identical native-38 IndexOutOfRange) —
+/// so unlike the OpenStorageConnection wall, the SQL DB-connection context is not established by the
+/// RegisterTags family. The request framing here is the captured/expected shape; the op stays bounded
+/// behind until the DB-connection registration is
+/// reproduced. Use the WCF transport for SQL.
///
///
internal static class HistorianGrpcSqlClient
diff --git a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcStatusClient.cs b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcStatusClient.cs
index 3e67538..ec290b3 100644
--- a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcStatusClient.cs
+++ b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcStatusClient.cs
@@ -111,6 +111,57 @@ internal static class HistorianGrpcStatusClient
}
}
+ ///
+ /// Returns a measured connection status over the 2023 R2 gRPC transport (plan #5). Mirrors
+ /// 's synthesize-from-handshake approach: it opens an
+ /// authenticated session and reports /
+ /// from whether the handshake
+ /// (GetInterfaceVersion → ValidateClientCredential token loop → OpenConnection, which yields the
+ /// storage-session GUID) succeeds. There is no dedicated connection-status RPC on either transport.
+ /// Store-forward connectivity is not observable here (D2-gated) and stays false.
+ ///
+ public static Task GetConnectionStatusAsync(
+ HistorianClientOptions options,
+ CancellationToken cancellationToken)
+ => Task.Run(() => GetConnectionStatus(options, cancellationToken), cancellationToken);
+
+ private static HistorianConnectionStatus GetConnectionStatus(HistorianClientOptions options, CancellationToken cancellationToken)
+ {
+ bool connected;
+ string? error = null;
+ try
+ {
+ using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options);
+ // A successful OpenConnection yields a non-empty storage-session GUID — proof the server and
+ // its storage session are reachable, the gRPC analog of the WCF handshake probe.
+ HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(connection, options, cancellationToken);
+ connected = session.StorageSessionId != Guid.Empty;
+ if (!connected)
+ {
+ error = "OpenConnection returned an empty storage-session handle.";
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ connected = false;
+ error = $"{ex.GetType().Name}: {ex.Message}";
+ }
+
+ return new HistorianConnectionStatus(
+ ServerName: options.Host,
+ Pending: false,
+ ErrorOccurred: !connected,
+ Error: error,
+ ConnectedToServer: connected,
+ ConnectedToServerStorage: connected,
+ ConnectedToStoreForward: false,
+ ConnectionKind: HistorianConnectionKind.Process);
+ }
+
///
/// Reads the Historian server's system time-zone name (roadmap item R1.3,
/// StatusService.GetSystemTimeZoneName). Unlike the 2020 WCF surface — where the native
diff --git a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcTagClient.cs b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcTagClient.cs
index e8476b0..79cd32e 100644
--- a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcTagClient.cs
+++ b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcTagClient.cs
@@ -112,6 +112,31 @@ internal static class HistorianGrpcTagClient
return Task.Run(() => GetTagExtendedProperties(options, tag, cancellationToken), cancellationToken);
}
+ ///
+ /// Issues a single page-0 GetTagExtendedPropertiesFromName call and returns the raw native
+ /// btTeps response buffer (empty when the server reports no rows / non-success). Internal so
+ /// reverse-engineering probes can capture the framing.
+ ///
+ internal static byte[] GetTagExtendedPropertiesRaw(HistorianClientOptions options, string tag, CancellationToken cancellationToken)
+ {
+ using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options);
+ HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(connection, options, cancellationToken);
+ var retrievalClient = new GrpcRetrieval.RetrievalService.RetrievalServiceClient(connection.Channel);
+ byte[] tagNames = HistorianTagExtendedPropertyProtocol.SerializeRequest(tag);
+ GrpcRetrieval.GetTagExtendedPropertiesFromNameResponse response = retrievalClient.GetTagExtendedPropertiesFromName(
+ new GrpcRetrieval.GetTagExtendedPropertiesFromNameRequest
+ {
+ StrHandle = session.StringHandle,
+ BtTagNames = ByteString.CopyFrom(tagNames),
+ UiSequence = 0
+ },
+ connection.Metadata,
+ DateTime.UtcNow.Add(options.RequestTimeout),
+ cancellationToken);
+
+ return (response.Status?.BSuccess ?? false) ? response.BtTeps?.ToByteArray() ?? [] : [];
+ }
+
private static IReadOnlyList GetTagExtendedProperties(
HistorianClientOptions options, string tag, CancellationToken cancellationToken)
{
diff --git a/src/AVEVA.Historian.Client/Protocol/Historian2020ProtocolDialect.cs b/src/AVEVA.Historian.Client/Protocol/Historian2020ProtocolDialect.cs
index dcc8942..613d820 100644
--- a/src/AVEVA.Historian.Client/Protocol/Historian2020ProtocolDialect.cs
+++ b/src/AVEVA.Historian.Client/Protocol/Historian2020ProtocolDialect.cs
@@ -52,7 +52,12 @@ internal sealed class Historian2020ProtocolDialect
public Task GetConnectionStatusAsync(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
- return Wcf.HistorianWcfStatusClient.GetConnectionStatusAsync(_options, cancellationToken);
+
+ // Over gRPC (2023 R2) the status is measured from the gRPC handshake (the WCF synthesize-from-
+ // probe path uses the MDAS binding, which can't reach the gRPC port). Non-gRPC stays on WCF.
+ return UseGrpc
+ ? HistorianGrpcStatusClient.GetConnectionStatusAsync(_options, cancellationToken)
+ : Wcf.HistorianWcfStatusClient.GetConnectionStatusAsync(_options, cancellationToken);
}
public Task GetStoreForwardStatusAsync(CancellationToken cancellationToken)
diff --git a/src/AVEVA.Historian.Client/Wcf/HistorianTagExtendedPropertyProtocol.cs b/src/AVEVA.Historian.Client/Wcf/HistorianTagExtendedPropertyProtocol.cs
index 87296c8..17faf75 100644
--- a/src/AVEVA.Historian.Client/Wcf/HistorianTagExtendedPropertyProtocol.cs
+++ b/src/AVEVA.Historian.Client/Wcf/HistorianTagExtendedPropertyProtocol.cs
@@ -25,9 +25,17 @@ namespace AVEVA.Historian.Client.Wcf;
/// 1-byte group marker (observed 0x01) + compact-ASCII tag name (0x09 + uint16 byte
/// length + ASCII), uint32 propertyCount, then per property a 1-byte marker (observed
/// 0x02) + compact-ASCII property name + a CRetVariant value (0x43 VT_BSTR + uint16
-/// payload length + uint16 charCount + UTF-16LE), and a 1-byte trailing marker (observed
-/// 0x01). Only the string value variant (0x43) is evidence-backed; other variant
-/// types throw .
+/// payload length + uint16 charCount + UTF-16LE) + a uint16 searchability-flags trailer
+/// (Facetable|Searchable|SubstringSearchable; e.g. 0x0003 for a built-in property,
+/// 0x0001 for a user-added one — captured live 2026-06-22). Only the string value variant
+/// (0x43) is evidence-backed; other variant types throw
+/// .
+///
+/// Per-property, not per-group: the server returns one group per property (the tag name
+/// repeats), each with propertyCount = 1, and the uint16 flags trailer belongs to the
+/// property — there is no separate group trailer. An earlier revision modelled the trailer as a
+/// single byte after the property block; that happened to parse a single-property buffer (the second
+/// flags byte was the buffer's end) but drifted one byte per group on multi-property responses.
///
/// The op is sequence-paged: call with sequence = 0, parse the buffer, then re-call
/// with the returned sequence until the response carries no rows. Only string-valued properties
@@ -219,13 +227,11 @@ internal static class HistorianTagExtendedPropertyProtocol
string propertyName = ReadCompactAsciiString(buffer, ref cursor);
string value = ReadVariantStringValue(buffer, ref cursor);
rows.Add(new HistorianTagExtendedPropertyRow(tagName, propertyName, value));
- }
- // 1-byte trailing marker (observed 0x01) after the property block. Read it only if a
- // byte remains so a tightly-packed terminal buffer doesn't over-read.
- if (cursor < buffer.Length)
- {
- SkipByte(buffer, ref cursor);
+ // uint16 searchability-flags trailer for this property (captured live). Tolerate a
+ // legacy single-byte tail or a tightly-packed terminal buffer so older single-property
+ // captures still parse.
+ SkipPropertyTrailer(buffer, ref cursor);
}
}
@@ -275,6 +281,22 @@ internal static class HistorianTagExtendedPropertyProtocol
cursor++;
}
+ ///
+ /// Consumes the per-property uint16 searchability-flags trailer. Consumes 2 bytes when available;
+ /// tolerates a legacy single-byte tail (consumes 1) so older single-property captures still parse.
+ ///
+ private static void SkipPropertyTrailer(ReadOnlySpan buffer, ref int cursor)
+ {
+ if (cursor <= buffer.Length - 2)
+ {
+ cursor += 2;
+ }
+ else if (cursor < buffer.Length)
+ {
+ cursor += 1;
+ }
+ }
+
private static ushort ReadUInt16(ReadOnlySpan buffer, ref int cursor)
{
EnsureAvailable(buffer, cursor, 2);