Files
lmxopcua/.claude/rules/drivers.md
T

41 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. Heidenhain (#831) raises on the event-driven shape this recommends: a reconnect whose R_VR identity (model / NC / PLC version / options) differs from the previous session — no timer, no extra request. 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). Sparkplug *Array metrics read as 1-D arrays onto "isArray": true tags only (#754 — shape mismatch is BadTypeMismatch, malformed body / wrong declared length BadDecodingError); read-only. The driver reads isArray/arrayLength with TagConfigIntent's exact semantics — keep them in step (MqttArrayShapeParityTests).
  • 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).
  • Sql (#753): five providers through ONE map (SqlDialects — factory, probe and browser all read it; they drifted when each had a switch). Odbc is the ANSI profile only (INFORMATION_SCHEMA + FETCH FIRST + positional ? — callers must add parameters in marker order). Named Query tags run inside an always-rolled-back transaction behind the SqlReadOnlyStatement guard; writes are allowWrites + tag "writable": true + a single-row model, as a parameterised UPDATE over gate-canonical identifiers that commits only on exactly one row. docs/drivers/Sql.md.
  • 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 Test Connect (#863): the probe opens no socket of its own — AdsClient.Connect + ReadStateAsync through the LOCAL AMS router, exactly the driver's path. Never derive a host from an AMS Net ID (it is not an IP; the retired pre-check dialled its first four octets on the AMS port — a public stranger — and always timed out). No router ⇒ red "No local AMS router" (no degrade-to-green). docs/drivers/TestConnectProbes.md.
  • 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.
  • OpcUaClient server host-name check (#816): the SDK checks the server certificate's host only with checkDomain: true; its 8-arg DefaultSessionFactory.CreateAsync hard-codes false, so driver, AdminUI browser and Client.Shared never checked before #816. Now certificateValidation.validateServerHostName (tri-state, unset = off = unchanged; AdminUI keeps it unset; stays opt-in by owner decision #837 — the toggle lives in the form's Security panel with the NAT/IPv6-zone/loose-match caveats) / CLI --validate-host-name. It checks the configured host (the rewritten ConfiguredEndpoint.EndpointUrl) and auto-accept accepts a mismatch. ⚠️ The SDK checks in TWO places — DefaultSessionFactory.CreateChannelAsync (GetEndpoints cert, BEFORE any security-mode test, so it fired on None) and Session.OpenAsync — so pass checkDomain only for a non-None endpoint (OpcUaEndpointSelection.CheckDomainFor). Any new session-create call site must do that — the overload without the argument silently disables the check. Zone-qualified IPv6 hosts never match (zone compared); the match is loose (first label alone counts). The rewriter keeps an IPv6 zone index verbatim (#819; .NET Uri drops it from every re-serialisation). docs/drivers/OpcUaClient.md.
  • 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 Java-parity review (#804): a 2.x Agent serves its error documents under non-2xx (OUT_OF_RANGE = HTTP 400, NO_DEVICE = 404); the client lifts them (≤ 64 KiB read) into the refusal-marked InvalidDataException — before #804 the 400 was a transport fault and the OUT_OF_RANGE re-baseline never ran live. The restart (instanceId) test also runs on every re-baseline snapshot (on 2.x that is where a restart is first seen). 3 re-baselines with no /sample chunk ⇒ Degraded with LastSuccessfulRead still advancing (no watchdog reconnect loop over a SampleCount > bufferSize — live: count == bufferSize is accepted; Test Connect + init warn, #825; the adaptive raised count is clamped to the known bufferSize, #846). Every parse prohibits DTDs; the streams walk is depth-bounded (64 — an unbounded recursion is a process-killing StackOverflowException). Non-finite floats are never Good; integer-valued decimals serve integral tags losslessly. Matrix: docs/drivers/MTConnect.md.
  • MTConnect empty value (#824, Java parity): an empty/whitespace observation on a string-typed (or unauthored) item is Good ""; on a non-string item (numeric/bool/DateTime/array) it stays BadNoCommunication (no value — not BadTypeMismatch). Only the literal UNAVAILABLE is the no-value sentinel on a string item; it, the comms-loss fan-out and the condition path are unchanged. A ScadaBridge string tag that used to go Bad on a blank value now receives Good "".
  • MTConnect refused-connection fast path (#846): a /sample/re-baseline failure whose chain holds SocketError.ConnectionRefused, unbroken for CommsLossConfirmMs (default 2000; 0 = first; negative = off), publishes every subscribed value BadNoCommunication once per outage; the first delivery afterwards republishes all of them from the index. Health/LastSuccessfulRead are untouched by it (the stream failure already degraded), so the #527 watchdog still re-inits at 90 s. Timeouts/resets/DNS/pause stay watchdog-only — never widen the definite set to them. #852: the same moment annotates every TOLD native condition quality-only BadNoCommunication (state/ack kept, recorded in the told record; one already UNAVAILABLE-Bad is skipped), and recovery re-diffs ALL subscribed conditions (EmitConditionTransitions(null, resync: false)) so unchanged ones get a quality-only Good. AdminUI field "Comms-loss confirm (ms)" is ALWAYS written (a negative = off must never be dropped like a non-positive timing knob — that would re-enable the 2000 default). Live gate: a plainly docker stopped Agent drops out of Docker DNS ⇒ HostNotFound (ambiguous) — use docker-dev/docker-compose.mtconnect-gate.yml (Agent in the adapter's netns ⇒ real refusal).
  • 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.
  • Heidenhain LSV2 (#803): read-only at three layers — no IWritable, the HeidenhainTagWritable deploy gate, and Lsv2ReadOnlyPolicy (payload-checked in Lsv2Codec.Frame, the only frame builder; logins + R_VR/R_PR/R_RI/R_MB/R_DP only). Never widen the allowlist to work around a controller refusal. Init opens nothing (acquisition starts on the first subscribe/read); reads are cache-only; a refusal/denial/decode error is per-tag quality and does NOT degrade health — only a down session does (with LastSuccessfulRead frozen). Passwords are NAMESPACED refs only — secret:heidenhain/… / env:OTOPCUA_HEIDENHAIN_…, enforced at parse, resolve, save path, probe and deploy (an unscoped ref let Test Connect exfiltrate any node secret in cleartext A_LG); a bad optional DNC/DATA credential is BadConfigurationError on that role's reads only (HeidenhainCredentialPersisted). Hardware-unproven: the in-process simulator + Java golden vectors are the evidence; Category=LiveIntegration (HEIDENHAIN_LSV2_HOST) is the commissioning gate. docs/drivers/Heidenhain.md.
  • Heidenhain alarms (#831): an ALARM: selector (*, OTHER, CLASS=<r>[/GROUP=<r>][/NUMBER=<r>], <r> = n | lo..hi) + the alarm object = ONE condition at the RawPath (either alone is REFUSED by the factory, so editor/save path/CSV/deploy agree; data type fixed Boolean). Opt-in per tag, no driver flag (FOCAS #647 precedent); Alarms/* arrays unchanged. Walked on Polling.AlarmMs (2 s) by the control's single loop with the SAME R_RI 27/28 — the allowlist is unchanged. FOCAS transition rules; identity = class+group+number+TEXT (numbers repeat, -1). A failed walk (refusal/overflow/comms) is quality-only, never a clear. ⚠️ Ack is LOCAL-ONLY (AcknowledgeAsync no-op success) — never add an LSV2 ack/write to the allowlist for it. Class/group codes have no invented SEVERITIES; since #839 they carry HEIDENHAIN DNC-interface names (labels + browse leaves only — corroborated by one iTNC 530 record, else hardware-unproven) and set the Part 9 ConditionClass (class 5 ⇒ Safety; group 1/2 ⇒ Process; 4/5 ⇒ System; else Base) via the new optional AlarmEventArgs.ConditionClass (projector keeps it across class-less events; node manager writes it; rebuild carries it). The browse picker offers AlarmConditions/ selector leaves; alarms no selector claims are counted (otopcua.driver.alarms.unmatched, /hosts <host>.alarms.unmatched[Total]) + rate-limited Warning.
  • Credential references are namespaced per driver (#815): ONE table (Core.Abstractions.DriverCredentialPolicy) + ONE parser/resolver (CredentialReferences) for Galaxy (secret:galaxy/·secret:otopcua/galaxy/·env:OTOPCUA_GALAXY_· env:GALAXY_MXGW_), OpcUaClient (secret:opcuaclient/·env:OTOPCUA_OPCUACLIENT_) and Heidenhain. Enforced at save (DriverConfigSaveGate), deploy (CredentialReferenceOutOfScope), Test Connect (AdminOperationsActor, before the probe), browse (BrowserSessionService, before the browser) and at resolve time before any store/env read; file: is refused everywhere. A NEW driver that resolves a secret:/env: field and sends it to an authored endpoint MUST add a row there and resolve through CredentialReferences — never ISecretResolver.GetAsync on a raw authored name. ⚠️ Deprecated warn-only window: a RUNNING Galaxy (init, every ReopenAsync gateway reconnect, ack fallback) or OpcUaClient (session open) driver passes its LegacyCredentialReferenceTracker and so still resolves a pre-#815 out-of-namespace ref with the OLD semantics + a rate-limited Warning + a credential-compat:<field> diagnostics entry; browsers/probes pass none (hard-fail), Heidenhain never has one. Chosen by call site — do not add a global flag, and do not hand the tracker to an admin-node path. Upgrade preflight: CredentialReferencePreflightService + the /raw banner. docs/security.md §"Credential references are namespaced per driver".
  • …and per instance (#835): a secret:<ns>/<X>/… whose first segment X is ANOTHER same-type driver's id or name (case-insensitive; another-instance match beats an own-name match) is refused at save (DriverConfigSaveGate.ValidateAsync), deploy (CredentialReferenceOtherInstance), Test Connect (TestDriverConnect.DriverInstanceId) and browse (OpenAsync(…, driverInstanceId, …); no ConfigDb ⇒ fail closed). One rule: DriverCredentialPolicy.DescribeOtherInstanceProblems
    • DriverInstanceIdentities. The runtime adds no check (drivers don't know the fleet). Since #849 env:<PREFIX><INSTANCE>_… is instance-scoped the same way (INSTANCE = id/name upper-cased, other chars _; _-bounded prefix match); an id-shaped part (DRV_<12 HEX>, secret:…/DRV-<12 hex>/) matches by id only, and id-shaped names (drv, DRV-<12 hex>…) are refused. #848: create/rename refuse a name another driver's stored reference uses, and a rename away from a name the driver's own references use (DescribeNameChangeProblemsAsync). ⚠️ Test Connect/browse configs can carry UNSAVED edits: the claimed owner counts only when every endpoint value (DriverCredentialRules.EndpointFields) is one that driver stores (EffectiveOwnerAsync, server-side) — a new credential-sending driver must declare its endpoint keys there. Credential-carrying Test Connect/browse is audited (CredentialUseAudit).
  • Key-material file paths (#836): OpcUaClient UserCertificatePath is a RELATIVE path inside Drivers:OpcUaClient:UserCertificateDirectory (DriverCredentialPolicy path field + CredentialFilePaths, all seams; load-time ResolveWithin = lexical + link-resolved containment, before the PFX password resolves). LoadCertificate refuses an unresolved (relative) path — build identities only from ResolveSecretRefsAsync output. A running driver's pre-#836 ABSOLUTE path stays warn-only (LegacyCredentialReferenceTracker.ReportPath).
  • 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.