diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs
index 72fafc22..a4d8eb5b 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs
@@ -72,7 +72,17 @@ public sealed record UnsLineProjection(string UnsLineId, string UnsAreaId, strin
/// multi-device driver by host. The value is normalized identically on both the live-edit composer and
/// the artifact-decode sides (single source of truth: );
/// the later partition task MUST normalize the driver-discovered device-host folder segment the same way
-/// (trim + lower-case) so the two compare equal.
+/// (trim + lower-case) so the two compare equal.
+/// Address-space-rebuild interaction (accepted trade-off). These three fields participate in
+/// 's record value-equality, which AddressSpacePlan.Compute uses to
+/// build its changed-equipment set. So editing a Device's DeviceConfig host/port, or
+/// rebinding an equipment's DriverInstanceId / DeviceId, now yields an
+/// delta that triggers a full structural address-space rebuild on the next
+/// deploy (a momentary subscription teardown for that equipment). This is a deliberate, accepted
+/// decision: it fires only on rare operator-initiated config edits at deploy time (routine redeploys of
+/// unchanged config are unaffected — the delta is empty), it is recoverable, and it is directionally
+/// correct for the multi-device FixedTree re-partition (a later task). AddressSpacePlan is left
+/// unchanged.
public sealed record EquipmentNode(
string EquipmentId,
string DisplayName,
@@ -343,10 +353,16 @@ public static class AddressSpaceComposer
// DeviceId → connection host, resolved once from each bound Device's schemaless DeviceConfig JSON
// via the shared TryExtractDeviceHost (single source of truth + normalization for both this
// composer and the artifact-decode mirror in DeploymentArtifact, so EquipmentNode.DeviceHost is
- // byte-parity-equal). Ordinal comparer matches the decode-side map exactly.
- var deviceHostById = (devices ?? Array.Empty())
- .Where(d => d.DeviceId != null)
- .ToDictionary(d => d.DeviceId, d => TryExtractDeviceHost(d.DeviceConfig), StringComparer.Ordinal);
+ // byte-parity-equal). This MUST match DeploymentArtifact.BuildDeviceHostMap semantics EXACTLY:
+ // Ordinal comparer, skip blank/whitespace DeviceIds, and LAST-WINS on a duplicate DeviceId (a
+ // foreach assignment, NOT ToDictionary which would THROW on a dupe — diverging from the decode
+ // side's last-wins). DeviceId is DB-unique so a dupe is defensive-only.
+ var deviceHostById = new Dictionary(StringComparer.Ordinal);
+ foreach (var d in devices ?? Array.Empty())
+ {
+ if (string.IsNullOrWhiteSpace(d.DeviceId)) continue;
+ deviceHostById[d.DeviceId] = TryExtractDeviceHost(d.DeviceConfig);
+ }
var areas = unsAreas
.OrderBy(a => a.UnsAreaId, StringComparer.Ordinal)
.Select(a => new UnsAreaProjection(a.UnsAreaId, a.Name))