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