Skip to content

Latest commit

 

History

History
637 lines (505 loc) · 37.7 KB

File metadata and controls

637 lines (505 loc) · 37.7 KB

Message Delivery & Reliability

Overview

The Offline Protocol SDK is designed for environments where connectivity is intermittent or absent for extended periods. The delivery system ensures that messages persist until delivered (or expired), and are sent immediately when any transport becomes available.

This guide explains how messages move through the system, how failures are handled, and how client applications should integrate with the delivery lifecycle.

Message Lifecycle

send_message()
    │
    ├─ Transport available ──► Send ──► ACK tracking ──► MessageDelivered
    │                                       │
    │                                       ├─ ACK timeout ──► Retry via queue (MessageRetrying)
    │                                       │                      │
    │                                       │                      ├─ Send succeeds ──► ACK tracking
    │                                       │                      └─ Max ACK retries ──► MessageFailed
    │                                       │                         (or re-park, if the recipient
    │                                       │                          is parked as unreachable)
    │                                       │
    │                                       └─ Relay: recipient unreachable ──► MessageUndeliverable
    │                                                              │              (message parked)
    │                                                              └─ Reachability edge ──► fresh ACK budget
    │
    └─ Transport unavailable ──► Outbox + Retry Queue ──► MessageDeferred
                                        │
                                        ├─ Backoff timer fires ──► Retry send
                                        ├─ Peer discovered ──► Immediate flush
                                        └─ Internet reconnects ──► Immediate flush

Successful Send

  1. send_message() creates the message and attempts transport delivery via DORS
  2. On success, the message is registered for ACK tracking
  3. When the recipient sends an ACK, a MessageDelivered event fires
  4. The message is removed from outbox and retry queue

Only the recipient settles a message. An acknowledgement naming a message sent to somebody else is ignored, whichever carrier it arrives on — every device that carried a frame knows its id, and settling on the id alone would let any of them drop the outbox entry and report a delivery that never happened. This is an attribution check, not authentication: acknowledgements are not signed, so it removes the unattributed answer rather than a determined forgery by someone who saw the frame — and who therefore also saw the recipient's name.

Deferred Send

  1. send_message() returns Ok(message_id) even when the transport send fails
  2. The message is persisted to the outbox and enqueued in the retry queue
  3. A MessageDeferred event fires with the reason
  4. The retry queue will attempt redelivery on its next cycle

The caller always receives the message ID. A deferred message is not a failure — it's queued for delivery.

Retry System

Two Independent Retry Paths

The system has two distinct retry mechanisms that serve different purposes:

Transport Retries (retry queue):

  • Fires when no transport can deliver the message right now
  • The retry queue is a pure scheduling mechanism with no attempt limit
  • Uses exponential backoff: 1s → 2s → 4s → 8s → ... → 300s (capped)
  • Messages stay in the queue indefinitely as long as the process runs
  • Processed in batches of 20 during each process() tick

ACK Retries (ACK manager):

  • Fires when a message was sent but no acknowledgment arrived
  • Each re-schedule emits a non-terminal MessageRetrying event with the actual next_retry_at
  • Limited to max_retries attempts (default: 10)
  • After exhausting retries, the message permanently fails with MessageFailed — unless the recipient is currently parked as unreachable, in which case the message re-parks instead of settling (see Unreachable Recipients: Parking)

This separation is critical: a message that can't reach any transport should not be permanently failed. Only messages that were sent but never acknowledged count toward the retry limit. The terminal paths for a regular message are ACK-retry exhaustion (against a reachable peer) and outbox lifetime/capacity expiry — nothing else settles it as failed.

Exponential Backoff

