diff --git a/docs/plans/hcal-roadmap.md b/docs/plans/hcal-roadmap.md
index bc8dc3d..fe8e2bd 100644
--- a/docs/plans/hcal-roadmap.md
+++ b/docs/plans/hcal-roadmap.md
@@ -166,8 +166,8 @@ read/browse/status surface is Windows-free and the gRPC stack is the default pat
| ~~R1.5~~ | Extended-property **read** | `Retrieval.GetTagExtendedPropertiesFromName` (`GetTepByNm`) | ✅ **DONE (2026-06-20), live-verified.** `GetTagExtendedPropertiesAsync(tag)` → name/value pairs. String-handle op via the uppercase storage GUID; name-based path (`GetTagExtendedPropertiesByName`, not the QTB-gated TagQuery path). Request `tagNames` = `uint count` + per-name(`uint charCount`+UTF-16); response = `uint tagCount` + per-tag(marker + compact-ASCII name + `uint propCount` + per-prop(marker + compact-ASCII name + `0x43` VT_BSTR value) + trailer). Sequence-paged. Shipped: `HistorianTagExtendedPropertyProtocol`, golden `WcfTagExtendedPropertyProtocolTests`, gated live test. See `docs/reverse-engineering/wcf-tag-extended-properties.md`. | uppercase string handle |
| ~~R1.6~~ | Localized-property **read** | (no op) | ⛔ **No distinct op on 2020 — collapses into R1.5.** There is no `GetTagLocalizedPropertiesFromName`/`GetTlpByNm` or `GetTagLocalizedPropertiesByName` in `current/aahClientManaged.dll`; the only "localized" surfaces are error-message/UI-text localization. Extended properties (R1.5) are the user-defined tag-property read surface. Closed, not throwing. | — |
| ~~R1.7~~ | Event **filters** | filter bytes in `Retrieval.StartEventQuery` | ✅ **DONE (2026-06-20), live-honored.** `ReadEventsAsync(start, end, HistorianEventFilter)`. The filter rides `StartEventQuery`'s `pRequestBuff` (captured via `EventQuery.AddEventFilter` + instrument-wcf-writemessage; Equal vs Contains diffed to isolate the op). Filter block: `ushort 0 + uint filterCount + uint condCount + uint nameLen + name(UTF-16) + uint 1 + ushort op + uint 1 + value(0x09-len-0x00 compact-ASCII) + byte 0`. **REAL, not inert** (a non-matching predicate returns 0 events; matching returns the subset). Single string-valued predicate only; multi-filter (OR) / multi-condition (AND via `AddEventFilterCondition`) framing not yet fully captured. See `HistorianEventFilter`, golden `WcfEventQueryProtocolTests`. | — |
-| R1.8 | Analog-summary query | `Retrieval.StartQuery` (summary mode) | summary row layout — **`uint`-handle, reachable. Scoped + decode targets located** (`CAnalogSummaryValue.UnpackFromValueBuffer`, fields Min/Max/First/Last/ValueCount/Integral/…). Plan: [`r1.8-r1.9-summary-queries.md`](r1.8-r1.9-summary-queries.md) | — |
-| R1.9 | State-summary query | `Retrieval.StartQuery` (state mode) | state-summary row layout — **`uint`-handle, reachable. Scoped** (`CStateSummaryStruct`: MinContained/MaxContained/TotalContained/PartialStart/PartialEnd/StateEntryCount). Plan: [`r1.8-r1.9-summary-queries.md`](r1.8-r1.9-summary-queries.md) | — |
+| ~~R1.8~~ | Analog-summary query | `Retrieval.StartQuery` (summary mode) | ✅ **RESOLVED (2026-06-21) — no new code; == existing `ReadAggregateAsync`.** Request + response both captured (`scripts/Capture-SummaryRequest.ps1 -WithResponse`): the `GetNextQueryResultBuffer2` response is the **ordinary version-9 row buffer** the raw/aggregate parser already handles (decoded 7 rows = SQL ground truth exactly). There is **no rich `CAnalogSummaryValue` struct on the wire** — each row carries a *single* value selected by `RetrievalMode`/QueryType (Integral→8, TimeWeightedAverage→5, …), not an all-aggregates-in-one row; `ValueSelector`/`AggregationType`/`MaxStates` are **inert** on the WCF retrieval path (they configure the SQL provider, not this query). The all-aggregates-at-once shape is the SQL/OLEDB provider's, or the gRPC front door — not 2020 WCF binary. Plan + capture evidence: [`r1.8-r1.9-summary-queries.md`](r1.8-r1.9-summary-queries.md). | — |
+| ~~R1.9~~ | State-summary query | `Retrieval.StartQuery` (state mode) | ✅ **RESOLVED (2026-06-21) — same finding as R1.8.** State-summary is the **same `StartQuery2` request** (only `MaxStates`/defaults differ on the wire); the response carries no distinct `CStateSummaryStruct` on the 2020 WCF binary path. Covered by the existing aggregate read; no new `src/` code warranted. Plan: [`r1.8-r1.9-summary-queries.md`](r1.8-r1.9-summary-queries.md). | — |
### 1c. Bounded config writes (S–M each)
| ID | Capability | gRPC op | Payload | Notes |
@@ -221,9 +221,21 @@ byte-correct `AddS2` (✅). Appears-and-reads-back is environment-gated on event
*Goal: insert original historical VTQs (backfill), the path that is NOT the gated cache push.*
+> ✅ **gRPC UNLOCK (2026-06-21, LIVE-VERIFIED): the transaction lifecycle is REACHABLE over the
+> 2023 R2 gRPC front door.** The `grpc-revision-probe` opened a **write-enabled** (`0x401`) gRPC
+> session and drove `TransactionService.AddNonStreamValuesBegin(storage-GUID **uppercase**)` →
+> real `strTransactionId` → `AddNonStreamValuesEnd(bCommit=false)` (discarded, no data written).
+> Where 2020 WCF returns `UnknownClient (51)`, the gRPC `TransactionService` is itself the gateway
+> to the storage engine, so the Open2 session GUID is accepted directly — **no legacy pipe**. This
+> answers the M3-over-gRPC question below: **yes**, the non-streamed *original* write transaction is
+> reachable from the pure-managed SDK. **Not yet shipped:** the `AddNonStreamValues` `btInput` VTQ
+> buffer must be captured before any value-commit (never guess wire bytes); revision *edits* (R4.2)
+> remain pipe-only even on gRPC. Full detail + decompile basis:
+> [`revision-write-path.md`](revision-write-path.md) §"2023 R2 gRPC — the wall is gone".
+>
> ⛔ **BLOCKED on 2020 WCF — re-confirmed by the D2 probe (2026-05-05), see
> [`revision-write-path.md`](revision-write-path.md).** The premise above ("the path that is NOT
-> the gated cache push") was **disproved**: R3.1's op
+> the gated cache push") was **disproved** *on WCF*: R3.1's op
> (`Transaction.AddNonStreamValuesBegin/AddNonStreamValues/End`) is the **same**
> `ITransactionServiceContract2.AddNonStreamValuesBegin2` D2 probed, and over WCF it returns
> `04 33 00 00 00` = `UnknownClient (51)` for every handle format **and** the full priming chain
@@ -242,12 +254,13 @@ byte-correct `AddS2` (✅). Appears-and-reads-back is environment-gated on event
| ID | Work | gRPC op | Status |
|---|---|---|---|
-| R3.1 | Decode non-streamed VTQ packet | `Transaction.AddNonStreamValuesBegin/AddNonStreamValues/End` | ⛔ WCF blocked (storage-engine pipe — D2). gRPC: untested |
-| R3.2 | `AddHistoricalValuesAsync` | batched begin→values→end | ⛔ gated on R3.1 |
-| R3.3 | Ingest-permission validation | confirm the target accepts original-data insert (distinct from `AddS2` cache wall) | ⛔ proven to share the same gate, not distinct |
+| R3.1 | Decode non-streamed VTQ packet | `Transaction.AddNonStreamValuesBegin/AddNonStreamValues/End` | 🟡 **gRPC transaction Begin/End LIVE-VERIFIED 2026-06-21** (WCF still blocked — D2). Remaining: capture the `AddNonStreamValues` `btInput` VTQ buffer (don't guess) |
+| R3.2 | `AddHistoricalValuesAsync` | batched begin→values→end | 🟡 unblocked by R3.1's gRPC proof; needs the `btInput` serializer + a real `bCommit=true` write/read-back |
+| R3.3 | Ingest-permission validation | confirm the target accepts original-data insert (distinct from `AddS2` cache wall) | ✅ **distinct on gRPC** — Begin succeeded against a real write-enabled session (the WCF/native cache gate does not apply here) |
-**Acceptance:** historical points inserted and read back. **WCF path closed (D2);** would require
-the gRPC write path (live 2023 R2 server + capture) to reopen.
+**Acceptance:** historical points inserted and read back. **WCF path closed (D2).** gRPC path:
+**transaction lifecycle proven (Begin/End live)**; full insert+read-back pending the `btInput`
+capture + serializer.
---
@@ -301,11 +314,13 @@ event-send). M3/M4 as demand dictates.
> **Status 2026-06-21:** sprints 1 + 2 are **complete** (M0 gRPC parity, the reachable M1 surface,
> and M2 event-send all shipped + live-verified; remaining M1 items are evidence-bounded-out). The
-> reachable surface on the **available 2020 WCF infrastructure is exhausted** — every remaining
-> roadmap item is now either (a) blocked by the storage-engine-pipe architecture (**M3-WCF**, R4.2),
-> (b) **gRPC/2023R2-only** and needs the live 2023 R2 server for a native capture (R1.3 timezone,
-> R1.4 EventStorageMode, M3/revisions over gRPC), or (c) a HARD deferred subsystem (M4). No further
-> work lands without one of: a live-2023R2 capture session, or a customer-demand trigger.
+> reachable surface on the **available 2020 WCF infrastructure is exhausted**. **M3 update
+> (2026-06-21):** with the live 2023 R2 server, the **M3 non-streamed write transaction is now
+> proven reachable over gRPC** — `TransactionService.AddNonStreamValuesBegin/End` round-trips live
+> (the D2 storage-engine-pipe wall is WCF-only). The remaining M3 work is bounded and concrete:
+> capture the `AddNonStreamValues` `btInput` VTQ buffer → golden-tested serializer → real
+> commit+read-back → public `AddHistoricalValuesAsync`. The other levers are unchanged: R4.2 revision
+> *edits* stay pipe-only even on gRPC, and M4 (SF / redundancy) is a HARD deferred subsystem.
## One-glance status
@@ -314,5 +329,5 @@ event-send). M3/M4 as demand dictates.
| M0 gRPC parity + capture tooling | foundation | M | unblocks everything, Windows-free | ✅ **done** |
| M1 cheap surface | TRIVIAL/BOUNDED | M–L | most remaining read/config | ✅ **done** (reachable surface; rest bounded out) |
| M2 event send | CAPTURE | S–M | headline write capability | ✅ **done** |
-| M3 historical writes | BOUNDED | M | backfill | ⛔ WCF blocked (D2); gRPC = on-demand + live 2023R2 |
+| M3 historical writes | BOUNDED | M | backfill | 🟡 **gRPC transaction Begin/End live-verified (2026-06-21)**; WCF blocked (D2). Remaining: `btInput` capture → commit+read-back |
| M4 SF / revisions / redundancy | HARD | L×N | parity completeness | defer (R4.2 = same pipe wall) |
diff --git a/docs/plans/revision-write-path.md b/docs/plans/revision-write-path.md
index b326689..66065aa 100644
--- a/docs/plans/revision-write-path.md
+++ b/docs/plans/revision-write-path.md
@@ -1,6 +1,66 @@
# Plan: Revision-Write Path (`AddRevisionValuesBegin/Value/End`)
-Status: **ARCHITECTURALLY BLOCKED — verified 2026-05-05.** Same root
+Status: **WCF: ARCHITECTURALLY BLOCKED (verified 2026-05-05).** **gRPC (2023 R2): the
+non-streamed-original transaction is REACHABLE — Begin/End round-trip LIVE-VERIFIED 2026-06-21.**
+Same root cause on WCF as `AddS2`: the `TransactionService` relay needs a pre-existing
+storage-engine *pipe* session no WCF op can create. The 2023 R2 gRPC front door removes that wall
+(see the §"2023 R2 gRPC — the wall is gone" section immediately below); the legacy WCF analysis is
+preserved unchanged after it.
+
+## 2023 R2 gRPC — the wall is gone (non-streamed original writes), LIVE-VERIFIED 2026-06-21
+
+The whole D2 WCF blocker was: `ITransactionServiceContract2.AddNonStreamValuesBegin2` returns
+`04 33 00 00 00` = `UnknownClient (51)` because the server-side Trx relay requires a storage-engine
+pipe session (`STransactPipeClient2` → `aaStorageEngine.exe`) that no WCF op establishes. On the
+**2023 R2 gRPC** transport that relay is replaced by a first-class `TransactionService` gRPC
+service, and the gRPC server is itself the gateway to the storage engine — so the client passes the
+**HistoryService Open2 storage-session GUID** straight in as `strHandle` and the transaction opens.
+
+**Live probe (`grpc-revision-probe` CLI command / `HistorianGrpcRevisionProbe`):** against the real
+2023 R2 server (History iface 12), over a **write-enabled** (`0x401`) gRPC session —
+
+| step | result |
+|---|---|
+| `HistoryService.OpenConnection` (write-enabled `0x401`) | ✅ `OpenSucceeded`, client handle + storage GUID returned |
+| `TransactionService.GetTransactionInterfaceVersion` | ✅ error 0, **version 2** |
+| `TransactionService.AddNonStreamValuesBegin(strHandle = storage GUID **UPPERCASE**)` | ✅ **`BeginSucceeded`** — returns a real `strTransactionId` (e.g. `…-FE0A-4822-…`) on the **first** handle format tried |
+| `TransactionService.AddNonStreamValuesEnd(handle, txId, bCommit=**false**)` | ✅ `EndDiscardSucceeded` — transaction discarded, **no data written** |
+
+So the answer to the roadmap's open M3-over-gRPC question ("does the 2023 R2 gRPC front door expose
+a non-streamed write that bypasses the legacy storage-engine pipe?") is **YES** — Begin/End is
+reachable from the pure-managed SDK with no pipe, no native wrapper. The probe is committed as the
+`grpc-revision-probe` CLI command + the gated test
+`HistorianGrpcIntegrationTests.NonStreamedWriteTransaction_OverGrpc_BeginsAndDiscards`; re-run any
+time to confirm the path is still open.
+
+### Decompile basis (handle + op group)
+
+`Archestra.Historian.GrpcClient.GrpcHistoryClient` drives the identical three-phase sequence
+(`AddNonStreamValuesBegin(strHandle) → strTransactionId`; `AddNonStreamValues(strHandle,
+strTransactionId, btInput)`; `AddNonStreamValuesEnd(strHandle, strTransactionId, bCommit)`), passing
+the Open2 session GUID as `strHandle`. `btInput` is the **same opaque native VTQ buffer** the 2020
+path uses. Proto: `src/AVEVA.Historian.Client/Grpc/Protos/TransactionService.proto`.
+
+### What is proven vs. what remains (do NOT ship yet)
+
+- ✅ **Proven:** the transaction lifecycle (Begin → End/rollback) is reachable over gRPC. The D2
+ architectural wall is specific to the WCF transport.
+- ⛔ **Not yet captured:** the `AddNonStreamValues` **`btInput` VTQ buffer byte layout**. Per project
+ discipline ("never guess wire bytes; capture first") no value-commit is implemented. The next step
+ to actually *ship* M3 (`AddHistoricalValuesAsync`) is to capture the native gRPC `AddNonStreamValues`
+ `btInput` (or decode the `GrpcHistoryClient` serializer), build a golden-tested serializer, then do a
+ real `bCommit=true` write + SQL read-back against a sandbox tag created by `EnsureTagAsync`.
+- 🔒 **Scope:** this is **non-streamed ORIGINAL backfill** (`HistorianDataCategory.NonStreamedOriginal`
+ → `TransactionService.AddNonStreamValues*`). **Revision EDITS** (`AddRevisionValue(s)` /
+ `RevisionInsert*`, the R4.2 path) are NOT on the gRPC contract even in 2023 R2 — the capability
+ matrix confirms they still ride the storage-engine pipe. The gRPC unlock here is original backfill,
+ not after-the-fact edits.
+
+---
+
+## Legacy WCF analysis (preserved — still accurate for the 2020 WCF transport)
+
+Status (WCF only): **ARCHITECTURALLY BLOCKED — verified 2026-05-05.** Same root
cause as `AddS2`: client-side cache rejects values for tags that
weren't registered through a configured IO server / Application Server
pipeline. Documented below; implementation deferred until / unless that
diff --git a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcHandshake.cs b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcHandshake.cs
index 787adf0..79d88b1 100644
--- a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcHandshake.cs
+++ b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcHandshake.cs
@@ -40,10 +40,17 @@ internal static class HistorianGrpcHandshake
CancellationToken cancellationToken)
=> OpenSession(connection, options, cancellationToken).ClientHandle;
+ ///
+ /// The native Open2 connection mode. Defaults to read-only (0x402); pass
+ ///
+ /// (0x401) for write-enabled sessions (e.g. the non-streamed/revision Transaction path,
+ /// which the read-only mode silently rejects with err 132 OperationNotEnabled).
+ ///
public static Session OpenSession(
HistorianGrpcConnection connection,
HistorianClientOptions options,
- CancellationToken cancellationToken)
+ CancellationToken cancellationToken,
+ uint connectionMode = HistorianWcfAuthChainHelper.NativeIntegratedReadOnlyConnectionMode)
{
DateTime Deadline() => DateTime.UtcNow.Add(options.RequestTimeout);
@@ -73,7 +80,7 @@ internal static class HistorianGrpcHandshake
cancellationToken);
byte[] open2Request = HistorianNativeHandshake.BuildOpenConnection3Request(
- options.Host, contextKey, HistorianWcfAuthChainHelper.NativeIntegratedReadOnlyConnectionMode);
+ options.Host, contextKey, connectionMode);
GrpcHistory.OpenConnectionResponse open2 = historyClient.OpenConnection(
new GrpcHistory.OpenConnectionRequest { BtConnectionRequest = ByteString.CopyFrom(open2Request) },
diff --git a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcRevisionProbe.cs b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcRevisionProbe.cs
new file mode 100644
index 0000000..cbff48e
--- /dev/null
+++ b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcRevisionProbe.cs
@@ -0,0 +1,157 @@
+using Google.Protobuf;
+using AVEVA.Historian.Client.Wcf;
+using GrpcTransaction = ArchestrA.Grpc.Contract.Transaction;
+
+namespace AVEVA.Historian.Client.Grpc;
+
+///
+/// Live probe for the M3 (historical / non-streamed original-value write) path over the 2023 R2
+/// gRPC front door. On 2020 WCF this op group is architecturally blocked: the
+/// ITransactionServiceContract2.AddNonStreamValuesBegin2 relay returns
+/// UnknownClient (51) because it requires a pre-existing storage-engine pipe session
+/// (STransactPipeClient2 → aaStorageEngine.exe) that no WCF op can establish — see
+/// docs/plans/revision-write-path.md (the D2 finding).
+///
+/// The 2023 R2 decompile shows the native gRPC client driving the SAME op group over
+/// TransactionService.AddNonStreamValuesBegin/AddNonStreamValues/AddNonStreamValuesEnd and
+/// passing the HistoryService Open2 session GUID directly as strHandle — i.e. the gRPC
+/// server is the gateway to the storage engine, so the client never touches the legacy pipe. This
+/// probe tests whether the SDK's pure-managed handshake can reproduce that: it opens a
+/// write-enabled session and calls AddNonStreamValuesBegin, surfacing whatever the server
+/// returns. It writes NO data — if Begin succeeds it immediately calls AddNonStreamValuesEnd
+/// with bCommit=false to discard the transaction.
+///
+internal sealed class HistorianGrpcRevisionProbe
+{
+ private readonly HistorianClientOptions _options;
+
+ public HistorianGrpcRevisionProbe(HistorianClientOptions options)
+ {
+ _options = options ?? throw new ArgumentNullException(nameof(options));
+ }
+
+ public Task ProbeBeginAsync(CancellationToken cancellationToken)
+ => Task.Run(() => ProbeBegin(cancellationToken), cancellationToken);
+
+ private HistorianGrpcRevisionProbeResult ProbeBegin(CancellationToken cancellationToken)
+ {
+ var result = new HistorianGrpcRevisionProbeResult();
+
+ using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(_options);
+
+ HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(
+ connection,
+ _options,
+ cancellationToken,
+ connectionMode: HistorianWcfAuthChainHelper.NativeIntegratedWriteEnabledConnectionMode);
+
+ result.OpenSucceeded = true;
+ result.ClientHandle = session.ClientHandle;
+ result.StorageSessionId = session.StorageSessionId;
+
+ var transactionClient = new GrpcTransaction.TransactionService.TransactionServiceClient(connection.Channel);
+ DateTime Deadline() => DateTime.UtcNow.Add(_options.RequestTimeout);
+
+ // Register the client with the Transaction service's session table (matches the
+ // cross-service GetV priming the WCF write path uses).
+ try
+ {
+ GrpcTransaction.GetTransactionInterfaceVersionResponse version = transactionClient.GetTransactionInterfaceVersion(
+ new GrpcTransaction.GetTransactionInterfaceVersionRequest(), connection.Metadata, Deadline(), cancellationToken);
+ result.TrxInterfaceVersionError = version.Error;
+ result.TrxInterfaceVersion = version.Version;
+ }
+ catch (Exception ex)
+ {
+ result.TrxInterfaceVersionException = $"{ex.GetType().Name}: {ex.Message}";
+ }
+
+ // The decompiled native client passes the Open2 storage-session GUID (string) as strHandle.
+ // Try that first (uppercase "D" form, as the other string-handle ops require), then a couple
+ // of fallbacks mirroring the WCF probe, so a wrong-format rejection is distinguishable from a
+ // genuine server-side block.
+ foreach ((string label, string handle) in new[]
+ {
+ ("storageSessionId-upper", session.StringHandle),
+ ("storageSessionId-lower", session.StorageSessionId.ToString("D")),
+ ("clientHandle-as-string", session.ClientHandle.ToString()),
+ })
+ {
+ var attempt = new HistorianGrpcRevisionBeginAttempt { HandleLabel = label, HandleSent = handle };
+ try
+ {
+ GrpcTransaction.AddNonStreamValuesBeginResponse begin = transactionClient.AddNonStreamValuesBegin(
+ new GrpcTransaction.AddNonStreamValuesBeginRequest { StrHandle = handle },
+ connection.Metadata, Deadline(), cancellationToken);
+
+ attempt.Succeeded = begin.Status?.BSuccess ?? false;
+ attempt.TransactionId = begin.StrTransactionId;
+ byte[] error = begin.Status?.BtError?.ToByteArray() ?? [];
+ attempt.ErrorHex = error.Length == 0 ? null : Convert.ToHexString(error);
+ result.BeginAttempts.Add(attempt);
+
+ if (attempt.Succeeded && !string.IsNullOrEmpty(attempt.TransactionId))
+ {
+ result.BeginSucceeded = true;
+ result.BeginTransactionId = attempt.TransactionId;
+
+ // Discard immediately — bCommit=false writes nothing. This keeps the probe
+ // read-only against the live (production) server.
+ try
+ {
+ GrpcTransaction.AddNonStreamValuesEndResponse end = transactionClient.AddNonStreamValuesEnd(
+ new GrpcTransaction.AddNonStreamValuesEndRequest
+ {
+ StrHandle = handle,
+ StrTransactionId = attempt.TransactionId,
+ BCommit = false,
+ },
+ connection.Metadata, Deadline(), cancellationToken);
+ result.EndDiscardSucceeded = end.Status?.BSuccess ?? false;
+ byte[] endError = end.Status?.BtError?.ToByteArray() ?? [];
+ result.EndDiscardErrorHex = endError.Length == 0 ? null : Convert.ToHexString(endError);
+ }
+ catch (Exception ex)
+ {
+ result.EndDiscardException = $"{ex.GetType().Name}: {ex.Message}";
+ }
+
+ break;
+ }
+ }
+ catch (Exception ex)
+ {
+ attempt.Exception = $"{ex.GetType().Name}: {ex.Message}";
+ result.BeginAttempts.Add(attempt);
+ }
+ }
+
+ return result;
+ }
+}
+
+internal sealed class HistorianGrpcRevisionProbeResult
+{
+ public bool OpenSucceeded { get; set; }
+ public uint ClientHandle { get; set; }
+ public Guid StorageSessionId { get; set; }
+ public uint? TrxInterfaceVersionError { get; set; }
+ public uint? TrxInterfaceVersion { get; set; }
+ public string? TrxInterfaceVersionException { get; set; }
+ public bool BeginSucceeded { get; set; }
+ public string? BeginTransactionId { get; set; }
+ public bool EndDiscardSucceeded { get; set; }
+ public string? EndDiscardErrorHex { get; set; }
+ public string? EndDiscardException { get; set; }
+ public List BeginAttempts { get; } = new();
+}
+
+internal sealed class HistorianGrpcRevisionBeginAttempt
+{
+ public string HandleLabel { get; set; } = "";
+ public string HandleSent { get; set; } = "";
+ public bool Succeeded { get; set; }
+ public string? TransactionId { get; set; }
+ public string? ErrorHex { get; set; }
+ public string? Exception { get; set; }
+}
diff --git a/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs b/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs
index facb60c..6a805f3 100644
--- a/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs
+++ b/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs
@@ -1,3 +1,4 @@
+using AVEVA.Historian.Client.Grpc;
using AVEVA.Historian.Client.Models;
namespace AVEVA.Historian.Client.Tests;
@@ -123,6 +124,30 @@ public sealed class HistorianGrpcIntegrationTests
Assert.All(names, n => Assert.StartsWith("Sys", n, StringComparison.Ordinal));
}
+ [Fact]
+ public async Task NonStreamedWriteTransaction_OverGrpc_BeginsAndDiscards()
+ {
+ string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST");
+ if (string.IsNullOrWhiteSpace(host) || string.IsNullOrEmpty(Environment.GetEnvironmentVariable("HISTORIAN_USER")))
+ {
+ return;
+ }
+
+ // M3 reachability probe: on 2020 WCF this op group is walled (TransactionService relay
+ // returns UnknownClient(51) — the storage-engine-pipe requirement, see
+ // docs/plans/revision-write-path.md). On the 2023 R2 gRPC front door the native client
+ // passes the Open2 storage-session GUID straight to TransactionService and it works.
+ // This asserts the wall is gone: a write-enabled session opens and AddNonStreamValuesBegin
+ // returns a transaction id, which we immediately End with bCommit=false (writes nothing).
+ var probe = new HistorianGrpcRevisionProbe(BuildOptions(host));
+ HistorianGrpcRevisionProbeResult result = await probe.ProbeBeginAsync(CancellationToken.None);
+
+ Assert.True(result.OpenSucceeded);
+ Assert.True(result.BeginSucceeded, "AddNonStreamValuesBegin should return a transaction id over gRPC.");
+ Assert.False(string.IsNullOrEmpty(result.BeginTransactionId));
+ Assert.True(result.EndDiscardSucceeded, "AddNonStreamValuesEnd(bCommit:false) should discard cleanly.");
+ }
+
private static HistorianClientOptions BuildOptions(string host)
{
string? user = Environment.GetEnvironmentVariable("HISTORIAN_USER");
diff --git a/tools/AVEVA.Historian.ReverseEngineering/Program.cs b/tools/AVEVA.Historian.ReverseEngineering/Program.cs
index 2b64c15..76168f8 100644
--- a/tools/AVEVA.Historian.ReverseEngineering/Program.cs
+++ b/tools/AVEVA.Historian.ReverseEngineering/Program.cs
@@ -13,6 +13,8 @@ using System.Runtime.Versioning;
using System.Text;
using System.Text.Json;
using AVEVA.Historian.Client;
+using AVEVA.Historian.Client.Grpc;
+using AVEVA.Historian.Client.Models;
using AVEVA.Historian.Client.Wcf;
using AVEVA.Historian.Client.Wcf.Contracts;
using AVEVA.Historian.ReverseEngineering.Capture;
@@ -72,6 +74,7 @@ try
"wcf-register-event-tag" => RegisterEventTagAndStartQuery(args),
"wcf-add-event-tag" => AddEventTagAndStartQuery(args),
"capture-tag-info" => CaptureTagInfo(args),
+ "grpc-revision-probe" => ProbeGrpcRevision(args),
_ => UnknownCommand(args[0])
};
}
@@ -3209,6 +3212,41 @@ static int WriteMarker(string[] args)
return 0;
}
+static int ProbeGrpcRevision(string[] args)
+{
+ // Usage: grpc-revision-probe [port] [--tls] [--dnsid ] [--insecure-cert]
+ // Reads HISTORIAN_USER / HISTORIAN_PASSWORD from the environment for explicit creds;
+ // falls back to IntegratedSecurity when unset.
+ string host = args.Length > 1 ? args[1] : "localhost";
+ int port = args.Length > 2 && int.TryParse(args[2], out int parsedPort)
+ ? parsedPort
+ : HistorianClientOptions.DefaultGrpcPort;
+ bool tls = HasOption(args, "--tls");
+ string? dnsId = GetOption(args, "--dnsid");
+
+ string? user = Environment.GetEnvironmentVariable("HISTORIAN_USER");
+ string? password = Environment.GetEnvironmentVariable("HISTORIAN_PASSWORD");
+ bool explicitCreds = !string.IsNullOrEmpty(user);
+
+ var options = new HistorianClientOptions
+ {
+ Host = host,
+ Port = port,
+ Transport = HistorianTransport.RemoteGrpc,
+ GrpcUseTls = tls,
+ AllowUntrustedServerCertificate = tls,
+ ServerDnsIdentity = dnsId,
+ IntegratedSecurity = !explicitCreds,
+ UserName = user ?? string.Empty,
+ Password = password ?? string.Empty,
+ };
+
+ var probe = new HistorianGrpcRevisionProbe(options);
+ HistorianGrpcRevisionProbeResult result = probe.ProbeBeginAsync(CancellationToken.None).GetAwaiter().GetResult();
+ Console.WriteLine(JsonSerializer.Serialize(result, CreateJsonOptions()));
+ return result.BeginSucceeded ? 0 : 2;
+}
+
static int ProbeWcf(string[] args)
{
string host = args.Length > 1 ? args[1] : "localhost";