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