Retry 0:  1s     (initial_delay_ms)
Retry 1:  2s     (1000 × 2^1)
Retry 2:  4s     (1000 × 2^2)
Retry 3:  8s     (1000 × 2^3)
Retry 4:  16s    (1000 × 2^4)
Retry 5:  32s    (1000 × 2^5)
Retry 6:  64s    (1000 × 2^6)
Retry 7:  128s   (1000 × 2^7)
Retry 8:  256s   (1000 × 2^8)
Retry 9+: 300s   (capped at max_delay_ms, 5 min)

The delay is initial_delay_ms × backoff_multiplier^retry_count, computed in f64 and clamped to max_delay_ms before the integer cast. That makes it total over every configuration rather than needing an exponent ceiling: a non-finite intermediate collapses to max_delay_ms, and the float-to-int cast saturates. At the default maxRetries: 10 the ladder sums to about 13.5 minutes of backoff, so a message that is going to fail permanently takes roughly 15 minutes to say so once ACK timeouts are included. Lower maxDelayMs and maxRetries if you need faster failure detection.

Priority Ordering

The retry queue is a priority-based min-heap. When multiple messages are ready:

  1. Earlier retry_at times are dequeued first
  2. Among messages with the same retry time, higher priority wins
  3. Within the same priority, order is arbitrary

Transport-Triggered Flush

When a transport becomes available, pending messages are sent immediately — they don't wait for their backoff timer to expire.

Peer Discovery Flush

When on_neighbor_discovered() is called (a BLE or Wi-Fi Direct peer appears):

  1. The outbox is scanned for messages addressed to that peer
  2. Matching messages are removed from the retry queue
  3. Each message is sent immediately (batch limit: 20)
  4. Failed sends are re-enqueued with their current attempt count

This means a message queued while a peer was unreachable will be delivered as soon as that peer reappears, without waiting for the next backoff cycle.

Internet Reconnect Flush

When internet_status_changed(true) is called after a disconnection:

  1. All entries are drained from the retry queue (ignoring timing)
  2. Outbox entries not in the retry queue are also collected (stranded messages)
  3. Each message is sent immediately (batch limit: 20)
  4. Failed sends are re-enqueued

Batch Limits

Both flush methods cap at 20 messages per invocation to prevent blocking. If more messages are pending, the remainder stays in the retry queue and will be picked up on the next process() tick or the next flush.

Relay identity: the address declaration

The relay authenticates a JWT and knows the connection by the account name behind it. The SDK, meanwhile, stamps every frame's sender with this device's off1… address, and the receiver strict-matches the two — so a relay that attributes a frame by account name hands the receiver a mismatch and the frame is rejected. That rejection applies to the security-gated control prefixes (__MLS_KEY_PKG__, __MLS_WELCOME__), which is what establishes a session; an already-established session keeps working, because __MLS_ENC__ is data-plane and ungated.

So, on every connection, immediately after the relay's Authenticated answer and before any queued message is flushed, each bridge proves its address: it signs the per-connection address_challenge from that frame — bound to a dedicated domain and to the relay-resolved account name — with the same identity key the address derives from, and sends a DeclareAddress. The relay verifies the signature, re-derives the address from the presented key, and from its next inbound frame onward attributes and routes that connection by address. This also makes an off1… recipient resolvable: the relay's registry is keyed by account name, and the declaration is what maps one to the other.

The ordering is load-bearing. A frame written before the declaration is attributed by account name permanently — the relay does not re-stamp retroactively — so the declaration precedes the reconnect flush described above, not merely the first send an app makes.

This is automatic and unconfigurable. It happens only against a relay advertising the address_routing_v1 capability; against an older relay, or before MLS holds an identity (an app running with encryption.enabled: false never has one), the bridges send nothing and the connection behaves exactly as it did before addresses existed. A refusal is non-fatal for the same reason: the relay answers AddressError, the connection stays up in account-name space, and the next reconnect declares again from scratch. Both the acceptance and the refusal also reach the app verbatim as internet_server_message events, and the bridge diagnostics name the reason it skipped.

Checking the answer

