Status: accepted
Date: 2026-07-13
Deciders: GoBridge core
Decision recorded: commit 4d8d76d (2026-07-13, this ADR file)
Implementation: NonDurableEgressReporter, Sender.NonDurableEgress, and egressDurabilityAdvisory in commit 9d8effb (2026-07-10)
The paho MQTT adapter connects through autopaho with cfg.Session left nil, so
the connection manager uses autopaho’s default in-memory packet/session store
(adapters/mqtt/transport/paho/acl_session.go, the deferred-alternative note on the
autopaho.ClientConfig build). That store holds the client-side outbound queue:
a QoS 1/2 PUBLISH that has been sent but whose PUBACK (QoS 1) / PUBCOMP (QoS 2)
has not yet arrived lives only in process memory.
The consequence is a hard ceiling: an in-flight outbound QoS 1/2 publish is
lost at the MQTT-protocol level when the process dies, and MQTT QoS 2 is
therefore not exactly-once across a restart. client_id / clean_start=false
do not close this — they resume broker-side session state (offline inbound
queueing, subscription retention), not the client-side outbound packet queue,
which is the volatile part.
This is easy to mistake for bridge-level message loss. It is not, because the bridge does not delegate egress durability to the MQTT protocol.
Keep the in-memory autopaho store and make durable, at-least-once egress the bridge’s responsibility at the route layer, not the transport’s. The MQTT sender is a non-durable boundary by design and it declares that fact so the runtime can reason about it.
Non-durability is reported, not hidden. The sender implements
ports.NonDurableEgressReporter: Sender.NonDurableEgress returns true for
QoS ≥ 1 (adapters/mqtt/transport/paho/sender.go). The bridge consults it in
egressDurabilityAdvisory and warns only when a route’s delivery mode
would settle the source before this non-durable boundary.
direct_hold holds the source delivery un-acked until the broker returns
PUBACK/PUBCOMP; a crash before that ack leaves the source message un-acked,
so the source redelivers when the source transport/session redelivers. A
QoS 0 source, an Ephemeral clean-start restart, or an expired source offline
queue has nothing to redeliver.shared_outbox invokes the sender from a version-fenced persisted outbox
record and marks it complete only after the send returns; a crash before
completion replays the record when it acquired a unique durable identity
and was persisted, and idempotency keys collapse the duplicate.
Because both modes recover the in-flight loss on the source side within those
boundaries, no durability advisory fires today. The full conditional contract —
the rows that are safe and the rows that can still lose or duplicate — is the
source-to-destination guarantee matrix.session.SessionManager (durable client-side store).
Assigning a persistent store to cfg.Session would make in-flight outbound
QoS 1/2 survive a restart at the protocol level. Deferred, not rejected: it
adds a local-disk durability dependency and a crash-consistency surface that
the route-layer outbox already covers within its boundary (a persisted,
uniquely-identified record), so it buys protocol-level exactly-once we do not
currently need. It remains the natural extension if a
future route mode settles the source ahead of the transport. Tracked as the
deferred alternative.NonDurableEgressReporter makes the boundary explicit
and machine-checkable.