Gossip Protocol
Overview
Nodes communicate via GossipSub (libp2p) using CBOR-encoded messages. Two topics are used:
p2p-mes/commands-- outgoing commands and broadcastsp2p-mes/responses-- query responses
Message deduplication is handled at GossipSub level using BLAKE3 hash of message data as message_id.
Audience: node implementers. This page documents the node-to-node gossip layer. Client applications never speak gossip directly -- they use the HTTP API (Building a Client, API Reference). Read on to understand how nodes propagate and reconcile data, or to build a node.
GossipMessage Enum
All gossip traffic is a single CBOR-encoded enum:
#![allow(unused)] fn main() { enum GossipMessage { PutMessage(PutMessage), // Store a new message InboxFanout(InboxFanout), // Disabled (full replication) BatchedInboxFanout(BatchedInboxFanout), // Disabled (full replication) Query(Query), // Request data QueryResponse(QueryResponse), // Respond to query Ack(Ack), // Deprecated ReadProgress(ReadProgress), // Mark-as-read ReadProgressAck(ReadProgressAck), // Mark-as-read acknowledgment MembershipOp(MembershipOp), // Membership change MembershipOpBatch(Vec<MembershipOp>), // Batch of ops PutIdentity(PutIdentity), // User identity propagation (HLC LWW) } }
Encoding/decoding:
#![allow(unused)] fn main() { let bytes = gossip_msg.encode(); // serde_cbor::to_vec let msg = GossipMessage::decode(&bytes); // serde_cbor::from_slice }
CBOR Serialization Format
GossipMessage uses serde's internally-tagged representation. Each variant serializes as a CBOR map with one key (the variant name) whose value is the variant's payload:
PutMessage example:
{"PutMessage": {"msg_id": <bytes32>, "chat_id": <bytes32>, ...}}
MembershipOp example:
{"MembershipOp": {"chat_id": <bytes32>, "target": <bytes20>, "sig": <bytes65>,
"role": 0, "op_type": 0, "hlc": 111669149696005}}
// `hlc` is a packed HlcTimestamp: 48 bits physical_ms + 16 bits logical.
MembershipOpBatch example:
{"MembershipOpBatch": [<MembershipOp>, <MembershipOp>, ...]}
CBOR type mapping:
| Rust Type | CBOR Type |
|---|---|
[u8; N] | array of N unsigned integers (major type 4) -- NOT a byte string |
Vec<u8> | array of unsigned integers (major type 4) -- NOT a byte string |
String | text string (major type 3) |
u8, u32, u64 | unsigned integer (major type 0) |
bool | simple value (major type 7): false=0xF4, true=0xF5 |
Option<T> | null (0xF6) if None, T if Some |
Vec<T> | array (major type 4) |
ChatKind | map with keys "t" and "d" (see TYPES.md) |
MembershipOpType | unsigned integer: Add=0, Remove=1, Create=2 |
Note: serde_cbor is the serialization library. Fields with #[serde(default)] are omitted when at default value on some paths -- implementors should handle both presence and absence.
Byte arrays are CBOR arrays, not byte strings. No serde_bytes annotation is used, so [u8; N] and Vec<u8> fields encode as CBOR arrays of u8 integers (major type 4), never byte strings (major type 2). The <bytes32> notation in examples above is shorthand for such an array. See decoding msg_cbor in the Client Guide for a worked decoder.
Message Types
PutMessage
Initiated by HTTP API. Broadcasts a new chat message to the network.
#![allow(unused)] fn main() { struct PutMessage { msg_id: [u8; 32], // BLAKE3(chat_id || sender || hlc_packed_be || text) chat_id: [u8; 32], // Chat identifier kind: ChatKind, // Dm { peer } | Group { title } | Channel { title } sender: [u8; 20], // Sender's address members: Option<Vec<[u8; 20]>>, // Chat members (for inbox fanout) text: String, // Message text (empty for control messages) hlc: HlcTimestamp, // Server-stamped HLC (storage/CRDT/sync) origin_wall_ts: u64, // Frozen originator wall-clock (UI display) origin: String, // PeerId of originating node needs_ack: bool, // Always false (fire-and-forget mode) msg_type: u8, // 0 = regular, 1 = handshake, 2 = key_rotation control: Option<Vec<u8>>, // E2EE control payload (CBOR, opaque to node) } }
Node-enforced fields (node validates and uses these):
msg_id-- computed by node, used for dedup and Merkle treechat_id-- used for routing, storage, membership checkssender-- verified against auth signaturemembers-- used for inbox fanout (DM only; groups use members CF)hlc-- stamped server-side by the originating API node'sHlcState; drives themessagesCF key, retention cutoff comparisons, and inboxlast_ts. Receiver-side gossip handler feeds the value throughHlcState::receiveand drops the message on drift violationorigin_wall_ts-- frozen sender wall-clock; UI display only, never participates in distributed logicorigin-- used for gossip routing
Client-opaque fields (node stores and relays without interpretation):
msg_type: u8-- client-defined, node does not switch on this valuecontrol: Option<Vec<u8>>-- opaque payload for client-to-client protocolstext: String-- node uses first 80 chars for inbox preview, otherwise opaque
The values 0=regular, 1=handshake, 2=key_rotation are a client convention, not a protocol requirement. Any u8 value is valid.
Flow:
- HTTP handler creates PutMessage with computed msg_id
- Publishes to
p2p-mes/commands - All nodes receive, each stores via
DbOp::PutMessage - Dedup via
seen_msgCF prevents double storage - On store success,
process_db_opupdates user inboxes locally
Query / QueryResponse
Request-reply pattern over gossip for data retrieval.
#![allow(unused)] fn main() { struct Query { query_id: [u8; 16], // Correlation ID kind: QueryKind, requester: String, // PeerId of requester } enum QueryKind { GetChatRange { user: [u8; 20], chat_id: [u8; 32], from_ts: u64, to_ts: Option<u64>, after_key: Option<Vec<u8>>, limit: usize, }, ListUserChats { user: [u8; 20], limit: usize, after_key: Option<Vec<u8>>, }, } }
#![allow(unused)] fn main() { struct QueryResponse { query_id: [u8; 16], // Matches Query.query_id kind: QueryResponseKind, } enum QueryResponseKind { GetChatRange(GetChatRangeResponseRaw), ListUserChats(ListUserChatsResponseRaw), } }
Flow:
- MPSC handler publishes
Querytop2p-mes/commands - Stores
PendingQuerywith oneshot channel for response - Any node with data publishes
QueryResponsetop2p-mes/responses - First response wins, sent back to HTTP client
- Timeout: 30 seconds, cleaned up by
ack_timeout_checktimer
ReadProgress / ReadProgressAck
Broadcasts read progress across nodes.
#![allow(unused)] fn main() { struct ReadProgress { progress_id: [u8; 16], // Correlation ID user: [u8; 20], chat_id: [u8; 32], seq: u32, // Mark all messages up to this seq as read origin: String, // PeerId of origin node } struct ReadProgressAck { progress_id: [u8; 16], from: String, // PeerId of acknowledging node } }
Each node stores read progress in user_read_progress CF. Update is monotonic: only advances if seq > current.
MembershipOp
Protocol-level membership change. Travels via gossip, updates members CF only (never messages CF).
#![allow(unused)] fn main() { struct MembershipOp { chat_id: [u8; 32], // Target group chat target: [u8; 20], // User being added/removed/created-as sig: Vec<u8>, // ECDSA signature (65 bytes) role: u8, // 0=participant, 1=admin (default 0) op_type: MembershipOpType, // Add=0, Remove=1, Create=2 hlc: HlcTimestamp, // Originator's HLC stamp (packed u64) } enum MembershipOpType { Add = 0, Remove = 1, Create = 2 } }
Client signature deliberately excludes hlc -- clients have no
access to node-level HLC state. The canonical signature message is
keccak256(chat_id || target || op_type_byte) exactly as before. The
node stamps hlc server-side via its HlcState immediately before
publishing, mirroring how the legacy ts: u64 was set by
now_millis(). See md/TIME_AND_CONSISTENCY.md for the rationale.
Flow:
- HTTP API
POST /groups/{chat_id}/opsverifies ECDSA signature - For Create ops: API verifies
chat_id == blake3(domain || signer || nonce) - MPSC handler stamps each op with
ctx.hlc.stamp()(one stamp per op) - MPSC handler checks role-based authorization via DB
- Duplicate Create rejected if group already has members
- All valid ops published as one
GossipMessage::MembershipOpBatch - Gossip handler feeds incoming
hlcintoctx.hlc.receive(), dropping the op if it exceeds local now by more than the configured max-drift bound (default 5 minutes). Then re-verifies sig + auth (don't trust other nodes) - Stored via
DbOp::MembershipOp { hlc, ... }. The DB writer routes toadd_member_synced/remove_member_synced, which perform the CRDT merge withremoved_atsemantics (see STORAGE.md)
Anti-entropy sync ships the full MemberInfo state (role,
added_at, optional removed_at) per record via
SyncMemberRecord. Receivers apply via DbOp::ApplyMemberRecord,
which calls apply_member_record_synced for a monotonic CRDT merge
that preserves tombstones either side might have observed
independently. See SYNC.md.
Admin self-remove prevention:
Both MPSC and gossip handlers independently check get_member_info(signer).role == 1 and reject Remove ops where signer == target and signer is admin. This prevents the admin from leaving their own group via any code path -- both the HTTP DELETE /groups/{chat_id}/membership endpoint and the compound POST /groups/{chat_id}/ops endpoint enforce this.
Group creation via compound endpoint:
Group creation is done via POST /groups/{chat_id}/ops with a Create op. The client generates a random 16-byte nonce, computes chat_id = blake3(domain || signer || nonce), and signs the Create op. The API verifies the nonce-to-chat_id derivation. This eliminates the security gap where the old POST /groups endpoint generated chat_id server-side and the client couldn't sign it.
LeaveGroup reuses MembershipOp(Remove):
DELETE /groups/{chat_id}/membership publishes a standard MembershipOp with op_type=Remove and target=sender. No new gossip variant was needed.
Duplicate Create protection:
Both MPSC handler and gossip handler reject Create ops when list_members(chat_id, limit=1) returns any results. This prevents group hijacking: once a group is created and has members, no subsequent Create op can overwrite ownership.
MembershipOpBatch:
GossipMessage::MembershipOpBatch(Vec<MembershipOp>) carries all ops from a single compound call in one gossip message. The receiver processes ops in order within the batch, ensuring atomic delivery (e.g. Create+Add arrives together, preventing split-brain where Add arrives before Create).
Authorization inside a batch uses an in-memory virtual-state overlay (see handlers/membership_batch.rs): a Create populates the overlay with the new admin, and a subsequent Add in the same batch consults the overlay before falling back to the on-disk members CF. Without this overlay an [Create, Add, Add] batch would fail because the async DB writer is FIFO and has not committed the Create by the time the Add is authorized.
Individual MembershipOp messages are still supported for backward compatibility and single-op flows (e.g. LeaveGroup).
Two delivery channels for compound membership:
One HTTP call to POST /groups/{chat_id}/ops produces:
- 1
GossipMessage::MembershipOpBatchmessage (all membership ops) - M
GossipMessage::PutMessagemessages (client data: MLS Welcome/Commit)
Membership ops are processed before accompanying messages.
sequenceDiagram
participant C as Client
participant API as HTTP API
participant MPSC as MPSC Handler
participant G as GossipSub
participant DB as DB Writer
C->>API: POST /groups/{chat_id}/ops
API->>API: Verify ECDSA sig + nonce (Create only)
API->>MPSC: Command::MembershipOp
MPSC->>MPSC: Verify admin role + duplicate Create guard
MPSC->>G: MembershipOpBatch (all ops)
MPSC->>DB: DbOp::MembershipOp (per op)
loop Each accompanying message
MPSC->>G: PutMessage
MPSC->>DB: DbOp::PutMessage
end
Note over DB: process_db_op updates inboxes locally
MPSC-->>C: 200 OK
Membership Operations
Membership operations use a dedicated MembershipOp gossip variant (not embedded in PutMessage with msg_type routing).
Operation Types (op_type)
| Op | Value | Description |
|---|---|---|
| Add | 0 | Add member to group chat |
| Remove | 1 | Remove member from group chat |
| Create | 2 | Create group (founder message) |
MembershipPayload (control field)
#![allow(unused)] fn main() { struct MembershipPayload { target: [u8; 20], // User being added/removed sig: Vec<u8>, // ECDSA signature (65 bytes) role: u8, // 0=participant, 1=admin (default 0) } }
Admin Signature Format
Canonical 53-byte message: chat_id[32] || target[20] || op_type[1]
op_type values: Add=0, Remove=1, Create=2.
Signature: sign(keccak256(canonical_message)) -- standard Ethereum ECDSA with recovery (r[32] || s[32] || v[1]).
verify_membership_sig in crates/crypto recovers the signer address for caller to verify against admin.
group_chat_id Computation
#![allow(unused)] fn main() { fn group_chat_id_with_nonce(admin: [u8; 20], nonce: &[u8]) -> [u8; 32] { BLAKE3("p2p-mes:chat:group:v1:" || admin[20] || nonce) } }
Uses a random 16-byte nonce generated client-side. This ensures unique chat_ids even when the same admin creates multiple groups at the same timestamp. The nonce is not stored -- the chat_id itself is the stable identifier.
CRDT add-wins Semantics
Membership uses add-wins conflict resolution:
- Each member entry has
added_attimestamp for ordering - If both add and remove exist for the same user at the same timestamp, add wins
add_member_crdtonly skips if existingadded_atis strictly greater- Removed members (key deleted) can always be re-added
This ensures convergence across nodes: all nodes applying the same set of membership messages in any order will reach the same state.
PutIdentity
Propagates a user identity blob to all nodes. Uses last-write-wins semantics under HLC.
#![allow(unused)] fn main() { struct PutIdentity { user: [u8; 20], // User address blob: Vec<u8>, // Opaque identity blob (max 1024 bytes) hlc: HlcTimestamp, // Server-side HLC stamp of the originating write origin: String, // PeerId of the originating node } }
Flow:
- HTTP
PUT /identityreaches the MPSC handler, which stampshlcvia the node'sHlcStateand sendsDbOp::PutIdentityto the async DB writer - DB writer applies HLC-LWW, updates
seen_identitysync index, notifies Merkle tree - MPSC handler publishes
PutIdentitytop2p-mes/commands(real-time gossip) - All nodes receive; gossip handler first calls
HlcState::receive(msg.hlc)-- the incoming HLC is rejected if it exceeds local now by more thanDEFAULT_MAX_DRIFT_MS, otherwise the local clock advances -- then routes throughDbOp::PutIdentity(same HLC-LWW pipeline) - If
incoming.hlc > stored.hlc(or no stored value): overwrite with[hlc_packed:u64be:8][blob] - If
incoming.hlc <= stored.hlc: silently discard (stale delivery)
Last-write-wins: hlc is stamped server-side by the originating
API node's HlcState; clients never specify time. The client signature
scheme is unchanged.
Merkle tree sync: Identity is included in the anti-entropy protocol (domain Identity). Nodes that were offline during gossip will receive the data via Merkle-tree sync; the receiver routes each SyncIdentityRecord through the same HLC-LWW pipeline, so resurrecting an older blob is impossible.
Deprecated and disabled variants
These variants remain in the GossipMessage enum for CBOR backward
compatibility but are not part of the active protocol. New node
implementations neither send nor process them, and clients never see them.
InboxFanout / BatchedInboxFanout (disabled)
Disabled under full replication. Because every node stores all messages, each
node updates user inboxes locally inside process_db_op right after writing
PutMessage, so explicit gossip fanout is unnecessary. If sharding is ever
re-introduced (where the node storing a message may differ from the node owning
a user's inbox), these would need to be re-enabled.
#![allow(unused)] fn main() { struct InboxFanout { users: Vec<[u8; 20]>, // All chat members (targets for inbox update) chat_id: [u8; 32], kind: ChatKind, last_sender: [u8; 20], last_ts: u64, last_seq: u32, last_msg_id: [u8; 32], last_text_preview: String, // First 80 chars of message text } struct BatchedInboxFanout { items: Vec<InboxFanout>, // Up to 100 items per batch } }
Ack (deprecated)
Legacy acknowledgment for PutMessage. No longer used -- all messages are
fire-and-forget (needs_ack = false).
#![allow(unused)] fn main() { struct Ack { msg_id: [u8; 32], from: String, response: PutMessageResponseRaw, } }
CBOR Compatibility Rules
When modifying gossip message types:
- Add new fields with
#[serde(default)]-- safe, backward compatible - Never remove fields -- old nodes will fail to deserialize
- Never change enum tag names or numbering
- Never change field types
- New enum variants are safe -- unknown variants cause deserialization errors, but senders should be updated first
msg_id Computation
#![allow(unused)] fn main() { fn compute_msg_id(chat: &[u8], sender: &[u8], hlc: HlcTimestamp, text: &str) -> [u8; 32] { BLAKE3(chat || sender || hlc.to_packed().to_be_bytes() || text.as_bytes()) } }
Same inputs always produce the same msg_id, enabling idempotent
storage across nodes. The packed HLC is hashed as 8 big-endian bytes
-- same shape as the legacy ts: u64, so the resulting digest stays
32 bytes.
DM chat_id Computation
#![allow(unused)] fn main() { fn dm_chat_id(a: [u8; 20], b: [u8; 20]) -> [u8; 32] { let (lo, hi) = if a <= b { (a, b) } else { (b, a) }; BLAKE3("p2p-mes:chat:dm:v1:" || lo || hi) } }
Deterministic: both participants compute the same chat_id. No membership storage needed for DMs.