Skip to content

Outbox Contract

Cross-platform behavioural contract for the per-server send queue, so operators see consistent semantics across clients. Storage backends (SQLite WAL on web, Room on Android) and drain loops are per-platform; this contract pins the behaviour both must satisfy. The shared eligibility classification + capacity/TTL constants live in common/src/outbox_policy.rs and are imported by every role (Android mirrors them across the JNI via WayPointCodecoutboxIsQueueEligible / outboxMaxPendingPerServer / outboxPendingTtlMs — rather than forking the values).

Roles

  • Outbox owners (web, Android) — maintain a per-server queue so messages bound for a disconnected server are not silently dropped. This contract is written from their view.
  • Node (pure pubsub, no outbox) — headless sensor/vehicle daemon on a Zenoh peer mesh; no upstream server link, so no per-destination queue. It uses the classification only: every outbound TacNetMessage is routed through is_queue_eligible, and a const _: () = assert!(!is_queue_eligible(KIND)) build guard fails if a node-emitted kind ever becomes queue-eligible. Node emits only real-time kinds (Position 1 Hz, Heartbeat 30 s) and drops silently on put failure. CommandAck is a response-path envelope, intentionally outside OutboxMessageKind.
  • Gateway — one WayPoint send path (gateway::send_outbound_via_mesh); translation publishers (CoT/ADatP-3/NFFI) are external export sinks, out of contract scope.

Eligibility (the authoritative classification)

Every outbound message MUST be routed through outbox_policy::is_queue_eligible (common/src/outbox_policy.rs):

Queue-eligible (buffered while disconnected) Real-time / lossy (never enqueue)
SendDrawing, DeleteDrawing, SendChat, ChatLeave, VoiceLeave Position, Heartbeat, VoiceFrame

Real-time kinds, when the destination is disconnected, are dropped and surfaced as Dropped(RealTimeDuringDisconnect) — stale positions/heartbeats are worse than none.

API surface (informative)

Concrete types are native (TS on web, Kotlin on Android):

outbox.send(message, &[server_a, server_b])  -> SendReceipt
outbox.send_one(message, server_a)           -> SendReceipt
SendReceipt = [ { server_id, status: Sent | Queued | Dropped(reason) }, ... ]

reasonRealTimeDuringDisconnect | EvictedForCapacity | NotQueueEligible. Callers render UX directly from the receipt.

Behavioural guarantees

  • Per-server FIFO. Order preserved per server_id. No global/cross-server ordering guarantee — two shapes sent in quick succession may land on different servers' meshes in different orders.
  • Idempotent replay. Servers accept duplicate shapeId (drawings, first-write-wins ownership) and messageId (chat dedup), so the outbox can retry aggressively without user-visible duplication.
  • Eviction visibility. When a server's pending count exceeds MAX_PENDING_PER_SERVER, the oldest rows by created_at are dropped with Dropped(EvictedForCapacity). UI must surface this (toast/snackbar) — silent drop is a UX trap.
  • TTL pruning. Rows older than PENDING_TTL_MS are pruned without notification (zombie rows; a toast would be noise). Opportunistic on drain or periodic sweep.
  • Restart durability. The outbox survives process restart (SQLite WAL / Room); a crash mid-drain must not lose unconfirmed pending rows.
  • Shutdown drain. On graceful stop, one final synchronous drain pass against connected servers; the rest stays persisted for next start.

What "Sent" means

Sent = Zenoh put success on the upstream link to the server. There is no end-to-end ACK from peer recipients — application-level ACKs are out of scope. ServerNack re-enqueue is a consumer-side follow-up (outbox.send_one with backoff); the outbox itself is ServerNack-unaware.

Constants

Name Value Source
MAX_PENDING_PER_SERVER 256 common/src/outbox_policy.rs
PENDING_TTL_MS 604_800_000 (7 days) common/src/outbox_policy.rs

Change these in outbox_policy.rs only; they propagate to every role via the waypoint_common dep (and the JNI mirror on Android). Do not hardcode copies.

Note on payload confidentiality

The outbox queues and replays the already-packed AuthEnvelope — it is agnostic to whether the inner payload is encrypted. Member content is AES-256-GCM sealed under the deployment group key before it enters the envelope (server-blind E2E confidentiality); the outbox contract is unaffected — it stores and replays opaque envelope bytes either way. See wire-protocol.md for the sealing wire format and implementation status.