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