diff --git a/README.md b/README.md
index 84b3eea..fc9c59e 100644
--- a/README.md
+++ b/README.md
@@ -17,7 +17,7 @@ The supported surface (per [`CLAUDE.md`](CLAUDE.md)):
| `BrowseTagNamesAsync` | live-verified |
| `GetTagMetadataAsync` | live-verified for 17 distinct native data-type codes |
| `GetConnectionStatusAsync` | synthesized from authenticated probe (matches native semantic) |
-| `GetStoreForwardStatusAsync` | synthesized defaults (no SF sidecar to probe) |
+| `GetStoreForwardStatusAsync` | gRPC: measured idle-state (live-verified — contacts server, reports `ErrorOccurred` when unreachable; active-SF magnitude is D2-gated). Non-gRPC: synthesized defaults |
| `GetSystemParameterAsync` | live-verified via `Stat/GetSystemParameter` |
| `EnsureTagAsync` | live-verified for analog Float/Double/Int2/Int4/UInt4; `ApplyScaling=true` persists distinct MinRaw/MaxRaw |
| `DeleteTagAsync` | live-verified |
diff --git a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcStatusClient.cs b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcStatusClient.cs
index d245b88..f96141a 100644
--- a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcStatusClient.cs
+++ b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcStatusClient.cs
@@ -1,4 +1,5 @@
using Grpc.Core;
+using AVEVA.Historian.Client.Models;
using GrpcStatus = ArchestrA.Grpc.Contract.Status;
namespace AVEVA.Historian.Client.Grpc;
@@ -36,6 +37,78 @@ internal static class HistorianGrpcStatusClient
return (response.Status?.BSuccess ?? false) ? response.StrParameterValue : null;
}
+ ///
+ /// Returns a measured store-forward status over the 2023 R2 gRPC transport (R4.3).
+ ///
+ /// Unlike the non-gRPC path — which synthesizes an
+ /// all-false result without contacting the server — this opens an authenticated session
+ /// and calls StatusService.GetHistorianConsoleStatus, the only SF-adjacent signal reachable
+ /// from a pure managed client. (The direct StorageService.GetSFParameter /
+ /// GetRemainingSnapshotsSize RPCs that carry the SF buffer magnitude require the
+ /// OpenStorageConnection storage-engine console handle, which is gated behind the D2
+ /// storage-engine-pipe wall and is unobtainable here — see
+ /// docs/plans/store-forward-cache-reverse-engineering.md §9.7.)
+ ///
+ ///
+ /// Semantics: a successful console-status read means the server is reachable and its storage
+ /// console is reporting normally ⇒ the not-storing baseline (all flags false), but now
+ /// measured rather than blindly assumed. If the server cannot be reached/authenticated,
+ /// or the console-status call itself fails,
+ /// is set with the underlying error. The active-SF state (
+ /// / /
+ /// magnitude) is NOT observable from this signal and remains false; populating it requires the
+ /// D2-gated storage-console path.
+ ///
+ ///
+ public static Task GetStoreForwardStatusAsync(
+ HistorianClientOptions options,
+ CancellationToken cancellationToken)
+ => Task.Run(() => GetStoreForwardStatus(options, cancellationToken), cancellationToken);
+
+ private static HistorianStoreForwardStatus GetStoreForwardStatus(HistorianClientOptions options, CancellationToken cancellationToken)
+ {
+ HistorianStoreForwardStatus NotStoring(bool errorOccurred, string? error) => new(
+ ServerName: options.Host,
+ Pending: false,
+ ErrorOccurred: errorOccurred,
+ Error: error,
+ DataStored: false,
+ Storing: false,
+ ConnectionKind: HistorianConnectionKind.Process);
+
+ try
+ {
+ using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options);
+ HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(connection, options, cancellationToken);
+
+ var statusClient = new GrpcStatus.StatusService.StatusServiceClient(connection.Channel);
+ GrpcStatus.GetHistorianConsoleStatusResponse response = statusClient.GetHistorianConsoleStatus(
+ new GrpcStatus.GetHistorianConsoleStatusRequest { StrHandle = session.StringHandle },
+ connection.Metadata,
+ DateTime.UtcNow.Add(options.RequestTimeout),
+ cancellationToken);
+
+ if (response.Status?.BSuccess ?? false)
+ {
+ // Measured: server reachable, storage console reporting normally → not-storing baseline.
+ return NotStoring(errorOccurred: false, error: null);
+ }
+
+ byte[] err = response.Status?.BtError?.ToByteArray() ?? [];
+ string detail = err.Length == 0 ? "GetHistorianConsoleStatus returned failure." : Convert.ToHexString(err);
+ return NotStoring(errorOccurred: true, error: $"GetHistorianConsoleStatus failed: {detail}");
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ // Server unreachable / auth failed — genuinely measured: report it instead of a silent all-false.
+ return NotStoring(errorOccurred: true, error: $"{ex.GetType().Name}: {ex.Message}");
+ }
+ }
+
///
/// 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/Protocol/Historian2020ProtocolDialect.cs b/src/AVEVA.Historian.Client/Protocol/Historian2020ProtocolDialect.cs
index 7442edf..82b4290 100644
--- a/src/AVEVA.Historian.Client/Protocol/Historian2020ProtocolDialect.cs
+++ b/src/AVEVA.Historian.Client/Protocol/Historian2020ProtocolDialect.cs
@@ -57,7 +57,14 @@ internal sealed class Historian2020ProtocolDialect
public Task GetStoreForwardStatusAsync(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
- return Wcf.HistorianWcfStatusClient.GetStoreForwardStatusAsync(_options, cancellationToken);
+
+ // Over gRPC (2023 R2) we return a MEASURED idle-state: the client actually contacts the server
+ // (GetHistorianConsoleStatus) and reports ErrorOccurred when unreachable. The active-SF buffer
+ // magnitude lives behind the D2 storage-engine console wall and stays false. Non-gRPC transports
+ // keep the synthesized all-false (no SF sidecar to probe). See R4.3 §9.7.
+ return UseGrpc
+ ? HistorianGrpcStatusClient.GetStoreForwardStatusAsync(_options, cancellationToken)
+ : Wcf.HistorianWcfStatusClient.GetStoreForwardStatusAsync(_options, cancellationToken);
}
public Task GetSystemParameterAsync(string name, CancellationToken cancellationToken)
diff --git a/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs b/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs
index 6512464..36bbdd6 100644
--- a/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs
+++ b/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs
@@ -83,6 +83,31 @@ public sealed class HistorianGrpcIntegrationTests
Assert.False(string.IsNullOrWhiteSpace(zone));
}
+ [Fact]
+ public async Task GetStoreForwardStatusAsync_OverGrpc_ReturnsMeasuredIdleState()
+ {
+ string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST");
+ if (string.IsNullOrWhiteSpace(host) || string.IsNullOrEmpty(Environment.GetEnvironmentVariable("HISTORIAN_USER")))
+ {
+ return;
+ }
+
+ // R4.3 measured idle-state: over gRPC, GetStoreForwardStatusAsync actually contacts the server
+ // (StatusService.GetHistorianConsoleStatus) rather than synthesizing. On an idle/normal server
+ // it reports the not-storing baseline WITHOUT ErrorOccurred. The active-SF buffer magnitude
+ // lives behind the D2 storage-engine console wall and is intentionally not surfaced (stays
+ // false). See docs/plans/store-forward-cache-reverse-engineering.md §9.7.
+ HistorianClient client = new(BuildOptions(host));
+ HistorianStoreForwardStatus status = await client.GetStoreForwardStatusAsync(CancellationToken.None);
+
+ Assert.Equal(host, status.ServerName);
+ Assert.False(status.ErrorOccurred, $"reachable server should not report an error: {status.Error}");
+ Assert.Null(status.Error);
+ Assert.False(status.Storing);
+ Assert.False(status.Pending);
+ Assert.False(status.DataStored);
+ }
+
[Fact]
public async Task GetTagMetadataAsync_OverGrpc_ReturnsRequestedTag()
{