Skip to main content

offline_protocol/
events.rs

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