Skip to main content

offline_protocol/
events.rs

1//! Event types and callbacks.
2
3use offline_protocol_core::{ContentType, MediaMetadata, Message, MessageId};
4use offline_protocol_services::ServiceEvent;
5use offline_protocol_transport::TransportType;
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::fmt;
9use std::sync::Arc;
10
11/// Presence status for a peer.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum PresenceStatus {
15    /// Peer is online and available.
16    Online,
17    /// Peer is away / idle.
18    Away,
19    /// Peer is explicitly offline.
20    Offline,
21}
22
23/// Where a `PresenceUpdated` event came from.
24///
25/// Apps that render relay-style presence UI (e.g. a direct-chat header's
26/// "Online" / "Last seen …") should filter on `Internet` so a nearby peer's
27/// self-report can't flip a header that is defined as relay-observed.
28#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum PresenceSource {
31    /// Relay-observed presence: the relay's authoritative answer to
32    /// `CheckPresence` (`PresenceStatus` / `PresenceStatusWithLastSeen`),
33    /// or relay-derived reachability — the platform bridges also report
34    /// `offline` here when a relay `DeliveryError` names the recipient
35    /// unreachable, so a failed send can produce an `Internet`-sourced
36    /// offline event without any explicit presence query.
37    Internet,
38    /// A peer-sent `__PRESENCE__` self-report. Transport-agnostic: it may
39    /// arrive over BLE, WiFi Direct, or even relay-forwarded frames — hence
40    /// "peer", not "mesh". Default for deserializing legacy events that
41    /// predate the field.
42    #[default]
43    Peer,
44}
45
46/// Event callback type for handling protocol events.
47pub type EventCallback = Arc<dyn Fn(Event) + Send + Sync>;
48
49/// Machine-readable reason taxonomy for welcome delivery failures.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
52pub enum WelcomeReasonCode {
53    /// No transport was available or all transport sends failed.
54    TransportUnavailable,
55    /// The transport is up but this specific peer is unreachable on it
56    /// (e.g. the internet relay reported the recipient offline). Treated as
57    /// per-peer no-carrier: the welcome parks instead of aging.
58    PeerUnreachable,
59    /// Peer was disconnected or became unavailable during send.
60    PeerDisconnected,
61    /// Send operation timed out.
62    Timeout,
63    /// Local/internal error prevented send.
64    InternalError,
65    /// Retry budget or lifecycle TTL was exhausted.
66    RetryExhausted,
67}
68
69impl WelcomeReasonCode {
70    /// Returns the stable machine-readable reason code.
71    pub fn as_str(&self) -> &'static str {
72        match self {
73            Self::TransportUnavailable => "TRANSPORT_UNAVAILABLE",
74            Self::PeerUnreachable => "PEER_UNREACHABLE",
75            Self::PeerDisconnected => "PEER_DISCONNECTED",
76            Self::Timeout => "TIMEOUT",
77            Self::InternalError => "INTERNAL_ERROR",
78            Self::RetryExhausted => "RETRY_EXHAUSTED",
79        }
80    }
81}
82
83/// Machine-readable taxonomy for [`Event::SecurityWarning`], so consumers can
84/// branch on a stable code instead of matching the human-readable `reason`
85/// string (which is for logs/UI and may change between versions).
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
87#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
88pub enum SecurityWarningCode {
89    /// A control message's signature verified, but the key that signed it does
90    /// not derive to the address the frame claims to come from.
91    ///
92    /// There is no benign reading. An address *is* the hash of its identity
93    /// key, so a legitimate peer's frames always re-derive to their own
94    /// address: a rotated key is a *different address*, i.e. a different peer,
95    /// not the same peer with new bytes. This is either an impersonation
96    /// attempt or a peer running a build with a broken derivation.
97    ///
98    /// Replaces `TOFU_KEY_MISMATCH`, whose remedy (`resetTofuForPeer`) no longer
99    /// exists because there is no pin to reset.
100    SenderAddressMismatch,
101    /// A control message's signed sender did not match its transport peer
102    /// identity.
103    TransportIdentityMismatch,
104    /// A control message's signature failed verification (invalid signature or
105    /// malformed metadata).
106    ControlSignatureInvalid,
107    /// An unsigned control message was rejected.
108    ///
109    /// All security-gated control traffic must be signed by the key its sender
110    /// address derives from. This is unconditional: it does not depend on
111    /// `require_transport_identity`, nor on whether the sender was seen before.
112    /// A peer that will not sign is making a claim that cannot be checked.
113    UnsignedControlRejected,
114    /// An encrypted media chunk's MLS group did not match the 1:1 session
115    /// group of the claimed wire sender — a valid ciphertext (from some
116    /// session) delivered under a different sender's name is a media
117    /// forgery/misattribution attempt.
118    MediaSenderGroupMismatch,
119    /// An outbound message left this node as plaintext because encryption is
120    /// disabled or MLS is uninitialized while `require_encryption` is `false`
121    /// (an explicit opt-out — the default fails closed). Emitted at most once
122    /// per peer per protocol instance.
123    PlaintextSend,
124    /// Inbound plaintext content (text or legacy media) was rejected by the
125    /// encryption policy: either `require_encryption` is enabled, or a
126    /// confirmed MLS session exists with the claimed sender and plaintext
127    /// from that peer is a downgrade/forgery attempt (plaintext carries no
128    /// sender authentication). Emitted at most once per peer; the per-peer
129    /// tracking set is bounded, so after a flood of forged sender ids a
130    /// peer may warn again.
131    PlaintextReceiveRejected,
132    /// An encrypted 1:1 envelope named an MLS group that is not the session
133    /// slot shared with the claimed wire sender. The text-path counterpart to
134    /// [`Self::MediaSenderGroupMismatch`]: it catches the same misattribution
135    /// on paths where MLS cannot, because the envelope failed before it
136    /// authenticated anything.
137    SessionSenderGroupMismatch,
138    /// A 1:1 MLS session was torn down and re-advertised after an epoch
139    /// desync. Expected occasionally (a genuine fork heals this way), but the
140    /// triggering frame is not authenticated — see `schedule_session_rekey` —
141    /// so a sustained rate of these for one peer indicates injected frames
142    /// rather than a real fork.
143    SessionRekeyTriggered,
144    /// A Nostr key-package publication slot could not be refilled, so cold
145    /// first contact over Nostr is degraded or unavailable until it succeeds.
146    ///
147    /// Slots are consumed locally — an init key leaves provider storage only
148    /// when *this* node processes a Welcome built against it — so consumption
149    /// itself is normal traffic, and a stranger can drive it only by actually
150    /// establishing sessions with us. What this warns about is the refill
151    /// failing (MLS or storage error), which is the failure the publication
152    /// design must never absorb silently: a stale published record makes every
153    /// stranger who fetches it build a Welcome that can never be processed.
154    NostrKeyPackageSlotExhausted,
155    /// The per-peer key-package pool hit its ceiling, so a peer was advertised
156    /// an init key another peer already holds.
157    ///
158    /// The push path normally gives each peer its own single-use init key. Past
159    /// the ceiling it reuses one instead of growing the pool without bound —
160    /// weakening forward secrecy at session establishment (one compromised init
161    /// key opens every Welcome built against it) and re-introducing the race
162    /// where the second peer to use a package finds it already consumed. Both
163    /// clear as packages are consumed or expire.
164    ///
165    /// Reaching it means a device has accumulated unconsumed advertisements:
166    /// many peers met that never established a session. Sustained emission is
167    /// the signal that the ceiling, not peer churn, is now the binding
168    /// constraint.
169    PushKeyPackagePoolExhausted,
170    /// The relay acknowledged an address declaration naming an address that is
171    /// not this node's.
172    ///
173    /// There is no benign reading. The bridge declares `local_address()` and
174    /// signs a proof over a per-connection challenge; the relay verifies that
175    /// the declared address derives from the key that signed it, and echoes
176    /// back what it bound. So the echo can only differ if the relay bound
177    /// something other than what it verified — a broken or hostile relay, or a
178    /// frame injected into the socket.
179    ///
180    /// Nothing is torn down: a relay that controls the socket already controls
181    /// everything a local refusal could protect, so the mitigation is that the
182    /// signal is loud rather than that the connection dies. What it means in
183    /// practice is that this connection's frames are attributed to an identity
184    /// this node cannot prove, so receivers strict-matching the sender against
185    /// the transport identity will reject its security-gated control traffic.
186    RelayAddressBindingMismatch,
187    /// The relay refused this connection's address declaration, so it stays
188    /// attributed by account name.
189    ///
190    /// Operational degradation, not an attack signal: the refusal path is
191    /// deliberately non-fatal on both sides, and the connection keeps working
192    /// exactly as it did before addresses existed. What is lost is *new* MLS
193    /// session establishment over the relay — `__MLS_KEY_PKG__` and
194    /// `__MLS_WELCOME__` are security-gated, so a receiver that strict-matches
195    /// the account name against the `off1…` sender rejects them, while already
196    /// established sessions keep working because `__MLS_ENC__` is data-plane.
197    ///
198    /// The relay's own text is **not** carried on the event. It is
199    /// remote-chosen, and this event's `reason` is shipped verbatim by the
200    /// telemetry scrubber, so the wording stays bounded in the device log
201    /// while the code above is the classification. The causes it distinguishes
202    /// range from unusable key material to this socket having been displaced
203    /// by a newer login; none of them changes what an app should do. No retry
204    /// is attempted — the next reconnect declares from scratch.
205    RelayAddressDeclarationRefused,
206    /// A group leaf carries a credential its own signature key does not derive
207    /// to — an identity claimed inside a group without the key to prove it.
208    ///
209    /// There is no benign reading, and unlike most codes here it accuses
210    /// someone the user is already in a room with. An address *is* the hash of
211    /// its identity key, so every honest leaf reproduces its own credential;
212    /// producing one that does not means deliberately building a leaf around
213    /// someone else's name.
214    ///
215    /// `peer_id` is **who the finding concerns, not always who to blame**. On
216    /// the three refusal sites it is the peer that delivered the forgery — the
217    /// Welcome's inviter, or the sender of the frame carrying the commit — and
218    /// never the address the forged leaf claimed. That attribution is worth
219    /// something because it is proved independently: `__GRP_MLS_WELCOME__`,
220    /// `__GRP_MLS_COMMIT__` and `__MLS_WELCOME__` are all security-gated, so
221    /// the sender signed with the key its own address derives from. On the
222    /// fourth site there is no delivering peer at all and `peer_id` is this
223    /// device's own id.
224    ///
225    /// `reason` is diagnostic text, must not be parsed, and deliberately
226    /// carries **no identifier** — the impersonated address appears only in
227    /// this device's logs. Telemetry scrubbing hashes `peer_id` and passes
228    /// `reason` through verbatim, and the identity at stake belongs to a third
229    /// party who is not even part of the exchange.
230    ///
231    /// Emitted from four sites. The first three refuse a claim *arriving*, so
232    /// nothing is installed and the frame is dropped:
233    ///
234    /// 1. joining a group Welcome whose ratchet tree contains such a leaf (the
235    ///    invite is declined outright);
236    /// 2. processing a membership commit that would install one (the commit is
237    ///    not merged, and is never buffered for retry);
238    /// 3. joining a 1:1 session Welcome whose ratchet tree contains one —
239    ///    including when we already hold a session with that peer, where the
240    ///    refusal is non-destructive and the existing session stays live (so a
241    ///    `secure_session_failed` alongside this does *not* mean the working
242    ///    session ended).
243    ///
244    /// The fourth is different in kind and needs a different response:
245    ///
246    /// 4. a roster read skipping a leaf **already seated in local group
247    ///    state**. No wire gate can have admitted it, so it arrived by a direct
248    ///    write to this device's secure store or via a group joined by a build
249    ///    predating those gates. Such a leaf is kept out of every roster and
250    ///    cannot speak — but it holds live group secrets and reads everything
251    ///    sent to the group, which no later refusal undoes. The remedy is to
252    ///    abandon the group, not to evict a member of it.
253    ///
254    /// Apps should treat a group that produces this as untrusted for
255    /// attribution — a message shown as coming from a member is exactly what
256    /// the forged leaf was for.
257    GroupLeafIdentityUnproven,
258    /// A control frame was refused for what its signature says about *when*:
259    /// stamped further in the past than this node accepts, further ahead of
260    /// this node's clock than it accepts, or carrying the older signing
261    /// payload from a peer that has proved it can produce the newer one.
262    ///
263    /// The three share a code because they share a remedy, and the remedy is
264    /// the first thing to check: **this device's own clock**. The signed
265    /// timestamp is compared against it, so a device whose clock is wrong
266    /// refuses honest peers in bulk, and the event stream then looks like a
267    /// coordinated attack rather than a local fault. A run of these naming
268    /// *many different* peers is a clock; a run naming *one* peer while others
269    /// are fine is that peer, either replaying captures or running a broken
270    /// build.
271    ///
272    /// It is the fresh-frame counterpart to
273    /// [`Self::UnsignedControlRejected`]: both refuse a frame whose signature
274    /// proves less than this node requires, one for what it omits and one for
275    /// what it fails to state. Recovery of last resort is
276    /// `security.control_freshness_enforced = false`, which returns this node
277    /// to accepting frames of any age (see issue 403 for what that gives back
278    /// to an attacker).
279    StaleControlFrame,
280}
281
282impl SecurityWarningCode {
283    /// Returns the stable machine-readable reason code.
284    pub fn as_str(&self) -> &'static str {
285        match self {
286            Self::SenderAddressMismatch => "SENDER_ADDRESS_MISMATCH",
287            Self::TransportIdentityMismatch => "TRANSPORT_IDENTITY_MISMATCH",
288            Self::ControlSignatureInvalid => "CONTROL_SIGNATURE_INVALID",
289            Self::UnsignedControlRejected => "UNSIGNED_CONTROL_REJECTED",
290            Self::MediaSenderGroupMismatch => "MEDIA_SENDER_GROUP_MISMATCH",
291            Self::PlaintextSend => "PLAINTEXT_SEND",
292            Self::PlaintextReceiveRejected => "PLAINTEXT_RECEIVE_REJECTED",
293            Self::SessionSenderGroupMismatch => "SESSION_SENDER_GROUP_MISMATCH",
294            Self::SessionRekeyTriggered => "SESSION_REKEY_TRIGGERED",
295            Self::NostrKeyPackageSlotExhausted => "NOSTR_KEY_PACKAGE_SLOT_EXHAUSTED",
296            Self::PushKeyPackagePoolExhausted => "PUSH_KEY_PACKAGE_POOL_EXHAUSTED",
297            Self::RelayAddressBindingMismatch => "RELAY_ADDRESS_BINDING_MISMATCH",
298            Self::RelayAddressDeclarationRefused => "RELAY_ADDRESS_DECLARATION_REFUSED",
299            Self::GroupLeafIdentityUnproven => "GROUP_LEAF_IDENTITY_UNPROVEN",
300            Self::StaleControlFrame => "STALE_CONTROL_FRAME",
301        }
302    }
303}
304
305/// Machine-readable reason for DORS selection or switch (observability).
306#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
307#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
308pub enum DorsReasonCode {
309    /// First selection; no previous transport.
310    InitialSelection,
311    /// DORS selected primary (before send attempt).
312    PrimarySelected,
313    /// Primary send succeeded; active transport is now primary.
314    PrimarySuccess,
315    /// Primary failed; fallback send succeeded.
316    FallbackSuccess,
317    /// BLE → WiFi fallback succeeded (escalation applied).
318    EscalationApplied,
319    /// Previous transport unavailable; switched to best available.
320    CurrentUnavailable,
321}
322
323impl DorsReasonCode {
324    /// Returns the stable machine-readable reason code.
325    pub fn as_str(&self) -> &'static str {
326        match self {
327            Self::InitialSelection => "INITIAL_SELECTION",
328            Self::PrimarySelected => "PRIMARY_SELECTED",
329            Self::PrimarySuccess => "PRIMARY_SUCCESS",
330            Self::FallbackSuccess => "FALLBACK_SUCCESS",
331            Self::EscalationApplied => "ESCALATION_APPLIED",
332            Self::CurrentUnavailable => "CURRENT_UNAVAILABLE",
333        }
334    }
335}
336
337/// Phase of DORS escalation: recommendation (trigger boundary) vs actual transition (applied).
338#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
339#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
340pub enum DorsEscalationPhase {
341    /// DORS decided to escalate (typed trigger reason); fallback may not succeed.
342    Triggered,
343    /// BLE→WiFi fallback send succeeded; escalation was applied.
344    Applied,
345}
346
347impl DorsEscalationPhase {
348    /// Returns the stable string representation (TRIGGERED / APPLIED).
349    pub fn as_str(&self) -> &'static str {
350        match self {
351            Self::Triggered => "TRIGGERED",
352            Self::Applied => "APPLIED",
353        }
354    }
355}
356
357/// Machine-readable reason for DORS escalation (BLE→Wi‑Fi) observability.
358#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
359#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
360pub enum DorsEscalationReasonCode {
361    /// Fallback to WiFi succeeded after primary (BLE) send failed.
362    FallbackSuccess,
363    /// Escalation suggested due to retry threshold (e.g. ble_to_wifi_retry_threshold).
364    RetryThreshold,
365    /// Escalation suggested due to sustained poor BLE signal.
366    PoorSignal,
367    /// Escalation suggested due to congestion.
368    Congestion,
369    /// Escalation suggested due to low TTL on messages.
370    LowTtl,
371    /// Escalation suggested due to BLE success rate below configured minimum (quality degradation).
372    LowSuccessRate,
373}
374
375impl DorsEscalationReasonCode {
376    /// Returns the stable machine-readable reason code.
377    pub fn as_str(&self) -> &'static str {
378        match self {
379            Self::FallbackSuccess => "FALLBACK_SUCCESS",
380            Self::RetryThreshold => "RETRY_THRESHOLD",
381            Self::PoorSignal => "POOR_SIGNAL",
382            Self::Congestion => "CONGESTION",
383            Self::LowTtl => "LOW_TTL",
384            Self::LowSuccessRate => "LOW_SUCCESS_RATE",
385        }
386    }
387}
388
389/// Machine-readable reason taxonomy for inbound decryption failures.
390#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
391#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
392pub enum DecryptionFailureCode {
393    /// Incoming encrypted payload is malformed and cannot be parsed.
394    InvalidPayload,
395    /// MLS subsystem is not initialized locally.
396    NotInitialized,
397    /// Ciphertext is invalid or cannot be decrypted.
398    InvalidCiphertext,
399    /// Sender identity/signature verification failed.
400    IdentityMismatch,
401    /// Cryptographic operation failed.
402    CryptoFailure,
403    /// A media chunk was evicted from the pending-decryption queue (overflow or
404    /// TTL expiry) before the sender's session became ready, so the file
405    /// transfer it belongs to is currently stalled.
406    ///
407    /// Under the deferred-ACK model this is **advisory, not terminal**: the
408    /// evicted chunk was never ACKed, so the sender keeps retransmitting and a
409    /// later resend re-enters the queue and can still complete the transfer once
410    /// the session confirms. Treat this as "the transfer is stalled and may need
411    /// a resend", not "the transfer has permanently failed" — the terminal
412    /// failure signal for media is `FileReceiveFailed`. The same is true of a
413    /// hard decrypt failure while `crypto_recovery_enabled`, which surfaces
414    /// under its own code but is equally un-ACKed and equally recoverable by a
415    /// resend; see the `MessageDecryptionFailed` docs.
416    PendingQueueDropped,
417    /// Failure class is unknown.
418    Unknown,
419}
420
421/// Forwarding attribution in events (mirrors `ForwardInfo` from core).
422#[derive(Debug, Clone, Serialize, Deserialize)]
423pub struct ForwardInfoEvent {
424    /// The original sender of the message.
425    pub original_sender: String,
426    /// The original message ID.
427    pub original_message_id: String,
428    /// The original timestamp (wall-clock ms).
429    pub original_timestamp: i64,
430    /// Number of times this message has been forwarded.
431    pub forward_count: u32,
432}
433
434impl From<&offline_protocol_core::ForwardInfo> for ForwardInfoEvent {
435    fn from(fwd: &offline_protocol_core::ForwardInfo) -> Self {
436        Self {
437            original_sender: fwd.original_sender.as_str().to_string(),
438            original_message_id: fwd.original_message_id.as_str(),
439            original_timestamp: fwd.original_timestamp.as_millis(),
440            forward_count: fwd.forward_count,
441        }
442    }
443}
444
445/// Quoted-reply context in events (mirrors `ReplyContext` from core).
446#[derive(Debug, Clone, Serialize, Deserialize)]
447pub struct ReplyContextEvent {
448    /// Sender of the message being replied to.
449    pub sender: String,
450    /// Text (or excerpt) of the message being replied to.
451    pub text: String,
452    /// Timestamp of the quoted message (wall-clock ms).
453    #[serde(default, skip_serializing_if = "Option::is_none")]
454    pub timestamp: Option<i64>,
455    /// Short human-readable label for quoted media (e.g. a file name).
456    #[serde(default, skip_serializing_if = "Option::is_none")]
457    pub reply_media_label: Option<String>,
458    /// Content type of the quoted message, as a display string.
459    #[serde(default, skip_serializing_if = "Option::is_none")]
460    pub reply_content_type: Option<String>,
461}
462
463impl From<&offline_protocol_core::ReplyContext> for ReplyContextEvent {
464    fn from(rc: &offline_protocol_core::ReplyContext) -> Self {
465        Self {
466            sender: rc.sender.as_str().to_string(),
467            text: rc.text.clone(),
468            timestamp: rc.timestamp.map(|t| t.as_millis()),
469            reply_media_label: rc.reply_media_label.clone(),
470            reply_content_type: rc.reply_content_type.clone(),
471        }
472    }
473}
474
475/// One device's verified claim to a username.
476///
477/// Every field here has already passed verification: the address derives from
478/// the public key, the record's signature verifies under that key, and the
479/// record was published under the Nostr key it names. What that proves is
480/// narrow and worth stating exactly — **this key asserts this name**. It does
481/// not prove the name belongs to the claimant, because nothing can: the
482/// directory is non-authoritative by design.
483#[derive(Clone, Debug, Serialize, Deserialize)]
484pub struct UsernameClaim {
485    /// The claimed address, in canonical `off1…` form.
486    ///
487    /// This is the value to keep. An app that stores the *name* has stored
488    /// something anyone can re-claim tomorrow; the address is self-certifying.
489    pub address: String,
490    /// The Ed25519 identity key the address derives from, base64.
491    pub public_key: String,
492    /// When the claimant signed the record, in milliseconds since the epoch.
493    ///
494    /// **Advisory.** A record is not a liveness signal — the key-package fetch
495    /// that follows it is. An old claim from a peer who has been offline for a
496    /// month is still perfectly valid, and rejecting it here would make them
497    /// unreachable by name while their key packages sit live on a relay. Sort
498    /// by it if that helps a user choose; do not filter on it.
499    pub issued_at_ms: i64,
500}
501
502/// Events that can occur in the protocol.
503///
504/// Note: This type implements a custom Debug that redacts sensitive fields
505/// (message content) to prevent accidental logging of sensitive data.
506#[derive(Clone, Serialize, Deserialize)]
507#[serde(tag = "type", rename_all = "snake_case")]
508pub enum Event {
509    /// A message was sent (queued for delivery).
510    MessageSent {
511        /// ID of the sent message.
512        message_id: String,
513        /// Sender's user ID.
514        sender: String,
515        /// Recipient's user ID.
516        recipient: String,
517        /// Content of the sent message.
518        content: String,
519        /// Priority of the message when sent.
520        priority: String,
521        /// Whether the message requires an acknowledgement.
522        requires_ack: bool,
523        /// When the message was queued for delivery (wall-clock, for display).
524        timestamp: i64,
525        /// Lamport logical clock value for causal ordering.
526        #[serde(default)]
527        lamport_clock: u64,
528        /// Forwarding attribution (present when this is a forwarded message).
529        #[serde(default, skip_serializing_if = "Option::is_none")]
530        forward_info: Option<ForwardInfoEvent>,
531    },
532
533    /// A message was received.
534    MessageReceived {
535        /// ID of the received message.
536        message_id: String,
537        /// Sender's user ID.
538        sender: String,
539        /// Recipient's user ID.
540        recipient: String,
541        /// Message content.
542        content: String,
543        /// Number of hops the message traversed.
544        hop_count: u8,
545        /// Transport used for final delivery.
546        transport: String,
547        /// When the message was received (wall-clock, for display).
548        timestamp: i64,
549        /// Lamport logical clock value for causal ordering.
550        #[serde(default)]
551        lamport_clock: u64,
552        /// ID of the message this is replying to (optional).
553        #[serde(skip_serializing_if = "Option::is_none")]
554        reply_to_msg: Option<String>,
555        /// Quoted-reply context (present when this message quotes another).
556        /// Boxed to keep the `Event` enum's by-value size in check.
557        #[serde(default, skip_serializing_if = "Option::is_none")]
558        reply_context: Option<Box<ReplyContextEvent>>,
559        /// The type of content (text, image, video, etc.).
560        #[serde(default)]
561        content_type: String,
562        /// Media metadata (present for non-text content).
563        #[serde(default, skip_serializing_if = "Option::is_none")]
564        media_metadata: Option<MediaMetadata>,
565        /// Forwarding attribution (present when this is a forwarded message).
566        #[serde(default, skip_serializing_if = "Option::is_none")]
567        forward_info: Option<ForwardInfoEvent>,
568        /// `true` when the content arrived MLS-encrypted and was decrypted by
569        /// this node; `false` for plaintext accepted under the
570        /// `require_encryption = false` opt-out.
571        #[serde(default)]
572        encrypted: bool,
573    },
574
575    /// A message was successfully delivered (ACK received).
576    MessageDelivered {
577        /// ID of the delivered message.
578        message_id: String,
579        /// Latency in milliseconds.
580        latency_ms: u64,
581        /// Number of hops traversed.
582        hop_count: u8,
583        /// Transport used.
584        transport: String,
585    },
586
587    /// A message failed to deliver.
588    MessageFailed {
589        /// ID of the failed message.
590        message_id: String,
591        /// Reason for failure.
592        reason: String,
593        /// Number of retries attempted.
594        retry_count: u32,
595    },
596
597    /// An inbound encrypted message failed to decrypt on this attempt.
598    ///
599    /// **Advisory, not terminal, and emitted once per failed *attempt*.** While
600    /// `EncryptionConfig::crypto_recovery_enabled` is on (the default) a
601    /// message that fails to decrypt is not delivery-ACKed, so the sender keeps
602    /// retrying — and each resend of a DM is re-sealed against the peer's
603    /// current session, so a resend can actually land. Every failing attempt
604    /// reports again, bounded by the sender's ACK retry budget. Read this as
605    /// "this attempt did not decrypt"; the terminal signals are
606    /// [`Event::MessageFailed`] on the sender and `FileReceiveFailed` for
607    /// media.
608    MessageDecryptionFailed {
609        /// ID of the message that could not be decrypted.
610        message_id: String,
611        /// Sender's user ID.
612        sender: String,
613        /// Machine-readable decryption failure code.
614        code: DecryptionFailureCode,
615        /// Clear failure reason for application handling/logging.
616        reason: String,
617    },
618
619    /// Transport was switched by DORS.
620    TransportSwitched {
621        /// Previous transport (if any).
622        from: Option<String>,
623        /// New transport.
624        to: String,
625        /// Reason for switch.
626        reason: String,
627    },
628
629    /// This device started acting as a relay — carrying traffic for other
630    /// devices that cannot reach each other directly.
631    ///
632    /// Reports observed behaviour, not capability: it fires because this
633    /// device has been forwarding, so it needs no battery feed and can fire on
634    /// a device that has never reported a level.
635    RelayPromoted {
636        /// Neighbors held when this device started relaying.
637        connection_count: usize,
638        /// Battery level at the time, or `None` when the host has reported
639        /// none — the standing does not depend on it.
640        battery_level: Option<u8>,
641    },
642
643    /// This device was demoted from relay role.
644    RelayDemoted {
645        /// Reason for demotion.
646        reason: String,
647    },
648
649    /// A new neighbor was discovered.
650    NeighborDiscovered {
651        /// The peer's canonical user id — the same value the peer supplied
652        /// as `ProtocolConfig.user_id`, on every transport. Valid directly
653        /// as the `recipient` of `send_message` / `send_connection_request`.
654        peer_id: String,
655        /// Transport used to discover.
656        transport: String,
657        /// RSSI signal strength (if available).
658        rssi: Option<i16>,
659    },
660
661    /// A neighbor was lost (disconnected).
662    NeighborLost {
663        /// Peer ID of the lost neighbor.
664        peer_id: String,
665    },
666
667    /// This device's own address is known: MLS storage was opened and the
668    /// identity key in it derived to `address`.
669    ///
670    /// Fires once per successful `initialize_mls`, before any frame can be
671    /// sent. `address` is this device's `Message.sender` from here on, and what
672    /// peers must be given to reach it. It is stable across restarts of the
673    /// same profile, so an app that already stored it will be handed the same
674    /// value rather than a new one.
675    IdentityReady {
676        /// This device's self-certifying address (`off1…`).
677        address: String,
678    },
679
680    /// Network metrics update.
681    NetworkMetrics {
682        /// Number of active neighbors.
683        neighbor_count: usize,
684        /// Number of active relays.
685        relay_count: usize,
686        /// Message delivery ratio (0.0-1.0).
687        delivery_ratio: f32,
688        /// Average message latency in milliseconds.
689        avg_latency_ms: u64,
690    },
691
692    /// File transfer progress update.
693    FileProgress {
694        /// File identifier.
695        file_id: String,
696        /// Number of chunks sent so far.
697        chunks_sent: u32,
698        /// Total number of chunks.
699        total_chunks: u32,
700        /// Progress percentage (0-100).
701        percentage: u8,
702    },
703
704    /// File was completely received.
705    FileReceived {
706        /// File identifier.
707        file_id: String,
708        /// File name.
709        file_name: String,
710        /// File size in bytes.
711        file_size: u64,
712        /// Sender's user ID.
713        sender: String,
714        /// The content type of the media (image, video, file, etc.).
715        content_type: String,
716        /// Media metadata from the first chunk (present for typed media).
717        #[serde(default, skip_serializing_if = "Option::is_none")]
718        media_metadata: Option<MediaMetadata>,
719        /// Base64-encoded reassembled file data.
720        file_data: String,
721        /// When the sender queued the transfer (chunk-0 outer message
722        /// timestamp, wall-clock ms) — for display ordering alongside
723        /// `MessageReceived.timestamp`. Absent only if the chunk-0 record
724        /// was evicted before completion.
725        #[serde(default, skip_serializing_if = "Option::is_none")]
726        timestamp: Option<i64>,
727        /// Caption text from the sealed chunk-0 rich extras.
728        #[serde(default, skip_serializing_if = "Option::is_none")]
729        caption: Option<String>,
730        /// ID of the message this media replies to (sealed chunk-0 extras).
731        #[serde(default, skip_serializing_if = "Option::is_none")]
732        reply_to_msg: Option<String>,
733        /// Quoted-reply context (sealed chunk-0 extras). Boxed to keep the
734        /// `Event` enum's by-value size in check.
735        #[serde(default, skip_serializing_if = "Option::is_none")]
736        reply_context: Option<Box<ReplyContextEvent>>,
737        /// Forwarding attribution (sealed chunk-0 extras).
738        #[serde(default, skip_serializing_if = "Option::is_none")]
739        forward_info: Option<ForwardInfoEvent>,
740    },
741
742    /// An inbound file transfer was dropped before completion — the receiver
743    /// hit a resource limit (too many concurrent transfers, per-sender
744    /// quota, or the buffered-bytes budget), the reassembled file failed
745    /// its integrity checks, or the transfer went stale (no chunks within
746    /// the stale timeout). Terminal and fired at most once per transfer:
747    /// the failed transfer's remaining in-flight chunks are dropped
748    /// silently. No `FileReceived` will follow for this `file_id`; the
749    /// sender must re-send the file (under a fresh `file_id`) to retry.
750    FileReceiveFailed {
751        /// File identifier.
752        file_id: String,
753        /// File name from the chunk metadata.
754        file_name: String,
755        /// Sender's user ID.
756        sender: String,
757        /// Machine-readable reason the transfer was dropped.
758        reason: String,
759    },
760
761    /// All chunks of a media attachment were ACK-delivered.
762    MediaSent {
763        /// File identifier for tracking.
764        file_id: String,
765        /// The content type of the media.
766        content_type: String,
767        /// Recipient's user ID.
768        recipient: String,
769    },
770
771    /// An outbound media transfer was aborted before all chunks were
772    /// delivered — a chunk failed to encrypt, or a chunk failed terminally
773    /// in the outbox. No `MediaSent` will follow for this `file_id`; retry
774    /// with a new `send_media` call.
775    MediaSendFailed {
776        /// File identifier for tracking.
777        file_id: String,
778        /// Recipient's user ID.
779        recipient: String,
780        /// Reason the transfer was aborted.
781        reason: String,
782    },
783
784    /// A message was deferred due to network conditions.
785    /// The message will be retried automatically.
786    MessageDeferred {
787        /// ID of the deferred message.
788        message_id: String,
789        /// Recipient's user ID.
790        ///
791        /// Added when `reason` stopped rendering the transport error: the
792        /// counterparty used to reach apps only inside that prose, which put an
793        /// unhashed address on every telemetry record for a routine send
794        /// failure. Carried as a field so the scrubber hashes it like any
795        /// other actor id.
796        recipient: String,
797        /// Stable classification of the deferral, not a rendered error.
798        ///
799        /// One of a fixed local vocabulary (`peer_not_reachable`,
800        /// `transport_not_connected`, `transport_send_failed`, …). Branch on it
801        /// if you must, but do not parse it for detail: the underlying error —
802        /// which renders the peer — stays in the device log.
803        reason: String,
804        /// Current retry count.
805        retry_count: u32,
806        /// Next retry scheduled time (Unix timestamp ms).
807        next_retry_at: Option<i64>,
808    },
809
810    /// A retry for a previously deferred/unacked message was scheduled.
811    ///
812    /// Emitted each time the retry machinery re-queues a message after a
813    /// failed attempt (transport send error or ACK timeout). Non-terminal:
814    /// `MessageDelivered` or `MessageFailed` still settles the message.
815    MessageRetrying {
816        /// ID of the retrying message.
817        message_id: String,
818        /// Recipient's user ID.
819        recipient: String,
820        /// Retry count this schedule corresponds to.
821        retry_count: u32,
822        /// Absolute time the retry is scheduled for (Unix timestamp ms).
823        next_retry_at: i64,
824    },
825
826    /// The transport layer reported the recipient unreachable for an
827    /// in-flight message (e.g. the internet relay's DeliveryError).
828    ///
829    /// Non-terminal: the message stays in the outbox. A plain DM is
830    /// *parked* — its ACK retry budget stops burning while the peer is
831    /// provably offline — and is re-driven on reachability edges (transport
832    /// reconnect, peer discovery, presence-online; the SDK adds parked
833    /// recipients to the presence watchlist). It settles only via
834    /// `MessageDelivered` or, at outbox-lifetime expiry, `MessageFailed`.
835    /// Media chunks are not parked and keep the normal retry machinery.
836    /// May fire multiple times for the same `message_id` while the
837    /// recipient remains offline: a parked DM keeps an escalating
838    /// reachability probe on every carrier (15s → 600s cap), and each probe
839    /// that reaches the relay while the peer is still offline earns a fresh
840    /// verdict and re-emits this event. Apps must treat it as a repeatable
841    /// status signal, never as a terminal one. `file_id` is set when the
842    /// message is a chunk of an outbound media transfer.
843    MessageUndeliverable {
844        /// ID of the affected message.
845        message_id: String,
846        /// Recipient's user ID.
847        recipient: String,
848        /// Stable classification of the failure — `recipient_unreachable` on
849        /// the relay-verdict path this event is normally reached by.
850        ///
851        /// The transport's own wording used to be appended here verbatim, and
852        /// on the relay path that wording is written by the relay: unbounded
853        /// remote text, shipped beside a `recipient` the telemetry scrubber
854        /// hashes. It is now dropped at the boundary — kept in the device log,
855        /// and on the internet path still delivered raw to apps on the
856        /// bridges' `diagnostic` channel.
857        reason: String,
858        /// Owning media transfer when the message is a media chunk.
859        #[serde(default, skip_serializing_if = "Option::is_none")]
860        file_id: Option<String>,
861    },
862
863    /// An outbound media transfer was in flight when the previous process
864    /// died. The SDK persists only the transfer descriptor (never chunk
865    /// bytes), so it cannot resume the transfer itself: the app must
866    /// re-supply the file bytes via `send_media` with this `file_id` —
867    /// they are checksum-validated against the original transfer.
868    MediaResendRequired {
869        /// File identifier of the interrupted transfer.
870        file_id: String,
871        /// Recipient's user ID.
872        recipient: String,
873        /// Original file name.
874        file_name: String,
875        /// Total file size in bytes.
876        file_size: u64,
877    },
878
879    /// A pending ACK was evicted due to capacity constraints.
880    AckEvicted {
881        /// ID of the message whose ACK was evicted.
882        message_id: String,
883        /// Priority of the evicted message.
884        priority: String,
885        /// Reason for eviction.
886        reason: String,
887    },
888
889    /// A fragment assembly was evicted to make room for new fragments.
890    FragmentAssemblyEvicted {
891        /// ID of the message whose fragments were evicted.
892        message_id: String,
893        /// Completion percentage when evicted.
894        completion_percent: u8,
895        /// Reason for eviction.
896        reason: String,
897    },
898
899    /// Relay role was demoted due to battery constraints.
900    RelayDemotedBattery {
901        /// Battery level at time of demotion.
902        battery_level: u8,
903        /// Minimum required battery level.
904        min_required: u8,
905    },
906
907    /// A secure MLS session was successfully established.
908    SecureSessionEstablished {
909        /// Peer ID of the other party.
910        peer_id: String,
911        /// MLS group ID for the session.
912        group_id: String,
913        /// Whether this is a 1:1 session (true) or a multi-party group (false).
914        is_session: bool,
915        /// Whether the local device initiated the session (sent the Welcome).
916        initiated_by_local: bool,
917    },
918
919    /// Failed to establish a secure MLS session.
920    ///
921    /// Reports a failed *handshake attempt*, which is not the same as "the
922    /// session with this peer is gone". One case makes the difference visible:
923    /// a Welcome refused for carrying an unprovable identity
924    /// ([`SecurityWarningCode::GroupLeafIdentityUnproven`], emitted alongside)
925    /// is refused non-destructively, so any session already held with that peer
926    /// stays live and usable. Apps must not tear down session state on this
927    /// event alone.
928    ///
929    /// `reason` is diagnostic text and must not be parsed. It carries no
930    /// identifier, of this peer or of anyone else: the telemetry scrubber
931    /// hashes `peer_id` and ships `reason` verbatim, so every arm that fills it
932    /// contributes either a fixed string or
933    /// [`MlsError::privacy_safe_reason`](offline_protocol_mls::MlsError::privacy_safe_reason),
934    /// never a rendered error. The full error stays in the device log at the
935    /// refusal site. This is a classification, so apps get the failure *class*
936    /// here and must look to the log for the specifics.
937    SecureSessionFailed {
938        /// Peer ID of the other party.
939        peer_id: String,
940        /// Reason for the failure.
941        reason: String,
942    },
943
944    /// Diagnostic breadcrumb for the 1:1 MLS convergence (Welcome receive /
945    /// adopt / confirm) path. Emitted so the receiver side — which otherwise
946    /// produces no event until it fully establishes or loudly fails — is
947    /// observable in Metro. Pure instrumentation; carries no protocol effect.
948    ConvergenceDiag {
949        /// Fixed stage label, e.g. "welcome_received" or "welcome_branch".
950        stage: String,
951        /// Peer ID this breadcrumb concerns.
952        peer_id: String,
953        /// Free-form `key=value` context (counts, branch taken, errors).
954        detail: String,
955    },
956
957    /// Welcome send lifecycle entered attempted state.
958    WelcomeSendAttempted {
959        /// Peer identifier for this welcome lifecycle.
960        peer_id: String,
961        /// Welcome message identifier for lifecycle correlation.
962        message_id: String,
963        /// MLS group identifier associated with the welcome.
964        group_id: String,
965        /// 1-based send attempt number.
966        attempt: u32,
967    },
968
969    /// Welcome send lifecycle reached successful sent state.
970    WelcomeSendSucceeded {
971        /// Peer identifier for this welcome lifecycle.
972        peer_id: String,
973        /// Welcome message identifier for lifecycle correlation.
974        message_id: String,
975        /// MLS group identifier associated with the welcome.
976        group_id: String,
977        /// 1-based send attempt number.
978        attempt: u32,
979    },
980
981    /// Welcome send attempt failed and may be retried.
982    WelcomeSendFailed {
983        /// Peer identifier for this welcome lifecycle.
984        peer_id: String,
985        /// Welcome message identifier for lifecycle correlation.
986        message_id: String,
987        /// MLS group identifier associated with the welcome.
988        group_id: String,
989        /// 1-based send attempt number.
990        attempt: u32,
991        /// Machine-readable reason code.
992        reason_code: WelcomeReasonCode,
993        #[serde(skip_serializing_if = "Option::is_none")]
994        /// Stable classification of the transport failure, when one was
995        /// recorded — never the rendered error.
996        ///
997        /// Both feeders named an identifier: the platform-supplied string is
998        /// the relay's own `DeliveryError` prose, and the locally raised one
999        /// renders a transport error that interpolates the peer. `peer_id` and
1000        /// `group_id` beside it are hashed, so either would have undone that.
1001        /// Classified at the boundary, so the persisted record holds the token
1002        /// too — and again at each emit as a backstop. The second pass is
1003        /// unreachable today (every writer overwrites the field before its
1004        /// emit, so no record restored from an older build can carry raw text
1005        /// into an event) and kept because the field is persisted and has five
1006        /// writers: it holds the invariant if one of them ever becomes
1007        /// conditional.
1008        transport_error: Option<String>,
1009        /// Whether this failure will be retried.
1010        retryable: bool,
1011        #[serde(skip_serializing_if = "Option::is_none")]
1012        /// Next scheduled retry timestamp in Unix ms.
1013        next_retry_at: Option<i64>,
1014    },
1015
1016    /// Welcome send lifecycle reached terminal expiry.
1017    WelcomeSendExpired {
1018        /// Peer identifier for this welcome lifecycle.
1019        peer_id: String,
1020        /// Welcome message identifier for lifecycle correlation.
1021        message_id: String,
1022        /// Final attempt number when lifecycle expired.
1023        attempt: u32,
1024        /// Terminal machine-readable reason code.
1025        reason_code: WelcomeReasonCode,
1026    },
1027
1028    /// A connection request was received from another user.
1029    ConnectionRequestReceived {
1030        /// User ID of the sender.
1031        sender: String,
1032        /// Display name of the sender.
1033        sender_name: String,
1034        /// Timestamp of the request (Unix ms).
1035        timestamp: i64,
1036        /// MLS key package data (if provided for encrypted session setup).
1037        #[serde(skip_serializing_if = "Option::is_none")]
1038        key_package: Option<Vec<u8>>,
1039        /// Optional first message sent along with the request.
1040        #[serde(skip_serializing_if = "Option::is_none")]
1041        initial_message: Option<String>,
1042    },
1043
1044    /// An outbound connection request could not be delivered: the transport
1045    /// reported the recipient unreachable (e.g. the relay's DeliveryError
1046    /// for an offline peer), or the request exhausted its ACK retry budget
1047    /// (in which case a generic `MessageFailed` also fires for the same
1048    /// id). This is a status signal, not proof of permanent failure — the
1049    /// original request may still be delivered by the retry machinery if
1050    /// the peer comes back online. Apps typically surface "user is
1051    /// offline"; a user-initiated resend can therefore arrive alongside the
1052    /// retried original (the recipient sees the request event again, which
1053    /// accept/reject flows already tolerate).
1054    ConnectionRequestUndeliverable {
1055        /// Recipient the request was addressed to.
1056        recipient: String,
1057        /// Message id returned by `send_connection_request`.
1058        message_id: String,
1059        /// Failure reason, drawn from a fixed local vocabulary:
1060        /// `recipient_unreachable` on the transport verdict path, or
1061        /// `max_retries_exceeded` / `outbox_lifetime_exceeded` /
1062        /// `outbox_capacity_exceeded` on the give-up paths.
1063        ///
1064        /// The relay's wording is no longer appended to the first of these —
1065        /// it is remote-chosen text, and `recipient` beside it is hashed by
1066        /// the telemetry scrubber.
1067        reason: String,
1068    },
1069
1070    /// A previously sent connection request was accepted.
1071    ConnectionAccepted {
1072        /// User ID of the accepting party.
1073        accepted_by: String,
1074        /// Display name of the accepting party.
1075        accepted_by_name: String,
1076        /// Timestamp of the acceptance (Unix ms).
1077        timestamp: i64,
1078        /// MLS key package data (if provided for encrypted session setup).
1079        #[serde(skip_serializing_if = "Option::is_none")]
1080        key_package: Option<Vec<u8>>,
1081    },
1082
1083    /// A previously sent connection request was rejected.
1084    ConnectionRejected {
1085        /// User ID of the rejecting party.
1086        rejected_by: String,
1087    },
1088
1089    /// A previously sent connection request was cancelled by the sender.
1090    ConnectionRequestCancelled {
1091        /// User ID of the party who cancelled their request.
1092        cancelled_by: String,
1093    },
1094
1095    // --- Group (relay) events ---
1096    /// A group was created (from relay).
1097    GroupCreated {
1098        /// Group identifier.
1099        group_id: String,
1100        /// Human-readable group name.
1101        name: String,
1102    },
1103
1104    /// A message was received in a group (from relay).
1105    GroupMessageReceived {
1106        /// Group identifier.
1107        group_id: String,
1108        /// User ID of the sender.
1109        sender: String,
1110        /// Message content.
1111        content: String,
1112        /// ISO-8601 timestamp.
1113        timestamp: String,
1114        /// Unique message identifier.
1115        message_id: String,
1116        /// Optional reply-to message ID.
1117        #[serde(default, skip_serializing_if = "Option::is_none")]
1118        reply_to_msg: Option<String>,
1119        /// Forwarding attribution (present when this is a forwarded message).
1120        #[serde(default, skip_serializing_if = "Option::is_none")]
1121        forward_info: Option<ForwardInfoEvent>,
1122        /// Media metadata restored from the sealed `__RICH_V1__` body of a
1123        /// rich group message (cloud attachments — including the
1124        /// `encryption_key`/`iv` secrets, which only ever travel inside the
1125        /// group MLS ciphertext). Absent on plain group messages.
1126        #[serde(default, skip_serializing_if = "Option::is_none")]
1127        media_metadata: Option<MediaMetadata>,
1128        /// Content-type rendering hint from the sealed body (text, image,
1129        /// video, ...). Absent on plain group messages.
1130        #[serde(default, skip_serializing_if = "Option::is_none")]
1131        content_type: Option<String>,
1132    },
1133
1134    /// A member was added to a group (from relay).
1135    GroupMemberAdded {
1136        /// Group identifier.
1137        group_id: String,
1138        /// User ID of the added member.
1139        user_id: String,
1140        /// User ID of who performed the add.
1141        added_by: String,
1142        /// Human-readable group name (present when the invitee first joins).
1143        #[serde(skip_serializing_if = "Option::is_none")]
1144        group_name: Option<String>,
1145        /// Whether `added_by` was authorized to make this change.
1146        ///
1147        /// `Some(false)` means the change **did** happen — MLS accepted the
1148        /// commit and the roster really has changed — but the committer was
1149        /// not a known admin. The membership event is still emitted because
1150        /// the local roster must not diverge from MLS state; see
1151        /// [`Event::GroupUnauthorizedMembershipChange`] for the full signal.
1152        ///
1153        /// `None` (absent in JSON) means authorization was **not evaluated**
1154        /// on the path that produced this event: our own join accepted from
1155        /// a Welcome (there is no prior group state to judge the inviter
1156        /// against) and relay reconciliation frames (no authenticated
1157        /// committer to judge). Events from an older core also omit the
1158        /// field. Only `Some(_)` is a positive statement either way.
1159        ///
1160        /// The judgment is made against this member's **local replica** of
1161        /// role state, which replicates best-effort and can lag — a
1162        /// legitimate change may be flagged `Some(false)`, and different
1163        /// members can disagree about the same commit. Do not act on it
1164        /// automatically.
1165        #[serde(default, skip_serializing_if = "Option::is_none")]
1166        authorized: Option<bool>,
1167    },
1168
1169    /// A member was removed from a group (from relay).
1170    GroupMemberRemoved {
1171        /// Group identifier.
1172        group_id: String,
1173        /// User ID of the removed member.
1174        user_id: String,
1175        /// User ID of who performed the removal.
1176        removed_by: String,
1177        /// Whether this change was authorized (`None` = not evaluated on
1178        /// this path). See [`Event::GroupMemberAdded::authorized`]. On the
1179        /// relay reconciliation path the judgment applies to the frame's
1180        /// authenticated wire sender, which is not necessarily the
1181        /// `removed_by` named here.
1182        #[serde(default, skip_serializing_if = "Option::is_none")]
1183        authorized: Option<bool>,
1184    },
1185
1186    /// Group info was received (from relay).
1187    GroupInfo {
1188        /// Group identifier.
1189        group_id: String,
1190        /// Human-readable group name.
1191        name: String,
1192        /// User ID of the group creator.
1193        created_by: String,
1194        /// ISO-8601 creation timestamp.
1195        created_at: String,
1196        /// Group member list.
1197        members: Vec<GroupInfoMember>,
1198    },
1199
1200    /// User's groups list was received (from relay).
1201    UserGroups {
1202        /// List of group summaries.
1203        groups: Vec<UserGroupSummary>,
1204    },
1205
1206    /// A group operation failed (from relay).
1207    ///
1208    /// The relay's own wording is **not** carried here. `__GROUP_ERROR__` is
1209    /// a relay answer, accepted unsigned on the relay ingest path (the
1210    /// bridges inject it unattributed), so its text is chosen by whoever put
1211    /// the frame on that socket — arbitrary content, arbitrary length, and
1212    /// not something this SDK can vouch for. `reason` is a fixed code
1213    /// classified locally instead; the
1214    /// relay's exact text is logged on-device and reaches apps, when they
1215    /// want it, as the raw `GroupError` frame the bridges dual-emit on the
1216    /// server-message channel.
1217    GroupError {
1218        /// Why the operation failed, from a fixed set: `not_found` (the
1219        /// relay has no such group — invite links and relay fan-out for it
1220        /// are dead, not merely refused), `sync_denied` (the relay refused
1221        /// to register or sync the group for this caller), or `error` (any
1222        /// other refusal).
1223        reason: String,
1224        /// Group the error concerns, when the relay scoped it. Absent for
1225        /// unscoped errors.
1226        ///
1227        /// This is the group-scoping the free-text `reason` used to smuggle
1228        /// past the telemetry scrubber — as a real field it is hashed like
1229        /// every other `group_id` instead.
1230        #[serde(default, skip_serializing_if = "Option::is_none")]
1231        group_id: Option<String>,
1232    },
1233
1234    /// The relay-side registration state of a group changed.
1235    ///
1236    /// `synced: true` fires only on the relay's positive registration
1237    /// acknowledgment (a `GroupCreated` answering a registration this
1238    /// device actually sent, over the Internet transport) — including the
1239    /// idempotent re-sync ack after a membership change. It is the signal
1240    /// that relay-dependent server commands for the group (invite links,
1241    /// server-side fan-out) can be issued. `synced: false` fires when that
1242    /// trust is revoked; `reason` says why. Not emitted for groups the
1243    /// relay was never asked about.
1244    GroupRelaySyncChanged {
1245        /// MLS group identifier.
1246        group_id: String,
1247        /// Whether the relay currently holds a positively acknowledged
1248        /// registration for this group.
1249        synced: bool,
1250        /// Why the state changed: `registered` (relay ack), `error`
1251        /// (group-scoped relay error), `removed` (we were removed from the
1252        /// group), `left` (local leave), `internet_dropped` (Internet
1253        /// transport lost; re-registration re-arms on reconnect), or
1254        /// `ack_timeout` (relay never answered the registration — likely a
1255        /// relay without group support).
1256        reason: String,
1257    },
1258
1259    /// A username resolution finished, carrying **every** claim found.
1260    ///
1261    /// # The set is the whole point, and there is deliberately no "best" claim
1262    ///
1263    /// Anyone may publish any username claim, so a name resolves to a set of
1264    /// assertions, never to an answer. Even a single-device user is a set of
1265    /// one. This event is emitted exactly once per resolution and carries the
1266    /// complete set precisely so an app cannot accidentally treat the first
1267    /// arrival as the winner: there is no per-claim event to race, and no
1268    /// ordering field to mistake for a ranking.
1269    ///
1270    /// **An app MUST let the user choose.** Silently picking one claim converts
1271    /// a non-authoritative directory into an authoritative-looking one, which
1272    /// is worse than not shipping the feature: the user believes they are
1273    /// talking to the name, and the protocol only ever promised them a key.
1274    /// Present the claims, let a human confirm out of band (a QR code, a
1275    /// shared secret, a voice), and remember the address rather than the name.
1276    ///
1277    /// An empty `claims` list is an ordinary outcome: the name may be
1278    /// unclaimed, the claimant offline-published to relays we do not use, or
1279    /// every claim we saw failed verification.
1280    UsernameResolved {
1281        /// The normalized username that was resolved.
1282        username: String,
1283        /// Every claim that verified, in no meaningful order.
1284        claims: Vec<UsernameClaim>,
1285        /// How many records were seen but rejected.
1286        ///
1287        /// Non-zero is *normal*, not an error: the tag is public, anyone may
1288        /// publish to it, and junk arrives. Surfaced because a resolution that
1289        /// returns nothing while rejecting many records means something
1290        /// different from one that returns nothing having seen nothing, and an
1291        /// app showing "not found" may want to distinguish them.
1292        rejected: u32,
1293        /// How many *verified* claims were dropped at the accumulator's
1294        /// ceiling.
1295        ///
1296        /// Separate from `rejected` because it is the opposite statement: these
1297        /// records were good and are missing anyway. Without it a crowded name
1298        /// renders exactly like an uncrowded one, a clean set with nothing
1299        /// refused, which hides the single signal that a name is being
1300        /// squatted at volume. Non-zero means `claims` is a sample and no
1301        /// absence in it can be trusted.
1302        truncated: u32,
1303    },
1304
1305    /// A group message was sent to all members via mesh (MLS-encrypted fan-out).
1306    GroupMessageSent {
1307        /// MLS group identifier.
1308        group_id: String,
1309        /// Per-member message IDs from fan-out.
1310        message_ids: Vec<String>,
1311        /// Number of members the message was sent to.
1312        member_count: u32,
1313    },
1314
1315    /// A group message was only partially delivered (some members failed).
1316    GroupMessagePartialFailure {
1317        /// MLS group identifier.
1318        group_id: String,
1319        /// Members for whom send failed.
1320        failed_members: Vec<String>,
1321        /// Members for whom send succeeded.
1322        succeeded_members: Vec<String>,
1323    },
1324
1325    /// The relay's settled per-recipient delivery report for a group
1326    /// message sent via relay broadcast, after the SDK acted on it.
1327    ///
1328    /// Fires once per broadcast whose report arrived, seconds after
1329    /// `group_message_sent` (the relay reports only when its whole fan-out
1330    /// has settled — correlate by `message_id`, never by order). Members in
1331    /// `delivered` took the message over a live relay socket; members in
1332    /// `pushed` took a device push carrying the ciphertext. Every other
1333    /// MLS roster member — the ones the relay reported missed *and* the
1334    /// ones it did not know about at all — has already been re-sent a
1335    /// per-member copy through the ordinary outbox/ACK/park delivery
1336    /// ladder (`missed_reissued`), so no app action is required; the event
1337    /// is delivery observability, not a failure signal. Not emitted when
1338    /// the report itself is lost — the SDK then re-broadcasts (bounded)
1339    /// and finally downgrades the whole message to per-member fan-out,
1340    /// which reports per-member like any mesh send.
1341    GroupMessageDeliveryReport {
1342        /// MLS group identifier.
1343        group_id: String,
1344        /// The logical group message id, as returned by the send and echoed
1345        /// by the relay.
1346        message_id: String,
1347        /// Members whose relay socket write was confirmed (a relay-side
1348        /// write-ack, not an end-to-end delivery ACK).
1349        delivered: Vec<String>,
1350        /// Members offline at the relay who took a device push.
1351        pushed: Vec<String>,
1352        /// Members re-sent a per-member copy because the relay reached them
1353        /// neither way (or never knew them).
1354        missed_reissued: Vec<String>,
1355    },
1356
1357    /// Rich media metadata was dropped from an outbound group message
1358    /// because the group is not fully rich-capable (not every member
1359    /// advertised sealed rich payload support, or the local kill switch
1360    /// disabled it). The text was still sent; members receive it without
1361    /// the media attachment. Apps can use this to warn the sender that an
1362    /// attachment did not go through.
1363    GroupRichExtrasDropped {
1364        /// MLS group identifier.
1365        group_id: String,
1366        /// Members not known to parse the sealed rich payload — the ones
1367        /// holding the seal gate closed. Absence of knowledge and known
1368        /// non-support are indistinguishable here. Empty when the drop was
1369        /// caused by the local `rich_payload_enabled` kill switch instead.
1370        /// The SDK probes these members for their capability automatically;
1371        /// apps can retry the send once a later attempt stops dropping.
1372        unknown_members: Vec<String>,
1373    },
1374
1375    /// An MLS membership change was applied that its committer was not
1376    /// authorized to make.
1377    ///
1378    /// The change **has been applied** — MLS authenticated the committer as a
1379    /// group member and accepted the commit, and the local epoch advanced with
1380    /// it. The SDK's admin model is an application-layer overlay on top of MLS
1381    /// (RFC 9420 leaves membership access control to the application), enforced
1382    /// on the *sending* side; refusing the change on receipt would mean
1383    /// refusing the merge, which permanently forks this member away from every
1384    /// peer that accepted it. So the change stands and is reported here instead.
1385    ///
1386    /// **This signal can false-positive.** "Unauthorized" is judged against
1387    /// this member's local replica of role state, which replicates
1388    /// best-effort (a joiner receives a point-in-time snapshot, role changes
1389    /// ride unreconciled mesh notifications) and can lag — so a legitimate
1390    /// change, e.g. a voluntary leave committed by a member whose admin role
1391    /// this device has not yet learned, may be reported here, and different
1392    /// members can disagree about the same commit.
1393    ///
1394    /// Apps should treat this as a moderation signal for a *human* admin: an
1395    /// admin can undo the change with `remove_from_group` /
1396    /// `invite_to_group`. Never reverse a change automatically off a single
1397    /// member's event — corroborate first. A member added this way can read
1398    /// all subsequent group traffic until removed.
1399    ///
1400    /// Reports are rate-limited per `(group, committer)`: a repeat within a
1401    /// short window is not re-emitted (divergent role metadata would
1402    /// otherwise re-fire it on every commit), but every affected
1403    /// [`Event::GroupMemberAdded`] / [`Event::GroupMemberRemoved`] still
1404    /// carries `authorized: Some(false)`.
1405    ///
1406    /// Known limitation: the member *removed* by an unauthorized Remove does
1407    /// not receive this event — it can no longer decrypt the commit that
1408    /// removed it, and it refuses to trust a non-admin's unencrypted claim
1409    /// that it was removed. Only the remaining members report the removal.
1410    GroupUnauthorizedMembershipChange {
1411        /// MLS group identifier.
1412        group_id: String,
1413        /// The MLS-authenticated committer that made the change.
1414        committer: String,
1415        /// Members the commit added, sorted. Empty for a pure removal.
1416        added: Vec<String>,
1417        /// Members the commit removed, sorted. Empty for a pure addition.
1418        removed: Vec<String>,
1419        /// Why the change was judged unauthorized. Currently
1420        /// `"sender_not_admin"` (the committer is not a known admin) or
1421        /// `"affected_member_mismatch"` (an admin committed, but the commit's
1422        /// unencrypted framing named a different member than the MLS delta
1423        /// actually changed). Treat as an opaque string — values may be added.
1424        reason: String,
1425        /// Whether the commit was *refused* rather than applied.
1426        ///
1427        /// `false` (the default configuration) means the membership change
1428        /// happened and is being reported after the fact — `added`/`removed`
1429        /// describe real roster changes an admin can undo. `true` means
1430        /// `GroupConfig::enforce_admin_commits` was on and the commit was
1431        /// rejected before merging: no roster event accompanies this one, no
1432        /// membership changed locally, and `added`/`removed` describe what the
1433        /// commit *would* have done.
1434        ///
1435        /// A `true` here also means this device declined an epoch every
1436        /// accepting member advanced to, so it can no longer decrypt that
1437        /// group's traffic and needs re-inviting — treat it as a partition
1438        /// alarm, not just a moderation signal. Note the re-invite arrives as
1439        /// a Welcome, which is not policy-gated, so it readmits us to the
1440        /// group *including* whatever the refused commit did; the change
1441        /// itself still has to be resolved separately.
1442        enforced: bool,
1443    },
1444
1445    /// An epoch fork was detected in a group — concurrent commits caused
1446    /// members to diverge onto different MLS branches. The deterministic
1447    /// leader will attempt automatic resolution via a key-update commit.
1448    GroupEpochForkDetected {
1449        /// MLS group identifier.
1450        group_id: String,
1451        /// Our local epoch when the fork was detected, or `None` if MLS
1452        /// state was unavailable at detection time.
1453        local_epoch: Option<u64>,
1454    },
1455
1456    /// An epoch fork was successfully resolved by the leader issuing a
1457    /// resync commit that re-established a canonical epoch.
1458    ///
1459    /// Members in `failed_members` could not be reached with the resolution
1460    /// commit and may still be on a forked branch — the application layer
1461    /// should consider re-inviting them.
1462    GroupEpochForkResolved {
1463        /// MLS group identifier.
1464        group_id: String,
1465        /// The new canonical epoch after resolution.
1466        resolved_epoch: u64,
1467        /// Members to whom the resolution commit could not be sent.
1468        /// These members may still be on a forked epoch branch and
1469        /// may need a re-invite to rejoin the canonical group state.
1470        failed_members: Vec<String>,
1471    },
1472
1473    /// A member's role was changed in a group.
1474    GroupRoleChanged {
1475        /// Group identifier.
1476        group_id: String,
1477        /// User ID of the member whose role changed.
1478        user_id: String,
1479        /// New role (e.g. "admin", "member").
1480        new_role: String,
1481        /// User ID of who changed the role.
1482        changed_by: String,
1483    },
1484
1485    /// A group was renamed.
1486    GroupRenamed {
1487        /// Group identifier.
1488        group_id: String,
1489        /// New group name.
1490        new_name: String,
1491        /// Previous group name (if known).
1492        old_name: Option<String>,
1493        /// User ID of who renamed the group.
1494        renamed_by: String,
1495    },
1496
1497    // --- Service discovery ---
1498    /// A service was discovered on the mesh in response to a discovery query.
1499    ServiceDiscovered {
1500        /// Query ID that triggered this discovery.
1501        query_id: String,
1502        /// Service identifier.
1503        service_id: String,
1504        /// Service version.
1505        version: String,
1506        /// Peer user ID of the provider.
1507        provider_peer_id: String,
1508        /// Service capabilities.
1509        capabilities: HashMap<String, String>,
1510        /// Number of hops from the provider.
1511        hop_count: u8,
1512    },
1513
1514    /// A service request was received from another peer.
1515    ServiceRequestReceived {
1516        /// Unique request identifier.
1517        request_id: String,
1518        /// Service identifier being invoked.
1519        service_id: String,
1520        /// Method name or action.
1521        method: String,
1522        /// Request body (JSON or arbitrary string).
1523        body: String,
1524        /// Peer user ID of the requester.
1525        sender: String,
1526    },
1527
1528    /// A response to a service request was received.
1529    ServiceResponseReceived {
1530        /// Request identifier this response corresponds to.
1531        request_id: String,
1532        /// Service identifier.
1533        service_id: String,
1534        /// Status: "ok", "not_found", or "error".
1535        status: String,
1536        /// Response body.
1537        body: String,
1538        /// Peer user ID of the provider.
1539        provider_peer_id: String,
1540    },
1541
1542    // --- Presence, typing, and read receipts ---
1543    /// A peer's presence status was updated.
1544    PresenceUpdated {
1545        /// Peer whose presence changed.
1546        peer_id: String,
1547        /// Presence status.
1548        status: PresenceStatus,
1549        /// Timestamp of the update (Unix ms).
1550        timestamp: i64,
1551        /// When the peer was last seen (Unix ms), if the source knows it —
1552        /// e.g. the internet relay's PresenceStatusWithLastSeen. Absent for
1553        /// peer-sent `__PRESENCE__` updates.
1554        #[serde(default, skip_serializing_if = "Option::is_none")]
1555        last_seen_ms: Option<i64>,
1556        /// Which channel produced this update: the internet relay's
1557        /// authoritative answer, or a peer-sent self-report. Always
1558        /// serialized; defaults to `peer` when deserializing legacy events.
1559        #[serde(default)]
1560        source: PresenceSource,
1561    },
1562
1563    /// A typing indicator was received from a peer.
1564    TypingIndicatorReceived {
1565        /// User who is typing (or stopped typing).
1566        sender: String,
1567        /// Conversation identifier, echoed back verbatim from the sender.
1568        ///
1569        /// Opaque to the SDK — never parsed or routed on. Whatever key the
1570        /// sender chose (conventionally a peer address for a DM, the group
1571        /// id for a group) arrives here unchanged.
1572        conversation_id: String,
1573        /// Whether the user is currently typing.
1574        is_typing: bool,
1575        /// Timestamp of the indicator (Unix ms).
1576        timestamp: i64,
1577    },
1578
1579    /// A read receipt was received from a peer.
1580    ReadReceiptReceived {
1581        /// User who read the messages.
1582        sender: String,
1583        /// IDs of the messages that were read.
1584        message_ids: Vec<String>,
1585        /// Timestamp when the messages were read (Unix ms).
1586        timestamp: i64,
1587    },
1588
1589    // --- DORS observability (OFF-258) ---
1590    /// DORS scored all available transports for this decision cycle.
1591    DorsScoreUpdated {
1592        /// Transport type and score pairs (descending by score).
1593        scores: Vec<(String, f32)>,
1594    },
1595    /// DORS selected a transport for the current message.
1596    DorsTransportSelected {
1597        /// Previous transport (if any); provides decision boundary context.
1598        from: Option<String>,
1599        /// Selected transport.
1600        transport: String,
1601        /// Typed reason for selection (initial vs primary selected).
1602        reason_code: DorsReasonCode,
1603        /// Score of the selected transport (supplemental).
1604        score: f32,
1605    },
1606    /// DORS switched from one transport to another. Emitted only when active transport
1607    /// actually changes after a successful send.
1608    DorsTransportSwitched {
1609        /// Previous transport (if any).
1610        from: Option<String>,
1611        /// New transport (now active).
1612        to: String,
1613        /// Stable enum-backed reason for the transition.
1614        reason_code: DorsReasonCode,
1615        /// Optional human-readable context (e.g. "primary failed, fallback succeeded").
1616        #[serde(skip_serializing_if = "Option::is_none")]
1617        reason_detail: Option<String>,
1618    },
1619    /// DORS escalation from BLE to Wi‑Fi. Use `phase` to distinguish recommendation vs applied.
1620    DorsEscalationTriggered {
1621        /// TRIGGERED = DORS decided to escalate (reason = trigger); APPLIED = fallback succeeded.
1622        phase: DorsEscalationPhase,
1623        /// Transport we escalated from (e.g. "ble").
1624        from: String,
1625        /// Transport we escalated to (e.g. "wifiDirect").
1626        to: String,
1627        /// Stable enum-backed reason code.
1628        reason_code: DorsEscalationReasonCode,
1629        /// Optional human-readable context.
1630        #[serde(skip_serializing_if = "Option::is_none")]
1631        reason_detail: Option<String>,
1632    },
1633
1634    /// A security-relevant anomaly was detected (e.g. sender spoofing, a
1635    /// signing key that does not derive to its claimed address, an unsigned
1636    /// control message).
1637    SecurityWarning {
1638        /// Peer ID involved in the warning.
1639        peer_id: String,
1640        /// Stable machine-readable classification of the warning. Branch on
1641        /// this rather than parsing `reason`.
1642        reason_code: SecurityWarningCode,
1643        /// Human-readable description of the security issue.
1644        reason: String,
1645    },
1646
1647    /// A message was relayed (forwarded for a third party).
1648    MessageRelayed {
1649        /// ID of the relayed message.
1650        message_id: String,
1651        /// Original sender.
1652        sender: String,
1653        /// Intended recipient.
1654        recipient: String,
1655        /// Current hop count after increment.
1656        hop_count: u8,
1657        /// Remaining TTL after decrement.
1658        remaining_ttl: u8,
1659    },
1660
1661    /// A user was blocked. Emitted for local UI notification only.
1662    UserBlocked {
1663        /// User ID that was blocked.
1664        user_id: String,
1665    },
1666
1667    /// A user was unblocked. Emitted for local UI notification only.
1668    UserUnblocked {
1669        /// User ID that was unblocked.
1670        user_id: String,
1671    },
1672
1673    /// A replicated document changed and the change reached storage.
1674    ///
1675    /// Emitted after the delta is durable, never before: an application that
1676    /// re-renders on this event is then rendering state that survives a
1677    /// crash.
1678    DataChanged {
1679        /// The space the document belongs to.
1680        space_id: String,
1681        /// The document that changed.
1682        doc_id: String,
1683        /// Size of the change that was persisted.
1684        delta_bytes: u64,
1685    },
1686
1687    /// A replicated document is approaching the per-document size cap.
1688    ///
1689    /// Fires while there is still room to act. Without it the cap would be a
1690    /// cliff an application meets for the first time as a failed write.
1691    DataDocSizeWarning {
1692        /// The space the document belongs to.
1693        space_id: String,
1694        /// The document approaching the cap.
1695        doc_id: String,
1696        /// Its current compacted size.
1697        compacted_bytes: u64,
1698        /// The cap it is measured against.
1699        cap_bytes: u64,
1700    },
1701
1702    /// A peer asked for the bytes behind an attachment reference.
1703    ///
1704    /// The SDK does not hold blob bytes, so it cannot answer this on its
1705    /// own: the application either supplies them with
1706    /// `data_provide_attachment` or says it cannot with
1707    /// `data_decline_attachment`. An application that handles neither leaves
1708    /// the asking peer with a reference it can see and never open, which is
1709    /// why declining is a first-class answer rather than silence.
1710    DataAttachmentRequested {
1711        /// The space the request arrived in.
1712        space_id: String,
1713        /// The peer that asked.
1714        peer_id: String,
1715        /// Lowercase hex SHA-256 of the blob they want.
1716        hash: String,
1717    },
1718
1719    /// Attachment bytes arrived and matched the hash that asked for them.
1720    ///
1721    /// The bytes are handed over rather than stored: blobs never enter
1722    /// protocol state, so where this file lives is the application's
1723    /// decision and its storage.
1724    DataAttachmentReceived {
1725        /// The space the attachment arrived in.
1726        space_id: String,
1727        /// The peer that supplied it.
1728        peer_id: String,
1729        /// Lowercase hex SHA-256 of the blob, verified against these bytes.
1730        hash: String,
1731        /// The blob, base64.
1732        data: String,
1733    },
1734
1735    /// A fetch ended without bytes.
1736    ///
1737    /// Either the peer answered that it no longer holds the blob, or the
1738    /// transfer carrying it failed. Both are ordinary: an attachment
1739    /// reference outlives the bytes it names, because the reference
1740    /// replicates and the bytes do not.
1741    DataAttachmentUnavailable {
1742        /// The space the fetch was made in.
1743        space_id: String,
1744        /// The peer that was asked.
1745        peer_id: String,
1746        /// Lowercase hex SHA-256 that was asked for.
1747        hash: String,
1748        /// Why it ended: `declined` (the peer answered that it no longer
1749        /// holds the blob), `timeout`, `evicted` (displaced by newer
1750        /// fetches), `hash_mismatch`, `peer_gone` (the peer was blocked,
1751        /// forgotten, or came back without the replication capability), or a
1752        /// transfer
1753        /// failure reason. A fixed token this crate chooses, never a peer's
1754        /// words.
1755        reason: String,
1756    },
1757
1758    /// A document cannot be replicated, in any form this protocol has.
1759    ///
1760    /// The honest report of a real dead end rather than a warning in a log
1761    /// nobody reads. It fires when a document is too large for a sync frame
1762    /// and the peer cannot carry it over the media path either, which leaves
1763    /// two replicas that will not converge and no further rung to try.
1764    DataDocUnsyncable {
1765        /// The space the document belongs to.
1766        space_id: String,
1767        /// The document that cannot be sent.
1768        doc_id: String,
1769        /// Size of the encoding that had nowhere to go.
1770        bytes: u64,
1771        /// Why it could not be sent.
1772        reason: String,
1773    },
1774}
1775
1776/// Member entry in GroupInfo.
1777#[derive(Debug, Clone, Serialize, Deserialize)]
1778pub struct GroupInfoMember {
1779    /// User ID of the member.
1780    pub user_id: String,
1781    /// Role within the group (e.g. "admin", "member").
1782    pub role: String,
1783    /// ISO-8601 timestamp when the member joined.
1784    pub joined_at: String,
1785}
1786
1787/// Group summary in UserGroups.
1788#[derive(Debug, Clone, Serialize, Deserialize)]
1789pub struct UserGroupSummary {
1790    /// Group identifier.
1791    pub group_id: String,
1792    /// Human-readable group name.
1793    pub name: String,
1794    /// ISO-8601 creation timestamp.
1795    pub created_at: String,
1796}
1797
1798impl Event {
1799    /// Creates a MessageSent event.
1800    pub fn message_sent(message: &Message) -> Self {
1801        use offline_protocol_core::MessagePriority;
1802
1803        let priority = match message.priority {
1804            MessagePriority::Low => "low",
1805            MessagePriority::Medium => "medium",
1806            MessagePriority::High => "high",
1807            MessagePriority::Critical => "critical",
1808        };
1809
1810        let forward_info = message.forwarded_from.as_ref().map(ForwardInfoEvent::from);
1811
1812        Self::MessageSent {
1813            message_id: message.id.as_str(),
1814            sender: message.sender.as_str().to_string(),
1815            recipient: message.recipient.as_str().to_string(),
1816            content: message.content.clone(),
1817            priority: priority.to_string(),
1818            requires_ack: message.requires_ack,
1819            timestamp: message.timestamp.as_millis(),
1820            lamport_clock: message.lamport_clock.value(),
1821            forward_info,
1822        }
1823    }
1824
1825    /// Creates a MessageDelivered event.
1826    pub fn message_delivered(
1827        message_id: MessageId,
1828        latency_ms: u64,
1829        hop_count: u8,
1830        transport: TransportType,
1831    ) -> Self {
1832        Self::MessageDelivered {
1833            message_id: message_id.as_str(),
1834            latency_ms,
1835            hop_count,
1836            transport: transport.to_string(),
1837        }
1838    }
1839
1840    /// Creates a MessageFailed event.
1841    pub fn message_failed(message_id: MessageId, reason: String, retry_count: u32) -> Self {
1842        Self::MessageFailed {
1843            message_id: message_id.as_str(),
1844            reason,
1845            retry_count,
1846        }
1847    }
1848
1849    /// Creates a MessageDecryptionFailed event.
1850    pub fn message_decryption_failed(
1851        message_id: MessageId,
1852        sender: String,
1853        code: DecryptionFailureCode,
1854        reason: String,
1855    ) -> Self {
1856        Self::MessageDecryptionFailed {
1857            message_id: message_id.as_str(),
1858            sender,
1859            code,
1860            reason,
1861        }
1862    }
1863
1864    /// Creates a TransportSwitched event.
1865    pub fn transport_switched(
1866        from: Option<TransportType>,
1867        to: TransportType,
1868        reason: String,
1869    ) -> Self {
1870        Self::TransportSwitched {
1871            from: from.map(|t| t.to_string()),
1872            to: to.to_string(),
1873            reason,
1874        }
1875    }
1876
1877    /// Creates a RelayPromoted event.
1878    pub fn relay_promoted(connection_count: usize, battery_level: Option<u8>) -> Self {
1879        Self::RelayPromoted {
1880            connection_count,
1881            battery_level,
1882        }
1883    }
1884
1885    /// Creates a RelayDemoted event.
1886    pub fn relay_demoted(reason: String) -> Self {
1887        Self::RelayDemoted { reason }
1888    }
1889
1890    /// Creates a NeighborDiscovered event.
1891    pub fn neighbor_discovered(
1892        peer_id: String,
1893        transport: TransportType,
1894        rssi: Option<i16>,
1895    ) -> Self {
1896        Self::NeighborDiscovered {
1897            peer_id,
1898            transport: format!("{:?}", transport),
1899            rssi,
1900        }
1901    }
1902
1903    /// Creates a NeighborLost event.
1904    pub fn neighbor_lost(peer_id: String) -> Self {
1905        Self::NeighborLost { peer_id }
1906    }
1907
1908    /// Creates an IdentityReady event.
1909    pub fn identity_ready(address: impl Into<String>) -> Self {
1910        Self::IdentityReady {
1911            address: address.into(),
1912        }
1913    }
1914
1915    /// Creates a NetworkMetrics event.
1916    pub fn network_metrics(
1917        neighbor_count: usize,
1918        relay_count: usize,
1919        delivery_ratio: f32,
1920        avg_latency_ms: u64,
1921    ) -> Self {
1922        Self::NetworkMetrics {
1923            neighbor_count,
1924            relay_count,
1925            delivery_ratio,
1926            avg_latency_ms,
1927        }
1928    }
1929
1930    /// Creates a FileProgress event.
1931    pub fn file_progress(file_id: String, chunks_sent: u32, total_chunks: u32) -> Self {
1932        let percentage = if total_chunks > 0 {
1933            ((chunks_sent as f32 / total_chunks as f32) * 100.0) as u8
1934        } else {
1935            0
1936        };
1937
1938        Self::FileProgress {
1939            file_id,
1940            chunks_sent,
1941            total_chunks,
1942            percentage,
1943        }
1944    }
1945
1946    /// Creates a FileReceived event.
1947    #[allow(clippy::too_many_arguments)]
1948    pub fn file_received(
1949        file_id: String,
1950        file_name: String,
1951        file_size: u64,
1952        sender: String,
1953        content_type: ContentType,
1954        media_metadata: Option<MediaMetadata>,
1955        file_data: Vec<u8>,
1956        timestamp: Option<i64>,
1957        caption: Option<String>,
1958        reply_to_msg: Option<String>,
1959        reply_context: Option<&offline_protocol_core::ReplyContext>,
1960        forward_info: Option<&offline_protocol_core::ForwardInfo>,
1961    ) -> Self {
1962        use base64::{engine::general_purpose::STANDARD, Engine};
1963        Self::FileReceived {
1964            file_id,
1965            file_name,
1966            file_size,
1967            sender,
1968            content_type: content_type.to_string(),
1969            media_metadata,
1970            file_data: STANDARD.encode(&file_data),
1971            timestamp,
1972            caption,
1973            reply_to_msg,
1974            reply_context: reply_context.map(|rc| Box::new(ReplyContextEvent::from(rc))),
1975            forward_info: forward_info.map(ForwardInfoEvent::from),
1976        }
1977    }
1978
1979    /// Creates a FileReceiveFailed event.
1980    pub fn file_receive_failed(
1981        file_id: String,
1982        file_name: String,
1983        sender: String,
1984        reason: String,
1985    ) -> Self {
1986        Self::FileReceiveFailed {
1987            file_id,
1988            file_name,
1989            sender,
1990            reason,
1991        }
1992    }
1993
1994    /// Creates a MediaSent event.
1995    pub fn media_sent(file_id: String, content_type: ContentType, recipient: String) -> Self {
1996        Self::MediaSent {
1997            file_id,
1998            content_type: content_type.to_string(),
1999            recipient,
2000        }
2001    }
2002
2003    /// Creates a MediaSendFailed event.
2004    pub fn media_send_failed(file_id: String, recipient: String, reason: String) -> Self {
2005        Self::MediaSendFailed {
2006            file_id,
2007            recipient,
2008            reason,
2009        }
2010    }
2011
2012    /// Creates a MessageDeferred event.
2013    pub fn message_deferred(
2014        message_id: MessageId,
2015        recipient: String,
2016        reason: &'static str,
2017        retry_count: u32,
2018        next_retry_at: Option<i64>,
2019    ) -> Self {
2020        Self::MessageDeferred {
2021            message_id: message_id.as_str(),
2022            recipient,
2023            reason: reason.to_string(),
2024            retry_count,
2025            next_retry_at,
2026        }
2027    }
2028
2029    /// Creates a MessageRetrying event.
2030    pub fn message_retrying(
2031        message_id: MessageId,
2032        recipient: String,
2033        retry_count: u32,
2034        next_retry_at: i64,
2035    ) -> Self {
2036        Self::MessageRetrying {
2037            message_id: message_id.as_str(),
2038            recipient,
2039            retry_count,
2040            next_retry_at,
2041        }
2042    }
2043
2044    /// Creates a MessageUndeliverable event.
2045    pub fn message_undeliverable(
2046        message_id: MessageId,
2047        recipient: String,
2048        reason: &'static str,
2049        file_id: Option<String>,
2050    ) -> Self {
2051        Self::MessageUndeliverable {
2052            message_id: message_id.as_str(),
2053            recipient,
2054            reason: reason.to_string(),
2055            file_id,
2056        }
2057    }
2058
2059    /// Creates a MediaResendRequired event.
2060    pub fn media_resend_required(
2061        file_id: String,
2062        recipient: String,
2063        file_name: String,
2064        file_size: u64,
2065    ) -> Self {
2066        Self::MediaResendRequired {
2067            file_id,
2068            recipient,
2069            file_name,
2070            file_size,
2071        }
2072    }
2073
2074    /// Creates an AckEvicted event.
2075    pub fn ack_evicted(message_id: MessageId, priority: &str, reason: String) -> Self {
2076        Self::AckEvicted {
2077            message_id: message_id.as_str(),
2078            priority: priority.to_string(),
2079            reason,
2080        }
2081    }
2082
2083    /// Creates a FragmentAssemblyEvicted event.
2084    pub fn fragment_assembly_evicted(
2085        message_id: String,
2086        completion_percent: u8,
2087        reason: String,
2088    ) -> Self {
2089        Self::FragmentAssemblyEvicted {
2090            message_id,
2091            completion_percent,
2092            reason,
2093        }
2094    }
2095
2096    /// Creates a RelayDemotedBattery event.
2097    pub fn relay_demoted_battery(battery_level: u8, min_required: u8) -> Self {
2098        Self::RelayDemotedBattery {
2099            battery_level,
2100            min_required,
2101        }
2102    }
2103
2104    /// Creates a SecureSessionEstablished event.
2105    pub fn secure_session_established(
2106        peer_id: String,
2107        group_id: String,
2108        is_session: bool,
2109        initiated_by_local: bool,
2110    ) -> Self {
2111        Self::SecureSessionEstablished {
2112            peer_id,
2113            group_id,
2114            is_session,
2115            initiated_by_local,
2116        }
2117    }
2118
2119    /// Creates a SecureSessionFailed event.
2120    pub fn secure_session_failed(peer_id: String, reason: String) -> Self {
2121        Self::SecureSessionFailed { peer_id, reason }
2122    }
2123
2124    /// Creates a ConvergenceDiag event (1:1 MLS convergence instrumentation).
2125    pub fn convergence_diag(stage: String, peer_id: String, detail: String) -> Self {
2126        Self::ConvergenceDiag {
2127            stage,
2128            peer_id,
2129            detail,
2130        }
2131    }
2132
2133    /// Creates a WelcomeSendAttempted event.
2134    pub fn welcome_send_attempted(
2135        peer_id: String,
2136        message_id: String,
2137        group_id: String,
2138        attempt: u32,
2139    ) -> Self {
2140        Self::WelcomeSendAttempted {
2141            peer_id,
2142            message_id,
2143            group_id,
2144            attempt,
2145        }
2146    }
2147
2148    /// Creates a WelcomeSendSucceeded event.
2149    pub fn welcome_send_succeeded(
2150        peer_id: String,
2151        message_id: String,
2152        group_id: String,
2153        attempt: u32,
2154    ) -> Self {
2155        Self::WelcomeSendSucceeded {
2156            peer_id,
2157            message_id,
2158            group_id,
2159            attempt,
2160        }
2161    }
2162
2163    /// Creates a WelcomeSendFailed event.
2164    #[allow(clippy::too_many_arguments)]
2165    pub fn welcome_send_failed(
2166        peer_id: String,
2167        message_id: String,
2168        group_id: String,
2169        attempt: u32,
2170        reason_code: WelcomeReasonCode,
2171        transport_error: Option<&'static str>,
2172        retryable: bool,
2173        next_retry_at: Option<i64>,
2174    ) -> Self {
2175        Self::WelcomeSendFailed {
2176            peer_id,
2177            message_id,
2178            group_id,
2179            attempt,
2180            reason_code,
2181            transport_error: transport_error.map(str::to_string),
2182            retryable,
2183            next_retry_at,
2184        }
2185    }
2186
2187    /// Creates a WelcomeSendExpired event.
2188    pub fn welcome_send_expired(
2189        peer_id: String,
2190        message_id: String,
2191        attempt: u32,
2192        reason_code: WelcomeReasonCode,
2193    ) -> Self {
2194        Self::WelcomeSendExpired {
2195            peer_id,
2196            message_id,
2197            attempt,
2198            reason_code,
2199        }
2200    }
2201
2202    /// Creates a ConnectionRequestReceived event.
2203    pub fn connection_request_received(
2204        sender: String,
2205        sender_name: String,
2206        timestamp: i64,
2207        key_package: Option<Vec<u8>>,
2208        initial_message: Option<String>,
2209    ) -> Self {
2210        Self::ConnectionRequestReceived {
2211            sender,
2212            sender_name,
2213            timestamp,
2214            key_package,
2215            initial_message,
2216        }
2217    }
2218
2219    /// Creates a ConnectionRequestUndeliverable event.
2220    pub fn connection_request_undeliverable(
2221        recipient: String,
2222        message_id: String,
2223        reason: &'static str,
2224    ) -> Self {
2225        Self::ConnectionRequestUndeliverable {
2226            recipient,
2227            message_id,
2228            reason: reason.to_string(),
2229        }
2230    }
2231
2232    /// Creates a ConnectionAccepted event.
2233    pub fn connection_accepted(
2234        accepted_by: String,
2235        accepted_by_name: String,
2236        timestamp: i64,
2237        key_package: Option<Vec<u8>>,
2238    ) -> Self {
2239        Self::ConnectionAccepted {
2240            accepted_by,
2241            accepted_by_name,
2242            timestamp,
2243            key_package,
2244        }
2245    }
2246
2247    /// Creates a ConnectionRejected event.
2248    pub fn connection_rejected(rejected_by: String) -> Self {
2249        Self::ConnectionRejected { rejected_by }
2250    }
2251
2252    /// Creates a ConnectionRequestCancelled event.
2253    pub fn connection_request_cancelled(cancelled_by: String) -> Self {
2254        Self::ConnectionRequestCancelled { cancelled_by }
2255    }
2256
2257    /// Creates a PresenceUpdated event for a peer-sent `__PRESENCE__`
2258    /// self-report (`source: peer`).
2259    pub fn presence_updated(peer_id: String, status: PresenceStatus, timestamp: i64) -> Self {
2260        Self::PresenceUpdated {
2261            peer_id,
2262            status,
2263            timestamp,
2264            last_seen_ms: None,
2265            source: PresenceSource::Peer,
2266        }
2267    }
2268
2269    /// Creates a PresenceUpdated event carrying a last-seen timestamp
2270    /// (relay-sourced presence, `source: internet`).
2271    pub fn presence_updated_with_last_seen(
2272        peer_id: String,
2273        status: PresenceStatus,
2274        timestamp: i64,
2275        last_seen_ms: Option<i64>,
2276    ) -> Self {
2277        Self::PresenceUpdated {
2278            peer_id,
2279            status,
2280            timestamp,
2281            last_seen_ms,
2282            source: PresenceSource::Internet,
2283        }
2284    }
2285
2286    /// Creates a TypingIndicatorReceived event.
2287    pub fn typing_indicator_received(
2288        sender: String,
2289        conversation_id: String,
2290        is_typing: bool,
2291        timestamp: i64,
2292    ) -> Self {
2293        Self::TypingIndicatorReceived {
2294            sender,
2295            conversation_id,
2296            is_typing,
2297            timestamp,
2298        }
2299    }
2300
2301    /// Creates a ReadReceiptReceived event.
2302    pub fn read_receipt_received(sender: String, message_ids: Vec<String>, timestamp: i64) -> Self {
2303        Self::ReadReceiptReceived {
2304            sender,
2305            message_ids,
2306            timestamp,
2307        }
2308    }
2309
2310    /// Creates a GroupCreated event.
2311    pub fn group_created(group_id: String, name: String) -> Self {
2312        Self::GroupCreated { group_id, name }
2313    }
2314
2315    /// Creates a GroupMessageReceived event.
2316    #[allow(clippy::too_many_arguments)]
2317    pub fn group_message_received(
2318        group_id: String,
2319        sender: String,
2320        content: String,
2321        timestamp: String,
2322        message_id: String,
2323        reply_to_msg: Option<String>,
2324        forward_info: Option<ForwardInfoEvent>,
2325        media_metadata: Option<MediaMetadata>,
2326        content_type: Option<String>,
2327    ) -> Self {
2328        Self::GroupMessageReceived {
2329            group_id,
2330            sender,
2331            content,
2332            timestamp,
2333            message_id,
2334            reply_to_msg,
2335            forward_info,
2336            media_metadata,
2337            content_type,
2338        }
2339    }
2340
2341    /// Creates a GroupMemberAdded event.
2342    pub fn group_member_added(
2343        group_id: String,
2344        user_id: String,
2345        added_by: String,
2346        group_name: Option<String>,
2347        authorized: Option<bool>,
2348    ) -> Self {
2349        Self::GroupMemberAdded {
2350            group_id,
2351            user_id,
2352            added_by,
2353            group_name,
2354            authorized,
2355        }
2356    }
2357
2358    /// Creates a GroupMemberRemoved event.
2359    pub fn group_member_removed(
2360        group_id: String,
2361        user_id: String,
2362        removed_by: String,
2363        authorized: Option<bool>,
2364    ) -> Self {
2365        Self::GroupMemberRemoved {
2366            group_id,
2367            user_id,
2368            removed_by,
2369            authorized,
2370        }
2371    }
2372
2373    /// Creates a GroupRoleChanged event.
2374    pub fn group_role_changed(
2375        group_id: String,
2376        user_id: String,
2377        new_role: String,
2378        changed_by: String,
2379    ) -> Self {
2380        Self::GroupRoleChanged {
2381            group_id,
2382            user_id,
2383            new_role,
2384            changed_by,
2385        }
2386    }
2387
2388    /// Creates a GroupRenamed event.
2389    pub fn group_renamed(
2390        group_id: String,
2391        new_name: String,
2392        old_name: Option<String>,
2393        renamed_by: String,
2394    ) -> Self {
2395        Self::GroupRenamed {
2396            group_id,
2397            new_name,
2398            old_name,
2399            renamed_by,
2400        }
2401    }
2402
2403    /// Creates a GroupInfo event.
2404    pub fn group_info(
2405        group_id: String,
2406        name: String,
2407        created_by: String,
2408        created_at: String,
2409        members: Vec<GroupInfoMember>,
2410    ) -> Self {
2411        Self::GroupInfo {
2412            group_id,
2413            name,
2414            created_by,
2415            created_at,
2416            members,
2417        }
2418    }
2419
2420    /// Creates a UserGroups event.
2421    pub fn user_groups(groups: Vec<UserGroupSummary>) -> Self {
2422        Self::UserGroups { groups }
2423    }
2424
2425    /// Creates a GroupError event.
2426    ///
2427    /// `reason` must be a locally-minted code, never relay text — callers on
2428    /// the wire path get theirs from `GroupErrorPayload::classify_reason`.
2429    pub fn group_error(reason: String, group_id: Option<String>) -> Self {
2430        Self::GroupError { reason, group_id }
2431    }
2432
2433    /// Creates a GroupRelaySyncChanged event.
2434    pub fn group_relay_sync_changed(group_id: String, synced: bool, reason: &str) -> Self {
2435        Self::GroupRelaySyncChanged {
2436            group_id,
2437            synced,
2438            reason: reason.to_string(),
2439        }
2440    }
2441
2442    /// Creates a GroupMessageSent event.
2443    pub fn group_message_sent(
2444        group_id: String,
2445        message_ids: Vec<String>,
2446        member_count: u32,
2447    ) -> Self {
2448        Self::GroupMessageSent {
2449            group_id,
2450            message_ids,
2451            member_count,
2452        }
2453    }
2454
2455    /// Creates a GroupMessagePartialFailure event.
2456    pub fn group_message_partial_failure(
2457        group_id: String,
2458        failed_members: Vec<String>,
2459        succeeded_members: Vec<String>,
2460    ) -> Self {
2461        Self::GroupMessagePartialFailure {
2462            group_id,
2463            failed_members,
2464            succeeded_members,
2465        }
2466    }
2467
2468    /// Creates a GroupMessageDeliveryReport event.
2469    pub fn group_message_delivery_report(
2470        group_id: String,
2471        message_id: String,
2472        delivered: Vec<String>,
2473        pushed: Vec<String>,
2474        missed_reissued: Vec<String>,
2475    ) -> Self {
2476        Self::GroupMessageDeliveryReport {
2477            group_id,
2478            message_id,
2479            delivered,
2480            pushed,
2481            missed_reissued,
2482        }
2483    }
2484
2485    /// Creates a GroupRichExtrasDropped event.
2486    pub fn group_rich_extras_dropped(group_id: String, unknown_members: Vec<String>) -> Self {
2487        Self::GroupRichExtrasDropped {
2488            group_id,
2489            unknown_members,
2490        }
2491    }
2492
2493    /// Creates a GroupUnauthorizedMembershipChange event.
2494    pub fn group_unauthorized_membership_change(
2495        group_id: String,
2496        committer: String,
2497        added: Vec<String>,
2498        removed: Vec<String>,
2499        reason: String,
2500        enforced: bool,
2501    ) -> Self {
2502        Self::GroupUnauthorizedMembershipChange {
2503            group_id,
2504            committer,
2505            added,
2506            removed,
2507            reason,
2508            enforced,
2509        }
2510    }
2511
2512    /// Creates a GroupEpochForkDetected event.
2513    pub fn group_epoch_fork_detected(group_id: String, local_epoch: Option<u64>) -> Self {
2514        Self::GroupEpochForkDetected {
2515            group_id,
2516            local_epoch,
2517        }
2518    }
2519
2520    /// Creates a GroupEpochForkResolved event.
2521    pub fn group_epoch_fork_resolved(
2522        group_id: String,
2523        resolved_epoch: u64,
2524        failed_members: Vec<String>,
2525    ) -> Self {
2526        Self::GroupEpochForkResolved {
2527            group_id,
2528            resolved_epoch,
2529            failed_members,
2530        }
2531    }
2532
2533    /// Creates a ServiceDiscovered event.
2534    pub fn service_discovered(
2535        query_id: String,
2536        service_id: String,
2537        version: String,
2538        provider_peer_id: String,
2539        capabilities: HashMap<String, String>,
2540        hop_count: u8,
2541    ) -> Self {
2542        Self::ServiceDiscovered {
2543            query_id,
2544            service_id,
2545            version,
2546            provider_peer_id,
2547            capabilities,
2548            hop_count,
2549        }
2550    }
2551
2552    /// Creates a ServiceRequestReceived event.
2553    pub fn service_request_received(
2554        request_id: String,
2555        service_id: String,
2556        method: String,
2557        body: String,
2558        sender: String,
2559    ) -> Self {
2560        Self::ServiceRequestReceived {
2561            request_id,
2562            service_id,
2563            method,
2564            body,
2565            sender,
2566        }
2567    }
2568
2569    /// Creates a ServiceResponseReceived event.
2570    pub fn service_response_received(
2571        request_id: String,
2572        service_id: String,
2573        status: String,
2574        body: String,
2575        provider_peer_id: String,
2576    ) -> Self {
2577        Self::ServiceResponseReceived {
2578            request_id,
2579            service_id,
2580            status,
2581            body,
2582            provider_peer_id,
2583        }
2584    }
2585
2586    /// Creates a DorsScoreUpdated event (DORS observability).
2587    pub fn dors_score_updated(scores: Vec<(String, f32)>) -> Self {
2588        Self::DorsScoreUpdated { scores }
2589    }
2590
2591    /// Creates a DorsTransportSelected event (DORS observability).
2592    pub fn dors_transport_selected(
2593        from: Option<String>,
2594        transport: String,
2595        reason_code: DorsReasonCode,
2596        score: f32,
2597    ) -> Self {
2598        Self::DorsTransportSelected {
2599            from,
2600            transport,
2601            reason_code,
2602            score,
2603        }
2604    }
2605
2606    /// Creates a DorsTransportSwitched event (DORS observability).
2607    /// Emitted only when active transport actually changes after a successful send.
2608    pub fn dors_transport_switched(
2609        from: Option<String>,
2610        to: String,
2611        reason_code: DorsReasonCode,
2612        reason_detail: Option<String>,
2613    ) -> Self {
2614        Self::DorsTransportSwitched {
2615            from,
2616            to,
2617            reason_code,
2618            reason_detail,
2619        }
2620    }
2621
2622    /// Creates a DorsEscalationTriggered event (DORS observability).
2623    /// Use phase Triggered at the DORS trigger boundary, Applied when fallback succeeds.
2624    pub fn dors_escalation_triggered(
2625        phase: DorsEscalationPhase,
2626        from: String,
2627        to: String,
2628        reason_code: DorsEscalationReasonCode,
2629        reason_detail: Option<String>,
2630    ) -> Self {
2631        Self::DorsEscalationTriggered {
2632            phase,
2633            from,
2634            to,
2635            reason_code,
2636            reason_detail,
2637        }
2638    }
2639
2640    /// Creates a SecurityWarning event.
2641    pub fn security_warning(
2642        peer_id: String,
2643        reason_code: SecurityWarningCode,
2644        reason: String,
2645    ) -> Self {
2646        Self::SecurityWarning {
2647            peer_id,
2648            reason_code,
2649            reason,
2650        }
2651    }
2652
2653    /// Creates a MessageRelayed event.
2654    pub fn message_relayed(
2655        message_id: String,
2656        sender: String,
2657        recipient: String,
2658        hop_count: u8,
2659        remaining_ttl: u8,
2660    ) -> Self {
2661        Self::MessageRelayed {
2662            message_id,
2663            sender,
2664            recipient,
2665            hop_count,
2666            remaining_ttl,
2667        }
2668    }
2669
2670    /// Creates a UserBlocked event.
2671    pub fn user_blocked(user_id: String) -> Self {
2672        Self::UserBlocked { user_id }
2673    }
2674
2675    /// Creates a UserUnblocked event.
2676    pub fn user_unblocked(user_id: String) -> Self {
2677        Self::UserUnblocked { user_id }
2678    }
2679
2680    /// Converts the event to JSON.
2681    pub fn to_json(&self) -> Result<String, serde_json::Error> {
2682        serde_json::to_string(self)
2683    }
2684
2685    /// Parses an event from JSON.
2686    pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
2687        serde_json::from_str(json)
2688    }
2689
2690    /// Returns the stable `snake.dot.case` telemetry name for this event.
2691    ///
2692    /// Names are compatible with OpenTelemetry naming conventions and are
2693    /// considered stable across minor versions of the SDK.
2694    pub fn telemetry_name(&self) -> &'static str {
2695        match self {
2696            Self::MessageSent { .. } => "protocol.message.sent",
2697            Self::MessageReceived { .. } => "protocol.message.received",
2698            Self::MessageDelivered { .. } => "protocol.message.delivered",
2699            Self::MessageFailed { .. } => "protocol.message.failed",
2700            Self::MessageDecryptionFailed { .. } => "protocol.message.decryption_failed",
2701            Self::TransportSwitched { .. } => "protocol.transport.switched",
2702            Self::RelayPromoted { .. } => "protocol.relay.promoted",
2703            Self::RelayDemoted { .. } => "protocol.relay.demoted",
2704            Self::NeighborDiscovered { .. } => "protocol.neighbor.discovered",
2705            Self::NeighborLost { .. } => "protocol.neighbor.lost",
2706            Self::IdentityReady { .. } => "protocol.identity.ready",
2707            Self::NetworkMetrics { .. } => "protocol.network.metrics",
2708            Self::FileProgress { .. } => "protocol.file.progress",
2709            Self::FileReceived { .. } => "protocol.file.received",
2710            Self::FileReceiveFailed { .. } => "protocol.file.receive_failed",
2711            Self::MediaSent { .. } => "protocol.media.sent",
2712            Self::MediaSendFailed { .. } => "protocol.media.send_failed",
2713            Self::MessageDeferred { .. } => "protocol.message.deferred",
2714            Self::MessageRetrying { .. } => "protocol.message.retrying",
2715            Self::MessageUndeliverable { .. } => "protocol.message.undeliverable",
2716            Self::MediaResendRequired { .. } => "protocol.media.resend_required",
2717            Self::AckEvicted { .. } => "protocol.ack.evicted",
2718            Self::FragmentAssemblyEvicted { .. } => "protocol.fragment.assembly_evicted",
2719            Self::RelayDemotedBattery { .. } => "protocol.relay.demoted_battery",
2720            Self::SecureSessionEstablished { .. } => "protocol.secure_session.established",
2721            Self::SecureSessionFailed { .. } => "protocol.secure_session.failed",
2722            Self::ConvergenceDiag { .. } => "protocol.convergence.diag",
2723            Self::WelcomeSendAttempted { .. } => "protocol.welcome.send_attempted",
2724            Self::WelcomeSendSucceeded { .. } => "protocol.welcome.send_succeeded",
2725            Self::WelcomeSendFailed { .. } => "protocol.welcome.send_failed",
2726            Self::WelcomeSendExpired { .. } => "protocol.welcome.send_expired",
2727            Self::ConnectionRequestReceived { .. } => "protocol.connection.request_received",
2728            Self::ConnectionRequestUndeliverable { .. } => {
2729                "protocol.connection.request_undeliverable"
2730            }
2731            Self::ConnectionAccepted { .. } => "protocol.connection.accepted",
2732            Self::ConnectionRejected { .. } => "protocol.connection.rejected",
2733            Self::ConnectionRequestCancelled { .. } => "protocol.connection.request_cancelled",
2734            Self::GroupCreated { .. } => "protocol.group.created",
2735            Self::GroupMessageReceived { .. } => "protocol.group.message_received",
2736            Self::GroupMemberAdded { .. } => "protocol.group.member_added",
2737            Self::GroupMemberRemoved { .. } => "protocol.group.member_removed",
2738            Self::GroupInfo { .. } => "protocol.group.info",
2739            Self::UserGroups { .. } => "protocol.group.user_groups",
2740            Self::GroupError { .. } => "protocol.group.error",
2741            Self::GroupRelaySyncChanged { .. } => "protocol.group.relay_sync_changed",
2742            Self::UsernameResolved { .. } => "protocol.username.resolved",
2743            Self::GroupMessageSent { .. } => "protocol.group.message_sent",
2744            Self::GroupMessagePartialFailure { .. } => "protocol.group.message_partial_failure",
2745            Self::GroupMessageDeliveryReport { .. } => "protocol.group.delivery_report",
2746            Self::GroupRichExtrasDropped { .. } => "protocol.group.rich_extras_dropped",
2747            Self::GroupUnauthorizedMembershipChange { .. } => {
2748                "protocol.group.unauthorized_membership_change"
2749            }
2750            Self::GroupEpochForkDetected { .. } => "protocol.group.epoch_fork_detected",
2751            Self::GroupEpochForkResolved { .. } => "protocol.group.epoch_fork_resolved",
2752            Self::GroupRoleChanged { .. } => "protocol.group.role_changed",
2753            Self::GroupRenamed { .. } => "protocol.group.renamed",
2754            Self::ServiceDiscovered { .. } => "protocol.service.discovered",
2755            Self::ServiceRequestReceived { .. } => "protocol.service.request_received",
2756            Self::ServiceResponseReceived { .. } => "protocol.service.response_received",
2757            Self::PresenceUpdated { .. } => "protocol.presence.updated",
2758            Self::TypingIndicatorReceived { .. } => "protocol.typing.received",
2759            Self::ReadReceiptReceived { .. } => "protocol.read_receipt.received",
2760            Self::DorsScoreUpdated { .. } => "protocol.dors.score_updated",
2761            Self::DorsTransportSelected { .. } => "protocol.dors.transport_selected",
2762            Self::DorsTransportSwitched { .. } => "protocol.dors.transport_switched",
2763            Self::DorsEscalationTriggered { .. } => "protocol.dors.escalation_triggered",
2764            Self::SecurityWarning { .. } => "protocol.security.warning",
2765            Self::MessageRelayed { .. } => "protocol.message.relayed",
2766            Self::UserBlocked { .. } => "protocol.user.blocked",
2767            Self::UserUnblocked { .. } => "protocol.user.unblocked",
2768            Self::DataChanged { .. } => "protocol.data.changed",
2769            Self::DataDocSizeWarning { .. } => "protocol.data.doc_size_warning",
2770            Self::DataAttachmentRequested { .. } => "protocol.data.attachment_requested",
2771            Self::DataAttachmentReceived { .. } => "protocol.data.attachment_received",
2772            Self::DataAttachmentUnavailable { .. } => "protocol.data.attachment_unavailable",
2773            Self::DataDocUnsyncable { .. } => "protocol.data.doc_unsyncable",
2774        }
2775    }
2776}
2777
2778impl From<ServiceEvent> for Event {
2779    fn from(event: ServiceEvent) -> Self {
2780        match event {
2781            ServiceEvent::ServiceDiscovered {
2782                query_id,
2783                service_id,
2784                version,
2785                provider_peer_id,
2786                capabilities,
2787                hop_count,
2788            } => Self::ServiceDiscovered {
2789                query_id,
2790                service_id,
2791                version,
2792                provider_peer_id,
2793                capabilities,
2794                hop_count,
2795            },
2796            ServiceEvent::ServiceRequestReceived {
2797                request_id,
2798                service_id,
2799                method,
2800                body,
2801                sender,
2802            } => Self::ServiceRequestReceived {
2803                request_id,
2804                service_id,
2805                method,
2806                body,
2807                sender,
2808            },
2809            ServiceEvent::ServiceResponseReceived {
2810                request_id,
2811                service_id,
2812                status,
2813                body,
2814                provider_peer_id,
2815            } => Self::ServiceResponseReceived {
2816                request_id,
2817                service_id,
2818                status,
2819                body,
2820                provider_peer_id,
2821            },
2822        }
2823    }
2824}
2825
2826/// Custom Debug implementation that redacts sensitive fields to prevent
2827/// accidental logging of message content and other potentially sensitive data.
2828impl fmt::Debug for Event {
2829    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2830        match self {
2831            Self::MessageSent {
2832                message_id,
2833                sender: _,
2834                recipient: _,
2835                content,
2836                priority,
2837                requires_ack,
2838                timestamp,
2839                lamport_clock,
2840                forward_info,
2841            } => f
2842                .debug_struct("MessageSent")
2843                .field("message_id", message_id)
2844                .field("sender", &"[REDACTED]")
2845                .field("recipient", &"[REDACTED]")
2846                .field("content", &format!("[REDACTED {} bytes]", content.len()))
2847                .field("priority", priority)
2848                .field("requires_ack", requires_ack)
2849                .field("timestamp", timestamp)
2850                .field("lamport_clock", lamport_clock)
2851                .field("forward_info", &forward_info.is_some())
2852                .finish(),
2853            Self::MessageReceived {
2854                message_id,
2855                sender: _,
2856                recipient: _,
2857                content,
2858                hop_count,
2859                transport,
2860                timestamp,
2861                lamport_clock,
2862                reply_to_msg: _,
2863                reply_context,
2864                content_type,
2865                media_metadata: _,
2866                forward_info,
2867                encrypted,
2868            } => f
2869                .debug_struct("MessageReceived")
2870                .field("message_id", message_id)
2871                .field("sender", &"[REDACTED]")
2872                .field("recipient", &"[REDACTED]")
2873                .field("content", &format!("[REDACTED {} bytes]", content.len()))
2874                .field("hop_count", hop_count)
2875                .field("transport", transport)
2876                .field("timestamp", timestamp)
2877                .field("lamport_clock", lamport_clock)
2878                .field("reply_to_msg", &"[REDACTED]")
2879                .field("reply_context", &reply_context.is_some())
2880                .field("content_type", content_type)
2881                .field("forward_info", &forward_info.is_some())
2882                .field("encrypted", encrypted)
2883                .finish(),
2884            Self::MessageDelivered {
2885                message_id,
2886                latency_ms,
2887                hop_count,
2888                transport,
2889            } => f
2890                .debug_struct("MessageDelivered")
2891                .field("message_id", message_id)
2892                .field("latency_ms", latency_ms)
2893                .field("hop_count", hop_count)
2894                .field("transport", transport)
2895                .finish(),
2896            Self::MessageFailed {
2897                message_id,
2898                reason,
2899                retry_count,
2900            } => f
2901                .debug_struct("MessageFailed")
2902                .field("message_id", message_id)
2903                .field("reason", reason)
2904                .field("retry_count", retry_count)
2905                .finish(),
2906            Self::MessageDecryptionFailed {
2907                message_id,
2908                sender: _,
2909                code,
2910                reason,
2911            } => f
2912                .debug_struct("MessageDecryptionFailed")
2913                .field("message_id", message_id)
2914                .field("sender", &"[REDACTED]")
2915                .field("code", code)
2916                .field("reason", reason)
2917                .finish(),
2918            Self::TransportSwitched { from, to, reason } => f
2919                .debug_struct("TransportSwitched")
2920                .field("from", from)
2921                .field("to", to)
2922                .field("reason", reason)
2923                .finish(),
2924            Self::RelayPromoted {
2925                connection_count,
2926                battery_level,
2927            } => f
2928                .debug_struct("RelayPromoted")
2929                .field("connection_count", connection_count)
2930                .field("battery_level", battery_level)
2931                .finish(),
2932            Self::RelayDemoted { reason } => f
2933                .debug_struct("RelayDemoted")
2934                .field("reason", reason)
2935                .finish(),
2936            Self::NeighborDiscovered {
2937                peer_id: _,
2938                transport,
2939                rssi,
2940            } => f
2941                .debug_struct("NeighborDiscovered")
2942                .field("peer_id", &"[REDACTED]")
2943                .field("transport", transport)
2944                .field("rssi", rssi)
2945                .finish(),
2946            Self::NeighborLost { peer_id: _ } => f
2947                .debug_struct("NeighborLost")
2948                .field("peer_id", &"[REDACTED]")
2949                .finish(),
2950            // Redacted like any other identifier: this one names *this* device,
2951            // which is the strongest correlator a log can carry.
2952            Self::IdentityReady { address: _ } => f
2953                .debug_struct("IdentityReady")
2954                .field("address", &"[REDACTED]")
2955                .finish(),
2956            Self::NetworkMetrics {
2957                neighbor_count,
2958                relay_count,
2959                delivery_ratio,
2960                avg_latency_ms,
2961            } => f
2962                .debug_struct("NetworkMetrics")
2963                .field("neighbor_count", neighbor_count)
2964                .field("relay_count", relay_count)
2965                .field("delivery_ratio", delivery_ratio)
2966                .field("avg_latency_ms", avg_latency_ms)
2967                .finish(),
2968            Self::FileProgress {
2969                file_id,
2970                chunks_sent,
2971                total_chunks,
2972                percentage,
2973            } => f
2974                .debug_struct("FileProgress")
2975                .field("file_id", file_id)
2976                .field("chunks_sent", chunks_sent)
2977                .field("total_chunks", total_chunks)
2978                .field("percentage", percentage)
2979                .finish(),
2980            Self::FileReceived {
2981                file_id,
2982                file_name,
2983                file_size,
2984                sender: _,
2985                content_type,
2986                media_metadata: _,
2987                file_data,
2988                timestamp,
2989                caption,
2990                reply_to_msg: _,
2991                reply_context,
2992                forward_info,
2993            } => f
2994                .debug_struct("FileReceived")
2995                .field("file_id", file_id)
2996                .field("file_name", file_name)
2997                .field("file_size", file_size)
2998                .field("sender", &"[REDACTED]")
2999                .field("content_type", content_type)
3000                .field("file_data", &format!("[{} bytes base64]", file_data.len()))
3001                .field("timestamp", timestamp)
3002                .field("caption", &caption.is_some())
3003                .field("reply_to_msg", &"[REDACTED]")
3004                .field("reply_context", &reply_context.is_some())
3005                .field("forward_info", &forward_info.is_some())
3006                .finish(),
3007            Self::FileReceiveFailed {
3008                file_id,
3009                file_name,
3010                sender: _,
3011                reason,
3012            } => f
3013                .debug_struct("FileReceiveFailed")
3014                .field("file_id", file_id)
3015                .field("file_name", file_name)
3016                .field("sender", &"[REDACTED]")
3017                .field("reason", reason)
3018                .finish(),
3019            Self::MediaSent {
3020                file_id,
3021                content_type,
3022                recipient: _,
3023            } => f
3024                .debug_struct("MediaSent")
3025                .field("file_id", file_id)
3026                .field("content_type", content_type)
3027                .field("recipient", &"[REDACTED]")
3028                .finish(),
3029            Self::MediaSendFailed {
3030                file_id,
3031                recipient: _,
3032                reason,
3033            } => f
3034                .debug_struct("MediaSendFailed")
3035                .field("file_id", file_id)
3036                .field("recipient", &"[REDACTED]")
3037                .field("reason", reason)
3038                .finish(),
3039            Self::MessageDeferred {
3040                message_id,
3041                recipient: _,
3042                reason,
3043                retry_count,
3044                next_retry_at,
3045            } => f
3046                .debug_struct("MessageDeferred")
3047                .field("message_id", message_id)
3048                .field("recipient", &"[REDACTED]")
3049                .field("reason", reason)
3050                .field("retry_count", retry_count)
3051                .field("next_retry_at", next_retry_at)
3052                .finish(),
3053            Self::MessageRetrying {
3054                message_id,
3055                recipient: _,
3056                retry_count,
3057                next_retry_at,
3058            } => f
3059                .debug_struct("MessageRetrying")
3060                .field("message_id", message_id)
3061                .field("recipient", &"[REDACTED]")
3062                .field("retry_count", retry_count)
3063                .field("next_retry_at", next_retry_at)
3064                .finish(),
3065            Self::MessageUndeliverable {
3066                message_id,
3067                recipient: _,
3068                reason,
3069                file_id,
3070            } => f
3071                .debug_struct("MessageUndeliverable")
3072                .field("message_id", message_id)
3073                .field("recipient", &"[REDACTED]")
3074                .field("reason", reason)
3075                .field("file_id", file_id)
3076                .finish(),
3077            Self::MediaResendRequired {
3078                file_id,
3079                recipient: _,
3080                file_name,
3081                file_size,
3082            } => f
3083                .debug_struct("MediaResendRequired")
3084                .field("file_id", file_id)
3085                .field("recipient", &"[REDACTED]")
3086                .field("file_name", file_name)
3087                .field("file_size", file_size)
3088                .finish(),
3089            Self::AckEvicted {
3090                message_id,
3091                priority,
3092                reason,
3093            } => f
3094                .debug_struct("AckEvicted")
3095                .field("message_id", message_id)
3096                .field("priority", priority)
3097                .field("reason", reason)
3098                .finish(),
3099            Self::FragmentAssemblyEvicted {
3100                message_id,
3101                completion_percent,
3102                reason,
3103            } => f
3104                .debug_struct("FragmentAssemblyEvicted")
3105                .field("message_id", message_id)
3106                .field("completion_percent", completion_percent)
3107                .field("reason", reason)
3108                .finish(),
3109            Self::RelayDemotedBattery {
3110                battery_level,
3111                min_required,
3112            } => f
3113                .debug_struct("RelayDemotedBattery")
3114                .field("battery_level", battery_level)
3115                .field("min_required", min_required)
3116                .finish(),
3117            Self::SecureSessionEstablished {
3118                peer_id: _,
3119                group_id,
3120                is_session,
3121                initiated_by_local,
3122            } => f
3123                .debug_struct("SecureSessionEstablished")
3124                .field("peer_id", &"[REDACTED]")
3125                .field("group_id", group_id)
3126                .field("is_session", is_session)
3127                .field("initiated_by_local", initiated_by_local)
3128                .finish(),
3129            Self::SecureSessionFailed { peer_id: _, reason } => f
3130                .debug_struct("SecureSessionFailed")
3131                .field("peer_id", &"[REDACTED]")
3132                .field("reason", reason)
3133                .finish(),
3134            Self::ConvergenceDiag {
3135                stage,
3136                peer_id: _,
3137                detail,
3138            } => f
3139                .debug_struct("ConvergenceDiag")
3140                .field("stage", stage)
3141                .field("peer_id", &"[REDACTED]")
3142                .field("detail", detail)
3143                .finish(),
3144            Self::WelcomeSendAttempted {
3145                peer_id: _,
3146                message_id,
3147                group_id,
3148                attempt,
3149            } => f
3150                .debug_struct("WelcomeSendAttempted")
3151                .field("peer_id", &"[REDACTED]")
3152                .field("message_id", message_id)
3153                .field("group_id", group_id)
3154                .field("attempt", attempt)
3155                .finish(),
3156            Self::WelcomeSendSucceeded {
3157                peer_id: _,
3158                message_id,
3159                group_id,
3160                attempt,
3161            } => f
3162                .debug_struct("WelcomeSendSucceeded")
3163                .field("peer_id", &"[REDACTED]")
3164                .field("message_id", message_id)
3165                .field("group_id", group_id)
3166                .field("attempt", attempt)
3167                .finish(),
3168            Self::WelcomeSendFailed {
3169                peer_id: _,
3170                message_id,
3171                group_id,
3172                attempt,
3173                reason_code,
3174                transport_error,
3175                retryable,
3176                next_retry_at,
3177            } => f
3178                .debug_struct("WelcomeSendFailed")
3179                .field("peer_id", &"[REDACTED]")
3180                .field("message_id", message_id)
3181                .field("group_id", group_id)
3182                .field("attempt", attempt)
3183                .field("reason_code", reason_code)
3184                .field("transport_error", transport_error)
3185                .field("retryable", retryable)
3186                .field("next_retry_at", next_retry_at)
3187                .finish(),
3188            Self::WelcomeSendExpired {
3189                peer_id: _,
3190                message_id,
3191                attempt,
3192                reason_code,
3193            } => f
3194                .debug_struct("WelcomeSendExpired")
3195                .field("peer_id", &"[REDACTED]")
3196                .field("message_id", message_id)
3197                .field("attempt", attempt)
3198                .field("reason_code", reason_code)
3199                .finish(),
3200            Self::ConnectionRequestReceived {
3201                sender: _,
3202                sender_name: _,
3203                timestamp,
3204                key_package,
3205                initial_message,
3206            } => f
3207                .debug_struct("ConnectionRequestReceived")
3208                .field("sender", &"[REDACTED]")
3209                .field("sender_name", &"[REDACTED]")
3210                .field("timestamp", timestamp)
3211                .field("has_key_package", &key_package.is_some())
3212                .field("has_initial_message", &initial_message.is_some())
3213                .finish(),
3214            Self::ConnectionRequestUndeliverable {
3215                recipient: _,
3216                message_id,
3217                reason,
3218            } => f
3219                .debug_struct("ConnectionRequestUndeliverable")
3220                .field("recipient", &"[REDACTED]")
3221                .field("message_id", message_id)
3222                .field("reason", reason)
3223                .finish(),
3224            Self::ConnectionAccepted {
3225                accepted_by: _,
3226                accepted_by_name: _,
3227                timestamp,
3228                key_package,
3229            } => f
3230                .debug_struct("ConnectionAccepted")
3231                .field("accepted_by", &"[REDACTED]")
3232                .field("accepted_by_name", &"[REDACTED]")
3233                .field("timestamp", timestamp)
3234                .field("has_key_package", &key_package.is_some())
3235                .finish(),
3236            Self::ConnectionRejected { rejected_by: _ } => f
3237                .debug_struct("ConnectionRejected")
3238                .field("rejected_by", &"[REDACTED]")
3239                .finish(),
3240            Self::ConnectionRequestCancelled { cancelled_by: _ } => f
3241                .debug_struct("ConnectionRequestCancelled")
3242                .field("cancelled_by", &"[REDACTED]")
3243                .finish(),
3244            Self::ServiceDiscovered {
3245                query_id,
3246                service_id,
3247                version,
3248                provider_peer_id: _,
3249                capabilities,
3250                hop_count,
3251            } => f
3252                .debug_struct("ServiceDiscovered")
3253                .field("query_id", query_id)
3254                .field("service_id", service_id)
3255                .field("version", version)
3256                .field("provider_peer_id", &"[REDACTED]")
3257                .field("capabilities_count", &capabilities.len())
3258                .field("hop_count", hop_count)
3259                .finish(),
3260            Self::ServiceRequestReceived {
3261                request_id,
3262                service_id,
3263                method,
3264                body,
3265                sender: _,
3266            } => f
3267                .debug_struct("ServiceRequestReceived")
3268                .field("request_id", request_id)
3269                .field("service_id", service_id)
3270                .field("method", method)
3271                .field("body", &format!("[REDACTED {} bytes]", body.len()))
3272                .field("sender", &"[REDACTED]")
3273                .finish(),
3274            Self::ServiceResponseReceived {
3275                request_id,
3276                service_id,
3277                status,
3278                body,
3279                provider_peer_id: _,
3280            } => f
3281                .debug_struct("ServiceResponseReceived")
3282                .field("request_id", request_id)
3283                .field("service_id", service_id)
3284                .field("status", status)
3285                .field("body", &format!("[REDACTED {} bytes]", body.len()))
3286                .field("provider_peer_id", &"[REDACTED]")
3287                .finish(),
3288            Self::GroupCreated { group_id, name } => f
3289                .debug_struct("GroupCreated")
3290                .field("group_id", group_id)
3291                .field("name", name)
3292                .finish(),
3293            Self::GroupMessageReceived {
3294                group_id,
3295                sender: _,
3296                content,
3297                forward_info,
3298                ..
3299            } => f
3300                .debug_struct("GroupMessageReceived")
3301                .field("group_id", group_id)
3302                .field("sender", &"[REDACTED]")
3303                .field("content", &format!("[REDACTED {} bytes]", content.len()))
3304                .field("forward_info", &forward_info.is_some())
3305                .finish(),
3306            Self::GroupMemberAdded {
3307                group_id,
3308                user_id: _,
3309                added_by: _,
3310                group_name,
3311                authorized,
3312            } => f
3313                .debug_struct("GroupMemberAdded")
3314                .field("group_id", group_id)
3315                .field("user_id", &"[REDACTED]")
3316                .field("added_by", &"[REDACTED]")
3317                .field("group_name", group_name)
3318                .field("authorized", authorized)
3319                .finish(),
3320            Self::GroupMemberRemoved {
3321                group_id,
3322                user_id: _,
3323                removed_by: _,
3324                authorized,
3325            } => f
3326                .debug_struct("GroupMemberRemoved")
3327                .field("group_id", group_id)
3328                .field("user_id", &"[REDACTED]")
3329                .field("removed_by", &"[REDACTED]")
3330                .field("authorized", authorized)
3331                .finish(),
3332            Self::GroupInfo {
3333                group_id,
3334                name,
3335                created_by: _,
3336                created_at,
3337                members,
3338            } => f
3339                .debug_struct("GroupInfo")
3340                .field("group_id", group_id)
3341                .field("name", name)
3342                .field("created_by", &"[REDACTED]")
3343                .field("created_at", created_at)
3344                .field("members_count", &members.len())
3345                .finish(),
3346            Self::UserGroups { groups } => f
3347                .debug_struct("UserGroups")
3348                .field("groups_count", &groups.len())
3349                .finish(),
3350            Self::GroupError { reason, group_id } => f
3351                .debug_struct("GroupError")
3352                .field("reason", reason)
3353                .field("group_id", group_id)
3354                .finish(),
3355            Self::GroupRelaySyncChanged {
3356                group_id,
3357                synced,
3358                reason,
3359            } => f
3360                .debug_struct("GroupRelaySyncChanged")
3361                .field("group_id", group_id)
3362                .field("synced", synced)
3363                .field("reason", reason)
3364                .finish(),
3365            // The username is redacted: it is a human-readable name for a
3366            // person, which is exactly the class of identifier this Debug
3367            // exists to keep out of logs. The counts say everything a reader
3368            // debugging a resolution needs.
3369            Self::UsernameResolved {
3370                username: _,
3371                claims,
3372                rejected,
3373                truncated,
3374            } => f
3375                .debug_struct("UsernameResolved")
3376                .field("username", &"<redacted>")
3377                .field("claim_count", &claims.len())
3378                .field("rejected", rejected)
3379                .field("truncated", truncated)
3380                .finish(),
3381            Self::GroupMessageSent {
3382                group_id,
3383                message_ids,
3384                member_count,
3385            } => f
3386                .debug_struct("GroupMessageSent")
3387                .field("group_id", group_id)
3388                .field("message_count", &message_ids.len())
3389                .field("member_count", member_count)
3390                .finish(),
3391            Self::GroupMessagePartialFailure {
3392                group_id,
3393                failed_members,
3394                succeeded_members,
3395            } => f
3396                .debug_struct("GroupMessagePartialFailure")
3397                .field("group_id", group_id)
3398                .field("failed_count", &failed_members.len())
3399                .field("succeeded_count", &succeeded_members.len())
3400                .finish(),
3401            Self::GroupMessageDeliveryReport {
3402                group_id,
3403                message_id,
3404                delivered,
3405                pushed,
3406                missed_reissued,
3407            } => f
3408                .debug_struct("GroupMessageDeliveryReport")
3409                .field("group_id", group_id)
3410                .field("message_id", message_id)
3411                .field("delivered_count", &delivered.len())
3412                .field("pushed_count", &pushed.len())
3413                .field("missed_reissued_count", &missed_reissued.len())
3414                .finish(),
3415            Self::GroupRichExtrasDropped {
3416                group_id,
3417                unknown_members,
3418            } => f
3419                .debug_struct("GroupRichExtrasDropped")
3420                .field("group_id", group_id)
3421                .field("unknown_count", &unknown_members.len())
3422                .finish(),
3423            Self::GroupUnauthorizedMembershipChange {
3424                group_id,
3425                committer: _,
3426                added,
3427                removed,
3428                reason,
3429                enforced,
3430            } => f
3431                .debug_struct("GroupUnauthorizedMembershipChange")
3432                .field("group_id", group_id)
3433                .field("committer", &"[REDACTED]")
3434                .field("added_count", &added.len())
3435                .field("removed_count", &removed.len())
3436                .field("reason", reason)
3437                .field("enforced", enforced)
3438                .finish(),
3439            Self::GroupEpochForkDetected {
3440                group_id,
3441                local_epoch,
3442            } => f
3443                .debug_struct("GroupEpochForkDetected")
3444                .field("group_id", group_id)
3445                .field("local_epoch", local_epoch)
3446                .finish(),
3447            Self::GroupEpochForkResolved {
3448                group_id,
3449                resolved_epoch,
3450                failed_members,
3451            } => f
3452                .debug_struct("GroupEpochForkResolved")
3453                .field("group_id", group_id)
3454                .field("resolved_epoch", resolved_epoch)
3455                .field("failed_members", failed_members)
3456                .finish(),
3457            Self::PresenceUpdated {
3458                peer_id: _,
3459                status,
3460                timestamp,
3461                last_seen_ms,
3462                source,
3463            } => f
3464                .debug_struct("PresenceUpdated")
3465                .field("peer_id", &"[REDACTED]")
3466                .field("status", status)
3467                .field("timestamp", timestamp)
3468                .field("last_seen_ms", last_seen_ms)
3469                .field("source", source)
3470                .finish(),
3471            Self::TypingIndicatorReceived {
3472                sender: _,
3473                conversation_id: _,
3474                is_typing,
3475                timestamp,
3476            } => f
3477                .debug_struct("TypingIndicatorReceived")
3478                .field("sender", &"[REDACTED]")
3479                .field("conversation_id", &"[REDACTED]")
3480                .field("is_typing", is_typing)
3481                .field("timestamp", timestamp)
3482                .finish(),
3483            Self::ReadReceiptReceived {
3484                sender: _,
3485                message_ids,
3486                timestamp,
3487            } => f
3488                .debug_struct("ReadReceiptReceived")
3489                .field("sender", &"[REDACTED]")
3490                .field("message_count", &message_ids.len())
3491                .field("timestamp", timestamp)
3492                .finish(),
3493            Self::DorsScoreUpdated { scores } => f
3494                .debug_struct("DorsScoreUpdated")
3495                .field("scores", scores)
3496                .finish(),
3497            Self::DorsTransportSelected {
3498                from,
3499                transport,
3500                reason_code,
3501                score,
3502            } => f
3503                .debug_struct("DorsTransportSelected")
3504                .field("from", from)
3505                .field("transport", transport)
3506                .field("reason_code", reason_code)
3507                .field("score", score)
3508                .finish(),
3509            Self::DorsTransportSwitched {
3510                from,
3511                to,
3512                reason_code,
3513                reason_detail,
3514            } => f
3515                .debug_struct("DorsTransportSwitched")
3516                .field("from", from)
3517                .field("to", to)
3518                .field("reason_code", reason_code)
3519                .field("reason_detail", reason_detail)
3520                .finish(),
3521            Self::DorsEscalationTriggered {
3522                phase,
3523                from,
3524                to,
3525                reason_code,
3526                reason_detail,
3527            } => f
3528                .debug_struct("DorsEscalationTriggered")
3529                .field("phase", phase)
3530                .field("from", from)
3531                .field("to", to)
3532                .field("reason_code", reason_code)
3533                .field("reason_detail", reason_detail)
3534                .finish(),
3535            // The one arm that used to print `peer_id` verbatim, and the worst
3536            // one to: a security warning is precisely the event an operator
3537            // dumps into a log while investigating, and the peer it names is
3538            // usually attacker-controlled — an injected frame carries whatever
3539            // sender it likes, so this field doubles as a log-injection
3540            // surface. The telemetry scrubber already hashed it; only `{:?}`
3541            // disagreed.
3542            Self::SecurityWarning {
3543                peer_id: _,
3544                reason_code,
3545                reason,
3546            } => f
3547                .debug_struct("SecurityWarning")
3548                .field("peer_id", &"[REDACTED]")
3549                .field("reason_code", reason_code)
3550                .field("reason", reason)
3551                .finish(),
3552            Self::MessageRelayed {
3553                message_id,
3554                sender: _,
3555                recipient: _,
3556                hop_count,
3557                remaining_ttl,
3558            } => f
3559                .debug_struct("MessageRelayed")
3560                .field("message_id", message_id)
3561                .field("sender", &"[REDACTED]")
3562                .field("recipient", &"[REDACTED]")
3563                .field("hop_count", hop_count)
3564                .field("remaining_ttl", remaining_ttl)
3565                .finish(),
3566            Self::UserBlocked { user_id: _ } => f
3567                .debug_struct("UserBlocked")
3568                .field("user_id", &"[REDACTED]")
3569                .finish(),
3570            Self::UserUnblocked { user_id: _ } => f
3571                .debug_struct("UserUnblocked")
3572                .field("user_id", &"[REDACTED]")
3573                .finish(),
3574            Self::DataChanged {
3575                space_id: _,
3576                doc_id: _,
3577                delta_bytes,
3578            } => f
3579                .debug_struct("DataChanged")
3580                // A space id is the peer or group the space rides on, and a
3581                // document name is application content. Both are identifiers,
3582                // so both redact.
3583                .field("space_id", &"[REDACTED]")
3584                .field("doc_id", &"[REDACTED]")
3585                .field("delta_bytes", delta_bytes)
3586                .finish(),
3587            Self::DataDocSizeWarning {
3588                space_id: _,
3589                doc_id: _,
3590                compacted_bytes,
3591                cap_bytes,
3592            } => f
3593                .debug_struct("DataDocSizeWarning")
3594                .field("space_id", &"[REDACTED]")
3595                .field("doc_id", &"[REDACTED]")
3596                .field("compacted_bytes", compacted_bytes)
3597                .field("cap_bytes", cap_bytes)
3598                .finish(),
3599            Self::DataAttachmentRequested {
3600                space_id: _,
3601                peer_id: _,
3602                hash,
3603            } => f
3604                .debug_struct("DataAttachmentRequested")
3605                .field("space_id", &"[REDACTED]")
3606                .field("peer_id", &"[REDACTED]")
3607                // A blob hash names bytes, not a person, and it is the one
3608                // field that makes a report about a fetch actionable.
3609                .field("hash", hash)
3610                .finish(),
3611            Self::DataAttachmentReceived {
3612                space_id: _,
3613                peer_id: _,
3614                hash,
3615                data,
3616            } => f
3617                .debug_struct("DataAttachmentReceived")
3618                .field("space_id", &"[REDACTED]")
3619                .field("peer_id", &"[REDACTED]")
3620                .field("hash", hash)
3621                // The blob itself is content and never goes near a log.
3622                .field("data", &format!("[{} base64 chars]", data.len()))
3623                .finish(),
3624            Self::DataAttachmentUnavailable {
3625                space_id: _,
3626                peer_id: _,
3627                hash,
3628                reason,
3629            } => f
3630                .debug_struct("DataAttachmentUnavailable")
3631                .field("space_id", &"[REDACTED]")
3632                .field("peer_id", &"[REDACTED]")
3633                .field("hash", hash)
3634                .field("reason", reason)
3635                .finish(),
3636            Self::DataDocUnsyncable {
3637                space_id: _,
3638                doc_id: _,
3639                bytes,
3640                reason,
3641            } => f
3642                .debug_struct("DataDocUnsyncable")
3643                .field("space_id", &"[REDACTED]")
3644                .field("doc_id", &"[REDACTED]")
3645                .field("bytes", bytes)
3646                .field("reason", reason)
3647                .finish(),
3648            Self::GroupRoleChanged {
3649                group_id,
3650                user_id: _,
3651                new_role,
3652                changed_by: _,
3653            } => f
3654                .debug_struct("GroupRoleChanged")
3655                .field("group_id", group_id)
3656                .field("user_id", &"[REDACTED]")
3657                .field("new_role", new_role)
3658                .field("changed_by", &"[REDACTED]")
3659                .finish(),
3660            Self::GroupRenamed {
3661                group_id,
3662                new_name,
3663                old_name: _,
3664                renamed_by: _,
3665            } => f
3666                .debug_struct("GroupRenamed")
3667                .field("group_id", group_id)
3668                .field("new_name", new_name)
3669                .field("renamed_by", &"[REDACTED]")
3670                .finish(),
3671        }
3672    }
3673}
3674
3675#[cfg(test)]
3676mod tests {
3677    use super::*;
3678    use crate::protocol::SEND_FAIL_REASON_RECIPIENT_UNREACHABLE;
3679    use offline_protocol_core::{AppId, Message, MessagePriority, UserId};
3680
3681    #[test]
3682    fn test_message_sent_event() {
3683        let message = Message::builder(
3684            UserId::new("alice").unwrap(),
3685            UserId::new("bob").unwrap(),
3686            AppId::new("app").unwrap(),
3687        )
3688        .content("Hello")
3689        .priority(MessagePriority::High)
3690        .build();
3691
3692        let event = Event::message_sent(&message);
3693
3694        match event {
3695            Event::MessageSent {
3696                message_id,
3697                sender,
3698                recipient,
3699                content,
3700                priority,
3701                requires_ack,
3702                timestamp,
3703                lamport_clock,
3704                forward_info,
3705            } => {
3706                assert_eq!(message_id, message.id.as_str());
3707                assert_eq!(sender, message.sender.as_str());
3708                assert_eq!(recipient, message.recipient.as_str());
3709                assert_eq!(content, message.content);
3710                assert_eq!(priority, "high");
3711                assert!(requires_ack);
3712                assert_eq!(timestamp, message.timestamp.as_millis());
3713                assert_eq!(lamport_clock, message.lamport_clock.value());
3714                assert!(forward_info.is_none());
3715            }
3716            _ => panic!("Wrong event type"),
3717        }
3718    }
3719
3720    #[test]
3721    fn test_message_delivered_event() {
3722        let msg_id = MessageId::new();
3723        let event = Event::message_delivered(msg_id.clone(), 100, 3, TransportType::BLE);
3724
3725        match event {
3726            Event::MessageDelivered {
3727                message_id,
3728                latency_ms,
3729                hop_count,
3730                transport,
3731            } => {
3732                assert_eq!(message_id, msg_id.as_str());
3733                assert_eq!(latency_ms, 100);
3734                assert_eq!(hop_count, 3);
3735                assert_eq!(transport, "ble");
3736            }
3737            _ => panic!("Wrong event type"),
3738        }
3739    }
3740
3741    #[test]
3742    fn test_transport_switched_event() {
3743        let event = Event::transport_switched(
3744            Some(TransportType::BLE),
3745            TransportType::WiFiDirect,
3746            "Poor signal".to_string(),
3747        );
3748
3749        match event {
3750            Event::TransportSwitched { from, to, reason } => {
3751                assert_eq!(from.as_deref(), Some("ble"));
3752                assert_eq!(to, "wifiDirect");
3753                assert_eq!(reason, "Poor signal");
3754            }
3755            _ => panic!("Wrong event type"),
3756        }
3757    }
3758
3759    #[test]
3760    fn test_relay_promoted_event() {
3761        let event = Event::relay_promoted(5, Some(80));
3762
3763        match event {
3764            Event::RelayPromoted {
3765                connection_count,
3766                battery_level,
3767            } => {
3768                assert_eq!(connection_count, 5);
3769                assert_eq!(battery_level, Some(80));
3770            }
3771            _ => panic!("Wrong event type"),
3772        }
3773    }
3774
3775    #[test]
3776    fn test_file_progress_event() {
3777        let event = Event::file_progress("file123".to_string(), 50, 100);
3778
3779        match event {
3780            Event::FileProgress {
3781                file_id,
3782                chunks_sent,
3783                total_chunks,
3784                percentage,
3785            } => {
3786                assert_eq!(file_id, "file123");
3787                assert_eq!(chunks_sent, 50);
3788                assert_eq!(total_chunks, 100);
3789                assert_eq!(percentage, 50);
3790            }
3791            _ => panic!("Wrong event type"),
3792        }
3793    }
3794
3795    #[test]
3796    fn test_event_serialization() {
3797        let event = Event::relay_promoted(5, Some(80));
3798        let json = event.to_json().unwrap();
3799        let deserialized = Event::from_json(&json).unwrap();
3800
3801        match deserialized {
3802            Event::RelayPromoted {
3803                connection_count,
3804                battery_level,
3805            } => {
3806                assert_eq!(connection_count, 5);
3807                assert_eq!(battery_level, Some(80));
3808            }
3809            _ => panic!("Wrong event type after deserialization"),
3810        }
3811    }
3812
3813    /// Pins the JSON wire shape the RN `types.ts` declarations model
3814    /// (`ConnectionRequestUndeliverableEvent` / `initial_message` on
3815    /// `ConnectionRequestReceivedEvent`) — events cross the FFI as JSON, so
3816    /// this contract is otherwise enforced only by convention.
3817    #[test]
3818    fn test_connection_request_event_wire_shapes() {
3819        let event = Event::connection_request_undeliverable(
3820            "bob".to_string(),
3821            "m1".to_string(),
3822            SEND_FAIL_REASON_RECIPIENT_UNREACHABLE,
3823        );
3824        let json: serde_json::Value = serde_json::from_str(&event.to_json().unwrap()).unwrap();
3825        assert_eq!(json["type"], "connection_request_undeliverable");
3826        assert_eq!(json["recipient"], "bob");
3827        assert_eq!(json["message_id"], "m1");
3828        // The bare token, never the relay's `recipient_unreachable: <prose>`.
3829        assert_eq!(json["reason"], "recipient_unreachable");
3830
3831        let received = Event::connection_request_received(
3832            "alice".to_string(),
3833            "Alice".to_string(),
3834            12345,
3835            None,
3836            Some("hey".to_string()),
3837        );
3838        let json: serde_json::Value = serde_json::from_str(&received.to_json().unwrap()).unwrap();
3839        assert_eq!(json["type"], "connection_request_received");
3840        assert_eq!(json["initial_message"], "hey");
3841
3842        // None must omit the key entirely (skip_serializing_if), keeping the
3843        // event byte-shape identical to pre-initial-message builds.
3844        let received = Event::connection_request_received(
3845            "alice".to_string(),
3846            "Alice".to_string(),
3847            12345,
3848            None,
3849            None,
3850        );
3851        assert!(!received.to_json().unwrap().contains("initial_message"));
3852    }
3853
3854    /// Pins the `group_error` wire shape (#349).
3855    ///
3856    /// `group_id` is additive, so a build that never scopes an error must
3857    /// serialize byte-identically to pre-change builds — the same
3858    /// `skip_serializing_if` contract `initial_message` is pinned on above.
3859    #[test]
3860    fn test_group_error_event_wire_shape() {
3861        let scoped = Event::group_error("sync_denied".to_string(), Some("g-1".to_string()));
3862        let json: serde_json::Value = serde_json::from_str(&scoped.to_json().unwrap()).unwrap();
3863        assert_eq!(json["type"], "group_error");
3864        assert_eq!(json["reason"], "sync_denied");
3865        assert_eq!(json["group_id"], "g-1");
3866
3867        let unscoped = Event::group_error("error".to_string(), None);
3868        assert!(!unscoped.to_json().unwrap().contains("group_id"));
3869    }
3870
3871    #[test]
3872    fn test_network_metrics_event() {
3873        let event = Event::network_metrics(10, 3, 0.95, 150);
3874
3875        match event {
3876            Event::NetworkMetrics {
3877                neighbor_count,
3878                relay_count,
3879                delivery_ratio,
3880                avg_latency_ms,
3881            } => {
3882                assert_eq!(neighbor_count, 10);
3883                assert_eq!(relay_count, 3);
3884                assert_eq!(delivery_ratio, 0.95);
3885                assert_eq!(avg_latency_ms, 150);
3886            }
3887            _ => panic!("Wrong event type"),
3888        }
3889    }
3890
3891    #[test]
3892    fn test_file_received_event() {
3893        let event = Event::file_received(
3894            "file123".to_string(),
3895            "photo.jpg".to_string(),
3896            1024000,
3897            "alice".to_string(),
3898            ContentType::Image,
3899            None,
3900            vec![1, 2, 3, 4],
3901            None,
3902            None,
3903            None,
3904            None,
3905            None,
3906        );
3907
3908        match event {
3909            Event::FileReceived {
3910                file_id,
3911                file_name,
3912                file_size,
3913                sender,
3914                content_type,
3915                file_data,
3916                ..
3917            } => {
3918                assert_eq!(file_id, "file123");
3919                assert_eq!(file_name, "photo.jpg");
3920                assert_eq!(file_size, 1024000);
3921                assert_eq!(sender, "alice");
3922                assert_eq!(content_type, "image");
3923                assert!(!file_data.is_empty());
3924            }
3925            _ => panic!("Wrong event type"),
3926        }
3927    }
3928
3929    #[test]
3930    fn test_file_received_event_rich_fields_absent_from_json_when_none() {
3931        // The rich fields are additive on the event's JSON wire form: a
3932        // plain transfer must serialize exactly as it did before they
3933        // existed, so older consumers see no new keys.
3934        let event = Event::file_received(
3935            "file123".to_string(),
3936            "f.bin".to_string(),
3937            4,
3938            "alice".to_string(),
3939            ContentType::File,
3940            None,
3941            vec![1, 2, 3, 4],
3942            None,
3943            None,
3944            None,
3945            None,
3946            None,
3947        );
3948        let json = serde_json::to_value(&event).unwrap();
3949        for key in [
3950            "timestamp",
3951            "caption",
3952            "reply_to_msg",
3953            "reply_context",
3954            "forward_info",
3955        ] {
3956            assert!(json.get(key).is_none(), "{} must be absent when unset", key);
3957        }
3958    }
3959
3960    #[test]
3961    fn test_file_received_event_rich_fields_round_trip() {
3962        use offline_protocol_core::UserId;
3963        let rc = offline_protocol_core::ReplyContext {
3964            sender: UserId::new("carol").unwrap(),
3965            text: "the original".to_string(),
3966            timestamp: None,
3967            reply_media_label: None,
3968            reply_content_type: Some("text".to_string()),
3969        };
3970        let fwd = offline_protocol_core::ForwardInfo {
3971            original_sender: UserId::new("dave").unwrap(),
3972            original_message_id: offline_protocol_core::MessageId::new(),
3973            original_timestamp: offline_protocol_core::Timestamp::now(),
3974            forward_count: 1,
3975        };
3976        let event = Event::file_received(
3977            "file123".to_string(),
3978            "photo.jpg".to_string(),
3979            1024,
3980            "alice".to_string(),
3981            ContentType::Image,
3982            None,
3983            vec![1, 2, 3],
3984            Some(1_700_000_000_000),
3985            Some("look".to_string()),
3986            Some("0192aaaa-bbbb-cccc-dddd-eeeeffff0000".to_string()),
3987            Some(&rc),
3988            Some(&fwd),
3989        );
3990        let json = serde_json::to_string(&event).unwrap();
3991        let parsed: Event = serde_json::from_str(&json).unwrap();
3992        match parsed {
3993            Event::FileReceived {
3994                timestamp,
3995                caption,
3996                reply_to_msg,
3997                reply_context,
3998                forward_info,
3999                ..
4000            } => {
4001                assert_eq!(timestamp, Some(1_700_000_000_000));
4002                assert_eq!(caption.as_deref(), Some("look"));
4003                assert_eq!(
4004                    reply_to_msg.as_deref(),
4005                    Some("0192aaaa-bbbb-cccc-dddd-eeeeffff0000")
4006                );
4007                let rc = reply_context.expect("reply_context must round-trip");
4008                assert_eq!(rc.sender, "carol");
4009                assert_eq!(rc.text, "the original");
4010                let fwd = forward_info.expect("forward_info must round-trip");
4011                assert_eq!(fwd.original_sender, "dave");
4012                assert_eq!(fwd.forward_count, 1);
4013            }
4014            _ => panic!("Wrong event type"),
4015        }
4016    }
4017
4018    #[test]
4019    fn test_security_warning_code_as_str_matches_serde_wire_form() {
4020        // `SecurityWarningCode::as_str()` is hand-written, while the JSON wire
4021        // form is derived from `#[serde(rename_all = "SCREAMING_SNAKE_CASE")]`.
4022        // JS consumers branch on the wire string (e.g. keying reinstall handling
4023        // off `SENDER_ADDRESS_MISMATCH`), so the two must never drift. Pin every
4024        // variant to its serialized form — a single-variant test would let the
4025        // others rot.
4026        let all = [
4027            SecurityWarningCode::SenderAddressMismatch,
4028            SecurityWarningCode::TransportIdentityMismatch,
4029            SecurityWarningCode::ControlSignatureInvalid,
4030            SecurityWarningCode::UnsignedControlRejected,
4031            SecurityWarningCode::MediaSenderGroupMismatch,
4032            SecurityWarningCode::PlaintextSend,
4033            SecurityWarningCode::PlaintextReceiveRejected,
4034            SecurityWarningCode::SessionSenderGroupMismatch,
4035            SecurityWarningCode::SessionRekeyTriggered,
4036            SecurityWarningCode::NostrKeyPackageSlotExhausted,
4037            SecurityWarningCode::PushKeyPackagePoolExhausted,
4038            SecurityWarningCode::RelayAddressBindingMismatch,
4039            SecurityWarningCode::RelayAddressDeclarationRefused,
4040            SecurityWarningCode::GroupLeafIdentityUnproven,
4041            SecurityWarningCode::StaleControlFrame,
4042        ];
4043        for code in all {
4044            // serde renders a unit enum variant as a quoted JSON string.
4045            let wire = serde_json::to_string(&code).unwrap();
4046            assert_eq!(
4047                format!("\"{}\"", code.as_str()),
4048                wire,
4049                "as_str() drifted from the serde wire form for {code:?}",
4050            );
4051            // Exhaustiveness guard (no wildcard): adding a variant makes this
4052            // match fail to compile until it is also added to `all` above and
4053            // pinned to its wire form.
4054            match code {
4055                SecurityWarningCode::SenderAddressMismatch
4056                | SecurityWarningCode::TransportIdentityMismatch
4057                | SecurityWarningCode::ControlSignatureInvalid
4058                | SecurityWarningCode::UnsignedControlRejected
4059                | SecurityWarningCode::MediaSenderGroupMismatch
4060                | SecurityWarningCode::PlaintextSend
4061                | SecurityWarningCode::PlaintextReceiveRejected
4062                | SecurityWarningCode::SessionSenderGroupMismatch
4063                | SecurityWarningCode::SessionRekeyTriggered
4064                | SecurityWarningCode::NostrKeyPackageSlotExhausted
4065                | SecurityWarningCode::PushKeyPackagePoolExhausted
4066                | SecurityWarningCode::RelayAddressBindingMismatch
4067                | SecurityWarningCode::RelayAddressDeclarationRefused
4068                | SecurityWarningCode::GroupLeafIdentityUnproven
4069                | SecurityWarningCode::StaleControlFrame => {}
4070            }
4071        }
4072    }
4073
4074    #[test]
4075    fn test_debug_redacts_every_peer_identifying_field() {
4076        // `SecurityWarning` printed its `peer_id` verbatim while the other
4077        // twelve peer-bearing arms redacted theirs, so a single missed arm was
4078        // invisible: `{:?}` on any *other* event looked correct. Pinning one
4079        // arm would leave the next omission just as invisible, so this
4080        // enumerates every variant carrying a peer-identifying field and
4081        // asserts the sentinel never survives formatting.
4082        //
4083        // Deliberately a *value* assertion rather than a field-name one: the
4084        // question is whether the id reaches the log, not how the arm spells
4085        // its placeholder.
4086        const SENTINEL: &str = "off1qy4aspkf0u8qptc6rlpn9ra8vw5jd9ereq4cwpfs";
4087
4088        let events = vec![
4089            Event::NeighborDiscovered {
4090                peer_id: SENTINEL.to_string(),
4091                transport: "ble".to_string(),
4092                rssi: Some(-40),
4093            },
4094            Event::NeighborLost {
4095                peer_id: SENTINEL.to_string(),
4096            },
4097            Event::SecureSessionEstablished {
4098                peer_id: SENTINEL.to_string(),
4099                group_id: "session:a:b".to_string(),
4100                is_session: true,
4101                initiated_by_local: true,
4102            },
4103            Event::SecureSessionFailed {
4104                peer_id: SENTINEL.to_string(),
4105                reason: "nope".to_string(),
4106            },
4107            Event::ConvergenceDiag {
4108                stage: "stage".to_string(),
4109                peer_id: SENTINEL.to_string(),
4110                detail: "detail".to_string(),
4111            },
4112            Event::WelcomeSendAttempted {
4113                peer_id: SENTINEL.to_string(),
4114                message_id: "m1".to_string(),
4115                group_id: "g1".to_string(),
4116                attempt: 1,
4117            },
4118            Event::WelcomeSendSucceeded {
4119                peer_id: SENTINEL.to_string(),
4120                message_id: "m1".to_string(),
4121                group_id: "g1".to_string(),
4122                attempt: 1,
4123            },
4124            Event::WelcomeSendFailed {
4125                peer_id: SENTINEL.to_string(),
4126                message_id: "m1".to_string(),
4127                group_id: "g1".to_string(),
4128                attempt: 1,
4129                reason_code: WelcomeReasonCode::Timeout,
4130                transport_error: None,
4131                retryable: true,
4132                next_retry_at: None,
4133            },
4134            Event::WelcomeSendExpired {
4135                peer_id: SENTINEL.to_string(),
4136                message_id: "m1".to_string(),
4137                attempt: 3,
4138                reason_code: WelcomeReasonCode::RetryExhausted,
4139            },
4140            Event::ServiceDiscovered {
4141                query_id: "q1".to_string(),
4142                service_id: "s1".to_string(),
4143                version: "1".to_string(),
4144                provider_peer_id: SENTINEL.to_string(),
4145                capabilities: HashMap::new(),
4146                hop_count: 1,
4147            },
4148            Event::ServiceResponseReceived {
4149                request_id: "r1".to_string(),
4150                service_id: "s1".to_string(),
4151                status: "ok".to_string(),
4152                body: "body".to_string(),
4153                provider_peer_id: SENTINEL.to_string(),
4154            },
4155            Event::PresenceUpdated {
4156                peer_id: SENTINEL.to_string(),
4157                status: PresenceStatus::Online,
4158                timestamp: 0,
4159                last_seen_ms: None,
4160                source: PresenceSource::Peer,
4161            },
4162            Event::SecurityWarning {
4163                peer_id: SENTINEL.to_string(),
4164                reason_code: SecurityWarningCode::SenderAddressMismatch,
4165                reason: "mismatch".to_string(),
4166            },
4167        ];
4168
4169        for event in &events {
4170            let rendered = format!("{:?}", event);
4171            assert!(
4172                !rendered.contains(SENTINEL),
4173                "Debug leaked a peer identifier: {rendered}",
4174            );
4175        }
4176    }
4177
4178    /// Mirrors serde's `rename_all = "snake_case"` rule.
4179    fn serde_snake_case(name: &str) -> String {
4180        let mut out = String::new();
4181        for (i, ch) in name.chars().enumerate() {
4182            if ch.is_ascii_uppercase() {
4183                if i > 0 {
4184                    out.push('_');
4185                }
4186                out.push(ch.to_ascii_lowercase());
4187            } else {
4188                out.push(ch);
4189            }
4190        }
4191        out
4192    }
4193
4194    /// Drift guard: every `Event` variant must have a typed declaration in the
4195    /// React Native bindings. Events cross UniFFI as tagged JSON, so nothing
4196    /// else fails when `types.ts` lags — the event just arrives untyped and
4197    /// invisible to the `ProtocolEvent` union (7 variants had silently drifted
4198    /// before this guard existed).
4199    #[test]
4200    fn react_native_types_cover_all_event_variants() {
4201        use std::collections::BTreeSet;
4202
4203        let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
4204        let events_rs = std::fs::read_to_string(manifest_dir.join("src/events.rs")).unwrap();
4205        let types_ts_path = manifest_dir.join("../../bindings/react-native/src/types.ts");
4206        let types_ts = std::fs::read_to_string(&types_ts_path)
4207            .unwrap_or_else(|e| panic!("cannot read {}: {e}", types_ts_path.display()));
4208
4209        // Collect each `Event` variant's serde tag by scanning the enum block:
4210        // variant names sit at 4-space indent, brace depth 1.
4211        let mut rust_tags = BTreeSet::new();
4212        let mut in_enum = false;
4213        let mut depth = 0usize;
4214        for line in events_rs.lines() {
4215            if !in_enum {
4216                if line.starts_with("pub enum Event {") {
4217                    in_enum = true;
4218                    depth = 1;
4219                }
4220                continue;
4221            }
4222            let trimmed = line.trim();
4223            if trimmed.starts_with("//") || trimmed.starts_with("#[") {
4224                continue;
4225            }
4226            if depth == 1
4227                && line.starts_with("    ")
4228                && !line.starts_with("     ")
4229                && trimmed
4230                    .chars()
4231                    .next()
4232                    .is_some_and(|c| c.is_ascii_uppercase())
4233            {
4234                let name: String = trimmed
4235                    .chars()
4236                    .take_while(|c| c.is_ascii_alphanumeric())
4237                    .collect();
4238                rust_tags.insert(serde_snake_case(&name));
4239            }
4240            depth += line.matches('{').count();
4241            depth = depth.saturating_sub(line.matches('}').count());
4242            if depth == 0 {
4243                break;
4244            }
4245        }
4246        assert!(
4247            rust_tags.len() >= 60,
4248            "enum scan looks broken: only {} variants found",
4249            rust_tags.len()
4250        );
4251
4252        // Event interface discriminants in types.ts: `  type: '<tag>';` at
4253        // exactly 2-space indent (nested object fields sit deeper).
4254        let ts_tags: BTreeSet<String> = types_ts
4255            .lines()
4256            .filter_map(|l| l.strip_prefix("  type: '"))
4257            .filter_map(|rest| rest.strip_suffix("';"))
4258            .map(str::to_string)
4259            .collect();
4260
4261        // Emitted by the native iOS/Android bridges, not the core enum.
4262        let bridge_only: BTreeSet<String> = [
4263            "diagnostic",
4264            "internet_server_message",
4265            "internet_status_changed",
4266            "internet_session_superseded",
4267            // Android only: the mesh foreground service's notification has no
4268            // iOS counterpart.
4269            "mesh_stopped_by_user",
4270        ]
4271        .map(str::to_string)
4272        .into();
4273
4274        let missing: Vec<_> = rust_tags.difference(&ts_tags).collect();
4275        assert!(
4276            missing.is_empty(),
4277            "Event variants missing a typed declaration in bindings/react-native/src/types.ts \
4278             (add the interface AND its ProtocolEvent union entry): {missing:?}"
4279        );
4280
4281        let stale: Vec<_> = ts_tags
4282            .difference(&rust_tags)
4283            .filter(|t| !bridge_only.contains(*t))
4284            .collect();
4285        assert!(
4286            stale.is_empty(),
4287            "types.ts declares event tags with no core Event variant \
4288             (stale, or a new bridge-only event that needs allowlisting here): {stale:?}"
4289        );
4290    }
4291
4292    /// Drift guard for the *payload* side of the same seam: apps switch on
4293    /// `reason_code`, so a code with no entry in the RN union is invisible to
4294    /// exhaustive handling on the app side and silently falls into whatever
4295    /// default a `switch` has. `NOSTR_KEY_PACKAGE_SLOT_EXHAUSTED` had already
4296    /// drifted this way before this guard existed.
4297    #[test]
4298    fn react_native_types_cover_all_security_warning_codes() {
4299        use std::collections::BTreeSet;
4300
4301        let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
4302        let events_rs = std::fs::read_to_string(manifest_dir.join("src/events.rs")).unwrap();
4303        let types_ts_path = manifest_dir.join("../../bindings/react-native/src/types.ts");
4304        let types_ts = std::fs::read_to_string(&types_ts_path)
4305            .unwrap_or_else(|e| panic!("cannot read {}: {e}", types_ts_path.display()));
4306
4307        // The wire forms are exactly the string literals `as_str` maps to, and
4308        // the test above already pins those to serde's rendering.
4309        // Anchored on the impl block: several enums in this file have an
4310        // identical `as_str` signature, and the first match is a different one.
4311        let as_str_body = events_rs
4312            .split_once("impl SecurityWarningCode {")
4313            .expect("impl SecurityWarningCode not found")
4314            .1
4315            .split_once("    pub fn as_str(&self) -> &'static str {")
4316            .expect("SecurityWarningCode::as_str not found")
4317            .1
4318            .split_once("\n    }")
4319            .expect("as_str body not terminated")
4320            .0;
4321        let rust_codes: BTreeSet<String> = as_str_body
4322            .lines()
4323            .filter_map(|l| l.split_once("=> \""))
4324            .filter_map(|(_, rest)| rest.split_once('"'))
4325            .map(|(code, _)| code.to_string())
4326            .collect();
4327
4328        // Negative control: a scan that silently matched nothing would make
4329        // every assertion below vacuously true. The floor tracks the variant
4330        // count loosely — it exists to catch a broken scan, not to freeze the
4331        // taxonomy, so it moves when variants are deliberately added or (as
4332        // when the TOFU codes were deleted) removed.
4333        assert!(
4334            rust_codes.len() >= 11,
4335            "as_str scan looks broken: only {} codes found",
4336            rust_codes.len()
4337        );
4338
4339        let union = types_ts
4340            .split_once("export type SecurityWarningCode =")
4341            .expect("SecurityWarningCode union not found in types.ts")
4342            .1
4343            .split_once(';')
4344            .expect("SecurityWarningCode union not terminated")
4345            .0;
4346        let ts_codes: BTreeSet<String> = union
4347            .lines()
4348            .filter_map(|l| l.trim().strip_prefix("| '"))
4349            .filter_map(|rest| rest.strip_suffix('\''))
4350            .map(str::to_string)
4351            .collect();
4352
4353        let missing: Vec<_> = rust_codes.difference(&ts_codes).collect();
4354        assert!(
4355            missing.is_empty(),
4356            "SecurityWarningCode variants missing from the union in \
4357             bindings/react-native/src/types.ts: {missing:?}"
4358        );
4359
4360        let stale: Vec<_> = ts_codes.difference(&rust_codes).collect();
4361        assert!(
4362            stale.is_empty(),
4363            "types.ts declares security warning codes with no Rust variant: {stale:?}"
4364        );
4365    }
4366}