CLAUDE.md: add scadaproj sister-project index, fix mxaccessgw path to ~/Desktop/MxAccessGateway, note the temporary CVE-2025-6965 NuGetAuditSuppress, and correct the driver-typed tag-editor list (OpcUaClient is now mapped; only Galaxy falls back). Also captures docker-dev data-plane GroupToRole + ServerHistorian live-verify config and the HistorianGateway integration/backlog notes.
11 KiB
HistorianGateway backend — issues identified
Date: 2026-06-27
Context: Installed the ZB.MOM.WW.HistorianGateway sidecar on wonder-app-vd03 and updated OtOpcUa to
origin/master (245316d8, PR #423 "HistorianGateway as the OtOpcUa historian backend"), then smoke-tested
the OtOpcUa→gateway link live. These are the issues found during that work.
File/line references are against
origin/master@245316d8(the build that contains the gateway backend —src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/). They will not resolve on older local checkouts that predate PR #423.
Live evidence came from the gateway's Prometheus
/metricson the box: an OPC UAhistoryreadon a historized tag producedhistorian_gateway_grpc_requests_total{method="ReadRaw",service="HistorianRead"}=1andhistorian_session_pool_warm_sessions=1, while a historized-tag deploy produced zeroEnsureTagscalls.
Issue 1 — GatewayTagProvisioner is never wired; EnsureTags auto-provisioning is dormant (HIGH, confirmed)
Summary. PR #423 ships GatewayTagProvisioner (the IHistorianProvisioning implementation that calls the
gateway's EnsureTags) and unit tests for it, but it is never registered in DI and never passed into the
AddressSpaceApplier. At runtime the applier uses the no-op NullHistorianProvisioning, so deploying
historized tags does NOT provision them in the historian. The documented "EnsureTags auto-provisions
historian tags on deploy" behaviour does not occur.
Root cause (code).
AddressSpaceApplierconstructor coalesces a missing provisioner to the no-op:src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs:63_provisioning = provisioning ?? NullHistorianProvisioning.Instance;
- The only construction site omits the
provisioning:argument:src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs:258var applier = new AddressSpaceApplier( addressSpaceSink, loggerFactory.CreateLogger<AddressSpaceApplier>(), historizedSubscriptions: historizedSubscriptions); // <-- no provisioning:
- No
IHistorianProvisioning/GatewayTagProvisionerregistration exists anywhere in the repo (grepoverorigin/masterfinds only the interface definition,NullHistorianProvisioning, and theGatewayTagProvisionerconstructor definition itself — noAddSingleton<IHistorianProvisioning, …>and nonew GatewayTagProvisioner(…)). - Consequently
ProvisionHistorizedTags(called unconditionally atAddressSpaceApplier.cs:208, body at:226, dispatch at:261) builds the request list and invokes_provisioning.EnsureTagsAsync(...)on the no-op, which returns without contacting the gateway.
Live evidence. On wonder-app-vd03: flagged the parts-count equipment tag isHistorized:true, deployed,
and restarted the host. The gateway /metrics showed no gRPC request series for HistorianTags/EnsureTags
(zero gateway calls). By contrast, an explicit OPC UA historyread on the same tag did reach the gateway
(ReadRaw), confirming the read path is wired but the provisioning path is not.
Impact.
- Historized tags are never created in the historian on deploy.
- When continuous historization (
ContinuousHistorization:Enabled=true) is later turned on, the recorder drains live values to historian tags that were never provisioned — those writes will fail / land nowhere unless the tags are created by some other means. - Docs (
docs/Historian.md, repoCLAUDE.md, and the umbrella scadaprojCLAUDE.md) describe provisioning as active; that claim is currently false. See Issue 4.
Recommended fix (two parts).
- Register the provisioner in the Host alongside the existing gateway wiring — in
src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs, inside theif (serverHistorianOptions.Enabled)block that already callsAddServerHistorian/AddAlarmHistorian— e.g.(mirror thebuilder.Services.AddSingleton<IHistorianProvisioning>(sp => new GatewayTagProvisioner( HistorianGatewayClientAdapter.Create(serverHistorianOptions, sp.GetRequiredService<ILoggerFactory>()), sp.GetRequiredService<ILogger<GatewayTagProvisioner>>()));GatewayHistorianValueWriterregistration pattern already in that block). - Pass it into the applier at
src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs:258by resolving it the same way sibling deps are resolved there (resolver.GetService<…>()):var provisioning = resolver.GetService<IHistorianProvisioning>(); var applier = new AddressSpaceApplier( addressSpaceSink, loggerFactory.CreateLogger<AddressSpaceApplier>(), provisioning: provisioning, historizedSubscriptions: historizedSubscriptions); - Add a wiring/integration test asserting
EnsureTagsis invoked on a historized-tag deploy. The existingGatewayTagProvisionerunit tests pass but do not cover the DI wiring, which is exactly the gap that let this ship dormant.
Issue 2 — Provisioning success is logged nothing; the dormant path is invisible (LOW, observability)
Summary. The provisioning continuation in AddressSpaceApplier only emits a log line when
result.Failed > 0 || result.Skipped > 0. A fully-successful provisioning (Ensured>0, Failed=0, Skipped=0)
and the no-op NullHistorianProvisioning path both log nothing. This is what made Issue 1 invisible:
there was no log line either way, so "no provisioning log" could not be distinguished from "provisioning ran
and fully succeeded."
Location. src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs — the
EnsureTagsAsync(...).ContinueWith(...) block (~lines 261–283); the Information log is gated behind
if (result.Failed > 0 || result.Skipped > 0).
Recommended fix. Emit an Information tally on every dispatch (e.g. requested/ensured/skipped/failed),
or at minimum whenever Ensured > 0, so a successful provisioning is observable and a no-op is detectable.
Issue 3 — HistoryRead on a historized-but-unprovisioned tag blocks for the full CallTimeout (~30s) (MEDIUM, needs investigation)
Summary. An OPC UA historyread on a tag that is historized but has no corresponding historian
tag/data returned only after ~30 s — the gateway's ReadRaw recorded
historian_gateway_grpc_request_duration_seconds_sum ≈ 30.5 for the single call, matching the
ServerHistorian:CallTimeout default of 00:00:30. The read did not fail fast on an unknown tag; it ran to
the timeout and then errored (outcome="error").
Notes / attribution.
- The slow leg is the gateway→historian read of a non-existent tag; the OtOpcUa-facing symptom is a 30 s OPC UA
HistoryRead latency governed by
ServerHistorianOptions.CallTimeout(default 30 s,…Runtime/Historian/ServerHistorianOptions.cs). - This is coupled to Issue 1: because provisioning never runs, freshly-historized tags don't exist in the historian, so every HistoryRead on them hits this slow path until tags/data exist.
- Single data point (one unprovisioned tag); confirm whether the slow path is the historian, the gateway, or the client-side timeout before changing behaviour.
Recommended action. Primarily fix Issue 1 so historian tags exist. Separately, consider a shorter default HistoryRead timeout and/or fast-fail when the historian reports an unknown tag, so a missing tag doesn't hang an OPC UA HistoryRead for 30 s.
Issue 4 — Docs describe provisioning as active (LOW, documentation)
docs/Historian.md, the repo CLAUDE.md, and the umbrella scadaproj/CLAUDE.md state that the gateway backend
"auto-provisions historian tags on deploy (EnsureTags)". Given Issue 1, that is inaccurate in the shipped build.
Update the docs once Issue 1 is fixed, or flag the provisioning behaviour as not-yet-wired in the interim.
Issue 5 — Provisioning covers only newly-added tags; flagging an existing tag for historization never provisions it (MEDIUM, follow-up to the Issue 1 fix)
Status: found while live-verifying the Issue 1 fix (commit 257214f7) on wonder-app-vd03.
Summary. The Issue 1 fix correctly wires GatewayTagProvisioner and is confirmed deployed (the new
"...provisioning completed (dispatched…" log literal is present in the deployed OpcUaServer.dll). However,
ProvisionHistorizedTags still iterates only plan.AddedEquipmentTags:
src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs(ProvisionHistorizedTags):foreach (var tag in plan.AddedEquipmentTags)—ChangedEquipmentTagsis not considered.
So when an operator flags an existing tag isHistorized:true and deploys, that tag is classified
Changed (its config changed; the tag already existed) and is never provisioned. This is compounded by
the bootstrap "restore served state" behaviour: a host restart re-applies the served address space
incrementally, so an existing tag comes back as unchanged/changed, never Added — i.e. a restart does
not provision it either.
Live evidence. Flagging parts-count (an existing tag) historized + deploy + restart produced zero
EnsureTags calls at the gateway (/metrics had no HistorianTags/EnsureTags series). The read path on the
same tag did reach the gateway (HistorianRead/ReadRaw), confirming the tag was historized and the gateway
link works — only provisioning was skipped. (A raw dbo.Tag INSERT of a brand-new historized tag did not
materialise either, because a raw insert bypasses the normal compose/validate path — so the clean way to
exercise the added path is to author a new historized tag via the AdminUI.)
Impact. The most common operator action — take an existing tag and turn on historization — does not auto-provision the historian tag. Continuous historization for such a tag would then write to a tag that does not exist. Only genuinely new historized tags get provisioned.
Recommended fix. Provision the union of added and newly-historized changed tags: also iterate
plan.ChangedEquipmentTags (or compute "tags whose IsHistorized transitioned false→true") and include them
in the EnsureTags request. EnsureTags is idempotent, so re-ensuring an already-present tag is safe. Add a
test that flips an existing tag to historized and asserts EnsureTags is invoked.
Pre-existing observation (not introduced by this work)
- Serilog log path is service-CWD-relative. On wonder-app-vd03 the host writes to
C:\Windows\System32\logs\otopcua-<date>.log(because the service CWD isSystem32), not under the install directory. This was noted in prior sessions; re-observed here. Not a gateway-integration issue, but it makes on-box diagnosis awkward and is worth fixing (anchor the Serilog file sink to an absolute/content-root path).
What is working (for context)
The OtOpcUa↔gateway read and credential path is confirmed working end-to-end on wonder-app-vd03: an OPC UA
HistoryRead routed through GatewayHistorianDataSource to the gateway over h2c, authenticated with a
peppered-HMAC API key (historian:read scope), and leased a real historian session (the gateway reached
wonder-sql-vd03). Only the provisioning half (Issue 1) is unwired.