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