feat(drivers): tag-set drift detection, gated on SupportsOnlineDiscovery
The second thing #516/§8.2 deliberately did not build. The original objection stands — Modbus, S7, MQTT, AbLegacy and Sql echo authored config from DiscoverAsync, so diffing discovered against authored is a tautology for them — but it is now ENCODED rather than used as a reason not to build. The discriminator already existed: ITagDiscovery.SupportsOnlineDiscovery is documented as "enumerates the tag set from the live backend rather than replaying pre-declared/authored tags", and it separates the fleet cleanly — FOCAS, TwinCAT, MTConnect and AbCip true; the five config-echo drivers explicitly false. The check runs only for a driver that is SupportsOnlineDiscovery AND reports the new AuthoredDiscoveryRefs. That is nullable rather than empty-by-default on purpose: an empty collection legitimately means "nothing authored, everything discovered is new", so conflating the two would report total drift for a driver that never opted in. Where the value is: AbCip and FOCAS browse a live backend but implement no IRediscoverable, so before this they had no change signal at all. A periodic compare is the only way to notice a PLC re-download. A finding that changed the design: FOCAS, TwinCAT and AbCip all re-emit their authored tags into the same DiscoverAsync stream as device-derived ones, so the browse picker can show an operator their existing tags. Discovered is therefore always a superset of authored, and a VANISHED tag can never appear missing — one whole direction is structurally undetectable for three of the four. Shipping a Vanished list that is permanently empty for them would have been the half-inert seam this whole exercise removes. So the driver declares DiscoveryStreamIncludesAuthoredTags, the detector does not compute the undetectable half, and the operator message says "missing-tag detection unavailable for this driver" rather than letting the absence of a missing-tag clause read as reassurance. MTConnect is the only driver where both directions work — its stream is purely probe-model-derived. Behaviour: 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; drift that resolves clears the prompt. Interval defaults to 5 minutes — it browses a real device, and a tag set changing is an engineering event, not a runtime one. It reuses the Stage-2 surfacing, raising the same /hosts re-browse prompt, and never rebuilds the served address space. Comparison logic is a pure, separately-tested type rather than actor-inline. 14 new tests. The gate was verified load-bearing by deleting the SupportsOnlineDiscovery half: the 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 never fired. Full suite green except the same three pre-existing fixture-gated integration suites, identical counts.
This commit is contained in:
@@ -38,4 +38,32 @@ public interface ITagDiscovery
|
||||
/// See docs/plans/2026-07-15-universal-discovery-browser-design.md §5.
|
||||
/// </summary>
|
||||
bool SupportsOnlineDiscovery => false;
|
||||
|
||||
/// <summary>
|
||||
/// The device-native references this driver's <b>authored</b> tags bind to — the same vocabulary
|
||||
/// <see cref="DiscoverAsync"/> streams as <see cref="DriverAttributeInfo.FullName"/>. Comparing the
|
||||
/// two sets detects that the remote's tag set has drifted from what an operator authored.
|
||||
/// <para><b>Null means "I do not report this", and drift detection is skipped.</b> 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.</para>
|
||||
/// <para>Only consulted when <see cref="SupportsOnlineDiscovery"/> 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.</para>
|
||||
/// </summary>
|
||||
IReadOnlyCollection<string>? AuthoredDiscoveryRefs => null;
|
||||
|
||||
/// <summary>
|
||||
/// True when <see cref="DiscoverAsync"/> re-emits this driver's <b>authored</b> 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.
|
||||
/// <para><b>This makes one half of drift detection structurally undetectable.</b> 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.</para>
|
||||
/// <para>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.</para>
|
||||
/// </summary>
|
||||
bool DiscoveryStreamIncludesAuthoredTags => false;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user