Skip to main content

rvoip_core/
vcon.rs

1//! In-flight vCon builder, per INTERFACE_DESIGN §3.9 / §11.4.
2//!
3//! The builder keeps core-native identifiers while a Session is active.
4//! At finalization its snapshot is converted into the canonical
5//! `rvoip-vcon` model, validated, and serialized with serde.
6
7use crate::identity::IdentityAssurance;
8use crate::ids::{AttachmentId, ParticipantId, StreamId};
9use bytes::Bytes;
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12use std::fmt;
13use uuid::Uuid;
14
15/// Opaque reference to a vCon document.
16///
17/// `RecordingComplete.vcon_ref` is not wired in 0.3.3 and remains `None`;
18/// finalized documents are announced separately through `VconReady`.
19/// These variants preserve the planned reference shape without claiming
20/// that recording completion owns Session-level vCon finalization.
21#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
22#[serde(tag = "kind", rename_all = "kebab-case")]
23pub enum VconRef {
24    /// Local store reference; the uuid resolves through whatever
25    /// `VconStore` the orchestrator was built with.
26    Local { uuid: Uuid },
27    /// Future: HTTPS-resolvable vCon URI. Variant reserved; not
28    /// constructed in v0.
29    Url { url: String },
30}
31
32impl fmt::Debug for VconRef {
33    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
34        formatter.write_str(match self {
35            Self::Local { .. } => "VconRef::Local",
36            Self::Url { .. } => "VconRef::Url",
37        })
38    }
39}
40
41#[derive(Clone)]
42pub struct VconParty {
43    pub participant_id: ParticipantId,
44    pub display_name: Option<String>,
45    /// Decentralized identifier URI, if one was asserted for this party.
46    pub did: Option<String>,
47    /// STIR PASSporT in compact JWS form, if one was captured.
48    pub stir: Option<String>,
49    pub validation: IdentityAssurance,
50}
51
52impl fmt::Debug for VconParty {
53    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
54        formatter
55            .debug_struct("VconParty")
56            .field("display_name_present", &self.display_name.is_some())
57            .field("did_present", &self.did.is_some())
58            .field("stir_present", &self.stir.is_some())
59            .field("validation_kind", &self.validation.kind())
60            .finish()
61    }
62}
63
64#[derive(Clone)]
65pub struct VconDialog {
66    pub kind: VconDialogKind,
67    pub stream_id: Option<StreamId>,
68    pub started: DateTime<Utc>,
69    pub ended: Option<DateTime<Utc>>,
70    pub parties: Vec<ParticipantId>,
71    pub mediatype: Option<String>,
72    /// Inline dialog content. Arbitrary bytes are encoded as base64url
73    /// when the snapshot becomes a canonical vCon.
74    pub body: Option<Bytes>,
75    /// External dialog content. `content_hash` is required whenever
76    /// this URL is present.
77    pub url: Option<String>,
78    pub content_hash: Option<String>,
79}
80
81impl fmt::Debug for VconDialog {
82    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
83        formatter
84            .debug_struct("VconDialog")
85            .field("kind", &self.kind)
86            .field("stream_id_present", &self.stream_id.is_some())
87            .field("started", &self.started)
88            .field("ended", &self.ended)
89            .field("party_count", &self.parties.len())
90            .field("mediatype_present", &self.mediatype.is_some())
91            .field("body_bytes", &self.body.as_ref().map_or(0, Bytes::len))
92            .field("url_present", &self.url.is_some())
93            .field("content_hash_present", &self.content_hash.is_some())
94            .finish()
95    }
96}
97
98#[derive(Clone)]
99pub enum VconDialogKind {
100    Audio,
101    Video,
102    Text,
103    Transfer,
104    Other(String),
105}
106
107impl fmt::Debug for VconDialogKind {
108    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
109        formatter.write_str(match self {
110            Self::Audio => "Audio",
111            Self::Video => "Video",
112            Self::Text => "Text",
113            Self::Transfer => "Transfer",
114            Self::Other(_) => "Other",
115        })
116    }
117}
118
119#[derive(Clone)]
120pub struct VconAnalysis {
121    pub kind: VconAnalysisKind,
122    /// Required by the vCon core schema.
123    pub vendor: String,
124    pub product: Option<String>,
125    pub body: Bytes,
126    pub mediatype: String,
127}
128
129impl fmt::Debug for VconAnalysis {
130    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
131        formatter
132            .debug_struct("VconAnalysis")
133            .field("kind", &self.kind)
134            .field("vendor_present", &!self.vendor.is_empty())
135            .field("product_present", &self.product.is_some())
136            .field("body_bytes", &self.body.len())
137            .field("mediatype_present", &!self.mediatype.is_empty())
138            .finish()
139    }
140}
141
142#[derive(Clone)]
143pub enum VconAnalysisKind {
144    Transcript,
145    Sentiment,
146    Summary,
147    Other(String),
148}
149
150impl fmt::Debug for VconAnalysisKind {
151    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
152        formatter.write_str(match self {
153            Self::Transcript => "Transcript",
154            Self::Sentiment => "Sentiment",
155            Self::Summary => "Summary",
156            Self::Other(_) => "Other",
157        })
158    }
159}
160
161#[derive(Clone)]
162pub struct VconAttachment {
163    pub id: AttachmentId,
164    /// Time the attachment was sent or exchanged.
165    pub started: DateTime<Utc>,
166    /// Party that contributed the attachment.
167    pub party: ParticipantId,
168    /// Dialog to which this attachment belongs.
169    pub dialog: StreamId,
170    pub mediatype: String,
171    pub body: Bytes,
172    pub purpose: Option<String>,
173}
174
175impl fmt::Debug for VconAttachment {
176    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
177        formatter
178            .debug_struct("VconAttachment")
179            .field("started", &self.started)
180            .field("mediatype_present", &!self.mediatype.is_empty())
181            .field("body_bytes", &self.body.len())
182            .field("purpose_present", &self.purpose.is_some())
183            .finish()
184    }
185}
186
187#[derive(Clone)]
188pub struct VconSnapshot {
189    /// Stable document identity allocated when the active builder is created.
190    pub uuid: Uuid,
191    /// Stable creation time allocated with the document identity.
192    pub created_at: DateTime<Utc>,
193    pub parties: Vec<VconParty>,
194    pub dialogs: Vec<VconDialog>,
195    pub analyses: Vec<VconAnalysis>,
196    pub attachments: Vec<VconAttachment>,
197}
198
199impl fmt::Debug for VconSnapshot {
200    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
201        formatter
202            .debug_struct("VconSnapshot")
203            .field("uuid_version", &self.uuid.get_version_num())
204            .field("created_at", &self.created_at)
205            .field("party_count", &self.parties.len())
206            .field("dialog_count", &self.dialogs.len())
207            .field("analysis_count", &self.analyses.len())
208            .field("attachment_count", &self.attachments.len())
209            .finish()
210    }
211}
212
213/// Append-only handle exposed by an active [`crate::Session`] for the
214/// transcription pipeline / harness / SIP-signaling capture to write into.
215/// Owned by the Session; validated, serialized, and persisted at session end.
216pub trait VconBuilderHandle: Send + Sync {
217    fn add_party(&self, party: VconParty);
218    fn add_dialog(&self, dialog: VconDialog);
219    fn add_analysis(&self, analysis: VconAnalysis);
220    fn add_attachment(&self, attachment: VconAttachment);
221    fn snapshot(&self) -> VconSnapshot;
222}
223
224/// P3 — default in-memory implementation of [`VconBuilderHandle`].
225/// Bound to a Session by the Orchestrator on `start_session`;
226/// finalized (snapshotted, validated, encoded as JSON, handed to
227/// `VconStore`) on `end_session`. JWS signing remains an explicit
228/// `rvoip-vcon` operation.
229pub struct DefaultVconBuilder {
230    inner: std::sync::Mutex<VconSnapshot>,
231}
232
233impl DefaultVconBuilder {
234    pub fn new() -> Self {
235        Self::with_identity(new_v8_uuid(), Utc::now())
236    }
237
238    /// Create a builder with a caller-supplied document UUID.
239    pub fn with_uuid(uuid: Uuid) -> Self {
240        Self::with_identity(uuid, Utc::now())
241    }
242
243    /// Create a builder with an explicit identity and timestamp.
244    ///
245    /// This is useful when an upstream recorder owns the identifier or
246    /// when deterministic fixtures need to retain their source timestamp.
247    pub fn with_identity(uuid: Uuid, created_at: DateTime<Utc>) -> Self {
248        Self {
249            inner: std::sync::Mutex::new(VconSnapshot {
250                uuid,
251                created_at,
252                parties: Vec::new(),
253                dialogs: Vec::new(),
254                analyses: Vec::new(),
255                attachments: Vec::new(),
256            }),
257        }
258    }
259}
260
261impl Default for DefaultVconBuilder {
262    fn default() -> Self {
263        Self::new()
264    }
265}
266
267impl VconBuilderHandle for DefaultVconBuilder {
268    fn add_party(&self, party: VconParty) {
269        let mut snapshot = self.inner.lock().expect("vcon builder lock poisoned");
270        if let Some(existing) = snapshot
271            .parties
272            .iter_mut()
273            .find(|existing| existing.participant_id == party.participant_id)
274        {
275            if existing.display_name.is_none() {
276                existing.display_name = party.display_name;
277            }
278            if existing.did.is_none() {
279                existing.did = party.did;
280            }
281            if existing.stir.is_none() {
282                existing.stir = party.stir;
283            }
284            if matches!(&existing.validation, IdentityAssurance::Anonymous)
285                && !matches!(&party.validation, IdentityAssurance::Anonymous)
286            {
287                existing.validation = party.validation;
288            }
289            return;
290        }
291        snapshot.parties.push(party);
292    }
293    fn add_dialog(&self, dialog: VconDialog) {
294        self.inner
295            .lock()
296            .expect("vcon builder lock poisoned")
297            .dialogs
298            .push(dialog);
299    }
300    fn add_analysis(&self, analysis: VconAnalysis) {
301        self.inner
302            .lock()
303            .expect("vcon builder lock poisoned")
304            .analyses
305            .push(analysis);
306    }
307    fn add_attachment(&self, attachment: VconAttachment) {
308        self.inner
309            .lock()
310            .expect("vcon builder lock poisoned")
311            .attachments
312            .push(attachment);
313    }
314    fn snapshot(&self) -> VconSnapshot {
315        let g = self.inner.lock().expect("vcon builder lock poisoned");
316        VconSnapshot {
317            uuid: g.uuid,
318            created_at: g.created_at,
319            parties: g.parties.clone(),
320            dialogs: g.dialogs.clone(),
321            analyses: g.analyses.clone(),
322            attachments: g.attachments.clone(),
323        }
324    }
325}
326
327fn new_v8_uuid() -> Uuid {
328    // uuid's workspace feature set includes random v4 generation but does
329    // not require its optional v8 feature. Start with cryptographically
330    // random bytes, then set the RFC 9562 custom-version and RFC variant
331    // bits explicitly.
332    let mut bytes = *Uuid::new_v4().as_bytes();
333    bytes[6] = (bytes[6] & 0x0f) | 0x80;
334    bytes[8] = (bytes[8] & 0x3f) | 0x80;
335    Uuid::from_bytes(bytes)
336}
337
338#[cfg(feature = "vcon")]
339impl TryFrom<&VconSnapshot> for rvoip_vcon::Vcon {
340    type Error = rvoip_vcon::VconError;
341
342    fn try_from(snapshot: &VconSnapshot) -> Result<Self, Self::Error> {
343        use rvoip_vcon::{
344            Analysis, Attachment, ContentEncoding, ContentHashes, Dialog, DialogKind, Party,
345            PartyIndices, Vcon, VconError,
346        };
347        use std::collections::{HashMap, HashSet};
348
349        let invalid = |message: String| VconError::Invalid(message);
350
351        let mut party_indices = HashMap::with_capacity(snapshot.parties.len());
352        let mut parties = Vec::with_capacity(snapshot.parties.len());
353        for (index, party) in snapshot.parties.iter().enumerate() {
354            let index = u32::try_from(index)
355                .map_err(|_| invalid("vCon contains more than u32::MAX parties".into()))?;
356            if party_indices
357                .insert(party.participant_id.clone(), index)
358                .is_some()
359            {
360                return Err(invalid(format!(
361                    "duplicate participant {} in vCon snapshot",
362                    party.participant_id
363                )));
364            }
365            parties.push(Party {
366                uuid: Some(party.participant_id.to_string()),
367                name: party.display_name.clone(),
368                did: party.did.clone(),
369                stir: party.stir.clone(),
370                validation: Some(party.validation.kind().to_owned()),
371                ..Party::default()
372            });
373        }
374
375        // Index all dialogs first so attachments may reference a dialog
376        // regardless of their relative insertion order.
377        let mut dialog_indices = HashMap::with_capacity(snapshot.dialogs.len());
378        for (index, dialog) in snapshot.dialogs.iter().enumerate() {
379            if let Some(stream_id) = &dialog.stream_id {
380                let index = u32::try_from(index)
381                    .map_err(|_| invalid("vCon contains more than u32::MAX dialogs".into()))?;
382                if dialog_indices.insert(stream_id.clone(), index).is_some() {
383                    return Err(invalid(format!(
384                        "duplicate stream {} in vCon snapshot",
385                        stream_id
386                    )));
387                }
388            }
389        }
390
391        let mut dialogs = Vec::with_capacity(snapshot.dialogs.len() + 1);
392        let mut recording_members = Vec::new();
393        for dialog in &snapshot.dialogs {
394            let kind = match &dialog.kind {
395                VconDialogKind::Audio | VconDialogKind::Video => DialogKind::Recording,
396                VconDialogKind::Text => DialogKind::Text,
397                VconDialogKind::Transfer => DialogKind::Transfer,
398                VconDialogKind::Other(kind) => {
399                    return Err(invalid(format!(
400                        "unsupported core dialog kind {kind:?}; declare a vCon extension instead"
401                    )));
402                }
403            };
404
405            let duration = match dialog.ended {
406                Some(ended) if ended < dialog.started => {
407                    return Err(invalid(
408                        "vCon dialog ended before its start timestamp".into(),
409                    ));
410                }
411                Some(ended) => {
412                    let micros = (ended - dialog.started)
413                        .num_microseconds()
414                        .ok_or_else(|| invalid("vCon dialog duration overflowed".into()))?;
415                    Some(micros as f64 / 1_000_000.0)
416                }
417                None => None,
418            };
419
420            let referenced_parties = dialog
421                .parties
422                .iter()
423                .map(|participant_id| {
424                    party_indices.get(participant_id).copied().ok_or_else(|| {
425                        invalid(format!(
426                            "dialog references unknown participant {}",
427                            participant_id
428                        ))
429                    })
430                })
431                .collect::<Result<Vec<_>, _>>()?;
432
433            let body = dialog
434                .body
435                .as_ref()
436                .map(|body| serde_json::Value::String(rvoip_vcon::encode_base64url(body.as_ref())));
437            let encoding = body.as_ref().map(|_| ContentEncoding::Base64Url);
438            let output_index = u32::try_from(dialogs.len())
439                .map_err(|_| invalid("vCon contains more than u32::MAX dialogs".into()))?;
440            if kind == DialogKind::Recording {
441                recording_members.push((
442                    output_index,
443                    dialog.started,
444                    dialog.ended,
445                    referenced_parties.clone(),
446                ));
447            }
448
449            dialogs.push(Dialog {
450                kind,
451                start: dialog.started,
452                duration,
453                parties: (!referenced_parties.is_empty())
454                    .then_some(PartyIndices::Many(referenced_parties)),
455                mediatype: dialog.mediatype.clone(),
456                body,
457                encoding,
458                url: dialog.url.clone(),
459                content_hash: dialog.content_hash.clone().map(ContentHashes::One),
460                ..Dialog::default()
461            });
462        }
463
464        // Every core snapshot represents one Session. When that Session has
465        // several recording files, append a recording-set after the source
466        // dialogs so their indices (and attachment references) stay stable.
467        if recording_members.len() > 1 {
468            let set_index = u32::try_from(dialogs.len())
469                .map_err(|_| invalid("vCon contains more than u32::MAX dialogs".into()))?;
470            let start = recording_members
471                .iter()
472                .map(|(_, start, _, _)| *start)
473                .min()
474                .expect("recording members is non-empty");
475            let duration = if recording_members
476                .iter()
477                .all(|(_, _, ended, _)| ended.is_some())
478            {
479                let ended = recording_members
480                    .iter()
481                    .filter_map(|(_, _, ended, _)| ended.as_ref())
482                    .max()
483                    .expect("all recording members have an end");
484                let micros = (*ended - start)
485                    .num_microseconds()
486                    .ok_or_else(|| invalid("vCon recording-set duration overflowed".into()))?;
487                Some(micros as f64 / 1_000_000.0)
488            } else {
489                None
490            };
491
492            let mut seen_parties = HashSet::new();
493            let mut set_parties = Vec::new();
494            for (_, _, _, parties) in &recording_members {
495                for party in parties {
496                    if seen_parties.insert(*party) {
497                        set_parties.push(*party);
498                    }
499                }
500            }
501            let recordings = recording_members
502                .iter()
503                .map(|(index, _, _, _)| *index)
504                .collect::<Vec<_>>();
505            for recording in &recordings {
506                dialogs[*recording as usize].recording_set = Some(set_index);
507            }
508            dialogs.push(Dialog {
509                kind: DialogKind::RecordingSet,
510                start,
511                duration,
512                parties: (!set_parties.is_empty()).then_some(PartyIndices::Many(set_parties)),
513                recordings,
514                ..Dialog::default()
515            });
516        }
517
518        let analysis = snapshot
519            .analyses
520            .iter()
521            .map(|item| {
522                let kind = match &item.kind {
523                    VconAnalysisKind::Transcript => "transcript",
524                    VconAnalysisKind::Sentiment => "sentiment",
525                    VconAnalysisKind::Summary => "summary",
526                    VconAnalysisKind::Other(kind) => kind,
527                };
528                Analysis {
529                    kind: kind.to_owned(),
530                    vendor: item.vendor.clone(),
531                    product: item.product.clone(),
532                    mediatype: Some(item.mediatype.clone()),
533                    body: Some(serde_json::Value::String(rvoip_vcon::encode_base64url(
534                        item.body.as_ref(),
535                    ))),
536                    encoding: Some(ContentEncoding::Base64Url),
537                    ..Analysis::default()
538                }
539            })
540            .collect();
541
542        let attachments = snapshot
543            .attachments
544            .iter()
545            .map(|item| {
546                let party = party_indices.get(&item.party).copied().ok_or_else(|| {
547                    invalid(format!(
548                        "attachment {} references unknown participant {}",
549                        item.id, item.party
550                    ))
551                })?;
552                let dialog = dialog_indices.get(&item.dialog).copied().ok_or_else(|| {
553                    invalid(format!(
554                        "attachment {} references unknown dialog {}",
555                        item.id, item.dialog
556                    ))
557                })?;
558                Ok(Attachment {
559                    purpose: item.purpose.clone(),
560                    start: item.started,
561                    party,
562                    dialog,
563                    mediatype: Some(item.mediatype.clone()),
564                    body: Some(serde_json::Value::String(rvoip_vcon::encode_base64url(
565                        item.body.as_ref(),
566                    ))),
567                    encoding: Some(ContentEncoding::Base64Url),
568                    ..Attachment::default()
569                })
570            })
571            .collect::<Result<Vec<_>, VconError>>()?;
572
573        let vcon = Vcon {
574            uuid: snapshot.uuid,
575            vcon: Some("0.4.0".into()),
576            created_at: snapshot.created_at,
577            parties,
578            dialog: dialogs,
579            analysis,
580            attachments,
581            ..Vcon::default()
582        };
583        vcon.validate()?;
584        Ok(vcon)
585    }
586}
587
588/// Convert, validate, and serialize a Session snapshot into an unsigned
589/// canonical vCon document.
590#[cfg(feature = "vcon")]
591pub fn encode_snapshot(snapshot: &VconSnapshot) -> Result<bytes::Bytes, rvoip_vcon::VconError> {
592    let vcon = rvoip_vcon::Vcon::try_from(snapshot)?;
593    Ok(bytes::Bytes::from(serde_json::to_vec(&vcon)?))
594}
595
596#[cfg(test)]
597mod tests {
598    use super::*;
599
600    #[test]
601    fn vcon_ref_local_roundtrips_through_json() {
602        let uuid = Uuid::nil();
603        let v = VconRef::Local { uuid };
604        let json = serde_json::to_value(&v).expect("encode");
605        assert_eq!(json["kind"], "local");
606        assert_eq!(json["uuid"], uuid.to_string());
607        let back: VconRef = serde_json::from_value(json).expect("decode");
608        assert_eq!(v, back);
609    }
610
611    #[test]
612    fn vcon_ref_url_roundtrips_through_json() {
613        let v = VconRef::Url {
614            url: "https://vcons.example/abc123".into(),
615        };
616        let json = serde_json::to_value(&v).expect("encode");
617        assert_eq!(json["kind"], "url");
618        let back: VconRef = serde_json::from_value(json).expect("decode");
619        assert_eq!(v, back);
620    }
621
622    #[test]
623    fn builder_retains_caller_supplied_identity() {
624        let uuid = Uuid::nil();
625        let created_at = DateTime::<Utc>::from_timestamp(1_700_000_000, 0).unwrap();
626        let snapshot = DefaultVconBuilder::with_identity(uuid, created_at).snapshot();
627        assert_eq!(snapshot.uuid, uuid);
628        assert_eq!(snapshot.created_at, created_at);
629    }
630
631    #[test]
632    fn duplicate_party_updates_fill_missing_metadata_without_downgrading() {
633        let builder = DefaultVconBuilder::new();
634        let participant_id = ParticipantId::new();
635        builder.add_party(VconParty {
636            participant_id: participant_id.clone(),
637            display_name: None,
638            did: None,
639            stir: None,
640            validation: IdentityAssurance::Anonymous,
641        });
642        builder.add_party(VconParty {
643            participant_id: participant_id.clone(),
644            display_name: Some("Alice".into()),
645            did: Some("did:example:alice".into()),
646            stir: Some("header.payload.signature".into()),
647            validation: IdentityAssurance::DtlsFingerprint {
648                algorithm: "sha-256".into(),
649                value: "AA:BB".into(),
650            },
651        });
652        builder.add_party(VconParty {
653            participant_id,
654            display_name: None,
655            did: None,
656            stir: None,
657            validation: IdentityAssurance::Anonymous,
658        });
659
660        let snapshot = builder.snapshot();
661        assert_eq!(snapshot.parties.len(), 1);
662        assert_eq!(snapshot.parties[0].display_name.as_deref(), Some("Alice"));
663        assert_eq!(
664            snapshot.parties[0].did.as_deref(),
665            Some("did:example:alice")
666        );
667        assert_eq!(
668            snapshot.parties[0].stir.as_deref(),
669            Some("header.payload.signature")
670        );
671        assert_eq!(snapshot.parties[0].validation.kind(), "dtls-fingerprint");
672    }
673}