Sending the declaration establishes what this device claims. The relay's AddressDeclared echo is the only evidence of what it actually bound, so the bridges hand both answers to the SDK, which checks them and reports through security_warning:

Answer Check Event
AddressDeclared echoed address == local_address() none — this is the expected outcome
AddressDeclared echoed address is anything else RELAY_ADDRESS_BINDING_MISMATCH, peer_id = the address the relay bound
AddressError RELAY_ADDRESS_DECLARATION_REFUSED, peer_id = this device, reason = a fixed local classification (the relay's own wording never travels onto the event)

Both are reported and neither is acted on. A mismatch has no benign reading — the relay verifies that the declared address derives from the key that signed the proof, so an echo naming something else means it bound what it did not verify — but a relay that controls the socket already controls everything a local teardown would protect, so the mitigation is that the signal is loud. A refusal is genuinely non-fatal by contract on both sides.

What a refusal costs is worth stating plainly, because "the connection works" and "the connection can start new conversations" are different claims: an undeclared connection keeps delivering on established sessions, and cannot establish new ones. Apps that surface relay health should treat RELAY_ADDRESS_DECLARATION_REFUSED as degraded rather than down.

Both answers arrive through dedicated FFI entry points (internet_address_declared, internet_address_declaration_refused) rather than through message-plane injection — an acknowledgement that could be synthesized from a notification payload would assert exactly what the check exists to establish.

Gateway identity: the same shape, one different consequence

A device attaching to a Reticulum gateway does the same thing under a different domain: it signs offline-gateway-addr-v1 over its own address and the gateway's per-connection challenge, sends DeclareAddress, and checks the address the gateway echoes back. The two answers report as GATEWAY_ADDRESS_BINDING_MISMATCH and GATEWAY_ADDRESS_DECLARATION_REFUSED, through their own entry points, for the same reasons.

The domains must stay distinct, and not only because every signing domain must: the two payloads have an identical layout, so under a shared domain a proof harvested by a hostile gateway would replay against the relay. The bytes are published and pinned.

What differs is the consequence of a refusal. A relay connection that fails to declare keeps delivering on established sessions, because it still has an account-name space to work in. A gateway has no such space: a session it has not bound may submit and be told a verdict, and is never registered as a recipient, so nothing addressed to that device arrives over it. The bridges therefore do not report the carrier available at all until the session is bound, and a refusal closes the connection. A Reticulum transport that connects and never becomes available is that, and the security warning is what says so.

The declaration itself is built in the core rather than in each bridge. The relay's has to be bridge-side because it commits the relay-resolved account name, which only the bridge knows; the gateway's commits only this device's own address, so it exists once, where a conformance vector pins the bytes instead of three hand-mirrored copies of a layout.

Outbox

The outbox persists messages that require acknowledgment. It serves as the source of truth for "what messages are in flight."

Parameter Default Description
Max entries 500 Regular messages
Max media entries 100 File chunk messages
Max lifetime 7 days outbox_max_lifetime_ms

When the outbox is full, the oldest entry is evicted with a terminal message_failed event (reason "Outbox capacity exceeded"). When a message exceeds its lifetime and has no pending ACK, it is dropped and a terminal message_failed event (reason "Outbox lifetime exceeded") is emitted so the app can settle its UI state.

Important: When a message storage backend is configured, regular-message outbox entries are persisted and restored on the next start() with a refreshed delivery window. Media chunks are never persisted — an interrupted transfer surfaces as media_resend_required instead. See Client-Side Persistence for the app-side layer.

Unreachable Recipients: Parking

When the internet relay reports a recipient unreachable for an in-flight regular message (its recipient_unreachable delivery verdict), the message does not burn its ACK retry budget against a peer that is provably offline. Instead it is parked:

  1. A non-terminal MessageUndeliverable event fires (message_id, recipient, reason, and file_id when the message is a media chunk)
  2. The pending ACK and the retry-queue entry are dropped — the retry machinery goes quiet
  3. The outbox entry stays put, so the message remains "in flight" and subject only to the outbox lifetime
  4. The message is offered to any mesh neighbors, who may be able to reach a recipient the relay cannot — see An online device in a mixed neighborhood

The mesh offer repeats on each subsequent park, so a recipient who was out of range when the message was first parked is still reached later. Handing a copy to a neighbor is not proof of arrival, so the park and its probe stand regardless; what settles the message is the acknowledgement coming back. Since parking removed the pending ACK, that acknowledgement settles the parked entry on its own and fires the ordinary MessageDelivered.

A parked message is re-driven with a fresh ACK budget on every reachability edge:

  • Transport reconnect (internet_status_changed(true))
  • start() after a restart
  • The peer being discovered on a local transport
  • The peer coming online per presence — internet_presence_watchlist() includes recipients of pending/parked outbox messages, so the SDK owns presence-watching its own outbox; apps do not need their own watch queue for offline sends

Reachability probing

A parked message never goes fully quiet — the SDK keeps a timed reachability probe running on every carrier. The probe interval escalates with each consecutive unreachable park (15s doubling up to a 600s cap) and resets on any reachability edge. The escalation counter is per recipient while the probes are per message: a burst of DMs to one offline peer climbs the shared ladder once per park, so later messages start at an already-escalated interval rather than each walking 15s → 600s on their own — the delivery re-drive below is the compensating edge. If a probe attempt exhausts its ACK budget while the recipient still holds a live park counter, the message re-parks at the escalated interval rather than settling.

The probe is deliberately carrier-agnostic. With a local mesh carrier (BLE / Wi-Fi Direct) up the peer may be a room away even though the relay reports it offline — and possibly already a discovered neighbor, so no future edge would fire for it. On an internet-only device the external edges above are the only other recovery, which leaves delivery hostage to the platform's presence-polling cadence (and to nothing at all for a consumer that never polls presence). Probing over the relay is self-limiting in every outcome: a still-offline peer returns a fresh verdict that escalates the interval, an accepted frame becomes an ordinary in-flight send on the ACK ladder, and a peer that is back means the probe was the delivery.

Relay traffic is bounded differently in each of those branches. When the relay answers with a verdict, the escalation is the bound — one frame per interval per parked message, settling at one per 600s. When the relay accepts the frame instead (its push fallback succeeded, so no verdict comes back), the probe rides the ordinary ACK ladder — up to max_retries sends on 1s → 300s backoff, roughly 800s cumulative on the defaults — before re-parking at the escalated interval. That ~800s is the fresh-entry bound: a resend that has to register a new ACK carries the retry-queue entry's accumulated retry count onto it (retry_count + 1) rather than restarting the ladder at 0, so a probe that already climbed the backoff resumes near its current position and re-parks after roughly one more timeout — the carried case only sends less. Plan relay capacity against the fresh-entry number, not the verdict one. Without this carry-forward a never-ACKing (offline) recipient's probe would pin the resend delay at its 1s floor and flood the relay once per second. Delivery of any one message to a parked peer immediately re-drives that peer's remaining parked messages rather than leaving them on their own escalated timers.

The outbox lifetime bounds the entry itself, with one caveat worth knowing: each probe refreshes the entry's last-send timestamp, so the sliding 7-day window stops binding and terminal message_failed moves out to the absolute cap (4× the lifetime, i.e. ~28 days).

Opt-out for deployments where returning peers always interact. The perpetual probe above is the default and stays the default. A deployment whose peers always send an inbound frame or advertise presence when they return (for example a machine-to-machine capability exchange) can set edgeDrivenUnreachableDm to true. The message is then timed-probed only a bounded number of times before it goes quiet and rests in the outbox, re-driven purely on the reachability edges above, and enabling the flag also caps the core resend rate to gone peers so a large unreachable backlog cannot trip a relay's rate limiter into a disconnect loop. This suspends the "never goes fully quiet" guarantee for unreachable DMs, so it must not be enabled for a consumer whose only recovery path is the timed probe (a silent returning peer that never polls presence would not be re-driven). It is off by default; the always-on behavior in this document is what every unconfigured integration sees.

Raising outbox_max_lifetime_ms interacts with control-frame freshness. A retransmitted control frame carries the signature it was minted with, timestamp included, and a receiver refuses one stamped more than 30 days ago (see Control messages). That window was chosen to clear the ~28-day absolute cap above, so the defaults are safe with room to spare. Configure a lifetime past a quarter of the window (7.5 days) and this device's own late retransmissions of __CONN_REQ__ and the other signed control frames start being refused as stale by the peer they finally reach. Ordinary messages are unaffected: __MLS_ENC__ is data-plane and carries no such signature. If a deployment genuinely needs a longer outbox, raise it for the reachability behaviour and expect control frames near the far end of the ladder to be dropped rather than delivered.

What parks and what doesn't:

Message kind Behavior on recipient_unreachable
Regular DM Parked (as above), and offered to mesh neighbors
Media chunk Not parked — normal retry exhaustion → transfer abort → media_resend_required — but offered to mesh neighbors, and it keeps its pending ACK, so an answer carried back settles it the ordinary way
Connection request Not parked — settles immediately via connection_request_undeliverable

Contract: MessageUndeliverable is the "recipient is offline" signal and may fire repeatedly for the same message while the peer stays offline. Terminal settlement happens only at delivery (MessageDelivered) or outbox-lifetime expiry (MessageFailed). Apps that previously keyed "recipient offline" UX off the ~15-minute terminal message_failed should key it off message_undeliverable instead.

Group sends

A group send takes one of two paths, and which one is not a config flag alone — it is negotiated with the relay.

Relay broadcast with a delivery report (the default against a v3 relay). When the group is relay-registered, group.relayBroadcastEnabled is on (the default), and the connected relay advertised the group_delivery_v3 capability in its Authenticated answer — the v2 settled-report contract plus an address-aware group path, i.e. members named by the identifiers the roster was registered under (off1… addresses), which is what makes the report comparable against the MLS roster at all — the send is one frame the relay fans out, and the send returns a single logical message id. The relay then answers with a settled per-recipient delivery report (its GroupMessageSent) naming which members took the message over a live socket and which took a device push carrying the ciphertext. The SDK consumes that report: every MLS roster member the report does not account for — the ones the relay names as missed and the ones it does not know about at all (its registered roster can lag the MLS roster) — is automatically re-sent a per-member copy that gets everything described on this page: outbox entry, ACK, retry ladder, relay write-ack, offline push, park-on-unreachable. The report is surfaced to the app as the group_message_delivery_report event (observability only — the backstop re-send has already happened by the time it fires).

The report itself is on a contract too: it can legitimately arrive tens of seconds after the send (the relay reports only when its whole fan-out settles, bounded by a 45 s wall-clock budget), so the SDK waits 60 s per attempt. A lost report re-sends the broadcast under the same logical id — receivers and the relay's push dedup key on it, so a duplicate fan-out costs bandwidth, never duplicate messages — at most twice, then the whole message downgrades to per-member fan-out, which needs no report to be correct. The same downgrade fires immediately if the Internet transport drops while a report is pending.

The one gap on this path: the report tracker is in memory only. Unlike an outbox entry, a broadcast awaiting its report does not survive process death. If the app is killed inside the report window (up to 60 s per attempt) the backstop is lost with it — members the relay could not reach get no per-member re-send, and nothing retries on restart, even though the send already reported group_message_sent. The exposure is narrow: it needs a member the relay missed and a process death in that window, and the relay's own push fan-out still covers members who are merely offline with a valid push token. It is also strictly smaller than the pre-report broadcast's, which had no backstop at all. But it is a real difference from per-member fan-out, where the outbox persists for 7 days — so an app that must not lose a group message to a mid-flight kill should set relayBroadcastEnabled: false and pay the O(N) uplink. Persisting the tracker is planned; it is not in this release.

Per-member fan-out (the fallback, and the opt-out). Against a relay that did not advertise group_delivery_v3 — or with relayBroadcastEnabled: false — a group send returns a Vec<MessageId> with one id per recipient: each is a real SendMessage frame carrying the same MLS group ciphertext, addressed to one member, with the full DM delivery ladder independently. The contract-less fire-and-forget broadcast of the v1 relay (no presence check, no push fallback, no persistence, "sent" answered before delivery was known) is never taken regardless of configuration; missing a group message on that path was undetectable, because MLS application messages do not advance the group epoch. See Group Configuration.

Cost of per-member fan-out. Sends are O(N) frames. This does not risk tripping the relay's rate limiter at any group size: the platform bridge meters every relay-bound frame through a client-side token bucket (28 capacity, 9/s refill) deliberately tighter than the relay's own (30 burst, 10/s), and defers a frame it cannot fund to a later poll tick rather than dropping it — so the fan-out self-paces below the server's budget regardless of member count.

What large groups cost on that path is drain latency, and past roughly 118 members, self-inflicted duplicate sends. The core enqueues all N frames at once and the bridge writes them at about 9/s after an initial burst of ~28, so frame N reaches the wire at roughly (N - 28) / 9 seconds. The ACK timer starts when a frame is enqueued locally, not when it reaches the wire, so beyond that size the tail of a single fan-out exceeds the 10s ACK timeout and is retransmitted before it was ever written. Those duplicates are absorbed by receiver and push dedup via the stable outbox id, so they cost bandwidth rather than correctness. Presence checks, typing indicators, and read receipts draw on the same bucket and lower the threshold. This is the strongest reason large groups should leave the broadcast enabled.

ACK Cleanup

When an ACK is received for a message:

  1. MessageDelivered event is emitted with latency and hop count
  2. The message is removed from the retry queue (prevents ghost re-sends)
  3. The message is removed from the outbox
  4. Transport delivery metrics are updated

When a message exceeds max ACK retries:

  1. MessageFailed event is emitted
  2. The message is removed from the retry queue
  3. The ACK tracking is removed
  4. The outbox entry is removed

Configuration

Reliability Parameters

const config = {
  reliability: {
    ack: {
      defaultTimeoutMs: 10000,  // 10s ACK timeout (default)
      maxPendingAcks: 1000,
    },
    retry: {
      maxRetries: 10,              // ACK retry limit (default)
      initialDelayMs: 1000,        // First backoff delay
      maxDelayMs: 300000,          // Backoff ceiling (5 min)
      backoffMultiplier: 2.0,      // Exponential factor
      outboxMaxLifetimeMs: 604800000, // 7 day outbox lifetime
      pendingMessageMaxLifetimeMs: 604800000, // 7 days awaiting MLS session
    },
  },
};

The outbound queue waiting for MLS session establishment is also hard-bounded, on entry count and on bytes — a count alone bounds neither memory nor durable storage, because message content is application-supplied and four very large messages sit there reporting 4/64 and looking fine:

Bound Value Behaviour at capacity
Messages per peer 64 oldest settled with message_failed, then the new message is admitted
Messages globally 4096 globally oldest settled the same way
Bytes per peer 2 MiB oldest evicted until the new message fits
Bytes globally 16 MiB globally oldest evicted until it fits

These are fixed, not configurable; only the lifetime above is. All four evict oldest-first and emit the same terminal message_failed, and restore applies them too, so a queue written by an older build cannot re-inflate memory on boot.

sendMessage rejects content over 256 KiB with an InvalidArgument error (send_message, send_message_with, and forward_message in Rust). The cap is enforced at the send boundary rather than at transmit time because a message waiting on session establishment is queued — in memory and on disk — long before it reaches the transport's own 1 MiB check, so a transmit-time cap would never run for exactly the messages that accumulate. It sits well under that 1 MiB ceiling to leave room for MLS ciphertext expansion, base64, and the JSON wire envelope, so anything accepted here can actually be delivered. Large payloads belong on sendMedia, which chunks them and is not subject to this limit.

Group sends are exempt from the pre-session queue bounds and from the content cap — and only from those. A group send encrypts to group state that already exists, so there is nothing to wait on and no durable pre-session queue behind it, and neither sendGroupMessage nor sendGroupMessageWith runs the 256 KiB check (they reject reserved internal prefixes, FileChunk, and oversized rich extras instead). Everything else on this page applies: over the Internet transport a group send is, by default, one ordinary SendMessage frame per member, so each member's copy gets its own outbox entry, ACK, retry ladder, and park-on-unreachable handling exactly like a direct message. See Group sends for what that means per member.

Expiry work is scheduled from the earliest queued deadline rather than scanning the queue on every process() tick.

Tuning for Different Scenarios

High-reliability (field operations, disaster response):

reliability: {
  ack: { defaultTimeoutMs: 15000 },
  retry: {
    maxRetries: 20,
    outboxMaxLifetimeMs: 2592000000,  // 30 days
  },
}

Low-latency (real-time chat with good connectivity):

reliability: {
  ack: { defaultTimeoutMs: 5000 },
  retry: {
    maxRetries: 5,
    maxDelayMs: 10000,
  },
}

Battery-constrained (IoT sensors):

reliability: {
  retry: {
    maxRetries: 3,
    initialDelayMs: 5000,
    maxDelayMs: 60000,  // Longer backoff to save power
  },
}

Events

The delivery system emits events at each stage of the message lifecycle:

Event When Key Fields
MessageSent Message accepted and sent via transport message_id, recipient, priority
MessageDeferred Message queued for retry (transport unavailable) message_id, reason, retry_count, next_retry_at
MessageRetrying Retry re-scheduled after a failed attempt (transport send error or ACK timeout); non-terminal message_id, recipient, retry_count, next_retry_at
MessageUndeliverable Transport reported the recipient unreachable; message parked, non-terminal message_id, recipient, reason, file_id?
MessageDelivered ACK received from recipient message_id, latency_ms, hop_count, transport
MessageFailed Terminal failure (max ACK retries, outbox lifetime or capacity exceeded) message_id, reason, retry_count
MediaResendRequired Interrupted outbound media transfer detected at start(); app must re-supply the bytes via send_media with the same file_id file_id, recipient, file_name, file_size
MessageDecryptionFailed An inbound encrypted message failed to decrypt on this attempt; advisory, not terminal (see below) message_id, sender, code, reason

MessageDecryptionFailed is the one receiver-side event on this list, and it does not settle anything. A message that fails to decrypt is not delivery-ACKed (so the sender keeps retrying, and each resend of a DM is re-sealed against the current session), which means the event fires once per failed attempt rather than once per message — bounded by the sender's ACK retry budget. Treat it as "this attempt did not decrypt"; the terminal signal is MessageFailed on the sender, or FileReceiveFailed for media. See Crypto-Failure Recovery.

Event Flow Examples

Immediate delivery:

MessageSent → MessageDelivered

Deferred then delivered:

MessageDeferred → (transport becomes available) → MessageDelivered

Permanent failure:

MessageSent → (ACK timeout) → MessageRetrying → ... → MessageFailed

Offline recipient (internet path):

MessageSent → MessageUndeliverable (parked) → (peer comes online) → MessageSent → MessageDelivered
                                            → (7-day outbox lifetime expires) → MessageFailed

Breaking Changes

This version introduces the following breaking changes to the delivery system:

send_message() now returns Ok on transport failure

Previously, send_message() returned Err when no transport could deliver the message immediately. Now it returns Ok(message_id) and emits a MessageDeferred event instead. The message is queued for automatic retry.

Migration: If your code matches on Err from send_message() to detect "no transport available," switch to listening for MessageDeferred events instead. An Err from send_message() now only indicates a true failure (e.g., invalid recipient, protocol not started).

Error::MaxRetriesExceeded removed

The MaxRetriesExceeded variant was removed from the reliability crate's error type. The retry queue no longer enforces a retry limit — only ACK timeouts count toward permanent failure.

Migration: Remove any match arms for Error::MaxRetriesExceeded. If you need to detect permanent failure, listen for the MessageFailed event.

Default constants changed

Parameter Old Default New Default
ACK timeout 5,000 ms 10,000 ms
Max ACK retries 3 10

Messages take considerably longer to permanently fail. With the current defaults (maxRetries: 10, maxDelayMs: 300000) the backoff ladder alone sums to about 13.5 minutes, versus ~15 seconds under the old defaults. Configure lower values if you need faster failure detection:

reliability: {
  ack: { defaultTimeoutMs: 5000 },
  retry: { maxRetries: 3 },
}

Client-Side Persistence

When a message storage backend is configured, the SDK persists regular-message outbox entries and restores them on the next start() with a refreshed delivery window (media chunks are never persisted — see media_resend_required). The refresh is bounded: an entry whose total age exceeds 4× the outbox lifetime (28 days at the default) is dropped at restore with a terminal message_failed instead of re-granted a window. Client applications should still maintain their own persistence layer for message history and UI state.

Recommended Pattern

┌─────────────────────────────────────┐
│           Client App                │
│                                     │
│  ┌───────────┐   ┌──────────────┐  │
│  │ Local DB  │   │  Message UI  │  │
│  │ (SQLite)  │   │              │  │
│  └─────┬─────┘   └──────────────┘  │
│        │                            │
│  Store message    Read events       │
│  with status      and update        │
│        │          status            │
│        ▼                            │
│  ┌──────────────────────────────┐   │
│  │     Offline Protocol SDK     │   │
│  │  (in-memory retry + outbox)  │   │
│  └──────────────────────────────┘   │
└─────────────────────────────────────┘

Implementation Steps

  1. On send: Store the message in local DB with status: pending
// Send the message
const messageId = protocol.sendMessage(recipient, content, priority);

// Persist locally
db.insert({
  id: messageId,
  recipient,
  content,
  priority,
  status: 'pending',
  createdAt: Date.now(),
});
  1. On delivery: Update status when ACK arrives
protocol.onEvent((event) => {
  if (event.type === 'message_delivered') {
    db.update(event.message_id, { status: 'delivered' });
  }
  if (event.type === 'message_failed') {
    db.update(event.message_id, { status: 'failed' });
  }
});
  1. On app restart: Re-send pending messages
const pending = db.query({ status: 'pending' });
for (const msg of pending) {
  // Optional: skip messages older than your retention policy
  if (Date.now() - msg.createdAt > 7 * 24 * 3600 * 1000) {
    db.update(msg.id, { status: 'expired' });
    continue;
  }
  protocol.sendMessage(msg.recipient, msg.content, msg.priority);
}

Considerations

  • Duplicate delivery: If the original send succeeded but the app was killed before receiving the ACK, re-sending creates a duplicate. The receiver's deduplicator catches duplicates by message ID, but re-sends generate a new message ID. Consider content-level dedup in the UI if this matters for your use case.
  • Message ordering: Re-sent messages get new timestamps and Lamport clocks. If strict ordering matters, the client should track sequence numbers.
  • Retention policy: Decide how long to keep unsent messages. A disaster-response app might keep them for days; a chat app might expire after hours.
  • Storage limits: Cap the local pending queue to prevent unbounded growth during extended offline periods.