Skip to main content

offline_protocol/
events.rs

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