Cryptid Messages
CryptidMessage is the application-level envelope for all encrypted communication within Cryptid groups. All messages are encrypted via MLS and signed by the sender’s device. This document specifies the message structure and wire format only.
Message Architecture
Section titled “Message Architecture”CryptidMessage uses a hierarchical structure that distinguishes freestanding messages from operations on existing messages:
Freestanding Messages
Section titled “Freestanding Messages”These are complete messages that don’t reference other content:
- Message: Text or sticker content
- TypingIndicator: Transient composition state
- ReadReceipts: Batch notification of read messages
- PersonaUpdate: Notify group of persona changes
- Custom: Extension point for future use
Operations on Messages
Section titled “Operations on Messages”These always reference existing content via message ID or file ID:
-
MessageAction: Targets a text message (via MessageId)
- Reaction: Add/remove emoji reaction
- Edit: Modify a past message
- AttachFile: Associate a file with a message
- MarkDeleted: Request deletion of a message
-
FileAction: Targets a file (via FileId)
- Request: Ask for file data (with optional byte range for resumable downloads)
- Data: Send file data chunk in response to Request
- MarkDeleted: Notify that file is no longer available
CryptidMessage Structure
Section titled “CryptidMessage Structure”/// Type-safe wrapper around a UUIDv7 identifying a messagepub struct MessageId(Uuidv7);
/// Application-level message structure (exists inside MLS ciphertext)/// This is what clients see after MLS decryption. Servers never see this.struct CryptidMessage { // A UUIDv7 (incorporates Unix millisecond timestamp) message_id: MessageId, // Derived from the Ed25519 public key sender: DeviceId, // The persona that was used to send this message sender_persona_id: Option<PersonaId>, // ThreadId is a Uuidv4 that represents a message thread thread_id: Option<ThreadId>, // Tagged enum for message content or operations inner: MessageInner,}Field Specification
Section titled “Field Specification”-
message_id- MessageId (wrapper around Uuidv7)
- MessageIds MUST NOT be reused
- We use UUIDv7 here for improved temporal uniqueness guarantees
- Timestamps are encoded within the UUIDv7 so clients can extract the millisecond if needed
-
sender- Fixed to the MLS member’s device identity
- Authenticated by MLS group signature
- Immutable once in the group
-
sender_persona_id- Optional u16
- A value of 0 or null should be treated as the default persona
- No validation against sender’s Personas
- Clients are responsible for validating this against the sender’s known personas
- If missing, clients use sender’s default persona
- No protocol-level constraints on the format
-
thread_id- ThreadId is a uuidv4 that represents a conversation thread
- It has no semantic meaning to the protocol itself. It purely exists as a way for clients to organize messages
- There’s no validation. Clients may assign any UUIDv4 value they like
- Thread nesting/hierarchy is client responsibility
-
inner- Tagged enum (
MessageInner) determining message semantics - Distinguishes between freestanding messages and operations on existing messages
- Tagged enum (
Shared File Reference
Section titled “Shared File Reference”FileRef is a unified structure for identifying files across both message attachments and media extensions:
/// Core file identity shared across attachments and media referencesstruct FileRef { size: u64, // File size in bytes plaintext_hash: [u8; 32], // Blake3 of unencrypted content file_id: FileId, // Structured file identifier}
/// Type-safe file identifierstruct FileId { uploader: DeviceId, id: u64, // Local counter maintained by uploader}MessageInner Structure
Section titled “MessageInner Structure”enum MessageInner { // Freestanding messages Message(String), // Text message (also does Stickers via "<media_pack/id>" encoding)
// Operations on existing messages MessageAction(MessageId, MessageAction),
// File operations FileAction(FileId, FileAction),
// Transient notifications ReadReceipts(Vec<MessageId>), TypingIndicator { timeout_secs: u8 },
// Persona synchronization PersonaUpdate { updated_persona_id: PersonaId, updated_persona: Persona, },
// Future extensibility Custom { custom_type: String, payload: serde_json::Value },}
enum MessageAction { // Anyone can send Reaction { emoji: String, add: bool },
// Sender or mods/admins only Edit { new_text: Option<String>, new_persona_id: Option<PersonaId>, }, AttachFile { filename: String, mime_type: String, file_ref: FileRef, alt_text: Option<String>, }, MarkDeleted,}
enum FileAction { Request(Option<Range<u64>>), // None = whole file Data { start: u64, data: Vec<u8> }, // Plaintext data MarkDeleted,}MessageInner Variants
Section titled “MessageInner Variants”// Sent as MessageInner::Message(String)// Example: "Hello, world!"The Message variant contains plain UTF-8 text. The protocol specifies two special syntaxes for interoperable references:
Emoji References
Section titled “Emoji References”Format: :emoji_name:
- Clients MUST recognize and attempt to resolve
:emoji_name:to an emoji from the group’s custom_media extension or as an emoji shortcode. - If neither are found, clients display the literal string
:emoji_name:as fallback - Example: “That’s
:excited:!” renders as “That’s 😆!” (if emoji exists) or “That’s :excited:!” (if not found)
Sticker References
Section titled “Sticker References”Format: <media_pack/{pack_id}/{sticker_id}>
- While stickers are technically sent as text messages, the protocol recognizes this special pattern
- Clients MUST recognize
<media_pack/...>as a sticker reference - Clients attempt to resolve {pack_id} and {sticker_id} from the group’s custom_media extension
- If not found, clients display the literal string as fallback
- Clients MAY render stickers as inline images, embeds, or other formats
Example: <media_pack/pack-alice-cats/excited-cat>
Additional Formatting
Section titled “Additional Formatting”Beyond emoji and sticker references, clients are responsible for all other text rendering and formatting. This includes:
- Markdown support (bold, italic, code blocks, etc.)
- Escape sequences (if clients choose to implement them locally)
- Link detection and previewing
- Quote formatting
- Syntax highlighting
- Custom parsing rules
Rationale: Different clients have different UX needs. The protocol specifies only what needs to be interoperable (references to shared group resources). Presentation is a client concern.
Reaction
Section titled “Reaction”MessageInner::MessageAction( MessageId, MessageAction::Reaction { emoji: String, add: bool, })message_idis the target message being reacted toemojiis the reaction emojiadd: trueadds the reaction,add: falseremoves it- No validation that target
message_idexists or is valid - No constraints on emoji content (client is responsible)
- Clients decide how to display, aggregate, or limit reactions
MessageInner::MessageAction( MessageId, MessageAction::Edit { new_text: Option<String>, new_persona_id: Option<PersonaId>, })message_idis the target message being editednew_textis the updated message content (if changing text)new_persona_idis the updated persona for this message (if changing persona)- Validation: At least one field (
new_textornew_persona_id) MUST beSome - Permission: Only the original message sender (or group admin) may send Edit actions
- Clients MUST verify that
senderin the Edit message matches thesenderof the original Message - If
new_persona_idis provided, it MUST be a valid persona for the sender - Servers/groups can enforce this via MLS permissions
- Clients MUST verify that
- No protocol-level ordering. Clients handle edit conflicts (last-write-wins or local conflict resolution)
- No history tracking. Only latest content available
- Clients decide whether to store and show edit history
Delete
Section titled “Delete”MesasgeInner::MessageAction( MessageId, MessageAction::MarkDeleted)message_idis the target message being deleted- Permission: Only the original message sender (or group admin/mod) may send MarkDeleted actions
- Clients MUST verify that
senderin the MarkDeleted message matches thesenderof the original Message - Servers/groups can enforce this via MLS permissions
- Clients MUST verify that
- Spec compliant clients WILL delete messages but deletion cannot be enforced on non-compliant clients
- Clients decide how to handle local deletion, sync, undo, etc.
ReadReceipts
Section titled “ReadReceipts”MessageInner::ReadReceipts(Vec<MessageId>)- Contains a list of message IDs that the sender has read
- No validation of message IDs
- Purely informational. Clients handle display logic
- No protocol requirement for reliability or ordering
TypingIndicator
Section titled “TypingIndicator”MessageInner::TypingIndicator { timeout_secs: u8 }- Transient notification indicating sender is composing a message
timeout_secstells recipients how long to display the typing indicator (recommended: 3-10 seconds)- Has no persistence requirement
- Clients decide timeout enforcement, display, and when to send updates
PersonaUpdate
Section titled “PersonaUpdate”MessageInner::PersonaUpdate { updated_persona_id: PersonaId, updated_persona: Persona,}- Notifies group members when a user updates their persona
updated_persona_ididentifies which persona was updated (Defaultor numbered persona)updated_personacontains the complete updated persona data (display name, profile picture, bio, pronouns)- Purpose: Keeps persona information synchronized across all groups without requiring manual refresh
- Validation:
updated_persona_idMUST bePersonaId::Defaultor a validPersonaId::Id(NonZeroU16)updated_personaMUST be a valid Persona structure- Sender MUST be a member of the group (verified via MLS authentication)
- Recipients update their local cache of the sender’s personas
- Not retroactive, only affects future message rendering
When to send:
- User changes display name, profile picture, bio, or pronouns
- User updates a numbered persona
- Broadcast to ALL groups the user is in
When NOT to send:
- Initial persona creation (no one knows about you yet)
- Persona deletion (just stop using it)
See Cryptid’s Identity System for persona structure details.
FileAttachment
Section titled “FileAttachment”Files are announced via MessageAction::AttachFile. Every file attachment is paired with a Message (which may be empty or contain a caption). An AttachFile action always targets a Message variant.
MessageInner::MessageAction( MessageId, MessageAction::AttachFile { filename: String, mime_type: String, file_ref: FileRef, alt_text: Option<String>, })Field Specification:
- MessageId is the ID of the associated Message. Clients MUST first send a Message (with caption text or empty string), then send the AttachFile MessageAction targeting that message ID.
filename: No path separators. UTF-8 required. Client sanitizes.mime_type: Hint only. No validation or enforcement.file_ref.size: File size in bytes. Informational. Clients use for progress and storage checks.file_ref.plaintext_hash: Blake3 hash of unencrypted file. Used for deduplication and verification.file_ref.file_id: Structured FileId (uploader + local counter). Type-safe and prevents malformed IDs.uploader: DeviceId of the file’s ownerid: Local counter maintained by uploader
alt_text: Alternative/descriptive text for the file (for accessibility and reference)
Invariant: File Attachment Protocol
Files are always associated with a message:
-
File with caption:
- Send
MessageInner::Message("Here's the document") - Receive message ID
msg-1 - Send
MessageInner::MessageAction(msg-1, MessageAction::AttachFile { ... })
- Send
-
File without caption:
- Send
MessageInner::Message("")(empty string) - Receive message ID
msg-1 - Send
MessageInner::MessageAction(msg-1, MessageAction::AttachFile { ... })
- Send
This invariant simplifies implementation: reactions and edits on files can be applied to the associated message just like any other message. Clients can display the attachment alongside its caption or in a single unified view.
Usage:
When a sender announces a file to the group:
- Sends MessageInner::Message (caption or empty string)
- Gets back a message ID
- Sends MessageAction(MessageId, AttachFile) with file metadata
- Recipients see the announcement
- Recipients send FileAction(Request) to fetch the file
- Sender responds with FileAction(Data) chunks containing plaintext data
- MLS encrypts the entire message containing chunks
- Recipient verifies
plaintext_hashafter receiving all chunks
FileRequest
Section titled “FileRequest”MessageInner::FileAction( file_id: FileId, FileAction::Request(Option<Range<u64>>))file_id: Matches the FileId from a FileAttachment message (structured with uploader and id)range:None= request entire file (full download)Some([start, end])= request specific byte range (resumable downloads)
- Validation: Clients MUST NOT request ranges beyond announced
size
Usage:
- Recipient sends this to request a file from the sender
- Used for initial download and resuming interrupted transfers
- Sender SHOULD respond with FileAction(Data) chunk(s)
FileData
Section titled “FileData”MessageInner::FileAction( file_id: FileId, FileAction::Data { start: u64, data: Vec<u8>, // File chunk })file_id: Matches the FileId of the requested file (structured with uploader and id)start: Byte offset where this chunk startsdata: file chunk (base64 in JSON format). MLS encrypts the outer message.- Validation: Clients verify
(start + data.len()) <= announced file size
Usage:
- Sender responds to FileAction(Request) with one or more FileAction(Data) messages
- Each message contains a chunk of plaintext file data
- Enables resumable downloads and memory-efficient transfer
- If range request was for bytes [1048576..2097152], sender responds with data covering that range
FileMarkDeleted
Section titled “FileMarkDeleted”MessageInner::FileAction( file_id: FileId, FileAction::MarkDeleted,)- Courtesy indicator that a file has been deleted
- Indicates the sending device will not respond to further requests for this file
- Clients SHOULD verify that the device sending the notice owns the corresponding FileId (matches
uploaderfield) before listening to it - Permission: Only the file uploader may send MarkDeleted
Custom
Section titled “Custom”MessageInner::Custom { custom_type: String, payload: serde_json::Value,}- Future extensibility mechanism
- No protocol validation. Implementation-specific.
- Clients ignore unknown types.
Serialization
Section titled “Serialization”- Format: JSON
- Encoding: UTF-8
- Optional fields: Omitted if None
- Binary data (Vec<u8>): Base64 encoded in JSON
- MessageInner uses tagged enum serialization (discriminator + payload)
- FileId serialized as:
{ "uploader": "<device_id>", "id": <u64> } - FileRef serialized as:
{ "size": <u64>, "plaintext_hash": "<hex>", "file_id": {...} } - Ranges serialized as:
[start, end]ornull
Validation Rules (Protocol Level)
Section titled “Validation Rules (Protocol Level)”message_idis a valid UUIDv7senderis a valid DeviceIdsender_persona_id(if present) is valid u16thread_id(if present) is a valid UUIDv4- Text in
Messagevariant is valid UTF-8 filenamein AttachFile is valid UTF-8 (no path separators)mime_typeis valid UTF-8plaintext_hashin FileRef is a 32-byte arrayFileId.uploaderis a valid DeviceIdFileId.idis a valid u64- In FileAction::Data:
start + data.len() <= announced file size emojiin Reaction is valid UTF-8 (protocol doesn’t restrict format)timeout_secsin TypingIndicator is a valid u8- Edit operations:
senderMUST match original message’ssender(client responsibility) - Edit operations: At least one of
new_textornew_persona_idMUST beSome - Edit operations: If
new_persona_idprovided, it must be a valid persona for the sender - PersonaUpdate:
updated_persona_idmust beDefaultor validId(NonZeroU16) - PersonaUpdate:
updated_personamust be a valid Persona structure - MarkDeleted operations on messages:
senderMUST match original message’ssender(client responsibility) - MarkDeleted on files:
senderMUST match FileId.uploader (client responsibility) - Invariant: Every MessageAction(MessageId, AttachFile) MUST reference a prior Message variant with that MessageId (clients enforce when constructing messages)
NOT Protocol Responsibilities
Section titled “NOT Protocol Responsibilities”- Checking if
sender_persona_idexists in sender’s Personas - Enforcing thread membership or hierarchy
- Validating message references (which messages exist)
- Persisting or enforcing deletes
- Handling edit conflicts or order
- Displaying reactions, read receipts, or typing indicators
- Security scanning file attachments
- Rate limiting messages per user
- Content moderation
- Validating sticker pack membership
- Enforcing file retention policies
- Rendering markdown or other text formatting
Example Messages
Section titled “Example Messages”Text message in thread as specific persona
Section titled “Text message in thread as specific persona”{ "message_id": "019a821b-d8d4-7dc1-8ea4-28dfcf55346b", "sender": "sender-device-id", "thread_id": "random-thread-id", "sender_persona_id": 0, "inner": { "type": "Message", "data": "Agreed, let's proceed" }}Text with inline emoji reference
Section titled “Text with inline emoji reference”{ "message_id": "019a821b-d8d4-7dc1-8ea4-28dfcf55346b", "sender": "sender-device-id", "thread_id": "random-thread-id", "sender_persona_id": 0, "inner": { "type": "Message", "data": "That's awesome! :excited:" }}Sticker message
Section titled “Sticker message”{ "message_id": "019a821b-d8d4-7dc1-8ea4-28dfcf55346b", "sender": "sender-device-id", "thread_id": null, "sender_persona_id": null, "inner": { "type": "Message", "data": "<media_pack/pack-alice-cats/excited-cat>" }}Reaction
Section titled “Reaction”{ "message_id": "019a821b-d8d4-7dc1-8ea4-28dfcf55346b", "sender": "sender-device-id", "thread_id": null, "sender_persona_id": 2, "inner": { "type": "MessageAction", "message_id": "019a821b-d8d4-7dc1-8ea4-28e09b9f1af1", "data": { "type": "Reaction", "emoji": "thumbs-up", "add": true } }}{ "message_id": "019a821b-d8d4-7dc1-8ea4-28dfcf55346b", "sender": "sender-device-id", "thread_id": null, "sender_persona_id": null, "inner": { "type": "MessageAction", "message_id": "019a821b-d8d4-7dc1-8ea4-28e09b9f1af1", "data": { "type": "Edit", "new_text": "Sorry, I meant cat", "new_persona_id": null } }}Delete
Section titled “Delete”{ "message_id": "019a821b-d8d4-7dc1-8ea4-28e09b9f1af1", "sender": "sender-device-id", "thread_id": null, "sender_persona_id": null, "inner": { "type": "MessageAction", "message_id": "019a821b-d8d4-7dc1-8ea4-28dfcf55346b", "data": { "type": "MarkDeleted" } }}ReadReceipts
Section titled “ReadReceipts”{ "message_id": "019a821b-d8d4-7dc1-8ea4-28e09b9f1af1", "sender": "recipient-device-id", "thread_id": null, "sender_persona_id": null, "inner": { "type": "ReadReceipts", "data": [ "msg-1", "msg-2", "msg-3" ] }}TypingIndicator
Section titled “TypingIndicator”{ "message_id": "019a821b-d8d4-7dc1-8ea4-28e09b9f1af1", "sender": "sender-device-id", "thread_id": "random-thread-id", "sender_persona_id": null, "inner": { "type": "TypingIndicator", "timeout_secs": 10 }}File with Caption (two messages)
Section titled “File with Caption (two messages)”Step 1: Send Caption Message
{ "message_id": "caption-msg-id", "sender": "sender-device-id", "thread_id": null, "sender_persona_id": null, "inner": { "type": "Message", "data": "Here's the latest contract" }}Step 2: Send file attachment
{ "message_id": "attachment-msg-id", "sender": "sender-device-id", "thread_id": null, "sender_persona_id": null, "inner": { "type": "MessageAction", "message_id": "caption-msg-id", "data": { "type": "AttachFile", "filename": "contract.pdf", "mime_type": "application/pdf", "file_ref": { "size": 2500000, "plaintext_hash": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2", "file_id": { "uploader": "device-xyz", "id": 12345 } }, "alt_text": "Latest contract version" } }}File without caption (empty message)
Section titled “File without caption (empty message)”Step 1: Send empty message
{ "message_id": "empty-msg-id", "sender": "sender-device-id", "thread_id": null, "sender_persona_id": null, "inner": { "type": "Message", "data": "" }}Step 2: Send file attachment
{ "message_id": "attachment-msg-id", "sender": "sender-device-id", "thread_id": null, "sender_persona_id": null, "inner": { "type": "MessageAction", "message_id": "empty-msg-id", "data": { "type": "AttachFile", "filename": "document.pdf", "mime_type": "application/pdf", "file_ref": { "size": 2500000, "plaintext_hash": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2", "file_id": { "uploader": "device-xyz", "id": 12345 } }, "alt_text": null } }}File request (full download)
Section titled “File request (full download)”{ "message_id": "019a821b-d8d4-7dc1-8ea4-28e09b9f1af1", "sender": "recipient-device-id", "thread_id": null, "sender_persona_id": null, "inner": { "type": "FileAction", "file_id": { "uploader": "device-xyz", "id": 12345 }, "data": { "type": "Request", "range": null } }}File request (resumable/range)
Section titled “File request (resumable/range)”{ "message_id": "019a821b-d8d4-7dc1-8ea4-28e09b9f1af1", "sender": "recipient-device-id", "thread_id": null, "sender_persona_id": null, "inner": { "type": "FileAction", "file_id": { "uploader": "device-xyz", "id": 12345 }, "data": { "type": "Request", "range": { "start": 1048576, "end": 2097152 } } }}File data response (chunk)
Section titled “File data response (chunk)”{ "message_id": "019a821b-d8d4-7dc1-8ea4-28e09b9f1af1", "sender": "sender-device-id", "thread_id": null, "sender_persona_id": null, "inner": { "type": "FileAction", "file_id": { "uploader": "device-xyz", "id": 12345 }, "data": { "type": "Data", "start": 1048576, "data": "cGxhaW50ZXh0X2NodW5rX2Jhc2U2NF9oZXJlLi4u" } }}File deletion notice
Section titled “File deletion notice”{ "message_id": "019a821b-d8d4-7dc1-8ea4-28e09b9f1af1", "sender": "sender-device-id", "thread_id": null, "sender_persona_id": null, "inner": { "type": "FileAction", "file_id": { "uploader": "device-xyz", "id": 12345 }, "data": { "type": "MarkDeleted" } }}File Transfer Protocol
Section titled “File Transfer Protocol”File attachments use a request/response protocol via FileAction variants.
Sequence
Section titled “Sequence”- Sender sends MessageInner::Message (caption or empty string)
- Sender receives message ID and sends MessageAction(MessageId, AttachFile) with file metadata
- Recipient receives attachment announcement
- Recipient sends FileAction(Request(None)) to request full file
- Sender responds with one or more FileAction(Data) messages containing plaintext chunks
- MLS encrypts entire CryptidMessage containing chunks
- Recipient assembles chunks and verifies received data matches
plaintext_hash
Resumable Downloads
Section titled “Resumable Downloads”- If transfer interrupted, recipient sends FileAction(Request(Some([start, end])))
- Specifies byte range to resume from (e.g.,
[1048576, 2097152]) - Sender responds with FileAction(Data) containing data from that offset
- Enables recovery without full re-transmission
Chunking
Section titled “Chunking”- Recommended chunk size: 512 KB (524,288 bytes)
- Max chunk recommended: 2 MB to prevent memory issues
- No protocol-level limit, clients enforce reasonable bounds
- Each FileAction(Data) chunk specifies its byte offset via
startfield
Verification
Section titled “Verification”- After receiving all chunks, verify Blake3 hash matches
plaintext_hash - Reject file if hash mismatch detected
File Deletion
Section titled “File Deletion”- Sender can send FileAction(MarkDeleted) to indicate file is no longer available
- Subsequent FileAction(Request) for that file SHOULD be rejected by sender
- Clients may cache files locally for offline use
Availability
Section titled “Availability”- Files available only while sender device is online
- Offline recipients queue FileAction(Request) and fetch when sender comes online
- Clients should cache all files locally for future use
Operations on Files
Section titled “Operations on Files”Because files are always associated with a Message, reactions and edits can be applied to the associated message just like any other message:
- React to file: Send MessageAction(message_id, Reaction) where message_id is the file’s associated message
- Edit file caption: Send MessageAction(message_id, Edit) to modify the associated message text
- Delete file: Send MessageAction(message_id, MarkDeleted) to delete the associated message (and conceptually the file announcement)
This unified model eliminates special cases and provides consistent UX across all message types.
Message Ordering
Section titled “Message Ordering”Server Ordering vs Sender Timestamps
Section titled “Server Ordering vs Sender Timestamps”Messages are delivered in the order received by the server (the received_at timestamp), but should be displayed using sender timestamps while validating for clock skew.
Example scenario: Alice’s clock is 5 min fast, Bob’s clock is correct.
| Sender | Sender Timestamp | Wall Time @ Send | Server Receive Time | Server Relay Order | Display Order |
|---|---|---|---|---|---|
| Alice | 10:00:05 | 09:55:05 | 09:55:09 | 1st | 2nd |
| Bob | 09:56:01 | 09:56:01 | 09:56:05 | 2nd | 1st |
What happens:
- Alice sends first (wall time 09:55:05) but her timestamp says 10:00:05
- Bob sends second (wall time 09:56:01) with correct timestamp 09:56:01
- Server delivers Alice’s message first (received at 09:55:09), then Bob’s (received at 09:56:05)
- Clients reorder for display: Bob’s message (09:56:01) before Alice’s (10:00:05) using sender timestamps
Why this matters:
- Server delivery order follows network arrival (prevents manipulation)
- Display order follows sender intent (respects conversation flow)
- Clients must validate timestamps against clock skew to prevent abuse
MLS Message Ordering
Section titled “MLS Message Ordering”MLS provides causal ordering within a group through epoch counters:
- Messages in epoch N are guaranteed to be processed before epoch N+1
- Messages within the same epoch have no guaranteed order
- Clients use
message_id(UUIDv7 with embedded timestamp) for tie-breaking
Recommended client behavior:
- Order by MLS epoch (ascending)
- Within same epoch, order by sender
timestampfield - If timestamps are equal, order by
message_id(UUIDv7 is time-ordered)
Clock Skew Handling
Section titled “Clock Skew Handling”Clients MUST validate sender timestamps to prevent abuse:
- Reject messages with
timestamp> 5 minutes in the future - Reject messages with
timestamp> 5 minutes in the past (relative to local time) - Display warning for messages with suspicious timestamps
- Fall back to
received_atfor display if sender timestamp is invalid
Edge case: If Alice’s clock is >5 minutes off, her messages are rejected by recipients as invalid timestamps.