diff --git a/CLAUDE.md b/CLAUDE.md
index ee6d2f81..e72a1e37 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -188,38 +188,22 @@ line and injected nothing. Gone with it: `HandleDiscoveredNodes`, `PartitionDisc
browse picker drives it through `Commons/Browsing/DiscoveryDriverBrowser`, which never went through
the actor.
-⛔ **Tag-set drift detection is slated for REMOVAL (Gitea #523).** It is in `master` today and the
-description below is accurate, but the periodic device re-browse is being taken back out: it is
-recurring, unconfigurable (interval is a constructor parameter, no appsettings key) and never
-live-gated traffic to real PLCs/CNCs/Agents. After removal, **AbCip and FOCAS have no tag-change signal
-at all** (they implement no `IRediscoverable`); TwinCAT and MTConnect keep their native ones. The
-`IRediscoverable` consumption above is unaffected.
+**`IRediscoverable` is the ONLY tag-change signal, and four drivers do not raise it.** A periodic
+tag-set drift check briefly existed (shipped `e77c8a35`, **removed** by Gitea **#523**): every
+`SupportsOnlineDiscovery` driver was re-browsed on a 5-minute timer and the result diffed against its
+authored tags. It was taken back out because it generated recurring, **unconfigurable** (interval was a
+constructor parameter with no appsettings key), **never-live-gated** outbound traffic to real
+PLCs/CNCs/Agents — how often to browse someone's plant equipment is an operational decision, and the
+design treated it as an implementation detail.
-**Tag-set drift detection (2026-07-28) — the second signal, and it is GATED.** `DriverInstanceActor`
-re-browses on a slow timer (`DefaultDriftCheckInterval`, 5 min) and compares what the remote offers
-against what an operator authored, raising the same re-browse prompt. This matters most for **AbCip and
-FOCAS**, which browse a live backend but implement no `IRediscoverable`, so a periodic compare is their
-only way to notice a PLC re-download.
-
-⚠️ **It runs ONLY for a driver declaring `ITagDiscovery.SupportsOnlineDiscovery` AND reporting
-`AuthoredDiscoveryRefs` (nullable, default null = opt out).** Modbus, S7, MQTT, AbLegacy and Sql *echo
-authored config* from `DiscoverAsync` rather than browsing the device, so the diff for them is a
-tautology that would report "no drift" forever and read as coverage. A check that cannot fail is worse
-than no check. Note `AuthoredDiscoveryRefs` is **nullable, not empty-by-default** — an empty collection
-legitimately means "nothing authored, so everything discovered is new".
-
-⚠️ **One direction is structurally undetectable for most drivers.** FOCAS, TwinCAT and AbCip re-emit
-their authored tags into the same `DiscoverAsync` stream as device-derived ones (so the browse picker
-shows existing tags), which means discovered ⊇ authored **always** and a *vanished* tag can never appear
-missing. Those drivers declare `DiscoveryStreamIncludesAuthoredTags => true`, and the detector then
-reports only what it can see and **says so** in the operator message rather than implying a clean bill of
-health. **MTConnect is currently the only driver where both directions are detectable** — its stream is
-built purely from the Agent's probe model.
-
-Other properties worth knowing: a **failed browse is not drift** (an unreachable device would otherwise
-report every authored tag as vanished); a **truncated** capture is skipped for the same reason; an
-**unchanged** drift is reported once rather than re-stamping its timestamp every 5 min; and drift that
-resolves **clears** the prompt. The timer starts on Connected entry and is cancelled on every exit.
+⚠️ **Consequence to know before you rely on rediscovery:** **AbCip and FOCAS browse a live backend but
+implement no `IRediscoverable`, so they have no tag-change signal at all** — a PLC program re-download
+or a CNC option install is invisible until someone re-browses the device by hand. Galaxy, TwinCAT,
+MTConnect and MQTT raise natively and are unaffected. If that coverage matters later, reach for an
+event-driven signal, or one that runs when something *else* already indicates change (a reconnect, a
+deploy) — not a wall-clock timer. Removed with the feature: `TagSetDrift`/`TagSetDriftDetector`,
+`DriverInstanceActor`'s drift timer, and `ITagDiscovery.AuthoredDiscoveryRefs` /
+`DiscoveryStreamIncludesAuthoredTags`.
⚠️ **`IHostConnectivityProbe` is still unconsumed.** `GetHostStatuses()` has no production call site and
nothing ever writes a `DriverHostStatus` row (the table was re-created in the v3 initial migration, so
diff --git a/deferment.md b/deferment.md
index dfe60f16..843640ee 100644
--- a/deferment.md
+++ b/deferment.md
@@ -46,11 +46,11 @@ themes into tracked decisions. What remains:
| **#520** node ACLs authored + deployed but **never enforced** | Decision: wire it or retire the surface. Non-enforcement is now stated in docs and warned in the UI, so nobody can author a deny rule believing it works — but reads are still ungated. | §3.1 |
| **#521** `IHostConnectivityProbe` / `DriverHostStatus` dead surface | Decision: build the publisher or delete it. The table was deliberately re-created in the v3 initial migration, so deleting on inference would be wrong. | §3.2 |
| **#522** driver stability tiers inert | Decision: pass real tiers or delete the machinery. Docs now say every driver runs Tier A. | §3.3 |
-| **#523** remove the periodic 5-min device re-browse | **Reversal of work shipped in this remediation.** After removal, AbCip and FOCAS have no tag-change signal at all. | §9 (2026-07-28 entry) |
+| ~~**#523** remove the periodic 5-min device re-browse~~ | ✅ **Done** — removed. **AbCip and FOCAS now have no tag-change signal at all**; that is the accepted consequence, not an oversight. | §9 (2026-07-28, #523) |
| **G-7** `EquipmentTagConfigInspector` covers 6 of 12 driver types | Pre-existing gap, not a regression; the only §1.2 gap left. | §1.2 |
| **13 `EquipmentTagsDarkBatch4` skipped tests** | Still stale — their unblock condition already happened and the path was retired instead. | §4.4 |
| 17 other tracker issues | Driver features + deferred work, unchanged by this remediation. | §2 |
-| Open live gates | #491 continuous historization, #468 browser tree, archreview R2-03 / R2-10, and the **un-gated drift detector** (moot if #523 lands). | §5 |
+| Open live gates | #491 continuous historization, #468 browser tree, archreview R2-03 / R2-10. *(The un-gated drift detector's gate closed by deletion — see #523.)* | §5 |
**Three issues are resolved but still open on the tracker** — **#516** (fixed for all 12 drivers),
**#518** (the `IRediscoverable` half fixed; the probe half split to #521) and **#507** (injection deleted,
@@ -300,7 +300,7 @@ mere env-var presence, so it cannot pass vacuously.
| Gate | Status | Evidence |
|---|---|---|
-| **Tag-set drift detection** (shipped `e77c8a35`) | **OPEN — and likely moot.** Never live-gated: it needs a device whose tag set actually changes. Gitea **#523** removes the feature, which would close this gate by deletion. |
+| ~~**Tag-set drift detection** (shipped `e77c8a35`)~~ | ✅ **CLOSED BY DELETION** — removed under Gitea **#523** rather than gated. The gate it needed (a device whose tag set actually changes) required exactly the traffic the removal objected to. | §9, 2026-07-28 (#523) |
| **Continuous-historization value capture** (#491) | **OPEN** — code complete, unproven in production | `CLAUDE.md:710`; no gate-passed commit exists |
| **Wave-0 universal discovery browser** full tree render (#468) | **OPEN** — fixture-blocked | tracking doc §Wave 0; named as blocking BACnet browse |
| **archreview R2-03** — VT Good→Bad on a data-fed rig | **OPEN** — explicitly "live-blocked on this data-less rig" | `archreview/plans/STATUS.md` |
@@ -470,7 +470,7 @@ the tree.
| 5. Bookkeeping sweep | ✅ **Done** — 13 plan files closed, 11 archreview gates written back | `88ce8df0` |
| 6. Tier truth | ✅ **Done** — docs reconciled; keep-or-delete is Gitea **#522** | `88ce8df0` |
| follow-up A | ✅ **Done** — Sql/FOCAS in-place re-parse made **atomic** | `5184a2e1` |
-| follow-up B | ⛔ **Shipped, then reverted by decision** — tag-set drift detection; removal tracked as Gitea **#523** | `e77c8a35` |
+| follow-up B | ⛔ **Shipped, then removed by decision** — tag-set drift detection; **removal landed**, Gitea **#523** | `e77c8a35`, removed below |
### 2026-07-27 — §8.1 ACL non-enforcement made explicit
@@ -624,10 +624,9 @@ guarantee. Sql.Tests 226 / FOCAS.Tests 275 pass.
### 2026-07-28 — the two things §8.3 deliberately did NOT build (2 of 2)
-> ⛔ **SUPERSEDED SAME DAY — the periodic re-browse is to be REMOVED (owner decision, Gitea #523).**
-> It ships in `master` (`e77c8a35`, merged `914d70bf`) and this entry stays as the record of what was
-> built and why, but it is **not the intended end state**. The objection is the one this entry does not
-> weigh: the check is recurring, **unconfigurable** (the interval is a constructor parameter with no
+> ⛔ **REMOVED — the periodic re-browse is gone from the tree (owner decision, Gitea #523; removal
+> recorded in the next entry).** It shipped in `master` (`e77c8a35`, merged `914d70bf`) and this entry
+> stays as the record of what was built and why. The objection is the one this entry does not weigh: the check is recurring, **unconfigurable** (the interval is a constructor parameter with no
> appsettings key) and **never live-gated** outbound traffic to real PLCs, CNCs and MTConnect Agents.
> Frequency of an unsolicited device browse is an operational decision, and the design treated it as an
> implementation detail.
@@ -679,6 +678,40 @@ The comparison is a pure, separately-tested type (`TagSetDriftDetector`) rather
tautology-driver test goes red, and it asserts discovery was **never invoked** rather than merely "no
prompt raised", which would have passed for the wrong reason if the timer simply never fired.
+### 2026-07-28 — #523: the periodic re-browse removed
+
+The feature described in the entry above is **gone from the tree**. Removed:
+
+| Removed | Where |
+|---|---|
+| `TagSetDrift` + `TagSetDriftDetector` | `Runtime/Drivers/TagSetDrift.cs` (file deleted) |
+| The drift timer, `DriftCheckTick`, `DefaultDriftCheckInterval`, the `driftCheckInterval` Props/ctor parameter, `QualifiesForDriftCheck`, `Start`/`StopDriftCheck`, `HandleDriftCheckAsync`, `CollectLeafRefs`, `_lastDriftSignature` | `DriverInstanceActor` |
+| `ITagDiscovery.AuthoredDiscoveryRefs` + `.DiscoveryStreamIncludesAuthoredTags`, and their four implementations | `Core.Abstractions`; FOCAS, TwinCAT, MTConnect, AbCip |
+| `DriverInstanceActorDriftCheckTests` (6), `TagSetDriftDetectorTests` (8) | `Runtime.Tests` |
+
+**What survives, deliberately.** `ITagDiscovery.SupportsOnlineDiscovery` stays — it predates drift
+detection and is what the universal browse picker dispatches on. The `IRediscoverable` consumption
+(`09a401b8`) is untouched: the driver tells us, we do not poll, and it costs nothing when nothing
+changes. The `/hosts` re-browse chip stays and is now fed by that one source.
+
+**The consequence, stated plainly rather than buried.** **AbCip and FOCAS now have no tag-change signal
+at all.** Both browse a live backend, neither implements `IRediscoverable`, and the periodic compare was
+the only thing that would have noticed a PLC re-download or a CNC option install. That is the accepted
+trade, not an oversight — it is recorded in `CLAUDE.md` §Change Detection so the next reader does not
+have to rediscover it, together with the shape to reach for if the coverage is wanted back
+(event-driven, or triggered by a reconnect/deploy, not a wall-clock timer).
+
+**Why the earlier entry's reasoning did not save it.** That entry defends the *design* — the gate is
+real, the tautology drivers are correctly excluded, the undetectable direction is declared rather than
+faked. All of that is true and none of it is the objection. The objection is to the feature existing at
+all in that shape: it browsed real plant equipment on a recurring timer, at a frequency nobody could
+configure, and that behaviour was never once observed against real hardware. A well-built mechanism for
+something that should not run is still something that should not run.
+
+Build clean; `Runtime.Tests` 476 passed / 13 skipped (unchanged skip set — the stale
+`EquipmentTagsDarkBatch4` group), FOCAS 275, TwinCAT 192, MTConnect 491, AbCip 342,
+`Core.Abstractions` 266, all green.
+
### 2026-07-27 — §8.4 dispatch-map parity (G-1 … G-6)
**The maps had to become data before they could be guarded.** A Razor `@switch` compiles into
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ITagDiscovery.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ITagDiscovery.cs
index 76d2009d..cc713a1b 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ITagDiscovery.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ITagDiscovery.cs
@@ -38,32 +38,4 @@ public interface ITagDiscovery
/// See docs/plans/2026-07-15-universal-discovery-browser-design.md §5.
///
bool SupportsOnlineDiscovery => false;
-
- ///
- /// The device-native references this driver's authored tags bind to — the same vocabulary
- /// streams as . Comparing the
- /// two sets detects that the remote's tag set has drifted from what an operator authored.
- /// Null means "I do not report this", and drift detection is skipped. That is the
- /// default on purpose: an empty collection legitimately means "no tags authored, so everything
- /// discovered is new", and the two must not be confused. A driver opting in is asserting that its
- /// authored refs are directly comparable to what it discovers.
- /// Only consulted when is true. For a driver that
- /// replays authored tags from config rather than browsing the backend, the comparison is a
- /// tautology — it would report "no drift" forever and read as reassurance.
- ///
- IReadOnlyCollection? AuthoredDiscoveryRefs => null;
-
- ///
- /// True when re-emits this driver's authored tags into the same
- /// stream as the device-derived ones — which FOCAS, TwinCAT and AbCip all do, so the browse picker
- /// shows an operator their existing tags alongside what the device offers.
- /// This makes one half of drift detection structurally undetectable. If authored tags
- /// are always re-emitted then the discovered set always contains the authored set, so an authored tag
- /// that has VANISHED from the device can never appear missing. Declaring it here means the detector
- /// reports only what it can actually see, instead of reporting "nothing vanished" forever and reading
- /// as reassurance.
- /// False (the default) means the stream is purely device-derived — as MTConnect's is, built
- /// from the Agent's probe model — so both directions are detectable.
- ///
- bool DiscoveryStreamIncludesAuthoredTags => false;
}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
index 60aff54f..05ad2904 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
@@ -1036,17 +1036,6 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// EnableControllerBrowse, which the universal browser's PatchForBrowse guarantees at open time.
public bool SupportsOnlineDiscovery => true;
- ///
- /// Pre-declared tags are emitted under their Name, which is the driver FullName the
- /// controller-browse leaves also use.
- public IReadOnlyCollection? AuthoredDiscoveryRefs =>
- _declaredTags.Select(t => t.Name).ToArray();
-
- ///
- /// True: DiscoverAsync emits browsed controller symbols AND re-emits every
- /// pre-declared tag.
- public bool DiscoveryStreamIncludesAuthoredTags => true;
-
///
public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
{
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs
index 72b28f91..b01ef4a4 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs
@@ -517,17 +517,6 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// until the node set is non-empty and stable.
public bool SupportsOnlineDiscovery => true;
- ///
- /// The authored tag's Name IS its driver-side FullName — DiscoverAsync emits
- /// FullName: tag.Name for every authored tag — so the two sets are directly comparable.
- public IReadOnlyCollection? AuthoredDiscoveryRefs =>
- _tagsByRawPath.Values.Select(t => t.Name).ToArray();
-
- ///
- /// True: DiscoverAsync emits the device-derived FixedTree AND re-emits every authored
- /// tag, so an authored tag that vanished from the CNC cannot appear missing.
- public bool DiscoveryStreamIncludesAuthoredTags => true;
-
///
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
{
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs
index 384d4f5b..9c60197c 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs
@@ -907,19 +907,6 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
///
public bool SupportsOnlineDiscovery => true;
- ///
- /// The DataItem ids the authored raw tags bind. DiscoverAsync emits
- /// FullName: dataItem.Id from the Agent's probe model, so the two sets are the same
- /// vocabulary.
- public IReadOnlyCollection? AuthoredDiscoveryRefs =>
- Volatile.Read(ref _binding).RawPathsByDataItemId.Keys;
-
- ///
- /// False — DiscoverAsync is built purely from the Agent's probe model and never
- /// re-emits authored tags, so an authored DataItem that the Agent stopped publishing IS visible as
- /// missing. MTConnect is currently the only driver where both drift directions are detectable.
- public bool DiscoveryStreamIncludesAuthoredTags => false;
-
///
///
/// Once, not the default UntilStable: streams the
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs
index 6e960bdd..4acdbdf2 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs
@@ -404,17 +404,6 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
/// EnableControllerBrowse, which the universal browser's PatchForBrowse guarantees at open time.
public bool SupportsOnlineDiscovery => true;
- ///
- /// The authored tag's Name is its RawPath and its driver FullName
- /// (DiscoverAsync emits FullName: tag.Name), directly comparable to the browsed
- /// symbols' InstancePath.
- public IReadOnlyCollection? AuthoredDiscoveryRefs =>
- _tagsByRawPath.Values.Select(t => t.Name).ToArray();
-
- ///
- /// True: DiscoverAsync emits browsed ADS symbols AND re-emits every authored tag.
- public bool DiscoveryStreamIncludesAuthoredTags => true;
-
///
public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
{
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs
index 19e2331e..5fc2139e 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs
@@ -1,6 +1,5 @@
using Akka.Actor;
using Akka.Event;
-using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
@@ -33,11 +32,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
{
public static readonly TimeSpan DefaultReconnectInterval = TimeSpan.FromSeconds(10);
- /// Default period of the tag-set drift check. Deliberately slow: it browses a real device, and
- /// a tag set changing is an engineering event (a PLC re-download, a CNC option install), not a runtime
- /// one. Five minutes bounds the wire cost while still surfacing drift the same shift it happens.
- public static readonly TimeSpan DefaultDriftCheckInterval = TimeSpan.FromMinutes(5);
-
public sealed record InitializeRequested(string DriverConfigJson);
public sealed record InitializeSucceeded(int Generation);
public sealed record InitializeFailed(string Reason, int Generation);
@@ -127,15 +121,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
/// between connects), and dropping it in one state would lose the signal silently.
private sealed record RediscoveryRaised(RediscoveryEventArgs Args);
- /// Self-sent on a slow timer to re-browse a genuinely-browsable driver and compare what the
- /// remote offers against what an operator authored. Only ever scheduled for a driver that opts in — see
- /// .
- private sealed record DriftCheckTick
- {
- public static readonly DriftCheckTick Instance = new();
- private DriftCheckTick() { }
- }
-
public sealed class RetryConnect
{
public static readonly RetryConnect Instance = new();
@@ -196,14 +181,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
private DateTime? _rediscoveryNeededUtc;
private string? _rediscoveryReason;
- /// Period of the tag-set drift check; tests inject a tiny value.
- private readonly TimeSpan _driftCheckInterval;
-
- /// Signature of the last drift REPORTED, so an unchanged drift is not re-announced on every
- /// check. Without this the operator's prompt would re-stamp its timestamp every interval forever,
- /// which reads as "it just happened again" rather than "it is still true".
- private string? _lastDriftSignature;
-
/// The references the host wants kept subscribed (set by ).
/// Re-applied on every entry into Connected so values resume after a reconnect or redeploy.
private IReadOnlyList _desiredRefs = Array.Empty();
@@ -237,8 +214,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
/// defaults to an empty string when not provided (e.g. in unit tests).
/// Optional Phase 6.1 resilience invoker wrapping this driver's capability
/// calls; defaults to (pass-through) when not supplied.
- /// Optional period of the tag-set drift check; defaults to
- /// . Tests inject a tiny value so the check runs without a wait.
/// Optional period of the health-poll heartbeat, which also drives the
/// subscription reconcile (); defaults to
/// . Exists so a test can drive the reconcile without waiting 30 s.
@@ -250,8 +225,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
IDriverHealthPublisher? healthPublisher = null,
string? clusterId = null,
IDriverCapabilityInvoker? invoker = null,
- TimeSpan? healthPollInterval = null,
- TimeSpan? driftCheckInterval = null) =>
+ TimeSpan? healthPollInterval = null) =>
Akka.Actor.Props.Create(() => new DriverInstanceActor(
driver,
reconnectInterval ?? DefaultReconnectInterval,
@@ -259,8 +233,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
healthPublisher ?? NullDriverHealthPublisher.Instance,
clusterId ?? string.Empty,
invoker,
- healthPollInterval,
- driftCheckInterval));
+ healthPollInterval));
///
/// Returns true when the driver should boot in DEV-STUB mode based on host platform and
@@ -291,8 +264,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
/// Cluster identifier forwarded in health snapshots.
/// Phase 6.1 resilience invoker wrapping this driver's capability calls;
/// defaults to (pass-through) when null.
- /// Period of the tag-set drift check; defaults to
- /// .
/// Period of the health-poll heartbeat, which also drives the
/// subscription reconcile; defaults to when null.
public DriverInstanceActor(
@@ -302,15 +273,13 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
IDriverHealthPublisher? healthPublisher = null,
string? clusterId = null,
IDriverCapabilityInvoker? invoker = null,
- TimeSpan? healthPollInterval = null,
- TimeSpan? driftCheckInterval = null)
+ TimeSpan? healthPollInterval = null)
{
_driver = driver;
_invoker = invoker ?? NullDriverCapabilityInvoker.Instance;
_driverInstanceId = driver.DriverInstanceId;
_clusterId = clusterId ?? string.Empty;
_healthPublisher = healthPublisher ?? NullDriverHealthPublisher.Instance;
- _driftCheckInterval = driftCheckInterval ?? DefaultDriftCheckInterval;
_reconnectInterval = reconnectInterval;
_healthPollInterval = healthPollInterval ?? HealthPollInterval;
OtOpcUaTelemetry.DriverInstanceLifecycle.Add(1,
@@ -381,7 +350,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
ResubscribeDesired();
AttachAlarmSource();
SubscribeDesiredAlarms();
- StartDriftCheck();
});
Receive(msg =>
{
@@ -427,7 +395,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
_driverInstanceId, msg.Reason);
DetachSubscription();
RecordFault();
- StopDriftCheck();
Become(Reconnecting);
PublishHealthSnapshot();
});
@@ -435,7 +402,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
{
_log.Info("DriverInstance {Id}: ForceReconnect requested by admin; re-entering Reconnecting", _driverInstanceId);
DetachSubscription();
- StopDriftCheck();
Become(Reconnecting);
PublishHealthSnapshot();
});
@@ -458,7 +424,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
SubscribeDesiredAlarms();
});
ReceiveAsync(HandleSubscribeAlarmsAsync);
- ReceiveAsync(HandleDriftCheckAsync);
Receive(OnDataChangeForward);
// Native alarm transition marshaled onto the actor thread from the driver's OnAlarmEvent;
// project it to the parent the same way DataChangeForward projects AttributeValuePublished.
@@ -553,7 +518,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
ResubscribeDesired();
AttachAlarmSource();
SubscribeDesiredAlarms();
- StartDriftCheck();
});
// A failure here is a no-op regardless of generation — the retry timer keeps trying the
// current config; only a (generation-matched) InitializeSucceeded transitions state.
@@ -798,10 +762,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
}
}
- /// Stops the drift check. Called on every Connected exit — browsing a disconnected driver would
- /// fail every pass, and a failed browse is deliberately NOT treated as drift.
- private void StopDriftCheck() => Timers.Cancel("drift-check");
-
/// Tear down the data-change + native-alarm event handlers + null the handle. Called from the
/// Unsubscribe path, on PostStop, and on Connected → Reconnecting transitions so a stale handler doesn't
/// push data-change / alarm events to an actor that has lost its driver connection.
@@ -849,119 +809,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
_rediscoveryHandler = null;
}
- ///
- /// True when this driver is worth drift-checking: it must genuinely BROWSE its remote
- /// () and must report the refs its authored tags
- /// bind ().
- /// The first condition is what makes the check meaningful rather than reassuring. Most drivers
- /// — Modbus, S7, AbLegacy, Sql, Mqtt — stream their AUTHORED tags back out of DiscoverAsync
- /// rather than enumerating the device, so diffing the two sets for them is a tautology that would
- /// report "no drift" forever. A check that cannot fail is worse than no check: it looks like
- /// coverage.
- ///
- private bool QualifiesForDriftCheck() =>
- _driver is ITagDiscovery { SupportsOnlineDiscovery: true, AuthoredDiscoveryRefs: not null };
-
- /// Starts the slow drift check on a Connected entry, for qualifying drivers only.
- private void StartDriftCheck()
- {
- if (!QualifiesForDriftCheck()) return;
- // Periodic, not fire-on-connect: a driver whose discovered shape fills in asynchronously after
- // connect (the FOCAS FixedTree) would otherwise be compared against a half-populated browse and
- // report phantom drift on every reconnect.
- Timers.StartPeriodicTimer("drift-check", DriftCheckTick.Instance, _driftCheckInterval);
- }
-
- ///
- /// Re-browses the remote and compares it against the driver's authored refs. A CHANGED drift raises
- /// the same operator prompt an raise does — this is the signal for the
- /// browsable drivers that have no native change notification (AbCip, FOCAS), where a periodic
- /// compare is the only way to notice a PLC program re-download.
- /// Advisory, like every rediscovery signal: the served address space is not touched. Raw tags
- /// are authored through the /raw browse-commit flow, so an operator re-browses and commits.
- ///
- private async Task HandleDriftCheckAsync(DriftCheckTick _)
- {
- if (_driver is not ITagDiscovery discovery) return;
- var authored = discovery.AuthoredDiscoveryRefs;
- if (authored is null) return;
-
- CapturedTree tree;
- try
- {
- var builder = new CapturingAddressSpaceBuilder();
- // Bounded: ReceiveAsync suspends the mailbox for the whole handler, so an unbounded browse would
- // block writes, reconnects and health polls behind it.
- using var cts = new CancellationTokenSource(DriftBrowseTimeout);
- await _invoker.ExecuteAsync(
- DriverCapability.Discover,
- _driverInstanceId,
- async ct => await discovery.DiscoverAsync(builder, ct),
- cts.Token);
- tree = builder.Build();
- }
- catch (Exception ex)
- {
- // A failed browse is not drift — the device may simply be unreachable, which the health surface
- // already reports. Reporting drift here would turn every comms blip into "all your tags vanished".
- _log.Debug(ex, "DriverInstance {Id}: drift check browse failed; skipping this pass", _driverInstanceId);
- return;
- }
-
- if (tree.Truncated)
- {
- // A truncated capture is a PARTIAL view, so every un-captured authored ref would look vanished.
- _log.Warning(
- "DriverInstance {Id}: drift check skipped — the browse hit the capture cap and is partial",
- _driverInstanceId);
- return;
- }
-
- var discovered = new List();
- CollectLeafRefs(tree.Root, discovered);
- var drift = TagSetDriftDetector.Compare(
- discovered, authored, detectVanished: !discovery.DiscoveryStreamIncludesAuthoredTags);
-
- if (!drift.HasDrift)
- {
- // Recovered: the device matches the authored set again, so drop the prompt and let the next
- // genuine drift re-raise with a fresh timestamp.
- if (_lastDriftSignature is not null)
- {
- _log.Info("DriverInstance {Id}: tag-set drift cleared — device matches the authored set",
- _driverInstanceId);
- _lastDriftSignature = null;
- _rediscoveryNeededUtc = null;
- _rediscoveryReason = null;
- PublishHealthSnapshot();
- }
-
- return;
- }
-
- // Report a CHANGED drift once. An unchanged one re-stamping its timestamp every interval would read
- // as "it just happened again" rather than "it is still true".
- if (string.Equals(_lastDriftSignature, drift.Signature, StringComparison.Ordinal)) return;
- _lastDriftSignature = drift.Signature;
- _rediscoveryNeededUtc = DateTime.UtcNow;
- _rediscoveryReason = "device tag set differs from authored: " + drift.Describe();
- _log.Info(
- "DriverInstance {Id}: tag-set drift detected ({Drift}) — surfaced for operator re-browse; the served address space is unchanged",
- _driverInstanceId, drift.Describe());
- PublishHealthSnapshot();
- }
-
- /// Per-pass timeout for the drift browse. Bounds the mailbox suspension.
- private static readonly TimeSpan DriftBrowseTimeout = TimeSpan.FromSeconds(30);
-
- /// Flattens a captured tree to its leaf ids — the driver-side FullNames, which is the
- /// vocabulary AuthoredDiscoveryRefs is defined in.
- private static void CollectLeafRefs(CapturedNode node, List into)
- {
- if (node.IsLeaf) into.Add(node.Id);
- foreach (var child in node.Children) CollectLeafRefs(child, into);
- }
-
/// Records the driver's rediscovery raise and re-publishes health so the signal reaches the
/// AdminUI promptly rather than waiting for the next 30 s heartbeat.
/// Advisory only. The served address space is deliberately NOT rebuilt: v3 authors raw tags
@@ -1137,7 +984,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
///
protected override void PostStop()
{
- StopDriftCheck();
DetachSubscription();
// MUST happen: the IDriver instance can outlive this actor (the host respawns a child around the
// same driver object), so a missing unsubscribe accumulates a handler per respawn holding a dead Self.
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/TagSetDrift.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/TagSetDrift.cs
deleted file mode 100644
index 7a2e0015..00000000
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/TagSetDrift.cs
+++ /dev/null
@@ -1,92 +0,0 @@
-namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
-
-///
-/// The difference between what a driver discovers on its remote and what an operator
-/// authored — i.e. whether the device's tag set has moved out from under the deployed config.
-///
-/// Refs the remote now offers that no authored tag binds.
-///
-/// Refs authored tags bind that the remote no longer offers. Always empty when
-/// is false — see that parameter.
-///
-///
-/// False when the driver re-emits its authored tags into the same discovery stream as the device-derived
-/// ones (ITagDiscovery.DiscoveryStreamIncludesAuthoredTags). The discovered set then always
-/// contains the authored set, so a vanished tag cannot be seen. Carried explicitly so
-/// can say "new-on-device only" rather than implying a clean bill of health.
-///
-public sealed record TagSetDrift(
- IReadOnlyList Appeared,
- IReadOnlyList Vanished,
- bool VanishedDetectable)
-{
- /// True when the two sets differ in either direction.
- public bool HasDrift => Appeared.Count > 0 || Vanished.Count > 0;
-
- ///
- /// A stable identity for this drift, used to report a CHANGED drift once rather than re-reporting an
- /// unchanged one on every check. Ordinal-ordered so set enumeration order cannot make an identical
- /// drift look new.
- ///
- public string Signature { get; } =
- string.Join('', Appeared) + '' + string.Join('', Vanished);
-
- /// An operator-facing one-liner naming what moved, suitable for a UI tooltip.
- /// A short human-readable description, or "no drift" when there is none.
- public string Describe()
- {
- if (!HasDrift) return "no drift";
- var parts = new List(3);
- if (Appeared.Count > 0) parts.Add($"{Appeared.Count} new on device (e.g. {Sample(Appeared)})");
- if (Vanished.Count > 0) parts.Add($"{Vanished.Count} authored missing (e.g. {Sample(Vanished)})");
- // Say so rather than let the absence of a "missing" clause read as "nothing is missing".
- if (!VanishedDetectable) parts.Add("missing-tag detection unavailable for this driver");
- return string.Join("; ", parts);
- }
-
- /// Names at most two refs so the message stays readable when a whole PLC program is swapped.
- private static string Sample(IReadOnlyList refs) =>
- refs.Count <= 2 ? string.Join(", ", refs) : $"{refs[0]}, {refs[1]}, +{refs.Count - 2} more";
-}
-
-///
-/// Compares a driver's discovered refs against its authored refs. Pure and allocation-light so the
-/// actor's periodic check stays cheap; kept out of DriverInstanceActor so it is directly testable
-/// without an actor system.
-///
-public static class TagSetDriftDetector
-{
- ///
- /// Computes the two-way difference.
- /// Ordinal comparison, matching how every driver keys its tag tables — a device that
- /// genuinely exposes both Speed and speed must not have them collapsed.
- ///
- /// Refs streamed by the driver's most recent discovery pass.
- /// Refs the driver's authored tags bind (ITagDiscovery.AuthoredDiscoveryRefs).
- ///
- /// False when the driver re-emits authored tags into its discovery stream, which makes a vanished tag
- /// structurally invisible. The vanished half is then not computed at all rather than computed to a
- /// misleading empty — see .
- ///
- /// The drift, which may be empty.
- public static TagSetDrift Compare(
- IReadOnlyCollection discovered,
- IReadOnlyCollection authored,
- bool detectVanished = true)
- {
- ArgumentNullException.ThrowIfNull(discovered);
- ArgumentNullException.ThrowIfNull(authored);
-
- var authoredSet = new HashSet(authored, StringComparer.Ordinal);
- var discoveredSet = new HashSet(discovered, StringComparer.Ordinal);
-
- var appeared = discoveredSet.Where(r => !authoredSet.Contains(r))
- .OrderBy(r => r, StringComparer.Ordinal).ToArray();
- var vanished = detectVanished
- ? authoredSet.Where(r => !discoveredSet.Contains(r))
- .OrderBy(r => r, StringComparer.Ordinal).ToArray()
- : [];
-
- return new TagSetDrift(appeared, vanished, detectVanished);
- }
-}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorDriftCheckTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorDriftCheckTests.cs
deleted file mode 100644
index f49e25b6..00000000
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorDriftCheckTests.cs
+++ /dev/null
@@ -1,268 +0,0 @@
-using Akka.Actor;
-using Shouldly;
-using Xunit;
-using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
-using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
-using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
-
-namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
-
-///
-/// The tag-set drift check: periodically re-browse a genuinely-browsable driver and compare what the
-/// remote offers against what an operator authored.
-/// Why this is gated rather than run for every driver. Modbus, S7, AbLegacy, Sql and Mqtt
-/// all stream their AUTHORED tags back out of DiscoverAsync rather than enumerating the device, so
-/// diffing the two sets for them is a tautology that reports "no drift" forever. A check that cannot fail
-/// is worse than no check, because it reads as coverage — so the actor runs this only for drivers that
-/// declare SupportsOnlineDiscovery AND report AuthoredDiscoveryRefs.
-/// The signal is advisory and shares the Stage-2 surfacing: it raises the same /hosts
-/// re-browse prompt an raise does. The served address space is never
-/// rebuilt — raw tags are authored through the /raw browse-commit flow.
-///
-[Trait("Category", "Unit")]
-public sealed class DriverInstanceActorDriftCheckTests : RuntimeActorTestBase
-{
- private static readonly TimeSpan Tick = TimeSpan.FromMilliseconds(60);
- private static readonly TimeSpan Budget = TimeSpan.FromSeconds(3);
-
- /// A device offering a tag nothing binds raises the operator prompt, naming what appeared.
- [Fact]
- public void A_new_ref_on_the_device_raises_the_re_browse_prompt()
- {
- var driver = new BrowsableStubDriver(discovered: ["a", "b", "c"], authored: ["a", "b"]);
- var publisher = new RecordingHealthPublisher();
- var actor = SpawnConnected(driver, publisher);
-
- AwaitAssert(
- () =>
- {
- var signalled = publisher.Published.LastOrDefault(p => p.RediscoveryNeededUtc is not null);
- signalled.ShouldNotBeNull();
- signalled!.RediscoveryReason.ShouldNotBeNull();
- signalled.RediscoveryReason!.ShouldContain("1 new on device");
- signalled.RediscoveryReason!.ShouldContain("c");
- },
- Budget);
-
- Sys.Stop(actor);
- }
-
- ///
- /// The gate that makes this feature honest. A driver whose DiscoverAsync replays
- /// authored config rather than browsing the device declares SupportsOnlineDiscovery == false,
- /// and must never be drift-checked — for it the comparison could only ever say "no drift".
- /// Positive control: the driver is handed a set that WOULD read as drift, and the assertion is
- /// that discovery is never even invoked. Asserting only "no prompt raised" would pass for the wrong
- /// reason (e.g. if the timer never fired at all).
- ///
- [Fact]
- public void A_driver_that_replays_authored_config_is_never_drift_checked()
- {
- var driver = new BrowsableStubDriver(
- discovered: ["a", "b", "c"], authored: ["a"], supportsOnlineDiscovery: false);
- var publisher = new RecordingHealthPublisher();
- var actor = SpawnConnected(driver, publisher);
-
- // Long enough for several ticks had the timer been scheduled.
- ExpectNoMsg(TimeSpan.FromMilliseconds(400));
-
- driver.DiscoverCount.ShouldBe(
- 0,
- "a driver that replays authored config was browsed for drift — the comparison is a tautology "
- + "for it and would report 'no drift' forever, which reads as coverage");
- publisher.Published.ShouldAllBe(p => p.RediscoveryNeededUtc == null);
-
- Sys.Stop(actor);
- }
-
- /// A driver that browses but does NOT report its authored refs opts out — the default. An empty
- /// collection would mean "nothing authored, everything is new", so null must not be read that way.
- [Fact]
- public void A_driver_that_reports_no_authored_refs_is_never_drift_checked()
- {
- var driver = new BrowsableStubDriver(discovered: ["a", "b"], authored: null);
- var publisher = new RecordingHealthPublisher();
- var actor = SpawnConnected(driver, publisher);
-
- ExpectNoMsg(TimeSpan.FromMilliseconds(400));
-
- driver.DiscoverCount.ShouldBe(0);
- publisher.Published.ShouldAllBe(p => p.RediscoveryNeededUtc == null);
-
- Sys.Stop(actor);
- }
-
- ///
- /// An UNCHANGED drift is reported once, not re-stamped on every check — a prompt whose timestamp
- /// keeps moving reads as "it just happened again" rather than "it is still true".
- ///
- [Fact]
- public void An_unchanged_drift_is_reported_once()
- {
- var driver = new BrowsableStubDriver(discovered: ["a", "b"], authored: ["a"]);
- var publisher = new RecordingHealthPublisher();
- var actor = SpawnConnected(driver, publisher);
-
- AwaitAssert(
- () => publisher.Published.Count(p => p.RediscoveryNeededUtc is not null).ShouldBe(1),
- Budget);
-
- // Several more ticks pass with the same drift.
- AwaitAssert(() => driver.DiscoverCount.ShouldBeGreaterThan(3), Budget);
-
- publisher.Published.Count(p => p.RediscoveryNeededUtc is not null).ShouldBe(
- 1,
- "an unchanged drift was re-announced; the operator prompt would keep re-stamping its timestamp");
-
- Sys.Stop(actor);
- }
-
- /// A failed browse is not drift. A device that is merely unreachable already shows on the health
- /// surface; reporting drift would turn every comms blip into "all your tags vanished".
- [Fact]
- public void A_failed_browse_is_not_reported_as_drift()
- {
- var driver = new BrowsableStubDriver(discovered: ["a"], authored: ["a", "b"]) { DiscoverThrows = true };
- var publisher = new RecordingHealthPublisher();
- var actor = SpawnConnected(driver, publisher);
-
- AwaitAssert(() => driver.DiscoverCount.ShouldBeGreaterThan(1), Budget);
-
- publisher.Published.ShouldAllBe(
- p => p.RediscoveryNeededUtc == null,
- "a browse that threw was treated as drift — an unreachable device would report every authored "
- + "tag as vanished");
-
- Sys.Stop(actor);
- }
-
- /// Drift that resolves — the operator re-browsed and committed — clears the prompt, so the next
- /// genuine drift re-raises with a fresh timestamp instead of being deduped against the stale one.
- [Fact]
- public void Drift_that_resolves_clears_the_prompt()
- {
- var driver = new BrowsableStubDriver(discovered: ["a", "b"], authored: ["a"]);
- var publisher = new RecordingHealthPublisher();
- var actor = SpawnConnected(driver, publisher);
-
- AwaitAssert(
- () => publisher.Published.Any(p => p.RediscoveryNeededUtc is not null),
- Budget);
-
- // The operator commits the new tag: authored now matches the device.
- driver.SetAuthored(["a", "b"]);
-
- AwaitAssert(
- () => publisher.Published[^1].RediscoveryNeededUtc.ShouldBeNull(),
- Budget);
-
- Sys.Stop(actor);
- }
-
- private IActorRef SpawnConnected(IDriver driver, IDriverHealthPublisher publisher)
- {
- var parent = CreateTestProbe();
- parent.IgnoreMessages(_ => true);
- var actor = parent.ChildActorOf(DriverInstanceActor.Props(
- driver, healthPublisher: publisher, driftCheckInterval: Tick));
- actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
- return actor;
- }
-
- /// Captured health publishes, so a test can read the rediscovery fields.
- private sealed record HealthPublish(DateTime? RediscoveryNeededUtc, string? RediscoveryReason);
-
- private sealed class RecordingHealthPublisher : IDriverHealthPublisher
- {
- private readonly List _published = [];
-
- public IReadOnlyList Published
- {
- get { lock (_published) return _published.ToArray(); }
- }
-
- ///
- public void Publish(
- string clusterId, string driverInstanceId, DriverHealth health, int errorCount5Min,
- DateTime? rediscoveryNeededUtc = null, string? rediscoveryReason = null)
- {
- lock (_published) _published.Add(new HealthPublish(rediscoveryNeededUtc, rediscoveryReason));
- }
- }
-
- ///
- /// A driver that browses a fake "device". Health is STABLE so the publish dedup genuinely engages —
- /// the shared StubDriver returns DateTime.UtcNow, which would make every publish look
- /// changed and let a dedup-related bug pass unnoticed.
- ///
- private sealed class BrowsableStubDriver(
- string[] discovered,
- string[]? authored,
- bool supportsOnlineDiscovery = true) : IDriver, ITagDiscovery
- {
- private static readonly DateTime FixedLastRead = new(2026, 7, 28, 9, 0, 0, DateTimeKind.Utc);
- private volatile string[]? _authored = authored;
- private int _discoverCount;
-
- /// Number of discovery passes the actor has driven — the direct read of whether the drift
- /// check ran at all.
- public int DiscoverCount => Volatile.Read(ref _discoverCount);
-
- /// When true, every browse throws — models an unreachable device.
- public bool DiscoverThrows { get; init; }
-
- ///
- public string DriverInstanceId => "browsable-stub-1";
-
- ///
- public string DriverType => "Stub";
-
- ///
- public bool SupportsOnlineDiscovery => supportsOnlineDiscovery;
-
- ///
- public IReadOnlyCollection? AuthoredDiscoveryRefs => _authored;
-
- /// Models the operator committing (or removing) tags between checks.
- public void SetAuthored(string[] refs) => _authored = refs;
-
- ///
- public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
- {
- Interlocked.Increment(ref _discoverCount);
- if (DiscoverThrows) throw new IOException("device unreachable");
-
- var folder = builder.Folder("Stub", "Stub");
- foreach (var r in discovered)
- {
- folder.Variable(r, r, new DriverAttributeInfo(
- FullName: r,
- DriverDataType: DriverDataType.Float64,
- IsArray: false,
- ArrayDim: null,
- SecurityClass: SecurityClassification.ViewOnly,
- IsHistorized: false));
- }
-
- return Task.CompletedTask;
- }
-
- ///
- public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask;
-
- ///
- public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask;
-
- ///
- public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
-
- ///
- public DriverHealth GetHealth() => new(DriverState.Healthy, FixedLastRead, null);
-
- ///
- public long GetMemoryFootprint() => 0;
-
- ///
- public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
- }
-}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/TagSetDriftDetectorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/TagSetDriftDetectorTests.cs
deleted file mode 100644
index b48f8d22..00000000
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/TagSetDriftDetectorTests.cs
+++ /dev/null
@@ -1,113 +0,0 @@
-using Shouldly;
-using Xunit;
-using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
-
-namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
-
-///
-/// The pure half of tag-set drift detection: what the remote offers vs. what an operator authored.
-/// Kept out of the actor so the comparison rules are testable without an actor system.
-///
-[Trait("Category", "Unit")]
-public sealed class TagSetDriftDetectorTests
-{
- /// Matching sets are not drift, and must not raise an operator prompt.
- [Fact]
- public void Identical_sets_are_not_drift()
- {
- var drift = TagSetDriftDetector.Compare(["a", "b"], ["b", "a"]);
-
- drift.HasDrift.ShouldBeFalse();
- drift.Describe().ShouldBe("no drift");
- }
-
- /// A tag the device now offers that nothing binds — the common case after a PLC re-download.
- [Fact]
- public void A_ref_on_the_device_that_is_not_authored_is_reported_as_appeared()
- {
- var drift = TagSetDriftDetector.Compare(["a", "b", "c"], ["a", "b"]);
-
- drift.Appeared.ShouldBe(["c"]);
- drift.Vanished.ShouldBeEmpty();
- drift.HasDrift.ShouldBeTrue();
- }
-
- /// An authored tag the device no longer offers — detectable only for a driver whose discovery
- /// stream is purely device-derived.
- [Fact]
- public void An_authored_ref_missing_from_the_device_is_reported_as_vanished()
- {
- var drift = TagSetDriftDetector.Compare(["a"], ["a", "b"]);
-
- drift.Vanished.ShouldBe(["b"]);
- drift.Appeared.ShouldBeEmpty();
- }
-
- ///
- /// The honesty guard. When a driver re-emits its authored tags into the discovery stream —
- /// FOCAS, TwinCAT and AbCip all do — the discovered set always contains the authored set, so a
- /// vanished tag is structurally invisible. The detector must not compute a misleadingly-empty
- /// Vanished list; it must flag that the direction is undetectable, and say so in the description.
- ///
- [Fact]
- public void When_vanished_is_undetectable_it_is_declared_rather_than_reported_as_empty()
- {
- // "b" is authored and absent from the device — but this driver would have re-emitted it, so its
- // absence here cannot occur in practice and must not be presented as a clean result either way.
- var drift = TagSetDriftDetector.Compare(["a", "c"], ["a", "b"], detectVanished: false);
-
- drift.VanishedDetectable.ShouldBeFalse();
- drift.Vanished.ShouldBeEmpty();
- drift.Appeared.ShouldBe(["c"]);
- drift.Describe().ShouldContain(
- "missing-tag detection unavailable",
- customMessage:
- "a driver that cannot see vanished tags must SAY so — otherwise the absence of a 'missing' "
- + "clause reads as 'nothing is missing', which is the reassurance this whole exercise removes");
- }
-
- /// Comparison is ordinal: a device legitimately exposing both Speed and speed
- /// must not have them collapsed, which would hide a genuinely new tag.
- [Fact]
- public void Comparison_is_case_sensitive()
- {
- var drift = TagSetDriftDetector.Compare(["Speed", "speed"], ["Speed"]);
-
- drift.Appeared.ShouldBe(["speed"]);
- }
-
- /// The signature identifies a drift so an UNCHANGED one is not re-announced every check.
- /// Enumeration order must not make an identical drift look new.
- [Fact]
- public void Signature_is_stable_across_enumeration_order()
- {
- var a = TagSetDriftDetector.Compare(["x", "y", "z"], ["x"]);
- var b = TagSetDriftDetector.Compare(["z", "y", "x"], ["x"]);
-
- a.Signature.ShouldBe(b.Signature);
- }
-
- /// A different drift must get a different signature, or the second one is never reported.
- [Fact]
- public void Signature_changes_when_the_drift_changes()
- {
- var a = TagSetDriftDetector.Compare(["x", "y"], ["x"]);
- var b = TagSetDriftDetector.Compare(["x", "y", "z"], ["x"]);
-
- a.Signature.ShouldNotBe(b.Signature);
- }
-
- /// The description names a couple of refs and then counts, so swapping a whole PLC program
- /// yields a readable message rather than a wall of tag names.
- [Fact]
- public void Describe_samples_rather_than_listing_everything()
- {
- var discovered = Enumerable.Range(0, 50).Select(i => $"tag{i:D2}").ToArray();
- var drift = TagSetDriftDetector.Compare(discovered, []);
-
- var text = drift.Describe();
- text.ShouldContain("50 new on device");
- text.ShouldContain("+48 more");
- text.Length.ShouldBeLessThan(120);
- }
-}