Files
lmxopcua/.claude/rules/drivers.md
T
Joseph Doherty 8c6067b62e docs: move subsystem gotchas out of CLAUDE.md into path-scoped .claude/rules
Keeps the root CLAUDE.md lean by lazy-loading per-area detail (drivers, topology,
data-plane auth, alarms, historian) only when matching files are touched.
2026-10-15 06:14:58 -04:00

29 KiB

paths
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 (*, <Type>, <Type>/<n>, <Type>/<lo>-<hi>, T<code>, 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<code> 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.