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;
15use crate::peer_meta::PeerMeta;
16use crate::realtime_transcript::{
17    RealtimeTranscriptApplyOutcome, RealtimeTranscriptEvent, RealtimeUserContentIdentity,
18    SESSION_REALTIME_TRANSCRIPT_STATE_KEY,
19};
20use crate::realtime_transcript_revision::{self, SessionRealtimeTranscriptState};
21use crate::service::{AppendSystemContextRequest, MobToolAuthorityContext};
22use crate::session_durable_config_authority;
23use crate::time_compat::SystemTime;
24use crate::tool_scope::ToolFilter;
25use crate::types::{
26    AssistantBlock, BlockAssistantMessage, ContentBlock, ContentInput, Message, SessionId,
27    StopReason, ToolDef, ToolName, ToolProvenance, ToolResult, Usage, UserMessage,
28};
29use serde::{Deserialize, Deserializer, Serialize, Serializer};
30use sha2::{Digest, Sha256};
31use std::collections::{BTreeMap, BTreeSet, HashMap};
32use std::sync::Arc;
33
34/// Current session format version.
35///
36/// The persisted `version` byte is mandatory and fail-closed: a stored row
37/// with a missing or non-current version (including pre-typed-owner v0/v1
38/// rows) is rejected at the serde boundary by the generated persistence
39/// version authority — it never silently defaults or upgrades on read.
40pub use crate::generated::session_persistence_version_authority::SESSION_VERSION;
41
42/// Current `SessionMetadata` schema version. Distinct from `SESSION_VERSION`
43/// so `SessionMetadata` can evolve independently of the Session envelope.
44///
45/// Mandatory and fail-closed on read, same contract as `SESSION_VERSION`.
46pub use crate::generated::session_persistence_version_authority::SESSION_METADATA_SCHEMA_VERSION;
47
48/// Current session format version accepted by generated persistence authority.
49pub fn session_version() -> u32 {
50    session_persistence_version_authority::session_envelope_version()
51}
52
53/// Current `SessionMetadata` schema version accepted by generated persistence authority.
54pub fn session_metadata_schema_version() -> u32 {
55    session_persistence_version_authority::session_metadata_schema_version()
56}
57
58/// Typed transcript replacement used to create an edited fork.
59///
60/// Replacements never mutate the source session in place. The owning service
61/// applies this to a forked prefix, producing a new `SessionId`.
62#[derive(Debug, Clone, Serialize, Deserialize)]
63#[serde(tag = "type", rename_all = "snake_case")]
64pub enum TranscriptReplacement {
65    /// Replace the addressed message with a full canonical message.
66    Message { message: Message },
67    /// Replace one user-message content block.
68    UserContentBlock {
69        block_index: usize,
70        block: ContentBlock,
71    },
72    /// Replace one block in a block-assistant message.
73    AssistantBlock {
74        block_index: usize,
75        block: AssistantBlock,
76    },
77    /// Replace one content block inside one tool-result payload.
78    ToolResultContentBlock {
79        result_index: usize,
80        block_index: usize,
81        block: ContentBlock,
82    },
83}
84
85/// Session metadata key for the typed transcript revision graph head.
86pub const SESSION_TRANSCRIPT_HISTORY_STATE_KEY: &str = "session_transcript_history_state_v1";
87
88/// A concrete transcript span selected for same-session rewrite.
89#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
90#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
91#[serde(tag = "type", rename_all = "snake_case")]
92pub enum TranscriptRewriteSelection {
93    /// Replace messages in `[start, end)`.
94    MessageRange { start: usize, end: usize },
95}
96
97impl TranscriptRewriteSelection {
98    fn bounds(&self) -> (usize, usize) {
99        match self {
100            Self::MessageRange { start, end } => (*start, *end),
101        }
102    }
103}
104
105/// Audit annotation carried with a transcript rewrite commit.
106///
107/// The free-form kind is for review, debugging, and provenance. It is not a
108/// second policy authority; rewrite admission is enforced by the typed
109/// selection, digest, parent-revision, and store-guard contracts.
110#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
111#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
112#[serde(rename_all = "snake_case")]
113pub struct TranscriptRewriteReason {
114    pub kind: String,
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub note: Option<String>,
117}
118
119impl TranscriptRewriteReason {
120    pub fn new(kind: impl Into<String>) -> Self {
121        Self {
122            kind: kind.into(),
123            note: None,
124        }
125    }
126}
127
128/// Typed rewrite-commit reason for a resume-time base-prompt refresh
129/// committed by [`Session::reconcile_resumed_system_prompt`].
130pub const RESUME_SYSTEM_PROMPT_REFRESH_REWRITE_REASON: &str = "resume-system-prompt-refresh";
131
132/// Typed outcome of [`Session::reconcile_resumed_system_prompt`].
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub enum ResumedSystemPromptReconciliation {
135    /// The persisted System message already carries the assembled base prompt
136    /// (identical, or extended only by runtime system-context appends). The
137    /// transcript was left untouched, so the resumed projection digests to
138    /// the persisted revision.
139    PreservedContinuation,
140    /// The assembled base prompt diverged from the persisted System message;
141    /// the replacement was committed as a typed transcript rewrite so the
142    /// first post-resume persist proves a graph edge from the persisted head.
143    RewrittenBase,
144    /// The resumed transcript has no leading System message and the assembled
145    /// prompt is empty — nothing to reconcile.
146    NoChange,
147}
148
149impl std::fmt::Display for TranscriptRewriteReason {
150    /// Human-facing projection consumed by revision-list reads. The typed
151    /// `{kind, note}` pair stays the owner; this rendering is derived only.
152    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
153        match &self.note {
154            Some(note) => write!(f, "{}: {note}", self.kind),
155            None => f.write_str(&self.kind),
156        }
157    }
158}
159
160/// Immutable rewrite commit that advances a session transcript head.
161#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
162#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
163#[serde(rename_all = "snake_case")]
164pub struct TranscriptRewriteCommit {
165    pub parent_revision: String,
166    pub revision: String,
167    pub selection: TranscriptRewriteSelection,
168    pub original_span_digest: String,
169    pub replacement_digest: String,
170    pub messages_before: usize,
171    pub messages_after: usize,
172    pub reason: TranscriptRewriteReason,
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    pub actor: Option<String>,
175    #[cfg_attr(feature = "schema", schemars(with = "SchemaSystemTime"))]
176    pub committed_at: SystemTime,
177}
178
179/// Immutable transcript revision body retained by the session-local graph.
180#[derive(Debug, Clone, Serialize, Deserialize)]
181#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
182#[serde(rename_all = "snake_case")]
183pub struct TranscriptRevisionBody {
184    pub revision: String,
185    #[serde(default, skip_serializing_if = "Option::is_none")]
186    pub parent_revision: Option<String>,
187    #[cfg_attr(feature = "schema", schemars(with = "Vec<serde_json::Value>"))]
188    pub messages: Vec<Message>,
189    #[cfg_attr(feature = "schema", schemars(with = "SchemaSystemTime"))]
190    pub created_at: SystemTime,
191}
192
193#[cfg(feature = "schema")]
194#[allow(dead_code)]
195#[derive(schemars::JsonSchema)]
196#[schemars(rename = "SystemTime")]
197struct SchemaSystemTime {
198    secs_since_epoch: u64,
199    nanos_since_epoch: u32,
200}
201
202/// Self-contained append-only transcript rewrite record.
203#[derive(Debug, Clone, Serialize)]
204#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
205#[serde(rename_all = "snake_case")]
206pub struct TranscriptRewriteRecord {
207    pub commit: TranscriptRewriteCommit,
208    pub parent_body: TranscriptRevisionBody,
209    pub revision_body: TranscriptRevisionBody,
210}
211
212impl<'de> Deserialize<'de> for TranscriptRewriteRecord {
213    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
214    where
215        D: Deserializer<'de>,
216    {
217        #[derive(Deserialize)]
218        #[serde(rename_all = "snake_case")]
219        struct Wire {
220            commit: TranscriptRewriteCommit,
221            parent_body: TranscriptRevisionBody,
222            revision_body: TranscriptRevisionBody,
223        }
224        let wire = Wire::deserialize(deserializer)?;
225        let mut revisions = vec![wire.parent_body, wire.revision_body];
226        let mut commits = vec![wire.commit];
227        heal_legacy_revision_strings(&mut revisions, &mut commits, None)
228            .map_err(serde::de::Error::custom)?;
229        let mut revisions = revisions.into_iter();
230        let parent_body = revisions
231            .next()
232            .ok_or_else(|| serde::de::Error::custom("rewrite record lost its parent body"))?;
233        let revision_body = revisions
234            .next()
235            .ok_or_else(|| serde::de::Error::custom("rewrite record lost its revision body"))?;
236        let commit = commits
237            .into_iter()
238            .next()
239            .ok_or_else(|| serde::de::Error::custom("rewrite record lost its commit"))?;
240        Ok(Self {
241            commit,
242            parent_body,
243            revision_body,
244        })
245    }
246}
247
248impl TranscriptRewriteRecord {
249    pub fn new(
250        commit: TranscriptRewriteCommit,
251        parent_body: TranscriptRevisionBody,
252        revision_body: TranscriptRevisionBody,
253    ) -> Result<Self, TranscriptEditError> {
254        validate_transcript_rewrite_record(&commit, &parent_body, &revision_body)?;
255        Ok(Self {
256            commit,
257            parent_body,
258            revision_body,
259        })
260    }
261}
262
263/// Typed session-local transcript revision graph state.
264#[derive(Debug, Clone, Serialize)]
265#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
266#[serde(rename_all = "snake_case")]
267pub struct TranscriptHistoryState {
268    pub head: String,
269    #[serde(default, skip_serializing_if = "Vec::is_empty")]
270    pub commits: Vec<TranscriptRewriteCommit>,
271    #[serde(default, skip_serializing_if = "Vec::is_empty")]
272    pub revisions: Vec<TranscriptRevisionBody>,
273}
274
275impl<'de> Deserialize<'de> for TranscriptHistoryState {
276    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
277    where
278        D: Deserializer<'de>,
279    {
280        #[derive(Deserialize)]
281        #[serde(rename_all = "snake_case")]
282        struct Wire {
283            head: String,
284            #[serde(default)]
285            commits: Vec<TranscriptRewriteCommit>,
286            #[serde(default)]
287            revisions: Vec<TranscriptRevisionBody>,
288        }
289        let wire = Wire::deserialize(deserializer)?;
290        let mut state = TranscriptHistoryState {
291            head: wire.head,
292            commits: wire.commits,
293            revisions: wire.revisions,
294        };
295        // Fast path: a graph written by the current digest format has a head
296        // body whose content digest equals the head string; skip the heal.
297        let head_is_current = match state
298            .revisions
299            .iter()
300            .find(|body| body.revision == state.head)
301        {
302            Some(head_body) => {
303                transcript_messages_digest(&head_body.messages).map_err(serde::de::Error::custom)?
304                    == state.head
305            }
306            None => true,
307        };
308        if !head_is_current {
309            let TranscriptHistoryState {
310                head,
311                commits,
312                revisions,
313            } = &mut state;
314            heal_legacy_revision_strings(revisions, commits, Some(head))
315                .map_err(serde::de::Error::custom)?;
316        }
317        Ok(state)
318    }
319}
320
321/// Re-derive pre-0.7.14 (bookkeeping-inclusive) transcript revision strings to
322/// the current content-addressed format at the durable-format parse boundary.
323///
324/// Retained revision bodies carry their full message lists, so every legacy
325/// string can be re-verified against the bytes it was computed from. Only
326/// strings that verify under the legacy digest of their own retained body are
327/// rewritten; anything else is left untouched for the validators to reject
328/// exactly as they would have before.
329fn heal_legacy_revision_strings(
330    revisions: &mut [TranscriptRevisionBody],
331    commits: &mut [TranscriptRewriteCommit],
332    head: Option<&mut String>,
333) -> Result<(), serde_json::Error> {
334    let mut remap: BTreeMap<String, String> = BTreeMap::new();
335    for body in revisions.iter() {
336        let content = transcript_messages_digest(&body.messages)?;
337        if body.revision == content {
338            continue;
339        }
340        if body.revision == legacy_transcript_messages_digest(&body.messages)? {
341            remap.insert(body.revision.clone(), content);
342        }
343    }
344    if remap.is_empty() {
345        return Ok(());
346    }
347    for body in revisions.iter_mut() {
348        if let Some(current) = remap.get(&body.revision) {
349            body.revision = current.clone();
350        }
351        if let Some(parent) = body.parent_revision.as_ref()
352            && let Some(current) = remap.get(parent)
353        {
354            body.parent_revision = Some(current.clone());
355        }
356    }
357    for commit in commits.iter_mut() {
358        if let Some(current) = remap.get(&commit.parent_revision) {
359            commit.parent_revision = current.clone();
360        }
361        if let Some(current) = remap.get(&commit.revision) {
362            commit.revision = current.clone();
363        }
364        heal_legacy_commit_span_digests(commit, revisions)?;
365    }
366    if let Some(head) = head
367        && let Some(current) = remap.get(head.as_str())
368    {
369        *head = current.clone();
370    }
371    Ok(())
372}
373
374/// Re-derive a legacy commit's span digests from its retained bodies.
375///
376/// Span digests are only rewritten when the stored value verifies under the
377/// legacy digest of the same span; malformed commits keep their stored bytes
378/// so [`validate_transcript_rewrite_record`] rejects them unchanged.
379fn heal_legacy_commit_span_digests(
380    commit: &mut TranscriptRewriteCommit,
381    revisions: &[TranscriptRevisionBody],
382) -> Result<(), serde_json::Error> {
383    let Some(parent_body) = revisions
384        .iter()
385        .find(|body| body.revision == commit.parent_revision)
386    else {
387        return Ok(());
388    };
389    let Some(revision_body) = revisions
390        .iter()
391        .find(|body| body.revision == commit.revision)
392    else {
393        return Ok(());
394    };
395    let (start, end) = commit.selection.bounds();
396    if start > end || end > parent_body.messages.len() {
397        return Ok(());
398    }
399    let removed_len = end - start;
400    let Some(retained_len) = commit.messages_before.checked_sub(removed_len) else {
401        return Ok(());
402    };
403    let Some(replacement_len) = commit.messages_after.checked_sub(retained_len) else {
404        return Ok(());
405    };
406    let Some(replacement_end) = start.checked_add(replacement_len) else {
407        return Ok(());
408    };
409    if replacement_end > revision_body.messages.len() {
410        return Ok(());
411    }
412    let original_span = &parent_body.messages[start..end];
413    if commit.original_span_digest == legacy_transcript_messages_digest(original_span)? {
414        commit.original_span_digest = transcript_messages_digest(original_span)?;
415    }
416    let replacement_span = &revision_body.messages[start..replacement_end];
417    if commit.replacement_digest == legacy_transcript_messages_digest(replacement_span)? {
418        commit.replacement_digest = transcript_messages_digest(replacement_span)?;
419    }
420    Ok(())
421}
422
423impl TranscriptHistoryState {
424    /// Rebuild transcript revision graph state from append-only rewrite records.
425    pub fn from_rewrite_records<I>(records: I) -> Result<Option<Self>, TranscriptEditError>
426    where
427        I: IntoIterator<Item = TranscriptRewriteRecord>,
428    {
429        let mut state: Option<Self> = None;
430        for record in records {
431            validate_transcript_rewrite_record(
432                &record.commit,
433                &record.parent_body,
434                &record.revision_body,
435            )?;
436            let state = state.get_or_insert_with(|| Self {
437                head: record.commit.parent_revision.clone(),
438                commits: Vec::new(),
439                revisions: Vec::new(),
440            });
441            if record.commit.parent_revision != state.head {
442                if revision_body_extends_head(&record.parent_body, &state.revisions, &state.head)? {
443                    state.head = record.commit.parent_revision.clone();
444                } else {
445                    return Err(TranscriptEditError::HistoryStateMalformed(format!(
446                        "rewrite record parent {} does not extend transcript head {}",
447                        record.commit.parent_revision, state.head
448                    )));
449                }
450            }
451            if !state
452                .revisions
453                .iter()
454                .any(|body| body.revision == record.parent_body.revision)
455            {
456                state.revisions.push(record.parent_body);
457            }
458            if !state
459                .revisions
460                .iter()
461                .any(|body| body.revision == record.revision_body.revision)
462            {
463                state.revisions.push(record.revision_body);
464            }
465            state.head = record.commit.revision.clone();
466            state.commits.push(record.commit);
467        }
468        Ok(state)
469    }
470}
471
472/// Invalid typed transcript edit request.
473#[derive(Debug, Clone, thiserror::Error)]
474pub enum TranscriptEditError {
475    #[error("message index {message_index} out of bounds for {message_count} messages")]
476    MessageIndexOutOfBounds {
477        message_index: usize,
478        message_count: usize,
479    },
480    #[error("{block_kind} index {block_index} out of bounds for {block_count} blocks")]
481    BlockIndexOutOfBounds {
482        block_kind: &'static str,
483        block_index: usize,
484        block_count: usize,
485    },
486    #[error("replacement expected {expected} at message index {message_index}, found {actual}")]
487    MessageRoleMismatch {
488        message_index: usize,
489        expected: &'static str,
490        actual: &'static str,
491    },
492    #[error("invalid transcript rewrite range {start}..{end} for {message_count} messages")]
493    InvalidRewriteRange {
494        start: usize,
495        end: usize,
496        message_count: usize,
497    },
498    #[error("transcript rewrite does not change transcript revision {revision}")]
499    NoOpRewrite { revision: String },
500    #[error("transcript rewrite parent revision mismatch: expected {expected}, actual {actual}")]
501    RevisionConflict { expected: String, actual: String },
502    #[error("transcript history state is malformed: {0}")]
503    HistoryStateMalformed(String),
504    #[error("invalid transcript shape after rewrite: {0}")]
505    InvalidTranscriptShape(String),
506}
507
508fn message_role_name(message: &Message) -> &'static str {
509    match message {
510        Message::System(_) => "system",
511        Message::SystemNotice(_) => "system_notice",
512        Message::User(_) => "user",
513        Message::BlockAssistant(_) => "block_assistant",
514        Message::ToolResults { .. } => "tool_results",
515    }
516}
517
518fn assistant_tool_use_ids(message: &Message) -> Vec<&str> {
519    match message {
520        Message::BlockAssistant(assistant) => assistant
521            .blocks
522            .iter()
523            .filter_map(|block| match block {
524                AssistantBlock::ToolUse { id, .. } => Some(id.as_str()),
525                _ => None,
526            })
527            .collect(),
528        _ => Vec::new(),
529    }
530}
531
532fn validate_transcript_tool_result_shape(messages: &[Message]) -> Result<(), TranscriptEditError> {
533    for (index, message) in messages.iter().enumerate() {
534        if let Message::ToolResults { results, .. } = message {
535            let Some(previous) = index
536                .checked_sub(1)
537                .and_then(|previous| messages.get(previous))
538            else {
539                return Err(TranscriptEditError::InvalidTranscriptShape(format!(
540                    "tool_results at message {index} has no preceding assistant tool-use message"
541                )));
542            };
543            let expected = assistant_tool_use_ids(previous);
544            if expected.is_empty() {
545                return Err(TranscriptEditError::InvalidTranscriptShape(format!(
546                    "tool_results at message {index} follows {}, not an assistant tool-use message",
547                    message_role_name(previous)
548                )));
549            }
550            let actual = results
551                .iter()
552                .map(|result| result.tool_use_id.as_str())
553                .collect::<Vec<_>>();
554            let actual_set = actual.iter().copied().collect::<BTreeSet<_>>();
555            let expected_set = expected.iter().copied().collect::<BTreeSet<_>>();
556            if actual.len() != actual_set.len() {
557                return Err(TranscriptEditError::InvalidTranscriptShape(format!(
558                    "tool_results at message {index} contains duplicate tool ids"
559                )));
560            }
561            if expected.len() != expected_set.len() {
562                return Err(TranscriptEditError::InvalidTranscriptShape(format!(
563                    "assistant tool-use message before tool_results at message {index} contains duplicate tool ids"
564                )));
565            }
566            if actual_set != expected_set {
567                return Err(TranscriptEditError::InvalidTranscriptShape(format!(
568                    "tool_results at message {index} resolve tool ids {actual_set:?}, expected {expected_set:?}"
569                )));
570            }
571        }
572
573        let tool_use_ids = assistant_tool_use_ids(message);
574        if tool_use_ids.is_empty() {
575            continue;
576        }
577        let Some(next) = messages.get(index + 1) else {
578            return Err(TranscriptEditError::InvalidTranscriptShape(format!(
579                "assistant tool-use message {index} has no following tool_results"
580            )));
581        };
582        if !matches!(next, Message::ToolResults { .. }) {
583            return Err(TranscriptEditError::InvalidTranscriptShape(format!(
584                "assistant tool-use message {index} is followed by {}, not tool_results",
585                message_role_name(next)
586            )));
587        }
588    }
589    Ok(())
590}
591
592fn canonicalize_digest_image_blocks(blocks: &mut [crate::types::ContentBlock]) {
593    for block in blocks.iter_mut() {
594        if let crate::types::ContentBlock::Image {
595            media_type,
596            data: crate::types::ImageData::Inline { data },
597        } = block
598        {
599            // An inline image hydrates from its blob's own bytes, so its
600            // content-addressed identity equals the blob id the store minted.
601            let blob_id = crate::blob::content_blob_id(media_type, data);
602            *block = crate::types::ContentBlock::Image {
603                media_type: media_type.clone(),
604                data: crate::types::ImageData::Blob { blob_id },
605            };
606        }
607    }
608}
609
610/// Canonicalize image payloads to their content-addressed blob identity so the
611/// transcript digest is invariant to inline-vs-blob representation.
612///
613/// The same image hydrated inline for model execution and externalized to a
614/// blob for persistence must share one transcript revision; otherwise a live
615/// session and its durable snapshot would appear "diverged" purely because of
616/// image storage form, and a runtime-backed live session would be discarded as
617/// stale mid-turn.
618fn canonicalize_message_images_for_digest(messages: &[Message]) -> Vec<Message> {
619    let mut canonical = messages.to_vec();
620    for message in &mut canonical {
621        match message {
622            Message::User(user) => canonicalize_digest_image_blocks(&mut user.content),
623            Message::ToolResults { results, .. } => {
624                for result in results.iter_mut() {
625                    canonicalize_digest_image_blocks(&mut result.content);
626                }
627            }
628            Message::SystemNotice(notice) => {
629                for block in &mut notice.blocks {
630                    match block {
631                        crate::types::SystemNoticeBlock::Comms { content, .. }
632                        | crate::types::SystemNoticeBlock::ExternalEvent { content, .. } => {
633                            canonicalize_digest_image_blocks(content);
634                        }
635                        _ => {}
636                    }
637                }
638            }
639            _ => {}
640        }
641    }
642    canonical
643}
644
645/// Timestamp sentinel used when erasing construction bookkeeping from the
646/// digest form. `created_at` always serializes, so a fixed value keeps the
647/// canonical bytes deterministic.
648fn digest_timestamp_sentinel() -> crate::types::MessageTimestamp {
649    chrono::DateTime::<chrono::Utc>::UNIX_EPOCH
650}
651
652/// Canonicalize messages to their conversational content before hashing so the
653/// transcript revision is a content address, not a construction record.
654///
655/// Two normalizations compose:
656/// - image payloads collapse to their content-addressed blob identity
657///   ([`canonicalize_message_images_for_digest`]);
658/// - per-construction bookkeeping is erased: [`TranscriptMessageIdentity`]
659///   (run/interaction ids are runtime-binding atoms — a re-created authority
660///   re-stamps them) and `created_at` timestamps. A resume that re-projects
661///   the same conversation through a new runtime authority must digest to the
662///   same revision as the persisted row, or the append-only save guard
663///   strands the session on restart (fails closed with
664///   `TranscriptContinuityViolation`).
665///
666/// Typed semantic facts stay in the digest — `transcript_role`,
667/// `mutation_kind`, `render_metadata`, notice kinds and blocks — because
668/// changing them changes the transcript's meaning.
669fn canonicalize_messages_for_digest(messages: &[Message]) -> Vec<Message> {
670    let mut canonical = canonicalize_message_images_for_digest(messages);
671    for message in &mut canonical {
672        match message {
673            Message::System(system) => {
674                system.created_at = digest_timestamp_sentinel();
675            }
676            Message::SystemNotice(notice) => {
677                notice.created_at = digest_timestamp_sentinel();
678            }
679            Message::User(user) => {
680                user.identity = crate::types::TranscriptMessageIdentity::default();
681                user.created_at = digest_timestamp_sentinel();
682            }
683            Message::BlockAssistant(assistant) => {
684                assistant.identity = crate::types::TranscriptMessageIdentity::default();
685                assistant.created_at = digest_timestamp_sentinel();
686            }
687            Message::ToolResults { created_at, .. } => {
688                *created_at = digest_timestamp_sentinel();
689            }
690        }
691    }
692    canonical
693}
694
695pub fn transcript_messages_digest(messages: &[Message]) -> Result<String, serde_json::Error> {
696    sha256_json_digest(&canonicalize_messages_for_digest(messages))
697}
698
699/// Digest format used by pre-0.7.14 transcript revision strings.
700///
701/// The legacy canonicalization only normalized image payloads, so persisted
702/// revision strings from older stores include construction bookkeeping
703/// (`identity`, `created_at`). This is a durable-format decoder: it exists
704/// solely so [`heal_legacy_revision_strings`] can verify a stored string
705/// against its retained body before re-deriving it to the current
706/// content-addressed format. Never mint new revisions with it.
707fn legacy_transcript_messages_digest(messages: &[Message]) -> Result<String, serde_json::Error> {
708    sha256_json_digest(&canonicalize_message_images_for_digest(messages))
709}
710
711fn validate_transcript_rewrite_record(
712    commit: &TranscriptRewriteCommit,
713    parent_body: &TranscriptRevisionBody,
714    revision_body: &TranscriptRevisionBody,
715) -> Result<(), TranscriptEditError> {
716    if parent_body.revision != commit.parent_revision {
717        return Err(TranscriptEditError::HistoryStateMalformed(format!(
718            "parent body revision {} does not match commit parent {}",
719            parent_body.revision, commit.parent_revision
720        )));
721    }
722    if revision_body.revision != commit.revision {
723        return Err(TranscriptEditError::HistoryStateMalformed(format!(
724            "revision body {} does not match commit revision {}",
725            revision_body.revision, commit.revision
726        )));
727    }
728    if commit.parent_revision == commit.revision {
729        return Err(TranscriptEditError::NoOpRewrite {
730            revision: commit.revision.clone(),
731        });
732    }
733    let parent_digest = transcript_messages_digest(&parent_body.messages)
734        .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
735    if parent_digest != commit.parent_revision {
736        return Err(TranscriptEditError::HistoryStateMalformed(format!(
737            "parent body digest {parent_digest} does not match commit parent {}",
738            commit.parent_revision
739        )));
740    }
741    let revision_digest = transcript_messages_digest(&revision_body.messages)
742        .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
743    if revision_digest != commit.revision {
744        return Err(TranscriptEditError::HistoryStateMalformed(format!(
745            "revision body digest {revision_digest} does not match commit revision {}",
746            commit.revision
747        )));
748    }
749    let (start, end) = commit.selection.bounds();
750    if start > end || end > parent_body.messages.len() {
751        return Err(TranscriptEditError::InvalidRewriteRange {
752            start,
753            end,
754            message_count: parent_body.messages.len(),
755        });
756    }
757    if commit.messages_before != parent_body.messages.len()
758        || commit.messages_after != revision_body.messages.len()
759    {
760        return Err(TranscriptEditError::HistoryStateMalformed(format!(
761            "commit message counts {} -> {} do not match revision bodies {} -> {}",
762            commit.messages_before,
763            commit.messages_after,
764            parent_body.messages.len(),
765            revision_body.messages.len()
766        )));
767    }
768    let original_span_digest = transcript_messages_digest(&parent_body.messages[start..end])
769        .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
770    if original_span_digest != commit.original_span_digest {
771        return Err(TranscriptEditError::HistoryStateMalformed(format!(
772            "original span digest {original_span_digest} does not match commit digest {}",
773            commit.original_span_digest
774        )));
775    }
776    let removed_len = end - start;
777    let retained_len = commit
778        .messages_before
779        .checked_sub(removed_len)
780        .ok_or_else(|| {
781            TranscriptEditError::HistoryStateMalformed(
782                "commit removed more messages than it recorded before rewrite".to_string(),
783            )
784        })?;
785    let replacement_len = commit
786        .messages_after
787        .checked_sub(retained_len)
788        .ok_or_else(|| {
789            TranscriptEditError::HistoryStateMalformed(
790                "commit message counts cannot describe a replacement span".to_string(),
791            )
792        })?;
793    let replacement_end = start.checked_add(replacement_len).ok_or_else(|| {
794        TranscriptEditError::HistoryStateMalformed("replacement span end overflowed".to_string())
795    })?;
796    if replacement_end > revision_body.messages.len() {
797        return Err(TranscriptEditError::InvalidRewriteRange {
798            start,
799            end: replacement_end,
800            message_count: revision_body.messages.len(),
801        });
802    }
803    let parent_prefix_digest = transcript_messages_digest(&parent_body.messages[..start])
804        .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
805    let revision_prefix_digest = transcript_messages_digest(&revision_body.messages[..start])
806        .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
807    if parent_prefix_digest != revision_prefix_digest {
808        return Err(TranscriptEditError::HistoryStateMalformed(
809            "rewrite revision changed messages before the selected span".to_string(),
810        ));
811    }
812    let parent_suffix_digest = transcript_messages_digest(&parent_body.messages[end..])
813        .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
814    let revision_suffix_digest =
815        transcript_messages_digest(&revision_body.messages[replacement_end..])
816            .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
817    if parent_suffix_digest != revision_suffix_digest {
818        return Err(TranscriptEditError::HistoryStateMalformed(
819            "rewrite revision changed messages after the selected span".to_string(),
820        ));
821    }
822    let replacement_digest =
823        transcript_messages_digest(&revision_body.messages[start..replacement_end])
824            .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
825    if replacement_digest != commit.replacement_digest {
826        return Err(TranscriptEditError::HistoryStateMalformed(format!(
827            "replacement span digest {replacement_digest} does not match commit digest {}",
828            commit.replacement_digest
829        )));
830    }
831    Ok(())
832}
833
834fn validate_transcript_history_state(
835    state: &TranscriptHistoryState,
836) -> Result<(), TranscriptEditError> {
837    if state
838        .revisions
839        .iter()
840        .all(|body| body.revision != state.head)
841    {
842        return Err(TranscriptEditError::HistoryStateMalformed(format!(
843            "missing transcript head body {}",
844            state.head
845        )));
846    }
847    for body in &state.revisions {
848        let digest = transcript_messages_digest(&body.messages)
849            .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
850        if digest != body.revision {
851            return Err(TranscriptEditError::HistoryStateMalformed(format!(
852                "transcript revision body {} has digest {digest}",
853                body.revision
854            )));
855        }
856    }
857    for commit in &state.commits {
858        let parent_body = state
859            .revisions
860            .iter()
861            .find(|body| body.revision == commit.parent_revision)
862            .ok_or_else(|| {
863                TranscriptEditError::HistoryStateMalformed(format!(
864                    "missing parent transcript body {}",
865                    commit.parent_revision
866                ))
867            })?;
868        let revision_body = state
869            .revisions
870            .iter()
871            .find(|body| body.revision == commit.revision)
872            .ok_or_else(|| {
873                TranscriptEditError::HistoryStateMalformed(format!(
874                    "missing transcript revision body {}",
875                    commit.revision
876                ))
877            })?;
878        validate_transcript_rewrite_record(commit, parent_body, revision_body)?;
879    }
880    let Some(first_commit) = state.commits.first() else {
881        return Ok(());
882    };
883    let mut expected_head = first_commit.parent_revision.clone();
884    for commit in &state.commits {
885        let parent_body = state
886            .revisions
887            .iter()
888            .find(|body| body.revision == commit.parent_revision)
889            .ok_or_else(|| {
890                TranscriptEditError::HistoryStateMalformed(format!(
891                    "missing parent transcript body {}",
892                    commit.parent_revision
893                ))
894            })?;
895        if commit.parent_revision != expected_head
896            && !revision_body_extends_head(parent_body, &state.revisions, &expected_head)?
897        {
898            return Err(TranscriptEditError::HistoryStateMalformed(format!(
899                "rewrite commit parent {} does not extend transcript head {}",
900                commit.parent_revision, expected_head
901            )));
902        }
903        expected_head = commit.revision.clone();
904    }
905    let mut cursor = state.head.clone();
906    while cursor != expected_head {
907        let Some(head_body) = state.revisions.iter().find(|body| body.revision == cursor) else {
908            break;
909        };
910        match head_body.parent_revision.as_deref() {
911            Some(parent) => cursor = parent.to_string(),
912            None => break,
913        }
914    }
915    if cursor != expected_head {
916        return Err(TranscriptEditError::HistoryStateMalformed(format!(
917            "transcript head {} does not extend the rewrite chain",
918            state.head
919        )));
920    }
921    Ok(())
922}
923
924fn revision_body_extends_head(
925    candidate: &TranscriptRevisionBody,
926    revisions: &[TranscriptRevisionBody],
927    head: &str,
928) -> Result<bool, TranscriptEditError> {
929    if candidate.parent_revision.as_deref() == Some(head) {
930        return Ok(true);
931    }
932    let Some(head_body) = revisions.iter().find(|body| body.revision == head) else {
933        return Ok(false);
934    };
935    if candidate.messages.len() < head_body.messages.len() {
936        return Ok(false);
937    }
938    let prefix_digest = transcript_messages_digest(&candidate.messages[..head_body.messages.len()])
939        .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
940    Ok(prefix_digest == head)
941}
942
943fn sha256_json_digest<T: Serialize + ?Sized>(value: &T) -> Result<String, serde_json::Error> {
944    let bytes = serde_json::to_vec(value)?;
945    let digest = Sha256::digest(bytes);
946    let mut out = String::with_capacity(digest.len() * 2);
947    const HEX: &[u8; 16] = b"0123456789abcdef";
948    for byte in digest {
949        out.push(HEX[(byte >> 4) as usize] as char);
950        out.push(HEX[(byte & 0x0f) as usize] as char);
951    }
952    Ok(format!("sha256:{out}"))
953}
954
955/// A conversation session with full history
956///
957/// Uses Arc<Vec<Message>> internally for efficient forking (copy-on-write).
958#[derive(Debug, Clone)]
959pub struct Session {
960    /// Persisted envelope format version, validated fail-closed on read by
961    /// the generated persistence version authority.
962    version: u32,
963    /// Unique identifier
964    id: SessionId,
965    /// All messages in order (Arc for CoW on fork)
966    pub(crate) messages: Arc<Vec<Message>>,
967    /// When the session was created
968    created_at: SystemTime,
969    /// When the session was last updated
970    updated_at: SystemTime,
971    /// Arbitrary metadata
972    metadata: serde_json::Map<String, serde_json::Value>,
973    /// Cumulative token usage across all LLM calls in this session
974    usage: Usage,
975}
976
977/// Serde helper for Session serialization (flattens Arc)
978#[derive(Serialize, Deserialize)]
979#[serde(rename_all = "snake_case")]
980struct SessionSerde {
981    version: u32,
982    id: SessionId,
983    messages: Vec<Message>,
984    created_at: SystemTime,
985    updated_at: SystemTime,
986    #[serde(default)]
987    metadata: serde_json::Map<String, serde_json::Value>,
988    #[serde(default)]
989    usage: Usage,
990}
991
992impl Serialize for Session {
993    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
994    where
995        S: Serializer,
996    {
997        let serde_repr = SessionSerde {
998            version: self.version,
999            id: self.id.clone(),
1000            messages: (*self.messages).clone(),
1001            created_at: self.created_at,
1002            updated_at: self.updated_at,
1003            metadata: self.metadata.clone(),
1004            usage: self.usage.clone(),
1005        };
1006        serde_repr.serialize(serializer)
1007    }
1008}
1009
1010impl<'de> Deserialize<'de> for Session {
1011    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1012    where
1013        D: Deserializer<'de>,
1014    {
1015        let serde_repr = SessionSerde::deserialize(deserializer)?;
1016        let version = session_persistence_version_authority::restore_session_envelope_version(
1017            serde_repr.version,
1018        )
1019        .map_err(<D::Error as serde::de::Error>::custom)?;
1020        Ok(Session {
1021            version,
1022            id: serde_repr.id,
1023            messages: Arc::new(serde_repr.messages),
1024            created_at: serde_repr.created_at,
1025            updated_at: serde_repr.updated_at,
1026            metadata: serde_repr.metadata,
1027            usage: serde_repr.usage,
1028        })
1029    }
1030}
1031
1032/// Serde helper for the metadata-only partial decode of a persisted session
1033/// envelope.
1034///
1035/// LOCKSTEP with [`SessionSerde`]: this struct must decode exactly the field
1036/// names and serde shapes that `SessionSerde` persists for `version`, `id`,
1037/// and `metadata` (`rename_all = "snake_case"`, `#[serde(default)]` on
1038/// `metadata`). The `session_metadata_document_lockstep_with_full_envelope`
1039/// pin test fails if the two drift.
1040#[derive(Deserialize)]
1041#[serde(rename_all = "snake_case")]
1042struct SessionMetadataDocumentSerde {
1043    version: u32,
1044    id: SessionId,
1045    #[serde(default)]
1046    metadata: serde_json::Map<String, serde_json::Value>,
1047}
1048
1049/// Metadata-only projection of a persisted session envelope.
1050///
1051/// Produced by [`session_metadata_document_from_slice`] without materializing
1052/// the transcript. Exposes ONLY the two session-authority facts the metadata
1053/// read seam is allowed to observe ([`SESSION_METADATA_KEY`] and
1054/// [`SESSION_LIFECYCLE_TERMINAL_KEY`]) — deliberately no raw metadata-map
1055/// accessor, so the partial decode can never grow into an untyped side
1056/// channel around [`Session`]'s authority-gated reads.
1057#[derive(Debug, Clone)]
1058pub struct SessionMetadataDocument {
1059    session_id: SessionId,
1060    metadata: serde_json::Map<String, serde_json::Value>,
1061}
1062
1063impl SessionMetadataDocument {
1064    /// Session identity carried by the envelope.
1065    pub fn session_id(&self) -> &SessionId {
1066        &self.session_id
1067    }
1068
1069    /// Raw projected [`SESSION_METADATA_KEY`] value, for divergence
1070    /// comparison against another projection of the same fact.
1071    pub fn session_metadata_value(&self) -> Option<&serde_json::Value> {
1072        self.metadata.get(SESSION_METADATA_KEY)
1073    }
1074
1075    /// Raw projected [`SESSION_LIFECYCLE_TERMINAL_KEY`] value, for divergence
1076    /// comparison against another projection of the same fact.
1077    pub fn lifecycle_terminal_value(&self) -> Option<&serde_json::Value> {
1078        self.metadata.get(SESSION_LIFECYCLE_TERMINAL_KEY)
1079    }
1080
1081    /// Decode the typed metadata view through the canonical map-level
1082    /// decoders, failing closed on corrupt values.
1083    pub fn try_into_view(self) -> Result<PersistedSessionMetadataView, serde_json::Error> {
1084        PersistedSessionMetadataView::try_from_metadata_map(self.session_id, &self.metadata)
1085    }
1086}
1087
1088/// Partially decode a persisted session envelope into its metadata-only
1089/// document, without materializing the transcript.
1090///
1091/// Fail-closed on the envelope format version through the generated
1092/// persistence version authority — exactly like the full [`Session`]
1093/// deserializer.
1094pub fn session_metadata_document_from_slice(
1095    bytes: &[u8],
1096) -> Result<SessionMetadataDocument, serde_json::Error> {
1097    let serde_repr: SessionMetadataDocumentSerde = serde_json::from_slice(bytes)?;
1098    session_persistence_version_authority::restore_session_envelope_version(serde_repr.version)
1099        .map_err(<serde_json::Error as serde::de::Error>::custom)?;
1100    Ok(SessionMetadataDocument {
1101        session_id: serde_repr.id,
1102        metadata: serde_repr.metadata,
1103    })
1104}
1105
1106impl Session {
1107    /// Rebuild a slim `Session` from persisted head-row parts.
1108    ///
1109    /// Used by [`crate::session_store::SessionHead::into_session`] to
1110    /// materialize a session from an incremental store's head row plus its
1111    /// strand messages. The envelope version is restored fail-closed through
1112    /// the generated persistence version authority, exactly like
1113    /// [`Session::deserialize`].
1114    pub(crate) fn from_head_parts(
1115        version: u32,
1116        id: SessionId,
1117        messages: Vec<Message>,
1118        created_at: SystemTime,
1119        updated_at: SystemTime,
1120        metadata: serde_json::Map<String, serde_json::Value>,
1121        usage: Usage,
1122    ) -> Result<Self, String> {
1123        let version =
1124            session_persistence_version_authority::restore_session_envelope_version(version)
1125                .map_err(|err| err.to_string())?;
1126        Ok(Self {
1127            version,
1128            id,
1129            messages: Arc::new(messages),
1130            created_at,
1131            updated_at,
1132            metadata,
1133            usage,
1134        })
1135    }
1136}
1137
1138/// Metadata key used to store durable system-context control state.
1139pub const SESSION_SYSTEM_CONTEXT_STATE_KEY: &str = "session_system_context_state";
1140
1141/// Metadata key used to store deferred-turn control state.
1142pub const SESSION_DEFERRED_TURN_STATE_KEY: &str = "session_deferred_turn_state";
1143
1144/// Metadata key used to store recoverable build-only session state.
1145pub const SESSION_BUILD_STATE_KEY: &str = "session_build_state";
1146
1147/// Metadata key used to store durable session-local tool visibility intent.
1148pub const SESSION_TOOL_VISIBILITY_STATE_KEY: &str = "session_tool_visibility_state_v1";
1149
1150/// Metadata key used to store the typed session lifecycle-terminal fact.
1151pub const SESSION_LIFECYCLE_TERMINAL_KEY: &str = "session_lifecycle_terminal";
1152
1153/// Typed provenance fact for a durable session-store row written by the
1154/// intra-turn best-effort checkpointer AHEAD of the runtime boundary commit.
1155/// Present on a row iff its last writer was the checkpointer; every
1156/// boundary-following persist strips it. The runtime-projection rollback
1157/// consults this fact so only tails the system itself checkpointed can be
1158/// converged back onto committed truth — out-of-band row divergence keeps
1159/// failing closed.
1160pub const SESSION_RUNTIME_CHECKPOINT_PROVENANCE_KEY: &str =
1161    "session_runtime_checkpoint_provenance_v1";
1162
1163/// Canonical tool name gated by `image_tool_results` capability.
1164pub const VIEW_IMAGE_TOOL_NAME: &str = "view_image";
1165
1166/// Canonical separator between appended runtime system-context blocks.
1167pub const SYSTEM_CONTEXT_SEPARATOR: &str = "\n\n---\n\n";
1168
1169#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1170#[error("metadata key `{key}` is reserved for session authority")]
1171pub struct ReservedSessionMetadataKey {
1172    key: String,
1173}
1174
1175impl ReservedSessionMetadataKey {
1176    fn new(key: &str) -> Self {
1177        Self {
1178            key: key.to_string(),
1179        }
1180    }
1181}
1182
1183fn is_session_authority_metadata_key(key: &str) -> bool {
1184    // Single reserved-key authority: the typed classifier owns the
1185    // session-authority key set (the `session_*` state constants).
1186    crate::surface_metadata::ReservedMetadataKey::is_session_authority(key)
1187}
1188
1189#[allow(clippy::panic)]
1190fn fail_closed_generated_restore(authority: &'static str, err: serde_json::Error) -> ! {
1191    tracing::error!(
1192        authority,
1193        error = %err,
1194        "generated authority rejected durable restore"
1195    );
1196    panic!("generated {authority} authority rejected durable restore: {err}");
1197}
1198
1199/// Shared runtime system-context authority handle.
1200///
1201/// This handle is intentionally narrower than `Arc<Mutex<SessionSystemContextState>>`:
1202/// callers can read snapshots or request generated-authority transitions, but
1203/// cannot replace the machine-owned state by taking a mutable guard.
1204#[derive(Clone)]
1205pub struct SystemContextStateHandle {
1206    inner: Arc<std::sync::Mutex<SessionSystemContextState>>,
1207}
1208
1209impl std::fmt::Debug for SystemContextStateHandle {
1210    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1211        f.debug_struct("SystemContextStateHandle")
1212            .field("inner", &"<Arc<Mutex<SessionSystemContextState>>>")
1213            .finish()
1214    }
1215}
1216
1217impl SystemContextStateHandle {
1218    pub fn new(state: SessionSystemContextState) -> Result<Self, serde_json::Error> {
1219        let state = system_context_authority::restore_system_context_state(state)
1220            .map_err(<serde_json::Error as serde::de::Error>::custom)?;
1221        Ok(Self {
1222            inner: Arc::new(std::sync::Mutex::new(state)),
1223        })
1224    }
1225
1226    pub fn from_shared_authority_state(
1227        inner: Arc<std::sync::Mutex<SessionSystemContextState>>,
1228    ) -> Self {
1229        Self { inner }
1230    }
1231
1232    pub fn snapshot(&self) -> SessionSystemContextState {
1233        match self.inner.lock() {
1234            Ok(guard) => guard.clone(),
1235            Err(poisoned) => {
1236                tracing::warn!("system-context state lock poisoned while reading snapshot");
1237                poisoned.into_inner().clone()
1238            }
1239        }
1240    }
1241
1242    pub fn replace_from_generated_restore(
1243        &self,
1244        state: SessionSystemContextState,
1245    ) -> Result<(), serde_json::Error> {
1246        let state = system_context_authority::restore_system_context_state(state)
1247            .map_err(<serde_json::Error as serde::de::Error>::custom)?;
1248        match self.inner.lock() {
1249            Ok(mut guard) => {
1250                *guard = state;
1251            }
1252            Err(poisoned) => {
1253                tracing::warn!("system-context state lock poisoned while restoring state");
1254                *poisoned.into_inner() = state;
1255            }
1256        }
1257        Ok(())
1258    }
1259
1260    pub fn replace_from_generated_restore_if_changed(
1261        &self,
1262        state: SessionSystemContextState,
1263    ) -> Result<bool, serde_json::Error> {
1264        let state = system_context_authority::restore_system_context_state(state)
1265            .map_err(<serde_json::Error as serde::de::Error>::custom)?;
1266        let mut guard = match self.inner.lock() {
1267            Ok(guard) => guard,
1268            Err(poisoned) => {
1269                tracing::warn!(
1270                    "system-context state lock poisoned while replacing generated-restored state"
1271                );
1272                poisoned.into_inner()
1273            }
1274        };
1275        if *guard == state {
1276            return Ok(false);
1277        }
1278        *guard = state;
1279        Ok(true)
1280    }
1281
1282    pub fn replace_from_generated_restore_if_current(
1283        &self,
1284        current: &SessionSystemContextState,
1285        replacement: SessionSystemContextState,
1286    ) -> Result<bool, serde_json::Error> {
1287        let replacement = system_context_authority::restore_system_context_state(replacement)
1288            .map_err(<serde_json::Error as serde::de::Error>::custom)?;
1289        let mut guard = match self.inner.lock() {
1290            Ok(guard) => guard,
1291            Err(poisoned) => {
1292                tracing::warn!(
1293                    "system-context state lock poisoned while conditionally replacing generated-restored state"
1294                );
1295                poisoned.into_inner()
1296            }
1297        };
1298        if *guard != *current {
1299            return Ok(false);
1300        }
1301        *guard = replacement;
1302        Ok(true)
1303    }
1304
1305    pub fn stage_append_with_snapshot(
1306        &self,
1307        req: &AppendSystemContextRequest,
1308        accepted_at: SystemTime,
1309    ) -> Result<
1310        (
1311            crate::service::AppendSystemContextStatus,
1312            SessionSystemContextState,
1313            SessionSystemContextState,
1314        ),
1315        SystemContextStageError,
1316    > {
1317        let mut guard = match self.inner.lock() {
1318            Ok(guard) => guard,
1319            Err(poisoned) => {
1320                tracing::warn!("system-context state lock poisoned while staging append");
1321                poisoned.into_inner()
1322            }
1323        };
1324        let snapshot = guard.clone();
1325        let status = guard.stage_append(req, accepted_at)?;
1326        let staged = guard.clone();
1327        Ok((status, snapshot, staged))
1328    }
1329
1330    pub fn stage_active_turn_appends_with_snapshot(
1331        &self,
1332        appends: Vec<(AppendSystemContextRequest, SystemTime)>,
1333    ) -> Result<(SessionSystemContextState, SessionSystemContextState), SystemContextStageError>
1334    {
1335        let mut guard = match self.inner.lock() {
1336            Ok(guard) => guard,
1337            Err(poisoned) => {
1338                tracing::warn!(
1339                    "system-context state lock poisoned while staging active-turn appends"
1340                );
1341                poisoned.into_inner()
1342            }
1343        };
1344        let snapshot = guard.clone();
1345        let mut candidate = snapshot.clone();
1346        for (req, accepted_at) in appends {
1347            candidate.stage_active_turn_append(&req, accepted_at)?;
1348        }
1349        *guard = candidate.clone();
1350        let staged = candidate;
1351        Ok((snapshot, staged))
1352    }
1353
1354    pub fn discard_unapplied_active_turn_pending(&self) -> usize {
1355        let discarded = match self.inner.lock() {
1356            Ok(mut guard) => guard.discard_unapplied_active_turn_pending(),
1357            Err(poisoned) => {
1358                tracing::warn!(
1359                    "system-context state lock poisoned while discarding active-turn context"
1360                );
1361                poisoned
1362                    .into_inner()
1363                    .discard_unapplied_active_turn_pending()
1364            }
1365        };
1366        discarded.len()
1367    }
1368
1369    pub fn discard_active_turn_pending_by_keys(
1370        &self,
1371        idempotency_keys: &[String],
1372    ) -> Vec<PendingSystemContextAppend> {
1373        match self.inner.lock() {
1374            Ok(mut guard) => guard.discard_active_turn_pending_by_keys(idempotency_keys),
1375            Err(poisoned) => {
1376                tracing::warn!(
1377                    "system-context state lock poisoned while discarding active-turn pending appends"
1378                );
1379                poisoned
1380                    .into_inner()
1381                    .discard_active_turn_pending_by_keys(idempotency_keys)
1382            }
1383        }
1384    }
1385
1386    pub fn stage_active_turn_append(
1387        &self,
1388        req: &AppendSystemContextRequest,
1389        accepted_at: SystemTime,
1390    ) -> Result<crate::service::AppendSystemContextStatus, SystemContextStageError> {
1391        match self.inner.lock() {
1392            Ok(mut guard) => guard.stage_active_turn_append(req, accepted_at),
1393            Err(poisoned) => {
1394                tracing::warn!(
1395                    "system-context state lock poisoned while staging active-turn context"
1396                );
1397                poisoned
1398                    .into_inner()
1399                    .stage_active_turn_append(req, accepted_at)
1400            }
1401        }
1402    }
1403}
1404
1405/// Durable control state for runtime system-context append requests.
1406// Cannot derive `Eq`: `PendingSystemContextAppend` carries a typed
1407// `peer_response_terminal` fact whose render payload is a `serde_json::Value`.
1408#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
1409#[serde(rename_all = "snake_case")]
1410pub struct SessionSystemContextState {
1411    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1412    pub(crate) pending: Vec<PendingSystemContextAppend>,
1413    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1414    pub(crate) applied: Vec<PendingSystemContextAppend>,
1415    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
1416    pub(crate) seen: std::collections::BTreeMap<String, SeenSystemContextKey>,
1417    #[serde(default, skip_serializing_if = "std::collections::BTreeSet::is_empty")]
1418    pub(crate) active_turn_pending_keys: std::collections::BTreeSet<String>,
1419}
1420
1421/// Typed provenance class for a runtime system-context append.
1422///
1423/// Canonical replacement for the retired `runtime:steer:` string-prefix
1424/// folklore. The PRODUCER of a runtime-steer append (the runtime input
1425/// projection in `meerkat-runtime`) constructs it with
1426/// [`SystemContextSource::RuntimeSteer`]; everything else is
1427/// [`SystemContextSource::Normal`]. No code reclassifies a `source` string
1428/// into this fact — it is set once at construction and the machine guards the
1429/// typed field.
1430#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1431#[serde(rename_all = "snake_case")]
1432pub enum SystemContextSource {
1433    /// A durable, non-transient runtime context append (peer responses, etc.).
1434    #[default]
1435    Normal,
1436    /// A transient operator/peer steer append that must not survive past the
1437    /// turn it steers and must not be promoted to the durable applied set.
1438    RuntimeSteer,
1439}
1440
1441impl From<SystemContextSource> for session_document::SystemContextSource {
1442    fn from(value: SystemContextSource) -> Self {
1443        match value {
1444            SystemContextSource::Normal => Self::Normal,
1445            SystemContextSource::RuntimeSteer => Self::RuntimeSteer,
1446        }
1447    }
1448}
1449
1450impl SystemContextSource {
1451    /// Whether this is the default (`Normal`) provenance. Used by
1452    /// `skip_serializing_if` so durable appends serialize without the field.
1453    #[must_use]
1454    pub fn is_normal(&self) -> bool {
1455        matches!(self, Self::Normal)
1456    }
1457
1458    /// Whether this append is a transient runtime steer.
1459    #[must_use]
1460    pub fn is_runtime_steer(&self) -> bool {
1461        matches!(self, Self::RuntimeSteer)
1462    }
1463}
1464
1465/// Pending append request accepted by the control plane but not yet applied at an LLM boundary.
1466// Cannot derive `Eq`: the typed `peer_response_terminal` fact carries a
1467// `serde_json::Value` render payload, which is `PartialEq` but not `Eq`.
1468#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1469#[serde(rename_all = "snake_case")]
1470pub struct PendingSystemContextAppend {
1471    /// Typed renderable append content, carried end-to-end from the surface
1472    /// request ([`AppendSystemContextRequest.content`]). The ONE lowering to
1473    /// model-facing prompt text happens where the transcript consumes the
1474    /// append ([`CoreRenderable::render_text`] inside the render seam) —
1475    /// surfaces never pre-flatten this into a string.
1476    ///
1477    /// [`CoreRenderable::render_text`]: crate::lifecycle::run_primitive::CoreRenderable::render_text
1478    pub content: crate::lifecycle::run_primitive::CoreRenderable,
1479    #[serde(default, skip_serializing_if = "Option::is_none")]
1480    pub source: Option<String>,
1481    #[serde(default, skip_serializing_if = "Option::is_none")]
1482    pub idempotency_key: Option<String>,
1483    /// Typed provenance: whether this append is a transient runtime steer.
1484    #[serde(default, skip_serializing_if = "SystemContextSource::is_normal")]
1485    pub source_kind: SystemContextSource,
1486    /// Typed terminal-peer-response fact this append carries, when the append
1487    /// projects a `PeerResponseTerminalFact`. The producer stamps the typed
1488    /// fact here at construction; realtime/live consumers read the typed fact
1489    /// directly instead of re-parsing the flattened prompt `text`/`source`
1490    /// string (the `peer_response_terminal:` prefix + `Payload:` split). This
1491    /// mirrors the `source_kind` precedent that retired the `runtime:steer:`
1492    /// string-prefix re-derivation.
1493    #[serde(default, skip_serializing_if = "Option::is_none")]
1494    pub peer_response_terminal: Option<crate::handles::PeerResponseTerminalFact>,
1495    pub accepted_at: SystemTime,
1496}
1497
1498/// Typed terminal-lifecycle projection of the canonical
1499/// [`session_document::SessionDocumentMachine`] `session_lifecycle_terminal`
1500/// fact.
1501///
1502/// The machine owns archive lifecycle truth for ALL profiles (LUC-524 R004
1503/// fold): both the runtime-backed and the store-only archive paths drive the
1504/// machine's `ArchiveSessionDocument` input, and this reserved-key field is
1505/// the machine-realized durable projection of the emitted verdict — the shell
1506/// realizes it, it never decides it. `RuntimeState::Retired` is the runtime
1507/// realization of the SAME verdict; the fail-closed realization order (durable
1508/// document commit first, runtime retire second) keeps the two projections
1509/// convergent. A two-variant enum (rather than a bare bool) keeps future
1510/// terminal classes — e.g. `Destroyed` — extending the type rather than the
1511/// call sites.
1512#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1513#[serde(rename_all = "snake_case")]
1514pub enum SessionLifecycleTerminal {
1515    /// The session is live / resumable.
1516    Active,
1517    /// The session has been archived and is terminal.
1518    Archived,
1519}
1520
1521impl SessionLifecycleTerminal {
1522    /// Whether this terminal fact marks the session as archived.
1523    #[must_use]
1524    pub fn is_archived(self) -> bool {
1525        matches!(self, Self::Archived)
1526    }
1527}
1528
1529impl From<SessionLifecycleTerminal> for session_document::SessionDocumentLifecycle {
1530    fn from(value: SessionLifecycleTerminal) -> Self {
1531        match value {
1532            SessionLifecycleTerminal::Active => Self::Active,
1533            SessionLifecycleTerminal::Archived => Self::Archived,
1534        }
1535    }
1536}
1537
1538impl From<session_document::SessionDocumentLifecycle> for SessionLifecycleTerminal {
1539    fn from(value: session_document::SessionDocumentLifecycle) -> Self {
1540        match value {
1541            session_document::SessionDocumentLifecycle::Active => Self::Active,
1542            session_document::SessionDocumentLifecycle::Archived => Self::Archived,
1543        }
1544    }
1545}
1546
1547/// Durable control state for deferred first-turn prompt and staged callback tool results.
1548#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
1549#[serde(rename_all = "snake_case")]
1550pub struct SessionDeferredTurnState {
1551    #[serde(default, skip_serializing_if = "DeferredFirstTurnPhase::is_inactive")]
1552    pub(crate) first_turn_phase: DeferredFirstTurnPhase,
1553    #[serde(default, skip_serializing_if = "Option::is_none")]
1554    pub(crate) pending_initial_prompt: Option<PendingDeferredPrompt>,
1555    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1556    pub(crate) pending_tool_results: Vec<PendingToolResultsMessage>,
1557}
1558
1559/// Canonical lifecycle phase for the session's deferred first turn.
1560#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
1561#[serde(rename_all = "snake_case")]
1562pub enum DeferredFirstTurnPhase {
1563    /// The session was not created in deferred-first-turn mode.
1564    #[default]
1565    Inactive,
1566    /// The session exists durably but the first turn has not started yet.
1567    Pending,
1568    /// The first turn has started; build-only overrides are no longer legal.
1569    Consumed,
1570}
1571
1572impl DeferredFirstTurnPhase {
1573    pub fn is_inactive(&self) -> bool {
1574        matches!(self, Self::Inactive)
1575    }
1576}
1577
1578impl From<DeferredFirstTurnPhase> for session_document::SessionFirstTurnPhase {
1579    fn from(value: DeferredFirstTurnPhase) -> Self {
1580        match value {
1581            DeferredFirstTurnPhase::Inactive => Self::Inactive,
1582            DeferredFirstTurnPhase::Pending => Self::Pending,
1583            DeferredFirstTurnPhase::Consumed => Self::Consumed,
1584        }
1585    }
1586}
1587
1588impl From<session_document::SessionFirstTurnPhase> for DeferredFirstTurnPhase {
1589    fn from(value: session_document::SessionFirstTurnPhase) -> Self {
1590        match value {
1591            session_document::SessionFirstTurnPhase::Inactive => Self::Inactive,
1592            session_document::SessionFirstTurnPhase::Pending => Self::Pending,
1593            session_document::SessionFirstTurnPhase::Consumed => Self::Consumed,
1594        }
1595    }
1596}
1597
1598fn is_default_hook_run_overrides(value: &crate::HookRunOverrides) -> bool {
1599    value == &crate::HookRunOverrides::default()
1600}
1601
1602fn is_default_call_timeout_override(value: &crate::CallTimeoutOverride) -> bool {
1603    value == &crate::CallTimeoutOverride::default()
1604}
1605
1606fn is_tool_filter_all(value: &ToolFilter) -> bool {
1607    matches!(value, ToolFilter::All)
1608}
1609
1610fn is_zero(value: &u64) -> bool {
1611    *value == 0
1612}
1613
1614/// Derive the machine-owned capability base filter from the current image-tool-results support.
1615pub fn capability_base_filter_for_image_tool_results(image_tool_results: bool) -> ToolFilter {
1616    if image_tool_results {
1617        ToolFilter::All
1618    } else {
1619        ToolFilter::Deny([VIEW_IMAGE_TOOL_NAME.to_string()].into_iter().collect())
1620    }
1621}
1622
1623/// Persisted witness for a durable tool-visibility name.
1624///
1625/// `last_seen_provenance` is the single typed identity owner. The formatted
1626/// `stable_owner_key` string is a read-only projection derived on demand via
1627/// [`crate::tool_catalog::stable_owner_key_from_provenance`], never stored
1628/// beside the owner.
1629#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
1630#[serde(rename_all = "snake_case")]
1631pub struct ToolVisibilityWitness {
1632    #[serde(default, skip_serializing_if = "Option::is_none")]
1633    pub last_seen_provenance: Option<ToolProvenance>,
1634}
1635
1636impl ToolVisibilityWitness {
1637    pub fn has_identity_witness(&self) -> bool {
1638        self.last_seen_provenance.is_some()
1639    }
1640}
1641
1642/// Typed authority value for a deferred-tool load request.
1643///
1644/// The public/effect seam carries the requested route name and provenance
1645/// witness as one value. Canonical owners may project this into name-indexed
1646/// maps internally, but callers do not get to make a map key the authority.
1647#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1648#[serde(rename_all = "snake_case")]
1649pub struct DeferredToolLoadAuthority {
1650    pub name: ToolName,
1651    pub witness: ToolVisibilityWitness,
1652}
1653
1654impl DeferredToolLoadAuthority {
1655    pub fn new(name: impl Into<ToolName>, witness: ToolVisibilityWitness) -> Self {
1656        Self {
1657            name: name.into(),
1658            witness,
1659        }
1660    }
1661
1662    pub fn into_parts(self) -> (ToolName, ToolVisibilityWitness) {
1663        (self.name, self.witness)
1664    }
1665}
1666
1667/// Durable tool-filter intent paired with the witnesses that made the names
1668/// authoritative at capture time.
1669#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
1670#[serde(rename_all = "snake_case")]
1671pub struct WitnessedToolFilter {
1672    pub filter: ToolFilter,
1673    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1674    pub witnesses: BTreeMap<ToolName, ToolVisibilityWitness>,
1675}
1676
1677impl WitnessedToolFilter {
1678    pub fn new(filter: ToolFilter, witnesses: BTreeMap<ToolName, ToolVisibilityWitness>) -> Self {
1679        Self { filter, witnesses }
1680    }
1681
1682    pub fn into_parts(self) -> (ToolFilter, BTreeMap<ToolName, ToolVisibilityWitness>) {
1683        (self.filter, self.witnesses)
1684    }
1685}
1686
1687/// Opaque parent/composition-authorized inherited tool visibility handoff.
1688///
1689/// The filter and witnesses are intentionally not public fields. Callers that
1690/// need to hand inherited visibility to a child build must obtain this from an
1691/// AgentFactory-minted parent composition authority; they cannot write
1692/// canonical session visibility state directly.
1693#[derive(Debug, Clone, PartialEq, Eq)]
1694pub struct InheritedToolVisibilityAuthority {
1695    filter: ToolFilter,
1696    witnesses: BTreeMap<ToolName, ToolVisibilityWitness>,
1697}
1698
1699impl InheritedToolVisibilityAuthority {
1700    pub(crate) fn from_generated_composition_authority(
1701        filter: ToolFilter,
1702        witnesses: BTreeMap<ToolName, ToolVisibilityWitness>,
1703    ) -> Self {
1704        Self { filter, witnesses }
1705    }
1706
1707    pub fn filter(&self) -> &ToolFilter {
1708        &self.filter
1709    }
1710
1711    pub fn witnesses(&self) -> &BTreeMap<ToolName, ToolVisibilityWitness> {
1712        &self.witnesses
1713    }
1714
1715    pub(crate) fn into_initial_visibility_state(self) -> SessionToolVisibilityState {
1716        SessionToolVisibilityState {
1717            inherited_base_filter: self.filter,
1718            filter_witnesses: self.witnesses,
1719            ..Default::default()
1720        }
1721    }
1722}
1723
1724/// Canonical durable session-local tool visibility intent.
1725#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
1726#[serde(rename_all = "snake_case")]
1727pub struct SessionToolVisibilityState {
1728    #[serde(default, skip_serializing_if = "is_tool_filter_all")]
1729    pub capability_base_filter: ToolFilter,
1730    #[serde(default, skip_serializing_if = "is_tool_filter_all")]
1731    pub inherited_base_filter: ToolFilter,
1732    #[serde(default, skip_serializing_if = "is_tool_filter_all")]
1733    pub active_filter: ToolFilter,
1734    #[serde(default, skip_serializing_if = "is_tool_filter_all")]
1735    pub staged_filter: ToolFilter,
1736    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
1737    pub active_requested_deferred_names: BTreeSet<ToolName>,
1738    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
1739    pub staged_requested_deferred_names: BTreeSet<ToolName>,
1740    #[serde(default, skip_serializing_if = "is_zero")]
1741    pub active_revision: u64,
1742    #[serde(default, skip_serializing_if = "is_zero")]
1743    pub staged_revision: u64,
1744    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1745    pub requested_witnesses: BTreeMap<ToolName, ToolVisibilityWitness>,
1746    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1747    pub filter_witnesses: BTreeMap<ToolName, ToolVisibilityWitness>,
1748}
1749
1750/// Generated-authority-approved durable tool visibility projection.
1751///
1752/// Session metadata stores this as a projection of the generated visibility
1753/// owner. Code that only has raw `SessionToolVisibilityState` must first route
1754/// it through a `ToolVisibilityOwner`/`ToolScope` restore path.
1755#[derive(Debug, Clone, PartialEq, Eq)]
1756pub struct AuthorizedSessionToolVisibilityState {
1757    state: SessionToolVisibilityState,
1758}
1759
1760impl AuthorizedSessionToolVisibilityState {
1761    pub(crate) fn from_generated_authority(state: SessionToolVisibilityState) -> Self {
1762        Self { state }
1763    }
1764
1765    pub fn as_state(&self) -> &SessionToolVisibilityState {
1766        &self.state
1767    }
1768
1769    pub fn into_state(self) -> SessionToolVisibilityState {
1770        self.state
1771    }
1772}
1773
1774/// Durable build-only session state required to faithfully recover and rebuild
1775/// a persisted session without surface-local shadow config.
1776#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1777#[serde(rename_all = "snake_case")]
1778pub struct SessionBuildState {
1779    #[serde(
1780        default,
1781        skip_serializing_if = "crate::config::SystemPromptOverride::is_inherit"
1782    )]
1783    pub system_prompt: crate::config::SystemPromptOverride,
1784    #[serde(default, skip_serializing_if = "Option::is_none")]
1785    pub output_schema: Option<crate::OutputSchema>,
1786    #[serde(default, skip_serializing_if = "is_default_hook_run_overrides")]
1787    pub hooks_override: crate::HookRunOverrides,
1788    #[serde(default, skip_serializing_if = "Option::is_none")]
1789    pub budget_limits: Option<crate::BudgetLimits>,
1790    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1791    pub recoverable_tool_defs: Vec<ToolDef>,
1792    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1793    pub silent_comms_intents: Vec<String>,
1794    #[serde(default, skip_serializing_if = "Option::is_none")]
1795    pub max_inline_peer_notifications: Option<i32>,
1796    #[serde(default, skip_serializing_if = "Option::is_none")]
1797    pub app_context: Option<serde_json::Value>,
1798    #[serde(default, skip_serializing_if = "Option::is_none")]
1799    pub additional_instructions: Option<Vec<String>>,
1800    #[serde(default, skip_serializing_if = "Option::is_none")]
1801    pub shell_env: Option<HashMap<String, String>>,
1802    /// Compatibility projection of mob operator authority.
1803    ///
1804    /// `MobToolAuthorityContext` deliberately loses its generated authority
1805    /// seal when serialized; restored behavior must be approved by the
1806    /// generated runtime bridge before this projection can affect tools.
1807    #[serde(default, skip_serializing_if = "Option::is_none")]
1808    pub mob_tool_authority_context: Option<MobToolAuthorityContext>,
1809    #[serde(default, skip_serializing_if = "is_default_call_timeout_override")]
1810    pub call_timeout_override: crate::CallTimeoutOverride,
1811    /// Exact assembled base-prompt bytes the last build applied (or verified)
1812    /// for this session. Runtime system-context appends extend the leading
1813    /// System message past this base; recording the base lets a later resume
1814    /// split the persisted content into `base + appended tail` byte-exactly
1815    /// (see [`Session::reconcile_resumed_system_prompt`]) instead of
1816    /// re-deriving append renders.
1817    #[serde(default, skip_serializing_if = "Option::is_none")]
1818    pub assembled_system_prompt: Option<String>,
1819}
1820
1821/// Deferred create-time prompt staged for the next turn.
1822#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1823#[serde(rename_all = "snake_case")]
1824pub struct PendingDeferredPrompt {
1825    pub prompt: ContentInput,
1826    pub accepted_at: SystemTime,
1827}
1828
1829/// Staged callback tool results waiting to be admitted on the next turn seam.
1830#[derive(Debug, Clone, Serialize, Deserialize)]
1831#[serde(rename_all = "snake_case")]
1832pub struct PendingToolResultsMessage {
1833    pub results: Vec<ToolResult>,
1834    pub accepted_at: SystemTime,
1835}
1836
1837impl PartialEq for PendingToolResultsMessage {
1838    fn eq(&self, other: &Self) -> bool {
1839        self.accepted_at == other.accepted_at
1840            && serde_json::to_value(&self.results).ok() == serde_json::to_value(&other.results).ok()
1841    }
1842}
1843
1844/// Deferred first-turn inputs consumed at the generated start-turn authority seam.
1845#[derive(Debug, Clone, Default, PartialEq)]
1846pub struct ConsumedDeferredTurnInputs {
1847    pub(crate) restore_first_turn_pending: bool,
1848    pub(crate) pending_initial_prompt: Option<PendingDeferredPrompt>,
1849    pub(crate) pending_tool_results: Vec<PendingToolResultsMessage>,
1850}
1851
1852impl ConsumedDeferredTurnInputs {
1853    pub fn is_empty(&self) -> bool {
1854        !self.restore_first_turn_pending
1855            && self.pending_initial_prompt.is_none()
1856            && self.pending_tool_results.is_empty()
1857    }
1858
1859    pub fn pending_initial_prompt(&self) -> Option<&PendingDeferredPrompt> {
1860        self.pending_initial_prompt.as_ref()
1861    }
1862
1863    pub fn pending_tool_results(&self) -> &[PendingToolResultsMessage] {
1864        &self.pending_tool_results
1865    }
1866}
1867
1868/// Seen idempotency-key entry for system-context append requests.
1869#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1870#[serde(rename_all = "snake_case")]
1871pub struct SeenSystemContextKey {
1872    /// Typed renderable content of the accepted append for this key.
1873    pub content: crate::lifecycle::run_primitive::CoreRenderable,
1874    #[serde(default, skip_serializing_if = "Option::is_none")]
1875    pub source: Option<String>,
1876    /// Typed provenance carried from the append, so runtime-steer cleanup can
1877    /// match seen entries by the typed marker rather than a `source` prefix.
1878    #[serde(default, skip_serializing_if = "SystemContextSource::is_normal")]
1879    pub source_kind: SystemContextSource,
1880    pub state: SeenSystemContextState,
1881}
1882
1883/// Lifecycle state for an accepted idempotency key.
1884#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1885#[serde(rename_all = "snake_case")]
1886pub enum SeenSystemContextState {
1887    Pending,
1888    Applied,
1889}
1890
1891impl SessionSystemContextState {
1892    pub fn pending(&self) -> &[PendingSystemContextAppend] {
1893        &self.pending
1894    }
1895
1896    pub fn applied(&self) -> &[PendingSystemContextAppend] {
1897        &self.applied
1898    }
1899
1900    pub fn seen(&self) -> &BTreeMap<String, SeenSystemContextKey> {
1901        &self.seen
1902    }
1903
1904    pub fn active_turn_pending_keys(&self) -> &BTreeSet<String> {
1905        &self.active_turn_pending_keys
1906    }
1907
1908    pub fn pending_len(&self) -> usize {
1909        self.pending.len()
1910    }
1911
1912    pub fn applied_len(&self) -> usize {
1913        self.applied.len()
1914    }
1915
1916    pub fn active_turn_pending_len(&self) -> usize {
1917        self.active_turn_pending_keys.len()
1918    }
1919
1920    pub fn realtime_projection_appends(&self) -> Vec<PendingSystemContextAppend> {
1921        self.applied
1922            .iter()
1923            .chain(self.pending.iter())
1924            .cloned()
1925            .collect()
1926    }
1927
1928    /// Stage an append request, enforcing per-session idempotency.
1929    pub fn stage_append(
1930        &mut self,
1931        req: &AppendSystemContextRequest,
1932        accepted_at: SystemTime,
1933    ) -> Result<crate::service::AppendSystemContextStatus, SystemContextStageError> {
1934        system_context_authority::stage_append(self, req, accepted_at, false)
1935    }
1936
1937    fn stage_append_with_generated_authority(
1938        &mut self,
1939        req: &AppendSystemContextRequest,
1940        accepted_at: SystemTime,
1941        active_turn_scoped: bool,
1942    ) -> Result<crate::service::AppendSystemContextStatus, SystemContextStageError> {
1943        system_context_authority::stage_append(self, req, accepted_at, active_turn_scoped)
1944    }
1945
1946    /// Stage an append that is scoped to the currently-active turn only.
1947    ///
1948    /// If the active turn reaches another model boundary, normal pending
1949    /// consumption moves it to `applied`. If the turn completes first, callers
1950    /// should discard the still-pending active-turn keys so the context cannot
1951    /// leak into an unrelated later run.
1952    pub fn stage_active_turn_append(
1953        &mut self,
1954        req: &AppendSystemContextRequest,
1955        accepted_at: SystemTime,
1956    ) -> Result<crate::service::AppendSystemContextStatus, SystemContextStageError> {
1957        self.stage_append_with_generated_authority(req, accepted_at, true)
1958    }
1959
1960    /// Mark all currently-pending appends as applied and clear the pending queue.
1961    pub fn mark_pending_applied(&mut self) {
1962        system_context_authority::mark_pending_applied(self);
1963    }
1964
1965    /// Discard active-turn-only appends that were not consumed by the turn's
1966    /// next LLM boundary.
1967    pub fn discard_unapplied_active_turn_pending(&mut self) -> Vec<PendingSystemContextAppend> {
1968        system_context_authority::discard_unapplied_active_turn_pending(self)
1969    }
1970
1971    /// Discard specific active-turn-only appends that are still pending.
1972    ///
1973    /// This is the rollback companion for live-boundary staging. The runtime
1974    /// owns the accepted input, so if that commit fails after the session has
1975    /// staged context, the session-side projection must be removed by the same
1976    /// idempotency keys before the caller reports failure.
1977    pub fn discard_active_turn_pending_by_keys(
1978        &mut self,
1979        idempotency_keys: &[String],
1980    ) -> Vec<PendingSystemContextAppend> {
1981        system_context_authority::discard_active_turn_pending_by_keys(self, idempotency_keys)
1982    }
1983
1984    /// Authorize this snapshot through the canonical
1985    /// [`session_document::SessionDocumentMachine`] system-context restore
1986    /// transition, returning the state unchanged on success.
1987    pub fn restore_from_snapshot(self) -> Result<Self, SystemContextStageError> {
1988        system_context_authority::restore_system_context_state(self)
1989    }
1990
1991    /// Record the machine-authorized applied system-context blocks, returning
1992    /// the appends that are newly applied (and thus need rendering into the
1993    /// system prompt by the caller).
1994    pub fn record_applied_blocks(
1995        &mut self,
1996        appends: &[PendingSystemContextAppend],
1997        current_system_prompt: &str,
1998    ) -> Vec<PendingSystemContextAppend> {
1999        system_context_authority::record_applied_system_context_blocks(
2000            self,
2001            appends,
2002            current_system_prompt,
2003        )
2004    }
2005}
2006
2007/// Per-session registry key for the first-turn region of the
2008/// [`session_document::SessionDocumentMachine`]. Each
2009/// [`SessionDeferredTurnState`] is a single session's projection, so its
2010/// machine instance carries exactly one registry entry under this key.
2011const SESSION_DOCUMENT_FIRST_TURN_KEY: &str = "first_turn";
2012
2013fn usize_to_u64(value: usize) -> u64 {
2014    u64::try_from(value).unwrap_or(u64::MAX)
2015}
2016
2017/// Authorize a durable deferred-turn snapshot through the canonical
2018/// [`session_document::SessionDocumentMachine`] recovery transition.
2019///
2020/// The machine validates that the persisted first-turn phase is a legal
2021/// recovery target and adopts it into its per-session registry, emitting
2022/// `SessionFirstTurnPhaseRecovered`. The snapshot is returned unchanged on
2023/// success; the machine — not this shell — owns the recovery legality.
2024fn validate_deferred_turn_snapshot(
2025    state: SessionDeferredTurnState,
2026) -> Result<SessionDeferredTurnState, session_document::SessionDocumentError> {
2027    let mut authority = session_document::SessionDocumentMachineAuthority::new();
2028    let key = session_document::SessionDocumentKey::new(SESSION_DOCUMENT_FIRST_TURN_KEY);
2029    // The recovery transition fails closed for any illegal first-turn phase
2030    // (its guard admits only the three known phases); a rejection surfaces as
2031    // `Err` here. On success the machine has adopted the snapshot.
2032    authority.recover_session_first_turn_phase(
2033        key,
2034        state.first_turn_phase.into(),
2035        state.pending_initial_prompt.is_some(),
2036        usize_to_u64(state.pending_tool_results.len()),
2037    )?;
2038    Ok(state)
2039}
2040
2041impl SessionDeferredTurnState {
2042    pub fn first_turn_phase(&self) -> DeferredFirstTurnPhase {
2043        self.first_turn_phase
2044    }
2045
2046    pub fn pending_initial_prompt(&self) -> Option<&PendingDeferredPrompt> {
2047        self.pending_initial_prompt.as_ref()
2048    }
2049
2050    pub fn pending_tool_results(&self) -> &[PendingToolResultsMessage] {
2051        &self.pending_tool_results
2052    }
2053
2054    pub fn pending_tool_results_len(&self) -> usize {
2055        self.pending_tool_results.len()
2056    }
2057
2058    pub(crate) fn pending_initial_prompt_mut_for_blob_rewrite(
2059        &mut self,
2060    ) -> Option<&mut PendingDeferredPrompt> {
2061        self.pending_initial_prompt.as_mut()
2062    }
2063
2064    pub(crate) fn pending_tool_results_mut_for_blob_rewrite(
2065        &mut self,
2066    ) -> &mut [PendingToolResultsMessage] {
2067        &mut self.pending_tool_results
2068    }
2069
2070    /// Build a [`SessionDocumentMachineAuthority`] seeded with this session's
2071    /// current durable first-turn projection.
2072    ///
2073    /// The machine owns the canonical first-turn phase + presence/count in its
2074    /// own per-session `Map`; the durable [`SessionDeferredTurnState`] is its
2075    /// projection. We recover the machine-owned registry from that projection
2076    /// before driving an operation so every subsequent decision reads the
2077    /// machine's own state — the shell never passes a phase conclusion as an
2078    /// operation input.
2079    fn document_authority(
2080        &self,
2081    ) -> (
2082        session_document::SessionDocumentMachineAuthority,
2083        session_document::SessionDocumentKey,
2084    ) {
2085        let mut authority = session_document::SessionDocumentMachineAuthority::new();
2086        let key = session_document::SessionDocumentKey::new(SESSION_DOCUMENT_FIRST_TURN_KEY);
2087        if let Err(err) = authority.recover_session_first_turn_phase(
2088            key.clone(),
2089            self.first_turn_phase.into(),
2090            self.pending_initial_prompt.is_some(),
2091            usize_to_u64(self.pending_tool_results.len()),
2092        ) {
2093            tracing::warn!(
2094                error = %err,
2095                "generated session document authority rejected first-turn recovery"
2096            );
2097        }
2098        (authority, key)
2099    }
2100
2101    /// Mirror the machine-resolved first-turn phase from one effect batch onto
2102    /// the durable projection, returning `was_pending` when present.
2103    fn mirror_first_turn_phase(
2104        &mut self,
2105        effects: &[session_document::SessionDocumentEffect],
2106    ) -> Option<bool> {
2107        for effect in effects {
2108            if let session_document::SessionDocumentEffect::SessionFirstTurnPhaseResolved {
2109                phase,
2110                was_pending,
2111            } = effect
2112            {
2113                self.first_turn_phase = (*phase).into();
2114                return Some(*was_pending);
2115            }
2116        }
2117        None
2118    }
2119
2120    /// Mark that this session has a deferred first turn waiting to start.
2121    pub fn mark_initial_turn_pending(&mut self) {
2122        let (mut authority, key) = self.document_authority();
2123        match authority.mark_session_initial_turn_pending(key) {
2124            Ok(effects) => {
2125                self.mirror_first_turn_phase(&effects);
2126            }
2127            Err(err) => tracing::warn!(
2128                error = %err,
2129                "generated session document authority rejected pending mark"
2130            ),
2131        }
2132    }
2133
2134    /// Mark the deferred first turn as started.
2135    ///
2136    /// Returns true when the phase transitioned from `Pending`.
2137    pub fn mark_initial_turn_started(&mut self) -> bool {
2138        let (mut authority, key) = self.document_authority();
2139        match authority.start_session_initial_turn(key) {
2140            Ok(effects) => self.mirror_first_turn_phase(&effects).unwrap_or(false),
2141            Err(err) => {
2142                tracing::warn!(
2143                    error = %err,
2144                    "generated session document authority rejected first-turn start"
2145                );
2146                false
2147            }
2148        }
2149    }
2150
2151    /// Restore the deferred first-turn pending phase after a failed pre-run setup.
2152    pub fn restore_initial_turn_pending(&mut self) {
2153        // The restore-to-pending decision is the machine's
2154        // `RestoreSessionConsumedInputs` transition with phase rollback
2155        // requested; presence/count mirrors are left untouched here because the
2156        // bulky payloads are restored separately by the caller.
2157        let (mut authority, key) = self.document_authority();
2158        match authority.restore_session_consumed_inputs(
2159            key.clone(),
2160            true,
2161            self.pending_initial_prompt.is_some(),
2162            usize_to_u64(self.pending_tool_results.len()),
2163        ) {
2164            Ok(_) => {
2165                // Mirror the machine-owned phase the restore transition wrote
2166                // into its per-session registry rather than re-deriving it.
2167                if let Some(phase) = authority.session_first_turn_phase_for(&key) {
2168                    self.first_turn_phase = phase.into();
2169                }
2170            }
2171            Err(err) => tracing::warn!(
2172                error = %err,
2173                "generated session document authority rejected pending restore"
2174            ),
2175        }
2176    }
2177
2178    /// Whether build-only first-turn overrides are still legal for this session.
2179    pub fn allows_initial_turn_overrides(&self) -> bool {
2180        let (mut authority, key) = self.document_authority();
2181        match authority.resolve_session_first_turn_overrides_allowed(key) {
2182            Ok(effects) => effects
2183                .iter()
2184                .find_map(|effect| {
2185                    match effect {
2186                session_document::SessionDocumentEffect::SessionFirstTurnOverridesResolved {
2187                    allowed,
2188                } => Some(*allowed),
2189                _ => None,
2190            }
2191                })
2192                .unwrap_or(false),
2193            Err(err) => {
2194                tracing::warn!(
2195                    error = %err,
2196                    "generated session document authority rejected override resolution"
2197                );
2198                false
2199            }
2200        }
2201    }
2202
2203    /// Stage the create-time prompt for a later first turn.
2204    pub fn stage_initial_prompt(&mut self, prompt: ContentInput, accepted_at: SystemTime) {
2205        let prompt_has_content = prompt.has_images() || !prompt.text_content().trim().is_empty();
2206        let (mut authority, key) = self.document_authority();
2207        match authority.stage_session_initial_prompt(key, prompt_has_content) {
2208            Ok(effects) => {
2209                let decision = effects.iter().find_map(|effect| {
2210                    match effect {
2211                    session_document::SessionDocumentEffect::SessionInitialPromptStageResolved {
2212                        decision,
2213                    } => Some(*decision),
2214                    _ => None,
2215                }
2216                });
2217                match decision {
2218                    Some(session_document::SessionInitialPromptStageDecision::Store) => {
2219                        self.pending_initial_prompt = Some(PendingDeferredPrompt {
2220                            prompt,
2221                            accepted_at,
2222                        });
2223                    }
2224                    Some(session_document::SessionInitialPromptStageDecision::Clear) => {
2225                        self.pending_initial_prompt = None;
2226                    }
2227                    None => tracing::warn!(
2228                        "generated session document authority returned no prompt-stage decision"
2229                    ),
2230                }
2231            }
2232            Err(err) => tracing::warn!(
2233                error = %err,
2234                "generated session document authority rejected initial prompt stage"
2235            ),
2236        }
2237    }
2238
2239    /// Stage one callback tool-results message for the next turn.
2240    pub fn stage_tool_results(
2241        &mut self,
2242        results: Vec<ToolResult>,
2243        accepted_at: SystemTime,
2244    ) -> usize {
2245        let (mut authority, key) = self.document_authority();
2246        let accepted = match authority.stage_session_tool_results(key, usize_to_u64(results.len()))
2247        {
2248            Ok(effects) => effects.iter().find_map(|effect| match effect {
2249                session_document::SessionDocumentEffect::SessionToolResultsStageResolved {
2250                    accepted_count,
2251                } => Some(*accepted_count),
2252                _ => None,
2253            }),
2254            Err(err) => {
2255                tracing::warn!(
2256                    error = %err,
2257                    "generated session document authority rejected tool-results stage"
2258                );
2259                return 0;
2260            }
2261        };
2262        let Some(accepted) = accepted else {
2263            tracing::warn!(
2264                "generated session document authority returned no tool-results decision"
2265            );
2266            return 0;
2267        };
2268        if accepted == 0 {
2269            return 0;
2270        }
2271        let accepted = usize::try_from(accepted).unwrap_or(usize::MAX);
2272        self.pending_tool_results.push(PendingToolResultsMessage {
2273            results,
2274            accepted_at,
2275        });
2276        accepted
2277    }
2278
2279    /// Whether any callback tool results are currently staged.
2280    pub fn has_pending_tool_results(&self) -> bool {
2281        !self.pending_tool_results.is_empty()
2282    }
2283
2284    /// Start a turn and consume all inputs generated-authorized for that seam.
2285    pub fn consume_for_started_turn(&mut self) -> ConsumedDeferredTurnInputs {
2286        let (mut authority, key) = self.document_authority();
2287        let was_pending = match authority.consume_session_deferred_inputs(key) {
2288            Ok(effects) => self.mirror_first_turn_phase(&effects).unwrap_or(false),
2289            Err(err) => {
2290                tracing::warn!(
2291                    error = %err,
2292                    "generated session document authority rejected started-turn consumption"
2293                );
2294                return ConsumedDeferredTurnInputs::default();
2295            }
2296        };
2297        ConsumedDeferredTurnInputs {
2298            restore_first_turn_pending: was_pending,
2299            pending_initial_prompt: self.pending_initial_prompt.take(),
2300            pending_tool_results: std::mem::take(&mut self.pending_tool_results),
2301        }
2302    }
2303
2304    /// Restore inputs previously consumed by `consume_for_started_turn`.
2305    pub fn restore_consumed_turn_inputs(&mut self, consumed: ConsumedDeferredTurnInputs) {
2306        if consumed.is_empty() {
2307            return;
2308        }
2309        let (mut authority, key) = self.document_authority();
2310        let effects = match authority.restore_session_consumed_inputs(
2311            key,
2312            consumed.restore_first_turn_pending,
2313            consumed.pending_initial_prompt.is_some(),
2314            usize_to_u64(consumed.pending_tool_results.len()),
2315        ) {
2316            Ok(effects) => effects,
2317            Err(err) => {
2318                tracing::warn!(
2319                    error = %err,
2320                    "generated session document authority rejected consumed input restore"
2321                );
2322                return;
2323            }
2324        };
2325        let Some((restore_first_turn_pending, restore_initial_prompt, restore_tool_results)) =
2326            effects.iter().find_map(|effect| match effect {
2327                session_document::SessionDocumentEffect::SessionConsumedInputsRestoreResolved {
2328                    restore_first_turn_pending,
2329                    restore_initial_prompt,
2330                    restore_tool_results,
2331                } => Some((
2332                    *restore_first_turn_pending,
2333                    *restore_initial_prompt,
2334                    *restore_tool_results,
2335                )),
2336                _ => None,
2337            })
2338        else {
2339            tracing::warn!(
2340                "generated session document authority returned no consumed-input restore decision"
2341            );
2342            return;
2343        };
2344        if restore_first_turn_pending {
2345            self.restore_initial_turn_pending();
2346        }
2347        if restore_initial_prompt && self.pending_initial_prompt.is_none() {
2348            self.pending_initial_prompt = consumed.pending_initial_prompt;
2349        }
2350        if restore_tool_results {
2351            let mut restored = consumed.pending_tool_results;
2352            restored.extend(std::mem::take(&mut self.pending_tool_results));
2353            self.pending_tool_results = restored;
2354        }
2355    }
2356}
2357
2358/// Failure when staging a system-context append request.
2359#[derive(Debug, Clone, PartialEq, Eq)]
2360pub enum SystemContextStageError {
2361    InvalidRequest(String),
2362    Conflict {
2363        key: String,
2364        existing_text: String,
2365        existing_source: Option<String>,
2366    },
2367}
2368
2369impl std::fmt::Display for SystemContextStageError {
2370    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2371        match self {
2372            Self::InvalidRequest(message) => {
2373                write!(f, "invalid system-context append request: {message}")
2374            }
2375            Self::Conflict { key, .. } => {
2376                write!(
2377                    f,
2378                    "system-context append conflict for idempotency key `{key}`"
2379                )
2380            }
2381        }
2382    }
2383}
2384
2385impl std::error::Error for SystemContextStageError {}
2386
2387/// Mechanical PRESENTATION helper: render a system-context append into the
2388/// display block string that is concatenated into the model-facing system
2389/// prompt. This is NOT a decision — it builds the `[Runtime System Context]`
2390/// label text for OUTPUT only. The authority for which appends to render and
2391/// whether one is a runtime steer lives in the
2392/// [`session_document::SessionDocumentMachine`]; this function never inspects
2393/// the `source` string to classify anything.
2394fn render_system_context_block(append: &PendingSystemContextAppend) -> String {
2395    let mut rendered = String::from(SYSTEM_CONTEXT_RENDER_LABEL);
2396    if let Some(source) = &append.source {
2397        rendered.push_str("\nsource: ");
2398        rendered.push_str(source);
2399    }
2400    rendered.push_str("\n\n");
2401    // The single CoreRenderable -> prompt-text lowering for system-context
2402    // appends. Surfaces carry the typed renderable through untouched.
2403    rendered.push_str(append.content.render_text().trim());
2404    rendered
2405}
2406
2407/// Display label prefix for a rendered runtime system-context block.
2408///
2409/// PRESENTATION only — this is the human/model-facing heading, not a
2410/// classification key. Nothing reads this back to make a semantic decision.
2411const SYSTEM_CONTEXT_RENDER_LABEL: &str = "[Runtime System Context]";
2412
2413/// Render a sequence of system-context appends into the
2414/// [`SYSTEM_CONTEXT_SEPARATOR`]-joined block text that
2415/// [`Session::append_system_context_blocks`] concatenates onto the system
2416/// prompt. The single composition rule — shared by the append path and the
2417/// resume-time tail verification so the two can never drift apart.
2418fn render_system_context_blocks_joined(appends: &[PendingSystemContextAppend]) -> String {
2419    appends
2420        .iter()
2421        .map(render_system_context_block)
2422        .collect::<Vec<_>>()
2423        .join(SYSTEM_CONTEXT_SEPARATOR)
2424}
2425
2426/// Compose a system prompt from a base and a verified runtime-context tail
2427/// (leading [`SYSTEM_CONTEXT_SEPARATOR`] included; empty = no tail),
2428/// mirroring [`Session::append_system_context_blocks`]' rule that an empty
2429/// base renders the blocks without a separator prefix.
2430fn compose_system_prompt_with_context_tail(base: &str, tail: &str) -> String {
2431    if tail.is_empty() {
2432        return base.to_string();
2433    }
2434    if base.is_empty() {
2435        return tail
2436            .strip_prefix(SYSTEM_CONTEXT_SEPARATOR)
2437            .unwrap_or(tail)
2438            .to_string();
2439    }
2440    format!("{base}{tail}")
2441}
2442
2443/// Drive the canonical [`session_document::SessionDocumentMachine`]
2444/// persist-append admission for the resume fast path: may the persisted
2445/// System prompt be admitted as a runtime-context-append continuation of the
2446/// freshly assembled base?
2447///
2448/// Mirrors the save-guard shell (`session_store::system_context_is_append`):
2449/// this extracts only pure structural observations plus the typed
2450/// [`crate::types::SystemPromptMutationKind`] provenance; the machine owns
2451/// the verdict. A machine error fails closed — the caller falls back to the
2452/// audited rewrite path.
2453fn persisted_prompt_is_admitted_context_append_continuation(
2454    assembled_base: &str,
2455    persisted_content: &str,
2456    persisted_mutation_kind: crate::types::SystemPromptMutationKind,
2457) -> bool {
2458    let content_identical = persisted_content == assembled_base;
2459    let content_extends = persisted_content.starts_with(assembled_base);
2460    let appended_starts_with_separator = content_extends
2461        && persisted_content[assembled_base.len()..].starts_with(SYSTEM_CONTEXT_SEPARATOR);
2462    let mut authority = session_document::SessionDocumentMachineAuthority::new();
2463    match authority.resolve_system_context_persist_append_admission(
2464        true,
2465        content_identical,
2466        content_extends,
2467        appended_starts_with_separator,
2468        persisted_mutation_kind.is_runtime_context_append(),
2469    ) {
2470        Ok(effects) => effects.into_iter().any(|effect| {
2471            matches!(
2472                effect,
2473                session_document::SessionDocumentEffect::SystemContextPersistAppendAdmissionResolved {
2474                    admission: session_document::SystemContextPersistAppendAdmission::Admit,
2475                }
2476            )
2477        }),
2478        Err(error) => {
2479            tracing::warn!(
2480                error = %error,
2481                "session document authority refused resume prompt continuation admission; \
2482                 falling back to audited rewrite"
2483            );
2484            false
2485        }
2486    }
2487}
2488
2489/// Shell adapter that drives the canonical
2490/// [`session_document::SessionDocumentMachine`] system-context region and
2491/// mirrors its emitted decisions onto the bulky `SessionSystemContextState`.
2492///
2493/// The machine owns every SEMANTIC decision (append disposition, per-append
2494/// apply/discard from the typed [`SystemContextSource`] marker, snapshot
2495/// restore legality). This module performs only the mechanical collection
2496/// work — iterating the shell's pending/applied/seen collections and applying
2497/// the machine's per-item verdict. It never decides; in particular it never
2498/// inspects a `source` string to classify a runtime steer.
2499mod system_context_authority {
2500    use super::{
2501        AppendSystemContextRequest, BTreeSet, PendingSystemContextAppend, SeenSystemContextKey,
2502        SeenSystemContextState, SessionSystemContextState, SystemContextSource,
2503        SystemContextStageError, SystemTime, render_system_context_block, session_document,
2504        usize_to_u64,
2505    };
2506    use crate::service::AppendSystemContextStatus;
2507
2508    fn document_authority() -> session_document::SessionDocumentMachineAuthority {
2509        session_document::SessionDocumentMachineAuthority::new()
2510    }
2511
2512    /// Resolve the four-way append disposition through the machine.
2513    fn resolve_append_decision(
2514        trimmed_text_byte_count: u64,
2515        idempotency_key_present: bool,
2516        existing_key_matches: bool,
2517        existing_key_conflicts: bool,
2518        active_turn_scoped: bool,
2519    ) -> Result<session_document::SystemContextAppendDecision, SystemContextStageError> {
2520        let mut authority = document_authority();
2521        let effects = authority
2522            .resolve_system_context_append(
2523                trimmed_text_byte_count,
2524                idempotency_key_present,
2525                existing_key_matches,
2526                existing_key_conflicts,
2527                active_turn_scoped,
2528            )
2529            .map_err(|err| SystemContextStageError::InvalidRequest(err.to_string()))?;
2530        effects
2531            .into_iter()
2532            .find_map(|effect| match effect {
2533                session_document::SessionDocumentEffect::SystemContextAppendResolved {
2534                    decision,
2535                    ..
2536                } => Some(decision),
2537                _ => None,
2538            })
2539            .ok_or_else(|| {
2540                SystemContextStageError::InvalidRequest(
2541                    "generated session document authority returned no append decision".to_string(),
2542                )
2543            })
2544    }
2545
2546    /// Per-pending-append apply verdict, decided by the machine from the typed
2547    /// `source_kind` marker (NOT a `source` string prefix).
2548    fn pending_apply_item(source_kind: SystemContextSource) -> Option<(bool, bool, bool)> {
2549        let mut authority = document_authority();
2550        match authority.resolve_system_context_pending_apply_item(source_kind.into()) {
2551            Ok(effects) => effects.into_iter().find_map(|effect| {
2552                match effect {
2553                session_document::SessionDocumentEffect::SystemContextPendingApplyItemResolved {
2554                    promote_to_applied,
2555                    mark_seen_applied,
2556                    remove_seen,
2557                } => Some((promote_to_applied, mark_seen_applied, remove_seen)),
2558                _ => None,
2559            }
2560            }),
2561            Err(err) => {
2562                tracing::warn!(
2563                    error = %err,
2564                    "generated session document authority rejected system-context apply item"
2565                );
2566                None
2567            }
2568        }
2569    }
2570
2571    /// Per-item transient-steer discard verdict, decided by the machine from
2572    /// the typed `source_kind` marker.
2573    fn steer_cleanup_discards(source_kind: SystemContextSource) -> bool {
2574        let mut authority = document_authority();
2575        match authority.resolve_system_context_steer_cleanup_item(source_kind.into()) {
2576            Ok(effects) => effects
2577                .into_iter()
2578                .find_map(|effect| {
2579                    match effect {
2580                    session_document::SessionDocumentEffect::SystemContextSteerCleanupItemResolved {
2581                        discard,
2582                    } => Some(discard),
2583                    _ => None,
2584                }
2585                })
2586                .unwrap_or(false),
2587            Err(err) => {
2588                tracing::warn!(
2589                    error = %err,
2590                    "generated session document authority rejected system-context steer cleanup item"
2591                );
2592                false
2593            }
2594        }
2595    }
2596
2597    pub(super) fn restore_system_context_state(
2598        state: SessionSystemContextState,
2599    ) -> Result<SessionSystemContextState, SystemContextStageError> {
2600        let active_keys_have_known_pending_or_seen =
2601            state.active_turn_pending_keys.iter().all(|key| {
2602                state.seen.contains_key(key)
2603                    || state
2604                        .pending
2605                        .iter()
2606                        .any(|append| append.idempotency_key.as_ref() == Some(key))
2607            });
2608        let seen_keys_match_known_appends = state.seen.iter().all(|(key, seen)| {
2609            state
2610                .pending
2611                .iter()
2612                .chain(state.applied.iter())
2613                .any(|append| {
2614                    append.idempotency_key.as_ref() == Some(key)
2615                        && seen.content == append.content
2616                        && seen.source.as_deref() == append.source.as_deref()
2617                })
2618        });
2619        let mut authority = document_authority();
2620        authority
2621            .restore_system_context_snapshot(
2622                active_keys_have_known_pending_or_seen,
2623                seen_keys_match_known_appends,
2624            )
2625            .map_err(|err| SystemContextStageError::InvalidRequest(err.to_string()))?;
2626        Ok(state)
2627    }
2628
2629    pub(super) fn stage_append(
2630        state: &mut SessionSystemContextState,
2631        req: &AppendSystemContextRequest,
2632        accepted_at: SystemTime,
2633        active_turn_scoped: bool,
2634    ) -> Result<AppendSystemContextStatus, SystemContextStageError> {
2635        // Emptiness is judged on the canonical text projection; the typed
2636        // renderable itself is what gets stored (lowering happens once, at
2637        // the transcript render seam).
2638        let rendered_text = req.content.render_text();
2639        let rendered_len = rendered_text.trim().len();
2640        let existing = req
2641            .idempotency_key
2642            .as_ref()
2643            .and_then(|key| state.seen.get(key));
2644        let existing_key_matches = existing.is_some_and(|existing| {
2645            existing.content == req.content && existing.source.as_deref() == req.source.as_deref()
2646        });
2647        let existing_key_conflicts = existing.is_some() && !existing_key_matches;
2648        let decision = resolve_append_decision(
2649            usize_to_u64(rendered_len),
2650            req.idempotency_key.is_some(),
2651            existing_key_matches,
2652            existing_key_conflicts,
2653            active_turn_scoped,
2654        )?;
2655
2656        match decision {
2657            session_document::SystemContextAppendDecision::RejectEmpty => {
2658                return Err(SystemContextStageError::InvalidRequest(
2659                    "system context text must not be empty".to_string(),
2660                ));
2661            }
2662            session_document::SystemContextAppendDecision::RejectConflict => {
2663                let Some(key) = req.idempotency_key.as_ref() else {
2664                    return Err(SystemContextStageError::InvalidRequest(
2665                        "generated system-context authority rejected append without a key"
2666                            .to_string(),
2667                    ));
2668                };
2669                let Some(existing) = existing else {
2670                    return Err(SystemContextStageError::InvalidRequest(
2671                        "generated system-context authority rejected append without a conflict"
2672                            .to_string(),
2673                    ));
2674                };
2675                return Err(SystemContextStageError::Conflict {
2676                    key: key.clone(),
2677                    existing_text: existing.content.render_text(),
2678                    existing_source: existing.source.clone(),
2679                });
2680            }
2681            session_document::SystemContextAppendDecision::Duplicate => {
2682                return Ok(AppendSystemContextStatus::Duplicate);
2683            }
2684            session_document::SystemContextAppendDecision::Staged => {}
2685        }
2686
2687        let append = PendingSystemContextAppend {
2688            content: req.content.clone(),
2689            source: req.source.clone(),
2690            idempotency_key: req.idempotency_key.clone(),
2691            source_kind: req.source_kind,
2692            // Carry the typed `PeerResponseTerminalFact` so realtime/live
2693            // consumers read it directly instead of re-parsing the flattened
2694            // prompt text. Mirrors the `source_kind` typed-provenance precedent.
2695            peer_response_terminal: req.peer_response_terminal.clone(),
2696            accepted_at,
2697        };
2698        if let Some(key) = req.idempotency_key.as_ref() {
2699            state.seen.insert(
2700                key.clone(),
2701                SeenSystemContextKey {
2702                    content: append.content.clone(),
2703                    source: append.source.clone(),
2704                    source_kind: append.source_kind,
2705                    state: SeenSystemContextState::Pending,
2706                },
2707            );
2708        }
2709        if active_turn_scoped && let Some(key) = req.idempotency_key.as_ref() {
2710            state.active_turn_pending_keys.insert(key.clone());
2711        }
2712        state.pending.push(append);
2713        Ok(AppendSystemContextStatus::Staged)
2714    }
2715
2716    pub(super) fn mark_pending_applied(state: &mut SessionSystemContextState) {
2717        // Promote pending appends to applied per the machine's per-item
2718        // verdict (keyed on the typed `source_kind`).
2719        let pending = std::mem::take(&mut state.pending);
2720        let mut seen_to_remove = Vec::new();
2721        for append in &pending {
2722            let Some((promote_to_applied, mark_seen_applied, remove_seen)) =
2723                pending_apply_item(append.source_kind)
2724            else {
2725                continue;
2726            };
2727            if promote_to_applied && !state.applied.contains(append) {
2728                state.applied.push(append.clone());
2729            }
2730            if let Some(key) = append.idempotency_key.as_ref() {
2731                if remove_seen {
2732                    seen_to_remove.push(key.clone());
2733                } else if mark_seen_applied && let Some(seen) = state.seen.get_mut(key) {
2734                    seen.state = SeenSystemContextState::Applied;
2735                }
2736            }
2737        }
2738        for key in seen_to_remove {
2739            state.seen.remove(&key);
2740        }
2741        state.active_turn_pending_keys.clear();
2742    }
2743
2744    pub(super) fn discard_unapplied_active_turn_pending(
2745        state: &mut SessionSystemContextState,
2746    ) -> Vec<PendingSystemContextAppend> {
2747        if state.active_turn_pending_keys.is_empty() {
2748            return Vec::new();
2749        }
2750        let active_keys = std::mem::take(&mut state.active_turn_pending_keys);
2751        let mut discarded = Vec::new();
2752        state.pending.retain(|append| {
2753            let should_discard = append
2754                .idempotency_key
2755                .as_ref()
2756                .is_some_and(|key| active_keys.contains(key));
2757            if should_discard {
2758                discarded.push(append.clone());
2759            }
2760            !should_discard
2761        });
2762
2763        for append in &discarded {
2764            if let Some(key) = append.idempotency_key.as_ref()
2765                && state
2766                    .seen
2767                    .get(key)
2768                    .is_some_and(|seen| seen.state == SeenSystemContextState::Pending)
2769            {
2770                state.seen.remove(key);
2771            }
2772        }
2773
2774        discarded
2775    }
2776
2777    pub(super) fn discard_active_turn_pending_by_keys(
2778        state: &mut SessionSystemContextState,
2779        idempotency_keys: &[String],
2780    ) -> Vec<PendingSystemContextAppend> {
2781        if idempotency_keys.is_empty() || state.active_turn_pending_keys.is_empty() {
2782            return Vec::new();
2783        }
2784        let requested_keys: BTreeSet<&str> = idempotency_keys.iter().map(String::as_str).collect();
2785        let mut discarded = Vec::new();
2786        let mut discarded_keys = Vec::new();
2787        state.pending.retain(|append| {
2788            let should_discard = append.idempotency_key.as_ref().is_some_and(|key| {
2789                requested_keys.contains(key.as_str())
2790                    && state.active_turn_pending_keys.contains(key)
2791            });
2792            if should_discard {
2793                if let Some(key) = append.idempotency_key.as_ref() {
2794                    discarded_keys.push(key.clone());
2795                }
2796                discarded.push(append.clone());
2797            }
2798            !should_discard
2799        });
2800
2801        for key in discarded_keys {
2802            state.active_turn_pending_keys.remove(&key);
2803            if state
2804                .seen
2805                .get(&key)
2806                .is_some_and(|seen| seen.state == SeenSystemContextState::Pending)
2807            {
2808                state.seen.remove(&key);
2809            }
2810        }
2811
2812        discarded
2813    }
2814
2815    pub(super) fn discard_transient_runtime_steer_state(
2816        state: &mut SessionSystemContextState,
2817    ) -> usize {
2818        let mut removed = 0usize;
2819
2820        let before_pending = state.pending.len();
2821        state
2822            .pending
2823            .retain(|append| !steer_cleanup_discards(append.source_kind));
2824        removed += before_pending.saturating_sub(state.pending.len());
2825
2826        let before_applied = state.applied.len();
2827        state
2828            .applied
2829            .retain(|append| !steer_cleanup_discards(append.source_kind));
2830        removed += before_applied.saturating_sub(state.applied.len());
2831
2832        let before_seen = state.seen.len();
2833        state
2834            .seen
2835            .retain(|_key, seen| !steer_cleanup_discards(seen.source_kind));
2836        removed += before_seen.saturating_sub(state.seen.len());
2837
2838        // Active-turn keys are tracked only by idempotency key, so an active
2839        // key is a runtime steer iff its seen entry (or pending append) was.
2840        // Recompute the surviving steer keys from the typed seen markers.
2841        let before_active = state.active_turn_pending_keys.len();
2842        let steer_keys: BTreeSet<String> = state
2843            .seen
2844            .iter()
2845            .filter(|(_key, seen)| steer_cleanup_discards(seen.source_kind))
2846            .map(|(key, _seen)| key.clone())
2847            .collect();
2848        // Any active key whose seen entry was already removed above (because it
2849        // was a steer) is no longer present in `seen`; drop those, plus any
2850        // still-present steer keys.
2851        state
2852            .active_turn_pending_keys
2853            .retain(|key| state.seen.contains_key(key) && !steer_keys.contains(key));
2854        removed += before_active.saturating_sub(state.active_turn_pending_keys.len());
2855
2856        removed
2857    }
2858
2859    pub(super) fn remove_runtime_steer_blocks_for_rendered(
2860        system_prompt: &str,
2861        runtime_steer_appends: &[PendingSystemContextAppend],
2862    ) -> (String, usize) {
2863        if runtime_steer_appends.is_empty() {
2864            return (system_prompt.to_string(), 0);
2865        }
2866        // Build the set of rendered blocks for the typed runtime-steer appends,
2867        // then remove those exact rendered blocks from the prompt. The typed
2868        // marker is the authority; rendering is mechanical presentation.
2869        let steer_blocks: BTreeSet<String> = runtime_steer_appends
2870            .iter()
2871            .map(render_system_context_block)
2872            .collect();
2873        let parts = system_prompt
2874            .split(super::SYSTEM_CONTEXT_SEPARATOR)
2875            .map(str::to_string)
2876            .collect::<Vec<_>>();
2877        let original_len = parts.len();
2878        let retained = parts
2879            .into_iter()
2880            .filter(|part| !steer_blocks.contains(part))
2881            .collect::<Vec<_>>();
2882        let removed = original_len.saturating_sub(retained.len());
2883        (retained.join(super::SYSTEM_CONTEXT_SEPARATOR), removed)
2884    }
2885
2886    pub(super) fn record_applied_system_context_blocks(
2887        state: &mut SessionSystemContextState,
2888        appends: &[PendingSystemContextAppend],
2889        current_system_prompt: &str,
2890    ) -> Vec<PendingSystemContextAppend> {
2891        let mut new_appends: Vec<PendingSystemContextAppend> = Vec::new();
2892        for append in appends {
2893            if append.content.render_text().trim().is_empty() {
2894                continue;
2895            }
2896            let rendered = render_system_context_block(append);
2897            if let Some(key) = append.idempotency_key.as_ref() {
2898                if let Some(existing) = state.seen.get(key)
2899                    && !seen_system_context_matches(existing, append)
2900                {
2901                    tracing::warn!(
2902                        idempotency_key = %key,
2903                        "skipping conflicting runtime system-context append"
2904                    );
2905                    continue;
2906                }
2907                if let Some(existing) = state
2908                    .applied
2909                    .iter()
2910                    .find(|applied| applied.idempotency_key.as_ref() == Some(key))
2911                    && !pending_system_context_matches(existing, append)
2912                {
2913                    tracing::warn!(
2914                        idempotency_key = %key,
2915                        "skipping conflicting runtime system-context append"
2916                    );
2917                    continue;
2918                }
2919                if let Some(existing) = new_appends
2920                    .iter()
2921                    .find(|pending| pending.idempotency_key.as_ref() == Some(key))
2922                {
2923                    if !pending_system_context_matches(existing, append) {
2924                        tracing::warn!(
2925                            idempotency_key = %key,
2926                            "skipping conflicting runtime system-context append"
2927                        );
2928                    }
2929                    continue;
2930                }
2931                if current_system_prompt.contains(&rendered) {
2932                    record_applied_append(state, append);
2933                    continue;
2934                }
2935            } else if new_appends.contains(append) || current_system_prompt.contains(&rendered) {
2936                continue;
2937            }
2938            record_applied_append(state, append);
2939            new_appends.push(append.clone());
2940        }
2941        new_appends
2942    }
2943
2944    fn record_applied_append(
2945        state: &mut SessionSystemContextState,
2946        append: &PendingSystemContextAppend,
2947    ) {
2948        if let Some(key) = append.idempotency_key.as_ref() {
2949            state.seen.insert(
2950                key.clone(),
2951                SeenSystemContextKey {
2952                    content: append.content.clone(),
2953                    source: append.source.clone(),
2954                    source_kind: append.source_kind,
2955                    state: SeenSystemContextState::Applied,
2956                },
2957            );
2958            if state
2959                .applied
2960                .iter()
2961                .any(|applied| applied.idempotency_key.as_ref() == Some(key))
2962            {
2963                return;
2964            }
2965        } else if state.applied.contains(append) {
2966            return;
2967        }
2968        state.applied.push(append.clone());
2969    }
2970
2971    fn seen_system_context_matches(
2972        seen: &SeenSystemContextKey,
2973        append: &PendingSystemContextAppend,
2974    ) -> bool {
2975        seen.content == append.content && seen.source.as_deref() == append.source.as_deref()
2976    }
2977
2978    fn pending_system_context_matches(
2979        existing: &PendingSystemContextAppend,
2980        append: &PendingSystemContextAppend,
2981    ) -> bool {
2982        existing.content == append.content && existing.source.as_deref() == append.source.as_deref()
2983    }
2984}
2985
2986impl Session {
2987    /// Create a new empty session
2988    pub fn new() -> Self {
2989        let now = SystemTime::now();
2990        Self {
2991            version: session_version(),
2992            id: SessionId::new(),
2993            messages: Arc::new(Vec::new()),
2994            created_at: now,
2995            updated_at: now,
2996            metadata: serde_json::Map::new(),
2997            usage: Usage::default(),
2998        }
2999    }
3000
3001    /// Create a session with a specific ID (for loading)
3002    pub fn with_id(id: SessionId) -> Self {
3003        let mut session = Self::new();
3004        session.id = id;
3005        session
3006    }
3007
3008    /// Get the session ID
3009    pub fn id(&self) -> &SessionId {
3010        &self.id
3011    }
3012
3013    /// Get the session version
3014    pub fn version(&self) -> u32 {
3015        self.version
3016    }
3017
3018    /// Get all messages.
3019    pub fn messages(&self) -> &[Message] {
3020        &self.messages
3021    }
3022
3023    /// Replace the message buffer for core-owned internal transcript rewrites.
3024    ///
3025    /// Intentionally `pub(crate)`: cross-crate consumers must route same-session
3026    /// rewrites through transcript-edit APIs so the revision graph remains the
3027    /// semantic owner of message history.
3028    pub(crate) fn replace_messages_internal(
3029        &mut self,
3030        messages: Vec<Message>,
3031        reason: TranscriptRewriteReason,
3032    ) -> Result<Option<TranscriptRewriteCommit>, TranscriptEditError> {
3033        if transcript_messages_digest(self.messages()).ok()
3034            == transcript_messages_digest(&messages).ok()
3035        {
3036            return Ok(None);
3037        }
3038        let commit = self.commit_transcript_rewrite(
3039            TranscriptRewriteSelection::MessageRange {
3040                start: 0,
3041                end: self.messages.len(),
3042            },
3043            messages,
3044            reason,
3045            Some("meerkat-core".to_string()),
3046            None,
3047        )?;
3048        Ok(Some(commit))
3049    }
3050
3051    /// Retain messages for core-owned synthetic-notice projection cleanup.
3052    pub(crate) fn retain_messages_internal<F>(
3053        &mut self,
3054        mut retain: F,
3055        reason: TranscriptRewriteReason,
3056    ) -> Result<Option<TranscriptRewriteCommit>, TranscriptEditError>
3057    where
3058        F: FnMut(&Message) -> bool,
3059    {
3060        let retained = self
3061            .messages
3062            .iter()
3063            .filter(|message| retain(message))
3064            .cloned()
3065            .collect::<Vec<_>>();
3066        if retained.len() == self.messages.len()
3067            && transcript_messages_digest(self.messages()).ok()
3068                == transcript_messages_digest(&retained).ok()
3069        {
3070            return Ok(None);
3071        }
3072        self.replace_messages_internal(retained, reason)
3073    }
3074
3075    /// Atomically refresh the synthetic runtime notices of one kind.
3076    ///
3077    /// This is the ONE transcript authority operation for synthetic-notice
3078    /// refresh: it strips every existing `SystemNotice` message of `kind` and
3079    /// appends `replacements` (possibly empty, meaning "no current notice")
3080    /// as a single edit. On a strip fault nothing is pushed and the typed
3081    /// [`TranscriptEditError`] propagates — callers must not re-implement
3082    /// the strip-then-push pair (the swallowed-strip variant leaves a stale
3083    /// notice beside a fresh one: a divergence window).
3084    pub fn replace_synthetic_notices(
3085        &mut self,
3086        kind: crate::types::SystemNoticeKind,
3087        replacements: Vec<Message>,
3088    ) -> Result<(), TranscriptEditError> {
3089        for (index, message) in replacements.iter().enumerate() {
3090            let matches_kind =
3091                matches!(message, Message::SystemNotice(notice) if notice.kind == kind);
3092            if !matches_kind {
3093                return Err(TranscriptEditError::InvalidTranscriptShape(format!(
3094                    "replacement {index} for synthetic notice kind {kind:?} is not a                      system notice of that kind"
3095                )));
3096            }
3097        }
3098        self.retain_messages_internal(
3099            |message| !matches!(message, Message::SystemNotice(notice) if notice.kind == kind),
3100            TranscriptRewriteReason::new("synthetic_notice_cleanup"),
3101        )?;
3102        for message in replacements {
3103            self.push(message);
3104        }
3105        Ok(())
3106    }
3107
3108    /// Get creation time
3109    pub fn created_at(&self) -> SystemTime {
3110        self.created_at
3111    }
3112
3113    /// Get last update time
3114    pub fn updated_at(&self) -> SystemTime {
3115        self.updated_at
3116    }
3117
3118    /// Add a message to the session
3119    ///
3120    /// Updates the timestamp. For adding multiple messages, prefer `push_batch`.
3121    pub fn push(&mut self, message: Message) {
3122        Arc::make_mut(&mut self.messages).push(message);
3123        self.updated_at = SystemTime::now();
3124        self.refresh_transcript_head_after_message_mutation();
3125    }
3126
3127    /// Add multiple messages in one operation (single timestamp update)
3128    ///
3129    /// More efficient than multiple `push` calls when adding many messages.
3130    pub fn push_batch(&mut self, messages: Vec<Message>) {
3131        if messages.is_empty() {
3132            return;
3133        }
3134        let inner = Arc::make_mut(&mut self.messages);
3135        inner.extend(messages);
3136        self.updated_at = SystemTime::now();
3137        self.refresh_transcript_head_after_message_mutation();
3138    }
3139
3140    /// Rewrite inline media payloads in-place as `BlobRef` pointers.
3141    ///
3142    /// Message count is invariant across this operation — `externalize`
3143    /// only swaps inline image/media bytes for opaque blob references.
3144    /// This is the cross-crate-legitimate rewrite operation that used
3145    /// to require public `messages_mut()`; post-C-H1 callers in
3146    /// `meerkat-session` go through this typed method.
3147    ///
3148    /// Does not touch `updated_at` — externalization is bookkeeping, not
3149    /// a semantic session mutation.
3150    pub async fn externalize_media(
3151        &mut self,
3152        blob_store: &dyn crate::BlobStore,
3153        start: usize,
3154    ) -> Result<(), crate::blob::BlobStoreError> {
3155        let previous_digest = if self
3156            .metadata
3157            .contains_key(SESSION_TRANSCRIPT_HISTORY_STATE_KEY)
3158        {
3159            transcript_messages_digest(self.messages()).ok()
3160        } else {
3161            None
3162        };
3163        let messages = Arc::make_mut(&mut self.messages);
3164        crate::image_content::externalize_messages_from(blob_store, messages, start).await?;
3165        if let Some(previous_digest) = previous_digest
3166            && transcript_messages_digest(self.messages()).ok().as_ref() != Some(&previous_digest)
3167        {
3168            self.refresh_transcript_head_after_message_mutation();
3169        }
3170        Ok(())
3171    }
3172
3173    /// Hydrate user-message images in-place for a realtime provider replay,
3174    /// under an explicit cumulative decoded-byte budget.
3175    ///
3176    /// Realtime reconnect/open is an execution seam, not a historical display
3177    /// read: missing or malformed blobs fail closed, repeated references count
3178    /// independently, and image-bearing tool/system content that the realtime
3179    /// history projector does not consume remains blob-backed.
3180    pub async fn hydrate_realtime_user_images(
3181        &mut self,
3182        blob_store: &dyn crate::BlobStore,
3183        max_decoded_bytes: usize,
3184    ) -> Result<(), crate::image_content::RealtimeUserImageHydrationError> {
3185        self.hydrate_realtime_user_images_with_usage(blob_store, max_decoded_bytes)
3186            .await
3187            .map(|_| ())
3188    }
3189
3190    /// Hydrate realtime user-message images and return the full canonical
3191    /// decoded-byte usage for seed-independent future-image admission.
3192    pub async fn hydrate_realtime_user_images_with_usage(
3193        &mut self,
3194        blob_store: &dyn crate::BlobStore,
3195        max_decoded_bytes: usize,
3196    ) -> Result<usize, crate::image_content::RealtimeUserImageHydrationError> {
3197        let previous_digest = if self
3198            .metadata
3199            .contains_key(SESSION_TRANSCRIPT_HISTORY_STATE_KEY)
3200        {
3201            transcript_messages_digest(self.messages()).ok()
3202        } else {
3203            None
3204        };
3205        let messages = Arc::make_mut(&mut self.messages);
3206        let decoded_total =
3207            crate::image_content::hydrate_user_images_for_realtime_projection_with_usage(
3208                blob_store,
3209                messages,
3210                max_decoded_bytes,
3211            )
3212            .await?;
3213        if let Some(previous_digest) = previous_digest
3214            && transcript_messages_digest(self.messages()).ok().as_ref() != Some(&previous_digest)
3215        {
3216            self.refresh_transcript_head_after_message_mutation();
3217        }
3218        Ok(decoded_total)
3219    }
3220
3221    /// Explicitly update the timestamp
3222    ///
3223    /// Call this after bulk operations that don't update timestamps automatically.
3224    pub fn touch(&mut self) {
3225        self.updated_at = SystemTime::now();
3226    }
3227
3228    /// Get the last N messages
3229    pub fn last_n(&self, n: usize) -> &[Message] {
3230        let start = self.messages.len().saturating_sub(n);
3231        &self.messages[start..]
3232    }
3233
3234    /// Count total tokens used.
3235    pub fn total_tokens(&self) -> u64 {
3236        self.usage.total_tokens()
3237    }
3238
3239    /// Get total usage statistics for the session.
3240    pub fn total_usage(&self) -> Usage {
3241        self.usage.clone()
3242    }
3243
3244    /// Update cumulative usage after an LLM call.
3245    pub fn record_usage(&mut self, turn_usage: Usage) {
3246        self.usage.add(&turn_usage);
3247        self.updated_at = SystemTime::now();
3248    }
3249
3250    /// Append externally-produced user content to the canonical transcript.
3251    pub fn append_external_user_content(&mut self, content: ContentInput) {
3252        self.push(Message::User(UserMessage::with_blocks(
3253            content.into_blocks(),
3254        )));
3255    }
3256
3257    /// Append externally-produced assistant output to the canonical transcript.
3258    pub fn append_external_assistant_blocks(
3259        &mut self,
3260        blocks: Vec<AssistantBlock>,
3261        stop_reason: StopReason,
3262        usage: Usage,
3263    ) {
3264        if !blocks.is_empty() {
3265            self.push(Message::BlockAssistant(BlockAssistantMessage::new(
3266                blocks,
3267                stop_reason,
3268            )));
3269        }
3270        if usage != Usage::default() {
3271            self.record_usage(usage);
3272        }
3273    }
3274
3275    /// Apply an identity-bearing provider realtime transcript event.
3276    ///
3277    /// This is the canonical append authority for provider-managed realtime
3278    /// turns: provider item ids, predecessor links, and content segment ids are
3279    /// persisted in session metadata so duplicate websocket delivery,
3280    /// reconnect replay, and causally equivalent event ordering cannot create
3281    /// duplicate or misordered canonical messages.
3282    pub fn append_realtime_transcript_event(
3283        &mut self,
3284        event: RealtimeTranscriptEvent,
3285    ) -> RealtimeTranscriptApplyOutcome {
3286        let mut state = self.realtime_transcript_state();
3287        let commit =
3288            realtime_transcript_revision::apply_realtime_transcript_event(&mut state, event)
3289                .unwrap_or_else(|err| {
3290                    fail_closed_generated_restore(
3291                        "realtime-transcript",
3292                        <serde_json::Error as serde::de::Error>::custom(err),
3293                    )
3294                });
3295        self.store_realtime_transcript_state(&state);
3296        self.push_batch(commit.messages);
3297        if commit.usage != Usage::default() {
3298            self.record_usage(commit.usage);
3299        }
3300        commit.outcome
3301    }
3302
3303    /// Preview replay/rejection for non-text realtime user content without
3304    /// mutating session state. Used by persistence before blob writes.
3305    #[must_use]
3306    pub fn preflight_realtime_user_content_event(
3307        &self,
3308        event: &RealtimeTranscriptEvent,
3309    ) -> Option<crate::RealtimeUserContentApplyOutcome> {
3310        let state = self.realtime_transcript_state();
3311        realtime_transcript_revision::preflight_realtime_user_content_event(&state, event)
3312            .unwrap_or_else(|err| {
3313                fail_closed_generated_restore(
3314                    "realtime-user-content-preflight",
3315                    <serde_json::Error as serde::de::Error>::custom(err),
3316                )
3317            })
3318    }
3319
3320    /// Return every distinct provider `response_id` currently staged in the
3321    /// realtime-transcript metadata that has at least one **unmaterialized**
3322    /// assistant item and is **not already discarded**.
3323    ///
3324    /// CC4 (Round-4 architectural reconciliation): when the live boundary
3325    /// signals a barge-in (`TurnInterrupted`), the projection sink does not
3326    /// know which provider response_ids have streaming deltas staged in
3327    /// session metadata. This accessor lets the sink fan
3328    /// [`RealtimeTranscriptEvent::AssistantTurnInterrupted`] events out to
3329    /// each in-flight response so staged-but-not-yet-materialized transcript
3330    /// fragments are discarded — preventing them from silently committing
3331    /// when the *next* turn's `AssistantTurnCompleted` (synthesized by the
3332    /// CC2 fix in `signal_turn_completed`) sweeps the materializer.
3333    ///
3334    /// Order is the [`SessionRealtimeTranscriptState::first_seen_order`]
3335    /// projection so callers see deterministic iteration. Items already
3336    /// materialized or skipped are excluded — only response_ids with at
3337    /// least one live unmaterialized assistant item are returned.
3338    #[must_use]
3339    pub fn in_flight_realtime_assistant_response_ids(&self) -> Vec<String> {
3340        let state = self.realtime_transcript_state();
3341        realtime_transcript_revision::in_flight_realtime_assistant_response_ids(&state)
3342    }
3343
3344    /// Durable session-scoped bindings used to make live non-text input retry
3345    /// safe across provider reconnects and lost public receipts.
3346    #[must_use]
3347    pub fn realtime_user_content_identities(&self) -> Vec<RealtimeUserContentIdentity> {
3348        let state = self.realtime_transcript_state();
3349        realtime_transcript_revision::realtime_user_content_identities(&state)
3350    }
3351
3352    /// Return the bounded metadata-only image-blob recovery anchor, if one is
3353    /// durably staged ahead of reducer finalization.
3354    #[must_use]
3355    pub fn pending_realtime_user_content_blob(
3356        &self,
3357    ) -> Option<crate::PendingRealtimeUserContentBlob> {
3358        let state = self.realtime_transcript_state();
3359        realtime_transcript_revision::pending_realtime_user_content_blob(&state)
3360    }
3361
3362    /// Stage or exactly reuse the one-slot durable image-blob recovery anchor
3363    /// through generated SessionDocument authority.
3364    pub fn stage_pending_realtime_user_content_blob(
3365        &mut self,
3366        pending: crate::PendingRealtimeUserContentBlob,
3367    ) -> Result<
3368        crate::generated::session_document::RealtimeUserContentBlobStageDisposition,
3369        realtime_transcript_revision::RealtimeTranscriptShellError,
3370    > {
3371        let mut state = self.realtime_transcript_state();
3372        let disposition = realtime_transcript_revision::stage_pending_realtime_user_content_blob(
3373            &mut state, pending,
3374        )?;
3375        self.store_realtime_transcript_state(&state);
3376        Ok(disposition)
3377    }
3378
3379    pub fn resolve_pending_realtime_user_content_blob_recovery(
3380        &self,
3381        request: Option<&crate::PendingRealtimeUserContentBlob>,
3382        pending_blob_valid: bool,
3383    ) -> Result<
3384        crate::generated::session_document::RealtimeUserContentBlobRecoveryDisposition,
3385        realtime_transcript_revision::RealtimeTranscriptShellError,
3386    > {
3387        let state = self.realtime_transcript_state();
3388        realtime_transcript_revision::resolve_pending_realtime_user_content_blob_recovery(
3389            &state,
3390            request,
3391            pending_blob_valid,
3392        )
3393    }
3394
3395    /// Clear a missing/corrupt occupied anchor only after generated recovery
3396    /// authority classifies a different request as `ClearInvalidBeforeCurrent`.
3397    pub fn clear_invalid_pending_realtime_user_content_blob(
3398        &mut self,
3399        request: Option<&crate::PendingRealtimeUserContentBlob>,
3400    ) -> Result<(), realtime_transcript_revision::RealtimeTranscriptShellError> {
3401        let mut state = self.realtime_transcript_state();
3402        realtime_transcript_revision::clear_invalid_pending_realtime_user_content_blob(
3403            &mut state, request,
3404        )?;
3405        self.store_realtime_transcript_state(&state);
3406        Ok(())
3407    }
3408
3409    /// Durable caller keys whose canonical realtime image was removed by a
3410    /// same-session transcript rewrite. Provider adapters consume these as a
3411    /// pre-send conflict registry on open and refresh.
3412    #[must_use]
3413    pub fn realtime_user_content_tombstones(
3414        &self,
3415    ) -> Vec<crate::realtime_transcript::RealtimeUserContentTombstone> {
3416        let state = self.realtime_transcript_state();
3417        realtime_transcript_revision::realtime_user_content_tombstones(&state)
3418    }
3419
3420    fn realtime_transcript_state(&self) -> SessionRealtimeTranscriptState {
3421        match self.try_realtime_transcript_state() {
3422            Ok(Some(state)) => state,
3423            Ok(None) => SessionRealtimeTranscriptState::default(),
3424            Err(err) => fail_closed_generated_restore("realtime-transcript", err),
3425        }
3426    }
3427
3428    fn try_realtime_transcript_state(
3429        &self,
3430    ) -> Result<Option<SessionRealtimeTranscriptState>, serde_json::Error> {
3431        self.metadata
3432            .get(SESSION_REALTIME_TRANSCRIPT_STATE_KEY)
3433            .map(|value| {
3434                let state = serde_json::from_value(value.clone())?;
3435                realtime_transcript_revision::restore_realtime_transcript_state(state)
3436                    .map_err(<serde_json::Error as serde::de::Error>::custom)
3437            })
3438            .transpose()
3439    }
3440
3441    fn store_realtime_transcript_state(&mut self, state: &SessionRealtimeTranscriptState) {
3442        match serde_json::to_value(state) {
3443            Ok(value) => self.set_metadata_unchecked(SESSION_REALTIME_TRANSCRIPT_STATE_KEY, value),
3444            Err(error) => {
3445                tracing::warn!(error = %error, "failed to serialize realtime transcript state");
3446            }
3447        }
3448    }
3449
3450    fn reconciled_realtime_transcript_metadata_after_rewrite(
3451        &self,
3452        messages: &[Message],
3453    ) -> Result<Option<serde_json::Value>, TranscriptEditError> {
3454        let Some(state) = self
3455            .try_realtime_transcript_state()
3456            .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?
3457        else {
3458            return Ok(None);
3459        };
3460        let state =
3461            realtime_transcript_revision::reconcile_realtime_transcript_state_after_rewrite(
3462                state, messages,
3463            )
3464            .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
3465        serde_json::to_value(state)
3466            .map(Some)
3467            .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))
3468    }
3469
3470    fn apply_authorized_system_prompt(
3471        &mut self,
3472        prompt: session_durable_config_authority::AuthorizedSystemPrompt,
3473    ) {
3474        use crate::types::SystemMessage;
3475
3476        // The typed mutation provenance is carried onto the applied system
3477        // message so the transcript-continuity save-guard recognizes a
3478        // runtime context-append shape from a typed field instead of the
3479        // rendered `[Runtime System Context]` label.
3480        let mutation_kind = prompt.mutation_kind();
3481        let (prompt, _replacing_existing) = prompt.into_parts();
3482        let message = SystemMessage::with_mutation_kind(prompt, mutation_kind);
3483        let inner = Arc::make_mut(&mut self.messages);
3484        // Check if first message is system
3485        if let Some(Message::System(_)) = inner.first() {
3486            inner[0] = Message::System(message);
3487        } else {
3488            inner.insert(0, Message::System(message));
3489        }
3490        self.updated_at = SystemTime::now();
3491        self.refresh_transcript_head_after_message_mutation();
3492    }
3493
3494    /// Set a system prompt through generated durable-config authority.
3495    pub fn set_system_prompt_with_source(
3496        &mut self,
3497        prompt: String,
3498        source: session_durable_config_authority::SessionSystemPromptSource,
3499    ) -> Result<(), session_durable_config_authority::SessionDurableConfigAuthorityError> {
3500        let replacing_existing = matches!(self.messages.first(), Some(Message::System(_)));
3501        let prompt = session_durable_config_authority::authorize_system_prompt_mutation(
3502            prompt,
3503            source,
3504            replacing_existing,
3505        )?;
3506        self.apply_authorized_system_prompt(prompt);
3507        Ok(())
3508    }
3509
3510    /// Set a system prompt (adds or replaces System message at start).
3511    pub fn set_system_prompt(&mut self, prompt: String) {
3512        if let Err(err) = self.set_system_prompt_with_source(
3513            prompt,
3514            session_durable_config_authority::SessionSystemPromptSource::DirectMutation,
3515        ) {
3516            tracing::warn!(error = %err, "generated session durable-config authority rejected system prompt mutation");
3517        }
3518    }
3519
3520    /// Remove transient active-turn steer context from persisted session state.
3521    ///
3522    /// Operator steers accepted into an already-running turn are request-local:
3523    /// they should be visible to that turn's next model boundary, then vanish
3524    /// instead of replaying into later turns after persistence or resume.
3525    pub fn discard_transient_runtime_steer_context(&mut self) -> usize {
3526        let mut removed = 0usize;
3527
3528        let mut state = match self.try_system_context_state() {
3529            Ok(state) => state.unwrap_or_default(),
3530            Err(err) => {
3531                tracing::warn!(
3532                    error = %err,
3533                    "generated system-context authority rejected runtime steer cleanup state"
3534                );
3535                return removed;
3536            }
3537        };
3538
3539        // The typed `source_kind` marker on persisted appends is the authority
3540        // for which rendered prompt blocks are transient runtime steers. Gather
3541        // the runtime-steer appends, then remove their exact rendered blocks
3542        // from the system prompt — no `runtime:steer:` string classification.
3543        let runtime_steer_appends = state
3544            .pending
3545            .iter()
3546            .chain(state.applied.iter())
3547            .filter(|append| append.source_kind.is_runtime_steer())
3548            .cloned()
3549            .collect::<Vec<_>>();
3550        if let Some(Message::System(system)) = self.messages.first() {
3551            let (retained_prompt, removed_blocks) =
3552                system_context_authority::remove_runtime_steer_blocks_for_rendered(
3553                    &system.content,
3554                    &runtime_steer_appends,
3555                );
3556            if removed_blocks > 0 {
3557                removed += removed_blocks;
3558                if let Err(err) = self.set_system_prompt_with_source(
3559                    retained_prompt,
3560                    session_durable_config_authority::SessionSystemPromptSource::RuntimeSteerCleanup,
3561                ) {
3562                    tracing::warn!(
3563                        error = %err,
3564                        "generated session durable-config authority rejected runtime steer prompt cleanup"
3565                    );
3566                }
3567            }
3568        }
3569
3570        removed += system_context_authority::discard_transient_runtime_steer_state(&mut state);
3571
3572        if removed > 0
3573            && let Err(err) = self.set_system_context_state(state)
3574        {
3575            tracing::warn!(
3576                error = %err,
3577                "failed to persist runtime steer context cleanup"
3578            );
3579        }
3580
3581        removed
3582    }
3583
3584    /// Append one or more runtime system-context blocks to the canonical system prompt.
3585    pub fn append_system_context_blocks(&mut self, appends: &[PendingSystemContextAppend]) {
3586        if appends.is_empty() {
3587            return;
3588        }
3589
3590        let current_system_prompt = self
3591            .messages
3592            .first()
3593            .and_then(|message| match message {
3594                Message::System(system) => Some(system.content.as_str()),
3595                _ => None,
3596            })
3597            .unwrap_or_default();
3598        let mut state = match self.try_system_context_state() {
3599            Ok(state) => state.unwrap_or_default(),
3600            Err(err) => {
3601                tracing::warn!(
3602                    error = %err,
3603                    "generated system-context authority rejected applied context state"
3604                );
3605                return;
3606            }
3607        };
3608        let new_appends = system_context_authority::record_applied_system_context_blocks(
3609            &mut state,
3610            appends,
3611            current_system_prompt,
3612        );
3613        if new_appends.is_empty() {
3614            if let Err(err) = self.set_system_context_state(state) {
3615                tracing::warn!(error = %err, "failed to persist applied system-context state");
3616            }
3617            return;
3618        }
3619
3620        let rendered = render_system_context_blocks_joined(&new_appends);
3621
3622        let next = match self.messages.first() {
3623            Some(Message::System(sys)) if !sys.content.is_empty() => {
3624                format!("{}{}{}", sys.content, SYSTEM_CONTEXT_SEPARATOR, rendered)
3625            }
3626            _ => rendered,
3627        };
3628        if let Err(err) = self.set_system_prompt_with_source(
3629            next,
3630            session_durable_config_authority::SessionSystemPromptSource::RuntimeContextAppend,
3631        ) {
3632            tracing::warn!(
3633                error = %err,
3634                "generated session durable-config authority rejected system-context prompt append"
3635            );
3636            return;
3637        }
3638        if let Err(err) = self.set_system_context_state(state) {
3639            tracing::warn!(error = %err, "failed to persist applied system-context state");
3640        }
3641    }
3642
3643    /// Reconcile a resumed session's persisted system prompt with a freshly
3644    /// assembled base prompt.
3645    ///
3646    /// A resumed transcript is durable state: its leading [`Message::System`]
3647    /// carries the base prompt PLUS every runtime system-context append the
3648    /// runtime durably applied (comms rosters, host context — rendered by
3649    /// [`Session::append_system_context_blocks`]). Blind-replacing that
3650    /// message with a re-assembled base prompt discards the runtime-applied
3651    /// context and produces a projection that is no longer a continuation of
3652    /// the persisted transcript revision — the append-only save guard then
3653    /// rejects the very first post-resume persist and the live session is
3654    /// discarded (the upstream cold-restart transcript-loss report).
3655    ///
3656    /// Reconciliation instead of replacement:
3657    /// - If the persisted System content IS the assembled base — identical, or
3658    ///   extended only by [`SYSTEM_CONTEXT_SEPARATOR`]-joined runtime context
3659    ///   appends — the transcript is left untouched (byte-for-byte, including
3660    ///   the typed `mutation_kind`), so the resumed projection digests to the
3661    ///   persisted revision.
3662    /// - If the base genuinely changed, the new System message (new base plus
3663    ///   the reconstructed runtime-append tail, when the persisted tail is
3664    ///   verifiable from the durable applied-append records) is committed
3665    ///   through [`Session::commit_transcript_rewrite`] — the canonical typed
3666    ///   rewrite path — so the first post-resume persist proves a transcript
3667    ///   graph edge from the persisted head instead of failing closed.
3668    pub fn reconcile_resumed_system_prompt(
3669        &mut self,
3670        assembled_base: String,
3671        actor: Option<String>,
3672    ) -> Result<ResumedSystemPromptReconciliation, TranscriptEditError> {
3673        let persisted = match self.messages.first() {
3674            Some(Message::System(system)) => Some((system.content.clone(), system.mutation_kind)),
3675            _ => None,
3676        };
3677
3678        let Some((persisted_content, persisted_mutation_kind)) = persisted else {
3679            if assembled_base.is_empty() {
3680                return Ok(ResumedSystemPromptReconciliation::NoChange);
3681            }
3682            // The persisted transcript never had a system prompt; introducing
3683            // one changes the transcript, so it flows through the same typed
3684            // rewrite path (an insert rewrite over the empty leading span).
3685            self.commit_resume_system_prompt_rewrite(assembled_base, false, actor)?;
3686            return Ok(ResumedSystemPromptReconciliation::RewrittenBase);
3687        };
3688
3689        if persisted_content == assembled_base {
3690            return Ok(ResumedSystemPromptReconciliation::PreservedContinuation);
3691        }
3692
3693        // Byte-exact reconciliation first: when the persisted content splits
3694        // into a VERIFIED base + runtime-appended tail, the expected content
3695        // for this build is `assembled_base + tail` — equal means the base is
3696        // unchanged (preserve untouched), different means the base changed
3697        // (audited rewrite that carries the tail). This runs before the
3698        // structural fast path so a shortened base whose removed remainder
3699        // merely looks like a context tail (the separator is ordinary
3700        // markdown) is applied instead of silently ignored.
3701        if let Some(tail) = self.verified_runtime_context_tail(&persisted_content) {
3702            let expected = compose_system_prompt_with_context_tail(&assembled_base, &tail);
3703            if expected == persisted_content {
3704                return Ok(ResumedSystemPromptReconciliation::PreservedContinuation);
3705            }
3706            self.commit_resume_system_prompt_rewrite(expected, true, actor)?;
3707            return Ok(ResumedSystemPromptReconciliation::RewrittenBase);
3708        }
3709
3710        // No verifiable tail record (rows written before the assembled base
3711        // was recorded, or applied-append state swept by the runtime path).
3712        // The canonical SessionDocumentMachine persist-append admission
3713        // decides — from the structural observations plus the typed mutation
3714        // provenance — whether the persisted prompt is a runtime-context-
3715        // append continuation of the assembled base. Machine refusal fails
3716        // closed into the audited rewrite below.
3717        if persisted_prompt_is_admitted_context_append_continuation(
3718            &assembled_base,
3719            &persisted_content,
3720            persisted_mutation_kind,
3721        ) {
3722            return Ok(ResumedSystemPromptReconciliation::PreservedContinuation);
3723        }
3724
3725        // The base diverged and the runtime-context tail is not
3726        // reconstructible: only the new base can be written. Dropping the
3727        // appended context silently would leave the durable applied/seen
3728        // records claiming those appends are applied — keyed re-sends would
3729        // be deduplicated forever — so clear the orphaned records to keep the
3730        // context restorable by the host.
3731        let dropping_applied_context = persisted_mutation_kind.is_runtime_context_append()
3732            || self
3733                .system_context_state()
3734                .is_some_and(|state| !state.applied.is_empty());
3735        self.commit_resume_system_prompt_rewrite(assembled_base, true, actor)?;
3736        if dropping_applied_context {
3737            tracing::warn!(
3738                session_id = %self.id,
3739                "resume base-prompt refresh dropped an unverifiable runtime system-context tail; \
3740                 clearing applied-append records so keyed re-sends can restore the context"
3741            );
3742            self.clear_applied_system_context_records();
3743        }
3744        Ok(ResumedSystemPromptReconciliation::RewrittenBase)
3745    }
3746
3747    /// Split the persisted System content into a VERIFIED runtime-appended
3748    /// tail (leading [`SYSTEM_CONTEXT_SEPARATOR`] included; empty when the
3749    /// content is exactly a verified base).
3750    ///
3751    /// Verification sources, strongest first: byte-exact against the prior
3752    /// build's recorded assembled base
3753    /// ([`SessionBuildState::assembled_system_prompt`]), then a re-render of
3754    /// the durable applied-append records. `None` means the tail is not
3755    /// reconstructible from durable facts.
3756    fn verified_runtime_context_tail(&self, persisted_content: &str) -> Option<String> {
3757        if let Some(prior_base) = self
3758            .build_state()
3759            .and_then(|state| state.assembled_system_prompt)
3760        {
3761            if persisted_content == prior_base {
3762                return Some(String::new());
3763            }
3764            if let Some(appended) = persisted_content.strip_prefix(prior_base.as_str())
3765                && appended.starts_with(SYSTEM_CONTEXT_SEPARATOR)
3766            {
3767                return Some(appended.to_string());
3768            }
3769            // The record does not split this content (e.g. it predates the
3770            // last prompt mutation); fall through to the render verification.
3771        }
3772        let rendered_tail = self
3773            .system_context_state()
3774            .map(|state| render_system_context_blocks_joined(&state.applied))
3775            .unwrap_or_default();
3776        if rendered_tail.is_empty() {
3777            return None;
3778        }
3779        if persisted_content == rendered_tail {
3780            // The entire persisted prompt is verified runtime context (a
3781            // promptless/empty-base build whose appends compose without a
3782            // separator prefix) — the tail is the whole content, not empty.
3783            return Some(format!("{SYSTEM_CONTEXT_SEPARATOR}{rendered_tail}"));
3784        }
3785        let with_separator = format!("{SYSTEM_CONTEXT_SEPARATOR}{rendered_tail}");
3786        persisted_content
3787            .ends_with(&with_separator)
3788            .then_some(with_separator)
3789    }
3790
3791    /// Commit a resume-time base-prompt refresh through the generated
3792    /// durable-config authority and the canonical typed rewrite path.
3793    fn commit_resume_system_prompt_rewrite(
3794        &mut self,
3795        content: String,
3796        replacing_existing: bool,
3797        actor: Option<String>,
3798    ) -> Result<(), TranscriptEditError> {
3799        let authorized = session_durable_config_authority::authorize_system_prompt_mutation(
3800            content,
3801            session_durable_config_authority::SessionSystemPromptSource::ExplicitBuild,
3802            replacing_existing,
3803        )
3804        .map_err(|err| {
3805            TranscriptEditError::HistoryStateMalformed(format!(
3806                "generated session durable-config authority rejected resume system prompt refresh: {err}"
3807            ))
3808        })?;
3809        let mutation_kind = authorized.mutation_kind();
3810        let (content, _replacing_existing) = authorized.into_parts();
3811        let replacement = Message::System(crate::types::SystemMessage::with_mutation_kind(
3812            content,
3813            mutation_kind,
3814        ));
3815        let end = usize::from(replacing_existing);
3816        self.commit_transcript_rewrite(
3817            TranscriptRewriteSelection::MessageRange { start: 0, end },
3818            vec![replacement],
3819            TranscriptRewriteReason::new(RESUME_SYSTEM_PROMPT_REFRESH_REWRITE_REASON),
3820            actor,
3821            None,
3822        )?;
3823        Ok(())
3824    }
3825
3826    /// Clear applied-append records (and their idempotency keys) after a
3827    /// resume rewrite dropped their rendered blocks from the System prompt,
3828    /// so the same keyed appends re-apply instead of deduplicating forever.
3829    fn clear_applied_system_context_records(&mut self) {
3830        let mut state = match self.try_system_context_state() {
3831            Ok(Some(state)) => state,
3832            Ok(None) => return,
3833            Err(error) => {
3834                tracing::warn!(
3835                    session_id = %self.id,
3836                    error = %error,
3837                    "failed to read system-context state while clearing orphaned applied records"
3838                );
3839                return;
3840            }
3841        };
3842        if state.applied.is_empty() {
3843            return;
3844        }
3845        let dropped_keys: Vec<String> = state
3846            .applied
3847            .iter()
3848            .filter_map(|append| append.idempotency_key.clone())
3849            .collect();
3850        state.applied.clear();
3851        for key in &dropped_keys {
3852            state.seen.remove(key);
3853        }
3854        if let Err(error) = self.set_system_context_state(state) {
3855            tracing::warn!(
3856                session_id = %self.id,
3857                error = %error,
3858                "failed to persist cleared applied system-context records after resume prompt refresh"
3859            );
3860        }
3861    }
3862
3863    /// Get the last assistant message text content.
3864    ///
3865    /// Concatenates both `Text` (display) and `Transcript` (spoken) blocks
3866    /// in document order, since both lanes project to the same human-readable
3867    /// stream. Lane provenance is preserved on the underlying `AssistantBlock`
3868    /// for callers that need it.
3869    pub fn last_assistant_text(&self) -> Option<String> {
3870        self.messages.iter().rev().find_map(|m| match m {
3871            Message::BlockAssistant(a) => {
3872                let mut buf = String::new();
3873                for block in &a.blocks {
3874                    match block {
3875                        crate::types::AssistantBlock::Text { text, .. }
3876                        | crate::types::AssistantBlock::Transcript { text, .. } => {
3877                            buf.push_str(text);
3878                        }
3879                        _ => {}
3880                    }
3881                }
3882                if buf.is_empty() { None } else { Some(buf) }
3883            }
3884            _ => None,
3885        })
3886    }
3887
3888    /// Count tool calls made
3889    pub fn tool_call_count(&self) -> usize {
3890        self.messages
3891            .iter()
3892            .filter_map(|m| match m {
3893                Message::BlockAssistant(a) => Some(
3894                    a.blocks
3895                        .iter()
3896                        .filter(|b| matches!(b, crate::types::AssistantBlock::ToolUse { .. }))
3897                        .count(),
3898                ),
3899                _ => None,
3900            })
3901            .sum()
3902    }
3903
3904    /// Get metadata
3905    pub fn metadata(&self) -> &serde_json::Map<String, serde_json::Value> {
3906        &self.metadata
3907    }
3908
3909    fn set_metadata_unchecked(&mut self, key: &str, value: serde_json::Value) {
3910        self.metadata.insert(key.to_string(), value);
3911        self.updated_at = SystemTime::now();
3912    }
3913
3914    #[cfg(test)]
3915    pub(crate) fn set_metadata_unchecked_for_test(&mut self, key: &str, value: serde_json::Value) {
3916        self.set_metadata_unchecked(key, value);
3917    }
3918
3919    fn fork_metadata_projection(&self) -> serde_json::Map<String, serde_json::Value> {
3920        let mut metadata = self.metadata.clone();
3921        metadata.retain(|key, _| !is_session_authority_metadata_key(key));
3922        metadata
3923    }
3924
3925    fn remove_metadata_unchecked(&mut self, key: &str) {
3926        self.metadata.remove(key);
3927        self.updated_at = SystemTime::now();
3928    }
3929
3930    /// Set a metadata value when the key is not reserved for generated authority.
3931    pub fn try_set_metadata(
3932        &mut self,
3933        key: &str,
3934        value: serde_json::Value,
3935    ) -> Result<(), ReservedSessionMetadataKey> {
3936        if is_session_authority_metadata_key(key) {
3937            return Err(ReservedSessionMetadataKey::new(key));
3938        }
3939        self.set_metadata_unchecked(key, value);
3940        Ok(())
3941    }
3942
3943    /// Set a metadata value.
3944    ///
3945    /// Reserved generated-authority metadata keys fail closed and are left
3946    /// untouched. Use the typed setters for those keys.
3947    pub fn set_metadata(&mut self, key: &str, value: serde_json::Value) {
3948        if let Err(err) = self.try_set_metadata(key, value) {
3949            tracing::warn!(error = %err, "rejected raw session metadata mutation");
3950        }
3951    }
3952
3953    /// Backfill a missing metadata value without changing `updated_at`.
3954    ///
3955    /// This is only for compatibility reads that need to hydrate metadata from
3956    /// an older projection. Semantic metadata mutations must use
3957    /// [`Session::set_metadata`] so the session timestamp advances.
3958    pub fn backfill_metadata_if_absent(&mut self, key: &str, value: serde_json::Value) -> bool {
3959        if is_session_authority_metadata_key(key) {
3960            tracing::warn!(
3961                metadata_key = key,
3962                "rejected raw session metadata backfill for authority key"
3963            );
3964            return false;
3965        }
3966        if self.metadata.contains_key(key) {
3967            false
3968        } else {
3969            self.metadata.insert(key.to_string(), value);
3970            true
3971        }
3972    }
3973
3974    /// Remove a metadata value.
3975    pub fn remove_metadata(&mut self, key: &str) {
3976        if is_session_authority_metadata_key(key) {
3977            tracing::warn!(
3978                metadata_key = key,
3979                "rejected raw session metadata removal for authority key"
3980            );
3981            return;
3982        }
3983        self.metadata.remove(key);
3984        self.updated_at = SystemTime::now();
3985    }
3986
3987    /// Store SessionMetadata in the session metadata map.
3988    pub fn set_session_metadata(
3989        &mut self,
3990        metadata: SessionMetadata,
3991    ) -> Result<(), serde_json::Error> {
3992        let metadata =
3993            session_durable_config_authority::authorize_session_metadata_persist(metadata)
3994                .map_err(<serde_json::Error as serde::ser::Error>::custom)?
3995                .into_metadata();
3996        let value = serde_json::to_value(metadata)?;
3997        self.set_metadata_unchecked(SESSION_METADATA_KEY, value);
3998        Ok(())
3999    }
4000
4001    /// Load SessionMetadata from the session metadata map.
4002    ///
4003    /// If the reserved key exists but cannot pass typed generated restore,
4004    /// fail closed instead of treating corrupted machine facts as absent.
4005    pub fn session_metadata(&self) -> Option<SessionMetadata> {
4006        match self.try_session_metadata() {
4007            Ok(metadata) => metadata,
4008            Err(err) => fail_closed_generated_restore("session-metadata", err),
4009        }
4010    }
4011
4012    /// Try to load SessionMetadata through generated restore authority.
4013    pub fn try_session_metadata(&self) -> Result<Option<SessionMetadata>, serde_json::Error> {
4014        try_session_metadata_from_map(&self.metadata)
4015    }
4016
4017    /// Store durable system-context control state in the session metadata map.
4018    pub fn set_system_context_state(
4019        &mut self,
4020        state: SessionSystemContextState,
4021    ) -> Result<(), serde_json::Error> {
4022        let state = system_context_authority::restore_system_context_state(state)
4023            .map_err(<serde_json::Error as serde::ser::Error>::custom)?;
4024        let value = serde_json::to_value(state)?;
4025        self.set_metadata_unchecked(SESSION_SYSTEM_CONTEXT_STATE_KEY, value);
4026        Ok(())
4027    }
4028
4029    /// Try to load durable system-context control state through generated restore authority.
4030    pub fn try_system_context_state(
4031        &self,
4032    ) -> Result<Option<SessionSystemContextState>, serde_json::Error> {
4033        self.metadata
4034            .get(SESSION_SYSTEM_CONTEXT_STATE_KEY)
4035            .map(|value| {
4036                let state = serde_json::from_value(value.clone())?;
4037                system_context_authority::restore_system_context_state(state)
4038                    .map_err(<serde_json::Error as serde::de::Error>::custom)
4039            })
4040            .transpose()
4041    }
4042
4043    /// Load durable system-context control state from the session metadata map.
4044    ///
4045    /// Rejected durable facts fail closed through the generated restore
4046    /// authority. Callers that need the typed rejection must use
4047    /// [`Self::try_system_context_state`].
4048    pub fn system_context_state(&self) -> Option<SessionSystemContextState> {
4049        match self.try_system_context_state() {
4050            Ok(state) => state,
4051            Err(err) => fail_closed_generated_restore("system-context", err),
4052        }
4053    }
4054
4055    /// Store durable deferred-turn control state in the session metadata map.
4056    pub fn set_deferred_turn_state(
4057        &mut self,
4058        state: SessionDeferredTurnState,
4059    ) -> Result<(), serde_json::Error> {
4060        let state = validate_deferred_turn_snapshot(state)
4061            .map_err(<serde_json::Error as serde::ser::Error>::custom)?;
4062        let value = serde_json::to_value(state)?;
4063        self.set_metadata_unchecked(SESSION_DEFERRED_TURN_STATE_KEY, value);
4064        Ok(())
4065    }
4066
4067    /// Try to load durable deferred-turn control state through generated restore authority.
4068    pub fn try_deferred_turn_state(
4069        &self,
4070    ) -> Result<Option<SessionDeferredTurnState>, serde_json::Error> {
4071        self.metadata
4072            .get(SESSION_DEFERRED_TURN_STATE_KEY)
4073            .map(|value| {
4074                let state = serde_json::from_value(value.clone())?;
4075                validate_deferred_turn_snapshot(state)
4076                    .map_err(<serde_json::Error as serde::de::Error>::custom)
4077            })
4078            .transpose()
4079    }
4080
4081    /// Load durable deferred-turn control state from the session metadata map.
4082    ///
4083    /// Rejected durable facts fail closed through the generated restore
4084    /// authority. Callers that need the typed rejection must use
4085    /// [`Self::try_deferred_turn_state`].
4086    pub fn deferred_turn_state(&self) -> Option<SessionDeferredTurnState> {
4087        match self.try_deferred_turn_state() {
4088            Ok(state) => state,
4089            Err(err) => fail_closed_generated_restore("deferred-turn", err),
4090        }
4091    }
4092
4093    /// Realize the typed session lifecycle-terminal projection in the session
4094    /// metadata map.
4095    ///
4096    /// The lifecycle-terminal fact is owned by the canonical
4097    /// [`session_document::SessionDocumentMachine`]; production archive paths
4098    /// call this only to realize a machine-emitted `SessionArchiveResolved`
4099    /// verdict (the value written mirrors the machine's decision — the shell
4100    /// decides nothing here).
4101    pub fn set_lifecycle_terminal(
4102        &mut self,
4103        terminal: SessionLifecycleTerminal,
4104    ) -> Result<(), serde_json::Error> {
4105        let value = serde_json::to_value(terminal)?;
4106        self.set_metadata_unchecked(SESSION_LIFECYCLE_TERMINAL_KEY, value);
4107        Ok(())
4108    }
4109
4110    /// Try to load the typed session lifecycle-terminal fact.
4111    ///
4112    /// Reads the typed [`SESSION_LIFECYCLE_TERMINAL_KEY`]; an absent key means
4113    /// no terminal fact.
4114    pub fn try_lifecycle_terminal(
4115        &self,
4116    ) -> Result<Option<SessionLifecycleTerminal>, serde_json::Error> {
4117        try_lifecycle_terminal_from_map(&self.metadata)
4118    }
4119
4120    /// Load the typed session lifecycle-terminal fact, failing closed on a
4121    /// corrupt typed value.
4122    ///
4123    /// Callers that need the typed rejection must use
4124    /// [`Self::try_lifecycle_terminal`].
4125    pub fn lifecycle_terminal(&self) -> Option<SessionLifecycleTerminal> {
4126        match self.try_lifecycle_terminal() {
4127            Ok(state) => state,
4128            Err(err) => fail_closed_generated_restore("session-lifecycle-terminal", err),
4129        }
4130    }
4131
4132    /// Store recoverable build-only session state in the session metadata map.
4133    pub fn set_build_state(&mut self, state: SessionBuildState) -> Result<(), serde_json::Error> {
4134        let state = session_durable_config_authority::authorize_session_build_state_persist(state)
4135            .map_err(<serde_json::Error as serde::ser::Error>::custom)?
4136            .into_state();
4137        let value = serde_json::to_value(state)?;
4138        self.set_metadata_unchecked(SESSION_BUILD_STATE_KEY, value);
4139        Ok(())
4140    }
4141
4142    /// Load recoverable build-only session state from the session metadata map.
4143    ///
4144    /// If the reserved key exists but cannot pass typed generated restore,
4145    /// fail closed instead of treating corrupted machine facts as absent.
4146    pub fn build_state(&self) -> Option<SessionBuildState> {
4147        match self.try_build_state() {
4148            Ok(state) => state,
4149            Err(err) => fail_closed_generated_restore("session-build-state", err),
4150        }
4151    }
4152
4153    /// Try to load recoverable build-only session state through generated restore authority.
4154    pub fn try_build_state(&self) -> Result<Option<SessionBuildState>, serde_json::Error> {
4155        let Some(value) = self.metadata.get(SESSION_BUILD_STATE_KEY) else {
4156            return Ok(None);
4157        };
4158        let state = serde_json::from_value::<SessionBuildState>(value.clone())?;
4159        session_durable_config_authority::restore_session_build_state(state)
4160            .map(Some)
4161            .map_err(<serde_json::Error as serde::de::Error>::custom)
4162    }
4163
4164    /// Store durable tool-visibility control state in the session metadata map.
4165    pub fn set_tool_visibility_state(
4166        &mut self,
4167        state: AuthorizedSessionToolVisibilityState,
4168    ) -> Result<(), serde_json::Error> {
4169        let value = serde_json::to_value(state.into_state())?;
4170        self.set_metadata_unchecked(SESSION_TOOL_VISIBILITY_STATE_KEY, value);
4171        Ok(())
4172    }
4173
4174    /// Test-only metadata clear for compatibility assertions.
4175    ///
4176    /// Production paths persist an explicit generated-authority projection
4177    /// rather than making durable absence carry semantic default truth.
4178    #[cfg(test)]
4179    pub(crate) fn clear_tool_visibility_state(&mut self) {
4180        self.remove_metadata_unchecked(SESSION_TOOL_VISIBILITY_STATE_KEY);
4181    }
4182
4183    /// Load durable tool-visibility control state from the session metadata map.
4184    pub fn tool_visibility_state(
4185        &self,
4186    ) -> Result<Option<SessionToolVisibilityState>, serde_json::Error> {
4187        self.try_tool_visibility_state()
4188    }
4189
4190    /// Load durable tool-visibility control state while distinguishing absent
4191    /// metadata from malformed canonical metadata.
4192    pub fn try_tool_visibility_state(
4193        &self,
4194    ) -> Result<Option<SessionToolVisibilityState>, serde_json::Error> {
4195        self.metadata
4196            .get(SESSION_TOOL_VISIBILITY_STATE_KEY)
4197            .map(|value| serde_json::from_value(value.clone()))
4198            .transpose()
4199    }
4200
4201    /// Load typed transcript revision state from metadata.
4202    pub fn transcript_history_state(
4203        &self,
4204    ) -> Result<Option<TranscriptHistoryState>, serde_json::Error> {
4205        self.metadata
4206            .get(SESSION_TRANSCRIPT_HISTORY_STATE_KEY)
4207            .map(|value| serde_json::from_value(value.clone()))
4208            .transpose()
4209    }
4210
4211    /// Validate the retained transcript revision graph, when present.
4212    pub fn validate_transcript_history_state(&self) -> Result<(), TranscriptEditError> {
4213        let Some(state) = self
4214            .transcript_history_state()
4215            .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?
4216        else {
4217            return Ok(());
4218        };
4219        validate_transcript_history_state(&state)
4220    }
4221
4222    /// Clear retained transcript revision metadata after a caller has
4223    /// materialized the desired message projection.
4224    pub fn clear_transcript_history_state(&mut self) {
4225        self.remove_metadata_unchecked(SESSION_TRANSCRIPT_HISTORY_STATE_KEY);
4226    }
4227
4228    /// Stamp this durable projection copy as written by the intra-turn
4229    /// best-effort checkpointer ahead of the runtime boundary commit.
4230    ///
4231    /// Stamped only on the persisted clone at checkpoint time — never on the
4232    /// live session — so the fact identifies the row's last writer.
4233    pub fn set_runtime_checkpoint_provenance(&mut self) {
4234        self.set_metadata_unchecked(
4235            SESSION_RUNTIME_CHECKPOINT_PROVENANCE_KEY,
4236            serde_json::Value::Bool(true),
4237        );
4238    }
4239
4240    /// Clear the intra-turn checkpoint provenance fact. Every
4241    /// boundary-following persist path clears it so the fact is present on a
4242    /// row iff the checkpointer wrote it last.
4243    pub fn clear_runtime_checkpoint_provenance(&mut self) {
4244        self.remove_metadata_unchecked(SESSION_RUNTIME_CHECKPOINT_PROVENANCE_KEY);
4245    }
4246
4247    /// Whether this durable projection copy was last written by the
4248    /// intra-turn best-effort checkpointer ahead of the runtime boundary
4249    /// commit.
4250    pub fn has_runtime_checkpoint_provenance(&self) -> bool {
4251        self.metadata
4252            .get(SESSION_RUNTIME_CHECKPOINT_PROVENANCE_KEY)
4253            .and_then(serde_json::Value::as_bool)
4254            .unwrap_or(false)
4255    }
4256
4257    /// Return the retained immutable body for a transcript revision.
4258    pub fn transcript_revision_body(
4259        &self,
4260        revision: &str,
4261    ) -> Result<Option<TranscriptRevisionBody>, serde_json::Error> {
4262        Ok(self.transcript_history_state()?.and_then(|state| {
4263            state
4264                .revisions
4265                .into_iter()
4266                .find(|body| body.revision == revision)
4267        }))
4268    }
4269
4270    /// Return the ordered messages for a retained transcript revision.
4271    pub fn transcript_revision_messages(
4272        &self,
4273        revision: &str,
4274    ) -> Result<Option<Vec<Message>>, serde_json::Error> {
4275        Ok(self
4276            .transcript_revision_body(revision)?
4277            .map(|body| body.messages))
4278    }
4279
4280    /// Materialize this session projection from a typed transcript history graph.
4281    pub fn apply_transcript_history_state(
4282        &mut self,
4283        state: TranscriptHistoryState,
4284    ) -> Result<(), TranscriptEditError> {
4285        validate_transcript_history_state(&state)?;
4286        let head_body = state
4287            .revisions
4288            .iter()
4289            .find(|body| body.revision == state.head)
4290            .ok_or_else(|| {
4291                TranscriptEditError::HistoryStateMalformed(format!(
4292                    "missing transcript head body {}",
4293                    state.head
4294                ))
4295            })?
4296            .clone();
4297        let realtime_state =
4298            self.reconciled_realtime_transcript_metadata_after_rewrite(&head_body.messages)?;
4299        let value = serde_json::to_value(&state)
4300            .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
4301        self.set_metadata_unchecked(SESSION_TRANSCRIPT_HISTORY_STATE_KEY, value);
4302        if let Some(value) = realtime_state {
4303            self.set_metadata_unchecked(SESSION_REALTIME_TRANSCRIPT_STATE_KEY, value);
4304        }
4305        let mut updated_at = head_body.created_at;
4306        for commit in &state.commits {
4307            if commit.committed_at > updated_at {
4308                updated_at = commit.committed_at;
4309            }
4310        }
4311        self.messages = Arc::new(head_body.messages);
4312        self.updated_at = updated_at;
4313        Ok(())
4314    }
4315
4316    /// Current transcript head revision. Rows written before transcript
4317    /// revisions derive their implicit head from the current message snapshot.
4318    pub fn transcript_revision(&self) -> Result<String, serde_json::Error> {
4319        if let Some(state) = self.transcript_history_state()? {
4320            Ok(state.head)
4321        } else {
4322            transcript_messages_digest(self.messages())
4323        }
4324    }
4325
4326    /// Monotonic durable generation for same-session transcript rewrites.
4327    /// Ordinary message appends advance the content revision but do not change
4328    /// this value, allowing live config refresh after normal turns while still
4329    /// forcing reopen after a rewrite.
4330    pub fn transcript_rewrite_generation(&self) -> Result<u64, serde_json::Error> {
4331        Ok(self.transcript_history_state()?.map_or(0, |state| {
4332            u64::try_from(state.commits.len()).unwrap_or(u64::MAX)
4333        }))
4334    }
4335
4336    /// Commit a same-session transcript rewrite and advance the transcript head.
4337    pub fn commit_transcript_rewrite(
4338        &mut self,
4339        selection: TranscriptRewriteSelection,
4340        replacement: Vec<Message>,
4341        reason: TranscriptRewriteReason,
4342        actor: Option<String>,
4343        expected_parent_revision: Option<String>,
4344    ) -> Result<TranscriptRewriteCommit, TranscriptEditError> {
4345        let parent_revision = self
4346            .transcript_revision()
4347            .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
4348        if let Some(expected) = expected_parent_revision
4349            && expected != parent_revision
4350        {
4351            return Err(TranscriptEditError::RevisionConflict {
4352                expected,
4353                actual: parent_revision,
4354            });
4355        }
4356
4357        let (start, end) = selection.bounds();
4358        let message_count = self.messages.len();
4359        if start > end || end > message_count {
4360            return Err(TranscriptEditError::InvalidRewriteRange {
4361                start,
4362                end,
4363                message_count,
4364            });
4365        }
4366
4367        let replacement_len = replacement.len();
4368        let mut rewritten = Vec::with_capacity(
4369            start
4370                .saturating_add(replacement_len)
4371                .saturating_add(message_count.saturating_sub(end)),
4372        );
4373        rewritten.extend_from_slice(&self.messages[..start]);
4374        rewritten.extend(replacement);
4375        rewritten.extend_from_slice(&self.messages[end..]);
4376        validate_transcript_tool_result_shape(&rewritten)?;
4377
4378        let original_span_digest = transcript_messages_digest(&self.messages[start..end])
4379            .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
4380        let replacement_digest =
4381            transcript_messages_digest(&rewritten[start..start + replacement_len])
4382                .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
4383        let revision = transcript_messages_digest(&rewritten)
4384            .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
4385        if revision == parent_revision {
4386            return Err(TranscriptEditError::NoOpRewrite { revision });
4387        }
4388        let realtime_state =
4389            self.reconciled_realtime_transcript_metadata_after_rewrite(&rewritten)?;
4390
4391        let commit = TranscriptRewriteCommit {
4392            parent_revision,
4393            revision: revision.clone(),
4394            selection,
4395            original_span_digest,
4396            replacement_digest,
4397            messages_before: message_count,
4398            messages_after: rewritten.len(),
4399            reason,
4400            actor,
4401            committed_at: SystemTime::now(),
4402        };
4403
4404        let mut state = self
4405            .transcript_history_state()
4406            .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?
4407            .unwrap_or_else(|| TranscriptHistoryState {
4408                head: commit.parent_revision.clone(),
4409                commits: Vec::new(),
4410                revisions: Vec::new(),
4411            });
4412        if !state
4413            .revisions
4414            .iter()
4415            .any(|body| body.revision == commit.parent_revision)
4416        {
4417            state.revisions.push(TranscriptRevisionBody {
4418                revision: commit.parent_revision.clone(),
4419                parent_revision: None,
4420                messages: self.messages().to_vec(),
4421                created_at: self.updated_at,
4422            });
4423        }
4424        if !state
4425            .revisions
4426            .iter()
4427            .any(|body| body.revision == commit.revision)
4428        {
4429            state.revisions.push(TranscriptRevisionBody {
4430                revision: commit.revision.clone(),
4431                parent_revision: Some(commit.parent_revision.clone()),
4432                messages: rewritten.clone(),
4433                created_at: commit.committed_at,
4434            });
4435        }
4436        state.head = revision;
4437        state.commits.push(commit.clone());
4438        let value = serde_json::to_value(state)
4439            .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
4440        self.set_metadata_unchecked(SESSION_TRANSCRIPT_HISTORY_STATE_KEY, value);
4441        if let Some(value) = realtime_state {
4442            self.set_metadata_unchecked(SESSION_REALTIME_TRANSCRIPT_STATE_KEY, value);
4443        }
4444
4445        self.messages = Arc::new(rewritten);
4446        self.updated_at = SystemTime::now();
4447        Ok(commit)
4448    }
4449
4450    fn refresh_transcript_head_after_message_mutation(&mut self) {
4451        if !self
4452            .metadata
4453            .contains_key(SESSION_TRANSCRIPT_HISTORY_STATE_KEY)
4454        {
4455            return;
4456        }
4457        let Ok(Some(mut state)) = self.transcript_history_state() else {
4458            tracing::warn!(
4459                session_id = %self.id,
4460                "transcript history state is malformed; leaving head unchanged after message mutation"
4461            );
4462            return;
4463        };
4464        let Ok(head) = transcript_messages_digest(self.messages()) else {
4465            tracing::warn!(
4466                session_id = %self.id,
4467                "failed to digest transcript after message mutation; leaving head unchanged"
4468            );
4469            return;
4470        };
4471        let previous_head = state.head.clone();
4472        if !state.revisions.iter().any(|body| body.revision == head) {
4473            state.revisions.push(TranscriptRevisionBody {
4474                revision: head.clone(),
4475                parent_revision: Some(previous_head),
4476                messages: self.messages().to_vec(),
4477                created_at: SystemTime::now(),
4478            });
4479        }
4480        state.head = head;
4481        match serde_json::to_value(state) {
4482            Ok(value) => self.set_metadata_unchecked(SESSION_TRANSCRIPT_HISTORY_STATE_KEY, value),
4483            Err(error) => {
4484                tracing::warn!(
4485                    session_id = %self.id,
4486                    error = %error,
4487                    "failed to serialize transcript history state after message mutation"
4488                );
4489            }
4490        }
4491    }
4492
4493    /// Store typed mob operator authority inside canonical build-state metadata.
4494    ///
4495    /// Store the mob operator authority projection inside build-state metadata.
4496    ///
4497    /// The projection is durable compatibility data only: serialization drops
4498    /// the generated authority seal, so behavior must re-enter generated
4499    /// authority before using restored facts.
4500    pub fn set_mob_tool_authority_context(
4501        &mut self,
4502        authority_context: Option<MobToolAuthorityContext>,
4503    ) -> Result<(), serde_json::Error> {
4504        if let Some(authority_context) = authority_context.as_ref()
4505            && !authority_context.is_generated_authority_context()
4506        {
4507            return Err(<serde_json::Error as serde::de::Error>::custom(
4508                "mob authority context was not minted by generated authority",
4509            ));
4510        }
4511        let mut build_state = self.build_state().ok_or_else(|| {
4512            <serde_json::Error as serde::de::Error>::custom(format!(
4513                "session {} is missing session build state",
4514                self.id
4515            ))
4516        })?;
4517        build_state.mob_tool_authority_context = authority_context;
4518        self.set_build_state(build_state)
4519    }
4520
4521    /// Load the in-memory generated mob operator authority, if still present.
4522    ///
4523    /// Stored/deserialized contexts deliberately fail this check and are not
4524    /// returned as behavior authority.
4525    pub fn mob_tool_authority_context(&self) -> Option<MobToolAuthorityContext> {
4526        self.build_state()
4527            .and_then(|state| state.mob_tool_authority_context)
4528            .filter(MobToolAuthorityContext::is_generated_authority_context)
4529    }
4530
4531    /// Fork the session at a specific message index
4532    ///
4533    /// Creates a new session with a subset of messages. The messages are copied
4534    /// (not shared) since the new session has a different prefix.
4535    pub fn fork_at(&self, index: usize) -> Self {
4536        let now = SystemTime::now();
4537        let truncated = self.messages[..index.min(self.messages.len())].to_vec();
4538        Self {
4539            version: session_version(),
4540            id: SessionId::new(),
4541            messages: Arc::new(truncated),
4542            created_at: now,
4543            updated_at: now,
4544            metadata: self.fork_metadata_projection(),
4545            usage: self.usage.clone(),
4546        }
4547    }
4548
4549    /// Fork the session and replace the message at `message_index`.
4550    ///
4551    /// The returned session contains the original prefix before
4552    /// `message_index`, followed by the typed replacement. Later source
4553    /// messages are intentionally omitted so follow-up work continues from the
4554    /// edited branch rather than replaying stale descendants.
4555    pub fn fork_replacing(
4556        &self,
4557        message_index: usize,
4558        replacement: TranscriptReplacement,
4559    ) -> Result<Self, TranscriptEditError> {
4560        let Some(original) = self.messages.get(message_index) else {
4561            return Err(TranscriptEditError::MessageIndexOutOfBounds {
4562                message_index,
4563                message_count: self.messages.len(),
4564            });
4565        };
4566
4567        let replacement_message = match replacement {
4568            TranscriptReplacement::Message { message } => message,
4569            TranscriptReplacement::UserContentBlock { block_index, block } => {
4570                let Message::User(user) = original else {
4571                    return Err(TranscriptEditError::MessageRoleMismatch {
4572                        message_index,
4573                        expected: "user",
4574                        actual: message_role_name(original),
4575                    });
4576                };
4577                if block_index >= user.content.len() {
4578                    return Err(TranscriptEditError::BlockIndexOutOfBounds {
4579                        block_kind: "user content block",
4580                        block_index,
4581                        block_count: user.content.len(),
4582                    });
4583                }
4584                let mut edited = user.clone();
4585                edited.content[block_index] = block;
4586                Message::User(edited)
4587            }
4588            TranscriptReplacement::AssistantBlock { block_index, block } => {
4589                let Message::BlockAssistant(assistant) = original else {
4590                    return Err(TranscriptEditError::MessageRoleMismatch {
4591                        message_index,
4592                        expected: "block_assistant",
4593                        actual: message_role_name(original),
4594                    });
4595                };
4596                if block_index >= assistant.blocks.len() {
4597                    return Err(TranscriptEditError::BlockIndexOutOfBounds {
4598                        block_kind: "assistant block",
4599                        block_index,
4600                        block_count: assistant.blocks.len(),
4601                    });
4602                }
4603                let mut edited = assistant.clone();
4604                edited.blocks[block_index] = block;
4605                Message::BlockAssistant(edited)
4606            }
4607            TranscriptReplacement::ToolResultContentBlock {
4608                result_index,
4609                block_index,
4610                block,
4611            } => {
4612                let Message::ToolResults {
4613                    results,
4614                    created_at,
4615                } = original
4616                else {
4617                    return Err(TranscriptEditError::MessageRoleMismatch {
4618                        message_index,
4619                        expected: "tool_results",
4620                        actual: message_role_name(original),
4621                    });
4622                };
4623                let Some(result) = results.get(result_index) else {
4624                    return Err(TranscriptEditError::BlockIndexOutOfBounds {
4625                        block_kind: "tool result",
4626                        block_index: result_index,
4627                        block_count: results.len(),
4628                    });
4629                };
4630                if block_index >= result.content.len() {
4631                    return Err(TranscriptEditError::BlockIndexOutOfBounds {
4632                        block_kind: "tool result content block",
4633                        block_index,
4634                        block_count: result.content.len(),
4635                    });
4636                }
4637                let mut edited_results = results.clone();
4638                edited_results[result_index].content[block_index] = block;
4639                Message::ToolResults {
4640                    results: edited_results,
4641                    created_at: *created_at,
4642                }
4643            }
4644        };
4645
4646        let mut forked = self.fork_at(message_index);
4647        forked.push(replacement_message);
4648        Ok(forked)
4649    }
4650
4651    /// Fork the entire session (full history)
4652    ///
4653    /// This is O(1) - the new session shares the message buffer via Arc.
4654    /// Copy-on-write occurs when either session mutates its messages.
4655    pub fn fork(&self) -> Self {
4656        let now = SystemTime::now();
4657        Self {
4658            version: session_version(),
4659            id: SessionId::new(),
4660            messages: Arc::clone(&self.messages),
4661            created_at: now,
4662            updated_at: now,
4663            metadata: self.fork_metadata_projection(),
4664            usage: self.usage.clone(),
4665        }
4666    }
4667}
4668
4669impl Default for Session {
4670    fn default() -> Self {
4671        Self::new()
4672    }
4673}
4674
4675/// Summary metadata for listing sessions
4676#[derive(Debug, Clone, Serialize, Deserialize)]
4677#[serde(rename_all = "snake_case")]
4678pub struct SessionMeta {
4679    pub id: SessionId,
4680    pub created_at: SystemTime,
4681    pub updated_at: SystemTime,
4682    pub message_count: usize,
4683    pub total_tokens: u64,
4684    #[serde(default)]
4685    pub metadata: serde_json::Map<String, serde_json::Value>,
4686}
4687
4688/// Metadata required to reliably resume a session across interfaces.
4689#[derive(Debug, Clone, Serialize, Deserialize)]
4690#[serde(rename_all = "snake_case")]
4691pub struct SessionMetadata {
4692    /// Per-entity schema version byte.
4693    ///
4694    /// Mandatory on read: a persisted row missing the byte (or carrying a
4695    /// non-current value) fails closed through the generated persistence
4696    /// version authority instead of silently defaulting. Stamped with the
4697    /// current `SESSION_METADATA_SCHEMA_VERSION` on every persist.
4698    pub schema_version: u32,
4699    pub model: String,
4700    pub max_tokens: u32,
4701    #[serde(default = "crate::config::default_structured_output_retries")]
4702    pub structured_output_retries: u32,
4703    pub provider: Provider,
4704    #[serde(default, skip_serializing_if = "Option::is_none")]
4705    pub self_hosted_server_id: Option<String>,
4706    /// Typed provider parameter overrides persisted with the session.
4707    /// Parsed fail-closed at the serde boundary — no JSON bag survives here.
4708    #[serde(default, skip_serializing_if = "Option::is_none")]
4709    pub provider_params: Option<crate::lifecycle::run_primitive::ProviderParamsOverride>,
4710    pub tooling: SessionTooling,
4711    #[serde(default)]
4712    pub keep_alive: bool,
4713    pub comms_name: Option<String>,
4714    /// Friendly metadata for peer discovery (populated when comms is enabled).
4715    #[serde(default, skip_serializing_if = "Option::is_none")]
4716    pub peer_meta: Option<PeerMeta>,
4717    /// Realm identity for cross-surface storage sharing/isolation.
4718    ///
4719    /// Typed [`crate::RealmId`]; the realm slug is validated at the serde
4720    /// boundary. `RealmId` serializes transparently as its slug string, so the
4721    /// durable JSON shape is identical to the prior `Option<String>` form.
4722    #[serde(default, skip_serializing_if = "Option::is_none")]
4723    pub realm_id: Option<crate::RealmId>,
4724    /// Optional process/agent instance identifier within a realm.
4725    #[serde(default, skip_serializing_if = "Option::is_none")]
4726    pub instance_id: Option<String>,
4727    /// Backend pinned by the realm manifest (e.g. "sqlite", "jsonl", "memory").
4728    #[serde(default, skip_serializing_if = "Option::is_none")]
4729    pub backend: Option<String>,
4730    /// Config generation used when this session was created/resumed.
4731    #[serde(default, skip_serializing_if = "Option::is_none")]
4732    pub config_generation: Option<u64>,
4733    /// Realm-scoped auth binding (Phase 3 provider-auth redesign).
4734    ///
4735    /// Persisted intent for the auth/backend binding this session resolved
4736    /// through. On resume, `apply_resumed_session_metadata` writes this
4737    /// back into `AgentBuildConfig.auth_binding` so the same realm
4738    /// binding is re-resolved. Never carries secret material — leases
4739    /// are rebuilt from the active realm connection set at resume time.
4740    /// Older persisted sessions without the field deserialize as `None`
4741    /// (backward compatible via `#[serde(default)]`).
4742    #[serde(default, skip_serializing_if = "Option::is_none")]
4743    pub auth_binding: Option<crate::AuthBindingRef>,
4744    /// Typed durable identity of a mob member, when this session was created by
4745    /// the mob runtime.
4746    ///
4747    /// This is the canonical owner of the `(mob_id, role, member)` identity
4748    /// fact used by mob ownership routing on resume/restart. It replaces the
4749    /// prior recovery-by-string-split of `comms_name` plus a realm
4750    /// format-string check. `comms_name`/`realm_id`/`peer_meta` remain as the
4751    /// transport routing name and discovery metadata.
4752    ///
4753    /// Older persisted sessions without the field deserialize as `None`
4754    /// (backward compatible via `#[serde(default)]`), so old rows read as
4755    /// "no typed binding" rather than failing.
4756    #[serde(default, skip_serializing_if = "Option::is_none")]
4757    pub mob_member_binding: Option<crate::MobMemberBinding>,
4758}
4759
4760/// Canonical durable LLM identity for a session.
4761#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
4762#[serde(rename_all = "snake_case")]
4763pub struct SessionLlmIdentity {
4764    pub model: String,
4765    pub provider: Provider,
4766    #[serde(default, skip_serializing_if = "Option::is_none")]
4767    pub self_hosted_server_id: Option<String>,
4768    /// Typed provider parameter overrides carried on the durable identity.
4769    #[serde(default, skip_serializing_if = "Option::is_none")]
4770    pub provider_params: Option<crate::lifecycle::run_primitive::ProviderParamsOverride>,
4771    /// Realm-scoped auth binding this session resolves credentials
4772    /// through. Carried on the identity so mid-session hot-swaps
4773    /// (`apply_live_session_llm_identity`) re-resolve against the
4774    /// same realm the session was created with — preventing
4775    /// cross-realm credential bleed in multi-tenant setups. Dogma
4776    /// §12 (dynamic policy follows dynamic identity): on swap the
4777    /// factory re-enters `ProviderRuntimeRegistry::resolve` against
4778    /// this binding, not a new synthesized env-default realm.
4779    ///
4780    /// Projection (dogma §1/§13): canonical owner is
4781    /// `SessionMetadata.auth_binding`; this field is the
4782    /// read/write projection used by hot-swap.
4783    #[serde(default, skip_serializing_if = "Option::is_none")]
4784    pub auth_binding: Option<crate::AuthBindingRef>,
4785}
4786
4787/// Typed per-turn override request for a session LLM identity.
4788///
4789/// `provider_params` and `auth_binding` carry the canonical Inherit/Set/Clear
4790/// tri-state via [`TurnMetadataOverride`]: `None` preserves the durable value,
4791/// `Some(Set)` overrides it for this turn, and `Some(Clear)` removes it. The
4792/// illegal "set and clear" fourth state is structurally unrepresentable, so the
4793/// resolver needs no reject branch for it.
4794pub struct SessionLlmIdentityOverride<'a> {
4795    pub model: Option<&'a str>,
4796    pub provider: Option<Provider>,
4797    pub provider_params:
4798        Option<TurnMetadataOverride<&'a crate::lifecycle::run_primitive::ProviderParamsOverride>>,
4799    pub auth_binding: Option<TurnMetadataOverride<&'a crate::AuthBindingRef>>,
4800}
4801
4802#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
4803pub enum SessionLlmIdentityOverrideError {
4804    #[error("provider override requires model on an existing session")]
4805    ProviderRequiresModel,
4806    #[error("{0}")]
4807    ProviderModelMismatch(String),
4808    #[error("self-hosted provider requires a registered model alias; '{model}' is not configured")]
4809    MissingSelfHostedAlias { model: String },
4810}
4811
4812/// Resolve a turn-time model/provider/auth override against the current
4813/// durable session identity.
4814///
4815/// The model registry is the authority for catalog ownership. A model-only
4816/// override follows catalog ownership when the target model is registered;
4817/// uncatalogued models keep the current provider so custom aliases remain
4818/// possible.
4819pub fn resolve_session_llm_identity_override(
4820    current: &SessionLlmIdentity,
4821    registry: &crate::ModelRegistry,
4822    overrides: SessionLlmIdentityOverride<'_>,
4823) -> Result<SessionLlmIdentity, SessionLlmIdentityOverrideError> {
4824    if overrides.provider.is_some() && overrides.model.is_none() {
4825        return Err(SessionLlmIdentityOverrideError::ProviderRequiresModel);
4826    }
4827
4828    let model = overrides
4829        .model
4830        .map(str::to_string)
4831        .unwrap_or_else(|| current.model.clone());
4832    let provider = if let Some(provider) = overrides.provider {
4833        provider
4834    } else if overrides.model.is_some() {
4835        registry
4836            .entry(&model)
4837            .map_or(current.provider, |entry| entry.provider)
4838    } else {
4839        current.provider
4840    };
4841
4842    if (overrides.model.is_some() || overrides.provider.is_some())
4843        && let Some(reason) = registry.provider_override_mismatch_reason(provider, &model)
4844    {
4845        return Err(SessionLlmIdentityOverrideError::ProviderModelMismatch(
4846            reason,
4847        ));
4848    }
4849
4850    let provider_params = match overrides.provider_params {
4851        Some(TurnMetadataOverride::Clear) => None,
4852        Some(TurnMetadataOverride::Set(value)) => Some(value.clone()),
4853        None => current.provider_params.clone(),
4854    };
4855    let self_hosted_server_id = if provider == Provider::SelfHosted {
4856        if overrides.model.is_none() {
4857            current.self_hosted_server_id.clone().or_else(|| {
4858                registry
4859                    .entry_for_provider(Provider::SelfHosted, &model)
4860                    .and_then(|entry| entry.self_hosted.as_ref())
4861                    .map(|server| server.server_id.clone())
4862            })
4863        } else {
4864            let entry = registry
4865                .entry_for_provider(Provider::SelfHosted, &model)
4866                .ok_or_else(|| SessionLlmIdentityOverrideError::MissingSelfHostedAlias {
4867                    model: model.clone(),
4868                })?;
4869            entry
4870                .self_hosted
4871                .as_ref()
4872                .map(|server| server.server_id.clone())
4873        }
4874    } else {
4875        None
4876    };
4877
4878    let auth_binding = match overrides.auth_binding {
4879        Some(TurnMetadataOverride::Clear) => None,
4880        Some(TurnMetadataOverride::Set(value)) => Some(value.clone()),
4881        // Inherit: a provider change without an explicit binding drops the
4882        // stale binding; otherwise the durable binding is retained.
4883        None if provider != current.provider => None,
4884        None => current.auth_binding.clone(),
4885    };
4886
4887    Ok(SessionLlmIdentity {
4888        model,
4889        provider,
4890        self_hosted_server_id,
4891        provider_params,
4892        auth_binding,
4893    })
4894}
4895
4896/// Live request policy paired with a session LLM identity hot-swap.
4897///
4898/// `SessionLlmIdentity` is the durable semantic identity. This projection is
4899/// the per-turn request policy the live agent must use for the next LLM call,
4900/// including provider params and provider-native tool defaults resolved for
4901/// the same target model/provider.
4902#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
4903#[serde(rename_all = "snake_case")]
4904pub struct SessionLlmRequestPolicy {
4905    pub model: String,
4906    /// Typed explicit provider parameter overrides for the next LLM call.
4907    #[serde(default, skip_serializing_if = "Option::is_none")]
4908    pub provider_params: Option<crate::lifecycle::run_primitive::ProviderParamsOverride>,
4909    /// Typed provider-native tool defaults resolved for the swapped target.
4910    #[serde(default, skip_serializing_if = "Option::is_none")]
4911    pub provider_tool_defaults: Option<crate::lifecycle::run_primitive::ProviderTag>,
4912}
4913
4914impl SessionMetadata {
4915    /// Return the current durable LLM identity for this session.
4916    pub fn llm_identity(&self) -> SessionLlmIdentity {
4917        SessionLlmIdentity {
4918            model: self.model.clone(),
4919            provider: self.provider,
4920            self_hosted_server_id: self.self_hosted_server_id.clone(),
4921            provider_params: self.provider_params.clone(),
4922            auth_binding: self.auth_binding.clone(),
4923        }
4924    }
4925
4926    /// Overwrite the durable LLM identity while preserving unrelated session metadata.
4927    pub fn apply_llm_identity(&mut self, identity: &SessionLlmIdentity) {
4928        self.model = identity.model.clone();
4929        self.provider = identity.provider;
4930        self.self_hosted_server_id = identity.self_hosted_server_id.clone();
4931        self.provider_params = identity.provider_params.clone();
4932        self.auth_binding = identity.auth_binding.clone();
4933    }
4934}
4935
4936/// Key used to store SessionMetadata in Session metadata map.
4937pub const SESSION_METADATA_KEY: &str = "session_metadata";
4938
4939/// Caller intent for a tool category.
4940///
4941/// Distinguishes "no opinion / didn't exist" (`Inherit`) from explicit
4942/// `Enable` / `Disable` so that resumed sessions don't freeze tool
4943/// availability at the capabilities of the Meerkat version that created them.
4944///
4945/// **Dogma §10:** Inherit, disable, and set are different facts.
4946#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
4947#[serde(rename_all = "snake_case")]
4948pub enum ToolCategoryOverride {
4949    /// No explicit intent — inherit runtime/factory default.
4950    #[default]
4951    Inherit,
4952    /// Explicitly enabled by caller.
4953    Enable,
4954    /// Explicitly disabled by caller.
4955    Disable,
4956}
4957
4958impl ToolCategoryOverride {
4959    /// Resolve this override against a runtime default.
4960    ///
4961    /// - `Enable` → `true`
4962    /// - `Disable` → `false`
4963    /// - `Inherit` → `runtime_default`
4964    #[must_use]
4965    pub fn resolve(self, runtime_default: bool) -> bool {
4966        match self {
4967            Self::Enable => true,
4968            Self::Disable => false,
4969            Self::Inherit => runtime_default,
4970        }
4971    }
4972
4973    /// Convert to `Option<bool>` for feeding `AgentBuildConfig` override fields.
4974    ///
4975    /// - `Enable` → `Some(true)`
4976    /// - `Disable` → `Some(false)`
4977    /// - `Inherit` → `None` (factory default wins)
4978    #[must_use]
4979    pub fn to_override(self) -> Option<bool> {
4980        match self {
4981            Self::Enable => Some(true),
4982            Self::Disable => Some(false),
4983            Self::Inherit => None,
4984        }
4985    }
4986
4987    /// Construct from a resolved effective bool.
4988    ///
4989    /// **Warning:** this collapses `Inherit` into `Enable`/`Disable`. Prefer
4990    /// [`from_override`] when persisting session metadata so that `Inherit`
4991    /// survives across save/resume cycles. Only use `from_effective` in test
4992    /// helpers or when constructing metadata from external sources that only
4993    /// provide a resolved bool.
4994    #[must_use]
4995    pub fn from_effective(enabled: bool) -> Self {
4996        if enabled { Self::Enable } else { Self::Disable }
4997    }
4998
4999    /// Construct from an `Option<bool>` override field, preserving `Inherit`.
5000    ///
5001    /// - `Some(true)` → `Enable`
5002    /// - `Some(false)` → `Disable`
5003    /// - `None` → `Inherit` (factory default was used, no explicit intent)
5004    ///
5005    /// This is the inverse of [`to_override`] and should be used when persisting
5006    /// session tooling metadata so that `Inherit` survives across save/resume
5007    /// cycles.
5008    #[must_use]
5009    pub fn from_override(value: Option<bool>) -> Self {
5010        match value {
5011            Some(true) => Self::Enable,
5012            Some(false) => Self::Disable,
5013            None => Self::Inherit,
5014        }
5015    }
5016}
5017
5018/// Tooling intent captured at session creation time.
5019///
5020/// Fields use [`ToolCategoryOverride`] to distinguish "no opinion" from
5021/// explicit enable/disable (Dogma §10). On resume, `Inherit` falls through
5022/// to the factory's current runtime default, allowing new tool categories
5023/// to become available without re-creating the session.
5024#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
5025#[serde(rename_all = "snake_case")]
5026pub struct SessionTooling {
5027    #[serde(default)]
5028    pub builtins: ToolCategoryOverride,
5029    #[serde(default)]
5030    pub shell: ToolCategoryOverride,
5031    #[serde(default)]
5032    pub comms: ToolCategoryOverride,
5033    /// Mob (multi-agent orchestration) tools.
5034    #[serde(default)]
5035    pub mob: ToolCategoryOverride,
5036    /// Semantic memory.
5037    #[serde(default)]
5038    pub memory: ToolCategoryOverride,
5039    /// Scheduler tools.
5040    #[serde(default)]
5041    pub schedule: ToolCategoryOverride,
5042    /// WorkGraph durable work tools.
5043    #[serde(default)]
5044    pub workgraph: ToolCategoryOverride,
5045    /// Assistant image generation.
5046    #[serde(default)]
5047    pub image_generation: ToolCategoryOverride,
5048    /// Meerkat-owned fallback web search.
5049    #[serde(default)]
5050    pub web_search: ToolCategoryOverride,
5051    /// Effective call-level tool execution policy for this session's builds.
5052    ///
5053    /// Persisted RESOLVED (never `Inherit`): the factory fails the build
5054    /// closed on an unresolved `Inherit` before metadata is written, so this
5055    /// field only ever holds `AllowList`/`DenyList`. Absent means
5056    /// unrestricted. Spawn/fork resolution reads this field as the parent's
5057    /// effective policy when a child requests `Inherit` (transitive
5058    /// containment — a restricted parent cannot mint an unrestricted child
5059    /// by spawning).
5060    #[serde(default, skip_serializing_if = "Option::is_none")]
5061    pub tool_access_policy: Option<crate::ops::ToolAccessPolicy>,
5062    /// Active skills at session creation time (for deterministic resume).
5063    #[serde(default, skip_serializing_if = "Option::is_none")]
5064    pub active_skills: Option<Vec<crate::skills::SkillKey>>,
5065}
5066
5067impl From<&Session> for SessionMeta {
5068    fn from(session: &Session) -> Self {
5069        Self {
5070            id: session.id.clone(),
5071            created_at: session.created_at,
5072            updated_at: session.updated_at,
5073            message_count: session.messages.len(),
5074            total_tokens: session.total_tokens(),
5075            metadata: session.metadata.clone(),
5076        }
5077    }
5078}
5079
5080/// Decode the typed [`SESSION_METADATA_KEY`] fact from a session metadata map
5081/// through the generated restore authority.
5082///
5083/// Canonical single decoder: [`Session::try_session_metadata`] and every
5084/// metadata-only read seam ([`PersistedSessionMetadataView`]) delegate here so
5085/// the full-session and metadata-only decode paths can never drift.
5086///
5087/// Fail-closed: a present-but-corrupt value is an error, never "absent".
5088pub fn try_session_metadata_from_map(
5089    metadata: &serde_json::Map<String, serde_json::Value>,
5090) -> Result<Option<SessionMetadata>, serde_json::Error> {
5091    let Some(value) = metadata.get(SESSION_METADATA_KEY) else {
5092        return Ok(None);
5093    };
5094    let mut metadata = serde_json::from_value::<SessionMetadata>(value.clone())?;
5095    metadata.schema_version =
5096        session_persistence_version_authority::restore_session_metadata_schema_version(
5097            metadata.schema_version,
5098        )
5099        .map_err(<serde_json::Error as serde::de::Error>::custom)?;
5100    session_durable_config_authority::restore_session_metadata(metadata)
5101        .map(Some)
5102        .map_err(<serde_json::Error as serde::de::Error>::custom)
5103}
5104
5105/// Decode the typed [`SESSION_LIFECYCLE_TERMINAL_KEY`] fact from a session
5106/// metadata map.
5107///
5108/// Canonical single decoder: [`Session::try_lifecycle_terminal`] and every
5109/// metadata-only read seam delegate here. An absent key means no terminal
5110/// fact; a present-but-corrupt value fails closed.
5111pub fn try_lifecycle_terminal_from_map(
5112    metadata: &serde_json::Map<String, serde_json::Value>,
5113) -> Result<Option<SessionLifecycleTerminal>, serde_json::Error> {
5114    match metadata.get(SESSION_LIFECYCLE_TERMINAL_KEY) {
5115        Some(value) => serde_json::from_value(value.clone()).map(Some),
5116        None => Ok(None),
5117    }
5118}
5119
5120/// Typed metadata-only view of a persisted session row or snapshot.
5121///
5122/// The metadata read seam's currency (mobkit ask-24 clause 3): carries the
5123/// session identity plus the two typed session-authority metadata facts,
5124/// decoded fail-closed through the canonical map-level decoders. Consumers
5125/// that only need ownership/policy/lifecycle facts read this view instead of
5126/// materializing the full session document.
5127#[derive(Debug, Clone)]
5128pub struct PersistedSessionMetadataView {
5129    pub session_id: SessionId,
5130    pub session_metadata: Option<SessionMetadata>,
5131    pub lifecycle_terminal: Option<SessionLifecycleTerminal>,
5132}
5133
5134impl PersistedSessionMetadataView {
5135    /// Build the view from a persisted metadata map (e.g. a
5136    /// [`SessionMeta`] row projection).
5137    ///
5138    /// Fail-closed: corrupt values under either reserved key are an error,
5139    /// never treated as absent.
5140    pub fn try_from_metadata_map(
5141        session_id: SessionId,
5142        metadata: &serde_json::Map<String, serde_json::Value>,
5143    ) -> Result<Self, serde_json::Error> {
5144        Ok(Self {
5145            session_id,
5146            session_metadata: try_session_metadata_from_map(metadata)?,
5147            lifecycle_terminal: try_lifecycle_terminal_from_map(metadata)?,
5148        })
5149    }
5150
5151    /// Project the view from a fully materialized session document.
5152    pub fn try_from_session(session: &Session) -> Result<Self, serde_json::Error> {
5153        Ok(Self {
5154            session_id: session.id().clone(),
5155            session_metadata: session.try_session_metadata()?,
5156            lifecycle_terminal: session.try_lifecycle_terminal()?,
5157        })
5158    }
5159
5160    /// Typed durable mob member identity carried on the session metadata,
5161    /// if any.
5162    pub fn mob_member_binding(&self) -> Option<&crate::MobMemberBinding> {
5163        self.session_metadata.as_ref()?.mob_member_binding.as_ref()
5164    }
5165}
5166
5167#[cfg(test)]
5168#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
5169mod tests {
5170    use super::*;
5171    use crate::realtime_transcript::RealtimeTranscriptRole;
5172    use crate::types::{
5173        AssistantBlock, BlockAssistantMessage, ContentBlock, StopReason, SystemMessage, Usage,
5174        UserMessage,
5175    };
5176    use std::sync::Arc;
5177
5178    fn block_assistant_text(message: &BlockAssistantMessage) -> String {
5179        message
5180            .blocks
5181            .iter()
5182            .filter_map(|block| match block {
5183                AssistantBlock::Text { text, .. } => Some(text.as_str()),
5184                _ => None,
5185            })
5186            .collect()
5187    }
5188
5189    /// Reducer tests enter through the same proof shape as persistent
5190    /// ingestion: a metadata-only anchor is staged first, then a canonical
5191    /// blob-backed event is applied. Blob bytes are verified in
5192    /// PersistentSessionService tests; this helper tests only reducer ownership.
5193    fn append_staged_user_image(
5194        session: &mut Session,
5195        event: &RealtimeTranscriptEvent,
5196    ) -> RealtimeTranscriptApplyOutcome {
5197        let RealtimeTranscriptEvent::UserContentFinal {
5198            idempotency_key,
5199            item_id,
5200            previous_item_id,
5201            content_index,
5202            content,
5203        } = event
5204        else {
5205            panic!("test helper requires user content final")
5206        };
5207        let [ContentBlock::Image { media_type, data }] = content.as_slice() else {
5208            panic!("test helper requires exactly one image")
5209        };
5210        let media_type = crate::image_generation::MediaType::canonical_str(media_type);
5211        let blob_id = match data {
5212            crate::types::ImageData::Inline { data } => {
5213                crate::blob::content_blob_id(&media_type, data)
5214            }
5215            crate::types::ImageData::Blob { blob_id } => blob_id.clone(),
5216        };
5217        let pending = crate::PendingRealtimeUserContentBlob {
5218            idempotency_key: idempotency_key.clone(),
5219            item_id: item_id.clone(),
5220            previous_item_id: previous_item_id.clone(),
5221            content_index: *content_index,
5222            blob_id,
5223            media_type,
5224        };
5225        assert_eq!(
5226            session
5227                .stage_pending_realtime_user_content_blob(pending.clone())
5228                .expect("test pending anchor should stage"),
5229            crate::generated::session_document::RealtimeUserContentBlobStageDisposition::StageNew
5230        );
5231        session.append_realtime_transcript_event(pending.canonical_event())
5232    }
5233
5234    #[test]
5235    fn transcript_digest_is_content_addressed() {
5236        let base_time = crate::types::message_timestamp_now();
5237        let stamped = vec![
5238            Message::User(UserMessage::text("turn one".to_string())),
5239            Message::BlockAssistant(BlockAssistantMessage {
5240                blocks: vec![AssistantBlock::Text {
5241                    text: "answer one".to_string(),
5242                    meta: None,
5243                }],
5244                stop_reason: StopReason::EndTurn,
5245                identity: crate::types::TranscriptMessageIdentity {
5246                    interaction_id: None,
5247                    run_id: Some(crate::lifecycle::RunId::new()),
5248                },
5249                created_at: base_time,
5250            }),
5251        ];
5252        let mut restamped = stamped.clone();
5253        for message in &mut restamped {
5254            match message {
5255                Message::User(user) => {
5256                    user.created_at = base_time + chrono::Duration::hours(2);
5257                }
5258                Message::BlockAssistant(assistant) => {
5259                    assistant.identity = crate::types::TranscriptMessageIdentity {
5260                        interaction_id: None,
5261                        run_id: Some(crate::lifecycle::RunId::new()),
5262                    };
5263                    assistant.created_at = base_time + chrono::Duration::hours(2);
5264                }
5265                _ => {}
5266            }
5267        }
5268        assert_eq!(
5269            transcript_messages_digest(&stamped).expect("digest"),
5270            transcript_messages_digest(&restamped).expect("digest"),
5271            "bookkeeping variance must not fork the transcript revision"
5272        );
5273
5274        let mut content_changed = stamped.clone();
5275        if let Message::User(user) = &mut content_changed[0] {
5276            user.content = vec![ContentBlock::Text {
5277                text: "a different turn".to_string(),
5278            }];
5279        }
5280        assert_ne!(
5281            transcript_messages_digest(&stamped).expect("digest"),
5282            transcript_messages_digest(&content_changed).expect("digest"),
5283            "content changes must fork the transcript revision"
5284        );
5285    }
5286
5287    fn legacy_rewrite_fixture() -> (TranscriptRewriteCommit, Vec<Message>, Vec<Message>) {
5288        let parent_messages = vec![
5289            Message::User(UserMessage::text("before rewrite".to_string())),
5290            Message::User(UserMessage::text("retained tail".to_string())),
5291        ];
5292        let revision_messages = vec![
5293            Message::User(UserMessage::text("after rewrite".to_string())),
5294            Message::User(UserMessage::text("retained tail".to_string())),
5295        ];
5296        // Compute the graph strings the way a pre-0.7.14 writer did:
5297        // bookkeeping-inclusive digests.
5298        let parent_revision =
5299            legacy_transcript_messages_digest(&parent_messages).expect("legacy parent digest");
5300        let revision =
5301            legacy_transcript_messages_digest(&revision_messages).expect("legacy revision digest");
5302        let commit = TranscriptRewriteCommit {
5303            parent_revision,
5304            revision,
5305            selection: TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
5306            original_span_digest: legacy_transcript_messages_digest(&parent_messages[0..1])
5307                .expect("legacy span digest"),
5308            replacement_digest: legacy_transcript_messages_digest(&revision_messages[0..1])
5309                .expect("legacy replacement digest"),
5310            messages_before: 2,
5311            messages_after: 2,
5312            reason: TranscriptRewriteReason::new("compaction"),
5313            actor: Some("legacy-test".to_string()),
5314            committed_at: SystemTime::now(),
5315        };
5316        (commit, parent_messages, revision_messages)
5317    }
5318
5319    #[test]
5320    fn legacy_transcript_history_state_heals_to_content_addressed_on_parse() {
5321        let (commit, parent_messages, revision_messages) = legacy_rewrite_fixture();
5322        let state = TranscriptHistoryState {
5323            head: commit.revision.clone(),
5324            commits: vec![commit.clone()],
5325            revisions: vec![
5326                TranscriptRevisionBody {
5327                    revision: commit.parent_revision.clone(),
5328                    parent_revision: None,
5329                    messages: parent_messages.clone(),
5330                    created_at: SystemTime::now(),
5331                },
5332                TranscriptRevisionBody {
5333                    revision: commit.revision.clone(),
5334                    parent_revision: Some(commit.parent_revision),
5335                    messages: revision_messages.clone(),
5336                    created_at: SystemTime::now(),
5337                },
5338            ],
5339        };
5340        let value = serde_json::to_value(&state).expect("serialize legacy state");
5341        let healed: TranscriptHistoryState =
5342            serde_json::from_value(value).expect("parse legacy state");
5343
5344        let content_parent =
5345            transcript_messages_digest(&parent_messages).expect("content parent digest");
5346        let content_revision =
5347            transcript_messages_digest(&revision_messages).expect("content revision digest");
5348        assert_eq!(healed.head, content_revision, "head must re-derive");
5349        assert_eq!(healed.commits[0].parent_revision, content_parent);
5350        assert_eq!(healed.commits[0].revision, content_revision);
5351        assert_eq!(healed.revisions[0].revision, content_parent);
5352        assert_eq!(healed.revisions[1].revision, content_revision);
5353        assert_eq!(
5354            healed.revisions[1].parent_revision.as_deref(),
5355            Some(content_parent.as_str())
5356        );
5357        validate_transcript_history_state(&healed).expect("healed graph must validate");
5358
5359        // A session materialized from the healed graph can extend the chain
5360        // with a current-format rewrite.
5361        let mut session = Session::new();
5362        session
5363            .apply_transcript_history_state(healed)
5364            .expect("apply healed graph");
5365        session
5366            .commit_transcript_rewrite(
5367                TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
5368                vec![Message::User(UserMessage::text(
5369                    "rewritten again".to_string(),
5370                ))],
5371                TranscriptRewriteReason::new("unit-test"),
5372                None,
5373                None,
5374            )
5375            .expect("extend healed graph with a new rewrite");
5376        session
5377            .validate_transcript_history_state()
5378            .expect("extended graph must validate");
5379    }
5380
5381    #[test]
5382    fn legacy_transcript_rewrite_record_heals_on_parse() {
5383        let (commit, parent_messages, revision_messages) = legacy_rewrite_fixture();
5384        let record_value = serde_json::json!({
5385            "commit": commit,
5386            "parent_body": TranscriptRevisionBody {
5387                revision: commit.parent_revision.clone(),
5388                parent_revision: None,
5389                messages: parent_messages,
5390                created_at: SystemTime::now(),
5391            },
5392            "revision_body": TranscriptRevisionBody {
5393                revision: commit.revision.clone(),
5394                parent_revision: Some(commit.parent_revision),
5395                messages: revision_messages.clone(),
5396                created_at: SystemTime::now(),
5397            },
5398        });
5399        let healed: TranscriptRewriteRecord =
5400            serde_json::from_value(record_value).expect("parse legacy record");
5401        assert_eq!(
5402            healed.commit.revision,
5403            transcript_messages_digest(&revision_messages).expect("content digest")
5404        );
5405        // The healed record passes the same validation `new` enforces.
5406        TranscriptRewriteRecord::new(healed.commit, healed.parent_body, healed.revision_body)
5407            .expect("healed record must validate");
5408    }
5409
5410    #[test]
5411    fn corrupt_transcript_history_strings_stay_untouched_and_fail_validation() {
5412        let (commit, parent_messages, _revision_messages) = legacy_rewrite_fixture();
5413        let bogus = "sha256:0000000000000000000000000000000000000000000000000000000000000000";
5414        let state = TranscriptHistoryState {
5415            head: bogus.to_string(),
5416            commits: Vec::new(),
5417            revisions: vec![TranscriptRevisionBody {
5418                revision: bogus.to_string(),
5419                parent_revision: None,
5420                messages: parent_messages,
5421                created_at: SystemTime::now(),
5422            }],
5423        };
5424        let _ = commit;
5425        let value = serde_json::to_value(&state).expect("serialize corrupt state");
5426        let parsed: TranscriptHistoryState =
5427            serde_json::from_value(value).expect("corrupt strings still parse");
5428        assert_eq!(
5429            parsed.head, bogus,
5430            "unverifiable strings must not be rewritten"
5431        );
5432        assert!(
5433            validate_transcript_history_state(&parsed).is_err(),
5434            "corrupt graph must keep failing validation"
5435        );
5436    }
5437
5438    /// K4 invariant: synthetic-notice refresh is ONE atomic transcript edit —
5439    /// after a refresh, at most the replacement notices of that kind exist
5440    /// (no stale notice survives beside a fresh one).
5441    #[test]
5442    fn replace_synthetic_notices_leaves_only_replacements_of_kind() {
5443        use crate::types::{SystemNoticeKind, SystemNoticeMessage};
5444
5445        let mut session = Session::new();
5446        session.push(Message::User(UserMessage::text("hello".to_string())));
5447        session.push(Message::SystemNotice(SystemNoticeMessage::new(
5448            SystemNoticeKind::McpPending,
5449            "stale one",
5450        )));
5451        session.push(Message::SystemNotice(SystemNoticeMessage::new(
5452            SystemNoticeKind::McpPending,
5453            "stale two",
5454        )));
5455        // A notice of another kind must be untouched.
5456        session.push(Message::SystemNotice(SystemNoticeMessage::new(
5457            SystemNoticeKind::BackgroundJob,
5458            "other-kind",
5459        )));
5460
5461        session
5462            .replace_synthetic_notices(
5463                SystemNoticeKind::McpPending,
5464                vec![Message::SystemNotice(SystemNoticeMessage::new(
5465                    SystemNoticeKind::McpPending,
5466                    "fresh",
5467                ))],
5468            )
5469            .expect("notice refresh succeeds");
5470
5471        let mcp_pending: Vec<&SystemNoticeMessage> = session
5472            .messages()
5473            .iter()
5474            .filter_map(|message| match message {
5475                Message::SystemNotice(notice) if notice.kind == SystemNoticeKind::McpPending => {
5476                    Some(notice)
5477                }
5478                _ => None,
5479            })
5480            .collect();
5481        assert_eq!(mcp_pending.len(), 1, "exactly one notice of the kind");
5482        assert_eq!(mcp_pending[0].body.as_deref(), Some("fresh"));
5483        assert!(
5484            session.messages().iter().any(|message| matches!(
5485                message,
5486                Message::SystemNotice(notice) if notice.kind == SystemNoticeKind::BackgroundJob
5487            )),
5488            "other-kind notices are untouched"
5489        );
5490
5491        // Empty replacements = pure strip.
5492        session
5493            .replace_synthetic_notices(SystemNoticeKind::McpPending, Vec::new())
5494            .expect("pure strip succeeds");
5495        assert!(
5496            !session.messages().iter().any(|message| matches!(
5497                message,
5498                Message::SystemNotice(notice) if notice.kind == SystemNoticeKind::McpPending
5499            )),
5500            "empty replacement clears the kind"
5501        );
5502    }
5503
5504    /// K4 invariant (fail-closed): an invalid replacement is rejected with a
5505    /// typed fault BEFORE any strip happens — the transcript is unchanged, so
5506    /// a fault can never strand a half-refreshed notice state.
5507    #[test]
5508    fn replace_synthetic_notices_rejects_mismatched_kind_without_mutation() {
5509        use crate::types::{SystemNoticeKind, SystemNoticeMessage};
5510
5511        let mut session = Session::new();
5512        session.push(Message::SystemNotice(SystemNoticeMessage::new(
5513            SystemNoticeKind::McpPending,
5514            "stale",
5515        )));
5516        let before = session.messages().to_vec();
5517
5518        let err = session
5519            .replace_synthetic_notices(
5520                SystemNoticeKind::McpPending,
5521                vec![Message::User(UserMessage::text("not a notice".to_string()))],
5522            )
5523            .expect_err("mismatched replacement must fail typed");
5524        assert!(
5525            matches!(err, TranscriptEditError::InvalidTranscriptShape(_)),
5526            "expected InvalidTranscriptShape, got {err:?}"
5527        );
5528        assert_eq!(
5529            session.messages(),
5530            before.as_slice(),
5531            "fault must leave the transcript unchanged (no partial strip)"
5532        );
5533    }
5534
5535    #[test]
5536    fn transcript_rewrite_preserves_full_assistant_block_trace() {
5537        let mut session = Session::new();
5538        session.push(Message::User(UserMessage::text(
5539            "run the trace".to_string(),
5540        )));
5541        session.push(Message::BlockAssistant(BlockAssistantMessage::new(
5542            vec![AssistantBlock::Text {
5543                text: "original assistant trace".to_string(),
5544                meta: None,
5545            }],
5546            StopReason::EndTurn,
5547        )));
5548
5549        let parent_revision = session.transcript_revision().expect("parent revision");
5550        let replacement = vec![
5551            Message::BlockAssistant(BlockAssistantMessage::new(
5552                vec![
5553                    AssistantBlock::Text {
5554                        text: "compacted assistant trace".to_string(),
5555                        meta: None,
5556                    },
5557                    AssistantBlock::ToolUse {
5558                        id: "toolu_trace".to_string(),
5559                        name: "trace_probe".to_string(),
5560                        args: serde_json::value::RawValue::from_string(
5561                            r#"{"path":"N-3"}"#.to_string(),
5562                        )
5563                        .expect("valid tool args"),
5564                        meta: None,
5565                    },
5566                ],
5567                StopReason::ToolUse,
5568            )),
5569            Message::tool_results(vec![ToolResult::new(
5570                "toolu_trace".to_string(),
5571                "trace complete".to_string(),
5572                false,
5573            )]),
5574        ];
5575
5576        let commit = session
5577            .commit_transcript_rewrite(
5578                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
5579                replacement,
5580                TranscriptRewriteReason::new("compaction"),
5581                Some("unit-test".to_string()),
5582                Some(parent_revision.clone()),
5583            )
5584            .expect("rewrite should commit");
5585
5586        assert_eq!(commit.parent_revision, parent_revision);
5587        let current = session
5588            .transcript_revision_messages(&commit.revision)
5589            .expect("history state should decode")
5590            .expect("current revision should be retained");
5591        let Message::BlockAssistant(assistant) = &current[1] else {
5592            panic!("replacement should remain a block assistant message");
5593        };
5594        assert!(assistant.blocks.iter().any(|block| matches!(
5595            block,
5596            AssistantBlock::ToolUse { name, args, .. }
5597                if name == "trace_probe" && args.get().contains("\"N-3\"")
5598        )));
5599
5600        let parent = session
5601            .transcript_revision_messages(&parent_revision)
5602            .expect("history state should decode")
5603            .expect("parent revision should remain retained");
5604        assert!(matches!(
5605            &parent[1],
5606            Message::BlockAssistant(assistant)
5607                if block_assistant_text(assistant).contains("original assistant trace")
5608        ));
5609    }
5610
5611    #[test]
5612    fn transcript_rewrite_rejects_trailing_block_assistant_tool_call() {
5613        let mut session = Session::new();
5614        session.push(Message::User(UserMessage::text("question".to_string())));
5615        session.push(Message::BlockAssistant(BlockAssistantMessage {
5616            blocks: vec![AssistantBlock::Text {
5617                text: "plain answer".to_string(),
5618                meta: None,
5619            }],
5620            stop_reason: StopReason::EndTurn,
5621            identity: crate::types::TranscriptMessageIdentity::default(),
5622            created_at: crate::types::message_timestamp_now(),
5623        }));
5624        let parent_revision = session.transcript_revision().expect("parent revision");
5625
5626        let err = session
5627            .commit_transcript_rewrite(
5628                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
5629                vec![Message::BlockAssistant(BlockAssistantMessage::new(
5630                    vec![AssistantBlock::ToolUse {
5631                        id: "toolu_1".to_string(),
5632                        name: "lookup".to_string(),
5633                        args: serde_json::value::RawValue::from_string("{}".to_string())
5634                            .expect("valid args"),
5635                        meta: None,
5636                    }],
5637                    StopReason::ToolUse,
5638                ))],
5639                TranscriptRewriteReason::new("compaction"),
5640                Some("unit-test".to_string()),
5641                Some(parent_revision),
5642            )
5643            .expect_err("rewrite should reject trailing unresolved block-assistant tool call");
5644        assert!(matches!(
5645            err,
5646            TranscriptEditError::InvalidTranscriptShape(_)
5647        ));
5648    }
5649
5650    #[test]
5651    fn transcript_rewrite_rejects_no_op_self_edge() {
5652        let mut session = Session::new();
5653        session.push(Message::User(UserMessage::text(
5654            "keep this exact transcript".to_string(),
5655        )));
5656        session.push(Message::BlockAssistant(BlockAssistantMessage {
5657            blocks: vec![AssistantBlock::Text {
5658                text: "unchanged".to_string(),
5659                meta: None,
5660            }],
5661            stop_reason: StopReason::EndTurn,
5662            identity: crate::types::TranscriptMessageIdentity::default(),
5663            created_at: crate::types::message_timestamp_now(),
5664        }));
5665
5666        let parent_revision = session.transcript_revision().expect("parent revision");
5667        let err = session
5668            .commit_transcript_rewrite(
5669                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
5670                vec![session.messages()[1].clone()],
5671                TranscriptRewriteReason::new("retry"),
5672                Some("unit-test".to_string()),
5673                Some(parent_revision.clone()),
5674            )
5675            .expect_err("same-content rewrite should not emit a self-edge commit");
5676
5677        assert!(matches!(
5678            err,
5679            TranscriptEditError::NoOpRewrite { revision } if revision == parent_revision
5680        ));
5681        assert!(
5682            session
5683                .transcript_history_state()
5684                .expect("history state should decode")
5685                .is_none()
5686        );
5687    }
5688
5689    #[test]
5690    fn transcript_rewrite_run_boundary_guard_accepts_rewrite_then_append() {
5691        let mut original = Session::new();
5692        original.push(Message::User(UserMessage::text("question".to_string())));
5693        original.push(Message::BlockAssistant(BlockAssistantMessage {
5694            blocks: vec![AssistantBlock::Text {
5695                text: "verbose answer".to_string(),
5696                meta: None,
5697            }],
5698            stop_reason: StopReason::EndTurn,
5699            identity: crate::types::TranscriptMessageIdentity::default(),
5700            created_at: crate::types::message_timestamp_now(),
5701        }));
5702
5703        let parent_revision = original.transcript_revision().expect("parent revision");
5704        let mut incoming = original.clone();
5705        incoming
5706            .commit_transcript_rewrite(
5707                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
5708                vec![Message::BlockAssistant(BlockAssistantMessage {
5709                    blocks: vec![AssistantBlock::Text {
5710                        text: "compact answer".to_string(),
5711                        meta: None,
5712                    }],
5713                    stop_reason: StopReason::EndTurn,
5714                    identity: crate::types::TranscriptMessageIdentity::default(),
5715                    created_at: crate::types::message_timestamp_now(),
5716                })],
5717                TranscriptRewriteReason::new("compaction"),
5718                Some("unit-test".to_string()),
5719                Some(parent_revision),
5720            )
5721            .expect("rewrite should commit");
5722        incoming.push(Message::User(UserMessage::text("follow-up".to_string())));
5723        incoming.push(Message::BlockAssistant(BlockAssistantMessage {
5724            blocks: vec![AssistantBlock::Text {
5725                text: "follow-up answer".to_string(),
5726                meta: None,
5727            }],
5728            stop_reason: StopReason::EndTurn,
5729            identity: crate::types::TranscriptMessageIdentity::default(),
5730            created_at: crate::types::message_timestamp_now(),
5731        }));
5732
5733        crate::session_store::run_boundary_snapshot_save_guard(&incoming, Some(&original))
5734            .expect("rewrite plus appended turn should be a valid run-boundary commit");
5735    }
5736
5737    #[test]
5738    fn transcript_rewrite_rejects_orphaned_tool_results() {
5739        let mut session = Session::new();
5740        session.push(Message::User(UserMessage::text("use a tool".to_string())));
5741        session.push(Message::BlockAssistant(BlockAssistantMessage::new(
5742            vec![AssistantBlock::ToolUse {
5743                id: "toolu_1".to_string(),
5744                name: "lookup".to_string(),
5745                args: serde_json::value::RawValue::from_string("{}".to_string())
5746                    .expect("valid args"),
5747                meta: None,
5748            }],
5749            StopReason::ToolUse,
5750        )));
5751        session.push(Message::tool_results(vec![ToolResult::new(
5752            "toolu_1".to_string(),
5753            "done".to_string(),
5754            false,
5755        )]));
5756        let parent_revision = session.transcript_revision().expect("parent revision");
5757
5758        let err = session
5759            .commit_transcript_rewrite(
5760                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
5761                vec![Message::BlockAssistant(BlockAssistantMessage {
5762                    blocks: vec![AssistantBlock::Text {
5763                        text: "no tool after all".to_string(),
5764                        meta: None,
5765                    }],
5766                    stop_reason: StopReason::EndTurn,
5767                    identity: crate::types::TranscriptMessageIdentity::default(),
5768                    created_at: crate::types::message_timestamp_now(),
5769                })],
5770                TranscriptRewriteReason::new("compaction"),
5771                Some("unit-test".to_string()),
5772                Some(parent_revision),
5773            )
5774            .expect_err("rewrite should reject stranded tool results");
5775        assert!(matches!(
5776            err,
5777            TranscriptEditError::InvalidTranscriptShape(_)
5778        ));
5779    }
5780
5781    #[test]
5782    fn transcript_rewrite_rejects_trailing_assistant_tool_call() {
5783        let mut session = Session::new();
5784        session.push(Message::User(UserMessage::text("question".to_string())));
5785        session.push(Message::BlockAssistant(BlockAssistantMessage {
5786            blocks: vec![AssistantBlock::Text {
5787                text: "plain answer".to_string(),
5788                meta: None,
5789            }],
5790            stop_reason: StopReason::EndTurn,
5791            identity: crate::types::TranscriptMessageIdentity::default(),
5792            created_at: crate::types::message_timestamp_now(),
5793        }));
5794        let parent_revision = session.transcript_revision().expect("parent revision");
5795
5796        let err = session
5797            .commit_transcript_rewrite(
5798                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
5799                vec![Message::BlockAssistant(BlockAssistantMessage {
5800                    blocks: vec![AssistantBlock::ToolUse {
5801                        id: "toolu_1".to_string(),
5802                        name: "lookup".to_string(),
5803                        args: serde_json::value::RawValue::from_string("{}".to_string())
5804                            .expect("valid args"),
5805                        meta: None,
5806                    }],
5807                    stop_reason: StopReason::ToolUse,
5808                    identity: crate::types::TranscriptMessageIdentity::default(),
5809                    created_at: crate::types::message_timestamp_now(),
5810                })],
5811                TranscriptRewriteReason::new("compaction"),
5812                Some("unit-test".to_string()),
5813                Some(parent_revision),
5814            )
5815            .expect_err("rewrite should reject trailing unresolved tool call");
5816        assert!(matches!(
5817            err,
5818            TranscriptEditError::InvalidTranscriptShape(_)
5819        ));
5820    }
5821
5822    #[test]
5823    fn transcript_rewrite_rejects_duplicate_tool_results() {
5824        let mut session = Session::new();
5825        session.push(Message::User(UserMessage::text("use a tool".to_string())));
5826        session.push(Message::BlockAssistant(BlockAssistantMessage {
5827            blocks: vec![AssistantBlock::Text {
5828                text: "plain answer".to_string(),
5829                meta: None,
5830            }],
5831            stop_reason: StopReason::EndTurn,
5832            identity: crate::types::TranscriptMessageIdentity::default(),
5833            created_at: crate::types::message_timestamp_now(),
5834        }));
5835        let parent_revision = session.transcript_revision().expect("parent revision");
5836
5837        let err = session
5838            .commit_transcript_rewrite(
5839                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
5840                vec![
5841                    Message::BlockAssistant(BlockAssistantMessage::new(
5842                        vec![AssistantBlock::ToolUse {
5843                            id: "toolu_1".to_string(),
5844                            name: "lookup".to_string(),
5845                            args: serde_json::value::RawValue::from_string("{}".to_string())
5846                                .expect("valid args"),
5847                            meta: None,
5848                        }],
5849                        StopReason::ToolUse,
5850                    )),
5851                    Message::tool_results(vec![
5852                        ToolResult::new("toolu_1".to_string(), "one".to_string(), false),
5853                        ToolResult::new("toolu_1".to_string(), "two".to_string(), false),
5854                    ]),
5855                ],
5856                TranscriptRewriteReason::new("compaction"),
5857                Some("unit-test".to_string()),
5858                Some(parent_revision),
5859            )
5860            .expect_err("rewrite should reject duplicate tool results");
5861        assert!(matches!(
5862            err,
5863            TranscriptEditError::InvalidTranscriptShape(_)
5864        ));
5865    }
5866
5867    #[test]
5868    fn transcript_rewrite_record_rejects_prefix_or_suffix_tampering() {
5869        let mut session = Session::new();
5870        session.push(Message::System(SystemMessage::new("keep prefix")));
5871        session.push(Message::BlockAssistant(BlockAssistantMessage {
5872            blocks: vec![AssistantBlock::Text {
5873                text: "verbose answer".to_string(),
5874                meta: None,
5875            }],
5876            stop_reason: StopReason::EndTurn,
5877            identity: crate::types::TranscriptMessageIdentity::default(),
5878            created_at: crate::types::message_timestamp_now(),
5879        }));
5880        session.push(Message::User(UserMessage::text("keep suffix".to_string())));
5881
5882        let parent_revision = session.transcript_revision().expect("parent revision");
5883        let commit = session
5884            .commit_transcript_rewrite(
5885                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
5886                vec![Message::BlockAssistant(BlockAssistantMessage {
5887                    blocks: vec![AssistantBlock::Text {
5888                        text: "compact answer".to_string(),
5889                        meta: None,
5890                    }],
5891                    stop_reason: StopReason::EndTurn,
5892                    identity: crate::types::TranscriptMessageIdentity::default(),
5893                    created_at: crate::types::message_timestamp_now(),
5894                })],
5895                TranscriptRewriteReason::new("compaction"),
5896                Some("unit-test".to_string()),
5897                Some(parent_revision),
5898            )
5899            .expect("rewrite should commit");
5900        let state = session
5901            .transcript_history_state()
5902            .expect("history state should decode")
5903            .expect("history state should exist");
5904        let parent_body = state
5905            .revisions
5906            .iter()
5907            .find(|body| body.revision == commit.parent_revision)
5908            .expect("parent body retained")
5909            .clone();
5910        let revision_body = state
5911            .revisions
5912            .iter()
5913            .find(|body| body.revision == commit.revision)
5914            .expect("revision body retained")
5915            .clone();
5916
5917        let mut forged_body = revision_body;
5918        forged_body.messages[0] = Message::System(SystemMessage::new("tampered prefix"));
5919        forged_body.revision =
5920            transcript_messages_digest(&forged_body.messages).expect("forged digest");
5921        let mut forged_commit = commit;
5922        forged_commit.revision = forged_body.revision.clone();
5923        let err = TranscriptRewriteRecord::new(forged_commit, parent_body, forged_body)
5924            .expect_err("record validation must reject changes outside selected span");
5925        assert!(
5926            err.to_string().contains("before the selected span"),
5927            "unexpected error: {err}"
5928        );
5929    }
5930
5931    #[test]
5932    fn transcript_rewrite_replay_allows_normal_turn_revisions_between_rewrites() {
5933        let mut session = Session::new();
5934        session.push(Message::User(UserMessage::text("first".to_string())));
5935        session.push(Message::BlockAssistant(BlockAssistantMessage {
5936            blocks: vec![AssistantBlock::Text {
5937                text: "verbose first answer".to_string(),
5938                meta: None,
5939            }],
5940            stop_reason: StopReason::EndTurn,
5941            identity: crate::types::TranscriptMessageIdentity::default(),
5942            created_at: crate::types::message_timestamp_now(),
5943        }));
5944
5945        let first_parent = session.transcript_revision().expect("first parent");
5946        let first_commit = session
5947            .commit_transcript_rewrite(
5948                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
5949                vec![Message::BlockAssistant(BlockAssistantMessage {
5950                    blocks: vec![AssistantBlock::Text {
5951                        text: "compact first answer".to_string(),
5952                        meta: None,
5953                    }],
5954                    stop_reason: StopReason::EndTurn,
5955                    identity: crate::types::TranscriptMessageIdentity::default(),
5956                    created_at: crate::types::message_timestamp_now(),
5957                })],
5958                TranscriptRewriteReason::new("compaction"),
5959                Some("unit-test".to_string()),
5960                Some(first_parent),
5961            )
5962            .expect("first rewrite");
5963
5964        session.push(Message::User(UserMessage::text("normal turn".to_string())));
5965        session.push(Message::BlockAssistant(BlockAssistantMessage {
5966            blocks: vec![AssistantBlock::Text {
5967                text: "verbose second answer".to_string(),
5968                meta: None,
5969            }],
5970            stop_reason: StopReason::EndTurn,
5971            identity: crate::types::TranscriptMessageIdentity::default(),
5972            created_at: crate::types::message_timestamp_now(),
5973        }));
5974        let bridge_parent = session
5975            .transcript_revision()
5976            .expect("normal turn should advance transcript head");
5977        assert_ne!(bridge_parent, first_commit.revision);
5978        validate_transcript_history_state(
5979            &session
5980                .transcript_history_state()
5981                .expect("history state should decode")
5982                .expect("history state should exist"),
5983        )
5984        .expect("normal turn head may legitimately differ from last rewrite commit");
5985
5986        let second_commit = session
5987            .commit_transcript_rewrite(
5988                TranscriptRewriteSelection::MessageRange { start: 3, end: 4 },
5989                vec![Message::BlockAssistant(BlockAssistantMessage {
5990                    blocks: vec![AssistantBlock::Text {
5991                        text: "compact second answer".to_string(),
5992                        meta: None,
5993                    }],
5994                    stop_reason: StopReason::EndTurn,
5995                    identity: crate::types::TranscriptMessageIdentity::default(),
5996                    created_at: crate::types::message_timestamp_now(),
5997                })],
5998                TranscriptRewriteReason::new("compaction"),
5999                Some("unit-test".to_string()),
6000                Some(bridge_parent.clone()),
6001            )
6002            .expect("second rewrite");
6003
6004        let state = session
6005            .transcript_history_state()
6006            .expect("history state should decode")
6007            .expect("history state should exist");
6008        let records = state.commits.iter().map(|commit| {
6009            let parent_body = state
6010                .revisions
6011                .iter()
6012                .find(|body| body.revision == commit.parent_revision)
6013                .expect("parent body retained")
6014                .clone();
6015            let revision_body = state
6016                .revisions
6017                .iter()
6018                .find(|body| body.revision == commit.revision)
6019                .expect("revision body retained")
6020                .clone();
6021            TranscriptRewriteRecord::new(commit.clone(), parent_body, revision_body)
6022                .expect("record should validate")
6023        });
6024
6025        let replayed = TranscriptHistoryState::from_rewrite_records(records)
6026            .expect("rewrite replay should accept normal-turn bridge revisions")
6027            .expect("rewrite records should exist");
6028        assert_eq!(replayed.head, second_commit.revision);
6029        assert!(
6030            replayed
6031                .revisions
6032                .iter()
6033                .any(|body| body.revision == bridge_parent)
6034        );
6035    }
6036
6037    #[test]
6038    fn transcript_rewrite_replay_rejects_branched_rewrite_records() {
6039        let mut base = Session::new();
6040        base.push(Message::User(UserMessage::text("question".to_string())));
6041        base.push(Message::BlockAssistant(BlockAssistantMessage {
6042            blocks: vec![AssistantBlock::Text {
6043                text: "verbose answer".to_string(),
6044                meta: None,
6045            }],
6046            stop_reason: StopReason::EndTurn,
6047            identity: crate::types::TranscriptMessageIdentity::default(),
6048            created_at: crate::types::message_timestamp_now(),
6049        }));
6050        let parent = base.transcript_revision().expect("parent revision");
6051
6052        let mut first = base.clone();
6053        let first_commit = first
6054            .commit_transcript_rewrite(
6055                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
6056                vec![Message::BlockAssistant(BlockAssistantMessage {
6057                    blocks: vec![AssistantBlock::Text {
6058                        text: "first compact answer".to_string(),
6059                        meta: None,
6060                    }],
6061                    stop_reason: StopReason::EndTurn,
6062                    identity: crate::types::TranscriptMessageIdentity::default(),
6063                    created_at: crate::types::message_timestamp_now(),
6064                })],
6065                TranscriptRewriteReason::new("compaction"),
6066                Some("unit-test".to_string()),
6067                Some(parent.clone()),
6068            )
6069            .expect("first rewrite");
6070        let first_state = first
6071            .transcript_history_state()
6072            .expect("first state decodes")
6073            .expect("first state exists");
6074
6075        let mut second = base;
6076        let second_commit = second
6077            .commit_transcript_rewrite(
6078                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
6079                vec![Message::BlockAssistant(BlockAssistantMessage {
6080                    blocks: vec![AssistantBlock::Text {
6081                        text: "second compact answer".to_string(),
6082                        meta: None,
6083                    }],
6084                    stop_reason: StopReason::EndTurn,
6085                    identity: crate::types::TranscriptMessageIdentity::default(),
6086                    created_at: crate::types::message_timestamp_now(),
6087                })],
6088                TranscriptRewriteReason::new("compaction"),
6089                Some("unit-test".to_string()),
6090                Some(parent),
6091            )
6092            .expect("second rewrite");
6093        let second_state = second
6094            .transcript_history_state()
6095            .expect("second state decodes")
6096            .expect("second state exists");
6097
6098        let record = |state: &TranscriptHistoryState, commit: &TranscriptRewriteCommit| {
6099            let parent_body = state
6100                .revisions
6101                .iter()
6102                .find(|body| body.revision == commit.parent_revision)
6103                .expect("parent body retained")
6104                .clone();
6105            let revision_body = state
6106                .revisions
6107                .iter()
6108                .find(|body| body.revision == commit.revision)
6109                .expect("revision body retained")
6110                .clone();
6111            TranscriptRewriteRecord::new(commit.clone(), parent_body, revision_body)
6112                .expect("record should validate")
6113        };
6114
6115        let err = TranscriptHistoryState::from_rewrite_records(vec![
6116            record(&first_state, &first_commit),
6117            record(&second_state, &second_commit),
6118        ])
6119        .expect_err("branched rewrite records must not replay as a linear source history");
6120        assert!(
6121            err.to_string().contains("does not extend transcript head"),
6122            "unexpected error: {err}"
6123        );
6124    }
6125
6126    #[test]
6127    fn internal_message_rewrites_refresh_transcript_history_head() {
6128        let mut session = Session::new();
6129        session.push(Message::User(UserMessage::text("question".to_string())));
6130        session.push(Message::BlockAssistant(BlockAssistantMessage {
6131            blocks: vec![AssistantBlock::Text {
6132                text: "verbose answer".to_string(),
6133                meta: None,
6134            }],
6135            stop_reason: StopReason::EndTurn,
6136            identity: crate::types::TranscriptMessageIdentity::default(),
6137            created_at: crate::types::message_timestamp_now(),
6138        }));
6139
6140        let parent = session.transcript_revision().expect("parent revision");
6141        session
6142            .commit_transcript_rewrite(
6143                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
6144                vec![Message::BlockAssistant(BlockAssistantMessage {
6145                    blocks: vec![AssistantBlock::Text {
6146                        text: "compact answer".to_string(),
6147                        meta: None,
6148                    }],
6149                    stop_reason: StopReason::EndTurn,
6150                    identity: crate::types::TranscriptMessageIdentity::default(),
6151                    created_at: crate::types::message_timestamp_now(),
6152                })],
6153                TranscriptRewriteReason::new("compaction"),
6154                Some("unit-test".to_string()),
6155                Some(parent),
6156            )
6157            .expect("rewrite should commit");
6158
6159        session.push(Message::User(UserMessage::text(
6160            "notice-bearing turn".to_string(),
6161        )));
6162        session
6163            .retain_messages_internal(
6164                |message| {
6165                    !matches!(
6166                        message,
6167                        Message::User(user)
6168                            if user.content.iter().any(|block| matches!(
6169                                block,
6170                                ContentBlock::Text { text } if text.contains("notice-bearing")
6171                            ))
6172                    )
6173                },
6174                TranscriptRewriteReason::new("synthetic_notice_cleanup"),
6175            )
6176            .expect("retain should commit internal rewrite");
6177        let retained_digest =
6178            transcript_messages_digest(session.messages()).expect("retained digest");
6179        assert_eq!(
6180            session.transcript_revision().expect("retained head"),
6181            retained_digest
6182        );
6183
6184        session
6185            .replace_messages_internal(
6186                vec![
6187                    Message::User(UserMessage::text("compacted question".to_string())),
6188                    Message::BlockAssistant(BlockAssistantMessage {
6189                        blocks: vec![AssistantBlock::Text {
6190                            text: "compacted answer".to_string(),
6191                            meta: None,
6192                        }],
6193                        stop_reason: StopReason::EndTurn,
6194                        identity: crate::types::TranscriptMessageIdentity::default(),
6195                        created_at: crate::types::message_timestamp_now(),
6196                    }),
6197                ],
6198                TranscriptRewriteReason::new("compaction"),
6199            )
6200            .expect("replace should commit internal rewrite");
6201        let replaced_digest =
6202            transcript_messages_digest(session.messages()).expect("replaced digest");
6203        assert_eq!(
6204            session.transcript_revision().expect("replaced head"),
6205            replaced_digest
6206        );
6207        let state = session
6208            .transcript_history_state()
6209            .expect("history state should decode")
6210            .expect("history state should exist");
6211        assert!(
6212            state
6213                .revisions
6214                .iter()
6215                .any(|body| body.revision == replaced_digest)
6216        );
6217        validate_transcript_history_state(&state).expect("history state remains valid");
6218    }
6219
6220    #[test]
6221    fn set_system_prompt_refreshes_transcript_history_head_after_rewrite() {
6222        let mut session = Session::new();
6223        session.push(Message::User(UserMessage::text("question".to_string())));
6224        session.push(Message::BlockAssistant(BlockAssistantMessage {
6225            blocks: vec![AssistantBlock::Text {
6226                text: "verbose answer".to_string(),
6227                meta: None,
6228            }],
6229            stop_reason: StopReason::EndTurn,
6230            identity: crate::types::TranscriptMessageIdentity::default(),
6231            created_at: crate::types::message_timestamp_now(),
6232        }));
6233
6234        let parent = session.transcript_revision().expect("parent revision");
6235        let rewrite = session
6236            .commit_transcript_rewrite(
6237                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
6238                vec![Message::BlockAssistant(BlockAssistantMessage {
6239                    blocks: vec![AssistantBlock::Text {
6240                        text: "compact answer".to_string(),
6241                        meta: None,
6242                    }],
6243                    stop_reason: StopReason::EndTurn,
6244                    identity: crate::types::TranscriptMessageIdentity::default(),
6245                    created_at: crate::types::message_timestamp_now(),
6246                })],
6247                TranscriptRewriteReason::new("compaction"),
6248                Some("unit-test".to_string()),
6249                Some(parent),
6250            )
6251            .expect("rewrite should commit");
6252
6253        session.set_system_prompt("durable system prompt".to_string());
6254
6255        let head = session
6256            .transcript_revision()
6257            .expect("system prompt should refresh transcript head");
6258        assert_ne!(head, rewrite.revision);
6259        assert_eq!(
6260            head,
6261            transcript_messages_digest(session.messages()).expect("current digest")
6262        );
6263        let head_messages = session
6264            .transcript_revision_messages(&head)
6265            .expect("history state should decode")
6266            .expect("refreshed head body should be retained");
6267        assert_eq!(
6268            serde_json::to_value(&head_messages).expect("head serializes"),
6269            serde_json::to_value(session.messages()).expect("session serializes")
6270        );
6271        validate_transcript_history_state(
6272            &session
6273                .transcript_history_state()
6274                .expect("history state should decode")
6275                .expect("history state should exist"),
6276        )
6277        .expect("history state remains valid after system prompt update");
6278    }
6279
6280    #[test]
6281    fn apply_transcript_history_state_uses_latest_commit_time_for_restored_head() {
6282        let mut session = Session::new();
6283        session.push(Message::User(UserMessage::text("question".to_string())));
6284        session.push(Message::BlockAssistant(BlockAssistantMessage {
6285            blocks: vec![AssistantBlock::Text {
6286                text: "verbose answer".to_string(),
6287                meta: None,
6288            }],
6289            stop_reason: StopReason::EndTurn,
6290            identity: crate::types::TranscriptMessageIdentity::default(),
6291            created_at: crate::types::message_timestamp_now(),
6292        }));
6293        let original_messages = session.messages().to_vec();
6294        let parent = session.transcript_revision().expect("parent revision");
6295        let compact = session
6296            .commit_transcript_rewrite(
6297                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
6298                vec![Message::BlockAssistant(BlockAssistantMessage {
6299                    blocks: vec![AssistantBlock::Text {
6300                        text: "compact answer".to_string(),
6301                        meta: None,
6302                    }],
6303                    stop_reason: StopReason::EndTurn,
6304                    identity: crate::types::TranscriptMessageIdentity::default(),
6305                    created_at: crate::types::message_timestamp_now(),
6306                })],
6307                TranscriptRewriteReason::new("compaction"),
6308                Some("unit-test".to_string()),
6309                Some(parent.clone()),
6310            )
6311            .expect("rewrite should commit");
6312
6313        std::thread::sleep(std::time::Duration::from_millis(2));
6314        let restore = session
6315            .commit_transcript_rewrite(
6316                TranscriptRewriteSelection::MessageRange {
6317                    start: 0,
6318                    end: session.messages().len(),
6319                },
6320                original_messages.clone(),
6321                TranscriptRewriteReason::new("restore"),
6322                Some("unit-test".to_string()),
6323                Some(compact.revision),
6324            )
6325            .expect("restore should commit");
6326        assert_eq!(restore.revision, parent);
6327
6328        let state = session
6329            .transcript_history_state()
6330            .expect("history state should decode")
6331            .expect("history state should exist");
6332        let restored_body_created_at = state
6333            .revisions
6334            .iter()
6335            .find(|body| body.revision == restore.revision)
6336            .expect("restored body should be retained")
6337            .created_at;
6338        assert!(
6339            restored_body_created_at < restore.committed_at,
6340            "test requires restore commit to be newer than retained body"
6341        );
6342
6343        let mut replayed = Session::new();
6344        replayed
6345            .apply_transcript_history_state(state)
6346            .expect("replay should materialize restored head");
6347        assert_eq!(
6348            serde_json::to_value(replayed.messages()).expect("replayed serializes"),
6349            serde_json::to_value(&original_messages).expect("original serializes")
6350        );
6351        assert_eq!(replayed.updated_at(), restore.committed_at);
6352    }
6353
6354    #[test]
6355    fn test_session_new() {
6356        let session = Session::new();
6357        assert_eq!(session.version(), SESSION_VERSION);
6358        assert!(session.messages().is_empty());
6359        assert!(session.created_at() <= session.updated_at());
6360    }
6361
6362    #[test]
6363    fn llm_identity_model_override_switches_to_catalog_provider() {
6364        let registry = crate::ModelRegistry::from_config(
6365            &crate::Config::default(),
6366            *crate::model_profile::test_catalog::TEST_CATALOG,
6367        )
6368        .unwrap();
6369        let current = SessionLlmIdentity {
6370            model: "test-anthropic-default".to_string(),
6371            provider: Provider::Anthropic,
6372            self_hosted_server_id: None,
6373            provider_params: None,
6374            auth_binding: Some(crate::AuthBindingRef {
6375                realm: crate::RealmId::parse("tenant_a").unwrap(),
6376                binding: crate::BindingId::parse("anthropic_default").unwrap(),
6377                profile: None,
6378                origin: crate::BindingOrigin::Configured,
6379            }),
6380        };
6381
6382        let resolved = resolve_session_llm_identity_override(
6383            &current,
6384            &registry,
6385            SessionLlmIdentityOverride {
6386                model: Some("test-openai-default"),
6387                provider: None,
6388                provider_params: None,
6389                auth_binding: None,
6390            },
6391        )
6392        .unwrap();
6393
6394        assert_eq!(resolved.model, "test-openai-default");
6395        assert_eq!(resolved.provider, Provider::OpenAI);
6396        assert!(
6397            resolved.auth_binding.is_none(),
6398            "provider switches must not inherit a binding from the previous provider"
6399        );
6400    }
6401
6402    #[test]
6403    fn llm_identity_model_override_keeps_uncatalogued_model_on_current_provider() {
6404        let registry = crate::ModelRegistry::from_config(
6405            &crate::Config::default(),
6406            *crate::model_profile::test_catalog::TEST_CATALOG,
6407        )
6408        .unwrap();
6409        let current = SessionLlmIdentity {
6410            model: "custom-model".to_string(),
6411            provider: Provider::Anthropic,
6412            self_hosted_server_id: None,
6413            provider_params: None,
6414            auth_binding: None,
6415        };
6416
6417        let resolved = resolve_session_llm_identity_override(
6418            &current,
6419            &registry,
6420            SessionLlmIdentityOverride {
6421                model: Some("uncatalogued-custom-model"),
6422                provider: None,
6423                provider_params: None,
6424                auth_binding: None,
6425            },
6426        )
6427        .unwrap();
6428
6429        assert_eq!(resolved.model, "uncatalogued-custom-model");
6430        assert_eq!(resolved.provider, Provider::Anthropic);
6431    }
6432
6433    #[test]
6434    fn realtime_transcript_append_is_idempotent_by_provider_item_and_delta_id() {
6435        let mut session = Session::new();
6436
6437        let user = RealtimeTranscriptEvent::UserTranscriptFinal {
6438            item_id: "item_user".to_string(),
6439            previous_item_id: None,
6440            content_index: 0,
6441            text: "hello".to_string(),
6442        };
6443        assert!(
6444            !session
6445                .append_realtime_transcript_event(user.clone())
6446                .is_inert()
6447        );
6448        assert!(session.append_realtime_transcript_event(user).is_inert());
6449
6450        let delta = RealtimeTranscriptEvent::AssistantTextDelta {
6451            response_id: "resp_assistant".to_string(),
6452            delta_id: "evt_delta_1".to_string(),
6453            item_id: "item_assistant".to_string(),
6454            previous_item_id: Some("item_user".to_string()),
6455            content_index: 0,
6456            delta: "hi".to_string(),
6457        };
6458        assert!(
6459            session
6460                .append_realtime_transcript_event(delta.clone())
6461                .is_inert()
6462        );
6463        assert!(session.append_realtime_transcript_event(delta).is_inert());
6464
6465        let terminal = RealtimeTranscriptEvent::AssistantTurnCompleted {
6466            response_id: "resp_assistant".to_string(),
6467            stop_reason: StopReason::EndTurn,
6468            usage: Usage::default(),
6469        };
6470        assert!(
6471            !session
6472                .append_realtime_transcript_event(terminal.clone())
6473                .is_inert()
6474        );
6475        assert!(
6476            session
6477                .append_realtime_transcript_event(terminal)
6478                .is_inert()
6479        );
6480
6481        assert_eq!(session.messages().len(), 2);
6482        assert!(matches!(
6483            &session.messages()[0],
6484            Message::User(user) if user.text_content() == "hello"
6485        ));
6486        assert!(matches!(
6487            &session.messages()[1],
6488            Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "hi"
6489        ));
6490    }
6491
6492    #[test]
6493    fn realtime_user_image_materializes_once_and_unblocks_causal_assistant() {
6494        let mut session = Session::new();
6495        let image_data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB".to_string();
6496        let image = RealtimeTranscriptEvent::UserContentFinal {
6497            idempotency_key: "image-request-1".to_string(),
6498            item_id: "item_image".to_string(),
6499            previous_item_id: None,
6500            content_index: 0,
6501            content: vec![ContentBlock::Image {
6502                media_type: "image/png".to_string(),
6503                data: crate::types::ImageData::Inline {
6504                    data: image_data.clone(),
6505                },
6506            }],
6507        };
6508
6509        assert!(
6510            !append_staged_user_image(&mut session, &image).is_inert(),
6511            "first image final must materialize canonical user content"
6512        );
6513        let replay = session
6514            .preflight_realtime_user_content_event(&image)
6515            .expect("exact retry should preflight as committed");
6516        assert!(matches!(
6517            replay,
6518            crate::RealtimeUserContentApplyOutcome::AlreadyCommitted(_)
6519        ));
6520
6521        let staged_state = session
6522            .metadata
6523            .get(SESSION_REALTIME_TRANSCRIPT_STATE_KEY)
6524            .expect("realtime state must be persisted");
6525        assert!(
6526            !staged_state.to_string().contains(&image_data),
6527            "materialized image bytes must not remain duplicated in transcript metadata"
6528        );
6529
6530        assert!(
6531            session
6532                .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
6533                    response_id: "resp_image".to_string(),
6534                    delta_id: "delta_image".to_string(),
6535                    item_id: "item_assistant".to_string(),
6536                    previous_item_id: Some("item_image".to_string()),
6537                    content_index: 0,
6538                    delta: "I see red.".to_string(),
6539                })
6540                .is_inert()
6541        );
6542        assert!(
6543            !session
6544                .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
6545                    response_id: "resp_image".to_string(),
6546                    stop_reason: StopReason::EndTurn,
6547                    usage: Usage::default(),
6548                },)
6549                .is_inert(),
6550            "materialized image predecessor must unblock the assistant response"
6551        );
6552
6553        assert_eq!(session.messages().len(), 2);
6554        assert!(matches!(
6555            &session.messages()[0],
6556            Message::User(user)
6557                if matches!(
6558                    user.content.as_slice(),
6559                    [ContentBlock::Image {
6560                        media_type,
6561                        data: crate::types::ImageData::Blob { blob_id },
6562                    }] if media_type == "image/png"
6563                        && blob_id == &crate::blob::content_blob_id("image/png", &image_data)
6564                )
6565        ));
6566        assert!(matches!(
6567            &session.messages()[1],
6568            Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "I see red."
6569        ));
6570    }
6571
6572    #[test]
6573    fn realtime_user_image_identity_is_durable_canonical_and_conflict_safe() {
6574        let mut session = Session::new();
6575        let data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB".to_string();
6576        let initial = RealtimeTranscriptEvent::UserContentFinal {
6577            idempotency_key: "stable-image-key".to_string(),
6578            item_id: "canonical-image-item".to_string(),
6579            previous_item_id: None,
6580            content_index: 0,
6581            content: vec![ContentBlock::Image {
6582                media_type: " image/PNG; charset=binary ".to_string(),
6583                data: crate::types::ImageData::Inline { data: data.clone() },
6584            }],
6585        };
6586        let committed = append_staged_user_image(&mut session, &initial);
6587        let Some(crate::RealtimeUserContentApplyOutcome::Committed(identity)) =
6588            committed.user_content
6589        else {
6590            panic!("first image must commit its durable identity");
6591        };
6592        assert_eq!(identity.item_id, "canonical-image-item");
6593        assert_eq!(identity.media_type, "image/png");
6594
6595        let encoded = serde_json::to_string(&session).expect("session should serialize");
6596        let restored: Session =
6597            serde_json::from_str(&encoded).expect("committed identity should restore");
6598
6599        let replay_event = RealtimeTranscriptEvent::UserContentFinal {
6600            idempotency_key: "stable-image-key".to_string(),
6601            item_id: "ignored-retry-item".to_string(),
6602            previous_item_id: None,
6603            content_index: 0,
6604            content: vec![ContentBlock::Image {
6605                media_type: "image/png".to_string(),
6606                data: crate::types::ImageData::Inline { data: data.clone() },
6607            }],
6608        };
6609        let replay = restored
6610            .preflight_realtime_user_content_event(&replay_event)
6611            .expect("exact retry should preflight");
6612        assert!(matches!(
6613            replay,
6614            crate::RealtimeUserContentApplyOutcome::AlreadyCommitted(
6615                crate::RealtimeUserContentIdentity { ref item_id, .. }
6616            ) if item_id == "canonical-image-item"
6617        ));
6618
6619        let conflict = restored
6620            .preflight_realtime_user_content_event(&RealtimeTranscriptEvent::UserContentFinal {
6621                idempotency_key: "stable-image-key".to_string(),
6622                item_id: "conflicting-item".to_string(),
6623                previous_item_id: None,
6624                content_index: 0,
6625                content: vec![ContentBlock::Image {
6626                    media_type: "image/png".to_string(),
6627                    data: crate::types::ImageData::Inline {
6628                        data: "different-payload".to_string(),
6629                    },
6630                }],
6631            })
6632            .expect("conflicting retry should preflight");
6633        assert!(matches!(
6634            conflict,
6635            crate::RealtimeUserContentApplyOutcome::RejectedConflict { .. }
6636        ));
6637
6638        let item_collision = restored
6639            .preflight_realtime_user_content_event(&RealtimeTranscriptEvent::UserContentFinal {
6640                idempotency_key: "another-key".to_string(),
6641                item_id: "canonical-image-item".to_string(),
6642                previous_item_id: None,
6643                content_index: 0,
6644                content: vec![ContentBlock::Image {
6645                    media_type: "image/png".to_string(),
6646                    data: crate::types::ImageData::Inline { data },
6647                }],
6648            })
6649            .expect("item collision should preflight");
6650        assert!(matches!(
6651            item_collision,
6652            crate::RealtimeUserContentApplyOutcome::RejectedConflict { .. }
6653        ));
6654        assert_eq!(restored.messages().len(), 1);
6655        serde_json::to_string(&restored).expect("rejections must not corrupt durable state");
6656    }
6657
6658    #[test]
6659    fn realtime_user_image_reducer_never_receipts_without_pending_blob_proof() {
6660        for data in [
6661            crate::types::ImageData::Inline {
6662                data: "iVBORw0KGgo=".to_string(),
6663            },
6664            crate::types::ImageData::Blob {
6665                blob_id: crate::blob::content_blob_id("image/png", "iVBORw0KGgo="),
6666            },
6667        ] {
6668            let mut session = Session::new();
6669            let outcome = session.append_realtime_transcript_event(
6670                RealtimeTranscriptEvent::UserContentFinal {
6671                    idempotency_key: "unstaged-image-key".to_string(),
6672                    item_id: "unstaged-image-item".to_string(),
6673                    previous_item_id: None,
6674                    content_index: 0,
6675                    content: vec![ContentBlock::Image {
6676                        media_type: "image/png".to_string(),
6677                        data,
6678                    }],
6679                },
6680            );
6681            assert!(matches!(
6682                outcome.user_content,
6683                Some(crate::RealtimeUserContentApplyOutcome::RejectedInvalidIdentity { .. })
6684            ));
6685            assert!(session.messages().is_empty());
6686            assert!(session.realtime_user_content_identities().is_empty());
6687        }
6688    }
6689
6690    #[test]
6691    fn realtime_user_image_pending_slot_is_generated_bounded_and_recovery_typed() {
6692        use crate::generated::session_document::{
6693            RealtimeUserContentBlobRecoveryDisposition, RealtimeUserContentBlobStageDisposition,
6694        };
6695        let mut session = Session::new();
6696        let pending = crate::PendingRealtimeUserContentBlob {
6697            idempotency_key: "pending-key-a".to_string(),
6698            item_id: "pending-item-a".to_string(),
6699            previous_item_id: None,
6700            content_index: 0,
6701            blob_id: crate::blob::content_blob_id("image/png", "iVBORw0KGgo="),
6702            media_type: "image/png".to_string(),
6703        };
6704        let different = crate::PendingRealtimeUserContentBlob {
6705            idempotency_key: "pending-key-b".to_string(),
6706            item_id: "pending-item-b".to_string(),
6707            previous_item_id: None,
6708            content_index: 0,
6709            blob_id: crate::blob::content_blob_id("image/png", "iVBORw0KGgoB"),
6710            media_type: "image/png".to_string(),
6711        };
6712        assert_eq!(
6713            session
6714                .stage_pending_realtime_user_content_blob(pending.clone())
6715                .expect("empty slot stages"),
6716            RealtimeUserContentBlobStageDisposition::StageNew
6717        );
6718        assert_eq!(
6719            session
6720                .stage_pending_realtime_user_content_blob(pending.clone())
6721                .expect("exact stage retry is idempotent"),
6722            RealtimeUserContentBlobStageDisposition::ReuseExact
6723        );
6724        assert_eq!(
6725            session
6726                .stage_pending_realtime_user_content_blob(different.clone())
6727                .expect("occupied decision is typed"),
6728            RealtimeUserContentBlobStageDisposition::RejectOccupied
6729        );
6730        assert_eq!(
6731            session.pending_realtime_user_content_blob(),
6732            Some(pending.clone())
6733        );
6734        assert_eq!(
6735            session
6736                .resolve_pending_realtime_user_content_blob_recovery(Some(&pending), false)
6737                .expect("exact recovery decision"),
6738            RealtimeUserContentBlobRecoveryDisposition::RetryExact
6739        );
6740        assert_eq!(
6741            session
6742                .resolve_pending_realtime_user_content_blob_recovery(Some(&different), true)
6743                .expect("verified older recovery decision"),
6744            RealtimeUserContentBlobRecoveryDisposition::CommitVerifiedBeforeCurrent
6745        );
6746        assert_eq!(
6747            session
6748                .resolve_pending_realtime_user_content_blob_recovery(Some(&different), false)
6749                .expect("invalid older recovery decision"),
6750            RealtimeUserContentBlobRecoveryDisposition::ClearInvalidBeforeCurrent
6751        );
6752        session
6753            .clear_invalid_pending_realtime_user_content_blob(Some(&different))
6754            .expect("generated clear-invalid disposition authorizes clear");
6755        assert!(session.pending_realtime_user_content_blob().is_none());
6756    }
6757
6758    #[test]
6759    fn transcript_rewrite_tombstones_removed_image_key_and_accepts_new_key() {
6760        let mut session = Session::new();
6761        let data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB".to_string();
6762        let original = RealtimeTranscriptEvent::UserContentFinal {
6763            idempotency_key: "removed-image-key".to_string(),
6764            item_id: "removed-image-item".to_string(),
6765            previous_item_id: None,
6766            content_index: 0,
6767            content: vec![ContentBlock::Image {
6768                media_type: "image/png".to_string(),
6769                data: crate::types::ImageData::Inline { data: data.clone() },
6770            }],
6771        };
6772        assert!(matches!(
6773            append_staged_user_image(&mut session, &original).user_content,
6774            Some(crate::RealtimeUserContentApplyOutcome::Committed(_))
6775        ));
6776
6777        let parent = session.transcript_revision().expect("parent revision");
6778        session
6779            .commit_transcript_rewrite(
6780                TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
6781                vec![Message::User(UserMessage::text("image removed"))],
6782                TranscriptRewriteReason::new("remove-image"),
6783                None,
6784                Some(parent),
6785            )
6786            .expect("rewrite should tombstone removed image identity");
6787
6788        assert!(session.realtime_user_content_identities().is_empty());
6789        assert_eq!(
6790            session.realtime_user_content_tombstones(),
6791            vec![crate::RealtimeUserContentTombstone {
6792                idempotency_key: "removed-image-key".to_string(),
6793            }]
6794        );
6795        assert!(matches!(
6796            session.preflight_realtime_user_content_event(&original),
6797            Some(crate::RealtimeUserContentApplyOutcome::RejectedConflict { .. })
6798        ));
6799        assert!(matches!(
6800            session
6801                .append_realtime_transcript_event(original)
6802                .user_content,
6803            Some(crate::RealtimeUserContentApplyOutcome::RejectedConflict { .. })
6804        ));
6805        assert_eq!(
6806            session.messages().len(),
6807            1,
6808            "stale retry emits no receipt content"
6809        );
6810
6811        let new_image = RealtimeTranscriptEvent::UserContentFinal {
6812            idempotency_key: "new-image-key".to_string(),
6813            item_id: "new-image-item".to_string(),
6814            previous_item_id: None,
6815            content_index: 0,
6816            content: vec![ContentBlock::Image {
6817                media_type: "image/png".to_string(),
6818                data: crate::types::ImageData::Inline { data },
6819            }],
6820        };
6821        assert!(matches!(
6822            append_staged_user_image(&mut session, &new_image).user_content,
6823            Some(crate::RealtimeUserContentApplyOutcome::Committed(_))
6824        ));
6825        assert_eq!(session.messages().len(), 2);
6826
6827        let restored: Session = serde_json::from_str(
6828            &serde_json::to_string(&session).expect("serialize rewritten session"),
6829        )
6830        .expect("cold restore rewritten session");
6831        assert_eq!(restored.realtime_user_content_identities().len(), 1);
6832        assert_eq!(restored.realtime_user_content_tombstones().len(), 1);
6833    }
6834
6835    #[test]
6836    fn transcript_rewrite_retains_only_canonical_image_occurrence_for_exact_replay() {
6837        let mut session = Session::new();
6838        let data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB".to_string();
6839        let original = RealtimeTranscriptEvent::UserContentFinal {
6840            idempotency_key: "retained-image-key".to_string(),
6841            item_id: "retained-image-item".to_string(),
6842            previous_item_id: None,
6843            content_index: 0,
6844            content: vec![ContentBlock::Image {
6845                media_type: "image/png".to_string(),
6846                data: crate::types::ImageData::Inline { data },
6847            }],
6848        };
6849        assert!(matches!(
6850            append_staged_user_image(&mut session, &original).user_content,
6851            Some(crate::RealtimeUserContentApplyOutcome::Committed(_))
6852        ));
6853        let retained_message = session.messages()[0].clone();
6854        let parent = session.transcript_revision().expect("parent revision");
6855        session
6856            .commit_transcript_rewrite(
6857                TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
6858                vec![
6859                    retained_message,
6860                    Message::User(UserMessage::text("new canonical neighbor")),
6861                ],
6862                TranscriptRewriteReason::new("retain-image"),
6863                None,
6864                Some(parent),
6865            )
6866            .expect("rewrite retaining exact inline image should reconcile");
6867
6868        assert!(session.realtime_user_content_tombstones().is_empty());
6869        let replay = session
6870            .preflight_realtime_user_content_event(&original)
6871            .expect("retained image should preflight as exact replay");
6872        assert!(matches!(
6873            replay,
6874            crate::RealtimeUserContentApplyOutcome::AlreadyCommitted(_)
6875        ));
6876        assert_eq!(session.messages().len(), 2);
6877    }
6878
6879    #[test]
6880    fn transcript_rewrite_rejects_atomically_while_image_blob_anchor_is_pending() {
6881        let mut session = Session::new();
6882        session.push(Message::User(UserMessage::text("before rewrite")));
6883        let pending = crate::PendingRealtimeUserContentBlob {
6884            idempotency_key: "pending-rewrite-key".to_string(),
6885            item_id: "pending-rewrite-item".to_string(),
6886            previous_item_id: None,
6887            content_index: 0,
6888            blob_id: crate::blob::content_blob_id("image/png", "pending-bytes"),
6889            media_type: "image/png".to_string(),
6890        };
6891        session
6892            .stage_pending_realtime_user_content_blob(pending.clone())
6893            .expect("stage durable pending anchor");
6894        let parent = session.transcript_revision().expect("parent revision");
6895        let error = session
6896            .commit_transcript_rewrite(
6897                TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
6898                vec![Message::User(UserMessage::text("after rewrite"))],
6899                TranscriptRewriteReason::new("blocked-pending-image"),
6900                None,
6901                Some(parent),
6902            )
6903            .expect_err("rewrite must not cross an unresolved image anchor");
6904        assert!(
6905            error
6906                .to_string()
6907                .contains("history_rewrite_pending_user_content_blob")
6908        );
6909        assert!(matches!(
6910            &session.messages()[0],
6911            Message::User(user) if user.text_content() == "before rewrite"
6912        ));
6913        assert_eq!(session.pending_realtime_user_content_blob(), Some(pending));
6914    }
6915
6916    #[test]
6917    fn realtime_user_image_rejects_noncanonical_blob_and_multiblock_shape() {
6918        let mut session = Session::new();
6919        for (key, content) in [
6920            (
6921                "invalid-blob",
6922                vec![ContentBlock::Image {
6923                    media_type: "image/png".to_string(),
6924                    data: crate::types::ImageData::Blob {
6925                        blob_id: crate::BlobId::new("sha256:not-a-digest"),
6926                    },
6927                }],
6928            ),
6929            (
6930                "multi-block",
6931                vec![
6932                    ContentBlock::Image {
6933                        media_type: "image/png".to_string(),
6934                        data: crate::types::ImageData::Inline {
6935                            data: "payload".to_string(),
6936                        },
6937                    },
6938                    ContentBlock::Text {
6939                        text: "smuggled".to_string(),
6940                    },
6941                ],
6942            ),
6943        ] {
6944            let outcome = session.append_realtime_transcript_event(
6945                RealtimeTranscriptEvent::UserContentFinal {
6946                    idempotency_key: key.to_string(),
6947                    item_id: format!("item-{key}"),
6948                    previous_item_id: None,
6949                    content_index: 0,
6950                    content,
6951                },
6952            );
6953            assert!(matches!(
6954                outcome.user_content,
6955                Some(crate::RealtimeUserContentApplyOutcome::RejectedInvalidIdentity { .. })
6956            ));
6957        }
6958        assert!(session.messages().is_empty());
6959        let encoded = serde_json::to_string(&session).expect("session should serialize");
6960        serde_json::from_str::<Session>(&encoded).expect("rejections must leave restorable state");
6961    }
6962
6963    #[test]
6964    fn realtime_restore_rejects_malformed_causal_graphs_and_accepts_waiting_dag() {
6965        fn restore(
6966            items: serde_json::Value,
6967            first_seen_order: Vec<&str>,
6968        ) -> Result<
6969            crate::realtime_transcript_revision::SessionRealtimeTranscriptState,
6970            crate::realtime_transcript_revision::RealtimeTranscriptShellError,
6971        > {
6972            let state = serde_json::from_value(serde_json::json!({
6973                "items": items,
6974                "first_seen_order": first_seen_order,
6975            }))
6976            .expect("test state shape should deserialize");
6977            crate::realtime_transcript_revision::restore_realtime_transcript_state(state)
6978        }
6979
6980        assert!(
6981            restore(
6982                serde_json::json!({
6983                    "child": { "role": "user", "previous_item_id": "missing" }
6984                }),
6985                vec!["child"],
6986            )
6987            .is_ok(),
6988            "an unmaterialized out-of-order item must survive cold restore until its predecessor arrives"
6989        );
6990        assert!(
6991            restore(
6992                serde_json::json!({
6993                    "child": {
6994                        "role": "user",
6995                        "previous_item_id": "missing",
6996                        "ready": true,
6997                        "materialized": true
6998                    }
6999                }),
7000                vec!["child"],
7001            )
7002            .is_err(),
7003            "a materialized item cannot reference a missing predecessor"
7004        );
7005        assert!(
7006            restore(
7007                serde_json::json!({
7008                    "self": { "role": "user", "previous_item_id": "self" }
7009                }),
7010                vec!["self"],
7011            )
7012            .is_err(),
7013            "self edge must fail cold restore"
7014        );
7015        assert!(
7016            restore(
7017                serde_json::json!({
7018                    "a": { "role": "user", "previous_item_id": "b" },
7019                    "b": { "role": "user", "previous_item_id": "a" }
7020                }),
7021                vec!["a", "b"],
7022            )
7023            .is_err(),
7024            "cycle must fail cold restore"
7025        );
7026        assert!(
7027            restore(
7028                serde_json::json!({
7029                    "root": { "role": "user" },
7030                    "materialized_child": {
7031                        "role": "user",
7032                        "previous_item_id": "root",
7033                        "ready": true,
7034                        "materialized": true
7035                    }
7036                }),
7037                vec!["root", "materialized_child"],
7038            )
7039            .is_err(),
7040            "materialized child cannot have unmaterialized ancestry"
7041        );
7042        assert!(
7043            restore(
7044                serde_json::json!({
7045                    "root": { "role": "user" },
7046                    "waiting_child": { "role": "user", "previous_item_id": "root" }
7047                }),
7048                vec!["waiting_child", "root"],
7049            )
7050            .is_ok(),
7051            "valid acyclic waiting graph should restore even when first-seen order is child-first"
7052        );
7053    }
7054
7055    #[test]
7056    fn realtime_restore_handles_long_waiting_chain_with_bounded_graph_walk() {
7057        const ITEM_COUNT: usize = 4_096;
7058        let mut items = serde_json::Map::new();
7059        let mut order = Vec::with_capacity(ITEM_COUNT);
7060        for index in 0..ITEM_COUNT {
7061            let item_id = format!("item-{index:04}");
7062            let value = if index == 0 {
7063                serde_json::json!({ "role": "user" })
7064            } else {
7065                serde_json::json!({
7066                    "role": "user",
7067                    "previous_item_id": format!("item-{:04}", index - 1),
7068                })
7069            };
7070            order.push(item_id.clone());
7071            items.insert(item_id, value);
7072        }
7073        let state = serde_json::from_value(serde_json::json!({
7074            "items": items,
7075            "first_seen_order": order,
7076        }))
7077        .expect("long-chain fixture should deserialize");
7078        crate::realtime_transcript_revision::restore_realtime_transcript_state(state)
7079            .expect("long valid waiting DAG should restore in one bounded graph walk");
7080    }
7081
7082    /// R5-7: `AssistantTranscriptFinalText` injects authoritative final text
7083    /// into the staged item. Verifies the override semantics: a partial
7084    /// delta is replaced, not concatenated, and the item promotes to the
7085    /// Spoken lane so flush emits `AssistantBlock::Transcript`.
7086    #[test]
7087    fn realtime_transcript_final_text_overrides_partial_delta_and_promotes_to_spoken_lane() {
7088        let mut session = Session::new();
7089
7090        // Partial delta accumulates "incom" — simulating delta loss before
7091        // the final arrives.
7092        assert!(
7093            session
7094                .append_realtime_transcript_event(
7095                    RealtimeTranscriptEvent::AssistantTranscriptDelta {
7096                        response_id: "resp_a".to_string(),
7097                        delta_id: "evt_1".to_string(),
7098                        item_id: "item_a".to_string(),
7099                        previous_item_id: None,
7100                        content_index: 0,
7101                        delta: "incom".to_string(),
7102                    }
7103                )
7104                .is_inert()
7105        );
7106
7107        // Authoritative final text overrides the staged content.
7108        assert!(
7109            session
7110                .append_realtime_transcript_event(
7111                    RealtimeTranscriptEvent::AssistantTranscriptFinalText {
7112                        response_id: "resp_a".to_string(),
7113                        item_id: "item_a".to_string(),
7114                        content_index: 0,
7115                        text: "complete answer".to_string(),
7116                    }
7117                )
7118                .is_inert()
7119        );
7120
7121        // Turn completion drives the flush.
7122        let outcome = session.append_realtime_transcript_event(
7123            RealtimeTranscriptEvent::AssistantTurnCompleted {
7124                response_id: "resp_a".to_string(),
7125                stop_reason: StopReason::EndTurn,
7126                usage: Usage::default(),
7127            },
7128        );
7129        assert!(!outcome.is_inert());
7130
7131        // Verify the materialized block has the final's authoritative text
7132        // (not the partial "incom") and the Spoken lane.
7133        assert_eq!(session.messages().len(), 1);
7134        match &session.messages()[0] {
7135            Message::BlockAssistant(assistant) => {
7136                let mut found_transcript = false;
7137                for block in &assistant.blocks {
7138                    if let AssistantBlock::Transcript { text, .. } = block {
7139                        assert_eq!(text, "complete answer");
7140                        found_transcript = true;
7141                    }
7142                }
7143                assert!(
7144                    found_transcript,
7145                    "AssistantTranscriptFinalText must promote to the Spoken lane and \
7146                     materialize as AssistantBlock::Transcript"
7147                );
7148            }
7149            other => unreachable!("expected BlockAssistant, got {other:?}"),
7150        }
7151    }
7152
7153    /// R5-7: `AssistantTranscriptFinalText` works for final-only providers
7154    /// where no prior delta has staged an item.
7155    #[test]
7156    fn realtime_transcript_final_text_creates_item_when_no_delta_staged() {
7157        let mut session = Session::new();
7158
7159        assert!(
7160            session
7161                .append_realtime_transcript_event(
7162                    RealtimeTranscriptEvent::AssistantTranscriptFinalText {
7163                        response_id: "resp_a".to_string(),
7164                        item_id: "item_a".to_string(),
7165                        content_index: 0,
7166                        text: "spoken-final-only".to_string(),
7167                    }
7168                )
7169                .is_inert()
7170        );
7171
7172        let outcome = session.append_realtime_transcript_event(
7173            RealtimeTranscriptEvent::AssistantTurnCompleted {
7174                response_id: "resp_a".to_string(),
7175                stop_reason: StopReason::EndTurn,
7176                usage: Usage::default(),
7177            },
7178        );
7179        assert!(!outcome.is_inert());
7180
7181        assert_eq!(session.messages().len(), 1);
7182        match &session.messages()[0] {
7183            Message::BlockAssistant(assistant) => {
7184                let has_transcript = assistant.blocks.iter().any(|b| {
7185                    matches!(b, AssistantBlock::Transcript { text, .. } if text == "spoken-final-only")
7186                });
7187                assert!(
7188                    has_transcript,
7189                    "final-only provider path must materialize as Transcript on the Spoken lane"
7190                );
7191            }
7192            other => unreachable!("expected BlockAssistant, got {other:?}"),
7193        }
7194    }
7195
7196    #[test]
7197    fn realtime_transcript_append_orders_causally_equivalent_out_of_order_items() {
7198        let mut session = Session::new();
7199
7200        assert!(
7201            session
7202                .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
7203                    response_id: "resp_assistant".to_string(),
7204                    delta_id: "evt_delta_1".to_string(),
7205                    item_id: "item_assistant".to_string(),
7206                    previous_item_id: Some("item_user".to_string()),
7207                    content_index: 0,
7208                    delta: "answer".to_string(),
7209                })
7210                .is_inert()
7211        );
7212        assert!(
7213            session
7214                .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
7215                    response_id: "resp_assistant".to_string(),
7216                    stop_reason: StopReason::EndTurn,
7217                    usage: Usage::default(),
7218                })
7219                .is_inert()
7220        );
7221
7222        let outcome = session.append_realtime_transcript_event(
7223            RealtimeTranscriptEvent::UserTranscriptFinal {
7224                item_id: "item_user".to_string(),
7225                previous_item_id: None,
7226                content_index: 0,
7227                text: "question".to_string(),
7228            },
7229        );
7230
7231        assert_eq!(outcome.materialized_messages.len(), 2);
7232        assert_eq!(session.messages().len(), 2);
7233        assert!(matches!(
7234            &session.messages()[0],
7235            Message::User(user) if user.text_content() == "question"
7236        ));
7237        assert!(matches!(
7238            &session.messages()[1],
7239            Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "answer"
7240        ));
7241    }
7242
7243    #[test]
7244    fn realtime_transcript_replay_of_seen_provider_items_is_inert() {
7245        let mut session = Session::new();
7246        let events = vec![
7247            RealtimeTranscriptEvent::UserTranscriptFinal {
7248                item_id: "item_user".to_string(),
7249                previous_item_id: None,
7250                content_index: 0,
7251                text: "hello".to_string(),
7252            },
7253            RealtimeTranscriptEvent::AssistantTextDelta {
7254                response_id: "resp_assistant".to_string(),
7255                delta_id: "evt_delta_1".to_string(),
7256                item_id: "item_assistant".to_string(),
7257                previous_item_id: Some("item_user".to_string()),
7258                content_index: 0,
7259                delta: "world".to_string(),
7260            },
7261            RealtimeTranscriptEvent::AssistantTurnCompleted {
7262                response_id: "resp_assistant".to_string(),
7263                stop_reason: StopReason::EndTurn,
7264                usage: Usage::default(),
7265            },
7266        ];
7267
7268        for event in events.iter().cloned() {
7269            let _ = session.append_realtime_transcript_event(event);
7270        }
7271        let first_messages = serde_json::to_value(session.messages()).unwrap();
7272
7273        for event in events {
7274            assert!(session.append_realtime_transcript_event(event).is_inert());
7275        }
7276
7277        assert_eq!(
7278            serde_json::to_value(session.messages()).unwrap(),
7279            first_messages
7280        );
7281    }
7282
7283    #[test]
7284    fn realtime_transcript_user_final_replay_cannot_erase_existing_segment() {
7285        let mut session = Session::new();
7286
7287        let user = RealtimeTranscriptEvent::UserTranscriptFinal {
7288            item_id: "item_user".to_string(),
7289            previous_item_id: None,
7290            content_index: 0,
7291            text: "remember amber lantern".to_string(),
7292        };
7293        assert!(
7294            !session
7295                .append_realtime_transcript_event(user.clone())
7296                .is_inert()
7297        );
7298        let first_messages = serde_json::to_value(session.messages()).unwrap();
7299
7300        assert!(
7301            session
7302                .append_realtime_transcript_event(RealtimeTranscriptEvent::UserTranscriptFinal {
7303                    item_id: "item_user".to_string(),
7304                    previous_item_id: None,
7305                    content_index: 0,
7306                    text: String::new(),
7307                })
7308                .is_inert()
7309        );
7310        assert!(session.append_realtime_transcript_event(user).is_inert());
7311        assert_eq!(
7312            serde_json::to_value(session.messages()).unwrap(),
7313            first_messages
7314        );
7315    }
7316
7317    #[test]
7318    fn realtime_transcript_empty_user_final_can_be_filled_by_later_nonempty_replay() {
7319        let mut session = Session::new();
7320
7321        assert!(
7322            session
7323                .append_realtime_transcript_event(RealtimeTranscriptEvent::UserTranscriptFinal {
7324                    item_id: "item_user".to_string(),
7325                    previous_item_id: None,
7326                    content_index: 0,
7327                    text: String::new(),
7328                })
7329                .is_inert()
7330        );
7331        assert!(session.messages().is_empty());
7332
7333        let outcome = session.append_realtime_transcript_event(
7334            RealtimeTranscriptEvent::UserTranscriptFinal {
7335                item_id: "item_user".to_string(),
7336                previous_item_id: None,
7337                content_index: 0,
7338                text: "remember amber lantern".to_string(),
7339            },
7340        );
7341        assert_eq!(outcome.materialized_messages.len(), 1);
7342        assert_eq!(session.messages().len(), 1);
7343        assert!(matches!(
7344            &session.messages()[0],
7345            Message::User(user) if user.text_content() == "remember amber lantern"
7346        ));
7347    }
7348
7349    #[test]
7350    fn realtime_transcript_skipped_provider_items_preserve_causal_order_without_content() {
7351        let mut session = Session::new();
7352
7353        let assistant_delta = RealtimeTranscriptEvent::AssistantTextDelta {
7354            response_id: "resp_assistant".to_string(),
7355            delta_id: "evt_delta_1".to_string(),
7356            item_id: "item_assistant".to_string(),
7357            previous_item_id: Some("item_tool".to_string()),
7358            content_index: 0,
7359            delta: "done".to_string(),
7360        };
7361        assert!(
7362            session
7363                .append_realtime_transcript_event(assistant_delta.clone())
7364                .is_inert()
7365        );
7366        let assistant_complete = RealtimeTranscriptEvent::AssistantTurnCompleted {
7367            response_id: "resp_assistant".to_string(),
7368            stop_reason: StopReason::EndTurn,
7369            usage: Usage::default(),
7370        };
7371        assert!(
7372            session
7373                .append_realtime_transcript_event(assistant_complete.clone())
7374                .is_inert()
7375        );
7376
7377        let skipped = RealtimeTranscriptEvent::ItemSkipped {
7378            item_id: "item_tool".to_string(),
7379            previous_item_id: Some("item_user".to_string()),
7380        };
7381        assert!(
7382            session
7383                .append_realtime_transcript_event(skipped.clone())
7384                .is_inert(),
7385            "a skipped provider item must not append transcript content"
7386        );
7387        assert!(session.messages().is_empty());
7388
7389        let outcome = session.append_realtime_transcript_event(
7390            RealtimeTranscriptEvent::UserTranscriptFinal {
7391                item_id: "item_user".to_string(),
7392                previous_item_id: None,
7393                content_index: 0,
7394                text: "please use the tool".to_string(),
7395            },
7396        );
7397        assert_eq!(outcome.materialized_messages.len(), 2);
7398        assert_eq!(session.messages().len(), 2);
7399        assert!(matches!(
7400            &session.messages()[0],
7401            Message::User(user) if user.text_content() == "please use the tool"
7402        ));
7403        assert!(matches!(
7404            &session.messages()[1],
7405            Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "done"
7406        ));
7407
7408        let first_messages = serde_json::to_value(session.messages()).unwrap();
7409        assert!(session.append_realtime_transcript_event(skipped).is_inert());
7410        assert!(
7411            session
7412                .append_realtime_transcript_event(assistant_delta)
7413                .is_inert()
7414        );
7415        assert!(
7416            session
7417                .append_realtime_transcript_event(assistant_complete)
7418                .is_inert()
7419        );
7420        assert_eq!(
7421            serde_json::to_value(session.messages()).unwrap(),
7422            first_messages
7423        );
7424    }
7425
7426    #[test]
7427    fn realtime_transcript_interrupted_assistant_item_unblocks_later_provider_items() {
7428        // R5-5 (Round-5): the staged assistant content is a Display-lane item
7429        // (`AssistantTextDelta`). Under the new lane-aware barge-in contract,
7430        // the Display lane survives interruption and materializes. The User
7431        // "Stop." item, gated on the chained Display item being materialized,
7432        // also unblocks. Round-4's "must stay non-canonical" assertion was
7433        // wrong — that contract was lane-blind.
7434        let mut session = Session::new();
7435
7436        let _ = session.append_realtime_transcript_event(
7437            RealtimeTranscriptEvent::UserTranscriptFinal {
7438                item_id: "item_repeat".to_string(),
7439                previous_item_id: None,
7440                content_index: 0,
7441                text: "repeat until stop".to_string(),
7442            },
7443        );
7444        assert!(
7445            session
7446                .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
7447                    response_id: "resp_loop".to_string(),
7448                    delta_id: "evt_loop_1".to_string(),
7449                    item_id: "item_loop".to_string(),
7450                    previous_item_id: Some("item_repeat".to_string()),
7451                    content_index: 0,
7452                    delta: "Looping now".to_string(),
7453                })
7454                .is_inert()
7455        );
7456        assert!(
7457            session
7458                .append_realtime_transcript_event(RealtimeTranscriptEvent::UserTranscriptFinal {
7459                    item_id: "item_stop".to_string(),
7460                    previous_item_id: Some("item_loop".to_string()),
7461                    content_index: 0,
7462                    text: "Stop.".to_string(),
7463                })
7464                .is_inert(),
7465            "the stop turn waits until the interrupted assistant provider item is resolved"
7466        );
7467
7468        let outcome = session.append_realtime_transcript_event(
7469            RealtimeTranscriptEvent::AssistantTurnInterrupted {
7470                response_id: "resp_loop".to_string(),
7471            },
7472        );
7473
7474        // R5-5: materializer commits 2 messages (the retained Display item +
7475        // the unblocked "Stop." User message).
7476        assert_eq!(outcome.materialized_messages.len(), 2);
7477        // Canonical history: User-repeat, BlockAssistant(Display "Looping now"), User-Stop.
7478        assert_eq!(session.messages().len(), 3);
7479        assert!(matches!(
7480            &session.messages()[0],
7481            Message::User(user) if user.text_content() == "repeat until stop"
7482        ));
7483        match &session.messages()[1] {
7484            Message::BlockAssistant(assistant) => {
7485                let text = block_assistant_text(assistant);
7486                assert_eq!(text, "Looping now");
7487            }
7488            other => unreachable!(
7489                "Display lane assistant item must be retained on Interrupted, got {other:?}"
7490            ),
7491        }
7492        assert!(matches!(
7493            &session.messages()[2],
7494            Message::User(user) if user.text_content() == "Stop."
7495        ));
7496    }
7497
7498    #[test]
7499    fn realtime_transcript_late_interrupted_assistant_delta_stays_noncanonical() {
7500        let mut session = Session::new();
7501
7502        let _ = session.append_realtime_transcript_event(
7503            RealtimeTranscriptEvent::UserTranscriptFinal {
7504                item_id: "item_repeat".to_string(),
7505                previous_item_id: None,
7506                content_index: 0,
7507                text: "repeat until stop".to_string(),
7508            },
7509        );
7510        assert!(
7511            session
7512                .append_realtime_transcript_event(RealtimeTranscriptEvent::ItemObserved {
7513                    item_id: "item_loop".to_string(),
7514                    previous_item_id: Some("item_repeat".to_string()),
7515                    role: RealtimeTranscriptRole::Assistant,
7516                    response_id: None,
7517                })
7518                .is_inert(),
7519            "provider can observe an assistant item before the adapter learns its response id"
7520        );
7521        assert!(
7522            session
7523                .append_realtime_transcript_event(
7524                    RealtimeTranscriptEvent::AssistantTurnInterrupted {
7525                        response_id: "resp_loop".to_string(),
7526                    }
7527                )
7528                .is_inert(),
7529            "an interruption can arrive before delayed transcript deltas for the response"
7530        );
7531        assert!(
7532            session
7533                .append_realtime_transcript_event(RealtimeTranscriptEvent::UserTranscriptFinal {
7534                    item_id: "item_stop".to_string(),
7535                    previous_item_id: Some("item_loop".to_string()),
7536                    content_index: 0,
7537                    text: "Stop.".to_string(),
7538                })
7539                .is_inert(),
7540            "the stop turn waits for the provider's interrupted assistant item anchor"
7541        );
7542
7543        let late_delta_outcome =
7544            session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
7545                response_id: "resp_loop".to_string(),
7546                delta_id: "evt_loop_late".to_string(),
7547                item_id: "item_loop".to_string(),
7548                previous_item_id: Some("item_repeat".to_string()),
7549                content_index: 0,
7550                delta: "Looping now".to_string(),
7551            });
7552        assert_eq!(late_delta_outcome.materialized_messages.len(), 1);
7553        assert!(matches!(
7554            &session.messages()[1],
7555            Message::User(user) if user.text_content() == "Stop."
7556        ));
7557        assert!(
7558            session
7559                .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
7560                    response_id: "resp_loop".to_string(),
7561                    stop_reason: StopReason::EndTurn,
7562                    usage: Usage::default(),
7563                })
7564                .is_inert(),
7565            "late completion for an interrupted response must not resurrect its deltas"
7566        );
7567        assert!(
7568            session
7569                .messages()
7570                .iter()
7571                .filter_map(|message| match message {
7572                    Message::BlockAssistant(assistant) => Some(block_assistant_text(assistant)),
7573                    _ => None,
7574                })
7575                .all(|text| !text.contains("Looping now")),
7576            "late interrupted assistant text must remain non-canonical"
7577        );
7578    }
7579
7580    #[test]
7581    fn realtime_transcript_completion_only_finalizes_matching_response() {
7582        let mut session = Session::new();
7583
7584        let _ = session.append_realtime_transcript_event(
7585            RealtimeTranscriptEvent::UserTranscriptFinal {
7586                item_id: "item_user".to_string(),
7587                previous_item_id: None,
7588                content_index: 0,
7589                text: "question".to_string(),
7590            },
7591        );
7592        assert!(
7593            session
7594                .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
7595                    response_id: "resp_a".to_string(),
7596                    delta_id: "evt_a".to_string(),
7597                    item_id: "item_a".to_string(),
7598                    previous_item_id: Some("item_user".to_string()),
7599                    content_index: 0,
7600                    delta: "answer a".to_string(),
7601                })
7602                .is_inert()
7603        );
7604
7605        assert!(
7606            session
7607                .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
7608                    response_id: "resp_b".to_string(),
7609                    stop_reason: StopReason::EndTurn,
7610                    usage: Usage::default(),
7611                })
7612                .is_inert(),
7613            "a completion for another response must not finalize buffered assistant text"
7614        );
7615        assert_eq!(session.messages().len(), 1);
7616
7617        let outcome = session.append_realtime_transcript_event(
7618            RealtimeTranscriptEvent::AssistantTurnCompleted {
7619                response_id: "resp_a".to_string(),
7620                stop_reason: StopReason::EndTurn,
7621                usage: Usage::default(),
7622            },
7623        );
7624        assert_eq!(outcome.materialized_messages.len(), 1);
7625        assert_eq!(session.messages().len(), 2);
7626        assert!(matches!(
7627            &session.messages()[1],
7628            Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "answer a"
7629        ));
7630    }
7631
7632    #[test]
7633    fn realtime_transcript_completion_before_later_delta_is_response_scoped() {
7634        let mut session = Session::new();
7635
7636        let _ = session.append_realtime_transcript_event(
7637            RealtimeTranscriptEvent::UserTranscriptFinal {
7638                item_id: "item_user".to_string(),
7639                previous_item_id: None,
7640                content_index: 0,
7641                text: "question".to_string(),
7642            },
7643        );
7644        assert!(
7645            session
7646                .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
7647                    response_id: "resp_a".to_string(),
7648                    stop_reason: StopReason::EndTurn,
7649                    usage: Usage::default(),
7650                })
7651                .is_inert()
7652        );
7653        assert!(
7654            session
7655                .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
7656                    response_id: "resp_b".to_string(),
7657                    delta_id: "evt_b".to_string(),
7658                    item_id: "item_b".to_string(),
7659                    previous_item_id: Some("item_user".to_string()),
7660                    content_index: 0,
7661                    delta: "wrong response".to_string(),
7662                })
7663                .is_inert(),
7664            "a later delta for another response must not be finalized by resp_a's pending completion"
7665        );
7666
7667        let outcome =
7668            session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
7669                response_id: "resp_a".to_string(),
7670                delta_id: "evt_a".to_string(),
7671                item_id: "item_a".to_string(),
7672                previous_item_id: Some("item_user".to_string()),
7673                content_index: 0,
7674                delta: "right response".to_string(),
7675            });
7676
7677        assert_eq!(outcome.materialized_messages.len(), 1);
7678        assert_eq!(session.messages().len(), 2);
7679        assert!(matches!(
7680            &session.messages()[1],
7681            Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "right response"
7682        ));
7683    }
7684
7685    #[test]
7686    fn realtime_transcript_late_duplicate_completion_cannot_finalize_unrelated_response() {
7687        let mut session = Session::new();
7688
7689        let _ = session.append_realtime_transcript_event(
7690            RealtimeTranscriptEvent::UserTranscriptFinal {
7691                item_id: "item_user".to_string(),
7692                previous_item_id: None,
7693                content_index: 0,
7694                text: "question".to_string(),
7695            },
7696        );
7697        let _ =
7698            session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
7699                response_id: "resp_a".to_string(),
7700                delta_id: "evt_a".to_string(),
7701                item_id: "item_a".to_string(),
7702                previous_item_id: Some("item_user".to_string()),
7703                content_index: 0,
7704                delta: "first".to_string(),
7705            });
7706        let _ = session.append_realtime_transcript_event(
7707            RealtimeTranscriptEvent::AssistantTurnCompleted {
7708                response_id: "resp_a".to_string(),
7709                stop_reason: StopReason::EndTurn,
7710                usage: Usage::default(),
7711            },
7712        );
7713        assert_eq!(session.messages().len(), 2);
7714
7715        assert!(
7716            session
7717                .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
7718                    response_id: "resp_b".to_string(),
7719                    delta_id: "evt_b".to_string(),
7720                    item_id: "item_b".to_string(),
7721                    previous_item_id: Some("item_a".to_string()),
7722                    content_index: 0,
7723                    delta: "second".to_string(),
7724                })
7725                .is_inert()
7726        );
7727        assert!(
7728            session
7729                .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
7730                    response_id: "resp_a".to_string(),
7731                    stop_reason: StopReason::EndTurn,
7732                    usage: Usage::default(),
7733                })
7734                .is_inert(),
7735            "a duplicate late terminal for resp_a must not finalize resp_b"
7736        );
7737        assert_eq!(session.messages().len(), 2);
7738
7739        let outcome = session.append_realtime_transcript_event(
7740            RealtimeTranscriptEvent::AssistantTurnCompleted {
7741                response_id: "resp_b".to_string(),
7742                stop_reason: StopReason::EndTurn,
7743                usage: Usage::default(),
7744            },
7745        );
7746        assert_eq!(outcome.materialized_messages.len(), 1);
7747        assert_eq!(session.messages().len(), 3);
7748    }
7749
7750    #[test]
7751    fn realtime_transcript_interruption_discards_only_matching_response() {
7752        // R5-5: cross-response isolation invariant — Interrupted on resp_a
7753        // does NOT touch resp_b's staged content. Both responses use
7754        // `AssistantTextDelta` (Display lane); under R5-5 resp_a's Display
7755        // item is RETAINED at Interrupted time and resp_b's continues
7756        // unaffected, materializing on its later TurnCompleted.
7757        let mut session = Session::new();
7758
7759        let _ = session.append_realtime_transcript_event(
7760            RealtimeTranscriptEvent::UserTranscriptFinal {
7761                item_id: "item_user".to_string(),
7762                previous_item_id: None,
7763                content_index: 0,
7764                text: "question".to_string(),
7765            },
7766        );
7767        let _ =
7768            session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
7769                response_id: "resp_a".to_string(),
7770                delta_id: "evt_a".to_string(),
7771                item_id: "item_a".to_string(),
7772                previous_item_id: Some("item_user".to_string()),
7773                content_index: 0,
7774                delta: "interrupted display".to_string(),
7775            });
7776        let _ =
7777            session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
7778                response_id: "resp_b".to_string(),
7779                delta_id: "evt_b".to_string(),
7780                item_id: "item_b".to_string(),
7781                previous_item_id: Some("item_user".to_string()),
7782                content_index: 0,
7783                delta: "keep me".to_string(),
7784            });
7785
7786        // R5-5: Interrupted commits the resp_a Display item; resp_b
7787        // remains untouched.
7788        let interrupt_outcome = session.append_realtime_transcript_event(
7789            RealtimeTranscriptEvent::AssistantTurnInterrupted {
7790                response_id: "resp_a".to_string(),
7791            },
7792        );
7793        assert_eq!(
7794            interrupt_outcome.materialized_messages.len(),
7795            1,
7796            "resp_a's Display item commits on Interrupted"
7797        );
7798
7799        let outcome = session.append_realtime_transcript_event(
7800            RealtimeTranscriptEvent::AssistantTurnCompleted {
7801                response_id: "resp_b".to_string(),
7802                stop_reason: StopReason::EndTurn,
7803                usage: Usage::default(),
7804            },
7805        );
7806        assert_eq!(
7807            outcome.materialized_messages.len(),
7808            1,
7809            "resp_b commits on its TurnCompleted, untouched by resp_a's Interrupted"
7810        );
7811
7812        // 1 user + 2 assistant messages.
7813        assert_eq!(session.messages().len(), 3);
7814        assert!(matches!(
7815            &session.messages()[1],
7816            Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "interrupted display"
7817        ));
7818        assert!(matches!(
7819            &session.messages()[2],
7820            Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "keep me"
7821        ));
7822    }
7823
7824    // Performance tests for Arc-based CoW
7825
7826    #[test]
7827    fn test_fork_shares_arc_no_clone() {
7828        let mut session = Session::new();
7829        for i in 0..100 {
7830            session.push(Message::User(UserMessage::text(format!("Message {i}"))));
7831        }
7832
7833        // Fork should share the same Arc, not clone messages
7834        let forked = session.fork();
7835
7836        // Both should point to the same underlying data (Arc refcount > 1)
7837        assert!(Arc::ptr_eq(&session.messages, &forked.messages));
7838        assert_eq!(forked.messages().len(), 100);
7839    }
7840
7841    #[test]
7842    fn test_fork_at_shares_arc_prefix() {
7843        let mut session = Session::new();
7844        for i in 0..100 {
7845            session.push(Message::User(UserMessage::text(format!("Message {i}"))));
7846        }
7847
7848        // Fork at 50 should create new Arc with copied prefix
7849        let forked = session.fork_at(50);
7850        assert_eq!(forked.messages().len(), 50);
7851
7852        // Original should be unchanged
7853        assert_eq!(session.messages().len(), 100);
7854    }
7855
7856    #[test]
7857    fn test_fork_at_resets_transcript_history_state_for_branch_identity() {
7858        let mut session = Session::new();
7859        session.push(Message::User(UserMessage::text(
7860            "summarize this".to_string(),
7861        )));
7862        session.push(Message::BlockAssistant(BlockAssistantMessage::new(
7863            vec![AssistantBlock::Text {
7864                text: "long assistant trace".to_string(),
7865                meta: None,
7866            }],
7867            StopReason::EndTurn,
7868        )));
7869        let parent_revision = session.transcript_revision().expect("parent revision");
7870        session
7871            .commit_transcript_rewrite(
7872                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
7873                vec![Message::BlockAssistant(BlockAssistantMessage::new(
7874                    vec![AssistantBlock::Text {
7875                        text: "compact trace".to_string(),
7876                        meta: None,
7877                    }],
7878                    StopReason::EndTurn,
7879                ))],
7880                TranscriptRewriteReason::new("compaction"),
7881                Some("test".to_string()),
7882                Some(parent_revision),
7883            )
7884            .expect("rewrite should commit");
7885
7886        let source_head = session.transcript_revision().expect("source head");
7887        let mut forked = session.fork_at(1);
7888        assert_ne!(forked.id(), session.id());
7889        assert!(
7890            !forked
7891                .metadata()
7892                .contains_key(SESSION_TRANSCRIPT_HISTORY_STATE_KEY)
7893        );
7894        assert_eq!(
7895            forked.transcript_revision().expect("fork head"),
7896            transcript_messages_digest(forked.messages()).expect("fork digest")
7897        );
7898        assert!(
7899            forked
7900                .transcript_revision_messages(&source_head)
7901                .expect("fork history lookup")
7902                .is_none()
7903        );
7904
7905        let fork_parent = forked.transcript_revision().expect("fork parent");
7906        let commit = forked
7907            .commit_transcript_rewrite(
7908                TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
7909                vec![Message::User(UserMessage::text(
7910                    "branch prompt".to_string(),
7911                ))],
7912                TranscriptRewriteReason::new("branch_edit"),
7913                Some("test".to_string()),
7914                Some(fork_parent.clone()),
7915            )
7916            .expect("fork rewrite should use fork-local parent");
7917        assert_eq!(commit.parent_revision, fork_parent);
7918    }
7919
7920    #[test]
7921    fn test_push_cow_behavior() {
7922        let mut session = Session::new();
7923        session.push(Message::User(UserMessage::text("First".to_string())));
7924
7925        // Fork shares the Arc
7926        let forked = session.fork();
7927        assert!(Arc::ptr_eq(&session.messages, &forked.messages));
7928
7929        // Push on original triggers CoW - original gets new Arc
7930        session.push(Message::User(UserMessage::text("Second".to_string())));
7931
7932        // Now they should have different Arcs
7933        assert!(!Arc::ptr_eq(&session.messages, &forked.messages));
7934        assert_eq!(session.messages().len(), 2);
7935        assert_eq!(forked.messages().len(), 1);
7936    }
7937
7938    // Performance tests for lazy timestamp updates
7939
7940    #[test]
7941    fn test_push_batch_single_timestamp() {
7942        let mut session = Session::new();
7943        let initial_updated = session.updated_at();
7944
7945        // Use push_batch to add multiple messages without repeated syscalls
7946        session.push_batch(vec![
7947            Message::User(UserMessage::text("First".to_string())),
7948            Message::User(UserMessage::text("Second".to_string())),
7949            Message::User(UserMessage::text("Third".to_string())),
7950        ]);
7951
7952        assert_eq!(session.messages().len(), 3);
7953        // Timestamp should have been updated once
7954        assert!(session.updated_at() >= initial_updated);
7955    }
7956
7957    #[test]
7958    fn test_touch_updates_timestamp() {
7959        let mut session = Session::new();
7960        let initial = session.updated_at();
7961
7962        std::thread::sleep(std::time::Duration::from_millis(10));
7963
7964        // Explicit touch to update timestamp
7965        session.touch();
7966
7967        assert!(session.updated_at() > initial);
7968    }
7969
7970    #[test]
7971    fn test_session_push() {
7972        let mut session = Session::new();
7973        let initial_updated = session.updated_at();
7974
7975        // Small delay to ensure time changes
7976        std::thread::sleep(std::time::Duration::from_millis(10));
7977
7978        session.push(Message::User(UserMessage::text("Hello".to_string())));
7979
7980        assert_eq!(session.messages().len(), 1);
7981        assert!(session.updated_at() > initial_updated);
7982    }
7983
7984    #[test]
7985    fn test_session_fork() {
7986        let mut session = Session::new();
7987        session.push(Message::System(SystemMessage::new("System prompt")));
7988        session.push(Message::User(UserMessage::text("Hello".to_string())));
7989        session.push(Message::BlockAssistant(BlockAssistantMessage {
7990            blocks: vec![AssistantBlock::Text {
7991                text: "Hi!".to_string(),
7992                meta: None,
7993            }],
7994            stop_reason: StopReason::EndTurn,
7995            identity: crate::types::TranscriptMessageIdentity::default(),
7996            created_at: crate::types::message_timestamp_now(),
7997        }));
7998
7999        // Fork at index 2 (system + user)
8000        let forked = session.fork_at(2);
8001        assert_eq!(forked.messages().len(), 2);
8002        assert_ne!(forked.id(), session.id());
8003
8004        // Full fork
8005        let full_fork = session.fork();
8006        assert_eq!(full_fork.messages().len(), 3);
8007    }
8008
8009    #[test]
8010    fn test_session_forks_drop_generated_authority_metadata() {
8011        let mut session = Session::new();
8012        session.push(Message::User(UserMessage::text("original")));
8013        session.set_metadata("ordinary", serde_json::json!("keep"));
8014        session
8015            .set_build_state(SessionBuildState::default())
8016            .expect("build state should serialize");
8017        session
8018            .set_system_context_state(SessionSystemContextState::default())
8019            .expect("system-context state should serialize");
8020        session
8021            .set_deferred_turn_state(SessionDeferredTurnState::default())
8022            .expect("deferred-turn state should serialize");
8023        session
8024            .set_tool_visibility_state(
8025                AuthorizedSessionToolVisibilityState::from_generated_authority(
8026                    SessionToolVisibilityState::default(),
8027                ),
8028            )
8029            .expect("visibility state should serialize");
8030        let _ = session.append_realtime_transcript_event(RealtimeTranscriptEvent::ItemObserved {
8031            item_id: "rt-item".to_string(),
8032            previous_item_id: None,
8033            role: RealtimeTranscriptRole::User,
8034            response_id: None,
8035        });
8036        assert!(
8037            session
8038                .metadata()
8039                .contains_key(SESSION_REALTIME_TRANSCRIPT_STATE_KEY),
8040            "test setup should install realtime transcript authority state"
8041        );
8042
8043        let forked_at = session.fork_at(1);
8044        let full_fork = session.fork();
8045        let replaced = session
8046            .fork_replacing(
8047                0,
8048                TranscriptReplacement::Message {
8049                    message: Message::User(UserMessage::text("replacement")),
8050                },
8051            )
8052            .expect("replacement fork should succeed");
8053
8054        for forked in [&forked_at, &full_fork, &replaced] {
8055            assert_eq!(forked.metadata().get("ordinary").unwrap(), "keep");
8056            assert!(
8057                !forked.metadata().contains_key(SESSION_BUILD_STATE_KEY),
8058                "forked sessions must not raw-copy durable build-state authority"
8059            );
8060            assert!(
8061                !forked
8062                    .metadata()
8063                    .contains_key(SESSION_SYSTEM_CONTEXT_STATE_KEY),
8064                "forked sessions must not raw-copy system-context authority state"
8065            );
8066            assert!(
8067                !forked
8068                    .metadata()
8069                    .contains_key(SESSION_DEFERRED_TURN_STATE_KEY),
8070                "forked sessions must not raw-copy deferred-turn authority state"
8071            );
8072            assert!(
8073                !forked
8074                    .metadata()
8075                    .contains_key(SESSION_TOOL_VISIBILITY_STATE_KEY),
8076                "forked sessions must not raw-copy tool-visibility authority state"
8077            );
8078            assert!(
8079                !forked
8080                    .metadata()
8081                    .contains_key(SESSION_REALTIME_TRANSCRIPT_STATE_KEY),
8082                "forked sessions must not raw-copy realtime transcript authority state"
8083            );
8084        }
8085    }
8086
8087    #[test]
8088    fn test_session_metadata() {
8089        let mut session = Session::new();
8090        session.set_metadata("key", serde_json::json!("value"));
8091
8092        assert_eq!(session.metadata().get("key").unwrap(), "value");
8093    }
8094
8095    #[test]
8096    fn session_metadata_realm_id_is_back_read_compatible_string() {
8097        // A typed realm_id serializes as a bare JSON string (byte-identical to
8098        // the prior Option<String> durable shape).
8099        let metadata = SessionMetadata {
8100            schema_version: SESSION_METADATA_SCHEMA_VERSION,
8101            model: "test-model".to_string(),
8102            max_tokens: 1024,
8103            structured_output_retries: 2,
8104            provider: Provider::Other,
8105            self_hosted_server_id: None,
8106            provider_params: None,
8107            tooling: SessionTooling::default(),
8108            keep_alive: false,
8109            comms_name: None,
8110            peer_meta: None,
8111            realm_id: Some(crate::RealmId::parse("env_default").unwrap()),
8112            instance_id: None,
8113            backend: None,
8114            config_generation: None,
8115            auth_binding: None,
8116            mob_member_binding: None,
8117        };
8118        let value = serde_json::to_value(&metadata).unwrap();
8119        assert_eq!(
8120            value.get("realm_id"),
8121            Some(&serde_json::json!("env_default")),
8122            "typed realm_id must serialize as a bare slug string"
8123        );
8124
8125        // A legacy persisted row stored realm_id as a JSON string; it must
8126        // deserialize into the typed RealmId (durable back-read).
8127        let legacy = serde_json::json!({
8128            "schema_version": SESSION_METADATA_SCHEMA_VERSION,
8129            "model": "test-model",
8130            "max_tokens": 1024,
8131            "structured_output_retries": 2,
8132            "provider": "other",
8133            "tooling": SessionTooling::default(),
8134            "keep_alive": false,
8135            "comms_name": null,
8136            "realm_id": "legacy_realm",
8137        });
8138        let restored: SessionMetadata = serde_json::from_value(legacy).unwrap();
8139        assert_eq!(
8140            restored.realm_id.as_ref().map(crate::RealmId::as_str),
8141            Some("legacy_realm")
8142        );
8143    }
8144
8145    /// Ask 6: `SessionTooling.tool_access_policy` is additive — a persisted
8146    /// row without the field back-reads as `None` (unrestricted), `None` is
8147    /// omitted on write (durable shape unchanged for ungated sessions), and a
8148    /// resolved policy round-trips intact.
8149    #[test]
8150    fn session_tooling_tool_access_policy_round_trip_and_absent_default() {
8151        // Absent field back-reads as None.
8152        let legacy = serde_json::json!({});
8153        let restored: SessionTooling = serde_json::from_value(legacy).unwrap();
8154        assert_eq!(restored.tool_access_policy, None);
8155
8156        // None is omitted on write — ungated sessions keep their prior shape.
8157        let value = serde_json::to_value(SessionTooling::default()).unwrap();
8158        assert!(
8159            value.get("tool_access_policy").is_none(),
8160            "None policy must not serialize"
8161        );
8162
8163        // A resolved policy round-trips intact.
8164        let tooling = SessionTooling {
8165            tool_access_policy: Some(crate::ops::ToolAccessPolicy::AllowList(
8166                ["read_file", "send_message"].into_iter().collect(),
8167            )),
8168            ..SessionTooling::default()
8169        };
8170        let value = serde_json::to_value(&tooling).unwrap();
8171        let restored: SessionTooling = serde_json::from_value(value).unwrap();
8172        assert_eq!(restored.tool_access_policy, tooling.tool_access_policy);
8173    }
8174
8175    #[test]
8176    fn lifecycle_terminal_typed_round_trip() {
8177        let mut session = Session::new();
8178        assert_eq!(session.lifecycle_terminal(), None);
8179
8180        session
8181            .set_lifecycle_terminal(SessionLifecycleTerminal::Archived)
8182            .expect("typed terminal write should serialize");
8183        assert_eq!(
8184            session.lifecycle_terminal(),
8185            Some(SessionLifecycleTerminal::Archived)
8186        );
8187        assert!(
8188            session
8189                .lifecycle_terminal()
8190                .is_some_and(SessionLifecycleTerminal::is_archived)
8191        );
8192        // Persisted JSON for the typed key is the snake_case variant string.
8193        assert_eq!(
8194            session
8195                .metadata()
8196                .get(SESSION_LIFECYCLE_TERMINAL_KEY)
8197                .unwrap(),
8198            &serde_json::json!("archived")
8199        );
8200    }
8201
8202    #[test]
8203    fn lifecycle_terminal_key_rejects_raw_mutation() {
8204        let mut session = Session::new();
8205        assert!(
8206            session
8207                .try_set_metadata(
8208                    SESSION_LIFECYCLE_TERMINAL_KEY,
8209                    serde_json::json!("archived")
8210                )
8211                .is_err(),
8212            "the typed lifecycle-terminal key is reserved for session authority"
8213        );
8214    }
8215
8216    #[test]
8217    fn test_session_metadata_backfill_preserves_timestamp() {
8218        let mut session = Session::new();
8219        let initial_updated = session.updated_at();
8220
8221        std::thread::sleep(std::time::Duration::from_millis(10));
8222
8223        assert!(session.backfill_metadata_if_absent("key", serde_json::json!("value")));
8224        assert_eq!(session.metadata().get("key").unwrap(), "value");
8225        assert_eq!(session.updated_at(), initial_updated);
8226        assert!(!session.backfill_metadata_if_absent("key", serde_json::json!("other")));
8227        assert_eq!(session.metadata().get("key").unwrap(), "value");
8228        assert_eq!(session.updated_at(), initial_updated);
8229    }
8230
8231    #[test]
8232    fn test_reserved_generated_authority_metadata_rejects_raw_mutation() {
8233        let mut session = Session::new();
8234
8235        assert!(
8236            session
8237                .try_set_metadata(SESSION_SYSTEM_CONTEXT_STATE_KEY, serde_json::json!({}))
8238                .is_err()
8239        );
8240        assert!(
8241            session
8242                .try_set_metadata(SESSION_METADATA_KEY, serde_json::json!({}))
8243                .is_err()
8244        );
8245        assert!(
8246            session
8247                .try_set_metadata(SESSION_BUILD_STATE_KEY, serde_json::json!({}))
8248                .is_err()
8249        );
8250        session
8251            .set_session_metadata(SessionMetadata {
8252                schema_version: SESSION_METADATA_SCHEMA_VERSION,
8253                model: "test-model".to_string(),
8254                max_tokens: 1024,
8255                structured_output_retries: 2,
8256                provider: Provider::Other,
8257                self_hosted_server_id: None,
8258                provider_params: None,
8259                tooling: SessionTooling::default(),
8260                keep_alive: false,
8261                comms_name: None,
8262                peer_meta: None,
8263                realm_id: None,
8264                instance_id: None,
8265                backend: None,
8266                config_generation: None,
8267                auth_binding: None,
8268                mob_member_binding: None,
8269            })
8270            .expect("typed metadata setter should route through generated authority");
8271        session
8272            .set_build_state(SessionBuildState::default())
8273            .expect("typed build-state setter should route through generated authority");
8274        session.remove_metadata(SESSION_METADATA_KEY);
8275        session.remove_metadata(SESSION_BUILD_STATE_KEY);
8276        assert!(
8277            session.metadata().contains_key(SESSION_METADATA_KEY),
8278            "raw removal must not delete generated-authority session metadata"
8279        );
8280        assert!(
8281            session.metadata().contains_key(SESSION_BUILD_STATE_KEY),
8282            "raw removal must not delete generated-authority build state"
8283        );
8284        session.set_metadata(SESSION_DEFERRED_TURN_STATE_KEY, serde_json::json!({}));
8285        assert!(
8286            !session
8287                .metadata()
8288                .contains_key(SESSION_DEFERRED_TURN_STATE_KEY)
8289        );
8290        assert!(
8291            !session.backfill_metadata_if_absent(
8292                SESSION_SYSTEM_CONTEXT_STATE_KEY,
8293                serde_json::json!({})
8294            )
8295        );
8296
8297        let state = SessionSystemContextState::default();
8298        session
8299            .set_system_context_state(state.clone())
8300            .expect("typed setter should route through generated authority");
8301        session.remove_metadata(SESSION_SYSTEM_CONTEXT_STATE_KEY);
8302        assert_eq!(
8303            session
8304                .try_system_context_state()
8305                .expect("typed state should restore"),
8306            Some(state)
8307        );
8308
8309        session.metadata.insert(
8310            SESSION_SYSTEM_CONTEXT_STATE_KEY.to_string(),
8311            serde_json::json!("not-a-state"),
8312        );
8313        assert!(
8314            session.try_system_context_state().is_err(),
8315            "malformed generated authority state must not decode as absent/default"
8316        );
8317
8318        session.metadata.insert(
8319            SESSION_METADATA_KEY.to_string(),
8320            serde_json::json!("not-metadata"),
8321        );
8322        assert!(
8323            session.try_session_metadata().is_err(),
8324            "malformed session metadata must not decode as absent/default"
8325        );
8326
8327        session.metadata.insert(
8328            SESSION_BUILD_STATE_KEY.to_string(),
8329            serde_json::json!("not-build-state"),
8330        );
8331        assert!(
8332            session.try_build_state().is_err(),
8333            "malformed build state must not decode as absent/default"
8334        );
8335
8336        assert!(
8337            session
8338                .try_set_metadata(SESSION_TOOL_VISIBILITY_STATE_KEY, serde_json::json!({}))
8339                .is_err()
8340        );
8341        session
8342            .set_tool_visibility_state(
8343                AuthorizedSessionToolVisibilityState::from_generated_authority(
8344                    SessionToolVisibilityState::default(),
8345                ),
8346            )
8347            .expect("typed visibility setter should route through typed authority handoff");
8348        session.remove_metadata(SESSION_TOOL_VISIBILITY_STATE_KEY);
8349        assert!(
8350            session
8351                .metadata()
8352                .contains_key(SESSION_TOOL_VISIBILITY_STATE_KEY)
8353        );
8354        session.clear_tool_visibility_state();
8355        assert!(
8356            !session
8357                .metadata()
8358                .contains_key(SESSION_TOOL_VISIBILITY_STATE_KEY)
8359        );
8360        assert!(
8361            session
8362                .try_set_metadata(SESSION_REALTIME_TRANSCRIPT_STATE_KEY, serde_json::json!({}))
8363                .is_err()
8364        );
8365        let _ = session.append_realtime_transcript_event(RealtimeTranscriptEvent::ItemObserved {
8366            item_id: "rt-item".to_string(),
8367            previous_item_id: None,
8368            role: RealtimeTranscriptRole::User,
8369            response_id: None,
8370        });
8371        assert!(
8372            session
8373                .metadata()
8374                .contains_key(SESSION_REALTIME_TRANSCRIPT_STATE_KEY),
8375            "typed realtime transcript append should retain authority to persist its state"
8376        );
8377        session.metadata.insert(
8378            SESSION_REALTIME_TRANSCRIPT_STATE_KEY.to_string(),
8379            serde_json::json!("not-a-state"),
8380        );
8381        assert!(
8382            session.try_realtime_transcript_state().is_err(),
8383            "malformed realtime generated authority state must not decode as absent/default"
8384        );
8385    }
8386
8387    #[test]
8388    fn test_session_mob_tool_authority_context_persists_projection_without_authority_seal() {
8389        let mut session = Session::new();
8390        session
8391            .set_build_state(SessionBuildState::default())
8392            .expect("session build state should serialize");
8393        let authority = MobToolAuthorityContext::generated_for_test(
8394            crate::service::OpaquePrincipalToken::new("opaque-principal"),
8395            false,
8396            false,
8397            false,
8398            std::collections::BTreeSet::from(["mob-a".to_string()]),
8399            std::collections::BTreeMap::new(),
8400            None,
8401            Some("audit-1".to_string()),
8402        );
8403
8404        session
8405            .set_mob_tool_authority_context(Some(authority))
8406            .expect("authority should serialize");
8407        assert!(session.mob_tool_authority_context().is_none());
8408        let stored = session
8409            .build_state()
8410            .and_then(|state| state.mob_tool_authority_context)
8411            .expect("stored projection should deserialize");
8412        assert!(!stored.is_generated_authority_context());
8413        assert!(!stored.can_manage_mob("mob-a"));
8414
8415        session
8416            .set_mob_tool_authority_context(None)
8417            .expect("authority should clear");
8418        assert!(session.mob_tool_authority_context().is_none());
8419    }
8420
8421    #[test]
8422    fn test_session_build_state_rejects_forged_mob_authority_projection() {
8423        let mut session = Session::new();
8424        let authority = MobToolAuthorityContext::generated_for_test(
8425            crate::service::OpaquePrincipalToken::new("opaque-principal"),
8426            false,
8427            false,
8428            false,
8429            std::collections::BTreeSet::from(["mob-a".to_string()]),
8430            std::collections::BTreeMap::new(),
8431            None,
8432            Some("audit-1".to_string()),
8433        );
8434        let forged_projection: MobToolAuthorityContext =
8435            serde_json::from_value(serde_json::to_value(authority).expect("serialize authority"))
8436                .expect("deserialize projection");
8437        assert!(!forged_projection.is_generated_authority_context());
8438
8439        let err = session
8440            .set_build_state(SessionBuildState {
8441                mob_tool_authority_context: Some(forged_projection),
8442                ..Default::default()
8443            })
8444            .expect_err("forged build state must be rejected by generated authority");
8445        // The build-state-persist admission decision now lives in the canonical
8446        // SessionDocumentMachine durable-config region (LUC-524); the rejection
8447        // surfaces with that machine's authority wording.
8448        assert!(
8449            err.to_string()
8450                .contains("generated session document authority rejected"),
8451            "unexpected error: {err}"
8452        );
8453    }
8454
8455    #[test]
8456    fn test_session_tool_visibility_state_roundtrip() {
8457        let mut session = Session::new();
8458        let state = SessionToolVisibilityState {
8459            inherited_base_filter: ToolFilter::Allow(["visible".to_string()].into_iter().collect()),
8460            active_filter: ToolFilter::Allow(
8461                ["visible".to_string(), "missing".to_string()]
8462                    .into_iter()
8463                    .collect(),
8464            ),
8465            staged_filter: ToolFilter::Allow(
8466                ["visible".to_string(), "missing".to_string()]
8467                    .into_iter()
8468                    .collect(),
8469            ),
8470            active_revision: 1,
8471            staged_revision: 2,
8472            ..Default::default()
8473        };
8474
8475        session
8476            .set_tool_visibility_state(
8477                AuthorizedSessionToolVisibilityState::from_generated_authority(state.clone()),
8478            )
8479            .expect("tool visibility state should serialize");
8480        assert_eq!(session.tool_visibility_state().unwrap(), Some(state));
8481    }
8482
8483    #[test]
8484    fn test_session_tool_visibility_state_malformed_returns_error() {
8485        let mut session = Session::new();
8486        session.metadata.insert(
8487            SESSION_TOOL_VISIBILITY_STATE_KEY.to_string(),
8488            serde_json::json!({
8489                "active_filter": {
8490                    "unexpected_filter_kind": ["secret"]
8491                }
8492            }),
8493        );
8494
8495        assert!(
8496            session.tool_visibility_state().is_err(),
8497            "malformed canonical visibility metadata must not decode as absent/default"
8498        );
8499    }
8500
8501    #[test]
8502    fn test_session_serialization() {
8503        let mut session = Session::new();
8504        session.push(Message::User(UserMessage::text("Test".to_string())));
8505
8506        let json = serde_json::to_string(&session).unwrap();
8507        let parsed: Session = serde_json::from_str(&json).unwrap();
8508
8509        assert_eq!(parsed.id(), session.id());
8510        assert_eq!(parsed.messages().len(), 1);
8511        assert_eq!(parsed.version(), SESSION_VERSION);
8512    }
8513
8514    #[test]
8515    fn test_session_meta_from_session() {
8516        let mut session = Session::new();
8517        session.push(Message::User(UserMessage::text("Hello".to_string())));
8518        session.push(Message::BlockAssistant(BlockAssistantMessage {
8519            blocks: vec![AssistantBlock::Text {
8520                text: "Hi!".to_string(),
8521                meta: None,
8522            }],
8523            stop_reason: StopReason::EndTurn,
8524            identity: crate::types::TranscriptMessageIdentity::default(),
8525            created_at: crate::types::message_timestamp_now(),
8526        }));
8527        session.record_usage(Usage {
8528            input_tokens: 10,
8529            output_tokens: 5,
8530            cache_creation_tokens: None,
8531            cache_read_tokens: None,
8532        });
8533
8534        let meta = SessionMeta::from(&session);
8535        assert_eq!(meta.id, *session.id());
8536        assert_eq!(meta.message_count, 2);
8537        assert_eq!(meta.total_tokens, 15);
8538    }
8539
8540    #[test]
8541    fn system_context_state_preserves_applied_runtime_context() {
8542        let accepted_at = SystemTime::UNIX_EPOCH;
8543        let mut state = SessionSystemContextState::default();
8544        state
8545            .stage_append(
8546                &AppendSystemContextRequest {
8547                    content: crate::lifecycle::run_primitive::CoreRenderable::text(
8548                        "Authoritative peer token is birch seventeen.".to_string(),
8549                    ),
8550                    source: Some(
8551                        "peer_response_terminal:analyst:018f6f79-7a82-7c4e-a552-a3b86f9630f1"
8552                            .to_string(),
8553                    ),
8554                    idempotency_key: Some("018f6f79-7a82-7c4e-a552-a3b86f9630f1".to_string()),
8555                    source_kind: SystemContextSource::Normal,
8556                    peer_response_terminal: None,
8557                },
8558                accepted_at,
8559            )
8560            .expect("append should stage");
8561
8562        state.mark_pending_applied();
8563
8564        assert!(state.pending.is_empty());
8565        assert_eq!(state.applied.len(), 1);
8566        assert_eq!(
8567            state.applied[0].content.render_text(),
8568            "Authoritative peer token is birch seventeen."
8569        );
8570        assert_eq!(
8571            state.applied[0].source.as_deref(),
8572            Some("peer_response_terminal:analyst:018f6f79-7a82-7c4e-a552-a3b86f9630f1")
8573        );
8574
8575        let round_tripped: SessionSystemContextState =
8576            serde_json::from_value(serde_json::to_value(&state).expect("serialize state"))
8577                .expect("deserialize state");
8578        assert_eq!(round_tripped.applied, state.applied);
8579    }
8580
8581    #[test]
8582    fn active_turn_system_context_is_discarded_when_not_applied() {
8583        let mut state = SessionSystemContextState::default();
8584        state
8585            .stage_active_turn_append(
8586                &AppendSystemContextRequest {
8587                    content: crate::lifecycle::run_primitive::CoreRenderable::text(
8588                        "only for the active run".to_string(),
8589                    ),
8590                    source: Some("runtime:steer:input-1".to_string()),
8591                    idempotency_key: Some("runtime:steer:input-1".to_string()),
8592                    source_kind: SystemContextSource::RuntimeSteer,
8593                    peer_response_terminal: None,
8594                },
8595                SystemTime::UNIX_EPOCH,
8596            )
8597            .expect("active context should stage");
8598
8599        let discarded = state.discard_unapplied_active_turn_pending();
8600
8601        assert_eq!(discarded.len(), 1);
8602        assert!(state.pending.is_empty());
8603        assert!(state.applied.is_empty());
8604        assert!(state.active_turn_pending_keys.is_empty());
8605        assert!(
8606            state.seen.is_empty(),
8607            "discarded active-turn context should not block later idempotency keys"
8608        );
8609    }
8610
8611    #[test]
8612    fn active_turn_system_context_can_roll_back_targeted_keys() {
8613        let mut state = SessionSystemContextState::default();
8614        for key in ["runtime:steer:input-1", "runtime:steer:input-2"] {
8615            state
8616                .stage_active_turn_append(
8617                    &AppendSystemContextRequest {
8618                        content: crate::lifecycle::run_primitive::CoreRenderable::text(format!(
8619                            "context for {key}"
8620                        )),
8621                        source: Some(key.to_string()),
8622                        idempotency_key: Some(key.to_string()),
8623                        source_kind: SystemContextSource::RuntimeSteer,
8624                        peer_response_terminal: None,
8625                    },
8626                    SystemTime::UNIX_EPOCH,
8627                )
8628                .expect("active context should stage");
8629        }
8630
8631        let discarded =
8632            state.discard_active_turn_pending_by_keys(&["runtime:steer:input-1".to_string()]);
8633
8634        assert_eq!(discarded.len(), 1);
8635        assert_eq!(
8636            discarded[0].idempotency_key.as_deref(),
8637            Some("runtime:steer:input-1")
8638        );
8639        assert_eq!(state.pending.len(), 1);
8640        assert_eq!(
8641            state.pending[0].idempotency_key.as_deref(),
8642            Some("runtime:steer:input-2")
8643        );
8644        assert!(!state.seen.contains_key("runtime:steer:input-1"));
8645        assert!(state.seen.contains_key("runtime:steer:input-2"));
8646        assert!(
8647            !state
8648                .active_turn_pending_keys
8649                .contains("runtime:steer:input-1")
8650        );
8651        assert!(
8652            state
8653                .active_turn_pending_keys
8654                .contains("runtime:steer:input-2")
8655        );
8656    }
8657
8658    #[test]
8659    fn active_turn_system_context_is_transient_when_boundary_consumes_it() {
8660        let mut state = SessionSystemContextState::default();
8661        state
8662            .stage_active_turn_append(
8663                &AppendSystemContextRequest {
8664                    content: crate::lifecycle::run_primitive::CoreRenderable::text(
8665                        "visible to this run".to_string(),
8666                    ),
8667                    source: Some("runtime:steer:input-2".to_string()),
8668                    idempotency_key: Some("runtime:steer:input-2".to_string()),
8669                    source_kind: SystemContextSource::RuntimeSteer,
8670                    peer_response_terminal: None,
8671                },
8672                SystemTime::UNIX_EPOCH,
8673            )
8674            .expect("active context should stage");
8675
8676        state.mark_pending_applied();
8677        let discarded = state.discard_unapplied_active_turn_pending();
8678
8679        assert!(discarded.is_empty());
8680        assert!(state.pending.is_empty());
8681        assert!(state.applied.is_empty());
8682        assert!(state.active_turn_pending_keys.is_empty());
8683        assert_eq!(
8684            state.seen.get("runtime:steer:input-2"),
8685            None,
8686            "consumed active-turn steer context must not become durable state"
8687        );
8688    }
8689
8690    #[test]
8691    fn discard_transient_runtime_steer_context_removes_steer_via_typed_marker() {
8692        let mut session = Session::new();
8693        // The runtime-steer fact is carried by the typed `source_kind`, not by
8694        // the `source` string. The durable peer fact uses the same `source`
8695        // string scheme but is marked `Normal`, so only the steers are removed.
8696        session.set_system_prompt(format!(
8697            "base{}{}{}{}",
8698            SYSTEM_CONTEXT_SEPARATOR,
8699            render_system_context_block(&PendingSystemContextAppend {
8700                content: crate::lifecycle::run_primitive::CoreRenderable::text(
8701                    "old steer".to_string()
8702                ),
8703                source: Some("steer-source-old".to_string()),
8704                idempotency_key: Some("steer-key-old".to_string()),
8705                source_kind: SystemContextSource::RuntimeSteer,
8706                peer_response_terminal: None,
8707                accepted_at: SystemTime::UNIX_EPOCH,
8708            }),
8709            SYSTEM_CONTEXT_SEPARATOR,
8710            render_system_context_block(&PendingSystemContextAppend {
8711                content: crate::lifecycle::run_primitive::CoreRenderable::text(
8712                    "durable peer fact".to_string()
8713                ),
8714                source: Some("peer_response_terminal:analyst:req".to_string()),
8715                idempotency_key: Some("peer_response_terminal:analyst:req".to_string()),
8716                source_kind: SystemContextSource::Normal,
8717                peer_response_terminal: None,
8718                accepted_at: SystemTime::UNIX_EPOCH,
8719            })
8720        ));
8721        session
8722            .set_system_context_state(SessionSystemContextState {
8723                pending: vec![PendingSystemContextAppend {
8724                    content: crate::lifecycle::run_primitive::CoreRenderable::text(
8725                        "pending steer".to_string(),
8726                    ),
8727                    source: Some("steer-source-pending".to_string()),
8728                    idempotency_key: Some("steer-key-pending".to_string()),
8729                    source_kind: SystemContextSource::RuntimeSteer,
8730                    peer_response_terminal: None,
8731                    accepted_at: SystemTime::UNIX_EPOCH,
8732                }],
8733                applied: vec![
8734                    PendingSystemContextAppend {
8735                        content: crate::lifecycle::run_primitive::CoreRenderable::text(
8736                            "old steer".to_string(),
8737                        ),
8738                        source: Some("steer-source-old".to_string()),
8739                        idempotency_key: Some("steer-key-old".to_string()),
8740                        source_kind: SystemContextSource::RuntimeSteer,
8741                        peer_response_terminal: None,
8742                        accepted_at: SystemTime::UNIX_EPOCH,
8743                    },
8744                    PendingSystemContextAppend {
8745                        content: crate::lifecycle::run_primitive::CoreRenderable::text(
8746                            "durable peer fact".to_string(),
8747                        ),
8748                        source: Some("peer_response_terminal:analyst:req".to_string()),
8749                        idempotency_key: Some("peer_response_terminal:analyst:req".to_string()),
8750                        source_kind: SystemContextSource::Normal,
8751                        peer_response_terminal: None,
8752                        accepted_at: SystemTime::UNIX_EPOCH,
8753                    },
8754                ],
8755                seen: BTreeMap::from([(
8756                    "steer-key-old".to_string(),
8757                    SeenSystemContextKey {
8758                        content: crate::lifecycle::run_primitive::CoreRenderable::text(
8759                            "old steer".to_string(),
8760                        ),
8761                        source: Some("steer-source-old".to_string()),
8762                        source_kind: SystemContextSource::RuntimeSteer,
8763                        state: SeenSystemContextState::Applied,
8764                    },
8765                )]),
8766                active_turn_pending_keys: BTreeSet::from(["steer-key-pending".to_string()]),
8767            })
8768            .expect("system context state should serialize");
8769
8770        let removed = session.discard_transient_runtime_steer_context();
8771
8772        assert!(removed >= 4);
8773        let system_prompt = match session.messages().first() {
8774            Some(Message::System(system)) => system.content.as_str(),
8775            other => panic!("expected system prompt, got {other:?}"),
8776        };
8777        assert!(!system_prompt.contains("old steer"));
8778        assert!(system_prompt.contains("durable peer fact"));
8779        let state = session.system_context_state().unwrap_or_default();
8780        assert!(state.pending.is_empty());
8781        assert_eq!(state.applied.len(), 1);
8782        assert_eq!(state.applied[0].content.render_text(), "durable peer fact");
8783        assert!(state.seen.is_empty());
8784        assert!(state.active_turn_pending_keys.is_empty());
8785    }
8786
8787    #[test]
8788    fn append_system_context_blocks_records_typed_applied_context() {
8789        let append = PendingSystemContextAppend {
8790            content: crate::lifecycle::run_primitive::CoreRenderable::text(
8791                "Authoritative peer token is birch seventeen.".to_string(),
8792            ),
8793            source: Some(
8794                "peer_response_terminal:analyst:018f6f79-7a82-7c4e-a552-a3b86f9630f1".to_string(),
8795            ),
8796            idempotency_key: Some("018f6f79-7a82-7c4e-a552-a3b86f9630f1".to_string()),
8797            source_kind: SystemContextSource::Normal,
8798            peer_response_terminal: None,
8799            accepted_at: SystemTime::UNIX_EPOCH,
8800        };
8801        let mut session = Session::new();
8802
8803        session.append_system_context_blocks(std::slice::from_ref(&append));
8804
8805        let state = session
8806            .system_context_state()
8807            .expect("append should persist typed context state");
8808        assert_eq!(state.applied, vec![append]);
8809    }
8810
8811    fn roster_append() -> PendingSystemContextAppend {
8812        PendingSystemContextAppend {
8813            content: crate::lifecycle::run_primitive::CoreRenderable::text(
8814                "peer roster: lead-1, w-1".to_string(),
8815            ),
8816            source: Some("comms:roster".to_string()),
8817            idempotency_key: Some("comms:roster:v1".to_string()),
8818            source_kind: SystemContextSource::Normal,
8819            peer_response_terminal: None,
8820            accepted_at: SystemTime::UNIX_EPOCH,
8821        }
8822    }
8823
8824    fn resumed_session_with_context_appended_prompt(base: &str) -> Session {
8825        let mut session = Session::new();
8826        session.set_system_prompt(base.to_string());
8827        session.push(Message::User(UserMessage::text("hello".to_string())));
8828        session.append_system_context_blocks(std::slice::from_ref(&roster_append()));
8829        session
8830    }
8831
8832    #[test]
8833    fn reconcile_resumed_system_prompt_preserves_identical_base() {
8834        let mut session = Session::new();
8835        session.set_system_prompt("base prompt".to_string());
8836        session.push(Message::User(UserMessage::text("hello".to_string())));
8837        let digest_before = transcript_messages_digest(session.messages()).unwrap();
8838
8839        let outcome = session
8840            .reconcile_resumed_system_prompt("base prompt".to_string(), None)
8841            .expect("reconcile");
8842
8843        assert_eq!(
8844            outcome,
8845            ResumedSystemPromptReconciliation::PreservedContinuation
8846        );
8847        assert_eq!(
8848            transcript_messages_digest(session.messages()).unwrap(),
8849            digest_before,
8850            "identical base must leave the transcript revision unchanged"
8851        );
8852    }
8853
8854    #[test]
8855    fn reconcile_resumed_system_prompt_preserves_context_appended_base() {
8856        let mut session = resumed_session_with_context_appended_prompt("base prompt");
8857        let digest_before = transcript_messages_digest(session.messages()).unwrap();
8858
8859        let outcome = session
8860            .reconcile_resumed_system_prompt("base prompt".to_string(), None)
8861            .expect("reconcile");
8862
8863        assert_eq!(
8864            outcome,
8865            ResumedSystemPromptReconciliation::PreservedContinuation
8866        );
8867        assert_eq!(
8868            transcript_messages_digest(session.messages()).unwrap(),
8869            digest_before,
8870            "a base extended only by runtime context appends must stay untouched"
8871        );
8872        let system = match session.messages().first() {
8873            Some(Message::System(system)) => system.clone(),
8874            other => panic!("expected system message, got {other:?}"),
8875        };
8876        assert!(system.content.contains("peer roster: lead-1, w-1"));
8877        assert!(
8878            system.mutation_kind.is_runtime_context_append(),
8879            "the persisted mutation provenance must survive reconciliation"
8880        );
8881    }
8882
8883    #[test]
8884    fn reconcile_resumed_system_prompt_rewrites_changed_base_preserving_tail() {
8885        let mut session = resumed_session_with_context_appended_prompt("base prompt");
8886
8887        let outcome = session
8888            .reconcile_resumed_system_prompt("new base prompt".to_string(), None)
8889            .expect("reconcile");
8890
8891        assert_eq!(outcome, ResumedSystemPromptReconciliation::RewrittenBase);
8892        let system_content = match session.messages().first() {
8893            Some(Message::System(system)) => system.content.clone(),
8894            other => panic!("expected system message, got {other:?}"),
8895        };
8896        assert!(
8897            system_content.starts_with("new base prompt"),
8898            "the changed base must be applied: {system_content}"
8899        );
8900        assert!(
8901            system_content.contains("peer roster: lead-1, w-1"),
8902            "the runtime-applied context tail must survive the base change: {system_content}"
8903        );
8904        let state = session
8905            .transcript_history_state()
8906            .expect("history state deserializes")
8907            .expect("rewrite must record transcript history");
8908        assert_eq!(state.commits.len(), 1);
8909        assert_eq!(
8910            state.commits[0].reason.kind,
8911            RESUME_SYSTEM_PROMPT_REFRESH_REWRITE_REASON
8912        );
8913        assert_eq!(
8914            state.head,
8915            transcript_messages_digest(session.messages()).unwrap(),
8916            "the committed head must match the rewritten transcript"
8917        );
8918    }
8919
8920    #[test]
8921    fn reconcile_resumed_system_prompt_inserts_prompt_on_promptless_transcript() {
8922        let mut session = Session::new();
8923        session.push(Message::User(UserMessage::text("hello".to_string())));
8924
8925        let outcome = session
8926            .reconcile_resumed_system_prompt("late prompt".to_string(), None)
8927            .expect("reconcile");
8928
8929        assert_eq!(outcome, ResumedSystemPromptReconciliation::RewrittenBase);
8930        assert!(matches!(
8931            session.messages().first(),
8932            Some(Message::System(system)) if system.content == "late prompt"
8933        ));
8934        let state = session
8935            .transcript_history_state()
8936            .expect("history state deserializes")
8937            .expect("insert must record transcript history");
8938        assert_eq!(state.commits.len(), 1);
8939        assert_eq!(
8940            state.commits[0].reason.kind,
8941            RESUME_SYSTEM_PROMPT_REFRESH_REWRITE_REASON
8942        );
8943    }
8944
8945    fn leading_system_content(session: &Session) -> String {
8946        match session.messages().first() {
8947            Some(Message::System(system)) => system.content.clone(),
8948            other => panic!("expected leading system message, got {other:?}"),
8949        }
8950    }
8951
8952    #[test]
8953    fn reconcile_resumed_system_prompt_preserves_full_context_prompt_from_empty_base() {
8954        // Promptless/empty-base build: appends compose as the WHOLE System
8955        // content with no separator prefix. A resume with a non-empty
8956        // explicit base must carry the verified all-context tail onto the
8957        // new base instead of discarding it as an "empty tail".
8958        let mut session = Session::new();
8959        session.push(Message::User(UserMessage::text("hello".to_string())));
8960        session.append_system_context_blocks(std::slice::from_ref(&roster_append()));
8961        let all_context_content = leading_system_content(&session);
8962        assert!(all_context_content.contains("peer roster: lead-1, w-1"));
8963
8964        let outcome = session
8965            .reconcile_resumed_system_prompt("new base prompt".to_string(), None)
8966            .expect("reconcile");
8967
8968        assert_eq!(outcome, ResumedSystemPromptReconciliation::RewrittenBase);
8969        assert_eq!(
8970            leading_system_content(&session),
8971            format!("new base prompt{SYSTEM_CONTEXT_SEPARATOR}{all_context_content}"),
8972            "the all-context prompt must survive as the runtime tail of the new base"
8973        );
8974    }
8975
8976    #[test]
8977    fn reconcile_resumed_system_prompt_preserves_context_only_prompt_on_empty_base_resume() {
8978        // Empty-base → empty-base resume: the all-context prompt IS the
8979        // expected composition; it must be preserved untouched.
8980        let mut session = Session::new();
8981        session.push(Message::User(UserMessage::text("hello".to_string())));
8982        session.append_system_context_blocks(std::slice::from_ref(&roster_append()));
8983        let digest_before = transcript_messages_digest(session.messages()).unwrap();
8984
8985        let outcome = session
8986            .reconcile_resumed_system_prompt(String::new(), None)
8987            .expect("reconcile");
8988
8989        assert_eq!(
8990            outcome,
8991            ResumedSystemPromptReconciliation::PreservedContinuation
8992        );
8993        assert_eq!(
8994            transcript_messages_digest(session.messages()).unwrap(),
8995            digest_before
8996        );
8997    }
8998
8999    #[test]
9000    fn reconcile_resumed_system_prompt_applies_shortened_base_with_recorded_prior() {
9001        // The separator is ordinary markdown: a base prompt may legitimately
9002        // contain it. Shortening the base must be APPLIED (audited rewrite),
9003        // not silently classified as a preserved context-append continuation.
9004        let full_base = format!("part one{SYSTEM_CONTEXT_SEPARATOR}part two");
9005        let mut session = Session::new();
9006        session.set_system_prompt(full_base.clone());
9007        session.push(Message::User(UserMessage::text("hello".to_string())));
9008        session
9009            .set_build_state(SessionBuildState {
9010                assembled_system_prompt: Some(full_base),
9011                ..Default::default()
9012            })
9013            .expect("build state");
9014
9015        let outcome = session
9016            .reconcile_resumed_system_prompt("part one".to_string(), None)
9017            .expect("reconcile");
9018
9019        assert_eq!(outcome, ResumedSystemPromptReconciliation::RewrittenBase);
9020        assert_eq!(leading_system_content(&session), "part one");
9021    }
9022
9023    #[test]
9024    fn reconcile_resumed_system_prompt_applies_shortened_base_without_context_provenance() {
9025        // No recorded prior base, no applied records, and the persisted
9026        // prompt's mutation provenance is not a runtime context append: the
9027        // machine rejects the structural-extends continuation, so the
9028        // shortened base is applied instead of silently ignored.
9029        let full_base = format!("part one{SYSTEM_CONTEXT_SEPARATOR}part two");
9030        let mut session = Session::new();
9031        session.set_system_prompt(full_base);
9032        session.push(Message::User(UserMessage::text("hello".to_string())));
9033
9034        let outcome = session
9035            .reconcile_resumed_system_prompt("part one".to_string(), None)
9036            .expect("reconcile");
9037
9038        assert_eq!(outcome, ResumedSystemPromptReconciliation::RewrittenBase);
9039        assert_eq!(leading_system_content(&session), "part one");
9040    }
9041
9042    #[test]
9043    fn reconcile_resumed_system_prompt_preserves_appended_prompt_without_applied_records() {
9044        // The runtime persistence path sweeps applied records and pre-0.7.15
9045        // rows have no recorded assembled base. The typed
9046        // RuntimeContextAppend provenance on the persisted message still
9047        // admits the continuation through the machine fast path.
9048        let mut session = resumed_session_with_context_appended_prompt("base prompt");
9049        session
9050            .set_system_context_state(SessionSystemContextState::default())
9051            .expect("sweep applied records");
9052        let digest_before = transcript_messages_digest(session.messages()).unwrap();
9053
9054        let outcome = session
9055            .reconcile_resumed_system_prompt("base prompt".to_string(), None)
9056            .expect("reconcile");
9057
9058        assert_eq!(
9059            outcome,
9060            ResumedSystemPromptReconciliation::PreservedContinuation
9061        );
9062        assert_eq!(
9063            transcript_messages_digest(session.messages()).unwrap(),
9064            digest_before
9065        );
9066    }
9067
9068    #[test]
9069    fn reconcile_resumed_system_prompt_clears_orphaned_applied_records_on_tail_drop() {
9070        let mut session = resumed_session_with_context_appended_prompt("base prompt");
9071        // An out-of-band prompt mutation makes the applied records'
9072        // re-render no longer reproduce the persisted content (and no
9073        // assembled base was recorded): the tail is unverifiable and must be
9074        // dropped by the rewrite.
9075        session.set_system_prompt(format!(
9076            "mutated base{SYSTEM_CONTEXT_SEPARATOR}stale-looking tail"
9077        ));
9078
9079        let outcome = session
9080            .reconcile_resumed_system_prompt("new base prompt".to_string(), None)
9081            .expect("reconcile");
9082
9083        assert_eq!(outcome, ResumedSystemPromptReconciliation::RewrittenBase);
9084        assert_eq!(leading_system_content(&session), "new base prompt");
9085        let state = session.system_context_state().unwrap_or_default();
9086        assert!(
9087            state.applied.is_empty(),
9088            "orphaned applied records must be cleared so the context stays restorable"
9089        );
9090        assert!(
9091            state.seen.is_empty(),
9092            "orphaned idempotency keys must be cleared so keyed re-sends re-apply"
9093        );
9094
9095        // A host re-send of the same keyed append restores the context
9096        // instead of deduplicating against the dropped application.
9097        session.append_system_context_blocks(std::slice::from_ref(&roster_append()));
9098        assert!(
9099            leading_system_content(&session).contains("peer roster: lead-1, w-1"),
9100            "re-sent keyed context must re-apply after the drop"
9101        );
9102    }
9103
9104    #[test]
9105    fn append_system_context_blocks_renders_pre_marked_pending_context() {
9106        let accepted_at = SystemTime::UNIX_EPOCH;
9107        let mut state = SessionSystemContextState::default();
9108        state
9109            .stage_append(
9110                &AppendSystemContextRequest {
9111                    content: crate::lifecycle::run_primitive::CoreRenderable::text(
9112                        "Apply this staged context at the request boundary.".to_string(),
9113                    ),
9114                    source: Some("rpc/session_inject_context".to_string()),
9115                    idempotency_key: Some("ctx-boundary".to_string()),
9116                    source_kind: SystemContextSource::Normal,
9117                    peer_response_terminal: None,
9118                },
9119                accepted_at,
9120            )
9121            .expect("append should stage");
9122        let pending = state.pending.clone();
9123        state.mark_pending_applied();
9124        let mut session = Session::new();
9125        session
9126            .set_system_context_state(state)
9127            .expect("state should serialize");
9128
9129        session.append_system_context_blocks(&pending);
9130
9131        let system_prompt = session
9132            .messages()
9133            .first()
9134            .and_then(|message| match message {
9135                Message::System(system) => Some(system.content.as_str()),
9136                _ => None,
9137            })
9138            .unwrap_or_default();
9139        assert!(system_prompt.contains("Apply this staged context at the request boundary."));
9140        let state = session
9141            .system_context_state()
9142            .expect("append should persist typed context state");
9143        assert_eq!(state.applied.len(), 1);
9144        assert_eq!(
9145            state.seen["ctx-boundary"].state,
9146            SeenSystemContextState::Applied
9147        );
9148    }
9149
9150    #[test]
9151    fn append_system_context_blocks_renders_pre_marked_context_without_idempotency_key() {
9152        let accepted_at = SystemTime::UNIX_EPOCH;
9153        let mut state = SessionSystemContextState::default();
9154        state
9155            .stage_append(
9156                &AppendSystemContextRequest {
9157                    content: crate::lifecycle::run_primitive::CoreRenderable::text(
9158                        "Apply this unkeyed staged context at the request boundary.".to_string(),
9159                    ),
9160                    source: Some("rpc/session_inject_context".to_string()),
9161                    idempotency_key: None,
9162                    source_kind: SystemContextSource::Normal,
9163                    peer_response_terminal: None,
9164                },
9165                accepted_at,
9166            )
9167            .expect("append should stage");
9168        let pending = state.pending.clone();
9169        state.mark_pending_applied();
9170        let mut session = Session::new();
9171        session
9172            .set_system_context_state(state)
9173            .expect("state should serialize");
9174
9175        session.append_system_context_blocks(&pending);
9176
9177        let system_prompt = session
9178            .messages()
9179            .first()
9180            .and_then(|message| match message {
9181                Message::System(system) => Some(system.content.as_str()),
9182                _ => None,
9183            })
9184            .unwrap_or_default();
9185        assert!(
9186            system_prompt.contains("Apply this unkeyed staged context at the request boundary.")
9187        );
9188    }
9189
9190    /// K5 invariant: the typed `CoreRenderable` travels end-to-end through
9191    /// staging — the pending append stores the renderable itself, and the
9192    /// ONE lowering to prompt text happens at the transcript render seam.
9193    #[test]
9194    fn staged_system_context_carries_typed_renderable_to_render_seam() {
9195        use crate::lifecycle::run_primitive::CoreRenderable;
9196
9197        let accepted_at = SystemTime::UNIX_EPOCH;
9198        let mut state = SessionSystemContextState::default();
9199        let renderable = CoreRenderable::Json {
9200            value: serde_json::json!({"alert": "disk-full", "severity": 2}),
9201        };
9202        state
9203            .stage_append(
9204                &AppendSystemContextRequest {
9205                    content: renderable.clone(),
9206                    source: Some("ops/monitor".to_string()),
9207                    idempotency_key: Some("alert-1".to_string()),
9208                    source_kind: SystemContextSource::Normal,
9209                    peer_response_terminal: None,
9210                },
9211                accepted_at,
9212            )
9213            .expect("typed renderable append should stage");
9214
9215        // The pending append owns the typed renderable — no pre-flattened
9216        // text shadow exists anywhere on the staging path.
9217        assert_eq!(state.pending.len(), 1);
9218        assert_eq!(state.pending[0].content, renderable);
9219
9220        // Lowering happens exactly once, at the render seam, via the single
9221        // canonical projection.
9222        let rendered = render_system_context_block(&state.pending[0]);
9223        assert!(rendered.starts_with(SYSTEM_CONTEXT_RENDER_LABEL));
9224        assert!(
9225            rendered.contains(renderable.render_text().trim()),
9226            "render seam must lower via CoreRenderable::render_text: {rendered}"
9227        );
9228    }
9229
9230    #[test]
9231    fn append_system_context_blocks_skips_duplicate_idempotency_key() {
9232        let first = PendingSystemContextAppend {
9233            content: crate::lifecycle::run_primitive::CoreRenderable::text(
9234                "Authoritative peer token is birch seventeen.".to_string(),
9235            ),
9236            source: Some("peer_response_terminal:analyst:req-1".to_string()),
9237            idempotency_key: Some("req-1".to_string()),
9238            source_kind: SystemContextSource::Normal,
9239            peer_response_terminal: None,
9240            accepted_at: SystemTime::UNIX_EPOCH,
9241        };
9242        let duplicate = PendingSystemContextAppend {
9243            accepted_at: SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1),
9244            ..first.clone()
9245        };
9246        let mut session = Session::new();
9247
9248        session.append_system_context_blocks(std::slice::from_ref(&first));
9249        session.append_system_context_blocks(std::slice::from_ref(&duplicate));
9250
9251        let state = session
9252            .system_context_state()
9253            .expect("append should persist typed context state");
9254        assert_eq!(state.applied, vec![first]);
9255        let system_prompt = session
9256            .messages()
9257            .first()
9258            .and_then(|message| match message {
9259                Message::System(system) => Some(system.content.as_str()),
9260                _ => None,
9261            })
9262            .unwrap_or_default();
9263        assert_eq!(
9264            system_prompt
9265                .matches("Authoritative peer token is birch seventeen.")
9266                .count(),
9267            1
9268        );
9269    }
9270
9271    #[test]
9272    fn append_system_context_blocks_skips_conflicting_duplicate_idempotency_key() {
9273        let first = PendingSystemContextAppend {
9274            content: crate::lifecycle::run_primitive::CoreRenderable::text(
9275                "Authoritative peer token is birch seventeen.".to_string(),
9276            ),
9277            source: Some("peer_response_terminal:analyst:req-1".to_string()),
9278            idempotency_key: Some("req-1".to_string()),
9279            source_kind: SystemContextSource::Normal,
9280            peer_response_terminal: None,
9281            accepted_at: SystemTime::UNIX_EPOCH,
9282        };
9283        let conflicting = PendingSystemContextAppend {
9284            content: crate::lifecycle::run_primitive::CoreRenderable::text(
9285                "Conflicting peer token should not reach the prompt.".to_string(),
9286            ),
9287            accepted_at: SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1),
9288            ..first.clone()
9289        };
9290        let mut session = Session::new();
9291
9292        session.append_system_context_blocks(std::slice::from_ref(&first));
9293        session.append_system_context_blocks(std::slice::from_ref(&conflicting));
9294
9295        let state = session
9296            .system_context_state()
9297            .expect("append should persist typed context state");
9298        assert_eq!(state.applied, vec![first]);
9299        let system_prompt = session
9300            .messages()
9301            .first()
9302            .and_then(|message| match message {
9303                Message::System(system) => Some(system.content.as_str()),
9304                _ => None,
9305            })
9306            .unwrap_or_default();
9307        assert!(system_prompt.contains("Authoritative peer token is birch seventeen."));
9308        assert!(!system_prompt.contains("Conflicting peer token should not reach the prompt."));
9309    }
9310
9311    // ------------------------------------------------------------------
9312    // T9/T10: realtime transcript lane materialization.
9313    //
9314    // The display-text lane (`AssistantTextDelta`) materializes as
9315    // `AssistantBlock::Text`; the spoken-transcript lane
9316    // (`AssistantTranscriptDelta`) materializes as
9317    // `AssistantBlock::Transcript { source: TranscriptSource::Spoken }`.
9318    // These regressions pin both flushes and prove the materializer
9319    // dispatches on the per-item `TranscriptLane`.
9320    // ------------------------------------------------------------------
9321
9322    #[test]
9323    fn realtime_transcript_assistant_transcript_delta_materializes_transcript_block() {
9324        let mut session = Session::new();
9325
9326        let delta = RealtimeTranscriptEvent::AssistantTranscriptDelta {
9327            response_id: "resp_spoken".to_string(),
9328            delta_id: "evt_delta_spoken_1".to_string(),
9329            item_id: "item_spoken".to_string(),
9330            previous_item_id: None,
9331            content_index: 0,
9332            delta: "I said hi".to_string(),
9333        };
9334        assert!(
9335            session.append_realtime_transcript_event(delta).is_inert(),
9336            "delta alone is inert until turn-completed flushes"
9337        );
9338
9339        let terminal = RealtimeTranscriptEvent::AssistantTurnCompleted {
9340            response_id: "resp_spoken".to_string(),
9341            stop_reason: StopReason::EndTurn,
9342            usage: Usage::default(),
9343        };
9344        let outcome = session.append_realtime_transcript_event(terminal);
9345        assert_eq!(outcome.materialized_messages.len(), 1);
9346
9347        // T9/T10: must be a Transcript block, NOT Text.
9348        let messages = session.messages();
9349        assert_eq!(messages.len(), 1);
9350        match &messages[0] {
9351            Message::BlockAssistant(assistant) => {
9352                assert_eq!(assistant.blocks.len(), 1);
9353                match &assistant.blocks[0] {
9354                    AssistantBlock::Transcript { text, source, .. } => {
9355                        assert_eq!(text, "I said hi");
9356                        assert_eq!(*source, crate::types::TranscriptSource::Spoken);
9357                    }
9358                    other => unreachable!(
9359                        "AssistantTranscriptDelta must materialize as AssistantBlock::Transcript, got {other:?}"
9360                    ),
9361                }
9362            }
9363            other => unreachable!("expected BlockAssistant message, got {other:?}"),
9364        }
9365    }
9366
9367    #[test]
9368    fn round4_cc4_in_flight_response_ids_lists_distinct_unmaterialized_responses() {
9369        // CC4 (Round-4 architectural reconciliation): the helper that
9370        // powers `signal_turn_interrupt`'s cross-layer fan-out must
9371        // return every distinct provider response_id that has at least
9372        // one unmaterialized assistant item, EXCLUDING already-discarded
9373        // responses and EXCLUDING the user role.
9374        let mut session = Session::new();
9375
9376        // Two transcript-delta items on resp_a (different content_index
9377        // ranges), one on resp_b. resp_c gets a delta and is then
9378        // discarded explicitly via AssistantTurnInterrupted.
9379        for (i, response_id) in [
9380            ("resp_a", "resp_a"),
9381            ("resp_a_extra", "resp_a"),
9382            ("resp_b", "resp_b"),
9383            ("resp_c", "resp_c"),
9384        ]
9385        .iter()
9386        .enumerate()
9387        {
9388            let event = RealtimeTranscriptEvent::AssistantTranscriptDelta {
9389                response_id: response_id.1.to_string(),
9390                delta_id: format!("delta_{i}"),
9391                item_id: response_id.0.to_string(),
9392                previous_item_id: None,
9393                content_index: 0,
9394                delta: "x".to_string(),
9395            };
9396            let _ = session.append_realtime_transcript_event(event);
9397        }
9398
9399        // Discard resp_c — it should not appear in the in-flight list.
9400        let _ = session.append_realtime_transcript_event(
9401            RealtimeTranscriptEvent::AssistantTurnInterrupted {
9402                response_id: "resp_c".to_string(),
9403            },
9404        );
9405
9406        // User-role item should never appear (CC4 only fans interrupts
9407        // to assistant responses).
9408        let _ = session.append_realtime_transcript_event(
9409            RealtimeTranscriptEvent::UserTranscriptFinal {
9410                item_id: "u_item".to_string(),
9411                previous_item_id: None,
9412                content_index: 0,
9413                text: "hi".to_string(),
9414            },
9415        );
9416
9417        let in_flight = session.in_flight_realtime_assistant_response_ids();
9418        assert!(in_flight.contains(&"resp_a".to_string()), "{in_flight:?}");
9419        assert!(in_flight.contains(&"resp_b".to_string()), "{in_flight:?}");
9420        assert!(
9421            !in_flight.contains(&"resp_c".to_string()),
9422            "discarded response must not appear in in_flight: {in_flight:?}"
9423        );
9424        // resp_a appears exactly once even though two items reference it.
9425        assert_eq!(
9426            in_flight.iter().filter(|r| *r == "resp_a").count(),
9427            1,
9428            "distinct response_ids only: {in_flight:?}"
9429        );
9430    }
9431
9432    #[test]
9433    fn round4_cc2_assistant_turn_completed_after_transcript_deltas_materializes_transcript() {
9434        // CC2 (Round-4 architectural reconciliation): once
9435        // `signal_turn_completed` synthesizes
9436        // `RealtimeTranscriptEvent::AssistantTurnCompleted`, the staging
9437        // materializer commits every staged transcript-delta item for
9438        // that response_id as `AssistantBlock::Transcript { Spoken }`.
9439        // This pins the production end-to-end shape the sink relies on.
9440        let mut session = Session::new();
9441
9442        let delta = RealtimeTranscriptEvent::AssistantTranscriptDelta {
9443            response_id: "resp_cc2".to_string(),
9444            delta_id: "delta_cc2_1".to_string(),
9445            item_id: "item_cc2".to_string(),
9446            previous_item_id: None,
9447            content_index: 0,
9448            delta: "hello world".to_string(),
9449        };
9450        assert!(session.append_realtime_transcript_event(delta).is_inert());
9451
9452        // Pre-completion: in-flight list reports resp_cc2.
9453        assert_eq!(
9454            session.in_flight_realtime_assistant_response_ids(),
9455            vec!["resp_cc2".to_string()]
9456        );
9457
9458        let outcome = session.append_realtime_transcript_event(
9459            RealtimeTranscriptEvent::AssistantTurnCompleted {
9460                response_id: "resp_cc2".to_string(),
9461                stop_reason: StopReason::EndTurn,
9462                usage: Usage::default(),
9463            },
9464        );
9465        assert_eq!(outcome.materialized_messages.len(), 1);
9466
9467        // Post-completion: in-flight list is empty (item is materialized).
9468        assert!(
9469            session
9470                .in_flight_realtime_assistant_response_ids()
9471                .is_empty(),
9472            "materialized items must not appear in in_flight_realtime_assistant_response_ids"
9473        );
9474
9475        let messages = session.messages();
9476        let assistant = messages.iter().find_map(|m| match m {
9477            Message::BlockAssistant(a) => Some(a),
9478            _ => None,
9479        });
9480        let assistant = assistant.expect("assistant block message expected");
9481        assert_eq!(assistant.blocks.len(), 1);
9482        assert!(matches!(
9483            &assistant.blocks[0],
9484            AssistantBlock::Transcript {
9485                source: crate::types::TranscriptSource::Spoken,
9486                ..
9487            }
9488        ));
9489    }
9490
9491    #[test]
9492    fn realtime_transcript_assistant_text_delta_still_materializes_text_block() {
9493        // Counter-regression: the display-text lane must continue to
9494        // produce `AssistantBlock::Text` after T9/T10. Prevents an
9495        // accidental cross-lane flip.
9496        let mut session = Session::new();
9497
9498        let delta = RealtimeTranscriptEvent::AssistantTextDelta {
9499            response_id: "resp_display".to_string(),
9500            delta_id: "evt_delta_display_1".to_string(),
9501            item_id: "item_display".to_string(),
9502            previous_item_id: None,
9503            content_index: 0,
9504            delta: "I wrote".to_string(),
9505        };
9506        let _ = session.append_realtime_transcript_event(delta);
9507
9508        let terminal = RealtimeTranscriptEvent::AssistantTurnCompleted {
9509            response_id: "resp_display".to_string(),
9510            stop_reason: StopReason::EndTurn,
9511            usage: Usage::default(),
9512        };
9513        let outcome = session.append_realtime_transcript_event(terminal);
9514        assert_eq!(outcome.materialized_messages.len(), 1);
9515
9516        let messages = session.messages();
9517        match &messages[0] {
9518            Message::BlockAssistant(assistant) => match &assistant.blocks[0] {
9519                AssistantBlock::Text { text, .. } => assert_eq!(text, "I wrote"),
9520                other => unreachable!(
9521                    "AssistantTextDelta must keep materializing AssistantBlock::Text, got {other:?}"
9522                ),
9523            },
9524            other => unreachable!("expected BlockAssistant message, got {other:?}"),
9525        }
9526    }
9527
9528    #[test]
9529    fn round4_cc7_mixed_response_persists_text_and_transcript_in_order() {
9530        // CC7 (Round-4 adversarial-verifier follow-up): a single mixed-modality
9531        // realtime response that emits BOTH display-text deltas
9532        // (`AssistantTextDelta`) AND spoken-transcript deltas
9533        // (`AssistantTranscriptDelta`) under the same response_id must
9534        // materialize as ONE `Message::BlockAssistant` whose `blocks` field
9535        // contains exactly two ordered entries:
9536        //   1. AssistantBlock::Text       (display-text lane)
9537        //   2. AssistantBlock::Transcript { source: Spoken } (spoken lane)
9538        // Pre-fix the materializer emitted one Message::BlockAssistant per
9539        // staged item, splitting the mixed response into two messages.
9540        //
9541        // This test drives the production materializer end-to-end: deltas
9542        // stage in `SessionRealtimeTranscriptState`; `AssistantTurnCompleted`
9543        // triggers the materializer; canonical history is the assertion
9544        // surface — exactly the same code path that
9545        // `SessionServiceProjectionSink::signal_turn_completed` invokes via
9546        // `runtime.append_realtime_transcript_event` in production.
9547        let mut session = Session::new();
9548
9549        // Provider-arrival order: display first, then spoken.
9550        let display_a = RealtimeTranscriptEvent::AssistantTextDelta {
9551            response_id: "resp_mixed_1".to_string(),
9552            delta_id: "delta_disp_1".to_string(),
9553            item_id: "item_display".to_string(),
9554            previous_item_id: None,
9555            content_index: 0,
9556            delta: "Here's the report:".to_string(),
9557        };
9558        assert!(
9559            session
9560                .append_realtime_transcript_event(display_a)
9561                .is_inert()
9562        );
9563
9564        let display_b = RealtimeTranscriptEvent::AssistantTextDelta {
9565            response_id: "resp_mixed_1".to_string(),
9566            delta_id: "delta_disp_2".to_string(),
9567            item_id: "item_display".to_string(),
9568            previous_item_id: None,
9569            content_index: 0,
9570            delta: " (still writing)".to_string(),
9571        };
9572        assert!(
9573            session
9574                .append_realtime_transcript_event(display_b)
9575                .is_inert()
9576        );
9577
9578        // Spoken items chain after the display item to mirror provider
9579        // arrival semantics — `previous_item_id` carries arrival ordering
9580        // that the materializer must preserve as block ordering inside the
9581        // single emitted message.
9582        let spoken_a = RealtimeTranscriptEvent::AssistantTranscriptDelta {
9583            response_id: "resp_mixed_1".to_string(),
9584            delta_id: "delta_spoken_1".to_string(),
9585            item_id: "item_spoken".to_string(),
9586            previous_item_id: Some("item_display".to_string()),
9587            content_index: 0,
9588            delta: "I'm reading the report aloud:".to_string(),
9589        };
9590        assert!(
9591            session
9592                .append_realtime_transcript_event(spoken_a)
9593                .is_inert()
9594        );
9595
9596        let spoken_b = RealtimeTranscriptEvent::AssistantTranscriptDelta {
9597            response_id: "resp_mixed_1".to_string(),
9598            delta_id: "delta_spoken_2".to_string(),
9599            item_id: "item_spoken".to_string(),
9600            previous_item_id: Some("item_display".to_string()),
9601            content_index: 0,
9602            delta: " sentence two.".to_string(),
9603        };
9604        assert!(
9605            session
9606                .append_realtime_transcript_event(spoken_b)
9607                .is_inert()
9608        );
9609
9610        // TurnCompleted triggers the materializer to flush all staged items
9611        // for this response_id into ONE BlockAssistant message.
9612        let outcome = session.append_realtime_transcript_event(
9613            RealtimeTranscriptEvent::AssistantTurnCompleted {
9614                response_id: "resp_mixed_1".to_string(),
9615                stop_reason: StopReason::EndTurn,
9616                usage: Usage {
9617                    input_tokens: 11,
9618                    output_tokens: 22,
9619                    cache_creation_tokens: None,
9620                    cache_read_tokens: None,
9621                },
9622            },
9623        );
9624        // Materializer reports two staged items got materialized.
9625        assert_eq!(outcome.materialized_messages.len(), 2);
9626
9627        // Canonical history MUST contain exactly ONE BlockAssistant message
9628        // (the CC7 fix: mixed lanes interleave into one message, not two).
9629        let messages = session.messages();
9630        let assistants: Vec<&BlockAssistantMessage> = messages
9631            .iter()
9632            .filter_map(|m| match m {
9633                Message::BlockAssistant(a) => Some(a),
9634                _ => None,
9635            })
9636            .collect();
9637        assert_eq!(
9638            assistants.len(),
9639            1,
9640            "mixed display+spoken response under one response_id must produce exactly ONE BlockAssistant message, got: {assistants:?}"
9641        );
9642        let assistant = assistants[0];
9643        assert_eq!(
9644            assistant.blocks.len(),
9645            2,
9646            "mixed response message must carry both blocks: {:?}",
9647            assistant.blocks
9648        );
9649
9650        // Block 0: display-text (concatenated deltas).
9651        match &assistant.blocks[0] {
9652            AssistantBlock::Text { text, .. } => {
9653                assert_eq!(text, "Here's the report: (still writing)");
9654            }
9655            other => unreachable!(
9656                "first block must be AssistantBlock::Text (display lane), got {other:?}"
9657            ),
9658        }
9659        // Block 1: spoken transcript (concatenated deltas), tagged Spoken.
9660        match &assistant.blocks[1] {
9661            AssistantBlock::Transcript { text, source, .. } => {
9662                assert_eq!(text, "I'm reading the report aloud: sentence two.");
9663                assert_eq!(*source, crate::types::TranscriptSource::Spoken);
9664            }
9665            other => unreachable!(
9666                "second block must be AssistantBlock::Transcript {{ source: Spoken }}, got {other:?}"
9667            ),
9668        }
9669
9670        // Usage was recorded once for the turn.
9671        assert_eq!(session.usage.input_tokens, 11);
9672        assert_eq!(session.usage.output_tokens, 22);
9673    }
9674
9675    #[test]
9676    fn round5_r55_mixed_response_barge_in_preserves_display_drops_spoken() {
9677        // R5-5 (Round-5 contract update): barge-in MUST filter staged items
9678        // by lane — `Spoken` is invalidated (the user spoke over the audio
9679        // they were hearing) but `Display` survives as committed history
9680        // (sideband display text from the same response is not "spoken
9681        // over"). Round-4's `round4_cc7_mixed_response_barge_in_discards_*`
9682        // pinned the wrong invariant; this test replaces it.
9683        //
9684        // Architectural decision: `AssistantTurnInterrupted` is terminal for
9685        // the response on the realtime-staging path — any later
9686        // `AssistantTurnCompleted { stop_reason: Cancelled }` short-circuits
9687        // via the `discarded_assistant_response_ids` guard. So the
9688        // Interrupted handler must seed a synthetic
9689        // `assistant_completions` entry (`StopReason::Cancelled`,
9690        // `Usage::default()`) so retained Display items materialize
9691        // immediately rather than stranding forever.
9692        let mut session = Session::new();
9693
9694        let display = RealtimeTranscriptEvent::AssistantTextDelta {
9695            response_id: "resp_mixed_2".to_string(),
9696            delta_id: "delta_disp_1".to_string(),
9697            item_id: "item_display_2".to_string(),
9698            previous_item_id: None,
9699            content_index: 0,
9700            delta: "Working on the report...".to_string(),
9701        };
9702        let _ = session.append_realtime_transcript_event(display);
9703
9704        let spoken = RealtimeTranscriptEvent::AssistantTranscriptDelta {
9705            response_id: "resp_mixed_2".to_string(),
9706            delta_id: "delta_spoken_1".to_string(),
9707            item_id: "item_spoken_2".to_string(),
9708            previous_item_id: Some("item_display_2".to_string()),
9709            content_index: 0,
9710            delta: "I'm reading the report".to_string(),
9711        };
9712        let _ = session.append_realtime_transcript_event(spoken);
9713
9714        // Barge-in arrives BEFORE TurnCompleted. The Display item with
9715        // staged content materializes immediately under the synthetic
9716        // Cancelled completion.
9717        let outcome = session.append_realtime_transcript_event(
9718            RealtimeTranscriptEvent::AssistantTurnInterrupted {
9719                response_id: "resp_mixed_2".to_string(),
9720            },
9721        );
9722        assert_eq!(
9723            outcome.materialized_messages.len(),
9724            1,
9725            "Display lane item must materialize on Interrupted: {outcome:?}"
9726        );
9727
9728        // A late `AssistantTurnCompleted` (the provider's response.done
9729        // emitted after cancel) must be a no-op: the Display item is
9730        // already materialized; the Spoken item was dropped at Interrupted.
9731        let late_completion = session.append_realtime_transcript_event(
9732            RealtimeTranscriptEvent::AssistantTurnCompleted {
9733                response_id: "resp_mixed_2".to_string(),
9734                stop_reason: StopReason::Cancelled,
9735                usage: Usage::default(),
9736            },
9737        );
9738        assert_eq!(
9739            late_completion.materialized_messages.len(),
9740            0,
9741            "post-barge-in TurnCompleted must not resurrect anything"
9742        );
9743
9744        // Canonical history: exactly one BlockAssistant carrying the
9745        // Display text (no Transcript block — Spoken was dropped).
9746        let messages = session.messages();
9747        let assistants: Vec<&BlockAssistantMessage> = messages
9748            .iter()
9749            .filter_map(|m| match m {
9750                Message::BlockAssistant(a) => Some(a),
9751                _ => None,
9752            })
9753            .collect();
9754        assert_eq!(
9755            assistants.len(),
9756            1,
9757            "barge-in must commit exactly one BlockAssistant containing the Display lane: {assistants:?}"
9758        );
9759        let assistant = assistants[0];
9760        assert_eq!(assistant.blocks.len(), 1, "blocks: {:?}", assistant.blocks);
9761        match &assistant.blocks[0] {
9762            AssistantBlock::Text { text, .. } => {
9763                assert_eq!(text, "Working on the report...");
9764            }
9765            other => {
9766                unreachable!("Display lane must materialize as AssistantBlock::Text, got {other:?}")
9767            }
9768        }
9769        // No Transcript block — Spoken lane was dropped.
9770        assert!(
9771            !assistant
9772                .blocks
9773                .iter()
9774                .any(|b| matches!(b, AssistantBlock::Transcript { .. })),
9775            "Spoken lane must be dropped on barge-in"
9776        );
9777
9778        // The in-flight tracker reports the response as no longer in flight
9779        // (the Display item is materialized; the Spoken item is skipped).
9780        assert!(
9781            !session
9782                .in_flight_realtime_assistant_response_ids()
9783                .contains(&"resp_mixed_2".to_string()),
9784            "barged-in response must not appear in in_flight_realtime_assistant_response_ids"
9785        );
9786    }
9787
9788    #[test]
9789    fn round5_r55_barge_in_preserves_display_lane_drops_spoken() {
9790        // R5-5 unit test: pin the lane-filter behavior at the staged-item
9791        // level (no chained predecessor). One Display item, one Spoken item,
9792        // both unchained, both staged before Interrupted.
9793        let mut session = Session::new();
9794
9795        let _ =
9796            session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
9797                response_id: "resp_a".to_string(),
9798                delta_id: "delta_d_1".to_string(),
9799                item_id: "item_display".to_string(),
9800                previous_item_id: None,
9801                content_index: 0,
9802                delta: "display-text".to_string(),
9803            });
9804        let _ = session.append_realtime_transcript_event(
9805            RealtimeTranscriptEvent::AssistantTranscriptDelta {
9806                response_id: "resp_a".to_string(),
9807                delta_id: "delta_s_1".to_string(),
9808                item_id: "item_spoken".to_string(),
9809                previous_item_id: None,
9810                content_index: 0,
9811                delta: "spoken-transcript".to_string(),
9812            },
9813        );
9814
9815        let outcome = session.append_realtime_transcript_event(
9816            RealtimeTranscriptEvent::AssistantTurnInterrupted {
9817                response_id: "resp_a".to_string(),
9818            },
9819        );
9820        // Display materializes, Spoken does not.
9821        assert_eq!(outcome.materialized_messages.len(), 1);
9822
9823        let messages = session.messages();
9824        let assistants: Vec<&BlockAssistantMessage> = messages
9825            .iter()
9826            .filter_map(|m| match m {
9827                Message::BlockAssistant(a) => Some(a),
9828                _ => None,
9829            })
9830            .collect();
9831        assert_eq!(assistants.len(), 1);
9832        // Single Text block (the Display lane) — no Transcript.
9833        assert_eq!(assistants[0].blocks.len(), 1);
9834        match &assistants[0].blocks[0] {
9835            AssistantBlock::Text { text, .. } => assert_eq!(text, "display-text"),
9836            other => unreachable!("expected Text, got {other:?}"),
9837        }
9838    }
9839
9840    #[test]
9841    fn round5_r55_barge_in_finalizes_retained_display_into_committed_block() {
9842        // R5-5: the architectural decision — Interrupted is terminal for the
9843        // response. Display lane must commit at Interrupted time, not wait
9844        // on a hypothetical AssistantTurnCompleted that may never arrive
9845        // (or arrives Cancelled and short-circuits).
9846        let mut session = Session::new();
9847
9848        let _ =
9849            session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
9850                response_id: "resp_a".to_string(),
9851                delta_id: "delta_d_1".to_string(),
9852                item_id: "item_display".to_string(),
9853                previous_item_id: None,
9854                content_index: 0,
9855                delta: "committed-display-text".to_string(),
9856            });
9857
9858        // Pre-condition: nothing committed yet.
9859        assert!(session.messages().is_empty());
9860
9861        let outcome = session.append_realtime_transcript_event(
9862            RealtimeTranscriptEvent::AssistantTurnInterrupted {
9863                response_id: "resp_a".to_string(),
9864            },
9865        );
9866        assert_eq!(
9867            outcome.materialized_messages.len(),
9868            1,
9869            "Interrupted must finalize retained Display lane immediately"
9870        );
9871
9872        // Post-condition: BlockAssistant in canonical history, no Transcript.
9873        let messages = session.messages();
9874        assert_eq!(messages.len(), 1);
9875        match &messages[0] {
9876            Message::BlockAssistant(assistant) => {
9877                assert_eq!(assistant.blocks.len(), 1);
9878                match &assistant.blocks[0] {
9879                    AssistantBlock::Text { text, .. } => {
9880                        assert_eq!(text, "committed-display-text");
9881                    }
9882                    other => unreachable!("expected Text, got {other:?}"),
9883                }
9884            }
9885            other => unreachable!("expected BlockAssistant, got {other:?}"),
9886        }
9887    }
9888
9889    #[test]
9890    fn round5_r56_truncation_promotes_default_lane_item_to_spoken() {
9891        // R5-6: when truncation is the first content-bearing event for an
9892        // item (no prior delta), the staged item's lane MUST be promoted to
9893        // Spoken so the materializer commits as `AssistantBlock::Transcript`.
9894        // Without the explicit promotion, the lane stays `Display` (the
9895        // default) and the heard audio transcript persists as
9896        // `AssistantBlock::Text`.
9897        let mut session = Session::new();
9898
9899        let _ = session.append_realtime_transcript_event(
9900            RealtimeTranscriptEvent::AssistantTranscriptTruncated {
9901                response_id: "resp_a".to_string(),
9902                item_id: "item_a".to_string(),
9903                content_index: 0,
9904                text: "what was actually heard".to_string(),
9905            },
9906        );
9907
9908        let outcome = session.append_realtime_transcript_event(
9909            RealtimeTranscriptEvent::AssistantTurnCompleted {
9910                response_id: "resp_a".to_string(),
9911                stop_reason: StopReason::EndTurn,
9912                usage: Usage::default(),
9913            },
9914        );
9915        assert_eq!(outcome.materialized_messages.len(), 1);
9916
9917        assert_eq!(session.messages().len(), 1);
9918        match &session.messages()[0] {
9919            Message::BlockAssistant(assistant) => {
9920                assert_eq!(assistant.blocks.len(), 1);
9921                match &assistant.blocks[0] {
9922                    AssistantBlock::Transcript { text, source, .. } => {
9923                        assert_eq!(text, "what was actually heard");
9924                        assert_eq!(*source, crate::types::TranscriptSource::Spoken);
9925                    }
9926                    other => unreachable!(
9927                        "truncation-only path must materialize as AssistantBlock::Transcript, got {other:?}"
9928                    ),
9929                }
9930            }
9931            other => unreachable!("expected BlockAssistant, got {other:?}"),
9932        }
9933    }
9934
9935    #[test]
9936    fn round5_r56_truncation_after_display_delta_is_no_op_keeping_display_content() {
9937        // R5-6 edge case: a Display delta arrived first and staged Display
9938        // content; a truncation event arrives for the SAME item id
9939        // (provider bug — truncation only applies to spoken/audio output).
9940        // Contract: the staged Display content must NOT be clobbered by
9941        // the truncation text. `promote_item_lane` keeps the existing
9942        // Display lane and emits a `tracing::warn!`; the truncation arm
9943        // sees the lane stayed Display and skips the segment-write.
9944        let mut session = Session::new();
9945
9946        let _ =
9947            session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
9948                response_id: "resp_a".to_string(),
9949                delta_id: "delta_d_1".to_string(),
9950                item_id: "item_a".to_string(),
9951                previous_item_id: None,
9952                content_index: 0,
9953                delta: "display-text-from-delta".to_string(),
9954            });
9955
9956        let _ = session.append_realtime_transcript_event(
9957            RealtimeTranscriptEvent::AssistantTranscriptTruncated {
9958                response_id: "resp_a".to_string(),
9959                item_id: "item_a".to_string(),
9960                content_index: 0,
9961                text: "spoken-truncation-text".to_string(),
9962            },
9963        );
9964
9965        let _ = session.append_realtime_transcript_event(
9966            RealtimeTranscriptEvent::AssistantTurnCompleted {
9967                response_id: "resp_a".to_string(),
9968                stop_reason: StopReason::EndTurn,
9969                usage: Usage::default(),
9970            },
9971        );
9972
9973        // Display content survives unchanged — the truncation text was
9974        // refused. Materializes as `AssistantBlock::Text` (Display lane).
9975        assert_eq!(session.messages().len(), 1);
9976        match &session.messages()[0] {
9977            Message::BlockAssistant(assistant) => {
9978                assert_eq!(assistant.blocks.len(), 1);
9979                match &assistant.blocks[0] {
9980                    AssistantBlock::Text { text, .. } => {
9981                        assert_eq!(text, "display-text-from-delta");
9982                    }
9983                    other => unreachable!(
9984                        "Display content must survive misrouted truncation, got {other:?}"
9985                    ),
9986                }
9987            }
9988            other => unreachable!("expected BlockAssistant, got {other:?}"),
9989        }
9990    }
9991
9992    /// R5-6 sibling: a Spoken-classified item (transcript-truncation
9993    /// arrived first and locked the lane to Spoken) must reject a later
9994    /// `AssistantTextDelta` rather than silently appending the Display
9995    /// text into the Spoken-locked content_segment. Pre-fix the delta
9996    /// arm called `promote_item_lane` and unconditionally pushed the
9997    /// delta — clobbering the lane invariant. Post-fix the delta is
9998    /// dropped (warn fires) and the Spoken-truncation text survives.
9999    #[test]
10000    fn round5_r56_sibling_display_delta_skipped_on_spoken_item() {
10001        let mut session = Session::new();
10002
10003        // Truncation arrives first and locks the item to the Spoken lane.
10004        let _ = session.append_realtime_transcript_event(
10005            RealtimeTranscriptEvent::AssistantTranscriptTruncated {
10006                response_id: "resp_a".to_string(),
10007                item_id: "item_a".to_string(),
10008                content_index: 0,
10009                text: "what was actually heard".to_string(),
10010            },
10011        );
10012
10013        // A Display delta arrives later for the SAME item id (provider
10014        // lane-classification bug). It MUST be dropped.
10015        let _ =
10016            session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
10017                response_id: "resp_a".to_string(),
10018                delta_id: "delta_d_1".to_string(),
10019                item_id: "item_a".to_string(),
10020                previous_item_id: None,
10021                content_index: 0,
10022                delta: "should-not-appear".to_string(),
10023            });
10024
10025        let _ = session.append_realtime_transcript_event(
10026            RealtimeTranscriptEvent::AssistantTurnCompleted {
10027                response_id: "resp_a".to_string(),
10028                stop_reason: StopReason::EndTurn,
10029                usage: Usage::default(),
10030            },
10031        );
10032
10033        // The Spoken-truncation text survives intact; no Display text
10034        // leaked into the Spoken lane content.
10035        assert_eq!(session.messages().len(), 1);
10036        match &session.messages()[0] {
10037            Message::BlockAssistant(assistant) => {
10038                assert_eq!(assistant.blocks.len(), 1);
10039                match &assistant.blocks[0] {
10040                    AssistantBlock::Transcript { text, source, .. } => {
10041                        assert_eq!(text, "what was actually heard");
10042                        assert_eq!(*source, crate::types::TranscriptSource::Spoken);
10043                    }
10044                    other => unreachable!(
10045                        "Spoken-locked item must materialize as Transcript, got {other:?}"
10046                    ),
10047                }
10048            }
10049            other => unreachable!("expected BlockAssistant, got {other:?}"),
10050        }
10051    }
10052
10053    /// R5-6 sibling: a Display-classified item (a Display delta arrived
10054    /// first and locked the lane to Display) must reject a later
10055    /// `AssistantTranscriptDelta` rather than appending the Spoken text
10056    /// into the Display-locked content_segment. Pre-fix the transcript
10057    /// delta arm called `promote_item_lane` and unconditionally pushed —
10058    /// silently mixing a Spoken stream into a Display block.
10059    #[test]
10060    fn round5_r56_sibling_spoken_delta_skipped_on_display_item() {
10061        let mut session = Session::new();
10062
10063        // Display delta arrives first and locks the item to the Display lane.
10064        let _ =
10065            session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
10066                response_id: "resp_a".to_string(),
10067                delta_id: "delta_d_1".to_string(),
10068                item_id: "item_a".to_string(),
10069                previous_item_id: None,
10070                content_index: 0,
10071                delta: "display-locked-text".to_string(),
10072            });
10073
10074        // A spoken-transcript delta arrives later for the SAME item id
10075        // (provider lane-classification bug). It MUST be dropped.
10076        let _ = session.append_realtime_transcript_event(
10077            RealtimeTranscriptEvent::AssistantTranscriptDelta {
10078                response_id: "resp_a".to_string(),
10079                delta_id: "delta_s_1".to_string(),
10080                item_id: "item_a".to_string(),
10081                previous_item_id: None,
10082                content_index: 0,
10083                delta: "should-not-appear".to_string(),
10084            },
10085        );
10086
10087        let _ = session.append_realtime_transcript_event(
10088            RealtimeTranscriptEvent::AssistantTurnCompleted {
10089                response_id: "resp_a".to_string(),
10090                stop_reason: StopReason::EndTurn,
10091                usage: Usage::default(),
10092            },
10093        );
10094
10095        // The Display text survives intact; no Spoken text leaked in.
10096        assert_eq!(session.messages().len(), 1);
10097        match &session.messages()[0] {
10098            Message::BlockAssistant(assistant) => {
10099                assert_eq!(assistant.blocks.len(), 1);
10100                match &assistant.blocks[0] {
10101                    AssistantBlock::Text { text, .. } => {
10102                        assert_eq!(text, "display-locked-text");
10103                    }
10104                    other => {
10105                        unreachable!("Display-locked item must materialize as Text, got {other:?}")
10106                    }
10107                }
10108            }
10109            other => unreachable!("expected BlockAssistant, got {other:?}"),
10110        }
10111    }
10112
10113    /// R5-7: a late `AssistantTranscriptFinalText` arriving AFTER
10114    /// `AssistantTurnCompleted` already materialized the item must NOT
10115    /// mutate `content_segments` and must NOT rewrite the canonical
10116    /// `Message::BlockAssistant` (append-only history is a stronger
10117    /// invariant than typed text repair). The committed message keeps
10118    /// the delta-accumulated text; the late final is dropped with a
10119    /// warn; the materializer outcome is inert (no new messages).
10120    #[test]
10121    fn round5_r57_late_final_text_after_turn_completed_warns_and_skips() {
10122        let mut session = Session::new();
10123
10124        // Delta accumulates partial text on the Spoken lane.
10125        let _ = session.append_realtime_transcript_event(
10126            RealtimeTranscriptEvent::AssistantTranscriptDelta {
10127                response_id: "resp_a".to_string(),
10128                delta_id: "delta_s_1".to_string(),
10129                item_id: "item_a".to_string(),
10130                previous_item_id: None,
10131                content_index: 0,
10132                delta: "delta-accumulated".to_string(),
10133            },
10134        );
10135
10136        // TurnCompleted materializes the item with the delta-accumulated text.
10137        let commit_outcome = session.append_realtime_transcript_event(
10138            RealtimeTranscriptEvent::AssistantTurnCompleted {
10139                response_id: "resp_a".to_string(),
10140                stop_reason: StopReason::EndTurn,
10141                usage: Usage::default(),
10142            },
10143        );
10144        assert_eq!(commit_outcome.materialized_messages.len(), 1);
10145
10146        // Late FinalText arrives — provider-side ordering bug. It MUST
10147        // be dropped: no canonical message rewrite, no segment mutation,
10148        // outcome is inert.
10149        let late_outcome = session.append_realtime_transcript_event(
10150            RealtimeTranscriptEvent::AssistantTranscriptFinalText {
10151                response_id: "resp_a".to_string(),
10152                item_id: "item_a".to_string(),
10153                content_index: 0,
10154                text: "authoritative-final-that-must-not-land".to_string(),
10155            },
10156        );
10157        assert!(
10158            late_outcome.is_inert(),
10159            "late FinalText after materialization must produce inert outcome"
10160        );
10161
10162        // Canonical history: still one message with the original
10163        // delta-accumulated text — NOT the authoritative final.
10164        assert_eq!(session.messages().len(), 1);
10165        match &session.messages()[0] {
10166            Message::BlockAssistant(assistant) => {
10167                assert_eq!(assistant.blocks.len(), 1);
10168                match &assistant.blocks[0] {
10169                    AssistantBlock::Transcript { text, .. } => {
10170                        assert_eq!(
10171                            text, "delta-accumulated",
10172                            "canonical message must preserve delta-accumulated text; \
10173                             append-only history forbids late FinalText repair"
10174                        );
10175                    }
10176                    other => unreachable!("expected Transcript, got {other:?}"),
10177                }
10178            }
10179            other => unreachable!("expected BlockAssistant, got {other:?}"),
10180        }
10181    }
10182
10183    fn metadata_seam_session_metadata() -> SessionMetadata {
10184        SessionMetadata {
10185            schema_version: SESSION_METADATA_SCHEMA_VERSION,
10186            model: "test-model".to_string(),
10187            max_tokens: 1024,
10188            structured_output_retries: 2,
10189            provider: Provider::Anthropic,
10190            self_hosted_server_id: None,
10191            provider_params: None,
10192            tooling: SessionTooling::default(),
10193            keep_alive: false,
10194            comms_name: Some("team/reviewer/alice".to_string()),
10195            peer_meta: None,
10196            realm_id: None,
10197            instance_id: None,
10198            backend: None,
10199            config_generation: None,
10200            auth_binding: None,
10201            mob_member_binding: Some(crate::MobMemberBinding {
10202                mob_id: "team".to_string(),
10203                role: "reviewer".to_string(),
10204                member: "alice".to_string(),
10205            }),
10206        }
10207    }
10208
10209    /// Lockstep pin: the metadata-only partial decode must read the exact
10210    /// envelope that `SessionSerde` writes. If a field rename or serde-shape
10211    /// change lands on the full envelope without the partial decoder
10212    /// following, this test fails.
10213    #[test]
10214    fn session_metadata_document_lockstep_with_full_envelope() {
10215        let mut session = Session::new();
10216        session.push(Message::User(UserMessage::text("hello".to_string())));
10217        session
10218            .set_session_metadata(metadata_seam_session_metadata())
10219            .expect("session metadata should persist");
10220        session
10221            .set_lifecycle_terminal(SessionLifecycleTerminal::Archived)
10222            .expect("lifecycle terminal should persist");
10223
10224        let bytes = serde_json::to_vec(&session).expect("session should serialize");
10225        let document = session_metadata_document_from_slice(&bytes)
10226            .expect("partial decode must accept the canonical envelope");
10227
10228        assert_eq!(document.session_id(), session.id());
10229        assert_eq!(
10230            document.session_metadata_value(),
10231            session.metadata().get(SESSION_METADATA_KEY),
10232            "partial decode must project the identical raw session-metadata value"
10233        );
10234        assert_eq!(
10235            document.lifecycle_terminal_value(),
10236            session.metadata().get(SESSION_LIFECYCLE_TERMINAL_KEY),
10237            "partial decode must project the identical raw lifecycle-terminal value"
10238        );
10239
10240        let view = document
10241            .try_into_view()
10242            .expect("typed view must decode from the partial document");
10243        let full_view =
10244            PersistedSessionMetadataView::try_from_session(&session).expect("full-session view");
10245        assert_eq!(view.session_id, full_view.session_id);
10246        assert_eq!(
10247            view.session_metadata.as_ref().map(|m| m.model.clone()),
10248            full_view.session_metadata.as_ref().map(|m| m.model.clone())
10249        );
10250        assert_eq!(
10251            view.mob_member_binding(),
10252            full_view.mob_member_binding(),
10253            "typed binding must be identical across the two decode paths"
10254        );
10255        assert_eq!(
10256            view.lifecycle_terminal,
10257            Some(SessionLifecycleTerminal::Archived)
10258        );
10259        assert_eq!(
10260            full_view.lifecycle_terminal,
10261            Some(SessionLifecycleTerminal::Archived)
10262        );
10263    }
10264
10265    /// The metadata-only partial decode fails closed on an unsupported
10266    /// envelope version — same contract as the full deserializer.
10267    #[test]
10268    fn session_metadata_document_fails_closed_on_envelope_version() {
10269        let session = Session::new();
10270        let mut value = serde_json::to_value(&session).expect("session should serialize");
10271        value["version"] = serde_json::json!(SESSION_VERSION + 999);
10272        let bytes = serde_json::to_vec(&value).expect("mangled envelope should serialize");
10273
10274        session_metadata_document_from_slice(&bytes)
10275            .expect_err("an unsupported envelope version must fail the partial decode closed");
10276    }
10277
10278    /// Corrupt values under either reserved key are a read FAULT for the
10279    /// metadata view — never coalesced into "absent".
10280    #[test]
10281    fn persisted_session_metadata_view_fails_closed_on_corrupt_values() {
10282        let session_id = SessionId::new();
10283
10284        let mut corrupt_metadata = serde_json::Map::new();
10285        corrupt_metadata.insert(SESSION_METADATA_KEY.to_string(), serde_json::json!(42));
10286        PersistedSessionMetadataView::try_from_metadata_map(session_id.clone(), &corrupt_metadata)
10287            .expect_err("corrupt session_metadata must fail the view decode closed");
10288
10289        let mut corrupt_terminal = serde_json::Map::new();
10290        corrupt_terminal.insert(
10291            SESSION_LIFECYCLE_TERMINAL_KEY.to_string(),
10292            serde_json::json!("definitely-not-a-terminal"),
10293        );
10294        PersistedSessionMetadataView::try_from_metadata_map(session_id, &corrupt_terminal)
10295            .expect_err("corrupt lifecycle terminal must fail the view decode closed");
10296    }
10297
10298    /// Absent reserved keys decode as typed absence through the view.
10299    #[test]
10300    fn persisted_session_metadata_view_reads_absent_facts_as_none() {
10301        let view = PersistedSessionMetadataView::try_from_metadata_map(
10302            SessionId::new(),
10303            &serde_json::Map::new(),
10304        )
10305        .expect("empty metadata map must decode");
10306        assert!(view.session_metadata.is_none());
10307        assert!(view.lifecycle_terminal.is_none());
10308        assert!(view.mob_member_binding().is_none());
10309    }
10310}