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