feat(persistence): P6a — use case RecordTurn (checkpoint fin de tour, isolé)

Logique du checkpoint conversationnel isolée et testable, sans câblage live :
append du tour au log canonique → load handoff → fold incrémental (tour neuf
seul) → save. Mapping StoreError→AppError via From existant. Debounce repoussé.

- application/src/conversation/{mod,record}.rs : RecordTurn::record
- tests : 6 cas (incrémentalité prouvée via summarizer-espion, propagation
  d'erreur sans effet de bord, isolation par conversation) ; suite verte

Câblage sur les seams réels (reply/ask) + composition root = P6b.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 13:20:05 +02:00
parent f3046f3dd8
commit 2a5873dcf0
4 changed files with 480 additions and 0 deletions

View File

@ -0,0 +1,100 @@
//! [`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<dyn …>` 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<dyn ConversationLog>,
handoffs: Arc<dyn HandoffStore>,
summarizer: Arc<dyn HandoffSummarizer>,
}
impl RecordTurn {
/// Builds the use case from its three injected ports.
#[must_use]
pub fn new(
log: Arc<dyn ConversationLog>,
handoffs: Arc<dyn HandoffStore>,
summarizer: Arc<dyn HandoffSummarizer>,
) -> 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(())
}
}