Skip to main content

offline_protocol/
events.rs

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