Skip to main content

meerkat_core/
session.rs

1//! Session management for Meerkat
2//!
3//! A session represents a conversation history that can be persisted and resumed.
4//!
5//! # Performance
6//!
7//! Sessions use Arc-based copy-on-write for message storage:
8//! - `fork()` shares the message buffer (O(1), no clone)
9//! - Mutation (push) triggers CoW only when refcount > 1
10//! - `push_batch()` adds multiple messages with a single timestamp update
11
12use crate::Provider;
13use crate::generated::{session_document, session_persistence_version_authority};
14use crate::lifecycle::run_primitive::{TurnMetadataOverride, TurnRequestContext};
15use crate::lifecycle::{CoreBoundaryStageError, RunId};
16use crate::peer_meta::PeerMeta;
17use crate::realtime_transcript::{
18    RealtimeTranscriptApplyOutcome, RealtimeTranscriptEvent, RealtimeUserContentIdentity,
19    SESSION_REALTIME_TRANSCRIPT_STATE_KEY,
20};
21use crate::realtime_transcript_revision::{self, SessionRealtimeTranscriptState};
22use crate::realtime_transcript_sidecar::{
23    PreparedRealtimeTranscriptRebase, RealtimeTranscriptSidecarError,
24    RealtimeTranscriptSnapshotReasonV1, SessionRealtimeTranscriptProjection,
25};
26use crate::service::MobToolAuthorityContext;
27use crate::session_durable_config_authority;
28use crate::time_compat::SystemTime;
29#[cfg(target_arch = "wasm32")]
30use crate::tokio;
31use crate::tool_scope::ToolFilter;
32use crate::types::{
33    AssistantBlock, BlockAssistantMessage, ContentBlock, ContentInput, Message, SessionId,
34    StopReason, ToolDef, ToolName, ToolProvenance, ToolResult, Usage, UserMessage,
35};
36use serde::{Deserialize, Deserializer, Serialize, Serializer};
37use sha2::{Digest, Sha256};
38use std::collections::{BTreeMap, BTreeSet, HashMap};
39use std::sync::Arc;
40
41/// Stable logical lineage selected for session identity and fork semantics.
42///
43/// This is domain identity only. It carries no persistence, verification, or
44/// store-currentness authority.
45#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
46#[serde(transparent)]
47pub struct SessionLineageId(String);
48
49impl SessionLineageId {
50    pub fn new(value: impl Into<String>) -> Result<Self, InvalidSessionLineageId> {
51        let value = value.into();
52        if value.trim().is_empty() {
53            return Err(InvalidSessionLineageId);
54        }
55        Ok(Self(value))
56    }
57
58    #[must_use]
59    pub fn for_session(session_id: &SessionId) -> Self {
60        Self(format!("session:{session_id}"))
61    }
62
63    #[must_use]
64    pub fn as_str(&self) -> &str {
65        &self.0
66    }
67}
68
69impl std::fmt::Display for SessionLineageId {
70    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71        self.0.fmt(f)
72    }
73}
74
75impl<'de> Deserialize<'de> for SessionLineageId {
76    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
77    where
78        D: Deserializer<'de>,
79    {
80        let value = String::deserialize(deserializer)?;
81        Self::new(value).map_err(serde::de::Error::custom)
82    }
83}
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub struct InvalidSessionLineageId;
87
88impl std::fmt::Display for InvalidSessionLineageId {
89    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90        f.write_str("session lineage id must not be empty")
91    }
92}
93
94impl std::error::Error for InvalidSessionLineageId {}
95
96/// Logical generation inside one session lineage.
97///
98/// Runtime restarts and store revisions do not change this value.
99#[derive(
100    Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
101)]
102#[serde(transparent)]
103pub struct SessionGeneration(u64);
104
105impl SessionGeneration {
106    pub const INITIAL: Self = Self(0);
107
108    #[must_use]
109    pub const fn new(value: u64) -> Self {
110        Self(value)
111    }
112
113    #[must_use]
114    pub const fn get(self) -> u64 {
115        self.0
116    }
117}
118
119mod digest_accumulator;
120mod head_metadata;
121mod import_0810;
122mod transcript_history;
123
124pub(crate) use digest_accumulator::TranscriptMessages;
125pub use head_metadata::{
126    SessionHeadMetadataCell, SessionHeadMetadataCellIdentity, SessionHeadMetadataCellMutation,
127    SessionHeadMetadataDigest, SessionHeadMetadataIdentity, SessionHeadMetadataProjection,
128    SessionHeadMetadataValueDigest,
129};
130pub(crate) use import_0810::is_released_checkpoint_metadata_key;
131pub use import_0810::{
132    ImportedReleased0810Session, Released0810ImportError, Released0810ImportEvidence,
133    Released0810ImportReceipt, import_released_0810_session,
134    released_0810_transcript_serialized_rows_digest,
135};
136#[cfg(test)]
137pub(crate) use transcript_history::graph::TRANSCRIPT_DIGEST_FORMAT_RELEASED_0810;
138pub(crate) use transcript_history::graph::import_released_0810_history;
139pub(crate) use transcript_history::validate::validate_transcript_history_state;
140use transcript_history::validate::{
141    assistant_tool_use_ids, message_role_name, validate_transcript_tool_result_shape,
142};
143pub use transcript_history::{
144    ProvenReleased0810RewriteRemap, TRANSCRIPT_HISTORY_FORMAT_CURRENT, TranscriptEndpointWitness,
145    TranscriptGraphPrefixAccumulator, TranscriptHistoryState, TranscriptParentAdvance,
146    TranscriptRevisionBody, TranscriptRevisionEdge, TranscriptRewriteAuditReceiptBatch,
147    TranscriptRewriteCommit, TranscriptRewriteParentTransition, TranscriptRewritePatch,
148    TranscriptRewritePrefixAccumulator, TranscriptRewriteRecord, ValidatedTranscriptHistory,
149    ValidatedTranscriptRewriteSuffix, extend_transcript_rewrite_prefix_accumulator,
150    remap_proven_released_0810_rewrite_record, transcript_history_full_body_materializations,
151    transcript_rewrite_prefix_digest,
152};
153
154/// Current session format version.
155///
156/// The persisted `version` byte is mandatory and fail-closed: a stored row
157/// with a missing or non-current version is rejected at the serde boundary by
158/// the generated persistence version authority. The exact released 0.8.10
159/// envelope crosses only the explicit one-time importer; ordinary reads never
160/// silently default or upgrade an envelope.
161pub use crate::generated::session_persistence_version_authority::SESSION_VERSION;
162
163/// Current `SessionMetadata` schema version. Distinct from `SESSION_VERSION`
164/// so `SessionMetadata` can evolve independently of the Session envelope.
165///
166/// Mandatory and fail-closed on read, same contract as `SESSION_VERSION`.
167pub use crate::generated::session_persistence_version_authority::SESSION_METADATA_SCHEMA_VERSION;
168
169/// Current session format version accepted by generated persistence authority.
170pub fn session_version() -> u32 {
171    session_persistence_version_authority::session_envelope_version()
172}
173
174/// Current `SessionMetadata` schema version accepted by generated persistence authority.
175pub fn session_metadata_schema_version() -> u32 {
176    session_persistence_version_authority::session_metadata_schema_version()
177}
178
179/// Typed transcript replacement used to create an edited fork.
180///
181/// Replacements never mutate the source session in place. The owning service
182/// applies this to a forked prefix, producing a new `SessionId`.
183#[derive(Debug, Clone, Serialize, Deserialize)]
184#[serde(tag = "type", rename_all = "snake_case")]
185pub enum TranscriptReplacement {
186    /// Replace the addressed message with a full canonical message.
187    Message { message: Message },
188    /// Replace one user-message content block.
189    UserContentBlock {
190        block_index: usize,
191        block: ContentBlock,
192    },
193    /// Replace one block in a block-assistant message.
194    AssistantBlock {
195        block_index: usize,
196        block: AssistantBlock,
197    },
198    /// Replace one content block inside one tool-result payload.
199    ToolResultContentBlock {
200        result_index: usize,
201        block_index: usize,
202        block: ContentBlock,
203    },
204}
205
206/// Session metadata key for the typed transcript revision graph head.
207pub const SESSION_TRANSCRIPT_HISTORY_STATE_KEY: &str = "session_transcript_history_state_v1";
208
209/// Rolling identity of the exact ordered rewrite-commit
210/// prefix represented by this session.
211///
212/// Kept outside the bulky graph value so a head-canonical cold read can prove
213/// replay coverage from the small head row without materializing commit
214/// history. Typed graph writers update it atomically with the graph.
215pub const SESSION_TRANSCRIPT_REWRITE_PREFIX_AUTHORITY_KEY: &str =
216    "session_transcript_rewrite_prefix_authority_v1";
217
218/// A concrete transcript span selected for same-session rewrite.
219#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
220#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
221#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
222pub enum TranscriptRewriteSelection {
223    /// Pre-semantic-marker range retained for source/API compatibility and
224    /// decoding prior durable records. New commits canonicalize this input to
225    /// [`TranscriptRewriteSelection::EditMessageRange`] before persistence.
226    MessageRange { start: usize, end: usize },
227    /// Current typed ordinary-edit semantic.
228    EditMessageRange { range: TranscriptEditRewriteRange },
229    /// Replace a full transcript from a core-validated compaction rebuild.
230    ///
231    /// The range payload has no public constructor. New values are minted only
232    /// by the validated compaction path; deserialization exists solely for the
233    /// durable transcript graph and is revalidated against its retained bodies.
234    CompactionMessageRange { range: CompactionRewriteRange },
235}
236
237/// Opaque current-format range carried by an ordinary transcript edit.
238#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
239#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
240#[serde(deny_unknown_fields)]
241pub struct TranscriptEditRewriteRange {
242    start: usize,
243    end: usize,
244}
245
246/// Opaque range carried by the typed compaction rewrite semantic.
247#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
248#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
249#[serde(deny_unknown_fields)]
250pub struct CompactionRewriteRange {
251    start: usize,
252    end: usize,
253}
254
255/// Canonical semantic class of a transcript rewrite.
256#[derive(Debug, Clone, Copy, PartialEq, Eq)]
257pub enum TranscriptRewriteSemantic {
258    /// Ordinary same-session edit.
259    Edit,
260    /// Core-validated context compaction.
261    Compaction,
262}
263
264impl TranscriptRewriteSelection {
265    /// Return the selected half-open message range without exposing the
266    /// authority-bearing representation used to classify the rewrite.
267    pub fn bounds(&self) -> (usize, usize) {
268        match self {
269            Self::MessageRange { start, end } => (*start, *end),
270            Self::EditMessageRange { range } => (range.start, range.end),
271            Self::CompactionMessageRange { range } => (range.start, range.end),
272        }
273    }
274
275    pub fn semantic(&self) -> TranscriptRewriteSemantic {
276        match self {
277            Self::MessageRange { .. } | Self::EditMessageRange { .. } => {
278                TranscriptRewriteSemantic::Edit
279            }
280            Self::CompactionMessageRange { .. } => TranscriptRewriteSemantic::Compaction,
281        }
282    }
283
284    fn into_current_edit_semantic(self) -> Self {
285        match self {
286            Self::MessageRange { start, end } => Self::EditMessageRange {
287                range: TranscriptEditRewriteRange { start, end },
288            },
289            current => current,
290        }
291    }
292
293    fn is_legacy_untyped(&self) -> bool {
294        matches!(self, Self::MessageRange { .. })
295    }
296
297    fn validated_compaction(
298        start: usize,
299        end: usize,
300        _authority: &crate::agent::compact::ValidatedCompactionRewrite,
301    ) -> Self {
302        Self::CompactionMessageRange {
303            range: CompactionRewriteRange { start, end },
304        }
305    }
306
307    fn migrated_legacy_compaction(start: usize, end: usize) -> Self {
308        Self::CompactionMessageRange {
309            range: CompactionRewriteRange { start, end },
310        }
311    }
312
313    #[cfg(test)]
314    pub(crate) fn typed_compaction_for_test(start: usize, end: usize) -> Self {
315        Self::CompactionMessageRange {
316            range: CompactionRewriteRange { start, end },
317        }
318    }
319}
320
321/// Audit annotation carried with a transcript rewrite commit.
322///
323/// The free-form kind is for review, debugging, and provenance only. It never
324/// classifies a rewrite as compaction; [`TranscriptRewriteSelection`] owns that
325/// semantic through its opaque typed compaction range.
326#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
327#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
328#[serde(rename_all = "snake_case", deny_unknown_fields)]
329pub struct TranscriptRewriteReason {
330    pub kind: String,
331    #[serde(default, skip_serializing_if = "Option::is_none")]
332    pub note: Option<String>,
333}
334
335impl TranscriptRewriteReason {
336    pub fn new(kind: impl Into<String>) -> Self {
337        Self {
338            kind: kind.into(),
339            note: None,
340        }
341    }
342}
343
344impl std::fmt::Display for TranscriptRewriteReason {
345    /// Human-facing projection consumed by revision-list reads. The typed
346    /// `{kind, note}` audit value is retained; this rendering is derived only
347    /// and never supplies rewrite semantic authority.
348    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
349        match &self.note {
350            Some(note) => write!(f, "{}: {note}", self.kind),
351            None => f.write_str(&self.kind),
352        }
353    }
354}
355
356/// Invalid typed transcript edit request.
357#[derive(Debug, Clone, thiserror::Error)]
358pub enum TranscriptEditError {
359    #[error("message index {message_index} out of bounds for {message_count} messages")]
360    MessageIndexOutOfBounds {
361        message_index: usize,
362        message_count: usize,
363    },
364    #[error("{block_kind} index {block_index} out of bounds for {block_count} blocks")]
365    BlockIndexOutOfBounds {
366        block_kind: &'static str,
367        block_index: usize,
368        block_count: usize,
369    },
370    #[error("replacement expected {expected} at message index {message_index}, found {actual}")]
371    MessageRoleMismatch {
372        message_index: usize,
373        expected: &'static str,
374        actual: &'static str,
375    },
376    #[error("invalid transcript rewrite range {start}..{end} for {message_count} messages")]
377    InvalidRewriteRange {
378        start: usize,
379        end: usize,
380        message_count: usize,
381    },
382    #[error("transcript rewrite does not change transcript revision {revision}")]
383    NoOpRewrite { revision: String },
384    #[error("transcript rewrite parent revision mismatch: expected {expected}, actual {actual}")]
385    RevisionConflict { expected: String, actual: String },
386    #[error("transcript history state is malformed: {0}")]
387    HistoryStateMalformed(String),
388    #[error("invalid transcript shape after rewrite: {0}")]
389    InvalidTranscriptShape(String),
390}
391
392fn canonicalize_digest_image_blocks(blocks: &mut [crate::types::ContentBlock]) {
393    for block in blocks.iter_mut() {
394        if let crate::types::ContentBlock::Image {
395            media_type,
396            data: crate::types::ImageData::Inline { data },
397        } = block
398        {
399            // An inline image hydrates from its blob's own bytes, so its
400            // content-addressed identity equals the blob id the store minted.
401            let blob_id = crate::blob::content_blob_id(media_type, data);
402            *block = crate::types::ContentBlock::Image {
403                media_type: media_type.clone(),
404                data: crate::types::ImageData::Blob { blob_id },
405            };
406        }
407    }
408}
409
410/// Normalize opaque JSON payloads before they participate in transcript identity.
411///
412/// `Session` metadata is buffered through [`serde_json::Value`] on durable
413/// ingress. That buffering is allowed to reorder object keys, while
414/// [`serde_json::value::RawValue`] otherwise preserves the producer's original
415/// spelling. Transcript revisions therefore must bind the JSON value, not its
416/// incidental object-key order or whitespace.
417fn canonicalize_raw_json_for_digest(
418    raw: &serde_json::value::RawValue,
419) -> Box<serde_json::value::RawValue> {
420    // `RawValue` is constructible only from valid JSON. Preserve the exact
421    // payload if that invariant ever changes instead of manufacturing a
422    // different transcript identity.
423    crate::types::canonicalize_raw_json(raw).unwrap_or_else(|_| raw.to_owned())
424}
425
426fn canonicalize_digest_structured_blocks(blocks: &mut [crate::types::ContentBlock]) {
427    for block in blocks {
428        if let crate::types::ContentBlock::Structured { data } = block {
429            *data = canonicalize_raw_json_for_digest(data);
430        }
431    }
432}
433
434/// Canonicalize image payloads to their content-addressed blob identity so the
435/// transcript digest is invariant to inline-vs-blob representation.
436///
437/// The same image hydrated inline for model execution and externalized to a
438/// blob for persistence must share one transcript revision; otherwise a live
439/// session and its durable snapshot would appear "diverged" purely because of
440/// image storage form, and a runtime-backed live session would be discarded as
441/// stale mid-turn.
442fn canonicalize_message_images_for_digest(messages: &[Message]) -> Vec<Message> {
443    let mut canonical = messages.to_vec();
444    for message in &mut canonical {
445        canonicalize_message_images_for_digest_in_place(message);
446    }
447    canonical
448}
449
450fn canonicalize_message_images_for_digest_in_place(message: &mut Message) {
451    match message {
452        Message::User(user) => canonicalize_digest_image_blocks(&mut user.content),
453        Message::ToolResults { results, .. } => {
454            for result in results.iter_mut() {
455                canonicalize_digest_image_blocks(&mut result.content);
456            }
457        }
458        Message::SystemNotice(notice) => {
459            for block in &mut notice.blocks {
460                match block {
461                    crate::types::SystemNoticeBlock::Comms { content, .. }
462                    | crate::types::SystemNoticeBlock::ExternalEvent { content, .. } => {
463                        canonicalize_digest_image_blocks(content);
464                    }
465                    _ => {}
466                }
467            }
468        }
469        _ => {}
470    }
471}
472
473fn canonicalize_message_raw_json_for_digest_in_place(message: &mut Message) {
474    match message {
475        Message::User(user) => canonicalize_digest_structured_blocks(&mut user.content),
476        Message::ToolResults { results, .. } => {
477            for result in results.iter_mut() {
478                canonicalize_digest_structured_blocks(&mut result.content);
479            }
480        }
481        Message::SystemNotice(notice) => {
482            for block in &mut notice.blocks {
483                match block {
484                    crate::types::SystemNoticeBlock::Comms { content, .. }
485                    | crate::types::SystemNoticeBlock::ExternalEvent { content, .. } => {
486                        canonicalize_digest_structured_blocks(content);
487                    }
488                    _ => {}
489                }
490            }
491        }
492        Message::BlockAssistant(assistant) => {
493            for block in &mut assistant.blocks {
494                if let crate::types::AssistantBlock::ToolUse { args, .. } = block {
495                    *args = canonicalize_raw_json_for_digest(args);
496                }
497            }
498        }
499        _ => {}
500    }
501}
502
503/// Validate only the compact current graph from a raw slice.
504///
505/// Released full-body history refuses before body decoding, making this the
506/// bounded doctor/diagnostic seam.
507pub fn validate_current_persisted_transcript_history_slice(
508    bytes: &[u8],
509) -> Result<u64, serde_json::Error> {
510    let rewrite_count =
511        transcript_history::graph::validate_current_transcript_history_slice(bytes)?;
512    u64::try_from(rewrite_count).map_err(|_| {
513        persisted_session_decode_error("persisted transcript-history occurrence count exceeds u64")
514    })
515}
516
517/// Shared parsed form of the current transcript-history graph.
518///
519/// Guards and the per-append head refresh need the TYPED graph; parsing the
520/// metadata value is O(graph), and a turn boundary parsed it twice (incoming
521/// and previous) plus once more per append. The typed installer caches the
522/// exact state it just serialized; readers share it by `Arc`. Every unchecked
523/// write to the history key clears the parsed state.
524#[derive(Debug, Default)]
525pub(crate) struct SharedTranscriptHistoryState {
526    inner: std::sync::Mutex<Option<std::sync::Arc<TranscriptHistoryState>>>,
527}
528
529impl Clone for SharedTranscriptHistoryState {
530    fn clone(&self) -> Self {
531        Self {
532            inner: std::sync::Mutex::new(self.locked().clone()),
533        }
534    }
535}
536
537impl SharedTranscriptHistoryState {
538    fn locked(&self) -> std::sync::MutexGuard<'_, Option<std::sync::Arc<TranscriptHistoryState>>> {
539        self.inner
540            .lock()
541            .unwrap_or_else(std::sync::PoisonError::into_inner)
542    }
543
544    fn clear(&self) {
545        *self.locked() = None;
546    }
547
548    fn set(&self, state: std::sync::Arc<TranscriptHistoryState>) {
549        *self.locked() = Some(state);
550    }
551
552    fn get(&self) -> Option<std::sync::Arc<TranscriptHistoryState>> {
553        self.locked().clone()
554    }
555}
556
557/// Timestamp sentinel used when erasing construction bookkeeping from the
558/// digest form. `created_at` always serializes, so a fixed value keeps the
559/// canonical bytes deterministic.
560fn digest_timestamp_sentinel() -> crate::types::MessageTimestamp {
561    chrono::DateTime::<chrono::Utc>::UNIX_EPOCH
562}
563
564/// Canonicalize messages to their conversational content before hashing so the
565/// transcript revision is a content address, not a construction record.
566///
567/// Three normalizations compose:
568/// - image payloads collapse to their content-addressed blob identity
569///   ([`canonicalize_message_images_for_digest`]);
570/// - opaque JSON payloads bind their recursively key-sorted value rather than
571///   producer spelling, so metadata buffering cannot change a revision;
572/// - per-construction bookkeeping is erased: [`TranscriptMessageIdentity`]
573///   (run/interaction ids are runtime-binding atoms — a re-created authority
574///   re-stamps them) and `created_at` timestamps. A resume that re-projects
575///   the same conversation through a new runtime authority must digest to the
576///   same revision as the persisted row, or the append-only save guard
577///   strands the session on restart (fails closed with
578///   `TranscriptContinuityViolation`).
579///
580/// Typed semantic facts stay in the digest — `transcript_role`,
581/// `render_metadata`, notice kinds and blocks — because changing them changes
582/// the transcript's meaning.
583pub(crate) fn canonicalize_messages_for_digest(messages: &[Message]) -> Vec<Message> {
584    let mut canonical = canonicalize_message_images_for_digest(messages);
585    for message in &mut canonical {
586        canonicalize_message_raw_json_for_digest_in_place(message);
587        erase_message_construction_bookkeeping(message);
588    }
589    canonical
590}
591
592/// Frozen semantic projection minted by released 0.8.10 format-2 revisions.
593///
594/// The one-time importer uses this only while proving predecessor graph
595/// identities. Current code must never mint a new revision from it.
596pub(crate) fn canonicalize_released_0810_messages_for_digest(messages: &[Message]) -> Vec<Message> {
597    let mut canonical = canonicalize_message_images_for_digest(messages);
598    for message in &mut canonical {
599        erase_message_construction_bookkeeping(message);
600    }
601    canonical
602}
603
604fn erase_message_construction_bookkeeping(message: &mut Message) {
605    match message {
606        Message::System(system) => {
607            system.created_at = digest_timestamp_sentinel();
608        }
609        Message::SystemNotice(notice) => {
610            notice.created_at = digest_timestamp_sentinel();
611        }
612        Message::User(user) => {
613            user.identity = crate::types::TranscriptMessageIdentity::default();
614            user.created_at = digest_timestamp_sentinel();
615        }
616        Message::BlockAssistant(assistant) => {
617            assistant.identity = crate::types::TranscriptMessageIdentity::default();
618            assistant.created_at = digest_timestamp_sentinel();
619        }
620        Message::ToolResults { created_at, .. } => {
621            *created_at = digest_timestamp_sentinel();
622        }
623    }
624}
625
626/// Per-message projection of [`canonicalize_messages_for_digest`].
627///
628/// Transcript canonicalization is element-wise, so the identity byte stream a
629/// transcript digest hashes is `"[" + json(c(m0)) + "," + json(c(m1)) + ... +
630/// "]"`. [`digest_accumulator`] folds exactly these per-message bytes, which
631/// is why an incremental midstate reproduces the format-2 digest value
632/// unchanged. `canonicalize_messages_for_digest_is_element_wise` pins the
633/// equivalence.
634pub(crate) fn canonicalize_message_for_digest(message: &Message) -> Message {
635    let mut canonical = message.clone();
636    canonicalize_message_images_for_digest_in_place(&mut canonical);
637    canonicalize_message_raw_json_for_digest_in_place(&mut canonical);
638    erase_message_construction_bookkeeping(&mut canonical);
639    canonical
640}
641
642pub fn transcript_messages_digest(messages: &[Message]) -> Result<String, serde_json::Error> {
643    sha256_json_digest(&canonicalize_messages_for_digest(messages))
644}
645
646/// Full transcript digest that does NOT bump the content-digest budget
647/// counter.
648///
649/// Reserved for focused meerkat-core unit-test witness cross-checks. Downstream
650/// debug/integration builds deliberately do not execute it: verification
651/// scaffolding must not turn ordinary runtime work back into O(document).
652pub(crate) fn transcript_messages_digest_uncounted(
653    messages: &[Message],
654) -> Result<String, serde_json::Error> {
655    let canonical = canonicalize_messages_for_digest(messages);
656    let bytes = serde_json::to_vec(&canonical)?;
657    Ok(format!("sha256:{:x}", Sha256::digest(bytes)))
658}
659
660fn sha256_json_digest<T: Serialize + ?Sized>(value: &T) -> Result<String, serde_json::Error> {
661    crate::digest_observability::record_content_digest_computation();
662    let bytes = serde_json::to_vec(value)?;
663    crate::digest_observability::record_content_digest_bytes(bytes.len() as u64);
664    let digest = Sha256::digest(bytes);
665    let mut out = String::with_capacity(digest.len() * 2);
666    const HEX: &[u8; 16] = b"0123456789abcdef";
667    for byte in digest {
668        out.push(HEX[(byte >> 4) as usize] as char);
669        out.push(HEX[(byte & 0x0f) as usize] as char);
670    }
671    Ok(format!("sha256:{out}"))
672}
673
674/// A conversation session with full history
675///
676/// Uses Arc<Vec<Message>> internally for efficient forking (copy-on-write).
677/// Process-local derived caches for the transcript-history graph.
678///
679/// Grouped behind ONE pointer deliberately. `Session` is embedded throughout
680/// the agent's nested async state machine, whose futures compose sizes
681/// additively, so every inline byte here is paid again at each spawn depth —
682/// and the CLI's full-tools spawn runs against a literal 2 MB production stack
683/// budget, pinned by
684/// `tools_full_with_explicit_auth_binding_can_spawn_within_production_stack_budget`.
685/// Holding these caches inline grew `Session` from 136 to 528 bytes and
686/// overflowed that stack. None is persisted or part of a session's identity;
687/// all are rebuildable from durable session fields.
688#[derive(Debug, Default, Clone)]
689pub(crate) struct SessionHistoryCaches {
690    /// Shared parsed form of the current history graph.
691    shared_state: SharedTranscriptHistoryState,
692    /// Actor-local authenticated-map baseline and coalesced dirty-key set for
693    /// metadata carried out of line by HeadCanonical persistence.
694    ///
695    /// This is structural continuation state rather than a value cache:
696    /// ordinary preparation canonicalizes only changed cells, and a durable
697    /// acknowledgement advances the exact sparse-Merkle baseline. Cold
698    /// materialization and explicit 0.8.10 activation install a fully verified
699    /// snapshot before ordinary delta writes are admitted.
700    head_canonical_metadata: head_metadata::SessionHeadMetadataTracker,
701}
702
703fn head_canonical_metadata_cell_carries_key(key: &str) -> bool {
704    !import_0810::is_released_checkpoint_metadata_key(key)
705        && !matches!(
706            key,
707            SESSION_TRANSCRIPT_HISTORY_STATE_KEY
708                | SESSION_TRANSCRIPT_REWRITE_PREFIX_AUTHORITY_KEY
709                | SESSION_REALTIME_TRANSCRIPT_STATE_KEY
710        )
711}
712
713#[cfg(test)]
714std::thread_local! {
715    /// Per-test-thread observability for exact metadata-value canonicalization.
716    ///
717    /// The Rust test harness runs independent tests concurrently in one
718    /// process. A process-global counter lets unrelated HeadCanonical fixture
719    /// construction inflate another test's O(delta) budget, so it cannot
720    /// certify how much work the measured caller performed.
721    static SESSION_HEAD_METADATA_CANONICALIZATION_COUNT: std::cell::Cell<u64> =
722        const { std::cell::Cell::new(0) };
723}
724
725#[cfg(test)]
726pub(crate) fn reset_session_head_metadata_canonicalization_count() {
727    SESSION_HEAD_METADATA_CANONICALIZATION_COUNT.set(0);
728}
729
730#[cfg(test)]
731pub(crate) fn session_head_metadata_canonicalization_count() -> u64 {
732    SESSION_HEAD_METADATA_CANONICALIZATION_COUNT.get()
733}
734
735#[cfg(test)]
736pub(crate) fn record_session_head_metadata_canonicalization() {
737    SESSION_HEAD_METADATA_CANONICALIZATION_COUNT.set(
738        SESSION_HEAD_METADATA_CANONICALIZATION_COUNT
739            .get()
740            .saturating_add(1),
741    );
742}
743
744#[derive(Debug, Clone)]
745pub struct Session {
746    /// Persisted envelope format version, validated fail-closed on read by
747    /// the generated persistence version authority.
748    version: u32,
749    /// Unique identifier
750    id: SessionId,
751    /// All messages in order (Arc for CoW on fork) plus the incremental
752    /// transcript-digest accumulator that owns them.
753    ///
754    /// The buffer is deliberately wrapped: [`TranscriptMessages`] exposes no
755    /// `DerefMut`, so every message mutation must name one of its typed
756    /// mutators, and each mutator states whether the retained digest midstate
757    /// survives. That makes the accumulator's invalidation set exhaustive by
758    /// construction instead of by convention.
759    pub(crate) messages: TranscriptMessages,
760    /// When the session was created
761    created_at: SystemTime,
762    /// When the session was last updated
763    updated_at: SystemTime,
764    /// Arbitrary metadata
765    metadata: serde_json::Map<String, serde_json::Value>,
766    /// Typed in-memory realtime reducer projection plus its authenticated
767    /// HeadCanonical component-event suffix.
768    ///
769    /// The accumulated reducer state is deliberately absent from `metadata`
770    /// during ordinary operation. WholeBlob serialization injects it only at
771    /// that exceptional O(document) representation boundary; HeadCanonical
772    /// binds the compact event-prefix authority and persists only new typed
773    /// records.
774    realtime_transcript: Box<SessionRealtimeTranscriptProjection>,
775    /// Derived actor-local indexes and structural continuation state.
776    ///
777    /// The transcript and authenticated store projections remain authority.
778    /// These caches accelerate terminal-notice membership, share the already
779    /// validated compact graph, and retain the sparse HeadCanonical metadata
780    /// baseline.
781    history_caches: Box<SessionHistoryCaches>,
782    /// Whether transcript-history metadata has already crossed a validating,
783    /// compacting authority boundary in this in-memory session.
784    ///
785    /// This is derived cache state only, never persisted authority. Typed
786    /// transcript mutations install validated state; deserialization validates
787    /// before setting it. Any unchecked history mutation invalidates the cache
788    /// so serialization retains the fail-closed corrupt-snapshot contract.
789    transcript_history_metadata_validation: TranscriptHistoryMetadataValidation,
790    /// Cumulative token usage across all LLM calls in this session
791    usage: Usage,
792}
793
794#[derive(Debug, Clone, Copy, PartialEq, Eq)]
795enum TranscriptHistoryMetadataValidation {
796    Validated,
797    RequiresValidation,
798}
799
800/// Serde helper for Session serialization (flattens Arc)
801#[derive(Deserialize)]
802#[serde(rename_all = "snake_case", deny_unknown_fields)]
803struct SessionSerde {
804    version: u32,
805    id: SessionId,
806    messages: Vec<Message>,
807    created_at: SystemTime,
808    updated_at: SystemTime,
809    #[serde(default)]
810    metadata: serde_json::Map<String, serde_json::Value>,
811    #[serde(default)]
812    usage: Usage,
813}
814
815/// Borrowed serialization view for Session. The persisted shape deliberately
816/// stays lockstep with `SessionSerde`, but large transcripts and metadata are
817/// streamed directly instead of being deep-cloned before serde sees them.
818#[derive(Serialize)]
819#[serde(rename_all = "snake_case")]
820struct SessionSerdeRef<'a> {
821    version: u32,
822    id: &'a SessionId,
823    messages: &'a [Message],
824    created_at: &'a SystemTime,
825    updated_at: &'a SystemTime,
826    metadata: &'a serde_json::Map<String, serde_json::Value>,
827    usage: &'a Usage,
828}
829
830/// Borrowed transient WholeBlob metadata overlay.
831///
832/// The live metadata map never owns the transcript graph or realtime
833/// projection. WholeBlob is the one representation that needs those values
834/// inline, so this serializer streams the base map and the typed projections
835/// into one object without cloning the map or constructing a graph-sized
836/// `serde_json::Value` shadow.
837struct SessionWholeBlobMetadataRef<'a> {
838    base: &'a serde_json::Map<String, serde_json::Value>,
839    history: Option<&'a TranscriptHistoryState>,
840    realtime: Option<&'a SessionRealtimeTranscriptState>,
841}
842
843impl Serialize for SessionWholeBlobMetadataRef<'_> {
844    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
845    where
846        S: Serializer,
847    {
848        use serde::ser::SerializeMap;
849
850        let mut map = serializer.serialize_map(None)?;
851        for (key, value) in self.base {
852            if key == SESSION_REALTIME_TRANSCRIPT_STATE_KEY
853                || (self.history.is_some()
854                    && (key == SESSION_TRANSCRIPT_HISTORY_STATE_KEY
855                        || key == SESSION_TRANSCRIPT_REWRITE_PREFIX_AUTHORITY_KEY))
856            {
857                continue;
858            }
859            map.serialize_entry(key, value)?;
860        }
861        if let Some(history) = self.history {
862            map.serialize_entry(SESSION_TRANSCRIPT_HISTORY_STATE_KEY, history)?;
863            map.serialize_entry(
864                SESSION_TRANSCRIPT_REWRITE_PREFIX_AUTHORITY_KEY,
865                history.rewrite_prefix(),
866            )?;
867        }
868        if let Some(realtime) = self.realtime {
869            map.serialize_entry(SESSION_REALTIME_TRANSCRIPT_STATE_KEY, realtime)?;
870        }
871        map.end()
872    }
873}
874
875#[derive(Serialize)]
876#[serde(rename_all = "snake_case")]
877struct SessionWholeBlobSerdeRef<'a> {
878    version: u32,
879    id: &'a SessionId,
880    messages: &'a [Message],
881    created_at: &'a SystemTime,
882    updated_at: &'a SystemTime,
883    metadata: SessionWholeBlobMetadataRef<'a>,
884    usage: &'a Usage,
885}
886
887/// Bind every persisted field of `session` into the borrowed encode view.
888///
889/// This is the exhaustiveness anchor for the durable envelope: a persisted
890/// field added to [`SessionSerdeRef`] stops this construction from compiling,
891/// and every site that destructures the returned view must then classify the
892/// addition instead of silently dropping it. `metadata_override` substitutes
893/// the compacted map the snapshot seam builds in place of the live one.
894fn persisted_envelope_ref<'a>(
895    session: &'a Session,
896    metadata_override: Option<&'a serde_json::Map<String, serde_json::Value>>,
897) -> SessionSerdeRef<'a> {
898    SessionSerdeRef {
899        version: session.version,
900        id: &session.id,
901        messages: session.messages(),
902        created_at: &session.created_at,
903        updated_at: &session.updated_at,
904        metadata: metadata_override.unwrap_or(&session.metadata),
905        usage: &session.usage,
906    }
907}
908
909impl Serialize for Session {
910    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
911    where
912        S: Serializer,
913    {
914        let _digest_site = crate::digest_observability::enter_digest_site(
915            crate::digest_observability::DIGEST_SITE_ENCODE,
916        );
917        if import_0810::contains_released_checkpoint_metadata(&self.metadata) {
918            return Err(<S::Error as serde::ser::Error>::custom(
919                "released checkpoint metadata cannot be serialized by the current Session domain",
920            ));
921        }
922        if self.transcript_history_metadata_validation
923            == TranscriptHistoryMetadataValidation::RequiresValidation
924            && (self
925                .metadata
926                .contains_key(SESSION_TRANSCRIPT_HISTORY_STATE_KEY)
927                || self.history_caches.shared_state.get().is_some())
928        {
929            return Err(<S::Error as serde::ser::Error>::custom(
930                "transcript-history graph lacks verified materialization or construction authority",
931            ));
932        }
933        let history = self.history_caches.shared_state.get();
934        let serde_repr = SessionWholeBlobSerdeRef {
935            version: self.version,
936            id: &self.id,
937            messages: self.messages(),
938            created_at: &self.created_at,
939            updated_at: &self.updated_at,
940            metadata: SessionWholeBlobMetadataRef {
941                base: &self.metadata,
942                history: history.as_deref(),
943                realtime: self.realtime_transcript.whole_blob_projection(),
944            },
945            usage: &self.usage,
946        };
947        serde_repr.serialize(serializer)
948    }
949}
950
951#[derive(Debug, Clone, Copy, PartialEq, Eq)]
952enum TranscriptHistoryWireKind {
953    Released0810,
954    Current,
955}
956
957fn transcript_history_wire_kind(
958    metadata: &serde_json::Map<String, serde_json::Value>,
959) -> Result<Option<TranscriptHistoryWireKind>, String> {
960    let Some(value) = metadata.get(SESSION_TRANSCRIPT_HISTORY_STATE_KEY) else {
961        return Ok(None);
962    };
963    let object = value
964        .as_object()
965        .ok_or_else(|| "transcript-history graph must be an object".to_string())?;
966    match object.get("format") {
967        None => Ok(Some(TranscriptHistoryWireKind::Released0810)),
968        Some(serde_json::Value::String(format)) if format == TRANSCRIPT_HISTORY_FORMAT_CURRENT => {
969            Ok(Some(TranscriptHistoryWireKind::Current))
970        }
971        Some(serde_json::Value::String(format)) => {
972            Err(format!("unsupported transcript graph format {format}"))
973        }
974        Some(_) => Err("transcript graph format must be a string".to_string()),
975    }
976}
977
978/// Decode and compact the transient transcript-history wire value, removing it
979/// from ordinary metadata and returning the singular typed in-memory graph.
980///
981/// Returning the proof is the sealed-capability seam: the graph this function
982/// just validated used to be dropped on the floor, so the first consumer after
983/// a decode re-parsed the very value serialized from it one statement earlier.
984/// `Ok(None)` means the metadata carries no transcript-history graph at all.
985fn compact_transcript_history_metadata_for_snapshot(
986    metadata: &mut serde_json::Map<String, serde_json::Value>,
987) -> Result<Option<std::sync::Arc<TranscriptHistoryState>>, String> {
988    let Some(value) = metadata.remove(SESSION_TRANSCRIPT_HISTORY_STATE_KEY) else {
989        return Ok(None);
990    };
991    let state: TranscriptHistoryState =
992        serde_json::from_value(value).map_err(|error| error.to_string())?;
993    // `TranscriptHistoryState::deserialize` has already performed the full
994    // current-graph validation. Current graphs carry no mechanical revision
995    // bodies to prune, so validating again here only repeats every retained
996    // rewrite-prefix serialization before installing the exact state that was
997    // just proved.
998    metadata.remove(SESSION_TRANSCRIPT_REWRITE_PREFIX_AUTHORITY_KEY);
999    Ok(Some(std::sync::Arc::new(state)))
1000}
1001
1002impl ValidatedTranscriptHistory {
1003    /// Seal one compact transcript graph reconstructed from exact
1004    /// HeadCanonical rows and persisted graph edges.
1005    ///
1006    /// This is a store-ingress capability, not a general graph constructor.
1007    /// The graph implementation revalidates the anchor, ordered edges, and both
1008    /// physical-head prefix authorities before this proof can be minted.
1009    #[doc(hidden)]
1010    pub fn from_store_replayed_compact_graph(
1011        anchor_revision: String,
1012        anchor_messages: Vec<Message>,
1013        anchor_row_prefix: crate::session_store::SessionMessageRowPrefixAccumulator,
1014        edges: Vec<TranscriptRevisionEdge>,
1015        expected_rewrite_prefix: &TranscriptRewritePrefixAccumulator,
1016        expected_graph_prefix: &TranscriptGraphPrefixAccumulator,
1017    ) -> Result<Self, TranscriptEditError> {
1018        let state = TranscriptHistoryState::from_store_replayed_compact_graph(
1019            anchor_revision,
1020            anchor_messages,
1021            anchor_row_prefix,
1022            edges,
1023            expected_rewrite_prefix,
1024            expected_graph_prefix,
1025        )?;
1026        Ok(Self::adopt_session_validated(std::sync::Arc::new(state)))
1027    }
1028
1029    /// Rebuild and seal a graph from generation-bearing rewrite records while
1030    /// reusing an optional already-proved prefix.
1031    ///
1032    /// The record builder validates every unproved endpoint body and edit
1033    /// relation, preserves only byte-equal bodies/commits from `proved`,
1034    /// checks occurrence contiguity and every bridge, and derives the rolling
1035    /// prefix authority as it appends. Those are exactly the facts the full
1036    /// graph validator would re-derive over the result, so this returns the
1037    /// proof-bearing capability directly instead of immediately hashing every
1038    /// retained body a second time.
1039    pub fn from_rewrite_records_with_proved<I>(
1040        records: I,
1041        proved: Option<&ValidatedTranscriptHistory>,
1042    ) -> Result<Option<Self>, TranscriptEditError>
1043    where
1044        I: IntoIterator<Item = TranscriptRewriteRecord>,
1045    {
1046        Ok(
1047            TranscriptHistoryState::from_rewrite_records_with_proved(records, proved)?.map(
1048                |state| {
1049                    ValidatedTranscriptHistory::adopt_session_validated(std::sync::Arc::new(state))
1050                },
1051            ),
1052        )
1053    }
1054}
1055
1056impl<'de> Deserialize<'de> for Session {
1057    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1058    where
1059        D: Deserializer<'de>,
1060    {
1061        let _digest_site = crate::digest_observability::enter_digest_site(
1062            crate::digest_observability::DIGEST_SITE_DECODE,
1063        );
1064        let serde_repr = SessionSerde::deserialize(deserializer)?;
1065        let version = session_persistence_version_authority::restore_session_envelope_version(
1066            serde_repr.version,
1067        )
1068        .map_err(<D::Error as serde::de::Error>::custom)?;
1069        let mut metadata = serde_repr.metadata;
1070        if import_0810::contains_released_checkpoint_metadata(&metadata) {
1071            return Err(<D::Error as serde::de::Error>::custom(
1072                "embedded released checkpoint metadata requires the explicit one-time 0.8.10 importer",
1073            ));
1074        }
1075        let realtime_transcript = match metadata.remove(SESSION_REALTIME_TRANSCRIPT_STATE_KEY) {
1076            Some(value) => {
1077                let state = serde_json::from_value(value)
1078                    .map_err(<D::Error as serde::de::Error>::custom)?;
1079                SessionRealtimeTranscriptProjection::from_inline_snapshot(&serde_repr.id, state)
1080                    .map_err(<D::Error as serde::de::Error>::custom)?
1081            }
1082            None => SessionRealtimeTranscriptProjection::empty(&serde_repr.id),
1083        };
1084        let history_wire_kind = transcript_history_wire_kind(&metadata)
1085            .map_err(<D::Error as serde::de::Error>::custom)?;
1086        if matches!(
1087            history_wire_kind,
1088            Some(TranscriptHistoryWireKind::Released0810)
1089        ) {
1090            return Err(<D::Error as serde::de::Error>::custom(
1091                "released 0.8.10 transcript history requires the explicit one-time importer",
1092            ));
1093        }
1094        let history_caches = Box::<SessionHistoryCaches>::default();
1095        let mut session = Session {
1096            version,
1097            id: serde_repr.id,
1098            messages: TranscriptMessages::from_vec(serde_repr.messages),
1099            created_at: serde_repr.created_at,
1100            updated_at: serde_repr.updated_at,
1101            metadata,
1102            realtime_transcript: Box::new(realtime_transcript),
1103            history_caches,
1104            transcript_history_metadata_validation: if history_wire_kind.is_some() {
1105                TranscriptHistoryMetadataValidation::RequiresValidation
1106            } else {
1107                TranscriptHistoryMetadataValidation::Validated
1108            },
1109            usage: serde_repr.usage,
1110        };
1111        if let Some(TranscriptHistoryWireKind::Current) = history_wire_kind {
1112            let state = compact_transcript_history_metadata_for_snapshot(&mut session.metadata)
1113                .map_err(<D::Error as serde::de::Error>::custom)?
1114                .ok_or_else(|| {
1115                    <D::Error as serde::de::Error>::custom(
1116                        "transcript-history graph disappeared during ingress",
1117                    )
1118                })?;
1119            let exact_live_prefix = state
1120                .derive_live_row_lineage_after_final_semantic_replay(session.messages())
1121                .map_err(<D::Error as serde::de::Error>::custom)?
1122                .ok_or_else(|| {
1123                    <D::Error as serde::de::Error>::custom(
1124                        "live transcript does not preserve the graph-proved audited endpoint",
1125                    )
1126                })?;
1127            let endpoint_prefix = state
1128                .final_endpoint_witness()
1129                .ok_or_else(|| {
1130                    <D::Error as serde::de::Error>::custom(
1131                        "compact transcript graph has no final endpoint witness",
1132                    )
1133                })?
1134                .row_prefix()
1135                .clone();
1136            if !session.install_exact_message_row_lineage(endpoint_prefix, exact_live_prefix) {
1137                return Err(<D::Error as serde::de::Error>::custom(
1138                    "failed to install exact live message-row authority",
1139                ));
1140            }
1141            session.transcript_history_metadata_validation =
1142                TranscriptHistoryMetadataValidation::Validated;
1143            session
1144                .history_caches
1145                .shared_state
1146                .set(std::sync::Arc::clone(&state));
1147        }
1148        Ok(session)
1149    }
1150}
1151
1152/// Serde helper for the metadata-only partial decode of a persisted session
1153/// envelope.
1154///
1155/// LOCKSTEP with [`SessionSerde`]: this struct must decode exactly the field
1156/// names and serde shapes that `SessionSerde` persists for `version`, `id`,
1157/// and `metadata` (`rename_all = "snake_case"`, `#[serde(default)]` on
1158/// `metadata`). The `session_metadata_document_lockstep_with_full_envelope`
1159/// pin test fails if the two drift.
1160#[derive(Deserialize)]
1161#[serde(rename_all = "snake_case")]
1162struct SessionMetadataDocumentSerde {
1163    version: u32,
1164    id: SessionId,
1165    #[serde(default)]
1166    metadata: serde_json::Map<String, serde_json::Value>,
1167}
1168
1169/// Metadata-only projection of a persisted session envelope.
1170///
1171/// Produced by [`session_metadata_document_from_slice`] without materializing
1172/// the transcript. Exposes ONLY the two session-authority facts the metadata
1173/// read seam is allowed to observe ([`SESSION_METADATA_KEY`] and
1174/// [`SESSION_LIFECYCLE_TERMINAL_KEY`]) — deliberately no raw metadata-map
1175/// accessor, so the partial decode can never grow into an untyped side
1176/// channel around [`Session`]'s authority-gated reads.
1177#[derive(Debug, Clone)]
1178pub struct SessionMetadataDocument {
1179    session_id: SessionId,
1180    metadata: serde_json::Map<String, serde_json::Value>,
1181}
1182
1183impl SessionMetadataDocument {
1184    /// Session identity carried by the envelope.
1185    pub fn session_id(&self) -> &SessionId {
1186        &self.session_id
1187    }
1188
1189    /// Raw projected [`SESSION_METADATA_KEY`] value, for divergence
1190    /// comparison against another projection of the same fact.
1191    pub fn session_metadata_value(&self) -> Option<&serde_json::Value> {
1192        self.metadata.get(SESSION_METADATA_KEY)
1193    }
1194
1195    /// Raw projected [`SESSION_LIFECYCLE_TERMINAL_KEY`] value, for divergence
1196    /// comparison against another projection of the same fact.
1197    pub fn lifecycle_terminal_value(&self) -> Option<&serde_json::Value> {
1198        self.metadata.get(SESSION_LIFECYCLE_TERMINAL_KEY)
1199    }
1200
1201    /// Decode the typed metadata view through the canonical map-level
1202    /// decoders, failing closed on corrupt values.
1203    pub fn try_into_view(self) -> Result<PersistedSessionMetadataView, serde_json::Error> {
1204        PersistedSessionMetadataView::try_from_metadata_map(self.session_id, &self.metadata)
1205    }
1206}
1207
1208/// Partially decode a persisted session envelope into its metadata-only
1209/// document, without materializing the transcript.
1210///
1211/// Fail-closed on the envelope format version through the generated
1212/// persistence version authority — exactly like the full [`Session`]
1213/// deserializer.
1214pub fn session_metadata_document_from_slice(
1215    bytes: &[u8],
1216) -> Result<SessionMetadataDocument, serde_json::Error> {
1217    let serde_repr: SessionMetadataDocumentSerde = serde_json::from_slice(bytes)?;
1218    session_persistence_version_authority::restore_session_envelope_version(serde_repr.version)
1219        .map_err(<serde_json::Error as serde::de::Error>::custom)?;
1220    if import_0810::contains_released_checkpoint_metadata(&serde_repr.metadata) {
1221        return Err(<serde_json::Error as serde::de::Error>::custom(
1222            "released 0.8.10 proof metadata requires the explicit one-time importer",
1223        ));
1224    }
1225    let history_wire_kind = transcript_history_wire_kind(&serde_repr.metadata)
1226        .map_err(<serde_json::Error as serde::de::Error>::custom)?;
1227    if matches!(
1228        history_wire_kind,
1229        Some(TranscriptHistoryWireKind::Released0810)
1230    ) {
1231        return Err(<serde_json::Error as serde::de::Error>::custom(
1232            "released 0.8.10 transcript history requires the explicit one-time importer",
1233        ));
1234    }
1235    Ok(SessionMetadataDocument {
1236        session_id: serde_repr.id,
1237        metadata: serde_repr.metadata,
1238    })
1239}
1240
1241/// One exact serialized session document plus its single-pass physical digest.
1242///
1243/// Typed producers construct this through [`Session::to_persisted_artifact`].
1244/// The streaming JSON writer feeds the output buffer and SHA-256 in the same
1245/// pass, so consumers can reuse `row_sha256_token` without scanning the full
1246/// document again.
1247#[derive(Debug, Clone)]
1248pub struct SerializedSessionArtifact {
1249    // `Arc<Vec<u8>>`, rather than `Arc<[u8]>`, is intentional: promoting a
1250    // completed Vec into an Arc slice may allocate and copy the full document.
1251    // Sharing the immutable Vec owner preserves the streaming writer's exact
1252    // allocation without a second O(document) memory pass.
1253    bytes: Arc<Vec<u8>>,
1254    raw_sha256: [u8; 32],
1255    row_sha256_token: Arc<str>,
1256}
1257
1258/// One decoded WholeBlob document paired with its observed physical identity.
1259///
1260/// This is an observation, not persistence authority: the owning store must
1261/// compare [`Self::row_sha256_token`] with its transaction-issued row token
1262/// before exposing [`Self::session`].  Decoding through this seam also installs
1263/// the exact serialized-message lineage proven by those same bytes, so a
1264/// subsequent transcript rewrite does not depend on a self-authenticating
1265/// `Session` field.
1266#[derive(Debug)]
1267pub struct DecodedWholeBlobSessionDocument {
1268    session: Session,
1269    row_sha256_token: String,
1270}
1271
1272impl DecodedWholeBlobSessionDocument {
1273    #[must_use]
1274    pub fn session(&self) -> &Session {
1275        &self.session
1276    }
1277
1278    #[must_use]
1279    pub fn row_sha256_token(&self) -> &str {
1280        &self.row_sha256_token
1281    }
1282
1283    #[must_use]
1284    pub fn into_session(self) -> Session {
1285        self.session
1286    }
1287}
1288
1289impl SerializedSessionArtifact {
1290    fn from_parts(bytes: Vec<u8>, raw_sha256: [u8; 32]) -> Self {
1291        Self {
1292            bytes: Arc::new(bytes),
1293            raw_sha256,
1294            row_sha256_token: Arc::from(row_sha256_token(raw_sha256)),
1295        }
1296    }
1297
1298    pub(crate) fn from_raw_bytes(bytes: Vec<u8>) -> Self {
1299        let raw_sha256 = sha256_key(&bytes);
1300        Self::from_parts(bytes, raw_sha256)
1301    }
1302
1303    #[must_use]
1304    pub fn bytes(&self) -> &[u8] {
1305        self.bytes.as_ref()
1306    }
1307
1308    #[must_use]
1309    pub fn bytes_arc(&self) -> Arc<Vec<u8>> {
1310        Arc::clone(&self.bytes)
1311    }
1312
1313    #[must_use]
1314    pub fn into_bytes(self) -> Vec<u8> {
1315        Arc::try_unwrap(self.bytes).unwrap_or_else(|shared| shared.as_ref().clone())
1316    }
1317
1318    #[must_use]
1319    pub const fn raw_sha256(&self) -> &[u8; 32] {
1320        &self.raw_sha256
1321    }
1322
1323    #[must_use]
1324    pub fn row_sha256_token(&self) -> &str {
1325        &self.row_sha256_token
1326    }
1327}
1328
1329struct SessionArtifactWriter {
1330    bytes: Vec<u8>,
1331    hasher: Sha256,
1332}
1333
1334impl SessionArtifactWriter {
1335    fn new() -> Self {
1336        Self {
1337            bytes: Vec::new(),
1338            hasher: Sha256::new(),
1339        }
1340    }
1341
1342    fn finish(self) -> SerializedSessionArtifact {
1343        crate::digest_observability::record_session_encode_bytes(self.bytes.len() as u64);
1344        let digest = self.hasher.finalize();
1345        let mut raw_sha256 = [0u8; 32];
1346        raw_sha256.copy_from_slice(&digest);
1347        SerializedSessionArtifact::from_parts(self.bytes, raw_sha256)
1348    }
1349}
1350
1351impl std::io::Write for SessionArtifactWriter {
1352    fn write(&mut self, buffer: &[u8]) -> std::io::Result<usize> {
1353        self.bytes.extend_from_slice(buffer);
1354        self.hasher.update(buffer);
1355        Ok(buffer.len())
1356    }
1357
1358    fn flush(&mut self) -> std::io::Result<()> {
1359        Ok(())
1360    }
1361}
1362
1363fn sha256_key(bytes: &[u8]) -> [u8; 32] {
1364    let digest = Sha256::digest(bytes);
1365    let mut key = [0u8; 32];
1366    key.copy_from_slice(&digest);
1367    key
1368}
1369
1370fn row_sha256_token(raw_sha256: [u8; 32]) -> String {
1371    use std::fmt::Write as _;
1372
1373    let mut token = String::with_capacity("row-sha256:".len() + 64);
1374    token.push_str("row-sha256:");
1375    for byte in raw_sha256 {
1376        let _ = write!(token, "{byte:02x}");
1377    }
1378    token
1379}
1380
1381fn persisted_session_decode_error(message: impl Into<String>) -> serde_json::Error {
1382    serde_json::Error::io(std::io::Error::new(
1383        std::io::ErrorKind::InvalidData,
1384        message.into(),
1385    ))
1386}
1387
1388impl Session {
1389    /// Install one current compact transcript graph after out-of-line domain
1390    /// projections have been materialized.
1391    ///
1392    /// Released 0.8.10 graphs are admitted only by the explicit one-time
1393    /// importer; normal current materialization never interprets them.
1394    pub(crate) fn normalize_persisted_transcript_history_ingress(
1395        &mut self,
1396    ) -> Result<(), TranscriptEditError> {
1397        let history_wire_kind = transcript_history_wire_kind(&self.metadata)
1398            .map_err(TranscriptEditError::HistoryStateMalformed)?;
1399        let Some(history_wire_kind) = history_wire_kind else {
1400            return Ok(());
1401        };
1402        if matches!(history_wire_kind, TranscriptHistoryWireKind::Released0810) {
1403            return Err(TranscriptEditError::HistoryStateMalformed(
1404                "released 0.8.10 transcript history requires the explicit one-time importer"
1405                    .to_string(),
1406            ));
1407        }
1408        let state = compact_transcript_history_metadata_for_snapshot(&mut self.metadata)
1409            .map_err(TranscriptEditError::HistoryStateMalformed)?
1410            .ok_or_else(|| {
1411                TranscriptEditError::HistoryStateMalformed(
1412                    "transcript-history graph disappeared during ingress".to_string(),
1413                )
1414            })?;
1415        let exact_live_prefix = state
1416            .derive_live_row_lineage_after_final_semantic_replay(self.messages())
1417            .map_err(|error| TranscriptEditError::HistoryStateMalformed(error.to_string()))?
1418            .ok_or_else(|| {
1419                TranscriptEditError::HistoryStateMalformed(
1420                    "live transcript does not preserve the graph-proved audited endpoint"
1421                        .to_string(),
1422                )
1423            })?;
1424        let endpoint_prefix = state
1425            .final_endpoint_witness()
1426            .ok_or_else(|| {
1427                TranscriptEditError::HistoryStateMalformed(
1428                    "compact transcript graph has no final endpoint witness".to_string(),
1429                )
1430            })?
1431            .row_prefix()
1432            .clone();
1433        if !self.install_exact_message_row_lineage(endpoint_prefix, exact_live_prefix) {
1434            return Err(TranscriptEditError::HistoryStateMalformed(
1435                "failed to install exact live message-row authority".to_string(),
1436            ));
1437        }
1438        self.transcript_history_metadata_validation =
1439            TranscriptHistoryMetadataValidation::Validated;
1440        self.history_caches
1441            .shared_state
1442            .set(std::sync::Arc::clone(&state));
1443        Ok(())
1444    }
1445
1446    /// Rebuild a slim `Session` from persisted head-row parts.
1447    ///
1448    /// Used by [`crate::session_store::SessionHead::into_session`] to
1449    /// materialize a session from an incremental store's head row plus its
1450    /// strand messages. The envelope version is restored fail-closed through
1451    /// the generated persistence version authority, exactly like
1452    /// [`Session::deserialize`].
1453    #[allow(clippy::too_many_arguments)]
1454    pub(crate) fn from_head_parts(
1455        version: u32,
1456        id: SessionId,
1457        messages: Vec<Message>,
1458        exact_row_prefix: Option<crate::SessionMessageRowPrefixAccumulator>,
1459        created_at: SystemTime,
1460        updated_at: SystemTime,
1461        metadata: serde_json::Map<String, serde_json::Value>,
1462        usage: Usage,
1463        head_canonical_metadata: Option<Arc<SessionHeadMetadataProjection>>,
1464    ) -> Result<Self, String> {
1465        let version =
1466            session_persistence_version_authority::restore_session_envelope_version(version)
1467                .map_err(|err| err.to_string())?;
1468        if import_0810::contains_released_checkpoint_metadata(&metadata) {
1469            return Err(
1470                "embedded released checkpoint metadata requires the explicit one-time 0.8.10 importer"
1471                    .to_string(),
1472            );
1473        }
1474        let transcript = TranscriptMessages::from_vec(messages);
1475        if let Some(prefix) = exact_row_prefix
1476            && !transcript.install_exact_row_prefix(prefix)
1477        {
1478            return Err(
1479                "exact message-row prefix count differs from materialized messages".to_string(),
1480            );
1481        }
1482        let realtime_transcript = Box::new(SessionRealtimeTranscriptProjection::empty(&id));
1483        let history_caches = Box::<SessionHistoryCaches>::default();
1484        let mut session = Self {
1485            version,
1486            id,
1487            messages: transcript,
1488            created_at,
1489            updated_at,
1490            transcript_history_metadata_validation: if metadata
1491                .contains_key(SESSION_TRANSCRIPT_HISTORY_STATE_KEY)
1492            {
1493                TranscriptHistoryMetadataValidation::RequiresValidation
1494            } else {
1495                TranscriptHistoryMetadataValidation::Validated
1496            },
1497            metadata,
1498            realtime_transcript,
1499            history_caches,
1500            usage,
1501        };
1502        if let Some(projection) = head_canonical_metadata {
1503            session
1504                .install_head_canonical_metadata_projection(&projection)
1505                .map_err(|error| {
1506                    format!(
1507                        "failed to install HeadCanonical metadata baseline for session {}: {error}",
1508                        session.id
1509                    )
1510                })?;
1511        }
1512        Ok(session)
1513    }
1514
1515    /// Serialize this current Session envelope to persisted JSON bytes.
1516    ///
1517    /// This does not populate process-global identity or digest caches.
1518    /// Callers that also need the exact physical blob digest should use
1519    /// [`Self::to_persisted_artifact`] so bytes and SHA-256 are produced in one
1520    /// pass.
1521    pub fn to_persisted_bytes(&self) -> Result<Vec<u8>, serde_json::Error> {
1522        Ok(self.to_persisted_artifact()?.into_bytes())
1523    }
1524
1525    /// Stream this exact Session into a sealed WholeBlob artifact.
1526    ///
1527    /// JSON bytes and their physical SHA-256 are produced in one pass. No
1528    /// process-global Session cache is populated: durable store authority, not
1529    /// process memory, owns exact byte identity.
1530    pub fn to_persisted_artifact(&self) -> Result<SerializedSessionArtifact, serde_json::Error> {
1531        let mut writer = SessionArtifactWriter::new();
1532        serde_json::to_writer(&mut writer, self)?;
1533        Ok(writer.finish())
1534    }
1535
1536    /// Decode one current Session envelope without a full-byte pre-hash or a
1537    /// process-global memo lookup.
1538    pub fn from_persisted_bytes(serialized: &[u8]) -> Result<Self, serde_json::Error> {
1539        serde_json::from_slice(serialized)
1540    }
1541
1542    /// Decode an observed WholeBlob row and derive its exact physical identity.
1543    ///
1544    /// The returned token is deliberately not trusted here. `RuntimeStore`
1545    /// owns the transaction-issued authority and must compare it before the
1546    /// decoded session is usable. Once that comparison succeeds, the exact
1547    /// serialized message vector establishes the row-lineage origin required
1548    /// by later rewrite commits.
1549    #[doc(hidden)]
1550    pub fn decode_whole_blob_document(
1551        serialized: &[u8],
1552    ) -> Result<DecodedWholeBlobSessionDocument, serde_json::Error> {
1553        let session = Self::from_persisted_bytes(serialized)?;
1554        let message_count = u64::try_from(session.messages().len()).map_err(|_| {
1555            <serde_json::Error as serde::de::Error>::custom(
1556                "WholeBlob transcript row count exceeds u64",
1557            )
1558        })?;
1559        if session.exact_message_row_prefix_at(message_count).is_none() {
1560            session.messages.mark_lazy_whole_blob_row_lineage();
1561        }
1562        Ok(DecodedWholeBlobSessionDocument {
1563            session,
1564            row_sha256_token: row_sha256_token(sha256_key(serialized)),
1565        })
1566    }
1567
1568    /// Exact durable-row lineage at one prefix count, when this Session was
1569    /// materialized from that authority and has changed only by appends.
1570    pub(crate) fn exact_message_row_prefix_at(
1571        &self,
1572        row_count: u64,
1573    ) -> Option<crate::SessionMessageRowPrefixAccumulator> {
1574        self.messages.exact_row_prefix_at(row_count)
1575    }
1576
1577    /// Adopt an exact row prefix after a prepared head-canonical boundary has
1578    /// been acknowledged as durable.
1579    pub(crate) fn install_exact_message_row_prefix(
1580        &self,
1581        prefix: crate::SessionMessageRowPrefixAccumulator,
1582    ) -> bool {
1583        self.messages.install_exact_row_prefix(prefix)
1584    }
1585
1586    pub(crate) fn install_exact_message_row_lineage(
1587        &self,
1588        anchor: crate::SessionMessageRowPrefixAccumulator,
1589        current: crate::SessionMessageRowPrefixAccumulator,
1590    ) -> bool {
1591        self.messages.install_exact_row_lineage(anchor, current)
1592    }
1593
1594    pub(crate) fn exact_message_row_lineage_extends(
1595        &self,
1596        anchor: &crate::SessionMessageRowPrefixAccumulator,
1597        current_count: u64,
1598    ) -> bool {
1599        self.messages
1600            .exact_row_lineage_extends(anchor, current_count)
1601    }
1602}
1603
1604/// Metadata key used to store deferred-turn control state.
1605pub const SESSION_DEFERRED_TURN_STATE_KEY: &str = "session_deferred_turn_state";
1606
1607/// Metadata key for a mixed local/external callback batch whose completed
1608/// sibling outcomes must remain hidden until the external callback result can
1609/// complete the provider-adjacent `ToolResults` set.
1610pub(crate) const SESSION_PENDING_CALLBACK_BATCH_KEY: &str = "session_pending_callback_batch_v1";
1611
1612/// Metadata key used to store recoverable build-only session state.
1613pub const SESSION_BUILD_STATE_KEY: &str = "session_build_state";
1614
1615/// Metadata key used to store durable session-local tool visibility intent.
1616pub const SESSION_TOOL_VISIBILITY_STATE_KEY: &str = "session_tool_visibility_state_v1";
1617
1618/// Metadata key used to store the typed session lifecycle-terminal fact.
1619pub const SESSION_LIFECYCLE_TERMINAL_KEY: &str = "session_lifecycle_terminal";
1620
1621/// Canonical tool name gated by `image_tool_results` capability.
1622pub const VIEW_IMAGE_TOOL_NAME: &str = "view_image";
1623
1624#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1625#[error("metadata key `{key}` is reserved for session authority")]
1626pub struct ReservedSessionMetadataKey {
1627    key: String,
1628}
1629
1630impl ReservedSessionMetadataKey {
1631    fn new(key: &str) -> Self {
1632        Self {
1633            key: key.to_string(),
1634        }
1635    }
1636}
1637
1638fn is_session_authority_metadata_key(key: &str) -> bool {
1639    // Single reserved-key authority: the typed classifier owns the
1640    // session-authority key set (the `session_*` state constants).
1641    key == SESSION_TRANSCRIPT_REWRITE_PREFIX_AUTHORITY_KEY
1642        || crate::surface_metadata::ReservedMetadataKey::is_session_authority(key)
1643}
1644
1645#[allow(clippy::panic)]
1646fn fail_closed_generated_restore(authority: &'static str, err: serde_json::Error) -> ! {
1647    tracing::error!(
1648        authority,
1649        error = %err,
1650        "generated authority rejected durable restore"
1651    );
1652    panic!("generated {authority} authority rejected durable restore: {err}");
1653}
1654
1655/// Request-only context coordinator for one live actor.
1656///
1657/// This handle owns no Session state and has no persistence or idempotency
1658/// semantics. It only coordinates publication of exact runtime-owned context
1659/// at one named model boundary.
1660#[derive(Clone)]
1661pub struct TransientTurnContextStateHandle {
1662    boundary: Arc<TransientTurnContextBoundaryCoordinator>,
1663}
1664
1665struct TransientTurnContextBoundaryCoordinator {
1666    incarnation_id: uuid::Uuid,
1667    lifecycle: std::sync::Mutex<TransientTurnContextBoundaryLifecycle>,
1668    notify: tokio::sync::Notify,
1669}
1670
1671struct TransientTurnContextBoundaryLifecycle {
1672    actor_live: bool,
1673    next_generation: u64,
1674    next_request_id: u64,
1675    window: TransientTurnContextBoundaryWindow,
1676}
1677
1678enum TransientTurnContextBoundaryWindow {
1679    Closed,
1680    Open {
1681        run_id: RunId,
1682        generation: u64,
1683        request: Option<RegisteredTransientTurnContextBoundaryRequest>,
1684    },
1685    Parked {
1686        run_id: RunId,
1687        generation: u64,
1688        request_id: u64,
1689        contexts: Vec<TurnRequestContext>,
1690    },
1691    Resolved {
1692        run_id: RunId,
1693        request_id: u64,
1694        contexts: Vec<TurnRequestContext>,
1695        resolution: TransientTurnContextBoundaryResolution,
1696    },
1697}
1698
1699struct RegisteredTransientTurnContextBoundaryRequest {
1700    request_id: u64,
1701    contexts: Vec<TurnRequestContext>,
1702}
1703
1704#[derive(Clone)]
1705enum TransientTurnContextBoundaryResolution {
1706    Committed,
1707    Aborted,
1708}
1709
1710impl Default for TransientTurnContextBoundaryCoordinator {
1711    fn default() -> Self {
1712        Self {
1713            incarnation_id: uuid::Uuid::new_v4(),
1714            lifecycle: std::sync::Mutex::new(TransientTurnContextBoundaryLifecycle {
1715                actor_live: true,
1716                next_generation: 0,
1717                next_request_id: 0,
1718                window: TransientTurnContextBoundaryWindow::Closed,
1719            }),
1720            notify: tokio::sync::Notify::new(),
1721        }
1722    }
1723}
1724
1725impl TransientTurnContextBoundaryCoordinator {
1726    fn lock(&self) -> std::sync::MutexGuard<'_, TransientTurnContextBoundaryLifecycle> {
1727        self.lifecycle.lock().unwrap_or_else(|poisoned| {
1728            tracing::warn!(
1729                "transient turn-context boundary lock poisoned; retaining exact actor authority"
1730            );
1731            poisoned.into_inner()
1732        })
1733    }
1734
1735    fn abort_request(&self, request_id: u64) -> Result<(), CoreBoundaryStageError> {
1736        let mut lifecycle = self.lock();
1737        let parked_owner = match &lifecycle.window {
1738            TransientTurnContextBoundaryWindow::Parked {
1739                run_id,
1740                request_id: current,
1741                contexts,
1742                ..
1743            } if *current == request_id => Some((run_id.clone(), contexts.clone())),
1744            _ => None,
1745        };
1746        if let Some((run_id, contexts)) = parked_owner {
1747            lifecycle.window = TransientTurnContextBoundaryWindow::Resolved {
1748                run_id,
1749                request_id,
1750                contexts,
1751                resolution: TransientTurnContextBoundaryResolution::Aborted,
1752            };
1753            drop(lifecycle);
1754            self.notify.notify_waiters();
1755            return Ok(());
1756        }
1757        match &mut lifecycle.window {
1758            TransientTurnContextBoundaryWindow::Open { request, .. }
1759                if request
1760                    .as_ref()
1761                    .is_some_and(|request| request.request_id == request_id) =>
1762            {
1763                *request = None;
1764            }
1765            TransientTurnContextBoundaryWindow::Resolved {
1766                request_id: current,
1767                ..
1768            } if *current == request_id => return Ok(()),
1769            _ => {
1770                return Err(CoreBoundaryStageError::stale(format!(
1771                    "transient boundary request {request_id} no longer owns its actor window"
1772                )));
1773            }
1774        }
1775        drop(lifecycle);
1776        self.notify.notify_waiters();
1777        Ok(())
1778    }
1779
1780    fn close_run(&self, run_id: &RunId) {
1781        let mut lifecycle = self.lock();
1782        let owns_window = match &lifecycle.window {
1783            TransientTurnContextBoundaryWindow::Open {
1784                run_id: current, ..
1785            }
1786            | TransientTurnContextBoundaryWindow::Parked {
1787                run_id: current, ..
1788            }
1789            | TransientTurnContextBoundaryWindow::Resolved {
1790                run_id: current, ..
1791            } => current == run_id,
1792            TransientTurnContextBoundaryWindow::Closed => false,
1793        };
1794        if owns_window {
1795            lifecycle.window = TransientTurnContextBoundaryWindow::Closed;
1796            drop(lifecycle);
1797            self.notify.notify_waiters();
1798        }
1799    }
1800
1801    fn revoke_actor(&self) {
1802        let mut lifecycle = self.lock();
1803        lifecycle.actor_live = false;
1804        lifecycle.window = TransientTurnContextBoundaryWindow::Closed;
1805        drop(lifecycle);
1806        self.notify.notify_waiters();
1807    }
1808}
1809
1810/// Run-scoped guard closing every unresolved transient-context preparation.
1811#[must_use]
1812pub(crate) struct TransientTurnContextBoundaryRunGuard {
1813    boundary: Arc<TransientTurnContextBoundaryCoordinator>,
1814    run_id: RunId,
1815}
1816
1817impl Drop for TransientTurnContextBoundaryRunGuard {
1818    fn drop(&mut self) {
1819        self.boundary.close_run(&self.run_id);
1820    }
1821}
1822
1823struct PendingTransientTurnContextBoundaryPreparation {
1824    boundary: Arc<TransientTurnContextBoundaryCoordinator>,
1825    request_id: u64,
1826    armed: bool,
1827}
1828
1829impl Drop for PendingTransientTurnContextBoundaryPreparation {
1830    fn drop(&mut self) {
1831        if self.armed {
1832            let _ = self.boundary.abort_request(self.request_id);
1833        }
1834    }
1835}
1836
1837/// Unique publication authority for one exact parked request boundary.
1838#[must_use = "prepared transient turn context must be committed or aborted"]
1839pub struct PreparedTransientTurnContextBoundary {
1840    state: TransientTurnContextStateHandle,
1841    expected_run_id: RunId,
1842    generation: u64,
1843    request_id: u64,
1844    armed: bool,
1845    _not_sync: std::marker::PhantomData<std::cell::Cell<()>>,
1846}
1847
1848impl std::fmt::Debug for PreparedTransientTurnContextBoundary {
1849    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1850        formatter
1851            .debug_struct("PreparedTransientTurnContextBoundary")
1852            .field("actor_incarnation", &self.state.boundary.incarnation_id)
1853            .field("expected_run_id", &self.expected_run_id)
1854            .field("generation", &self.generation)
1855            .field("request_id", &self.request_id)
1856            .finish_non_exhaustive()
1857    }
1858}
1859
1860impl PreparedTransientTurnContextBoundary {
1861    #[must_use]
1862    pub fn expected_run_id(&self) -> &RunId {
1863        &self.expected_run_id
1864    }
1865
1866    #[must_use]
1867    pub fn boundary_generation(&self) -> u64 {
1868        self.generation
1869    }
1870
1871    /// Bind this request-only preparation to the generic commit/abort carrier.
1872    ///
1873    /// Transient context never has a Session snapshot; callers pass None.
1874    pub fn into_stage_output(
1875        self,
1876        session_snapshot: Option<Vec<u8>>,
1877    ) -> crate::lifecycle::CoreBoundaryStageOutput {
1878        debug_assert!(
1879            session_snapshot.is_none(),
1880            "transient turn context cannot carry a durable Session snapshot"
1881        );
1882        crate::lifecycle::CoreBoundaryStageOutput::prepared(None, Box::new(self))
1883    }
1884
1885    fn resolve(
1886        &mut self,
1887        resolution: TransientTurnContextBoundaryResolution,
1888    ) -> Result<(), CoreBoundaryStageError> {
1889        if !self.armed {
1890            return Err(CoreBoundaryStageError::stale(
1891                "prepared transient boundary authority was already resolved",
1892            ));
1893        }
1894        let mut lifecycle = self.state.boundary.lock();
1895        if !lifecycle.actor_live {
1896            self.armed = false;
1897            return Err(CoreBoundaryStageError::stale(format!(
1898                "actor incarnation {} was revoked",
1899                self.state.boundary.incarnation_id
1900            )));
1901        }
1902        let matches_exact = matches!(
1903            &lifecycle.window,
1904            TransientTurnContextBoundaryWindow::Parked {
1905                run_id,
1906                generation,
1907                request_id,
1908                ..
1909            } if run_id == &self.expected_run_id
1910                && *generation == self.generation
1911                && *request_id == self.request_id
1912        );
1913        if !matches_exact {
1914            self.armed = false;
1915            return Err(CoreBoundaryStageError::stale(
1916                "prepared transient boundary no longer owns the exact parked generation",
1917            ));
1918        }
1919        let contexts = match std::mem::replace(
1920            &mut lifecycle.window,
1921            TransientTurnContextBoundaryWindow::Closed,
1922        ) {
1923            TransientTurnContextBoundaryWindow::Parked { contexts, .. } => contexts,
1924            _ => {
1925                self.armed = false;
1926                return Err(CoreBoundaryStageError::stale(
1927                    "prepared transient boundary lost its parked context",
1928                ));
1929            }
1930        };
1931        lifecycle.window = TransientTurnContextBoundaryWindow::Resolved {
1932            run_id: self.expected_run_id.clone(),
1933            request_id: self.request_id,
1934            contexts,
1935            resolution,
1936        };
1937        self.armed = false;
1938        drop(lifecycle);
1939        self.state.boundary.notify.notify_waiters();
1940        Ok(())
1941    }
1942}
1943
1944impl crate::lifecycle::core_executor::CoreBoundaryStageCommitAuthority
1945    for PreparedTransientTurnContextBoundary
1946{
1947    fn commit(&mut self) -> Result<(), CoreBoundaryStageError> {
1948        self.resolve(TransientTurnContextBoundaryResolution::Committed)
1949    }
1950
1951    fn abort(&mut self) -> Result<(), CoreBoundaryStageError> {
1952        self.resolve(TransientTurnContextBoundaryResolution::Aborted)
1953    }
1954}
1955
1956impl Drop for PreparedTransientTurnContextBoundary {
1957    fn drop(&mut self) {
1958        if self.armed {
1959            let _ = self.resolve(TransientTurnContextBoundaryResolution::Aborted);
1960        }
1961    }
1962}
1963
1964impl Default for TransientTurnContextStateHandle {
1965    fn default() -> Self {
1966        Self::new()
1967    }
1968}
1969
1970impl std::fmt::Debug for TransientTurnContextStateHandle {
1971    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1972        formatter
1973            .debug_struct("TransientTurnContextStateHandle")
1974            .field("actor_incarnation", &self.boundary.incarnation_id)
1975            .finish_non_exhaustive()
1976    }
1977}
1978
1979impl TransientTurnContextStateHandle {
1980    #[must_use]
1981    pub fn new() -> Self {
1982        Self {
1983            boundary: Arc::new(TransientTurnContextBoundaryCoordinator::default()),
1984        }
1985    }
1986
1987    pub(crate) fn begin_boundary_run(
1988        &self,
1989        run_id: RunId,
1990    ) -> Result<TransientTurnContextBoundaryRunGuard, CoreBoundaryStageError> {
1991        self.open_next_boundary(&run_id)?;
1992        Ok(TransientTurnContextBoundaryRunGuard {
1993            boundary: Arc::clone(&self.boundary),
1994            run_id,
1995        })
1996    }
1997
1998    pub(crate) fn open_next_boundary(&self, run_id: &RunId) -> Result<u64, CoreBoundaryStageError> {
1999        let mut lifecycle = self.boundary.lock();
2000        if !lifecycle.actor_live {
2001            return Err(CoreBoundaryStageError::stale(format!(
2002                "actor incarnation {} was revoked",
2003                self.boundary.incarnation_id
2004            )));
2005        }
2006        match &lifecycle.window {
2007            TransientTurnContextBoundaryWindow::Open {
2008                run_id: current,
2009                generation,
2010                ..
2011            } if current == run_id => return Ok(*generation),
2012            TransientTurnContextBoundaryWindow::Parked { .. }
2013            | TransientTurnContextBoundaryWindow::Resolved { .. } => {
2014                return Err(CoreBoundaryStageError::fault(
2015                    "runner attempted to open a boundary while its predecessor was unresolved",
2016                ));
2017            }
2018            TransientTurnContextBoundaryWindow::Open {
2019                run_id: current, ..
2020            } => {
2021                return Err(CoreBoundaryStageError::stale(format!(
2022                    "run {run_id} cannot replace boundary owned by {current}"
2023                )));
2024            }
2025            TransientTurnContextBoundaryWindow::Closed => {}
2026        }
2027        lifecycle.next_generation = lifecycle
2028            .next_generation
2029            .checked_add(1)
2030            .ok_or_else(|| CoreBoundaryStageError::fault("boundary generation overflow"))?;
2031        let generation = lifecycle.next_generation;
2032        lifecycle.window = TransientTurnContextBoundaryWindow::Open {
2033            run_id: run_id.clone(),
2034            generation,
2035            request: None,
2036        };
2037        drop(lifecycle);
2038        self.boundary.notify.notify_waiters();
2039        Ok(generation)
2040    }
2041
2042    pub async fn prepare_active_turn_boundary(
2043        &self,
2044        expected_run_id: &RunId,
2045        contexts: Vec<TurnRequestContext>,
2046    ) -> Result<PreparedTransientTurnContextBoundary, CoreBoundaryStageError> {
2047        if contexts.is_empty() {
2048            return Err(CoreBoundaryStageError::fault(
2049                "transient boundary preparation requires at least one context value",
2050            ));
2051        }
2052
2053        let request_id = {
2054            let mut lifecycle = self.boundary.lock();
2055            if !lifecycle.actor_live {
2056                return Err(CoreBoundaryStageError::stale(format!(
2057                    "actor incarnation {} was revoked",
2058                    self.boundary.incarnation_id
2059                )));
2060            }
2061            let (run_id, request) = match &mut lifecycle.window {
2062                TransientTurnContextBoundaryWindow::Open {
2063                    run_id, request, ..
2064                } => (run_id, request),
2065                TransientTurnContextBoundaryWindow::Closed => {
2066                    return Err(CoreBoundaryStageError::unavailable(format!(
2067                        "run {expected_run_id} has no open cooperative model boundary"
2068                    )));
2069                }
2070                TransientTurnContextBoundaryWindow::Parked { .. }
2071                | TransientTurnContextBoundaryWindow::Resolved { .. } => {
2072                    return Err(CoreBoundaryStageError::unavailable(format!(
2073                        "the next boundary for run {expected_run_id} was already claimed"
2074                    )));
2075                }
2076            };
2077            if run_id != expected_run_id {
2078                return Err(CoreBoundaryStageError::stale(format!(
2079                    "open boundary belongs to run {run_id}, not {expected_run_id}"
2080                )));
2081            }
2082            if request.is_some() {
2083                return Err(CoreBoundaryStageError::unavailable(format!(
2084                    "the next boundary for run {expected_run_id} already has a preparation"
2085                )));
2086            }
2087            lifecycle.next_request_id = lifecycle
2088                .next_request_id
2089                .checked_add(1)
2090                .ok_or_else(|| CoreBoundaryStageError::fault("boundary request id overflow"))?;
2091            let request_id = lifecycle.next_request_id;
2092            let TransientTurnContextBoundaryWindow::Open { request, .. } = &mut lifecycle.window
2093            else {
2094                return Err(CoreBoundaryStageError::fault(
2095                    "boundary window changed while registering preparation",
2096                ));
2097            };
2098            *request = Some(RegisteredTransientTurnContextBoundaryRequest {
2099                request_id,
2100                contexts,
2101            });
2102            request_id
2103        };
2104
2105        let mut pending = PendingTransientTurnContextBoundaryPreparation {
2106            boundary: Arc::clone(&self.boundary),
2107            request_id,
2108            armed: true,
2109        };
2110        self.boundary.notify.notify_waiters();
2111
2112        loop {
2113            let notified = self.boundary.notify.notified();
2114            tokio::pin!(notified);
2115            notified.as_mut().enable();
2116            let poll = {
2117                let lifecycle = self.boundary.lock();
2118                if lifecycle.actor_live {
2119                    match &lifecycle.window {
2120                        TransientTurnContextBoundaryWindow::Parked {
2121                            run_id,
2122                            generation,
2123                            request_id: parked_request_id,
2124                            ..
2125                        } if *parked_request_id == request_id => {
2126                            Ok(Some(PreparedTransientTurnContextBoundary {
2127                                state: self.clone(),
2128                                expected_run_id: run_id.clone(),
2129                                generation: *generation,
2130                                request_id,
2131                                armed: true,
2132                                _not_sync: std::marker::PhantomData,
2133                            }))
2134                        }
2135                        TransientTurnContextBoundaryWindow::Open { request, .. }
2136                            if request
2137                                .as_ref()
2138                                .is_some_and(|request| request.request_id == request_id) =>
2139                        {
2140                            Ok(None)
2141                        }
2142                        _ => Err(CoreBoundaryStageError::unavailable(format!(
2143                            "run {expected_run_id} ended before transient boundary request {request_id} parked"
2144                        ))),
2145                    }
2146                } else {
2147                    Err(CoreBoundaryStageError::stale(format!(
2148                        "actor incarnation {} was revoked while preparing boundary",
2149                        self.boundary.incarnation_id
2150                    )))
2151                }
2152            };
2153            match poll {
2154                Ok(Some(prepared)) => {
2155                    pending.armed = false;
2156                    return Ok(prepared);
2157                }
2158                Ok(None) => notified.as_mut().await,
2159                Err(error) => return Err(error),
2160            }
2161        }
2162    }
2163
2164    /// Consume context published for this exact boundary.
2165    ///
2166    /// Runner-first closes the window with an empty result. Prepare-first parks
2167    /// until the unique external authority commits or aborts.
2168    pub(crate) async fn take_pending_at_exact_boundary(
2169        &self,
2170        run_id: &RunId,
2171    ) -> Result<Vec<TurnRequestContext>, CoreBoundaryStageError> {
2172        let request_id = {
2173            let mut lifecycle = self.boundary.lock();
2174            if !lifecycle.actor_live {
2175                return Err(CoreBoundaryStageError::stale(format!(
2176                    "actor incarnation {} was revoked",
2177                    self.boundary.incarnation_id
2178                )));
2179            }
2180            let (generation, request) = match &mut lifecycle.window {
2181                TransientTurnContextBoundaryWindow::Open {
2182                    run_id: current,
2183                    generation,
2184                    request,
2185                } if current == run_id => (*generation, request.take()),
2186                TransientTurnContextBoundaryWindow::Open {
2187                    run_id: current, ..
2188                } => {
2189                    return Err(CoreBoundaryStageError::stale(format!(
2190                        "runner {run_id} reached boundary owned by {current}"
2191                    )));
2192                }
2193                TransientTurnContextBoundaryWindow::Closed => {
2194                    return Err(CoreBoundaryStageError::unavailable(format!(
2195                        "run {run_id} reached a boundary with no open generation"
2196                    )));
2197                }
2198                TransientTurnContextBoundaryWindow::Parked { .. }
2199                | TransientTurnContextBoundaryWindow::Resolved { .. } => {
2200                    return Err(CoreBoundaryStageError::fault(
2201                        "runner re-entered an unresolved transient model boundary",
2202                    ));
2203                }
2204            };
2205            let Some(request) = request else {
2206                lifecycle.window = TransientTurnContextBoundaryWindow::Closed;
2207                return Ok(Vec::new());
2208            };
2209            let request_id = request.request_id;
2210            lifecycle.window = TransientTurnContextBoundaryWindow::Parked {
2211                run_id: run_id.clone(),
2212                generation,
2213                request_id,
2214                contexts: request.contexts,
2215            };
2216            request_id
2217        };
2218        self.boundary.notify.notify_waiters();
2219
2220        struct RunnerParkGuard {
2221            boundary: Arc<TransientTurnContextBoundaryCoordinator>,
2222            request_id: u64,
2223            armed: bool,
2224        }
2225        impl Drop for RunnerParkGuard {
2226            fn drop(&mut self) {
2227                if self.armed {
2228                    let _ = self.boundary.abort_request(self.request_id);
2229                }
2230            }
2231        }
2232        let mut park_guard = RunnerParkGuard {
2233            boundary: Arc::clone(&self.boundary),
2234            request_id,
2235            armed: true,
2236        };
2237
2238        loop {
2239            let notified = self.boundary.notify.notified();
2240            tokio::pin!(notified);
2241            notified.as_mut().enable();
2242            let poll = {
2243                let mut lifecycle = self.boundary.lock();
2244                if lifecycle.actor_live {
2245                    match &lifecycle.window {
2246                        TransientTurnContextBoundaryWindow::Parked {
2247                            request_id: parked_request_id,
2248                            ..
2249                        } if *parked_request_id == request_id => Ok(None),
2250                        TransientTurnContextBoundaryWindow::Resolved {
2251                            run_id: resolved_run_id,
2252                            request_id: resolved_request_id,
2253                            ..
2254                        } if resolved_run_id == run_id && *resolved_request_id == request_id => {
2255                            let (resolution, contexts) = match std::mem::replace(
2256                                &mut lifecycle.window,
2257                                TransientTurnContextBoundaryWindow::Closed,
2258                            ) {
2259                                TransientTurnContextBoundaryWindow::Resolved {
2260                                    contexts,
2261                                    resolution,
2262                                    ..
2263                                } => (resolution, contexts),
2264                                _ => unreachable!("matched resolved transient boundary"),
2265                            };
2266                            let contexts = if matches!(
2267                                resolution,
2268                                TransientTurnContextBoundaryResolution::Committed
2269                            ) {
2270                                contexts
2271                            } else {
2272                                Vec::new()
2273                            };
2274                            Ok(Some(contexts))
2275                        }
2276                        _ => Err(CoreBoundaryStageError::stale(format!(
2277                            "parked transient request {request_id} lost exact authority"
2278                        ))),
2279                    }
2280                } else {
2281                    Err(CoreBoundaryStageError::stale(format!(
2282                        "actor incarnation {} was revoked while parked",
2283                        self.boundary.incarnation_id
2284                    )))
2285                }
2286            };
2287            match poll {
2288                Ok(Some(contexts)) => {
2289                    park_guard.armed = false;
2290                    return Ok(contexts);
2291                }
2292                Ok(None) => notified.as_mut().await,
2293                Err(error) => {
2294                    park_guard.armed = false;
2295                    return Err(error);
2296                }
2297            }
2298        }
2299    }
2300
2301    #[doc(hidden)]
2302    pub fn revoke_boundary_actor(&self) {
2303        self.boundary.revoke_actor();
2304    }
2305}
2306/// Typed terminal-lifecycle projection of the canonical
2307/// [`session_document::SessionDocumentMachine`] `session_lifecycle_terminal`
2308/// fact.
2309///
2310/// The machine owns archive lifecycle truth for ALL profiles (LUC-524 R004
2311/// fold): both the runtime-backed and the store-only archive paths drive the
2312/// machine's `ArchiveSessionDocument` input, and this reserved-key field is
2313/// the machine-realized durable projection of the emitted verdict — the shell
2314/// realizes it, it never decides it. `RuntimeState::Retired` is the runtime
2315/// realization of the SAME verdict; the fail-closed realization order (durable
2316/// document commit first, runtime retire second) keeps the two projections
2317/// convergent. A two-variant enum (rather than a bare bool) keeps future
2318/// terminal classes — e.g. `Destroyed` — extending the type rather than the
2319/// call sites.
2320#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2321#[serde(rename_all = "snake_case")]
2322pub enum SessionLifecycleTerminal {
2323    /// The session is live / resumable.
2324    Active,
2325    /// The session has been archived and is terminal.
2326    Archived,
2327}
2328
2329impl SessionLifecycleTerminal {
2330    /// Whether this terminal fact marks the session as archived.
2331    #[must_use]
2332    pub fn is_archived(self) -> bool {
2333        matches!(self, Self::Archived)
2334    }
2335}
2336
2337impl From<SessionLifecycleTerminal> for session_document::SessionDocumentLifecycle {
2338    fn from(value: SessionLifecycleTerminal) -> Self {
2339        match value {
2340            SessionLifecycleTerminal::Active => Self::Active,
2341            SessionLifecycleTerminal::Archived => Self::Archived,
2342        }
2343    }
2344}
2345
2346impl From<session_document::SessionDocumentLifecycle> for SessionLifecycleTerminal {
2347    fn from(value: session_document::SessionDocumentLifecycle) -> Self {
2348        match value {
2349            session_document::SessionDocumentLifecycle::Active => Self::Active,
2350            session_document::SessionDocumentLifecycle::Archived => Self::Archived,
2351        }
2352    }
2353}
2354
2355/// Durable control state for deferred first-turn prompt and staged callback tool results.
2356#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
2357#[serde(rename_all = "snake_case")]
2358pub struct SessionDeferredTurnState {
2359    #[serde(default, skip_serializing_if = "DeferredFirstTurnPhase::is_inactive")]
2360    pub(crate) first_turn_phase: DeferredFirstTurnPhase,
2361    #[serde(default, skip_serializing_if = "Option::is_none")]
2362    pub(crate) pending_initial_prompt: Option<PendingDeferredPrompt>,
2363    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2364    pub(crate) pending_tool_results: Vec<PendingToolResultsMessage>,
2365}
2366
2367/// Canonical lifecycle phase for the session's deferred first turn.
2368#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
2369#[serde(rename_all = "snake_case")]
2370pub enum DeferredFirstTurnPhase {
2371    /// The session was not created in deferred-first-turn mode.
2372    #[default]
2373    Inactive,
2374    /// The session exists durably but the first turn has not started yet.
2375    Pending,
2376    /// The first turn has started; build-only overrides are no longer legal.
2377    Consumed,
2378}
2379
2380impl DeferredFirstTurnPhase {
2381    pub fn is_inactive(&self) -> bool {
2382        matches!(self, Self::Inactive)
2383    }
2384}
2385
2386impl From<DeferredFirstTurnPhase> for session_document::SessionFirstTurnPhase {
2387    fn from(value: DeferredFirstTurnPhase) -> Self {
2388        match value {
2389            DeferredFirstTurnPhase::Inactive => Self::Inactive,
2390            DeferredFirstTurnPhase::Pending => Self::Pending,
2391            DeferredFirstTurnPhase::Consumed => Self::Consumed,
2392        }
2393    }
2394}
2395
2396impl From<session_document::SessionFirstTurnPhase> for DeferredFirstTurnPhase {
2397    fn from(value: session_document::SessionFirstTurnPhase) -> Self {
2398        match value {
2399            session_document::SessionFirstTurnPhase::Inactive => Self::Inactive,
2400            session_document::SessionFirstTurnPhase::Pending => Self::Pending,
2401            session_document::SessionFirstTurnPhase::Consumed => Self::Consumed,
2402        }
2403    }
2404}
2405
2406fn is_default_hook_run_overrides(value: &crate::HookRunOverrides) -> bool {
2407    value == &crate::HookRunOverrides::default()
2408}
2409
2410fn is_default_call_timeout_override(value: &crate::CallTimeoutOverride) -> bool {
2411    value == &crate::CallTimeoutOverride::default()
2412}
2413
2414fn is_tool_filter_all(value: &ToolFilter) -> bool {
2415    matches!(value, ToolFilter::All)
2416}
2417
2418fn is_zero(value: &u64) -> bool {
2419    *value == 0
2420}
2421
2422/// Derive the machine-owned capability base filter from the current image-tool-results support.
2423pub fn capability_base_filter_for_image_tool_results(image_tool_results: bool) -> ToolFilter {
2424    if image_tool_results {
2425        ToolFilter::All
2426    } else {
2427        ToolFilter::Deny([VIEW_IMAGE_TOOL_NAME.to_string()].into_iter().collect())
2428    }
2429}
2430
2431/// Persisted witness for a durable tool-visibility name.
2432///
2433/// `last_seen_provenance` is the single typed identity owner. The formatted
2434/// `stable_owner_key` string is a read-only projection derived on demand via
2435/// [`crate::tool_catalog::stable_owner_key_from_provenance`], never stored
2436/// beside the owner.
2437#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
2438#[serde(rename_all = "snake_case")]
2439pub struct ToolVisibilityWitness {
2440    #[serde(default, skip_serializing_if = "Option::is_none")]
2441    pub last_seen_provenance: Option<ToolProvenance>,
2442}
2443
2444impl ToolVisibilityWitness {
2445    pub fn has_identity_witness(&self) -> bool {
2446        self.last_seen_provenance.is_some()
2447    }
2448}
2449
2450/// Typed authority value for a deferred-tool load request.
2451///
2452/// The public/effect seam carries the requested route name and provenance
2453/// witness as one value. Canonical owners may project this into name-indexed
2454/// maps internally, but callers do not get to make a map key the authority.
2455#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2456#[serde(rename_all = "snake_case")]
2457pub struct DeferredToolLoadAuthority {
2458    pub name: ToolName,
2459    pub witness: ToolVisibilityWitness,
2460}
2461
2462impl DeferredToolLoadAuthority {
2463    pub fn new(name: impl Into<ToolName>, witness: ToolVisibilityWitness) -> Self {
2464        Self {
2465            name: name.into(),
2466            witness,
2467        }
2468    }
2469
2470    pub fn into_parts(self) -> (ToolName, ToolVisibilityWitness) {
2471        (self.name, self.witness)
2472    }
2473}
2474
2475/// Durable tool-filter intent paired with the witnesses that made the names
2476/// authoritative at capture time.
2477#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
2478#[serde(rename_all = "snake_case")]
2479pub struct WitnessedToolFilter {
2480    pub filter: ToolFilter,
2481    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2482    pub witnesses: BTreeMap<ToolName, ToolVisibilityWitness>,
2483}
2484
2485impl WitnessedToolFilter {
2486    pub fn new(filter: ToolFilter, witnesses: BTreeMap<ToolName, ToolVisibilityWitness>) -> Self {
2487        Self { filter, witnesses }
2488    }
2489
2490    pub fn into_parts(self) -> (ToolFilter, BTreeMap<ToolName, ToolVisibilityWitness>) {
2491        (self.filter, self.witnesses)
2492    }
2493}
2494
2495/// Opaque parent/composition-authorized inherited tool visibility handoff.
2496///
2497/// The filter and witnesses are intentionally not public fields. Callers that
2498/// need to hand inherited visibility to a child build must obtain this from an
2499/// AgentFactory-minted parent composition authority; they cannot write
2500/// canonical session visibility state directly.
2501#[derive(Debug, Clone, PartialEq, Eq)]
2502pub struct InheritedToolVisibilityAuthority {
2503    filter: ToolFilter,
2504    witnesses: BTreeMap<ToolName, ToolVisibilityWitness>,
2505}
2506
2507impl InheritedToolVisibilityAuthority {
2508    pub(crate) fn from_generated_composition_authority(
2509        filter: ToolFilter,
2510        witnesses: BTreeMap<ToolName, ToolVisibilityWitness>,
2511    ) -> Self {
2512        Self { filter, witnesses }
2513    }
2514
2515    pub fn filter(&self) -> &ToolFilter {
2516        &self.filter
2517    }
2518
2519    pub fn witnesses(&self) -> &BTreeMap<ToolName, ToolVisibilityWitness> {
2520        &self.witnesses
2521    }
2522
2523    pub(crate) fn into_initial_visibility_state(self) -> SessionToolVisibilityState {
2524        SessionToolVisibilityState {
2525            inherited_base_filter: self.filter,
2526            filter_witnesses: self.witnesses,
2527            ..Default::default()
2528        }
2529    }
2530}
2531
2532/// Canonical durable session-local tool visibility intent.
2533#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
2534#[serde(rename_all = "snake_case")]
2535pub struct SessionToolVisibilityState {
2536    #[serde(default, skip_serializing_if = "is_tool_filter_all")]
2537    pub capability_base_filter: ToolFilter,
2538    #[serde(default, skip_serializing_if = "is_tool_filter_all")]
2539    pub inherited_base_filter: ToolFilter,
2540    #[serde(default, skip_serializing_if = "is_tool_filter_all")]
2541    pub active_filter: ToolFilter,
2542    #[serde(default, skip_serializing_if = "is_tool_filter_all")]
2543    pub staged_filter: ToolFilter,
2544    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
2545    pub active_requested_deferred_names: BTreeSet<ToolName>,
2546    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
2547    pub staged_requested_deferred_names: BTreeSet<ToolName>,
2548    #[serde(default, skip_serializing_if = "is_zero")]
2549    pub active_revision: u64,
2550    #[serde(default, skip_serializing_if = "is_zero")]
2551    pub staged_revision: u64,
2552    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2553    pub requested_witnesses: BTreeMap<ToolName, ToolVisibilityWitness>,
2554    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2555    pub filter_witnesses: BTreeMap<ToolName, ToolVisibilityWitness>,
2556}
2557
2558impl SessionToolVisibilityState {
2559    /// Deterministic projection of the generated CallingLlm visibility
2560    /// boundary. This is a comparison witness only: semantic promotion still
2561    /// belongs to the generated visibility owner.
2562    #[cfg(test)]
2563    pub(crate) fn projected_boundary_applied(&self) -> Self {
2564        let mut projected = self.clone();
2565        projected.active_filter = self.staged_filter.clone();
2566        projected.active_requested_deferred_names = self.staged_requested_deferred_names.clone();
2567        projected.active_revision = self.staged_revision;
2568        projected
2569    }
2570}
2571
2572/// Generated-authority-approved durable tool visibility projection.
2573///
2574/// Session metadata stores this as a projection of the generated visibility
2575/// owner. Code that only has raw `SessionToolVisibilityState` must first route
2576/// it through a `ToolVisibilityOwner`/`ToolScope` restore path.
2577#[derive(Debug, Clone, PartialEq, Eq)]
2578pub struct AuthorizedSessionToolVisibilityState {
2579    state: SessionToolVisibilityState,
2580}
2581
2582impl AuthorizedSessionToolVisibilityState {
2583    pub(crate) fn from_generated_authority(state: SessionToolVisibilityState) -> Self {
2584        Self { state }
2585    }
2586
2587    pub fn as_state(&self) -> &SessionToolVisibilityState {
2588        &self.state
2589    }
2590
2591    pub fn into_state(self) -> SessionToolVisibilityState {
2592        self.state
2593    }
2594}
2595
2596/// Durable build-only session state required to faithfully recover and rebuild
2597/// a persisted session without surface-local shadow config.
2598#[derive(Debug, Clone, Serialize, Deserialize, Default)]
2599#[serde(rename_all = "snake_case")]
2600pub struct SessionBuildState {
2601    #[serde(default, skip_serializing_if = "Option::is_none")]
2602    pub output_schema: Option<crate::OutputSchema>,
2603    #[serde(default, skip_serializing_if = "is_default_hook_run_overrides")]
2604    pub hooks_override: crate::HookRunOverrides,
2605    #[serde(default, skip_serializing_if = "Option::is_none")]
2606    pub budget_limits: Option<crate::BudgetLimits>,
2607    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2608    pub recoverable_tool_defs: Vec<ToolDef>,
2609    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2610    pub silent_comms_intents: Vec<String>,
2611    #[serde(default, skip_serializing_if = "Option::is_none")]
2612    pub max_inline_peer_notifications: Option<i32>,
2613    #[serde(default, skip_serializing_if = "Option::is_none")]
2614    pub app_context: Option<serde_json::Value>,
2615    #[serde(default, skip_serializing_if = "Option::is_none")]
2616    pub additional_instructions: Option<Vec<String>>,
2617    #[serde(default, skip_serializing_if = "Option::is_none")]
2618    pub shell_env: Option<HashMap<String, String>>,
2619    /// Compatibility projection of mob operator authority.
2620    ///
2621    /// `MobToolAuthorityContext` deliberately loses its generated authority
2622    /// seal when serialized; restored behavior must be approved by the
2623    /// generated runtime bridge before this projection can affect tools.
2624    #[serde(default, skip_serializing_if = "Option::is_none")]
2625    pub mob_tool_authority_context: Option<MobToolAuthorityContext>,
2626    #[serde(default, skip_serializing_if = "is_default_call_timeout_override")]
2627    pub call_timeout_override: crate::CallTimeoutOverride,
2628}
2629
2630/// Deferred create-time prompt staged for the next turn.
2631#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2632#[serde(rename_all = "snake_case")]
2633pub struct PendingDeferredPrompt {
2634    pub prompt: ContentInput,
2635    pub accepted_at: SystemTime,
2636}
2637
2638/// Staged callback tool results waiting to be admitted on the next turn seam.
2639#[derive(Debug, Clone, Serialize, Deserialize)]
2640#[serde(rename_all = "snake_case")]
2641pub struct PendingToolResultsMessage {
2642    pub results: Vec<ToolResult>,
2643    pub accepted_at: SystemTime,
2644}
2645
2646/// Typed refusal at the deferred callback-result ingress seam.
2647#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
2648pub enum DeferredToolResultsIngressError {
2649    #[error("callback result ingress contains duplicate tool id '{0}'")]
2650    DuplicateToolUseId(String),
2651    #[error("callback result for tool id '{0}' conflicts with its staged payload")]
2652    ConflictingRedelivery(String),
2653    #[error("callback result tool id '{0}' is outside the staged pending set")]
2654    WrongToolUseId(String),
2655}
2656
2657/// Durable staging record for one assistant tool-use batch that contains one
2658/// or more external callbacks and optional locally completed siblings.
2659///
2660/// Nothing in this record is provider-visible until the callback result is
2661/// admitted. The completed results and transcript-producing effects are
2662/// published together as one complete adjacent batch.
2663#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2664#[serde(rename_all = "snake_case")]
2665pub(crate) struct PendingCallbackToolBatch {
2666    pub run_id: RunId,
2667    pub tool_use_order: Vec<String>,
2668    pub pending_tool_use_ids: Vec<String>,
2669    pub completed_results: Vec<ToolResult>,
2670    pub session_effects: Vec<crate::ops::SessionEffect>,
2671    pub async_ops: Vec<crate::ops::AsyncOpRef>,
2672}
2673
2674#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2675#[serde(tag = "state", rename_all = "snake_case")]
2676enum CallbackToolBatchState {
2677    Pending {
2678        batch: PendingCallbackToolBatch,
2679    },
2680    Applied {
2681        tool_use_order: Vec<String>,
2682        results: Vec<ToolResult>,
2683        #[serde(default, skip_serializing_if = "Vec::is_empty")]
2684        async_ops: Vec<crate::ops::AsyncOpRef>,
2685        #[serde(default, skip_serializing_if = "Vec::is_empty")]
2686        post_tool_messages: Vec<Message>,
2687        #[serde(default)]
2688        post_tool_messages_applied: bool,
2689    },
2690}
2691
2692pub(crate) enum ResolvedPendingCallbackToolResults {
2693    NoState,
2694    Pending {
2695        batch: PendingCallbackToolBatch,
2696        ordered_results: Vec<ToolResult>,
2697    },
2698    AlreadyApplied {
2699        async_ops: Vec<crate::ops::AsyncOpRef>,
2700    },
2701}
2702
2703/// Admission verdict for callback results presented at a session-service
2704/// boundary before they enter deferred-turn state.
2705#[derive(Debug, Clone, PartialEq, Eq)]
2706#[doc(hidden)]
2707pub enum CallbackResultIngress {
2708    /// The session has no durable callback batch; legacy callers may use their
2709    /// ordinary deferred-input policy.
2710    NoPendingBatch,
2711    /// The exact result set belongs to the pending batch.
2712    Pending { pending_tool_use_ids: Vec<String> },
2713    /// The identical callback payload was already committed.
2714    AlreadyApplied,
2715}
2716
2717/// Typed failures at the durable callback-batch staging/apply seam.
2718#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
2719pub(crate) enum PendingCallbackBatchError {
2720    #[error("a pending callback batch is already staged")]
2721    AlreadyStaged,
2722    #[error("no pending callback batch is staged")]
2723    Missing,
2724    #[error("pending callback batch is malformed: {0}")]
2725    Malformed(String),
2726    #[error("callback results contain duplicate tool id '{0}'")]
2727    DuplicateResult(String),
2728    #[error("mob authority replacement cannot cross a durable callback staging boundary")]
2729    NonDurableAuthorityEffect,
2730    #[error("callback result ids {actual:?} do not match pending ids {expected:?}")]
2731    ResultSetMismatch {
2732        expected: BTreeSet<String>,
2733        actual: BTreeSet<String>,
2734    },
2735    #[error("callback result redelivery conflicts with the already applied payload")]
2736    ConflictingRedelivery,
2737}
2738
2739fn unique_tool_results(
2740    results: Vec<ToolResult>,
2741) -> Result<BTreeMap<String, ToolResult>, PendingCallbackBatchError> {
2742    let mut by_id = BTreeMap::new();
2743    for result in results {
2744        let id = result.tool_use_id.clone();
2745        if by_id.insert(id.clone(), result).is_some() {
2746            return Err(PendingCallbackBatchError::DuplicateResult(id));
2747        }
2748    }
2749    Ok(by_id)
2750}
2751
2752fn validate_pending_callback_batch(
2753    messages: &[Message],
2754    batch: &PendingCallbackToolBatch,
2755) -> Result<(), PendingCallbackBatchError> {
2756    let Some(assistant) = messages.last() else {
2757        return Err(PendingCallbackBatchError::Malformed(
2758            "staged callback batch has no assistant transcript tail".to_string(),
2759        ));
2760    };
2761    let assistant_order = assistant_tool_use_ids(assistant)
2762        .into_iter()
2763        .map(str::to_string)
2764        .collect::<Vec<_>>();
2765    if assistant_order != batch.tool_use_order {
2766        return Err(PendingCallbackBatchError::Malformed(format!(
2767            "assistant tool ids {assistant_order:?} do not match staged order {:?}",
2768            batch.tool_use_order
2769        )));
2770    }
2771    let assistant_set = assistant_order.iter().cloned().collect::<BTreeSet<_>>();
2772    if assistant_set.len() != assistant_order.len() {
2773        return Err(PendingCallbackBatchError::Malformed(
2774            "assistant tool-use batch contains duplicate ids".to_string(),
2775        ));
2776    }
2777    let pending_set = batch
2778        .pending_tool_use_ids
2779        .iter()
2780        .cloned()
2781        .collect::<BTreeSet<_>>();
2782    if pending_set.len() != batch.pending_tool_use_ids.len() || pending_set.is_empty() {
2783        return Err(PendingCallbackBatchError::Malformed(
2784            "staged callback batch must contain at least one unique pending tool id".to_string(),
2785        ));
2786    }
2787    let completed = unique_tool_results(batch.completed_results.clone())?;
2788    let completed_set = completed.keys().cloned().collect::<BTreeSet<_>>();
2789    if !pending_set.is_disjoint(&completed_set)
2790        || pending_set
2791            .union(&completed_set)
2792            .cloned()
2793            .collect::<BTreeSet<_>>()
2794            != assistant_set
2795    {
2796        return Err(PendingCallbackBatchError::Malformed(format!(
2797            "pending ids {pending_set:?} plus completed ids {completed_set:?} do not partition assistant ids {assistant_set:?}"
2798        )));
2799    }
2800    if batch.session_effects.iter().any(|effect| {
2801        matches!(
2802            effect,
2803            crate::ops::SessionEffect::ReplaceMobToolAuthorityContext { .. }
2804        )
2805    }) {
2806        return Err(PendingCallbackBatchError::NonDurableAuthorityEffect);
2807    }
2808    Ok(())
2809}
2810
2811impl PartialEq for PendingToolResultsMessage {
2812    fn eq(&self, other: &Self) -> bool {
2813        self.accepted_at == other.accepted_at
2814            && serde_json::to_value(&self.results).ok() == serde_json::to_value(&other.results).ok()
2815    }
2816}
2817
2818/// Deferred first-turn inputs consumed at the generated start-turn authority seam.
2819#[derive(Debug, Clone, Default, PartialEq)]
2820pub struct ConsumedDeferredTurnInputs {
2821    pub(crate) restore_first_turn_pending: bool,
2822    pub(crate) pending_initial_prompt: Option<PendingDeferredPrompt>,
2823    pub(crate) pending_tool_results: Vec<PendingToolResultsMessage>,
2824}
2825
2826impl ConsumedDeferredTurnInputs {
2827    pub fn is_empty(&self) -> bool {
2828        !self.restore_first_turn_pending
2829            && self.pending_initial_prompt.is_none()
2830            && self.pending_tool_results.is_empty()
2831    }
2832
2833    pub fn pending_initial_prompt(&self) -> Option<&PendingDeferredPrompt> {
2834        self.pending_initial_prompt.as_ref()
2835    }
2836
2837    pub fn pending_tool_results(&self) -> &[PendingToolResultsMessage] {
2838        &self.pending_tool_results
2839    }
2840}
2841
2842/// Per-session registry key for the first-turn region of the
2843/// [`session_document::SessionDocumentMachine`]. Each
2844/// [`SessionDeferredTurnState`] is a single session's projection, so its
2845/// machine instance carries exactly one registry entry under this key.
2846const SESSION_DOCUMENT_FIRST_TURN_KEY: &str = "first_turn";
2847
2848fn usize_to_u64(value: usize) -> u64 {
2849    u64::try_from(value).unwrap_or(u64::MAX)
2850}
2851
2852/// Authorize a durable deferred-turn snapshot through the canonical
2853/// [`session_document::SessionDocumentMachine`] recovery transition.
2854///
2855/// The machine validates that the persisted first-turn phase is a legal
2856/// recovery target and adopts it into its per-session registry, emitting
2857/// `SessionFirstTurnPhaseRecovered`. The snapshot is returned unchanged on
2858/// success; the machine — not this shell — owns the recovery legality.
2859fn validate_deferred_turn_snapshot(
2860    state: SessionDeferredTurnState,
2861) -> Result<SessionDeferredTurnState, session_document::SessionDocumentError> {
2862    let mut authority = session_document::SessionDocumentMachineAuthority::new();
2863    let key = session_document::SessionDocumentKey::new(SESSION_DOCUMENT_FIRST_TURN_KEY);
2864    // The recovery transition fails closed for any illegal first-turn phase
2865    // (its guard admits only the three known phases); a rejection surfaces as
2866    // `Err` here. On success the machine has adopted the snapshot.
2867    authority.recover_session_first_turn_phase(
2868        key,
2869        state.first_turn_phase.into(),
2870        state.pending_initial_prompt.is_some(),
2871        usize_to_u64(state.pending_tool_results.len()),
2872    )?;
2873    Ok(state)
2874}
2875
2876impl SessionDeferredTurnState {
2877    pub fn first_turn_phase(&self) -> DeferredFirstTurnPhase {
2878        self.first_turn_phase
2879    }
2880
2881    pub fn pending_initial_prompt(&self) -> Option<&PendingDeferredPrompt> {
2882        self.pending_initial_prompt.as_ref()
2883    }
2884
2885    pub fn pending_tool_results(&self) -> &[PendingToolResultsMessage] {
2886        &self.pending_tool_results
2887    }
2888
2889    pub fn pending_tool_results_len(&self) -> usize {
2890        self.pending_tool_results.len()
2891    }
2892
2893    pub(crate) fn pending_initial_prompt_mut_for_blob_rewrite(
2894        &mut self,
2895    ) -> Option<&mut PendingDeferredPrompt> {
2896        self.pending_initial_prompt.as_mut()
2897    }
2898
2899    pub(crate) fn pending_tool_results_mut_for_blob_rewrite(
2900        &mut self,
2901    ) -> &mut [PendingToolResultsMessage] {
2902        &mut self.pending_tool_results
2903    }
2904
2905    /// Build a [`SessionDocumentMachineAuthority`] seeded with this session's
2906    /// current durable first-turn projection.
2907    ///
2908    /// The machine owns the canonical first-turn phase + presence/count in its
2909    /// own per-session `Map`; the durable [`SessionDeferredTurnState`] is its
2910    /// projection. We recover the machine-owned registry from that projection
2911    /// before driving an operation so every subsequent decision reads the
2912    /// machine's own state — the shell never passes a phase conclusion as an
2913    /// operation input.
2914    fn document_authority(
2915        &self,
2916    ) -> (
2917        session_document::SessionDocumentMachineAuthority,
2918        session_document::SessionDocumentKey,
2919    ) {
2920        let mut authority = session_document::SessionDocumentMachineAuthority::new();
2921        let key = session_document::SessionDocumentKey::new(SESSION_DOCUMENT_FIRST_TURN_KEY);
2922        if let Err(err) = authority.recover_session_first_turn_phase(
2923            key.clone(),
2924            self.first_turn_phase.into(),
2925            self.pending_initial_prompt.is_some(),
2926            usize_to_u64(self.pending_tool_results.len()),
2927        ) {
2928            tracing::warn!(
2929                error = %err,
2930                "generated session document authority rejected first-turn recovery"
2931            );
2932        }
2933        (authority, key)
2934    }
2935
2936    /// Mirror the machine-resolved first-turn phase from one effect batch onto
2937    /// the durable projection, returning `was_pending` when present.
2938    fn mirror_first_turn_phase(
2939        &mut self,
2940        effects: &[session_document::SessionDocumentEffect],
2941    ) -> Option<bool> {
2942        for effect in effects {
2943            if let session_document::SessionDocumentEffect::SessionFirstTurnPhaseResolved {
2944                phase,
2945                was_pending,
2946            } = effect
2947            {
2948                self.first_turn_phase = (*phase).into();
2949                return Some(*was_pending);
2950            }
2951        }
2952        None
2953    }
2954
2955    /// Mark that this session has a deferred first turn waiting to start.
2956    pub fn mark_initial_turn_pending(&mut self) {
2957        let (mut authority, key) = self.document_authority();
2958        match authority.mark_session_initial_turn_pending(key) {
2959            Ok(effects) => {
2960                self.mirror_first_turn_phase(&effects);
2961            }
2962            Err(err) => tracing::warn!(
2963                error = %err,
2964                "generated session document authority rejected pending mark"
2965            ),
2966        }
2967    }
2968
2969    /// Mark the deferred first turn as started.
2970    ///
2971    /// Returns true when the phase transitioned from `Pending`.
2972    pub fn mark_initial_turn_started(&mut self) -> bool {
2973        let (mut authority, key) = self.document_authority();
2974        match authority.start_session_initial_turn(key) {
2975            Ok(effects) => self.mirror_first_turn_phase(&effects).unwrap_or(false),
2976            Err(err) => {
2977                tracing::warn!(
2978                    error = %err,
2979                    "generated session document authority rejected first-turn start"
2980                );
2981                false
2982            }
2983        }
2984    }
2985
2986    /// Restore the deferred first-turn pending phase after a failed pre-run setup.
2987    pub fn restore_initial_turn_pending(&mut self) {
2988        // The restore-to-pending decision is the machine's
2989        // `RestoreSessionConsumedInputs` transition with phase rollback
2990        // requested; presence/count mirrors are left untouched here because the
2991        // bulky payloads are restored separately by the caller.
2992        let (mut authority, key) = self.document_authority();
2993        match authority.restore_session_consumed_inputs(
2994            key.clone(),
2995            true,
2996            self.pending_initial_prompt.is_some(),
2997            usize_to_u64(self.pending_tool_results.len()),
2998        ) {
2999            Ok(_) => {
3000                // Mirror the machine-owned phase the restore transition wrote
3001                // into its per-session registry rather than re-deriving it.
3002                if let Some(phase) = authority.session_first_turn_phase_for(&key) {
3003                    self.first_turn_phase = phase.into();
3004                }
3005            }
3006            Err(err) => tracing::warn!(
3007                error = %err,
3008                "generated session document authority rejected pending restore"
3009            ),
3010        }
3011    }
3012
3013    /// Whether build-only first-turn overrides are still legal for this session.
3014    pub fn allows_initial_turn_overrides(&self) -> bool {
3015        let (mut authority, key) = self.document_authority();
3016        match authority.resolve_session_first_turn_overrides_allowed(key) {
3017            Ok(effects) => effects
3018                .iter()
3019                .find_map(|effect| {
3020                    match effect {
3021                session_document::SessionDocumentEffect::SessionFirstTurnOverridesResolved {
3022                    allowed,
3023                } => Some(*allowed),
3024                _ => None,
3025            }
3026                })
3027                .unwrap_or(false),
3028            Err(err) => {
3029                tracing::warn!(
3030                    error = %err,
3031                    "generated session document authority rejected override resolution"
3032                );
3033                false
3034            }
3035        }
3036    }
3037
3038    /// Stage the create-time prompt for a later first turn.
3039    pub fn stage_initial_prompt(&mut self, prompt: ContentInput, accepted_at: SystemTime) {
3040        let prompt_has_content = prompt.has_images() || !prompt.text_content().trim().is_empty();
3041        let (mut authority, key) = self.document_authority();
3042        match authority.stage_session_initial_prompt(key, prompt_has_content) {
3043            Ok(effects) => {
3044                let decision = effects.iter().find_map(|effect| {
3045                    match effect {
3046                    session_document::SessionDocumentEffect::SessionInitialPromptStageResolved {
3047                        decision,
3048                    } => Some(*decision),
3049                    _ => None,
3050                }
3051                });
3052                match decision {
3053                    Some(session_document::SessionInitialPromptStageDecision::Store) => {
3054                        self.pending_initial_prompt = Some(PendingDeferredPrompt {
3055                            prompt,
3056                            accepted_at,
3057                        });
3058                    }
3059                    Some(session_document::SessionInitialPromptStageDecision::Clear) => {
3060                        self.pending_initial_prompt = None;
3061                    }
3062                    None => tracing::warn!(
3063                        "generated session document authority returned no prompt-stage decision"
3064                    ),
3065                }
3066            }
3067            Err(err) => tracing::warn!(
3068                error = %err,
3069                "generated session document authority rejected initial prompt stage"
3070            ),
3071        }
3072    }
3073
3074    /// Stage one callback tool-results message for the next turn.
3075    pub fn try_stage_tool_results(
3076        &mut self,
3077        results: Vec<ToolResult>,
3078        accepted_at: SystemTime,
3079    ) -> Result<usize, DeferredToolResultsIngressError> {
3080        let mut incoming_by_id = BTreeMap::new();
3081        for result in &results {
3082            if incoming_by_id
3083                .insert(result.tool_use_id.clone(), result)
3084                .is_some()
3085            {
3086                return Err(DeferredToolResultsIngressError::DuplicateToolUseId(
3087                    result.tool_use_id.clone(),
3088                ));
3089            }
3090        }
3091
3092        let mut staged_by_id = BTreeMap::new();
3093        for pending in &self.pending_tool_results {
3094            for result in &pending.results {
3095                match staged_by_id.insert(result.tool_use_id.clone(), result) {
3096                    Some(previous) if previous != result => {
3097                        return Err(DeferredToolResultsIngressError::ConflictingRedelivery(
3098                            result.tool_use_id.clone(),
3099                        ));
3100                    }
3101                    _ => {}
3102                }
3103            }
3104        }
3105        if !staged_by_id.is_empty() {
3106            for (id, incoming) in &incoming_by_id {
3107                match staged_by_id.get(id) {
3108                    Some(staged) if *staged == *incoming => {}
3109                    Some(_) => {
3110                        return Err(DeferredToolResultsIngressError::ConflictingRedelivery(
3111                            id.clone(),
3112                        ));
3113                    }
3114                    None => {
3115                        return Err(DeferredToolResultsIngressError::WrongToolUseId(id.clone()));
3116                    }
3117                }
3118            }
3119            return Ok(0);
3120        }
3121
3122        let (mut authority, key) = self.document_authority();
3123        let accepted = match authority.stage_session_tool_results(key, usize_to_u64(results.len()))
3124        {
3125            Ok(effects) => effects.iter().find_map(|effect| match effect {
3126                session_document::SessionDocumentEffect::SessionToolResultsStageResolved {
3127                    accepted_count,
3128                } => Some(*accepted_count),
3129                _ => None,
3130            }),
3131            Err(err) => {
3132                tracing::warn!(
3133                    error = %err,
3134                    "generated session document authority rejected tool-results stage"
3135                );
3136                return Ok(0);
3137            }
3138        };
3139        let Some(accepted) = accepted else {
3140            tracing::warn!(
3141                "generated session document authority returned no tool-results decision"
3142            );
3143            return Ok(0);
3144        };
3145        if accepted == 0 {
3146            return Ok(0);
3147        }
3148        let accepted = usize::try_from(accepted).unwrap_or(usize::MAX);
3149        self.pending_tool_results.push(PendingToolResultsMessage {
3150            results,
3151            accepted_at,
3152        });
3153        Ok(accepted)
3154    }
3155
3156    /// Compatibility projection for callers that cannot surface a typed
3157    /// ingress refusal. Public session-service ingress uses
3158    /// [`Self::try_stage_tool_results`] and preserves the error.
3159    pub fn stage_tool_results(
3160        &mut self,
3161        results: Vec<ToolResult>,
3162        accepted_at: SystemTime,
3163    ) -> usize {
3164        match self.try_stage_tool_results(results, accepted_at) {
3165            Ok(accepted) => accepted,
3166            Err(error) => {
3167                tracing::warn!(%error, "deferred callback-result ingress was rejected");
3168                0
3169            }
3170        }
3171    }
3172
3173    /// Whether any callback tool results are currently staged.
3174    pub fn has_pending_tool_results(&self) -> bool {
3175        !self.pending_tool_results.is_empty()
3176    }
3177
3178    /// Start a turn and consume all inputs generated-authorized for that seam.
3179    pub fn consume_for_started_turn(&mut self) -> ConsumedDeferredTurnInputs {
3180        let (mut authority, key) = self.document_authority();
3181        let was_pending = match authority.consume_session_deferred_inputs(key) {
3182            Ok(effects) => self.mirror_first_turn_phase(&effects).unwrap_or(false),
3183            Err(err) => {
3184                tracing::warn!(
3185                    error = %err,
3186                    "generated session document authority rejected started-turn consumption"
3187                );
3188                return ConsumedDeferredTurnInputs::default();
3189            }
3190        };
3191        ConsumedDeferredTurnInputs {
3192            restore_first_turn_pending: was_pending,
3193            pending_initial_prompt: self.pending_initial_prompt.take(),
3194            pending_tool_results: std::mem::take(&mut self.pending_tool_results),
3195        }
3196    }
3197
3198    /// Restore inputs previously consumed by `consume_for_started_turn`.
3199    pub fn restore_consumed_turn_inputs(&mut self, consumed: ConsumedDeferredTurnInputs) {
3200        if consumed.is_empty() {
3201            return;
3202        }
3203        let (mut authority, key) = self.document_authority();
3204        let effects = match authority.restore_session_consumed_inputs(
3205            key,
3206            consumed.restore_first_turn_pending,
3207            consumed.pending_initial_prompt.is_some(),
3208            usize_to_u64(consumed.pending_tool_results.len()),
3209        ) {
3210            Ok(effects) => effects,
3211            Err(err) => {
3212                tracing::warn!(
3213                    error = %err,
3214                    "generated session document authority rejected consumed input restore"
3215                );
3216                return;
3217            }
3218        };
3219        let Some((restore_first_turn_pending, restore_initial_prompt, restore_tool_results)) =
3220            effects.iter().find_map(|effect| match effect {
3221                session_document::SessionDocumentEffect::SessionConsumedInputsRestoreResolved {
3222                    restore_first_turn_pending,
3223                    restore_initial_prompt,
3224                    restore_tool_results,
3225                } => Some((
3226                    *restore_first_turn_pending,
3227                    *restore_initial_prompt,
3228                    *restore_tool_results,
3229                )),
3230                _ => None,
3231            })
3232        else {
3233            tracing::warn!(
3234                "generated session document authority returned no consumed-input restore decision"
3235            );
3236            return;
3237        };
3238        if restore_first_turn_pending {
3239            self.restore_initial_turn_pending();
3240        }
3241        if restore_initial_prompt && self.pending_initial_prompt.is_none() {
3242            self.pending_initial_prompt = consumed.pending_initial_prompt;
3243        }
3244        if restore_tool_results {
3245            let mut restored = consumed.pending_tool_results;
3246            restored.extend(std::mem::take(&mut self.pending_tool_results));
3247            self.pending_tool_results = restored;
3248        }
3249    }
3250}
3251
3252/// Failure when appending an identity-bearing System message.
3253#[derive(Debug, Clone, PartialEq, Eq)]
3254pub enum SystemMessageAppendError {
3255    Conflict {
3256        key: String,
3257        existing_text: String,
3258        existing_source: Option<String>,
3259    },
3260}
3261
3262impl std::fmt::Display for SystemMessageAppendError {
3263    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3264        match self {
3265            Self::Conflict { key, .. } => {
3266                write!(
3267                    f,
3268                    "System-message append conflict for idempotency key `{key}`"
3269                )
3270            }
3271        }
3272    }
3273}
3274
3275impl std::error::Error for SystemMessageAppendError {}
3276
3277impl Session {
3278    /// Validate callback-result ingress against the exact durable callback
3279    /// batch without mutating transcript or deferred-turn state.
3280    #[doc(hidden)]
3281    pub fn classify_callback_result_ingress(
3282        &self,
3283        incoming: &[ToolResult],
3284    ) -> Result<CallbackResultIngress, crate::error::AgentError> {
3285        match self
3286            .resolve_pending_callback_tool_results(incoming.to_vec())
3287            .map_err(|error| {
3288                crate::error::AgentError::ConfigError(format!(
3289                    "callback result ingress was rejected: {error}"
3290                ))
3291            })? {
3292            ResolvedPendingCallbackToolResults::NoState => {
3293                Ok(CallbackResultIngress::NoPendingBatch)
3294            }
3295            ResolvedPendingCallbackToolResults::AlreadyApplied { .. } => {
3296                Ok(CallbackResultIngress::AlreadyApplied)
3297            }
3298            ResolvedPendingCallbackToolResults::Pending { batch, .. } => {
3299                Ok(CallbackResultIngress::Pending {
3300                    pending_tool_use_ids: batch.pending_tool_use_ids,
3301                })
3302            }
3303        }
3304    }
3305
3306    /// Create a new empty session
3307    pub fn new() -> Self {
3308        let now = SystemTime::now();
3309        let id = SessionId::new();
3310        Self {
3311            version: session_version(),
3312            realtime_transcript: Box::new(SessionRealtimeTranscriptProjection::empty(&id)),
3313            id,
3314            messages: TranscriptMessages::default(),
3315            created_at: now,
3316            updated_at: now,
3317            metadata: serde_json::Map::new(),
3318            history_caches: Box::default(),
3319            transcript_history_metadata_validation: TranscriptHistoryMetadataValidation::Validated,
3320            usage: Usage::default(),
3321        }
3322    }
3323
3324    /// Create a session with a specific ID (for loading)
3325    pub fn with_id(id: SessionId) -> Self {
3326        let mut session = Self::new();
3327        session.realtime_transcript = Box::new(SessionRealtimeTranscriptProjection::empty(&id));
3328        session.id = id;
3329        session
3330    }
3331
3332    /// Get the session ID
3333    pub fn id(&self) -> &SessionId {
3334        &self.id
3335    }
3336
3337    /// Get the session version
3338    pub fn version(&self) -> u32 {
3339        self.version
3340    }
3341
3342    /// Get all messages.
3343    pub fn messages(&self) -> &[Message] {
3344        &self.messages
3345    }
3346
3347    /// Format-2 content digest of the live transcript.
3348    ///
3349    /// Byte-identical to `transcript_messages_digest(session.messages())` —
3350    /// same canonicalization, same bytes, same string — but served from the
3351    /// session's retained SHA-256 midstate when one covers the current buffer,
3352    /// so an ordinary append costs O(delta) instead of O(document). Prefer
3353    /// this over the free function anywhere a `Session` is in hand; the free
3354    /// function stays for slices that no session owns (revision bodies,
3355    /// candidate vectors).
3356    pub fn transcript_content_digest(&self) -> Result<String, serde_json::Error> {
3357        self.messages.digest()
3358    }
3359
3360    /// Format-2 content digest of the first `count` live messages.
3361    ///
3362    /// Served from the boundary ring when a previous full digest was taken at
3363    /// exactly that count — which is the save-guard prefix question — and by
3364    /// full recompute otherwise.
3365    pub fn transcript_prefix_digest(&self, count: usize) -> Result<String, serde_json::Error> {
3366        if count > self.messages.len() {
3367            // Fail closed rather than silently digesting a shorter prefix: a
3368            // caller asking past the end has lost track of which row it is
3369            // comparing against, and answering with a different prefix's
3370            // digest would launder that into a continuity verdict.
3371            return Err(<serde_json::Error as serde::ser::Error>::custom(format!(
3372                "transcript prefix digest requested for {count} messages but the transcript has {}",
3373                self.messages.len()
3374            )));
3375        }
3376        if let Some(witness) = self.messages.prefix_digest_witness(count) {
3377            return Ok(witness);
3378        }
3379        transcript_messages_digest(&self.messages[..count])
3380    }
3381
3382    /// Number of non-append transcript mutations this in-memory session has
3383    /// applied. Diagnostics and regression tests only.
3384    #[doc(hidden)]
3385    #[must_use]
3386    pub fn transcript_mutation_epoch(&self) -> u64 {
3387        self.messages.mutation_epoch()
3388    }
3389
3390    /// Replace the message buffer for core-owned internal transcript rewrites.
3391    ///
3392    /// Intentionally `pub(crate)`: cross-crate consumers must route same-session
3393    /// rewrites through transcript-edit APIs so the revision graph remains the
3394    /// semantic owner of message history.
3395    #[allow(dead_code)] // Kept for core-owned optional rewrite paths and focused invariants.
3396    pub(crate) fn replace_messages_internal(
3397        &mut self,
3398        messages: Vec<Message>,
3399        reason: TranscriptRewriteReason,
3400    ) -> Result<Option<TranscriptRewriteCommit>, TranscriptEditError> {
3401        if transcript_messages_digest(self.messages()).ok()
3402            == transcript_messages_digest(&messages).ok()
3403        {
3404            return Ok(None);
3405        }
3406        let commit = self.commit_transcript_rewrite(
3407            TranscriptRewriteSelection::MessageRange {
3408                start: 0,
3409                end: self.messages.len(),
3410            },
3411            messages,
3412            reason,
3413            Some("meerkat-core".to_string()),
3414            None,
3415        )?;
3416        Ok(Some(commit))
3417    }
3418
3419    /// Replace the full transcript under the opaque authority minted by the
3420    /// validated compaction rebuild path.
3421    pub(crate) fn replace_messages_for_compaction_internal(
3422        &mut self,
3423        messages: Vec<Message>,
3424        authority: &crate::agent::compact::ValidatedCompactionRewrite,
3425    ) -> Result<Option<TranscriptRewriteCommit>, TranscriptEditError> {
3426        // Authority first. The parent side binds against the session
3427        // accumulator (O(delta), byte-identical to
3428        // `transcript_messages_digest(self.messages())`); the rebuilt side
3429        // binds inside the commit below, where its one required digest is
3430        // computed and compared against the token's revision. The no-op
3431        // answer then falls out of the token's own two digests instead of
3432        // two more whole-document hashes.
3433        let parent_revision = self
3434            .transcript_content_digest()
3435            .map_err(|error| TranscriptEditError::HistoryStateMalformed(error.to_string()))?;
3436        if !authority.authorizes_parent_digest(
3437            &parent_revision,
3438            self.messages.len(),
3439            messages.len(),
3440        ) {
3441            return Err(TranscriptEditError::InvalidTranscriptShape(
3442                "validated compaction witness does not authorize this exact transcript rebuild"
3443                    .to_string(),
3444            ));
3445        }
3446        if authority.is_no_op() {
3447            return Ok(None);
3448        }
3449        let summary_count = messages
3450            .iter()
3451            .filter(|message| {
3452                matches!(message, Message::User(user) if user.transcript_role.is_compaction_summary())
3453            })
3454            .count();
3455        if messages.len() >= self.messages.len() || summary_count != 1 {
3456            return Err(TranscriptEditError::InvalidTranscriptShape(
3457                "validated compaction rewrite must shrink the transcript and carry exactly one CompactionSummary"
3458                    .to_string(),
3459            ));
3460        }
3461        let selection =
3462            TranscriptRewriteSelection::validated_compaction(0, self.messages.len(), authority);
3463        let commit = self.commit_transcript_rewrite_bound(
3464            selection,
3465            messages,
3466            TranscriptRewriteReason::new("compaction"),
3467            Some("meerkat-core".to_string()),
3468            Some(authority.parent_revision().to_string()),
3469            Some(authority.revision()),
3470        )?;
3471        Ok(Some(commit))
3472    }
3473
3474    /// Construct one legitimate typed compaction and its paired projection
3475    /// intent for downstream persistence-contract tests.
3476    ///
3477    /// This seam deliberately remains behind `test-support`: tests may ask the
3478    /// core compaction owner to mint the opaque witness, but may not reproduce
3479    /// its selection tag or projection fingerprint outside this crate.
3480    #[cfg(any(test, feature = "test-support"))]
3481    #[doc(hidden)]
3482    pub fn stage_validated_compaction_for_test(
3483        &mut self,
3484        replacement: Vec<Message>,
3485        summary_tokens: u64,
3486    ) -> Result<
3487        (
3488            TranscriptRewriteCommit,
3489            crate::memory::CompactionProjectionIntent,
3490        ),
3491        String,
3492    > {
3493        let messages_before = self.messages.len();
3494        let authority = crate::agent::compact::ValidatedCompactionRewrite::for_test(
3495            self.messages(),
3496            &replacement,
3497        )
3498        .map_err(|error| error.to_string())?;
3499        let commit = self
3500            .replace_messages_for_compaction_internal(replacement, &authority)
3501            .map_err(|error| error.to_string())?
3502            .ok_or_else(|| "test compaction rewrite was a no-op".to_string())?;
3503        let projection = crate::memory::CompactionProjectionId::from_validated_transcript_rewrite(
3504            self.id().clone(),
3505            &commit,
3506            &authority,
3507        )
3508        .ok_or_else(|| {
3509            "core-owned test compaction did not mint a projection identity".to_string()
3510        })?;
3511        let intent = crate::memory::CompactionProjectionIntent {
3512            projection,
3513            summary_tokens,
3514            messages_before,
3515            messages_after: self.messages.len(),
3516        };
3517        self.add_compaction_projection_intent(intent.clone())
3518            .map_err(|error| error.to_string())?;
3519        Ok((commit, intent))
3520    }
3521
3522    /// Atomically refresh the synthetic runtime notices of one kind.
3523    ///
3524    /// This is the ONE transcript authority operation for synthetic-notice
3525    /// refresh: it strips every synthetic `SystemNotice` projection of `kind`
3526    /// while preserving durable notices that share the kind, then appends
3527    /// `replacements` (possibly empty, meaning "no current synthetic notice")
3528    /// as one mechanical projection update. It deliberately does not mint an
3529    /// audited transcript rewrite commit. On a strip fault nothing is pushed
3530    /// and the typed [`TranscriptEditError`] propagates — callers must not
3531    /// re-implement the strip-then-push pair (the swallowed-strip variant
3532    /// leaves a stale notice beside a fresh one: a divergence window).
3533    pub fn replace_synthetic_notices(
3534        &mut self,
3535        kind: crate::types::SystemNoticeKind,
3536        replacements: Vec<Message>,
3537    ) -> Result<(), TranscriptEditError> {
3538        if !kind.is_synthetic_refresh_projection() {
3539            return Err(TranscriptEditError::InvalidTranscriptShape(format!(
3540                "system notice kind {kind:?} is durable transcript content, not a synthetic refresh projection"
3541            )));
3542        }
3543        for (index, message) in replacements.iter().enumerate() {
3544            let matches_kind = matches!(
3545                message,
3546                Message::SystemNotice(notice)
3547                    if notice.kind == kind && notice.is_synthetic_refresh_projection()
3548            );
3549            if !matches_kind {
3550                return Err(TranscriptEditError::InvalidTranscriptShape(format!(
3551                    "replacement {index} for synthetic notice kind {kind:?} is not a system notice of that kind"
3552                )));
3553            }
3554        }
3555
3556        // No-op detection without hashing the document. The refresh removes
3557        // every synthetic notice of `kind` and appends `replacements` at the
3558        // tail, and every retained message is a clone of the live one, so
3559        // the refreshed vector is canonically identical to the current one
3560        // IFF the existing notices already sit contiguously at the tail and
3561        // each is canonical-equal to its replacement (canonical equality
3562        // erases construction bookkeeping such as `created_at`, exactly like
3563        // the digest comparison this replaces — which re-canonicalized and
3564        // re-hashed the WHOLE document once per pre-LLM boundary). A
3565        // non-notice message can never be canonical-equal to a notice, so
3566        // the positional argument is exact, not heuristic.
3567        let is_refresh_notice = |message: &Message| {
3568            matches!(
3569                message,
3570                Message::SystemNotice(notice)
3571                    if notice.kind == kind && notice.is_synthetic_refresh_projection()
3572            )
3573        };
3574        let existing_count = self
3575            .messages
3576            .iter()
3577            .filter(|message| is_refresh_notice(message))
3578            .count();
3579        let tail_start = self.messages.len().saturating_sub(existing_count);
3580        let existing_are_contiguous_tail =
3581            self.messages[tail_start..].iter().all(&is_refresh_notice);
3582        if existing_count == replacements.len()
3583            && existing_are_contiguous_tail
3584            && self.messages[tail_start..]
3585                .iter()
3586                .zip(replacements.iter())
3587                .all(|(existing, replacement)| {
3588                    canonicalize_message_for_digest(existing)
3589                        == canonicalize_message_for_digest(replacement)
3590                })
3591        {
3592            return Ok(());
3593        }
3594
3595        let lowest_mutated_index = self
3596            .messages
3597            .iter()
3598            .position(&is_refresh_notice)
3599            .unwrap_or(self.messages.len());
3600        let mut refreshed = self
3601            .messages
3602            .iter()
3603            .filter(|message| !is_refresh_notice(message))
3604            .cloned()
3605            .collect::<Vec<_>>();
3606        refreshed.extend(replacements);
3607        let realtime_rebase = self.prepare_realtime_transcript_rebase_after_rewrite(
3608            &refreshed,
3609            RealtimeTranscriptSnapshotReasonV1::TranscriptRewrite,
3610        )?;
3611        if let Some(history) = self.validated_transcript_history_state()? {
3612            let head_len = history
3613                .final_endpoint_witness()
3614                .ok_or_else(|| {
3615                    TranscriptEditError::HistoryStateMalformed(
3616                        "compact graph has no final endpoint witness".to_string(),
3617                    )
3618                })?
3619                .message_count();
3620            // This transformation removes only matching notices and appends
3621            // every replacement at the tail. Therefore an existing matching
3622            // notice inside the audited prefix is exactly the shape that
3623            // would alter it; no whole-prefix hash or message copy is needed
3624            // to prove the negative.
3625            if self.messages.len() < head_len
3626                || self.messages[..head_len].iter().any(&is_refresh_notice)
3627            {
3628                return Err(TranscriptEditError::InvalidTranscriptShape(
3629                    "synthetic notice refresh would rewrite the audited transcript prefix; route it through a typed transcript rewrite"
3630                        .to_string(),
3631                ));
3632            }
3633        }
3634        let updated_at = SystemTime::now();
3635        if lowest_mutated_index == self.messages.len() {
3636            // No prior notice existed: this is an exact append, so preserve
3637            // the live digest and row-lineage accumulators as an append.
3638            let appended = refreshed.split_off(lowest_mutated_index);
3639            self.messages.extend_batch(appended);
3640        } else {
3641            // SEAM 1 (known-index replacement): synthetic notices are stripped
3642            // from `lowest_mutated_index` onward and replacements are appended.
3643            // Park the accumulator so it can retain an exact durable-row anchor
3644            // only when the first changed row is at or beyond that anchor. A
3645            // refresh that reaches into committed history therefore still drops
3646            // the witness and must cross the typed rewrite boundary before the
3647            // next HeadCanonical persist.
3648            *self.messages.begin_in_place_scan() = refreshed;
3649            self.messages
3650                .finish_in_place_scan(Some(lowest_mutated_index));
3651        }
3652        self.mark_content_mutated(updated_at);
3653        self.realtime_transcript
3654            .apply_prepared_rebase(realtime_rebase);
3655        Ok(())
3656    }
3657
3658    /// Get creation time
3659    pub fn created_at(&self) -> SystemTime {
3660        self.created_at
3661    }
3662
3663    /// Get last update time
3664    pub fn updated_at(&self) -> SystemTime {
3665        self.updated_at
3666    }
3667
3668    /// Add a message to the session
3669    ///
3670    /// Updates the timestamp. For adding multiple messages, prefer `push_batch`.
3671    pub fn push(&mut self, message: Message) {
3672        // SEAM 2 (append): the accumulator folds only the appended bytes.
3673        // Retained rewrite history is intentionally untouched: its head is the
3674        // latest AUDITED endpoint, while this live append is owned by
3675        // `messages` plus the digest accumulator.
3676        self.messages.push(message);
3677        self.mark_content_mutated(SystemTime::now());
3678    }
3679
3680    /// Add multiple messages in one operation (single timestamp update)
3681    ///
3682    /// More efficient than multiple `push` calls when adding many messages.
3683    pub fn push_batch(&mut self, messages: Vec<Message>) {
3684        if messages.is_empty() {
3685            return;
3686        }
3687        // SEAM 3 (append): the accumulator folds only the appended batch.
3688        // See `push`: ordinary appends never materialize or rewrite the
3689        // transcript-history compatibility projection.
3690        self.messages.extend_batch(messages);
3691        self.mark_content_mutated(SystemTime::now());
3692    }
3693
3694    /// Rewrite inline media payloads in-place as `BlobRef` pointers.
3695    ///
3696    /// Message count is invariant across this operation — `externalize`
3697    /// only swaps inline image/media bytes for opaque blob references.
3698    /// This is the cross-crate-legitimate rewrite operation that used
3699    /// to require public `messages_mut()`; post-C-H1 callers in
3700    /// `meerkat-session` go through this typed method.
3701    ///
3702    /// Does not touch `updated_at` — externalization is bookkeeping, not
3703    /// a semantic session mutation.
3704    pub async fn externalize_media(
3705        &mut self,
3706        blob_store: &dyn crate::BlobStore,
3707        start: usize,
3708    ) -> Result<(), crate::blob::BlobStoreError> {
3709        // SEAM 4 (in-place media scan): the scan reports the lowest mutated
3710        // index. `None` means the buffer is byte-identical, so the retained
3711        // midstate stays valid. Either way audited graph metadata is
3712        // independent and remains untouched.
3713        let buffer = self.messages.begin_in_place_scan();
3714        let lowest_mutated = match crate::image_content::externalize_messages_from_reporting_lowest(
3715            blob_store, buffer, start,
3716        )
3717        .await
3718        {
3719            Ok(lowest_mutated) => lowest_mutated,
3720            Err(error) => {
3721                // The scan may have externalized part of the buffer before
3722                // failing; fail safe by discarding the parked midstate.
3723                self.messages.finish_in_place_scan(Some(start));
3724                return Err(error);
3725            }
3726        };
3727        self.messages.finish_in_place_scan(lowest_mutated);
3728        Ok(())
3729    }
3730
3731    /// Hydrate user-message images in-place for a realtime provider replay,
3732    /// under an explicit cumulative decoded-byte budget.
3733    ///
3734    /// Realtime reconnect/open is an execution seam, not a historical display
3735    /// read: missing or malformed blobs fail closed, repeated references count
3736    /// independently, and image-bearing tool/system content that the realtime
3737    /// history projector does not consume remains blob-backed.
3738    pub async fn hydrate_realtime_user_images(
3739        &mut self,
3740        blob_store: &dyn crate::BlobStore,
3741        max_decoded_bytes: usize,
3742    ) -> Result<(), crate::image_content::RealtimeUserImageHydrationError> {
3743        self.hydrate_realtime_user_images_with_usage(blob_store, max_decoded_bytes)
3744            .await
3745            .map(|_| ())
3746    }
3747
3748    /// Hydrate realtime user-message images and return the full canonical
3749    /// decoded-byte usage for seed-independent future-image admission.
3750    pub async fn hydrate_realtime_user_images_with_usage(
3751        &mut self,
3752        blob_store: &dyn crate::BlobStore,
3753        max_decoded_bytes: usize,
3754    ) -> Result<usize, crate::image_content::RealtimeUserImageHydrationError> {
3755        // SEAM 5 (in-place media scan): same contract as `externalize_media`.
3756        let buffer = self.messages.begin_in_place_scan();
3757        let (decoded_total, lowest_mutated) =
3758            match crate::image_content::hydrate_user_images_for_realtime_projection_reporting_lowest(
3759                blob_store,
3760                buffer,
3761                max_decoded_bytes,
3762            )
3763            .await
3764            {
3765                Ok(outcome) => outcome,
3766                Err(error) => {
3767                    self.messages.finish_in_place_scan(Some(0));
3768                    return Err(error);
3769                }
3770            };
3771        self.messages.finish_in_place_scan(lowest_mutated);
3772        // This typed hydrator mutates User messages only. It cannot change a
3773        // SystemNotice semantic identity, so the terminal index remains exact.
3774        Ok(decoded_total)
3775    }
3776
3777    /// Advance the durable content timestamp.
3778    fn mark_content_mutated(&mut self, at: SystemTime) {
3779        self.updated_at = at;
3780    }
3781
3782    /// Explicitly update the timestamp
3783    ///
3784    /// Call this after bulk operations that don't update timestamps automatically.
3785    pub fn touch(&mut self) {
3786        self.mark_content_mutated(SystemTime::now());
3787    }
3788
3789    /// Get the last N messages
3790    pub fn last_n(&self, n: usize) -> &[Message] {
3791        let start = self.messages.len().saturating_sub(n);
3792        &self.messages[start..]
3793    }
3794
3795    /// Count total tokens used.
3796    pub fn total_tokens(&self) -> u64 {
3797        self.usage.total_tokens()
3798    }
3799
3800    /// Get total usage statistics for the session.
3801    pub fn total_usage(&self) -> Usage {
3802        self.usage.clone()
3803    }
3804
3805    /// Update cumulative usage after an LLM call.
3806    pub fn record_usage(&mut self, turn_usage: Usage) {
3807        self.usage.add(&turn_usage);
3808        self.mark_content_mutated(SystemTime::now());
3809    }
3810
3811    /// Append externally-produced user content to the canonical transcript.
3812    pub fn append_external_user_content(&mut self, content: ContentInput) {
3813        self.push(Message::User(UserMessage::with_blocks(
3814            content.into_blocks(),
3815        )));
3816    }
3817
3818    /// Append externally-produced assistant output to the canonical transcript.
3819    pub fn append_external_assistant_blocks(
3820        &mut self,
3821        blocks: Vec<AssistantBlock>,
3822        stop_reason: StopReason,
3823        usage: Usage,
3824    ) {
3825        if !blocks.is_empty() {
3826            self.push(Message::BlockAssistant(BlockAssistantMessage::new(
3827                blocks,
3828                stop_reason,
3829            )));
3830        }
3831        if usage != Usage::default() {
3832            self.record_usage(usage);
3833        }
3834    }
3835
3836    /// Apply an identity-bearing provider realtime transcript event.
3837    ///
3838    /// This is the canonical append authority for provider-managed realtime
3839    /// turns. Provider item ids, predecessor links, and content segment ids
3840    /// reduce into the in-memory projection while the exact typed event is
3841    /// appended to the authenticated HeadCanonical component sidecar.
3842    /// WholeBlob serialization alone materializes the accumulated projection.
3843    pub fn append_realtime_transcript_event(
3844        &mut self,
3845        event: RealtimeTranscriptEvent,
3846    ) -> RealtimeTranscriptApplyOutcome {
3847        let (commit, recorded) =
3848            self.realtime_transcript
3849                .apply_event(event)
3850                .unwrap_or_else(|err| {
3851                    fail_closed_generated_restore(
3852                        "realtime-transcript",
3853                        <serde_json::Error as serde::de::Error>::custom(err),
3854                    )
3855                });
3856        if recorded {
3857            self.mark_content_mutated(SystemTime::now());
3858        }
3859        self.push_batch(commit.messages);
3860        if commit.usage != Usage::default() {
3861            self.record_usage(commit.usage);
3862        }
3863        commit.outcome
3864    }
3865
3866    /// Preview replay/rejection for non-text realtime user content without
3867    /// mutating session state. Used by persistence before blob writes.
3868    #[must_use]
3869    pub fn preflight_realtime_user_content_event(
3870        &self,
3871        event: &RealtimeTranscriptEvent,
3872    ) -> Option<crate::RealtimeUserContentApplyOutcome> {
3873        realtime_transcript_revision::preflight_realtime_user_content_event(
3874            self.realtime_transcript.state(),
3875            event,
3876        )
3877        .unwrap_or_else(|err| {
3878            fail_closed_generated_restore(
3879                "realtime-user-content-preflight",
3880                <serde_json::Error as serde::de::Error>::custom(err),
3881            )
3882        })
3883    }
3884
3885    /// Return every distinct provider `response_id` currently staged in the
3886    /// realtime-transcript metadata that has at least one **unmaterialized**
3887    /// assistant item and is **not already discarded**.
3888    ///
3889    /// CC4 (Round-4 architectural reconciliation): when the live boundary
3890    /// signals a barge-in (`TurnInterrupted`), the projection sink does not
3891    /// know which provider response_ids have streaming deltas staged in
3892    /// session metadata. This accessor lets the sink fan
3893    /// [`RealtimeTranscriptEvent::AssistantTurnInterrupted`] events out to
3894    /// each in-flight response so staged-but-not-yet-materialized transcript
3895    /// fragments are discarded — preventing them from silently committing
3896    /// when the *next* turn's `AssistantTurnCompleted` (synthesized by the
3897    /// CC2 fix in `signal_turn_completed`) sweeps the materializer.
3898    ///
3899    /// Order is the [`SessionRealtimeTranscriptState::first_seen_order`]
3900    /// projection so callers see deterministic iteration. Items already
3901    /// materialized or skipped are excluded — only response_ids with at
3902    /// least one live unmaterialized assistant item are returned.
3903    #[must_use]
3904    pub fn in_flight_realtime_assistant_response_ids(&self) -> Vec<String> {
3905        realtime_transcript_revision::in_flight_realtime_assistant_response_ids(
3906            self.realtime_transcript.state(),
3907        )
3908    }
3909
3910    /// Durable session-scoped bindings used to make live non-text input retry
3911    /// safe across provider reconnects and lost public receipts.
3912    #[must_use]
3913    pub fn realtime_user_content_identities(&self) -> Vec<RealtimeUserContentIdentity> {
3914        realtime_transcript_revision::realtime_user_content_identities(
3915            self.realtime_transcript.state(),
3916        )
3917    }
3918
3919    /// Return the bounded metadata-only image-blob recovery anchor, if one is
3920    /// durably staged ahead of reducer finalization.
3921    #[must_use]
3922    pub fn pending_realtime_user_content_blob(
3923        &self,
3924    ) -> Option<crate::PendingRealtimeUserContentBlob> {
3925        realtime_transcript_revision::pending_realtime_user_content_blob(
3926            self.realtime_transcript.state(),
3927        )
3928    }
3929
3930    /// Stage or exactly reuse the one-slot durable image-blob recovery anchor
3931    /// through generated SessionDocument authority.
3932    pub fn stage_pending_realtime_user_content_blob(
3933        &mut self,
3934        pending: crate::PendingRealtimeUserContentBlob,
3935    ) -> Result<
3936        crate::generated::session_document::RealtimeUserContentBlobStageDisposition,
3937        realtime_transcript_revision::RealtimeTranscriptShellError,
3938    > {
3939        match self
3940            .realtime_transcript
3941            .stage_pending_user_content_blob(pending)
3942        {
3943            Ok(disposition) => {
3944                if disposition
3945                    == crate::generated::session_document::RealtimeUserContentBlobStageDisposition::StageNew
3946                {
3947                    self.mark_content_mutated(SystemTime::now());
3948                }
3949                Ok(disposition)
3950            }
3951            Err(RealtimeTranscriptSidecarError::Reducer(error)) => Err(error),
3952            Err(error) => fail_closed_generated_restore(
3953                "realtime-user-content-stage",
3954                <serde_json::Error as serde::de::Error>::custom(error),
3955            ),
3956        }
3957    }
3958
3959    pub fn resolve_pending_realtime_user_content_blob_recovery(
3960        &self,
3961        request: Option<&crate::PendingRealtimeUserContentBlob>,
3962        pending_blob_valid: bool,
3963    ) -> Result<
3964        crate::generated::session_document::RealtimeUserContentBlobRecoveryDisposition,
3965        realtime_transcript_revision::RealtimeTranscriptShellError,
3966    > {
3967        realtime_transcript_revision::resolve_pending_realtime_user_content_blob_recovery(
3968            self.realtime_transcript.state(),
3969            request,
3970            pending_blob_valid,
3971        )
3972    }
3973
3974    /// Clear a missing/corrupt occupied anchor only after generated recovery
3975    /// authority classifies a different request as `ClearInvalidBeforeCurrent`.
3976    pub fn clear_invalid_pending_realtime_user_content_blob(
3977        &mut self,
3978        request: Option<&crate::PendingRealtimeUserContentBlob>,
3979    ) -> Result<(), realtime_transcript_revision::RealtimeTranscriptShellError> {
3980        match self
3981            .realtime_transcript
3982            .clear_invalid_pending_user_content_blob(request)
3983        {
3984            Ok(()) => {
3985                self.mark_content_mutated(SystemTime::now());
3986                Ok(())
3987            }
3988            Err(RealtimeTranscriptSidecarError::Reducer(error)) => Err(error),
3989            Err(error) => fail_closed_generated_restore(
3990                "realtime-user-content-clear",
3991                <serde_json::Error as serde::de::Error>::custom(error),
3992            ),
3993        }
3994    }
3995
3996    /// Durable caller keys whose canonical realtime image was removed by a
3997    /// same-session transcript rewrite. Provider adapters consume these as a
3998    /// pre-send conflict registry on open and refresh.
3999    #[must_use]
4000    pub fn realtime_user_content_tombstones(
4001        &self,
4002    ) -> Vec<crate::realtime_transcript::RealtimeUserContentTombstone> {
4003        realtime_transcript_revision::realtime_user_content_tombstones(
4004            self.realtime_transcript.state(),
4005        )
4006    }
4007
4008    fn prepare_realtime_transcript_rebase_after_rewrite(
4009        &self,
4010        messages: &[Message],
4011        reason: RealtimeTranscriptSnapshotReasonV1,
4012    ) -> Result<PreparedRealtimeTranscriptRebase, TranscriptEditError> {
4013        let state =
4014            realtime_transcript_revision::reconcile_realtime_transcript_state_after_rewrite(
4015                self.realtime_transcript.state().clone(),
4016                messages,
4017            )
4018            .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
4019        self.realtime_transcript
4020            .prepare_rebase_snapshot(state, reason)
4021            .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))
4022    }
4023
4024    /// Append an ordinary System message at the current transcript boundary.
4025    pub fn append_system_message(&mut self, content: impl Into<String>) {
4026        use crate::types::SystemMessage;
4027
4028        self.push(Message::System(SystemMessage::new(content)));
4029    }
4030
4031    /// Append an ordinary System message with optional control-ingress
4032    /// identity.
4033    ///
4034    /// The ordered transcript is the singular durable owner. Idempotency is
4035    /// checked only for this explicit control operation; ordinary turn and
4036    /// resume paths never scan the transcript.
4037    pub fn append_system_message_idempotent(
4038        &mut self,
4039        content: impl Into<String>,
4040        source: Option<String>,
4041        idempotency_key: Option<String>,
4042        created_at: crate::types::MessageTimestamp,
4043    ) -> Result<crate::service::AppendSystemContextStatus, SystemMessageAppendError> {
4044        use crate::types::{SystemMessage, SystemMessageIdentity};
4045
4046        let content = content.into();
4047        if let Some(key) = idempotency_key.as_deref() {
4048            for message in self.messages() {
4049                let Message::System(existing) = message else {
4050                    continue;
4051                };
4052                let Some(identity) = existing.identity.as_ref() else {
4053                    continue;
4054                };
4055                if identity.idempotency_key.as_deref() != Some(key) {
4056                    continue;
4057                }
4058                if existing.content == content && identity.source == source {
4059                    return Ok(crate::service::AppendSystemContextStatus::Duplicate);
4060                }
4061                return Err(SystemMessageAppendError::Conflict {
4062                    key: key.to_string(),
4063                    existing_text: existing.content.clone(),
4064                    existing_source: identity.source.clone(),
4065                });
4066            }
4067        }
4068
4069        let identity =
4070            (source.is_some() || idempotency_key.is_some()).then_some(SystemMessageIdentity {
4071                source,
4072                idempotency_key,
4073            });
4074        self.push(Message::System(SystemMessage {
4075            content,
4076            created_at,
4077            identity,
4078        }));
4079        Ok(crate::service::AppendSystemContextStatus::Applied)
4080    }
4081
4082    /// Clone the active ordered transcript for a model request.
4083    ///
4084    /// System messages are ordinary durable rows. No request-local System
4085    /// message is synthesized or repositioned at this boundary.
4086    pub fn messages_for_model_boundary(&self) -> Vec<Message> {
4087        self.messages().to_vec()
4088    }
4089
4090    /// Get the last assistant message text content.
4091    ///
4092    /// Concatenates both `Text` (display) and `Transcript` (spoken) blocks
4093    /// in document order, since both lanes project to the same human-readable
4094    /// stream. Lane provenance is preserved on the underlying `AssistantBlock`
4095    /// for callers that need it.
4096    pub fn last_assistant_text(&self) -> Option<String> {
4097        self.messages.iter().rev().find_map(|m| match m {
4098            Message::BlockAssistant(a) => {
4099                let mut buf = String::new();
4100                for block in &a.blocks {
4101                    match block {
4102                        crate::types::AssistantBlock::Text { text, .. }
4103                        | crate::types::AssistantBlock::Transcript { text, .. } => {
4104                            buf.push_str(text);
4105                        }
4106                        _ => {}
4107                    }
4108                }
4109                if buf.is_empty() { None } else { Some(buf) }
4110            }
4111            _ => None,
4112        })
4113    }
4114
4115    /// Count tool calls made
4116    pub fn tool_call_count(&self) -> usize {
4117        self.messages
4118            .iter()
4119            .filter_map(|m| match m {
4120                Message::BlockAssistant(a) => Some(
4121                    a.blocks
4122                        .iter()
4123                        .filter(|b| matches!(b, crate::types::AssistantBlock::ToolUse { .. }))
4124                        .count(),
4125                ),
4126                _ => None,
4127            })
4128            .sum()
4129    }
4130
4131    /// Get non-component session metadata.
4132    ///
4133    /// Typed realtime/system-context reducer projections are intentionally not
4134    /// exposed through this raw map. WholeBlob serialization materializes its
4135    /// compatibility projection separately; HeadCanonical binds typed
4136    /// component roots.
4137    pub fn metadata(&self) -> &serde_json::Map<String, serde_json::Value> {
4138        &self.metadata
4139    }
4140
4141    /// Borrow the accumulated projection for explicit WholeBlob compatibility
4142    /// encoding. HeadCanonical code must use the compact component prefix.
4143    pub(crate) fn whole_blob_realtime_transcript_state(
4144        &self,
4145    ) -> Option<&SessionRealtimeTranscriptState> {
4146        self.realtime_transcript.whole_blob_projection()
4147    }
4148
4149    /// Inject the accumulated realtime projection into a WholeBlob metadata
4150    /// map. HeadCanonical digest/head builders must not call this: they bind
4151    /// the compact event prefix directly and never materialize this value.
4152    pub(crate) fn inject_realtime_whole_blob_projection(
4153        &self,
4154        metadata: &mut serde_json::Map<String, serde_json::Value>,
4155    ) -> Result<(), serde_json::Error> {
4156        if let Some(projection) = self.whole_blob_realtime_transcript_state() {
4157            metadata.insert(
4158                SESSION_REALTIME_TRANSCRIPT_STATE_KEY.to_string(),
4159                serde_json::to_value(projection)?,
4160            );
4161        }
4162        Ok(())
4163    }
4164
4165    /// Current authenticated realtime component-event prefix, including every
4166    /// event staged since the last durable acknowledgement.
4167    pub(crate) fn realtime_component_event_prefix(
4168        &self,
4169    ) -> Result<crate::ComponentEventPrefixAuthority, RealtimeTranscriptSidecarError> {
4170        self.realtime_transcript.successor_prefix()
4171    }
4172
4173    /// Durable predecessor from which the pending realtime suffix extends.
4174    pub(crate) fn realtime_component_event_acknowledged_prefix(
4175        &self,
4176    ) -> &crate::ComponentEventPrefixAuthority {
4177        self.realtime_transcript.acknowledged_prefix()
4178    }
4179
4180    /// Convert the inline WholeBlob realtime projection into one parked
4181    /// HeadCanonical snapshot event.
4182    ///
4183    /// This is an explicit store-activation seam, not an ordinary mutation
4184    /// fallback. Callers must persist the resulting component suffix and
4185    /// rebound schema-v4 head in one transaction.
4186    #[doc(hidden)]
4187    pub fn activate_realtime_component_sidecar(
4188        &mut self,
4189    ) -> Result<(), RealtimeTranscriptSidecarError> {
4190        let Some(value) = self.metadata.get(SESSION_REALTIME_TRANSCRIPT_STATE_KEY) else {
4191            // WholeBlob deserialization already parks the activation snapshot,
4192            // while new sessions legitimately begin with an empty prefix.
4193            return Ok(());
4194        };
4195        if !self.realtime_transcript.is_pristine() {
4196            return Err(RealtimeTranscriptSidecarError::Incoherent(
4197                "inline realtime projection cannot replace an active component sidecar".to_string(),
4198            ));
4199        }
4200        let state = serde_json::from_value(value.clone())?;
4201        let projection =
4202            SessionRealtimeTranscriptProjection::from_inline_snapshot(&self.id, state)?;
4203        self.metadata.remove(SESSION_REALTIME_TRANSCRIPT_STATE_KEY);
4204        *self.realtime_transcript = projection;
4205        Ok(())
4206    }
4207
4208    /// Seal the exact realtime event suffix pending at this boundary.
4209    /// Seal the parked realtime activation/ordinary suffix for an atomic
4210    /// HeadCanonical store transaction.
4211    #[doc(hidden)]
4212    pub fn prepare_realtime_component_event_suffix(
4213        &self,
4214    ) -> Result<Option<crate::PreparedComponentEventSuffix>, RealtimeTranscriptSidecarError> {
4215        self.realtime_transcript.prepare_suffix()
4216    }
4217
4218    /// Install a store-verified complete realtime sidecar projection.
4219    pub(crate) fn install_verified_realtime_component_sequence(
4220        &mut self,
4221        sequence: &crate::VerifiedComponentEventSequence,
4222    ) -> Result<(), RealtimeTranscriptSidecarError> {
4223        *self.realtime_transcript =
4224            SessionRealtimeTranscriptProjection::from_verified_sequence(&self.id, sequence)?;
4225        Ok(())
4226    }
4227
4228    /// Adopt the exact prepared realtime prefix after the writing transaction
4229    /// acknowledges that same successor.
4230    pub(crate) fn acknowledge_realtime_component_event_suffix(
4231        &mut self,
4232        prepared: &crate::PreparedComponentEventSuffix,
4233        committed: &crate::ComponentEventPrefixAuthority,
4234    ) -> Result<(), RealtimeTranscriptSidecarError> {
4235        self.realtime_transcript
4236            .acknowledge_suffix(prepared, committed)
4237    }
4238
4239    pub(crate) fn head_canonical_metadata_projection(
4240        &self,
4241    ) -> Result<Arc<SessionHeadMetadataProjection>, serde_json::Error> {
4242        self.history_caches
4243            .head_canonical_metadata
4244            .projection(&self.metadata)
4245            .map_err(<serde_json::Error as serde::ser::Error>::custom)
4246    }
4247
4248    pub(crate) fn install_head_canonical_metadata_projection(
4249        &mut self,
4250        projection: &Arc<SessionHeadMetadataProjection>,
4251    ) -> Result<(), String> {
4252        self.history_caches
4253            .head_canonical_metadata
4254            .install_snapshot(projection)
4255    }
4256
4257    pub(crate) fn acknowledge_head_canonical_metadata_projection(
4258        &mut self,
4259        projection: &Arc<SessionHeadMetadataProjection>,
4260    ) -> Result<(), String> {
4261        self.history_caches
4262            .head_canonical_metadata
4263            .acknowledge(projection, &self.metadata)
4264    }
4265
4266    pub(crate) fn validate_head_canonical_metadata_acknowledgement(
4267        &self,
4268        projection: &Arc<SessionHeadMetadataProjection>,
4269    ) -> Result<(), String> {
4270        self.history_caches
4271            .head_canonical_metadata
4272            .validate_acknowledgement(projection, &self.metadata)
4273    }
4274
4275    fn mark_head_canonical_metadata_key_mutated(&mut self, key: &str) {
4276        self.history_caches
4277            .head_canonical_metadata
4278            .mark_key_mutated(key);
4279    }
4280
4281    fn adopt_head_canonical_metadata_baseline_from(&mut self, source: &Session) {
4282        self.history_caches.head_canonical_metadata =
4283            source.history_caches.head_canonical_metadata.clone();
4284    }
4285
4286    fn set_metadata_unchecked(&mut self, key: &str, value: serde_json::Value) {
4287        // Reapplying an identical durable projection is not a session-content
4288        // mutation. In particular, cold materialization restores
4289        // SessionMetadata and SessionBuildState before it knows whether the
4290        // values changed; advancing `updated_at` for an exact no-op would
4291        // manufacture a content change even though the committed document is
4292        // unchanged.
4293        if self.metadata.get(key) == Some(&value) {
4294            return;
4295        }
4296        self.mark_head_canonical_metadata_key_mutated(key);
4297        self.metadata.insert(key.to_string(), value);
4298        if key == SESSION_TRANSCRIPT_HISTORY_STATE_KEY {
4299            self.metadata
4300                .remove(SESSION_TRANSCRIPT_REWRITE_PREFIX_AUTHORITY_KEY);
4301            self.history_caches.shared_state.clear();
4302            self.transcript_history_metadata_validation =
4303                TranscriptHistoryMetadataValidation::RequiresValidation;
4304        }
4305        self.mark_content_mutated(SystemTime::now());
4306    }
4307
4308    /// Install a graph a typed path already validated as the singular
4309    /// in-memory authority.
4310    ///
4311    /// The serialized graph and rewrite-prefix projection are absent from
4312    /// ordinary metadata. WholeBlob encoding synthesizes them at the explicit
4313    /// wire boundary; HeadCanonical consumes this shared typed state directly.
4314    fn install_validated_transcript_history_state(
4315        &mut self,
4316        state: TranscriptHistoryState,
4317    ) -> Result<(), serde_json::Error> {
4318        let state = std::sync::Arc::new(state);
4319        let unchanged = self
4320            .history_caches
4321            .shared_state
4322            .get()
4323            .is_some_and(|current| {
4324                current.graph_prefix() == state.graph_prefix()
4325                    && current.rewrite_prefix() == state.rewrite_prefix()
4326                    && current.head() == state.head()
4327            });
4328        self.metadata.remove(SESSION_TRANSCRIPT_HISTORY_STATE_KEY);
4329        self.metadata
4330            .remove(SESSION_TRANSCRIPT_REWRITE_PREFIX_AUTHORITY_KEY);
4331        if unchanged {
4332            self.history_caches.shared_state.set(state);
4333            self.transcript_history_metadata_validation =
4334                TranscriptHistoryMetadataValidation::Validated;
4335            return Ok(());
4336        }
4337        self.history_caches.shared_state.set(state);
4338        self.transcript_history_metadata_validation =
4339            TranscriptHistoryMetadataValidation::Validated;
4340        Ok(())
4341    }
4342
4343    /// Small rewrite-prefix fact for receipt comparison.
4344    #[must_use]
4345    pub fn transcript_rewrite_prefix_authority(
4346        &self,
4347    ) -> Option<TranscriptRewritePrefixAccumulator> {
4348        if let Some(state) = self.history_caches.shared_state.get() {
4349            return Some(state.rewrite_prefix().clone());
4350        }
4351        serde_json::from_value(
4352            self.metadata
4353                .get(SESSION_TRANSCRIPT_REWRITE_PREFIX_AUTHORITY_KEY)?
4354                .clone(),
4355        )
4356        .ok()
4357    }
4358
4359    #[cfg(test)]
4360    pub(crate) fn set_metadata_unchecked_for_test(&mut self, key: &str, value: serde_json::Value) {
4361        self.set_metadata_unchecked(key, value);
4362    }
4363
4364    fn fork_metadata_projection(&self) -> serde_json::Map<String, serde_json::Value> {
4365        let mut metadata = self.metadata.clone();
4366        metadata.retain(|key, _| !is_session_authority_metadata_key(key));
4367        metadata
4368    }
4369
4370    fn remove_metadata_unchecked(&mut self, key: &str) {
4371        let removed = self.metadata.remove(key).is_some();
4372        let mut changed = removed;
4373        if key == SESSION_TRANSCRIPT_HISTORY_STATE_KEY {
4374            changed |= self.history_caches.shared_state.get().is_some();
4375            changed |= self
4376                .metadata
4377                .remove(SESSION_TRANSCRIPT_REWRITE_PREFIX_AUTHORITY_KEY)
4378                .is_some();
4379            self.history_caches.shared_state.clear();
4380            self.transcript_history_metadata_validation =
4381                TranscriptHistoryMetadataValidation::Validated;
4382        }
4383        if changed {
4384            self.mark_head_canonical_metadata_key_mutated(key);
4385            self.mark_content_mutated(SystemTime::now());
4386        }
4387    }
4388
4389    /// Set a metadata value when the key is not reserved for generated authority.
4390    pub fn try_set_metadata(
4391        &mut self,
4392        key: &str,
4393        value: serde_json::Value,
4394    ) -> Result<(), ReservedSessionMetadataKey> {
4395        if is_session_authority_metadata_key(key) {
4396            return Err(ReservedSessionMetadataKey::new(key));
4397        }
4398        self.set_metadata_unchecked(key, value);
4399        Ok(())
4400    }
4401
4402    /// Set a metadata value.
4403    ///
4404    /// Reserved generated-authority metadata keys fail closed and are left
4405    /// untouched. Use the typed setters for those keys.
4406    pub fn set_metadata(&mut self, key: &str, value: serde_json::Value) {
4407        if let Err(err) = self.try_set_metadata(key, value) {
4408            tracing::warn!(error = %err, "rejected raw session metadata mutation");
4409        }
4410    }
4411
4412    /// Backfill a missing metadata value without changing `updated_at`.
4413    ///
4414    /// This is only for compatibility reads that need to hydrate metadata from
4415    /// an older projection. Semantic metadata mutations must use
4416    /// [`Session::set_metadata`] so the session timestamp advances.
4417    pub fn backfill_metadata_if_absent(&mut self, key: &str, value: serde_json::Value) -> bool {
4418        if is_session_authority_metadata_key(key) {
4419            tracing::warn!(
4420                metadata_key = key,
4421                "rejected raw session metadata backfill for authority key"
4422            );
4423            return false;
4424        }
4425        if self.metadata.contains_key(key) {
4426            false
4427        } else {
4428            self.metadata.insert(key.to_string(), value);
4429            self.mark_head_canonical_metadata_key_mutated(key);
4430            true
4431        }
4432    }
4433
4434    /// Remove a metadata value.
4435    pub fn remove_metadata(&mut self, key: &str) {
4436        if is_session_authority_metadata_key(key) {
4437            tracing::warn!(
4438                metadata_key = key,
4439                "rejected raw session metadata removal for authority key"
4440            );
4441            return;
4442        }
4443        if self.metadata.remove(key).is_some() {
4444            self.mark_head_canonical_metadata_key_mutated(key);
4445            self.mark_content_mutated(SystemTime::now());
4446        }
4447    }
4448
4449    /// Store SessionMetadata in the session metadata map.
4450    pub fn set_session_metadata(
4451        &mut self,
4452        metadata: SessionMetadata,
4453    ) -> Result<(), serde_json::Error> {
4454        let metadata =
4455            session_durable_config_authority::authorize_session_metadata_persist(metadata)
4456                .map_err(<serde_json::Error as serde::ser::Error>::custom)?
4457                .into_metadata();
4458        let value = serde_json::to_value(metadata)?;
4459        self.set_metadata_unchecked(SESSION_METADATA_KEY, value);
4460        Ok(())
4461    }
4462
4463    /// Load SessionMetadata from the session metadata map.
4464    ///
4465    /// If the reserved key exists but cannot pass typed generated restore,
4466    /// fail closed instead of treating corrupted machine facts as absent.
4467    pub fn session_metadata(&self) -> Option<SessionMetadata> {
4468        match self.try_session_metadata() {
4469            Ok(metadata) => metadata,
4470            Err(err) => fail_closed_generated_restore("session-metadata", err),
4471        }
4472    }
4473
4474    /// Try to load SessionMetadata through generated restore authority.
4475    pub fn try_session_metadata(&self) -> Result<Option<SessionMetadata>, serde_json::Error> {
4476        try_session_metadata_from_map(&self.metadata)
4477    }
4478
4479    /// Store durable deferred-turn control state in the session metadata map.
4480    pub fn set_deferred_turn_state(
4481        &mut self,
4482        state: SessionDeferredTurnState,
4483    ) -> Result<(), serde_json::Error> {
4484        let state = validate_deferred_turn_snapshot(state)
4485            .map_err(<serde_json::Error as serde::ser::Error>::custom)?;
4486        let value = serde_json::to_value(state)?;
4487        self.set_metadata_unchecked(SESSION_DEFERRED_TURN_STATE_KEY, value);
4488        Ok(())
4489    }
4490
4491    /// Try to load durable deferred-turn control state through generated restore authority.
4492    pub fn try_deferred_turn_state(
4493        &self,
4494    ) -> Result<Option<SessionDeferredTurnState>, serde_json::Error> {
4495        self.metadata
4496            .get(SESSION_DEFERRED_TURN_STATE_KEY)
4497            .map(|value| {
4498                let state = serde_json::from_value(value.clone())?;
4499                validate_deferred_turn_snapshot(state)
4500                    .map_err(<serde_json::Error as serde::de::Error>::custom)
4501            })
4502            .transpose()
4503    }
4504
4505    /// Load durable deferred-turn control state from the session metadata map.
4506    ///
4507    /// Rejected durable facts fail closed through the generated restore
4508    /// authority. Callers that need the typed rejection must use
4509    /// [`Self::try_deferred_turn_state`].
4510    pub fn deferred_turn_state(&self) -> Option<SessionDeferredTurnState> {
4511        match self.try_deferred_turn_state() {
4512            Ok(state) => state,
4513            Err(err) => fail_closed_generated_restore("deferred-turn", err),
4514        }
4515    }
4516
4517    /// Stage an external-callback batch without publishing any
4518    /// provider-visible tool results or sibling transcript effects.
4519    pub(crate) fn stage_pending_callback_tool_batch(
4520        &mut self,
4521        batch: PendingCallbackToolBatch,
4522    ) -> Result<(), PendingCallbackBatchError> {
4523        if matches!(
4524            self.callback_tool_batch_state()?,
4525            Some(CallbackToolBatchState::Pending { .. })
4526        ) {
4527            return Err(PendingCallbackBatchError::AlreadyStaged);
4528        }
4529        validate_pending_callback_batch(self.messages(), &batch)?;
4530        let value = serde_json::to_value(CallbackToolBatchState::Pending { batch })
4531            .map_err(|error| PendingCallbackBatchError::Malformed(error.to_string()))?;
4532        self.set_metadata_unchecked(SESSION_PENDING_CALLBACK_BATCH_KEY, value);
4533        Ok(())
4534    }
4535
4536    fn callback_tool_batch_state(
4537        &self,
4538    ) -> Result<Option<CallbackToolBatchState>, PendingCallbackBatchError> {
4539        self.metadata
4540            .get(SESSION_PENDING_CALLBACK_BATCH_KEY)
4541            .map(|value| {
4542                serde_json::from_value(value.clone())
4543                    .map_err(|error| PendingCallbackBatchError::Malformed(error.to_string()))
4544            })
4545            .transpose()
4546    }
4547
4548    /// Restore the typed callback batch. A corrupt durable record is a typed
4549    /// refusal, never "no pending callback".
4550    pub(crate) fn pending_callback_tool_batch(
4551        &self,
4552    ) -> Result<Option<PendingCallbackToolBatch>, PendingCallbackBatchError> {
4553        match self.callback_tool_batch_state()? {
4554            Some(CallbackToolBatchState::Pending { batch }) => {
4555                validate_pending_callback_batch(self.messages(), &batch)?;
4556                Ok(Some(batch))
4557            }
4558            Some(CallbackToolBatchState::Applied { .. }) | None => Ok(None),
4559        }
4560    }
4561
4562    /// Validate external callback results and combine them with staged sibling
4563    /// results in the original assistant tool-use order, without mutation.
4564    pub(crate) fn resolve_pending_callback_tool_results(
4565        &self,
4566        incoming: Vec<ToolResult>,
4567    ) -> Result<ResolvedPendingCallbackToolResults, PendingCallbackBatchError> {
4568        let Some(state) = self.callback_tool_batch_state()? else {
4569            return Ok(ResolvedPendingCallbackToolResults::NoState);
4570        };
4571        let batch = match state {
4572            CallbackToolBatchState::Pending { batch } => batch,
4573            CallbackToolBatchState::Applied {
4574                tool_use_order,
4575                results,
4576                async_ops,
4577                ..
4578            } => {
4579                let incoming_by_id = unique_tool_results(incoming)?;
4580                let expected = tool_use_order.iter().cloned().collect::<BTreeSet<_>>();
4581                let actual = incoming_by_id.keys().cloned().collect::<BTreeSet<_>>();
4582                if actual != expected {
4583                    return Err(PendingCallbackBatchError::ResultSetMismatch { expected, actual });
4584                }
4585                let delivered = tool_use_order
4586                    .iter()
4587                    .map(|id| incoming_by_id.get(id).cloned())
4588                    .collect::<Option<Vec<_>>>()
4589                    .ok_or_else(|| {
4590                        PendingCallbackBatchError::Malformed(
4591                            "applied callback receipt is missing an ordered result".to_string(),
4592                        )
4593                    })?;
4594                return if delivered == results {
4595                    Ok(ResolvedPendingCallbackToolResults::AlreadyApplied { async_ops })
4596                } else {
4597                    Err(PendingCallbackBatchError::ConflictingRedelivery)
4598                };
4599            }
4600        };
4601        validate_pending_callback_batch(self.messages(), &batch)?;
4602        let incoming_by_id = unique_tool_results(incoming)?;
4603        let expected = batch
4604            .pending_tool_use_ids
4605            .iter()
4606            .cloned()
4607            .collect::<BTreeSet<_>>();
4608        let actual = incoming_by_id.keys().cloned().collect::<BTreeSet<_>>();
4609        if actual != expected {
4610            return Err(PendingCallbackBatchError::ResultSetMismatch { expected, actual });
4611        }
4612        let mut all_by_id = unique_tool_results(batch.completed_results.clone())?;
4613        all_by_id.extend(incoming_by_id);
4614        let ordered = batch
4615            .tool_use_order
4616            .iter()
4617            .map(|id| {
4618                all_by_id.remove(id).ok_or_else(|| {
4619                    PendingCallbackBatchError::Malformed(format!(
4620                        "no result is available for assistant tool id '{id}'"
4621                    ))
4622                })
4623            })
4624            .collect::<Result<Vec<_>, _>>()?;
4625        if !all_by_id.is_empty() {
4626            return Err(PendingCallbackBatchError::Malformed(format!(
4627                "results contain ids absent from assistant tool-use order: {:?}",
4628                all_by_id.keys().collect::<Vec<_>>()
4629            )));
4630        }
4631        Ok(ResolvedPendingCallbackToolResults::Pending {
4632            batch,
4633            ordered_results: ordered,
4634        })
4635    }
4636
4637    /// Publish the already-resolved full `ToolResults` set and any
4638    /// transcript-producing sibling effects as one adjacent message batch,
4639    /// then replace the durable staging record with an idempotency receipt.
4640    pub(crate) fn commit_pending_callback_tool_results(
4641        &mut self,
4642        batch: &PendingCallbackToolBatch,
4643        ordered_results: Vec<ToolResult>,
4644        post_tool_messages: Vec<Message>,
4645    ) -> Result<(), PendingCallbackBatchError> {
4646        let current = self
4647            .pending_callback_tool_batch()?
4648            .ok_or(PendingCallbackBatchError::Missing)?;
4649        if &current != batch {
4650            return Err(PendingCallbackBatchError::Malformed(
4651                "pending callback batch changed between prepare and commit".to_string(),
4652            ));
4653        }
4654        let actual_order = ordered_results
4655            .iter()
4656            .map(|result| result.tool_use_id.clone())
4657            .collect::<Vec<_>>();
4658        if actual_order != batch.tool_use_order {
4659            return Err(PendingCallbackBatchError::Malformed(format!(
4660                "resolved result order {actual_order:?} does not match assistant order {:?}",
4661                batch.tool_use_order
4662            )));
4663        }
4664        self.push(Message::tool_results(ordered_results.clone()));
4665        let pending_ids = batch
4666            .pending_tool_use_ids
4667            .iter()
4668            .cloned()
4669            .collect::<BTreeSet<_>>();
4670        let applied_callback_results = ordered_results
4671            .into_iter()
4672            .filter(|result| pending_ids.contains(&result.tool_use_id))
4673            .collect();
4674        let value = serde_json::to_value(CallbackToolBatchState::Applied {
4675            tool_use_order: batch.pending_tool_use_ids.clone(),
4676            results: applied_callback_results,
4677            async_ops: batch.async_ops.clone(),
4678            post_tool_messages,
4679            post_tool_messages_applied: false,
4680        })
4681        .map_err(|error| PendingCallbackBatchError::Malformed(error.to_string()))?;
4682        self.set_metadata_unchecked(SESSION_PENDING_CALLBACK_BATCH_KEY, value);
4683        Ok(())
4684    }
4685
4686    /// Apply callback-staged post-tool transcript effects only after the
4687    /// ToolResults tail has been admitted as a pending continuation. This
4688    /// preserves provider adjacency and prevents the effects from hiding the
4689    /// continuation boundary from session admission.
4690    pub(crate) fn apply_pending_callback_resume_effects(
4691        &mut self,
4692    ) -> Result<Vec<crate::event::AssistantImageEvent>, PendingCallbackBatchError> {
4693        let Some(CallbackToolBatchState::Applied {
4694            tool_use_order,
4695            results,
4696            async_ops,
4697            post_tool_messages,
4698            post_tool_messages_applied,
4699        }) = self.callback_tool_batch_state()?
4700        else {
4701            return Ok(Vec::new());
4702        };
4703        if post_tool_messages_applied {
4704            return Ok(Vec::new());
4705        }
4706        let image_events = post_tool_messages
4707            .iter()
4708            .filter_map(|message| match message {
4709                Message::BlockAssistant(assistant) => Some(assistant.blocks.as_slice()),
4710                _ => None,
4711            })
4712            .flatten()
4713            .filter_map(crate::event::AssistantImageEvent::from_assistant_block)
4714            .collect::<Vec<_>>();
4715        let applied_state = CallbackToolBatchState::Applied {
4716            tool_use_order,
4717            results,
4718            async_ops,
4719            post_tool_messages: post_tool_messages.clone(),
4720            post_tool_messages_applied: true,
4721        };
4722        let value = serde_json::to_value(applied_state)
4723            .map_err(|error| PendingCallbackBatchError::Malformed(error.to_string()))?;
4724        self.push_batch(post_tool_messages);
4725        self.set_metadata_unchecked(SESSION_PENDING_CALLBACK_BATCH_KEY, value);
4726        Ok(image_events)
4727    }
4728
4729    /// Realize the typed session lifecycle-terminal projection in the session
4730    /// metadata map.
4731    ///
4732    /// The lifecycle-terminal fact is owned by the canonical
4733    /// [`session_document::SessionDocumentMachine`]; production archive paths
4734    /// call this only to realize a machine-emitted `SessionArchiveResolved`
4735    /// verdict (the value written mirrors the machine's decision — the shell
4736    /// decides nothing here).
4737    pub fn set_lifecycle_terminal(
4738        &mut self,
4739        terminal: SessionLifecycleTerminal,
4740    ) -> Result<(), serde_json::Error> {
4741        let value = serde_json::to_value(terminal)?;
4742        self.set_metadata_unchecked(SESSION_LIFECYCLE_TERMINAL_KEY, value);
4743        Ok(())
4744    }
4745
4746    /// Try to load the typed session lifecycle-terminal fact.
4747    ///
4748    /// Reads the typed [`SESSION_LIFECYCLE_TERMINAL_KEY`]; an absent key means
4749    /// no terminal fact.
4750    pub fn try_lifecycle_terminal(
4751        &self,
4752    ) -> Result<Option<SessionLifecycleTerminal>, serde_json::Error> {
4753        try_lifecycle_terminal_from_map(&self.metadata)
4754    }
4755
4756    /// Load the typed session lifecycle-terminal fact, failing closed on a
4757    /// corrupt typed value.
4758    ///
4759    /// Callers that need the typed rejection must use
4760    /// [`Self::try_lifecycle_terminal`].
4761    pub fn lifecycle_terminal(&self) -> Option<SessionLifecycleTerminal> {
4762        match self.try_lifecycle_terminal() {
4763            Ok(state) => state,
4764            Err(err) => fail_closed_generated_restore("session-lifecycle-terminal", err),
4765        }
4766    }
4767
4768    /// Store recoverable build-only session state in the session metadata map.
4769    pub fn set_build_state(&mut self, state: SessionBuildState) -> Result<(), serde_json::Error> {
4770        let state = session_durable_config_authority::authorize_session_build_state_persist(state)
4771            .map_err(<serde_json::Error as serde::ser::Error>::custom)?
4772            .into_state();
4773        let value = serde_json::to_value(state)?;
4774        self.set_metadata_unchecked(SESSION_BUILD_STATE_KEY, value);
4775        Ok(())
4776    }
4777
4778    /// Load recoverable build-only session state from the session metadata map.
4779    ///
4780    /// If the reserved key exists but cannot pass typed generated restore,
4781    /// fail closed instead of treating corrupted machine facts as absent.
4782    pub fn build_state(&self) -> Option<SessionBuildState> {
4783        match self.try_build_state() {
4784            Ok(state) => state,
4785            Err(err) => fail_closed_generated_restore("session-build-state", err),
4786        }
4787    }
4788
4789    /// Try to load recoverable build-only session state through generated restore authority.
4790    pub fn try_build_state(&self) -> Result<Option<SessionBuildState>, serde_json::Error> {
4791        let Some(value) = self.metadata.get(SESSION_BUILD_STATE_KEY) else {
4792            return Ok(None);
4793        };
4794        let state = serde_json::from_value::<SessionBuildState>(value.clone())?;
4795        session_durable_config_authority::restore_session_build_state(state)
4796            .map(Some)
4797            .map_err(<serde_json::Error as serde::de::Error>::custom)
4798    }
4799
4800    /// Store durable tool-visibility control state in the session metadata map.
4801    pub fn set_tool_visibility_state(
4802        &mut self,
4803        state: AuthorizedSessionToolVisibilityState,
4804    ) -> Result<(), serde_json::Error> {
4805        let value = serde_json::to_value(state.into_state())?;
4806        self.set_metadata_unchecked(SESSION_TOOL_VISIBILITY_STATE_KEY, value);
4807        Ok(())
4808    }
4809
4810    /// Test-only metadata clear for compatibility assertions.
4811    ///
4812    /// Production paths persist an explicit generated-authority projection
4813    /// rather than making durable absence carry semantic default truth.
4814    #[cfg(test)]
4815    pub(crate) fn clear_tool_visibility_state(&mut self) {
4816        self.remove_metadata_unchecked(SESSION_TOOL_VISIBILITY_STATE_KEY);
4817    }
4818
4819    /// Load durable tool-visibility control state from the session metadata map.
4820    pub fn tool_visibility_state(
4821        &self,
4822    ) -> Result<Option<SessionToolVisibilityState>, serde_json::Error> {
4823        self.try_tool_visibility_state()
4824    }
4825
4826    /// Load durable tool-visibility control state while distinguishing absent
4827    /// metadata from malformed canonical metadata.
4828    pub fn try_tool_visibility_state(
4829        &self,
4830    ) -> Result<Option<SessionToolVisibilityState>, serde_json::Error> {
4831        self.metadata
4832            .get(SESSION_TOOL_VISIBILITY_STATE_KEY)
4833            .map(|value| serde_json::from_value(value.clone()))
4834            .transpose()
4835    }
4836
4837    /// Load typed transcript revision state from metadata.
4838    pub fn transcript_history_state(
4839        &self,
4840    ) -> Result<Option<TranscriptHistoryState>, serde_json::Error> {
4841        if let Some(state) = self.history_caches.shared_state.get() {
4842            return Ok(Some(state.as_ref().clone()));
4843        }
4844        self.metadata
4845            .get(SESSION_TRANSCRIPT_HISTORY_STATE_KEY)
4846            .map(|value| serde_json::from_value(value.clone()))
4847            .transpose()
4848    }
4849
4850    /// [`Self::transcript_history_state`] served from the per-instance
4851    /// shared cache: one parse per graph value, shared by `Arc` thereafter.
4852    /// Every write to the history key clears the cache.
4853    ///
4854    /// Public for graph-walk consumers (the session-service rewrite-chain
4855    /// persistence loop materializes 1-2 projections PER COMMIT; a fresh
4856    /// owned parse per call re-materializes every retained body each time —
4857    /// the 2026-07-29 per-turn latency incident's dominant clone).
4858    pub fn transcript_history_state_shared(
4859        &self,
4860    ) -> Result<Option<std::sync::Arc<TranscriptHistoryState>>, serde_json::Error> {
4861        if let Some(state) = self.history_caches.shared_state.get() {
4862            return Ok(Some(state));
4863        }
4864        let Some(state) = self.transcript_history_state()? else {
4865            return Ok(None);
4866        };
4867        let state = std::sync::Arc::new(state);
4868        self.history_caches
4869            .shared_state
4870            .set(std::sync::Arc::clone(&state));
4871        Ok(Some(state))
4872    }
4873
4874    /// This session's transcript graph together with the proof that it
4875    /// validates.
4876    ///
4877    /// Prefer this over pairing [`Self::validate_transcript_history_state`]
4878    /// with a separate parse. That pairing establishes the fact and then drops
4879    /// it on the floor: the parsed value carries no evidence, so every guard it
4880    /// is handed to re-derives the same whole-graph proof at O(document) cost.
4881    /// A session whose in-memory marker already records the validation returns
4882    /// the sealed graph without re-verifying; anything else pays exactly one
4883    /// full verification here, and no consumer pays again.
4884    pub fn validated_transcript_history_state(
4885        &self,
4886    ) -> Result<Option<ValidatedTranscriptHistory>, TranscriptEditError> {
4887        let Some(state) = self
4888            .transcript_history_state_shared()
4889            .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?
4890        else {
4891            return Ok(None);
4892        };
4893        if self.transcript_history_metadata_validation
4894            == TranscriptHistoryMetadataValidation::Validated
4895        {
4896            return Ok(Some(ValidatedTranscriptHistory::adopt_session_validated(
4897                state,
4898            )));
4899        }
4900        Err(TranscriptEditError::HistoryStateMalformed(
4901            "transcript-history graph has structural bytes but no verified materialization or construction authority"
4902                .to_string(),
4903        ))
4904    }
4905
4906    /// This session's transcript graph, but ONLY when the session's own marker
4907    /// already proves it.
4908    ///
4909    /// [`Self::validated_transcript_history_state`] SEALS an unmarked graph,
4910    /// which costs a whole-graph hash. A caller that wants the proof as an
4911    /// OPTIMIZATION — evidence that lets it skip work it would otherwise do —
4912    /// has already spent the saving by the time that hash finishes, so this
4913    /// reports absence rather than paying for evidence. Absence here is never
4914    /// a verdict about the graph; it only means no proof is on hand for free.
4915    pub fn already_validated_transcript_history_state(
4916        &self,
4917    ) -> Result<Option<ValidatedTranscriptHistory>, serde_json::Error> {
4918        if self.transcript_history_metadata_validation
4919            != TranscriptHistoryMetadataValidation::Validated
4920        {
4921            return Ok(None);
4922        }
4923        Ok(self
4924            .transcript_history_state_shared()?
4925            .map(ValidatedTranscriptHistory::adopt_session_validated))
4926    }
4927
4928    /// Prove that `state.head` is either the exact live transcript revision or
4929    /// a content-addressed prefix ancestor of the live transcript.
4930    ///
4931    /// `state` must already have crossed graph validation: that proof binds the
4932    /// retained head body's messages to `state.head`. The remaining relation is
4933    /// therefore one prefix digest over the live buffer. Warm append paths serve
4934    /// it from the retained boundary witness; cold/replay callers may pay one
4935    /// fail-closed prefix derivation.
4936    pub(crate) fn live_transcript_extends_history_head(
4937        &self,
4938        state: &TranscriptHistoryState,
4939        _live_revision: &str,
4940    ) -> Result<bool, TranscriptEditError> {
4941        let current_count = u64::try_from(self.messages.len()).map_err(|_| {
4942            TranscriptEditError::HistoryStateMalformed(
4943                "live transcript row count exceeds u64".to_string(),
4944            )
4945        })?;
4946        let endpoint = state.final_endpoint_witness().ok_or_else(|| {
4947            TranscriptEditError::HistoryStateMalformed(
4948                "compact transcript graph has no final endpoint witness".to_string(),
4949            )
4950        })?;
4951        Ok(self.exact_message_row_lineage_extends(endpoint.row_prefix(), current_count))
4952    }
4953
4954    /// Load exact compaction projection intents carried to the runtime's
4955    /// atomic-apply outbox by this session snapshot.
4956    pub fn compaction_projection_intents(
4957        &self,
4958    ) -> Result<Vec<crate::memory::CompactionProjectionIntent>, serde_json::Error> {
4959        self.metadata
4960            .get(crate::memory::SESSION_COMPACTION_PROJECTION_INTENTS_KEY)
4961            .map(|value| serde_json::from_value(value.clone()))
4962            .transpose()
4963            .map(Option::unwrap_or_default)
4964    }
4965
4966    /// Load persisted compaction intents only after proving that every
4967    /// already-carried projection ID is backed by this session's validated
4968    /// transcript graph.
4969    ///
4970    /// This is deliberately a validation boundary, not an ID constructor:
4971    /// durable typed rewrite tags and legacy records can confirm an existing
4972    /// identity during recovery but cannot mint a new identity.
4973    pub fn validated_compaction_projection_intents(
4974        &self,
4975    ) -> Result<Vec<crate::memory::CompactionProjectionIntent>, serde_json::Error> {
4976        let intents = self.compaction_projection_intents()?;
4977        if intents.is_empty() {
4978            return Ok(intents);
4979        }
4980        let history = self
4981            .validated_transcript_history_state()
4982            .map_err(|error| <serde_json::Error as serde::ser::Error>::custom(error.to_string()))?;
4983        let mut unique = std::collections::HashSet::new();
4984        for intent in &intents {
4985            if intent.projection.session_id() != self.id() {
4986                return Err(<serde_json::Error as serde::ser::Error>::custom(
4987                    "compaction projection outbox intent has a foreign session id",
4988                ));
4989            }
4990            if !unique.insert(intent.projection.clone()) {
4991                return Err(<serde_json::Error as serde::ser::Error>::custom(
4992                    "compaction projection outbox contains a duplicate rewrite identity",
4993                ));
4994            }
4995            let backed = history.as_ref().is_some_and(|history| {
4996                history.commits().any(|commit| {
4997                    intent
4998                        .projection
4999                        .matches_transcript_rewrite(self.id(), commit)
5000                })
5001            });
5002            if !backed {
5003                return Err(<serde_json::Error as serde::ser::Error>::custom(format!(
5004                    "compaction projection outbox intent {} has no matching TranscriptRewriteCommit",
5005                    intent.projection.revision()
5006                )));
5007            }
5008        }
5009        Ok(intents)
5010    }
5011
5012    /// Record one invisible staged-memory intent only after its exact
5013    /// TranscriptRewriteCommit is present in the session graph.
5014    pub fn add_compaction_projection_intent(
5015        &mut self,
5016        intent: crate::memory::CompactionProjectionIntent,
5017    ) -> Result<(), serde_json::Error> {
5018        if intent.projection.session_id() != self.id() {
5019            return Err(<serde_json::Error as serde::ser::Error>::custom(
5020                "compaction projection intent session does not match snapshot session",
5021            ));
5022        }
5023        let history = self
5024            .validated_transcript_history_state()
5025            .map_err(|error| <serde_json::Error as serde::ser::Error>::custom(error.to_string()))?
5026            .ok_or_else(|| {
5027                <serde_json::Error as serde::ser::Error>::custom(
5028                    "compaction projection intent requires transcript history state",
5029                )
5030            })?;
5031        let owns_commit = history.commits().any(|commit| {
5032            commit.parent_revision == intent.projection.parent_revision()
5033                && commit.revision == intent.projection.revision()
5034                && intent
5035                    .projection
5036                    .matches_transcript_rewrite(self.id(), commit)
5037        });
5038        if !owns_commit {
5039            return Err(<serde_json::Error as serde::ser::Error>::custom(
5040                "compaction projection intent is not backed by the session transcript graph",
5041            ));
5042        }
5043        let mut intents = self.validated_compaction_projection_intents()?;
5044        if let Some(existing) = intents
5045            .iter()
5046            .find(|existing| existing.projection == intent.projection)
5047        {
5048            if existing == &intent {
5049                return Ok(());
5050            }
5051            return Err(<serde_json::Error as serde::ser::Error>::custom(
5052                "compaction projection intent conflicts with an existing rewrite identity",
5053            ));
5054        }
5055        intents.push(intent);
5056        self.set_metadata_unchecked(
5057            crate::memory::SESSION_COMPACTION_PROJECTION_INTENTS_KEY,
5058            serde_json::to_value(intents)?,
5059        );
5060        Ok(())
5061    }
5062
5063    /// Remove an intent after the runtime outbox has finalized its staged
5064    /// memory batch. Idempotent for repeated recovery finalization.
5065    pub fn complete_compaction_projection_intent(
5066        &mut self,
5067        projection: &crate::memory::CompactionProjectionId,
5068    ) -> Result<Option<crate::memory::CompactionProjectionIntent>, serde_json::Error> {
5069        let mut intents = self.compaction_projection_intents()?;
5070        let Some(position) = intents
5071            .iter()
5072            .position(|intent| &intent.projection == projection)
5073        else {
5074            return Ok(None);
5075        };
5076        let completed = intents.remove(position);
5077        if intents.is_empty() {
5078            self.remove_metadata_unchecked(
5079                crate::memory::SESSION_COMPACTION_PROJECTION_INTENTS_KEY,
5080            );
5081        } else {
5082            self.set_metadata_unchecked(
5083                crate::memory::SESSION_COMPACTION_PROJECTION_INTENTS_KEY,
5084                serde_json::to_value(intents)?,
5085            );
5086        }
5087        Ok(Some(completed))
5088    }
5089
5090    /// Validate the retained transcript revision graph, when present.
5091    pub fn validate_transcript_history_state(&self) -> Result<(), TranscriptEditError> {
5092        if self.transcript_history_metadata_validation
5093            == TranscriptHistoryMetadataValidation::Validated
5094        {
5095            return Ok(());
5096        }
5097        if self.history_caches.shared_state.get().is_some()
5098            || self
5099                .metadata
5100                .contains_key(SESSION_TRANSCRIPT_HISTORY_STATE_KEY)
5101        {
5102            return Err(TranscriptEditError::HistoryStateMalformed(
5103                "transcript-history graph has not crossed verified materialization or construction authority"
5104                    .to_string(),
5105            ));
5106        }
5107        Ok(())
5108    }
5109
5110    /// Clear retained transcript revision metadata after a caller has
5111    /// materialized the desired message projection.
5112    pub fn clear_transcript_history_state(&mut self) {
5113        self.remove_metadata_unchecked(SESSION_TRANSCRIPT_HISTORY_STATE_KEY);
5114    }
5115
5116    /// Adopt a durable head's non-transcript persisted state onto a recovery
5117    /// document whose transcript was rebuilt through the typed mutation seam.
5118    ///
5119    /// This is a mechanical document seam, not target-store write authority. A
5120    /// persistence implementation must still atomically validate its own
5121    /// observation and fencing preconditions before committing the resulting
5122    /// bytes.
5123    ///
5124    /// Exhaustive over the persisted envelope (`SessionSerde` and its
5125    /// borrowed encode view `SessionSerdeRef`):
5126    /// - `version`, `id`, `created_at` — identity fields the digest-verified
5127    ///   prefix relation already proved equal; untouched.
5128    /// - `messages` (and the inline transcript-history graph under
5129    ///   [`SESSION_TRANSCRIPT_HISTORY_STATE_KEY`]) — rebuilt by recovery
5130    ///   through the mutation seam; owned by the target.
5131    /// - the lifecycle terminal — merged through generated
5132    ///   `SessionDocumentMachine` authority because Archived is absorbing; it
5133    ///   never rides the generic metadata overwrite.
5134    /// - `updated_at`, `usage`, and EVERY other metadata key (compaction
5135    ///   projection intents, visibility state, deferred context, ...) — the
5136    ///   head's values are the newer durable truth and are adopted verbatim,
5137    ///   including deletions.
5138    ///
5139    /// Anyone adding a persisted field must classify it here: the head is read
5140    /// through an exhaustive `SessionSerdeRef` destructure with no `..` rest
5141    /// pattern, so an unclassified addition is a compile error at this
5142    /// adoption site rather than a field that silently reverts to the stale
5143    /// snapshot value on every recovery.
5144    pub fn adopt_recovered_head_state(&mut self, head: &Session) -> Result<(), String> {
5145        const RECOVERY_OWNED_KEYS: [&str; 3] = [
5146            SESSION_TRANSCRIPT_HISTORY_STATE_KEY,
5147            SESSION_TRANSCRIPT_REWRITE_PREFIX_AUTHORITY_KEY,
5148            SESSION_LIFECYCLE_TERMINAL_KEY,
5149        ];
5150        let recovered_archived = self
5151            .try_lifecycle_terminal()
5152            .map_err(|error| format!("recovered lifecycle-terminal is malformed: {error}"))?
5153            == Some(SessionLifecycleTerminal::Archived);
5154        let head_terminal = head
5155            .try_lifecycle_terminal()
5156            .map_err(|error| format!("durable-head lifecycle-terminal is malformed: {error}"))?;
5157        let head_archived = head_terminal == Some(SessionLifecycleTerminal::Archived);
5158        let mut lifecycle_authority = session_document::SessionDocumentMachineAuthority::new();
5159        let lifecycle_merge = lifecycle_authority
5160            .resolve_session_document_lifecycle_merge(
5161                session_document::SessionDocumentKey::new(self.id.to_string()),
5162                recovered_archived,
5163                head_archived,
5164            )
5165            .map_err(|error| {
5166                format!("session document authority rejected recovered lifecycle merge: {error}")
5167            })?
5168            .into_iter()
5169            .find_map(|effect| {
5170                match effect {
5171                session_document::SessionDocumentEffect::SessionDocumentLifecycleMergeResolved {
5172                    merge,
5173                } => Some(merge),
5174                _ => None,
5175            }
5176            })
5177            .ok_or_else(|| {
5178                "session document authority emitted no recovered lifecycle merge".to_string()
5179            })?;
5180        // The bindings below ARE the classification: identity-invariant and
5181        // recovery-owned fields are bound to `_`-prefixed names precisely
5182        // because reading them from the head would be wrong.
5183        let SessionSerdeRef {
5184            version: _identity_version,
5185            id: _identity_id,
5186            messages: _recovery_owned_messages,
5187            created_at: _identity_created_at,
5188            updated_at: head_updated_at,
5189            metadata: head_metadata,
5190            usage: head_usage,
5191        } = persisted_envelope_ref(head, None);
5192        self.usage = head_usage.clone();
5193        // `head` was materialized from the exact authenticated metadata state
5194        // that owns these general values. Adopt that baseline together with
5195        // the values instead of diffing or re-hashing the complete map.
5196        self.adopt_head_canonical_metadata_baseline_from(head);
5197        self.metadata.retain(|key, _| {
5198            RECOVERY_OWNED_KEYS.contains(&key.as_str()) || head_metadata.contains_key(key)
5199        });
5200        for (key, value) in head_metadata {
5201            if RECOVERY_OWNED_KEYS.contains(&key.as_str()) {
5202                continue;
5203            }
5204            self.metadata.insert(key.clone(), value.clone());
5205        }
5206        match lifecycle_merge {
5207            session_document::SessionDocumentLifecycleMerge::CarryArchived => self
5208                .set_lifecycle_terminal(SessionLifecycleTerminal::Archived)
5209                .map_err(|error| {
5210                    format!("failed to realize absorbing Archived terminal: {error}")
5211                })?,
5212            session_document::SessionDocumentLifecycleMerge::CarryAuthority => {
5213                match head_terminal {
5214                    Some(terminal) => self.set_lifecycle_terminal(terminal).map_err(|error| {
5215                        format!("failed to realize durable-head lifecycle terminal: {error}")
5216                    })?,
5217                    None => {
5218                        self.remove_metadata_unchecked(SESSION_LIFECYCLE_TERMINAL_KEY);
5219                    }
5220                }
5221            }
5222        }
5223        self.mark_content_mutated(*head_updated_at);
5224        Ok(())
5225    }
5226
5227    /// Return the retained immutable body for a transcript revision.
5228    pub fn transcript_revision_body(
5229        &self,
5230        revision: &str,
5231    ) -> Result<Option<TranscriptRevisionBody>, serde_json::Error> {
5232        let Some(history) = self
5233            .validated_transcript_history_state()
5234            .map_err(|error| <serde_json::Error as serde::ser::Error>::custom(error.to_string()))?
5235        else {
5236            return Ok(None);
5237        };
5238        if !history.state().contains_revision(revision) {
5239            return Ok(None);
5240        }
5241        history
5242            .materialize_revision(revision)
5243            .map(Some)
5244            .map_err(|error| <serde_json::Error as serde::ser::Error>::custom(error.to_string()))
5245    }
5246
5247    /// Return the ordered messages for a retained transcript revision.
5248    pub fn transcript_revision_messages(
5249        &self,
5250        revision: &str,
5251    ) -> Result<Option<Vec<Message>>, serde_json::Error> {
5252        Ok(self
5253            .transcript_revision_body(revision)?
5254            .map(|body| body.messages))
5255    }
5256
5257    /// Materialize this session projection from a typed transcript history graph.
5258    pub fn apply_transcript_history_state(
5259        &mut self,
5260        mut state: TranscriptHistoryState,
5261    ) -> Result<(), TranscriptEditError> {
5262        state.compact_mechanical_revision_bodies()?;
5263        self.apply_proved_transcript_history_state(state)
5264    }
5265
5266    /// Materialize this session from a proof-bearing transcript-history
5267    /// projection without re-validating every retained body.
5268    ///
5269    /// The capability can only be minted by a full validator or by
5270    /// proof-preserving graph transformations such as
5271    /// [`ValidatedTranscriptHistory::project_at_revision`]. This keeps the
5272    /// write seam fail-closed while letting a rewrite-chain persistence walk
5273    /// project each already-proved prefix without turning `N` commits into
5274    /// `N` full-graph verification passes.
5275    pub fn apply_validated_transcript_history_state(
5276        &mut self,
5277        validated: ValidatedTranscriptHistory,
5278    ) -> Result<(), TranscriptEditError> {
5279        let mut state = validated.into_state();
5280        // The graph is already proved, so pruning is the construction-safe
5281        // half only. Calling `compact_mechanical_revision_bodies()` here
5282        // would discard the capability and re-run FullVerify.
5283        state.prune_mechanical_revision_bodies();
5284        self.apply_proved_transcript_history_state(state)
5285    }
5286
5287    /// Install a proof-bearing AUDITED graph while preserving an extending
5288    /// live transcript.
5289    ///
5290    /// Replay consumers reconstruct rewrite history independently from the
5291    /// current strand tail. Replacing `messages` with the audited endpoint
5292    /// would discard that tail; manufacturing a mechanical graph head would
5293    /// copy it into retained history. This seam does neither. It canonicalizes
5294    /// the proved graph to its latest audited endpoint, proves that endpoint is
5295    /// the exact live revision or a content-addressed live prefix, and installs
5296    /// only the graph metadata.
5297    pub fn install_validated_audited_transcript_history_preserving_live(
5298        &mut self,
5299        validated: ValidatedTranscriptHistory,
5300    ) -> Result<(), TranscriptEditError> {
5301        let mut state = validated.into_state();
5302        state.canonicalize_to_latest_audited_head();
5303        let live_revision = self
5304            .transcript_content_digest()
5305            .map_err(|error| TranscriptEditError::HistoryStateMalformed(error.to_string()))?;
5306        if !self.live_transcript_extends_history_head(&state, &live_revision)? {
5307            return Err(TranscriptEditError::HistoryStateMalformed(format!(
5308                "audited transcript head {} is not a prefix ancestor of live revision {live_revision}",
5309                state.head()
5310            )));
5311        }
5312        // Installing a store- or audit-proved graph is materialization of
5313        // authority that already belongs to this transcript, not a new domain
5314        // mutation. The rewrite mutation seams advance `updated_at` after
5315        // changing content; this projection-only seam must remain neutral.
5316        self.install_validated_transcript_history_state(state)
5317            .map_err(|error| TranscriptEditError::HistoryStateMalformed(error.to_string()))
5318    }
5319
5320    /// Clone the persisted envelope around a proof-bearing transcript
5321    /// projection without cloning the source graph's metadata value.
5322    ///
5323    /// `Session::clone()` is cheap for live messages but deep-clones every
5324    /// metadata value. Once transcript history is present, that includes the
5325    /// complete retained graph, so a rewrite-chain walk that immediately
5326    /// replaces the graph still copied all retained bodies once per commit.
5327    /// This constructor copies every other persisted field exactly, omits only
5328    /// the graph the sealed projection replaces, and then installs that
5329    /// projection through the proof-preserving apply seam.
5330    pub fn with_validated_transcript_history_projection(
5331        &self,
5332        validated: ValidatedTranscriptHistory,
5333    ) -> Result<Self, TranscriptEditError> {
5334        let metadata = self
5335            .metadata
5336            .iter()
5337            .filter(|(key, _)| key.as_str() != SESSION_TRANSCRIPT_HISTORY_STATE_KEY)
5338            .map(|(key, value)| (key.clone(), value.clone()))
5339            .collect();
5340        let mut projected = Self {
5341            version: self.version,
5342            id: self.id.clone(),
5343            messages: self.messages.clone(),
5344            created_at: self.created_at,
5345            updated_at: self.updated_at,
5346            metadata,
5347            realtime_transcript: self.realtime_transcript.clone(),
5348            history_caches: Box::default(),
5349            transcript_history_metadata_validation: TranscriptHistoryMetadataValidation::Validated,
5350            usage: self.usage.clone(),
5351        };
5352        projected.apply_validated_transcript_history_state(validated)?;
5353        Ok(projected)
5354    }
5355
5356    fn apply_proved_transcript_history_state(
5357        &mut self,
5358        state: TranscriptHistoryState,
5359    ) -> Result<(), TranscriptEditError> {
5360        let head_body = state.materialize_revision(state.head())?;
5361        let realtime_rebase = self.prepare_realtime_transcript_rebase_after_rewrite(
5362            &head_body.messages,
5363            RealtimeTranscriptSnapshotReasonV1::RecoveryRebase,
5364        )?;
5365        let mut updated_at = head_body.created_at;
5366        for commit in state.commits() {
5367            if commit.committed_at > updated_at {
5368                updated_at = commit.committed_at;
5369            }
5370        }
5371        self.install_validated_transcript_history_state(state)
5372            .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
5373        self.realtime_transcript
5374            .apply_prepared_rebase(realtime_rebase);
5375        // SEAM 7 (non-append): the projection adopts a graph head body.
5376        self.messages.replace(head_body.messages);
5377        self.mark_content_mutated(updated_at);
5378        Ok(())
5379    }
5380
5381    /// Current LIVE transcript revision.
5382    ///
5383    /// The retained graph's `head` is the latest audited rewrite endpoint and
5384    /// may be a prefix ancestor after ordinary appends. Live identity always
5385    /// comes from the message buffer and its incremental digest accumulator.
5386    pub fn transcript_revision(&self) -> Result<String, serde_json::Error> {
5387        self.transcript_content_digest()
5388    }
5389
5390    /// Monotonic durable generation for same-session transcript rewrites.
5391    /// Ordinary message appends advance the content revision but do not change
5392    /// this value, allowing live config refresh after normal turns while still
5393    /// forcing reopen after a rewrite.
5394    pub fn transcript_rewrite_generation(&self) -> Result<u64, serde_json::Error> {
5395        Ok(self
5396            .transcript_history_state_shared()?
5397            .and_then(|state| state.last_commit().map(|commit| commit.rewrite_generation))
5398            .unwrap_or(0))
5399    }
5400
5401    /// Commit a same-session transcript rewrite and advance the transcript head.
5402    pub fn commit_transcript_rewrite(
5403        &mut self,
5404        selection: TranscriptRewriteSelection,
5405        replacement: Vec<Message>,
5406        reason: TranscriptRewriteReason,
5407        actor: Option<String>,
5408        expected_parent_revision: Option<String>,
5409    ) -> Result<TranscriptRewriteCommit, TranscriptEditError> {
5410        let selection = selection.into_current_edit_semantic();
5411        if selection.semantic() == TranscriptRewriteSemantic::Compaction {
5412            return Err(TranscriptEditError::InvalidTranscriptShape(
5413                "typed compaction rewrites require a core-validated compaction witness".to_string(),
5414            ));
5415        }
5416        self.commit_transcript_rewrite_authorized(
5417            selection,
5418            replacement,
5419            reason,
5420            actor,
5421            expected_parent_revision,
5422        )
5423    }
5424
5425    fn commit_transcript_rewrite_authorized(
5426        &mut self,
5427        selection: TranscriptRewriteSelection,
5428        replacement: Vec<Message>,
5429        reason: TranscriptRewriteReason,
5430        actor: Option<String>,
5431        expected_parent_revision: Option<String>,
5432    ) -> Result<TranscriptRewriteCommit, TranscriptEditError> {
5433        self.commit_transcript_rewrite_bound(
5434            selection,
5435            replacement,
5436            reason,
5437            actor,
5438            expected_parent_revision,
5439            None,
5440        )
5441    }
5442
5443    /// [`Self::commit_transcript_rewrite_authorized`] with an additional
5444    /// expected digest for the FULL rewritten transcript. The compaction
5445    /// authority passes its minted revision here, so the rebuilt side of the
5446    /// token binds against the one digest this commit computes anyway
5447    /// instead of a second whole-document hash at the authorization seam. A
5448    /// mismatch fails closed before any state is touched.
5449    fn commit_transcript_rewrite_bound(
5450        &mut self,
5451        selection: TranscriptRewriteSelection,
5452        replacement: Vec<Message>,
5453        reason: TranscriptRewriteReason,
5454        actor: Option<String>,
5455        expected_parent_revision: Option<String>,
5456        expected_revision: Option<&str>,
5457    ) -> Result<TranscriptRewriteCommit, TranscriptEditError> {
5458        let parent_revision = self
5459            .transcript_revision()
5460            .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
5461        if let Some(expected) = expected_parent_revision
5462            && expected != parent_revision
5463        {
5464            return Err(TranscriptEditError::RevisionConflict {
5465                expected,
5466                actual: parent_revision,
5467            });
5468        }
5469
5470        let (start, end) = selection.bounds();
5471        let message_count = self.messages.len();
5472        if start > end || end > message_count {
5473            return Err(TranscriptEditError::InvalidRewriteRange {
5474                start,
5475                end,
5476                message_count,
5477            });
5478        }
5479
5480        let replacement_len = replacement.len();
5481        let mut rewritten = Vec::with_capacity(
5482            start
5483                .saturating_add(replacement_len)
5484                .saturating_add(message_count.saturating_sub(end)),
5485        );
5486        rewritten.extend_from_slice(&self.messages[..start]);
5487        rewritten.extend(replacement.iter().cloned());
5488        rewritten.extend_from_slice(&self.messages[end..]);
5489        validate_transcript_tool_result_shape(&rewritten)?;
5490        // One required hash of the genuinely new content, computed FIRST so
5491        // the whole-span digests below reuse it instead of re-hashing the
5492        // same bytes. The reuse conditions are slice-identity arithmetic,
5493        // never rewrite semantics: a partial-span edit keeps paying O(span).
5494        let revision = transcript_messages_digest(&rewritten)
5495            .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
5496        if let Some(expected) = expected_revision
5497            && expected != revision
5498        {
5499            return Err(TranscriptEditError::InvalidTranscriptShape(
5500                "validated compaction witness does not authorize this exact transcript rebuild"
5501                    .to_string(),
5502            ));
5503        }
5504        if revision == parent_revision {
5505            return Err(TranscriptEditError::NoOpRewrite { revision });
5506        }
5507        let original_span_digest = if start == 0 && end == message_count {
5508            // The span IS the whole live transcript; the accumulator serves
5509            // its digest in O(delta), byte-identical to the free function.
5510            self.transcript_content_digest()
5511                .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?
5512        } else {
5513            transcript_messages_digest(&self.messages[start..end])
5514                .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?
5515        };
5516        let replacement_digest = if start == 0 && start + replacement_len == rewritten.len() {
5517            // The replacement span IS the whole rewritten transcript.
5518            revision.clone()
5519        } else {
5520            transcript_messages_digest(&rewritten[start..start + replacement_len])
5521                .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?
5522        };
5523        let realtime_rebase = self.prepare_realtime_transcript_rebase_after_rewrite(
5524            &rewritten,
5525            RealtimeTranscriptSnapshotReasonV1::TranscriptRewrite,
5526        )?;
5527        let prior_history = self.validated_transcript_history_state()?;
5528        let rewrite_generation = prior_history
5529            .as_ref()
5530            .and_then(|history| history.last_commit())
5531            .map_or(Some(1), |commit| commit.rewrite_generation.checked_add(1))
5532            .ok_or_else(|| {
5533                TranscriptEditError::HistoryStateMalformed(
5534                    "transcript rewrite generation exhausted u64".to_string(),
5535                )
5536            })?;
5537        let committed_at = SystemTime::now();
5538        let commit = TranscriptRewriteCommit {
5539            rewrite_generation,
5540            parent_revision,
5541            revision,
5542            selection,
5543            original_span_digest,
5544            replacement_digest,
5545            messages_before: message_count,
5546            messages_after: rewritten.len(),
5547            reason,
5548            actor,
5549            committed_at,
5550        };
5551        self.finish_compact_transcript_rewrite(
5552            prior_history,
5553            commit,
5554            replacement,
5555            rewritten,
5556            realtime_rebase,
5557        )
5558    }
5559
5560    fn finish_compact_transcript_rewrite(
5561        &mut self,
5562        prior_history: Option<ValidatedTranscriptHistory>,
5563        commit: TranscriptRewriteCommit,
5564        replacement: Vec<Message>,
5565        rewritten: Vec<Message>,
5566        realtime_rebase: PreparedRealtimeTranscriptRebase,
5567    ) -> Result<TranscriptRewriteCommit, TranscriptEditError> {
5568        let parent_row_prefix = self
5569            .exact_message_row_prefix_at(u64::try_from(self.messages.len()).map_err(|_| {
5570                TranscriptEditError::HistoryStateMalformed(
5571                    "live transcript row count exceeds u64".to_string(),
5572                )
5573            })?)
5574            .ok_or_else(|| {
5575                TranscriptEditError::HistoryStateMalformed(
5576                    "live transcript has no exact row-lineage authority".to_string(),
5577                )
5578            })?;
5579        let (start, end) = commit.selection.bounds();
5580        let serialized_replacement = replacement
5581            .iter()
5582            .map(serde_json::to_vec)
5583            .collect::<Result<Vec<_>, _>>()
5584            .map_err(|error| TranscriptEditError::HistoryStateMalformed(error.to_string()))?;
5585        let start = u64::try_from(start).map_err(|_| {
5586            TranscriptEditError::HistoryStateMalformed(
5587                "rewrite start exceeds durable row coordinates".to_string(),
5588            )
5589        })?;
5590        let end = u64::try_from(end).map_err(|_| {
5591            TranscriptEditError::HistoryStateMalformed(
5592                "rewrite end exceeds durable row coordinates".to_string(),
5593            )
5594        })?;
5595        let result_row_prefix = parent_row_prefix
5596            .replace_serialized_range(start, end, &serialized_replacement)
5597            .map_err(|error| TranscriptEditError::HistoryStateMalformed(error.to_string()))?;
5598        let result_witness = TranscriptEndpointWitness::from_messages_with_row_prefix(
5599            &rewritten,
5600            result_row_prefix.clone(),
5601        )
5602        .map_err(|error| TranscriptEditError::HistoryStateMalformed(error.to_string()))?;
5603
5604        let state = match prior_history {
5605            None => {
5606                let parent = TranscriptRevisionBody {
5607                    revision: commit.parent_revision.clone(),
5608                    parent_revision: None,
5609                    messages: self.messages.to_vec(),
5610                    created_at: self.updated_at,
5611                };
5612                TranscriptHistoryState::from_authorized_first_rewrite(
5613                    parent,
5614                    parent_row_prefix,
5615                    &commit.revision,
5616                    &rewritten,
5617                    commit.committed_at,
5618                    result_row_prefix.clone(),
5619                    replacement,
5620                    commit.clone(),
5621                )?
5622            }
5623            Some(history) => {
5624                let mut state = history.state().clone();
5625                let endpoint = state.final_endpoint_witness().ok_or_else(|| {
5626                    TranscriptEditError::HistoryStateMalformed(
5627                        "compact transcript graph has no final endpoint witness".to_string(),
5628                    )
5629                })?;
5630                if self.messages.len() < endpoint.message_count() {
5631                    return Err(TranscriptEditError::HistoryStateMalformed(
5632                        "live rewrite parent is shorter than the audited endpoint".to_string(),
5633                    ));
5634                }
5635                let appended = self.messages[endpoint.message_count()..].to_vec();
5636                let serialized_appended = appended
5637                    .iter()
5638                    .map(serde_json::to_vec)
5639                    .collect::<Result<Vec<_>, _>>()
5640                    .map_err(|error| {
5641                        TranscriptEditError::HistoryStateMalformed(error.to_string())
5642                    })?;
5643                let exact_append_prefix = endpoint
5644                    .row_prefix()
5645                    .extend_serialized_rows(&serialized_appended)
5646                    .map_err(|error| {
5647                        TranscriptEditError::HistoryStateMalformed(error.to_string())
5648                    })?;
5649                let parent_advance = if exact_append_prefix == parent_row_prefix {
5650                    TranscriptParentAdvance::ExactAppend { appended }
5651                } else {
5652                    return Err(TranscriptEditError::HistoryStateMalformed(
5653                        "live rewrite parent is not an exact audited append".to_string(),
5654                    ));
5655                };
5656                let messages_before_base = endpoint.message_count();
5657                state.append_authorized_rewrite(
5658                    commit.clone(),
5659                    messages_before_base,
5660                    parent_advance,
5661                    parent_row_prefix,
5662                    replacement,
5663                    result_witness,
5664                    self.updated_at,
5665                    commit.committed_at,
5666                )?;
5667                state
5668            }
5669        };
5670        self.install_validated_transcript_history_state(state)
5671            .map_err(|error| TranscriptEditError::HistoryStateMalformed(error.to_string()))?;
5672        self.realtime_transcript
5673            .apply_prepared_rebase(realtime_rebase);
5674        self.messages.replace(rewritten);
5675        self.mark_content_mutated(commit.committed_at);
5676        if !self.install_exact_message_row_prefix(result_row_prefix) {
5677            return Err(TranscriptEditError::HistoryStateMalformed(
5678                "failed to install rewrite result row-lineage authority".to_string(),
5679            ));
5680        }
5681        Ok(commit)
5682    }
5683
5684    /// Store typed mob operator authority inside canonical build-state metadata.
5685    ///
5686    /// Store the mob operator authority projection inside build-state metadata.
5687    ///
5688    /// The projection is durable compatibility data only: serialization drops
5689    /// the generated authority seal, so behavior must re-enter generated
5690    /// authority before using restored facts.
5691    pub fn set_mob_tool_authority_context(
5692        &mut self,
5693        authority_context: Option<MobToolAuthorityContext>,
5694    ) -> Result<(), serde_json::Error> {
5695        if let Some(authority_context) = authority_context.as_ref()
5696            && !authority_context.is_generated_authority_context()
5697        {
5698            return Err(<serde_json::Error as serde::de::Error>::custom(
5699                "mob authority context was not minted by generated authority",
5700            ));
5701        }
5702        let mut build_state = self.build_state().ok_or_else(|| {
5703            <serde_json::Error as serde::de::Error>::custom(format!(
5704                "session {} is missing session build state",
5705                self.id
5706            ))
5707        })?;
5708        build_state.mob_tool_authority_context = authority_context;
5709        self.set_build_state(build_state)
5710    }
5711
5712    /// Load the in-memory generated mob operator authority, if still present.
5713    ///
5714    /// Stored/deserialized contexts deliberately fail this check and are not
5715    /// returned as behavior authority.
5716    pub fn mob_tool_authority_context(&self) -> Option<MobToolAuthorityContext> {
5717        self.build_state()
5718            .and_then(|state| state.mob_tool_authority_context)
5719            .filter(MobToolAuthorityContext::is_generated_authority_context)
5720    }
5721
5722    /// Fork the session at a specific message index
5723    ///
5724    /// Creates a new session with a subset of messages. The messages are copied
5725    /// (not shared) since the new session has a different prefix.
5726    pub fn fork_at(&self, index: usize) -> Self {
5727        let now = SystemTime::now();
5728        let truncated = self.messages[..index.min(self.messages.len())].to_vec();
5729        let id = SessionId::new();
5730        Self {
5731            version: session_version(),
5732            realtime_transcript: Box::new(SessionRealtimeTranscriptProjection::empty(&id)),
5733            id,
5734            messages: TranscriptMessages::from_fresh_branch(truncated),
5735            created_at: now,
5736            updated_at: now,
5737            metadata: self.fork_metadata_projection(),
5738            history_caches: Box::default(),
5739            transcript_history_metadata_validation: TranscriptHistoryMetadataValidation::Validated,
5740            usage: self.usage.clone(),
5741        }
5742    }
5743
5744    /// Fork the session and replace the message at `message_index`.
5745    ///
5746    /// The returned session contains the original prefix before
5747    /// `message_index`, followed by the typed replacement. Later source
5748    /// messages are intentionally omitted so follow-up work continues from the
5749    /// edited branch rather than replaying stale descendants.
5750    pub fn fork_replacing(
5751        &self,
5752        message_index: usize,
5753        replacement: TranscriptReplacement,
5754    ) -> Result<Self, TranscriptEditError> {
5755        let Some(original) = self.messages.get(message_index) else {
5756            return Err(TranscriptEditError::MessageIndexOutOfBounds {
5757                message_index,
5758                message_count: self.messages.len(),
5759            });
5760        };
5761
5762        let replacement_message = match replacement {
5763            TranscriptReplacement::Message { message } => message,
5764            TranscriptReplacement::UserContentBlock { block_index, block } => {
5765                let Message::User(user) = original else {
5766                    return Err(TranscriptEditError::MessageRoleMismatch {
5767                        message_index,
5768                        expected: "user",
5769                        actual: message_role_name(original),
5770                    });
5771                };
5772                if block_index >= user.content.len() {
5773                    return Err(TranscriptEditError::BlockIndexOutOfBounds {
5774                        block_kind: "user content block",
5775                        block_index,
5776                        block_count: user.content.len(),
5777                    });
5778                }
5779                let mut edited = user.clone();
5780                edited.content[block_index] = block;
5781                Message::User(edited)
5782            }
5783            TranscriptReplacement::AssistantBlock { block_index, block } => {
5784                let Message::BlockAssistant(assistant) = original else {
5785                    return Err(TranscriptEditError::MessageRoleMismatch {
5786                        message_index,
5787                        expected: "block_assistant",
5788                        actual: message_role_name(original),
5789                    });
5790                };
5791                if block_index >= assistant.blocks.len() {
5792                    return Err(TranscriptEditError::BlockIndexOutOfBounds {
5793                        block_kind: "assistant block",
5794                        block_index,
5795                        block_count: assistant.blocks.len(),
5796                    });
5797                }
5798                let mut edited = assistant.clone();
5799                edited.blocks[block_index] = block;
5800                Message::BlockAssistant(edited)
5801            }
5802            TranscriptReplacement::ToolResultContentBlock {
5803                result_index,
5804                block_index,
5805                block,
5806            } => {
5807                let Message::ToolResults {
5808                    results,
5809                    created_at,
5810                } = original
5811                else {
5812                    return Err(TranscriptEditError::MessageRoleMismatch {
5813                        message_index,
5814                        expected: "tool_results",
5815                        actual: message_role_name(original),
5816                    });
5817                };
5818                let Some(result) = results.get(result_index) else {
5819                    return Err(TranscriptEditError::BlockIndexOutOfBounds {
5820                        block_kind: "tool result",
5821                        block_index: result_index,
5822                        block_count: results.len(),
5823                    });
5824                };
5825                if block_index >= result.content.len() {
5826                    return Err(TranscriptEditError::BlockIndexOutOfBounds {
5827                        block_kind: "tool result content block",
5828                        block_index,
5829                        block_count: result.content.len(),
5830                    });
5831                }
5832                let mut edited_results = results.clone();
5833                edited_results[result_index].content[block_index] = block;
5834                Message::ToolResults {
5835                    results: edited_results,
5836                    created_at: *created_at,
5837                }
5838            }
5839        };
5840
5841        let mut forked = self.fork_at(message_index);
5842        forked.push(replacement_message);
5843        Ok(forked)
5844    }
5845
5846    /// Fork the entire session (full history)
5847    ///
5848    /// This is O(1) - the new session shares the message buffer via Arc.
5849    /// Copy-on-write occurs when either session mutates its messages.
5850    pub fn fork(&self) -> Self {
5851        let now = SystemTime::now();
5852        let id = SessionId::new();
5853        Self {
5854            version: session_version(),
5855            realtime_transcript: Box::new(SessionRealtimeTranscriptProjection::empty(&id)),
5856            id,
5857            messages: self.messages.clone(),
5858            created_at: now,
5859            updated_at: now,
5860            metadata: self.fork_metadata_projection(),
5861            history_caches: Box::default(),
5862            transcript_history_metadata_validation: TranscriptHistoryMetadataValidation::Validated,
5863            usage: self.usage.clone(),
5864        }
5865    }
5866}
5867
5868impl Default for Session {
5869    fn default() -> Self {
5870        Self::new()
5871    }
5872}
5873
5874/// Summary metadata for listing sessions
5875#[derive(Debug, Clone, Serialize, Deserialize)]
5876#[serde(rename_all = "snake_case")]
5877pub struct SessionMeta {
5878    pub id: SessionId,
5879    pub created_at: SystemTime,
5880    pub updated_at: SystemTime,
5881    pub message_count: usize,
5882    pub total_tokens: u64,
5883    #[serde(default)]
5884    pub metadata: serde_json::Map<String, serde_json::Value>,
5885}
5886
5887/// Metadata required to reliably resume a session across interfaces.
5888#[derive(Debug, Clone, Serialize, Deserialize)]
5889#[serde(rename_all = "snake_case")]
5890pub struct SessionMetadata {
5891    /// Per-entity schema version byte.
5892    ///
5893    /// Mandatory on read: a persisted row missing the byte (or carrying a
5894    /// non-current value) fails closed through the generated persistence
5895    /// version authority instead of silently defaulting. Stamped with the
5896    /// current `SESSION_METADATA_SCHEMA_VERSION` on every persist.
5897    pub schema_version: u32,
5898    pub model: String,
5899    pub max_tokens: u32,
5900    #[serde(default = "crate::config::default_structured_output_retries")]
5901    pub structured_output_retries: u32,
5902    pub provider: Provider,
5903    #[serde(default, skip_serializing_if = "Option::is_none")]
5904    pub self_hosted_server_id: Option<String>,
5905    /// Typed provider parameter overrides persisted with the session.
5906    /// Parsed fail-closed at the serde boundary — no JSON bag survives here.
5907    #[serde(default, skip_serializing_if = "Option::is_none")]
5908    pub provider_params: Option<crate::lifecycle::run_primitive::ProviderParamsOverride>,
5909    pub tooling: SessionTooling,
5910    #[serde(default)]
5911    pub keep_alive: bool,
5912    pub comms_name: Option<String>,
5913    /// Friendly metadata for peer discovery (populated when comms is enabled).
5914    #[serde(default, skip_serializing_if = "Option::is_none")]
5915    pub peer_meta: Option<PeerMeta>,
5916    /// Realm identity for cross-surface storage sharing/isolation.
5917    ///
5918    /// Typed [`crate::RealmId`]; the realm slug is validated at the serde
5919    /// boundary. `RealmId` serializes transparently as its slug string, so the
5920    /// durable JSON shape is identical to the prior `Option<String>` form.
5921    #[serde(default, skip_serializing_if = "Option::is_none")]
5922    pub realm_id: Option<crate::RealmId>,
5923    /// Optional process/agent instance identifier within a realm.
5924    #[serde(default, skip_serializing_if = "Option::is_none")]
5925    pub instance_id: Option<String>,
5926    /// Backend pinned by the realm manifest (e.g. "sqlite", "jsonl", "memory").
5927    #[serde(default, skip_serializing_if = "Option::is_none")]
5928    pub backend: Option<String>,
5929    /// Config generation used when this session was created/resumed.
5930    #[serde(default, skip_serializing_if = "Option::is_none")]
5931    pub config_generation: Option<u64>,
5932    /// Realm-scoped auth binding (Phase 3 provider-auth redesign).
5933    ///
5934    /// Persisted intent for the auth/backend binding this session resolved
5935    /// through. On resume, `apply_resumed_session_metadata` writes this
5936    /// back into `AgentBuildConfig.auth_binding` so the same realm
5937    /// binding is re-resolved. Never carries secret material — leases
5938    /// are rebuilt from the active realm connection set at resume time.
5939    /// Older persisted sessions without the field deserialize as `None`
5940    /// (backward compatible via `#[serde(default)]`).
5941    #[serde(default, skip_serializing_if = "Option::is_none")]
5942    pub auth_binding: Option<crate::AuthBindingRef>,
5943    /// Typed durable identity of a mob member, when this session was created by
5944    /// the mob runtime.
5945    ///
5946    /// This is the canonical owner of the `(mob_id, role, member)` identity
5947    /// fact used by mob ownership routing on resume/restart. It replaces the
5948    /// prior recovery-by-string-split of `comms_name` plus a realm
5949    /// format-string check. `comms_name`/`realm_id`/`peer_meta` remain as the
5950    /// transport routing name and discovery metadata.
5951    ///
5952    /// Older persisted sessions without the field deserialize as `None`
5953    /// (backward compatible via `#[serde(default)]`), so old rows read as
5954    /// "no typed binding" rather than failing.
5955    #[serde(default, skip_serializing_if = "Option::is_none")]
5956    pub mob_member_binding: Option<crate::MobMemberBinding>,
5957}
5958
5959/// Canonical durable LLM identity for a session.
5960#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
5961#[serde(rename_all = "snake_case")]
5962pub struct SessionLlmIdentity {
5963    pub model: String,
5964    pub provider: Provider,
5965    #[serde(default, skip_serializing_if = "Option::is_none")]
5966    pub self_hosted_server_id: Option<String>,
5967    /// Typed provider parameter overrides carried on the durable identity.
5968    #[serde(default, skip_serializing_if = "Option::is_none")]
5969    pub provider_params: Option<crate::lifecycle::run_primitive::ProviderParamsOverride>,
5970    /// Realm-scoped auth binding this session resolves credentials
5971    /// through. Carried on the identity so mid-session hot-swaps
5972    /// (`apply_live_session_llm_identity`) re-resolve against the
5973    /// same realm the session was created with — preventing
5974    /// cross-realm credential bleed in multi-tenant setups. Dogma
5975    /// §12 (dynamic policy follows dynamic identity): on swap the
5976    /// factory re-enters `ProviderRuntimeRegistry::resolve` against
5977    /// this binding, not a new synthesized env-default realm.
5978    ///
5979    /// Projection (dogma §1/§13): canonical owner is
5980    /// `SessionMetadata.auth_binding`; this field is the
5981    /// read/write projection used by hot-swap.
5982    #[serde(default, skip_serializing_if = "Option::is_none")]
5983    pub auth_binding: Option<crate::AuthBindingRef>,
5984}
5985
5986/// Typed per-turn override request for a session LLM identity.
5987///
5988/// `provider_params` and `auth_binding` carry the canonical Inherit/Set/Clear
5989/// tri-state via [`TurnMetadataOverride`]: `None` preserves the durable value,
5990/// `Some(Set)` overrides it for this turn, and `Some(Clear)` removes it. The
5991/// illegal "set and clear" fourth state is structurally unrepresentable, so the
5992/// resolver needs no reject branch for it.
5993pub struct SessionLlmIdentityOverride<'a> {
5994    pub model: Option<&'a str>,
5995    pub provider: Option<Provider>,
5996    /// Exact configured route for a self-hosted model. This cannot be inferred
5997    /// from provider/model when multiple local servers expose the same model
5998    /// identifier.
5999    pub self_hosted_server_id: Option<&'a str>,
6000    pub provider_params:
6001        Option<TurnMetadataOverride<&'a crate::lifecycle::run_primitive::ProviderParamsOverride>>,
6002    pub auth_binding: Option<TurnMetadataOverride<&'a crate::AuthBindingRef>>,
6003}
6004
6005#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
6006pub enum SessionLlmIdentityOverrideError {
6007    #[error("provider override requires model on an existing session")]
6008    ProviderRequiresModel,
6009    #[error("{0}")]
6010    ProviderModelMismatch(String),
6011    #[error("self-hosted provider requires a registered model alias; '{model}' is not configured")]
6012    MissingSelfHostedAlias { model: String },
6013    #[error("self_hosted_server_id requires provider 'self_hosted'")]
6014    SelfHostedServerRequiresSelfHostedProvider,
6015    #[error("self_hosted_server_id must not be empty")]
6016    EmptySelfHostedServerId,
6017    #[error(
6018        "self-hosted model '{model}' is configured on server '{configured}', not requested server '{requested}'"
6019    )]
6020    SelfHostedServerMismatch {
6021        model: String,
6022        requested: String,
6023        configured: String,
6024    },
6025}
6026
6027/// Resolve a turn-time model/provider/auth override against the current
6028/// durable session identity.
6029///
6030/// The model registry is the authority for catalog ownership. A model-only
6031/// override follows catalog ownership when the target model is registered;
6032/// uncatalogued models keep the current provider so custom aliases remain
6033/// possible.
6034pub fn resolve_session_llm_identity_override(
6035    current: &SessionLlmIdentity,
6036    registry: &crate::ModelRegistry,
6037    overrides: SessionLlmIdentityOverride<'_>,
6038) -> Result<SessionLlmIdentity, SessionLlmIdentityOverrideError> {
6039    if overrides.provider.is_some() && overrides.model.is_none() {
6040        return Err(SessionLlmIdentityOverrideError::ProviderRequiresModel);
6041    }
6042
6043    let model = overrides
6044        .model
6045        .map(str::to_string)
6046        .unwrap_or_else(|| current.model.clone());
6047    let provider = if let Some(provider) = overrides.provider {
6048        provider
6049    } else if overrides.model.is_some() {
6050        registry
6051            .entry(&model)
6052            .map_or(current.provider, |entry| entry.provider)
6053    } else {
6054        current.provider
6055    };
6056
6057    if (overrides.model.is_some() || overrides.provider.is_some())
6058        && let Some(reason) = registry.provider_override_mismatch_reason(provider, &model)
6059    {
6060        return Err(SessionLlmIdentityOverrideError::ProviderModelMismatch(
6061            reason,
6062        ));
6063    }
6064
6065    let provider_params = match overrides.provider_params {
6066        Some(TurnMetadataOverride::Clear) => None,
6067        Some(TurnMetadataOverride::Set(value)) => Some(value.clone()),
6068        None => current.provider_params.clone(),
6069    };
6070    if overrides.self_hosted_server_id.is_some() && provider != Provider::SelfHosted {
6071        return Err(SessionLlmIdentityOverrideError::SelfHostedServerRequiresSelfHostedProvider);
6072    }
6073    let self_hosted_server_id = if provider == Provider::SelfHosted {
6074        if let Some(requested_server_id) = overrides.self_hosted_server_id {
6075            if requested_server_id.trim().is_empty() {
6076                return Err(SessionLlmIdentityOverrideError::EmptySelfHostedServerId);
6077            }
6078            let entry = registry
6079                .entry_for_provider(Provider::SelfHosted, &model)
6080                .ok_or_else(|| SessionLlmIdentityOverrideError::MissingSelfHostedAlias {
6081                    model: model.clone(),
6082                })?;
6083            let configured_server_id = entry
6084                .self_hosted
6085                .as_ref()
6086                .map(|server| server.server_id.as_str())
6087                .ok_or_else(|| SessionLlmIdentityOverrideError::MissingSelfHostedAlias {
6088                    model: model.clone(),
6089                })?;
6090            if configured_server_id != requested_server_id {
6091                return Err(SessionLlmIdentityOverrideError::SelfHostedServerMismatch {
6092                    model,
6093                    requested: requested_server_id.to_string(),
6094                    configured: configured_server_id.to_string(),
6095                });
6096            }
6097            Some(requested_server_id.to_string())
6098        } else if overrides.model.is_none() {
6099            current.self_hosted_server_id.clone().or_else(|| {
6100                registry
6101                    .entry_for_provider(Provider::SelfHosted, &model)
6102                    .and_then(|entry| entry.self_hosted.as_ref())
6103                    .map(|server| server.server_id.clone())
6104            })
6105        } else {
6106            let entry = registry
6107                .entry_for_provider(Provider::SelfHosted, &model)
6108                .ok_or_else(|| SessionLlmIdentityOverrideError::MissingSelfHostedAlias {
6109                    model: model.clone(),
6110                })?;
6111            entry
6112                .self_hosted
6113                .as_ref()
6114                .map(|server| server.server_id.clone())
6115        }
6116    } else {
6117        None
6118    };
6119
6120    let auth_binding = match overrides.auth_binding {
6121        Some(TurnMetadataOverride::Clear) => None,
6122        Some(TurnMetadataOverride::Set(value)) => Some(value.clone()),
6123        // Inherit: a provider change without an explicit binding drops the
6124        // stale binding; otherwise the durable binding is retained.
6125        None if provider != current.provider => None,
6126        None => current.auth_binding.clone(),
6127    };
6128
6129    Ok(SessionLlmIdentity {
6130        model,
6131        provider,
6132        self_hosted_server_id,
6133        provider_params,
6134        auth_binding,
6135    })
6136}
6137
6138/// Live request policy paired with a session LLM identity hot-swap.
6139///
6140/// `SessionLlmIdentity` is the durable semantic identity. This projection is
6141/// the per-turn request policy the live agent must use for the next LLM call,
6142/// including provider params and provider-native request defaults resolved for
6143/// the same target model/provider.
6144#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
6145#[serde(rename_all = "snake_case")]
6146pub struct SessionLlmRequestPolicy {
6147    pub model: String,
6148    /// Typed explicit provider parameter overrides for the next LLM call.
6149    #[serde(default, skip_serializing_if = "Option::is_none")]
6150    pub provider_params: Option<crate::lifecycle::run_primitive::ProviderParamsOverride>,
6151    /// Typed provider-native request defaults resolved for the swapped target.
6152    #[serde(default, skip_serializing_if = "Option::is_none")]
6153    pub provider_tool_defaults: Option<crate::lifecycle::run_primitive::ProviderTag>,
6154}
6155
6156impl SessionMetadata {
6157    /// Return the current durable LLM identity for this session.
6158    pub fn llm_identity(&self) -> SessionLlmIdentity {
6159        SessionLlmIdentity {
6160            model: self.model.clone(),
6161            provider: self.provider,
6162            self_hosted_server_id: self.self_hosted_server_id.clone(),
6163            provider_params: self.provider_params.clone(),
6164            auth_binding: self.auth_binding.clone(),
6165        }
6166    }
6167
6168    /// Overwrite the durable LLM identity while preserving unrelated session metadata.
6169    pub fn apply_llm_identity(&mut self, identity: &SessionLlmIdentity) {
6170        self.model = identity.model.clone();
6171        self.provider = identity.provider;
6172        self.self_hosted_server_id = identity.self_hosted_server_id.clone();
6173        self.provider_params = identity.provider_params.clone();
6174        self.auth_binding = identity.auth_binding.clone();
6175    }
6176}
6177
6178/// Key used to store SessionMetadata in Session metadata map.
6179pub const SESSION_METADATA_KEY: &str = "session_metadata";
6180
6181/// Caller intent for a tool category.
6182///
6183/// Distinguishes "no opinion / didn't exist" (`Inherit`) from explicit
6184/// `Enable` / `Disable` so that resumed sessions don't freeze tool
6185/// availability at the capabilities of the Meerkat version that created them.
6186///
6187/// **Dogma §10:** Inherit, disable, and set are different facts.
6188#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
6189#[serde(rename_all = "snake_case")]
6190pub enum ToolCategoryOverride {
6191    /// No explicit intent — inherit runtime/factory default.
6192    #[default]
6193    Inherit,
6194    /// Explicitly enabled by caller.
6195    Enable,
6196    /// Explicitly disabled by caller.
6197    Disable,
6198}
6199
6200impl ToolCategoryOverride {
6201    /// Resolve this override against a runtime default.
6202    ///
6203    /// - `Enable` → `true`
6204    /// - `Disable` → `false`
6205    /// - `Inherit` → `runtime_default`
6206    #[must_use]
6207    pub fn resolve(self, runtime_default: bool) -> bool {
6208        match self {
6209            Self::Enable => true,
6210            Self::Disable => false,
6211            Self::Inherit => runtime_default,
6212        }
6213    }
6214
6215    /// Convert to `Option<bool>` for feeding `AgentBuildConfig` override fields.
6216    ///
6217    /// - `Enable` → `Some(true)`
6218    /// - `Disable` → `Some(false)`
6219    /// - `Inherit` → `None` (factory default wins)
6220    #[must_use]
6221    pub fn to_override(self) -> Option<bool> {
6222        match self {
6223            Self::Enable => Some(true),
6224            Self::Disable => Some(false),
6225            Self::Inherit => None,
6226        }
6227    }
6228
6229    /// Construct from a resolved effective bool.
6230    ///
6231    /// **Warning:** this collapses `Inherit` into `Enable`/`Disable`. Prefer
6232    /// [`from_override`] when persisting session metadata so that `Inherit`
6233    /// survives across save/resume cycles. Only use `from_effective` in test
6234    /// helpers or when constructing metadata from external sources that only
6235    /// provide a resolved bool.
6236    #[must_use]
6237    pub fn from_effective(enabled: bool) -> Self {
6238        if enabled { Self::Enable } else { Self::Disable }
6239    }
6240
6241    /// Construct from an `Option<bool>` override field, preserving `Inherit`.
6242    ///
6243    /// - `Some(true)` → `Enable`
6244    /// - `Some(false)` → `Disable`
6245    /// - `None` → `Inherit` (factory default was used, no explicit intent)
6246    ///
6247    /// This is the inverse of [`to_override`] and should be used when persisting
6248    /// session tooling metadata so that `Inherit` survives across save/resume
6249    /// cycles.
6250    #[must_use]
6251    pub fn from_override(value: Option<bool>) -> Self {
6252        match value {
6253            Some(true) => Self::Enable,
6254            Some(false) => Self::Disable,
6255            None => Self::Inherit,
6256        }
6257    }
6258}
6259
6260/// Tooling intent captured at session creation time.
6261///
6262/// Fields use [`ToolCategoryOverride`] to distinguish "no opinion" from
6263/// explicit enable/disable (Dogma §10). On resume, `Inherit` falls through
6264/// to the factory's current runtime default, allowing new tool categories
6265/// to become available without re-creating the session.
6266#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
6267#[serde(rename_all = "snake_case")]
6268pub struct SessionTooling {
6269    #[serde(default)]
6270    pub builtins: ToolCategoryOverride,
6271    #[serde(default)]
6272    pub shell: ToolCategoryOverride,
6273    #[serde(default)]
6274    pub comms: ToolCategoryOverride,
6275    /// Mob (multi-agent orchestration) tools.
6276    #[serde(default)]
6277    pub mob: ToolCategoryOverride,
6278    /// Semantic memory.
6279    #[serde(default)]
6280    pub memory: ToolCategoryOverride,
6281    /// Scheduler tools.
6282    #[serde(default)]
6283    pub schedule: ToolCategoryOverride,
6284    /// WorkGraph durable work tools.
6285    #[serde(default)]
6286    pub workgraph: ToolCategoryOverride,
6287    /// Assistant image generation.
6288    #[serde(default)]
6289    pub image_generation: ToolCategoryOverride,
6290    /// Meerkat-owned fallback web search.
6291    #[serde(default)]
6292    pub web_search: ToolCategoryOverride,
6293    /// Effective call-level tool execution policy for this session's builds.
6294    ///
6295    /// Persisted RESOLVED (never `Inherit`): the factory fails the build
6296    /// closed on an unresolved `Inherit` before metadata is written, so this
6297    /// field only ever holds `AllowList`/`DenyList`. Absent means
6298    /// unrestricted. Spawn/fork resolution reads this field as the parent's
6299    /// effective policy when a child requests `Inherit` (transitive
6300    /// containment — a restricted parent cannot mint an unrestricted child
6301    /// by spawning).
6302    #[serde(default, skip_serializing_if = "Option::is_none")]
6303    pub tool_access_policy: Option<crate::ops::ToolAccessPolicy>,
6304    /// Active skills at session creation time (for deterministic resume).
6305    #[serde(default, skip_serializing_if = "Option::is_none")]
6306    pub active_skills: Option<Vec<crate::skills::SkillKey>>,
6307}
6308
6309impl From<&Session> for SessionMeta {
6310    fn from(session: &Session) -> Self {
6311        Self {
6312            id: session.id.clone(),
6313            created_at: session.created_at,
6314            updated_at: session.updated_at,
6315            message_count: session.messages.len(),
6316            total_tokens: session.total_tokens(),
6317            metadata: session.metadata.clone(),
6318        }
6319    }
6320}
6321
6322/// Decode the typed [`SESSION_METADATA_KEY`] fact from a session metadata map
6323/// through the generated restore authority.
6324///
6325/// Canonical single decoder: [`Session::try_session_metadata`] and every
6326/// metadata-only read seam ([`PersistedSessionMetadataView`]) delegate here so
6327/// the full-session and metadata-only decode paths can never drift.
6328///
6329/// Fail-closed: a present-but-corrupt value is an error, never "absent".
6330pub fn try_session_metadata_from_map(
6331    metadata: &serde_json::Map<String, serde_json::Value>,
6332) -> Result<Option<SessionMetadata>, serde_json::Error> {
6333    let Some(value) = metadata.get(SESSION_METADATA_KEY) else {
6334        return Ok(None);
6335    };
6336    let mut metadata = serde_json::from_value::<SessionMetadata>(value.clone())?;
6337    metadata.schema_version =
6338        session_persistence_version_authority::restore_session_metadata_schema_version(
6339            metadata.schema_version,
6340        )
6341        .map_err(<serde_json::Error as serde::de::Error>::custom)?;
6342    session_durable_config_authority::restore_session_metadata(metadata)
6343        .map(Some)
6344        .map_err(<serde_json::Error as serde::de::Error>::custom)
6345}
6346
6347/// Decode the typed [`SESSION_LIFECYCLE_TERMINAL_KEY`] fact from a session
6348/// metadata map.
6349///
6350/// Canonical single decoder: [`Session::try_lifecycle_terminal`] and every
6351/// metadata-only read seam delegate here. An absent key means no terminal
6352/// fact; a present-but-corrupt value fails closed.
6353pub fn try_lifecycle_terminal_from_map(
6354    metadata: &serde_json::Map<String, serde_json::Value>,
6355) -> Result<Option<SessionLifecycleTerminal>, serde_json::Error> {
6356    match metadata.get(SESSION_LIFECYCLE_TERMINAL_KEY) {
6357        Some(value) => serde_json::from_value(value.clone()).map(Some),
6358        None => Ok(None),
6359    }
6360}
6361
6362/// Typed metadata-only view of a persisted session row or snapshot.
6363///
6364/// The metadata read seam's currency (mobkit ask-24 clause 3): carries the
6365/// session identity plus the two typed session-authority metadata facts,
6366/// decoded fail-closed through the canonical map-level decoders. Consumers
6367/// that only need ownership/policy/lifecycle facts read this view instead of
6368/// materializing the full session document.
6369#[derive(Debug, Clone)]
6370pub struct PersistedSessionMetadataView {
6371    pub session_id: SessionId,
6372    pub session_metadata: Option<SessionMetadata>,
6373    pub lifecycle_terminal: Option<SessionLifecycleTerminal>,
6374}
6375
6376impl PersistedSessionMetadataView {
6377    /// Build the view from a persisted metadata map (e.g. a
6378    /// [`SessionMeta`] row projection).
6379    ///
6380    /// Fail-closed: corrupt values under either reserved key are an error,
6381    /// never treated as absent.
6382    pub fn try_from_metadata_map(
6383        session_id: SessionId,
6384        metadata: &serde_json::Map<String, serde_json::Value>,
6385    ) -> Result<Self, serde_json::Error> {
6386        Ok(Self {
6387            session_id,
6388            session_metadata: try_session_metadata_from_map(metadata)?,
6389            lifecycle_terminal: try_lifecycle_terminal_from_map(metadata)?,
6390        })
6391    }
6392
6393    /// Project the view from a fully materialized session document.
6394    pub fn try_from_session(session: &Session) -> Result<Self, serde_json::Error> {
6395        Ok(Self {
6396            session_id: session.id().clone(),
6397            session_metadata: session.try_session_metadata()?,
6398            lifecycle_terminal: session.try_lifecycle_terminal()?,
6399        })
6400    }
6401
6402    /// Typed durable mob member identity carried on the session metadata,
6403    /// if any.
6404    pub fn mob_member_binding(&self) -> Option<&crate::MobMemberBinding> {
6405        self.session_metadata.as_ref()?.mob_member_binding.as_ref()
6406    }
6407}
6408
6409#[cfg(test)]
6410#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
6411mod tests {
6412
6413    /// Ordinary append does not consult or rewrite transcript-history
6414    /// metadata, regardless of whether the session's parsed-graph cache is
6415    /// warm or requires validation.
6416    ///
6417    /// Control session takes the slow path (its history-validation flag is
6418    /// flipped back to `RequiresValidation` before every append, which is the
6419    /// state an unchecked metadata write leaves behind); the subject takes the
6420    /// Both sessions must retain the same audited head, commits, and bodies;
6421    /// only their live transcript digest advances.
6422    #[test]
6423    fn ordinary_append_graph_is_independent_of_validation_cache_state()
6424    -> Result<(), Box<dyn std::error::Error>> {
6425        fn seeded() -> Result<Session, Box<dyn std::error::Error>> {
6426            let mut session = Session::new();
6427            session.push(Message::User(UserMessage::text("A".to_string())));
6428            session.push(Message::User(UserMessage::text("B".to_string())));
6429            session.commit_transcript_rewrite(
6430                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
6431                vec![Message::User(UserMessage::text("B2".to_string()))],
6432                TranscriptRewriteReason::new("unit-test"),
6433                Some("unit-test".to_string()),
6434                None,
6435            )?;
6436            Ok(session)
6437        }
6438
6439        let mut subject = seeded()?;
6440        let mut control = subject.clone();
6441
6442        for index in 0..4 {
6443            let message = Message::User(UserMessage::text(format!("append {index}")));
6444            subject.push(message.clone());
6445
6446            // Force the control down the full validating path.
6447            let value = serde_json::to_value(
6448                control
6449                    .transcript_history_state()?
6450                    .ok_or_else(|| std::io::Error::other("control history missing"))?,
6451            )?;
6452            control.set_metadata_unchecked_for_test(SESSION_TRANSCRIPT_HISTORY_STATE_KEY, value);
6453            control.transcript_history_metadata_validation =
6454                TranscriptHistoryMetadataValidation::RequiresValidation;
6455            control.push(message);
6456
6457            let subject_state = subject
6458                .transcript_history_state()?
6459                .ok_or_else(|| std::io::Error::other("subject history missing"))?;
6460            let control_state = control
6461                .transcript_history_state()?
6462                .ok_or_else(|| std::io::Error::other("control history missing"))?;
6463            assert_eq!(
6464                subject_state.head(),
6465                control_state.head(),
6466                "head at {index}"
6467            );
6468            assert_eq!(
6469                subject_state.commits().collect::<Vec<_>>(),
6470                control_state.commits().collect::<Vec<_>>(),
6471                "commits at {index}"
6472            );
6473            let project = |state: &TranscriptHistoryState| {
6474                let mut bodies = state
6475                    .materialize_revision_bodies()
6476                    .expect("audited bodies should materialize")
6477                    .into_iter()
6478                    .map(|body| (body.revision, body.messages))
6479                    .collect::<Vec<_>>();
6480                bodies.sort_by(|left, right| left.0.cmp(&right.0));
6481                bodies
6482            };
6483            assert_eq!(
6484                project(&subject_state),
6485                project(&control_state),
6486                "retained bodies at {index}"
6487            );
6488            subject.validate_transcript_history_state()?;
6489        }
6490        Ok(())
6491    }
6492    use super::*;
6493    use crate::realtime_transcript::RealtimeTranscriptRole;
6494    use crate::types::{
6495        AssistantBlock, BlockAssistantMessage, ContentBlock, StopReason, SystemMessage, Usage,
6496        UserMessage,
6497    };
6498    use std::sync::Arc;
6499
6500    fn rewrite_record_at(
6501        state: &TranscriptHistoryState,
6502        edge_index: usize,
6503    ) -> TranscriptRewriteRecord {
6504        let commit = state
6505            .commit(edge_index)
6506            .unwrap_or_else(|| panic!("rewrite occurrence {edge_index} should exist"))
6507            .clone();
6508        let parent_body = state
6509            .materialize_occurrence_parent(edge_index)
6510            .unwrap_or_else(|error| {
6511                panic!("rewrite occurrence {edge_index} parent should materialize: {error}")
6512            });
6513        let revision_body = state
6514            .materialize_occurrence_child(edge_index)
6515            .unwrap_or_else(|error| {
6516                panic!("rewrite occurrence {edge_index} child should materialize: {error}")
6517            });
6518        TranscriptRewriteRecord::new(commit, parent_body, revision_body).unwrap_or_else(|error| {
6519            panic!("rewrite occurrence {edge_index} should validate: {error}")
6520        })
6521    }
6522
6523    fn replace_all_bytes(haystack: &mut Vec<u8>, needle: &[u8], replacement: &[u8]) -> usize {
6524        let mut replaced = 0;
6525        let mut cursor = 0;
6526        while let Some(offset) = haystack[cursor..]
6527            .windows(needle.len())
6528            .position(|candidate| candidate == needle)
6529        {
6530            let start = cursor + offset;
6531            haystack.splice(start..start + needle.len(), replacement.iter().copied());
6532            cursor = start + replacement.len();
6533            replaced += 1;
6534        }
6535        replaced
6536    }
6537
6538    fn released_0810_transcript_messages_digest_with_raw_spelling(
6539        messages: &[Message],
6540        canonical_raw: &str,
6541        released_raw: &str,
6542    ) -> String {
6543        let canonical = canonicalize_released_0810_messages_for_digest(messages);
6544        let mut bytes = serde_json::to_vec(&canonical).expect("released digest form serializes");
6545        replace_all_bytes(
6546            &mut bytes,
6547            canonical_raw.as_bytes(),
6548            released_raw.as_bytes(),
6549        );
6550        format!("sha256:{:x}", Sha256::digest(bytes))
6551    }
6552
6553    fn released_0810_document(
6554        session: &Session,
6555        head: String,
6556        revisions: Vec<TranscriptRevisionBody>,
6557    ) -> serde_json::Value {
6558        let state = session
6559            .transcript_history_state()
6560            .expect("current history should decode")
6561            .expect("current history should exist");
6562        let mut commits = serde_json::to_value(state.commits().cloned().collect::<Vec<_>>())
6563            .expect("commits should serialize");
6564        for commit in commits.as_array_mut().expect("commit vector") {
6565            let fields = commit.as_object_mut().expect("commit object");
6566            fields.remove("rewrite_generation");
6567            let selection = fields
6568                .get_mut("selection")
6569                .and_then(serde_json::Value::as_object_mut)
6570                .expect("selection object");
6571            if matches!(
6572                selection.get("type").and_then(serde_json::Value::as_str),
6573                Some("edit_message_range" | "compaction_message_range")
6574            ) {
6575                let range = selection
6576                    .remove("range")
6577                    .and_then(|value| value.as_object().cloned())
6578                    .expect("typed range object");
6579                *selection = serde_json::Map::from_iter([
6580                    (
6581                        "type".to_string(),
6582                        serde_json::Value::String("message_range".to_string()),
6583                    ),
6584                    (
6585                        "start".to_string(),
6586                        range.get("start").cloned().expect("range start"),
6587                    ),
6588                    (
6589                        "end".to_string(),
6590                        range.get("end").cloned().expect("range end"),
6591                    ),
6592                ]);
6593            }
6594        }
6595
6596        let mut document = serde_json::to_value(session).expect("current session should serialize");
6597        document["version"] = serde_json::json!(2);
6598        let metadata = document["metadata"]
6599            .as_object_mut()
6600            .expect("metadata object");
6601        metadata.remove(SESSION_TRANSCRIPT_REWRITE_PREFIX_AUTHORITY_KEY);
6602        metadata.insert(
6603            SESSION_TRANSCRIPT_HISTORY_STATE_KEY.to_string(),
6604            serde_json::json!({
6605                "head": head,
6606                "commits": commits,
6607                "revisions": revisions,
6608                "digest_format": TRANSCRIPT_DIGEST_FORMAT_RELEASED_0810,
6609            }),
6610        );
6611        document
6612    }
6613
6614    fn transient_context(text: &str) -> TurnRequestContext {
6615        TurnRequestContext::new(text.to_string()).expect("non-empty transient context")
6616    }
6617    async fn wait_for_transient_boundary_request(handle: &TransientTurnContextStateHandle) {
6618        for _ in 0..1_000 {
6619            let registered = matches!(
6620                &handle.boundary.lock().window,
6621                TransientTurnContextBoundaryWindow::Open {
6622                    request: Some(_),
6623                    ..
6624                }
6625            );
6626            if registered {
6627                return;
6628            }
6629            tokio::task::yield_now().await;
6630        }
6631        panic!("transient boundary request did not register");
6632    }
6633    #[test]
6634    fn prepared_transient_boundary_authority_is_send() {
6635        fn assert_send<T: Send>() {}
6636        assert_send::<PreparedTransientTurnContextBoundary>();
6637        assert_send::<crate::lifecycle::CoreBoundaryStageOutput>();
6638    }
6639    #[tokio::test]
6640    async fn transient_boundary_runner_first_consumes_no_context() {
6641        let state = TransientTurnContextStateHandle::new();
6642        let run_id = RunId::new();
6643        let _guard = state
6644            .begin_boundary_run(run_id.clone())
6645            .expect("open boundary");
6646        let contexts = state
6647            .take_pending_at_exact_boundary(&run_id)
6648            .await
6649            .expect("consume empty boundary");
6650        assert!(contexts.is_empty());
6651        let error = state
6652            .prepare_active_turn_boundary(&run_id, vec![transient_context("late")])
6653            .await
6654            .expect_err("runner-first boundary is closed");
6655        assert!(error.is_unavailable());
6656    }
6657    #[tokio::test]
6658    async fn transient_boundary_prepare_commit_publishes_exact_order_once() {
6659        let state = TransientTurnContextStateHandle::new();
6660        let run_id = RunId::new();
6661        let _guard = state
6662            .begin_boundary_run(run_id.clone())
6663            .expect("open boundary");
6664        let prepare_state = state.clone();
6665        let prepare_run_id = run_id.clone();
6666        let prepare = tokio::spawn(async move {
6667            prepare_state
6668                .prepare_active_turn_boundary(
6669                    &prepare_run_id,
6670                    vec![transient_context(" first "), transient_context("second")],
6671                )
6672                .await
6673        });
6674        wait_for_transient_boundary_request(&state).await;
6675        let runner_state = state.clone();
6676        let runner_run_id = run_id.clone();
6677        let runner = tokio::spawn(async move {
6678            runner_state
6679                .take_pending_at_exact_boundary(&runner_run_id)
6680                .await
6681        });
6682        let prepared = prepare
6683            .await
6684            .expect("prepare task")
6685            .expect("parked preparation");
6686        prepared
6687            .into_stage_output(None)
6688            .commit()
6689            .expect("publish transient context");
6690        let contexts = runner.await.expect("runner task").expect("runner consume");
6691        assert_eq!(
6692            contexts
6693                .iter()
6694                .map(TurnRequestContext::as_str)
6695                .collect::<Vec<_>>(),
6696            vec![" first ", "second"]
6697        );
6698    }
6699
6700    #[tokio::test]
6701    async fn transient_boundary_prepare_abort_releases_runner_without_context() {
6702        let state = TransientTurnContextStateHandle::new();
6703        let run_id = RunId::new();
6704        let _guard = state
6705            .begin_boundary_run(run_id.clone())
6706            .expect("open boundary");
6707        let prepare_state = state.clone();
6708        let prepare_run_id = run_id.clone();
6709        let prepare = tokio::spawn(async move {
6710            prepare_state
6711                .prepare_active_turn_boundary(
6712                    &prepare_run_id,
6713                    vec![transient_context("must not publish")],
6714                )
6715                .await
6716        });
6717        wait_for_transient_boundary_request(&state).await;
6718        let runner_state = state.clone();
6719        let runner_run_id = run_id.clone();
6720        let runner = tokio::spawn(async move {
6721            runner_state
6722                .take_pending_at_exact_boundary(&runner_run_id)
6723                .await
6724        });
6725        let prepared = prepare
6726            .await
6727            .expect("prepare task")
6728            .expect("parked preparation");
6729        prepared
6730            .into_stage_output(None)
6731            .abort()
6732            .expect("abort transient context");
6733        assert!(
6734            runner
6735                .await
6736                .expect("runner task")
6737                .expect("runner released")
6738                .is_empty()
6739        );
6740    }
6741
6742    fn block_assistant_text(message: &BlockAssistantMessage) -> String {
6743        message
6744            .blocks
6745            .iter()
6746            .filter_map(|block| match block {
6747                AssistantBlock::Text { text, .. } => Some(text.as_str()),
6748                _ => None,
6749            })
6750            .collect()
6751    }
6752
6753    /// Reducer tests enter through the same proof shape as persistent
6754    /// ingestion: a metadata-only anchor is staged first, then a canonical
6755    /// blob-backed event is applied. Blob bytes are verified in
6756    /// PersistentSessionService tests; this helper tests only reducer ownership.
6757    fn append_staged_user_image(
6758        session: &mut Session,
6759        event: &RealtimeTranscriptEvent,
6760    ) -> RealtimeTranscriptApplyOutcome {
6761        let RealtimeTranscriptEvent::UserContentFinal {
6762            idempotency_key,
6763            item_id,
6764            previous_item_id,
6765            content_index,
6766            content,
6767        } = event
6768        else {
6769            panic!("test helper requires user content final")
6770        };
6771        let [ContentBlock::Image { media_type, data }] = content.as_slice() else {
6772            panic!("test helper requires exactly one image")
6773        };
6774        let media_type = crate::image_generation::MediaType::canonical_str(media_type);
6775        let blob_id = match data {
6776            crate::types::ImageData::Inline { data } => {
6777                crate::blob::content_blob_id(&media_type, data)
6778            }
6779            crate::types::ImageData::Blob { blob_id } => blob_id.clone(),
6780        };
6781        let pending = crate::PendingRealtimeUserContentBlob {
6782            idempotency_key: idempotency_key.clone(),
6783            item_id: item_id.clone(),
6784            previous_item_id: previous_item_id.clone(),
6785            content_index: *content_index,
6786            blob_id,
6787            media_type,
6788        };
6789        assert_eq!(
6790            session
6791                .stage_pending_realtime_user_content_blob(pending.clone())
6792                .expect("test pending anchor should stage"),
6793            crate::generated::session_document::RealtimeUserContentBlobStageDisposition::StageNew
6794        );
6795        session.append_realtime_transcript_event(pending.canonical_event())
6796    }
6797
6798    #[test]
6799    fn transcript_digest_is_content_addressed() {
6800        let base_time = crate::types::message_timestamp_now();
6801        let stamped = vec![
6802            Message::User(UserMessage::text("turn one".to_string())),
6803            Message::BlockAssistant(BlockAssistantMessage {
6804                blocks: vec![AssistantBlock::Text {
6805                    text: "answer one".to_string(),
6806                    meta: None,
6807                }],
6808                stop_reason: StopReason::EndTurn,
6809                identity: crate::types::TranscriptMessageIdentity {
6810                    interaction_id: None,
6811                    run_id: Some(crate::lifecycle::RunId::new()),
6812                    objective_id: None,
6813                },
6814                created_at: base_time,
6815            }),
6816        ];
6817        let mut restamped = stamped.clone();
6818        for message in &mut restamped {
6819            match message {
6820                Message::User(user) => {
6821                    user.created_at = base_time + chrono::Duration::hours(2);
6822                }
6823                Message::BlockAssistant(assistant) => {
6824                    assistant.identity = crate::types::TranscriptMessageIdentity {
6825                        interaction_id: None,
6826                        run_id: Some(crate::lifecycle::RunId::new()),
6827                        objective_id: None,
6828                    };
6829                    assistant.created_at = base_time + chrono::Duration::hours(2);
6830                }
6831                _ => {}
6832            }
6833        }
6834        assert_eq!(
6835            transcript_messages_digest(&stamped).expect("digest"),
6836            transcript_messages_digest(&restamped).expect("digest"),
6837            "bookkeeping variance must not fork the transcript revision"
6838        );
6839
6840        let mut content_changed = stamped.clone();
6841        if let Message::User(user) = &mut content_changed[0] {
6842            user.content = vec![ContentBlock::Text {
6843                text: "a different turn".to_string(),
6844            }];
6845        }
6846        assert_ne!(
6847            transcript_messages_digest(&stamped).expect("digest"),
6848            transcript_messages_digest(&content_changed).expect("digest"),
6849            "content changes must fork the transcript revision"
6850        );
6851    }
6852
6853    #[test]
6854    fn public_generic_rewrite_api_rejects_typed_compaction_semantic() {
6855        let mut session = Session::new();
6856        session.push(Message::User(UserMessage::text("old context")));
6857        let error = session
6858            .commit_transcript_rewrite(
6859                TranscriptRewriteSelection::typed_compaction_for_test(0, 1),
6860                vec![Message::User(UserMessage::compaction_summary("summary"))],
6861                TranscriptRewriteReason::new("anything"),
6862                None,
6863                None,
6864            )
6865            .unwrap_err();
6866        assert!(matches!(
6867            error,
6868            TranscriptEditError::InvalidTranscriptShape(_)
6869        ));
6870        assert_eq!(session.messages().len(), 1);
6871    }
6872
6873    #[test]
6874    fn compaction_witness_authorizes_only_the_exact_validated_rebuild() {
6875        let mut session = Session::new();
6876        session.push(Message::User(UserMessage::text("old context one")));
6877        session.push(Message::User(UserMessage::text("old context two")));
6878        let validated = vec![Message::User(UserMessage::compaction_summary(
6879            "validated summary",
6880        ))];
6881        let authority = crate::agent::compact::ValidatedCompactionRewrite::for_test(
6882            session.messages(),
6883            &validated,
6884        )
6885        .unwrap();
6886        let error = session
6887            .replace_messages_for_compaction_internal(
6888                vec![Message::User(UserMessage::compaction_summary(
6889                    "substituted summary",
6890                ))],
6891                &authority,
6892            )
6893            .unwrap_err();
6894        assert!(matches!(
6895            error,
6896            TranscriptEditError::InvalidTranscriptShape(_)
6897        ));
6898        assert_eq!(session.messages().len(), 2);
6899    }
6900
6901    /// Whole-span digest reuse pin: the commit seam may substitute the
6902    /// already-held whole-document digests ONLY when the selection is the
6903    /// entire transcript. A partial-span rewrite must keep recording genuine
6904    /// O(span) digests — flip the reuse condition to `start == 0` alone and
6905    /// this fails (the recorded span digest would wrongly be the whole
6906    /// transcript's).
6907    #[test]
6908    fn partial_span_rewrite_records_span_digests_not_whole_document_digests() {
6909        let mut session = Session::new();
6910        session.push(Message::User(UserMessage::text("m-0")));
6911        session.push(Message::User(UserMessage::text("m-1")));
6912        session.push(Message::User(UserMessage::text("m-2")));
6913        let original = session.messages().to_vec();
6914        let whole_before = session.transcript_content_digest().unwrap();
6915        let replacement = vec![Message::User(UserMessage::text("m-1-rewritten"))];
6916        let commit = session
6917            .commit_transcript_rewrite(
6918                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
6919                replacement.clone(),
6920                TranscriptRewriteReason::new("unit-test"),
6921                Some("unit-test".to_string()),
6922                None,
6923            )
6924            .unwrap();
6925        assert_eq!(
6926            commit.original_span_digest,
6927            transcript_messages_digest(&original[1..2]).unwrap(),
6928            "partial-span original digest must cover exactly the selected span"
6929        );
6930        assert_ne!(commit.original_span_digest, whole_before);
6931        assert_eq!(
6932            commit.replacement_digest,
6933            transcript_messages_digest(&replacement).unwrap(),
6934            "partial-span replacement digest must cover exactly the replacement"
6935        );
6936        assert_ne!(commit.replacement_digest, commit.revision);
6937        // The graph the commit installed must survive the full validator —
6938        // in particular `validate_transcript_rewrite_record`'s span/prefix/
6939        // suffix relations over these exact digests.
6940        let state = session.transcript_history_state().unwrap().unwrap();
6941        validate_transcript_history_state(&state).unwrap();
6942    }
6943
6944    /// A cosmetic synthetic-notice refresh may never rewrite bytes inside an
6945    /// audited endpoint. Refuse it atomically at the mechanical mutation seam
6946    /// instead of allowing a graph/live divergence that only the next save or
6947    /// rewrite discovers.
6948    #[test]
6949    fn synthetic_notice_refresh_inside_audited_prefix_fails_before_mutation() {
6950        use crate::types::{SystemNoticeKind, SystemNoticeMessage};
6951
6952        let mut session = Session::new();
6953        session.push(Message::User(UserMessage::text("u-0")));
6954        session.push(Message::User(UserMessage::text("u-1")));
6955        // A synthetic refresh notice INSIDE the window later mutations retain.
6956        session
6957            .replace_synthetic_notices(
6958                SystemNoticeKind::McpPending,
6959                vec![Message::SystemNotice(SystemNoticeMessage::new(
6960                    SystemNoticeKind::McpPending,
6961                    "pending v1",
6962                ))],
6963            )
6964            .expect("initial notice install");
6965        // First audited rewrite creates the graph; its endpoint body retains
6966        // the notice at index 2.
6967        session
6968            .commit_transcript_rewrite(
6969                TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
6970                vec![Message::User(UserMessage::text("u-0-rewritten"))],
6971                TranscriptRewriteReason::new("unit-test"),
6972                Some("unit-test".to_string()),
6973                None,
6974            )
6975            .expect("first rewrite commits");
6976        // Ordinary append, then an attempted refresh that would move the
6977        // audited notice from inside the prefix to the tail.
6978        session.push(Message::User(UserMessage::text("u-2")));
6979        let before = session.messages().to_vec();
6980        let error = session
6981            .replace_synthetic_notices(
6982                SystemNoticeKind::McpPending,
6983                vec![Message::SystemNotice(SystemNoticeMessage::new(
6984                    SystemNoticeKind::McpPending,
6985                    "pending v2",
6986                ))],
6987            )
6988            .expect_err("refresh must not rewrite an audited prefix");
6989        assert!(
6990            matches!(error, TranscriptEditError::InvalidTranscriptShape(_)),
6991            "expected the atomic audited-prefix refusal, got: {error:?}"
6992        );
6993        assert_eq!(session.messages(), before.as_slice());
6994
6995        // Fail-closed means untouched and durable.
6996        let bytes = serde_json::to_vec(&session).expect("session serializes");
6997        let decoded: Session = serde_json::from_slice(&bytes)
6998            .expect("the failed rewrite must leave a graph every cold reader accepts");
6999        assert_eq!(decoded.messages().len(), session.messages().len());
7000    }
7001
7002    /// Decode validates the graph internally but the save/rewrite boundary
7003    /// owns its audited-prefix relation to top-level live messages. A current
7004    /// envelope whose live rows diverge inside the graph-proved
7005    /// endpoint must fail at ingress. Historical exact parent splices remain
7006    /// materializable only when already encoded as imported graph edges;
7007    /// current top-level rows cannot manufacture that relationship around the
7008    /// graph.
7009    #[test]
7010    fn current_envelope_rejects_non_append_live_rows_at_ingress() {
7011        let mut session = Session::new();
7012        session.append_system_message("original system");
7013        session.push(Message::User(UserMessage::text("m-1")));
7014        session
7015            .commit_transcript_rewrite(
7016                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
7017                vec![Message::User(UserMessage::text("m-1-rewritten"))],
7018                TranscriptRewriteReason::new("unit-test"),
7019                Some("unit-test".to_string()),
7020                None,
7021            )
7022            .expect("seed rewrite commits");
7023        session.push(Message::User(UserMessage::text("m-2")));
7024
7025        // Tamper only the first top-level row. The compact graph
7026        // remains internally valid, but the live vector is no longer its
7027        // exact endpoint plus an append-only suffix.
7028        let mut document = serde_json::to_value(&session).expect("session serializes");
7029        let divergent_message =
7030            serde_json::to_value(Message::System(SystemMessage::new("replacement system")))
7031                .expect("message serializes");
7032        let messages = document
7033            .get_mut("messages")
7034            .and_then(serde_json::Value::as_array_mut)
7035            .expect("messages array");
7036        messages[0] = divergent_message;
7037        let error = serde_json::from_value::<Session>(document)
7038            .expect_err("a current non-append live tail must fail closed at ingress");
7039        assert!(
7040            error
7041                .to_string()
7042                .contains("live transcript does not preserve the graph-proved audited endpoint"),
7043            "unexpected error: {error}"
7044        );
7045    }
7046
7047    #[test]
7048    fn current_rewrite_refuses_non_append_parent_divergence() {
7049        let mut session = Session::new();
7050        session.push(Message::User(UserMessage::text("original row")));
7051        session.push(Message::User(UserMessage::text("question")));
7052        session
7053            .commit_transcript_rewrite(
7054                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
7055                vec![Message::User(UserMessage::text("edited question"))],
7056                TranscriptRewriteReason::new("unit-test"),
7057                Some("unit-test".to_string()),
7058                None,
7059            )
7060            .expect("seed audited endpoint");
7061        let generation_before = session
7062            .transcript_rewrite_generation()
7063            .expect("rewrite generation");
7064
7065        let mut divergent = session.messages().to_vec();
7066        divergent[0] = Message::User(UserMessage::text("replacement row"));
7067        session.messages.replace(divergent);
7068        let current_prefix =
7069            crate::SessionMessageRowPrefixAccumulator::from_messages(session.messages())
7070                .expect("current row prefix");
7071        assert!(session.install_exact_message_row_prefix(current_prefix));
7072
7073        let error = session
7074            .commit_transcript_rewrite(
7075                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
7076                vec![Message::User(UserMessage::text("edited again"))],
7077                TranscriptRewriteReason::new("unit-test"),
7078                Some("unit-test".to_string()),
7079                None,
7080            )
7081            .expect_err("current writer must not infer a non-append parent splice");
7082        assert!(
7083            matches!(error, TranscriptEditError::HistoryStateMalformed(ref message)
7084                if message.contains("not an exact audited append")),
7085            "unexpected error: {error}"
7086        );
7087        assert_eq!(
7088            session
7089                .transcript_rewrite_generation()
7090                .expect("rewrite generation"),
7091            generation_before,
7092            "failed current write must not append a graph edge"
7093        );
7094    }
7095
7096    #[test]
7097    fn semantic_marker_prevents_new_generic_compaction_forgery_and_heals_prior_data() {
7098        let mut session = Session::new();
7099        session.push(Message::User(UserMessage::text("old context one")));
7100        session.push(Message::User(UserMessage::text("old context two")));
7101        session
7102            .commit_transcript_rewrite(
7103                TranscriptRewriteSelection::MessageRange { start: 0, end: 2 },
7104                vec![Message::User(UserMessage::compaction_summary("summary"))],
7105                TranscriptRewriteReason::new("compaction"),
7106                None,
7107                None,
7108            )
7109            .unwrap();
7110        let session: Session =
7111            serde_json::from_value(serde_json::to_value(&session).unwrap()).unwrap();
7112        let history = session.transcript_history_state().unwrap().unwrap();
7113        let current_commit = history.commit(0).expect("current rewrite commit");
7114        assert_eq!(
7115            current_commit.selection.semantic(),
7116            TranscriptRewriteSemantic::Edit,
7117            "new generic rewrites retain an explicit typed edit marker after roundtrip"
7118        );
7119        assert_eq!(current_commit.reason.kind, "compaction");
7120
7121        let mut legacy_value =
7122            serde_json::to_value(rewrite_record_at(&history, 0)).expect("record wire");
7123        let legacy = legacy_value.as_object_mut().expect("record object");
7124        legacy.remove("digest_format");
7125        legacy.get_mut("commit").expect("legacy commit")["selection"] = serde_json::json!({
7126            "type": "message_range",
7127            "start": 0,
7128            "end": 2,
7129        });
7130        let legacy: TranscriptRewriteRecord =
7131            serde_json::from_value(legacy_value).expect("legacy record should heal");
7132        assert_eq!(
7133            legacy.commit.selection.semantic(),
7134            TranscriptRewriteSemantic::Compaction,
7135            "marker-absent prior data derives compaction from typed transcript evidence"
7136        );
7137
7138        let mut ordinary = Session::new();
7139        ordinary.push(Message::User(UserMessage::text("ordinary old one")));
7140        ordinary.push(Message::User(UserMessage::text("ordinary old two")));
7141        ordinary
7142            .commit_transcript_rewrite(
7143                TranscriptRewriteSelection::MessageRange { start: 0, end: 2 },
7144                vec![Message::User(UserMessage::text("ordinary replacement"))],
7145                TranscriptRewriteReason::new("compaction"),
7146                None,
7147                None,
7148            )
7149            .unwrap();
7150        let history = ordinary.transcript_history_state().unwrap().unwrap();
7151        assert_eq!(
7152            history
7153                .commit(0)
7154                .expect("ordinary rewrite commit")
7155                .selection
7156                .semantic(),
7157            TranscriptRewriteSemantic::Edit,
7158            "free-form reason must not upgrade an ordinary edit"
7159        );
7160    }
7161
7162    /// Sealed-capability seam: the snapshot compaction returns the proof of
7163    /// exactly the graph value it installed into the metadata map.
7164    #[test]
7165    fn snapshot_compaction_returns_the_proof_of_the_installed_graph() {
7166        let mut session = Session::new();
7167        session.push(Message::User(UserMessage::text("seam proof before")));
7168        session
7169            .commit_transcript_rewrite(
7170                TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
7171                vec![Message::User(UserMessage::text("seam proof after"))],
7172                TranscriptRewriteReason::new("edit"),
7173                None,
7174                None,
7175            )
7176            .unwrap();
7177        let document = serde_json::to_value(&session).unwrap();
7178        let mut metadata = serde_json::Map::new();
7179        metadata.insert(
7180            SESSION_TRANSCRIPT_HISTORY_STATE_KEY.to_string(),
7181            document["metadata"][SESSION_TRANSCRIPT_HISTORY_STATE_KEY].clone(),
7182        );
7183        let graph_wire = metadata[SESSION_TRANSCRIPT_HISTORY_STATE_KEY].clone();
7184
7185        let sealed = compact_transcript_history_metadata_for_snapshot(&mut metadata)
7186            .expect("valid graph compacts")
7187            .expect("graph value present");
7188        assert_eq!(
7189            serde_json::to_value(sealed.as_ref()).unwrap(),
7190            graph_wire,
7191            "the returned proof must cover exactly the consumed graph value"
7192        );
7193        assert!(
7194            !metadata.contains_key(SESSION_TRANSCRIPT_HISTORY_STATE_KEY),
7195            "the transient wire graph must not remain beside the typed authority"
7196        );
7197
7198        // No graph, no proof: the seam must not manufacture evidence.
7199        let mut empty = serde_json::Map::new();
7200        assert!(
7201            compact_transcript_history_metadata_for_snapshot(&mut empty)
7202                .expect("empty metadata compacts")
7203                .is_none()
7204        );
7205    }
7206
7207    /// Decode threads the proven parse into the per-instance shared cache and
7208    /// removes the transient wire projection from ordinary metadata. The first
7209    /// consumer after a decode must not re-parse the graph value serialized
7210    /// from that exact state one statement earlier.
7211    #[test]
7212    fn decode_seeds_shared_transcript_graph_with_the_proven_parse() {
7213        let mut session = Session::new();
7214        session.push(Message::User(UserMessage::text("seed shared parse")));
7215        session
7216            .commit_transcript_rewrite(
7217                TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
7218                vec![Message::User(UserMessage::text("seed shared parse two"))],
7219                TranscriptRewriteReason::new("edit"),
7220                None,
7221                None,
7222            )
7223            .unwrap();
7224        let document = serde_json::to_value(&session).unwrap();
7225        let serialized_graph = document["metadata"][SESSION_TRANSCRIPT_HISTORY_STATE_KEY].clone();
7226
7227        let decoded: Session = serde_json::from_value(document).unwrap();
7228        let seeded = decoded
7229            .history_caches
7230            .shared_state
7231            .get()
7232            .expect("decode must seed the shared graph parse");
7233        assert_eq!(
7234            serde_json::to_value(&*seeded).unwrap(),
7235            serialized_graph,
7236            "the seeded graph must be the value the wire carried"
7237        );
7238        assert!(
7239            !decoded
7240                .metadata
7241                .contains_key(SESSION_TRANSCRIPT_HISTORY_STATE_KEY),
7242            "the typed graph cache is the singular in-memory authority"
7243        );
7244        // The sealed accessor serves the seeded allocation, not a re-parse.
7245        let sealed = decoded
7246            .validated_transcript_history_state()
7247            .expect("validated read")
7248            .expect("graph present");
7249        assert!(
7250            std::sync::Arc::ptr_eq(&seeded, &sealed.shared()),
7251            "validated_transcript_history_state must serve the decode-seeded parse"
7252        );
7253
7254        // A graph-free document must not seed anything.
7255        let bare: Session =
7256            serde_json::from_value(serde_json::to_value(Session::new()).unwrap()).unwrap();
7257        assert!(bare.history_caches.shared_state.get().is_none());
7258    }
7259
7260    /// A `ValidatedTranscriptHistory` is the evidence its consumers stopped
7261    /// re-deriving, so the one place that mints it must never hand one out for
7262    /// a graph this process has not actually verified. Metadata written through
7263    /// an unchecked seam clears the validation marker; the accessor owes that
7264    /// session a full verification, and a digest-inconsistent body must fail it.
7265    #[test]
7266    fn sealed_transcript_history_refuses_unverified_corrupt_graph() {
7267        let mut source = Session::new();
7268        source.push(Message::User(UserMessage::text("hello".to_string())));
7269        source
7270            .commit_transcript_rewrite(
7271                TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
7272                vec![Message::User(UserMessage::text("hello again".to_string()))],
7273                TranscriptRewriteReason::new("unit-test"),
7274                Some("unit-test".to_string()),
7275                None,
7276            )
7277            .expect("consistent rewrite should commit");
7278        let mut source_document = serde_json::to_value(&source).expect("source serializes");
7279        source_document["metadata"][SESSION_TRANSCRIPT_HISTORY_STATE_KEY]["anchor"]["messages"]
7280            [0] = serde_json::to_value(Message::User(UserMessage::text("tampered".to_string())))
7281            .expect("tampered message");
7282        assert!(
7283            serde_json::from_value::<Session>(source_document).is_err(),
7284            "decode must not mint a proof for a digest-inconsistent transcript graph"
7285        );
7286    }
7287
7288    /// The other half of the same contract: verification is what the accessor
7289    /// owes, not refusal. A consistent graph installed through the same
7290    /// unchecked seam seals, so downstream guards keep working without each
7291    /// re-running the whole-graph validator.
7292    #[test]
7293    fn sealed_transcript_history_verifies_and_seals_consistent_graph() {
7294        let mut source = Session::new();
7295        source.push(Message::User(UserMessage::text("hello".to_string())));
7296        let commit = source
7297            .commit_transcript_rewrite(
7298                TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
7299                vec![Message::User(UserMessage::text("hello again".to_string()))],
7300                TranscriptRewriteReason::new("unit-test"),
7301                Some("unit-test".to_string()),
7302                None,
7303            )
7304            .expect("consistent rewrite should commit");
7305        let source_document = serde_json::to_value(&source).expect("source serializes");
7306        let session: Session =
7307            serde_json::from_value(source_document).expect("consistent graph decodes");
7308        let sealed = session
7309            .validated_transcript_history_state()
7310            .expect("a consistent graph must seal")
7311            .expect("history metadata is present");
7312        assert_eq!(sealed.state().head(), commit.revision);
7313    }
7314
7315    /// K4 invariant: synthetic-notice refresh is ONE atomic transcript edit —
7316    /// after a refresh, at most the replacement notices of that kind exist
7317    /// (no stale notice survives beside a fresh one).
7318    #[test]
7319    fn replace_synthetic_notices_leaves_only_replacements_of_kind() {
7320        use crate::types::{SystemNoticeKind, SystemNoticeMessage};
7321
7322        let mut session = Session::new();
7323        session.push(Message::User(UserMessage::text("hello".to_string())));
7324        session.push(Message::SystemNotice(SystemNoticeMessage::new(
7325            SystemNoticeKind::McpPending,
7326            "stale one",
7327        )));
7328        session.push(Message::SystemNotice(SystemNoticeMessage::new(
7329            SystemNoticeKind::McpPending,
7330            "stale two",
7331        )));
7332        // A notice of another kind must be untouched.
7333        session.push(Message::SystemNotice(SystemNoticeMessage::new(
7334            SystemNoticeKind::BackgroundJob,
7335            "other-kind",
7336        )));
7337
7338        session
7339            .replace_synthetic_notices(
7340                SystemNoticeKind::McpPending,
7341                vec![Message::SystemNotice(SystemNoticeMessage::new(
7342                    SystemNoticeKind::McpPending,
7343                    "fresh",
7344                ))],
7345            )
7346            .expect("notice refresh succeeds");
7347
7348        let mcp_pending: Vec<&SystemNoticeMessage> = session
7349            .messages()
7350            .iter()
7351            .filter_map(|message| match message {
7352                Message::SystemNotice(notice) if notice.kind == SystemNoticeKind::McpPending => {
7353                    Some(notice)
7354                }
7355                _ => None,
7356            })
7357            .collect();
7358        assert_eq!(mcp_pending.len(), 1, "exactly one notice of the kind");
7359        assert_eq!(mcp_pending[0].body.as_deref(), Some("fresh"));
7360        assert!(
7361            session.messages().iter().any(|message| matches!(
7362                message,
7363                Message::SystemNotice(notice) if notice.kind == SystemNoticeKind::BackgroundJob
7364            )),
7365            "other-kind notices are untouched"
7366        );
7367
7368        // Empty replacements = pure strip.
7369        session
7370            .replace_synthetic_notices(SystemNoticeKind::McpPending, Vec::new())
7371            .expect("pure strip succeeds");
7372        assert!(
7373            !session.messages().iter().any(|message| matches!(
7374                message,
7375                Message::SystemNotice(notice) if notice.kind == SystemNoticeKind::McpPending
7376            )),
7377            "empty replacement clears the kind"
7378        );
7379    }
7380
7381    #[test]
7382    fn ordinary_appends_after_rewrite_leave_audited_graph_untouched() {
7383        let mut session = Session::new();
7384        for message in 0..133 {
7385            session.push(Message::User(UserMessage::text(format!(
7386                "seed message {message}"
7387            ))));
7388        }
7389        let parent = session.transcript_revision().expect("parent revision");
7390        let commit = session
7391            .commit_transcript_rewrite(
7392                TranscriptRewriteSelection::MessageRange {
7393                    start: 132,
7394                    end: 133,
7395                },
7396                vec![Message::User(UserMessage::text("edited question"))],
7397                TranscriptRewriteReason::new("unit-test-edit"),
7398                Some("unit-test".to_string()),
7399                Some(parent),
7400            )
7401            .expect("rewrite should commit");
7402        let graph_before = session
7403            .validated_transcript_history_state()
7404            .expect("history validation")
7405            .expect("rewrite graph");
7406
7407        for turn in 0..762 {
7408            session.push(Message::User(UserMessage::text(format!("turn {turn}"))));
7409        }
7410
7411        let graph_after = session
7412            .validated_transcript_history_state()
7413            .expect("history validation")
7414            .expect("rewrite graph");
7415        assert!(
7416            graph_before.shares_exact_state_with(&graph_after),
7417            "ordinary appends must preserve the exact audited graph authority"
7418        );
7419        let state = session
7420            .transcript_history_state()
7421            .expect("history state should decode")
7422            .expect("rewrite should create history state");
7423        assert_eq!(session.messages().len(), 895);
7424        assert_eq!(state.commit_count(), 1, "ordinary appends are not rewrites");
7425        let retained_bodies = state
7426            .materialize_revision_bodies()
7427            .expect("audited bodies should materialize");
7428        assert_eq!(
7429            retained_bodies.len(),
7430            2,
7431            "one real rewrite retains only its two audited endpoints"
7432        );
7433        assert_eq!(state.head(), commit.revision);
7434        assert_ne!(
7435            session.transcript_revision().expect("live revision"),
7436            state.head(),
7437            "the live append tail is Session authority, not a mechanical graph head"
7438        );
7439        let retained_message_entries = retained_bodies
7440            .iter()
7441            .map(|body| body.messages.len())
7442            .sum::<usize>();
7443        assert!(retained_message_entries <= 2 * session.messages().len());
7444
7445        let live_bytes = serde_json::to_vec(session.messages())
7446            .expect("live transcript should serialize")
7447            .len();
7448        let snapshot_bytes = serde_json::to_vec(&session)
7449            .expect("session snapshot should serialize")
7450            .len();
7451        assert!(
7452            snapshot_bytes <= live_bytes.saturating_mul(5).saturating_add(64 * 1024),
7453            "snapshot must remain linear in the live transcript: {snapshot_bytes} bytes for {live_bytes} live bytes"
7454        );
7455    }
7456
7457    #[test]
7458    fn repeated_synthetic_notice_refreshes_do_not_mint_rewrite_commits() {
7459        use crate::types::{SystemNoticeKind, SystemNoticeMessage};
7460
7461        let mut session = Session::new();
7462        session.push(Message::User(UserMessage::text("before".to_string())));
7463        session
7464            .commit_transcript_rewrite(
7465                TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
7466                vec![Message::User(UserMessage::text("after".to_string()))],
7467                TranscriptRewriteReason::new("unit-test-edit"),
7468                Some("unit-test".to_string()),
7469                None,
7470            )
7471            .expect("seed rewrite");
7472        let graph_before = session
7473            .validated_transcript_history_state()
7474            .expect("history validation")
7475            .expect("audited graph");
7476
7477        for refresh in 0..64 {
7478            session
7479                .replace_synthetic_notices(
7480                    SystemNoticeKind::McpPending,
7481                    vec![Message::SystemNotice(SystemNoticeMessage::new(
7482                        SystemNoticeKind::McpPending,
7483                        format!("refresh {refresh}"),
7484                    ))],
7485                )
7486                .expect("mechanical refresh");
7487        }
7488        let graph_after = session
7489            .validated_transcript_history_state()
7490            .expect("history validation")
7491            .expect("audited graph");
7492        assert!(
7493            graph_before.shares_exact_state_with(&graph_after),
7494            "tail-only synthetic refreshes must preserve the exact audited graph authority"
7495        );
7496
7497        let state = session
7498            .transcript_history_state()
7499            .expect("history state")
7500            .expect("seed rewrite history");
7501        assert_eq!(state.commit_count(), 1);
7502        assert_eq!(session.transcript_rewrite_generation().unwrap(), 1);
7503        assert_eq!(
7504            state
7505                .materialize_revision_bodies()
7506                .expect("audited bodies should materialize")
7507                .len(),
7508            2,
7509            "mechanical refreshes do not mint retained live-head bodies"
7510        );
7511    }
7512
7513    #[test]
7514    fn snapshot_compaction_does_not_launder_corrupt_old_body() {
7515        let mut session = Session::new();
7516        session.push(Message::User(UserMessage::text("seed".to_string())));
7517        session
7518            .commit_transcript_rewrite(
7519                TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
7520                vec![Message::User(UserMessage::text("rewritten".to_string()))],
7521                TranscriptRewriteReason::new("unit-test-edit"),
7522                Some("unit-test".to_string()),
7523                None,
7524            )
7525            .expect("seed rewrite");
7526        let state = session
7527            .transcript_history_state()
7528            .expect("state")
7529            .expect("history");
7530        let mut state = serde_json::to_value(&state).expect("current history value");
7531        state["anchor"]["messages"][0] =
7532            serde_json::to_value(Message::User(UserMessage::text("tampered".to_string())))
7533                .expect("tampered message");
7534        session.set_metadata_unchecked_for_test(SESSION_TRANSCRIPT_HISTORY_STATE_KEY, state);
7535
7536        assert!(
7537            serde_json::to_vec(&session).is_err(),
7538            "serialization must fail before pruning a corrupt old body"
7539        );
7540    }
7541
7542    /// The compatibility floor is 0.8.10. Its current-digest graph could still
7543    /// carry full mechanical append bodies; the explicit one-time importer
7544    /// validates that exact released shape before canonicalizing to audited
7545    /// endpoints.
7546    #[test]
7547    fn released_0_8_10_mechanical_history_is_compacted_at_import_boundary() {
7548        let mut session = Session::new();
7549        session.push(Message::User(UserMessage::text("seed".to_string())));
7550        session
7551            .commit_transcript_rewrite(
7552                TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
7553                vec![Message::User(UserMessage::text("rewritten".to_string()))],
7554                TranscriptRewriteReason::new("unit-test-edit"),
7555                Some("unit-test".to_string()),
7556                None,
7557            )
7558            .expect("seed rewrite");
7559        let state = session
7560            .transcript_history_state()
7561            .expect("state")
7562            .expect("history");
7563        let mut revisions = state
7564            .materialize_revision_bodies()
7565            .expect("released audit bodies should materialize");
7566        let mut parent = state.head().to_string();
7567        for index in 0..8 {
7568            session.push(Message::User(UserMessage::text(format!(
7569                "0.8.10 ordinary append {index}"
7570            ))));
7571            let revision = transcript_messages_digest(session.messages()).expect("revision digest");
7572            revisions.push(TranscriptRevisionBody {
7573                revision: revision.clone(),
7574                parent_revision: Some(parent),
7575                messages: session.messages().to_vec(),
7576                created_at: SystemTime::now(),
7577            });
7578            parent = revision;
7579        }
7580        let released = released_0810_document(&session, parent, revisions);
7581        let released = serde_json::to_vec(&released).expect("released document bytes");
7582        let imported =
7583            import_released_0810_session(&released).expect("released document should import");
7584        assert_eq!(
7585            imported.receipt().evidence(),
7586            Released0810ImportEvidence::StoreAuthorizationRequired
7587        );
7588        let compact = imported
7589            .session()
7590            .transcript_history_state()
7591            .expect("imported history")
7592            .expect("imported graph");
7593
7594        assert_eq!(
7595            compact
7596                .materialize_revision_bodies()
7597                .expect("compact bodies should materialize")
7598                .len(),
7599            2,
7600            "import boundary should retain only the two audited endpoints"
7601        );
7602        assert_eq!(
7603            compact.head(),
7604            compact.last_commit().expect("rewrite commit").revision
7605        );
7606        validate_transcript_history_state(&compact).expect("compacted history remains valid");
7607    }
7608
7609    #[test]
7610    fn released_0_8_10_lost_raw_json_spelling_is_authorized_then_rebound_once() {
7611        let opaque = r#"{"z":1,"a":{"y":2,"x":3}}"#;
7612        let canonical_opaque = r#"{"a":{"x":3,"y":2},"z":1}"#;
7613        let mut session = Session::new();
7614        session.append_system_message("system".to_string());
7615        session.push(Message::User(UserMessage::text("question".to_string())));
7616        session.push(Message::BlockAssistant(BlockAssistantMessage::new(
7617            vec![AssistantBlock::ToolUse {
7618                id: "tool-1".to_string(),
7619                name: "opaque".to_string(),
7620                args: serde_json::value::RawValue::from_string(opaque.to_string())
7621                    .expect("valid tool args"),
7622                meta: None,
7623            }],
7624            StopReason::ToolUse,
7625        )));
7626        session.push(Message::tool_results(vec![
7627            crate::types::ToolResult::with_blocks(
7628                "tool-1".to_string(),
7629                vec![ContentBlock::Structured {
7630                    data: serde_json::value::RawValue::from_string(opaque.to_string())
7631                        .expect("valid structured result"),
7632                }],
7633                false,
7634            ),
7635        ]));
7636        session.push(Message::User(UserMessage::text("tail".to_string())));
7637        session
7638            .commit_transcript_rewrite(
7639                TranscriptRewriteSelection::MessageRange { start: 2, end: 3 },
7640                vec![Message::BlockAssistant(BlockAssistantMessage::new(
7641                    vec![AssistantBlock::ToolUse {
7642                        id: "tool-1".to_string(),
7643                        name: "opaque-revised".to_string(),
7644                        args: serde_json::value::RawValue::from_string(opaque.to_string())
7645                            .expect("valid revised tool args"),
7646                        meta: None,
7647                    }],
7648                    StopReason::ToolUse,
7649                ))],
7650                TranscriptRewriteReason::new("released-rich-content"),
7651                Some("unit-test".to_string()),
7652                None,
7653            )
7654            .expect("seed current rewrite");
7655
7656        let state = session
7657            .transcript_history_state()
7658            .expect("current state")
7659            .expect("current graph");
7660        let revisions = state
7661            .materialize_revision_bodies()
7662            .expect("released bodies materialize");
7663        let released_ids = revisions
7664            .iter()
7665            .map(|body| {
7666                (
7667                    body.revision.clone(),
7668                    released_0810_transcript_messages_digest_with_raw_spelling(
7669                        &body.messages,
7670                        canonical_opaque,
7671                        opaque,
7672                    ),
7673                )
7674            })
7675            .collect::<std::collections::BTreeMap<_, _>>();
7676        assert!(
7677            released_ids
7678                .iter()
7679                .any(|(current, released)| current != released),
7680            "fixture must reproduce a real pre-buffer format-2 identity mismatch"
7681        );
7682        let released_span_ids = (0..state.commit_count())
7683            .map(|index| {
7684                let record = rewrite_record_at(&state, index);
7685                let (start, end) = record.commit.selection.bounds();
7686                let removed = end - start;
7687                let retained = record.commit.messages_before - removed;
7688                let replacement_len = record.commit.messages_after - retained;
7689                (
7690                    released_0810_transcript_messages_digest_with_raw_spelling(
7691                        &record.parent_body.messages[start..end],
7692                        canonical_opaque,
7693                        opaque,
7694                    ),
7695                    released_0810_transcript_messages_digest_with_raw_spelling(
7696                        &record.revision_body.messages[start..start + replacement_len],
7697                        canonical_opaque,
7698                        opaque,
7699                    ),
7700                )
7701            })
7702            .collect::<Vec<_>>();
7703        for (index, (released_original, released_replacement)) in
7704            released_span_ids.iter().enumerate()
7705        {
7706            let record = rewrite_record_at(&state, index);
7707            assert_ne!(
7708                released_original, &record.commit.original_span_digest,
7709                "fixture must reproduce a real pre-buffer format-2 original-span mismatch"
7710            );
7711            assert_ne!(
7712                released_replacement, &record.commit.replacement_digest,
7713                "fixture must reproduce a real pre-buffer format-2 replacement-span mismatch"
7714            );
7715        }
7716        let mut released = released_0810_document(&session, state.head().to_string(), revisions);
7717        let history = released["metadata"][SESSION_TRANSCRIPT_HISTORY_STATE_KEY]
7718            .as_object_mut()
7719            .expect("released history object");
7720        let revisions = history["revisions"]
7721            .as_array_mut()
7722            .expect("released revision vector");
7723        for body in revisions {
7724            body["revision"] = serde_json::Value::String(
7725                released_ids
7726                    .get(body["revision"].as_str().expect("body revision"))
7727                    .expect("body remap")
7728                    .clone(),
7729            );
7730            if let Some(parent) = body["parent_revision"].as_str() {
7731                body["parent_revision"] = serde_json::Value::String(
7732                    released_ids.get(parent).expect("parent remap").clone(),
7733                );
7734            }
7735        }
7736        history["head"] = serde_json::Value::String(
7737            released_ids
7738                .get(history["head"].as_str().expect("released head"))
7739                .expect("head remap")
7740                .clone(),
7741        );
7742        for (index, commit) in history["commits"]
7743            .as_array_mut()
7744            .expect("released commits")
7745            .iter_mut()
7746            .enumerate()
7747        {
7748            commit["parent_revision"] = serde_json::Value::String(
7749                released_ids
7750                    .get(commit["parent_revision"].as_str().expect("commit parent"))
7751                    .expect("commit parent remap")
7752                    .clone(),
7753            );
7754            commit["revision"] = serde_json::Value::String(
7755                released_ids
7756                    .get(commit["revision"].as_str().expect("commit revision"))
7757                    .expect("commit revision remap")
7758                    .clone(),
7759            );
7760            commit["original_span_digest"] =
7761                serde_json::Value::String(released_span_ids[index].0.clone());
7762            commit["replacement_digest"] =
7763                serde_json::Value::String(released_span_ids[index].1.clone());
7764        }
7765
7766        let mut inconsistent = released.clone();
7767        inconsistent["metadata"][SESSION_TRANSCRIPT_HISTORY_STATE_KEY]["commits"][0]["messages_before"] =
7768            serde_json::json!(999);
7769        let inconsistent = serde_json::to_vec(&inconsistent).expect("inconsistent released bytes");
7770        assert!(
7771            import_released_0810_session(&inconsistent).is_err(),
7772            "source authorization must not launder contradictory commit/body topology"
7773        );
7774
7775        let released = serde_json::to_vec(&released).expect("released bytes");
7776        let imported = import_released_0810_session(&released)
7777            .expect("store-authorized 0.8.10 lost-spelling graph imports");
7778        assert_eq!(
7779            imported.receipt().evidence(),
7780            Released0810ImportEvidence::StoreAuthorizationRequired
7781        );
7782        let imported_state = imported
7783            .session()
7784            .transcript_history_state()
7785            .expect("imported state")
7786            .expect("imported graph");
7787        assert_eq!(
7788            imported_state.digest_format(),
7789            transcript_history::graph::TRANSCRIPT_DIGEST_FORMAT_CURRENT
7790        );
7791        for body in imported_state
7792            .materialize_revision_bodies()
7793            .expect("current bodies materialize")
7794        {
7795            assert_eq!(
7796                body.revision,
7797                transcript_messages_digest(&body.messages).expect("current body digest"),
7798                "every released format-2 label must be replaced by current identity"
7799            );
7800        }
7801        let current = serde_json::to_vec(imported.session()).expect("current session serializes");
7802        let restored: Session =
7803            serde_json::from_slice(&current).expect("current session round-trips");
7804        assert_eq!(
7805            restored.transcript_revision().expect("restored revision"),
7806            imported
7807                .session()
7808                .transcript_revision()
7809                .expect("imported revision")
7810        );
7811    }
7812
7813    #[test]
7814    fn released_0_8_10_collapsed_raw_json_rewrite_is_squashed_and_tail_rebased() {
7815        let canonical_raw = r#"{"a":2,"z":1}"#;
7816        let released_raw = r#"{"z":1,"a":2}"#;
7817        let tool_message = |name: &str, raw: &str| {
7818            Message::BlockAssistant(BlockAssistantMessage::new(
7819                vec![AssistantBlock::ToolUse {
7820                    id: "tool-1".to_string(),
7821                    name: name.to_string(),
7822                    args: serde_json::value::RawValue::from_string(raw.to_string())
7823                        .expect("valid tool args"),
7824                    meta: None,
7825                }],
7826                StopReason::ToolUse,
7827            ))
7828        };
7829
7830        let parent_messages = vec![tool_message("opaque", released_raw)];
7831        let collapsed_messages = vec![tool_message("opaque", canonical_raw)];
7832        let final_messages = vec![tool_message("opaque-revised", released_raw)];
7833        let old_parent = released_0810_transcript_messages_digest_with_raw_spelling(
7834            &parent_messages,
7835            canonical_raw,
7836            released_raw,
7837        );
7838        let old_collapsed = released_0810_transcript_messages_digest_with_raw_spelling(
7839            &collapsed_messages,
7840            canonical_raw,
7841            canonical_raw,
7842        );
7843        let old_final = released_0810_transcript_messages_digest_with_raw_spelling(
7844            &final_messages,
7845            canonical_raw,
7846            released_raw,
7847        );
7848        assert_ne!(old_parent, old_collapsed);
7849        assert_eq!(
7850            transcript_messages_digest(&parent_messages).expect("current parent digest"),
7851            transcript_messages_digest(&collapsed_messages).expect("current collapsed digest"),
7852            "only released RawValue spelling may distinguish the squashed endpoints"
7853        );
7854
7855        let body = |revision: String, parent_revision: Option<String>, messages: Vec<Message>| {
7856            TranscriptRevisionBody {
7857                revision,
7858                parent_revision,
7859                messages,
7860                created_at: SystemTime::UNIX_EPOCH,
7861            }
7862        };
7863        let parent_body = body(old_parent.clone(), None, parent_messages.clone());
7864        let collapsed_body = body(
7865            old_collapsed.clone(),
7866            Some(old_parent.clone()),
7867            collapsed_messages.clone(),
7868        );
7869        let final_body = body(
7870            old_final.clone(),
7871            Some(old_collapsed.clone()),
7872            final_messages.clone(),
7873        );
7874        let commit = |parent_revision: String,
7875                      revision: String,
7876                      original_span_digest: String,
7877                      replacement_digest: String| TranscriptRewriteCommit {
7878            rewrite_generation: 0,
7879            parent_revision,
7880            revision,
7881            selection: TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
7882            original_span_digest,
7883            replacement_digest,
7884            messages_before: 1,
7885            messages_after: 1,
7886            reason: TranscriptRewriteReason::new("released-rich-content"),
7887            actor: Some("unit-test".to_string()),
7888            committed_at: SystemTime::UNIX_EPOCH,
7889        };
7890        let collapsed_commit = commit(
7891            old_parent,
7892            old_collapsed.clone(),
7893            released_0810_transcript_messages_digest_with_raw_spelling(
7894                &parent_messages,
7895                canonical_raw,
7896                released_raw,
7897            ),
7898            released_0810_transcript_messages_digest_with_raw_spelling(
7899                &collapsed_messages,
7900                canonical_raw,
7901                canonical_raw,
7902            ),
7903        );
7904        let final_commit = commit(
7905            old_collapsed.clone(),
7906            old_final.clone(),
7907            released_0810_transcript_messages_digest_with_raw_spelling(
7908                &collapsed_messages,
7909                canonical_raw,
7910                canonical_raw,
7911            ),
7912            released_0810_transcript_messages_digest_with_raw_spelling(
7913                &final_messages,
7914                canonical_raw,
7915                released_raw,
7916            ),
7917        );
7918
7919        let mut session = Session::new();
7920        session.push(final_messages[0].clone());
7921        let mut released = serde_json::to_value(&session).expect("released session serializes");
7922        released["version"] = serde_json::json!(2);
7923        released["metadata"][SESSION_TRANSCRIPT_HISTORY_STATE_KEY] = serde_json::json!({
7924            "head": old_final,
7925            "commits": [collapsed_commit, final_commit],
7926            "revisions": [parent_body, collapsed_body, final_body],
7927            "digest_format": TRANSCRIPT_DIGEST_FORMAT_RELEASED_0810,
7928        });
7929
7930        for invalid_format in [None, Some(1), Some(3)] {
7931            let mut invalid = released.clone();
7932            match invalid_format {
7933                Some(format) => {
7934                    invalid["metadata"][SESSION_TRANSCRIPT_HISTORY_STATE_KEY]["digest_format"] =
7935                        serde_json::json!(format);
7936                }
7937                None => {
7938                    invalid["metadata"][SESSION_TRANSCRIPT_HISTORY_STATE_KEY]
7939                        .as_object_mut()
7940                        .expect("history object")
7941                        .remove("digest_format");
7942                }
7943            }
7944            assert!(
7945                import_released_0810_session(
7946                    &serde_json::to_vec(&invalid).expect("invalid released bytes")
7947                )
7948                .is_err(),
7949                "released history digest format {invalid_format:?} must fail closed"
7950            );
7951        }
7952
7953        let mut retired_stamp = released.clone();
7954        retired_stamp["metadata"]["session_checkpoint_stamp_v1"] =
7955            serde_json::json!("untrusted-retired-metadata");
7956        let retired_stamp_import = import_released_0810_session(
7957            &serde_json::to_vec(&retired_stamp).expect("released fixture with retired stamp"),
7958        )
7959        .expect("retired checkpoint metadata does not claim physical authority");
7960        assert_eq!(
7961            retired_stamp_import.receipt().evidence(),
7962            Released0810ImportEvidence::StoreAuthorizationRequired
7963        );
7964        assert!(
7965            !retired_stamp_import
7966                .session()
7967                .metadata()
7968                .contains_key("session_checkpoint_stamp_v1")
7969        );
7970
7971        let mut all_collapsed = released.clone();
7972        all_collapsed["messages"] =
7973            serde_json::to_value(&collapsed_messages).expect("collapsed live messages");
7974        all_collapsed["metadata"][SESSION_TRANSCRIPT_HISTORY_STATE_KEY]["head"] =
7975            serde_json::Value::String(old_collapsed);
7976        all_collapsed["metadata"][SESSION_TRANSCRIPT_HISTORY_STATE_KEY]["commits"]
7977            .as_array_mut()
7978            .expect("released commits")
7979            .truncate(1);
7980        all_collapsed["metadata"][SESSION_TRANSCRIPT_HISTORY_STATE_KEY]["revisions"]
7981            .as_array_mut()
7982            .expect("released bodies")
7983            .truncate(2);
7984        let all_collapsed = import_released_0810_session(
7985            &serde_json::to_vec(&all_collapsed).expect("all-collapsed fixture"),
7986        )
7987        .expect("fully collapsed released graph imports");
7988        assert!(
7989            all_collapsed
7990                .session()
7991                .transcript_history_state()
7992                .expect("current history query")
7993                .is_none(),
7994            "a graph containing only semantic no-ops disappears at migration"
7995        );
7996
7997        let imported = import_released_0810_session(
7998            &serde_json::to_vec(&released).expect("released collapse fixture"),
7999        )
8000        .expect("source-proven collapsed rewrite imports");
8001        let state = imported
8002            .session()
8003            .transcript_history_state()
8004            .expect("current graph decodes")
8005            .expect("retained tail keeps one graph");
8006        assert_eq!(state.commit_count(), 1);
8007        assert_eq!(
8008            state.commit(0).expect("retained tail").rewrite_generation,
8009            1,
8010            "collapsed occurrences do not consume current generations"
8011        );
8012        assert_eq!(
8013            state
8014                .materialize_revision_bodies()
8015                .expect("current bodies materialize")
8016                .len(),
8017            2,
8018            "collapsed body identity is deduplicated"
8019        );
8020        assert_eq!(
8021            state.commit(0).expect("retained tail").parent_revision,
8022            transcript_messages_digest(&parent_messages).expect("current parent digest")
8023        );
8024        assert_eq!(
8025            state.head(),
8026            transcript_messages_digest(&final_messages).expect("current final digest")
8027        );
8028    }
8029
8030    #[test]
8031    fn transcript_history_rejects_stale_branch_after_digest_recurrence() {
8032        let mut restored = Session::new();
8033        restored.push(Message::User(UserMessage::text("A".to_string())));
8034        restored
8035            .commit_transcript_rewrite(
8036                TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
8037                vec![Message::User(UserMessage::text("B".to_string()))],
8038                TranscriptRewriteReason::new("to-b"),
8039                Some("unit-test".to_string()),
8040                None,
8041            )
8042            .expect("A to B");
8043        let mut stale_branch = restored.clone();
8044        restored
8045            .commit_transcript_rewrite(
8046                TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
8047                vec![Message::User(UserMessage::text("A".to_string()))],
8048                TranscriptRewriteReason::new("restore-a"),
8049                Some("unit-test".to_string()),
8050                None,
8051            )
8052            .expect("B back to A");
8053        stale_branch
8054            .commit_transcript_rewrite(
8055                TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
8056                vec![Message::User(UserMessage::text("C".to_string()))],
8057                TranscriptRewriteReason::new("stale-b-to-c"),
8058                Some("unit-test".to_string()),
8059                None,
8060            )
8061            .expect("stale B to C is locally valid");
8062
8063        let stale_state = stale_branch
8064            .transcript_history_state()
8065            .expect("stale state")
8066            .expect("stale history");
8067        let restored_state = restored
8068            .transcript_history_state()
8069            .expect("restored state")
8070            .expect("restored history");
8071        let mut records = (0..restored_state.commit_count())
8072            .map(|index| rewrite_record_at(&restored_state, index))
8073            .collect::<Vec<_>>();
8074        let mut stale_record = rewrite_record_at(&stale_state, 1);
8075        stale_record.commit.rewrite_generation = 3;
8076        records.push(stale_record);
8077
8078        assert!(
8079            TranscriptHistoryState::from_rewrite_records(records).is_err(),
8080            "an old B<-A body edge cannot authorize stale B->C after B->A restored A"
8081        );
8082    }
8083
8084    #[test]
8085    fn zero_generation_0_8_10_cycle_normalizes_from_proved_vector_order() {
8086        let mut session = Session::new();
8087        session.push(Message::User(UserMessage::text("A".to_string())));
8088        session
8089            .commit_transcript_rewrite(
8090                TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
8091                vec![Message::User(UserMessage::text("B".to_string()))],
8092                TranscriptRewriteReason::new("to-b"),
8093                Some("unit-test".to_string()),
8094                None,
8095            )
8096            .expect("A to B");
8097        session
8098            .commit_transcript_rewrite(
8099                TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
8100                vec![Message::User(UserMessage::text("A".to_string()))],
8101                TranscriptRewriteReason::new("restore-a"),
8102                Some("unit-test".to_string()),
8103                None,
8104            )
8105            .expect("B back to A");
8106
8107        let current = session
8108            .transcript_history_state()
8109            .expect("current history decodes")
8110            .expect("current history exists");
8111        let released = released_0810_document(
8112            &session,
8113            current.head().to_string(),
8114            current
8115                .materialize_revision_bodies()
8116                .expect("released bodies should materialize"),
8117        );
8118        let released = serde_json::to_vec(&released).expect("released document bytes");
8119        let imported =
8120            import_released_0810_session(&released).expect("0.8.10 cyclic graph remains supported");
8121        assert_eq!(
8122            imported.receipt().evidence(),
8123            Released0810ImportEvidence::StoreAuthorizationRequired
8124        );
8125        let state = imported
8126            .session()
8127            .transcript_history_state()
8128            .expect("history decodes")
8129            .expect("history exists");
8130        assert_eq!(
8131            state
8132                .commits()
8133                .map(|commit| commit.rewrite_generation)
8134                .collect::<Vec<_>>(),
8135            vec![1, 2],
8136            "content recurrence must not rotate or refuse the proved commit-vector order"
8137        );
8138        validate_transcript_history_state(&state).expect("normalized cycle remains valid");
8139    }
8140
8141    #[test]
8142    fn transcript_history_rejects_cyclic_edge_base() {
8143        let mut session = Session::new();
8144        session.push(Message::User(UserMessage::text("P".to_string())));
8145        session
8146            .commit_transcript_rewrite(
8147                TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
8148                vec![Message::User(UserMessage::text("Q".to_string()))],
8149                TranscriptRewriteReason::new("valid"),
8150                Some("unit-test".to_string()),
8151                None,
8152            )
8153            .expect("valid seed rewrite");
8154        let state = session
8155            .transcript_history_state()
8156            .expect("state")
8157            .expect("history");
8158        let mut state = serde_json::to_value(&state).expect("current history value");
8159        let child_revision = state["edges"][0]["commit"]["revision"]
8160            .as_str()
8161            .expect("edge child revision")
8162            .to_string();
8163        state["edges"][0]["base_revision"] = serde_json::Value::String(child_revision);
8164        session.set_metadata_unchecked_for_test(SESSION_TRANSCRIPT_HISTORY_STATE_KEY, state);
8165
8166        assert!(
8167            serde_json::to_vec(&session).is_err(),
8168            "a cyclic compact-edge base must fail instead of looping"
8169        );
8170    }
8171
8172    #[test]
8173    fn live_append_can_recur_to_an_audited_digest_without_moving_audited_head() {
8174        let a = Message::User(UserMessage::text("A".to_string()));
8175        let b = Message::User(UserMessage::text("B".to_string()));
8176        let mut session = Session::new();
8177        session.push(Message::User(UserMessage::text("X".to_string())));
8178        let first = session
8179            .commit_transcript_rewrite(
8180                TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
8181                vec![a.clone(), b.clone()],
8182                TranscriptRewriteReason::new("to-a-b"),
8183                Some("unit-test".to_string()),
8184                None,
8185            )
8186            .expect("X to [A,B]");
8187        let h_parent = session
8188            .transcript_revision_body(&first.revision)
8189            .expect("H body")
8190            .expect("H retained")
8191            .parent_revision;
8192        let second = session
8193            .commit_transcript_rewrite(
8194                TranscriptRewriteSelection::MessageRange { start: 0, end: 2 },
8195                vec![a],
8196                TranscriptRewriteReason::new("to-a"),
8197                Some("unit-test".to_string()),
8198                None,
8199            )
8200            .expect("[A,B] to [A]");
8201
8202        session.push(b);
8203
8204        let state = session
8205            .transcript_history_state()
8206            .expect("state")
8207            .expect("history");
8208        assert_eq!(
8209            state.head(),
8210            second.revision,
8211            "graph head remains the latest audited endpoint"
8212        );
8213        assert_eq!(session.transcript_revision().unwrap(), first.revision);
8214        let recurred_body = state
8215            .materialize_revision(&first.revision)
8216            .expect("recurred H body");
8217        assert_eq!(
8218            recurred_body.parent_revision, h_parent,
8219            "reusing an audited digest must not rewrite its occurrence metadata"
8220        );
8221        validate_transcript_history_state(&state).expect("audited graph remains valid");
8222    }
8223
8224    /// K4 invariant (fail-closed): an invalid replacement is rejected with a
8225    /// typed fault BEFORE any strip happens — the transcript is unchanged, so
8226    /// a fault can never strand a half-refreshed notice state.
8227    #[test]
8228    fn replace_synthetic_notices_rejects_mismatched_kind_without_mutation() {
8229        use crate::types::{SystemNoticeKind, SystemNoticeMessage};
8230
8231        let mut session = Session::new();
8232        session.push(Message::SystemNotice(SystemNoticeMessage::new(
8233            SystemNoticeKind::McpPending,
8234            "stale",
8235        )));
8236        let before = session.messages().to_vec();
8237
8238        let err = session
8239            .replace_synthetic_notices(
8240                SystemNoticeKind::McpPending,
8241                vec![Message::User(UserMessage::text("not a notice".to_string()))],
8242            )
8243            .expect_err("mismatched replacement must fail typed");
8244        assert!(
8245            matches!(err, TranscriptEditError::InvalidTranscriptShape(_)),
8246            "expected InvalidTranscriptShape, got {err:?}"
8247        );
8248        assert_eq!(
8249            session.messages(),
8250            before.as_slice(),
8251            "fault must leave the transcript unchanged (no partial strip)"
8252        );
8253    }
8254
8255    #[test]
8256    fn replace_synthetic_notices_rejects_malformed_history_atomically() {
8257        use crate::types::{SystemNoticeKind, SystemNoticeMessage};
8258
8259        let mut session = Session::new();
8260        session.push(Message::User(UserMessage::text("before".to_string())));
8261        session
8262            .commit_transcript_rewrite(
8263                TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
8264                vec![Message::User(UserMessage::text("after".to_string()))],
8265                TranscriptRewriteReason::new("unit-test-edit"),
8266                Some("unit-test".to_string()),
8267                None,
8268            )
8269            .expect("seed rewrite");
8270        session.push(Message::SystemNotice(SystemNoticeMessage::new(
8271            SystemNoticeKind::McpPending,
8272            "stale",
8273        )));
8274        let state = session
8275            .transcript_history_state()
8276            .expect("state")
8277            .expect("history");
8278        let mut state = serde_json::to_value(&state).expect("current history value");
8279        state["anchor"]["messages"][0] =
8280            serde_json::to_value(Message::User(UserMessage::text("tampered".to_string())))
8281                .expect("tampered message");
8282        session.set_metadata_unchecked_for_test(SESSION_TRANSCRIPT_HISTORY_STATE_KEY, state);
8283        let before_messages = session.messages.clone();
8284        let before_metadata = session.metadata.clone();
8285        let before_updated_at = session.updated_at;
8286
8287        assert!(
8288            session
8289                .replace_synthetic_notices(SystemNoticeKind::McpPending, Vec::new())
8290                .is_err()
8291        );
8292        assert_eq!(session.messages(), before_messages.as_slice());
8293        assert_eq!(session.metadata, before_metadata);
8294        assert_eq!(session.updated_at, before_updated_at);
8295    }
8296
8297    #[test]
8298    fn replace_synthetic_notices_rejects_durable_notice_kinds() {
8299        use crate::types::SystemNoticeKind;
8300
8301        let mut session = Session::new();
8302        let before = session.messages().to_vec();
8303        assert!(
8304            session
8305                .replace_synthetic_notices(SystemNoticeKind::Comms, Vec::new())
8306                .is_err()
8307        );
8308        assert_eq!(session.messages(), before);
8309    }
8310
8311    #[test]
8312    fn replace_synthetic_notices_preserves_persisted_mcp_pending_notice() {
8313        use crate::types::{SystemNoticeBlock, SystemNoticeKind, SystemNoticeMessage};
8314
8315        let mut session = Session::new();
8316        session.push(Message::SystemNotice(SystemNoticeMessage::with_block(
8317            SystemNoticeKind::McpPending,
8318            Some("persisted pending fact".to_string()),
8319            SystemNoticeBlock::Mcp {
8320                server_id: Some("server".to_string()),
8321                operation: None,
8322                phase: None,
8323                persisted: true,
8324                detail: None,
8325                pending_sources: Vec::new(),
8326            },
8327        )));
8328        let before = session.messages().to_vec();
8329
8330        session
8331            .replace_synthetic_notices(SystemNoticeKind::McpPending, Vec::new())
8332            .expect("synthetic refresh must coexist with a durable notice of the same kind");
8333        assert_eq!(session.messages(), before);
8334    }
8335
8336    #[test]
8337    fn replace_synthetic_notices_replaces_projection_beside_persisted_mcp_fact() {
8338        use crate::types::{SystemNoticeBlock, SystemNoticeKind, SystemNoticeMessage};
8339
8340        let durable = Message::SystemNotice(SystemNoticeMessage::with_block(
8341            SystemNoticeKind::McpPending,
8342            Some("persisted pending fact".to_string()),
8343            SystemNoticeBlock::Mcp {
8344                server_id: Some("server".to_string()),
8345                operation: None,
8346                phase: None,
8347                persisted: true,
8348                detail: None,
8349                pending_sources: Vec::new(),
8350            },
8351        ));
8352        let stale = Message::SystemNotice(SystemNoticeMessage::new(
8353            SystemNoticeKind::McpPending,
8354            "stale synthetic projection",
8355        ));
8356        let fresh = Message::SystemNotice(SystemNoticeMessage::new(
8357            SystemNoticeKind::McpPending,
8358            "fresh synthetic projection",
8359        ));
8360        let mut session = Session::new();
8361        session.push(durable.clone());
8362        session.push(stale);
8363
8364        session
8365            .replace_synthetic_notices(SystemNoticeKind::McpPending, vec![fresh.clone()])
8366            .expect("synthetic refresh beside durable fact");
8367
8368        assert_eq!(session.messages(), &[durable, fresh]);
8369    }
8370
8371    #[test]
8372    fn transcript_rewrite_preserves_full_assistant_block_trace() {
8373        let mut session = Session::new();
8374        session.push(Message::User(UserMessage::text(
8375            "run the trace".to_string(),
8376        )));
8377        session.push(Message::BlockAssistant(BlockAssistantMessage::new(
8378            vec![AssistantBlock::Text {
8379                text: "original assistant trace".to_string(),
8380                meta: None,
8381            }],
8382            StopReason::EndTurn,
8383        )));
8384
8385        let parent_revision = session.transcript_revision().expect("parent revision");
8386        let replacement = vec![
8387            Message::BlockAssistant(BlockAssistantMessage::new(
8388                vec![
8389                    AssistantBlock::Text {
8390                        text: "compacted assistant trace".to_string(),
8391                        meta: None,
8392                    },
8393                    AssistantBlock::ToolUse {
8394                        id: "toolu_trace".to_string(),
8395                        name: "trace_probe".to_string(),
8396                        args: serde_json::value::RawValue::from_string(
8397                            r#"{"path":"N-3"}"#.to_string(),
8398                        )
8399                        .expect("valid tool args"),
8400                        meta: None,
8401                    },
8402                ],
8403                StopReason::ToolUse,
8404            )),
8405            Message::tool_results(vec![ToolResult::new(
8406                "toolu_trace".to_string(),
8407                "trace complete".to_string(),
8408                false,
8409            )]),
8410        ];
8411
8412        let commit = session
8413            .commit_transcript_rewrite(
8414                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
8415                replacement,
8416                TranscriptRewriteReason::new("compaction"),
8417                Some("unit-test".to_string()),
8418                Some(parent_revision.clone()),
8419            )
8420            .expect("rewrite should commit");
8421
8422        assert_eq!(commit.parent_revision, parent_revision);
8423        let current = session
8424            .transcript_revision_messages(&commit.revision)
8425            .expect("history state should decode")
8426            .expect("current revision should be retained");
8427        let Message::BlockAssistant(assistant) = &current[1] else {
8428            panic!("replacement should remain a block assistant message");
8429        };
8430        assert!(assistant.blocks.iter().any(|block| matches!(
8431            block,
8432            AssistantBlock::ToolUse { name, args, .. }
8433                if name == "trace_probe" && args.get().contains("\"N-3\"")
8434        )));
8435
8436        let parent = session
8437            .transcript_revision_messages(&parent_revision)
8438            .expect("history state should decode")
8439            .expect("parent revision should remain retained");
8440        assert!(matches!(
8441            &parent[1],
8442            Message::BlockAssistant(assistant)
8443                if block_assistant_text(assistant).contains("original assistant trace")
8444        ));
8445    }
8446
8447    #[test]
8448    fn transcript_rewrite_rejects_trailing_block_assistant_tool_call() {
8449        let mut session = Session::new();
8450        session.push(Message::User(UserMessage::text("question".to_string())));
8451        session.push(Message::BlockAssistant(BlockAssistantMessage {
8452            blocks: vec![AssistantBlock::Text {
8453                text: "plain answer".to_string(),
8454                meta: None,
8455            }],
8456            stop_reason: StopReason::EndTurn,
8457            identity: crate::types::TranscriptMessageIdentity::default(),
8458            created_at: crate::types::message_timestamp_now(),
8459        }));
8460        let parent_revision = session.transcript_revision().expect("parent revision");
8461
8462        let err = session
8463            .commit_transcript_rewrite(
8464                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
8465                vec![Message::BlockAssistant(BlockAssistantMessage::new(
8466                    vec![AssistantBlock::ToolUse {
8467                        id: "toolu_1".to_string(),
8468                        name: "lookup".to_string(),
8469                        args: serde_json::value::RawValue::from_string("{}".to_string())
8470                            .expect("valid args"),
8471                        meta: None,
8472                    }],
8473                    StopReason::ToolUse,
8474                ))],
8475                TranscriptRewriteReason::new("compaction"),
8476                Some("unit-test".to_string()),
8477                Some(parent_revision),
8478            )
8479            .expect_err("rewrite should reject trailing unresolved block-assistant tool call");
8480        assert!(matches!(
8481            err,
8482            TranscriptEditError::InvalidTranscriptShape(_)
8483        ));
8484    }
8485
8486    #[test]
8487    fn transcript_rewrite_rejects_no_op_self_edge() {
8488        let mut session = Session::new();
8489        session.push(Message::User(UserMessage::text(
8490            "keep this exact transcript".to_string(),
8491        )));
8492        session.push(Message::BlockAssistant(BlockAssistantMessage {
8493            blocks: vec![AssistantBlock::Text {
8494                text: "unchanged".to_string(),
8495                meta: None,
8496            }],
8497            stop_reason: StopReason::EndTurn,
8498            identity: crate::types::TranscriptMessageIdentity::default(),
8499            created_at: crate::types::message_timestamp_now(),
8500        }));
8501
8502        let parent_revision = session.transcript_revision().expect("parent revision");
8503        let err = session
8504            .commit_transcript_rewrite(
8505                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
8506                vec![session.messages()[1].clone()],
8507                TranscriptRewriteReason::new("retry"),
8508                Some("unit-test".to_string()),
8509                Some(parent_revision.clone()),
8510            )
8511            .expect_err("same-content rewrite should not emit a self-edge commit");
8512
8513        assert!(matches!(
8514            err,
8515            TranscriptEditError::NoOpRewrite { revision } if revision == parent_revision
8516        ));
8517        assert!(
8518            session
8519                .transcript_history_state()
8520                .expect("history state should decode")
8521                .is_none()
8522        );
8523    }
8524
8525    #[test]
8526    fn transcript_rewrite_run_boundary_guard_accepts_rewrite_then_append() {
8527        let mut original = Session::new();
8528        original.push(Message::User(UserMessage::text("question".to_string())));
8529        original.push(Message::BlockAssistant(BlockAssistantMessage {
8530            blocks: vec![AssistantBlock::Text {
8531                text: "verbose answer".to_string(),
8532                meta: None,
8533            }],
8534            stop_reason: StopReason::EndTurn,
8535            identity: crate::types::TranscriptMessageIdentity::default(),
8536            created_at: crate::types::message_timestamp_now(),
8537        }));
8538
8539        let parent_revision = original.transcript_revision().expect("parent revision");
8540        let mut incoming = original.clone();
8541        incoming
8542            .commit_transcript_rewrite(
8543                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
8544                vec![Message::BlockAssistant(BlockAssistantMessage {
8545                    blocks: vec![AssistantBlock::Text {
8546                        text: "compact answer".to_string(),
8547                        meta: None,
8548                    }],
8549                    stop_reason: StopReason::EndTurn,
8550                    identity: crate::types::TranscriptMessageIdentity::default(),
8551                    created_at: crate::types::message_timestamp_now(),
8552                })],
8553                TranscriptRewriteReason::new("compaction"),
8554                Some("unit-test".to_string()),
8555                Some(parent_revision),
8556            )
8557            .expect("rewrite should commit");
8558        incoming.push(Message::User(UserMessage::text("follow-up".to_string())));
8559        incoming.push(Message::BlockAssistant(BlockAssistantMessage {
8560            blocks: vec![AssistantBlock::Text {
8561                text: "follow-up answer".to_string(),
8562                meta: None,
8563            }],
8564            stop_reason: StopReason::EndTurn,
8565            identity: crate::types::TranscriptMessageIdentity::default(),
8566            created_at: crate::types::message_timestamp_now(),
8567        }));
8568
8569        crate::session_store::run_boundary_snapshot_save_guard(&incoming, Some(&original))
8570            .expect("rewrite plus appended turn should be a valid run-boundary commit");
8571    }
8572
8573    #[test]
8574    fn transcript_rewrite_rejects_orphaned_tool_results() {
8575        let mut session = Session::new();
8576        session.push(Message::User(UserMessage::text("use a tool".to_string())));
8577        session.push(Message::BlockAssistant(BlockAssistantMessage::new(
8578            vec![AssistantBlock::ToolUse {
8579                id: "toolu_1".to_string(),
8580                name: "lookup".to_string(),
8581                args: serde_json::value::RawValue::from_string("{}".to_string())
8582                    .expect("valid args"),
8583                meta: None,
8584            }],
8585            StopReason::ToolUse,
8586        )));
8587        session.push(Message::tool_results(vec![ToolResult::new(
8588            "toolu_1".to_string(),
8589            "done".to_string(),
8590            false,
8591        )]));
8592        let parent_revision = session.transcript_revision().expect("parent revision");
8593
8594        let err = session
8595            .commit_transcript_rewrite(
8596                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
8597                vec![Message::BlockAssistant(BlockAssistantMessage {
8598                    blocks: vec![AssistantBlock::Text {
8599                        text: "no tool after all".to_string(),
8600                        meta: None,
8601                    }],
8602                    stop_reason: StopReason::EndTurn,
8603                    identity: crate::types::TranscriptMessageIdentity::default(),
8604                    created_at: crate::types::message_timestamp_now(),
8605                })],
8606                TranscriptRewriteReason::new("compaction"),
8607                Some("unit-test".to_string()),
8608                Some(parent_revision),
8609            )
8610            .expect_err("rewrite should reject stranded tool results");
8611        assert!(matches!(
8612            err,
8613            TranscriptEditError::InvalidTranscriptShape(_)
8614        ));
8615    }
8616
8617    #[test]
8618    fn transcript_rewrite_rejects_trailing_assistant_tool_call() {
8619        let mut session = Session::new();
8620        session.push(Message::User(UserMessage::text("question".to_string())));
8621        session.push(Message::BlockAssistant(BlockAssistantMessage {
8622            blocks: vec![AssistantBlock::Text {
8623                text: "plain answer".to_string(),
8624                meta: None,
8625            }],
8626            stop_reason: StopReason::EndTurn,
8627            identity: crate::types::TranscriptMessageIdentity::default(),
8628            created_at: crate::types::message_timestamp_now(),
8629        }));
8630        let parent_revision = session.transcript_revision().expect("parent revision");
8631
8632        let err = session
8633            .commit_transcript_rewrite(
8634                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
8635                vec![Message::BlockAssistant(BlockAssistantMessage {
8636                    blocks: vec![AssistantBlock::ToolUse {
8637                        id: "toolu_1".to_string(),
8638                        name: "lookup".to_string(),
8639                        args: serde_json::value::RawValue::from_string("{}".to_string())
8640                            .expect("valid args"),
8641                        meta: None,
8642                    }],
8643                    stop_reason: StopReason::ToolUse,
8644                    identity: crate::types::TranscriptMessageIdentity::default(),
8645                    created_at: crate::types::message_timestamp_now(),
8646                })],
8647                TranscriptRewriteReason::new("compaction"),
8648                Some("unit-test".to_string()),
8649                Some(parent_revision),
8650            )
8651            .expect_err("rewrite should reject trailing unresolved tool call");
8652        assert!(matches!(
8653            err,
8654            TranscriptEditError::InvalidTranscriptShape(_)
8655        ));
8656    }
8657
8658    #[test]
8659    fn transcript_rewrite_rejects_duplicate_tool_results() {
8660        let mut session = Session::new();
8661        session.push(Message::User(UserMessage::text("use a tool".to_string())));
8662        session.push(Message::BlockAssistant(BlockAssistantMessage {
8663            blocks: vec![AssistantBlock::Text {
8664                text: "plain answer".to_string(),
8665                meta: None,
8666            }],
8667            stop_reason: StopReason::EndTurn,
8668            identity: crate::types::TranscriptMessageIdentity::default(),
8669            created_at: crate::types::message_timestamp_now(),
8670        }));
8671        let parent_revision = session.transcript_revision().expect("parent revision");
8672
8673        let err = session
8674            .commit_transcript_rewrite(
8675                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
8676                vec![
8677                    Message::BlockAssistant(BlockAssistantMessage::new(
8678                        vec![AssistantBlock::ToolUse {
8679                            id: "toolu_1".to_string(),
8680                            name: "lookup".to_string(),
8681                            args: serde_json::value::RawValue::from_string("{}".to_string())
8682                                .expect("valid args"),
8683                            meta: None,
8684                        }],
8685                        StopReason::ToolUse,
8686                    )),
8687                    Message::tool_results(vec![
8688                        ToolResult::new("toolu_1".to_string(), "one".to_string(), false),
8689                        ToolResult::new("toolu_1".to_string(), "two".to_string(), false),
8690                    ]),
8691                ],
8692                TranscriptRewriteReason::new("compaction"),
8693                Some("unit-test".to_string()),
8694                Some(parent_revision),
8695            )
8696            .expect_err("rewrite should reject duplicate tool results");
8697        assert!(matches!(
8698            err,
8699            TranscriptEditError::InvalidTranscriptShape(_)
8700        ));
8701    }
8702
8703    #[test]
8704    fn transcript_rewrite_record_rejects_prefix_or_suffix_tampering() {
8705        let mut session = Session::new();
8706        session.push(Message::System(SystemMessage::new("keep prefix")));
8707        session.push(Message::BlockAssistant(BlockAssistantMessage {
8708            blocks: vec![AssistantBlock::Text {
8709                text: "verbose answer".to_string(),
8710                meta: None,
8711            }],
8712            stop_reason: StopReason::EndTurn,
8713            identity: crate::types::TranscriptMessageIdentity::default(),
8714            created_at: crate::types::message_timestamp_now(),
8715        }));
8716        session.push(Message::User(UserMessage::text("keep suffix".to_string())));
8717
8718        let parent_revision = session.transcript_revision().expect("parent revision");
8719        let commit = session
8720            .commit_transcript_rewrite(
8721                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
8722                vec![Message::BlockAssistant(BlockAssistantMessage {
8723                    blocks: vec![AssistantBlock::Text {
8724                        text: "compact answer".to_string(),
8725                        meta: None,
8726                    }],
8727                    stop_reason: StopReason::EndTurn,
8728                    identity: crate::types::TranscriptMessageIdentity::default(),
8729                    created_at: crate::types::message_timestamp_now(),
8730                })],
8731                TranscriptRewriteReason::new("compaction"),
8732                Some("unit-test".to_string()),
8733                Some(parent_revision),
8734            )
8735            .expect("rewrite should commit");
8736        let state = session
8737            .transcript_history_state()
8738            .expect("history state should decode")
8739            .expect("history state should exist");
8740        let record = rewrite_record_at(&state, 0);
8741        let parent_body = record.parent_body;
8742        let revision_body = record.revision_body;
8743
8744        let mut forged_body = revision_body;
8745        forged_body.messages[0] = Message::System(SystemMessage::new("tampered prefix"));
8746        forged_body.revision =
8747            transcript_messages_digest(&forged_body.messages).expect("forged digest");
8748        let mut forged_commit = commit;
8749        forged_commit.revision = forged_body.revision.clone();
8750        let err = TranscriptRewriteRecord::new(forged_commit, parent_body, forged_body)
8751            .expect_err("record validation must reject changes outside selected span");
8752        assert!(
8753            err.to_string().contains("before the selected span"),
8754            "unexpected error: {err}"
8755        );
8756    }
8757
8758    #[test]
8759    fn transcript_rewrite_replay_allows_normal_turn_revisions_between_rewrites() {
8760        let mut session = Session::new();
8761        session.push(Message::User(UserMessage::text("first".to_string())));
8762        session.push(Message::BlockAssistant(BlockAssistantMessage {
8763            blocks: vec![AssistantBlock::Text {
8764                text: "verbose first answer".to_string(),
8765                meta: None,
8766            }],
8767            stop_reason: StopReason::EndTurn,
8768            identity: crate::types::TranscriptMessageIdentity::default(),
8769            created_at: crate::types::message_timestamp_now(),
8770        }));
8771
8772        let first_parent = session.transcript_revision().expect("first parent");
8773        let first_commit = session
8774            .commit_transcript_rewrite(
8775                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
8776                vec![Message::BlockAssistant(BlockAssistantMessage {
8777                    blocks: vec![AssistantBlock::Text {
8778                        text: "compact first answer".to_string(),
8779                        meta: None,
8780                    }],
8781                    stop_reason: StopReason::EndTurn,
8782                    identity: crate::types::TranscriptMessageIdentity::default(),
8783                    created_at: crate::types::message_timestamp_now(),
8784                })],
8785                TranscriptRewriteReason::new("compaction"),
8786                Some("unit-test".to_string()),
8787                Some(first_parent),
8788            )
8789            .expect("first rewrite");
8790
8791        session.push(Message::User(UserMessage::text("normal turn".to_string())));
8792        session.push(Message::BlockAssistant(BlockAssistantMessage {
8793            blocks: vec![AssistantBlock::Text {
8794                text: "verbose second answer".to_string(),
8795                meta: None,
8796            }],
8797            stop_reason: StopReason::EndTurn,
8798            identity: crate::types::TranscriptMessageIdentity::default(),
8799            created_at: crate::types::message_timestamp_now(),
8800        }));
8801        let bridge_parent = session
8802            .transcript_revision()
8803            .expect("normal turn should advance transcript head");
8804        assert_ne!(bridge_parent, first_commit.revision);
8805        validate_transcript_history_state(
8806            &session
8807                .transcript_history_state()
8808                .expect("history state should decode")
8809                .expect("history state should exist"),
8810        )
8811        .expect("normal turn head may legitimately differ from last rewrite commit");
8812
8813        let second_commit = session
8814            .commit_transcript_rewrite(
8815                TranscriptRewriteSelection::MessageRange { start: 3, end: 4 },
8816                vec![Message::BlockAssistant(BlockAssistantMessage {
8817                    blocks: vec![AssistantBlock::Text {
8818                        text: "compact second answer".to_string(),
8819                        meta: None,
8820                    }],
8821                    stop_reason: StopReason::EndTurn,
8822                    identity: crate::types::TranscriptMessageIdentity::default(),
8823                    created_at: crate::types::message_timestamp_now(),
8824                })],
8825                TranscriptRewriteReason::new("compaction"),
8826                Some("unit-test".to_string()),
8827                Some(bridge_parent.clone()),
8828            )
8829            .expect("second rewrite");
8830
8831        let state = session
8832            .transcript_history_state()
8833            .expect("history state should decode")
8834            .expect("history state should exist");
8835        let records =
8836            (0..state.commit_count()).map(|edge_index| rewrite_record_at(&state, edge_index));
8837
8838        let replayed = TranscriptHistoryState::from_rewrite_records(records)
8839            .expect("rewrite replay should accept normal-turn bridge revisions")
8840            .expect("rewrite records should exist");
8841        assert_eq!(replayed.head(), second_commit.revision);
8842        assert!(replayed.contains_revision(&bridge_parent));
8843    }
8844
8845    #[test]
8846    fn transcript_rewrite_replay_rejects_branched_rewrite_records() {
8847        let mut base = Session::new();
8848        base.push(Message::User(UserMessage::text("question".to_string())));
8849        base.push(Message::BlockAssistant(BlockAssistantMessage {
8850            blocks: vec![AssistantBlock::Text {
8851                text: "verbose answer".to_string(),
8852                meta: None,
8853            }],
8854            stop_reason: StopReason::EndTurn,
8855            identity: crate::types::TranscriptMessageIdentity::default(),
8856            created_at: crate::types::message_timestamp_now(),
8857        }));
8858        let parent = base.transcript_revision().expect("parent revision");
8859
8860        let mut first = base.clone();
8861        first
8862            .commit_transcript_rewrite(
8863                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
8864                vec![Message::BlockAssistant(BlockAssistantMessage {
8865                    blocks: vec![AssistantBlock::Text {
8866                        text: "first compact answer".to_string(),
8867                        meta: None,
8868                    }],
8869                    stop_reason: StopReason::EndTurn,
8870                    identity: crate::types::TranscriptMessageIdentity::default(),
8871                    created_at: crate::types::message_timestamp_now(),
8872                })],
8873                TranscriptRewriteReason::new("compaction"),
8874                Some("unit-test".to_string()),
8875                Some(parent.clone()),
8876            )
8877            .expect("first rewrite");
8878        let first_state = first
8879            .transcript_history_state()
8880            .expect("first state decodes")
8881            .expect("first state exists");
8882
8883        let mut second = base;
8884        second
8885            .commit_transcript_rewrite(
8886                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
8887                vec![Message::BlockAssistant(BlockAssistantMessage {
8888                    blocks: vec![AssistantBlock::Text {
8889                        text: "second compact answer".to_string(),
8890                        meta: None,
8891                    }],
8892                    stop_reason: StopReason::EndTurn,
8893                    identity: crate::types::TranscriptMessageIdentity::default(),
8894                    created_at: crate::types::message_timestamp_now(),
8895                })],
8896                TranscriptRewriteReason::new("compaction"),
8897                Some("unit-test".to_string()),
8898                Some(parent),
8899            )
8900            .expect("second rewrite");
8901        let second_state = second
8902            .transcript_history_state()
8903            .expect("second state decodes")
8904            .expect("second state exists");
8905
8906        let err = TranscriptHistoryState::from_rewrite_records(vec![
8907            rewrite_record_at(&first_state, 0),
8908            rewrite_record_at(&second_state, 0),
8909        ])
8910        .expect_err("branched rewrite records must not replay as a linear source history");
8911        assert!(
8912            err.to_string()
8913                .contains("not expected contiguous generation"),
8914            "unexpected error: {err}"
8915        );
8916    }
8917
8918    #[test]
8919    fn internal_message_rewrites_refresh_transcript_history_head() {
8920        let mut session = Session::new();
8921        session.push(Message::User(UserMessage::text("question".to_string())));
8922        session.push(Message::BlockAssistant(BlockAssistantMessage {
8923            blocks: vec![AssistantBlock::Text {
8924                text: "verbose answer".to_string(),
8925                meta: None,
8926            }],
8927            stop_reason: StopReason::EndTurn,
8928            identity: crate::types::TranscriptMessageIdentity::default(),
8929            created_at: crate::types::message_timestamp_now(),
8930        }));
8931
8932        let parent = session.transcript_revision().expect("parent revision");
8933        session
8934            .commit_transcript_rewrite(
8935                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
8936                vec![Message::BlockAssistant(BlockAssistantMessage {
8937                    blocks: vec![AssistantBlock::Text {
8938                        text: "compact answer".to_string(),
8939                        meta: None,
8940                    }],
8941                    stop_reason: StopReason::EndTurn,
8942                    identity: crate::types::TranscriptMessageIdentity::default(),
8943                    created_at: crate::types::message_timestamp_now(),
8944                })],
8945                TranscriptRewriteReason::new("compaction"),
8946                Some("unit-test".to_string()),
8947                Some(parent),
8948            )
8949            .expect("rewrite should commit");
8950
8951        session.push(Message::User(UserMessage::text(
8952            "notice-bearing turn".to_string(),
8953        )));
8954        let retained = session
8955            .messages()
8956            .iter()
8957            .filter(|message| {
8958                !matches!(
8959                    message,
8960                    Message::User(user)
8961                        if user.content.iter().any(|block| matches!(
8962                            block,
8963                            ContentBlock::Text { text } if text.contains("notice-bearing")
8964                        ))
8965                )
8966            })
8967            .cloned()
8968            .collect();
8969        session
8970            .replace_messages_internal(
8971                retained,
8972                TranscriptRewriteReason::new("synthetic_notice_cleanup"),
8973            )
8974            .expect("retain should commit internal rewrite");
8975        let retained_digest =
8976            transcript_messages_digest(session.messages()).expect("retained digest");
8977        assert_eq!(
8978            session.transcript_revision().expect("retained head"),
8979            retained_digest
8980        );
8981
8982        session
8983            .replace_messages_internal(
8984                vec![
8985                    Message::User(UserMessage::text("compacted question".to_string())),
8986                    Message::BlockAssistant(BlockAssistantMessage {
8987                        blocks: vec![AssistantBlock::Text {
8988                            text: "compacted answer".to_string(),
8989                            meta: None,
8990                        }],
8991                        stop_reason: StopReason::EndTurn,
8992                        identity: crate::types::TranscriptMessageIdentity::default(),
8993                        created_at: crate::types::message_timestamp_now(),
8994                    }),
8995                ],
8996                TranscriptRewriteReason::new("compaction"),
8997            )
8998            .expect("replace should commit internal rewrite");
8999        let replaced_digest =
9000            transcript_messages_digest(session.messages()).expect("replaced digest");
9001        assert_eq!(
9002            session.transcript_revision().expect("replaced head"),
9003            replaced_digest
9004        );
9005        let state = session
9006            .transcript_history_state()
9007            .expect("history state should decode")
9008            .expect("history state should exist");
9009        assert!(state.contains_revision(&replaced_digest));
9010        validate_transcript_history_state(&state).expect("history state remains valid");
9011    }
9012
9013    #[test]
9014    fn append_system_message_preserves_exact_prefix_without_rewriting_history() {
9015        let mut session = Session::new();
9016        session.push(Message::User(UserMessage::text("question".to_string())));
9017        session.push(Message::BlockAssistant(BlockAssistantMessage {
9018            blocks: vec![AssistantBlock::Text {
9019                text: "verbose answer".to_string(),
9020                meta: None,
9021            }],
9022            stop_reason: StopReason::EndTurn,
9023            identity: crate::types::TranscriptMessageIdentity::default(),
9024            created_at: crate::types::message_timestamp_now(),
9025        }));
9026
9027        let parent = session.transcript_revision().expect("parent revision");
9028        let rewrite = session
9029            .commit_transcript_rewrite(
9030                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
9031                vec![Message::BlockAssistant(BlockAssistantMessage {
9032                    blocks: vec![AssistantBlock::Text {
9033                        text: "compact answer".to_string(),
9034                        meta: None,
9035                    }],
9036                    stop_reason: StopReason::EndTurn,
9037                    identity: crate::types::TranscriptMessageIdentity::default(),
9038                    created_at: crate::types::message_timestamp_now(),
9039                })],
9040                TranscriptRewriteReason::new("compaction"),
9041                Some("unit-test".to_string()),
9042                Some(parent),
9043            )
9044            .expect("rewrite should commit");
9045        let graph_before = session
9046            .validated_transcript_history_state()
9047            .expect("history validation")
9048            .expect("audited graph");
9049        let messages_before = session.messages().to_vec();
9050
9051        session.append_system_message("durable system prompt".to_string());
9052
9053        assert_eq!(
9054            &session.messages()[..messages_before.len()],
9055            messages_before.as_slice(),
9056            "setting a System prompt must preserve every existing message as an exact prefix"
9057        );
9058        assert!(matches!(
9059            session.messages().last(),
9060            Some(Message::System(system)) if system.content == "durable system prompt"
9061        ));
9062        let head = session
9063            .transcript_revision()
9064            .expect("live system prompt digest");
9065        assert_ne!(head, rewrite.revision);
9066        assert_eq!(
9067            head,
9068            transcript_messages_digest(session.messages()).expect("current digest")
9069        );
9070        let graph_after = session
9071            .validated_transcript_history_state()
9072            .expect("history validation")
9073            .expect("audited graph");
9074        assert!(
9075            graph_before.shares_exact_state_with(&graph_after),
9076            "mechanical prompt mutation must preserve the exact audited graph authority"
9077        );
9078        assert!(
9079            session
9080                .transcript_revision_messages(&head)
9081                .expect("history state should decode")
9082                .is_none(),
9083            "live message digests are not retained graph revisions"
9084        );
9085        let state = session
9086            .transcript_history_state()
9087            .expect("history state should decode")
9088            .expect("history state should exist");
9089        assert_eq!(state.head(), rewrite.revision);
9090        validate_transcript_history_state(&state).expect("audited graph remains valid");
9091    }
9092
9093    #[test]
9094    fn apply_transcript_history_state_uses_latest_commit_time_for_restored_head() {
9095        let mut session = Session::new();
9096        session.push(Message::User(UserMessage::text("question".to_string())));
9097        session.push(Message::BlockAssistant(BlockAssistantMessage {
9098            blocks: vec![AssistantBlock::Text {
9099                text: "verbose answer".to_string(),
9100                meta: None,
9101            }],
9102            stop_reason: StopReason::EndTurn,
9103            identity: crate::types::TranscriptMessageIdentity::default(),
9104            created_at: crate::types::message_timestamp_now(),
9105        }));
9106        let original_messages = session.messages().to_vec();
9107        let parent = session.transcript_revision().expect("parent revision");
9108        let compact = session
9109            .commit_transcript_rewrite(
9110                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
9111                vec![Message::BlockAssistant(BlockAssistantMessage {
9112                    blocks: vec![AssistantBlock::Text {
9113                        text: "compact answer".to_string(),
9114                        meta: None,
9115                    }],
9116                    stop_reason: StopReason::EndTurn,
9117                    identity: crate::types::TranscriptMessageIdentity::default(),
9118                    created_at: crate::types::message_timestamp_now(),
9119                })],
9120                TranscriptRewriteReason::new("compaction"),
9121                Some("unit-test".to_string()),
9122                Some(parent.clone()),
9123            )
9124            .expect("rewrite should commit");
9125
9126        let restore = session
9127            .commit_transcript_rewrite(
9128                TranscriptRewriteSelection::MessageRange {
9129                    start: 0,
9130                    end: session.messages().len(),
9131                },
9132                original_messages.clone(),
9133                TranscriptRewriteReason::new("restore"),
9134                Some("unit-test".to_string()),
9135                Some(compact.revision),
9136            )
9137            .expect("restore should commit");
9138        assert_eq!(restore.revision, parent);
9139
9140        let state = session
9141            .transcript_history_state()
9142            .expect("history state should decode")
9143            .expect("history state should exist");
9144        let restored_body_created_at = state
9145            .materialize_revision(&restore.revision)
9146            .expect("restored body should be retained")
9147            .created_at;
9148        assert_eq!(
9149            restored_body_created_at, restore.committed_at,
9150            "restoring a repeated revision selects its latest occurrence timestamp"
9151        );
9152
9153        let mut replayed = Session::new();
9154        replayed
9155            .apply_transcript_history_state(state)
9156            .expect("replay should materialize restored head");
9157        assert_eq!(
9158            serde_json::to_value(replayed.messages()).expect("replayed serializes"),
9159            serde_json::to_value(&original_messages).expect("original serializes")
9160        );
9161        assert_eq!(replayed.updated_at(), restore.committed_at);
9162    }
9163
9164    #[test]
9165    fn validated_bridge_parent_materialization_preserves_its_selected_head() {
9166        let mut session = Session::new();
9167        session.push(Message::User(UserMessage::text("question".to_string())));
9168        session.push(Message::BlockAssistant(BlockAssistantMessage {
9169            blocks: vec![AssistantBlock::Text {
9170                text: "verbose answer".to_string(),
9171                meta: None,
9172            }],
9173            stop_reason: StopReason::EndTurn,
9174            identity: crate::types::TranscriptMessageIdentity::default(),
9175            created_at: crate::types::message_timestamp_now(),
9176        }));
9177
9178        let first_parent = session.transcript_revision().expect("first parent");
9179        let _first = session
9180            .commit_transcript_rewrite(
9181                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
9182                vec![Message::BlockAssistant(BlockAssistantMessage {
9183                    blocks: vec![AssistantBlock::Text {
9184                        text: "compact answer".to_string(),
9185                        meta: None,
9186                    }],
9187                    stop_reason: StopReason::EndTurn,
9188                    identity: crate::types::TranscriptMessageIdentity::default(),
9189                    created_at: crate::types::message_timestamp_now(),
9190                })],
9191                TranscriptRewriteReason::new("compaction"),
9192                Some("unit-test".to_string()),
9193                Some(first_parent),
9194            )
9195            .expect("first rewrite should commit");
9196
9197        session.push(Message::User(UserMessage::text("follow up".to_string())));
9198        session.push(Message::BlockAssistant(BlockAssistantMessage {
9199            blocks: vec![AssistantBlock::Text {
9200                text: "verbose follow-up".to_string(),
9201                meta: None,
9202            }],
9203            stop_reason: StopReason::EndTurn,
9204            identity: crate::types::TranscriptMessageIdentity::default(),
9205            created_at: crate::types::message_timestamp_now(),
9206        }));
9207        let bridge_messages = session.messages().to_vec();
9208        let bridge_revision = session.transcript_revision().expect("bridge revision");
9209
9210        let second = session
9211            .commit_transcript_rewrite(
9212                TranscriptRewriteSelection::MessageRange { start: 3, end: 4 },
9213                vec![Message::BlockAssistant(BlockAssistantMessage {
9214                    blocks: vec![AssistantBlock::Text {
9215                        text: "compact follow-up".to_string(),
9216                        meta: None,
9217                    }],
9218                    stop_reason: StopReason::EndTurn,
9219                    identity: crate::types::TranscriptMessageIdentity::default(),
9220                    created_at: crate::types::message_timestamp_now(),
9221                })],
9222                TranscriptRewriteReason::new("compaction"),
9223                Some("unit-test".to_string()),
9224                Some(bridge_revision.clone()),
9225            )
9226            .expect("second rewrite should commit");
9227        assert_ne!(second.revision, bridge_revision);
9228
9229        let full = session
9230            .transcript_history_state()
9231            .expect("history state should decode")
9232            .expect("history state should exist");
9233        assert_eq!(full.head(), second.revision);
9234        let bridge_body = ValidatedTranscriptHistory::seal_owned(full)
9235            .expect("full graph should seal")
9236            .materialize_rewrite_parent(&second)
9237            .expect("the exact rewrite occurrence must materialize its bridge parent");
9238        assert_eq!(
9239            bridge_body.revision, bridge_revision,
9240            "explicit parent materialization must preserve the selected bridge revision"
9241        );
9242        assert_eq!(
9243            serde_json::to_value(&bridge_body.messages).expect("projection serializes"),
9244            serde_json::to_value(&bridge_messages).expect("bridge serializes")
9245        );
9246    }
9247
9248    #[test]
9249    fn exact_rewrite_occurrence_projection_orders_digest_recurrence() {
9250        let message_a = Message::User(UserMessage::text("A".to_string()));
9251        let message_b = Message::User(UserMessage::text("B".to_string()));
9252        let mut session = Session::new();
9253        session.push(message_a.clone());
9254        let revision_a = session.transcript_revision().expect("A revision");
9255
9256        let first_b = session
9257            .commit_transcript_rewrite(
9258                TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
9259                vec![message_b.clone()],
9260                TranscriptRewriteReason::new("A-to-B"),
9261                Some("unit-test".to_string()),
9262                Some(revision_a.clone()),
9263            )
9264            .expect("first B occurrence should commit");
9265        let back_to_a = session
9266            .commit_transcript_rewrite(
9267                TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
9268                vec![message_a.clone()],
9269                TranscriptRewriteReason::new("B-to-A"),
9270                Some("unit-test".to_string()),
9271                Some(first_b.revision.clone()),
9272            )
9273            .expect("second A occurrence should commit");
9274        let second_b = session
9275            .commit_transcript_rewrite(
9276                TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
9277                vec![message_b.clone()],
9278                TranscriptRewriteReason::new("A-to-B-again"),
9279                Some("unit-test".to_string()),
9280                Some(back_to_a.revision.clone()),
9281            )
9282            .expect("second B occurrence should commit");
9283
9284        assert_eq!(back_to_a.revision, revision_a);
9285        assert_eq!(second_b.revision, first_b.revision);
9286        let graph = session
9287            .transcript_history_state()
9288            .expect("history state should decode")
9289            .expect("history state should exist");
9290        let sealed =
9291            ValidatedTranscriptHistory::seal_owned(graph).expect("recurrence graph should seal");
9292
9293        for (generation, commit, parent_message, revision_message) in [
9294            (1_u64, &first_b, &message_a, &message_b),
9295            (2_u64, &back_to_a, &message_b, &message_a),
9296            (3_u64, &second_b, &message_a, &message_b),
9297        ] {
9298            assert_eq!(commit.rewrite_generation, generation);
9299
9300            let before = sealed
9301                .materialize_rewrite_parent(commit)
9302                .expect("exact parent occurrence should materialize");
9303            assert_eq!(before.messages, std::slice::from_ref(parent_message));
9304
9305            let mut after = Session::new();
9306            after
9307                .apply_validated_transcript_history_state(
9308                    sealed
9309                        .project_at_rewrite_commit(commit)
9310                        .expect("exact rewrite occurrence should project"),
9311                )
9312                .expect("exact rewrite occurrence should materialize");
9313            assert_eq!(after.messages(), std::slice::from_ref(revision_message));
9314            let after_graph = after
9315                .transcript_history_state()
9316                .expect("rewrite graph should decode")
9317                .expect("rewrite graph should exist");
9318            assert_eq!(
9319                after_graph.commit_count(),
9320                usize::try_from(generation).expect("test generation fits usize")
9321            );
9322            assert_eq!(
9323                after_graph.last_commit(),
9324                Some(commit),
9325                "the projection must end at this occurrence, not a later equal digest"
9326            );
9327        }
9328
9329        let latest_b = sealed
9330            .project_at_revision(&first_b.revision)
9331            .expect("content lookup should remain available");
9332        assert_eq!(
9333            latest_b.commit_count(),
9334            3,
9335            "digest-only lookup intentionally selects the latest matching occurrence"
9336        );
9337    }
9338
9339    #[test]
9340    fn test_session_new() {
9341        let session = Session::new();
9342        assert_eq!(session.version(), SESSION_VERSION);
9343        assert!(session.messages().is_empty());
9344        assert!(session.created_at() <= session.updated_at());
9345    }
9346
9347    #[test]
9348    fn llm_identity_model_override_switches_to_catalog_provider() {
9349        let registry = crate::ModelRegistry::from_config(
9350            &crate::Config::default(),
9351            *crate::model_profile::test_catalog::TEST_CATALOG,
9352        )
9353        .unwrap();
9354        let current = SessionLlmIdentity {
9355            model: "test-anthropic-default".to_string(),
9356            provider: Provider::Anthropic,
9357            self_hosted_server_id: None,
9358            provider_params: None,
9359            auth_binding: Some(crate::AuthBindingRef {
9360                realm: crate::RealmId::parse("tenant_a").unwrap(),
9361                binding: crate::BindingId::parse("anthropic_default").unwrap(),
9362                profile: None,
9363                origin: crate::BindingOrigin::Configured,
9364            }),
9365        };
9366
9367        let resolved = resolve_session_llm_identity_override(
9368            &current,
9369            &registry,
9370            SessionLlmIdentityOverride {
9371                model: Some("test-openai-default"),
9372                provider: None,
9373                self_hosted_server_id: None,
9374                provider_params: None,
9375                auth_binding: None,
9376            },
9377        )
9378        .unwrap();
9379
9380        assert_eq!(resolved.model, "test-openai-default");
9381        assert_eq!(resolved.provider, Provider::OpenAI);
9382        assert!(
9383            resolved.auth_binding.is_none(),
9384            "provider switches must not inherit a binding from the previous provider"
9385        );
9386    }
9387
9388    #[test]
9389    fn llm_identity_model_override_keeps_uncatalogued_model_on_current_provider() {
9390        let registry = crate::ModelRegistry::from_config(
9391            &crate::Config::default(),
9392            *crate::model_profile::test_catalog::TEST_CATALOG,
9393        )
9394        .unwrap();
9395        let current = SessionLlmIdentity {
9396            model: "custom-model".to_string(),
9397            provider: Provider::Anthropic,
9398            self_hosted_server_id: None,
9399            provider_params: None,
9400            auth_binding: None,
9401        };
9402
9403        let resolved = resolve_session_llm_identity_override(
9404            &current,
9405            &registry,
9406            SessionLlmIdentityOverride {
9407                model: Some("uncatalogued-custom-model"),
9408                provider: None,
9409                self_hosted_server_id: None,
9410                provider_params: None,
9411                auth_binding: None,
9412            },
9413        )
9414        .unwrap();
9415
9416        assert_eq!(resolved.model, "uncatalogued-custom-model");
9417        assert_eq!(resolved.provider, Provider::Anthropic);
9418    }
9419
9420    fn self_hosted_registry_with_shared_remote_model() -> crate::ModelRegistry {
9421        use crate::config::{
9422            SelfHostedApiStyle, SelfHostedModelConfig, SelfHostedServerConfig, SelfHostedTransport,
9423        };
9424        use crate::model_profile::catalog::ModelTier;
9425
9426        let mut config = crate::Config::default();
9427        for server_id in ["local-a", "local-b"] {
9428            config.self_hosted.servers.insert(
9429                server_id.to_string(),
9430                SelfHostedServerConfig {
9431                    transport: SelfHostedTransport::OpenAiCompatible,
9432                    base_url: format!("http://{server_id}.test"),
9433                    api_style: SelfHostedApiStyle::Responses,
9434                },
9435            );
9436            config.self_hosted.models.insert(
9437                format!("shared-local-{server_id}"),
9438                SelfHostedModelConfig {
9439                    server: server_id.to_string(),
9440                    remote_model: "shared-local-model".to_string(),
9441                    display_name: "Shared local model".to_string(),
9442                    family: "shared-local".to_string(),
9443                    tier: ModelTier::Supported,
9444                    ..Default::default()
9445                },
9446            );
9447        }
9448        config.self_hosted.default_model = Some("shared-local-local-a".to_string());
9449        crate::ModelRegistry::from_config(
9450            &config,
9451            *crate::model_profile::test_catalog::TEST_CATALOG,
9452        )
9453        .expect("shared local registry")
9454    }
9455
9456    #[test]
9457    fn llm_identity_override_preserves_exact_self_hosted_server_route() {
9458        let registry = self_hosted_registry_with_shared_remote_model();
9459        let current = SessionLlmIdentity {
9460            model: "shared-local-local-a".to_string(),
9461            provider: Provider::SelfHosted,
9462            self_hosted_server_id: Some("local-a".to_string()),
9463            provider_params: None,
9464            auth_binding: None,
9465        };
9466
9467        let resolved = resolve_session_llm_identity_override(
9468            &current,
9469            &registry,
9470            SessionLlmIdentityOverride {
9471                model: Some("shared-local-local-b"),
9472                provider: Some(Provider::SelfHosted),
9473                self_hosted_server_id: Some("local-b"),
9474                provider_params: None,
9475                auth_binding: None,
9476            },
9477        )
9478        .expect("exact configured local route should resolve");
9479
9480        assert_eq!(resolved.model, "shared-local-local-b");
9481        assert_eq!(resolved.provider, Provider::SelfHosted);
9482        assert_eq!(resolved.self_hosted_server_id.as_deref(), Some("local-b"));
9483    }
9484
9485    #[test]
9486    fn llm_identity_override_rejects_self_hosted_server_model_mismatch() {
9487        let registry = self_hosted_registry_with_shared_remote_model();
9488        let current = SessionLlmIdentity {
9489            model: "shared-local-local-a".to_string(),
9490            provider: Provider::SelfHosted,
9491            self_hosted_server_id: Some("local-a".to_string()),
9492            provider_params: None,
9493            auth_binding: None,
9494        };
9495
9496        let error = resolve_session_llm_identity_override(
9497            &current,
9498            &registry,
9499            SessionLlmIdentityOverride {
9500                model: Some("shared-local-local-b"),
9501                provider: Some(Provider::SelfHosted),
9502                self_hosted_server_id: Some("local-a"),
9503                provider_params: None,
9504                auth_binding: None,
9505            },
9506        )
9507        .expect_err("server id must match the requested model alias route");
9508
9509        assert!(matches!(
9510            error,
9511            SessionLlmIdentityOverrideError::SelfHostedServerMismatch {
9512                requested,
9513                configured,
9514                ..
9515            } if requested == "local-a" && configured == "local-b"
9516        ));
9517    }
9518
9519    #[test]
9520    fn realtime_transcript_append_is_idempotent_by_provider_item_and_delta_id() {
9521        let mut session = Session::new();
9522
9523        let user = RealtimeTranscriptEvent::UserTranscriptFinal {
9524            item_id: "item_user".to_string(),
9525            previous_item_id: None,
9526            content_index: 0,
9527            text: "hello".to_string(),
9528        };
9529        assert!(
9530            !session
9531                .append_realtime_transcript_event(user.clone())
9532                .is_inert()
9533        );
9534        assert!(session.append_realtime_transcript_event(user).is_inert());
9535
9536        let delta = RealtimeTranscriptEvent::AssistantTextDelta {
9537            response_id: "resp_assistant".to_string(),
9538            delta_id: "evt_delta_1".to_string(),
9539            item_id: "item_assistant".to_string(),
9540            previous_item_id: Some("item_user".to_string()),
9541            content_index: 0,
9542            delta: "hi".to_string(),
9543        };
9544        assert!(
9545            session
9546                .append_realtime_transcript_event(delta.clone())
9547                .is_inert()
9548        );
9549        assert!(session.append_realtime_transcript_event(delta).is_inert());
9550
9551        let terminal = RealtimeTranscriptEvent::AssistantTurnCompleted {
9552            response_id: "resp_assistant".to_string(),
9553            stop_reason: StopReason::EndTurn,
9554            usage: Usage::default(),
9555        };
9556        assert!(
9557            !session
9558                .append_realtime_transcript_event(terminal.clone())
9559                .is_inert()
9560        );
9561        assert!(
9562            session
9563                .append_realtime_transcript_event(terminal)
9564                .is_inert()
9565        );
9566
9567        assert_eq!(session.messages().len(), 2);
9568        assert!(matches!(
9569            &session.messages()[0],
9570            Message::User(user) if user.text_content() == "hello"
9571        ));
9572        assert!(matches!(
9573            &session.messages()[1],
9574            Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "hi"
9575        ));
9576    }
9577
9578    #[test]
9579    fn realtime_legacy_inline_activation_is_failure_atomic_and_preserves_whole_blob() {
9580        let mut malformed = Session::new();
9581        malformed.set_metadata_unchecked_for_test(
9582            SESSION_REALTIME_TRANSCRIPT_STATE_KEY,
9583            serde_json::json!("not-a-realtime-state"),
9584        );
9585        let pristine_prefix = malformed
9586            .realtime_component_event_prefix()
9587            .expect("pristine realtime prefix");
9588        assert!(matches!(
9589            malformed.activate_realtime_component_sidecar(),
9590            Err(RealtimeTranscriptSidecarError::Serialization(_))
9591        ));
9592        assert_eq!(
9593            malformed
9594                .metadata()
9595                .get(SESSION_REALTIME_TRANSCRIPT_STATE_KEY),
9596            Some(&serde_json::json!("not-a-realtime-state")),
9597            "failed activation must leave the exact legacy value in place"
9598        );
9599        assert_eq!(
9600            malformed
9601                .realtime_component_event_prefix()
9602                .expect("unchanged realtime prefix"),
9603            pristine_prefix,
9604            "failed activation must not advance component authority"
9605        );
9606
9607        let mut session = Session::new();
9608        let state = SessionRealtimeTranscriptState::default();
9609        let inline = serde_json::to_value(&state).expect("inline projection");
9610        session
9611            .set_metadata_unchecked_for_test(SESSION_REALTIME_TRANSCRIPT_STATE_KEY, inline.clone());
9612        session
9613            .activate_realtime_component_sidecar()
9614            .expect("supported inline activation");
9615        assert!(
9616            !session
9617                .metadata()
9618                .contains_key(SESSION_REALTIME_TRANSCRIPT_STATE_KEY),
9619            "successful activation removes raw shadow authority"
9620        );
9621        let suffix = session
9622            .prepare_realtime_component_event_suffix()
9623            .expect("prepare activation suffix")
9624            .expect("SnapshotV1 suffix");
9625        assert_eq!(suffix.events().len(), 1);
9626        assert!(matches!(
9627            suffix.events()[0]
9628                .decode_payload::<crate::RealtimeTranscriptSidecarRecord>(
9629                    crate::REALTIME_TRANSCRIPT_SIDECAR_EVENT_SCHEMA_V1
9630                )
9631                .expect("decode activation record"),
9632            crate::RealtimeTranscriptSidecarRecord::SnapshotV1 { .. }
9633        ));
9634
9635        let whole_blob =
9636            serde_json::to_value(&session).expect("WholeBlob projection after activation");
9637        assert_eq!(
9638            whole_blob
9639                .get("metadata")
9640                .and_then(serde_json::Value::as_object)
9641                .and_then(|metadata| metadata.get(SESSION_REALTIME_TRANSCRIPT_STATE_KEY)),
9642            Some(&inline),
9643            "activation changes storage authority, not the WholeBlob projection"
9644        );
9645    }
9646
9647    #[test]
9648    fn realtime_user_image_materializes_once_and_unblocks_causal_assistant() {
9649        let mut session = Session::new();
9650        let image_data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB".to_string();
9651        let image = RealtimeTranscriptEvent::UserContentFinal {
9652            idempotency_key: "image-request-1".to_string(),
9653            item_id: "item_image".to_string(),
9654            previous_item_id: None,
9655            content_index: 0,
9656            content: vec![ContentBlock::Image {
9657                media_type: "image/png".to_string(),
9658                data: crate::types::ImageData::Inline {
9659                    data: image_data.clone(),
9660                },
9661            }],
9662        };
9663
9664        assert!(
9665            !append_staged_user_image(&mut session, &image).is_inert(),
9666            "first image final must materialize canonical user content"
9667        );
9668        let replay = session
9669            .preflight_realtime_user_content_event(&image)
9670            .expect("exact retry should preflight as committed");
9671        assert!(matches!(
9672            replay,
9673            crate::RealtimeUserContentApplyOutcome::AlreadyCommitted(_)
9674        ));
9675
9676        assert!(
9677            !session
9678                .metadata()
9679                .contains_key(SESSION_REALTIME_TRANSCRIPT_STATE_KEY),
9680            "ordinary operation must keep the full realtime projection out of raw metadata"
9681        );
9682        let whole_blob =
9683            serde_json::to_value(&session).expect("WholeBlob projection should serialize");
9684        let staged_state = whole_blob
9685            .get("metadata")
9686            .and_then(serde_json::Value::as_object)
9687            .and_then(|metadata| metadata.get(SESSION_REALTIME_TRANSCRIPT_STATE_KEY))
9688            .expect("WholeBlob compatibility projection must include realtime state");
9689        assert!(
9690            !staged_state.to_string().contains(&image_data),
9691            "materialized image bytes must not remain duplicated in transcript metadata"
9692        );
9693
9694        assert!(
9695            session
9696                .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
9697                    response_id: "resp_image".to_string(),
9698                    delta_id: "delta_image".to_string(),
9699                    item_id: "item_assistant".to_string(),
9700                    previous_item_id: Some("item_image".to_string()),
9701                    content_index: 0,
9702                    delta: "I see red.".to_string(),
9703                })
9704                .is_inert()
9705        );
9706        assert!(
9707            !session
9708                .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
9709                    response_id: "resp_image".to_string(),
9710                    stop_reason: StopReason::EndTurn,
9711                    usage: Usage::default(),
9712                },)
9713                .is_inert(),
9714            "materialized image predecessor must unblock the assistant response"
9715        );
9716
9717        assert_eq!(session.messages().len(), 2);
9718        assert!(matches!(
9719            &session.messages()[0],
9720            Message::User(user)
9721                if matches!(
9722                    user.content.as_slice(),
9723                    [ContentBlock::Image {
9724                        media_type,
9725                        data: crate::types::ImageData::Blob { blob_id },
9726                    }] if media_type == "image/png"
9727                        && blob_id == &crate::blob::content_blob_id("image/png", &image_data)
9728                )
9729        ));
9730        assert!(matches!(
9731            &session.messages()[1],
9732            Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "I see red."
9733        ));
9734    }
9735
9736    #[test]
9737    fn realtime_user_image_identity_is_durable_canonical_and_conflict_safe() {
9738        let mut session = Session::new();
9739        let data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB".to_string();
9740        let initial = RealtimeTranscriptEvent::UserContentFinal {
9741            idempotency_key: "stable-image-key".to_string(),
9742            item_id: "canonical-image-item".to_string(),
9743            previous_item_id: None,
9744            content_index: 0,
9745            content: vec![ContentBlock::Image {
9746                media_type: " image/PNG; charset=binary ".to_string(),
9747                data: crate::types::ImageData::Inline { data: data.clone() },
9748            }],
9749        };
9750        let committed = append_staged_user_image(&mut session, &initial);
9751        let Some(crate::RealtimeUserContentApplyOutcome::Committed(identity)) =
9752            committed.user_content
9753        else {
9754            panic!("first image must commit its durable identity");
9755        };
9756        assert_eq!(identity.item_id, "canonical-image-item");
9757        assert_eq!(identity.media_type, "image/png");
9758
9759        let encoded = serde_json::to_string(&session).expect("session should serialize");
9760        let restored: Session =
9761            serde_json::from_str(&encoded).expect("committed identity should restore");
9762
9763        let replay_event = RealtimeTranscriptEvent::UserContentFinal {
9764            idempotency_key: "stable-image-key".to_string(),
9765            item_id: "ignored-retry-item".to_string(),
9766            previous_item_id: None,
9767            content_index: 0,
9768            content: vec![ContentBlock::Image {
9769                media_type: "image/png".to_string(),
9770                data: crate::types::ImageData::Inline { data: data.clone() },
9771            }],
9772        };
9773        let replay = restored
9774            .preflight_realtime_user_content_event(&replay_event)
9775            .expect("exact retry should preflight");
9776        assert!(matches!(
9777            replay,
9778            crate::RealtimeUserContentApplyOutcome::AlreadyCommitted(
9779                crate::RealtimeUserContentIdentity { ref item_id, .. }
9780            ) if item_id == "canonical-image-item"
9781        ));
9782
9783        let conflict = restored
9784            .preflight_realtime_user_content_event(&RealtimeTranscriptEvent::UserContentFinal {
9785                idempotency_key: "stable-image-key".to_string(),
9786                item_id: "conflicting-item".to_string(),
9787                previous_item_id: None,
9788                content_index: 0,
9789                content: vec![ContentBlock::Image {
9790                    media_type: "image/png".to_string(),
9791                    data: crate::types::ImageData::Inline {
9792                        data: "different-payload".to_string(),
9793                    },
9794                }],
9795            })
9796            .expect("conflicting retry should preflight");
9797        assert!(matches!(
9798            conflict,
9799            crate::RealtimeUserContentApplyOutcome::RejectedConflict { .. }
9800        ));
9801
9802        let item_collision = restored
9803            .preflight_realtime_user_content_event(&RealtimeTranscriptEvent::UserContentFinal {
9804                idempotency_key: "another-key".to_string(),
9805                item_id: "canonical-image-item".to_string(),
9806                previous_item_id: None,
9807                content_index: 0,
9808                content: vec![ContentBlock::Image {
9809                    media_type: "image/png".to_string(),
9810                    data: crate::types::ImageData::Inline { data },
9811                }],
9812            })
9813            .expect("item collision should preflight");
9814        assert!(matches!(
9815            item_collision,
9816            crate::RealtimeUserContentApplyOutcome::RejectedConflict { .. }
9817        ));
9818        assert_eq!(restored.messages().len(), 1);
9819        serde_json::to_string(&restored).expect("rejections must not corrupt durable state");
9820    }
9821
9822    #[test]
9823    fn realtime_user_image_reducer_never_receipts_without_pending_blob_proof() {
9824        for data in [
9825            crate::types::ImageData::Inline {
9826                data: "iVBORw0KGgo=".to_string(),
9827            },
9828            crate::types::ImageData::Blob {
9829                blob_id: crate::blob::content_blob_id("image/png", "iVBORw0KGgo="),
9830            },
9831        ] {
9832            let mut session = Session::new();
9833            let outcome = session.append_realtime_transcript_event(
9834                RealtimeTranscriptEvent::UserContentFinal {
9835                    idempotency_key: "unstaged-image-key".to_string(),
9836                    item_id: "unstaged-image-item".to_string(),
9837                    previous_item_id: None,
9838                    content_index: 0,
9839                    content: vec![ContentBlock::Image {
9840                        media_type: "image/png".to_string(),
9841                        data,
9842                    }],
9843                },
9844            );
9845            assert!(matches!(
9846                outcome.user_content,
9847                Some(crate::RealtimeUserContentApplyOutcome::RejectedInvalidIdentity { .. })
9848            ));
9849            assert!(session.messages().is_empty());
9850            assert!(session.realtime_user_content_identities().is_empty());
9851        }
9852    }
9853
9854    #[test]
9855    fn realtime_user_image_pending_slot_is_generated_bounded_and_recovery_typed() {
9856        use crate::generated::session_document::{
9857            RealtimeUserContentBlobRecoveryDisposition, RealtimeUserContentBlobStageDisposition,
9858        };
9859        let mut session = Session::new();
9860        let pending = crate::PendingRealtimeUserContentBlob {
9861            idempotency_key: "pending-key-a".to_string(),
9862            item_id: "pending-item-a".to_string(),
9863            previous_item_id: None,
9864            content_index: 0,
9865            blob_id: crate::blob::content_blob_id("image/png", "iVBORw0KGgo="),
9866            media_type: "image/png".to_string(),
9867        };
9868        let different = crate::PendingRealtimeUserContentBlob {
9869            idempotency_key: "pending-key-b".to_string(),
9870            item_id: "pending-item-b".to_string(),
9871            previous_item_id: None,
9872            content_index: 0,
9873            blob_id: crate::blob::content_blob_id("image/png", "iVBORw0KGgoB"),
9874            media_type: "image/png".to_string(),
9875        };
9876        assert_eq!(
9877            session
9878                .stage_pending_realtime_user_content_blob(pending.clone())
9879                .expect("empty slot stages"),
9880            RealtimeUserContentBlobStageDisposition::StageNew
9881        );
9882        assert_eq!(
9883            session
9884                .stage_pending_realtime_user_content_blob(pending.clone())
9885                .expect("exact stage retry is idempotent"),
9886            RealtimeUserContentBlobStageDisposition::ReuseExact
9887        );
9888        assert_eq!(
9889            session
9890                .stage_pending_realtime_user_content_blob(different.clone())
9891                .expect("occupied decision is typed"),
9892            RealtimeUserContentBlobStageDisposition::RejectOccupied
9893        );
9894        assert_eq!(
9895            session.pending_realtime_user_content_blob(),
9896            Some(pending.clone())
9897        );
9898        assert_eq!(
9899            session
9900                .resolve_pending_realtime_user_content_blob_recovery(Some(&pending), false)
9901                .expect("exact recovery decision"),
9902            RealtimeUserContentBlobRecoveryDisposition::RetryExact
9903        );
9904        assert_eq!(
9905            session
9906                .resolve_pending_realtime_user_content_blob_recovery(Some(&different), true)
9907                .expect("verified older recovery decision"),
9908            RealtimeUserContentBlobRecoveryDisposition::CommitVerifiedBeforeCurrent
9909        );
9910        assert_eq!(
9911            session
9912                .resolve_pending_realtime_user_content_blob_recovery(Some(&different), false)
9913                .expect("invalid older recovery decision"),
9914            RealtimeUserContentBlobRecoveryDisposition::ClearInvalidBeforeCurrent
9915        );
9916        session
9917            .clear_invalid_pending_realtime_user_content_blob(Some(&different))
9918            .expect("generated clear-invalid disposition authorizes clear");
9919        assert!(session.pending_realtime_user_content_blob().is_none());
9920    }
9921
9922    #[test]
9923    fn transcript_rewrite_tombstones_removed_image_key_and_accepts_new_key() {
9924        let mut session = Session::new();
9925        let data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB".to_string();
9926        let original = RealtimeTranscriptEvent::UserContentFinal {
9927            idempotency_key: "removed-image-key".to_string(),
9928            item_id: "removed-image-item".to_string(),
9929            previous_item_id: None,
9930            content_index: 0,
9931            content: vec![ContentBlock::Image {
9932                media_type: "image/png".to_string(),
9933                data: crate::types::ImageData::Inline { data: data.clone() },
9934            }],
9935        };
9936        assert!(matches!(
9937            append_staged_user_image(&mut session, &original).user_content,
9938            Some(crate::RealtimeUserContentApplyOutcome::Committed(_))
9939        ));
9940
9941        let parent = session.transcript_revision().expect("parent revision");
9942        session
9943            .commit_transcript_rewrite(
9944                TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
9945                vec![Message::User(UserMessage::text("image removed"))],
9946                TranscriptRewriteReason::new("remove-image"),
9947                None,
9948                Some(parent),
9949            )
9950            .expect("rewrite should tombstone removed image identity");
9951
9952        assert!(session.realtime_user_content_identities().is_empty());
9953        assert_eq!(
9954            session.realtime_user_content_tombstones(),
9955            vec![crate::RealtimeUserContentTombstone {
9956                idempotency_key: "removed-image-key".to_string(),
9957            }]
9958        );
9959        assert!(matches!(
9960            session.preflight_realtime_user_content_event(&original),
9961            Some(crate::RealtimeUserContentApplyOutcome::RejectedConflict { .. })
9962        ));
9963        assert!(matches!(
9964            session
9965                .append_realtime_transcript_event(original)
9966                .user_content,
9967            Some(crate::RealtimeUserContentApplyOutcome::RejectedConflict { .. })
9968        ));
9969        assert_eq!(
9970            session.messages().len(),
9971            1,
9972            "stale retry emits no receipt content"
9973        );
9974
9975        let new_image = RealtimeTranscriptEvent::UserContentFinal {
9976            idempotency_key: "new-image-key".to_string(),
9977            item_id: "new-image-item".to_string(),
9978            previous_item_id: None,
9979            content_index: 0,
9980            content: vec![ContentBlock::Image {
9981                media_type: "image/png".to_string(),
9982                data: crate::types::ImageData::Inline { data },
9983            }],
9984        };
9985        assert!(matches!(
9986            append_staged_user_image(&mut session, &new_image).user_content,
9987            Some(crate::RealtimeUserContentApplyOutcome::Committed(_))
9988        ));
9989        assert_eq!(session.messages().len(), 2);
9990
9991        let restored: Session = serde_json::from_str(
9992            &serde_json::to_string(&session).expect("serialize rewritten session"),
9993        )
9994        .expect("cold restore rewritten session");
9995        assert_eq!(restored.realtime_user_content_identities().len(), 1);
9996        assert_eq!(restored.realtime_user_content_tombstones().len(), 1);
9997    }
9998
9999    #[test]
10000    fn transcript_rewrite_retains_only_canonical_image_occurrence_for_exact_replay() {
10001        let mut session = Session::new();
10002        let data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB".to_string();
10003        let original = RealtimeTranscriptEvent::UserContentFinal {
10004            idempotency_key: "retained-image-key".to_string(),
10005            item_id: "retained-image-item".to_string(),
10006            previous_item_id: None,
10007            content_index: 0,
10008            content: vec![ContentBlock::Image {
10009                media_type: "image/png".to_string(),
10010                data: crate::types::ImageData::Inline { data },
10011            }],
10012        };
10013        assert!(matches!(
10014            append_staged_user_image(&mut session, &original).user_content,
10015            Some(crate::RealtimeUserContentApplyOutcome::Committed(_))
10016        ));
10017        let retained_message = session.messages()[0].clone();
10018        let parent = session.transcript_revision().expect("parent revision");
10019        session
10020            .commit_transcript_rewrite(
10021                TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
10022                vec![
10023                    retained_message,
10024                    Message::User(UserMessage::text("new canonical neighbor")),
10025                ],
10026                TranscriptRewriteReason::new("retain-image"),
10027                None,
10028                Some(parent),
10029            )
10030            .expect("rewrite retaining exact inline image should reconcile");
10031
10032        assert!(session.realtime_user_content_tombstones().is_empty());
10033        let replay = session
10034            .preflight_realtime_user_content_event(&original)
10035            .expect("retained image should preflight as exact replay");
10036        assert!(matches!(
10037            replay,
10038            crate::RealtimeUserContentApplyOutcome::AlreadyCommitted(_)
10039        ));
10040        assert_eq!(session.messages().len(), 2);
10041    }
10042
10043    #[test]
10044    fn transcript_rewrite_rejects_atomically_while_image_blob_anchor_is_pending() {
10045        let mut session = Session::new();
10046        session.push(Message::User(UserMessage::text("before rewrite")));
10047        let pending = crate::PendingRealtimeUserContentBlob {
10048            idempotency_key: "pending-rewrite-key".to_string(),
10049            item_id: "pending-rewrite-item".to_string(),
10050            previous_item_id: None,
10051            content_index: 0,
10052            blob_id: crate::blob::content_blob_id("image/png", "pending-bytes"),
10053            media_type: "image/png".to_string(),
10054        };
10055        session
10056            .stage_pending_realtime_user_content_blob(pending.clone())
10057            .expect("stage durable pending anchor");
10058        let parent = session.transcript_revision().expect("parent revision");
10059        let error = session
10060            .commit_transcript_rewrite(
10061                TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
10062                vec![Message::User(UserMessage::text("after rewrite"))],
10063                TranscriptRewriteReason::new("blocked-pending-image"),
10064                None,
10065                Some(parent),
10066            )
10067            .expect_err("rewrite must not cross an unresolved image anchor");
10068        assert!(
10069            error
10070                .to_string()
10071                .contains("history_rewrite_pending_user_content_blob")
10072        );
10073        assert!(matches!(
10074            &session.messages()[0],
10075            Message::User(user) if user.text_content() == "before rewrite"
10076        ));
10077        assert_eq!(session.pending_realtime_user_content_blob(), Some(pending));
10078    }
10079
10080    #[test]
10081    fn realtime_user_image_rejects_noncanonical_blob_and_multiblock_shape() {
10082        let mut session = Session::new();
10083        for (key, content) in [
10084            (
10085                "invalid-blob",
10086                vec![ContentBlock::Image {
10087                    media_type: "image/png".to_string(),
10088                    data: crate::types::ImageData::Blob {
10089                        blob_id: crate::BlobId::new("sha256:not-a-digest"),
10090                    },
10091                }],
10092            ),
10093            (
10094                "multi-block",
10095                vec![
10096                    ContentBlock::Image {
10097                        media_type: "image/png".to_string(),
10098                        data: crate::types::ImageData::Inline {
10099                            data: "payload".to_string(),
10100                        },
10101                    },
10102                    ContentBlock::Text {
10103                        text: "smuggled".to_string(),
10104                    },
10105                ],
10106            ),
10107        ] {
10108            let outcome = session.append_realtime_transcript_event(
10109                RealtimeTranscriptEvent::UserContentFinal {
10110                    idempotency_key: key.to_string(),
10111                    item_id: format!("item-{key}"),
10112                    previous_item_id: None,
10113                    content_index: 0,
10114                    content,
10115                },
10116            );
10117            assert!(matches!(
10118                outcome.user_content,
10119                Some(crate::RealtimeUserContentApplyOutcome::RejectedInvalidIdentity { .. })
10120            ));
10121        }
10122        assert!(session.messages().is_empty());
10123        let encoded = serde_json::to_string(&session).expect("session should serialize");
10124        serde_json::from_str::<Session>(&encoded).expect("rejections must leave restorable state");
10125    }
10126
10127    #[test]
10128    fn realtime_restore_rejects_malformed_causal_graphs_and_accepts_waiting_dag() {
10129        fn restore(
10130            items: serde_json::Value,
10131            first_seen_order: Vec<&str>,
10132        ) -> Result<
10133            crate::realtime_transcript_revision::SessionRealtimeTranscriptState,
10134            crate::realtime_transcript_revision::RealtimeTranscriptShellError,
10135        > {
10136            let state = serde_json::from_value(serde_json::json!({
10137                "items": items,
10138                "first_seen_order": first_seen_order,
10139            }))
10140            .expect("test state shape should deserialize");
10141            crate::realtime_transcript_revision::restore_realtime_transcript_state(state)
10142        }
10143
10144        assert!(
10145            restore(
10146                serde_json::json!({
10147                    "child": { "role": "user", "previous_item_id": "missing" }
10148                }),
10149                vec!["child"],
10150            )
10151            .is_ok(),
10152            "an unmaterialized out-of-order item must survive cold restore until its predecessor arrives"
10153        );
10154        assert!(
10155            restore(
10156                serde_json::json!({
10157                    "child": {
10158                        "role": "user",
10159                        "previous_item_id": "missing",
10160                        "ready": true,
10161                        "materialized": true
10162                    }
10163                }),
10164                vec!["child"],
10165            )
10166            .is_err(),
10167            "a materialized item cannot reference a missing predecessor"
10168        );
10169        assert!(
10170            restore(
10171                serde_json::json!({
10172                    "self": { "role": "user", "previous_item_id": "self" }
10173                }),
10174                vec!["self"],
10175            )
10176            .is_err(),
10177            "self edge must fail cold restore"
10178        );
10179        assert!(
10180            restore(
10181                serde_json::json!({
10182                    "a": { "role": "user", "previous_item_id": "b" },
10183                    "b": { "role": "user", "previous_item_id": "a" }
10184                }),
10185                vec!["a", "b"],
10186            )
10187            .is_err(),
10188            "cycle must fail cold restore"
10189        );
10190        assert!(
10191            restore(
10192                serde_json::json!({
10193                    "root": { "role": "user" },
10194                    "materialized_child": {
10195                        "role": "user",
10196                        "previous_item_id": "root",
10197                        "ready": true,
10198                        "materialized": true
10199                    }
10200                }),
10201                vec!["root", "materialized_child"],
10202            )
10203            .is_err(),
10204            "materialized child cannot have unmaterialized ancestry"
10205        );
10206        assert!(
10207            restore(
10208                serde_json::json!({
10209                    "root": { "role": "user" },
10210                    "waiting_child": { "role": "user", "previous_item_id": "root" }
10211                }),
10212                vec!["waiting_child", "root"],
10213            )
10214            .is_ok(),
10215            "valid acyclic waiting graph should restore even when first-seen order is child-first"
10216        );
10217    }
10218
10219    #[test]
10220    fn realtime_restore_handles_long_waiting_chain_with_bounded_graph_walk() {
10221        const ITEM_COUNT: usize = 4_096;
10222        let mut items = serde_json::Map::new();
10223        let mut order = Vec::with_capacity(ITEM_COUNT);
10224        for index in 0..ITEM_COUNT {
10225            let item_id = format!("item-{index:04}");
10226            let value = if index == 0 {
10227                serde_json::json!({ "role": "user" })
10228            } else {
10229                serde_json::json!({
10230                    "role": "user",
10231                    "previous_item_id": format!("item-{:04}", index - 1),
10232                })
10233            };
10234            order.push(item_id.clone());
10235            items.insert(item_id, value);
10236        }
10237        let state = serde_json::from_value(serde_json::json!({
10238            "items": items,
10239            "first_seen_order": order,
10240        }))
10241        .expect("long-chain fixture should deserialize");
10242        crate::realtime_transcript_revision::restore_realtime_transcript_state(state)
10243            .expect("long valid waiting DAG should restore in one bounded graph walk");
10244    }
10245
10246    /// R5-7: `AssistantTranscriptFinalText` injects authoritative final text
10247    /// into the staged item. Verifies the override semantics: a partial
10248    /// delta is replaced, not concatenated, and the item promotes to the
10249    /// Spoken lane so flush emits `AssistantBlock::Transcript`.
10250    #[test]
10251    fn realtime_transcript_final_text_overrides_partial_delta_and_promotes_to_spoken_lane() {
10252        let mut session = Session::new();
10253
10254        // Partial delta accumulates "incom" — simulating delta loss before
10255        // the final arrives.
10256        assert!(
10257            session
10258                .append_realtime_transcript_event(
10259                    RealtimeTranscriptEvent::AssistantTranscriptDelta {
10260                        response_id: "resp_a".to_string(),
10261                        delta_id: "evt_1".to_string(),
10262                        item_id: "item_a".to_string(),
10263                        previous_item_id: None,
10264                        content_index: 0,
10265                        delta: "incom".to_string(),
10266                    }
10267                )
10268                .is_inert()
10269        );
10270
10271        // Authoritative final text overrides the staged content.
10272        assert!(
10273            session
10274                .append_realtime_transcript_event(
10275                    RealtimeTranscriptEvent::AssistantTranscriptFinalText {
10276                        response_id: "resp_a".to_string(),
10277                        item_id: "item_a".to_string(),
10278                        content_index: 0,
10279                        text: "complete answer".to_string(),
10280                    }
10281                )
10282                .is_inert()
10283        );
10284
10285        // Turn completion drives the flush.
10286        let outcome = session.append_realtime_transcript_event(
10287            RealtimeTranscriptEvent::AssistantTurnCompleted {
10288                response_id: "resp_a".to_string(),
10289                stop_reason: StopReason::EndTurn,
10290                usage: Usage::default(),
10291            },
10292        );
10293        assert!(!outcome.is_inert());
10294
10295        // Verify the materialized block has the final's authoritative text
10296        // (not the partial "incom") and the Spoken lane.
10297        assert_eq!(session.messages().len(), 1);
10298        match &session.messages()[0] {
10299            Message::BlockAssistant(assistant) => {
10300                let mut found_transcript = false;
10301                for block in &assistant.blocks {
10302                    if let AssistantBlock::Transcript { text, .. } = block {
10303                        assert_eq!(text, "complete answer");
10304                        found_transcript = true;
10305                    }
10306                }
10307                assert!(
10308                    found_transcript,
10309                    "AssistantTranscriptFinalText must promote to the Spoken lane and \
10310                     materialize as AssistantBlock::Transcript"
10311                );
10312            }
10313            other => unreachable!("expected BlockAssistant, got {other:?}"),
10314        }
10315    }
10316
10317    /// R5-7: `AssistantTranscriptFinalText` works for final-only providers
10318    /// where no prior delta has staged an item.
10319    #[test]
10320    fn realtime_transcript_final_text_creates_item_when_no_delta_staged() {
10321        let mut session = Session::new();
10322
10323        assert!(
10324            session
10325                .append_realtime_transcript_event(
10326                    RealtimeTranscriptEvent::AssistantTranscriptFinalText {
10327                        response_id: "resp_a".to_string(),
10328                        item_id: "item_a".to_string(),
10329                        content_index: 0,
10330                        text: "spoken-final-only".to_string(),
10331                    }
10332                )
10333                .is_inert()
10334        );
10335
10336        let outcome = session.append_realtime_transcript_event(
10337            RealtimeTranscriptEvent::AssistantTurnCompleted {
10338                response_id: "resp_a".to_string(),
10339                stop_reason: StopReason::EndTurn,
10340                usage: Usage::default(),
10341            },
10342        );
10343        assert!(!outcome.is_inert());
10344
10345        assert_eq!(session.messages().len(), 1);
10346        match &session.messages()[0] {
10347            Message::BlockAssistant(assistant) => {
10348                let has_transcript = assistant.blocks.iter().any(|b| {
10349                    matches!(b, AssistantBlock::Transcript { text, .. } if text == "spoken-final-only")
10350                });
10351                assert!(
10352                    has_transcript,
10353                    "final-only provider path must materialize as Transcript on the Spoken lane"
10354                );
10355            }
10356            other => unreachable!("expected BlockAssistant, got {other:?}"),
10357        }
10358    }
10359
10360    #[test]
10361    fn realtime_transcript_append_orders_causally_equivalent_out_of_order_items() {
10362        let mut session = Session::new();
10363
10364        assert!(
10365            session
10366                .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
10367                    response_id: "resp_assistant".to_string(),
10368                    delta_id: "evt_delta_1".to_string(),
10369                    item_id: "item_assistant".to_string(),
10370                    previous_item_id: Some("item_user".to_string()),
10371                    content_index: 0,
10372                    delta: "answer".to_string(),
10373                })
10374                .is_inert()
10375        );
10376        assert!(
10377            session
10378                .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
10379                    response_id: "resp_assistant".to_string(),
10380                    stop_reason: StopReason::EndTurn,
10381                    usage: Usage::default(),
10382                })
10383                .is_inert()
10384        );
10385
10386        let outcome = session.append_realtime_transcript_event(
10387            RealtimeTranscriptEvent::UserTranscriptFinal {
10388                item_id: "item_user".to_string(),
10389                previous_item_id: None,
10390                content_index: 0,
10391                text: "question".to_string(),
10392            },
10393        );
10394
10395        assert_eq!(outcome.materialized_messages.len(), 2);
10396        assert_eq!(session.messages().len(), 2);
10397        assert!(matches!(
10398            &session.messages()[0],
10399            Message::User(user) if user.text_content() == "question"
10400        ));
10401        assert!(matches!(
10402            &session.messages()[1],
10403            Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "answer"
10404        ));
10405    }
10406
10407    #[test]
10408    fn realtime_transcript_replay_of_seen_provider_items_is_inert() {
10409        let mut session = Session::new();
10410        let events = vec![
10411            RealtimeTranscriptEvent::UserTranscriptFinal {
10412                item_id: "item_user".to_string(),
10413                previous_item_id: None,
10414                content_index: 0,
10415                text: "hello".to_string(),
10416            },
10417            RealtimeTranscriptEvent::AssistantTextDelta {
10418                response_id: "resp_assistant".to_string(),
10419                delta_id: "evt_delta_1".to_string(),
10420                item_id: "item_assistant".to_string(),
10421                previous_item_id: Some("item_user".to_string()),
10422                content_index: 0,
10423                delta: "world".to_string(),
10424            },
10425            RealtimeTranscriptEvent::AssistantTurnCompleted {
10426                response_id: "resp_assistant".to_string(),
10427                stop_reason: StopReason::EndTurn,
10428                usage: Usage::default(),
10429            },
10430        ];
10431
10432        for event in events.iter().cloned() {
10433            let _ = session.append_realtime_transcript_event(event);
10434        }
10435        let first_messages = serde_json::to_value(session.messages()).unwrap();
10436
10437        for event in events {
10438            assert!(session.append_realtime_transcript_event(event).is_inert());
10439        }
10440
10441        assert_eq!(
10442            serde_json::to_value(session.messages()).unwrap(),
10443            first_messages
10444        );
10445    }
10446
10447    #[test]
10448    fn realtime_transcript_user_final_replay_cannot_erase_existing_segment() {
10449        let mut session = Session::new();
10450
10451        let user = RealtimeTranscriptEvent::UserTranscriptFinal {
10452            item_id: "item_user".to_string(),
10453            previous_item_id: None,
10454            content_index: 0,
10455            text: "remember amber lantern".to_string(),
10456        };
10457        assert!(
10458            !session
10459                .append_realtime_transcript_event(user.clone())
10460                .is_inert()
10461        );
10462        let first_messages = serde_json::to_value(session.messages()).unwrap();
10463
10464        assert!(
10465            session
10466                .append_realtime_transcript_event(RealtimeTranscriptEvent::UserTranscriptFinal {
10467                    item_id: "item_user".to_string(),
10468                    previous_item_id: None,
10469                    content_index: 0,
10470                    text: String::new(),
10471                })
10472                .is_inert()
10473        );
10474        assert!(session.append_realtime_transcript_event(user).is_inert());
10475        assert_eq!(
10476            serde_json::to_value(session.messages()).unwrap(),
10477            first_messages
10478        );
10479    }
10480
10481    #[test]
10482    fn realtime_transcript_empty_user_final_can_be_filled_by_later_nonempty_replay() {
10483        let mut session = Session::new();
10484
10485        assert!(
10486            session
10487                .append_realtime_transcript_event(RealtimeTranscriptEvent::UserTranscriptFinal {
10488                    item_id: "item_user".to_string(),
10489                    previous_item_id: None,
10490                    content_index: 0,
10491                    text: String::new(),
10492                })
10493                .is_inert()
10494        );
10495        assert!(session.messages().is_empty());
10496
10497        let outcome = session.append_realtime_transcript_event(
10498            RealtimeTranscriptEvent::UserTranscriptFinal {
10499                item_id: "item_user".to_string(),
10500                previous_item_id: None,
10501                content_index: 0,
10502                text: "remember amber lantern".to_string(),
10503            },
10504        );
10505        assert_eq!(outcome.materialized_messages.len(), 1);
10506        assert_eq!(session.messages().len(), 1);
10507        assert!(matches!(
10508            &session.messages()[0],
10509            Message::User(user) if user.text_content() == "remember amber lantern"
10510        ));
10511    }
10512
10513    #[test]
10514    fn realtime_transcript_skipped_provider_items_preserve_causal_order_without_content() {
10515        let mut session = Session::new();
10516
10517        let assistant_delta = RealtimeTranscriptEvent::AssistantTextDelta {
10518            response_id: "resp_assistant".to_string(),
10519            delta_id: "evt_delta_1".to_string(),
10520            item_id: "item_assistant".to_string(),
10521            previous_item_id: Some("item_tool".to_string()),
10522            content_index: 0,
10523            delta: "done".to_string(),
10524        };
10525        assert!(
10526            session
10527                .append_realtime_transcript_event(assistant_delta.clone())
10528                .is_inert()
10529        );
10530        let assistant_complete = RealtimeTranscriptEvent::AssistantTurnCompleted {
10531            response_id: "resp_assistant".to_string(),
10532            stop_reason: StopReason::EndTurn,
10533            usage: Usage::default(),
10534        };
10535        assert!(
10536            session
10537                .append_realtime_transcript_event(assistant_complete.clone())
10538                .is_inert()
10539        );
10540
10541        let skipped = RealtimeTranscriptEvent::ItemSkipped {
10542            item_id: "item_tool".to_string(),
10543            previous_item_id: Some("item_user".to_string()),
10544        };
10545        assert!(
10546            session
10547                .append_realtime_transcript_event(skipped.clone())
10548                .is_inert(),
10549            "a skipped provider item must not append transcript content"
10550        );
10551        assert!(session.messages().is_empty());
10552
10553        let outcome = session.append_realtime_transcript_event(
10554            RealtimeTranscriptEvent::UserTranscriptFinal {
10555                item_id: "item_user".to_string(),
10556                previous_item_id: None,
10557                content_index: 0,
10558                text: "please use the tool".to_string(),
10559            },
10560        );
10561        assert_eq!(outcome.materialized_messages.len(), 2);
10562        assert_eq!(session.messages().len(), 2);
10563        assert!(matches!(
10564            &session.messages()[0],
10565            Message::User(user) if user.text_content() == "please use the tool"
10566        ));
10567        assert!(matches!(
10568            &session.messages()[1],
10569            Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "done"
10570        ));
10571
10572        let first_messages = serde_json::to_value(session.messages()).unwrap();
10573        assert!(session.append_realtime_transcript_event(skipped).is_inert());
10574        assert!(
10575            session
10576                .append_realtime_transcript_event(assistant_delta)
10577                .is_inert()
10578        );
10579        assert!(
10580            session
10581                .append_realtime_transcript_event(assistant_complete)
10582                .is_inert()
10583        );
10584        assert_eq!(
10585            serde_json::to_value(session.messages()).unwrap(),
10586            first_messages
10587        );
10588    }
10589
10590    #[test]
10591    fn realtime_transcript_interrupted_assistant_item_unblocks_later_provider_items() {
10592        // R5-5 (Round-5): the staged assistant content is a Display-lane item
10593        // (`AssistantTextDelta`). Under the new lane-aware barge-in contract,
10594        // the Display lane survives interruption and materializes. The User
10595        // "Stop." item, gated on the chained Display item being materialized,
10596        // also unblocks. Round-4's "must stay non-canonical" assertion was
10597        // wrong — that contract was lane-blind.
10598        let mut session = Session::new();
10599
10600        let _ = session.append_realtime_transcript_event(
10601            RealtimeTranscriptEvent::UserTranscriptFinal {
10602                item_id: "item_repeat".to_string(),
10603                previous_item_id: None,
10604                content_index: 0,
10605                text: "repeat until stop".to_string(),
10606            },
10607        );
10608        assert!(
10609            session
10610                .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
10611                    response_id: "resp_loop".to_string(),
10612                    delta_id: "evt_loop_1".to_string(),
10613                    item_id: "item_loop".to_string(),
10614                    previous_item_id: Some("item_repeat".to_string()),
10615                    content_index: 0,
10616                    delta: "Looping now".to_string(),
10617                })
10618                .is_inert()
10619        );
10620        assert!(
10621            session
10622                .append_realtime_transcript_event(RealtimeTranscriptEvent::UserTranscriptFinal {
10623                    item_id: "item_stop".to_string(),
10624                    previous_item_id: Some("item_loop".to_string()),
10625                    content_index: 0,
10626                    text: "Stop.".to_string(),
10627                })
10628                .is_inert(),
10629            "the stop turn waits until the interrupted assistant provider item is resolved"
10630        );
10631
10632        let outcome = session.append_realtime_transcript_event(
10633            RealtimeTranscriptEvent::AssistantTurnInterrupted {
10634                response_id: "resp_loop".to_string(),
10635            },
10636        );
10637
10638        // R5-5: materializer commits 2 messages (the retained Display item +
10639        // the unblocked "Stop." User message).
10640        assert_eq!(outcome.materialized_messages.len(), 2);
10641        // Canonical history: User-repeat, BlockAssistant(Display "Looping now"), User-Stop.
10642        assert_eq!(session.messages().len(), 3);
10643        assert!(matches!(
10644            &session.messages()[0],
10645            Message::User(user) if user.text_content() == "repeat until stop"
10646        ));
10647        match &session.messages()[1] {
10648            Message::BlockAssistant(assistant) => {
10649                let text = block_assistant_text(assistant);
10650                assert_eq!(text, "Looping now");
10651            }
10652            other => unreachable!(
10653                "Display lane assistant item must be retained on Interrupted, got {other:?}"
10654            ),
10655        }
10656        assert!(matches!(
10657            &session.messages()[2],
10658            Message::User(user) if user.text_content() == "Stop."
10659        ));
10660    }
10661
10662    #[test]
10663    fn realtime_transcript_late_interrupted_assistant_delta_stays_noncanonical() {
10664        let mut session = Session::new();
10665
10666        let _ = session.append_realtime_transcript_event(
10667            RealtimeTranscriptEvent::UserTranscriptFinal {
10668                item_id: "item_repeat".to_string(),
10669                previous_item_id: None,
10670                content_index: 0,
10671                text: "repeat until stop".to_string(),
10672            },
10673        );
10674        assert!(
10675            session
10676                .append_realtime_transcript_event(RealtimeTranscriptEvent::ItemObserved {
10677                    item_id: "item_loop".to_string(),
10678                    previous_item_id: Some("item_repeat".to_string()),
10679                    role: RealtimeTranscriptRole::Assistant,
10680                    response_id: None,
10681                })
10682                .is_inert(),
10683            "provider can observe an assistant item before the adapter learns its response id"
10684        );
10685        assert!(
10686            session
10687                .append_realtime_transcript_event(
10688                    RealtimeTranscriptEvent::AssistantTurnInterrupted {
10689                        response_id: "resp_loop".to_string(),
10690                    }
10691                )
10692                .is_inert(),
10693            "an interruption can arrive before delayed transcript deltas for the response"
10694        );
10695        assert!(
10696            session
10697                .append_realtime_transcript_event(RealtimeTranscriptEvent::UserTranscriptFinal {
10698                    item_id: "item_stop".to_string(),
10699                    previous_item_id: Some("item_loop".to_string()),
10700                    content_index: 0,
10701                    text: "Stop.".to_string(),
10702                })
10703                .is_inert(),
10704            "the stop turn waits for the provider's interrupted assistant item anchor"
10705        );
10706
10707        let late_delta_outcome =
10708            session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
10709                response_id: "resp_loop".to_string(),
10710                delta_id: "evt_loop_late".to_string(),
10711                item_id: "item_loop".to_string(),
10712                previous_item_id: Some("item_repeat".to_string()),
10713                content_index: 0,
10714                delta: "Looping now".to_string(),
10715            });
10716        assert_eq!(late_delta_outcome.materialized_messages.len(), 1);
10717        assert!(matches!(
10718            &session.messages()[1],
10719            Message::User(user) if user.text_content() == "Stop."
10720        ));
10721        assert!(
10722            session
10723                .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
10724                    response_id: "resp_loop".to_string(),
10725                    stop_reason: StopReason::EndTurn,
10726                    usage: Usage::default(),
10727                })
10728                .is_inert(),
10729            "late completion for an interrupted response must not resurrect its deltas"
10730        );
10731        assert!(
10732            session
10733                .messages()
10734                .iter()
10735                .filter_map(|message| match message {
10736                    Message::BlockAssistant(assistant) => Some(block_assistant_text(assistant)),
10737                    _ => None,
10738                })
10739                .all(|text| !text.contains("Looping now")),
10740            "late interrupted assistant text must remain non-canonical"
10741        );
10742    }
10743
10744    #[test]
10745    fn realtime_transcript_completion_only_finalizes_matching_response() {
10746        let mut session = Session::new();
10747
10748        let _ = session.append_realtime_transcript_event(
10749            RealtimeTranscriptEvent::UserTranscriptFinal {
10750                item_id: "item_user".to_string(),
10751                previous_item_id: None,
10752                content_index: 0,
10753                text: "question".to_string(),
10754            },
10755        );
10756        assert!(
10757            session
10758                .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
10759                    response_id: "resp_a".to_string(),
10760                    delta_id: "evt_a".to_string(),
10761                    item_id: "item_a".to_string(),
10762                    previous_item_id: Some("item_user".to_string()),
10763                    content_index: 0,
10764                    delta: "answer a".to_string(),
10765                })
10766                .is_inert()
10767        );
10768
10769        assert!(
10770            session
10771                .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
10772                    response_id: "resp_b".to_string(),
10773                    stop_reason: StopReason::EndTurn,
10774                    usage: Usage::default(),
10775                })
10776                .is_inert(),
10777            "a completion for another response must not finalize buffered assistant text"
10778        );
10779        assert_eq!(session.messages().len(), 1);
10780
10781        let outcome = session.append_realtime_transcript_event(
10782            RealtimeTranscriptEvent::AssistantTurnCompleted {
10783                response_id: "resp_a".to_string(),
10784                stop_reason: StopReason::EndTurn,
10785                usage: Usage::default(),
10786            },
10787        );
10788        assert_eq!(outcome.materialized_messages.len(), 1);
10789        assert_eq!(session.messages().len(), 2);
10790        assert!(matches!(
10791            &session.messages()[1],
10792            Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "answer a"
10793        ));
10794    }
10795
10796    #[test]
10797    fn realtime_transcript_completion_before_later_delta_is_response_scoped() {
10798        let mut session = Session::new();
10799
10800        let _ = session.append_realtime_transcript_event(
10801            RealtimeTranscriptEvent::UserTranscriptFinal {
10802                item_id: "item_user".to_string(),
10803                previous_item_id: None,
10804                content_index: 0,
10805                text: "question".to_string(),
10806            },
10807        );
10808        assert!(
10809            session
10810                .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
10811                    response_id: "resp_a".to_string(),
10812                    stop_reason: StopReason::EndTurn,
10813                    usage: Usage::default(),
10814                })
10815                .is_inert()
10816        );
10817        assert!(
10818            session
10819                .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
10820                    response_id: "resp_b".to_string(),
10821                    delta_id: "evt_b".to_string(),
10822                    item_id: "item_b".to_string(),
10823                    previous_item_id: Some("item_user".to_string()),
10824                    content_index: 0,
10825                    delta: "wrong response".to_string(),
10826                })
10827                .is_inert(),
10828            "a later delta for another response must not be finalized by resp_a's pending completion"
10829        );
10830
10831        let outcome =
10832            session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
10833                response_id: "resp_a".to_string(),
10834                delta_id: "evt_a".to_string(),
10835                item_id: "item_a".to_string(),
10836                previous_item_id: Some("item_user".to_string()),
10837                content_index: 0,
10838                delta: "right response".to_string(),
10839            });
10840
10841        assert_eq!(outcome.materialized_messages.len(), 1);
10842        assert_eq!(session.messages().len(), 2);
10843        assert!(matches!(
10844            &session.messages()[1],
10845            Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "right response"
10846        ));
10847    }
10848
10849    #[test]
10850    fn realtime_transcript_late_duplicate_completion_cannot_finalize_unrelated_response() {
10851        let mut session = Session::new();
10852
10853        let _ = session.append_realtime_transcript_event(
10854            RealtimeTranscriptEvent::UserTranscriptFinal {
10855                item_id: "item_user".to_string(),
10856                previous_item_id: None,
10857                content_index: 0,
10858                text: "question".to_string(),
10859            },
10860        );
10861        let _ =
10862            session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
10863                response_id: "resp_a".to_string(),
10864                delta_id: "evt_a".to_string(),
10865                item_id: "item_a".to_string(),
10866                previous_item_id: Some("item_user".to_string()),
10867                content_index: 0,
10868                delta: "first".to_string(),
10869            });
10870        let _ = session.append_realtime_transcript_event(
10871            RealtimeTranscriptEvent::AssistantTurnCompleted {
10872                response_id: "resp_a".to_string(),
10873                stop_reason: StopReason::EndTurn,
10874                usage: Usage::default(),
10875            },
10876        );
10877        assert_eq!(session.messages().len(), 2);
10878
10879        assert!(
10880            session
10881                .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
10882                    response_id: "resp_b".to_string(),
10883                    delta_id: "evt_b".to_string(),
10884                    item_id: "item_b".to_string(),
10885                    previous_item_id: Some("item_a".to_string()),
10886                    content_index: 0,
10887                    delta: "second".to_string(),
10888                })
10889                .is_inert()
10890        );
10891        assert!(
10892            session
10893                .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
10894                    response_id: "resp_a".to_string(),
10895                    stop_reason: StopReason::EndTurn,
10896                    usage: Usage::default(),
10897                })
10898                .is_inert(),
10899            "a duplicate late terminal for resp_a must not finalize resp_b"
10900        );
10901        assert_eq!(session.messages().len(), 2);
10902
10903        let outcome = session.append_realtime_transcript_event(
10904            RealtimeTranscriptEvent::AssistantTurnCompleted {
10905                response_id: "resp_b".to_string(),
10906                stop_reason: StopReason::EndTurn,
10907                usage: Usage::default(),
10908            },
10909        );
10910        assert_eq!(outcome.materialized_messages.len(), 1);
10911        assert_eq!(session.messages().len(), 3);
10912    }
10913
10914    #[test]
10915    fn realtime_transcript_interruption_discards_only_matching_response() {
10916        // R5-5: cross-response isolation invariant — Interrupted on resp_a
10917        // does NOT touch resp_b's staged content. Both responses use
10918        // `AssistantTextDelta` (Display lane); under R5-5 resp_a's Display
10919        // item is RETAINED at Interrupted time and resp_b's continues
10920        // unaffected, materializing on its later TurnCompleted.
10921        let mut session = Session::new();
10922
10923        let _ = session.append_realtime_transcript_event(
10924            RealtimeTranscriptEvent::UserTranscriptFinal {
10925                item_id: "item_user".to_string(),
10926                previous_item_id: None,
10927                content_index: 0,
10928                text: "question".to_string(),
10929            },
10930        );
10931        let _ =
10932            session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
10933                response_id: "resp_a".to_string(),
10934                delta_id: "evt_a".to_string(),
10935                item_id: "item_a".to_string(),
10936                previous_item_id: Some("item_user".to_string()),
10937                content_index: 0,
10938                delta: "interrupted display".to_string(),
10939            });
10940        let _ =
10941            session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
10942                response_id: "resp_b".to_string(),
10943                delta_id: "evt_b".to_string(),
10944                item_id: "item_b".to_string(),
10945                previous_item_id: Some("item_user".to_string()),
10946                content_index: 0,
10947                delta: "keep me".to_string(),
10948            });
10949
10950        // R5-5: Interrupted commits the resp_a Display item; resp_b
10951        // remains untouched.
10952        let interrupt_outcome = session.append_realtime_transcript_event(
10953            RealtimeTranscriptEvent::AssistantTurnInterrupted {
10954                response_id: "resp_a".to_string(),
10955            },
10956        );
10957        assert_eq!(
10958            interrupt_outcome.materialized_messages.len(),
10959            1,
10960            "resp_a's Display item commits on Interrupted"
10961        );
10962
10963        let outcome = session.append_realtime_transcript_event(
10964            RealtimeTranscriptEvent::AssistantTurnCompleted {
10965                response_id: "resp_b".to_string(),
10966                stop_reason: StopReason::EndTurn,
10967                usage: Usage::default(),
10968            },
10969        );
10970        assert_eq!(
10971            outcome.materialized_messages.len(),
10972            1,
10973            "resp_b commits on its TurnCompleted, untouched by resp_a's Interrupted"
10974        );
10975
10976        // 1 user + 2 assistant messages.
10977        assert_eq!(session.messages().len(), 3);
10978        assert!(matches!(
10979            &session.messages()[1],
10980            Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "interrupted display"
10981        ));
10982        assert!(matches!(
10983            &session.messages()[2],
10984            Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "keep me"
10985        ));
10986    }
10987
10988    // Performance tests for Arc-based CoW
10989
10990    #[test]
10991    fn test_fork_shares_arc_no_clone() {
10992        let mut session = Session::new();
10993        for i in 0..100 {
10994            session.push(Message::User(UserMessage::text(format!("Message {i}"))));
10995        }
10996
10997        // Fork should share the same Arc, not clone messages
10998        let forked = session.fork();
10999
11000        // Both should point to the same underlying data (Arc refcount > 1)
11001        assert!(Arc::ptr_eq(session.messages.arc(), forked.messages.arc()));
11002        assert_eq!(forked.messages().len(), 100);
11003    }
11004
11005    #[test]
11006    fn test_fork_at_shares_arc_prefix() {
11007        let mut session = Session::new();
11008        for i in 0..100 {
11009            session.push(Message::User(UserMessage::text(format!("Message {i}"))));
11010        }
11011
11012        // Fork at 50 should create new Arc with copied prefix
11013        let forked = session.fork_at(50);
11014        assert_eq!(forked.messages().len(), 50);
11015
11016        // Original should be unchanged
11017        assert_eq!(session.messages().len(), 100);
11018    }
11019
11020    #[test]
11021    fn test_fork_at_resets_transcript_history_state_for_branch_identity() {
11022        let mut session = Session::new();
11023        session.push(Message::User(UserMessage::text(
11024            "summarize this".to_string(),
11025        )));
11026        session.push(Message::BlockAssistant(BlockAssistantMessage::new(
11027            vec![AssistantBlock::Text {
11028                text: "long assistant trace".to_string(),
11029                meta: None,
11030            }],
11031            StopReason::EndTurn,
11032        )));
11033        let parent_revision = session.transcript_revision().expect("parent revision");
11034        session
11035            .commit_transcript_rewrite(
11036                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
11037                vec![Message::BlockAssistant(BlockAssistantMessage::new(
11038                    vec![AssistantBlock::Text {
11039                        text: "compact trace".to_string(),
11040                        meta: None,
11041                    }],
11042                    StopReason::EndTurn,
11043                ))],
11044                TranscriptRewriteReason::new("compaction"),
11045                Some("test".to_string()),
11046                Some(parent_revision),
11047            )
11048            .expect("rewrite should commit");
11049
11050        let source_head = session.transcript_revision().expect("source head");
11051        let mut forked = session.fork_at(1);
11052        assert_ne!(forked.id(), session.id());
11053        assert!(
11054            !forked
11055                .metadata()
11056                .contains_key(SESSION_TRANSCRIPT_HISTORY_STATE_KEY)
11057        );
11058        assert_eq!(
11059            forked.transcript_revision().expect("fork head"),
11060            transcript_messages_digest(forked.messages()).expect("fork digest")
11061        );
11062        assert!(
11063            forked
11064                .transcript_revision_messages(&source_head)
11065                .expect("fork history lookup")
11066                .is_none()
11067        );
11068
11069        let fork_parent = forked.transcript_revision().expect("fork parent");
11070        let commit = forked
11071            .commit_transcript_rewrite(
11072                TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
11073                vec![Message::User(UserMessage::text(
11074                    "branch prompt".to_string(),
11075                ))],
11076                TranscriptRewriteReason::new("branch_edit"),
11077                Some("test".to_string()),
11078                Some(fork_parent.clone()),
11079            )
11080            .expect("fork rewrite should use fork-local parent");
11081        assert_eq!(commit.parent_revision, fork_parent);
11082    }
11083
11084    #[test]
11085    fn test_push_cow_behavior() {
11086        let mut session = Session::new();
11087        session.push(Message::User(UserMessage::text("First".to_string())));
11088
11089        // Fork shares the Arc
11090        let forked = session.fork();
11091        assert!(Arc::ptr_eq(session.messages.arc(), forked.messages.arc()));
11092
11093        // Push on original triggers CoW - original gets new Arc
11094        session.push(Message::User(UserMessage::text("Second".to_string())));
11095
11096        // Now they should have different Arcs
11097        assert!(!Arc::ptr_eq(session.messages.arc(), forked.messages.arc()));
11098        assert_eq!(session.messages().len(), 2);
11099        assert_eq!(forked.messages().len(), 1);
11100    }
11101
11102    // Performance tests for lazy timestamp updates
11103
11104    #[test]
11105    fn test_push_batch_single_timestamp() {
11106        let mut session = Session::new();
11107        let initial_updated = session.updated_at();
11108
11109        // Use push_batch to add multiple messages without repeated syscalls
11110        session.push_batch(vec![
11111            Message::User(UserMessage::text("First".to_string())),
11112            Message::User(UserMessage::text("Second".to_string())),
11113            Message::User(UserMessage::text("Third".to_string())),
11114        ]);
11115
11116        assert_eq!(session.messages().len(), 3);
11117        // Timestamp should have been updated once
11118        assert!(session.updated_at() >= initial_updated);
11119    }
11120
11121    #[test]
11122    fn test_touch_updates_timestamp() {
11123        let mut session = Session::new();
11124        let initial = session.updated_at();
11125
11126        std::thread::sleep(std::time::Duration::from_millis(10));
11127
11128        // Explicit touch to update timestamp
11129        session.touch();
11130
11131        assert!(session.updated_at() > initial);
11132    }
11133
11134    #[test]
11135    fn test_session_push() {
11136        let mut session = Session::new();
11137        let initial_updated = session.updated_at();
11138
11139        // Small delay to ensure time changes
11140        std::thread::sleep(std::time::Duration::from_millis(10));
11141
11142        session.push(Message::User(UserMessage::text("Hello".to_string())));
11143
11144        assert_eq!(session.messages().len(), 1);
11145        assert!(session.updated_at() > initial_updated);
11146    }
11147
11148    #[test]
11149    fn test_session_fork() {
11150        let mut session = Session::new();
11151        session.push(Message::System(SystemMessage::new("System prompt")));
11152        session.push(Message::User(UserMessage::text("Hello".to_string())));
11153        session.push(Message::BlockAssistant(BlockAssistantMessage {
11154            blocks: vec![AssistantBlock::Text {
11155                text: "Hi!".to_string(),
11156                meta: None,
11157            }],
11158            stop_reason: StopReason::EndTurn,
11159            identity: crate::types::TranscriptMessageIdentity::default(),
11160            created_at: crate::types::message_timestamp_now(),
11161        }));
11162
11163        // Fork at index 2 (system + user)
11164        let forked = session.fork_at(2);
11165        assert_eq!(forked.messages().len(), 2);
11166        assert_ne!(forked.id(), session.id());
11167
11168        // Full fork
11169        let full_fork = session.fork();
11170        assert_eq!(full_fork.messages().len(), 3);
11171    }
11172
11173    #[test]
11174    fn test_session_forks_drop_generated_authority_metadata() {
11175        let mut session = Session::new();
11176        session.push(Message::User(UserMessage::text("original")));
11177        session.set_metadata("ordinary", serde_json::json!("keep"));
11178        session
11179            .set_build_state(SessionBuildState::default())
11180            .expect("build state should serialize");
11181        session
11182            .set_deferred_turn_state(SessionDeferredTurnState::default())
11183            .expect("deferred-turn state should serialize");
11184        session
11185            .set_tool_visibility_state(
11186                AuthorizedSessionToolVisibilityState::from_generated_authority(
11187                    SessionToolVisibilityState::default(),
11188                ),
11189            )
11190            .expect("visibility state should serialize");
11191        let _ = session.append_realtime_transcript_event(RealtimeTranscriptEvent::ItemObserved {
11192            item_id: "rt-item".to_string(),
11193            previous_item_id: None,
11194            role: RealtimeTranscriptRole::User,
11195            response_id: None,
11196        });
11197        session.metadata.insert(
11198            crate::memory::SESSION_COMPACTION_PROJECTION_INTENTS_KEY.to_string(),
11199            serde_json::json!([{"sealed_projection": "must-not-fork"}]),
11200        );
11201        assert!(
11202            !session
11203                .metadata()
11204                .contains_key(SESSION_REALTIME_TRANSCRIPT_STATE_KEY),
11205            "typed realtime authority must not leak into the raw metadata map"
11206        );
11207        assert_eq!(
11208            session
11209                .realtime_component_event_prefix()
11210                .expect("realtime component prefix")
11211                .event_count(),
11212            1,
11213            "test setup should park one typed realtime event"
11214        );
11215
11216        let forked_at = session.fork_at(1);
11217        let full_fork = session.fork();
11218        let replaced = session
11219            .fork_replacing(
11220                0,
11221                TranscriptReplacement::Message {
11222                    message: Message::User(UserMessage::text("replacement")),
11223                },
11224            )
11225            .expect("replacement fork should succeed");
11226
11227        for forked in [&forked_at, &full_fork, &replaced] {
11228            assert_eq!(forked.metadata().get("ordinary").unwrap(), "keep");
11229            assert!(
11230                !forked.metadata().contains_key(SESSION_BUILD_STATE_KEY),
11231                "forked sessions must not raw-copy durable build-state authority"
11232            );
11233            assert!(
11234                !forked
11235                    .metadata()
11236                    .contains_key(SESSION_DEFERRED_TURN_STATE_KEY),
11237                "forked sessions must not raw-copy deferred-turn authority state"
11238            );
11239            assert!(
11240                !forked
11241                    .metadata()
11242                    .contains_key(SESSION_TOOL_VISIBILITY_STATE_KEY),
11243                "forked sessions must not raw-copy tool-visibility authority state"
11244            );
11245            assert!(
11246                !forked
11247                    .metadata()
11248                    .contains_key(SESSION_REALTIME_TRANSCRIPT_STATE_KEY),
11249                "forked sessions must not raw-copy realtime transcript authority state"
11250            );
11251            assert_eq!(
11252                forked
11253                    .realtime_component_event_prefix()
11254                    .expect("fork realtime component prefix")
11255                    .event_count(),
11256                0,
11257                "forked sessions must start a new empty realtime component lineage"
11258            );
11259            assert!(
11260                !forked
11261                    .metadata()
11262                    .contains_key(crate::memory::SESSION_COMPACTION_PROJECTION_INTENTS_KEY),
11263                "forked sessions must not raw-copy compaction outbox authority"
11264            );
11265        }
11266    }
11267
11268    #[test]
11269    fn test_session_metadata() {
11270        let mut session = Session::new();
11271        session.set_metadata("key", serde_json::json!("value"));
11272
11273        assert_eq!(session.metadata().get("key").unwrap(), "value");
11274    }
11275
11276    #[test]
11277    fn identical_metadata_projection_is_wire_idempotent() {
11278        let mut session = Session::new();
11279        session.set_metadata("key", serde_json::json!({ "value": 1 }));
11280        let updated_at = session.updated_at;
11281        let bytes = session
11282            .to_persisted_bytes()
11283            .expect("session bytes before identical projection");
11284
11285        session.set_metadata("key", serde_json::json!({ "value": 1 }));
11286        session.remove_metadata("already_absent");
11287
11288        assert_eq!(
11289            session.updated_at, updated_at,
11290            "an identical durable projection must not manufacture a content mutation"
11291        );
11292        assert_eq!(
11293            session
11294                .to_persisted_bytes()
11295                .expect("session bytes after identical projection"),
11296            bytes,
11297            "an identical durable projection must not rotate current Session bytes"
11298        );
11299    }
11300
11301    #[test]
11302    fn session_metadata_realm_id_is_back_read_compatible_string() {
11303        // A typed realm_id serializes as a bare JSON string (byte-identical to
11304        // the prior Option<String> durable shape).
11305        let metadata = SessionMetadata {
11306            schema_version: SESSION_METADATA_SCHEMA_VERSION,
11307            model: "test-model".to_string(),
11308            max_tokens: 1024,
11309            structured_output_retries: 2,
11310            provider: Provider::Other,
11311            self_hosted_server_id: None,
11312            provider_params: None,
11313            tooling: SessionTooling::default(),
11314            keep_alive: false,
11315            comms_name: None,
11316            peer_meta: None,
11317            realm_id: Some(crate::RealmId::parse("env_default").unwrap()),
11318            instance_id: None,
11319            backend: None,
11320            config_generation: None,
11321            auth_binding: None,
11322            mob_member_binding: None,
11323        };
11324        let value = serde_json::to_value(&metadata).unwrap();
11325        assert_eq!(
11326            value.get("realm_id"),
11327            Some(&serde_json::json!("env_default")),
11328            "typed realm_id must serialize as a bare slug string"
11329        );
11330
11331        // A legacy persisted row stored realm_id as a JSON string; it must
11332        // deserialize into the typed RealmId (durable back-read).
11333        let legacy = serde_json::json!({
11334            "schema_version": SESSION_METADATA_SCHEMA_VERSION,
11335            "model": "test-model",
11336            "max_tokens": 1024,
11337            "structured_output_retries": 2,
11338            "provider": "other",
11339            "tooling": SessionTooling::default(),
11340            "keep_alive": false,
11341            "comms_name": null,
11342            "realm_id": "legacy_realm",
11343        });
11344        let restored: SessionMetadata = serde_json::from_value(legacy).unwrap();
11345        assert_eq!(
11346            restored.realm_id.as_ref().map(crate::RealmId::as_str),
11347            Some("legacy_realm")
11348        );
11349    }
11350
11351    /// Ask 6: `SessionTooling.tool_access_policy` is additive — a persisted
11352    /// row without the field back-reads as `None` (unrestricted), `None` is
11353    /// omitted on write (durable shape unchanged for ungated sessions), and a
11354    /// resolved policy round-trips intact.
11355    #[test]
11356    fn session_tooling_tool_access_policy_round_trip_and_absent_default() {
11357        // Absent field back-reads as None.
11358        let legacy = serde_json::json!({});
11359        let restored: SessionTooling = serde_json::from_value(legacy).unwrap();
11360        assert_eq!(restored.tool_access_policy, None);
11361
11362        // None is omitted on write — ungated sessions keep their prior shape.
11363        let value = serde_json::to_value(SessionTooling::default()).unwrap();
11364        assert!(
11365            value.get("tool_access_policy").is_none(),
11366            "None policy must not serialize"
11367        );
11368
11369        // A resolved policy round-trips intact.
11370        let tooling = SessionTooling {
11371            tool_access_policy: Some(crate::ops::ToolAccessPolicy::AllowList(
11372                ["read_file", "send_message"].into_iter().collect(),
11373            )),
11374            ..SessionTooling::default()
11375        };
11376        let value = serde_json::to_value(&tooling).unwrap();
11377        let restored: SessionTooling = serde_json::from_value(value).unwrap();
11378        assert_eq!(restored.tool_access_policy, tooling.tool_access_policy);
11379    }
11380
11381    #[test]
11382    fn lifecycle_terminal_typed_round_trip() {
11383        let mut session = Session::new();
11384        assert_eq!(session.lifecycle_terminal(), None);
11385
11386        session
11387            .set_lifecycle_terminal(SessionLifecycleTerminal::Archived)
11388            .expect("typed terminal write should serialize");
11389        assert_eq!(
11390            session.lifecycle_terminal(),
11391            Some(SessionLifecycleTerminal::Archived)
11392        );
11393        assert!(
11394            session
11395                .lifecycle_terminal()
11396                .is_some_and(SessionLifecycleTerminal::is_archived)
11397        );
11398        // Persisted JSON for the typed key is the snake_case variant string.
11399        assert_eq!(
11400            session
11401                .metadata()
11402                .get(SESSION_LIFECYCLE_TERMINAL_KEY)
11403                .unwrap(),
11404            &serde_json::json!("archived")
11405        );
11406    }
11407
11408    #[test]
11409    fn recovered_head_adoption_keeps_archived_absorbing_from_either_copy() {
11410        let mut archived_recovery = Session::new();
11411        archived_recovery
11412            .set_lifecycle_terminal(SessionLifecycleTerminal::Archived)
11413            .expect("archive terminal serializes");
11414        let mut active_head = archived_recovery.clone();
11415        active_head
11416            .set_lifecycle_terminal(SessionLifecycleTerminal::Active)
11417            .expect("active terminal serializes");
11418        archived_recovery
11419            .adopt_recovered_head_state(&active_head)
11420            .expect("generated lifecycle merge resolves");
11421        assert_eq!(
11422            archived_recovery.lifecycle_terminal(),
11423            Some(SessionLifecycleTerminal::Archived),
11424            "a newer Active projection must not resurrect an Archived recovery base"
11425        );
11426
11427        let mut active_recovery = Session::new();
11428        active_recovery
11429            .set_lifecycle_terminal(SessionLifecycleTerminal::Active)
11430            .expect("active terminal serializes");
11431        let mut archived_head = active_recovery.clone();
11432        archived_head
11433            .set_lifecycle_terminal(SessionLifecycleTerminal::Archived)
11434            .expect("archive terminal serializes");
11435        active_recovery
11436            .adopt_recovered_head_state(&archived_head)
11437            .expect("generated lifecycle merge resolves");
11438        assert_eq!(
11439            active_recovery.lifecycle_terminal(),
11440            Some(SessionLifecycleTerminal::Archived),
11441            "an Archived durable head must remain terminal after recovery adoption"
11442        );
11443    }
11444
11445    #[test]
11446    fn lifecycle_terminal_key_rejects_raw_mutation() {
11447        let mut session = Session::new();
11448        assert!(
11449            session
11450                .try_set_metadata(
11451                    SESSION_LIFECYCLE_TERMINAL_KEY,
11452                    serde_json::json!("archived")
11453                )
11454                .is_err(),
11455            "the typed lifecycle-terminal key is reserved for session authority"
11456        );
11457    }
11458
11459    #[test]
11460    fn test_session_metadata_backfill_preserves_timestamp() {
11461        let mut session = Session::new();
11462        let initial_updated = session.updated_at();
11463
11464        std::thread::sleep(std::time::Duration::from_millis(10));
11465
11466        assert!(session.backfill_metadata_if_absent("key", serde_json::json!("value")));
11467        assert_eq!(session.metadata().get("key").unwrap(), "value");
11468        assert_eq!(session.updated_at(), initial_updated);
11469        assert!(!session.backfill_metadata_if_absent("key", serde_json::json!("other")));
11470        assert_eq!(session.metadata().get("key").unwrap(), "value");
11471        assert_eq!(session.updated_at(), initial_updated);
11472    }
11473
11474    #[test]
11475    fn test_reserved_generated_authority_metadata_rejects_raw_mutation() {
11476        let mut session = Session::new();
11477
11478        assert!(
11479            session
11480                .try_set_metadata(SESSION_METADATA_KEY, serde_json::json!({}))
11481                .is_err()
11482        );
11483        assert!(
11484            session
11485                .try_set_metadata(SESSION_BUILD_STATE_KEY, serde_json::json!({}))
11486                .is_err()
11487        );
11488        assert!(
11489            session
11490                .try_set_metadata(
11491                    SESSION_TRANSCRIPT_REWRITE_PREFIX_AUTHORITY_KEY,
11492                    serde_json::json!({
11493                        "occurrence_count": 0,
11494                        "digest": "sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
11495                    })
11496                )
11497                .is_err(),
11498            "raw metadata must not forge rewrite-prefix authority"
11499        );
11500        let compaction_intents_key = crate::memory::SESSION_COMPACTION_PROJECTION_INTENTS_KEY;
11501        let sealed_compaction_intents =
11502            serde_json::json!([{"sealed_projection": "typed-owner-only"}]);
11503        session.metadata.insert(
11504            compaction_intents_key.to_string(),
11505            sealed_compaction_intents.clone(),
11506        );
11507        assert!(
11508            session
11509                .try_set_metadata(compaction_intents_key, serde_json::json!([]))
11510                .is_err(),
11511            "raw metadata must not overwrite compaction outbox authority"
11512        );
11513        session.remove_metadata(compaction_intents_key);
11514        assert_eq!(
11515            session.metadata().get(compaction_intents_key),
11516            Some(&sealed_compaction_intents),
11517            "raw metadata removal must not erase compaction outbox authority"
11518        );
11519        let mut absent = Session::new();
11520        assert!(
11521            !absent.backfill_metadata_if_absent(
11522                compaction_intents_key,
11523                serde_json::json!([{"forged_projection": true}])
11524            ),
11525            "compatibility backfill must not fabricate compaction outbox authority"
11526        );
11527        assert!(!absent.metadata().contains_key(compaction_intents_key));
11528        session
11529            .set_session_metadata(SessionMetadata {
11530                schema_version: SESSION_METADATA_SCHEMA_VERSION,
11531                model: "test-model".to_string(),
11532                max_tokens: 1024,
11533                structured_output_retries: 2,
11534                provider: Provider::Other,
11535                self_hosted_server_id: None,
11536                provider_params: None,
11537                tooling: SessionTooling::default(),
11538                keep_alive: false,
11539                comms_name: None,
11540                peer_meta: None,
11541                realm_id: None,
11542                instance_id: None,
11543                backend: None,
11544                config_generation: None,
11545                auth_binding: None,
11546                mob_member_binding: None,
11547            })
11548            .expect("typed metadata setter should route through generated authority");
11549        session
11550            .set_build_state(SessionBuildState::default())
11551            .expect("typed build-state setter should route through generated authority");
11552        session.remove_metadata(SESSION_METADATA_KEY);
11553        session.remove_metadata(SESSION_BUILD_STATE_KEY);
11554        assert!(
11555            session.metadata().contains_key(SESSION_METADATA_KEY),
11556            "raw removal must not delete generated-authority session metadata"
11557        );
11558        assert!(
11559            session.metadata().contains_key(SESSION_BUILD_STATE_KEY),
11560            "raw removal must not delete generated-authority build state"
11561        );
11562        session.set_metadata(SESSION_DEFERRED_TURN_STATE_KEY, serde_json::json!({}));
11563        assert!(
11564            !session
11565                .metadata()
11566                .contains_key(SESSION_DEFERRED_TURN_STATE_KEY)
11567        );
11568        session.metadata.insert(
11569            SESSION_METADATA_KEY.to_string(),
11570            serde_json::json!("not-metadata"),
11571        );
11572        assert!(
11573            session.try_session_metadata().is_err(),
11574            "malformed session metadata must not decode as absent/default"
11575        );
11576
11577        session.metadata.insert(
11578            SESSION_BUILD_STATE_KEY.to_string(),
11579            serde_json::json!("not-build-state"),
11580        );
11581        assert!(
11582            session.try_build_state().is_err(),
11583            "malformed build state must not decode as absent/default"
11584        );
11585
11586        assert!(
11587            session
11588                .try_set_metadata(SESSION_TOOL_VISIBILITY_STATE_KEY, serde_json::json!({}))
11589                .is_err()
11590        );
11591        session
11592            .set_tool_visibility_state(
11593                AuthorizedSessionToolVisibilityState::from_generated_authority(
11594                    SessionToolVisibilityState::default(),
11595                ),
11596            )
11597            .expect("typed visibility setter should route through typed authority handoff");
11598        session.remove_metadata(SESSION_TOOL_VISIBILITY_STATE_KEY);
11599        assert!(
11600            session
11601                .metadata()
11602                .contains_key(SESSION_TOOL_VISIBILITY_STATE_KEY)
11603        );
11604        session.clear_tool_visibility_state();
11605        assert!(
11606            !session
11607                .metadata()
11608                .contains_key(SESSION_TOOL_VISIBILITY_STATE_KEY)
11609        );
11610        assert!(
11611            session
11612                .try_set_metadata(SESSION_REALTIME_TRANSCRIPT_STATE_KEY, serde_json::json!({}))
11613                .is_err()
11614        );
11615        let _ = session.append_realtime_transcript_event(RealtimeTranscriptEvent::ItemObserved {
11616            item_id: "rt-item".to_string(),
11617            previous_item_id: None,
11618            role: RealtimeTranscriptRole::User,
11619            response_id: None,
11620        });
11621        assert!(
11622            !session
11623                .metadata()
11624                .contains_key(SESSION_REALTIME_TRANSCRIPT_STATE_KEY),
11625            "typed realtime transcript append must not recreate raw shadow authority"
11626        );
11627        assert_eq!(
11628            session
11629                .realtime_component_event_prefix()
11630                .expect("typed realtime prefix")
11631                .event_count(),
11632            1,
11633            "typed append must advance the authenticated component prefix"
11634        );
11635        session.metadata.insert(
11636            SESSION_REALTIME_TRANSCRIPT_STATE_KEY.to_string(),
11637            serde_json::json!("not-a-state"),
11638        );
11639        let whole_blob =
11640            serde_json::to_value(&session).expect("typed projection must override a raw shadow");
11641        let projected = whole_blob
11642            .get("metadata")
11643            .and_then(serde_json::Value::as_object)
11644            .and_then(|metadata| metadata.get(SESSION_REALTIME_TRANSCRIPT_STATE_KEY))
11645            .expect("WholeBlob projection");
11646        assert!(
11647            serde_json::from_value::<SessionRealtimeTranscriptState>(projected.clone()).is_ok(),
11648            "WholeBlob encoding must derive from typed authority, never a raw metadata shadow"
11649        );
11650    }
11651
11652    #[test]
11653    fn test_session_mob_tool_authority_context_persists_projection_without_authority_seal() {
11654        let mut session = Session::new();
11655        session
11656            .set_build_state(SessionBuildState::default())
11657            .expect("session build state should serialize");
11658        let authority = MobToolAuthorityContext::generated_for_test(
11659            crate::service::OpaquePrincipalToken::new("opaque-principal"),
11660            false,
11661            false,
11662            false,
11663            std::collections::BTreeSet::from(["mob-a".to_string()]),
11664            std::collections::BTreeMap::new(),
11665            None,
11666            Some("audit-1".to_string()),
11667        );
11668
11669        session
11670            .set_mob_tool_authority_context(Some(authority))
11671            .expect("authority should serialize");
11672        assert!(session.mob_tool_authority_context().is_none());
11673        let stored = session
11674            .build_state()
11675            .and_then(|state| state.mob_tool_authority_context)
11676            .expect("stored projection should deserialize");
11677        assert!(!stored.is_generated_authority_context());
11678        assert!(!stored.can_manage_mob("mob-a"));
11679
11680        session
11681            .set_mob_tool_authority_context(None)
11682            .expect("authority should clear");
11683        assert!(session.mob_tool_authority_context().is_none());
11684    }
11685
11686    #[test]
11687    fn test_session_build_state_rejects_forged_mob_authority_projection() {
11688        let mut session = Session::new();
11689        let authority = MobToolAuthorityContext::generated_for_test(
11690            crate::service::OpaquePrincipalToken::new("opaque-principal"),
11691            false,
11692            false,
11693            false,
11694            std::collections::BTreeSet::from(["mob-a".to_string()]),
11695            std::collections::BTreeMap::new(),
11696            None,
11697            Some("audit-1".to_string()),
11698        );
11699        let forged_projection: MobToolAuthorityContext =
11700            serde_json::from_value(serde_json::to_value(authority).expect("serialize authority"))
11701                .expect("deserialize projection");
11702        assert!(!forged_projection.is_generated_authority_context());
11703
11704        let err = session
11705            .set_build_state(SessionBuildState {
11706                mob_tool_authority_context: Some(forged_projection),
11707                ..Default::default()
11708            })
11709            .expect_err("forged build state must be rejected by generated authority");
11710        // The build-state-persist admission decision now lives in the canonical
11711        // SessionDocumentMachine durable-config region (LUC-524); the rejection
11712        // surfaces with that machine's authority wording.
11713        assert!(
11714            err.to_string()
11715                .contains("generated session document authority rejected"),
11716            "unexpected error: {err}"
11717        );
11718    }
11719
11720    #[test]
11721    fn test_session_tool_visibility_state_roundtrip() {
11722        let mut session = Session::new();
11723        let state = SessionToolVisibilityState {
11724            inherited_base_filter: ToolFilter::Allow(["visible".to_string()].into_iter().collect()),
11725            active_filter: ToolFilter::Allow(
11726                ["visible".to_string(), "missing".to_string()]
11727                    .into_iter()
11728                    .collect(),
11729            ),
11730            staged_filter: ToolFilter::Allow(
11731                ["visible".to_string(), "missing".to_string()]
11732                    .into_iter()
11733                    .collect(),
11734            ),
11735            active_revision: 1,
11736            staged_revision: 2,
11737            ..Default::default()
11738        };
11739
11740        session
11741            .set_tool_visibility_state(
11742                AuthorizedSessionToolVisibilityState::from_generated_authority(state.clone()),
11743            )
11744            .expect("tool visibility state should serialize");
11745        assert_eq!(session.tool_visibility_state().unwrap(), Some(state));
11746    }
11747
11748    #[test]
11749    fn test_session_tool_visibility_state_malformed_returns_error() {
11750        let mut session = Session::new();
11751        session.metadata.insert(
11752            SESSION_TOOL_VISIBILITY_STATE_KEY.to_string(),
11753            serde_json::json!({
11754                "active_filter": {
11755                    "unexpected_filter_kind": ["secret"]
11756                }
11757            }),
11758        );
11759
11760        assert!(
11761            session.tool_visibility_state().is_err(),
11762            "malformed canonical visibility metadata must not decode as absent/default"
11763        );
11764    }
11765
11766    #[test]
11767    fn test_session_serialization() {
11768        let mut session = Session::new();
11769        session.push(Message::User(UserMessage::text("Test".to_string())));
11770
11771        let json = serde_json::to_string(&session).unwrap();
11772        let parsed: Session = serde_json::from_str(&json).unwrap();
11773
11774        assert_eq!(parsed.id(), session.id());
11775        assert_eq!(parsed.messages().len(), 1);
11776        assert_eq!(parsed.version(), SESSION_VERSION);
11777    }
11778
11779    #[test]
11780    fn test_session_meta_from_session() {
11781        let mut session = Session::new();
11782        session.push(Message::User(UserMessage::text("Hello".to_string())));
11783        session.push(Message::BlockAssistant(BlockAssistantMessage {
11784            blocks: vec![AssistantBlock::Text {
11785                text: "Hi!".to_string(),
11786                meta: None,
11787            }],
11788            stop_reason: StopReason::EndTurn,
11789            identity: crate::types::TranscriptMessageIdentity::default(),
11790            created_at: crate::types::message_timestamp_now(),
11791        }));
11792        session.record_usage(Usage {
11793            input_tokens: 10,
11794            output_tokens: 5,
11795            cache_creation_tokens: None,
11796            cache_read_tokens: None,
11797        });
11798
11799        let meta = SessionMeta::from(&session);
11800        assert_eq!(meta.id, *session.id());
11801        assert_eq!(meta.message_count, 2);
11802        assert_eq!(meta.total_tokens, 15);
11803    }
11804
11805    #[test]
11806    fn deferred_tool_result_redelivery_is_idempotent_per_exact_payload() {
11807        let mut state = SessionDeferredTurnState::default();
11808        let results = vec![
11809            ToolResult::new("callback-a".to_string(), "a".to_string(), false),
11810            ToolResult::new("callback-b".to_string(), "b".to_string(), false),
11811        ];
11812        assert_eq!(
11813            state.stage_tool_results(results.clone(), SystemTime::UNIX_EPOCH),
11814            2
11815        );
11816        let before = serde_json::to_value(&state).expect("serialize staged state");
11817
11818        assert_eq!(
11819            state.stage_tool_results(
11820                results,
11821                SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1),
11822            ),
11823            0,
11824            "identical redelivery must coalesce without restaging"
11825        );
11826        assert_eq!(
11827            serde_json::to_value(&state).expect("serialize redelivered state"),
11828            before,
11829            "duplicate ingress must be a byte-identical no-op"
11830        );
11831    }
11832
11833    #[test]
11834    fn deferred_tool_result_conflict_and_wrong_id_fail_without_poison_after_replay() {
11835        let mut state = SessionDeferredTurnState::default();
11836        state
11837            .try_stage_tool_results(
11838                vec![ToolResult::new(
11839                    "callback-a".to_string(),
11840                    "approved".to_string(),
11841                    false,
11842                )],
11843                SystemTime::UNIX_EPOCH,
11844            )
11845            .expect("first callback payload should stage");
11846        let mut replayed: SessionDeferredTurnState = serde_json::from_value(
11847            serde_json::to_value(&state).expect("serialize deferred callback state"),
11848        )
11849        .expect("restore deferred callback state");
11850        let before = serde_json::to_value(&replayed).expect("serialize replayed state");
11851
11852        assert!(matches!(
11853            replayed.try_stage_tool_results(
11854                vec![ToolResult::new(
11855                    "callback-a".to_string(),
11856                    "denied".to_string(),
11857                    false,
11858                )],
11859                SystemTime::UNIX_EPOCH,
11860            ),
11861            Err(DeferredToolResultsIngressError::ConflictingRedelivery(id))
11862                if id == "callback-a"
11863        ));
11864        assert_eq!(serde_json::to_value(&replayed).unwrap(), before);
11865
11866        assert!(matches!(
11867            replayed.try_stage_tool_results(
11868                vec![ToolResult::new(
11869                    "callback-b".to_string(),
11870                    "wrong".to_string(),
11871                    false,
11872                )],
11873                SystemTime::UNIX_EPOCH,
11874            ),
11875            Err(DeferredToolResultsIngressError::WrongToolUseId(id))
11876                if id == "callback-b"
11877        ));
11878        assert_eq!(
11879            serde_json::to_value(&replayed).unwrap(),
11880            before,
11881            "typed ingress refusals must leave the valid pending continuation intact"
11882        );
11883    }
11884
11885    #[test]
11886    fn persisted_round_trip_preserves_multiple_systems_anywhere_exactly() {
11887        let mut session = Session::new();
11888        session.append_system_message("first");
11889        session.push(Message::User(UserMessage::text("hello")));
11890        session.append_system_message(" second ");
11891        session.append_system_message("");
11892        session.append_system_message(" second ");
11893        let expected = session.messages().to_vec();
11894        let bytes = serde_json::to_vec(&session).expect("serialize session");
11895        let resumed: Session = serde_json::from_slice(&bytes).expect("deserialize session");
11896        assert_eq!(resumed.messages(), expected.as_slice());
11897        assert_eq!(resumed.messages_for_model_boundary(), expected);
11898    }
11899    #[test]
11900    fn system_control_idempotency_is_explicit_and_does_not_coalesce_keyless_rows() {
11901        let mut session = Session::new();
11902        let timestamp = crate::types::message_timestamp_now();
11903        let first = session
11904            .append_system_message_idempotent(
11905                " exact ",
11906                Some("host".to_string()),
11907                Some("key".to_string()),
11908                timestamp,
11909            )
11910            .expect("first append");
11911        assert_eq!(first, crate::service::AppendSystemContextStatus::Applied);
11912        let duplicate = session
11913            .append_system_message_idempotent(
11914                " exact ",
11915                Some("host".to_string()),
11916                Some("key".to_string()),
11917                timestamp,
11918            )
11919            .expect("exact retry");
11920        assert_eq!(
11921            duplicate,
11922            crate::service::AppendSystemContextStatus::Duplicate
11923        );
11924        session
11925            .append_system_message_idempotent("", None, None, timestamp)
11926            .expect("empty keyless System");
11927        session
11928            .append_system_message_idempotent("", None, None, timestamp)
11929            .expect("duplicate keyless System");
11930        assert_eq!(
11931            session
11932                .messages()
11933                .iter()
11934                .filter(|message| matches!(message, Message::System(system) if system.content.is_empty()))
11935                .count(),
11936            2
11937        );
11938        assert!(matches!(
11939            session.append_system_message_idempotent(
11940                "different",
11941                Some("host".to_string()),
11942                Some("key".to_string()),
11943                timestamp,
11944            ),
11945            Err(SystemMessageAppendError::Conflict { .. })
11946        ));
11947    }
11948    #[test]
11949    fn realtime_transcript_assistant_transcript_delta_materializes_transcript_block() {
11950        let mut session = Session::new();
11951
11952        let delta = RealtimeTranscriptEvent::AssistantTranscriptDelta {
11953            response_id: "resp_spoken".to_string(),
11954            delta_id: "evt_delta_spoken_1".to_string(),
11955            item_id: "item_spoken".to_string(),
11956            previous_item_id: None,
11957            content_index: 0,
11958            delta: "I said hi".to_string(),
11959        };
11960        assert!(
11961            session.append_realtime_transcript_event(delta).is_inert(),
11962            "delta alone is inert until turn-completed flushes"
11963        );
11964
11965        let terminal = RealtimeTranscriptEvent::AssistantTurnCompleted {
11966            response_id: "resp_spoken".to_string(),
11967            stop_reason: StopReason::EndTurn,
11968            usage: Usage::default(),
11969        };
11970        let outcome = session.append_realtime_transcript_event(terminal);
11971        assert_eq!(outcome.materialized_messages.len(), 1);
11972
11973        // T9/T10: must be a Transcript block, NOT Text.
11974        let messages = session.messages();
11975        assert_eq!(messages.len(), 1);
11976        match &messages[0] {
11977            Message::BlockAssistant(assistant) => {
11978                assert_eq!(assistant.blocks.len(), 1);
11979                match &assistant.blocks[0] {
11980                    AssistantBlock::Transcript { text, source, .. } => {
11981                        assert_eq!(text, "I said hi");
11982                        assert_eq!(*source, crate::types::TranscriptSource::Spoken);
11983                    }
11984                    other => unreachable!(
11985                        "AssistantTranscriptDelta must materialize as AssistantBlock::Transcript, got {other:?}"
11986                    ),
11987                }
11988            }
11989            other => unreachable!("expected BlockAssistant message, got {other:?}"),
11990        }
11991    }
11992
11993    #[test]
11994    fn round4_cc4_in_flight_response_ids_lists_distinct_unmaterialized_responses() {
11995        // CC4 (Round-4 architectural reconciliation): the helper that
11996        // powers `signal_turn_interrupt`'s cross-layer fan-out must
11997        // return every distinct provider response_id that has at least
11998        // one unmaterialized assistant item, EXCLUDING already-discarded
11999        // responses and EXCLUDING the user role.
12000        let mut session = Session::new();
12001
12002        // Two transcript-delta items on resp_a (different content_index
12003        // ranges), one on resp_b. resp_c gets a delta and is then
12004        // discarded explicitly via AssistantTurnInterrupted.
12005        for (i, response_id) in [
12006            ("resp_a", "resp_a"),
12007            ("resp_a_extra", "resp_a"),
12008            ("resp_b", "resp_b"),
12009            ("resp_c", "resp_c"),
12010        ]
12011        .iter()
12012        .enumerate()
12013        {
12014            let event = RealtimeTranscriptEvent::AssistantTranscriptDelta {
12015                response_id: response_id.1.to_string(),
12016                delta_id: format!("delta_{i}"),
12017                item_id: response_id.0.to_string(),
12018                previous_item_id: None,
12019                content_index: 0,
12020                delta: "x".to_string(),
12021            };
12022            let _ = session.append_realtime_transcript_event(event);
12023        }
12024
12025        // Discard resp_c — it should not appear in the in-flight list.
12026        let _ = session.append_realtime_transcript_event(
12027            RealtimeTranscriptEvent::AssistantTurnInterrupted {
12028                response_id: "resp_c".to_string(),
12029            },
12030        );
12031
12032        // User-role item should never appear (CC4 only fans interrupts
12033        // to assistant responses).
12034        let _ = session.append_realtime_transcript_event(
12035            RealtimeTranscriptEvent::UserTranscriptFinal {
12036                item_id: "u_item".to_string(),
12037                previous_item_id: None,
12038                content_index: 0,
12039                text: "hi".to_string(),
12040            },
12041        );
12042
12043        let in_flight = session.in_flight_realtime_assistant_response_ids();
12044        assert!(in_flight.contains(&"resp_a".to_string()), "{in_flight:?}");
12045        assert!(in_flight.contains(&"resp_b".to_string()), "{in_flight:?}");
12046        assert!(
12047            !in_flight.contains(&"resp_c".to_string()),
12048            "discarded response must not appear in in_flight: {in_flight:?}"
12049        );
12050        // resp_a appears exactly once even though two items reference it.
12051        assert_eq!(
12052            in_flight.iter().filter(|r| *r == "resp_a").count(),
12053            1,
12054            "distinct response_ids only: {in_flight:?}"
12055        );
12056    }
12057
12058    #[test]
12059    fn round4_cc2_assistant_turn_completed_after_transcript_deltas_materializes_transcript() {
12060        // CC2 (Round-4 architectural reconciliation): once
12061        // `signal_turn_completed` synthesizes
12062        // `RealtimeTranscriptEvent::AssistantTurnCompleted`, the staging
12063        // materializer commits every staged transcript-delta item for
12064        // that response_id as `AssistantBlock::Transcript { Spoken }`.
12065        // This pins the production end-to-end shape the sink relies on.
12066        let mut session = Session::new();
12067
12068        let delta = RealtimeTranscriptEvent::AssistantTranscriptDelta {
12069            response_id: "resp_cc2".to_string(),
12070            delta_id: "delta_cc2_1".to_string(),
12071            item_id: "item_cc2".to_string(),
12072            previous_item_id: None,
12073            content_index: 0,
12074            delta: "hello world".to_string(),
12075        };
12076        assert!(session.append_realtime_transcript_event(delta).is_inert());
12077
12078        // Pre-completion: in-flight list reports resp_cc2.
12079        assert_eq!(
12080            session.in_flight_realtime_assistant_response_ids(),
12081            vec!["resp_cc2".to_string()]
12082        );
12083
12084        let outcome = session.append_realtime_transcript_event(
12085            RealtimeTranscriptEvent::AssistantTurnCompleted {
12086                response_id: "resp_cc2".to_string(),
12087                stop_reason: StopReason::EndTurn,
12088                usage: Usage::default(),
12089            },
12090        );
12091        assert_eq!(outcome.materialized_messages.len(), 1);
12092
12093        // Post-completion: in-flight list is empty (item is materialized).
12094        assert!(
12095            session
12096                .in_flight_realtime_assistant_response_ids()
12097                .is_empty(),
12098            "materialized items must not appear in in_flight_realtime_assistant_response_ids"
12099        );
12100
12101        let messages = session.messages();
12102        let assistant = messages.iter().find_map(|m| match m {
12103            Message::BlockAssistant(a) => Some(a),
12104            _ => None,
12105        });
12106        let assistant = assistant.expect("assistant block message expected");
12107        assert_eq!(assistant.blocks.len(), 1);
12108        assert!(matches!(
12109            &assistant.blocks[0],
12110            AssistantBlock::Transcript {
12111                source: crate::types::TranscriptSource::Spoken,
12112                ..
12113            }
12114        ));
12115    }
12116
12117    #[test]
12118    fn realtime_transcript_assistant_text_delta_still_materializes_text_block() {
12119        // Counter-regression: the display-text lane must continue to
12120        // produce `AssistantBlock::Text` after T9/T10. Prevents an
12121        // accidental cross-lane flip.
12122        let mut session = Session::new();
12123
12124        let delta = RealtimeTranscriptEvent::AssistantTextDelta {
12125            response_id: "resp_display".to_string(),
12126            delta_id: "evt_delta_display_1".to_string(),
12127            item_id: "item_display".to_string(),
12128            previous_item_id: None,
12129            content_index: 0,
12130            delta: "I wrote".to_string(),
12131        };
12132        let _ = session.append_realtime_transcript_event(delta);
12133
12134        let terminal = RealtimeTranscriptEvent::AssistantTurnCompleted {
12135            response_id: "resp_display".to_string(),
12136            stop_reason: StopReason::EndTurn,
12137            usage: Usage::default(),
12138        };
12139        let outcome = session.append_realtime_transcript_event(terminal);
12140        assert_eq!(outcome.materialized_messages.len(), 1);
12141
12142        let messages = session.messages();
12143        match &messages[0] {
12144            Message::BlockAssistant(assistant) => match &assistant.blocks[0] {
12145                AssistantBlock::Text { text, .. } => assert_eq!(text, "I wrote"),
12146                other => unreachable!(
12147                    "AssistantTextDelta must keep materializing AssistantBlock::Text, got {other:?}"
12148                ),
12149            },
12150            other => unreachable!("expected BlockAssistant message, got {other:?}"),
12151        }
12152    }
12153
12154    #[test]
12155    fn round4_cc7_mixed_response_persists_text_and_transcript_in_order() {
12156        // CC7 (Round-4 adversarial-verifier follow-up): a single mixed-modality
12157        // realtime response that emits BOTH display-text deltas
12158        // (`AssistantTextDelta`) AND spoken-transcript deltas
12159        // (`AssistantTranscriptDelta`) under the same response_id must
12160        // materialize as ONE `Message::BlockAssistant` whose `blocks` field
12161        // contains exactly two ordered entries:
12162        //   1. AssistantBlock::Text       (display-text lane)
12163        //   2. AssistantBlock::Transcript { source: Spoken } (spoken lane)
12164        // Pre-fix the materializer emitted one Message::BlockAssistant per
12165        // staged item, splitting the mixed response into two messages.
12166        //
12167        // This test drives the production materializer end-to-end: deltas
12168        // stage in `SessionRealtimeTranscriptState`; `AssistantTurnCompleted`
12169        // triggers the materializer; canonical history is the assertion
12170        // surface — exactly the same code path that
12171        // `SessionServiceProjectionSink::signal_turn_completed` invokes via
12172        // `runtime.append_realtime_transcript_event` in production.
12173        let mut session = Session::new();
12174
12175        // Provider-arrival order: display first, then spoken.
12176        let display_a = RealtimeTranscriptEvent::AssistantTextDelta {
12177            response_id: "resp_mixed_1".to_string(),
12178            delta_id: "delta_disp_1".to_string(),
12179            item_id: "item_display".to_string(),
12180            previous_item_id: None,
12181            content_index: 0,
12182            delta: "Here's the report:".to_string(),
12183        };
12184        assert!(
12185            session
12186                .append_realtime_transcript_event(display_a)
12187                .is_inert()
12188        );
12189
12190        let display_b = RealtimeTranscriptEvent::AssistantTextDelta {
12191            response_id: "resp_mixed_1".to_string(),
12192            delta_id: "delta_disp_2".to_string(),
12193            item_id: "item_display".to_string(),
12194            previous_item_id: None,
12195            content_index: 0,
12196            delta: " (still writing)".to_string(),
12197        };
12198        assert!(
12199            session
12200                .append_realtime_transcript_event(display_b)
12201                .is_inert()
12202        );
12203
12204        // Spoken items chain after the display item to mirror provider
12205        // arrival semantics — `previous_item_id` carries arrival ordering
12206        // that the materializer must preserve as block ordering inside the
12207        // single emitted message.
12208        let spoken_a = RealtimeTranscriptEvent::AssistantTranscriptDelta {
12209            response_id: "resp_mixed_1".to_string(),
12210            delta_id: "delta_spoken_1".to_string(),
12211            item_id: "item_spoken".to_string(),
12212            previous_item_id: Some("item_display".to_string()),
12213            content_index: 0,
12214            delta: "I'm reading the report aloud:".to_string(),
12215        };
12216        assert!(
12217            session
12218                .append_realtime_transcript_event(spoken_a)
12219                .is_inert()
12220        );
12221
12222        let spoken_b = RealtimeTranscriptEvent::AssistantTranscriptDelta {
12223            response_id: "resp_mixed_1".to_string(),
12224            delta_id: "delta_spoken_2".to_string(),
12225            item_id: "item_spoken".to_string(),
12226            previous_item_id: Some("item_display".to_string()),
12227            content_index: 0,
12228            delta: " sentence two.".to_string(),
12229        };
12230        assert!(
12231            session
12232                .append_realtime_transcript_event(spoken_b)
12233                .is_inert()
12234        );
12235
12236        // TurnCompleted triggers the materializer to flush all staged items
12237        // for this response_id into ONE BlockAssistant message.
12238        let outcome = session.append_realtime_transcript_event(
12239            RealtimeTranscriptEvent::AssistantTurnCompleted {
12240                response_id: "resp_mixed_1".to_string(),
12241                stop_reason: StopReason::EndTurn,
12242                usage: Usage {
12243                    input_tokens: 11,
12244                    output_tokens: 22,
12245                    cache_creation_tokens: None,
12246                    cache_read_tokens: None,
12247                },
12248            },
12249        );
12250        // Materializer reports two staged items got materialized.
12251        assert_eq!(outcome.materialized_messages.len(), 2);
12252
12253        // Canonical history MUST contain exactly ONE BlockAssistant message
12254        // (the CC7 fix: mixed lanes interleave into one message, not two).
12255        let messages = session.messages();
12256        let assistants: Vec<&BlockAssistantMessage> = messages
12257            .iter()
12258            .filter_map(|m| match m {
12259                Message::BlockAssistant(a) => Some(a),
12260                _ => None,
12261            })
12262            .collect();
12263        assert_eq!(
12264            assistants.len(),
12265            1,
12266            "mixed display+spoken response under one response_id must produce exactly ONE BlockAssistant message, got: {assistants:?}"
12267        );
12268        let assistant = assistants[0];
12269        assert_eq!(
12270            assistant.blocks.len(),
12271            2,
12272            "mixed response message must carry both blocks: {:?}",
12273            assistant.blocks
12274        );
12275
12276        // Block 0: display-text (concatenated deltas).
12277        match &assistant.blocks[0] {
12278            AssistantBlock::Text { text, .. } => {
12279                assert_eq!(text, "Here's the report: (still writing)");
12280            }
12281            other => unreachable!(
12282                "first block must be AssistantBlock::Text (display lane), got {other:?}"
12283            ),
12284        }
12285        // Block 1: spoken transcript (concatenated deltas), tagged Spoken.
12286        match &assistant.blocks[1] {
12287            AssistantBlock::Transcript { text, source, .. } => {
12288                assert_eq!(text, "I'm reading the report aloud: sentence two.");
12289                assert_eq!(*source, crate::types::TranscriptSource::Spoken);
12290            }
12291            other => unreachable!(
12292                "second block must be AssistantBlock::Transcript {{ source: Spoken }}, got {other:?}"
12293            ),
12294        }
12295
12296        // Usage was recorded once for the turn.
12297        assert_eq!(session.usage.input_tokens, 11);
12298        assert_eq!(session.usage.output_tokens, 22);
12299    }
12300
12301    #[test]
12302    fn round5_r55_mixed_response_barge_in_preserves_display_drops_spoken() {
12303        // R5-5 (Round-5 contract update): barge-in MUST filter staged items
12304        // by lane — `Spoken` is invalidated (the user spoke over the audio
12305        // they were hearing) but `Display` survives as committed history
12306        // (sideband display text from the same response is not "spoken
12307        // over"). Round-4's `round4_cc7_mixed_response_barge_in_discards_*`
12308        // pinned the wrong invariant; this test replaces it.
12309        //
12310        // Architectural decision: `AssistantTurnInterrupted` is terminal for
12311        // the response on the realtime-staging path — any later
12312        // `AssistantTurnCompleted { stop_reason: Cancelled }` short-circuits
12313        // via the `discarded_assistant_response_ids` guard. So the
12314        // Interrupted handler must seed a synthetic
12315        // `assistant_completions` entry (`StopReason::Cancelled`,
12316        // `Usage::default()`) so retained Display items materialize
12317        // immediately rather than stranding forever.
12318        let mut session = Session::new();
12319
12320        let display = RealtimeTranscriptEvent::AssistantTextDelta {
12321            response_id: "resp_mixed_2".to_string(),
12322            delta_id: "delta_disp_1".to_string(),
12323            item_id: "item_display_2".to_string(),
12324            previous_item_id: None,
12325            content_index: 0,
12326            delta: "Working on the report...".to_string(),
12327        };
12328        let _ = session.append_realtime_transcript_event(display);
12329
12330        let spoken = RealtimeTranscriptEvent::AssistantTranscriptDelta {
12331            response_id: "resp_mixed_2".to_string(),
12332            delta_id: "delta_spoken_1".to_string(),
12333            item_id: "item_spoken_2".to_string(),
12334            previous_item_id: Some("item_display_2".to_string()),
12335            content_index: 0,
12336            delta: "I'm reading the report".to_string(),
12337        };
12338        let _ = session.append_realtime_transcript_event(spoken);
12339
12340        // Barge-in arrives BEFORE TurnCompleted. The Display item with
12341        // staged content materializes immediately under the synthetic
12342        // Cancelled completion.
12343        let outcome = session.append_realtime_transcript_event(
12344            RealtimeTranscriptEvent::AssistantTurnInterrupted {
12345                response_id: "resp_mixed_2".to_string(),
12346            },
12347        );
12348        assert_eq!(
12349            outcome.materialized_messages.len(),
12350            1,
12351            "Display lane item must materialize on Interrupted: {outcome:?}"
12352        );
12353
12354        // A late `AssistantTurnCompleted` (the provider's response.done
12355        // emitted after cancel) must be a no-op: the Display item is
12356        // already materialized; the Spoken item was dropped at Interrupted.
12357        let late_completion = session.append_realtime_transcript_event(
12358            RealtimeTranscriptEvent::AssistantTurnCompleted {
12359                response_id: "resp_mixed_2".to_string(),
12360                stop_reason: StopReason::Cancelled,
12361                usage: Usage::default(),
12362            },
12363        );
12364        assert_eq!(
12365            late_completion.materialized_messages.len(),
12366            0,
12367            "post-barge-in TurnCompleted must not resurrect anything"
12368        );
12369
12370        // Canonical history: exactly one BlockAssistant carrying the
12371        // Display text (no Transcript block — Spoken was dropped).
12372        let messages = session.messages();
12373        let assistants: Vec<&BlockAssistantMessage> = messages
12374            .iter()
12375            .filter_map(|m| match m {
12376                Message::BlockAssistant(a) => Some(a),
12377                _ => None,
12378            })
12379            .collect();
12380        assert_eq!(
12381            assistants.len(),
12382            1,
12383            "barge-in must commit exactly one BlockAssistant containing the Display lane: {assistants:?}"
12384        );
12385        let assistant = assistants[0];
12386        assert_eq!(assistant.blocks.len(), 1, "blocks: {:?}", assistant.blocks);
12387        match &assistant.blocks[0] {
12388            AssistantBlock::Text { text, .. } => {
12389                assert_eq!(text, "Working on the report...");
12390            }
12391            other => {
12392                unreachable!("Display lane must materialize as AssistantBlock::Text, got {other:?}")
12393            }
12394        }
12395        // No Transcript block — Spoken lane was dropped.
12396        assert!(
12397            !assistant
12398                .blocks
12399                .iter()
12400                .any(|b| matches!(b, AssistantBlock::Transcript { .. })),
12401            "Spoken lane must be dropped on barge-in"
12402        );
12403
12404        // The in-flight tracker reports the response as no longer in flight
12405        // (the Display item is materialized; the Spoken item is skipped).
12406        assert!(
12407            !session
12408                .in_flight_realtime_assistant_response_ids()
12409                .contains(&"resp_mixed_2".to_string()),
12410            "barged-in response must not appear in in_flight_realtime_assistant_response_ids"
12411        );
12412    }
12413
12414    #[test]
12415    fn round5_r55_barge_in_preserves_display_lane_drops_spoken() {
12416        // R5-5 unit test: pin the lane-filter behavior at the staged-item
12417        // level (no chained predecessor). One Display item, one Spoken item,
12418        // both unchained, both staged before Interrupted.
12419        let mut session = Session::new();
12420
12421        let _ =
12422            session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
12423                response_id: "resp_a".to_string(),
12424                delta_id: "delta_d_1".to_string(),
12425                item_id: "item_display".to_string(),
12426                previous_item_id: None,
12427                content_index: 0,
12428                delta: "display-text".to_string(),
12429            });
12430        let _ = session.append_realtime_transcript_event(
12431            RealtimeTranscriptEvent::AssistantTranscriptDelta {
12432                response_id: "resp_a".to_string(),
12433                delta_id: "delta_s_1".to_string(),
12434                item_id: "item_spoken".to_string(),
12435                previous_item_id: None,
12436                content_index: 0,
12437                delta: "spoken-transcript".to_string(),
12438            },
12439        );
12440
12441        let outcome = session.append_realtime_transcript_event(
12442            RealtimeTranscriptEvent::AssistantTurnInterrupted {
12443                response_id: "resp_a".to_string(),
12444            },
12445        );
12446        // Display materializes, Spoken does not.
12447        assert_eq!(outcome.materialized_messages.len(), 1);
12448
12449        let messages = session.messages();
12450        let assistants: Vec<&BlockAssistantMessage> = messages
12451            .iter()
12452            .filter_map(|m| match m {
12453                Message::BlockAssistant(a) => Some(a),
12454                _ => None,
12455            })
12456            .collect();
12457        assert_eq!(assistants.len(), 1);
12458        // Single Text block (the Display lane) — no Transcript.
12459        assert_eq!(assistants[0].blocks.len(), 1);
12460        match &assistants[0].blocks[0] {
12461            AssistantBlock::Text { text, .. } => assert_eq!(text, "display-text"),
12462            other => unreachable!("expected Text, got {other:?}"),
12463        }
12464    }
12465
12466    #[test]
12467    fn round5_r55_barge_in_finalizes_retained_display_into_committed_block() {
12468        // R5-5: the architectural decision — Interrupted is terminal for the
12469        // response. Display lane must commit at Interrupted time, not wait
12470        // on a hypothetical AssistantTurnCompleted that may never arrive
12471        // (or arrives Cancelled and short-circuits).
12472        let mut session = Session::new();
12473
12474        let _ =
12475            session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
12476                response_id: "resp_a".to_string(),
12477                delta_id: "delta_d_1".to_string(),
12478                item_id: "item_display".to_string(),
12479                previous_item_id: None,
12480                content_index: 0,
12481                delta: "committed-display-text".to_string(),
12482            });
12483
12484        // Pre-condition: nothing committed yet.
12485        assert!(session.messages().is_empty());
12486
12487        let outcome = session.append_realtime_transcript_event(
12488            RealtimeTranscriptEvent::AssistantTurnInterrupted {
12489                response_id: "resp_a".to_string(),
12490            },
12491        );
12492        assert_eq!(
12493            outcome.materialized_messages.len(),
12494            1,
12495            "Interrupted must finalize retained Display lane immediately"
12496        );
12497
12498        // Post-condition: BlockAssistant in canonical history, no Transcript.
12499        let messages = session.messages();
12500        assert_eq!(messages.len(), 1);
12501        match &messages[0] {
12502            Message::BlockAssistant(assistant) => {
12503                assert_eq!(assistant.blocks.len(), 1);
12504                match &assistant.blocks[0] {
12505                    AssistantBlock::Text { text, .. } => {
12506                        assert_eq!(text, "committed-display-text");
12507                    }
12508                    other => unreachable!("expected Text, got {other:?}"),
12509                }
12510            }
12511            other => unreachable!("expected BlockAssistant, got {other:?}"),
12512        }
12513    }
12514
12515    #[test]
12516    fn round5_r56_truncation_promotes_default_lane_item_to_spoken() {
12517        // R5-6: when truncation is the first content-bearing event for an
12518        // item (no prior delta), the staged item's lane MUST be promoted to
12519        // Spoken so the materializer commits as `AssistantBlock::Transcript`.
12520        // Without the explicit promotion, the lane stays `Display` (the
12521        // default) and the heard audio transcript persists as
12522        // `AssistantBlock::Text`.
12523        let mut session = Session::new();
12524
12525        let _ = session.append_realtime_transcript_event(
12526            RealtimeTranscriptEvent::AssistantTranscriptTruncated {
12527                response_id: "resp_a".to_string(),
12528                item_id: "item_a".to_string(),
12529                content_index: 0,
12530                text: "what was actually heard".to_string(),
12531            },
12532        );
12533
12534        let outcome = session.append_realtime_transcript_event(
12535            RealtimeTranscriptEvent::AssistantTurnCompleted {
12536                response_id: "resp_a".to_string(),
12537                stop_reason: StopReason::EndTurn,
12538                usage: Usage::default(),
12539            },
12540        );
12541        assert_eq!(outcome.materialized_messages.len(), 1);
12542
12543        assert_eq!(session.messages().len(), 1);
12544        match &session.messages()[0] {
12545            Message::BlockAssistant(assistant) => {
12546                assert_eq!(assistant.blocks.len(), 1);
12547                match &assistant.blocks[0] {
12548                    AssistantBlock::Transcript { text, source, .. } => {
12549                        assert_eq!(text, "what was actually heard");
12550                        assert_eq!(*source, crate::types::TranscriptSource::Spoken);
12551                    }
12552                    other => unreachable!(
12553                        "truncation-only path must materialize as AssistantBlock::Transcript, got {other:?}"
12554                    ),
12555                }
12556            }
12557            other => unreachable!("expected BlockAssistant, got {other:?}"),
12558        }
12559    }
12560
12561    #[test]
12562    fn round5_r56_truncation_after_display_delta_is_no_op_keeping_display_content() {
12563        // R5-6 edge case: a Display delta arrived first and staged Display
12564        // content; a truncation event arrives for the SAME item id
12565        // (provider bug — truncation only applies to spoken/audio output).
12566        // Contract: the staged Display content must NOT be clobbered by
12567        // the truncation text. `promote_item_lane` keeps the existing
12568        // Display lane and emits a `tracing::warn!`; the truncation arm
12569        // sees the lane stayed Display and skips the segment-write.
12570        let mut session = Session::new();
12571
12572        let _ =
12573            session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
12574                response_id: "resp_a".to_string(),
12575                delta_id: "delta_d_1".to_string(),
12576                item_id: "item_a".to_string(),
12577                previous_item_id: None,
12578                content_index: 0,
12579                delta: "display-text-from-delta".to_string(),
12580            });
12581
12582        let _ = session.append_realtime_transcript_event(
12583            RealtimeTranscriptEvent::AssistantTranscriptTruncated {
12584                response_id: "resp_a".to_string(),
12585                item_id: "item_a".to_string(),
12586                content_index: 0,
12587                text: "spoken-truncation-text".to_string(),
12588            },
12589        );
12590
12591        let _ = session.append_realtime_transcript_event(
12592            RealtimeTranscriptEvent::AssistantTurnCompleted {
12593                response_id: "resp_a".to_string(),
12594                stop_reason: StopReason::EndTurn,
12595                usage: Usage::default(),
12596            },
12597        );
12598
12599        // Display content survives unchanged — the truncation text was
12600        // refused. Materializes as `AssistantBlock::Text` (Display lane).
12601        assert_eq!(session.messages().len(), 1);
12602        match &session.messages()[0] {
12603            Message::BlockAssistant(assistant) => {
12604                assert_eq!(assistant.blocks.len(), 1);
12605                match &assistant.blocks[0] {
12606                    AssistantBlock::Text { text, .. } => {
12607                        assert_eq!(text, "display-text-from-delta");
12608                    }
12609                    other => unreachable!(
12610                        "Display content must survive misrouted truncation, got {other:?}"
12611                    ),
12612                }
12613            }
12614            other => unreachable!("expected BlockAssistant, got {other:?}"),
12615        }
12616    }
12617
12618    /// R5-6 sibling: a Spoken-classified item (transcript-truncation
12619    /// arrived first and locked the lane to Spoken) must reject a later
12620    /// `AssistantTextDelta` rather than silently appending the Display
12621    /// text into the Spoken-locked content_segment. Pre-fix the delta
12622    /// arm called `promote_item_lane` and unconditionally pushed the
12623    /// delta — clobbering the lane invariant. Post-fix the delta is
12624    /// dropped (warn fires) and the Spoken-truncation text survives.
12625    #[test]
12626    fn round5_r56_sibling_display_delta_skipped_on_spoken_item() {
12627        let mut session = Session::new();
12628
12629        // Truncation arrives first and locks the item to the Spoken lane.
12630        let _ = session.append_realtime_transcript_event(
12631            RealtimeTranscriptEvent::AssistantTranscriptTruncated {
12632                response_id: "resp_a".to_string(),
12633                item_id: "item_a".to_string(),
12634                content_index: 0,
12635                text: "what was actually heard".to_string(),
12636            },
12637        );
12638
12639        // A Display delta arrives later for the SAME item id (provider
12640        // lane-classification bug). It MUST be dropped.
12641        let _ =
12642            session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
12643                response_id: "resp_a".to_string(),
12644                delta_id: "delta_d_1".to_string(),
12645                item_id: "item_a".to_string(),
12646                previous_item_id: None,
12647                content_index: 0,
12648                delta: "should-not-appear".to_string(),
12649            });
12650
12651        let _ = session.append_realtime_transcript_event(
12652            RealtimeTranscriptEvent::AssistantTurnCompleted {
12653                response_id: "resp_a".to_string(),
12654                stop_reason: StopReason::EndTurn,
12655                usage: Usage::default(),
12656            },
12657        );
12658
12659        // The Spoken-truncation text survives intact; no Display text
12660        // leaked into the Spoken lane content.
12661        assert_eq!(session.messages().len(), 1);
12662        match &session.messages()[0] {
12663            Message::BlockAssistant(assistant) => {
12664                assert_eq!(assistant.blocks.len(), 1);
12665                match &assistant.blocks[0] {
12666                    AssistantBlock::Transcript { text, source, .. } => {
12667                        assert_eq!(text, "what was actually heard");
12668                        assert_eq!(*source, crate::types::TranscriptSource::Spoken);
12669                    }
12670                    other => unreachable!(
12671                        "Spoken-locked item must materialize as Transcript, got {other:?}"
12672                    ),
12673                }
12674            }
12675            other => unreachable!("expected BlockAssistant, got {other:?}"),
12676        }
12677    }
12678
12679    /// R5-6 sibling: a Display-classified item (a Display delta arrived
12680    /// first and locked the lane to Display) must reject a later
12681    /// `AssistantTranscriptDelta` rather than appending the Spoken text
12682    /// into the Display-locked content_segment. Pre-fix the transcript
12683    /// delta arm called `promote_item_lane` and unconditionally pushed —
12684    /// silently mixing a Spoken stream into a Display block.
12685    #[test]
12686    fn round5_r56_sibling_spoken_delta_skipped_on_display_item() {
12687        let mut session = Session::new();
12688
12689        // Display delta arrives first and locks the item to the Display lane.
12690        let _ =
12691            session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
12692                response_id: "resp_a".to_string(),
12693                delta_id: "delta_d_1".to_string(),
12694                item_id: "item_a".to_string(),
12695                previous_item_id: None,
12696                content_index: 0,
12697                delta: "display-locked-text".to_string(),
12698            });
12699
12700        // A spoken-transcript delta arrives later for the SAME item id
12701        // (provider lane-classification bug). It MUST be dropped.
12702        let _ = session.append_realtime_transcript_event(
12703            RealtimeTranscriptEvent::AssistantTranscriptDelta {
12704                response_id: "resp_a".to_string(),
12705                delta_id: "delta_s_1".to_string(),
12706                item_id: "item_a".to_string(),
12707                previous_item_id: None,
12708                content_index: 0,
12709                delta: "should-not-appear".to_string(),
12710            },
12711        );
12712
12713        let _ = session.append_realtime_transcript_event(
12714            RealtimeTranscriptEvent::AssistantTurnCompleted {
12715                response_id: "resp_a".to_string(),
12716                stop_reason: StopReason::EndTurn,
12717                usage: Usage::default(),
12718            },
12719        );
12720
12721        // The Display text survives intact; no Spoken text leaked in.
12722        assert_eq!(session.messages().len(), 1);
12723        match &session.messages()[0] {
12724            Message::BlockAssistant(assistant) => {
12725                assert_eq!(assistant.blocks.len(), 1);
12726                match &assistant.blocks[0] {
12727                    AssistantBlock::Text { text, .. } => {
12728                        assert_eq!(text, "display-locked-text");
12729                    }
12730                    other => {
12731                        unreachable!("Display-locked item must materialize as Text, got {other:?}")
12732                    }
12733                }
12734            }
12735            other => unreachable!("expected BlockAssistant, got {other:?}"),
12736        }
12737    }
12738
12739    /// R5-7: a late `AssistantTranscriptFinalText` arriving AFTER
12740    /// `AssistantTurnCompleted` already materialized the item must NOT
12741    /// mutate `content_segments` and must NOT rewrite the canonical
12742    /// `Message::BlockAssistant` (append-only history is a stronger
12743    /// invariant than typed text repair). The committed message keeps
12744    /// the delta-accumulated text; the late final is dropped with a
12745    /// warn; the materializer outcome is inert (no new messages).
12746    #[test]
12747    fn round5_r57_late_final_text_after_turn_completed_warns_and_skips() {
12748        let mut session = Session::new();
12749
12750        // Delta accumulates partial text on the Spoken lane.
12751        let _ = session.append_realtime_transcript_event(
12752            RealtimeTranscriptEvent::AssistantTranscriptDelta {
12753                response_id: "resp_a".to_string(),
12754                delta_id: "delta_s_1".to_string(),
12755                item_id: "item_a".to_string(),
12756                previous_item_id: None,
12757                content_index: 0,
12758                delta: "delta-accumulated".to_string(),
12759            },
12760        );
12761
12762        // TurnCompleted materializes the item with the delta-accumulated text.
12763        let commit_outcome = session.append_realtime_transcript_event(
12764            RealtimeTranscriptEvent::AssistantTurnCompleted {
12765                response_id: "resp_a".to_string(),
12766                stop_reason: StopReason::EndTurn,
12767                usage: Usage::default(),
12768            },
12769        );
12770        assert_eq!(commit_outcome.materialized_messages.len(), 1);
12771
12772        // Late FinalText arrives — provider-side ordering bug. It MUST
12773        // be dropped: no canonical message rewrite, no segment mutation,
12774        // outcome is inert.
12775        let late_outcome = session.append_realtime_transcript_event(
12776            RealtimeTranscriptEvent::AssistantTranscriptFinalText {
12777                response_id: "resp_a".to_string(),
12778                item_id: "item_a".to_string(),
12779                content_index: 0,
12780                text: "authoritative-final-that-must-not-land".to_string(),
12781            },
12782        );
12783        assert!(
12784            late_outcome.is_inert(),
12785            "late FinalText after materialization must produce inert outcome"
12786        );
12787
12788        // Canonical history: still one message with the original
12789        // delta-accumulated text — NOT the authoritative final.
12790        assert_eq!(session.messages().len(), 1);
12791        match &session.messages()[0] {
12792            Message::BlockAssistant(assistant) => {
12793                assert_eq!(assistant.blocks.len(), 1);
12794                match &assistant.blocks[0] {
12795                    AssistantBlock::Transcript { text, .. } => {
12796                        assert_eq!(
12797                            text, "delta-accumulated",
12798                            "canonical message must preserve delta-accumulated text; \
12799                             append-only history forbids late FinalText repair"
12800                        );
12801                    }
12802                    other => unreachable!("expected Transcript, got {other:?}"),
12803                }
12804            }
12805            other => unreachable!("expected BlockAssistant, got {other:?}"),
12806        }
12807    }
12808
12809    fn metadata_seam_session_metadata() -> SessionMetadata {
12810        SessionMetadata {
12811            schema_version: SESSION_METADATA_SCHEMA_VERSION,
12812            model: "test-model".to_string(),
12813            max_tokens: 1024,
12814            structured_output_retries: 2,
12815            provider: Provider::Anthropic,
12816            self_hosted_server_id: None,
12817            provider_params: None,
12818            tooling: SessionTooling::default(),
12819            keep_alive: false,
12820            comms_name: Some("team/reviewer/alice".to_string()),
12821            peer_meta: None,
12822            realm_id: None,
12823            instance_id: None,
12824            backend: None,
12825            config_generation: None,
12826            auth_binding: None,
12827            mob_member_binding: Some(crate::MobMemberBinding {
12828                mob_id: "team".to_string(),
12829                role: "reviewer".to_string(),
12830                member: "alice".to_string(),
12831            }),
12832        }
12833    }
12834
12835    /// Lockstep pin: the metadata-only partial decode must read the exact
12836    /// envelope that `SessionSerde` writes. If a field rename or serde-shape
12837    /// change lands on the full envelope without the partial decoder
12838    /// following, this test fails.
12839    #[test]
12840    fn session_metadata_document_lockstep_with_full_envelope() {
12841        let mut session = Session::new();
12842        session.push(Message::User(UserMessage::text("hello".to_string())));
12843        session
12844            .set_session_metadata(metadata_seam_session_metadata())
12845            .expect("session metadata should persist");
12846        session
12847            .set_lifecycle_terminal(SessionLifecycleTerminal::Archived)
12848            .expect("lifecycle terminal should persist");
12849
12850        let bytes = serde_json::to_vec(&session).expect("session should serialize");
12851        let document = session_metadata_document_from_slice(&bytes)
12852            .expect("partial decode must accept the canonical envelope");
12853
12854        assert_eq!(document.session_id(), session.id());
12855        assert_eq!(
12856            document.session_metadata_value(),
12857            session.metadata().get(SESSION_METADATA_KEY),
12858            "partial decode must project the identical raw session-metadata value"
12859        );
12860        assert_eq!(
12861            document.lifecycle_terminal_value(),
12862            session.metadata().get(SESSION_LIFECYCLE_TERMINAL_KEY),
12863            "partial decode must project the identical raw lifecycle-terminal value"
12864        );
12865
12866        let view = document
12867            .try_into_view()
12868            .expect("typed view must decode from the partial document");
12869        let full_view =
12870            PersistedSessionMetadataView::try_from_session(&session).expect("full-session view");
12871        assert_eq!(view.session_id, full_view.session_id);
12872        assert_eq!(
12873            view.session_metadata.as_ref().map(|m| m.model.clone()),
12874            full_view.session_metadata.as_ref().map(|m| m.model.clone())
12875        );
12876        assert_eq!(
12877            view.mob_member_binding(),
12878            full_view.mob_member_binding(),
12879            "typed binding must be identical across the two decode paths"
12880        );
12881        assert_eq!(
12882            view.lifecycle_terminal,
12883            Some(SessionLifecycleTerminal::Archived)
12884        );
12885        assert_eq!(
12886            full_view.lifecycle_terminal,
12887            Some(SessionLifecycleTerminal::Archived)
12888        );
12889    }
12890
12891    /// The metadata-only partial decode fails closed on an unsupported
12892    /// envelope version — same contract as the full deserializer.
12893    #[test]
12894    fn session_metadata_document_fails_closed_on_envelope_version() {
12895        let session = Session::new();
12896        let mut value = serde_json::to_value(&session).expect("session should serialize");
12897        value["version"] = serde_json::json!(SESSION_VERSION + 999);
12898        let bytes = serde_json::to_vec(&value).expect("mangled envelope should serialize");
12899
12900        session_metadata_document_from_slice(&bytes)
12901            .expect_err("an unsupported envelope version must fail the partial decode closed");
12902    }
12903
12904    #[test]
12905    fn current_session_deserializer_rejects_released_envelope_version() {
12906        let session = Session::new();
12907        let mut value = serde_json::to_value(&session).expect("session should serialize");
12908        value["version"] = serde_json::json!(2);
12909        let bytes = serde_json::to_vec(&value).expect("released envelope should serialize");
12910
12911        Session::from_persisted_bytes(&bytes)
12912            .expect_err("ordinary Session decode must accept only current envelope v3");
12913    }
12914
12915    /// Corrupt values under either reserved key are a read FAULT for the
12916    /// metadata view — never coalesced into "absent".
12917    #[test]
12918    fn persisted_session_metadata_view_fails_closed_on_corrupt_values() {
12919        let session_id = SessionId::new();
12920
12921        let mut corrupt_metadata = serde_json::Map::new();
12922        corrupt_metadata.insert(SESSION_METADATA_KEY.to_string(), serde_json::json!(42));
12923        PersistedSessionMetadataView::try_from_metadata_map(session_id.clone(), &corrupt_metadata)
12924            .expect_err("corrupt session_metadata must fail the view decode closed");
12925
12926        let mut corrupt_terminal = serde_json::Map::new();
12927        corrupt_terminal.insert(
12928            SESSION_LIFECYCLE_TERMINAL_KEY.to_string(),
12929            serde_json::json!("definitely-not-a-terminal"),
12930        );
12931        PersistedSessionMetadataView::try_from_metadata_map(session_id, &corrupt_terminal)
12932            .expect_err("corrupt lifecycle terminal must fail the view decode closed");
12933    }
12934
12935    /// Absent reserved keys decode as typed absence through the view.
12936    #[test]
12937    fn persisted_session_metadata_view_reads_absent_facts_as_none() {
12938        let view = PersistedSessionMetadataView::try_from_metadata_map(
12939            SessionId::new(),
12940            &serde_json::Map::new(),
12941        )
12942        .expect("empty metadata map must decode");
12943        assert!(view.session_metadata.is_none());
12944        assert!(view.lifecycle_terminal.is_none());
12945        assert!(view.mob_member_binding().is_none());
12946    }
12947}