Skip to main content

offline_protocol/
events.rs

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