Skip to main content

meerkat_core/
session.rs

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