Skip to main content

rvoip_core/
events.rs

1use crate::adapter::{EndReason, TransferStatus, TransferTarget};
2use crate::identity::{AuthenticatedPrincipal, IdentityAssurance};
3use crate::ids::{
4    AiAttachmentId, BridgeId, ConnectionId, ConversationId, IdentityId, ListenerId, MessageId,
5    ParticipantId, RecordingId, SessionId, StreamId, TenantId,
6};
7use crate::store::VconHandle;
8use crate::stream::QualitySnapshot;
9use crate::vcon::VconRef;
10use crate::DataMessage;
11use chrono::{DateTime, Utc};
12use rvoip_infra_common::events::cross_crate::{RvoipCoreCrossCrateEvent, RvoipCrossCrateEvent};
13use std::fmt;
14
15/// Normalized event vocabulary emitted by rvoip-core. Adapters produce
16/// `AdapterEvent`s, which are translated into these by the orchestrator.
17///
18/// In step 4 these will be wired through `infra-common`'s
19/// `RvoipCrossCrateEvent::Core(RvoipCoreCrossCrateEvent)` variant. Steps 1-2
20/// keep them as a plain enum so the surface is reviewable in isolation.
21///
22/// All variants carry timestamps; routing-relevant identifiers
23/// (`tenant_id`/`conversation_id`/`session_id`/`connection_id`/`correlation_id`)
24/// are added by the cross-crate envelope wrapper at publish time, per
25/// INTERFACE_DESIGN §5.
26#[derive(Clone)]
27#[non_exhaustive]
28pub enum Event {
29    // --- Conversation lifecycle ---
30    ConversationOpened {
31        conversation_id: ConversationId,
32        at: DateTime<Utc>,
33    },
34    ConversationClosed {
35        conversation_id: ConversationId,
36        at: DateTime<Utc>,
37    },
38
39    // --- Session lifecycle ---
40    SessionStarted {
41        session_id: SessionId,
42        conversation_id: ConversationId,
43        at: DateTime<Utc>,
44    },
45    SessionEnded {
46        session_id: SessionId,
47        /// P9 — quality / accounting payload aggregated over the
48        /// Session's lifetime. `None` when no quality samples landed
49        /// (e.g. message-only Session).
50        report: Option<SessionQualityReport>,
51        at: DateTime<Utc>,
52    },
53    SessionFailed {
54        session_id: SessionId,
55        detail: String,
56        at: DateTime<Utc>,
57    },
58
59    // --- Connection lifecycle ---
60    ConnectionInbound {
61        connection_id: ConnectionId,
62        at: DateTime<Utc>,
63    },
64    ConnectionOutbound {
65        connection_id: ConnectionId,
66        at: DateTime<Utc>,
67    },
68    ConnectionConnected {
69        connection_id: ConnectionId,
70        at: DateTime<Utc>,
71    },
72    /// A peer completed the UCTP auth handshake on this Connection.
73    /// Fires immediately after `ConnectionInbound` for UCTP-family
74    /// substrates; absent for substrates that don't model peer-level
75    /// auth (SIP, WebRTC). The `participant_id` is the peer's claimed
76    /// identifier from `auth.session`; `identity_id` is the
77    /// server-issued binding. Plan §7 G1 / A3.
78    ConnectionAuthenticated {
79        connection_id: ConnectionId,
80        identity_id: String,
81        participant_id: String,
82        assurance: IdentityAssurance,
83        at: DateTime<Utc>,
84    },
85    /// Complete principal retained alongside the legacy authentication event.
86    /// Authorization decisions should compare `principal.ownership_key()`.
87    ConnectionPrincipalAuthenticated {
88        connection_id: ConnectionId,
89        participant_id: String,
90        principal: AuthenticatedPrincipal,
91        at: DateTime<Utc>,
92    },
93    /// Early states (per INTERFACE_DESIGN §5).
94    ConnectionProgress {
95        connection_id: ConnectionId,
96        kind: ConnectionProgressKind,
97        at: DateTime<Utc>,
98    },
99    ConnectionEnded {
100        connection_id: ConnectionId,
101        reason: EndReason,
102        at: DateTime<Utc>,
103    },
104    ConnectionFailed {
105        connection_id: ConnectionId,
106        detail: String,
107        at: DateTime<Utc>,
108    },
109
110    // --- Bridge lifecycle ---
111    ConnectionsBridged {
112        bridge_id: BridgeId,
113        a: ConnectionId,
114        b: ConnectionId,
115        at: DateTime<Utc>,
116    },
117    ConnectionsUnbridged {
118        bridge_id: BridgeId,
119        at: DateTime<Utc>,
120    },
121
122    // --- Transfer ---
123    /// Legacy compatibility event indicating that an adapter accepted the
124    /// transfer command for submission. It is not a final transfer outcome;
125    /// use [`Self::ConnectionTransferStatus`] for authoritative completion.
126    ConnectionTransferred {
127        connection_id: ConnectionId,
128        target: TransferTarget,
129        at: DateTime<Utc>,
130    },
131    /// Protocol-authoritative asynchronous transfer progress or completion.
132    ConnectionTransferStatus {
133        connection_id: ConnectionId,
134        status: TransferStatus,
135        at: DateTime<Utc>,
136    },
137
138    // --- Participant lifecycle (per-Session) ---
139    ParticipantJoined {
140        session_id: SessionId,
141        participant_id: ParticipantId,
142        at: DateTime<Utc>,
143    },
144    ParticipantLeft {
145        session_id: SessionId,
146        participant_id: ParticipantId,
147        at: DateTime<Utc>,
148    },
149
150    // --- AI / listener attach ---
151    AiAttached {
152        connection_id: ConnectionId,
153        attachment_id: AiAttachmentId,
154        provider_ref: String,
155        at: DateTime<Utc>,
156    },
157    AiDetached {
158        attachment_id: AiAttachmentId,
159        at: DateTime<Utc>,
160    },
161    ListenerAttached {
162        listener_id: ListenerId,
163        at: DateTime<Utc>,
164    },
165    ListenerDetached {
166        listener_id: ListenerId,
167        at: DateTime<Utc>,
168    },
169
170    // --- Messaging ---
171    MessageReceived {
172        message_id: MessageId,
173        conversation_id: ConversationId,
174        at: DateTime<Utc>,
175    },
176    DataMessageReceived {
177        connection_id: ConnectionId,
178        message: DataMessage,
179        at: DateTime<Utc>,
180    },
181    MessageSent {
182        message_id: MessageId,
183        conversation_id: ConversationId,
184        at: DateTime<Utc>,
185    },
186    MessageDelivered {
187        message_id: MessageId,
188        at: DateTime<Utc>,
189    },
190    MessageRead {
191        message_id: MessageId,
192        at: DateTime<Utc>,
193    },
194
195    // --- DTMF ---
196    DtmfReceived {
197        connection_id: ConnectionId,
198        digits: String,
199        at: DateTime<Utc>,
200    },
201
202    // --- Transcription / recording ---
203    TranscriptTurn {
204        stream_id: StreamId,
205        speaker: Option<ParticipantId>,
206        text: String,
207        confidence: f32,
208        is_final: bool,
209        assigned_provider: Option<String>,
210        at: DateTime<Utc>,
211    },
212    RecordingStarted {
213        recording_id: RecordingId,
214        at: DateTime<Utc>,
215    },
216    RecordingStopped {
217        recording_id: RecordingId,
218        at: DateTime<Utc>,
219    },
220    RecordingComplete {
221        recording_id: RecordingId,
222        sink: String,
223        /// Reserved for future recording-level linkage.
224        ///
225        /// This remains `None` in 0.3.3; session-level vCons are announced
226        /// separately through [`Self::VconReady`].
227        vcon_ref: Option<VconRef>,
228        at: DateTime<Utc>,
229    },
230
231    // --- vCon ---
232    VconReady {
233        session_id: SessionId,
234        handle: VconHandle,
235        at: DateTime<Utc>,
236    },
237    VconRedacted {
238        session_id: SessionId,
239        old: VconHandle,
240        new: VconHandle,
241        at: DateTime<Utc>,
242    },
243
244    // --- Identity ---
245    IdentityAssuranceChanged {
246        connection_id: ConnectionId,
247        identity_id: Option<IdentityId>,
248        at: DateTime<Utc>,
249    },
250
251    /// P12.6 — emitted after `Orchestrator::request_step_up` has
252    /// pushed an `identity.step-up-request` to the peer's adapter. The
253    /// consumer can use this as a positive signal that the request
254    /// reached the wire. Carries the requested assurance for context.
255    IdentityStepUpRequested {
256        connection_id: ConnectionId,
257        required: crate::capability::IdentityAssuranceRequirement,
258        at: DateTime<Utc>,
259    },
260
261    /// P12.6 — peer sent an `identity.step-up-response`. Consumer
262    /// resolves the `(method, credential)` pair to a
263    /// [`crate::identity::Credential`] and calls
264    /// [`crate::Orchestrator::complete_step_up`] to finish the
265    /// round-trip (which emits `IdentityAssuranceChanged` on success).
266    IdentityStepUpResponseReceived {
267        connection_id: ConnectionId,
268        method: String,
269        credential: String,
270        at: DateTime<Utc>,
271    },
272
273    // --- Registration (emitted by adapters that include a registrar) ---
274    RegistrationChanged {
275        aor: String,
276        at: DateTime<Utc>,
277    },
278    RegistrationHeartbeat {
279        aor: String,
280        at: DateTime<Utc>,
281    },
282
283    // --- Observability ---
284    CapacityReport {
285        tenant_id: Option<TenantId>,
286        active_connections: u64,
287        active_bridges: u64,
288        admission_in_use: u64,
289        at: DateTime<Utc>,
290    },
291    UsageRecord {
292        tenant_id: TenantId,
293        kind: UsageKind,
294        units: u64,
295        at: DateTime<Utc>,
296    },
297    Anomaly {
298        kind: AnomalyKind,
299        connection_id: Option<ConnectionId>,
300        detail: String,
301        at: DateTime<Utc>,
302    },
303    MediaQuality {
304        connection_id: ConnectionId,
305        snapshot: QualitySnapshot,
306        at: DateTime<Utc>,
307    },
308
309    /// P5 — fired when ASR detects speech while a TTS playback is
310    /// in flight. The orchestrator's AI loop cancels the current
311    /// playback before firing this; downstream consumers can use it
312    /// for analytics (barge-in count is one of the AI-quality
313    /// signals PRD §11 calls out).
314    BargeInDetected {
315        connection_id: ConnectionId,
316        ai_attachment_id: AiAttachmentId,
317        at: DateTime<Utc>,
318    },
319
320    /// P8 — active-speaker advisory per CONVERSATION_PROTOCOL §6
321    /// `stream.active-speaker`. Emitted (optionally) by the UCTP
322    /// coordinator when audio-level extension data identifies a new
323    /// dominant speaker. Pure advisory — subscribers may use it to
324    /// drive UI focus; no media routing decisions are made off it.
325    ActiveSpeakerChanged {
326        session_id: SessionId,
327        connection_id: ConnectionId,
328        audio_level_dbov: i8,
329        at: DateTime<Utc>,
330    },
331}
332
333impl fmt::Debug for Event {
334    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
335        match self {
336            Self::ConversationOpened { .. } => formatter.write_str("ConversationOpened"),
337            Self::ConversationClosed { .. } => formatter.write_str("ConversationClosed"),
338            Self::SessionStarted { .. } => formatter.write_str("SessionStarted"),
339            Self::SessionEnded { report, .. } => formatter
340                .debug_struct("SessionEnded")
341                .field("quality_report_present", &report.is_some())
342                .finish(),
343            Self::SessionFailed { detail, .. } => formatter
344                .debug_struct("SessionFailed")
345                .field("detail_present", &!detail.is_empty())
346                .field("detail_bytes", &detail.len())
347                .finish(),
348            Self::ConnectionInbound { .. } => formatter.write_str("ConnectionInbound"),
349            Self::ConnectionOutbound { .. } => formatter.write_str("ConnectionOutbound"),
350            Self::ConnectionConnected { .. } => formatter.write_str("ConnectionConnected"),
351            Self::ConnectionAuthenticated { .. } => formatter.write_str("ConnectionAuthenticated"),
352            Self::ConnectionPrincipalAuthenticated { .. } => {
353                formatter.write_str("ConnectionPrincipalAuthenticated")
354            }
355            Self::ConnectionProgress { kind, .. } => formatter
356                .debug_struct("ConnectionProgress")
357                .field("kind", kind)
358                .finish(),
359            Self::ConnectionEnded { .. } => formatter.write_str("ConnectionEnded"),
360            Self::ConnectionFailed { detail, .. } => formatter
361                .debug_struct("ConnectionFailed")
362                .field("detail_present", &!detail.is_empty())
363                .field("detail_bytes", &detail.len())
364                .finish(),
365            Self::ConnectionsBridged { .. } => formatter.write_str("ConnectionsBridged"),
366            Self::ConnectionsUnbridged { .. } => formatter.write_str("ConnectionsUnbridged"),
367            Self::ConnectionTransferred { .. } => formatter.write_str("ConnectionTransferred"),
368            Self::ConnectionTransferStatus { status, .. } => formatter
369                .debug_struct("ConnectionTransferStatus")
370                .field("status", status)
371                .finish(),
372            Self::ParticipantJoined { .. } => formatter.write_str("ParticipantJoined"),
373            Self::ParticipantLeft { .. } => formatter.write_str("ParticipantLeft"),
374            Self::AiAttached { provider_ref, .. } => formatter
375                .debug_struct("AiAttached")
376                .field("provider_ref_present", &!provider_ref.is_empty())
377                .field("provider_ref_bytes", &provider_ref.len())
378                .finish(),
379            Self::AiDetached { .. } => formatter.write_str("AiDetached"),
380            Self::ListenerAttached { .. } => formatter.write_str("ListenerAttached"),
381            Self::ListenerDetached { .. } => formatter.write_str("ListenerDetached"),
382            Self::MessageReceived { .. } => formatter.write_str("MessageReceived"),
383            Self::DataMessageReceived { message, .. } => formatter
384                .debug_struct("DataMessageReceived")
385                .field("body_bytes", &message.bytes.len())
386                .finish(),
387            Self::MessageSent { .. } => formatter.write_str("MessageSent"),
388            Self::MessageDelivered { .. } => formatter.write_str("MessageDelivered"),
389            Self::MessageRead { .. } => formatter.write_str("MessageRead"),
390            Self::DtmfReceived { digits, .. } => formatter
391                .debug_struct("DtmfReceived")
392                .field("digit_count", &digits.chars().count())
393                .finish(),
394            Self::TranscriptTurn {
395                text,
396                speaker,
397                confidence,
398                is_final,
399                assigned_provider,
400                ..
401            } => formatter
402                .debug_struct("TranscriptTurn")
403                .field("speaker_present", &speaker.is_some())
404                .field("text_bytes", &text.len())
405                .field("confidence", confidence)
406                .field("is_final", is_final)
407                .field("assigned_provider_present", &assigned_provider.is_some())
408                .finish(),
409            Self::RecordingStarted { .. } => formatter.write_str("RecordingStarted"),
410            Self::RecordingStopped { .. } => formatter.write_str("RecordingStopped"),
411            Self::RecordingComplete { sink, vcon_ref, .. } => formatter
412                .debug_struct("RecordingComplete")
413                .field("sink_present", &!sink.is_empty())
414                .field("sink_bytes", &sink.len())
415                .field("vcon_ref_present", &vcon_ref.is_some())
416                .finish(),
417            Self::VconReady { .. } => formatter.write_str("VconReady"),
418            Self::VconRedacted { .. } => formatter.write_str("VconRedacted"),
419            Self::IdentityAssuranceChanged { identity_id, .. } => formatter
420                .debug_struct("IdentityAssuranceChanged")
421                .field("identity_present", &identity_id.is_some())
422                .finish(),
423            Self::IdentityStepUpRequested { .. } => formatter.write_str("IdentityStepUpRequested"),
424            Self::IdentityStepUpResponseReceived {
425                method, credential, ..
426            } => formatter
427                .debug_struct("IdentityStepUpResponseReceived")
428                .field("method_present", &!method.is_empty())
429                .field("method_bytes", &method.len())
430                .field("credential_present", &!credential.is_empty())
431                .field("credential_bytes", &credential.len())
432                .finish(),
433            Self::RegistrationChanged { .. } => formatter.write_str("RegistrationChanged"),
434            Self::RegistrationHeartbeat { .. } => formatter.write_str("RegistrationHeartbeat"),
435            Self::CapacityReport {
436                tenant_id,
437                active_connections,
438                active_bridges,
439                admission_in_use,
440                ..
441            } => formatter
442                .debug_struct("CapacityReport")
443                .field("tenant_present", &tenant_id.is_some())
444                .field("active_connections", active_connections)
445                .field("active_bridges", active_bridges)
446                .field("admission_in_use", admission_in_use)
447                .finish(),
448            Self::UsageRecord { kind, units, .. } => formatter
449                .debug_struct("UsageRecord")
450                .field("kind", kind)
451                .field("units", units)
452                .finish(),
453            Self::Anomaly {
454                kind,
455                connection_id,
456                detail,
457                ..
458            } => formatter
459                .debug_struct("Anomaly")
460                .field("kind", kind)
461                .field("connection_present", &connection_id.is_some())
462                .field("detail_present", &!detail.is_empty())
463                .field("detail_bytes", &detail.len())
464                .finish(),
465            Self::MediaQuality { .. } => formatter.write_str("MediaQuality"),
466            Self::BargeInDetected { .. } => formatter.write_str("BargeInDetected"),
467            Self::ActiveSpeakerChanged {
468                audio_level_dbov, ..
469            } => formatter
470                .debug_struct("ActiveSpeakerChanged")
471                .field("audio_level_dbov", audio_level_dbov)
472                .finish(),
473        }
474    }
475}
476
477/// P9 — per-Session quality + accounting report carried on
478/// `Event::SessionEnded`. Mirrors PRD §10.2.
479#[derive(Clone, Default)]
480pub struct SessionQualityReport {
481    pub mos: Option<f32>,
482    pub packet_loss_pct: f32,
483    pub jitter_ms: f32,
484    pub rtt_ms: Option<f32>,
485    pub codec: Option<String>,
486    pub bitrate_bps: Option<u32>,
487    pub talk_pct: Option<f32>,
488    pub silence_pct: Option<f32>,
489    pub pdd_ms: Option<u32>,
490    pub ring_time_ms: Option<u32>,
491    pub setup_time_ms: Option<u32>,
492    pub hangup_reason: Option<String>,
493}
494
495impl fmt::Debug for SessionQualityReport {
496    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
497        formatter
498            .debug_struct("SessionQualityReport")
499            .field("mos", &self.mos)
500            .field("packet_loss_pct", &self.packet_loss_pct)
501            .field("jitter_ms", &self.jitter_ms)
502            .field("rtt_ms", &self.rtt_ms)
503            .field("codec_present", &self.codec.is_some())
504            .field("codec_bytes", &self.codec.as_ref().map_or(0, String::len))
505            .field("bitrate_bps", &self.bitrate_bps)
506            .field("talk_pct", &self.talk_pct)
507            .field("silence_pct", &self.silence_pct)
508            .field("pdd_ms", &self.pdd_ms)
509            .field("ring_time_ms", &self.ring_time_ms)
510            .field("setup_time_ms", &self.setup_time_ms)
511            .field("hangup_reason_present", &self.hangup_reason.is_some())
512            .field(
513                "hangup_reason_bytes",
514                &self.hangup_reason.as_ref().map_or(0, String::len),
515            )
516            .finish()
517    }
518}
519
520#[derive(Clone, Copy, Debug, Eq, PartialEq)]
521pub enum ConnectionProgressKind {
522    Trying,
523    Ringing,
524    EarlyMedia,
525    Busy,
526    NoAnswer,
527    AnsweringMachine,
528    HumanAnswered,
529}
530
531#[derive(Clone, Copy, Debug, Eq, PartialEq)]
532pub enum UsageKind {
533    SessionSeconds,
534    RecordingSeconds,
535    TranscriptionSeconds,
536    BridgedMinutes,
537    MessagesSent,
538}
539
540#[derive(Clone, Copy, Debug, Eq, PartialEq)]
541pub enum AnomalyKind {
542    QualityDrop,
543    PossibleFraud,
544    UnexpectedTeardown,
545    AssuranceMismatch,
546}
547
548impl Event {
549    /// Translate the rich in-process event to the primitive-payload wire form
550    /// published through `infra-common::GlobalEventCoordinator`. The
551    /// `RvoipCrossCrateEvent::Core` variant lives in infra-common per the
552    /// CARVE_PLAN events.rs commitment.
553    pub fn to_cross_crate(&self) -> RvoipCrossCrateEvent {
554        use Event::*;
555        let inner = match self {
556            ConversationOpened {
557                conversation_id, ..
558            } => RvoipCoreCrossCrateEvent::ConversationOpened {
559                conversation_id: conversation_id.to_string(),
560            },
561            ConversationClosed {
562                conversation_id, ..
563            } => RvoipCoreCrossCrateEvent::ConversationClosed {
564                conversation_id: conversation_id.to_string(),
565            },
566            SessionStarted {
567                session_id,
568                conversation_id,
569                ..
570            } => RvoipCoreCrossCrateEvent::SessionStarted {
571                session_id: session_id.to_string(),
572                conversation_id: conversation_id.to_string(),
573            },
574            SessionEnded { session_id, .. } => RvoipCoreCrossCrateEvent::SessionEnded {
575                session_id: session_id.to_string(),
576            },
577            SessionFailed {
578                session_id, detail, ..
579            } => RvoipCoreCrossCrateEvent::SessionFailed {
580                session_id: session_id.to_string(),
581                detail: detail.clone(),
582            },
583            ConnectionInbound { connection_id, .. } => {
584                RvoipCoreCrossCrateEvent::ConnectionInbound {
585                    connection_id: connection_id.to_string(),
586                }
587            }
588            ConnectionOutbound { connection_id, .. } => {
589                RvoipCoreCrossCrateEvent::ConnectionOutbound {
590                    connection_id: connection_id.to_string(),
591                }
592            }
593            ConnectionConnected { connection_id, .. } => {
594                RvoipCoreCrossCrateEvent::ConnectionConnected {
595                    connection_id: connection_id.to_string(),
596                }
597            }
598            ConnectionAuthenticated { connection_id, .. } => {
599                // Authentication context is tenant-sensitive. The global
600                // cross-crate bus is not tenant-authorized, so it receives
601                // only a lifecycle marker; rich identity stays on the typed
602                // in-process event and connection route.
603                RvoipCoreCrossCrateEvent::IdentityAssuranceChanged {
604                    connection_id: connection_id.to_string(),
605                    identity_id: None,
606                }
607            }
608            ConnectionPrincipalAuthenticated { connection_id, .. } => {
609                RvoipCoreCrossCrateEvent::IdentityAssuranceChanged {
610                    connection_id: connection_id.to_string(),
611                    identity_id: None,
612                }
613            }
614            ConnectionProgress {
615                connection_id,
616                kind,
617                ..
618            } => RvoipCoreCrossCrateEvent::ConnectionProgress {
619                connection_id: connection_id.to_string(),
620                kind: format!("{:?}", kind),
621            },
622            ConnectionEnded {
623                connection_id,
624                reason,
625                ..
626            } => RvoipCoreCrossCrateEvent::ConnectionEnded {
627                connection_id: connection_id.to_string(),
628                reason: format!("{:?}", reason),
629            },
630            ConnectionFailed {
631                connection_id,
632                detail,
633                ..
634            } => RvoipCoreCrossCrateEvent::ConnectionFailed {
635                connection_id: connection_id.to_string(),
636                detail: detail.clone(),
637            },
638            ConnectionsBridged {
639                bridge_id, a, b, ..
640            } => RvoipCoreCrossCrateEvent::ConnectionsBridged {
641                bridge_id: bridge_id.to_string(),
642                a: a.to_string(),
643                b: b.to_string(),
644            },
645            ConnectionsUnbridged { bridge_id, .. } => {
646                RvoipCoreCrossCrateEvent::ConnectionsUnbridged {
647                    bridge_id: bridge_id.to_string(),
648                }
649            }
650            ConnectionTransferred {
651                connection_id,
652                target,
653                ..
654            } => RvoipCoreCrossCrateEvent::ConnectionTransferred {
655                connection_id: connection_id.to_string(),
656                target: format!("{:?}", target),
657            },
658            ConnectionTransferStatus {
659                connection_id,
660                status,
661                ..
662            } => RvoipCoreCrossCrateEvent::ConnectionProgress {
663                connection_id: connection_id.to_string(),
664                kind: format!("transfer:{status:?}"),
665            },
666            ParticipantJoined {
667                session_id,
668                participant_id,
669                ..
670            } => RvoipCoreCrossCrateEvent::ParticipantJoined {
671                session_id: session_id.to_string(),
672                participant_id: participant_id.to_string(),
673            },
674            ParticipantLeft {
675                session_id,
676                participant_id,
677                ..
678            } => RvoipCoreCrossCrateEvent::ParticipantLeft {
679                session_id: session_id.to_string(),
680                participant_id: participant_id.to_string(),
681            },
682            AiAttached {
683                connection_id,
684                attachment_id,
685                provider_ref,
686                ..
687            } => RvoipCoreCrossCrateEvent::AiAttached {
688                connection_id: connection_id.to_string(),
689                attachment_id: attachment_id.to_string(),
690                provider_ref: provider_ref.clone(),
691            },
692            AiDetached { attachment_id, .. } => RvoipCoreCrossCrateEvent::AiDetached {
693                attachment_id: attachment_id.to_string(),
694            },
695            ListenerAttached { listener_id, .. } => RvoipCoreCrossCrateEvent::ListenerAttached {
696                listener_id: listener_id.to_string(),
697            },
698            ListenerDetached { listener_id, .. } => RvoipCoreCrossCrateEvent::ListenerDetached {
699                listener_id: listener_id.to_string(),
700            },
701            MessageReceived {
702                message_id,
703                conversation_id,
704                ..
705            } => RvoipCoreCrossCrateEvent::MessageReceived {
706                message_id: message_id.to_string(),
707                conversation_id: conversation_id.to_string(),
708            },
709            DataMessageReceived {
710                connection_id,
711                message,
712                ..
713            } => RvoipCoreCrossCrateEvent::DataMessageReceived {
714                connection_id: connection_id.to_string(),
715                body_size: message.bytes.len(),
716                reliability: format!("{:?}", message.reliability),
717            },
718            MessageSent {
719                message_id,
720                conversation_id,
721                ..
722            } => RvoipCoreCrossCrateEvent::MessageSent {
723                message_id: message_id.to_string(),
724                conversation_id: conversation_id.to_string(),
725            },
726            MessageDelivered { message_id, .. } => RvoipCoreCrossCrateEvent::MessageDelivered {
727                message_id: message_id.to_string(),
728            },
729            MessageRead { message_id, .. } => RvoipCoreCrossCrateEvent::MessageRead {
730                message_id: message_id.to_string(),
731            },
732            DtmfReceived {
733                connection_id,
734                digits,
735                ..
736            } => RvoipCoreCrossCrateEvent::DtmfReceived {
737                connection_id: connection_id.to_string(),
738                digits: digits.clone(),
739            },
740            TranscriptTurn {
741                stream_id,
742                speaker,
743                text,
744                confidence,
745                is_final,
746                assigned_provider,
747                ..
748            } => RvoipCoreCrossCrateEvent::TranscriptTurn {
749                stream_id: stream_id.to_string(),
750                speaker: speaker.as_ref().map(|p| p.to_string()),
751                text: text.clone(),
752                confidence: *confidence,
753                is_final: *is_final,
754                assigned_provider: assigned_provider.clone(),
755            },
756            RecordingStarted { recording_id, .. } => RvoipCoreCrossCrateEvent::RecordingStarted {
757                recording_id: recording_id.to_string(),
758            },
759            RecordingStopped { recording_id, .. } => RvoipCoreCrossCrateEvent::RecordingStopped {
760                recording_id: recording_id.to_string(),
761            },
762            RecordingComplete {
763                recording_id,
764                sink,
765                vcon_ref: _,
766                ..
767            } => RvoipCoreCrossCrateEvent::RecordingComplete {
768                recording_id: recording_id.to_string(),
769                sink: sink.clone(),
770            },
771            VconReady {
772                session_id, handle, ..
773            } => RvoipCoreCrossCrateEvent::VconReady {
774                session_id: session_id.to_string(),
775                handle_url: handle.url.clone(),
776                content_hash: handle.content_hash.clone(),
777            },
778            VconRedacted {
779                session_id,
780                old,
781                new,
782                ..
783            } => RvoipCoreCrossCrateEvent::VconRedacted {
784                session_id: session_id.to_string(),
785                old_url: old.url.clone(),
786                new_url: new.url.clone(),
787            },
788            IdentityAssuranceChanged {
789                connection_id,
790                identity_id,
791                ..
792            } => RvoipCoreCrossCrateEvent::IdentityAssuranceChanged {
793                connection_id: connection_id.to_string(),
794                identity_id: identity_id.as_ref().map(|i| i.to_string()),
795            },
796            RegistrationChanged { aor, .. } => {
797                RvoipCoreCrossCrateEvent::RegistrationChanged { aor: aor.clone() }
798            }
799            RegistrationHeartbeat { aor, .. } => {
800                RvoipCoreCrossCrateEvent::RegistrationHeartbeat { aor: aor.clone() }
801            }
802            CapacityReport {
803                tenant_id,
804                active_connections,
805                active_bridges,
806                admission_in_use,
807                ..
808            } => RvoipCoreCrossCrateEvent::CapacityReport {
809                tenant_id: tenant_id.as_ref().map(|t| t.to_string()),
810                active_connections: *active_connections,
811                active_bridges: *active_bridges,
812                admission_in_use: *admission_in_use,
813            },
814            UsageRecord {
815                tenant_id,
816                kind,
817                units,
818                ..
819            } => RvoipCoreCrossCrateEvent::UsageRecord {
820                tenant_id: tenant_id.to_string(),
821                kind: format!("{:?}", kind),
822                units: *units,
823            },
824            Anomaly {
825                kind,
826                connection_id,
827                detail,
828                ..
829            } => RvoipCoreCrossCrateEvent::Anomaly {
830                kind: format!("{:?}", kind),
831                connection_id: connection_id.as_ref().map(|c| c.to_string()),
832                detail: detail.clone(),
833            },
834            MediaQuality {
835                connection_id,
836                snapshot,
837                ..
838            } => RvoipCoreCrossCrateEvent::MediaQuality {
839                connection_id: connection_id.to_string(),
840                jitter_ms: snapshot.jitter_ms,
841                packet_loss_pct: snapshot.packet_loss_pct,
842                mos: snapshot.mos,
843            },
844            BargeInDetected { connection_id, .. } => {
845                // No dedicated cross-crate variant yet; surface as
846                // IdentityAssuranceChanged with None identity so
847                // downstream services notice an event on the bus.
848                RvoipCoreCrossCrateEvent::IdentityAssuranceChanged {
849                    connection_id: connection_id.to_string(),
850                    identity_id: None,
851                }
852            }
853            ActiveSpeakerChanged { connection_id, .. } => {
854                // No dedicated wire variant yet — surface as MediaQuality
855                // with zero loss so downstream crates that don't know
856                // about ActiveSpeaker still see *something* on the bus.
857                RvoipCoreCrossCrateEvent::MediaQuality {
858                    connection_id: connection_id.to_string(),
859                    jitter_ms: 0.0,
860                    packet_loss_pct: 0.0,
861                    mos: None,
862                }
863            }
864            IdentityStepUpRequested { connection_id, .. }
865            | IdentityStepUpResponseReceived { connection_id, .. } => {
866                // P12.6 — no dedicated cross-crate variant yet; surface
867                // as IdentityAssuranceChanged with None identity_id so
868                // downstream services see the round-trip on the bus.
869                // The actual assurance change still emits a separate
870                // IdentityAssuranceChanged event when the consumer calls
871                // `complete_step_up`.
872                RvoipCoreCrossCrateEvent::IdentityAssuranceChanged {
873                    connection_id: connection_id.to_string(),
874                    identity_id: None,
875                }
876            }
877        };
878        RvoipCrossCrateEvent::Core(inner)
879    }
880}
881
882#[cfg(test)]
883mod credential_diagnostic_tests {
884    use super::*;
885
886    #[test]
887    fn enclosing_step_up_event_redacts_credential_and_keeps_live_value() {
888        const CANARY: &str = "core-event-credential-canary\r\nAuthorization: exposed";
889        let event = Event::IdentityStepUpResponseReceived {
890            connection_id: ConnectionId::new(),
891            method: "bearer".into(),
892            credential: CANARY.into(),
893            at: Utc::now(),
894        };
895        let rendered = format!("{event:?}");
896        assert!(!rendered.contains(CANARY), "credential leaked: {rendered}");
897        match event {
898            Event::IdentityStepUpResponseReceived { credential, .. } => {
899                assert_eq!(credential, CANARY)
900            }
901            other => panic!("unexpected event: {other:?}"),
902        }
903    }
904
905    #[test]
906    fn quality_report_debug_redacts_codec_and_hangup_text() {
907        const CANARY: &str = "quality-report-canary\r\nAuthorization: exposed";
908        let report = SessionQualityReport {
909            codec: Some(CANARY.into()),
910            hangup_reason: Some(CANARY.into()),
911            ..SessionQualityReport::default()
912        };
913        let debug = format!("{report:?}");
914        assert!(!debug.contains(CANARY));
915        assert!(debug.contains("codec_present: true"));
916        assert!(debug.contains("hangup_reason_present: true"));
917    }
918}