--- paths: - "src/Drivers/**" - "src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/**" - "src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/**" - "tests/Drivers/**" - "docs/DriverLifecycle.md" - "docs/drivers/**" --- # Drivers: rediscovery signal, comms-loss watchdog + quality contract, AB native transport, per-driver notes > Moved verbatim from the root `CLAUDE.md` (2026-10-13) so it loads only when working in this area. > Root `CLAUDE.md` § "Subsystem guides" indexes it. Edit it here, not in the root file. ### Change Detection `DeployWatcher` (`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/DeployWatcher.cs`) polls the gateway's deploy-event signal and raises `IRediscoverable.OnRediscoveryNeeded` when the Galaxy redeploys. **The signal is consumed as an operator prompt, not as an address-space rebuild** (§8.2, 2026-07-27). `DriverInstanceActor` subscribes in `PreStart` and unsubscribes in `PostStop` — the detach is load-bearing, because the `IDriver` instance outlives the actor when the host respawns a child around the same driver object. A raise is recorded and rides the driver-health snapshot (`DriverHealthChanged.RediscoveryNeededUtc` / `.RediscoveryReason`) to the AdminUI `/hosts` page as a **"re-browse" chip**. ⚠️ **It is advisory: a Galaxy redeploy still does NOT change the served address space.** v3 authors raw tags explicitly through the `/raw` browse-commit flow, so an operator must re-browse the device and commit. Injecting nodes at runtime would materialise nodes nobody approved and no deployment artifact records. Two gotchas if you touch this: - `PublishHealthSnapshot` dedups on a health fingerprint. The rediscovery timestamp **must** stay in that tuple, or a raise on an otherwise-unchanged Healthy driver is swallowed and never reaches the operator. Pinned by `DriverInstanceActorRediscoverySignalTests`. - The shared test `StubDriver.GetHealth()` returns `DateTime.UtcNow`, so its fingerprint differs on every call and the dedup never engages — a test built on it passes vacuously. The rediscovery tests use a stub with stable health for exactly this reason. **#507 is closed as superseded, and the injection path is deleted.** Its retained code read `EquipmentNode.DriverInstanceId` and `EquipmentTags`, both *structurally empty* in v3 (`AddressSpaceComposer.cs`, `DeploymentArtifact.cs`), so removing the guard would have changed a log line and injected nothing. Gone with it: `HandleDiscoveredNodes`, `PartitionDiscoveredByDeviceHost`, `ApplyDiscoveredPlansForDriver`, the redeploy re-inject tail, `DiscoveredNodeMapper`, `AddressSpaceApplier.MaterialiseDiscoveredNodes`, the connect-time discovery loop (`StartDiscovery`/`RediscoverTick`/`DiscoveredNodesReady`/`TriggerRediscovery`), and the 18 `DiscoveryInjectionDormantV3` tests — v2 characterization tests asserting an equipment-rooted graft (`EquipmentRootNodeId == "EQ-1"`) that v3 cannot produce. `ITagDiscovery` itself stays: the `/raw` browse picker drives it through `Commons/Browsing/DiscoveryDriverBrowser`, which never went through the actor. **`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. ⚠️ **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 consumed (Gitea #521, closed).** `DriverInstanceActor` subscribes `OnHostStatusChanged`, pulls `GetHostStatuses()` fresh, and the host set rides `DriverHealthChanged` to the AdminUI `/hosts` page — no SQL: the `DriverHostStatus` table is dropped, and so is `DriverInstanceResilienceStatus` (#524; the live data goes over the `driver-resilience-status` telemetry channel only). **Multi-host dispatch is wired too:** `DriverInstanceActor` resolves the breaker/host key per call via `IPerCallHostResolver` (Modbus, FOCAS, AbLegacy, TwinCAT, AbCip). ### Comms-loss detection: `GetHealth()` is load-bearing (Gitea #527, closed) Until #527, **`DriverInstanceActor.DisconnectObserved` had no production sender.** The only paths into `Reconnecting` were a failed *initial* connect and an operator's Force Reconnect, so a driver that connected and then lost its device stayed `Connected` indefinitely — publishing Bad for every tag, never re-initialising, and **not recovering when the device returned** (live-confirmed: a Modbus driver against a stopped fixture needed a node restart). `PublishHealthSnapshot` read `GetHealth()` for *reporting only*. The `Connected` health tick now runs a watchdog that self-sends `DisconnectObserved` when a driver reports a non-`Healthy` state **and** its `LastSuccessfulRead` has been frozen for 90 s. Things to know before touching it: - ⚠️ **The conjunction is the design — "non-Healthy for N polls" is wrong and was the issue's own proposal.** `Degraded` means *connected but some data is bad*, and several drivers write health **per tag inside the read loop** (`ModbusDriver.ReadAsync`), so one unreadable address latches `Degraded` forever against a perfectly reachable device. State-only tripping would reconnect that driver every 90 s indefinitely. `LastSuccessfulRead` is the honest evidence — preserved across `Degraded`/`Faulted` writes, advanced by any success. Each conjunct has a test that goes red when the *other* is deleted. - **The mechanism rests on every driver reaching a non-Healthy state on sustained failure.** Surveyed across all 12: nine poll drivers via `Degraded`, MQTT via live connection state, Galaxy via its reconnect supervisor, OpcUaClient via a bad session keep-alive (#670 — it used to stay `Healthy` forever; its SDK `SessionReconnectHandler` is deleted, a lost session publishes `BadNoCommunication` once per outage and the watchdog's re-init is the recovery; good keep-alives + notifications advance `LastSuccessfulRead`). `CalculationDriver` reports `Healthy` while running and so can never trip — correct, as it has no transport. Re-check this if a new driver lands. - **The reconnect retry MUST tear down before re-initialising — FIXED in both drivers, keep it that way.** `ModbusDriver` (`_transport`) and `MqttDriver` (`_connection`) used to overwrite an `IAsyncDisposable` field inside `InitializeAsync` without disposing the old one, so re-initialising without `ShutdownAsync` leaked a socket per attempt — ~360 for an hour-long outage at the 10 s retry interval. Both now dispose first: `ModbusDriver.InitializeCoreAsync` releases `_transport` **and** the `_probeCts`/`_reprobeCts` probe loops before rebuilding them (`c3970a2e` + the lifecycle gate in `409a58b6`), deliberately *not* via the shared `TeardownAsync()` because that would also drop `_poll` and every active subscription with nothing to re-drive them; `MqttDriver.InitializeCoreAsync` calls its (idempotent) `TeardownAsync()` first, which reaches `_connection` and the primary-host connection (`2b4b3fcb`). The path is no longer latent — the comms-loss watchdog reaches it, not just a manual Force Reconnect — so a new driver, or a new `IAsyncDisposable` field on these two, must repeat the dispose-before-overwrite. - The threshold is hardcoded (`DefaultCommsLossTimeout`), matching `HealthPollInterval` / `DefaultReconnectInterval`. Unlike the #523 drift detector this generates **no** traffic on a healthy system — it is a local `GetHealth()` read, and only reaches the network once a driver has already stopped reading. - **Fast path for DEFINITIVE losses (#676):** a driver implementing the opt-in `IDriverConnectionLossSource` raises `ConnectionLost` (once per connection) when its protocol proves the connection can never recover in place — OpcUaClient: keep-alive `BadSessionIdInvalid`/`BadSessionClosed`/`BadSessionNotActivated`/`BadServerHalted`, a SUSTAINED closed connection (2nd `BadSecureChannelClosed`/`BadConnectionClosed` in one outage — what a real process stop produces; the SDK never reopens it) or a subscription the SERVER reported dropped (its publishing-stall heuristic does not raise) — and `DriverInstanceActor` (attach PreStart, detach PostStop) turns it into the same disconnect only while Connected; ambiguous failures (timeouts, `BadNoCommunication`, a stalled socket) must never raise and stay with the watchdog's conjunction. `docs/DriverLifecycle.md`. - **What comms loss now does downstream — one contract, six seams (archreview R3-02).** "A value consumer never reports a quality better than its source's" is stated once in `docs/DriverLifecycle.md` §"The quality contract" and implemented by `Core.Abstractions.InputQuality` (`WorstOf`/`IsUsable`). Three things changed with it, all client-visible: - ⚠️ **Value nodes of a comms-lost driver now go Bad.** `DriverHostActor.FanCommsLossToValueNodes` stamps `BadNoCommunication` on the raw NodeId **and every referencing UNS NodeId**, **value retained server-side**, via the new status-only sink primitive `IOpcUaAddressSpaceSink.WriteStatuses` (the host holds no value cache, so only the node can keep the value). ⚠️ Retention is **not client-observable**: the SDK's `NodeState.ReadAttribute` nulls the value of any Bad-status read/sample, so clients see `Value = null` + the Bad status; the retained value is what the next Good re-publish and the `(value, status)` dedup compare against. #481 had wired comms loss to the mux and to Part 9 conditions and stopped one seam short of the external data plane. A consumer that treats Bad as "drop the tag" (ScadaBridge) will see more Bads — that is the fix working. - **Nothing Good is written on reconnect** — recovery is the driver's own re-publish (the poll engine's `forceRaise`, MQTT's new initial push). Instead the host **audits**: refs still latched 30 s after a reconnect log a Warning and increment `otopcua.driver.comms_latched_refs`. Non-zero on that counter means a driver reconnects without re-publishing. - **Every dedup on the recovery path is keyed on (value, status), not value.** A device that comes back usually comes back holding the same reading, so a value-only dedup swallows the recovery and the node stays Bad forever — the v3-gate trap, now closed in the Calculation driver, the virtual-tag actor and (via a connection-loss stale) MQTT's plain-mode suppression. **Any new consumer of driver values must repeat this**; a regression test for it must replay the IDENTICAL value or it proves nothing. - Consequences per seam: MQTT/Sparkplug stale on connection loss and push cached snapshots on every (re)subscribe (`docs/drivers/Mqtt.md`); the Calculation driver and virtual tags **hold** while any input is Bad and inherit the worst input's sub-code, evaluate through Uncertain, and hand scripts the input's real `StatusCode`/`SourceTimestampUtc` (`docs/drivers/Calculation.md`, `docs/ScriptEditor.md`) — so a script that relied on receiving `null` for a dead input no longer runs at all while it is Bad. See `docs/DriverLifecycle.md` §"The comms-loss watchdog" and §"The quality contract". ## AB drivers: native transport (`Transport`) AbCip and AbLegacy have a second, managed wire transport (`Driver.Ab.Transport`) beside libplctag, chosen by the driver-config key `transport` = `Native` | `Libplctag` (string; takes effect when the driver is **constructed** — every process start, or a deploy that recreates it, not `ReinitializeAsync`; AdminUI "Transport" dropdown; CLIs `--transport`). "Default" is encoded by omitting the key; the AdminUI must never re-emit a concrete default. The defaults differ per driver (final review, 2026-10-07): - **AbCip: an absent key resolves to the node default, `Native` unless pinned.** Precedence (one place, `AbCipTransportResolver`, shared by driver, probe and CLI): explicit per-driver key → node-level appsetting **`Drivers:AbCip:DefaultTransport`** (`Native` | `Libplctag`, env `Drivers__AbCip__DefaultTransport`, `AbCipDriverDefaultsOptions`; a bad value **fails host start**) → compiled `Native`. Bound unconditionally because the driver factory AND the admin-node Test Connect probe both read it. ⚠️ So **the upgrade restart alone moves no-key AbCip drivers to Native**; to stay on libplctag, set the node key — NOT a per-driver key through the old AdminUI, whose typed form drops unknown keys (#576). Evidence: AbCip reads proven on a real CompactLogix (Z-35162); scalar native writes (DINT/BOOL/REAL) proven on it once (2026-10-09, user-approved one-off, restored) — and native UDT member writes (DINT/BIT/REAL, #611 gate, same day) and a native STRING write (hw5, same day, restored byte-exact); and a native array-element write (`EM01_PowerUnit._SEQ_EP[31]`, user-approved, an element no alias targets, restored; neighbours unchanged); libplctag member and fragmented writes are `ab_server`-only. #566: a real Logix drops TCP on a connection idled past its timeout (no 0x0107/0x0203); the faulted session reopens on the next request. - **AbLegacy: an absent key resolves to `Libplctag` for every family; `Native` is opt-in** (no node key). On a real MicroLogix 1400 (Z-33323, idle, 2026-10-09) native read S/N/F/B/T/C/R/ST/O/I incl. `S:0`, T/C/R sub-elements and status bits, and ST byte order, all correct, and wrote N/F8/B-bit (restored). libplctag (the default) used to fail `S:`/`O:`/`I:` and `R` status bits there (incl. the default probe `S:0`) — its `pccc.c` needs explicit file numbers and has no R status-bit mnemonics; since #659 `LibplctagLegacyName` translates (`S2:`/`O0:`/`I1:`, `R6:0.CON/13`) and both transports read identically on that press. The authored grammar is unchanged. Read-side only: a libplctag WRITE to any address the translation changes (`S:`/`O:`/`I:` incl. bit writes, R `EN…FD`, `C…UA`) is refused `BadNotSupported` before any runtime is created (none could reach the PLC before — no write-surface widening on the default transport); libplctag T/C status-bit writes it parses itself are unchanged. L/A files, function files, SLC, PLC-5 and `logixpccc` remain unverified natively. Explicit Native on PLC-5/`LogixPccc` logs a once-per-instance Warning. AbLegacy native refuses status-bit sub-element writes (`T4:0.DN`…, `BadNotSupported`). Refusals are not comms faults: an Unsupported refusal (`BadNotSupported`) and an AbCip native **type mismatch** (`BadTypeMismatch`, where libplctag returned Good garbage for a same-width mis-authored type) both keep the tag handle, leave driver health alone (no `Degraded`, nothing for the comms-loss watchdog) and warn once per tag — only genuine transport failures evict and degrade (a runtime `NotSupportedException` too, #611 — it used to latch `Degraded`). AbCip width-checks every scalar **write** against the controller's bytes before sending, on both transports (#611; libplctag used to patch part of its buffer and send it all). Controller-discovered UDT members are writable once browse-committed and authored `ReadWrite` (BIT members included — the controller applies the bit) — native member writes incl. a BIT member are live-gated on Z35162 (2026-10-09); sibling-bit preservation and the libplctag path are ab_server-only; whole-UDT writes are refused `BadNotSupported` by decision (`docs/drivers/AbCip.md` §Member writes). `DeviceSessionPool.Shared` logs through the host logger factory (wired in `DriverFactoryBootstrap`; the FOCAS and TwinCAT factories' `Register(registry, ILoggerFactory?)` take one too, #602 CONV-3). The AbCip `browsePageDelayMs` knob has an AdminUI field (`AbCipDriverForm` "Browse page delay (ms)"). libplctag is confined to `Libplctag*.cs` adapters by `LibplctagConfinementGuardTests`; do not reference it elsewhere. See `docs/drivers/AbCip.md` §Transport, `docs/drivers/AbLegacy.md` §Transport + §Wire evidence, `docs/Configuration.md` §`Drivers:AbCip`, and `docs/plans/2026-10-07-native-ab-transport-gate-results.md`. ## Other driver notes - **S7:** connect attempts back off 1 s doubling to 30 s (one attempt per window, not one per call/poll/probe tick); a write-side access-denied (PUT/GET disabled) latches a **sticky Faulted** that outranks later read passes; driver-side array writes cover non-Bool narrow/`Int32`/`Float32` types only — Bool arrays are refused (a one-byte-per-element encode would clobber neighbour bits). - **MQTT:** Plain `writeTopic` writes publish at QoS 1 (Sparkplug NCMD/DCMD stay QoS 0); the Sparkplug primary-host claim backs off 2 s → 30 s; writes encode the birth's own datatype (unsupported birth types `BadNotSupported`). - **No driver-side history (#655):** `IHistoryProvider`, `DriverCapability.HistoryRead`, `HistoryReadStatusException` and the OpcUaClient history passthrough are deleted — nothing ever dispatched HistoryRead to a driver; it is served only by the server-wide `IHistorianDataSource`. `isHistorized` on an OpcUaClient tag means OtOpcUa records it, not that upstream history is proxied. A stored `ResilienceConfig` still carrying a `HistoryRead` key parses (diagnostic only). - **FOCAS / AbLegacy:** background loops are joined (≤ 5 s) on shutdown. - **FOCAS (#257/#266):** `cnc_statinfo` flags are a `Status/` fixed-tree subtree (`FIXED:Status/Run`…, capability-probed); adjacent PMC tags in one poll batch coalesce into one `pmc_rdpmcrng` (`PmcCoalesce` config, default on) — a refused range falls back to per-tag reads, a transport failure fails all members without retry. Parts count stays `PARAM:6711` (docs only). - **FOCAS alarms (#647):** an `ALARM:` address (`*`, ``, `/`, `/-`, `T`, `OTHER`) + the `alarm` object = ONE condition at the RawPath (either alone is skipped + reported). Overlapping selectors all fire; `OTHER` = what no type selector claims. ⚠️ A failed `cnc_rdalmmsg2` THROWS (it used to return `[]` ⇒ mass clear + un-acked re-raise) — and annotates its conditions `BadNoCommunication` once per outage (quality-only) and degrades driver health with `LastSuccessfulRead` frozen, so an alarm-only driver trips the #527 watchdog. ⚠️ A CNC REFUSAL (non-OK rc, transport fine) is `BadNotSupported` + one Warning and does NOT touch health — else the watchdog reconnect-loops a production control. Health = worst of the value and alarm paths, computed together. The told record lives on the driver INSTANCE (reconnect / in-place re-init re-raise nothing); a deploy RESPAWNS the driver, and its Raise for a still-active alarm is a host-side re-statement (below). Type names are the 16i-era table only (30i codes unconfirmed, not invented — use `T` there). Read-only; ack server-side. `AlarmProjection.Enabled` is a no-op. E2E: `Runtime.Tests/FocasNativeAlarmEndToEndTests` (incl. a respawn). `docs/drivers/FOCAS.md`. - **A Raise on an already-active condition with the SAME message is a RE-STATEMENT (#647 review):** `NativeAlarmProjector` keeps its ack and `ForwardNativeAlarm` writes no second "Activated" `/alerts` row — every native driver's told-record dies with its instance, and a deploy respawns the driver. Only `Retrigger` (or inactive → active) un-acks, so a source whose UPSTREAM is authoritative for the ack sends Retrigger for an unacked re-statement: OpcUaClient on first sight of a condition (no prior told), Galaxy for an unacked active snapshot entry. **Those two also flag `AlarmEventArgs.IsRestatement` (#656)** — history-only: a flagged Retrigger on an Active+unacked condition with the same message AND severity, or a flagged Clear on an inactive one, writes no `/alerts` row (every Galaxy reconnect used to duplicate them); a flagged `Unspecified` on an already-active condition (AbCip ALMA governing-limit drop-out) moves the node's text/severity, keeps active/acked and writes no row. Active+acked is the atomic **`AlarmTransitionKind.ActiveAcknowledged`** (Galaxy snapshot `ActiveAcked`, OpcUaClient inactive→acked-active) — never Acknowledge (kept a cold host's `Active=false`: the alarm was invisible) nor Raise+Acknowledge (an unacked intermediate). Its mirror **`InactiveUnacknowledged`** (#657, OpcUaClient) replaces Raise/Retrigger+Clear (an Active flicker; rows only for what changed). OpcUaClient's first sight of an inactive condition is a flagged InactiveUnacknowledged, or flagged Clear+Acknowledge for inactive+acked (a flagged Acknowledge on an acked condition writes no row) — never `Unspecified`, which wrote "Activated" for an inactive alarm and left a retained-Active condition Active. `NativeAlarmProjector` returns the rows to write; unflagged sources (FOCAS/MTConnect, live Galaxy) are unchanged. E2E: `Runtime.Tests/NativeAlarmRestatementEndToEndTests`. - **Re-Initialize without teardown (#630):** FOCAS, AbLegacy, AbCip, TwinCAT, S7 and OpcUaClient `InitializeAsync` first release the prior device/session state (cancel, ≤ 5 s join, dispose — incl. the AbCip alarm projection; FOCAS's is PAUSED, its told record kept, #647) — the device-state half of `ShutdownAsync` only; the poll engine is kept and the actor re-subscribes after every successful init. AbCip publishes fresh tag tables by reference swap (a second init used to throw "tag name collision"); S7 swaps `Plc` under `_gate`. - **Data and alarm subscriptions are independent in `DriverInstanceActor` (#662):** a data re-subscribe (every deploy's bulk push to a kept driver) detaches DATA only (`DetachDataSubscription`) — it used to unhook `OnAlarmEvent` too, silently killing the native-alarm feed until a respawn. The health-poll reconcile re-attaches/re-subscribes alarms as well as data, so a failed alarm subscribe is retried every tick. - **A config change is a respawn — there is NO in-place path (#665).** The `ApplyDelta`/`ApplyResult`/`DeltaApplied` seam (host `ApplyChildDelta`, `DriverSpawnPlan.ToApplyDelta`, the actor's Connected reinit and mid-connect `AdoptConfigDuringInit`) was unreachable since #516 and is deleted; #663/#664 were latent bugs in it. `IDriver.ReinitializeAsync` stays as a per-driver contract the runtime never calls. Every driver's `InitializeAsync` still serves the config it is handed (Calculation adopts, Galaxy refuses a non-equivalent one — #664), and still releases prior state (#630). `docs/DriverLifecycle.md`. - **S7 (#632/#638):** STRING/WSTRING/CHAR/WCHAR and DTL/DATE/TOD/TIME/S5TIME codecs (`S7ValueCodecs`; TOD/TIME/S5TIME surface as Int32 ms); an undecodable buffer or unrepresentable write is `BadOutOfRange` with no health impact. Optional `localTsap`/`remoteTsap`/`connectionType` override the slot TSAP (`remoteTsap` ⊕ `connectionType`). Test Connect reads 2 bytes of `MB0` so a PUT/GET-disabled CPU fails the probe. The negotiated PDU size is logged, on `S7Driver.GetDiagnostics()`, and (#640) on `/hosts` via the optional `IDriverDiagnostics` bag (string map on `DriverHealthChanged.Diagnostics` / proto `has_diagnostics`+`diagnostics`; its digest is in the publish fingerprint). - **TwinCAT (#637):** TIME/TOD publish Float64 ms, DATE/DT a UTC DateTime; a tag authored `UDInt` keeps the raw counter (and keeps historizing — DateTime is not historized). - **TwinCAT (#645, #310/#314):** opt-in ADS sum-read (`useSumRead`, `sumReadMaxItems` default 200 / ceiling 500, 32 KiB payload budget). It reads raw bytes and decodes as the declared type, so only a scalar passing the strict width+kind check (`CheckNotification`) rides a batch; value-converted pairs (DInt over INT, Real over LREAL/DINT) read singly. Only overall ADS 1793/1794 latch the device to single reads; any other non-comms overall code (1792/1795/1797/1798...) reads that chunk singly for the poll without latching, with a once-per-device Warning; a comms failure neither. Opt-in `diagnosticsEnabled` (default off) puts ADS state/app/online-change/cycle/exec/`HeadroomMs` on the `/hosts` `IDriverDiagnostics` bag (≤ 4 extra reads per device per probe tick once settled; TC2 falls back to `.SYSTEMINFO` / `.SYSTEMTASKINFOARR[1]`, costing 8 on the first tick and after each reconnect). Live-gated read-only 2026-10-08 on Z35474 (TC3) + Z34834 (TC2, which answers sum commands) via a harness driving `TwinCATDriver` — `/hosts` end-to-end not run (a stock host has no AMS router); the gate found ⚠️ Beckhoff's `SumRead` ctor throws for a `List<>` of entities (only an array survives its internal cast) — `AdsTwinCATClient.CreateSumRead` owns it. Flat browse stays (VirtualTree is a strict subset on TC3). Sum-write not ported. - **AbLegacy (#636):** `AbLegacyAddress` (in `AbLegacy.Contracts`) is family-aware: PLC-5 `I:`/`O:` indices are OCTAL and are emitted to libplctag as decimal (libplctag's `pccc.c` parses decimal only). MicroLogix function files parse but read `BadNotSupported`. Indirect `N7:[N7:0]` (#646) is resolved by the driver at read/write time (index re-read every call, never cached; bad index = `BadOutOfRange`; indirect writes are not atomic). One check (`AbLegacyAddress.Validate`) backs the editor (family-independent) and the deploy gate (family from the tag's `DeviceConfig.PlcFamily`); an unparseable address reads `BadNodeIdUnknown` for that tag only. - **OpcUaClient (#635):** OperationLimits are honoured by the SDK session (`SessionClientBatched`) — do not re-add driver-side chunking. Subscription tuning keys are range-checked at `ParseOptions`. `discoveryUrl` endpoints go AFTER configured ones; more than one discovered server without `discoveryServerUri` fails the connect. Session events are the OTel counter `otopcua.driver.opcuaclient.events`. `HistoryAggregateType` carries the full Part 13 set; an aggregate the gateway can't serve is `BadAggregateNotSupported` per node. - **MTConnect (#627/#628):** TIME_SERIES reads as a variable-length `double[]` (Float64 only); a count disagreeing with `sampleCount` is `BadDecodingError`. One MTConnect driver = one agent client = at most one `/raw` Device (`MTConnectMultipleDevices` deploy error; `RawTreeService.CreateDeviceAsync` refuses a second). - **MTConnect CONDITION alarms (#626):** a CONDITION raw tag is a native Part 9 condition only when its `TagConfig` carries the standard `alarm` object (opt-in — alarm tags leave the value subscription, so auto-promotion would break existing String tags). Normal→inactive, Warning→500, Fault→800; one condition per DataItem showing the most severe active nativeCode (ties: ordinal nativeCode, then type); ack is server-side only. ⚠️ UNAVAILABLE never clears: an active condition stays active+acked with a quality-only `BadNoCommunication` (same as #481 transport loss). The driver dedups against what the server was last TOLD (level+nativeCode+nativeSeverity+qualifier — not text), records only while a handler is attached, and drops the record of only the conditions that LEFT the set when the subscribed alarm SET changes (#641) — never on a reconnect of the same set; a replayed `/current` must never re-raise (un-ack). `AlarmEventArgs` gained optional `OpcUaSeverity` (exact Part 9 severity) and `QualityStatusCode`; a quality-only event goes out as `AlarmQualityUpdate` per node and never through the snapshot projection. - **Native condition state survives a full rebuild (#641):** `RebuildAddressSpace` captures each NATIVE condition's live node state (client acks included), the same-apply re-materialise restores it, and `CompleteAddressSpaceApply` (called by `OpcUaPublishActor` at the end of every apply) reports ONE event per restored off-normal condition and discards unclaimed captures. `NativeAlarmProjector` is pruned per apply, not cleared. Native routing is an exact `ConditionId == RawPath` match — AbCip's `#active` suffix was fixed; FOCAS routes since #647 (below). - **Driver removal (#609):** removing a driver releases its capability-invoker pipelines and resilience tracker, *then* evicts its telemetry snapshots. ⚠️ Both are **tombstoned**, not just cleared: the removed child stops asynchronously and its in-flight subscribe/connect still completes through the invoker (and emits Health) on the thread pool *after* `StopChild`, which used to resurrect the state — so a removed driver's counters were republished forever. `Release` → tracker `Retire` and hub `Evict` ignore every later write for that id until it is spawned again (`Create` → `Reinstate`, `SpawnChild` → `Readmit`). A Watch/`Terminated` release would not work — the late writes land after the actor terminates.