diff --git a/Directory.Packages.props b/Directory.Packages.props
index b8d364c58..19f5fa241 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -242,7 +242,7 @@
-
-
+
+
\ No newline at end of file
diff --git a/docs/Historian.md b/docs/Historian.md
index 4f8f36d11..6b2200e33 100644
--- a/docs/Historian.md
+++ b/docs/Historian.md
@@ -337,7 +337,7 @@ and is registered the same way for every backend.
> **Cross-repo follow-up — a multi-tag batch `WriteLiveValues` on the gateway contract.** The drain's
> unit of work is one gateway round-trip **per historian tag**, because
-> `ZB.MOM.WW.HistorianGateway.Client` **0.4.0** exposes no RPC that writes values for more than one tag
+> `ZB.MOM.WW.HistorianGateway.Client` **1.0.0** exposes no RPC that writes values for more than one tag
> in a call. Bounded parallelism (`DrainConcurrency`) is therefore the ceiling: it overlaps the
> round-trips but cannot remove them, so a wide tag set still pays `⌈tags / concurrency⌉` network
> hops per drain pass. Collapsing that to a single hop needs a **batch/multi-tag `WriteLiveValues`** on
@@ -345,8 +345,11 @@ and is registered the same way for every backend.
> lands, raising `DrainBatchSize` widens each hop's payload but not the hop count — which is why the
> concurrency window, not the batch width, is what governs drain throughput today.
-The HistorianGateway **Client and Contracts packages are pinned together at 0.4.0**
-([historiangw#119](https://gitea.dohertylan.com/dohertj2/historiangw/issues/119)). The removed
+The HistorianGateway **Client and Contracts packages are pinned together at 1.0.0**
+([historiangw#131](https://gitea.dohertylan.com/dohertj2/historiangw/issues/131)). The adapter maps
+`AllowUntrustedServerCertificate` directly and only for TLS: the default verifies the server, while an
+explicit opt-out applies only when `UseTls=true`. Stale certificate options are ignored on plaintext h2c.
+This follows the earlier [0.4.0 update](https://gitea.dohertylan.com/dohertj2/historiangw/issues/119). The removed
`ReadBlocks` RPC and `ReadBlocksAsync` wrapper were unused here. A partially imported CA PEM bundle now
fails client creation instead of accepting a narrower certificate pin; repair the configured bundle if
startup reports `ArgumentException` for `CaCertificatePath`.
diff --git a/docs/plans/evidence/2026-11-10-historiangw-131.json b/docs/plans/evidence/2026-11-10-historiangw-131.json
new file mode 100644
index 000000000..615c2e230
--- /dev/null
+++ b/docs/plans/evidence/2026-11-10-historiangw-131.json
@@ -0,0 +1,113 @@
+{
+ "issue": "https://gitea.dohertylan.com/dohertj2/historiangw/issues/131",
+ "consumerBase": "16111a5e",
+ "opcUaSdk": "1.5.378.182 (independent of SDK2 migration)",
+ "build": {
+ "command": "dotnet build ZB.MOM.WW.OtOpcUa.slnx -c Release -m:1",
+ "exit": 0,
+ "warnings": 0,
+ "errors": 0,
+ "seconds": 37.33,
+ "logSha256": "cb4f1ee96bf05254e5c707179c1e81d99d8267e28c5117a84afa0b0c853fa329"
+ },
+ "packages": [
+ {
+ "package": "ZB.MOM.WW.HistorianGateway.Client/1.0.0",
+ "type": "package",
+ "source": "https://gitea.dohertylan.com/api/packages/dohertj2/nuget/index.json",
+ "repositoryCommit": "ea9f21c850fb618767e12690924f9966f1e433ac",
+ "sha256": {
+ "lib/net10.0/ZB.MOM.WW.HistorianGateway.Client.dll": "b89a02e0d228ec549c4dfb755694f7f67b573a6077f06ff2781165e884fc5e12",
+ "zb.mom.ww.historiangateway.client.1.0.0.nupkg": "0a3f2836727671c07c0c393e705cc9737d2ed44258213a51fb4acf0d0ab7129e"
+ }
+ },
+ {
+ "package": "ZB.MOM.WW.HistorianGateway.Contracts/1.0.0",
+ "type": "package",
+ "source": "https://gitea.dohertylan.com/api/packages/dohertj2/nuget/index.json",
+ "repositoryCommit": "ea9f21c850fb618767e12690924f9966f1e433ac",
+ "sha256": {
+ "lib/net10.0/ZB.MOM.WW.HistorianGateway.Contracts.dll": "e60d3d4650be5d251982f7847404daf1b666f61d56e2b0ccb844af8677e97743",
+ "zb.mom.ww.historiangateway.contracts.1.0.0.nupkg": "83ffd12bcbbf919c973ba522560e10aa93a4cb8c686a3232030a2cfa5f00995e"
+ }
+ }
+ ],
+ "sourceOverrides": false,
+ "tests": [
+ {
+ "project": "gateway",
+ "testExit": 0,
+ "executionGuardExit": 0,
+ "counters": {
+ "total": "529",
+ "executed": "523",
+ "passed": "523",
+ "failed": "0",
+ "error": "0",
+ "timeout": "0",
+ "aborted": "0",
+ "inconclusive": "0",
+ "passedButRunAborted": "0",
+ "notRunnable": "0",
+ "notExecuted": "0",
+ "disconnected": "0",
+ "warning": "0",
+ "completed": "0",
+ "inProgress": "0",
+ "pending": "0"
+ },
+ "outcomes": {
+ "Passed": 523,
+ "Failed": 0,
+ "NotExecuted": 6
+ },
+ "requiredTlsWireTests": [
+ {
+ "name": "ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests.HistorianGatewayTlsPolicyWireTests.Explicit_tls_opt_out_reaches_the_same_self_signed_server_shape",
+ "outcome": "Passed"
+ },
+ {
+ "name": "ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests.HistorianGatewayTlsPolicyWireTests.Default_tls_rejects_an_untrusted_server_before_the_rpc",
+ "outcome": "Passed"
+ },
+ {
+ "name": "ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests.HistorianGatewayTlsPolicyWireTests.Plaintext_ignores_stale_tls_opt_out_and_ca_settings_and_reaches_the_rpc",
+ "outcome": "Passed"
+ }
+ ],
+ "trxSha256": "68086e66da0942214c0be805366b47c443f633d90f217c91e07ec685e8aea9d9"
+ },
+ {
+ "project": "runtime",
+ "testExit": 0,
+ "executionGuardExit": 0,
+ "counters": {
+ "total": "1005",
+ "executed": "1002",
+ "passed": "1002",
+ "failed": "0",
+ "error": "0",
+ "timeout": "0",
+ "aborted": "0",
+ "inconclusive": "0",
+ "passedButRunAborted": "0",
+ "notRunnable": "0",
+ "notExecuted": "0",
+ "disconnected": "0",
+ "warning": "0",
+ "completed": "0",
+ "inProgress": "0",
+ "pending": "0"
+ },
+ "outcomes": {
+ "Passed": 1002,
+ "Failed": 0,
+ "NotExecuted": 3
+ },
+ "requiredTlsWireTests": [],
+ "trxSha256": "c12308fd650be0646beba044a4b3301dc2aefc43097b2da667d0eb5da408fc65"
+ }
+ ],
+ "review": "Astra approved source/security mapping and corrected real-wire tests; actual published package verification complete.",
+ "deploymentPerformed": false
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/HistorianGatewayClientAdapter.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/HistorianGatewayClientAdapter.cs
index 2a716ff6b..ab4fb07b8 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/HistorianGatewayClientAdapter.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/HistorianGatewayClientAdapter.cs
@@ -57,23 +57,15 @@ public sealed class HistorianGatewayClientAdapter : IHistorianGatewayClient, IDi
$"ServerHistorian:Endpoint must be an absolute http(s) URI (e.g. https://host:5222); got '{options.Endpoint}'.");
}
- // TLS-only options must stay at their defaults on a plaintext h2c connection: the client rejects
- // each of them outright when UseTls=false (" is a TLS-only option and requires
- // UseTls=true"). Forwarding them unconditionally made h2c UNREACHABLE — AllowUntrustedServer-
- // Certificate defaults to false, so RequireCertificateValidation was always sent as true and
- // every http:// deployment crashed at startup, even though the scheme is documented as the
- // supported way to select h2c. There is no certificate to have a posture about here.
+ // Certificate settings apply only to TLS. The client rejects an explicit opt-out or a
+ // CA path on h2c, where there is no certificate to validate.
var clientOptions = new HistorianGatewayClientOptions
{
Endpoint = endpointUri,
ApiKey = options.ApiKey,
UseTls = options.UseTls,
CaCertificatePath = options.UseTls ? options.CaCertificatePath : null,
- // INVERTED mapping (TLS only): ServerHistorianOptions.AllowUntrustedServerCertificate
- // (opt-in to accept a self-signed cert) is the negation of the client's
- // RequireCertificateValidation. Allowing an untrusted cert == not requiring validation; a
- // pinned CaCertificatePath always verifies.
- RequireCertificateValidation = options.UseTls && !options.AllowUntrustedServerCertificate,
+ AllowUntrustedServerCertificate = options.UseTls && options.AllowUntrustedServerCertificate,
DefaultCallTimeout = options.CallTimeout,
LoggerFactory = loggerFactory,
};
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/HistorianGatewayClientAdapterTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/HistorianGatewayClientAdapterTests.cs
index f20e440b0..a75f0f6af 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/HistorianGatewayClientAdapterTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/HistorianGatewayClientAdapterTests.cs
@@ -27,23 +27,6 @@ public sealed class HistorianGatewayClientAdapterTests
/// A plaintext h2c gateway (an http:// endpoint with UseTls=false) must
/// construct, using nothing but documented defaults.
///
- ///
- ///
- /// Found by the LocalDb Phase 2 live gate, on the third consecutive crash-loop of the rig.
- /// docs/Historian.md and CLAUDE.md both document http:// as a supported
- /// transport ("scheme selects transport: https = TLS, http = h2c"), but it was in fact
- /// unreachable: this factory forwarded the TLS-only options unconditionally, and
- /// defaults to
- /// , so it always sent RequireCertificateValidation=true —
- /// which the client rejects outright when UseTls=false
- /// ("RequireCertificateValidation is a TLS-only option and requires UseTls=true").
- ///
- ///
- /// So every h2c deployment crashed at startup, and the only way to avoid it was to
- /// set AllowUntrustedServerCertificate=true — asserting a certificate posture for a
- /// connection that has no certificate at all.
- ///
- ///
[Fact]
public void Adapter_constructs_for_plaintext_h2c_endpoint()
{
@@ -71,6 +54,7 @@ public sealed class HistorianGatewayClientAdapterTests
ApiKey = "histgw_x_y",
UseTls = false,
CaCertificatePath = "/etc/ssl/certs/leftover-from-a-tls-config.pem",
+ AllowUntrustedServerCertificate = true,
};
using var adapter = HistorianGatewayClientAdapter.Create(opts, NullLoggerFactory.Instance);
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/HistorianGatewayTlsPolicyWireTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/HistorianGatewayTlsPolicyWireTests.cs
new file mode 100644
index 000000000..10c422f62
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/HistorianGatewayTlsPolicyWireTests.cs
@@ -0,0 +1,142 @@
+using System.Net;
+using System.Security.Authentication;
+using System.Security.Cryptography;
+using System.Security.Cryptography.X509Certificates;
+using Grpc.Core;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Server.Kestrel.Core;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.HistorianGateway.Client;
+using ZB.MOM.WW.HistorianGateway.Contracts.Grpc;
+using ZB.MOM.WW.OtOpcUa.Runtime.Historian;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests;
+
+/// historiangw#131: verify the mapped TLS posture through an actual local gRPC connection.
+public sealed class HistorianGatewayTlsPolicyWireTests(ITestOutputHelper output)
+{
+ private static CancellationToken Ct => TestContext.Current.CancellationToken;
+
+ [Fact]
+ public async Task Default_tls_rejects_an_untrusted_server_before_the_rpc()
+ {
+ await using var server = await Server.StartAsync(tls: true);
+ using var adapter = HistorianGatewayClientAdapter.Create(server.Options(), NullLoggerFactory.Instance);
+
+ var error = await Should.ThrowAsync(
+ () => adapter.TagExistsAsync("T", Ct).WaitAsync(TimeSpan.FromSeconds(15), Ct));
+ output.WriteLine($"TLS rejection: {error}");
+ ContainsTlsAuthenticationFailure(error).ShouldBeTrue("the failure must be TLS authentication, not an arbitrary connection failure");
+ server.MetadataCalls.ShouldBe(0);
+ }
+
+ [Fact]
+ public async Task Explicit_tls_opt_out_reaches_the_same_self_signed_server_shape()
+ {
+ await using var server = await Server.StartAsync(tls: true);
+ var options = server.Options(allowUntrusted: true);
+ using var adapter = HistorianGatewayClientAdapter.Create(options, NullLoggerFactory.Instance);
+
+ (await adapter.TagExistsAsync("T", Ct).WaitAsync(TimeSpan.FromSeconds(15), Ct)).ShouldBeTrue();
+ server.MetadataCalls.ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task Plaintext_ignores_stale_tls_opt_out_and_ca_settings_and_reaches_the_rpc()
+ {
+ await using var server = await Server.StartAsync(tls: false);
+ var options = server.Options(allowUntrusted: true, caPath: "/nonexistent/historiangw-131-stale-ca.pem");
+ using var adapter = HistorianGatewayClientAdapter.Create(options, NullLoggerFactory.Instance);
+
+ (await adapter.TagExistsAsync("T", Ct).WaitAsync(TimeSpan.FromSeconds(15), Ct)).ShouldBeTrue();
+ server.MetadataCalls.ShouldBe(1);
+ }
+
+ private static bool ContainsTlsAuthenticationFailure(Exception error)
+ {
+ var pending = new Stack();
+ var seen = new HashSet(ReferenceEqualityComparer.Instance);
+ pending.Push(error);
+ while (pending.TryPop(out var current))
+ {
+ if (!seen.Add(current)) continue;
+ if (current is AuthenticationException) return true;
+ if (current.InnerException is { } inner) pending.Push(inner);
+ if (current is RpcException { Status.DebugException: { } debug }) pending.Push(debug);
+ }
+ return false;
+ }
+
+ private sealed class TagsService : HistorianTags.HistorianTagsBase
+ {
+ public int Calls;
+ public override Task GetTagMetadata(GetTagMetadataRequest request, ServerCallContext context)
+ {
+ Interlocked.Increment(ref Calls);
+ return Task.FromResult(new GetTagMetadataReply
+ { Metadata = new HistorianTagMetadata { Name = request.Tag } });
+ }
+ }
+
+ private sealed class Server(WebApplication app, TagsService service, string endpoint,
+ RSA? key, X509Certificate2? certificate) : IAsyncDisposable
+ {
+ public int MetadataCalls => Volatile.Read(ref service.Calls);
+ public ServerHistorianOptions Options(bool allowUntrusted = false, string? caPath = null) => new()
+ {
+ Endpoint = endpoint, ApiKey = "histgw_131_test", UseTls = certificate is not null,
+ CallTimeout = TimeSpan.FromSeconds(2),
+ AllowUntrustedServerCertificate = allowUntrusted, CaCertificatePath = caPath,
+ };
+
+ public static async Task StartAsync(bool tls)
+ {
+ RSA? key = null;
+ X509Certificate2? certificate = null;
+ if (tls)
+ {
+ key = RSA.Create(2048);
+ var request = new CertificateRequest("CN=localhost", key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
+ request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, true));
+ request.CertificateExtensions.Add(new X509KeyUsageExtension(
+ X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, true));
+ var names = new SubjectAlternativeNameBuilder();
+ names.AddDnsName("localhost"); names.AddIpAddress(IPAddress.Loopback);
+ request.CertificateExtensions.Add(names.Build());
+ certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddDays(1));
+ }
+ var service = new TagsService();
+ var builder = WebApplication.CreateBuilder();
+ builder.Logging.ClearProviders();
+ builder.WebHost.ConfigureKestrel(options => options.Listen(IPAddress.Loopback, 0, listen =>
+ {
+ listen.Protocols = HttpProtocols.Http2;
+ if (certificate is not null) listen.UseHttps(certificate);
+ }));
+ builder.Services.AddGrpc(); builder.Services.AddSingleton(service);
+ var app = builder.Build();
+ app.MapGrpcService();
+ try
+ {
+ await app.StartAsync(Ct);
+ return new Server(app, service, app.Urls.Single(), key, certificate);
+ }
+ catch
+ {
+ await app.DisposeAsync(); certificate?.Dispose(); key?.Dispose();
+ throw;
+ }
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ try { await app.StopAsync(Ct); }
+ finally { await app.DisposeAsync(); certificate?.Dispose(); key?.Dispose(); }
+ }
+ }
+}