diff --git a/CLAUDE.md b/CLAUDE.md
index 3d33852a..f93cad60 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -264,6 +264,14 @@ deploy. Non-numeric (`String`/`DateTime`/`Reference`) data types are skipped (no
recorder likewise drops + meters non-numeric values. Continuous historization is **numeric-analog only** in
v1 (`UInt16→UInt4` is a documented fallback).
+The provisioner is registered by the Host's `AddHistorianProvisioning` (`Runtime.ServiceCollectionExtensions`),
+built via `GatewayHistorian.CreateProvisioner`, and **gated on the same `ServerHistorian:Enabled` flag as the
+read path** — `WithOtOpcUaRuntimeActors` resolves `IHistorianProvisioning` and passes it into the applier.
+When the section is disabled the applier binds the no-op `NullHistorianProvisioning` and provisioning does not
+run. Every dispatch logs a tally (`dispatched/requested/ensured/skipped/failed`); `dispatched=N` with
+`requested=0` flags the dormant no-op. The API key must carry `historian:tags:write` for `EnsureTags`. (PR
+#423 shipped `GatewayTagProvisioner` but left this wiring out, so provisioning was dormant; restored 2026-06-27.)
+
### Gateway-side prerequisites
The target HistorianGateway OtOpcUa points at **must** run with:
diff --git a/docs/Historian.md b/docs/Historian.md
index cdb0c33c..e97e9ba3 100644
--- a/docs/Historian.md
+++ b/docs/Historian.md
@@ -82,7 +82,7 @@ and all HistoryRead calls on historized nodes return `GoodNoData` (empty, not an
| `UseTls` | bool | `true` | Connect over TLS; must match the `Endpoint` scheme. |
| `AllowUntrustedServerCertificate` | bool | `false` | Accept a self-signed / untrusted server certificate (dev / on-prem only). |
| `CaCertificatePath` | string\|null | `null` | PEM CA file pinning the gateway's TLS chain. Null/empty uses the OS trust store. |
-| `CallTimeout` | TimeSpan | `00:00:30` | Per-call deadline applied to each unary gateway read. |
+| `CallTimeout` | TimeSpan | `00:00:30` | Per-call deadline applied to each unary gateway read. A read for a tag the historian does not yet know about — e.g. a freshly-historized tag that has **not** been provisioned (see [Tag auto-provisioning](#tag-auto-provisioning-ensuretags)) — can run to this **full** deadline before the gateway errors, so an unprovisioned-tag HistoryRead can block for ~30 s. Lower this to fail faster, at the cost of truncating legitimately long reads. |
| `MaxTieClusterOverfetch` | int | `65536` | Maximum samples the server will fetch in one shot to page through a tie cluster (multiple samples sharing one `SourceTimestamp`). A cluster larger than this ceiling fails `BadHistoryOperationUnsupported`. Raise to handle abnormally large tie clusters; the default covers all normal-data cases. |
> **Do not commit `ApiKey` to `appsettings.json`.** Set it via the environment variable
@@ -104,6 +104,33 @@ source it from there.
---
+## Tag auto-provisioning (EnsureTags)
+
+When a deploy adds a historized tag, the server auto-ensures it exists in the historian via the gateway's
+`EnsureTags` **before** the first value or HistoryRead lands. `AddressSpaceApplier.Apply()` fires a
+**non-blocking, fire-and-forget** `IHistorianProvisioning.EnsureTagsAsync` for each added historized value
+tag (with the resolved historian name — the `historianTagname` override when set, else the driver
+`FullName`); the hook is wrapped so a faulted, slow, or throwing provisioner can **never** block or fail a
+deploy. Non-numeric (`String`/`DateTime`/`Reference`) types are skipped, not provisioned.
+
+**Wiring + gating.** The concrete provisioner is the gateway-backed `GatewayTagProvisioner`, registered by
+the Host's `AddHistorianProvisioning` (`GatewayHistorian.CreateProvisioner`) and gated on the **same**
+`ServerHistorian:Enabled` flag as the read path — both target the same single gateway. When the section is
+disabled (or absent) the applier resolves the no-op `NullHistorianProvisioning` and **provisioning does not
+run** (so on a read-only-historian deployment, ensure tags exist by some other means). The Host requires the
+API key to carry the `historian:tags:write` scope for `EnsureTags` to succeed.
+
+**Observability.** Every dispatch logs an Information tally —
+`historian provisioning completed (dispatched=N, requested=…, ensured=…, skipped=…, failed=…)`. A line
+showing `dispatched=N` but `requested=0` indicates the **no-op** provisioner ran (provisioning is not wired
+for this node), which is the signal to check that `ServerHistorian:Enabled=true`.
+
+> **Why this matters for HistoryRead latency.** Until a tag is provisioned, the historian does not know it,
+> so a HistoryRead on it runs to the full `CallTimeout` (~30 s) before erroring. Auto-provisioning on deploy
+> keeps freshly-historized tags from hitting that slow path. See the `CallTimeout` row above.
+
+---
+
## HistoryRead behavior
### Read variants
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianServiceCollectionExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianServiceCollectionExtensions.cs
index 4acd5b88..6033bde7 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianServiceCollectionExtensions.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianServiceCollectionExtensions.cs
@@ -78,4 +78,40 @@ public static class GatewayHistorian
HistorianGatewayClientAdapter.Create(options, loggerFactory),
logger);
}
+
+ ///
+ /// Builds a over a lazily connected
+ /// mapped from the bound
+ /// — the same single gateway the read path
+ /// () and alarm-write path () target. The
+ /// Host's AddHistorianProvisioning wiring supplies this as the concrete
+ /// the AddressSpaceApplier calls (non-blocking
+ /// EnsureTags) when a deploy adds historized tags, so a brand-new historized tag exists in the
+ /// historian before the recorder's WriteLiveValues land. Resolves an
+ /// and the provisioner's from , falling
+ /// back to the null implementations when absent. Performs no network I/O — the underlying channel dials
+ /// on first EnsureTags.
+ ///
+ ///
+ /// Like , this constructs its own
+ /// — a separate gRPC channel to the same gateway. A second
+ /// channel to a co-located sidecar is cheap (the gateway pools the historian sessions server-side), and
+ /// it keeps each path's channel lifetime clean and independent.
+ ///
+ /// The bound ServerHistorian configuration (endpoint, key, TLS posture).
+ /// The resolving service provider (used only to locate logging services).
+ /// The gateway-backed .
+ public static IHistorianProvisioning CreateProvisioner(ServerHistorianOptions options, IServiceProvider services)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+ ArgumentNullException.ThrowIfNull(services);
+
+ var loggerFactory = services.GetService() ?? NullLoggerFactory.Instance;
+ var logger = services.GetService>()
+ ?? NullLogger.Instance;
+
+ return new GatewayTagProvisioner(
+ HistorianGatewayClientAdapter.Create(options, loggerFactory),
+ logger);
+ }
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs
index a3e6bfee..eb083b0b 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs
@@ -72,6 +72,16 @@ if (roleSuffix is not null)
builder.Configuration.AddCommandLine(args);
}
+// Anchor the process working directory to the install directory (AppContext.BaseDirectory) so every
+// relative runtime path resolves under the install dir rather than the service's startup CWD. A Windows
+// service starts with CWD=C:\Windows\System32, which otherwise scattered the Serilog rolling-file sink
+// (appsettings "logs/otopcua-.log") into System32 and made on-box diagnosis awkward; the same anchor also
+// roots the script log, the historization outbox, and any other relative artifact path. Done AFTER
+// WebApplication.CreateBuilder (which has already resolved the content root + appsettings.json) and BEFORE
+// AddZbSerilog (which instantiates the file sink), so only path resolution moves — config loading is
+// untouched. UseWindowsService already roots the content root at the base dir, so this only affects sinks.
+Directory.SetCurrentDirectory(AppContext.BaseDirectory);
+
// Serilog — shared ZB.MOM.WW.Telemetry bootstrap. Sinks (Console + rolling daily file)
// now live in appsettings.json (ReadFrom.Configuration); AddZbSerilog layers in the
// shared NodeHostname / TraceContext / Redaction enrichers and trace correlation.
@@ -125,6 +135,17 @@ if (hasDriver)
builder.Configuration,
(opts, sp) => GatewayHistorian.CreateDataSource(opts, sp));
+ // Config-gated historian tag provisioning. When the ServerHistorian section is enabled this registers the
+ // gateway-backed GatewayTagProvisioner (overriding the NullHistorianProvisioning default) so that deploying
+ // a historized tag auto-ensures it in the historian via the gateway's EnsureTags. The AddressSpaceApplier
+ // (constructed in WithOtOpcUaRuntimeActors) resolves IHistorianProvisioning and fires a non-blocking
+ // EnsureTagsAsync for added historized tags on every deploy; without this registration the applier falls
+ // back to the no-op NullHistorianProvisioning and provisioning never reaches the gateway. Gated on the SAME
+ // ServerHistorian:Enabled flag as the read path — both target the same single gateway.
+ builder.Services.AddHistorianProvisioning(
+ builder.Configuration,
+ (opts, sp) => GatewayHistorian.CreateProvisioner(opts, sp));
+
// Continuous historization of driver (non-Galaxy) tag values. Gated on ContinuousHistorization:Enabled
// AND the ServerHistorian gateway being configured: the recorder drains driver-tag live values to the
// SAME single gateway's WriteLiveValues SQL path, sourcing endpoint/key/TLS from the ServerHistorian
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs
index b7d636bb..4c97c570 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs
@@ -271,13 +271,15 @@ public sealed class AddressSpaceApplier
return;
}
+ // Emit a tally on EVERY successful dispatch (not only on Failed/Skipped) so a fully-
+ // successful provisioning is observable AND a dormant no-op is detectable: the no-op
+ // NullHistorianProvisioning reports Requested=0 regardless of input, so a line showing
+ // dispatched={N} but requested=0 unmistakably flags "provisioning is not wired" — the
+ // exact invisibility that let the dormant-provisioner bug ship unnoticed.
var result = t.Result;
- if (result.Failed > 0 || result.Skipped > 0)
- {
- _logger.LogInformation(
- "AddressSpaceApplier: historian provisioning completed (requested={Requested}, ensured={Ensured}, skipped={Skipped}, failed={Failed})",
- result.Requested, result.Ensured, result.Skipped, result.Failed);
- }
+ _logger.LogInformation(
+ "AddressSpaceApplier: historian provisioning completed (dispatched={Dispatched}, requested={Requested}, ensured={Ensured}, skipped={Skipped}, failed={Failed})",
+ provisionCount, result.Requested, result.Ensured, result.Skipped, result.Failed);
},
CancellationToken.None,
TaskContinuationOptions.None,
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs
index fb60a6bd..9d0d6cf3 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs
@@ -47,6 +47,10 @@ public static class ServiceCollectionExtensions
{
services.TryAddSingleton(NullAlarmHistorianSink.Instance);
services.TryAddSingleton(NullHistorianDataSource.Instance);
+ // Historian tag provisioning. Null default (no-op) so the AddressSpaceApplier resolves a real
+ // IHistorianProvisioning only when AddHistorianProvisioning registers the gateway-backed one
+ // (ServerHistorian:Enabled). TryAddSingleton so the gateway registration wins last.
+ services.TryAddSingleton(NullHistorianProvisioning.Instance);
// VirtualTag historization sink. Null default — the durable AVEVA sink is infra-gated (there is
// no live-data historian write RPC). TryAddSingleton so a deployment that bound a real
// IHistoryWriter earlier wins.
@@ -135,6 +139,39 @@ public static class ServiceCollectionExtensions
return services;
}
+ ///
+ /// Config-gated historian tag provisioning. When the ServerHistorian section has
+ /// Enabled=true, registers the -supplied
+ /// (the gateway-backed GatewayTagProvisioner that calls
+ /// the gateway's EnsureTags) overriding the default from
+ /// . Otherwise a no-op (the Null default stays and deploying historized
+ /// tags provisions nothing). The provisioner is consumed by the AddressSpaceApplier, which fires
+ /// a non-blocking for added historized tags on
+ /// every deploy. Gated on the same ServerHistorian:Enabled flag as the read path
+ /// () — provisioning targets the same single gateway. The provisioner
+ /// is injected so the gateway-backed client can be supplied by the Host, which is the only project that
+ /// references the driver.
+ ///
+ /// The service collection to register with.
+ /// The configuration carrying the ServerHistorian section.
+ ///
+ /// Factory the Host supplies to build the concrete
+ /// (the gateway-backed provisioner) from the bound options + the resolving provider.
+ ///
+ /// The same instance for chaining.
+ public static IServiceCollection AddHistorianProvisioning(
+ this IServiceCollection services,
+ IConfiguration configuration,
+ Func provisioningFactory)
+ {
+ var opts = configuration.GetSection(ServerHistorianOptions.SectionName).Get();
+ if (opts is not { Enabled: true }) return services; // leave the Null default from AddOtOpcUaRuntime
+
+ // Last-registration-wins over the TryAddSingleton Null default seeded by AddOtOpcUaRuntime.
+ services.AddSingleton(sp => provisioningFactory(opts, sp));
+ return services;
+ }
+
///
/// Spawns the per-node driver-role actors on the host's :
/// (one per node),
@@ -252,12 +289,19 @@ public static class ServiceCollectionExtensions
? new ActorHistorizedTagSubscriptionSink(continuousRecorder)
: NullHistorizedTagSubscriptionSink.Instance;
+ // Historian tag provisioner fed to the applier so deploying historized tags auto-ensures
+ // them in the historian (EnsureTags). The Host registers the gateway-backed provisioner via
+ // AddHistorianProvisioning when ServerHistorian:Enabled; otherwise this resolves the no-op
+ // NullHistorianProvisioning default seeded by AddOtOpcUaRuntime (so the applier's hook is inert).
+ var provisioning = resolver.GetService();
+
// OPC UA publish actor — pinned dispatcher, owns the address-space side of the
// pipeline. AddressSpaceApplier is constructed here so the actor + applier share the
// same sink reference (when DeferredAddressSpaceSink swaps later, both see it).
var applier = new AddressSpaceApplier(
addressSpaceSink,
loggerFactory.CreateLogger(),
+ provisioning: provisioning,
historizedSubscriptions: historizedSubscriptions);
var publishActor = system.ActorOf(
OpcUaPublishActor.Props(
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierProvisioningTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierProvisioningTests.cs
index 02b60e5f..0e753aac 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierProvisioningTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierProvisioningTests.cs
@@ -1,3 +1,4 @@
+using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
@@ -143,6 +144,77 @@ public sealed class AddressSpaceApplierProvisioningTests
prov.Seen[0].TagName.ShouldBe("Pump1.Good");
}
+ /// Capturing that records every log line and signals a
+ /// the first time a "historian provisioning completed" tally is
+ /// emitted, so the fire-and-forget continuation's log can be awaited deterministically.
+ private sealed class CapturingLogger : ILogger
+ {
+ private readonly TaskCompletionSource _provisioningLogged =
+ new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ /// Completes with the rendered message of the first provisioning-completed tally logged.
+ public Task ProvisioningLogged => _provisioningLogged.Task;
+
+ public IDisposable BeginScope(TState state) where TState : notnull => NullScope.Instance;
+
+ public bool IsEnabled(LogLevel logLevel) => true;
+
+ public void Log(
+ LogLevel logLevel, EventId eventId, TState state, Exception? exception,
+ Func formatter)
+ {
+ var message = formatter(state, exception);
+ if (logLevel == LogLevel.Information &&
+ message.Contains("historian provisioning completed", StringComparison.Ordinal))
+ {
+ _provisioningLogged.TrySetResult(message);
+ }
+ }
+
+ private sealed class NullScope : IDisposable
+ {
+ public static readonly NullScope Instance = new();
+ public void Dispose() { }
+ }
+ }
+
+ /// Issue 2 (observability): a fully-successful provisioning (Ensured>0, Failed=0, Skipped=0) — the
+ /// case that previously logged NOTHING — now emits an Information tally, so a successful provisioning is
+ /// observable rather than silent.
+ [Fact]
+ public async Task Apply_logs_an_information_tally_on_a_fully_successful_provisioning()
+ {
+ var logger = new CapturingLogger();
+ var applier = new AddressSpaceApplier(
+ NullOpcUaAddressSpaceSink.Instance, logger, new CapturingProvisioner());
+
+ applier.Apply(PlanWithAddedTags(
+ HistorizedTag(displayName: "Temp", historianName: "Pump1.Temp", dataType: "Float32")));
+
+ var message = await logger.ProvisioningLogged.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
+ message.ShouldContain("dispatched=1");
+ message.ShouldContain("ensured=1");
+ message.ShouldContain("failed=0");
+ }
+
+ /// Issue 2 (observability): the no-op path is now
+ /// DETECTABLE — it reports Requested=0 regardless of input, so a tally showing dispatched=1 but requested=0
+ /// unmistakably flags "provisioning is not wired", the invisibility that let the dormant-provisioner bug
+ /// ship unnoticed.
+ [Fact]
+ public async Task Apply_logs_a_detectable_tally_for_the_noop_provisioning_path()
+ {
+ var logger = new CapturingLogger();
+ var applier = new AddressSpaceApplier(NullOpcUaAddressSpaceSink.Instance, logger);
+
+ applier.Apply(PlanWithAddedTags(
+ HistorizedTag(displayName: "Temp", historianName: "Pump1.Temp", dataType: "Float32")));
+
+ var message = await logger.ProvisioningLogged.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
+ message.ShouldContain("dispatched=1"); // we DID hand one request to EnsureTagsAsync
+ message.ShouldContain("requested=0"); // ...but the no-op acknowledges nothing ⇒ dormant
+ }
+
/// Capturing double. Records the add/remove
/// ref deltas the applier feeds it. A flag simulates a faulting feed.
private sealed class CapturingSubscriptionSink : IHistorizedTagSubscriptionSink
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/AddHistorianProvisioningTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/AddHistorianProvisioningTests.cs
new file mode 100644
index 00000000..32220a41
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/AddHistorianProvisioningTests.cs
@@ -0,0 +1,199 @@
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging.Abstractions;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.OpcUaServer;
+using ZB.MOM.WW.OtOpcUa.Runtime;
+using ZB.MOM.WW.OtOpcUa.Runtime.Historian;
+
+namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Historian;
+
+///
+/// Verifies the config-gated AddHistorianProvisioning registration AND that a registered
+/// provisioner is actually reached by the address-space applier on a historized-tag deploy. This is the
+/// wiring the HistorianGateway-integration review found missing: PR #423 shipped
+/// GatewayTagProvisioner + unit tests but never registered it in DI nor passed it into the applier,
+/// so EnsureTags was dormant (every deploy used the no-op ).
+///
+/// When the ServerHistorian section is absent or disabled the
+/// default seeded by AddOtOpcUaRuntime survives (the factory is never invoked); when it is enabled
+/// the factory's returned wins (last-registration-wins over the
+/// TryAddSingleton Null default) and — constructed into the applier the way
+/// WithOtOpcUaRuntimeActors does — its fires
+/// for an added historized tag.
+///
+///
+public sealed class AddHistorianProvisioningTests
+{
+ /// Capturing double — records the requests it was handed and
+ /// signals a when invoked so the fire-and-forget applier dispatch can be
+ /// awaited deterministically.
+ private sealed class CapturingProvisioner : IHistorianProvisioning
+ {
+ private readonly TaskCompletionSource _called = new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ /// The requests the applier handed to .
+ public List Seen { get; } = new();
+
+ /// Completes once has been invoked.
+ public Task Called => _called.Task;
+
+ ///
+ public Task EnsureTagsAsync(
+ IReadOnlyList requests, CancellationToken ct)
+ {
+ Seen.AddRange(requests);
+ _called.TrySetResult();
+ return Task.FromResult(new HistorianProvisionResult(requests.Count, requests.Count, 0, 0));
+ }
+ }
+
+ private static IConfiguration ConfigFrom(Dictionary values)
+ => new ConfigurationBuilder().AddInMemoryCollection(values).Build();
+
+ private static IConfiguration EnabledConfig() => ConfigFrom(new Dictionary
+ {
+ ["ServerHistorian:Enabled"] = "true",
+ ["ServerHistorian:Endpoint"] = "https://historian.example.com:5222",
+ ["ServerHistorian:ApiKey"] = "histgw_x_y",
+ });
+
+ [Fact]
+ public void Section_absent_keeps_null_provisioning_and_factory_not_invoked()
+ {
+ var factoryInvoked = false;
+ var services = new ServiceCollection();
+ services.AddOtOpcUaRuntime();
+
+ services.AddHistorianProvisioning(ConfigFrom(new Dictionary()), (_, _) =>
+ {
+ factoryInvoked = true;
+ return new CapturingProvisioner();
+ });
+
+ using var provider = services.BuildServiceProvider();
+ provider.GetRequiredService().ShouldBeSameAs(NullHistorianProvisioning.Instance);
+ factoryInvoked.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void Section_disabled_keeps_null_provisioning_and_factory_not_invoked()
+ {
+ var factoryInvoked = false;
+ var services = new ServiceCollection();
+ services.AddOtOpcUaRuntime();
+ var config = ConfigFrom(new Dictionary { ["ServerHistorian:Enabled"] = "false" });
+
+ services.AddHistorianProvisioning(config, (_, _) =>
+ {
+ factoryInvoked = true;
+ return new CapturingProvisioner();
+ });
+
+ using var provider = services.BuildServiceProvider();
+ provider.GetRequiredService().ShouldBeSameAs(NullHistorianProvisioning.Instance);
+ factoryInvoked.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void Section_enabled_registers_factory_provisioner_over_the_null_default()
+ {
+ var prov = new CapturingProvisioner();
+ var services = new ServiceCollection();
+ services.AddOtOpcUaRuntime();
+
+ services.AddHistorianProvisioning(EnabledConfig(), (_, _) => prov);
+
+ using var provider = services.BuildServiceProvider();
+ var resolved = provider.GetRequiredService();
+ resolved.ShouldBeSameAs(prov);
+ resolved.ShouldNotBeSameAs(NullHistorianProvisioning.Instance);
+ }
+
+ [Fact]
+ public void Section_enabled_passes_bound_options_to_factory()
+ {
+ ServerHistorianOptions? seen = null;
+ var services = new ServiceCollection();
+ services.AddOtOpcUaRuntime();
+ var config = ConfigFrom(new Dictionary
+ {
+ ["ServerHistorian:Enabled"] = "true",
+ ["ServerHistorian:Endpoint"] = "https://historian.example.com:5222",
+ ["ServerHistorian:ApiKey"] = "histgw_x_y",
+ ["ServerHistorian:UseTls"] = "true",
+ ["ServerHistorian:CaCertificatePath"] = "/etc/ssl/gateway-ca.pem",
+ });
+
+ services.AddHistorianProvisioning(config, (opts, _) =>
+ {
+ seen = opts;
+ return new CapturingProvisioner();
+ });
+
+ using var provider = services.BuildServiceProvider();
+ _ = provider.GetRequiredService();
+
+ seen.ShouldNotBeNull();
+ seen.Endpoint.ShouldBe("https://historian.example.com:5222");
+ seen.ApiKey.ShouldBe("histgw_x_y");
+ seen.UseTls.ShouldBeTrue();
+ seen.CaCertificatePath.ShouldBe("/etc/ssl/gateway-ca.pem");
+ }
+
+ ///
+ /// The end-to-end wiring proof the dormant-provisioner bug needed: register the provisioner via
+ /// AddHistorianProvisioning, resolve it, construct the EXACTLY
+ /// as WithOtOpcUaRuntimeActors does (provisioning: resolver.GetService<IHistorianProvisioning>()),
+ /// then apply a plan that adds a historized tag and assert the gateway-backed
+ /// actually fires (with the resolved historian name).
+ ///
+ [Fact]
+ public async Task Registered_provisioner_is_invoked_by_the_applier_on_a_historized_deploy()
+ {
+ var prov = new CapturingProvisioner();
+ var services = new ServiceCollection();
+ services.AddOtOpcUaRuntime();
+ services.AddHistorianProvisioning(EnabledConfig(), (_, _) => prov);
+ using var provider = services.BuildServiceProvider();
+
+ // Mirror the production construction in WithOtOpcUaRuntimeActors: resolve the provisioner and pass it in.
+ var resolved = provider.GetService();
+ var applier = new AddressSpaceApplier(
+ NullOpcUaAddressSpaceSink.Instance, NullLogger.Instance, provisioning: resolved);
+
+ applier.Apply(PlanWithAddedTags(
+ HistorizedTag(displayName: "Temp", historianName: "Pump1.Temp", dataType: "Float32"),
+ NonHistorizedTag(displayName: "Run", dataType: "Boolean")));
+
+ await prov.Called.WaitAsync(TimeSpan.FromSeconds(5));
+ prov.Seen.Count.ShouldBe(1);
+ prov.Seen[0].TagName.ShouldBe("Pump1.Temp");
+ prov.Seen[0].DataType.ShouldBe(DriverDataType.Float32);
+ }
+
+ private static EquipmentTagPlan HistorizedTag(string displayName, string? historianName, string dataType, string fullName = "ref")
+ => new("tag-" + displayName, "eq-1", "drv", FolderPath: "", Name: displayName, DataType: dataType, FullName: fullName,
+ Writable: false, Alarm: null, IsHistorized: true, HistorianTagname: historianName);
+
+ private static EquipmentTagPlan NonHistorizedTag(string displayName, string dataType)
+ => new("tag-" + displayName, "eq-1", "drv", FolderPath: "", Name: displayName, DataType: dataType, FullName: "ref",
+ Writable: false, Alarm: null, IsHistorized: false, HistorianTagname: null);
+
+ private static AddressSpacePlan PlanWithAddedTags(params EquipmentTagPlan[] tags) => new(
+ AddedEquipment: Array.Empty(),
+ RemovedEquipment: Array.Empty(),
+ ChangedEquipment: Array.Empty(),
+ AddedDrivers: Array.Empty(),
+ RemovedDrivers: Array.Empty(),
+ ChangedDrivers: Array.Empty(),
+ AddedAlarms: Array.Empty(),
+ RemovedAlarms: Array.Empty(),
+ ChangedAlarms: Array.Empty())
+ {
+ AddedEquipmentTags = tags,
+ };
+}