From b4f8cb8de7be7c47119577b10f99efdc29f59653 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 9 Aug 2026 21:56:29 -0400 Subject: [PATCH] refactor(opcuaclient): move NamespaceMap to a .Session leaf so Contracts is dependency-light MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit G-7 wired OpcUaClientTagDefinitionFactory.Inspect into the deploy-time TagConfig gate, which gave ControlPlane a ProjectReference to Driver.OpcUaClient.Contracts — and with it, transitively, the whole OPC UA client stack (5 OPCFoundation packages) into every host that loads the control plane. NamespaceMap was the only reason Contracts carried OPCFoundation.NetStandard.Opc.Ua.Client: its entire public surface is ISession/NamespaceTable/NodeId-typed. It moves to a new leaf project, ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session, referenced by the runtime .Driver and by .Browser. Contracts is left with Core.Abstractions and NOTHING else — the same shape as Calculation.Contracts. The namespace stays ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient, so no consumer's `using` changed. The .Session -> .Contracts direction is deliberate: the blob read NamespaceMap needs is OpcUaClientTagDefinitionFactory.TryReadNodeId, the one authority it shares with the deploy gate, and that must stay in the dependency-light half. Why a new project rather than folding NamespaceMap into the runtime .Driver and having .Browser reference it (the Driver.Mqtt.Browser #512-style deliberate exception): the AdminUI project-references .Browser and NOT .Driver, so that shape would pull the OpcUaClient driver runtime — plus Core, Opc.Ua.Configuration and Secrets.Abstractions — into the AdminUI's build/deploy graph. That is exactly the violation #512 spent two extractions removing from Mqtt.Browser; re-introducing it to save one csproj is a bad trade. Guard: ContractsIsDependencyLightTests (Driver.OpcUaClient.Tests) asserts the Contracts manifest names no Opc.Ua* assembly, plus a counterpart asserting .Session does. An ABSENCE assertion is sound on the manifest where a presence one is not — the compiler elides an unused reference, but it MUST emit one the moment Opc.Ua-typed code is added. Falsified by temporarily re-adding the package plus an Opc.Ua.NodeId-typed member: red on Opc.Ua.Types, green again on removal. Evidence — `dotnet list ControlPlane package --include-transitive | grep -i opc`: before: OPCFoundation.NetStandard.Opc.Ua.{Client,Configuration,Core, Security.Certificates,Types} 1.5.378.106 after: none (and 0 Opc.Ua*.dll in ControlPlane's output directory) --- ZB.MOM.WW.OtOpcUa.slnx | 1 + ....OtOpcUa.Driver.OpcUaClient.Browser.csproj | 4 + .../OpcUaClientDriverOptions.cs | 3 +- .../OpcUaClientTagDefinitionFactory.cs | 12 ++- ...tOpcUa.Driver.OpcUaClient.Contracts.csproj | 11 +-- .../NamespaceMap.cs | 11 +++ ....OtOpcUa.Driver.OpcUaClient.Session.csproj | 46 ++++++++++++ ...B.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj | 3 + .../ZB.MOM.WW.OtOpcUa.ControlPlane.csproj | 6 +- ...Ua.Driver.OpcUaClient.Browser.Tests.csproj | 4 +- .../ContractsIsDependencyLightTests.cs | 74 +++++++++++++++++++ 11 files changed, 161 insertions(+), 14 deletions(-) rename src/Drivers/{ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts => ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session}/NamespaceMap.cs (94%) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session.csproj create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/ContractsIsDependencyLightTests.cs diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx index f9170dfc..85b032b2 100644 --- a/ZB.MOM.WW.OtOpcUa.slnx +++ b/ZB.MOM.WW.OtOpcUa.slnx @@ -48,6 +48,7 @@ + diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.csproj index 83eccb18..88d6e667 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.csproj +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.csproj @@ -8,6 +8,10 @@ + + diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/OpcUaClientDriverOptions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/OpcUaClientDriverOptions.cs index d1bc2f08..47c27525 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/OpcUaClientDriverOptions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/OpcUaClientDriverOptions.cs @@ -31,7 +31,8 @@ public sealed record OpcUaClientDriverOptions /// with the cluster's raw OPC UA Client tags. Each carries the tag's /// RawPath identity plus a driver TagConfig blob whose nodeId key holds the /// upstream OPC UA node id string the driver dials. The driver builds a RawPath → nodeId - /// resolution table (via ) from this list at connect time; a + /// resolution table (via NamespaceMap, in the sibling .Session project) from this + /// list at connect time; a /// read/write/subscribe/history reference is always a RawPath resolved through that table. /// public IReadOnlyList RawTags { get; init; } = []; diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/OpcUaClientTagDefinitionFactory.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/OpcUaClientTagDefinitionFactory.cs index 6f35ab84..7a63848a 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/OpcUaClientTagDefinitionFactory.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/OpcUaClientTagDefinitionFactory.cs @@ -4,13 +4,19 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; /// /// The authored-raw-tag reader for the OPC UA Client (gateway) driver: one authority, consumed by both -/// the runtime ('s RawPath → upstream node id table build) and the +/// the runtime (NamespaceMap's RawPath → upstream node id table build) and the /// deploy-time TagConfig gate (, deferment-register G-7). /// -/// This read used to be private to . It moved here — unchanged — +/// This read used to be private to NamespaceMap. It moved here — unchanged — /// rather than being mirrored: a mirror of an accept/reject boundary drifts the moment either side /// is touched, and the drift is silent, with the gate describing a shape the driver no longer -/// reads. now calls . +/// reads. NamespaceMap now calls . +/// +/// +/// NamespaceMap is named by text, not cref, on purpose. It lives in the +/// sibling Driver.OpcUaClient.Session project, which references THIS one — the dependency +/// runs that way round so this assembly stays dependency-light for ControlPlane, which +/// project-references it for the G-7 gate. A cref here would need the reverse reference. /// /// /// is never stricter than — it asks the diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts.csproj index aa67cc0d..d547a460 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts.csproj +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts.csproj @@ -6,11 +6,12 @@ true - - - - + diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/NamespaceMap.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/NamespaceMap.cs similarity index 94% rename from src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/NamespaceMap.cs rename to src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/NamespaceMap.cs index d7371d86..a71dc1c0 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/NamespaceMap.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/NamespaceMap.cs @@ -52,6 +52,17 @@ public readonly record struct RawTagBinding(string NodeId, bool WriteIdempotent) /// driver then re-binds against the live session via the static /// . /// +/// +/// Why this type sits in .Session and not .Contracts. Its whole public +/// surface is OPC UA SDK-typed (, , +/// ), so while it lived in .Contracts that project had to carry +/// OPCFoundation.NetStandard.Opc.Ua.Client — and once ControlPlane project-referenced +/// .Contracts for the G-7 deploy-time TagConfig gate, the whole OPC UA client stack +/// came with it into every host that loads the control plane. The blob read this class needs +/// stayed behind in .Contracts as +/// OpcUaClientTagDefinitionFactory.TryReadNodeId (the ONE authority the deploy gate and +/// this table share), which is why the reference runs .Session → .Contracts. +/// /// public sealed class NamespaceMap { diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session.csproj new file mode 100644 index 00000000..3ecc9663 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session.csproj @@ -0,0 +1,46 @@ + + + net10.0 + enable + enable + true + + ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient + ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session + + + + + + + + + + + diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj index 0226d2fe..7c2369d4 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj @@ -14,6 +14,9 @@ + + diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj index e4f43208..36fedc39 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj +++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj @@ -54,10 +54,8 @@ TagConfigValidator checked them. Adding the reference is what makes EquipmentTagConfigInspectorCoverageTests demand a dispatch-map entry for them — the guard scans the output directory, so an unreferenced Contracts assembly is invisible to it. - Note OpcUaClient.Contracts brings OPCFoundation.NetStandard.Opc.Ua.Client transitively: its - NamespaceMap re-binds a stable nsu= reference against a live session. Every host that loads - ControlPlane (Host, AdminUI) already carries the OPC UA stack, so this adds no new deployment - surface. --> + OpcUaClient.Contracts is dependency-light (Core.Abstractions only) — the OPC UA client SDK it + used to pull in transitively left with NamespaceMap, to Driver.OpcUaClient.Session. --> diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests.csproj b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests.csproj index 84c25286..d2bb2cfd 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests.csproj +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests.csproj @@ -22,7 +22,9 @@ - + + diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/ContractsIsDependencyLightTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/ContractsIsDependencyLightTests.cs new file mode 100644 index 00000000..9cf5de8a --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/ContractsIsDependencyLightTests.cs @@ -0,0 +1,74 @@ +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests; + +/// +/// Pins the dependency shape of ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts: it must not +/// reference the OPC UA client SDK. +/// +/// Why this matters beyond tidiness. Deferment-register G-7 wired +/// into the deploy-time TagConfig gate, which +/// made ControlPlane project-reference this Contracts assembly. ControlPlane is loaded by +/// every admin- and driver-role host, so whatever Contracts drags in, they all carry. Contracts +/// used to hold NamespaceMap — ISession/NodeId-typed throughout — and with it a +/// OPCFoundation.NetStandard.Opc.Ua.Client PackageReference; NamespaceMap moved to the +/// sibling .Session leaf so the parse-only half could stay light (the same shape as +/// Calculation.Contracts: Core.Abstractions and nothing else). +/// +/// +/// Direction matters: this is an ABSENCE assertion, and that is what makes it sound here. +/// The assembly manifest is an unreliable witness for PRESENCE — the C# compiler elides a reference +/// nothing in the IL actually uses, so "assembly X is referenced" can be false while the csproj says +/// otherwise (the trap EquipmentTagConfigInspectorCoverageTests documents, which is why that +/// one scans the output directory instead). Absence has no such hole in the direction that matters: +/// the moment someone adds Opc.Ua-typed code to Contracts, the compiler MUST emit the reference and +/// this test goes red. A csproj PackageReference that nothing consumes is elided and slips past — +/// accepted deliberately, because an unused package reference is not the failure being guarded. +/// +/// +/// Falsified 2026-08-09 by temporarily adding an Opc.Ua.NodeId-typed member to Contracts: +/// the assertion failed naming Opc.Ua.Core, and passed again once removed. +/// +/// +public sealed class ContractsIsDependencyLightTests +{ + /// The Contracts assembly's manifest must name no OPC UA SDK assembly. + [Fact] + public void The_Contracts_assembly_does_not_reference_the_OPC_UA_SDK() + { + var contracts = typeof(OpcUaClientTagDefinitionFactory).Assembly; + + // Positive control: name the assembly under test, so a rename/retarget cannot silently point this + // at something else that trivially satisfies the assertion below. + contracts.GetName().Name.ShouldBe("ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts"); + + var opcUa = contracts.GetReferencedAssemblies() + .Select(a => a.Name ?? string.Empty) + .Where(n => n.StartsWith("Opc.Ua", StringComparison.OrdinalIgnoreCase)) + .OrderBy(n => n, StringComparer.Ordinal) + .ToArray(); + + opcUa.ShouldBeEmpty( + "OpcUaClient.Contracts must stay free of the OPC UA client SDK — ControlPlane project-references " + + "it for the G-7 deploy-time TagConfig gate, so every host that loads the control plane would " + + "carry the stack. Put SDK-typed code in Driver.OpcUaClient.Session instead. Found: " + + string.Join(", ", opcUa)); + } + + /// + /// The counterpart the assertion above is meaningful against: the SDK-typed half really did land + /// somewhere, and that somewhere does reference the SDK. Without this, "no Opc.Ua reference + /// anywhere" would read as a pass. + /// + [Fact] + public void The_Session_assembly_is_where_the_OPC_UA_SDK_reference_lives() + { + var session = typeof(NamespaceMap).Assembly; + + session.GetName().Name.ShouldBe("ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session"); + session.GetReferencedAssemblies() + .Select(a => a.Name ?? string.Empty) + .ShouldContain(n => n.StartsWith("Opc.Ua", StringComparison.OrdinalIgnoreCase)); + } +}