diff --git a/docs/plans/hcal-roadmap.md b/docs/plans/hcal-roadmap.md
index 685aa10..8df028c 100644
--- a/docs/plans/hcal-roadmap.md
+++ b/docs/plans/hcal-roadmap.md
@@ -36,6 +36,14 @@ HCAL replacement, built on the **2023 R2 gRPC transport**. Derived from
records (reuses `ParseGetTagInfoResponse`); string handle = uppercase Open2 storage GUID. The 2020
WCF string-handle wall does **not** apply on the gRPC front door (as predicted). **LIVE-VERIFIED
2026-06-21** â `GetTagMetadataAsync` returned the requested tag + a valid data type.
+- đĄ **R0.1 Browse over gRPC** â PARTIAL. Probed live 2026-06-21: the 2023 R2 gRPC front door does
+ **not** hit the 2020 metadata-server-pipe wall. `RetrievalService.StartTagQuery` is **cracked** â it
+ parses the filter as **OData** (`startswith(TagName,'Sys')`/`contains`/`eq`/empty succeed, returning
+ the 8-byte `(queryHandle, tagCount)`; SQL-LIKE `%`/glob `*` â `ODataFilter: bad token`). Live: 220
+ Sys* tags counted. **Remaining:** the `QueryTag` paging request format (constant native error
+ type 4 / code 72 across guessed `btRequest` shapes) â needs a native capture, not guessing. Probe
+ helpers (`HistorianGrpcTagClient.ProbeStartTagQuery`/`ProbeTagQuerySequence`) + a gated StartTagQuery
+ test are committed. Full finding: `docs/reverse-engineering/grpc-tag-query-odata.md`.
> âšī¸ **Auth note (2026-06-21, resolved):** an apparent NTLM round-1 `SEC_E_LOGON_DENIED` blocker
> turned out to be a **test-harness credential-parsing bug**, not a server/account/SDK issue â the
diff --git a/docs/reverse-engineering/grpc-tag-query-odata.md b/docs/reverse-engineering/grpc-tag-query-odata.md
new file mode 100644
index 0000000..1650f6a
--- /dev/null
+++ b/docs/reverse-engineering/grpc-tag-query-odata.md
@@ -0,0 +1,42 @@
+# R0.1 browse over gRPC â StartTagQuery takes an OData filter (2026-06-21)
+
+Live-probed `RetrievalService.StartTagQuery` / `QueryTag` against a real **2023 R2** server over the
+gRPC front door (string-handle = uppercase Open2 storage GUID). Key result: **browse is feasible on
+2023 R2** â the 2020 WCF "metadata-server pipe" wall does **not** block here.
+
+## StartTagQuery â CRACKED
+
+`StartTagQuery(strHandle, btRequest)` where `btRequest` = the native
+`marker(26449) + version(1) + WriteHistorianString(filter)` buffer
+(`HistorianTagQueryProtocol.CreateStartTagQueryAttempt`). The server runs
+`CMdServer::StartTagQuery::StartActiveTagnamesQuery` over `\\.\pipe\aahMetadataServer\console` and
+**parses the filter string as OData** (not SQL-LIKE). Swept filters:
+
+| filter | result |
+|---|---|
+| `startswith(TagName,'Sys')` | â
success, 8-byte response |
+| `contains(TagName,'Sys')` | â
success |
+| `TagName eq 'SysTimeSec'` | â
success |
+| `` (empty) | â
success (all tags) |
+| `Sys*` / `*` | â `ODataFilter ... bad token` |
+| `TagName like 'Sys%'` / `Name like 'Sys%'` | â rejected |
+
+Success response `btResponse` is the 8-byte `(queryHandle:uint, tagCount:uint)` pair
+(`ParseStartTagQueryResponse`). Live: `startswith(TagName,'Sys')` â tagCount = 220.
+
+**Implication for the public API:** browse must translate the SDK's glob filter to OData â
+`*` â empty, `Pre*` â `startswith(TagName,'Pre')`, `*sub*` â `contains(TagName,'sub')`,
+exact â `TagName eq '...'`. (Escaping single-quotes in names still TBD.)
+
+## QueryTag â OPEN (one capture away)
+
+`QueryTag(strHandle, uiQueryHandle, btRequest)` is the paging call that should return the actual
+tag-name rows. Every `btRequest` shape tried returns a constant native error **type 4 / code 72**
+(independent of content: empty, count, column-name historian-string, `$select=TagName`,
+marker+version+name all give the same `04 48000000`). The constant code regardless of input means the
+request *framing* is wrong, not the field values â this needs a **native capture** of the real 2023 R2
+client driving a browse to recover the exact `QueryTag` `btRequest` (and the row framing in
+`btResonse`). Do not ship a guessed QueryTag request (project discipline: no guessed wire bytes).
+
+Probe helpers live in `Grpc/HistorianGrpcTagClient` (`ProbeStartTagQuery`, `ProbeTagQuerySequence`)
+and the gated `StartTagQuery_OverGrpc_AcceptsODataFilter` test pins the StartTagQuery+OData result.
diff --git a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcTagClient.cs b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcTagClient.cs
index 83433bf..487de4c 100644
--- a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcTagClient.cs
+++ b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcTagClient.cs
@@ -89,6 +89,97 @@ internal static class HistorianGrpcTagClient
return response.BtTagInfos?.ToByteArray() ?? [];
}
+ /// Outcome of a StartTagQuery probe â whether the gRPC front door accepts the op.
+ internal readonly record struct StartTagQueryProbeResult(bool Success, int ResponseLength, byte[] Response, int ErrorLength, byte[] Error);
+
+ ///
+ /// Reverse-engineering probe for R0.1 (browse): drives RetrievalService.StartTagQuery with
+ /// the native filter request and reports whether the 2023 R2 front door accepts it (on 2020 WCF this
+ /// op fails server-side on the aahMetadataServer pipe regardless of handle format).
+ ///
+ internal static StartTagQueryProbeResult ProbeStartTagQuery(HistorianClientOptions options, string filter, 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[] requestBuffer = HistorianTagQueryProtocol.CreateStartTagQueryAttempt(filter).RequestBuffer;
+ GrpcRetrieval.StartTagQueryResponse response = retrievalClient.StartTagQuery(
+ new GrpcRetrieval.StartTagQueryRequest
+ {
+ StrHandle = session.StringHandle,
+ BtRequest = ByteString.CopyFrom(requestBuffer)
+ },
+ connection.Metadata,
+ DateTime.UtcNow.Add(options.RequestTimeout),
+ cancellationToken);
+
+ bool success = response.Status?.BSuccess ?? false;
+ byte[] resp = response.BtResponse?.ToByteArray() ?? [];
+ byte[] error = response.Status?.BtError?.ToByteArray() ?? [];
+ return new StartTagQueryProbeResult(success, resp.Length, resp, error.Length, error);
+ }
+
+ /// Outcome of a full StartTagQuery â QueryTag â EndTagQuery probe.
+ internal readonly record struct TagQuerySequenceResult(
+ bool StartSuccess, uint QueryHandle, uint TagCount,
+ bool QuerySuccess, byte[] QueryResponse, byte[] QueryError);
+
+ ///
+ /// Reverse-engineering probe for R0.1 (browse): runs the full StartTagQuery (OData filter) â
+ /// QueryTag (paging) â EndTagQuery sequence and returns the raw QueryTag response buffer so its
+ /// tag-name framing can be discovered. is the candidate
+ /// QueryTag btRequest buffer (format unknown â swept by the probe).
+ ///
+ internal static TagQuerySequenceResult ProbeTagQuerySequence(
+ HistorianClientOptions options, string odataFilter, byte[] queryRequest, CancellationToken cancellationToken)
+ {
+ using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options);
+ HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(connection, options, cancellationToken);
+ var retrievalClient = new GrpcRetrieval.RetrievalService.RetrievalServiceClient(connection.Channel);
+ DateTime Deadline() => DateTime.UtcNow.Add(options.RequestTimeout);
+
+ byte[] startRequest = HistorianTagQueryProtocol.CreateStartTagQueryAttempt(odataFilter).RequestBuffer;
+ GrpcRetrieval.StartTagQueryResponse start = retrievalClient.StartTagQuery(
+ new GrpcRetrieval.StartTagQueryRequest { StrHandle = session.StringHandle, BtRequest = ByteString.CopyFrom(startRequest) },
+ connection.Metadata, Deadline(), cancellationToken);
+ if (!(start.Status?.BSuccess ?? false))
+ {
+ return new TagQuerySequenceResult(false, 0, 0, false, [], start.Status?.BtError?.ToByteArray() ?? []);
+ }
+
+ byte[] startResp = start.BtResponse?.ToByteArray() ?? [];
+ HistorianTagQueryStartResponse parsed = HistorianTagQueryProtocol.ParseStartTagQueryResponse(startResp);
+
+ try
+ {
+ GrpcRetrieval.QueryTagResponse query = retrievalClient.QueryTag(
+ new GrpcRetrieval.QueryTagRequest
+ {
+ StrHandle = session.StringHandle,
+ UiQueryHandle = parsed.QueryHandle,
+ BtRequest = ByteString.CopyFrom(queryRequest)
+ },
+ connection.Metadata, Deadline(), cancellationToken);
+
+ return new TagQuerySequenceResult(
+ true, parsed.QueryHandle, parsed.TagCount,
+ query.Status?.BSuccess ?? false,
+ query.BtResonse?.ToByteArray() ?? [],
+ query.Status?.BtError?.ToByteArray() ?? []);
+ }
+ finally
+ {
+ try
+ {
+ retrievalClient.EndTagQuery(
+ new GrpcRetrieval.EndTagQueryRequest { StrHandle = session.StringHandle, UiQueryHandle = parsed.QueryHandle },
+ connection.Metadata, Deadline(), CancellationToken.None);
+ }
+ catch { /* best-effort cleanup */ }
+ }
+ }
+
/// Builds the native tag-names request buffer: uint count + per-name(uint charCount + UTF-16LE).
internal static byte[] BuildTagNamesBuffer(IReadOnlyList tags)
{
diff --git a/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs b/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs
index a140dfe..0015e87 100644
--- a/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs
+++ b/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs
@@ -86,6 +86,30 @@ public sealed class HistorianGrpcIntegrationTests
Assert.True(Enum.IsDefined(metadata.DataType));
}
+ [Fact]
+ public async Task StartTagQuery_OverGrpc_AcceptsODataFilter()
+ {
+ string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST");
+ if (string.IsNullOrWhiteSpace(host) || string.IsNullOrEmpty(Environment.GetEnvironmentVariable("HISTORIAN_USER")))
+ {
+ return;
+ }
+
+ // R0.1 finding (2026-06-21): on the 2023 R2 gRPC front door the metadata-server pipe IS
+ // reachable (unlike 2020 WCF) and StartActiveTagnamesQuery parses the filter as OData â
+ // startswith/contains/eq succeed; SQL-LIKE "%"/glob "*" fail with "ODataFilter: bad token".
+ // StartTagQuery returns the 8-byte (queryHandle, tagCount) response. The follow-on QueryTag
+ // paging request format is not yet captured (see roadmap R0.1), so browse is not yet wired.
+ HistorianClientOptions options = BuildOptions(host);
+ var result = await Task.Run(() =>
+ AVEVA.Historian.Client.Grpc.HistorianGrpcTagClient.ProbeStartTagQuery(options, "startswith(TagName,'Sys')", CancellationToken.None));
+
+ Assert.True(result.Success,
+ $"StartTagQuery(OData) should succeed; errLen={result.ErrorLength} " +
+ $"err=\"{System.Text.Encoding.ASCII.GetString(result.Error).Replace('\0', '.')}\"");
+ Assert.Equal(8, result.ResponseLength); // (queryHandle, tagCount)
+ }
+
private static HistorianClientOptions BuildOptions(string host)
{
string? user = Environment.GetEnvironmentVariable("HISTORIAN_USER");