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