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