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