diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs
index 763fd5c3..33ec3a59 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs
@@ -209,9 +209,12 @@ public static class DeploymentArtifact
/// Cluster-scoped overload: the address-space composition a node should materialise given
/// its NodeId. Filters every projection to the node's own ClusterId (see ).
+ /// Equipment attribution is dual-path: driver-bound equipment (non-null DriverInstanceId) is kept when
+ /// its driver is in-cluster; driver-less equipment (null DriverInstanceId) is kept when its UNS line's
+ /// area is in-cluster.
/// When is supplied it is invoked with a human-readable message for each
- /// kept equipment whose owning UNS line is NOT in the node's cluster — a cross-cluster binding that
- /// violates the same-cluster invariant (decision #122) and would orphan the equipment folder. This is
+ /// kept driver-bound equipment whose owning UNS line is NOT in the node's cluster — a cross-cluster binding
+ /// that violates the same-cluster invariant (decision #122) and would orphan the equipment folder. This is
/// detection only (observability); the equipment is still returned, since the upstream draft validator
/// is the authority that should prevent the binding in the first place.
/// The deployment artifact blob.
@@ -234,6 +237,9 @@ public static class DeploymentArtifact
if (onInconsistency is not null)
{
var keptLineIds = keptLines.Select(l => l.UnsLineId).ToHashSet(StringComparer.OrdinalIgnoreCase);
+ // This cross-cluster check only fires for DRIVER-BOUND equipment. Driver-less equipment
+ // is attributed by its UNS line's area cluster, so by construction its line is always in
+ // keptLines — the condition `!keptLineIds.Contains(e.UnsLineId)` is never true for it.
foreach (var e in keptEquipment)
{
if (!string.IsNullOrEmpty(e.UnsLineId) && !keptLineIds.Contains(e.UnsLineId))
@@ -265,7 +271,9 @@ public static class DeploymentArtifact
private sealed record ClusterSets(HashSet DriverIds, HashSet AreaIds, HashSet EquipmentIds);
/// Build the in-cluster id sets used to filter a composition: DriverInstanceIds + UnsAreaIds
- /// that directly carry the ClusterId, plus EquipmentIds whose DriverInstanceId is in-cluster.
+ /// that directly carry the ClusterId, plus in-cluster EquipmentIds — driver-bound equipment attributed
+ /// by its driver's cluster, and driver-less equipment (null DriverInstanceId) attributed by its UNS
+ /// line's area cluster.
/// The deployment artifact blob.
/// The node's ClusterId to scope to.
/// The resolved in-cluster id sets (empty on parse failure => empty composition).
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactTests.cs
index 17f93746..e20fd048 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactTests.cs
@@ -579,6 +579,44 @@ public sealed class DeploymentArtifactTests
comp.EquipmentVirtualTags.ShouldBeEmpty();
}
+ /// Verifies driver-less equipment with no UnsLineId (null/absent) is EXCLUDED from the scoped
+ /// composition and throws nothing: without a line there is no area chain to resolve a cluster from.
+ [Fact]
+ public void Driverless_equipment_excluded_when_it_has_no_line()
+ {
+ var blob = BlobOf(new
+ {
+ Clusters = new[] { new { ClusterId = "C1" }, new { ClusterId = "C2" } },
+ Nodes = new[]
+ {
+ new { NodeId = "c1-1:4053", ClusterId = "C1" },
+ new { NodeId = "c2-1:4053", ClusterId = "C2" },
+ },
+ DriverInstances = new[]
+ {
+ // One driver per cluster so the artifact is genuinely multi-cluster (scoped, not None).
+ new { DriverInstanceId = "c1-driver", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "C1", NamespaceId = "c1-ns" },
+ new { DriverInstanceId = "c2-driver", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "C2", NamespaceId = "c2-ns" },
+ },
+ UnsAreas = new[] { new { UnsAreaId = "A1", Name = "Area1", ClusterId = "C1" } },
+ // Equipment has no DriverInstanceId (driver-less) and no UnsLineId — cannot resolve a cluster.
+ Equipment = new[]
+ {
+ new { EquipmentId = "E-noline", Name = "NoLine" },
+ },
+ Scripts = new[] { new { ScriptId = "scr", SourceCode = "return 1;" } },
+ VirtualTags = new[]
+ {
+ new { VirtualTagId = "vt-noline", EquipmentId = "E-noline", Name = "VNL", DataType = "Float", ScriptId = "scr" },
+ },
+ });
+
+ var comp = DeploymentArtifact.ParseComposition(blob, "c1-1:4053");
+
+ comp.EquipmentNodes.ShouldBeEmpty();
+ comp.EquipmentVirtualTags.ShouldBeEmpty();
+ }
+
/// Verifies the unchanged driver-bound path: equipment with an in-cluster DriverInstanceId is
/// kept even when (as here) no UNS line/area rows describe it — attribution is still by driver.
[Fact]