diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs
index 5415a1de..0d4a573e 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs
@@ -1245,11 +1245,14 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
_lastComposition = composition;
// Re-inject discovered (FixedTree) nodes after the authored rebuild. PushDesiredSubscriptions cleared
- // _nodeIdByDriverRef and re-pushed authored-only subscriptions above; without this, every redeploy /
- // bootstrap-restore would drop the injected FixedTree routes + materialised nodes until the driver
- // happens to reconnect and re-discover. Re-resolve each cached driver's equipment from the CURRENT
- // composition; drop the cache entry if the driver/equipment no longer resolves to exactly one (a rebind
- // or removal — the driver's next reconnect re-discovery will rebuild it cleanly).
+ // _nodeIdByDriverRef and re-pushed authored-only subscriptions above; without this, an IN-PROCESS
+ // redeploy / re-apply (one that runs while the host is alive, so _discoveredByDriver is populated)
+ // would drop the injected FixedTree routes + materialised nodes until the driver happens to reconnect
+ // and re-discover. This loop is INERT on the bootstrap-restore path (RestoreApplied): there the actor
+ // is freshly constructed so _discoveredByDriver is empty — restart survival comes from Task 6's
+ // post-connect re-discovery, NOT this re-apply. Re-resolve each cached driver's equipment from the
+ // CURRENT composition; drop the cache entry if the driver/equipment no longer resolves to exactly one
+ // (a rebind or removal — the driver's next reconnect re-discovery will rebuild it cleanly).
foreach (var driverId in _discoveredByDriver.Keys.ToList()) // snapshot — we mutate the dict below
{
var plan = _discoveredByDriver[driverId];
@@ -1267,6 +1270,14 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
var equipmentId = equipmentIds[0];
// If the equipment was rebound (the cached plan's NodeIds are scoped to the OLD equipment), drop +
// let re-discovery rebuild against the new equipment. The plan's NodeIds are "{equipmentId}/...".
+ // KNOWN LIMITATION (follow-up, alongside the multi-device-per-driver limitation): a
+ // CONFIG-UNCHANGED rebind (the driver's DriverConfig is identical, only its authored tag's
+ // EquipmentId moved) drops the cached plan here but does NOT itself re-trigger discovery —
+ // ReconcileDrivers only restarts a child on a DriverConfig change, so a config-unchanged child is
+ // never stopped/reconnected. The FixedTree subtree therefore stays ABSENT under the new equipment
+ // until the driver's next reconnect/restart re-discovers it. We deliberately do NOT add re-trigger
+ // logic here (it would couple the subscription pass to driver-lifecycle control); the drop is the
+ // safe, correct fail-state (a stale EQ-1-scoped graft under EQ-2 would be worse).
var planEquipmentConsistent = plan.Variables.Count > 0
&& plan.Variables[0].NodeId.StartsWith(equipmentId + "/", StringComparison.Ordinal);
if (!planEquipmentConsistent)
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDiscoveryTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDiscoveryTests.cs
index f08d3579..54706648 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDiscoveryTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDiscoveryTests.cs
@@ -341,6 +341,50 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
}
+ /// Task 8 rebind-guard: a redeploy that REBINDS the driver to a DIFFERENT equipment must DROP the
+ /// cached discovered plan rather than re-graft EQ-1-scoped nodes under EQ-2. d1 still resolves to exactly
+ /// one equipment (so the Count==0 drop does NOT fire), but the cached plan's NodeIds are scoped to the OLD
+ /// equipment (EQ-1), so the StartsWith(equipmentId + "/") guard sees they no longer match EQ-2 and
+ /// drops the entry. After the redeploy NO is
+ /// re-told. (Complements , which
+ /// covers the Count==0 branch; this covers the rebind/StartsWith branch.)
+ [Fact]
+ public void Discovered_nodes_dropped_when_driver_rebound_to_a_different_equipment()
+ {
+ var db = NewInMemoryDbFactory();
+ var factory = new SubscribingDriverFactory("Modbus");
+ var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
+ (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"));
+
+ var (actor, publish, coordinator) = SpawnHostAndApply(db, deploymentId, factory);
+
+ actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[]
+ {
+ new DiscoveredNode(
+ FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" },
+ BrowseName: "Model", DisplayName: "Model", FullReference: "ft-ref-1",
+ DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null,
+ Writable: false, IsHistorized: false),
+ }));
+
+ // First injection materialises under EQ-1 (the cached plan's NodeIds are scoped to EQ-1).
+ publish.ExpectMsg(Timeout);
+
+ // Apply a SECOND deployment where d1 is REBOUND to a DIFFERENT equipment EQ-2 (d1 still present + still
+ // resolves to exactly one equipment, but the cached plan is scoped to EQ-1). The DriverConfig is
+ // unchanged ("{}") so ReconcileDrivers does NOT restart d1 — exactly the config-unchanged rebind the
+ // guard's known-limitation comment describes.
+ var deploymentId2 = SeedDeploymentWithEquipmentTags(db, RevB,
+ (Equip: "EQ-2", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"));
+ actor.Tell(new DispatchDeployment(deploymentId2, RevB, CorrelationId.NewId()));
+ coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied);
+
+ // After draining the fresh RebuildAddressSpace, NO MaterialiseDiscoveredNodes is re-told — the cached
+ // EQ-1-scoped plan was dropped by the rebind guard (its NodeId no longer starts with "EQ-2/").
+ publish.ExpectMsg(Timeout);
+ publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
+ }
+
/// Spawns the host with the subscribing driver factory + a publish probe, dispatches the
/// deployment, and waits for the Applied ACK so the apply (and thus _lastComposition + the live
/// child + the initial SubscribeBulk pass) has completed before the test injects discovered nodes. A