Skip to main content

kcode_audio_session_ingress/
lib.rs

1#![forbid(unsafe_code)]
2
3use std::collections::HashMap;
4
5use chrono::{DateTime, Utc};
6use kcode_audio_history_handoff::{
7    Error as HandoffError, Handoff, PieceProjection, RecordingProjection,
8};
9use kcode_audio_ingress::{
10    AudioIngress, AudioInput, ConfirmationState, CorrectionPacket, ErrorKind as AudioErrorKind,
11    RecordingConfirmation, RecordingState, RecordingStatus,
12};
13use kcode_session_history::{SessionHistory, SessionRecord};
14use serde_json::Value;
15use uuid::Uuid;
16
17/// Application policy needed to coordinate audio with Session History.
18#[derive(Clone, Debug)]
19pub struct Config {
20    /// Stable application user identifier attributed to accepted recordings.
21    pub user_id: String,
22    /// Effective context window available to an audio-ingress session.
23    pub effective_context_tokens: u64,
24}
25
26/// Admitted recording bytes and source metadata.
27#[derive(Clone, Debug)]
28pub struct RecordingInput {
29    /// Complete WAV bytes already accepted by the transport.
30    pub bytes: Vec<u8>,
31    /// Instant at which the original recording began.
32    pub recorded_at: DateTime<Utc>,
33    /// Original leaf filename, when known.
34    pub original_filename: Option<String>,
35}
36
37/// Result of durably submitting one recording.
38#[derive(Clone, Debug)]
39pub struct RecordingSubmission {
40    /// Current combined state after submission.
41    pub recording: Recording,
42    /// Whether AudioIngress already knew the same bytes.
43    pub deduplicated: bool,
44}
45
46/// Combined recording and memory-ingress status.
47#[derive(Clone, Debug)]
48pub struct Recording {
49    pub id: Uuid,
50    pub sha256: String,
51    pub original_filename: String,
52    pub content_type: &'static str,
53    pub size_bytes: u64,
54    pub source_created_at: String,
55    pub received_at: String,
56    pub updated_at: String,
57    pub status: String,
58    pub transcription_model: String,
59    pub reconciliation_model: String,
60    pub reconciliation_reasoning: String,
61    pub transcription_status: Option<Value>,
62    pub attempt_count: i64,
63    pub next_attempt_at: Option<String>,
64    pub last_error: Option<String>,
65    pub speaker_review: Option<SpeakerReview>,
66    pub transcript_piece_count: usize,
67    pub completed_piece_count: usize,
68}
69
70/// Bounded review summary for one classifier-aware recording.
71#[derive(Clone, Debug)]
72pub struct SpeakerReview {
73    pub clean: bool,
74    pub confirmation_state: ConfirmationState,
75    pub observation_count: usize,
76}
77
78/// One deterministic transcript piece and its Session History lifecycle.
79#[derive(Clone, Debug)]
80pub struct IngressPiece {
81    pub id: String,
82    pub recording_id: Uuid,
83    pub sha256: String,
84    pub original_filename: String,
85    pub source_created_at: String,
86    pub piece_index: u32,
87    pub piece_count: u32,
88    pub transcript_text: String,
89    pub estimated_tokens: u64,
90    pub phase: String,
91    pub provenance_id: Option<String>,
92    pub state: Value,
93    pub version: i64,
94    pub ingress_failure_count: i64,
95    pub ingress_failures: Value,
96    pub created_at: String,
97    pub updated_at: String,
98}
99
100/// Detailed state for one recording.
101#[derive(Clone, Debug)]
102pub struct RecordingHistory {
103    pub recording: Recording,
104    pub final_transcript: Option<String>,
105    pub correction_packet: Option<CorrectionPacket>,
106    pub pieces: Vec<IngressPiece>,
107}
108
109/// Request to retry one transcript piece's memory ingress.
110#[derive(Clone, Debug)]
111pub struct RetryIngress {
112    pub piece_id: String,
113    pub expected_version: i64,
114    /// Optional state retained for 0.2 wire compatibility. When supplied, it
115    /// must exactly equal the current retained Session History state.
116    pub state: Option<Value>,
117}
118
119/// Stable coordinator error category for transport mapping.
120#[derive(Clone, Copy, Debug, Eq, PartialEq)]
121pub enum ErrorKind {
122    InvalidInput,
123    NotFound,
124    Conflict,
125    Internal,
126}
127
128/// Error returned by the audio/session-ingress coordinator.
129#[derive(Debug)]
130pub struct Error {
131    kind: ErrorKind,
132    message: String,
133}
134
135impl Error {
136    fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
137        Self {
138            kind,
139            message: message.into(),
140        }
141    }
142
143    fn invalid(message: impl Into<String>) -> Self {
144        Self::new(ErrorKind::InvalidInput, message)
145    }
146
147    fn conflict(message: impl Into<String>) -> Self {
148        Self::new(ErrorKind::Conflict, message)
149    }
150
151    fn not_found() -> Self {
152        Self::new(
153            ErrorKind::NotFound,
154            "Audio recording or transcript piece not found.",
155        )
156    }
157
158    fn internal(error: impl std::fmt::Display) -> Self {
159        tracing::warn!(%error, "Audio session ingress operation failed");
160        Self::new(
161            ErrorKind::Internal,
162            "An unexpected audio session ingress error occurred.",
163        )
164    }
165
166    pub fn kind(&self) -> ErrorKind {
167        self.kind
168    }
169
170    pub fn message(&self) -> &str {
171        &self.message
172    }
173}
174
175impl std::fmt::Display for Error {
176    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
177        formatter.write_str(&self.message)
178    }
179}
180
181impl std::error::Error for Error {}
182
183/// Cloneable typed coordinator over AudioIngress and Session History.
184#[derive(Clone)]
185pub struct Coordinator {
186    audio: AudioIngress,
187    handoff: Handoff,
188    user_id: String,
189}
190
191impl Coordinator {
192    pub fn new(
193        audio: AudioIngress,
194        history: SessionHistory,
195        config: Config,
196    ) -> Result<Self, Error> {
197        let handoff = Handoff::new(
198            history,
199            config.user_id.clone(),
200            config.effective_context_tokens,
201        )
202        .map_err(handoff_error)?;
203        Ok(Self {
204            audio,
205            handoff,
206            user_id: config.user_id,
207        })
208    }
209
210    pub fn health(&self) -> Result<(), Error> {
211        self.audio.status().map_err(audio_error)?;
212        Ok(())
213    }
214
215    pub async fn submit(&self, input: RecordingInput) -> Result<RecordingSubmission, Error> {
216        let submission = self
217            .audio
218            .submit(AudioInput {
219                user_id: self.user_id.clone(),
220                bytes: input.bytes,
221                recorded_at: input.recorded_at,
222                original_filename: input.original_filename,
223            })
224            .await
225            .map_err(audio_error)?;
226        let recording_status = self
227            .audio
228            .status()
229            .map_err(audio_error)?
230            .recordings
231            .into_iter()
232            .find(|recording| recording.id == submission.recording_id)
233            .ok_or_else(Error::not_found)?;
234        validate_submission_owner(&recording_status, &self.user_id)?;
235
236        let projection = self
237            .handoff
238            .project(std::slice::from_ref(&recording_status))
239            .await
240            .map_err(handoff_error)?
241            .into_iter()
242            .next()
243            .ok_or_else(|| Error::internal("handoff omitted recording projection"))?;
244        let projection = IngressProjection::from_handoff(&recording_status, projection);
245        Ok(RecordingSubmission {
246            recording: Recording::from_status(recording_status, &projection),
247            deduplicated: submission.deduplicated,
248        })
249    }
250
251    pub async fn recordings(&self) -> Result<Vec<Recording>, Error> {
252        let recordings = self
253            .audio
254            .status()
255            .map_err(audio_error)?
256            .recordings
257            .into_iter()
258            .filter(|recording| recording_belongs_to(recording, &self.user_id))
259            .collect::<Vec<_>>();
260        let projections = self
261            .handoff
262            .project(&recordings)
263            .await
264            .map_err(handoff_error)?;
265        if recordings.len() != projections.len() {
266            return Err(Error::internal("handoff returned incomplete projections"));
267        }
268        Ok(recordings
269            .into_iter()
270            .zip(projections)
271            .map(|(recording, projection)| {
272                let projection = IngressProjection::from_handoff(&recording, projection);
273                Recording::from_status(recording, &projection)
274            })
275            .collect())
276    }
277
278    pub async fn recording_by_sha256(&self, sha256: &str) -> Result<Recording, Error> {
279        if sha256.len() != 64 || !sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) {
280            return Err(Error::invalid(
281                "audio SHA-256 must contain exactly 64 hexadecimal characters",
282            ));
283        }
284        let normalized = sha256.to_ascii_lowercase();
285        self.recordings()
286            .await?
287            .into_iter()
288            .find(|recording| recording.sha256 == normalized)
289            .ok_or_else(Error::not_found)
290    }
291
292    pub async fn recording_history(&self, recording_id: Uuid) -> Result<RecordingHistory, Error> {
293        let recording_status = self
294            .audio
295            .status()
296            .map_err(audio_error)?
297            .recordings
298            .into_iter()
299            .find(|recording| {
300                recording.id == recording_id && recording_belongs_to(recording, &self.user_id)
301            })
302            .ok_or_else(Error::not_found)?;
303        let projection = self
304            .handoff
305            .project(std::slice::from_ref(&recording_status))
306            .await
307            .map_err(handoff_error)?
308            .into_iter()
309            .next()
310            .ok_or_else(|| Error::internal("handoff omitted recording projection"))?;
311        let projection = IngressProjection::from_handoff(&recording_status, projection);
312        let final_transcript = match &recording_status.state {
313            RecordingState::Complete { transcript } => Some(transcript.clone()),
314            _ => None,
315        };
316        let correction_packet = recording_status.correction_packet.clone();
317        let recording = Recording::from_status(recording_status, &projection);
318        Ok(RecordingHistory {
319            recording,
320            final_transcript,
321            correction_packet,
322            pieces: projection.pieces,
323        })
324    }
325
326    pub fn retry_recording(&self, recording_id: Uuid) -> Result<(), Error> {
327        let owned = self
328            .audio
329            .status()
330            .map_err(audio_error)?
331            .recordings
332            .into_iter()
333            .any(|recording| {
334                recording.id == recording_id && recording_belongs_to(&recording, &self.user_id)
335            });
336        if !owned {
337            return Err(Error::not_found());
338        }
339        self.audio.retry(recording_id).map_err(audio_error)
340    }
341
342    pub async fn confirm_speakers(
343        &self,
344        confirmation: RecordingConfirmation,
345    ) -> Result<CorrectionPacket, Error> {
346        let recording_id = confirmation.recording_id;
347        let recording = self
348            .audio
349            .status()
350            .map_err(audio_error)?
351            .recordings
352            .into_iter()
353            .find(|recording| {
354                recording.id == recording_id && recording_belongs_to(recording, &self.user_id)
355            })
356            .ok_or_else(Error::not_found)?;
357
358        if let Some(packet) = recording
359            .correction_packet
360            .as_ref()
361            .filter(|packet| confirmation_matches(packet, &confirmation))
362        {
363            self.handoff
364                .synchronize(std::slice::from_ref(&recording))
365                .await
366                .map_err(handoff_error)?;
367            return Ok(packet.clone());
368        }
369        if self
370            .handoff
371            .has_ingress(recording_id)
372            .await
373            .map_err(handoff_error)?
374        {
375            return Err(Error::conflict(
376                "speaker labels are already bound to accepted transcript ingress",
377            ));
378        }
379
380        let packet = self
381            .audio
382            .confirm_speakers(confirmation)
383            .map_err(audio_error)?;
384        let recording = self
385            .audio
386            .status()
387            .map_err(audio_error)?
388            .recordings
389            .into_iter()
390            .find(|recording| {
391                recording.id == recording_id && recording_belongs_to(recording, &self.user_id)
392            })
393            .ok_or_else(Error::not_found)?;
394        self.handoff
395            .synchronize(std::slice::from_ref(&recording))
396            .await
397            .map_err(handoff_error)?;
398        Ok(packet)
399    }
400
401    pub async fn retry_ingress(&self, input: RetryIngress) -> Result<SessionRecord, Error> {
402        let recordings = self.audio.status().map_err(audio_error)?.recordings;
403        self.handoff
404            .retry(
405                &recordings,
406                &input.piece_id,
407                input.expected_version,
408                input.state,
409            )
410            .await
411            .map_err(handoff_error)
412    }
413
414    pub async fn synchronize_completed_transcripts(&self) -> Result<(), Error> {
415        let recordings = self.audio.status().map_err(audio_error)?.recordings;
416        self.handoff
417            .synchronize(&recordings)
418            .await
419            .map_err(handoff_error)
420    }
421}
422
423fn recording_belongs_to(recording: &RecordingStatus, user_id: &str) -> bool {
424    recording.user_id == user_id
425}
426
427fn validate_submission_owner(recording: &RecordingStatus, user_id: &str) -> Result<(), Error> {
428    if recording_belongs_to(recording, user_id) {
429        Ok(())
430    } else {
431        Err(Error::conflict(
432            "identical audio is already attributed to another user",
433        ))
434    }
435}
436
437fn confirmation_matches(packet: &CorrectionPacket, confirmation: &RecordingConfirmation) -> bool {
438    if packet.confirmation_state != ConfirmationState::Confirmed {
439        return false;
440    }
441    let existing = packet
442        .chunks
443        .iter()
444        .flat_map(|chunk| &chunk.observations)
445        .filter_map(|observation| {
446            observation
447                .confirmed_full_name
448                .as_deref()
449                .map(|name| (&observation.observation_key, name))
450        })
451        .collect::<HashMap<_, _>>();
452    existing.len() == confirmation.observations.len()
453        && confirmation.observations.iter().all(|observation| {
454            existing.get(&observation.observation_key).copied()
455                == Some(observation.confirmed_full_name.trim())
456        })
457}
458
459#[derive(Debug, Default)]
460struct IngressProjection {
461    pieces: Vec<IngressPiece>,
462}
463
464impl IngressProjection {
465    fn from_handoff(recording: &RecordingStatus, projection: RecordingProjection) -> Self {
466        Self {
467            pieces: projection
468                .pieces
469                .into_iter()
470                .map(|piece| IngressPiece::from_projection(recording, piece))
471                .collect(),
472        }
473    }
474}
475
476impl Recording {
477    fn from_status(recording: RecordingStatus, projection: &IngressProjection) -> Self {
478        let speaker_review = recording
479            .correction_packet
480            .as_ref()
481            .map(|packet| SpeakerReview {
482                clean: packet.clean,
483                confirmation_state: packet.confirmation_state,
484                observation_count: packet
485                    .chunks
486                    .iter()
487                    .map(|chunk| chunk.observations.len())
488                    .sum(),
489            });
490        let awaiting_speaker_review = speaker_review
491            .as_ref()
492            .is_some_and(|review| review.confirmation_state != ConfirmationState::Confirmed);
493        let (mut status, transcription_status, attempt_count, last_error) = match recording.state {
494            RecordingState::Queued => ("uploaded".into(), None, 0, None),
495            RecordingState::Processing { attempt, progress } => (
496                processing_stage(&progress).into(),
497                serde_json::to_value(progress).ok(),
498                i64::from(attempt),
499                None,
500            ),
501            RecordingState::Complete { .. } if awaiting_speaker_review => {
502                ("speaker_review".into(), None, 0, None)
503            }
504            RecordingState::Complete { .. } => ("ready_for_ingress".into(), None, 0, None),
505            RecordingState::Failed {
506                attempts, error, ..
507            } => ("failed".into(), None, i64::from(attempts), Some(error)),
508        };
509        if !projection.pieces.is_empty() {
510            status = if projection
511                .pieces
512                .iter()
513                .all(|piece| piece.phase == "complete")
514            {
515                "complete".into()
516            } else if projection
517                .pieces
518                .iter()
519                .any(|piece| piece.phase == "ingress_failed")
520            {
521                "ingress_failed".into()
522            } else if projection
523                .pieces
524                .iter()
525                .any(|piece| piece.phase == "ingress_in_progress")
526            {
527                "ingressing".into()
528            } else {
529                "ready_for_ingress".into()
530            };
531        }
532        let completed_piece_count = projection
533            .pieces
534            .iter()
535            .filter(|piece| piece.phase == "complete")
536            .count();
537        Self {
538            id: recording.id,
539            sha256: recording.sha256,
540            original_filename: recording.original_filename,
541            content_type: "audio/wav",
542            size_bytes: recording.size_bytes,
543            source_created_at: recording.recorded_at.to_rfc3339(),
544            received_at: recording.received_at.to_rfc3339(),
545            updated_at: recording.received_at.to_rfc3339(),
546            status,
547            transcription_model: recording.transcription_model,
548            reconciliation_model: recording.reconciliation_model,
549            reconciliation_reasoning: recording.reconciliation_reasoning,
550            transcription_status,
551            attempt_count,
552            next_attempt_at: None,
553            last_error,
554            speaker_review,
555            transcript_piece_count: projection.pieces.len(),
556            completed_piece_count,
557        }
558    }
559}
560
561impl IngressPiece {
562    fn from_projection(recording: &RecordingStatus, piece: PieceProjection) -> Self {
563        let record = piece.record;
564        Self {
565            id: record.id,
566            recording_id: recording.id,
567            sha256: recording.sha256.clone(),
568            original_filename: recording.original_filename.clone(),
569            source_created_at: recording.recorded_at.to_rfc3339(),
570            piece_index: piece.piece_index,
571            piece_count: piece.piece_count,
572            transcript_text: piece.transcript_text,
573            estimated_tokens: piece.estimated_tokens,
574            phase: record.phase,
575            provenance_id: record.provenance_id,
576            state: record.state,
577            version: record.version,
578            ingress_failure_count: record.ingress_failure_count,
579            ingress_failures: record.ingress_failures,
580            created_at: record.started_at,
581            updated_at: record.updated_at,
582        }
583    }
584}
585
586fn processing_stage(status: &kcode_audio_ingress::TranscriptionStatus) -> &'static str {
587    let plan_complete = status.steps.iter().any(|entry| {
588        entry.step == kcode_audio_ingress::Step::PlanChunks
589            && entry.state == kcode_audio_ingress::StepState::Completed
590    });
591    if !plan_complete {
592        return "chunking";
593    }
594    let chunks_complete = status
595        .steps
596        .iter()
597        .filter(|entry| {
598            matches!(
599                entry.step,
600                kcode_audio_ingress::Step::TranscribeChunk { .. }
601            )
602        })
603        .all(|entry| entry.state == kcode_audio_ingress::StepState::Completed);
604    if chunks_complete {
605        let analyses_complete = status
606            .steps
607            .iter()
608            .filter(|entry| matches!(entry.step, kcode_audio_ingress::Step::ParseChunk { .. }))
609            .all(|entry| entry.state == kcode_audio_ingress::StepState::Completed);
610        if !analyses_complete {
611            return "analyzing_speakers";
612        }
613        let training_active = status.steps.iter().any(|entry| {
614            entry.step == kcode_audio_ingress::Step::TrainIdentities
615                && matches!(
616                    entry.state,
617                    kcode_audio_ingress::StepState::Running
618                        | kcode_audio_ingress::StepState::Retrying
619                )
620        });
621        if training_active {
622            "training_speakers"
623        } else {
624            "reconciling"
625        }
626    } else {
627        "transcribing"
628    }
629}
630
631fn audio_error(error: kcode_audio_ingress::Error) -> Error {
632    match error.kind() {
633        AudioErrorKind::InvalidInput => Error::new(ErrorKind::InvalidInput, error.to_string()),
634        AudioErrorKind::NotFound => Error::new(ErrorKind::NotFound, error.to_string()),
635        AudioErrorKind::Conflict => Error::new(ErrorKind::Conflict, error.to_string()),
636        AudioErrorKind::Internal => Error::internal(error),
637    }
638}
639
640fn handoff_error(error: HandoffError) -> Error {
641    match error {
642        HandoffError::InvalidInput(message) => Error::new(ErrorKind::InvalidInput, message),
643        HandoffError::NotFound(message) => Error::new(ErrorKind::NotFound, message),
644        HandoffError::Conflict(message) => Error::new(ErrorKind::Conflict, message),
645        HandoffError::Internal(message) => Error::new(ErrorKind::Internal, message),
646    }
647}
648
649#[cfg(test)]
650mod tests {
651    use std::path::{Path, PathBuf};
652
653    use super::*;
654
655    struct TestRoot(PathBuf);
656
657    impl TestRoot {
658        fn new() -> Self {
659            let path = std::env::temp_dir().join(format!(
660                "kcode-audio-session-ingress-test-{}",
661                Uuid::new_v4()
662            ));
663            std::fs::create_dir(&path).unwrap();
664            Self(path)
665        }
666
667        fn path(&self) -> &Path {
668            &self.0
669        }
670    }
671
672    impl Drop for TestRoot {
673        fn drop(&mut self) {
674            let _ = std::fs::remove_dir_all(&self.0);
675        }
676    }
677
678    fn history(root: &Path) -> SessionHistory {
679        SessionHistory::open(kcode_session_history::Config {
680            directory: root.join("active"),
681            completed_list: root.join("completed.txt"),
682            provider_cost_compatibility: None,
683        })
684        .unwrap()
685    }
686
687    fn completed_recording_for_user(
688        user_id: impl Into<String>,
689        transcript: impl Into<String>,
690    ) -> RecordingStatus {
691        let now = Utc::now();
692        RecordingStatus {
693            id: Uuid::new_v4(),
694            user_id: user_id.into(),
695            sha256: "0".repeat(64),
696            original_filename: "meeting.final.WAV".into(),
697            size_bytes: 42,
698            recorded_at: now,
699            received_at: now,
700            transcription_model: "transcription-model".into(),
701            reconciliation_model: "reconciliation-model".into(),
702            reconciliation_reasoning: "xhigh".into(),
703            state: RecordingState::Complete {
704                transcript: transcript.into(),
705            },
706            correction_packet: None,
707        }
708    }
709
710    fn with_speaker_packet(
711        mut recording: RecordingStatus,
712        confirmed_name: Option<&str>,
713    ) -> RecordingStatus {
714        recording.correction_packet = Some(CorrectionPacket {
715            recording_id: recording.id,
716            user_id: recording.user_id.clone(),
717            sha256: recording.sha256.clone(),
718            original_filename: recording.original_filename.clone(),
719            size_bytes: recording.size_bytes,
720            recorded_at: recording.recorded_at,
721            clean: true,
722            chunk_count: 1,
723            chunks: vec![kcode_audio_ingress::CorrectionChunk {
724                chunk_index: 0,
725                chunk_count: 1,
726                audio_start_ms: 0,
727                audio_end_ms: 1_000,
728                raw_gemini_response: "raw".into(),
729                parsed: kcode_audio_ingress::ParsedChunk {
730                    utterances: Vec::new(),
731                    notes: Vec::new(),
732                    clip_valid: true,
733                    clip_validity_reason: None,
734                    speakers: Vec::new(),
735                },
736                observations: vec![kcode_audio_ingress::CorrectionObservation {
737                    local_label: "Speaker A".into(),
738                    speaker_ordinal: 0,
739                    observation_key: kcode_audio_ingress::ObservationKey {
740                        object_id: format!(
741                            "kcode-audio-ingress/recording/{}/chunk/0",
742                            recording.id
743                        ),
744                        piece_index: 0,
745                    },
746                    candidate: None,
747                    identified_full_name: None,
748                    confirmed_full_name: confirmed_name.map(str::to_owned),
749                }],
750                clean: true,
751            }],
752            confirmation_state: ConfirmationState::Confirmed,
753        });
754        recording
755    }
756
757    #[test]
758    fn recording_filter_is_exactly_scoped_to_the_configured_user() {
759        let own = completed_recording_for_user("own-user", "Own transcript");
760        let foreign = completed_recording_for_user("foreign-user", "Foreign transcript");
761        let visible = [own.clone(), foreign]
762            .into_iter()
763            .filter(|recording| recording_belongs_to(recording, "own-user"))
764            .collect::<Vec<_>>();
765        assert_eq!(visible.len(), 1);
766        assert_eq!(visible[0].id, own.id);
767    }
768
769    #[test]
770    fn cross_user_sha_deduplication_fails_closed() {
771        let mut own = completed_recording_for_user("own-user", "Own transcript");
772        own.sha256 = "a".repeat(64);
773        let mut foreign = completed_recording_for_user("foreign-user", "Foreign transcript");
774        foreign.sha256 = own.sha256.clone();
775
776        assert!(validate_submission_owner(&own, "own-user").is_ok());
777        let error = validate_submission_owner(&foreign, "own-user").unwrap_err();
778        assert_eq!(error.kind(), ErrorKind::Conflict);
779        assert_eq!(
780            error.message(),
781            "identical audio is already attributed to another user"
782        );
783    }
784
785    #[test]
786    fn confirmed_label_retries_must_match_the_complete_mapping() {
787        let recording = with_speaker_packet(
788            completed_recording_for_user("user", "Transcript"),
789            Some("Human Choice"),
790        );
791        let packet = recording.correction_packet.as_ref().unwrap();
792        let key = packet.chunks[0].observations[0].observation_key.clone();
793        let matching = RecordingConfirmation {
794            recording_id: recording.id,
795            observations: vec![kcode_audio_ingress::ObservationConfirmation {
796                observation_key: key.clone(),
797                confirmed_full_name: " Human Choice ".into(),
798            }],
799        };
800        let conflicting = RecordingConfirmation {
801            recording_id: recording.id,
802            observations: vec![kcode_audio_ingress::ObservationConfirmation {
803                observation_key: key,
804                confirmed_full_name: "Different Choice".into(),
805            }],
806        };
807        let incomplete = RecordingConfirmation {
808            recording_id: recording.id,
809            observations: Vec::new(),
810        };
811
812        assert!(confirmation_matches(packet, &matching));
813        assert!(!confirmation_matches(packet, &conflicting));
814        assert!(!confirmation_matches(packet, &incomplete));
815    }
816
817    #[tokio::test]
818    async fn facade_adapts_handoff_projection_and_status_without_drift() {
819        let root = TestRoot::new();
820        let history = history(root.path());
821        let recording = completed_recording_for_user("user", "Transcript");
822        let handoff = Handoff::new(history.clone(), "user".into(), 400).unwrap();
823        handoff
824            .synchronize(std::slice::from_ref(&recording))
825            .await
826            .unwrap();
827
828        let projection = handoff
829            .project(std::slice::from_ref(&recording))
830            .await
831            .unwrap()
832            .remove(0);
833        let projection = IngressProjection::from_handoff(&recording, projection);
834        let pending = Recording::from_status(recording.clone(), &projection);
835        assert_eq!(pending.status, "ready_for_ingress");
836        assert_eq!(pending.transcript_piece_count, 1);
837        assert_eq!(pending.completed_piece_count, 0);
838        assert_eq!(projection.pieces[0].recording_id, recording.id);
839        assert_eq!(projection.pieces[0].sha256, recording.sha256);
840        assert_eq!(projection.pieces[0].piece_index, 0);
841        assert_eq!(projection.pieces[0].piece_count, 1);
842        assert_eq!(projection.pieces[0].transcript_text, "Transcript");
843        assert_eq!(projection.pieces[0].phase, "ingress_pending");
844
845        let record = &projection.pieces[0];
846        history
847            .start_ingress(
848                &record.id,
849                kcode_session_history::StartIngress {
850                    expected_version: record.version,
851                    provenance_id: "test:facade-adaptation".into(),
852                },
853            )
854            .await
855            .unwrap();
856        let projection = handoff
857            .project(std::slice::from_ref(&recording))
858            .await
859            .unwrap()
860            .remove(0);
861        let projection = IngressProjection::from_handoff(&recording, projection);
862        let ingressing = Recording::from_status(recording, &projection);
863        assert_eq!(ingressing.status, "ingressing");
864        assert_eq!(projection.pieces[0].phase, "ingress_in_progress");
865    }
866
867    #[test]
868    fn handoff_errors_preserve_category_and_message() {
869        let cases = [
870            (
871                HandoffError::InvalidInput("invalid".into()),
872                ErrorKind::InvalidInput,
873                "invalid",
874            ),
875            (
876                HandoffError::NotFound("missing".into()),
877                ErrorKind::NotFound,
878                "missing",
879            ),
880            (
881                HandoffError::Conflict("conflict".into()),
882                ErrorKind::Conflict,
883                "conflict",
884            ),
885            (
886                HandoffError::Internal("raw storage message".into()),
887                ErrorKind::Internal,
888                "raw storage message",
889            ),
890        ];
891
892        for (source, kind, message) in cases {
893            let error = handoff_error(source);
894            assert_eq!(error.kind(), kind);
895            assert_eq!(error.message(), message);
896        }
897    }
898}