//! [`RecordTurn`] — the end-of-turn checkpoint use case (ARCHITECTURE §19, lot P6a). use std::sync::Arc; use domain::{ConversationId, ConversationLog, ConversationTurn, HandoffStore, HandoffSummarizer}; use crate::error::AppError; /// Records one conversation turn at a checkpoint: appends it to the canonical /// log, then advances the incremental handoff (ARCHITECTURE §19, décision D19-5). /// /// **Single Responsibility**: materialise *one* end-of-turn checkpoint. It is the /// only place that couples the append-only log (source of truth, [`ConversationLog`]) /// with the cumulative resume summary ([`HandoffStore`]) via the incremental /// [`HandoffSummarizer`]. It consumes **exactly** those three ports (Interface /// Segregation), injected as `Arc` at the composition root. /// /// ## Incremental by contract (no full re-read) /// /// Each call folds **only the new turn** into the previous handoff /// (`fold(prev, &[turn])`), never re-reading the whole thread — exactly the seam /// [`HandoffSummarizer`] was designed for (§19, lot P4). The resulting handoff's /// [`Handoff::up_to`](domain::Handoff::up_to) is the recorded turn's id (guaranteed /// by the summarizer, P4). /// /// ## Best-effort fold, honest `Result` /// /// The fold itself cannot fail (it returns no `Result`, P4 — a future LLM /// summarizer falls back rather than erroring), so the only failures are the /// store operations (`append` / `load` / `save`), which propagate as [`AppError`]. /// This use case stays **honest** and surfaces them; the live wiring (P6b) decides /// whether to swallow a handoff failure rather than block the turn. /// /// ## No debounce here (deferred) /// /// One `append` + one `save` per recorded turn. The debounce optimisation (coalesce /// several rapid turns into a single handoff recompute) is intentionally **deferred** /// — it belongs to the live wiring (P6b), not to this isolated logic. pub struct RecordTurn { log: Arc, handoffs: Arc, summarizer: Arc, } impl RecordTurn { /// Builds the use case from its three injected ports. #[must_use] pub fn new( log: Arc, handoffs: Arc, summarizer: Arc, ) -> Self { Self { log, handoffs, summarizer, } } /// Records `turn` in `conversation` at an end-of-turn checkpoint. /// /// Steps (ordering is contractual): /// 1. **append** the turn to the canonical log (source of truth) ; /// 2. **load** the previous handoff (`None` on a first checkpoint) ; /// 3. **fold** `prev` with the single new turn (incremental, best-effort) ; /// 4. **save** the advanced handoff. /// /// The append happens **first** so the durable source of truth is never behind /// the derived handoff. The turn is cloned for the append because the fold (step /// 3) borrows it as the incremental slice. /// /// # Errors /// [`AppError::Store`] (or [`AppError::NotFound`] for a missing store item) when /// the canonical log append, the handoff load, or the handoff save fails — every /// [`domain::ports::StoreError`] is mapped through the existing `From` impl. The /// fold never fails (P4). pub async fn record( &self, conversation: ConversationId, turn: ConversationTurn, ) -> Result<(), AppError> { // 1. Append to the canonical append-only log (source of truth, D19-1a). self.log.append(conversation, turn.clone()).await?; // 2. Load the previous resume point (absence is never an error, P3). let prev = self.handoffs.load(conversation).await?; // 3. Fold the previous handoff with *only* the new turn (incremental, P4): // no full re-read of the log; best-effort, so no `Result` to propagate. let handoff = self .summarizer .fold(prev, std::slice::from_ref(&turn)) .await; // 4. Persist the advanced handoff (overwrites the previous resume point). self.handoffs.save(conversation, handoff).await?; Ok(()) } }