Files
Joseph Doherty 1ea1da8236 refactor(runtime): delete the dead in-place ApplyDelta seam; config change = respawn only (#665)
Since #516 a DriverConfig change is stop + respawn, so DriverSpawnPlan.ToApplyDelta
was always empty and the in-place path had no producer. #663 and #664 were both
latent bugs in it. This deletes it:
- host: ApplyChildDelta, _pendingDelta, HandleApplyResult and HandleDeltaApplied
- plan: DriverSpawnPlan.ToApplyDelta
- actor: the ApplyDelta, ApplyResult and DeltaApplied messages, the Connected
  reinit handler and the mid-connect AdoptConfigDuringInit
- tests: the actor tests that drove the seam

IDriver.ReinitializeAsync and each driver's implementation and unit tests stay.
Comments and docs that cited AdoptConfigDuringInit as the reason a driver must
release prior state at init now cite the IDriver contract and overlapping
retries, which still apply.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-09 11:23:16 -04:00

45 KiB
Raw Permalink Blame History

MQTT Driver

In-process MQTT broker-subscribe driver. It holds one MQTTnet 5 client against one broker, subscribes to the topics its authored tags name, and forwards each PUBLISH straight to ISubscribable.OnDataChange. Ingest is push, not poll; writes go back out as ordinary publishes (see Write-through).

Two ingest shapes, selected by the driver's Mode: Plain binds a tag to a concrete MQTT topic; SparkplugB binds it to a group / edge node / device? / metric tuple and decodes Eclipse-Tahu protobuf under one spBv1.0/{GroupId}/# subscription. Both run over the same single client.

See Mqtt-Test-Fixture.md for the harness and ../plans/2026-07-15-mqtt-sparkplug-driver-design.md for the design.

Status. P1 (plain MQTT), P2 (Sparkplug B ingest), write-through (#508) and the Sparkplug primary-host claim (#511) are all shipped and live-gated against the Mosquitto + Sparkplug-simulator fixture. See Known gaps — every entry there carries a tracking issue.

Project Layout

Project Holds
Driver.Mqtt.Contracts Config records, enums, MqttTagDefinition + the pure MqttTagDefinitionFactory, and the one shared MqttJson.Options. Deliberately transport-free — no MQTTnet reference
Driver.Mqtt Runtime: MqttDriver, MqttConnection (connect + reconnect supervisor), MqttSubscriptionManager, LastValueCache, SparkplugIngestor, SparkplugPrimaryHost, factory registration, MqttDriverProbe
Driver.Mqtt.Transport MqttClientOptionsFactory — TLS posture, PEM CA-pin chain validator, credentials, protocol mapping. Connection policy only; nothing here connects
Driver.Mqtt.Browser MqttDriverBrowser + MqttBrowseSession — the AdminUI address picker's #-observation session

.Browser depends on the shared leaves only — .Contracts + .Transport, never the runtime .Driver. It used to reference .Driver under a documented exception; #512 removed it via two extractions (the client-options builder to .Transport, and the Sparkplug protocol layer — SparkplugTopic, SparkplugCodec, RebirthRequester — to .Contracts, beside the Tahu wire schema they encode). MqttBrowserLayeringTests guards the graph in both directions.

There must stay exactly one copy of the TLS/CA-pin logic. A second copy would not break the build — it would silently diverge, browse quietly not honouring a pinned CA while the driver does.

Capability Surface

public sealed class MqttDriver
    : IDriver, ITagDiscovery, ISubscribable, IReadable, IWritable, IHostConnectivityProbe, IRediscoverable,
      IAsyncDisposable, IDisposable
Capability Entry point Notes
IDriver InitializeAsync / ReinitializeAsync / ShutdownAsync / GetHealth Order register → attach → connect is contractual. ReinitializeAsync applies a tag-only delta in place, and rebuilds the session when the broker identity changed
ITagDiscovery DiscoverAsync Replays only the authored rawTags into an Mqtt folder. SupportsOnlineDiscovery => false. RediscoverPolicy is Once in Plain and UntilStable in Sparkplug (half of what a Sparkplug tag needs — its datatype — is only knowable from a birth)
ISubscribable SubscribeAsync / OnDataChange One SUBSCRIBE per distinct authored topic. publishingInterval is ignored — MQTT is push-based; this driver neither polls nor throttles
IReadable ReadAsync Serves the last received/retained value from LastValueCache; never throws. A never-seen tag reads BadWaitingForInitialData
IWritable WriteAsync Publishes to the tag's writeTopic (Plain) or as an NCMD/DCMD command (Sparkplug). One status per request; a tag with no write destination is refused BadNotWritable. See Write-through
IHostConnectivityProbe GetHostStatuses 1-second poll of MqttConnection.State; HostName is "{host}:{port}"
IRediscoverable OnRediscoveryNeeded Nothing raises it in Plain mode. Sparkplug raises it when a birth for an authored scope carries a metric-name set different from the last one seen for that scope — both conditions are anti-storm, see Rediscovery

Not implemented: IPerCallHostResolver, IAlarmSource. Materialized nodes are IsAlarm: false, IsHistorized: false. A node is SecurityClassification.Operate when its tag declares a write destination and ViewOnly when it does not — the browse-time answer comes from the same encoder the write path uses, so a node can never advertise writability it would then refuse.

Driver-type string: Mqtt (DriverTypeNames.Mqtt).

Configuration

Bound through MqttJson.Options — case-insensitive, enums by name, unknown members skipped.

The whole connection is authored on the DRIVER (DriverConfig), via MqttDriverForm in the /raw driver-config modal. Unlike Modbus / S7 / OPC UA Client, MQTT does not use the v3 endpoint→DeviceConfig split: an MQTT driver instance holds exactly one broker session, so MqttDeviceForm is informational (the same shape as GalaxyDeviceForm) and devices under an MQTT driver are pure organisational grouping in the RawPath.

Why not the device. DriverDeviceConfigMerger merges a device's keys up to the top level only when the driver has exactly one device. A device-authored broker connection therefore disappears from the merged blob the moment a second device is added — a silent outage with nothing to see at deploy time. Driver-level authoring is correct for 0, 1 or N devices. A pre-form deployment that put the connection on a sole device still works (merge-up wins over the driver's keys) and MqttDeviceForm flags those keys so the mismatch is visible; move them to the driver.

DriverDeviceConfigMerger still merges driver + device and injects rawTags.

// DriverConfig — authored by MqttDriverForm. PascalCase keys, enums as names.
{
  "Host": "10.100.0.35",
  "Port": 8883,
  "UseTls": true,
  "AllowUntrustedServerCertificate": false,
  "CaCertificatePath": null,      // pins the chain; null = OS trust store
  "Username": "otopcua",
  "Password": "",                 // never commit
  "ProtocolVersion": "V500",
  "CleanSession": true,
  "KeepAliveSeconds": 30,
  "ConnectTimeoutSeconds": 15,
  "ReconnectMinBackoffSeconds": 1,
  "ReconnectMaxBackoffSeconds": 30,
  "MaxPayloadBytes": 1048576,
  "Mode": "Plain",
  "Plain": { "TopicPrefix": "", "DefaultQos": 1 }
}

// DeviceConfig — empty; MQTT has no per-device endpoint.
{}

Sparkplug sub-object (Mode: "SparkplugB")

{
  "Mode": "SparkplugB",
  "Sparkplug": {
    "GroupId": "OtOpcUaSim",            // REQUIRED — the driver's whole subscription scope
    "HostId": "",                       // Host Application identity — spBv1.0/STATE/{HostId}
    "ActAsPrimaryHost": false,          // claim the primary host id (Primary-gated; see below)
    "RequestRebirthOnGap": true,        // a seq gap asks the edge node to re-announce (NCMD)
    "BirthObservationWindowSeconds": 15 // how long discovery collects births before calling it stable
  }
}
Key Default Notes
GroupId "" Required. The driver subscribes exactly spBv1.0/{GroupId}/#. Blank ⇒ it connects, reports Healthy, and ingests nothing. It is a literal topic segment, so /, +, # are refused by the form
HostId "" Sparkplug Host Application identity — the spBv1.0/STATE/{HostId} topic. Optional for observation; required to claim. Set without ActAsPrimaryHost, the driver only observes
ActAsPrimaryHost false Claim the Sparkplug Primary Host Application identity. Primary-gated — only the node holding the redundancy Primary role claims it. See Primary host
RequestRebirthOnGap true On a detected sequence-number gap, publish a rebirth-request NCMD instead of running on stale metric state
BirthObservationWindowSeconds 15 Discovery's birth-collection window. Must be ≥ 1

MqttDriverForm authors all five in Sparkplug mode. The sub-object is merged, never replaced (a key a newer driver adds inside it survives an older AdminUI), and in Plain mode it is not touched at all — so flipping a driver to Plain to look at it and back does not lose the group id. The form never writes RawTags; the deploy artifact owns it.

This was a live-gate finding. Through the P2 implementation MqttDriverForm still carried its P1 placeholder — switching Mode to SparkplugB rendered a "not available yet" notice and no group-id field, so once Sparkplug ingest shipped there was no way to author a Sparkplug driver from the AdminUI at all. Pinned by MqttDriverFormModelTests' Sparkplug cases.

Every key has a default, so nothing is strictly required by the binder. Notable ones: port 8883, useTls true, protocolVersion V500, cleanSession true, keepAliveSeconds 30, connectTimeoutSeconds 15, reconnectMinBackoffSeconds 1 / reconnectMaxBackoffSeconds 30, maxPayloadBytes 1 MiB, allowUntrustedServerCertificate false.

⚠️ Leave clientId unset on a redundant pair. Both nodes of a pair run the driver. MQTT requires client ids to be unique per broker, so a fixed clientId makes the two nodes evict each other forever — the broker logs Client <id> already connected, closing old connection and both nodes reconnect every few seconds while still reporting Healthy. Unset (the default) lets MQTTnet generate a unique id per connection, which is correct. This was observed live during the P1 gate. (The probe and browser already self-uniquify with -probe- / -browse- suffixes.) MqttDriverForm defaults the field blank and raises this warning inline the moment a value is typed.

The form validates every knob against the driver's own [Range] bounds (port 1–65535, keep-alive / connect-timeout / backoffs ≥ 1 s, max-backoff ≥ min-backoff, maxPayloadBytes ≥ 1, QoS 0–2) and additionally clamps on serialize, so an operator who ignores the inline error still cannot persist a connectTimeoutSeconds: 0 driver-brick.

Tag Configuration

A tag binds in one of two shapes, and which one is decided by which keys are present, not by a mode key on the tag. MqttTagDefinitionFactory reads the Sparkplug tuple when any Sparkplug descriptor key is set, and the topic otherwise; the AdminUI's "Tag shape" selector infers the same way on reopen. The key names are single-sourced in MqttTagConfigKeys so the browse-commit mapper and the factory cannot drift.

Plain shape

Key Type Default Notes
topic string — Required. Absent/blank rejects the tag ⇒ BadNodeIdUnknown
payloadFormat Json | Raw | Scalar Json Strict — a typo'd value rejects the tag
jsonPath string "$" Absent or empty ⇒ document root. Json only
dataType DriverDataType String Strict. Float64, not Double — see below
qos int 0–2 driver's defaultQos Strict — a malformed value rejects rather than silently weakening delivery
retainSeed bool true Seed this tag from the broker's retained message on subscribe

There is no mode key — the AdminUI's "Tag shape" selector is UI-only, inferred from the blob and never serialized.

dataType uses DriverDataType, which has Float64 and has no Double. Authoring "Double" is a strict-enum rejection, not a fallback.

Payload handling: Json selects via JSONPath then coerces (miss ⇒ BadDecodingError, coerce fail ⇒ BadTypeMismatch); Raw returns the payload's UTF-8 text verbatim and ignores dataType; Scalar decodes then coerces. The JSONPath subset is $, $.a.b, $['a'], $.a[0] — no filters, slices, wildcards or recursive descent.

Wildcards

The runtime accepts a +/# topic (it is ambiguous, not unparseable) and the deploy-time Inspect pass warns. The AdminUI editor rejects it outright — the one place the editor is deliberately stricter than the driver, because one Tag holds one value and a wildcard would feed it from many source topics. Wildcard tags are indexed by filter, not by concrete topic, so they survive a reconnect; a message fans out to every matching tag, and unauthored topics are dropped.

Sparkplug shape

Key Type Default Notes
groupId string — Required. Must match the driver's Sparkplug.GroupId to ever receive a message
edgeNodeId string — Required.
deviceId string absent Omit for a metric published by the edge node itself (NBIRTH/NDATA). Absent is meaningfully different from ""
metricName string — Required. The tag's stable binding key across rebirths. May contain / — Node Control/Rebirth and Properties/Serial Number are canonical Sparkplug names, and the whole name is one value, never split. Also how a flattened structured member is addressed: Motor/Speed, Recipe/Target/0 (see Structured metrics)
dataType DriverDataType from the birth Optional override. Absent ⇒ take whatever type the birth certificate declared. The AdminUI's "(from birth certificate)" option removes the key entirely
isHistorized / historianTagname — — Server-side historization, unchanged from Plain

payloadFormat, jsonPath, qos and retainSeed are Plain-only and meaningless here: a Sparkplug body is protobuf, and the subscription is one driver-wide spBv1.0/{GroupId}/#, not a per-tag filter.

groupId / edgeNodeId / deviceId are literal MQTT topic segments, so the AdminUI editor refuses /, + and # in them — a decoded incoming id can never contain one, so such a tag could never bind. metricName is deliberately exempt: it is not a topic segment.

// A device metric.
{"groupId":"OtOpcUaSim","edgeNodeId":"EdgeA","deviceId":"Filler1","metricName":"FillCount"}

// A node-level metric — note deviceId is ABSENT, not blank.
{"groupId":"OtOpcUaSim","edgeNodeId":"EdgeA","metricName":"Temperature"}

// A flattened Template member, and a flattened DataSet cell. `Motor` and `Recipe` are what the wire
// carries; nothing on the wire is ever called by these names.
{"groupId":"OtOpcUaSim","edgeNodeId":"EdgeA","deviceId":"Filler1","metricName":"Motor/Stator/Temp"}
{"groupId":"OtOpcUaSim","edgeNodeId":"EdgeA","deviceId":"Filler1","metricName":"Recipe/Target/1"}

Sparkplug B Ingest

One subscription (spBv1.0/{GroupId}/#) feeds a per-edge-node state machine (SparkplugIngestor + SparkplugCodec / AliasTable / BirthCache / SequenceTracker):

  • Births are the only self-describing message. NBIRTH/DBIRTH name every metric and assign its alias; NDATA/DDATA carry an alias and no name at all. So the alias table is rebuilt per birth and tags bind by name, never by alias — aliases are per-birth and may be reused for a different metric after a rebirth.
  • NBIRTH is node-scoped, DBIRTH is device-scoped, and routing a DBIRTH through the node path wipes bdSeq. They are handled separately on purpose.
  • Sequence tracking: seq is a wrapping 0–255 counter. A detected gap raises a rebirth request when RequestRebirthOnGap is on, rather than silently continuing on stale metric state.
  • Death → STALE. An NDEATH whose bdSeq matches the live session (the bdSeq tie is what stops a stale death from killing a fresh session) marks every metric under that edge node Bad / BadNoCommunication. A DDEATH does the same for one device. The next birth restores them.
  • Structured metrics flatten to sibling tags — Template and DataSet; see Structured metrics.
  • Unsupported datatypes are skipped with a warning, never guessed: PropertySetList, Unknown, extension values, and any Template/DataSet that cannot be flattened honestly. Int8→Int16 and UInt8→UInt16 widen; *Array variants map to the element type with the array bit set.
  • Ingest state is reset at every session/authoring seam — a reconnect or a ReinitializeAsync must not carry a previous session's alias table into a new one.

Rediscovery (Sparkplug)

OnRediscoveryNeeded fires only when a birth is for an authored scope and its metric-name set differs from the last one seen for that scope (order-insensitive, ordinal). Both conditions are anti-storm, not optimisations: rediscovery triggers an address-space rebuild, and an edge node re-births freely — on its own schedule, on every reconnect, and once per rebirth NCMD.

⚠️ The signal is an operator prompt, not a rebuild. DriverInstanceActor subscribes and the raise rides the driver-health snapshot to the AdminUI /hosts page as a re-browse chip — but the served address space still comes from the deploy artifact, so a DBIRTH introducing a new metric does not change the OPC UA tree. Re-browse the device and commit. That is deliberate: v3 authors raw tags through the /raw browse-commit flow, and grafting nodes at runtime would materialise ones nobody approved and no deployment artifact records.

Structured metrics (Template / DataSet / PropertySet)

Sparkplug metrics are not all scalars. SparkplugFlattener (#515) turns the structured ones into addressable tags:

Metric kind Mapping Example
Template (UDT instance) members become sibling metrics, /-joined, recursively Motor → Motor/Speed, Motor/Stator/Temp
DataSet one metric per cell, {column}/{rowIndex} Recipe → Recipe/Step/0, Recipe/Target/1
PropertySet (a metric's properties) node metadata, never tags engUnit, Range/Low

Flattened members are authored exactly like any other Sparkplug metric — the composed name goes in metricName. The browse picker offers them directly (the structured parent is deliberately not offered: it has no value and no servable datatype, so a tag committed against it could never read).

Why flattening rather than OPC UA Structures. Owner decision, 2026-07-31. Structures are truer to Sparkplug's model but much harder to consume — flattened scalars are readable by any OPC UA client with no type dictionary, and the result mirrors how the /raw tree already works.

Things worth knowing before you touch this

  • The row index is always present, including on row 0. Recipe/Target/0, never Recipe/Target. Emitting a bare column name for a single-row table and an indexed one otherwise would make a tag's name depend on how many rows the plant published — so a table growing from one row to two would rename every tag already authored against it.
  • A DATA metric carries the parent's alias and no name, so member names cannot be composed until the alias table has resolved the parent. Flattening at decode time alone is not enough; the expansion happens in SparkplugIngestor.PublishStructuredMembers, after resolution.
  • Member datatype falls back to the birth's. A DDATA template may omit its members' datatypes. Guessing from the CLR type of the wire value would read a signed Int32 of −42 out of its unsigned field as 4294967254 — plausible, Good, and wrong.
  • Skip-and-warn survives, and refusal is whole. A template definition (is_definition), an unnamed member, an extension value, a column/type count mismatch, a short row, or a payload past MaxDepth (5) / MaxMembers (512) refuses the entire metric rather than publishing part of it. A silently half-flattened UDT looks exactly like a complete one in the picker.
  • Member paths contain /, which RawBrowseCommitMapper.ToRawSegment (#509) already handles: the slashed name travels intact as metricName while the derived tag Name is coerced to a legal RawPath segment, with a collision guard for two members that would sanitise onto one name.
  • The rediscovery signature tracks members, not the parent. A UDT gaining or losing a member leaves the parent metric's name identical, so a gate built on raw wire names would never move.

PropertySet is projected as metadata rather than as sibling tags because it describes a metric (units, ranges, quality hints) — surfacing it as tags would invent plant signals out of documentation. PropertySetList and property extension values are dropped rather than stringified.

Primary host (actAsPrimaryHost)

A Sparkplug Primary Host Application is the thing edge nodes watch to decide whether anyone is listening — many stop publishing while their host is offline. Claiming it is a three-part contract, and this driver ships all three or none of it (#511):

  1. a retained {"online":true,"timestamp":…} on spBv1.0/STATE/{hostId}, QoS 1, published after CONNACK;
  2. a matching retained OFFLINE armed as the MQTT Last Will, so a crash still reports the truth;
  3. an explicit OFFLINE on a clean stand-down, so edge nodes do not wait out a keep-alive timeout.

A claim the broker keeps refusing backs off (STAB-27): 2 s, 4 s, 8 s … capped at 30 s between attempts, one Warning per attempt, reset by a successful claim or a stand-down. The role is still sampled every second.

Set Sparkplug.HostId and Sparkplug.ActAsPrimaryHost: true. A blank host id is refused with a warning — the driver does not publish to spBv1.0/STATE/, which is a legal topic reaching nobody.

The claim is a whole connection, not a message

A Will can only be registered in the CONNECT packet. It cannot be added to a live session, changed, or withdrawn. So the claim gets its own broker connection (client id {clientId}-spbhost, forced clean-session) whose lifetime is the claim: opened on gaining Primary, closed on losing it. Every leg then follows from the connection's lifecycle rather than from bookkeeping that could drift.

The ingest session is untouched by a role change — no dropped subscriptions, no lost birth cache, and no data gap at the Secondary→Primary transition, which is the moment a plant most needs values flowing.

Primary-gated — and why the obvious alternatives are wrong

Both nodes of a redundant pair subscribe independently (MQTT fan-out is per-connection, and the Primary gate applies downstream of ingest), so an ungated claim means both claim the same host id. The sharp failure is not the two fighting over the retained STATE. It is that the Secondary's death fires its Will, publishing OFFLINE while the Primary is perfectly healthy — every edge node in the group stops publishing, and redundancy has caused the outage the mechanism exists to prevent.

Registering the Will only on whichever node is Primary at connect time fails the other way: a node that later gains Primary would publish ONLINE with no Will behind it, and a crash would leave a retained ONLINE asserting forever that a dead host is alive.

Hence: bind the claim to a connection, and rebuild the connection on role change.

Node state STATE connection Retained STATE
Primary open, Will armed ONLINE
Secondary / Detached never opened — (whatever the Primary published)
Role unknown, driver peer present not opened (fail closed) —
Role unknown, no driver peer (single node) open ONLINE

The verdict comes from PrimaryGatePolicy.ShouldServiceAsPrimary via IDriverPrimaryRoleSource (Core.Abstractions), published by DriverHostActor at host start, on every cluster membership change and on every redundancy snapshot, and sampled on a 1-second timer rather than evented — a process-singleton raising events at per-instance drivers outlives every driver it holds a reference to.

⚠️ "Driver peer present" is this node's own cluster view. Before the node has joined, it sees no members, which is the single-node row: a node cold-starting into a pair whose peer is already Primary can open the claim until its join completes (seconds; up to the bootstrap guard's 25 s partner probe), so both nodes may briefly hold it (review R6 MINOR-2).

Accepted trade: a brief edge-visible OFFLINE during failover, while the old Primary stands down and the new one connects and republishes. That is a genuine state transition — for that window there really is no host — not a glitch worth hiding.

Operational notes

  • At most one primary host per host id per broker. Two separate OtOpcUa deployments pointing at one broker with the same hostId will fight, and nothing in either can detect the other. The gate covers a redundant pair, not two independent deployments.
  • A reconnect republishes the birth. A broker restart drops retained messages; without the republish the host would be connected and believed offline by everyone, silently.
  • A failed claim never faults the driver. A broker whose ACL denies spBv1.0/STATE/# costs a warning and a plant-visible missing host, not every tag's value. Ingest is the data plane; the claim is an assertion to other applications.
  • The driver still observes its own STATE. The ingestor subscribes the configured host id regardless, so a claiming node sees its own retained ONLINE come back. Nothing gates on it, so the echo costs one log line.

Connection + Failure Semantics

MqttConnectionState: Disconnected → Connected → Reconnecting → Faulted → Disposed. A broker that is merely down stays Reconnecting indefinitely — unreachable is never a fault. Backoff is exponential between the two reconnectBackoff bounds.

MQTTnet 5 does not throw on a rejected CONNACK — it returns the code and leaves the client disconnected. Left unhandled, a wrong password produced a Healthy driver that never received a value. MqttConnection therefore raises MqttConnectRejectedException:

  • Unrecoverable ⇒ Faulted, supervisor stops (retrying cannot help): MalformedPacket, ProtocolError, UnsupportedProtocolVersion, ClientIdentifierNotValid, BadUserNameOrPassword, NotAuthorized, Banned, BadAuthenticationMethod, TopicNameInvalid, PacketTooLarge, PayloadFormatInvalid, RetainNotSupported, QoSNotSupported, ServerMoved.
  • Everything else is transient and keeps retrying — including UnspecifiedError and any future code. (ServerMoved is permanent, UseAnotherServer is temporary — per the MQTT 5 spec.)

A reconnect that completes without re-subscribing would be a healthy-looking connection that receives nothing forever, so OnReconnectedAsync throws when zero filters are granted, tearing the session down rather than swallowing it. A SUBACK failure degrades just the affected RawPaths to BadCommunicationError.

There are two kinds of reconnect and only one of them fires that callback. A drop the connection's own supervisor recovers keeps the same MqttConnection, so Reconnected → OnReconnectedAsync re-establishes every live filter. A session rebuild — ShutdownAsync + InitializeAsync (the #527 comms-loss watchdog's retry path, and a ReinitializeAsync that changes the endpoint or credentials) — builds a new connection and re-attaches the same MqttSubscriptionManager, and the re-subscribe then comes from the host (DriverInstanceActor.ResubscribeDesired) rather than from the callback. AttachTo therefore forgets the filters the previous session had established: the dedupe set is a claim about one session, and carrying it across made the host's re-subscribe resolve an empty pending set, send no SUBSCRIBE at all, and leave the driver Connected/Healthy and permanently deaf. Sparkplug's SparkplugIngestor.AttachTo resets its own session state for the same reason.

Quality on connection loss and re-subscribe

The quality contract this implements is stated once, in docs/DriverLifecycle.md §"The quality contract". Two edge behaviours make MQTT — a push-model driver — honour it. A poll driver gets both free from PollGroupEngine (a fresh subscription's first poll runs with forceRaise: true); MQTT had neither, which is archreview 05/STAB-18.

  • Connection loss stales, keeping the value. On the Connected → Reconnecting edge MqttConnection.ConnectionLost fires, and both ingest paths mark every already-observed reference BadNoCommunication with its last value retained (server-side; a client reads the Bad status with a null value) and publish the change. A never-observed reference (still BadWaitingForInitialData) and an already-Bad one are left alone. The event does not fire for a failed initial connect or during dispose — neither is an outage.
  • Every (re)subscribe pushes the cached snapshots. SubscribeAsync republishes each observed reference's cached snapshot onto the new handle before any broker traffic. This is what refills a node an address-space rebuild reseeded BadWaitingForInitialData on a quiet topic — a rebuild triggers no retained replay and, in Sparkplug, no rebirth.

Why the stale is what makes recovery work. The plain path suppresses a message whose value and status equal the driver-owned cache, and that cache survives reconnects and redeploys. So the retained message a broker replays on reconnect — routinely the identical payload — was suppressed, and every node the host had latched Bad stayed Bad until the plant value happened to change. Staling the status first makes the identical replay a change. (A regression test for this must replay the identical payload; a different value defeats the suppression on its own and proves nothing.)

Two "not communicating" codes, deliberately. BadNoCommunication (0x80310000) means the link is down — the connection-loss stale, and what the host's comms-loss fan-out writes onto the value nodes. BadCommunicationError (0x80050000) keeps its narrower Sparkplug meaning: the source announced its own death (NDEATH/DDEATH), or a birth voided the scope feeding the tag. A refused SUBSCRIBE filter also degrades to BadCommunicationError.

Operator-visible change: an MQTT tag now goes Bad when the broker session drops, rather than holding its last value at Good until the host's 90 s comms-loss watchdog notices — which, since MQTTnet usually reconnects well inside that window, in practice meant never.

Write-through

IWritable landed with #508. Before it, every MQTT node materialized read-only regardless of what the operator authored.

Mode Destination Payload
Plain the tag's writeTopic the value as UTF-8 text (Raw/Scalar) or a bare JSON literal (Json)
Sparkplug B spBv1.0/{group}/DCMD/{node}/{device} for a device metric, spBv1.0/{group}/NCMD/{node} for an edge-node metric a Sparkplug Payload carrying the metric by name, with an explicit Datatype and no seq

Both publish with retain: false. A Plain write goes at QoS 1 (STAB-26): the publish completes only once the broker PUBACKs it, so Good means "the broker accepted it", and a broker refusal or a connection that died under the send is Bad. Sparkplug commands stay at QoS 0 — a redelivered NCMD/DCMD would be re-applied. A Sparkplug metric the tag did not give a dataType is commanded with the type its live birth certificate declared (STAB-25), the same authored-beats-birth precedence reads use — using the birth's own Sparkplug datatype, not the lossy driver-type mapping, so Int8/UInt8/Text/Uuid/Bytes/File metrics are commanded with their real type; a birth type the encoder cannot represent (any *Array, DataSet, Template, …) is refused BadNotSupported rather than sent as another type. Retain is the one that matters and is deliberately not configurable: a retained command would be replayed by the broker to the device on every future subscribe — including the device's own reconnect — so one stale write would be re-applied forever.

Plain QoS 1 is at-least-once. Know the consequences before pointing a Plain writeTopic at a non-idempotent device topic:

  • A lost PUBACK can make a device subscribed at QoS ≥ 1 with a persistent session receive the command twice (at QoS 0 delivery was downgraded and a duplicate could not happen).
  • A lost PUBACK, or the ConnectTimeoutSeconds (15 s) deadline, reports Bad and the node is reverted, even though the broker may already have delivered the write. Good is "the broker accepted it", Bad is "not confirmed", never "not delivered".
  • Each write holds DriverInstanceActor's mailbox until the PUBACK arrives — up to 15 s per write, one after another; comparable to a Modbus round trip.
  • A broker advertising MQTT 5 Maximum QoS 0 will disconnect on every Plain write.

There is no per-tag QoS option; make the receiving topic idempotent (absolute setpoints, not increments).

writeTopic is separate from topic, and its absence is meaningful

A tag commonly reads …/state and writes …/set, so the destination is its own authored field. A Plain tag with no writeTopic is refused BadNotWritable — it is not published anywhere, and the node is discovered ViewOnly.

This is the whole point of the design. The one outcome worse than read-only nodes is a node that looks writable, accepts a write, returns Good, and publishes nothing. Publishing back onto the tag's own read topic would be worse still: it would echo straight back and read as a confirmed device value.

{
  "topic":      "factory/oven/temp/state",
  "writeTopic": "factory/oven/temp/set",     // omit for a read-only tag
  "payloadFormat": "Scalar",
  "dataType":   "Float64"
}

Sparkplug needs no extra key — a command is addressed by the binding tuple the tag already carries.

Good means published, not applied

MQTT has no application-level write acknowledgement, so a successful PUBLISH is the strongest truth available at this seam — the same optimistic-Good posture Galaxy's fire-and-forget write takes. Device truth arrives afterwards as the echoed value on the read topic and corrects the node through ordinary ingest (write-outcome self-correction, #5).

Inherited consequence: this driver can never surface a failed device write, exactly as Galaxy cannot. A publish that itself fails is reported Bad — the optimism covers what the device did with the command, never whether it was sent.

Status codes

Situation Status
Published Good
Reference is not an authored tag BadNodeIdUnknown
No write destination, or a data type with no command encoding BadNotWritable
Value not representable in the tag's declared type BadTypeMismatch
Publish failed, or no live broker connection BadCommunicationError

One status per request, in request order; one bad request never fails the rest of the batch.

WriteIdempotent stays default-off

MQTT gives no write ack and a Sparkplug command is not necessarily idempotent (a pulse, a recipe step, a counter increment), so the resilience pipeline must not replay one. Operators opt in per tag.

Two things to know

  • jsonPath is not applied on write. It is a read-side extraction from the read topic's document; the write topic is a different topic whose payload shape nothing in the tag config describes. A Json write emits a bare JSON literal. Reconstructing a wrapped document from an extraction path would be guesswork.
  • NCMD is a shared channel. The driver publishes its own rebirth requests (Node Control/Rebirth) on spBv1.0/{group}/NCMD/{node} — the same topic an edge-node metric write uses. Anything consuming that topic must match on the metric name. The live gate caught exactly this: a rebirth NCMD arrived before the write command. DCMD has no such contention.

Browse

MqttDriverBrowser opens a read-only observation session: it subscribes at QoS 0 to # (or {topicPrefix}/#) under a -browse--suffixed client id and builds a /-segment tree from whatever publishes. Nothing is ever published. It is inherently incomplete — a topic that stays silent during the window is invisible — so manual entry remains the escape hatch.

Bounds: 50 000 nodes (then ⚠ Observation truncated), 1024-char topics / 256-char segments (then ⚠ Some topics were too long), 512-byte payload inspection. Sessions are reaped after 2 minutes idle.

Sparkplug B browse serves a different tree from the same passive window: observed NBIRTH/DBIRTH certificates are decoded into Group → EdgeNode → [Device] → Metric (a birth is the only Sparkplug message that names its metrics). Same node cap, same bounds, same "publishes nothing" guarantee.

The bespoke browser wins over the universal DiscoveryDriverBrowser, which would decline MQTT anyway (SupportsOnlineDiscovery is false).

Browse-commit writes the address the factory reads

A committed leaf's address is a descriptor, not a single reference string, so RawBrowseCommitMapper reads it from the AttributeInfo.AddressFields the session states — Plain emits topic; Sparkplug emits groupId / edgeNodeId / deviceId? / metricName, the names MqttTagConfigKeys single-sources for both the mapper and MqttTagDefinitionFactory.

It is never parsed out of the browse node id. A Sparkplug node id is {group}/{node}[/{device}]::{metric} and a metric name legitimately contains / (Node Control/Rebirth), so splitting the id cannot recover the tuple. Which keys the session emits is also how the mapper learns the shape — the driver type reaching it is just Mqtt, and the mode lives on the driver config. A leaf whose attribute lookup returned nothing carries no address and is refused at commit, in words, rather than committed as a tag that deploys clean and then reports BadNodeIdUnknown forever.

Request rebirth (Sparkplug only)

Births are never retained, so a healthy but quiet plant shows an empty picker tree until one lands. The Request rebirth affordance in the browse modal is the way to fill it on demand: it publishes a Sparkplug rebirth-request NCMD (Node Control/Rebirth) to the selected scope — the one and only browse action that publishes anything.

  • Scope is the last node clicked in the tree. A device or metric resolves up to its owning edge node (Sparkplug has no device-scoped rebirth); a group fans out to every observed edge node beneath it and is refused whole past 32, publishing nothing.
  • An empty tree can still arm a rebirth (#514, fixed 7f53bd96): when no scope is selected the panel offers a group/edgeNode input, so a plant whose edge nodes all birthed before the window opened — the exact case the affordance exists for — is reachable without a tree click. Deliberately an edge node, never a group: a typed group broadcast is a self-inflicted broker storm on a large plant.
  • Two clicks, never one — Request rebirth… arms a confirm panel naming the resolved scope and its consequence; changing the selection disarms it.
  • Gated on DriverOperator, enforced server-side in BrowserSessionService.RequestRebirthAsync (fail-closed) before the session is touched; the UI gate is defence in depth. A refusal — policy, over-wide group, unresolvable scope — is shown, not swallowed.
  • Offered only for a Sparkplug session (IRebirthCapableBrowseSession.RebirthAvailable); a Plain MQTT window publishes nothing, ever.

Refresh

An observation window accumulates, but the tree renders once, when it is created — and OpenAsync returns as soon as the SUBSCRIBE is granted, without waiting for the birth-observation window. So the first render is normally empty on Plain (a topic that has not published yet is invisible) and almost always empty on Sparkplug (births are never retained). Refresh re-reads the root against the same session: nothing reconnects, nothing already observed is lost, and the tag selection is kept. Only the armed rebirth scope is cleared, because the node it named may no longer be in the rebuilt tree.

Also fixed by this gate, and not MQTT-specific: every node label in the shared DriverBrowseTree was an <a href="#">. blazor.web.js's enhanced-navigation click interceptor resolves a bare # against <base href="/">, so clicking a node label navigated the whole AdminUI to / and destroyed the hosting modal (@onclick:preventDefault suppresses the browser's default action, not Blazor's interceptor). The labels are now <button type="button">. This affected every driver's picker since 2026-05-28; it surfaced here because choosing a rebirth scope is the first flow that requires clicking a label rather than the ▶ toggle.

Test Connect

MqttDriverProbe performs one CONNECT (no SUBSCRIBE, no publish) under a -probe- client id and disconnects. Success message: "MQTT CONNECT OK". A rejected CONNACK is described by the same DescribeConnackRejection the running driver uses, so the button and the driver report a rejection in identical words. See TestConnectProbes.md.

Testing

  • Unit — tests/Drivers/…Driver.Mqtt.Tests (581 tests): tag-config mapping (both shapes), JSONPath subset, strict-enum rejection, subscription indexing (incl. the wildcard-by-filter case), reconnect/rebuild branches, CONNACK classification, and the Sparkplug half — golden-payload decode, topic parse/format, datatype map, alias/birth-cache rebuild, sequence + bdSeq-death ties, the rediscovery change gate, and the browse session's "publishes nothing" assertion.
  • Live (env-gated) — tests/Drivers/…Driver.Mqtt.IntegrationTests (29 tests) against the Mosquitto broker + the C# Sparkplug edge-node simulator; skips cleanly when MQTT_FIXTURE_ENDPOINT is unset. See Mqtt-Test-Fixture.md and infra/README.md §3.
    The primary-host legs (MqttPrimaryHostLiveTests) include the one the ordinary lifecycle cannot reach: AbandonClaimForTests drops the socket with no DISCONNECT, because a clean disconnect makes the broker discard the Will by protocol rule — so a test driven through StopAsync would observe the explicit stand-down OFFLINE and pass against an implementation that never armed a Will at all.
  • AdminUI — MqttDriverFormModelTests / MqttTagConfigModelTests cover both editors' round-trip, mode inference and validation. AdminUI has no bUnit, so the razor shells themselves are only ever proven by a live /run gate.

Operational Notes

  • No alarms, no driver-side history. Historization is a server-side concern (isHistorized).
  • Both pair nodes subscribe independently — MQTT fan-out is per-connection, and the Primary gate applies downstream, not to ingest. This is also why clientId must stay unset (above).
  • Unauthored traffic is ignored. A busy broker is normal; the driver never auto-provisions a tag.

Known gaps

Every row is tracked. None is a silent failure — each either refuses in words or logs.

Closed 2026-07-31: write-through (#508, live-gated), primary-host STATE publishing (#511, live-gated), structured Template/DataSet/PropertySet metrics (#515, live-gated), the slashed-metric browse-commit (#509), the stale rebirth affordance (#510), the .Browser layering exception (#512), the stray Sparkplug payloadFormat (#513) and rebirth on a not-yet-birthed plant (#514).

Gap Issue Effect Notes
Rediscovery is advisory, not a rebuild #507 (closed, superseded) A DBIRTH introducing a metric does not change the served OPC UA tree; an operator must re-browse the device and commit No longer "inert". DriverInstanceActor now subscribes to OnRediscoveryNeeded and the raise rides the driver-health snapshot to the AdminUI /hosts page as a re-browse chip. It deliberately does not inject nodes: v3 authors raw tags through the /raw browse-commit flow, and grafting nodes at runtime would materialise ones nobody approved and no deployment artifact records. #507's injection path was deleted rather than revived