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