ProtocolMessage Envelope
chronicle intermediate 4 min read
ELI5
Every message between app and relay is a sealed envelope with a type: label on the outside. The label tells the relay which drawer to open. There is one master enum that lists every legal label, and changing it without updating both Rust and TypeScript is how you summon INVALID_MESSAGE.
Technical Deep Dive
Wire Shape
ProtocolMessage (relay/src/protocol/schema.rs:427) is a tagged enum:
#[serde(tag = "type", rename_all = "snake_case")]pub enum ProtocolMessage { Authenticate(...), AuthResult(...), ... }JSON example: {"type":"authenticate","token":"...","protocol_version":1}. The TypeScript mirror in app/src/services/protocol.ts produces and consumes the same shape.
Variant Categories
classDiagram class ProtocolMessage { <<enum, tag=type>> } class Authentication { authenticate auth_result } class DocumentRoom { join_document joined_document leave_document left_document } class Presence { presence presence_sync user_joined user_left heartbeat } class Operations { operation operation_ack operation_rejected operation_batch } class Sync { sync_request sync_response snapshot_request snapshot } class Timeline { timeline_query timeline_snapshot timeline_metadata_request timeline_metadata } class System { ping pong error system_notification } ProtocolMessage <|-- Authentication ProtocolMessage <|-- DocumentRoom ProtocolMessage <|-- Presence ProtocolMessage <|-- Operations ProtocolMessage <|-- Sync ProtocolMessage <|-- Timeline ProtocolMessage <|-- SystemVersioning
PROTOCOL_VERSION and MIN_PROTOCOL_VERSION live in relay/src/protocol/mod.rs. The client embeds protocol_version inside Authenticate (defaulting via default_protocol_version() to PROTOCOL_VERSION). Validation rejects values below the minimum with UNSUPPORTED_PROTOCOL_VERSION.
Validation Pass
flowchart LR raw["raw bytes"] --> parse[serde_json::from_str] parse -- ok --> val[validate_message] parse -- err --> e1[INVALID_MESSAGE] val -- ok --> route[handler dispatch] val -- err --> e2[ValidationError → ErrorMsg]validate_message (relay/src/protocol/validation.rs) enforces non-empty tokens, non-nil UUIDs on JoinDocument / Operation, and the protocol-version floor.
Server-Augmented Fields
Some fields are server-added when broadcasting and MUST NOT be sent by clients:
OperationMsg.author_id— filled in by the relay from the verified identity.OperationAckMsg.server_timestamp,audit_ref— set on the relay side.
Key Terms
- tag = “type” → serde attribute making the variant discriminator a JSON string field; the canonical name comes from
rename_all = "snake_case". - mirror file →
app/src/services/protocol.ts; any Rust change here lands there. - system messages →
Ping/Pong/Error/SystemNotification;PingandPongare unit variants (no payload object).
Q&A
Q: How is ProtocolMessage::Ping serialised?
A: As {"type":"ping"} — unit variants have no extra fields with this serde config.
Q: What error fires when type is unknown?
A: serde fails to deserialise and the relay emits Error { code: "INVALID_MESSAGE" }.
Q: Are operation IDs required to be non-nil?
A: Yes — validate_message rejects nil UUIDs on Operation with INVALID_OPERATION (validation.rs:91).
Q: Why are some fields wrapped in Option with skip_serializing_if?
A: To keep the on-wire JSON compact and to clearly distinguish “absent” from “null”.
Examples
Adding a comment_thread message means: (1) add a struct + variant in schema.rs, (2) add validation in validation.rs, (3) add the same variant and TS interface in app/src/services/protocol.ts, (4) bump PROTOCOL_VERSION only if the change is breaking. Forgetting (3) is the canonical way to ship a “works on my machine” bug.
neighbors on the map
- Auth-First WebSocket Handshake debugging AUTH_REQUIRED errors on the relay
- CRDT Operation Message adding a new operation type