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 WayPointCodec — outboxIsQueueEligible / 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
TacNetMessageis routed throughis_queue_eligible, and aconst _: () = assert!(!is_queue_eligible(KIND))build guard fails if a node-emitted kind ever becomes queue-eligible. Node emits only real-time kinds (Position1 Hz,Heartbeat30 s) and drops silently onputfailure.CommandAckis a response-path envelope, intentionally outsideOutboxMessageKind. - 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) }, ... ]
reason ∈ RealTimeDuringDisconnect | 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) andmessageId(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 bycreated_atare dropped withDropped(EvictedForCapacity). UI must surface this (toast/snackbar) — silent drop is a UX trap. - TTL pruning. Rows older than
PENDING_TTL_MSare 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.