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.
29 KiB
paths
| paths | ||||||
|---|---|---|---|---|---|---|
|
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. RootCLAUDE.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:
PublishHealthSnapshotdedups 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 byDriverInstanceActorRediscoverySignalTests.- The shared test
StubDriver.GetHealth()returnsDateTime.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.
Degradedmeans connected but some data is bad, and several drivers write health per tag inside the read loop (ModbusDriver.ReadAsync), so one unreadable address latchesDegradedforever against a perfectly reachable device. State-only tripping would reconnect that driver every 90 s indefinitely.LastSuccessfulReadis the honest evidence — preserved acrossDegraded/Faultedwrites, 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 stayHealthyforever; its SDKSessionReconnectHandleris deleted, a lost session publishesBadNoCommunicationonce per outage and the watchdog's re-init is the recovery; good keep-alives + notifications advanceLastSuccessfulRead).CalculationDriverreportsHealthywhile 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) andMqttDriver(_connection) used to overwrite anIAsyncDisposablefield insideInitializeAsyncwithout disposing the old one, so re-initialising withoutShutdownAsyncleaked a socket per attempt — ~360 for an hour-long outage at the 10 s retry interval. Both now dispose first:ModbusDriver.InitializeCoreAsyncreleases_transportand the_probeCts/_reprobeCtsprobe loops before rebuilding them (c3970a2e+ the lifecycle gate in409a58b6), deliberately not via the sharedTeardownAsync()because that would also drop_polland every active subscription with nothing to re-drive them;MqttDriver.InitializeCoreAsynccalls its (idempotent)TeardownAsync()first, which reaches_connectionand 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 newIAsyncDisposablefield on these two, must repeat the dispose-before-overwrite. -
The threshold is hardcoded (
DefaultCommsLossTimeout), matchingHealthPollInterval/DefaultReconnectInterval. Unlike the #523 drift detector this generates no traffic on a healthy system — it is a localGetHealth()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
IDriverConnectionLossSourceraisesConnectionLost(once per connection) when its protocol proves the connection can never recover in place — OpcUaClient: keep-aliveBadSessionIdInvalid/BadSessionClosed/BadSessionNotActivated/BadServerHalted, a SUSTAINED closed connection (2ndBadSecureChannelClosed/BadConnectionClosedin 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) — andDriverInstanceActor(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 byCore.Abstractions.InputQuality(WorstOf/IsUsable). Three things changed with it, all client-visible:- ⚠️ Value nodes of a comms-lost driver now go Bad.
DriverHostActor.FanCommsLossToValueNodesstampsBadNoCommunicationon the raw NodeId and every referencing UNS NodeId, value retained server-side, via the new status-only sink primitiveIOpcUaAddressSpaceSink.WriteStatuses(the host holds no value cache, so only the node can keep the value). ⚠️ Retention is not client-observable: the SDK'sNodeState.ReadAttributenulls the value of any Bad-status read/sample, so clients seeValue = 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 incrementotopcua.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 realStatusCode/SourceTimestampUtc(docs/drivers/Calculation.md,docs/ScriptEditor.md) — so a script that relied on receivingnullfor a dead input no longer runs at all while it is Bad.
- ⚠️ Value nodes of a comms-lost driver now go 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,
Nativeunless pinned. Precedence (one place,AbCipTransportResolver, shared by driver, probe and CLI): explicit per-driver key → node-level appsettingDrivers:AbCip:DefaultTransport(Native|Libplctag, envDrivers__AbCip__DefaultTransport,AbCipDriverDefaultsOptions; a bad value fails host start) → compiledNative. 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 areab_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
Libplctagfor every family;Nativeis 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 failS:/O:/I:andRstatus bits there (incl. the default probeS:0) — itspccc.cneeds explicit file numbers and has no R status-bit mnemonics; since #659LibplctagLegacyNametranslates (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, REN…FD,C…UA) is refusedBadNotSupportedbefore 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 andlogixpcccremain unverified natively. Explicit Native on PLC-5/LogixPccclogs 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/Float32types only — Bool arrays are refused (a one-byte-per-element encode would clobber neighbour bits). - MQTT: Plain
writeTopicwrites 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 typesBadNotSupported). - No driver-side history (#655):
IHistoryProvider,DriverCapability.HistoryRead,HistoryReadStatusExceptionand the OpcUaClient history passthrough are deleted — nothing ever dispatched HistoryRead to a driver; it is served only by the server-wideIHistorianDataSource.isHistorizedon an OpcUaClient tag means OtOpcUa records it, not that upstream history is proxied. A storedResilienceConfigstill carrying aHistoryReadkey parses (diagnostic only). - FOCAS / AbLegacy: background loops are joined (≤ 5 s) on shutdown.
- FOCAS (#257/#266):
cnc_statinfoflags are aStatus/fixed-tree subtree (FIXED:Status/Run…, capability-probed); adjacent PMC tags in one poll batch coalesce into onepmc_rdpmcrng(PmcCoalesceconfig, default on) — a refused range falls back to per-tag reads, a transport failure fails all members without retry. Parts count staysPARAM:6711(docs only). - FOCAS alarms (#647): an
ALARM:address (*,<Type>,<Type>/<n>,<Type>/<lo>-<hi>,T<code>,OTHER) + thealarmobject = ONE condition at the RawPath (either alone is skipped + reported). Overlapping selectors all fire;OTHER= what no type selector claims. ⚠️ A failedcnc_rdalmmsg2THROWS (it used to return[]⇒ mass clear + un-acked re-raise) — and annotates its conditionsBadNoCommunicationonce per outage (quality-only) and degrades driver health withLastSuccessfulReadfrozen, so an alarm-only driver trips the #527 watchdog. ⚠️ A CNC REFUSAL (non-OK rc, transport fine) isBadNotSupported+ 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 — useT<code>there). Read-only; ack server-side.AlarmProjection.Enabledis 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):
NativeAlarmProjectorkeeps its ack andForwardNativeAlarmwrites no second "Activated"/alertsrow — every native driver's told-record dies with its instance, and a deploy respawns the driver. OnlyRetrigger(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 flagAlarmEventArgs.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/alertsrow (every Galaxy reconnect used to duplicate them); a flaggedUnspecifiedon 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 atomicAlarmTransitionKind.ActiveAcknowledged(Galaxy snapshotActiveAcked, OpcUaClient inactive→acked-active) — never Acknowledge (kept a cold host'sActive=false: the alarm was invisible) nor Raise+Acknowledge (an unacked intermediate). Its mirrorInactiveUnacknowledged(#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) — neverUnspecified, which wrote "Activated" for an inactive alarm and left a retained-Active condition Active.NativeAlarmProjectorreturns 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
InitializeAsyncfirst 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 ofShutdownAsynconly; 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 swapsPlcunder_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 unhookOnAlarmEventtoo, 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/DeltaAppliedseam (hostApplyChildDelta,DriverSpawnPlan.ToApplyDelta, the actor's Connected reinit and mid-connectAdoptConfigDuringInit) was unreachable since #516 and is deleted; #663/#664 were latent bugs in it.IDriver.ReinitializeAsyncstays as a per-driver contract the runtime never calls. Every driver'sInitializeAsyncstill 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 isBadOutOfRangewith no health impact. OptionallocalTsap/remoteTsap/connectionTypeoverride the slot TSAP (remoteTsap⊕connectionType). Test Connect reads 2 bytes ofMB0so a PUT/GET-disabled CPU fails the probe. The negotiated PDU size is logged, onS7Driver.GetDiagnostics(), and (#640) on/hostsvia the optionalIDriverDiagnosticsbag (string map onDriverHealthChanged.Diagnostics/ protohas_diagnostics+diagnostics; its digest is in the publish fingerprint). - TwinCAT (#637): TIME/TOD publish Float64 ms, DATE/DT a UTC DateTime; a tag authored
UDIntkeeps the raw counter (and keeps historizing — DateTime is not historized). - TwinCAT (#645, #310/#314): opt-in ADS sum-read (
useSumRead,sumReadMaxItemsdefault 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-indiagnosticsEnabled(default off) puts ADS state/app/online-change/cycle/exec/HeadroomMson the/hostsIDriverDiagnosticsbag (≤ 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 drivingTwinCATDriver—/hostsend-to-end not run (a stock host has no AMS router); the gate found ⚠️ Beckhoff'sSumReadctor throws for aList<>of entities (only an array survives its internal cast) —AdsTwinCATClient.CreateSumReadowns it. Flat browse stays (VirtualTree is a strict subset on TC3). Sum-write not ported. - AbLegacy (#636):
AbLegacyAddress(inAbLegacy.Contracts) is family-aware: PLC-5I:/O:indices are OCTAL and are emitted to libplctag as decimal (libplctag'spccc.cparses decimal only). MicroLogix function files parse but readBadNotSupported. IndirectN7:[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'sDeviceConfig.PlcFamily); an unparseable address readsBadNodeIdUnknownfor 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 atParseOptions.discoveryUrlendpoints go AFTER configured ones; more than one discovered server withoutdiscoveryServerUrifails the connect. Session events are the OTel counterotopcua.driver.opcuaclient.events.HistoryAggregateTypecarries the full Part 13 set; an aggregate the gateway can't serve isBadAggregateNotSupportedper node. - MTConnect (#627/#628): TIME_SERIES reads as a variable-length
double[](Float64 only); a count disagreeing withsampleCountisBadDecodingError. One MTConnect driver = one agent client = at most one/rawDevice (MTConnectMultipleDevicesdeploy error;RawTreeService.CreateDeviceAsyncrefuses a second). - MTConnect CONDITION alarms (#626): a CONDITION raw tag is a native Part 9 condition only when its
TagConfigcarries the standardalarmobject (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-onlyBadNoCommunication(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/currentmust never re-raise (un-ack).AlarmEventArgsgained optionalOpcUaSeverity(exact Part 9 severity) andQualityStatusCode; a quality-only event goes out asAlarmQualityUpdateper node and never through the snapshot projection. - Native condition state survives a full rebuild (#641):
RebuildAddressSpacecaptures each NATIVE condition's live node state (client acks included), the same-apply re-materialise restores it, andCompleteAddressSpaceApply(called byOpcUaPublishActorat the end of every apply) reports ONE event per restored off-normal condition and discards unclaimed captures.NativeAlarmProjectoris pruned per apply, not cleared. Native routing is an exactConditionId == RawPathmatch — AbCip's#activesuffix 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→ trackerRetireand hubEvictignore every later write for that id until it is spawned again (Create→Reinstate,SpawnChild→Readmit). A Watch/Terminatedrelease would not work — the late writes land after the actor terminates.