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