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::lifecycle::{CoreBoundaryStageError, RunId};
16use crate::peer_meta::PeerMeta;
17use crate::realtime_transcript::{
18    RealtimeTranscriptApplyOutcome, RealtimeTranscriptEvent, RealtimeUserContentIdentity,
19    SESSION_REALTIME_TRANSCRIPT_STATE_KEY,
20};
21use crate::realtime_transcript_revision::{self, SessionRealtimeTranscriptState};
22use crate::service::{AppendSystemContextRequest, MobToolAuthorityContext};
23use crate::session_durable_config_authority;
24use crate::time_compat::SystemTime;
25#[cfg(target_arch = "wasm32")]
26use crate::tokio;
27use crate::tool_scope::ToolFilter;
28use crate::types::{
29    AssistantBlock, BlockAssistantMessage, ContentBlock, ContentInput, Message, SessionId,
30    StopReason, ToolDef, ToolName, ToolProvenance, ToolResult, Usage, UserMessage,
31};
32use serde::{Deserialize, Deserializer, Serialize, Serializer};
33use sha2::{Digest, Sha256};
34use std::collections::{BTreeMap, BTreeSet, HashMap};
35use std::sync::Arc;
36
37/// Current session format version.
38///
39/// The persisted `version` byte is mandatory and fail-closed: a stored row
40/// with a missing or non-current version (including pre-typed-owner v0/v1
41/// rows) is rejected at the serde boundary by the generated persistence
42/// version authority — it never silently defaults or upgrades on read.
43pub use crate::generated::session_persistence_version_authority::SESSION_VERSION;
44
45/// Current `SessionMetadata` schema version. Distinct from `SESSION_VERSION`
46/// so `SessionMetadata` can evolve independently of the Session envelope.
47///
48/// Mandatory and fail-closed on read, same contract as `SESSION_VERSION`.
49pub use crate::generated::session_persistence_version_authority::SESSION_METADATA_SCHEMA_VERSION;
50
51/// Current session format version accepted by generated persistence authority.
52pub fn session_version() -> u32 {
53    session_persistence_version_authority::session_envelope_version()
54}
55
56/// Current `SessionMetadata` schema version accepted by generated persistence authority.
57pub fn session_metadata_schema_version() -> u32 {
58    session_persistence_version_authority::session_metadata_schema_version()
59}
60
61/// Typed transcript replacement used to create an edited fork.
62///
63/// Replacements never mutate the source session in place. The owning service
64/// applies this to a forked prefix, producing a new `SessionId`.
65#[derive(Debug, Clone, Serialize, Deserialize)]
66#[serde(tag = "type", rename_all = "snake_case")]
67pub enum TranscriptReplacement {
68    /// Replace the addressed message with a full canonical message.
69    Message { message: Message },
70    /// Replace one user-message content block.
71    UserContentBlock {
72        block_index: usize,
73        block: ContentBlock,
74    },
75    /// Replace one block in a block-assistant message.
76    AssistantBlock {
77        block_index: usize,
78        block: AssistantBlock,
79    },
80    /// Replace one content block inside one tool-result payload.
81    ToolResultContentBlock {
82        result_index: usize,
83        block_index: usize,
84        block: ContentBlock,
85    },
86}
87
88/// Session metadata key for the typed transcript revision graph head.
89pub const SESSION_TRANSCRIPT_HISTORY_STATE_KEY: &str = "session_transcript_history_state_v1";
90
91/// Storage-representation witness for transcript history that an incremental
92/// session store keeps out of line.
93///
94/// A full session carries [`SESSION_TRANSCRIPT_HISTORY_STATE_KEY`]. A slim
95/// incremental projection carries this digest instead, allowing the typed
96/// checkpoint digest to bind the same semantic history without rehydrating
97/// every retained revision on each read. Only typed store code may author it.
98pub const SESSION_TRANSCRIPT_HISTORY_CHECKPOINT_DIGEST_KEY: &str =
99    "session_transcript_history_checkpoint_digest_v1";
100
101/// A concrete transcript span selected for same-session rewrite.
102#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
103#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
104#[serde(tag = "type", rename_all = "snake_case")]
105pub enum TranscriptRewriteSelection {
106    /// Pre-semantic-marker range retained for source/API compatibility and
107    /// decoding prior durable records. New commits canonicalize this input to
108    /// [`TranscriptRewriteSelection::EditMessageRange`] before persistence.
109    MessageRange { start: usize, end: usize },
110    /// Current typed ordinary-edit semantic.
111    EditMessageRange { range: TranscriptEditRewriteRange },
112    /// Replace a full transcript from a core-validated compaction rebuild.
113    ///
114    /// The range payload has no public constructor. New values are minted only
115    /// by the validated compaction path; deserialization exists solely for the
116    /// durable transcript graph and is revalidated against its retained bodies.
117    CompactionMessageRange { range: CompactionRewriteRange },
118}
119
120/// Opaque current-format range carried by an ordinary transcript edit.
121#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
122#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
123pub struct TranscriptEditRewriteRange {
124    start: usize,
125    end: usize,
126}
127
128/// Opaque range carried by the typed compaction rewrite semantic.
129#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
130#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
131pub struct CompactionRewriteRange {
132    start: usize,
133    end: usize,
134}
135
136/// Canonical semantic class of a transcript rewrite.
137#[derive(Debug, Clone, Copy, PartialEq, Eq)]
138pub enum TranscriptRewriteSemantic {
139    /// Ordinary same-session edit.
140    Edit,
141    /// Core-validated context compaction.
142    Compaction,
143}
144
145impl TranscriptRewriteSelection {
146    /// Return the selected half-open message range without exposing the
147    /// authority-bearing representation used to classify the rewrite.
148    pub fn bounds(&self) -> (usize, usize) {
149        match self {
150            Self::MessageRange { start, end } => (*start, *end),
151            Self::EditMessageRange { range } => (range.start, range.end),
152            Self::CompactionMessageRange { range } => (range.start, range.end),
153        }
154    }
155
156    pub fn semantic(&self) -> TranscriptRewriteSemantic {
157        match self {
158            Self::MessageRange { .. } | Self::EditMessageRange { .. } => {
159                TranscriptRewriteSemantic::Edit
160            }
161            Self::CompactionMessageRange { .. } => TranscriptRewriteSemantic::Compaction,
162        }
163    }
164
165    fn into_current_edit_semantic(self) -> Self {
166        match self {
167            Self::MessageRange { start, end } => Self::EditMessageRange {
168                range: TranscriptEditRewriteRange { start, end },
169            },
170            current => current,
171        }
172    }
173
174    fn is_legacy_untyped(&self) -> bool {
175        matches!(self, Self::MessageRange { .. })
176    }
177
178    fn validated_compaction(
179        start: usize,
180        end: usize,
181        _authority: &crate::agent::compact::ValidatedCompactionRewrite,
182    ) -> Self {
183        Self::CompactionMessageRange {
184            range: CompactionRewriteRange { start, end },
185        }
186    }
187
188    fn migrated_legacy_compaction(start: usize, end: usize) -> Self {
189        Self::CompactionMessageRange {
190            range: CompactionRewriteRange { start, end },
191        }
192    }
193
194    #[cfg(test)]
195    pub(crate) fn typed_compaction_for_test(start: usize, end: usize) -> Self {
196        Self::CompactionMessageRange {
197            range: CompactionRewriteRange { start, end },
198        }
199    }
200}
201
202/// Audit annotation carried with a transcript rewrite commit.
203///
204/// The free-form kind is for review, debugging, and provenance only. It never
205/// classifies a rewrite as compaction; [`TranscriptRewriteSelection`] owns that
206/// semantic through its opaque typed compaction range.
207#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
208#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
209#[serde(rename_all = "snake_case")]
210pub struct TranscriptRewriteReason {
211    pub kind: String,
212    #[serde(default, skip_serializing_if = "Option::is_none")]
213    pub note: Option<String>,
214}
215
216impl TranscriptRewriteReason {
217    pub fn new(kind: impl Into<String>) -> Self {
218        Self {
219            kind: kind.into(),
220            note: None,
221        }
222    }
223}
224
225/// Typed rewrite-commit reason for a resume-time base-prompt refresh
226/// committed by [`Session::reconcile_resumed_system_prompt`].
227pub const RESUME_SYSTEM_PROMPT_REFRESH_REWRITE_REASON: &str = "resume-system-prompt-refresh";
228
229/// Typed outcome of [`Session::reconcile_resumed_system_prompt`].
230#[derive(Debug, Clone, Copy, PartialEq, Eq)]
231pub enum ResumedSystemPromptReconciliation {
232    /// The persisted System message already carries the assembled base prompt
233    /// (identical, or extended only by runtime system-context appends). The
234    /// transcript was left untouched, so the resumed projection digests to
235    /// the persisted revision.
236    PreservedContinuation,
237    /// The assembled base prompt diverged from the persisted System message;
238    /// the replacement was committed as a typed transcript rewrite so the
239    /// first post-resume persist proves a graph edge from the persisted head.
240    RewrittenBase,
241    /// The resumed transcript has no leading System message and the assembled
242    /// prompt is empty — nothing to reconcile.
243    NoChange,
244}
245
246impl std::fmt::Display for TranscriptRewriteReason {
247    /// Human-facing projection consumed by revision-list reads. The typed
248    /// `{kind, note}` audit value is retained; this rendering is derived only
249    /// and never supplies rewrite semantic authority.
250    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
251        match &self.note {
252            Some(note) => write!(f, "{}: {note}", self.kind),
253            None => f.write_str(&self.kind),
254        }
255    }
256}
257
258/// Immutable rewrite commit that advances a session transcript head.
259#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
260#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
261#[serde(rename_all = "snake_case")]
262pub struct TranscriptRewriteCommit {
263    pub parent_revision: String,
264    pub revision: String,
265    pub selection: TranscriptRewriteSelection,
266    pub original_span_digest: String,
267    pub replacement_digest: String,
268    pub messages_before: usize,
269    pub messages_after: usize,
270    pub reason: TranscriptRewriteReason,
271    #[serde(default, skip_serializing_if = "Option::is_none")]
272    pub actor: Option<String>,
273    #[cfg_attr(feature = "schema", schemars(with = "SchemaSystemTime"))]
274    pub committed_at: SystemTime,
275}
276
277/// Immutable transcript revision body retained by the session-local graph.
278#[derive(Debug, Clone, Serialize, Deserialize)]
279#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
280#[serde(rename_all = "snake_case")]
281pub struct TranscriptRevisionBody {
282    pub revision: String,
283    #[serde(default, skip_serializing_if = "Option::is_none")]
284    pub parent_revision: Option<String>,
285    #[cfg_attr(feature = "schema", schemars(with = "Vec<serde_json::Value>"))]
286    pub messages: Vec<Message>,
287    #[cfg_attr(feature = "schema", schemars(with = "SchemaSystemTime"))]
288    pub created_at: SystemTime,
289}
290
291#[cfg(feature = "schema")]
292#[allow(dead_code)]
293#[derive(schemars::JsonSchema)]
294#[schemars(rename = "SystemTime")]
295struct SchemaSystemTime {
296    secs_since_epoch: u64,
297    nanos_since_epoch: u32,
298}
299
300/// Self-contained append-only transcript rewrite record.
301#[derive(Debug, Clone, Serialize)]
302#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
303#[serde(rename_all = "snake_case")]
304pub struct TranscriptRewriteRecord {
305    pub commit: TranscriptRewriteCommit,
306    pub parent_body: TranscriptRevisionBody,
307    pub revision_body: TranscriptRevisionBody,
308}
309
310impl<'de> Deserialize<'de> for TranscriptRewriteRecord {
311    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
312    where
313        D: Deserializer<'de>,
314    {
315        #[derive(Deserialize)]
316        #[serde(rename_all = "snake_case")]
317        struct Wire {
318            commit: TranscriptRewriteCommit,
319            parent_body: TranscriptRevisionBody,
320            revision_body: TranscriptRevisionBody,
321        }
322        let wire = Wire::deserialize(deserializer)?;
323        let mut revisions = vec![wire.parent_body, wire.revision_body];
324        let mut commits = vec![wire.commit];
325        heal_legacy_revision_strings(&mut revisions, &mut commits, None)
326            .map_err(serde::de::Error::custom)?;
327        heal_legacy_compaction_rewrite_semantics(&mut commits, &revisions);
328        let mut revisions = revisions.into_iter();
329        let parent_body = revisions
330            .next()
331            .ok_or_else(|| serde::de::Error::custom("rewrite record lost its parent body"))?;
332        let revision_body = revisions
333            .next()
334            .ok_or_else(|| serde::de::Error::custom("rewrite record lost its revision body"))?;
335        let commit = commits
336            .into_iter()
337            .next()
338            .ok_or_else(|| serde::de::Error::custom("rewrite record lost its commit"))?;
339        Ok(Self {
340            commit,
341            parent_body,
342            revision_body,
343        })
344    }
345}
346
347impl TranscriptRewriteRecord {
348    pub fn new(
349        commit: TranscriptRewriteCommit,
350        parent_body: TranscriptRevisionBody,
351        revision_body: TranscriptRevisionBody,
352    ) -> Result<Self, TranscriptEditError> {
353        validate_transcript_rewrite_record(&commit, &parent_body, &revision_body)?;
354        Ok(Self {
355            commit,
356            parent_body,
357            revision_body,
358        })
359    }
360}
361
362/// Typed session-local transcript revision graph state.
363#[derive(Debug, Clone, Serialize)]
364#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
365#[serde(rename_all = "snake_case")]
366pub struct TranscriptHistoryState {
367    pub head: String,
368    #[serde(default, skip_serializing_if = "Vec::is_empty")]
369    pub commits: Vec<TranscriptRewriteCommit>,
370    #[serde(default, skip_serializing_if = "Vec::is_empty")]
371    pub revisions: Vec<TranscriptRevisionBody>,
372}
373
374impl<'de> Deserialize<'de> for TranscriptHistoryState {
375    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
376    where
377        D: Deserializer<'de>,
378    {
379        #[derive(Deserialize)]
380        #[serde(rename_all = "snake_case")]
381        struct Wire {
382            head: String,
383            #[serde(default)]
384            commits: Vec<TranscriptRewriteCommit>,
385            #[serde(default)]
386            revisions: Vec<TranscriptRevisionBody>,
387        }
388        let wire = Wire::deserialize(deserializer)?;
389        let mut state = TranscriptHistoryState {
390            head: wire.head,
391            commits: wire.commits,
392            revisions: wire.revisions,
393        };
394        // Pre-parent-pointer v1 snapshots serialized each body as
395        // {created_at,messages,revision}. When every non-root body lacks a
396        // parent, the append order is the only lineage the old format
397        // carried; reconstruct that exact linear order before digest healing
398        // and full validation.
399        if state.revisions.len() > 1
400            && state
401                .revisions
402                .iter()
403                .skip(1)
404                .all(|body| body.parent_revision.is_none())
405        {
406            for index in 1..state.revisions.len() {
407                let parent = state.revisions[index - 1].revision.clone();
408                state.revisions[index].parent_revision = Some(parent);
409            }
410        }
411        // Fast path: a graph written by the current digest format has a head
412        // body whose content digest equals the head string; skip the heal.
413        let head_is_current = match state
414            .revisions
415            .iter()
416            .find(|body| body.revision == state.head)
417        {
418            Some(head_body) => {
419                transcript_messages_digest(&head_body.messages).map_err(serde::de::Error::custom)?
420                    == state.head
421            }
422            None => true,
423        };
424        if !head_is_current {
425            let TranscriptHistoryState {
426                head,
427                commits,
428                revisions,
429            } = &mut state;
430            heal_legacy_revision_strings(revisions, commits, Some(head))
431                .map_err(serde::de::Error::custom)?;
432        }
433        heal_legacy_compaction_rewrite_semantics(&mut state.commits, &state.revisions);
434        Ok(state)
435    }
436}
437
438impl TranscriptHistoryState {
439    /// Drop mechanical append-head snapshots while preserving every body that
440    /// is an endpoint of an audited rewrite plus the current live head.
441    ///
442    /// Ordinary appends previously accumulated a complete transcript body on
443    /// every message mutation once any rewrite had occurred. Those bodies are
444    /// not rewrite history and are never selected for restore. Repointing the
445    /// live head directly at the latest rewrite endpoint keeps the existing
446    /// full-body lineage validator intact after the intermediate append heads
447    /// are removed.
448    fn compact_mechanical_revision_bodies(&mut self) -> Result<(), TranscriptEditError> {
449        validate_transcript_history_state(self)?;
450
451        let mut retained = BTreeSet::from([self.head.clone()]);
452        for commit in &self.commits {
453            retained.insert(commit.parent_revision.clone());
454            retained.insert(commit.revision.clone());
455        }
456
457        let head_is_audited_endpoint = self
458            .commits
459            .iter()
460            .any(|commit| commit.parent_revision == self.head || commit.revision == self.head);
461        if !head_is_audited_endpoint
462            && let Some(last_commit) = self
463                .commits
464                .last()
465                .filter(|commit| commit.revision != self.head)
466            && let Some(head_body) = self
467                .revisions
468                .iter_mut()
469                .find(|body| body.revision == self.head)
470        {
471            head_body.parent_revision = Some(last_commit.revision.clone());
472        }
473
474        let mut seen = BTreeSet::new();
475        self.revisions
476            .retain(|body| retained.contains(&body.revision) && seen.insert(body.revision.clone()));
477
478        // The full graph was validated before any pruning, so corrupt bodies
479        // cannot be laundered by dropping them. The transformation changes no
480        // message, revision digest, commit, or audited endpoint: it only
481        // de-duplicates bodies by revision, removes non-endpoint mechanical
482        // bodies, and points an unaudited live head directly at the already
483        // validated latest commit. Re-hashing every retained transcript here
484        // would repeat the dominant snapshot cost without adding evidence.
485        Ok(())
486    }
487}
488
489/// Re-derive pre-0.7.14 (bookkeeping-inclusive) transcript revision strings to
490/// the current content-addressed format at the durable-format parse boundary.
491///
492/// Retained revision bodies carry their full message lists, so every legacy
493/// string can be re-verified against the bytes it was computed from. Only
494/// strings that verify under the legacy digest of their own retained body are
495/// rewritten; anything else is left untouched for the validators to reject
496/// exactly as they would have before.
497fn heal_legacy_revision_strings(
498    revisions: &mut [TranscriptRevisionBody],
499    commits: &mut [TranscriptRewriteCommit],
500    head: Option<&mut String>,
501) -> Result<(), serde_json::Error> {
502    let mut remap: BTreeMap<String, String> = BTreeMap::new();
503    for body in revisions.iter() {
504        let content = transcript_messages_digest(&body.messages)?;
505        if body.revision == content {
506            continue;
507        }
508        if body.revision == legacy_transcript_messages_digest(&body.messages)? {
509            remap.insert(body.revision.clone(), content);
510        }
511    }
512    if remap.is_empty() {
513        return Ok(());
514    }
515    for body in revisions.iter_mut() {
516        if let Some(current) = remap.get(&body.revision) {
517            body.revision = current.clone();
518        }
519        if let Some(parent) = body.parent_revision.as_ref()
520            && let Some(current) = remap.get(parent)
521        {
522            body.parent_revision = Some(current.clone());
523        }
524    }
525    for commit in commits.iter_mut() {
526        if let Some(current) = remap.get(&commit.parent_revision) {
527            commit.parent_revision = current.clone();
528        }
529        if let Some(current) = remap.get(&commit.revision) {
530            commit.revision = current.clone();
531        }
532        heal_legacy_commit_span_digests(commit, revisions)?;
533    }
534    if let Some(head) = head
535        && let Some(current) = remap.get(head.as_str())
536    {
537        *head = current.clone();
538    }
539    Ok(())
540}
541
542/// Re-derive a legacy commit's span digests from its retained bodies.
543///
544/// Span digests are only rewritten when the stored value verifies under the
545/// legacy digest of the same span; malformed commits keep their stored bytes
546/// so [`validate_transcript_rewrite_record`] rejects them unchanged.
547fn heal_legacy_commit_span_digests(
548    commit: &mut TranscriptRewriteCommit,
549    revisions: &[TranscriptRevisionBody],
550) -> Result<(), serde_json::Error> {
551    let Some(parent_body) = revisions
552        .iter()
553        .find(|body| body.revision == commit.parent_revision)
554    else {
555        return Ok(());
556    };
557    let Some(revision_body) = revisions
558        .iter()
559        .find(|body| body.revision == commit.revision)
560    else {
561        return Ok(());
562    };
563    let (start, end) = commit.selection.bounds();
564    if start > end || end > parent_body.messages.len() {
565        return Ok(());
566    }
567    let removed_len = end - start;
568    let Some(retained_len) = commit.messages_before.checked_sub(removed_len) else {
569        return Ok(());
570    };
571    let Some(replacement_len) = commit.messages_after.checked_sub(retained_len) else {
572        return Ok(());
573    };
574    let Some(replacement_end) = start.checked_add(replacement_len) else {
575        return Ok(());
576    };
577    if replacement_end > revision_body.messages.len() {
578        return Ok(());
579    }
580    let original_span = &parent_body.messages[start..end];
581    if commit.original_span_digest == legacy_transcript_messages_digest(original_span)? {
582        commit.original_span_digest = transcript_messages_digest(original_span)?;
583    }
584    let replacement_span = &revision_body.messages[start..replacement_end];
585    if commit.replacement_digest == legacy_transcript_messages_digest(replacement_span)? {
586        commit.replacement_digest = transcript_messages_digest(replacement_span)?;
587    }
588    Ok(())
589}
590
591/// Upgrade pre-semantic-field compaction records from retained typed transcript
592/// evidence, never from the free-form audit reason.
593///
594/// Old compaction commits used the generic `message_range` selection, but their
595/// revision body already carries the runtime-minted `CompactionSummary` role.
596/// A full-transcript, shrinking rewrite with exactly one such summary is the
597/// complete legacy witness. Other edits remain ordinary edits even when their
598/// display reason happens to say "compaction".
599fn heal_legacy_compaction_rewrite_semantics(
600    commits: &mut [TranscriptRewriteCommit],
601    revisions: &[TranscriptRevisionBody],
602) {
603    for commit in commits {
604        if !commit.selection.is_legacy_untyped() {
605            continue;
606        }
607        let (start, end) = commit.selection.bounds();
608        if start != 0
609            || end != commit.messages_before
610            || commit.messages_after >= commit.messages_before
611        {
612            continue;
613        }
614        let Some(parent) = revisions
615            .iter()
616            .find(|body| body.revision == commit.parent_revision)
617        else {
618            continue;
619        };
620        let Some(revision) = revisions
621            .iter()
622            .find(|body| body.revision == commit.revision)
623        else {
624            continue;
625        };
626        if parent.messages.len() != commit.messages_before
627            || revision.messages.len() != commit.messages_after
628        {
629            continue;
630        }
631        let summary_count = revision
632            .messages
633            .iter()
634            .filter(|message| {
635                matches!(message, Message::User(user) if user.transcript_role.is_compaction_summary())
636            })
637            .count();
638        if summary_count == 1 {
639            commit.selection = TranscriptRewriteSelection::migrated_legacy_compaction(start, end);
640        }
641    }
642}
643
644impl TranscriptHistoryState {
645    /// Rebuild transcript revision graph state from append-only rewrite records.
646    pub fn from_rewrite_records<I>(records: I) -> Result<Option<Self>, TranscriptEditError>
647    where
648        I: IntoIterator<Item = TranscriptRewriteRecord>,
649    {
650        let mut state: Option<Self> = None;
651        for record in records {
652            validate_transcript_rewrite_record(
653                &record.commit,
654                &record.parent_body,
655                &record.revision_body,
656            )?;
657            let state = state.get_or_insert_with(|| Self {
658                head: record.commit.parent_revision.clone(),
659                commits: Vec::new(),
660                revisions: Vec::new(),
661            });
662            if record.commit.parent_revision != state.head {
663                if revision_body_extends_head(&record.parent_body, &state.revisions, &state.head)? {
664                    state.head = record.commit.parent_revision.clone();
665                } else {
666                    return Err(TranscriptEditError::HistoryStateMalformed(format!(
667                        "rewrite record parent {} does not extend transcript head {}",
668                        record.commit.parent_revision, state.head
669                    )));
670                }
671            }
672            if !state
673                .revisions
674                .iter()
675                .any(|body| body.revision == record.parent_body.revision)
676            {
677                state.revisions.push(record.parent_body);
678            }
679            if !state
680                .revisions
681                .iter()
682                .any(|body| body.revision == record.revision_body.revision)
683            {
684                state.revisions.push(record.revision_body);
685            }
686            state.head = record.commit.revision.clone();
687            state.commits.push(record.commit);
688        }
689        Ok(state)
690    }
691}
692
693/// Invalid typed transcript edit request.
694#[derive(Debug, Clone, thiserror::Error)]
695pub enum TranscriptEditError {
696    #[error("message index {message_index} out of bounds for {message_count} messages")]
697    MessageIndexOutOfBounds {
698        message_index: usize,
699        message_count: usize,
700    },
701    #[error("{block_kind} index {block_index} out of bounds for {block_count} blocks")]
702    BlockIndexOutOfBounds {
703        block_kind: &'static str,
704        block_index: usize,
705        block_count: usize,
706    },
707    #[error("replacement expected {expected} at message index {message_index}, found {actual}")]
708    MessageRoleMismatch {
709        message_index: usize,
710        expected: &'static str,
711        actual: &'static str,
712    },
713    #[error("invalid transcript rewrite range {start}..{end} for {message_count} messages")]
714    InvalidRewriteRange {
715        start: usize,
716        end: usize,
717        message_count: usize,
718    },
719    #[error("transcript rewrite does not change transcript revision {revision}")]
720    NoOpRewrite { revision: String },
721    #[error("transcript rewrite parent revision mismatch: expected {expected}, actual {actual}")]
722    RevisionConflict { expected: String, actual: String },
723    #[error("transcript history state is malformed: {0}")]
724    HistoryStateMalformed(String),
725    #[error("invalid transcript shape after rewrite: {0}")]
726    InvalidTranscriptShape(String),
727}
728
729fn message_role_name(message: &Message) -> &'static str {
730    match message {
731        Message::System(_) => "system",
732        Message::SystemNotice(_) => "system_notice",
733        Message::User(_) => "user",
734        Message::BlockAssistant(_) => "block_assistant",
735        Message::ToolResults { .. } => "tool_results",
736    }
737}
738
739fn assistant_tool_use_ids(message: &Message) -> Vec<&str> {
740    match message {
741        Message::BlockAssistant(assistant) => assistant
742            .blocks
743            .iter()
744            .filter_map(|block| match block {
745                AssistantBlock::ToolUse { id, .. } => Some(id.as_str()),
746                _ => None,
747            })
748            .collect(),
749        _ => Vec::new(),
750    }
751}
752
753fn validate_transcript_tool_result_shape(messages: &[Message]) -> Result<(), TranscriptEditError> {
754    for (index, message) in messages.iter().enumerate() {
755        if let Message::ToolResults { results, .. } = message {
756            let Some(previous) = index
757                .checked_sub(1)
758                .and_then(|previous| messages.get(previous))
759            else {
760                return Err(TranscriptEditError::InvalidTranscriptShape(format!(
761                    "tool_results at message {index} has no preceding assistant tool-use message"
762                )));
763            };
764            let expected = assistant_tool_use_ids(previous);
765            if expected.is_empty() {
766                return Err(TranscriptEditError::InvalidTranscriptShape(format!(
767                    "tool_results at message {index} follows {}, not an assistant tool-use message",
768                    message_role_name(previous)
769                )));
770            }
771            let actual = results
772                .iter()
773                .map(|result| result.tool_use_id.as_str())
774                .collect::<Vec<_>>();
775            let actual_set = actual.iter().copied().collect::<BTreeSet<_>>();
776            let expected_set = expected.iter().copied().collect::<BTreeSet<_>>();
777            if actual.len() != actual_set.len() {
778                return Err(TranscriptEditError::InvalidTranscriptShape(format!(
779                    "tool_results at message {index} contains duplicate tool ids"
780                )));
781            }
782            if expected.len() != expected_set.len() {
783                return Err(TranscriptEditError::InvalidTranscriptShape(format!(
784                    "assistant tool-use message before tool_results at message {index} contains duplicate tool ids"
785                )));
786            }
787            if actual_set != expected_set {
788                return Err(TranscriptEditError::InvalidTranscriptShape(format!(
789                    "tool_results at message {index} resolve tool ids {actual_set:?}, expected {expected_set:?}"
790                )));
791            }
792        }
793
794        let tool_use_ids = assistant_tool_use_ids(message);
795        if tool_use_ids.is_empty() {
796            continue;
797        }
798        let Some(next) = messages.get(index + 1) else {
799            return Err(TranscriptEditError::InvalidTranscriptShape(format!(
800                "assistant tool-use message {index} has no following tool_results"
801            )));
802        };
803        if !matches!(next, Message::ToolResults { .. }) {
804            return Err(TranscriptEditError::InvalidTranscriptShape(format!(
805                "assistant tool-use message {index} is followed by {}, not tool_results",
806                message_role_name(next)
807            )));
808        }
809    }
810    Ok(())
811}
812
813fn canonicalize_digest_image_blocks(blocks: &mut [crate::types::ContentBlock]) {
814    for block in blocks.iter_mut() {
815        if let crate::types::ContentBlock::Image {
816            media_type,
817            data: crate::types::ImageData::Inline { data },
818        } = block
819        {
820            // An inline image hydrates from its blob's own bytes, so its
821            // content-addressed identity equals the blob id the store minted.
822            let blob_id = crate::blob::content_blob_id(media_type, data);
823            *block = crate::types::ContentBlock::Image {
824                media_type: media_type.clone(),
825                data: crate::types::ImageData::Blob { blob_id },
826            };
827        }
828    }
829}
830
831/// Canonicalize image payloads to their content-addressed blob identity so the
832/// transcript digest is invariant to inline-vs-blob representation.
833///
834/// The same image hydrated inline for model execution and externalized to a
835/// blob for persistence must share one transcript revision; otherwise a live
836/// session and its durable snapshot would appear "diverged" purely because of
837/// image storage form, and a runtime-backed live session would be discarded as
838/// stale mid-turn.
839fn canonicalize_message_images_for_digest(messages: &[Message]) -> Vec<Message> {
840    let mut canonical = messages.to_vec();
841    for message in &mut canonical {
842        match message {
843            Message::User(user) => canonicalize_digest_image_blocks(&mut user.content),
844            Message::ToolResults { results, .. } => {
845                for result in results.iter_mut() {
846                    canonicalize_digest_image_blocks(&mut result.content);
847                }
848            }
849            Message::SystemNotice(notice) => {
850                for block in &mut notice.blocks {
851                    match block {
852                        crate::types::SystemNoticeBlock::Comms { content, .. }
853                        | crate::types::SystemNoticeBlock::ExternalEvent { content, .. } => {
854                            canonicalize_digest_image_blocks(content);
855                        }
856                        _ => {}
857                    }
858                }
859            }
860            _ => {}
861        }
862    }
863    canonical
864}
865
866/// Canonical checkpoint representation of the retained transcript graph.
867///
868/// Revision bodies are content-addressed by `revision`; their cached parent
869/// pointers and construction timestamps are storage bookkeeping. The ordered
870/// commit log remains intact because it is durable audit/selection history.
871pub(crate) fn canonicalize_checkpoint_history_value(
872    value: &serde_json::Value,
873) -> Result<serde_json::Value, serde_json::Error> {
874    let state: TranscriptHistoryState = serde_json::from_value(value.clone())?;
875    let mut revisions = state
876        .revisions
877        .into_iter()
878        .map(|body| {
879            serde_json::json!({
880                "revision": body.revision,
881                "messages": canonicalize_messages_for_digest(&body.messages),
882            })
883        })
884        .collect::<Vec<_>>();
885    revisions.sort_by(|left, right| {
886        left.get("revision")
887            .and_then(serde_json::Value::as_str)
888            .cmp(&right.get("revision").and_then(serde_json::Value::as_str))
889    });
890    Ok(serde_json::json!({
891        "head": state.head,
892        "commits": state.commits,
893        "revisions": revisions,
894    }))
895}
896
897fn canonicalize_checkpoint_deferred_turn_value(
898    value: &serde_json::Value,
899) -> Result<serde_json::Value, serde_json::Error> {
900    let mut state: SessionDeferredTurnState = serde_json::from_value(value.clone())?;
901    if let Some(prompt) = state.pending_initial_prompt_mut_for_blob_rewrite()
902        && let crate::types::ContentInput::Blocks(blocks) = &mut prompt.prompt
903    {
904        canonicalize_digest_image_blocks(blocks);
905    }
906    for pending in state.pending_tool_results_mut_for_blob_rewrite() {
907        for result in &mut pending.results {
908            canonicalize_digest_image_blocks(&mut result.content);
909        }
910    }
911    serde_json::to_value(state)
912}
913
914/// Timestamp sentinel used when erasing construction bookkeeping from the
915/// digest form. `created_at` always serializes, so a fixed value keeps the
916/// canonical bytes deterministic.
917fn digest_timestamp_sentinel() -> crate::types::MessageTimestamp {
918    chrono::DateTime::<chrono::Utc>::UNIX_EPOCH
919}
920
921/// Canonicalize messages to their conversational content before hashing so the
922/// transcript revision is a content address, not a construction record.
923///
924/// Two normalizations compose:
925/// - image payloads collapse to their content-addressed blob identity
926///   ([`canonicalize_message_images_for_digest`]);
927/// - per-construction bookkeeping is erased: [`TranscriptMessageIdentity`]
928///   (run/interaction ids are runtime-binding atoms — a re-created authority
929///   re-stamps them) and `created_at` timestamps. A resume that re-projects
930///   the same conversation through a new runtime authority must digest to the
931///   same revision as the persisted row, or the append-only save guard
932///   strands the session on restart (fails closed with
933///   `TranscriptContinuityViolation`).
934///
935/// Typed semantic facts stay in the digest — `transcript_role`,
936/// `mutation_kind`, `render_metadata`, notice kinds and blocks — because
937/// changing them changes the transcript's meaning.
938fn canonicalize_messages_for_digest(messages: &[Message]) -> Vec<Message> {
939    let mut canonical = canonicalize_message_images_for_digest(messages);
940    for message in &mut canonical {
941        match message {
942            Message::System(system) => {
943                system.created_at = digest_timestamp_sentinel();
944            }
945            Message::SystemNotice(notice) => {
946                notice.created_at = digest_timestamp_sentinel();
947            }
948            Message::User(user) => {
949                user.identity = crate::types::TranscriptMessageIdentity::default();
950                user.created_at = digest_timestamp_sentinel();
951            }
952            Message::BlockAssistant(assistant) => {
953                assistant.identity = crate::types::TranscriptMessageIdentity::default();
954                assistant.created_at = digest_timestamp_sentinel();
955            }
956            Message::ToolResults { created_at, .. } => {
957                *created_at = digest_timestamp_sentinel();
958            }
959        }
960    }
961    canonical
962}
963
964pub fn transcript_messages_digest(messages: &[Message]) -> Result<String, serde_json::Error> {
965    sha256_json_digest(&canonicalize_messages_for_digest(messages))
966}
967
968/// Digest format used by pre-0.7.14 transcript revision strings.
969///
970/// The legacy canonicalization only normalized image payloads, so persisted
971/// revision strings from older stores include construction bookkeeping
972/// (`identity`, `created_at`). This is a durable-format decoder: it exists
973/// solely so [`heal_legacy_revision_strings`] can verify a stored string
974/// against its retained body before re-deriving it to the current
975/// content-addressed format. Never mint new revisions with it.
976fn legacy_transcript_messages_digest(messages: &[Message]) -> Result<String, serde_json::Error> {
977    sha256_json_digest(&canonicalize_message_images_for_digest(messages))
978}
979
980fn validate_transcript_rewrite_record(
981    commit: &TranscriptRewriteCommit,
982    parent_body: &TranscriptRevisionBody,
983    revision_body: &TranscriptRevisionBody,
984) -> Result<(), TranscriptEditError> {
985    if parent_body.revision != commit.parent_revision {
986        return Err(TranscriptEditError::HistoryStateMalformed(format!(
987            "parent body revision {} does not match commit parent {}",
988            parent_body.revision, commit.parent_revision
989        )));
990    }
991    if revision_body.revision != commit.revision {
992        return Err(TranscriptEditError::HistoryStateMalformed(format!(
993            "revision body {} does not match commit revision {}",
994            revision_body.revision, commit.revision
995        )));
996    }
997    if commit.parent_revision == commit.revision {
998        return Err(TranscriptEditError::NoOpRewrite {
999            revision: commit.revision.clone(),
1000        });
1001    }
1002    let parent_digest = transcript_messages_digest(&parent_body.messages)
1003        .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
1004    if parent_digest != commit.parent_revision {
1005        return Err(TranscriptEditError::HistoryStateMalformed(format!(
1006            "parent body digest {parent_digest} does not match commit parent {}",
1007            commit.parent_revision
1008        )));
1009    }
1010    let revision_digest = transcript_messages_digest(&revision_body.messages)
1011        .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
1012    if revision_digest != commit.revision {
1013        return Err(TranscriptEditError::HistoryStateMalformed(format!(
1014            "revision body digest {revision_digest} does not match commit revision {}",
1015            commit.revision
1016        )));
1017    }
1018    let (start, end) = commit.selection.bounds();
1019    if start > end || end > parent_body.messages.len() {
1020        return Err(TranscriptEditError::InvalidRewriteRange {
1021            start,
1022            end,
1023            message_count: parent_body.messages.len(),
1024        });
1025    }
1026    if commit.messages_before != parent_body.messages.len()
1027        || commit.messages_after != revision_body.messages.len()
1028    {
1029        return Err(TranscriptEditError::HistoryStateMalformed(format!(
1030            "commit message counts {} -> {} do not match revision bodies {} -> {}",
1031            commit.messages_before,
1032            commit.messages_after,
1033            parent_body.messages.len(),
1034            revision_body.messages.len()
1035        )));
1036    }
1037    let original_span_digest = transcript_messages_digest(&parent_body.messages[start..end])
1038        .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
1039    if original_span_digest != commit.original_span_digest {
1040        return Err(TranscriptEditError::HistoryStateMalformed(format!(
1041            "original span digest {original_span_digest} does not match commit digest {}",
1042            commit.original_span_digest
1043        )));
1044    }
1045    let removed_len = end - start;
1046    let retained_len = commit
1047        .messages_before
1048        .checked_sub(removed_len)
1049        .ok_or_else(|| {
1050            TranscriptEditError::HistoryStateMalformed(
1051                "commit removed more messages than it recorded before rewrite".to_string(),
1052            )
1053        })?;
1054    let replacement_len = commit
1055        .messages_after
1056        .checked_sub(retained_len)
1057        .ok_or_else(|| {
1058            TranscriptEditError::HistoryStateMalformed(
1059                "commit message counts cannot describe a replacement span".to_string(),
1060            )
1061        })?;
1062    let replacement_end = start.checked_add(replacement_len).ok_or_else(|| {
1063        TranscriptEditError::HistoryStateMalformed("replacement span end overflowed".to_string())
1064    })?;
1065    if replacement_end > revision_body.messages.len() {
1066        return Err(TranscriptEditError::InvalidRewriteRange {
1067            start,
1068            end: replacement_end,
1069            message_count: revision_body.messages.len(),
1070        });
1071    }
1072    if commit.selection.semantic() == TranscriptRewriteSemantic::Compaction {
1073        let summary_count = revision_body.messages[start..replacement_end]
1074            .iter()
1075            .filter(|message| {
1076                matches!(message, Message::User(user) if user.transcript_role.is_compaction_summary())
1077            })
1078            .count();
1079        if start != 0
1080            || end != commit.messages_before
1081            || commit.messages_after >= commit.messages_before
1082            || summary_count != 1
1083        {
1084            return Err(TranscriptEditError::HistoryStateMalformed(
1085                "typed compaction rewrite must shrink the full transcript and carry exactly one CompactionSummary"
1086                    .to_string(),
1087            ));
1088        }
1089    }
1090    let parent_prefix_digest = transcript_messages_digest(&parent_body.messages[..start])
1091        .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
1092    let revision_prefix_digest = transcript_messages_digest(&revision_body.messages[..start])
1093        .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
1094    if parent_prefix_digest != revision_prefix_digest {
1095        return Err(TranscriptEditError::HistoryStateMalformed(
1096            "rewrite revision changed messages before the selected span".to_string(),
1097        ));
1098    }
1099    let parent_suffix_digest = transcript_messages_digest(&parent_body.messages[end..])
1100        .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
1101    let revision_suffix_digest =
1102        transcript_messages_digest(&revision_body.messages[replacement_end..])
1103            .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
1104    if parent_suffix_digest != revision_suffix_digest {
1105        return Err(TranscriptEditError::HistoryStateMalformed(
1106            "rewrite revision changed messages after the selected span".to_string(),
1107        ));
1108    }
1109    let replacement_digest =
1110        transcript_messages_digest(&revision_body.messages[start..replacement_end])
1111            .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
1112    if replacement_digest != commit.replacement_digest {
1113        return Err(TranscriptEditError::HistoryStateMalformed(format!(
1114            "replacement span digest {replacement_digest} does not match commit digest {}",
1115            commit.replacement_digest
1116        )));
1117    }
1118    Ok(())
1119}
1120
1121pub(crate) fn validate_transcript_history_state(
1122    state: &TranscriptHistoryState,
1123) -> Result<(), TranscriptEditError> {
1124    if state
1125        .revisions
1126        .iter()
1127        .all(|body| body.revision != state.head)
1128    {
1129        return Err(TranscriptEditError::HistoryStateMalformed(format!(
1130            "missing transcript head body {}",
1131            state.head
1132        )));
1133    }
1134    for body in &state.revisions {
1135        let digest = transcript_messages_digest(&body.messages)
1136            .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
1137        if digest != body.revision {
1138            return Err(TranscriptEditError::HistoryStateMalformed(format!(
1139                "transcript revision body {} has digest {digest}",
1140                body.revision
1141            )));
1142        }
1143    }
1144    for commit in &state.commits {
1145        let parent_body = state
1146            .revisions
1147            .iter()
1148            .find(|body| body.revision == commit.parent_revision)
1149            .ok_or_else(|| {
1150                TranscriptEditError::HistoryStateMalformed(format!(
1151                    "missing parent transcript body {}",
1152                    commit.parent_revision
1153                ))
1154            })?;
1155        let revision_body = state
1156            .revisions
1157            .iter()
1158            .find(|body| body.revision == commit.revision)
1159            .ok_or_else(|| {
1160                TranscriptEditError::HistoryStateMalformed(format!(
1161                    "missing transcript revision body {}",
1162                    commit.revision
1163                ))
1164            })?;
1165        validate_transcript_rewrite_record(commit, parent_body, revision_body)?;
1166    }
1167    let Some(first_commit) = state.commits.first() else {
1168        return Ok(());
1169    };
1170    let mut expected_head = first_commit.parent_revision.clone();
1171    for commit in &state.commits {
1172        let parent_body = state
1173            .revisions
1174            .iter()
1175            .find(|body| body.revision == commit.parent_revision)
1176            .ok_or_else(|| {
1177                TranscriptEditError::HistoryStateMalformed(format!(
1178                    "missing parent transcript body {}",
1179                    commit.parent_revision
1180                ))
1181            })?;
1182        if commit.parent_revision != expected_head
1183            && !revision_body_extends_head(parent_body, &state.revisions, &expected_head)?
1184        {
1185            return Err(TranscriptEditError::HistoryStateMalformed(format!(
1186                "rewrite commit parent {} does not extend transcript head {}",
1187                commit.parent_revision, expected_head
1188            )));
1189        }
1190        expected_head = commit.revision.clone();
1191    }
1192    let head_is_audited_endpoint = state
1193        .commits
1194        .iter()
1195        .any(|commit| commit.parent_revision == state.head || commit.revision == state.head);
1196    let head_extends_latest_commit = if head_is_audited_endpoint {
1197        let Some(head_body) = state
1198            .revisions
1199            .iter()
1200            .find(|body| body.revision == state.head)
1201        else {
1202            return Err(TranscriptEditError::HistoryStateMalformed(format!(
1203                "missing transcript head body {}",
1204                state.head
1205            )));
1206        };
1207        revision_body_extends_head(head_body, &state.revisions, &expected_head)?
1208    } else {
1209        let mut cursor = state.head.as_str();
1210        let mut visited = BTreeSet::new();
1211        while cursor != expected_head {
1212            if !visited.insert(cursor.to_string()) {
1213                break;
1214            }
1215            let Some(head_body) = state.revisions.iter().find(|body| body.revision == cursor)
1216            else {
1217                break;
1218            };
1219            let Some(parent) = head_body.parent_revision.as_deref() else {
1220                break;
1221            };
1222            cursor = parent;
1223        }
1224        cursor == expected_head
1225    };
1226    if !head_extends_latest_commit {
1227        return Err(TranscriptEditError::HistoryStateMalformed(format!(
1228            "transcript head {} does not extend the rewrite chain",
1229            state.head
1230        )));
1231    }
1232    Ok(())
1233}
1234
1235fn revision_body_extends_head(
1236    candidate: &TranscriptRevisionBody,
1237    revisions: &[TranscriptRevisionBody],
1238    head: &str,
1239) -> Result<bool, TranscriptEditError> {
1240    let Some(head_body) = revisions.iter().find(|body| body.revision == head) else {
1241        return Ok(false);
1242    };
1243    if candidate.revision == head {
1244        return Ok(true);
1245    }
1246    if candidate.messages.len() < head_body.messages.len() {
1247        return Ok(false);
1248    }
1249    let prefix_digest = transcript_messages_digest(&candidate.messages[..head_body.messages.len()])
1250        .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
1251    if prefix_digest == head {
1252        return Ok(true);
1253    }
1254
1255    // A resume-time system refresh may replace the single leading System
1256    // projection while preserving (and possibly appending to) the exact
1257    // conversation tail. Prove that content shape directly; a historical
1258    // parent_revision pointer is not occurrence identity and must never, by
1259    // itself, authorize a later commit after a digest has recurred.
1260    let (Some(Message::System(_)), Some(Message::System(_))) =
1261        (candidate.messages.first(), head_body.messages.first())
1262    else {
1263        return Ok(false);
1264    };
1265    let head_tail_len = head_body.messages.len().saturating_sub(1);
1266    if head_tail_len == 0 {
1267        return Ok(true);
1268    }
1269    let candidate_tail = &candidate.messages[1..];
1270    if candidate_tail.len() < head_tail_len {
1271        return Ok(false);
1272    }
1273    let head_tail_digest = transcript_messages_digest(&head_body.messages[1..])
1274        .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
1275    let candidate_tail_prefix_digest = transcript_messages_digest(&candidate_tail[..head_tail_len])
1276        .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
1277    Ok(candidate_tail_prefix_digest == head_tail_digest)
1278}
1279
1280fn sha256_json_digest<T: Serialize + ?Sized>(value: &T) -> Result<String, serde_json::Error> {
1281    let bytes = serde_json::to_vec(value)?;
1282    let digest = Sha256::digest(bytes);
1283    let mut out = String::with_capacity(digest.len() * 2);
1284    const HEX: &[u8; 16] = b"0123456789abcdef";
1285    for byte in digest {
1286        out.push(HEX[(byte >> 4) as usize] as char);
1287        out.push(HEX[(byte & 0x0f) as usize] as char);
1288    }
1289    Ok(format!("sha256:{out}"))
1290}
1291
1292/// A conversation session with full history
1293///
1294/// Uses Arc<Vec<Message>> internally for efficient forking (copy-on-write).
1295#[derive(Debug, Clone)]
1296pub struct Session {
1297    /// Persisted envelope format version, validated fail-closed on read by
1298    /// the generated persistence version authority.
1299    version: u32,
1300    /// Unique identifier
1301    id: SessionId,
1302    /// All messages in order (Arc for CoW on fork)
1303    pub(crate) messages: Arc<Vec<Message>>,
1304    /// When the session was created
1305    created_at: SystemTime,
1306    /// When the session was last updated
1307    updated_at: SystemTime,
1308    /// Arbitrary metadata
1309    metadata: serde_json::Map<String, serde_json::Value>,
1310    /// Whether transcript-history metadata has already crossed a validating,
1311    /// compacting authority boundary in this in-memory session.
1312    ///
1313    /// This is derived cache state only, never persisted authority. Typed
1314    /// transcript mutations install validated state; deserialization validates
1315    /// before setting it. Any unchecked history mutation invalidates the cache
1316    /// so serialization retains the fail-closed corrupt-snapshot contract.
1317    transcript_history_metadata_validation: TranscriptHistoryMetadataValidation,
1318    /// Cumulative token usage across all LLM calls in this session
1319    usage: Usage,
1320}
1321
1322#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1323enum TranscriptHistoryMetadataValidation {
1324    Validated,
1325    RequiresValidation,
1326}
1327
1328/// Serde helper for Session serialization (flattens Arc)
1329#[derive(Deserialize)]
1330#[serde(rename_all = "snake_case")]
1331struct SessionSerde {
1332    version: u32,
1333    id: SessionId,
1334    messages: Vec<Message>,
1335    created_at: SystemTime,
1336    updated_at: SystemTime,
1337    #[serde(default)]
1338    metadata: serde_json::Map<String, serde_json::Value>,
1339    #[serde(default)]
1340    usage: Usage,
1341}
1342
1343/// Borrowed serialization view for Session. The persisted shape deliberately
1344/// stays lockstep with `SessionSerde`, but large transcripts and metadata are
1345/// streamed directly instead of being deep-cloned before serde sees them.
1346#[derive(Serialize)]
1347#[serde(rename_all = "snake_case")]
1348struct SessionSerdeRef<'a> {
1349    version: u32,
1350    id: &'a SessionId,
1351    messages: &'a [Message],
1352    created_at: &'a SystemTime,
1353    updated_at: &'a SystemTime,
1354    metadata: &'a serde_json::Map<String, serde_json::Value>,
1355    usage: &'a Usage,
1356}
1357
1358impl Serialize for Session {
1359    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1360    where
1361        S: Serializer,
1362    {
1363        let compacted_metadata = if self.transcript_history_metadata_validation
1364            == TranscriptHistoryMetadataValidation::RequiresValidation
1365        {
1366            let mut metadata = self.metadata.clone();
1367            compact_transcript_history_metadata_for_snapshot(&mut metadata)
1368                .map_err(<S::Error as serde::ser::Error>::custom)?;
1369            Some(metadata)
1370        } else {
1371            None
1372        };
1373        let metadata = compacted_metadata.as_ref().unwrap_or(&self.metadata);
1374        let serde_repr = SessionSerdeRef {
1375            version: self.version,
1376            id: &self.id,
1377            messages: self.messages(),
1378            created_at: &self.created_at,
1379            updated_at: &self.updated_at,
1380            metadata,
1381            usage: &self.usage,
1382        };
1383        serde_repr.serialize(serializer)
1384    }
1385}
1386
1387fn compact_transcript_history_metadata_for_snapshot(
1388    metadata: &mut serde_json::Map<String, serde_json::Value>,
1389) -> Result<(), String> {
1390    let Some(value) = metadata.remove(SESSION_TRANSCRIPT_HISTORY_STATE_KEY) else {
1391        return Ok(());
1392    };
1393    let mut state: TranscriptHistoryState =
1394        serde_json::from_value(value).map_err(|error| error.to_string())?;
1395    state
1396        .compact_mechanical_revision_bodies()
1397        .map_err(|error| error.to_string())?;
1398    metadata.insert(
1399        SESSION_TRANSCRIPT_HISTORY_STATE_KEY.to_string(),
1400        serde_json::to_value(state).map_err(|error| error.to_string())?,
1401    );
1402    Ok(())
1403}
1404
1405impl<'de> Deserialize<'de> for Session {
1406    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1407    where
1408        D: Deserializer<'de>,
1409    {
1410        let serde_repr = SessionSerde::deserialize(deserializer)?;
1411        let version = session_persistence_version_authority::restore_session_envelope_version(
1412            serde_repr.version,
1413        )
1414        .map_err(<D::Error as serde::de::Error>::custom)?;
1415        let mut metadata = serde_repr.metadata;
1416        compact_transcript_history_metadata_for_snapshot(&mut metadata)
1417            .map_err(<D::Error as serde::de::Error>::custom)?;
1418        Ok(Session {
1419            version,
1420            id: serde_repr.id,
1421            messages: Arc::new(serde_repr.messages),
1422            created_at: serde_repr.created_at,
1423            updated_at: serde_repr.updated_at,
1424            metadata,
1425            transcript_history_metadata_validation: TranscriptHistoryMetadataValidation::Validated,
1426            usage: serde_repr.usage,
1427        })
1428    }
1429}
1430
1431/// Serde helper for the metadata-only partial decode of a persisted session
1432/// envelope.
1433///
1434/// LOCKSTEP with [`SessionSerde`]: this struct must decode exactly the field
1435/// names and serde shapes that `SessionSerde` persists for `version`, `id`,
1436/// and `metadata` (`rename_all = "snake_case"`, `#[serde(default)]` on
1437/// `metadata`). The `session_metadata_document_lockstep_with_full_envelope`
1438/// pin test fails if the two drift.
1439#[derive(Deserialize)]
1440#[serde(rename_all = "snake_case")]
1441struct SessionMetadataDocumentSerde {
1442    version: u32,
1443    id: SessionId,
1444    #[serde(default)]
1445    metadata: serde_json::Map<String, serde_json::Value>,
1446}
1447
1448/// Metadata-only projection of a persisted session envelope.
1449///
1450/// Produced by [`session_metadata_document_from_slice`] without materializing
1451/// the transcript. Exposes ONLY the two session-authority facts the metadata
1452/// read seam is allowed to observe ([`SESSION_METADATA_KEY`] and
1453/// [`SESSION_LIFECYCLE_TERMINAL_KEY`]) — deliberately no raw metadata-map
1454/// accessor, so the partial decode can never grow into an untyped side
1455/// channel around [`Session`]'s authority-gated reads.
1456#[derive(Debug, Clone)]
1457pub struct SessionMetadataDocument {
1458    session_id: SessionId,
1459    metadata: serde_json::Map<String, serde_json::Value>,
1460}
1461
1462impl SessionMetadataDocument {
1463    /// Session identity carried by the envelope.
1464    pub fn session_id(&self) -> &SessionId {
1465        &self.session_id
1466    }
1467
1468    /// Raw projected [`SESSION_METADATA_KEY`] value, for divergence
1469    /// comparison against another projection of the same fact.
1470    pub fn session_metadata_value(&self) -> Option<&serde_json::Value> {
1471        self.metadata.get(SESSION_METADATA_KEY)
1472    }
1473
1474    /// Raw projected [`SESSION_LIFECYCLE_TERMINAL_KEY`] value, for divergence
1475    /// comparison against another projection of the same fact.
1476    pub fn lifecycle_terminal_value(&self) -> Option<&serde_json::Value> {
1477        self.metadata.get(SESSION_LIFECYCLE_TERMINAL_KEY)
1478    }
1479
1480    /// Decode typed checkpoint metadata without materializing the transcript.
1481    ///
1482    /// This validates schema and session identity and preserves explicit
1483    /// legacy-unverified state. Digest verification still requires the full
1484    /// document through [`Session::try_checkpoint_state`].
1485    pub fn try_checkpoint_metadata_state(
1486        &self,
1487    ) -> Result<
1488        crate::checkpoint::SessionCheckpointMetadataState,
1489        crate::checkpoint::SessionCheckpointError,
1490    > {
1491        crate::checkpoint::session_checkpoint_metadata_state(&self.session_id, &self.metadata)
1492    }
1493
1494    /// Decode the typed metadata view through the canonical map-level
1495    /// decoders, failing closed on corrupt values.
1496    pub fn try_into_view(self) -> Result<PersistedSessionMetadataView, serde_json::Error> {
1497        PersistedSessionMetadataView::try_from_metadata_map(self.session_id, &self.metadata)
1498    }
1499}
1500
1501/// Partially decode a persisted session envelope into its metadata-only
1502/// document, without materializing the transcript.
1503///
1504/// Fail-closed on the envelope format version through the generated
1505/// persistence version authority — exactly like the full [`Session`]
1506/// deserializer.
1507pub fn session_metadata_document_from_slice(
1508    bytes: &[u8],
1509) -> Result<SessionMetadataDocument, serde_json::Error> {
1510    let serde_repr: SessionMetadataDocumentSerde = serde_json::from_slice(bytes)?;
1511    session_persistence_version_authority::restore_session_envelope_version(serde_repr.version)
1512        .map_err(<serde_json::Error as serde::de::Error>::custom)?;
1513    Ok(SessionMetadataDocument {
1514        session_id: serde_repr.id,
1515        metadata: serde_repr.metadata,
1516    })
1517}
1518
1519impl Session {
1520    /// Rebuild a slim `Session` from persisted head-row parts.
1521    ///
1522    /// Used by [`crate::session_store::SessionHead::into_session`] to
1523    /// materialize a session from an incremental store's head row plus its
1524    /// strand messages. The envelope version is restored fail-closed through
1525    /// the generated persistence version authority, exactly like
1526    /// [`Session::deserialize`].
1527    pub(crate) fn from_head_parts(
1528        version: u32,
1529        id: SessionId,
1530        messages: Vec<Message>,
1531        created_at: SystemTime,
1532        updated_at: SystemTime,
1533        metadata: serde_json::Map<String, serde_json::Value>,
1534        usage: Usage,
1535    ) -> Result<Self, String> {
1536        let version =
1537            session_persistence_version_authority::restore_session_envelope_version(version)
1538                .map_err(|err| err.to_string())?;
1539        Ok(Self {
1540            version,
1541            id,
1542            messages: Arc::new(messages),
1543            created_at,
1544            updated_at,
1545            transcript_history_metadata_validation: if metadata
1546                .contains_key(SESSION_TRANSCRIPT_HISTORY_STATE_KEY)
1547            {
1548                TranscriptHistoryMetadataValidation::RequiresValidation
1549            } else {
1550                TranscriptHistoryMetadataValidation::Validated
1551            },
1552            metadata,
1553            usage,
1554        })
1555    }
1556
1557    /// Build the canonical, storage-representation-invariant document used by
1558    /// the typed checkpoint digest.
1559    pub(crate) fn checkpoint_digest_document(
1560        &self,
1561    ) -> Result<serde_json::Value, serde_json::Error> {
1562        let messages = canonicalize_messages_for_digest(self.messages());
1563        let mut metadata = self.metadata.clone();
1564        if self.transcript_history_metadata_validation
1565            == TranscriptHistoryMetadataValidation::RequiresValidation
1566        {
1567            compact_transcript_history_metadata_for_snapshot(&mut metadata)
1568                .map_err(<serde_json::Error as serde::ser::Error>::custom)?;
1569        }
1570        if let Some(history) = metadata.get_mut(SESSION_TRANSCRIPT_HISTORY_STATE_KEY) {
1571            *history = canonicalize_checkpoint_history_value(history)?;
1572        }
1573        if let Some(deferred) = metadata.get_mut(SESSION_DEFERRED_TURN_STATE_KEY) {
1574            *deferred = canonicalize_checkpoint_deferred_turn_value(deferred)?;
1575        }
1576        serde_json::to_value(SessionSerdeRef {
1577            version: self.version,
1578            id: &self.id,
1579            messages: &messages,
1580            created_at: &self.created_at,
1581            updated_at: &self.updated_at,
1582            metadata: &metadata,
1583            usage: &self.usage,
1584        })
1585    }
1586}
1587
1588/// Metadata key used to store durable system-context control state.
1589pub const SESSION_SYSTEM_CONTEXT_STATE_KEY: &str = "session_system_context_state";
1590
1591/// Metadata key used to store deferred-turn control state.
1592pub const SESSION_DEFERRED_TURN_STATE_KEY: &str = "session_deferred_turn_state";
1593
1594/// Metadata key used to store recoverable build-only session state.
1595pub const SESSION_BUILD_STATE_KEY: &str = "session_build_state";
1596
1597/// Metadata key used to store durable session-local tool visibility intent.
1598pub const SESSION_TOOL_VISIBILITY_STATE_KEY: &str = "session_tool_visibility_state_v1";
1599
1600/// Metadata key used to store the typed session lifecycle-terminal fact.
1601pub const SESSION_LIFECYCLE_TERMINAL_KEY: &str = "session_lifecycle_terminal";
1602
1603/// Single canonical metadata key for the typed session checkpoint stamp.
1604pub const SESSION_CHECKPOINT_STAMP_KEY: &str = "session_checkpoint_stamp_v1";
1605
1606/// Legacy compatibility marker for a session-store row written by the
1607/// pre-typed intra-turn checkpointer.
1608///
1609/// This Boolean is decoded only as explicit legacy-unverified evidence. It
1610/// never grants rollback authority; typed writers and recovery use the exact
1611/// [`crate::checkpoint::SessionCheckpointStamp`] instead.
1612pub const SESSION_RUNTIME_CHECKPOINT_PROVENANCE_KEY: &str =
1613    "session_runtime_checkpoint_provenance_v1";
1614
1615/// Canonical tool name gated by `image_tool_results` capability.
1616pub const VIEW_IMAGE_TOOL_NAME: &str = "view_image";
1617
1618/// Canonical separator between appended runtime system-context blocks.
1619pub const SYSTEM_CONTEXT_SEPARATOR: &str = "\n\n---\n\n";
1620
1621#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1622#[error("metadata key `{key}` is reserved for session authority")]
1623pub struct ReservedSessionMetadataKey {
1624    key: String,
1625}
1626
1627impl ReservedSessionMetadataKey {
1628    fn new(key: &str) -> Self {
1629        Self {
1630            key: key.to_string(),
1631        }
1632    }
1633}
1634
1635fn is_session_authority_metadata_key(key: &str) -> bool {
1636    // Single reserved-key authority: the typed classifier owns the
1637    // session-authority key set (the `session_*` state constants).
1638    crate::surface_metadata::ReservedMetadataKey::is_session_authority(key)
1639}
1640
1641#[allow(clippy::panic)]
1642fn fail_closed_generated_restore(authority: &'static str, err: serde_json::Error) -> ! {
1643    tracing::error!(
1644        authority,
1645        error = %err,
1646        "generated authority rejected durable restore"
1647    );
1648    panic!("generated {authority} authority rejected durable restore: {err}");
1649}
1650
1651/// Shared runtime system-context authority handle.
1652///
1653/// This handle is intentionally narrower than `Arc<Mutex<SessionSystemContextState>>`:
1654/// callers can read snapshots or request generated-authority transitions, but
1655/// cannot replace the machine-owned state by taking a mutable guard.
1656#[derive(Clone)]
1657pub struct SystemContextStateHandle {
1658    inner: Arc<std::sync::Mutex<SessionSystemContextState>>,
1659    boundary: Arc<SystemContextBoundaryCoordinator>,
1660}
1661
1662struct SystemContextBoundaryCoordinator {
1663    incarnation_id: uuid::Uuid,
1664    lifecycle: std::sync::Mutex<SystemContextBoundaryLifecycle>,
1665    notify: tokio::sync::Notify,
1666}
1667
1668struct SystemContextBoundaryLifecycle {
1669    actor_live: bool,
1670    next_generation: u64,
1671    next_request_id: u64,
1672    window: SystemContextBoundaryWindow,
1673}
1674
1675enum SystemContextBoundaryWindow {
1676    Closed,
1677    Open {
1678        run_id: RunId,
1679        generation: u64,
1680        request: Option<RegisteredSystemContextBoundaryRequest>,
1681    },
1682    Parked {
1683        run_id: RunId,
1684        generation: u64,
1685        request_id: u64,
1686        candidate_state: SessionSystemContextState,
1687    },
1688    Resolved {
1689        run_id: RunId,
1690        generation: u64,
1691        request_id: u64,
1692        resolution: SystemContextBoundaryResolution,
1693    },
1694    /// The external prepare authority has resolved (or runner-first won), and
1695    /// the runner is preprocessing the exact request immediately before the
1696    /// model call. Canonical pending state remains unapplied until the runner
1697    /// consumes this witness synchronously at that final call seam.
1698    Consuming {
1699        run_id: RunId,
1700        generation: u64,
1701        request_id: Option<u64>,
1702    },
1703}
1704
1705struct RegisteredSystemContextBoundaryRequest {
1706    request_id: u64,
1707    appends: Vec<(AppendSystemContextRequest, SystemTime)>,
1708}
1709
1710#[derive(Clone)]
1711enum SystemContextBoundaryResolution {
1712    Committed,
1713    Aborted,
1714    Failed(CoreBoundaryStageError),
1715}
1716
1717impl Default for SystemContextBoundaryCoordinator {
1718    fn default() -> Self {
1719        Self {
1720            incarnation_id: uuid::Uuid::new_v4(),
1721            lifecycle: std::sync::Mutex::new(SystemContextBoundaryLifecycle {
1722                actor_live: true,
1723                next_generation: 0,
1724                next_request_id: 0,
1725                window: SystemContextBoundaryWindow::Closed,
1726            }),
1727            notify: tokio::sync::Notify::new(),
1728        }
1729    }
1730}
1731
1732impl SystemContextBoundaryCoordinator {
1733    fn lock(&self) -> std::sync::MutexGuard<'_, SystemContextBoundaryLifecycle> {
1734        self.lifecycle.lock().unwrap_or_else(|poisoned| {
1735            tracing::warn!(
1736                "system-context boundary coordinator lock poisoned; retaining exact authority"
1737            );
1738            poisoned.into_inner()
1739        })
1740    }
1741
1742    fn abort_request(&self, request_id: u64) -> Result<(), CoreBoundaryStageError> {
1743        let mut lifecycle = self.lock();
1744        let parked_owner = match &lifecycle.window {
1745            SystemContextBoundaryWindow::Parked {
1746                run_id,
1747                generation,
1748                request_id: current_request_id,
1749                ..
1750            } if *current_request_id == request_id => Some((run_id.clone(), *generation)),
1751            _ => None,
1752        };
1753        if let Some((run_id, generation)) = parked_owner {
1754            lifecycle.window = SystemContextBoundaryWindow::Resolved {
1755                run_id,
1756                generation,
1757                request_id,
1758                resolution: SystemContextBoundaryResolution::Aborted,
1759            };
1760            drop(lifecycle);
1761            self.notify.notify_waiters();
1762            return Ok(());
1763        }
1764        match &mut lifecycle.window {
1765            SystemContextBoundaryWindow::Open { request, .. }
1766                if request
1767                    .as_ref()
1768                    .is_some_and(|request| request.request_id == request_id) =>
1769            {
1770                *request = None;
1771            }
1772            SystemContextBoundaryWindow::Resolved {
1773                request_id: current_request_id,
1774                ..
1775            } if *current_request_id == request_id => return Ok(()),
1776            _ => {
1777                return Err(CoreBoundaryStageError::stale(format!(
1778                    "boundary request {request_id} no longer owns its actor window"
1779                )));
1780            }
1781        }
1782        drop(lifecycle);
1783        self.notify.notify_waiters();
1784        Ok(())
1785    }
1786
1787    fn close_run(&self, run_id: &RunId) {
1788        let mut lifecycle = self.lock();
1789        let owns_window = match &lifecycle.window {
1790            SystemContextBoundaryWindow::Open {
1791                run_id: current, ..
1792            }
1793            | SystemContextBoundaryWindow::Parked {
1794                run_id: current, ..
1795            }
1796            | SystemContextBoundaryWindow::Resolved {
1797                run_id: current, ..
1798            }
1799            | SystemContextBoundaryWindow::Consuming {
1800                run_id: current, ..
1801            } => current == run_id,
1802            SystemContextBoundaryWindow::Closed => false,
1803        };
1804        if owns_window {
1805            lifecycle.window = SystemContextBoundaryWindow::Closed;
1806            drop(lifecycle);
1807            self.notify.notify_waiters();
1808        }
1809    }
1810
1811    fn revoke_actor(&self) {
1812        let mut lifecycle = self.lock();
1813        lifecycle.actor_live = false;
1814        lifecycle.window = SystemContextBoundaryWindow::Closed;
1815        drop(lifecycle);
1816        self.notify.notify_waiters();
1817    }
1818}
1819
1820/// Run-scoped closure guard for the exact actor's cooperative model boundary.
1821/// Every normal return, error, hard-cancel drop, and task abort closes any
1822/// registered or parked request for this run.
1823#[must_use]
1824pub(crate) struct SystemContextBoundaryRunGuard {
1825    boundary: Arc<SystemContextBoundaryCoordinator>,
1826    run_id: RunId,
1827}
1828
1829impl Drop for SystemContextBoundaryRunGuard {
1830    fn drop(&mut self) {
1831        self.boundary.close_run(&self.run_id);
1832    }
1833}
1834
1835struct PendingSystemContextBoundaryPreparation {
1836    boundary: Arc<SystemContextBoundaryCoordinator>,
1837    request_id: u64,
1838    armed: bool,
1839}
1840
1841impl Drop for PendingSystemContextBoundaryPreparation {
1842    fn drop(&mut self) {
1843        if self.armed {
1844            let _ = self.boundary.abort_request(self.request_id);
1845        }
1846    }
1847}
1848
1849/// Runner-owned witness for the exact model request currently being prepared.
1850///
1851/// External commit only publishes the candidate as canonical pending state; it
1852/// does not claim that the model has consumed it. The runner retains this
1853/// second, actor-local witness across fallible/async request preprocessing and
1854/// marks the pending state applied synchronously at the final LLM call seam.
1855/// Dropping the witness closes the generation without marking anything applied.
1856#[must_use = "model-boundary context must be consumed or dropped before opening another boundary"]
1857pub(crate) struct ModelBoundarySystemContext {
1858    state: SystemContextStateHandle,
1859    run_id: RunId,
1860    generation: u64,
1861    request_id: Option<u64>,
1862    appends: Vec<PendingSystemContextAppend>,
1863    armed: bool,
1864}
1865
1866impl ModelBoundarySystemContext {
1867    pub(crate) fn appends(&self) -> &[PendingSystemContextAppend] {
1868        &self.appends
1869    }
1870
1871    /// Pre-serialize the exact post-consumption metadata state while failure is
1872    /// still harmless. The consuming window rejects concurrent mutation, so
1873    /// this projection remains exact until [`Self::consume`].
1874    pub(crate) fn projected_state_after_consume(&self) -> SessionSystemContextState {
1875        let mut projected = self.state.snapshot();
1876        projected.mark_pending_applied();
1877        projected
1878    }
1879
1880    pub(crate) fn consume(
1881        mut self,
1882    ) -> Result<Vec<PendingSystemContextAppend>, CoreBoundaryStageError> {
1883        self.state.finish_model_boundary_consumption(
1884            &self.run_id,
1885            self.generation,
1886            self.request_id,
1887            true,
1888        )?;
1889        self.armed = false;
1890        Ok(std::mem::take(&mut self.appends))
1891    }
1892}
1893
1894impl Drop for ModelBoundarySystemContext {
1895    fn drop(&mut self) {
1896        if self.armed {
1897            let _ = self.state.finish_model_boundary_consumption(
1898                &self.run_id,
1899                self.generation,
1900                self.request_id,
1901                false,
1902            );
1903            self.armed = false;
1904        }
1905    }
1906}
1907
1908/// Unforgeable exact `{actor incarnation, run, boundary generation}`
1909/// preparation. It is created only by the shared system-context authority
1910/// after the runner has parked at the named boundary.
1911///
1912/// ```compile_fail
1913/// use meerkat_core::PreparedSystemContextBoundary;
1914/// fn cannot_duplicate(authority: &PreparedSystemContextBoundary) {
1915///     let _duplicate = authority.clone();
1916/// }
1917/// ```
1918#[must_use = "prepared system context must be committed or aborted"]
1919pub struct PreparedSystemContextBoundary {
1920    state: SystemContextStateHandle,
1921    expected_run_id: RunId,
1922    generation: u64,
1923    request_id: u64,
1924    candidate_state: SessionSystemContextState,
1925    armed: bool,
1926    // The unique resolution authority may move to an owned commit task, but
1927    // sharing one authority by reference across threads is unnecessary and
1928    // obscures its exactly-once ownership contract.
1929    _not_sync: std::marker::PhantomData<std::cell::Cell<()>>,
1930}
1931
1932impl std::fmt::Debug for PreparedSystemContextBoundary {
1933    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1934        formatter
1935            .debug_struct("PreparedSystemContextBoundary")
1936            .field("actor_incarnation", &self.state.boundary.incarnation_id)
1937            .field("expected_run_id", &self.expected_run_id)
1938            .field("generation", &self.generation)
1939            .field("request_id", &self.request_id)
1940            .finish_non_exhaustive()
1941    }
1942}
1943
1944impl PreparedSystemContextBoundary {
1945    #[must_use]
1946    pub fn expected_run_id(&self) -> &RunId {
1947        &self.expected_run_id
1948    }
1949
1950    #[must_use]
1951    pub fn boundary_generation(&self) -> u64 {
1952        self.generation
1953    }
1954
1955    #[must_use]
1956    pub fn candidate_state(&self) -> &SessionSystemContextState {
1957        &self.candidate_state
1958    }
1959
1960    /// Bind the unforgeable parked authority to its optional durable session
1961    /// snapshot. Surfaces cannot manufacture a successful output without this
1962    /// core-minted preparation value.
1963    pub fn into_stage_output(
1964        self,
1965        session_snapshot: Option<Vec<u8>>,
1966    ) -> crate::lifecycle::CoreBoundaryStageOutput {
1967        crate::lifecycle::CoreBoundaryStageOutput::prepared(session_snapshot, Box::new(self))
1968    }
1969
1970    fn resolve(
1971        &mut self,
1972        resolution: SystemContextBoundaryResolution,
1973    ) -> Result<(), CoreBoundaryStageError> {
1974        if !self.armed {
1975            return Err(CoreBoundaryStageError::stale(
1976                "prepared boundary authority was already resolved",
1977            ));
1978        }
1979        let mut lifecycle = self.state.boundary.lock();
1980        if !lifecycle.actor_live {
1981            self.armed = false;
1982            return Err(CoreBoundaryStageError::stale(format!(
1983                "actor incarnation {} was revoked",
1984                self.state.boundary.incarnation_id
1985            )));
1986        }
1987        let matches_exact = matches!(
1988            &lifecycle.window,
1989            SystemContextBoundaryWindow::Parked {
1990                run_id,
1991                generation,
1992                request_id,
1993                ..
1994            } if run_id == &self.expected_run_id
1995                && *generation == self.generation
1996                && *request_id == self.request_id
1997        );
1998        if !matches_exact {
1999            self.armed = false;
2000            return Err(CoreBoundaryStageError::stale(format!(
2001                "actor/run/boundary witness no longer matches request {}",
2002                self.request_id
2003            )));
2004        }
2005        if matches!(&resolution, SystemContextBoundaryResolution::Committed) {
2006            let mut state = self
2007                .state
2008                .inner
2009                .lock()
2010                .unwrap_or_else(std::sync::PoisonError::into_inner);
2011            *state = self.candidate_state.clone();
2012        }
2013        lifecycle.window = SystemContextBoundaryWindow::Resolved {
2014            run_id: self.expected_run_id.clone(),
2015            generation: self.generation,
2016            request_id: self.request_id,
2017            resolution,
2018        };
2019        self.armed = false;
2020        drop(lifecycle);
2021        self.state.boundary.notify.notify_waiters();
2022        Ok(())
2023    }
2024}
2025
2026impl crate::lifecycle::core_executor::CoreBoundaryStageCommitAuthority
2027    for PreparedSystemContextBoundary
2028{
2029    fn commit(&mut self) -> Result<(), CoreBoundaryStageError> {
2030        self.resolve(SystemContextBoundaryResolution::Committed)
2031    }
2032
2033    fn abort(&mut self) -> Result<(), CoreBoundaryStageError> {
2034        self.resolve(SystemContextBoundaryResolution::Aborted)
2035    }
2036}
2037
2038impl Drop for PreparedSystemContextBoundary {
2039    fn drop(&mut self) {
2040        if self.armed {
2041            let _ = self.state.boundary.abort_request(self.request_id);
2042            self.armed = false;
2043        }
2044    }
2045}
2046
2047impl std::fmt::Debug for SystemContextStateHandle {
2048    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2049        f.debug_struct("SystemContextStateHandle")
2050            .field("inner", &"<Arc<Mutex<SessionSystemContextState>>>")
2051            .field("actor_incarnation", &self.boundary.incarnation_id)
2052            .finish()
2053    }
2054}
2055
2056impl SystemContextStateHandle {
2057    fn boundary_reserves_state(lifecycle: &SystemContextBoundaryLifecycle) -> bool {
2058        matches!(
2059            &lifecycle.window,
2060            SystemContextBoundaryWindow::Parked { .. }
2061                | SystemContextBoundaryWindow::Resolved { .. }
2062                | SystemContextBoundaryWindow::Consuming { .. }
2063        )
2064    }
2065
2066    pub fn new(state: SessionSystemContextState) -> Result<Self, serde_json::Error> {
2067        let state = system_context_authority::restore_system_context_state(state)
2068            .map_err(<serde_json::Error as serde::de::Error>::custom)?;
2069        Ok(Self {
2070            inner: Arc::new(std::sync::Mutex::new(state)),
2071            boundary: Arc::new(SystemContextBoundaryCoordinator::default()),
2072        })
2073    }
2074
2075    /// Open the first exact cooperative model-boundary window for `run_id` and
2076    /// return a guard that closes it on every exit, including future drop.
2077    pub(crate) fn begin_boundary_run(
2078        &self,
2079        run_id: RunId,
2080    ) -> Result<SystemContextBoundaryRunGuard, CoreBoundaryStageError> {
2081        self.open_next_boundary(&run_id)?;
2082        Ok(SystemContextBoundaryRunGuard {
2083            boundary: Arc::clone(&self.boundary),
2084            run_id,
2085        })
2086    }
2087
2088    /// Ensure an exact next-boundary window is open for the active run. Calling
2089    /// this twice before consumption is idempotent; after consumption it mints
2090    /// the next monotonically increasing actor-local generation.
2091    pub(crate) fn open_next_boundary(&self, run_id: &RunId) -> Result<u64, CoreBoundaryStageError> {
2092        let mut lifecycle = self.boundary.lock();
2093        if !lifecycle.actor_live {
2094            return Err(CoreBoundaryStageError::stale(format!(
2095                "actor incarnation {} was revoked",
2096                self.boundary.incarnation_id
2097            )));
2098        }
2099        match &lifecycle.window {
2100            SystemContextBoundaryWindow::Open {
2101                run_id: current,
2102                generation,
2103                ..
2104            } if current == run_id => return Ok(*generation),
2105            SystemContextBoundaryWindow::Parked { .. }
2106            | SystemContextBoundaryWindow::Resolved { .. }
2107            | SystemContextBoundaryWindow::Consuming { .. } => {
2108                return Err(CoreBoundaryStageError::fault(
2109                    "runner attempted to open a new boundary while the prior boundary was unresolved",
2110                ));
2111            }
2112            SystemContextBoundaryWindow::Open {
2113                run_id: current, ..
2114            } => {
2115                return Err(CoreBoundaryStageError::stale(format!(
2116                    "run {run_id} cannot replace still-open boundary owned by {current}"
2117                )));
2118            }
2119            SystemContextBoundaryWindow::Closed => {}
2120        }
2121        lifecycle.next_generation = lifecycle
2122            .next_generation
2123            .checked_add(1)
2124            .ok_or_else(|| CoreBoundaryStageError::fault("boundary generation overflow"))?;
2125        let generation = lifecycle.next_generation;
2126        lifecycle.window = SystemContextBoundaryWindow::Open {
2127            run_id: run_id.clone(),
2128            generation,
2129            request: None,
2130        };
2131        drop(lifecycle);
2132        self.boundary.notify.notify_waiters();
2133        Ok(generation)
2134    }
2135
2136    /// Register context for the exact currently-open generation, then wait
2137    /// until the runner is parked immediately before consuming it. The lock
2138    /// linearization makes runner-first return `Unavailable` and prepare-first
2139    /// park; no snapshot/boolean sampling participates in the verdict.
2140    pub async fn prepare_active_turn_boundary(
2141        &self,
2142        expected_run_id: &RunId,
2143        appends: Vec<PendingSystemContextAppend>,
2144    ) -> Result<PreparedSystemContextBoundary, CoreBoundaryStageError> {
2145        if appends.is_empty() {
2146            return Err(CoreBoundaryStageError::fault(
2147                "boundary preparation requires at least one context append",
2148            ));
2149        }
2150        let stage_inputs = appends
2151            .into_iter()
2152            .map(|append| {
2153                (
2154                    AppendSystemContextRequest {
2155                        content: append.content,
2156                        source: append.source,
2157                        idempotency_key: append.idempotency_key,
2158                        source_kind: append.source_kind,
2159                        peer_response_terminal: append.peer_response_terminal,
2160                    },
2161                    append.accepted_at,
2162                )
2163            })
2164            .collect::<Vec<_>>();
2165
2166        let request_id = {
2167            let mut lifecycle = self.boundary.lock();
2168            if !lifecycle.actor_live {
2169                return Err(CoreBoundaryStageError::stale(format!(
2170                    "actor incarnation {} was revoked",
2171                    self.boundary.incarnation_id
2172                )));
2173            }
2174            let (run_id, request) = match &mut lifecycle.window {
2175                SystemContextBoundaryWindow::Open {
2176                    run_id, request, ..
2177                } => (run_id, request),
2178                SystemContextBoundaryWindow::Closed => {
2179                    return Err(CoreBoundaryStageError::unavailable(format!(
2180                        "run {expected_run_id} has no open cooperative model boundary"
2181                    )));
2182                }
2183                SystemContextBoundaryWindow::Parked { .. }
2184                | SystemContextBoundaryWindow::Resolved { .. }
2185                | SystemContextBoundaryWindow::Consuming { .. } => {
2186                    return Err(CoreBoundaryStageError::unavailable(format!(
2187                        "the open boundary for run {expected_run_id} was already claimed or consumed"
2188                    )));
2189                }
2190            };
2191            if run_id != expected_run_id {
2192                return Err(CoreBoundaryStageError::stale(format!(
2193                    "open boundary belongs to run {run_id}, not {expected_run_id}"
2194                )));
2195            }
2196            if request.is_some() {
2197                return Err(CoreBoundaryStageError::unavailable(format!(
2198                    "the next boundary for run {expected_run_id} already has a preparation"
2199                )));
2200            }
2201            // Validate idempotency/conflict semantics against the exact state
2202            // observed at registration without publishing the candidate.
2203            let state = self
2204                .inner
2205                .lock()
2206                .unwrap_or_else(std::sync::PoisonError::into_inner);
2207            let mut candidate = state.clone();
2208            for (append, accepted_at) in &stage_inputs {
2209                candidate
2210                    .stage_active_turn_append(append, *accepted_at)
2211                    .map_err(|error| CoreBoundaryStageError::fault(error.to_string()))?;
2212            }
2213            drop(state);
2214            lifecycle.next_request_id = lifecycle
2215                .next_request_id
2216                .checked_add(1)
2217                .ok_or_else(|| CoreBoundaryStageError::fault("boundary request id overflow"))?;
2218            let request_id = lifecycle.next_request_id;
2219            let SystemContextBoundaryWindow::Open { request, .. } = &mut lifecycle.window else {
2220                return Err(CoreBoundaryStageError::fault(
2221                    "boundary window changed while registering preparation",
2222                ));
2223            };
2224            *request = Some(RegisteredSystemContextBoundaryRequest {
2225                request_id,
2226                appends: stage_inputs,
2227            });
2228            request_id
2229        };
2230
2231        let mut pending = PendingSystemContextBoundaryPreparation {
2232            boundary: Arc::clone(&self.boundary),
2233            request_id,
2234            armed: true,
2235        };
2236        self.boundary.notify.notify_waiters();
2237
2238        loop {
2239            let notified = self.boundary.notify.notified();
2240            tokio::pin!(notified);
2241            notified.as_mut().enable();
2242            let poll = {
2243                let lifecycle = self.boundary.lock();
2244                if lifecycle.actor_live {
2245                    match &lifecycle.window {
2246                        SystemContextBoundaryWindow::Parked {
2247                            run_id,
2248                            generation,
2249                            request_id: parked_request_id,
2250                            candidate_state,
2251                        } if *parked_request_id == request_id => {
2252                            Ok(Some(PreparedSystemContextBoundary {
2253                                state: self.clone(),
2254                                expected_run_id: run_id.clone(),
2255                                generation: *generation,
2256                                request_id,
2257                                candidate_state: candidate_state.clone(),
2258                                armed: true,
2259                                _not_sync: std::marker::PhantomData,
2260                            }))
2261                        }
2262                        SystemContextBoundaryWindow::Open { request, .. }
2263                            if request
2264                                .as_ref()
2265                                .is_some_and(|request| request.request_id == request_id) =>
2266                        {
2267                            Ok(None)
2268                        }
2269                        SystemContextBoundaryWindow::Resolved {
2270                            request_id: resolved_request_id,
2271                            resolution,
2272                            ..
2273                        } if *resolved_request_id == request_id => match resolution {
2274                            SystemContextBoundaryResolution::Failed(error) => Err(error.clone()),
2275                            SystemContextBoundaryResolution::Committed
2276                            | SystemContextBoundaryResolution::Aborted => {
2277                                Err(CoreBoundaryStageError::stale(format!(
2278                                    "boundary request {request_id} resolved before its authority was delivered"
2279                                )))
2280                            }
2281                        },
2282                        _ => Err(CoreBoundaryStageError::unavailable(format!(
2283                            "run {expected_run_id} ended before boundary request {request_id} parked"
2284                        ))),
2285                    }
2286                } else {
2287                    Err(CoreBoundaryStageError::stale(format!(
2288                        "actor incarnation {} was revoked while preparing boundary",
2289                        self.boundary.incarnation_id
2290                    )))
2291                }
2292            };
2293            match poll {
2294                Ok(Some(prepared)) => {
2295                    pending.armed = false;
2296                    return Ok(prepared);
2297                }
2298                Ok(None) => notified.as_mut().await,
2299                Err(error) => return Err(error),
2300            }
2301        }
2302    }
2303
2304    /// Park at the exact model boundary and return a runner-owned consumption
2305    /// witness. Once a preparation has registered, this future cannot return
2306    /// until its authority commits, aborts, is dropped, or the run/actor closes.
2307    /// Returned pending state is not marked applied until the witness is
2308    /// synchronously consumed at the final LLM call seam.
2309    pub(crate) async fn take_pending_at_exact_boundary(
2310        &self,
2311        run_id: &RunId,
2312    ) -> Result<ModelBoundarySystemContext, CoreBoundaryStageError> {
2313        let parked_request_id;
2314        {
2315            let mut lifecycle = self.boundary.lock();
2316            if !lifecycle.actor_live {
2317                return Err(CoreBoundaryStageError::stale(format!(
2318                    "actor incarnation {} was revoked",
2319                    self.boundary.incarnation_id
2320                )));
2321            }
2322            let (generation, request) = match &mut lifecycle.window {
2323                SystemContextBoundaryWindow::Open {
2324                    run_id: current,
2325                    generation,
2326                    request,
2327                } if current == run_id => (*generation, request.take()),
2328                SystemContextBoundaryWindow::Open {
2329                    run_id: current, ..
2330                } => {
2331                    return Err(CoreBoundaryStageError::stale(format!(
2332                        "runner {run_id} reached boundary owned by {current}"
2333                    )));
2334                }
2335                SystemContextBoundaryWindow::Closed => {
2336                    return Err(CoreBoundaryStageError::unavailable(format!(
2337                        "run {run_id} reached a boundary with no open generation"
2338                    )));
2339                }
2340                SystemContextBoundaryWindow::Parked { .. }
2341                | SystemContextBoundaryWindow::Resolved { .. }
2342                | SystemContextBoundaryWindow::Consuming { .. } => {
2343                    return Err(CoreBoundaryStageError::fault(
2344                        "runner re-entered an unresolved model boundary",
2345                    ));
2346                }
2347            };
2348            if let Some(request) = request {
2349                let RegisteredSystemContextBoundaryRequest {
2350                    request_id,
2351                    appends,
2352                } = request;
2353                let state = self
2354                    .inner
2355                    .lock()
2356                    .unwrap_or_else(std::sync::PoisonError::into_inner);
2357                let mut candidate_state = state.clone();
2358                let candidate_result = appends.into_iter().try_for_each(|(append, accepted_at)| {
2359                    candidate_state
2360                        .stage_active_turn_append(&append, accepted_at)
2361                        .map(|_| ())
2362                });
2363                if let Err(error) = candidate_result {
2364                    drop(state);
2365                    let error = CoreBoundaryStageError::fault(error.to_string());
2366                    lifecycle.window = SystemContextBoundaryWindow::Resolved {
2367                        run_id: run_id.clone(),
2368                        generation,
2369                        request_id,
2370                        resolution: SystemContextBoundaryResolution::Failed(error.clone()),
2371                    };
2372                    drop(lifecycle);
2373                    self.boundary.notify.notify_waiters();
2374                    return Err(error);
2375                }
2376                drop(state);
2377                parked_request_id = request_id;
2378                lifecycle.window = SystemContextBoundaryWindow::Parked {
2379                    run_id: run_id.clone(),
2380                    generation,
2381                    request_id,
2382                    candidate_state,
2383                };
2384            } else {
2385                let state = self
2386                    .inner
2387                    .lock()
2388                    .unwrap_or_else(std::sync::PoisonError::into_inner);
2389                let pending = state.pending().to_vec();
2390                drop(state);
2391                lifecycle.window = SystemContextBoundaryWindow::Consuming {
2392                    run_id: run_id.clone(),
2393                    generation,
2394                    request_id: None,
2395                };
2396                return Ok(ModelBoundarySystemContext {
2397                    state: self.clone(),
2398                    run_id: run_id.clone(),
2399                    generation,
2400                    request_id: None,
2401                    appends: pending,
2402                    armed: true,
2403                });
2404            }
2405        }
2406        self.boundary.notify.notify_waiters();
2407
2408        let request_id = parked_request_id;
2409        struct RunnerParkGuard {
2410            boundary: Arc<SystemContextBoundaryCoordinator>,
2411            request_id: u64,
2412            armed: bool,
2413        }
2414        impl Drop for RunnerParkGuard {
2415            fn drop(&mut self) {
2416                if self.armed {
2417                    let _ = self.boundary.abort_request(self.request_id);
2418                }
2419            }
2420        }
2421        let mut park_guard = RunnerParkGuard {
2422            boundary: Arc::clone(&self.boundary),
2423            request_id,
2424            armed: true,
2425        };
2426
2427        loop {
2428            let notified = self.boundary.notify.notified();
2429            tokio::pin!(notified);
2430            notified.as_mut().enable();
2431            let poll = {
2432                let mut lifecycle = self.boundary.lock();
2433                if lifecycle.actor_live {
2434                    match &lifecycle.window {
2435                        SystemContextBoundaryWindow::Parked {
2436                            request_id: parked_request_id,
2437                            ..
2438                        } if *parked_request_id == request_id => Ok(None),
2439                        SystemContextBoundaryWindow::Resolved {
2440                            run_id: resolved_run_id,
2441                            generation,
2442                            request_id: resolved_request_id,
2443                            resolution,
2444                        } if resolved_run_id == run_id && *resolved_request_id == request_id => {
2445                            let resolution = resolution.clone();
2446                            let generation = *generation;
2447                            if let SystemContextBoundaryResolution::Failed(error) = resolution {
2448                                Err(error)
2449                            } else {
2450                                let pending = {
2451                                    let state = self
2452                                        .inner
2453                                        .lock()
2454                                        .unwrap_or_else(std::sync::PoisonError::into_inner);
2455                                    state.pending().to_vec()
2456                                };
2457                                lifecycle.window = SystemContextBoundaryWindow::Consuming {
2458                                    run_id: run_id.clone(),
2459                                    generation,
2460                                    request_id: Some(request_id),
2461                                };
2462                                Ok(Some((
2463                                    ModelBoundarySystemContext {
2464                                        state: self.clone(),
2465                                        run_id: run_id.clone(),
2466                                        generation,
2467                                        request_id: Some(request_id),
2468                                        appends: pending,
2469                                        armed: true,
2470                                    },
2471                                    resolution,
2472                                )))
2473                            }
2474                        }
2475                        _ => Err(CoreBoundaryStageError::stale(format!(
2476                            "parked boundary request {request_id} lost exact run/generation authority"
2477                        ))),
2478                    }
2479                } else {
2480                    Err(CoreBoundaryStageError::stale(format!(
2481                        "actor incarnation {} was revoked while parked",
2482                        self.boundary.incarnation_id
2483                    )))
2484                }
2485            };
2486            match poll {
2487                Ok(Some((context, resolution))) => {
2488                    park_guard.armed = false;
2489                    self.boundary.notify.notify_waiters();
2490                    if matches!(resolution, SystemContextBoundaryResolution::Aborted) {
2491                        tracing::debug!(
2492                            actor_incarnation = %self.boundary.incarnation_id,
2493                            run_id = %run_id,
2494                            request_id,
2495                            "exact model-boundary preparation aborted; consuming ordinary pending context only"
2496                        );
2497                    }
2498                    return Ok(context);
2499                }
2500                Ok(None) => notified.as_mut().await,
2501                Err(error) => {
2502                    park_guard.armed = false;
2503                    return Err(error);
2504                }
2505            }
2506        }
2507    }
2508
2509    fn finish_model_boundary_consumption(
2510        &self,
2511        run_id: &RunId,
2512        generation: u64,
2513        request_id: Option<u64>,
2514        apply: bool,
2515    ) -> Result<(), CoreBoundaryStageError> {
2516        let mut lifecycle = self.boundary.lock();
2517        if !lifecycle.actor_live {
2518            return Err(CoreBoundaryStageError::stale(format!(
2519                "actor incarnation {} was revoked before model-boundary consumption",
2520                self.boundary.incarnation_id
2521            )));
2522        }
2523        let matches_exact = matches!(
2524            &lifecycle.window,
2525            SystemContextBoundaryWindow::Consuming {
2526                run_id: current_run_id,
2527                generation: current_generation,
2528                request_id: current_request_id,
2529            } if current_run_id == run_id
2530                && *current_generation == generation
2531                && *current_request_id == request_id
2532        );
2533        if !matches_exact {
2534            return Err(CoreBoundaryStageError::stale(format!(
2535                "runner model-boundary witness for run {run_id} generation {generation} is no longer current"
2536            )));
2537        }
2538        if apply {
2539            let mut state = self
2540                .inner
2541                .lock()
2542                .unwrap_or_else(std::sync::PoisonError::into_inner);
2543            state.mark_pending_applied();
2544        }
2545        lifecycle.window = SystemContextBoundaryWindow::Closed;
2546        drop(lifecycle);
2547        self.boundary.notify.notify_waiters();
2548        Ok(())
2549    }
2550
2551    /// Revoke this exact actor allocation. Existing prepared authorities can
2552    /// no longer publish, and all runner/preparer waiters are synchronously
2553    /// released before actor-registry removal awaits anything.
2554    pub fn revoke_boundary_actor(&self) {
2555        self.boundary.revoke_actor();
2556    }
2557
2558    pub fn snapshot(&self) -> SessionSystemContextState {
2559        match self.inner.lock() {
2560            Ok(guard) => guard.clone(),
2561            Err(poisoned) => {
2562                tracing::warn!("system-context state lock poisoned while reading snapshot");
2563                poisoned.into_inner().clone()
2564            }
2565        }
2566    }
2567
2568    pub fn replace_from_generated_restore(
2569        &self,
2570        state: SessionSystemContextState,
2571    ) -> Result<(), serde_json::Error> {
2572        let state = system_context_authority::restore_system_context_state(state)
2573            .map_err(<serde_json::Error as serde::de::Error>::custom)?;
2574        let boundary = self.boundary.lock();
2575        if Self::boundary_reserves_state(&boundary) {
2576            return Err(<serde_json::Error as serde::de::Error>::custom(
2577                "system-context state is reserved by an exact parked boundary",
2578            ));
2579        }
2580        match self.inner.lock() {
2581            Ok(mut guard) => {
2582                *guard = state;
2583            }
2584            Err(poisoned) => {
2585                tracing::warn!("system-context state lock poisoned while restoring state");
2586                *poisoned.into_inner() = state;
2587            }
2588        }
2589        Ok(())
2590    }
2591
2592    pub fn replace_from_generated_restore_if_changed(
2593        &self,
2594        state: SessionSystemContextState,
2595    ) -> Result<bool, serde_json::Error> {
2596        let state = system_context_authority::restore_system_context_state(state)
2597            .map_err(<serde_json::Error as serde::de::Error>::custom)?;
2598        let boundary = self.boundary.lock();
2599        if Self::boundary_reserves_state(&boundary) {
2600            return Err(<serde_json::Error as serde::de::Error>::custom(
2601                "system-context state is reserved by an exact parked boundary",
2602            ));
2603        }
2604        let mut guard = match self.inner.lock() {
2605            Ok(guard) => guard,
2606            Err(poisoned) => {
2607                tracing::warn!(
2608                    "system-context state lock poisoned while replacing generated-restored state"
2609                );
2610                poisoned.into_inner()
2611            }
2612        };
2613        if *guard == state {
2614            return Ok(false);
2615        }
2616        *guard = state;
2617        Ok(true)
2618    }
2619
2620    pub fn replace_from_generated_restore_if_current(
2621        &self,
2622        current: &SessionSystemContextState,
2623        replacement: SessionSystemContextState,
2624    ) -> Result<bool, serde_json::Error> {
2625        let replacement = system_context_authority::restore_system_context_state(replacement)
2626            .map_err(<serde_json::Error as serde::de::Error>::custom)?;
2627        let boundary = self.boundary.lock();
2628        if Self::boundary_reserves_state(&boundary) {
2629            return Err(<serde_json::Error as serde::de::Error>::custom(
2630                "system-context state is reserved by an exact parked boundary",
2631            ));
2632        }
2633        let mut guard = match self.inner.lock() {
2634            Ok(guard) => guard,
2635            Err(poisoned) => {
2636                tracing::warn!(
2637                    "system-context state lock poisoned while conditionally replacing generated-restored state"
2638                );
2639                poisoned.into_inner()
2640            }
2641        };
2642        if *guard != *current {
2643            return Ok(false);
2644        }
2645        *guard = replacement;
2646        Ok(true)
2647    }
2648
2649    pub fn stage_append_with_snapshot(
2650        &self,
2651        req: &AppendSystemContextRequest,
2652        accepted_at: SystemTime,
2653    ) -> Result<
2654        (
2655            crate::service::AppendSystemContextStatus,
2656            SessionSystemContextState,
2657            SessionSystemContextState,
2658        ),
2659        SystemContextStageError,
2660    > {
2661        let boundary = self.boundary.lock();
2662        if Self::boundary_reserves_state(&boundary) {
2663            return Err(SystemContextStageError::InvalidRequest(
2664                "system-context state is reserved by an exact parked boundary".to_string(),
2665            ));
2666        }
2667        let mut guard = match self.inner.lock() {
2668            Ok(guard) => guard,
2669            Err(poisoned) => {
2670                tracing::warn!("system-context state lock poisoned while staging append");
2671                poisoned.into_inner()
2672            }
2673        };
2674        let snapshot = guard.clone();
2675        let status = guard.stage_append(req, accepted_at)?;
2676        let staged = guard.clone();
2677        Ok((status, snapshot, staged))
2678    }
2679
2680    pub fn stage_active_turn_appends_with_snapshot(
2681        &self,
2682        appends: Vec<(AppendSystemContextRequest, SystemTime)>,
2683    ) -> Result<(SessionSystemContextState, SessionSystemContextState), SystemContextStageError>
2684    {
2685        let boundary = self.boundary.lock();
2686        if Self::boundary_reserves_state(&boundary) {
2687            return Err(SystemContextStageError::InvalidRequest(
2688                "system-context state is reserved by an exact parked boundary".to_string(),
2689            ));
2690        }
2691        let mut guard = match self.inner.lock() {
2692            Ok(guard) => guard,
2693            Err(poisoned) => {
2694                tracing::warn!(
2695                    "system-context state lock poisoned while staging active-turn appends"
2696                );
2697                poisoned.into_inner()
2698            }
2699        };
2700        let snapshot = guard.clone();
2701        let mut candidate = snapshot.clone();
2702        for (req, accepted_at) in appends {
2703            candidate.stage_active_turn_append(&req, accepted_at)?;
2704        }
2705        *guard = candidate.clone();
2706        let staged = candidate;
2707        Ok((snapshot, staged))
2708    }
2709
2710    pub fn discard_unapplied_active_turn_pending(&self) -> Result<usize, CoreBoundaryStageError> {
2711        let boundary = self.boundary.lock();
2712        if Self::boundary_reserves_state(&boundary) {
2713            return Err(CoreBoundaryStageError::fault(format!(
2714                "cannot discard active-turn system context while exact actor incarnation {} owns a parked or consuming boundary",
2715                self.boundary.incarnation_id
2716            )));
2717        }
2718        let discarded = match self.inner.lock() {
2719            Ok(mut guard) => guard.discard_unapplied_active_turn_pending(),
2720            Err(poisoned) => {
2721                tracing::warn!(
2722                    "system-context state lock poisoned while discarding active-turn context"
2723                );
2724                poisoned
2725                    .into_inner()
2726                    .discard_unapplied_active_turn_pending()
2727            }
2728        };
2729        Ok(discarded.len())
2730    }
2731
2732    pub fn discard_active_turn_pending_by_keys(
2733        &self,
2734        idempotency_keys: &[String],
2735    ) -> Result<Vec<PendingSystemContextAppend>, CoreBoundaryStageError> {
2736        let boundary = self.boundary.lock();
2737        if Self::boundary_reserves_state(&boundary) {
2738            return Err(CoreBoundaryStageError::fault(format!(
2739                "cannot discard keyed active-turn system context while exact actor incarnation {} owns a parked or consuming boundary",
2740                self.boundary.incarnation_id
2741            )));
2742        }
2743        let discarded = match self.inner.lock() {
2744            Ok(mut guard) => guard.discard_active_turn_pending_by_keys(idempotency_keys),
2745            Err(poisoned) => {
2746                tracing::warn!(
2747                    "system-context state lock poisoned while discarding active-turn pending appends"
2748                );
2749                poisoned
2750                    .into_inner()
2751                    .discard_active_turn_pending_by_keys(idempotency_keys)
2752            }
2753        };
2754        Ok(discarded)
2755    }
2756
2757    pub fn stage_active_turn_append(
2758        &self,
2759        req: &AppendSystemContextRequest,
2760        accepted_at: SystemTime,
2761    ) -> Result<crate::service::AppendSystemContextStatus, SystemContextStageError> {
2762        let boundary = self.boundary.lock();
2763        if Self::boundary_reserves_state(&boundary) {
2764            return Err(SystemContextStageError::InvalidRequest(
2765                "system-context state is reserved by an exact parked boundary".to_string(),
2766            ));
2767        }
2768        match self.inner.lock() {
2769            Ok(mut guard) => guard.stage_active_turn_append(req, accepted_at),
2770            Err(poisoned) => {
2771                tracing::warn!(
2772                    "system-context state lock poisoned while staging active-turn context"
2773                );
2774                poisoned
2775                    .into_inner()
2776                    .stage_active_turn_append(req, accepted_at)
2777            }
2778        }
2779    }
2780}
2781
2782/// Durable control state for runtime system-context append requests.
2783// Cannot derive `Eq`: `PendingSystemContextAppend` carries a typed
2784// `peer_response_terminal` fact whose render payload is a `serde_json::Value`.
2785#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
2786#[serde(rename_all = "snake_case")]
2787pub struct SessionSystemContextState {
2788    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2789    pub(crate) pending: Vec<PendingSystemContextAppend>,
2790    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2791    pub(crate) applied: Vec<PendingSystemContextAppend>,
2792    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
2793    pub(crate) seen: std::collections::BTreeMap<String, SeenSystemContextKey>,
2794    /// Keyed projection used for idempotency-aware rollback. This is not the
2795    /// lifetime owner because active-turn appends may be keyless.
2796    #[serde(default, skip_serializing_if = "std::collections::BTreeSet::is_empty")]
2797    pub(crate) active_turn_pending_keys: std::collections::BTreeSet<String>,
2798    /// Exact positions in `pending` that belong to the active turn.
2799    ///
2800    /// Idempotency keys are optional, so they cannot carry lifetime ownership.
2801    /// The positional witness is durable and independent of deduplication;
2802    /// every pending-queue mutation rebases it atomically with the queue.
2803    #[serde(default, skip_serializing_if = "std::collections::BTreeSet::is_empty")]
2804    pub(crate) active_turn_pending_indices: std::collections::BTreeSet<u64>,
2805}
2806
2807/// Typed provenance class for a runtime system-context append.
2808///
2809/// Canonical replacement for the retired `runtime:steer:` string-prefix
2810/// folklore. The PRODUCER of a runtime-steer append (the runtime input
2811/// projection in `meerkat-runtime`) constructs it with
2812/// [`SystemContextSource::RuntimeSteer`]; everything else is
2813/// [`SystemContextSource::Normal`]. No code reclassifies a `source` string
2814/// into this fact — it is set once at construction and the machine guards the
2815/// typed field.
2816#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
2817#[serde(rename_all = "snake_case")]
2818pub enum SystemContextSource {
2819    /// A durable, non-transient runtime context append (peer responses, etc.).
2820    #[default]
2821    Normal,
2822    /// A transient operator/peer steer append that must not survive past the
2823    /// turn it steers and must not be promoted to the durable applied set.
2824    RuntimeSteer,
2825}
2826
2827impl From<SystemContextSource> for session_document::SystemContextSource {
2828    fn from(value: SystemContextSource) -> Self {
2829        match value {
2830            SystemContextSource::Normal => Self::Normal,
2831            SystemContextSource::RuntimeSteer => Self::RuntimeSteer,
2832        }
2833    }
2834}
2835
2836impl SystemContextSource {
2837    /// Whether this is the default (`Normal`) provenance. Used by
2838    /// `skip_serializing_if` so durable appends serialize without the field.
2839    #[must_use]
2840    pub fn is_normal(&self) -> bool {
2841        matches!(self, Self::Normal)
2842    }
2843
2844    /// Whether this append is a transient runtime steer.
2845    #[must_use]
2846    pub fn is_runtime_steer(&self) -> bool {
2847        matches!(self, Self::RuntimeSteer)
2848    }
2849}
2850
2851/// Pending append request accepted by the control plane but not yet applied at an LLM boundary.
2852// Cannot derive `Eq`: the typed `peer_response_terminal` fact carries a
2853// `serde_json::Value` render payload, which is `PartialEq` but not `Eq`.
2854#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2855#[serde(rename_all = "snake_case")]
2856pub struct PendingSystemContextAppend {
2857    /// Typed renderable append content, carried end-to-end from the surface
2858    /// request ([`AppendSystemContextRequest.content`]). The ONE lowering to
2859    /// model-facing prompt text happens where the transcript consumes the
2860    /// append ([`CoreRenderable::render_text`] inside the render seam) —
2861    /// surfaces never pre-flatten this into a string.
2862    ///
2863    /// [`CoreRenderable::render_text`]: crate::lifecycle::run_primitive::CoreRenderable::render_text
2864    pub content: crate::lifecycle::run_primitive::CoreRenderable,
2865    #[serde(default, skip_serializing_if = "Option::is_none")]
2866    pub source: Option<String>,
2867    #[serde(default, skip_serializing_if = "Option::is_none")]
2868    pub idempotency_key: Option<String>,
2869    /// Typed provenance: whether this append is a transient runtime steer.
2870    #[serde(default, skip_serializing_if = "SystemContextSource::is_normal")]
2871    pub source_kind: SystemContextSource,
2872    /// Typed terminal-peer-response fact this append carries, when the append
2873    /// projects a `PeerResponseTerminalFact`. The producer stamps the typed
2874    /// fact here at construction; realtime/live consumers read the typed fact
2875    /// directly instead of re-parsing the flattened prompt `text`/`source`
2876    /// string (the `peer_response_terminal:` prefix + `Payload:` split). This
2877    /// mirrors the `source_kind` precedent that retired the `runtime:steer:`
2878    /// string-prefix re-derivation.
2879    #[serde(default, skip_serializing_if = "Option::is_none")]
2880    pub peer_response_terminal: Option<crate::handles::PeerResponseTerminalFact>,
2881    pub accepted_at: SystemTime,
2882}
2883
2884/// Typed terminal-lifecycle projection of the canonical
2885/// [`session_document::SessionDocumentMachine`] `session_lifecycle_terminal`
2886/// fact.
2887///
2888/// The machine owns archive lifecycle truth for ALL profiles (LUC-524 R004
2889/// fold): both the runtime-backed and the store-only archive paths drive the
2890/// machine's `ArchiveSessionDocument` input, and this reserved-key field is
2891/// the machine-realized durable projection of the emitted verdict — the shell
2892/// realizes it, it never decides it. `RuntimeState::Retired` is the runtime
2893/// realization of the SAME verdict; the fail-closed realization order (durable
2894/// document commit first, runtime retire second) keeps the two projections
2895/// convergent. A two-variant enum (rather than a bare bool) keeps future
2896/// terminal classes — e.g. `Destroyed` — extending the type rather than the
2897/// call sites.
2898#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2899#[serde(rename_all = "snake_case")]
2900pub enum SessionLifecycleTerminal {
2901    /// The session is live / resumable.
2902    Active,
2903    /// The session has been archived and is terminal.
2904    Archived,
2905}
2906
2907impl SessionLifecycleTerminal {
2908    /// Whether this terminal fact marks the session as archived.
2909    #[must_use]
2910    pub fn is_archived(self) -> bool {
2911        matches!(self, Self::Archived)
2912    }
2913}
2914
2915impl From<SessionLifecycleTerminal> for session_document::SessionDocumentLifecycle {
2916    fn from(value: SessionLifecycleTerminal) -> Self {
2917        match value {
2918            SessionLifecycleTerminal::Active => Self::Active,
2919            SessionLifecycleTerminal::Archived => Self::Archived,
2920        }
2921    }
2922}
2923
2924impl From<session_document::SessionDocumentLifecycle> for SessionLifecycleTerminal {
2925    fn from(value: session_document::SessionDocumentLifecycle) -> Self {
2926        match value {
2927            session_document::SessionDocumentLifecycle::Active => Self::Active,
2928            session_document::SessionDocumentLifecycle::Archived => Self::Archived,
2929        }
2930    }
2931}
2932
2933/// Durable control state for deferred first-turn prompt and staged callback tool results.
2934#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
2935#[serde(rename_all = "snake_case")]
2936pub struct SessionDeferredTurnState {
2937    #[serde(default, skip_serializing_if = "DeferredFirstTurnPhase::is_inactive")]
2938    pub(crate) first_turn_phase: DeferredFirstTurnPhase,
2939    #[serde(default, skip_serializing_if = "Option::is_none")]
2940    pub(crate) pending_initial_prompt: Option<PendingDeferredPrompt>,
2941    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2942    pub(crate) pending_tool_results: Vec<PendingToolResultsMessage>,
2943}
2944
2945/// Canonical lifecycle phase for the session's deferred first turn.
2946#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
2947#[serde(rename_all = "snake_case")]
2948pub enum DeferredFirstTurnPhase {
2949    /// The session was not created in deferred-first-turn mode.
2950    #[default]
2951    Inactive,
2952    /// The session exists durably but the first turn has not started yet.
2953    Pending,
2954    /// The first turn has started; build-only overrides are no longer legal.
2955    Consumed,
2956}
2957
2958impl DeferredFirstTurnPhase {
2959    pub fn is_inactive(&self) -> bool {
2960        matches!(self, Self::Inactive)
2961    }
2962}
2963
2964impl From<DeferredFirstTurnPhase> for session_document::SessionFirstTurnPhase {
2965    fn from(value: DeferredFirstTurnPhase) -> Self {
2966        match value {
2967            DeferredFirstTurnPhase::Inactive => Self::Inactive,
2968            DeferredFirstTurnPhase::Pending => Self::Pending,
2969            DeferredFirstTurnPhase::Consumed => Self::Consumed,
2970        }
2971    }
2972}
2973
2974impl From<session_document::SessionFirstTurnPhase> for DeferredFirstTurnPhase {
2975    fn from(value: session_document::SessionFirstTurnPhase) -> Self {
2976        match value {
2977            session_document::SessionFirstTurnPhase::Inactive => Self::Inactive,
2978            session_document::SessionFirstTurnPhase::Pending => Self::Pending,
2979            session_document::SessionFirstTurnPhase::Consumed => Self::Consumed,
2980        }
2981    }
2982}
2983
2984fn is_default_hook_run_overrides(value: &crate::HookRunOverrides) -> bool {
2985    value == &crate::HookRunOverrides::default()
2986}
2987
2988fn is_default_call_timeout_override(value: &crate::CallTimeoutOverride) -> bool {
2989    value == &crate::CallTimeoutOverride::default()
2990}
2991
2992fn is_tool_filter_all(value: &ToolFilter) -> bool {
2993    matches!(value, ToolFilter::All)
2994}
2995
2996fn is_zero(value: &u64) -> bool {
2997    *value == 0
2998}
2999
3000/// Derive the machine-owned capability base filter from the current image-tool-results support.
3001pub fn capability_base_filter_for_image_tool_results(image_tool_results: bool) -> ToolFilter {
3002    if image_tool_results {
3003        ToolFilter::All
3004    } else {
3005        ToolFilter::Deny([VIEW_IMAGE_TOOL_NAME.to_string()].into_iter().collect())
3006    }
3007}
3008
3009/// Persisted witness for a durable tool-visibility name.
3010///
3011/// `last_seen_provenance` is the single typed identity owner. The formatted
3012/// `stable_owner_key` string is a read-only projection derived on demand via
3013/// [`crate::tool_catalog::stable_owner_key_from_provenance`], never stored
3014/// beside the owner.
3015#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
3016#[serde(rename_all = "snake_case")]
3017pub struct ToolVisibilityWitness {
3018    #[serde(default, skip_serializing_if = "Option::is_none")]
3019    pub last_seen_provenance: Option<ToolProvenance>,
3020}
3021
3022impl ToolVisibilityWitness {
3023    pub fn has_identity_witness(&self) -> bool {
3024        self.last_seen_provenance.is_some()
3025    }
3026}
3027
3028/// Typed authority value for a deferred-tool load request.
3029///
3030/// The public/effect seam carries the requested route name and provenance
3031/// witness as one value. Canonical owners may project this into name-indexed
3032/// maps internally, but callers do not get to make a map key the authority.
3033#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
3034#[serde(rename_all = "snake_case")]
3035pub struct DeferredToolLoadAuthority {
3036    pub name: ToolName,
3037    pub witness: ToolVisibilityWitness,
3038}
3039
3040impl DeferredToolLoadAuthority {
3041    pub fn new(name: impl Into<ToolName>, witness: ToolVisibilityWitness) -> Self {
3042        Self {
3043            name: name.into(),
3044            witness,
3045        }
3046    }
3047
3048    pub fn into_parts(self) -> (ToolName, ToolVisibilityWitness) {
3049        (self.name, self.witness)
3050    }
3051}
3052
3053/// Durable tool-filter intent paired with the witnesses that made the names
3054/// authoritative at capture time.
3055#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
3056#[serde(rename_all = "snake_case")]
3057pub struct WitnessedToolFilter {
3058    pub filter: ToolFilter,
3059    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
3060    pub witnesses: BTreeMap<ToolName, ToolVisibilityWitness>,
3061}
3062
3063impl WitnessedToolFilter {
3064    pub fn new(filter: ToolFilter, witnesses: BTreeMap<ToolName, ToolVisibilityWitness>) -> Self {
3065        Self { filter, witnesses }
3066    }
3067
3068    pub fn into_parts(self) -> (ToolFilter, BTreeMap<ToolName, ToolVisibilityWitness>) {
3069        (self.filter, self.witnesses)
3070    }
3071}
3072
3073/// Opaque parent/composition-authorized inherited tool visibility handoff.
3074///
3075/// The filter and witnesses are intentionally not public fields. Callers that
3076/// need to hand inherited visibility to a child build must obtain this from an
3077/// AgentFactory-minted parent composition authority; they cannot write
3078/// canonical session visibility state directly.
3079#[derive(Debug, Clone, PartialEq, Eq)]
3080pub struct InheritedToolVisibilityAuthority {
3081    filter: ToolFilter,
3082    witnesses: BTreeMap<ToolName, ToolVisibilityWitness>,
3083}
3084
3085impl InheritedToolVisibilityAuthority {
3086    pub(crate) fn from_generated_composition_authority(
3087        filter: ToolFilter,
3088        witnesses: BTreeMap<ToolName, ToolVisibilityWitness>,
3089    ) -> Self {
3090        Self { filter, witnesses }
3091    }
3092
3093    pub fn filter(&self) -> &ToolFilter {
3094        &self.filter
3095    }
3096
3097    pub fn witnesses(&self) -> &BTreeMap<ToolName, ToolVisibilityWitness> {
3098        &self.witnesses
3099    }
3100
3101    pub(crate) fn into_initial_visibility_state(self) -> SessionToolVisibilityState {
3102        SessionToolVisibilityState {
3103            inherited_base_filter: self.filter,
3104            filter_witnesses: self.witnesses,
3105            ..Default::default()
3106        }
3107    }
3108}
3109
3110/// Canonical durable session-local tool visibility intent.
3111#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
3112#[serde(rename_all = "snake_case")]
3113pub struct SessionToolVisibilityState {
3114    #[serde(default, skip_serializing_if = "is_tool_filter_all")]
3115    pub capability_base_filter: ToolFilter,
3116    #[serde(default, skip_serializing_if = "is_tool_filter_all")]
3117    pub inherited_base_filter: ToolFilter,
3118    #[serde(default, skip_serializing_if = "is_tool_filter_all")]
3119    pub active_filter: ToolFilter,
3120    #[serde(default, skip_serializing_if = "is_tool_filter_all")]
3121    pub staged_filter: ToolFilter,
3122    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
3123    pub active_requested_deferred_names: BTreeSet<ToolName>,
3124    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
3125    pub staged_requested_deferred_names: BTreeSet<ToolName>,
3126    #[serde(default, skip_serializing_if = "is_zero")]
3127    pub active_revision: u64,
3128    #[serde(default, skip_serializing_if = "is_zero")]
3129    pub staged_revision: u64,
3130    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
3131    pub requested_witnesses: BTreeMap<ToolName, ToolVisibilityWitness>,
3132    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
3133    pub filter_witnesses: BTreeMap<ToolName, ToolVisibilityWitness>,
3134}
3135
3136impl SessionToolVisibilityState {
3137    /// Deterministic projection of the generated CallingLlm visibility
3138    /// boundary. This is a comparison witness only: semantic promotion still
3139    /// belongs to the generated visibility owner.
3140    #[cfg(test)]
3141    pub(crate) fn projected_boundary_applied(&self) -> Self {
3142        let mut projected = self.clone();
3143        projected.active_filter = self.staged_filter.clone();
3144        projected.active_requested_deferred_names = self.staged_requested_deferred_names.clone();
3145        projected.active_revision = self.staged_revision;
3146        projected
3147    }
3148}
3149
3150/// Generated-authority-approved durable tool visibility projection.
3151///
3152/// Session metadata stores this as a projection of the generated visibility
3153/// owner. Code that only has raw `SessionToolVisibilityState` must first route
3154/// it through a `ToolVisibilityOwner`/`ToolScope` restore path.
3155#[derive(Debug, Clone, PartialEq, Eq)]
3156pub struct AuthorizedSessionToolVisibilityState {
3157    state: SessionToolVisibilityState,
3158}
3159
3160impl AuthorizedSessionToolVisibilityState {
3161    pub(crate) fn from_generated_authority(state: SessionToolVisibilityState) -> Self {
3162        Self { state }
3163    }
3164
3165    pub fn as_state(&self) -> &SessionToolVisibilityState {
3166        &self.state
3167    }
3168
3169    pub fn into_state(self) -> SessionToolVisibilityState {
3170        self.state
3171    }
3172}
3173
3174/// Durable build-only session state required to faithfully recover and rebuild
3175/// a persisted session without surface-local shadow config.
3176#[derive(Debug, Clone, Serialize, Deserialize, Default)]
3177#[serde(rename_all = "snake_case")]
3178pub struct SessionBuildState {
3179    #[serde(
3180        default,
3181        skip_serializing_if = "crate::config::SystemPromptOverride::is_inherit"
3182    )]
3183    pub system_prompt: crate::config::SystemPromptOverride,
3184    #[serde(default, skip_serializing_if = "Option::is_none")]
3185    pub output_schema: Option<crate::OutputSchema>,
3186    #[serde(default, skip_serializing_if = "is_default_hook_run_overrides")]
3187    pub hooks_override: crate::HookRunOverrides,
3188    #[serde(default, skip_serializing_if = "Option::is_none")]
3189    pub budget_limits: Option<crate::BudgetLimits>,
3190    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3191    pub recoverable_tool_defs: Vec<ToolDef>,
3192    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3193    pub silent_comms_intents: Vec<String>,
3194    #[serde(default, skip_serializing_if = "Option::is_none")]
3195    pub max_inline_peer_notifications: Option<i32>,
3196    #[serde(default, skip_serializing_if = "Option::is_none")]
3197    pub app_context: Option<serde_json::Value>,
3198    #[serde(default, skip_serializing_if = "Option::is_none")]
3199    pub additional_instructions: Option<Vec<String>>,
3200    #[serde(default, skip_serializing_if = "Option::is_none")]
3201    pub shell_env: Option<HashMap<String, String>>,
3202    /// Compatibility projection of mob operator authority.
3203    ///
3204    /// `MobToolAuthorityContext` deliberately loses its generated authority
3205    /// seal when serialized; restored behavior must be approved by the
3206    /// generated runtime bridge before this projection can affect tools.
3207    #[serde(default, skip_serializing_if = "Option::is_none")]
3208    pub mob_tool_authority_context: Option<MobToolAuthorityContext>,
3209    #[serde(default, skip_serializing_if = "is_default_call_timeout_override")]
3210    pub call_timeout_override: crate::CallTimeoutOverride,
3211    /// Exact assembled base-prompt bytes the last build applied (or verified)
3212    /// for this session. Runtime system-context appends extend the leading
3213    /// System message past this base; recording the base lets a later resume
3214    /// split the persisted content into `base + appended tail` byte-exactly
3215    /// (see [`Session::reconcile_resumed_system_prompt`]) instead of
3216    /// re-deriving append renders.
3217    #[serde(default, skip_serializing_if = "Option::is_none")]
3218    pub assembled_system_prompt: Option<String>,
3219}
3220
3221/// Deferred create-time prompt staged for the next turn.
3222#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
3223#[serde(rename_all = "snake_case")]
3224pub struct PendingDeferredPrompt {
3225    pub prompt: ContentInput,
3226    pub accepted_at: SystemTime,
3227}
3228
3229/// Staged callback tool results waiting to be admitted on the next turn seam.
3230#[derive(Debug, Clone, Serialize, Deserialize)]
3231#[serde(rename_all = "snake_case")]
3232pub struct PendingToolResultsMessage {
3233    pub results: Vec<ToolResult>,
3234    pub accepted_at: SystemTime,
3235}
3236
3237impl PartialEq for PendingToolResultsMessage {
3238    fn eq(&self, other: &Self) -> bool {
3239        self.accepted_at == other.accepted_at
3240            && serde_json::to_value(&self.results).ok() == serde_json::to_value(&other.results).ok()
3241    }
3242}
3243
3244/// Deferred first-turn inputs consumed at the generated start-turn authority seam.
3245#[derive(Debug, Clone, Default, PartialEq)]
3246pub struct ConsumedDeferredTurnInputs {
3247    pub(crate) restore_first_turn_pending: bool,
3248    pub(crate) pending_initial_prompt: Option<PendingDeferredPrompt>,
3249    pub(crate) pending_tool_results: Vec<PendingToolResultsMessage>,
3250}
3251
3252impl ConsumedDeferredTurnInputs {
3253    pub fn is_empty(&self) -> bool {
3254        !self.restore_first_turn_pending
3255            && self.pending_initial_prompt.is_none()
3256            && self.pending_tool_results.is_empty()
3257    }
3258
3259    pub fn pending_initial_prompt(&self) -> Option<&PendingDeferredPrompt> {
3260        self.pending_initial_prompt.as_ref()
3261    }
3262
3263    pub fn pending_tool_results(&self) -> &[PendingToolResultsMessage] {
3264        &self.pending_tool_results
3265    }
3266}
3267
3268/// Seen idempotency-key entry for system-context append requests.
3269#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
3270#[serde(rename_all = "snake_case")]
3271pub struct SeenSystemContextKey {
3272    /// Typed renderable content of the accepted append for this key.
3273    pub content: crate::lifecycle::run_primitive::CoreRenderable,
3274    #[serde(default, skip_serializing_if = "Option::is_none")]
3275    pub source: Option<String>,
3276    /// Typed provenance carried from the append, so runtime-steer cleanup can
3277    /// match seen entries by the typed marker rather than a `source` prefix.
3278    #[serde(default, skip_serializing_if = "SystemContextSource::is_normal")]
3279    pub source_kind: SystemContextSource,
3280    pub state: SeenSystemContextState,
3281}
3282
3283/// Lifecycle state for an accepted idempotency key.
3284#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
3285#[serde(rename_all = "snake_case")]
3286pub enum SeenSystemContextState {
3287    Pending,
3288    Applied,
3289}
3290
3291impl SessionSystemContextState {
3292    pub fn pending(&self) -> &[PendingSystemContextAppend] {
3293        &self.pending
3294    }
3295
3296    pub fn applied(&self) -> &[PendingSystemContextAppend] {
3297        &self.applied
3298    }
3299
3300    pub fn seen(&self) -> &BTreeMap<String, SeenSystemContextKey> {
3301        &self.seen
3302    }
3303
3304    pub fn active_turn_pending_keys(&self) -> &BTreeSet<String> {
3305        &self.active_turn_pending_keys
3306    }
3307
3308    pub fn pending_len(&self) -> usize {
3309        self.pending.len()
3310    }
3311
3312    pub fn applied_len(&self) -> usize {
3313        self.applied.len()
3314    }
3315
3316    pub fn active_turn_pending_len(&self) -> usize {
3317        if self.active_turn_pending_indices.is_empty() && !self.active_turn_pending_keys.is_empty()
3318        {
3319            return self
3320                .pending
3321                .iter()
3322                .filter(|append| {
3323                    append
3324                        .idempotency_key
3325                        .as_ref()
3326                        .is_some_and(|key| self.active_turn_pending_keys.contains(key))
3327                })
3328                .count();
3329        }
3330        self.active_turn_pending_indices.len()
3331    }
3332
3333    pub fn realtime_projection_appends(&self) -> Vec<PendingSystemContextAppend> {
3334        self.applied
3335            .iter()
3336            .chain(self.pending.iter())
3337            .cloned()
3338            .collect()
3339    }
3340
3341    /// Stage an append request, enforcing per-session idempotency.
3342    pub fn stage_append(
3343        &mut self,
3344        req: &AppendSystemContextRequest,
3345        accepted_at: SystemTime,
3346    ) -> Result<crate::service::AppendSystemContextStatus, SystemContextStageError> {
3347        system_context_authority::stage_append(self, req, accepted_at, false)
3348    }
3349
3350    fn stage_append_with_generated_authority(
3351        &mut self,
3352        req: &AppendSystemContextRequest,
3353        accepted_at: SystemTime,
3354        active_turn_scoped: bool,
3355    ) -> Result<crate::service::AppendSystemContextStatus, SystemContextStageError> {
3356        system_context_authority::stage_append(self, req, accepted_at, active_turn_scoped)
3357    }
3358
3359    /// Stage an append that is scoped to the currently-active turn only.
3360    ///
3361    /// If the active turn reaches another model boundary, normal pending
3362    /// consumption moves it to `applied`. If the turn completes first, callers
3363    /// should discard the still-pending active-turn keys so the context cannot
3364    /// leak into an unrelated later run.
3365    pub fn stage_active_turn_append(
3366        &mut self,
3367        req: &AppendSystemContextRequest,
3368        accepted_at: SystemTime,
3369    ) -> Result<crate::service::AppendSystemContextStatus, SystemContextStageError> {
3370        self.stage_append_with_generated_authority(req, accepted_at, true)
3371    }
3372
3373    /// Mark all currently-pending appends as applied and clear the pending queue.
3374    pub fn mark_pending_applied(&mut self) {
3375        system_context_authority::mark_pending_applied(self);
3376    }
3377
3378    /// Discard active-turn-only appends that were not consumed by the turn's
3379    /// next LLM boundary.
3380    pub fn discard_unapplied_active_turn_pending(&mut self) -> Vec<PendingSystemContextAppend> {
3381        system_context_authority::discard_unapplied_active_turn_pending(self)
3382    }
3383
3384    /// Discard specific active-turn-only appends that are still pending.
3385    ///
3386    /// This is the rollback companion for live-boundary staging. The runtime
3387    /// owns the accepted input, so if that commit fails after the session has
3388    /// staged context, the session-side projection must be removed by the same
3389    /// idempotency keys before the caller reports failure.
3390    pub fn discard_active_turn_pending_by_keys(
3391        &mut self,
3392        idempotency_keys: &[String],
3393    ) -> Vec<PendingSystemContextAppend> {
3394        system_context_authority::discard_active_turn_pending_by_keys(self, idempotency_keys)
3395    }
3396
3397    /// Authorize this snapshot through the canonical
3398    /// [`session_document::SessionDocumentMachine`] system-context restore
3399    /// transition, returning the state unchanged on success.
3400    pub fn restore_from_snapshot(self) -> Result<Self, SystemContextStageError> {
3401        system_context_authority::restore_system_context_state(self)
3402    }
3403
3404    /// Record the machine-authorized applied system-context blocks, returning
3405    /// the appends that are newly applied (and thus need rendering into the
3406    /// system prompt by the caller).
3407    pub fn record_applied_blocks(
3408        &mut self,
3409        appends: &[PendingSystemContextAppend],
3410        current_system_prompt: &str,
3411    ) -> Vec<PendingSystemContextAppend> {
3412        system_context_authority::record_applied_system_context_blocks(
3413            self,
3414            appends,
3415            current_system_prompt,
3416        )
3417    }
3418}
3419
3420/// Per-session registry key for the first-turn region of the
3421/// [`session_document::SessionDocumentMachine`]. Each
3422/// [`SessionDeferredTurnState`] is a single session's projection, so its
3423/// machine instance carries exactly one registry entry under this key.
3424const SESSION_DOCUMENT_FIRST_TURN_KEY: &str = "first_turn";
3425
3426fn usize_to_u64(value: usize) -> u64 {
3427    u64::try_from(value).unwrap_or(u64::MAX)
3428}
3429
3430/// Authorize a durable deferred-turn snapshot through the canonical
3431/// [`session_document::SessionDocumentMachine`] recovery transition.
3432///
3433/// The machine validates that the persisted first-turn phase is a legal
3434/// recovery target and adopts it into its per-session registry, emitting
3435/// `SessionFirstTurnPhaseRecovered`. The snapshot is returned unchanged on
3436/// success; the machine — not this shell — owns the recovery legality.
3437fn validate_deferred_turn_snapshot(
3438    state: SessionDeferredTurnState,
3439) -> Result<SessionDeferredTurnState, session_document::SessionDocumentError> {
3440    let mut authority = session_document::SessionDocumentMachineAuthority::new();
3441    let key = session_document::SessionDocumentKey::new(SESSION_DOCUMENT_FIRST_TURN_KEY);
3442    // The recovery transition fails closed for any illegal first-turn phase
3443    // (its guard admits only the three known phases); a rejection surfaces as
3444    // `Err` here. On success the machine has adopted the snapshot.
3445    authority.recover_session_first_turn_phase(
3446        key,
3447        state.first_turn_phase.into(),
3448        state.pending_initial_prompt.is_some(),
3449        usize_to_u64(state.pending_tool_results.len()),
3450    )?;
3451    Ok(state)
3452}
3453
3454impl SessionDeferredTurnState {
3455    pub fn first_turn_phase(&self) -> DeferredFirstTurnPhase {
3456        self.first_turn_phase
3457    }
3458
3459    pub fn pending_initial_prompt(&self) -> Option<&PendingDeferredPrompt> {
3460        self.pending_initial_prompt.as_ref()
3461    }
3462
3463    pub fn pending_tool_results(&self) -> &[PendingToolResultsMessage] {
3464        &self.pending_tool_results
3465    }
3466
3467    pub fn pending_tool_results_len(&self) -> usize {
3468        self.pending_tool_results.len()
3469    }
3470
3471    pub(crate) fn pending_initial_prompt_mut_for_blob_rewrite(
3472        &mut self,
3473    ) -> Option<&mut PendingDeferredPrompt> {
3474        self.pending_initial_prompt.as_mut()
3475    }
3476
3477    pub(crate) fn pending_tool_results_mut_for_blob_rewrite(
3478        &mut self,
3479    ) -> &mut [PendingToolResultsMessage] {
3480        &mut self.pending_tool_results
3481    }
3482
3483    /// Build a [`SessionDocumentMachineAuthority`] seeded with this session's
3484    /// current durable first-turn projection.
3485    ///
3486    /// The machine owns the canonical first-turn phase + presence/count in its
3487    /// own per-session `Map`; the durable [`SessionDeferredTurnState`] is its
3488    /// projection. We recover the machine-owned registry from that projection
3489    /// before driving an operation so every subsequent decision reads the
3490    /// machine's own state — the shell never passes a phase conclusion as an
3491    /// operation input.
3492    fn document_authority(
3493        &self,
3494    ) -> (
3495        session_document::SessionDocumentMachineAuthority,
3496        session_document::SessionDocumentKey,
3497    ) {
3498        let mut authority = session_document::SessionDocumentMachineAuthority::new();
3499        let key = session_document::SessionDocumentKey::new(SESSION_DOCUMENT_FIRST_TURN_KEY);
3500        if let Err(err) = authority.recover_session_first_turn_phase(
3501            key.clone(),
3502            self.first_turn_phase.into(),
3503            self.pending_initial_prompt.is_some(),
3504            usize_to_u64(self.pending_tool_results.len()),
3505        ) {
3506            tracing::warn!(
3507                error = %err,
3508                "generated session document authority rejected first-turn recovery"
3509            );
3510        }
3511        (authority, key)
3512    }
3513
3514    /// Mirror the machine-resolved first-turn phase from one effect batch onto
3515    /// the durable projection, returning `was_pending` when present.
3516    fn mirror_first_turn_phase(
3517        &mut self,
3518        effects: &[session_document::SessionDocumentEffect],
3519    ) -> Option<bool> {
3520        for effect in effects {
3521            if let session_document::SessionDocumentEffect::SessionFirstTurnPhaseResolved {
3522                phase,
3523                was_pending,
3524            } = effect
3525            {
3526                self.first_turn_phase = (*phase).into();
3527                return Some(*was_pending);
3528            }
3529        }
3530        None
3531    }
3532
3533    /// Mark that this session has a deferred first turn waiting to start.
3534    pub fn mark_initial_turn_pending(&mut self) {
3535        let (mut authority, key) = self.document_authority();
3536        match authority.mark_session_initial_turn_pending(key) {
3537            Ok(effects) => {
3538                self.mirror_first_turn_phase(&effects);
3539            }
3540            Err(err) => tracing::warn!(
3541                error = %err,
3542                "generated session document authority rejected pending mark"
3543            ),
3544        }
3545    }
3546
3547    /// Mark the deferred first turn as started.
3548    ///
3549    /// Returns true when the phase transitioned from `Pending`.
3550    pub fn mark_initial_turn_started(&mut self) -> bool {
3551        let (mut authority, key) = self.document_authority();
3552        match authority.start_session_initial_turn(key) {
3553            Ok(effects) => self.mirror_first_turn_phase(&effects).unwrap_or(false),
3554            Err(err) => {
3555                tracing::warn!(
3556                    error = %err,
3557                    "generated session document authority rejected first-turn start"
3558                );
3559                false
3560            }
3561        }
3562    }
3563
3564    /// Restore the deferred first-turn pending phase after a failed pre-run setup.
3565    pub fn restore_initial_turn_pending(&mut self) {
3566        // The restore-to-pending decision is the machine's
3567        // `RestoreSessionConsumedInputs` transition with phase rollback
3568        // requested; presence/count mirrors are left untouched here because the
3569        // bulky payloads are restored separately by the caller.
3570        let (mut authority, key) = self.document_authority();
3571        match authority.restore_session_consumed_inputs(
3572            key.clone(),
3573            true,
3574            self.pending_initial_prompt.is_some(),
3575            usize_to_u64(self.pending_tool_results.len()),
3576        ) {
3577            Ok(_) => {
3578                // Mirror the machine-owned phase the restore transition wrote
3579                // into its per-session registry rather than re-deriving it.
3580                if let Some(phase) = authority.session_first_turn_phase_for(&key) {
3581                    self.first_turn_phase = phase.into();
3582                }
3583            }
3584            Err(err) => tracing::warn!(
3585                error = %err,
3586                "generated session document authority rejected pending restore"
3587            ),
3588        }
3589    }
3590
3591    /// Whether build-only first-turn overrides are still legal for this session.
3592    pub fn allows_initial_turn_overrides(&self) -> bool {
3593        let (mut authority, key) = self.document_authority();
3594        match authority.resolve_session_first_turn_overrides_allowed(key) {
3595            Ok(effects) => effects
3596                .iter()
3597                .find_map(|effect| {
3598                    match effect {
3599                session_document::SessionDocumentEffect::SessionFirstTurnOverridesResolved {
3600                    allowed,
3601                } => Some(*allowed),
3602                _ => None,
3603            }
3604                })
3605                .unwrap_or(false),
3606            Err(err) => {
3607                tracing::warn!(
3608                    error = %err,
3609                    "generated session document authority rejected override resolution"
3610                );
3611                false
3612            }
3613        }
3614    }
3615
3616    /// Stage the create-time prompt for a later first turn.
3617    pub fn stage_initial_prompt(&mut self, prompt: ContentInput, accepted_at: SystemTime) {
3618        let prompt_has_content = prompt.has_images() || !prompt.text_content().trim().is_empty();
3619        let (mut authority, key) = self.document_authority();
3620        match authority.stage_session_initial_prompt(key, prompt_has_content) {
3621            Ok(effects) => {
3622                let decision = effects.iter().find_map(|effect| {
3623                    match effect {
3624                    session_document::SessionDocumentEffect::SessionInitialPromptStageResolved {
3625                        decision,
3626                    } => Some(*decision),
3627                    _ => None,
3628                }
3629                });
3630                match decision {
3631                    Some(session_document::SessionInitialPromptStageDecision::Store) => {
3632                        self.pending_initial_prompt = Some(PendingDeferredPrompt {
3633                            prompt,
3634                            accepted_at,
3635                        });
3636                    }
3637                    Some(session_document::SessionInitialPromptStageDecision::Clear) => {
3638                        self.pending_initial_prompt = None;
3639                    }
3640                    None => tracing::warn!(
3641                        "generated session document authority returned no prompt-stage decision"
3642                    ),
3643                }
3644            }
3645            Err(err) => tracing::warn!(
3646                error = %err,
3647                "generated session document authority rejected initial prompt stage"
3648            ),
3649        }
3650    }
3651
3652    /// Stage one callback tool-results message for the next turn.
3653    pub fn stage_tool_results(
3654        &mut self,
3655        results: Vec<ToolResult>,
3656        accepted_at: SystemTime,
3657    ) -> usize {
3658        let (mut authority, key) = self.document_authority();
3659        let accepted = match authority.stage_session_tool_results(key, usize_to_u64(results.len()))
3660        {
3661            Ok(effects) => effects.iter().find_map(|effect| match effect {
3662                session_document::SessionDocumentEffect::SessionToolResultsStageResolved {
3663                    accepted_count,
3664                } => Some(*accepted_count),
3665                _ => None,
3666            }),
3667            Err(err) => {
3668                tracing::warn!(
3669                    error = %err,
3670                    "generated session document authority rejected tool-results stage"
3671                );
3672                return 0;
3673            }
3674        };
3675        let Some(accepted) = accepted else {
3676            tracing::warn!(
3677                "generated session document authority returned no tool-results decision"
3678            );
3679            return 0;
3680        };
3681        if accepted == 0 {
3682            return 0;
3683        }
3684        let accepted = usize::try_from(accepted).unwrap_or(usize::MAX);
3685        self.pending_tool_results.push(PendingToolResultsMessage {
3686            results,
3687            accepted_at,
3688        });
3689        accepted
3690    }
3691
3692    /// Whether any callback tool results are currently staged.
3693    pub fn has_pending_tool_results(&self) -> bool {
3694        !self.pending_tool_results.is_empty()
3695    }
3696
3697    /// Start a turn and consume all inputs generated-authorized for that seam.
3698    pub fn consume_for_started_turn(&mut self) -> ConsumedDeferredTurnInputs {
3699        let (mut authority, key) = self.document_authority();
3700        let was_pending = match authority.consume_session_deferred_inputs(key) {
3701            Ok(effects) => self.mirror_first_turn_phase(&effects).unwrap_or(false),
3702            Err(err) => {
3703                tracing::warn!(
3704                    error = %err,
3705                    "generated session document authority rejected started-turn consumption"
3706                );
3707                return ConsumedDeferredTurnInputs::default();
3708            }
3709        };
3710        ConsumedDeferredTurnInputs {
3711            restore_first_turn_pending: was_pending,
3712            pending_initial_prompt: self.pending_initial_prompt.take(),
3713            pending_tool_results: std::mem::take(&mut self.pending_tool_results),
3714        }
3715    }
3716
3717    /// Restore inputs previously consumed by `consume_for_started_turn`.
3718    pub fn restore_consumed_turn_inputs(&mut self, consumed: ConsumedDeferredTurnInputs) {
3719        if consumed.is_empty() {
3720            return;
3721        }
3722        let (mut authority, key) = self.document_authority();
3723        let effects = match authority.restore_session_consumed_inputs(
3724            key,
3725            consumed.restore_first_turn_pending,
3726            consumed.pending_initial_prompt.is_some(),
3727            usize_to_u64(consumed.pending_tool_results.len()),
3728        ) {
3729            Ok(effects) => effects,
3730            Err(err) => {
3731                tracing::warn!(
3732                    error = %err,
3733                    "generated session document authority rejected consumed input restore"
3734                );
3735                return;
3736            }
3737        };
3738        let Some((restore_first_turn_pending, restore_initial_prompt, restore_tool_results)) =
3739            effects.iter().find_map(|effect| match effect {
3740                session_document::SessionDocumentEffect::SessionConsumedInputsRestoreResolved {
3741                    restore_first_turn_pending,
3742                    restore_initial_prompt,
3743                    restore_tool_results,
3744                } => Some((
3745                    *restore_first_turn_pending,
3746                    *restore_initial_prompt,
3747                    *restore_tool_results,
3748                )),
3749                _ => None,
3750            })
3751        else {
3752            tracing::warn!(
3753                "generated session document authority returned no consumed-input restore decision"
3754            );
3755            return;
3756        };
3757        if restore_first_turn_pending {
3758            self.restore_initial_turn_pending();
3759        }
3760        if restore_initial_prompt && self.pending_initial_prompt.is_none() {
3761            self.pending_initial_prompt = consumed.pending_initial_prompt;
3762        }
3763        if restore_tool_results {
3764            let mut restored = consumed.pending_tool_results;
3765            restored.extend(std::mem::take(&mut self.pending_tool_results));
3766            self.pending_tool_results = restored;
3767        }
3768    }
3769}
3770
3771/// Failure when staging a system-context append request.
3772#[derive(Debug, Clone, PartialEq, Eq)]
3773pub enum SystemContextStageError {
3774    InvalidRequest(String),
3775    Conflict {
3776        key: String,
3777        existing_text: String,
3778        existing_source: Option<String>,
3779    },
3780}
3781
3782impl std::fmt::Display for SystemContextStageError {
3783    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3784        match self {
3785            Self::InvalidRequest(message) => {
3786                write!(f, "invalid system-context append request: {message}")
3787            }
3788            Self::Conflict { key, .. } => {
3789                write!(
3790                    f,
3791                    "system-context append conflict for idempotency key `{key}`"
3792                )
3793            }
3794        }
3795    }
3796}
3797
3798impl std::error::Error for SystemContextStageError {}
3799
3800/// Mechanical PRESENTATION helper: render a system-context append into the
3801/// display block string that is concatenated into the model-facing system
3802/// prompt. This is NOT a decision — it builds the `[Runtime System Context]`
3803/// label text for OUTPUT only. The authority for which appends to render and
3804/// whether one is a runtime steer lives in the
3805/// [`session_document::SessionDocumentMachine`]; this function never inspects
3806/// the `source` string to classify anything.
3807fn render_system_context_block(append: &PendingSystemContextAppend) -> String {
3808    let mut rendered = String::from(SYSTEM_CONTEXT_RENDER_LABEL);
3809    if let Some(source) = &append.source {
3810        rendered.push_str("\nsource: ");
3811        rendered.push_str(source);
3812    }
3813    rendered.push_str("\n\n");
3814    // The single CoreRenderable -> prompt-text lowering for system-context
3815    // appends. Surfaces carry the typed renderable through untouched.
3816    rendered.push_str(append.content.render_text().trim());
3817    rendered
3818}
3819
3820/// Display label prefix for a rendered runtime system-context block.
3821///
3822/// PRESENTATION only — this is the human/model-facing heading, not a
3823/// classification key. Nothing reads this back to make a semantic decision.
3824const SYSTEM_CONTEXT_RENDER_LABEL: &str = "[Runtime System Context]";
3825
3826/// Render a sequence of system-context appends into the
3827/// [`SYSTEM_CONTEXT_SEPARATOR`]-joined block text that
3828/// [`Session::append_system_context_blocks`] concatenates onto the system
3829/// prompt. The single composition rule — shared by the append path and the
3830/// resume-time tail verification so the two can never drift apart.
3831fn render_system_context_blocks_joined(appends: &[PendingSystemContextAppend]) -> String {
3832    appends
3833        .iter()
3834        .map(render_system_context_block)
3835        .collect::<Vec<_>>()
3836        .join(SYSTEM_CONTEXT_SEPARATOR)
3837}
3838
3839/// Compose a system prompt from a base and a verified runtime-context tail
3840/// (leading [`SYSTEM_CONTEXT_SEPARATOR`] included; empty = no tail),
3841/// mirroring [`Session::append_system_context_blocks`]' rule that an empty
3842/// base renders the blocks without a separator prefix.
3843fn compose_system_prompt_with_context_tail(base: &str, tail: &str) -> String {
3844    if tail.is_empty() {
3845        return base.to_string();
3846    }
3847    if base.is_empty() {
3848        return tail
3849            .strip_prefix(SYSTEM_CONTEXT_SEPARATOR)
3850            .unwrap_or(tail)
3851            .to_string();
3852    }
3853    format!("{base}{tail}")
3854}
3855
3856/// Drive the canonical [`session_document::SessionDocumentMachine`]
3857/// persist-append admission for the resume fast path: may the persisted
3858/// System prompt be admitted as a runtime-context-append continuation of the
3859/// freshly assembled base?
3860///
3861/// Mirrors the save-guard shell (`session_store::system_context_is_append`):
3862/// this extracts only pure structural observations plus the typed
3863/// [`crate::types::SystemPromptMutationKind`] provenance; the machine owns
3864/// the verdict. A machine error fails closed — the caller falls back to the
3865/// audited rewrite path.
3866fn persisted_prompt_is_admitted_context_append_continuation(
3867    assembled_base: &str,
3868    persisted_content: &str,
3869    persisted_mutation_kind: crate::types::SystemPromptMutationKind,
3870) -> bool {
3871    let content_identical = persisted_content == assembled_base;
3872    let content_extends = persisted_content.starts_with(assembled_base);
3873    let appended_starts_with_separator = content_extends
3874        && persisted_content[assembled_base.len()..].starts_with(SYSTEM_CONTEXT_SEPARATOR);
3875    let mut authority = session_document::SessionDocumentMachineAuthority::new();
3876    match authority.resolve_system_context_persist_append_admission(
3877        true,
3878        content_identical,
3879        content_extends,
3880        appended_starts_with_separator,
3881        persisted_mutation_kind.is_runtime_context_append(),
3882    ) {
3883        Ok(effects) => effects.into_iter().any(|effect| {
3884            matches!(
3885                effect,
3886                session_document::SessionDocumentEffect::SystemContextPersistAppendAdmissionResolved {
3887                    admission: session_document::SystemContextPersistAppendAdmission::Admit,
3888                }
3889            )
3890        }),
3891        Err(error) => {
3892            tracing::warn!(
3893                error = %error,
3894                "session document authority refused resume prompt continuation admission; \
3895                 falling back to audited rewrite"
3896            );
3897            false
3898        }
3899    }
3900}
3901
3902/// Shell adapter that drives the canonical
3903/// [`session_document::SessionDocumentMachine`] system-context region and
3904/// mirrors its emitted decisions onto the bulky `SessionSystemContextState`.
3905///
3906/// The machine owns every SEMANTIC decision (append disposition, per-append
3907/// apply/discard from the typed [`SystemContextSource`] marker, snapshot
3908/// restore legality). This module performs only the mechanical collection
3909/// work — iterating the shell's pending/applied/seen collections and applying
3910/// the machine's per-item verdict. It never decides; in particular it never
3911/// inspects a `source` string to classify a runtime steer.
3912mod system_context_authority {
3913    use super::{
3914        AppendSystemContextRequest, BTreeSet, PendingSystemContextAppend, SeenSystemContextKey,
3915        SeenSystemContextState, SessionSystemContextState, SystemContextSource,
3916        SystemContextStageError, SystemTime, render_system_context_block, session_document,
3917        usize_to_u64,
3918    };
3919    use crate::service::AppendSystemContextStatus;
3920
3921    fn document_authority() -> session_document::SessionDocumentMachineAuthority {
3922        session_document::SessionDocumentMachineAuthority::new()
3923    }
3924
3925    /// Resolve the four-way append disposition through the machine.
3926    fn resolve_append_decision(
3927        trimmed_text_byte_count: u64,
3928        idempotency_key_present: bool,
3929        existing_key_matches: bool,
3930        existing_key_conflicts: bool,
3931        active_turn_scoped: bool,
3932    ) -> Result<session_document::SystemContextAppendDecision, SystemContextStageError> {
3933        let mut authority = document_authority();
3934        let effects = authority
3935            .resolve_system_context_append(
3936                trimmed_text_byte_count,
3937                idempotency_key_present,
3938                existing_key_matches,
3939                existing_key_conflicts,
3940                active_turn_scoped,
3941            )
3942            .map_err(|err| SystemContextStageError::InvalidRequest(err.to_string()))?;
3943        effects
3944            .into_iter()
3945            .find_map(|effect| match effect {
3946                session_document::SessionDocumentEffect::SystemContextAppendResolved {
3947                    decision,
3948                    ..
3949                } => Some(decision),
3950                _ => None,
3951            })
3952            .ok_or_else(|| {
3953                SystemContextStageError::InvalidRequest(
3954                    "generated session document authority returned no append decision".to_string(),
3955                )
3956            })
3957    }
3958
3959    /// Per-pending-append apply verdict, decided by the machine from the typed
3960    /// `source_kind` marker (NOT a `source` string prefix).
3961    fn pending_apply_item(source_kind: SystemContextSource) -> Option<(bool, bool, bool)> {
3962        let mut authority = document_authority();
3963        match authority.resolve_system_context_pending_apply_item(source_kind.into()) {
3964            Ok(effects) => effects.into_iter().find_map(|effect| {
3965                match effect {
3966                session_document::SessionDocumentEffect::SystemContextPendingApplyItemResolved {
3967                    promote_to_applied,
3968                    mark_seen_applied,
3969                    remove_seen,
3970                } => Some((promote_to_applied, mark_seen_applied, remove_seen)),
3971                _ => None,
3972            }
3973            }),
3974            Err(err) => {
3975                tracing::warn!(
3976                    error = %err,
3977                    "generated session document authority rejected system-context apply item"
3978                );
3979                None
3980            }
3981        }
3982    }
3983
3984    /// Per-item transient-steer discard verdict, decided by the machine from
3985    /// the typed `source_kind` marker.
3986    fn steer_cleanup_discards(source_kind: SystemContextSource) -> bool {
3987        let mut authority = document_authority();
3988        match authority.resolve_system_context_steer_cleanup_item(source_kind.into()) {
3989            Ok(effects) => effects
3990                .into_iter()
3991                .find_map(|effect| {
3992                    match effect {
3993                    session_document::SessionDocumentEffect::SystemContextSteerCleanupItemResolved {
3994                        discard,
3995                    } => Some(discard),
3996                    _ => None,
3997                }
3998                })
3999                .unwrap_or(false),
4000            Err(err) => {
4001                tracing::warn!(
4002                    error = %err,
4003                    "generated session document authority rejected system-context steer cleanup item"
4004                );
4005                false
4006            }
4007        }
4008    }
4009
4010    fn discard_pending_where(
4011        state: &mut SessionSystemContextState,
4012        mut should_discard: impl FnMut(&PendingSystemContextAppend, bool) -> bool,
4013    ) -> Vec<PendingSystemContextAppend> {
4014        reconstruct_legacy_active_turn_indices(state);
4015        let active_indices = std::mem::take(&mut state.active_turn_pending_indices);
4016        let pending = std::mem::take(&mut state.pending);
4017        let mut retained = Vec::with_capacity(pending.len());
4018        let mut retained_active_indices = BTreeSet::new();
4019        let mut retained_active_keys = BTreeSet::new();
4020        let mut discarded = Vec::new();
4021
4022        for (index, append) in pending.into_iter().enumerate() {
4023            let is_active_turn = active_indices.contains(&usize_to_u64(index));
4024            if should_discard(&append, is_active_turn) {
4025                discarded.push(append);
4026                continue;
4027            }
4028            if is_active_turn {
4029                retained_active_indices.insert(usize_to_u64(retained.len()));
4030                if let Some(key) = append.idempotency_key.as_ref() {
4031                    retained_active_keys.insert(key.clone());
4032                }
4033            }
4034            retained.push(append);
4035        }
4036
4037        state.pending = retained;
4038        state.active_turn_pending_indices = retained_active_indices;
4039        state.active_turn_pending_keys = retained_active_keys;
4040        discarded
4041    }
4042
4043    fn reconstruct_legacy_active_turn_indices(state: &mut SessionSystemContextState) {
4044        if !state.active_turn_pending_indices.is_empty()
4045            || state.active_turn_pending_keys.is_empty()
4046        {
4047            return;
4048        }
4049        state.active_turn_pending_indices = state
4050            .pending
4051            .iter()
4052            .enumerate()
4053            .filter(|(_index, append)| {
4054                append
4055                    .idempotency_key
4056                    .as_ref()
4057                    .is_some_and(|key| state.active_turn_pending_keys.contains(key))
4058            })
4059            .map(|(index, _append)| usize_to_u64(index))
4060            .collect();
4061    }
4062
4063    pub(super) fn restore_system_context_state(
4064        mut state: SessionSystemContextState,
4065    ) -> Result<SessionSystemContextState, SystemContextStageError> {
4066        // Backward compatibility for snapshots written before active-turn
4067        // membership had an identity independent of idempotency. Keyed
4068        // members can be reconstructed exactly from the pending queue.
4069        reconstruct_legacy_active_turn_indices(&mut state);
4070        let active_indices_are_in_bounds = state
4071            .active_turn_pending_indices
4072            .iter()
4073            .all(|index| usize::try_from(*index).is_ok_and(|index| index < state.pending.len()));
4074        let active_keys_have_indexed_pending = state.active_turn_pending_keys.iter().all(|key| {
4075            state.active_turn_pending_indices.iter().any(|index| {
4076                usize::try_from(*index)
4077                    .ok()
4078                    .and_then(|index| state.pending.get(index))
4079                    .and_then(|append| append.idempotency_key.as_ref())
4080                    == Some(key)
4081            })
4082        });
4083        let indexed_pending_keys_are_active =
4084            state.active_turn_pending_indices.iter().all(|index| {
4085                usize::try_from(*index)
4086                    .ok()
4087                    .and_then(|index| state.pending.get(index))
4088                    .is_some_and(|append| {
4089                        append
4090                            .idempotency_key
4091                            .as_ref()
4092                            .is_none_or(|key| state.active_turn_pending_keys.contains(key))
4093                    })
4094            });
4095        let active_turn_membership_is_consistent = active_indices_are_in_bounds
4096            && active_keys_have_indexed_pending
4097            && indexed_pending_keys_are_active;
4098        let seen_keys_match_known_appends = state.seen.iter().all(|(key, seen)| {
4099            state
4100                .pending
4101                .iter()
4102                .chain(state.applied.iter())
4103                .any(|append| {
4104                    append.idempotency_key.as_ref() == Some(key)
4105                        && seen.content == append.content
4106                        && seen.source.as_deref() == append.source.as_deref()
4107                })
4108        });
4109        let mut authority = document_authority();
4110        authority
4111            .restore_system_context_snapshot(
4112                active_turn_membership_is_consistent,
4113                seen_keys_match_known_appends,
4114            )
4115            .map_err(|err| SystemContextStageError::InvalidRequest(err.to_string()))?;
4116        Ok(state)
4117    }
4118
4119    pub(super) fn stage_append(
4120        state: &mut SessionSystemContextState,
4121        req: &AppendSystemContextRequest,
4122        accepted_at: SystemTime,
4123        active_turn_scoped: bool,
4124    ) -> Result<AppendSystemContextStatus, SystemContextStageError> {
4125        // Emptiness is judged on the canonical text projection; the typed
4126        // renderable itself is what gets stored (lowering happens once, at
4127        // the transcript render seam).
4128        let rendered_text = req.content.render_text();
4129        let rendered_len = rendered_text.trim().len();
4130        let existing = req
4131            .idempotency_key
4132            .as_ref()
4133            .and_then(|key| state.seen.get(key));
4134        let existing_key_matches = existing.is_some_and(|existing| {
4135            existing.content == req.content && existing.source.as_deref() == req.source.as_deref()
4136        });
4137        let existing_key_conflicts = existing.is_some() && !existing_key_matches;
4138        let decision = resolve_append_decision(
4139            usize_to_u64(rendered_len),
4140            req.idempotency_key.is_some(),
4141            existing_key_matches,
4142            existing_key_conflicts,
4143            active_turn_scoped,
4144        )?;
4145
4146        match decision {
4147            session_document::SystemContextAppendDecision::RejectEmpty => {
4148                return Err(SystemContextStageError::InvalidRequest(
4149                    "system context text must not be empty".to_string(),
4150                ));
4151            }
4152            session_document::SystemContextAppendDecision::RejectConflict => {
4153                let Some(key) = req.idempotency_key.as_ref() else {
4154                    return Err(SystemContextStageError::InvalidRequest(
4155                        "generated system-context authority rejected append without a key"
4156                            .to_string(),
4157                    ));
4158                };
4159                let Some(existing) = existing else {
4160                    return Err(SystemContextStageError::InvalidRequest(
4161                        "generated system-context authority rejected append without a conflict"
4162                            .to_string(),
4163                    ));
4164                };
4165                return Err(SystemContextStageError::Conflict {
4166                    key: key.clone(),
4167                    existing_text: existing.content.render_text(),
4168                    existing_source: existing.source.clone(),
4169                });
4170            }
4171            session_document::SystemContextAppendDecision::Duplicate => {
4172                return Ok(AppendSystemContextStatus::Duplicate);
4173            }
4174            session_document::SystemContextAppendDecision::Staged => {}
4175        }
4176
4177        let append = PendingSystemContextAppend {
4178            content: req.content.clone(),
4179            source: req.source.clone(),
4180            idempotency_key: req.idempotency_key.clone(),
4181            source_kind: req.source_kind,
4182            // Carry the typed `PeerResponseTerminalFact` so realtime/live
4183            // consumers read it directly instead of re-parsing the flattened
4184            // prompt text. Mirrors the `source_kind` typed-provenance precedent.
4185            peer_response_terminal: req.peer_response_terminal.clone(),
4186            accepted_at,
4187        };
4188        if let Some(key) = req.idempotency_key.as_ref() {
4189            state.seen.insert(
4190                key.clone(),
4191                SeenSystemContextKey {
4192                    content: append.content.clone(),
4193                    source: append.source.clone(),
4194                    source_kind: append.source_kind,
4195                    state: SeenSystemContextState::Pending,
4196                },
4197            );
4198        }
4199        if active_turn_scoped {
4200            state
4201                .active_turn_pending_indices
4202                .insert(usize_to_u64(state.pending.len()));
4203            if let Some(key) = req.idempotency_key.as_ref() {
4204                state.active_turn_pending_keys.insert(key.clone());
4205            }
4206        }
4207        state.pending.push(append);
4208        Ok(AppendSystemContextStatus::Staged)
4209    }
4210
4211    pub(super) fn mark_pending_applied(state: &mut SessionSystemContextState) {
4212        // Promote pending appends to applied per the machine's per-item
4213        // verdict (keyed on the typed `source_kind`).
4214        let pending = std::mem::take(&mut state.pending);
4215        let mut seen_to_remove = Vec::new();
4216        for append in &pending {
4217            let Some((promote_to_applied, mark_seen_applied, remove_seen)) =
4218                pending_apply_item(append.source_kind)
4219            else {
4220                continue;
4221            };
4222            if promote_to_applied && !state.applied.contains(append) {
4223                state.applied.push(append.clone());
4224            }
4225            if let Some(key) = append.idempotency_key.as_ref() {
4226                if remove_seen {
4227                    seen_to_remove.push(key.clone());
4228                } else if mark_seen_applied && let Some(seen) = state.seen.get_mut(key) {
4229                    seen.state = SeenSystemContextState::Applied;
4230                }
4231            }
4232        }
4233        for key in seen_to_remove {
4234            state.seen.remove(&key);
4235        }
4236        state.active_turn_pending_keys.clear();
4237        state.active_turn_pending_indices.clear();
4238    }
4239
4240    pub(super) fn discard_unapplied_active_turn_pending(
4241        state: &mut SessionSystemContextState,
4242    ) -> Vec<PendingSystemContextAppend> {
4243        reconstruct_legacy_active_turn_indices(state);
4244        if state.active_turn_pending_indices.is_empty() {
4245            return Vec::new();
4246        }
4247        let discarded = discard_pending_where(state, |_append, is_active_turn| is_active_turn);
4248
4249        for append in &discarded {
4250            if let Some(key) = append.idempotency_key.as_ref()
4251                && state
4252                    .seen
4253                    .get(key)
4254                    .is_some_and(|seen| seen.state == SeenSystemContextState::Pending)
4255            {
4256                state.seen.remove(key);
4257            }
4258        }
4259
4260        discarded
4261    }
4262
4263    pub(super) fn discard_active_turn_pending_by_keys(
4264        state: &mut SessionSystemContextState,
4265        idempotency_keys: &[String],
4266    ) -> Vec<PendingSystemContextAppend> {
4267        reconstruct_legacy_active_turn_indices(state);
4268        if idempotency_keys.is_empty() || state.active_turn_pending_indices.is_empty() {
4269            return Vec::new();
4270        }
4271        let requested_keys: BTreeSet<&str> = idempotency_keys.iter().map(String::as_str).collect();
4272        let discarded = discard_pending_where(state, |append, is_active_turn| {
4273            is_active_turn
4274                && append
4275                    .idempotency_key
4276                    .as_ref()
4277                    .is_some_and(|key| requested_keys.contains(key.as_str()))
4278        });
4279
4280        for append in &discarded {
4281            let Some(key) = append.idempotency_key.as_ref() else {
4282                continue;
4283            };
4284            if state
4285                .seen
4286                .get(key)
4287                .is_some_and(|seen| seen.state == SeenSystemContextState::Pending)
4288            {
4289                state.seen.remove(key);
4290            }
4291        }
4292
4293        discarded
4294    }
4295
4296    pub(super) fn discard_transient_runtime_steer_state(
4297        state: &mut SessionSystemContextState,
4298    ) -> usize {
4299        let mut removed = 0usize;
4300
4301        let before_active = state.active_turn_pending_keys.len();
4302        removed += discard_pending_where(state, |append, _is_active_turn| {
4303            steer_cleanup_discards(append.source_kind)
4304        })
4305        .len();
4306
4307        let before_applied = state.applied.len();
4308        state
4309            .applied
4310            .retain(|append| !steer_cleanup_discards(append.source_kind));
4311        removed += before_applied.saturating_sub(state.applied.len());
4312
4313        let before_seen = state.seen.len();
4314        state
4315            .seen
4316            .retain(|_key, seen| !steer_cleanup_discards(seen.source_kind));
4317        removed += before_seen.saturating_sub(state.seen.len());
4318
4319        removed += before_active.saturating_sub(state.active_turn_pending_keys.len());
4320
4321        removed
4322    }
4323
4324    pub(super) fn remove_runtime_steer_blocks_for_rendered(
4325        system_prompt: &str,
4326        runtime_steer_appends: &[PendingSystemContextAppend],
4327    ) -> (String, usize) {
4328        if runtime_steer_appends.is_empty() {
4329            return (system_prompt.to_string(), 0);
4330        }
4331        // Build the set of rendered blocks for the typed runtime-steer appends,
4332        // then remove those exact rendered blocks from the prompt. The typed
4333        // marker is the authority; rendering is mechanical presentation.
4334        let steer_blocks: BTreeSet<String> = runtime_steer_appends
4335            .iter()
4336            .map(render_system_context_block)
4337            .collect();
4338        let parts = system_prompt
4339            .split(super::SYSTEM_CONTEXT_SEPARATOR)
4340            .map(str::to_string)
4341            .collect::<Vec<_>>();
4342        let original_len = parts.len();
4343        let retained = parts
4344            .into_iter()
4345            .filter(|part| !steer_blocks.contains(part))
4346            .collect::<Vec<_>>();
4347        let removed = original_len.saturating_sub(retained.len());
4348        (retained.join(super::SYSTEM_CONTEXT_SEPARATOR), removed)
4349    }
4350
4351    pub(super) fn record_applied_system_context_blocks(
4352        state: &mut SessionSystemContextState,
4353        appends: &[PendingSystemContextAppend],
4354        current_system_prompt: &str,
4355    ) -> Vec<PendingSystemContextAppend> {
4356        let mut new_appends: Vec<PendingSystemContextAppend> = Vec::new();
4357        for append in appends {
4358            if append.content.render_text().trim().is_empty() {
4359                continue;
4360            }
4361            let rendered = render_system_context_block(append);
4362            if let Some(key) = append.idempotency_key.as_ref() {
4363                if let Some(existing) = state.seen.get(key)
4364                    && !seen_system_context_matches(existing, append)
4365                {
4366                    tracing::warn!(
4367                        idempotency_key = %key,
4368                        "skipping conflicting runtime system-context append"
4369                    );
4370                    continue;
4371                }
4372                if let Some(existing) = state
4373                    .applied
4374                    .iter()
4375                    .find(|applied| applied.idempotency_key.as_ref() == Some(key))
4376                    && !pending_system_context_matches(existing, append)
4377                {
4378                    tracing::warn!(
4379                        idempotency_key = %key,
4380                        "skipping conflicting runtime system-context append"
4381                    );
4382                    continue;
4383                }
4384                if let Some(existing) = new_appends
4385                    .iter()
4386                    .find(|pending| pending.idempotency_key.as_ref() == Some(key))
4387                {
4388                    if !pending_system_context_matches(existing, append) {
4389                        tracing::warn!(
4390                            idempotency_key = %key,
4391                            "skipping conflicting runtime system-context append"
4392                        );
4393                    }
4394                    continue;
4395                }
4396                if current_system_prompt.contains(&rendered) {
4397                    record_applied_append(state, append);
4398                    continue;
4399                }
4400            } else if new_appends.contains(append) || current_system_prompt.contains(&rendered) {
4401                continue;
4402            }
4403            record_applied_append(state, append);
4404            new_appends.push(append.clone());
4405        }
4406        new_appends
4407    }
4408
4409    fn record_applied_append(
4410        state: &mut SessionSystemContextState,
4411        append: &PendingSystemContextAppend,
4412    ) {
4413        if let Some(key) = append.idempotency_key.as_ref() {
4414            state.seen.insert(
4415                key.clone(),
4416                SeenSystemContextKey {
4417                    content: append.content.clone(),
4418                    source: append.source.clone(),
4419                    source_kind: append.source_kind,
4420                    state: SeenSystemContextState::Applied,
4421                },
4422            );
4423            if state
4424                .applied
4425                .iter()
4426                .any(|applied| applied.idempotency_key.as_ref() == Some(key))
4427            {
4428                return;
4429            }
4430        } else if state.applied.contains(append) {
4431            return;
4432        }
4433        state.applied.push(append.clone());
4434    }
4435
4436    fn seen_system_context_matches(
4437        seen: &SeenSystemContextKey,
4438        append: &PendingSystemContextAppend,
4439    ) -> bool {
4440        seen.content == append.content && seen.source.as_deref() == append.source.as_deref()
4441    }
4442
4443    fn pending_system_context_matches(
4444        existing: &PendingSystemContextAppend,
4445        append: &PendingSystemContextAppend,
4446    ) -> bool {
4447        existing.content == append.content && existing.source.as_deref() == append.source.as_deref()
4448    }
4449}
4450
4451impl Session {
4452    /// Create a new empty session
4453    pub fn new() -> Self {
4454        let now = SystemTime::now();
4455        Self {
4456            version: session_version(),
4457            id: SessionId::new(),
4458            messages: Arc::new(Vec::new()),
4459            created_at: now,
4460            updated_at: now,
4461            metadata: serde_json::Map::new(),
4462            transcript_history_metadata_validation: TranscriptHistoryMetadataValidation::Validated,
4463            usage: Usage::default(),
4464        }
4465    }
4466
4467    /// Create a session with a specific ID (for loading)
4468    pub fn with_id(id: SessionId) -> Self {
4469        let mut session = Self::new();
4470        session.id = id;
4471        session
4472    }
4473
4474    /// Get the session ID
4475    pub fn id(&self) -> &SessionId {
4476        &self.id
4477    }
4478
4479    /// Get the session version
4480    pub fn version(&self) -> u32 {
4481        self.version
4482    }
4483
4484    /// Get all messages.
4485    pub fn messages(&self) -> &[Message] {
4486        &self.messages
4487    }
4488
4489    /// Replace the message buffer for core-owned internal transcript rewrites.
4490    ///
4491    /// Intentionally `pub(crate)`: cross-crate consumers must route same-session
4492    /// rewrites through transcript-edit APIs so the revision graph remains the
4493    /// semantic owner of message history.
4494    #[allow(dead_code)] // Kept for core-owned optional rewrite paths and focused invariants.
4495    pub(crate) fn replace_messages_internal(
4496        &mut self,
4497        messages: Vec<Message>,
4498        reason: TranscriptRewriteReason,
4499    ) -> Result<Option<TranscriptRewriteCommit>, TranscriptEditError> {
4500        if transcript_messages_digest(self.messages()).ok()
4501            == transcript_messages_digest(&messages).ok()
4502        {
4503            return Ok(None);
4504        }
4505        let commit = self.commit_transcript_rewrite(
4506            TranscriptRewriteSelection::MessageRange {
4507                start: 0,
4508                end: self.messages.len(),
4509            },
4510            messages,
4511            reason,
4512            Some("meerkat-core".to_string()),
4513            None,
4514        )?;
4515        Ok(Some(commit))
4516    }
4517
4518    /// Replace the full transcript under the opaque authority minted by the
4519    /// validated compaction rebuild path.
4520    pub(crate) fn replace_messages_for_compaction_internal(
4521        &mut self,
4522        messages: Vec<Message>,
4523        authority: &crate::agent::compact::ValidatedCompactionRewrite,
4524    ) -> Result<Option<TranscriptRewriteCommit>, TranscriptEditError> {
4525        if transcript_messages_digest(self.messages()).ok()
4526            == transcript_messages_digest(&messages).ok()
4527        {
4528            return Ok(None);
4529        }
4530        if !authority
4531            .authorizes(self.messages(), &messages)
4532            .map_err(|error| TranscriptEditError::HistoryStateMalformed(error.to_string()))?
4533        {
4534            return Err(TranscriptEditError::InvalidTranscriptShape(
4535                "validated compaction witness does not authorize this exact transcript rebuild"
4536                    .to_string(),
4537            ));
4538        }
4539        let summary_count = messages
4540            .iter()
4541            .filter(|message| {
4542                matches!(message, Message::User(user) if user.transcript_role.is_compaction_summary())
4543            })
4544            .count();
4545        if messages.len() >= self.messages.len() || summary_count != 1 {
4546            return Err(TranscriptEditError::InvalidTranscriptShape(
4547                "validated compaction rewrite must shrink the transcript and carry exactly one CompactionSummary"
4548                    .to_string(),
4549            ));
4550        }
4551        let selection =
4552            TranscriptRewriteSelection::validated_compaction(0, self.messages.len(), authority);
4553        let commit = self.commit_transcript_rewrite_authorized(
4554            selection,
4555            messages,
4556            TranscriptRewriteReason::new("compaction"),
4557            Some("meerkat-core".to_string()),
4558            None,
4559        )?;
4560        Ok(Some(commit))
4561    }
4562
4563    /// Atomically refresh the synthetic runtime notices of one kind.
4564    ///
4565    /// This is the ONE transcript authority operation for synthetic-notice
4566    /// refresh: it strips every synthetic `SystemNotice` projection of `kind`
4567    /// while preserving durable notices that share the kind, then appends
4568    /// `replacements` (possibly empty, meaning "no current synthetic notice")
4569    /// as one mechanical projection update. It deliberately does not mint an
4570    /// audited transcript rewrite commit. On a strip fault nothing is pushed
4571    /// and the typed [`TranscriptEditError`] propagates — callers must not
4572    /// re-implement the strip-then-push pair (the swallowed-strip variant
4573    /// leaves a stale notice beside a fresh one: a divergence window).
4574    pub fn replace_synthetic_notices(
4575        &mut self,
4576        kind: crate::types::SystemNoticeKind,
4577        replacements: Vec<Message>,
4578    ) -> Result<(), TranscriptEditError> {
4579        if !kind.is_synthetic_refresh_projection() {
4580            return Err(TranscriptEditError::InvalidTranscriptShape(format!(
4581                "system notice kind {kind:?} is durable transcript content, not a synthetic refresh projection"
4582            )));
4583        }
4584        for (index, message) in replacements.iter().enumerate() {
4585            let matches_kind = matches!(
4586                message,
4587                Message::SystemNotice(notice)
4588                    if notice.kind == kind && notice.is_synthetic_refresh_projection()
4589            );
4590            if !matches_kind {
4591                return Err(TranscriptEditError::InvalidTranscriptShape(format!(
4592                    "replacement {index} for synthetic notice kind {kind:?} is not a system notice of that kind"
4593                )));
4594            }
4595        }
4596
4597        let mut refreshed = self
4598            .messages
4599            .iter()
4600            .filter(|message| {
4601                !matches!(
4602                    message,
4603                    Message::SystemNotice(notice)
4604                        if notice.kind == kind && notice.is_synthetic_refresh_projection()
4605                )
4606            })
4607            .cloned()
4608            .collect::<Vec<_>>();
4609        refreshed.extend(replacements);
4610        if transcript_messages_digest(self.messages()).ok()
4611            == transcript_messages_digest(&refreshed).ok()
4612        {
4613            return Ok(());
4614        }
4615
4616        let realtime_state =
4617            self.reconciled_realtime_transcript_metadata_after_rewrite(&refreshed)?;
4618        let updated_at = SystemTime::now();
4619        let history_state = self
4620            .transcript_history_state_after_message_mutation(&refreshed, updated_at)?
4621            .map(serde_json::to_value)
4622            .transpose()
4623            .map_err(|error| TranscriptEditError::HistoryStateMalformed(error.to_string()))?;
4624
4625        self.messages = Arc::new(refreshed);
4626        self.updated_at = updated_at;
4627        if let Some(value) = realtime_state {
4628            self.set_metadata_unchecked(SESSION_REALTIME_TRANSCRIPT_STATE_KEY, value);
4629        }
4630        if let Some(value) = history_state {
4631            self.set_validated_transcript_history_metadata(value);
4632        }
4633        Ok(())
4634    }
4635
4636    /// Get creation time
4637    pub fn created_at(&self) -> SystemTime {
4638        self.created_at
4639    }
4640
4641    /// Get last update time
4642    pub fn updated_at(&self) -> SystemTime {
4643        self.updated_at
4644    }
4645
4646    /// Add a message to the session
4647    ///
4648    /// Updates the timestamp. For adding multiple messages, prefer `push_batch`.
4649    pub fn push(&mut self, message: Message) {
4650        Arc::make_mut(&mut self.messages).push(message);
4651        self.updated_at = SystemTime::now();
4652        self.refresh_transcript_head_after_message_mutation();
4653    }
4654
4655    /// Add multiple messages in one operation (single timestamp update)
4656    ///
4657    /// More efficient than multiple `push` calls when adding many messages.
4658    pub fn push_batch(&mut self, messages: Vec<Message>) {
4659        if messages.is_empty() {
4660            return;
4661        }
4662        let inner = Arc::make_mut(&mut self.messages);
4663        inner.extend(messages);
4664        self.updated_at = SystemTime::now();
4665        self.refresh_transcript_head_after_message_mutation();
4666    }
4667
4668    /// Rewrite inline media payloads in-place as `BlobRef` pointers.
4669    ///
4670    /// Message count is invariant across this operation — `externalize`
4671    /// only swaps inline image/media bytes for opaque blob references.
4672    /// This is the cross-crate-legitimate rewrite operation that used
4673    /// to require public `messages_mut()`; post-C-H1 callers in
4674    /// `meerkat-session` go through this typed method.
4675    ///
4676    /// Does not touch `updated_at` — externalization is bookkeeping, not
4677    /// a semantic session mutation.
4678    pub async fn externalize_media(
4679        &mut self,
4680        blob_store: &dyn crate::BlobStore,
4681        start: usize,
4682    ) -> Result<(), crate::blob::BlobStoreError> {
4683        let previous_digest = if self
4684            .metadata
4685            .contains_key(SESSION_TRANSCRIPT_HISTORY_STATE_KEY)
4686        {
4687            transcript_messages_digest(self.messages()).ok()
4688        } else {
4689            None
4690        };
4691        let messages = Arc::make_mut(&mut self.messages);
4692        crate::image_content::externalize_messages_from(blob_store, messages, start).await?;
4693        if let Some(previous_digest) = previous_digest
4694            && transcript_messages_digest(self.messages()).ok().as_ref() != Some(&previous_digest)
4695        {
4696            self.refresh_transcript_head_after_message_mutation();
4697        }
4698        Ok(())
4699    }
4700
4701    /// Hydrate user-message images in-place for a realtime provider replay,
4702    /// under an explicit cumulative decoded-byte budget.
4703    ///
4704    /// Realtime reconnect/open is an execution seam, not a historical display
4705    /// read: missing or malformed blobs fail closed, repeated references count
4706    /// independently, and image-bearing tool/system content that the realtime
4707    /// history projector does not consume remains blob-backed.
4708    pub async fn hydrate_realtime_user_images(
4709        &mut self,
4710        blob_store: &dyn crate::BlobStore,
4711        max_decoded_bytes: usize,
4712    ) -> Result<(), crate::image_content::RealtimeUserImageHydrationError> {
4713        self.hydrate_realtime_user_images_with_usage(blob_store, max_decoded_bytes)
4714            .await
4715            .map(|_| ())
4716    }
4717
4718    /// Hydrate realtime user-message images and return the full canonical
4719    /// decoded-byte usage for seed-independent future-image admission.
4720    pub async fn hydrate_realtime_user_images_with_usage(
4721        &mut self,
4722        blob_store: &dyn crate::BlobStore,
4723        max_decoded_bytes: usize,
4724    ) -> Result<usize, crate::image_content::RealtimeUserImageHydrationError> {
4725        let previous_digest = if self
4726            .metadata
4727            .contains_key(SESSION_TRANSCRIPT_HISTORY_STATE_KEY)
4728        {
4729            transcript_messages_digest(self.messages()).ok()
4730        } else {
4731            None
4732        };
4733        let messages = Arc::make_mut(&mut self.messages);
4734        let decoded_total =
4735            crate::image_content::hydrate_user_images_for_realtime_projection_with_usage(
4736                blob_store,
4737                messages,
4738                max_decoded_bytes,
4739            )
4740            .await?;
4741        if let Some(previous_digest) = previous_digest
4742            && transcript_messages_digest(self.messages()).ok().as_ref() != Some(&previous_digest)
4743        {
4744            self.refresh_transcript_head_after_message_mutation();
4745        }
4746        Ok(decoded_total)
4747    }
4748
4749    /// Explicitly update the timestamp
4750    ///
4751    /// Call this after bulk operations that don't update timestamps automatically.
4752    pub fn touch(&mut self) {
4753        self.updated_at = SystemTime::now();
4754    }
4755
4756    /// Get the last N messages
4757    pub fn last_n(&self, n: usize) -> &[Message] {
4758        let start = self.messages.len().saturating_sub(n);
4759        &self.messages[start..]
4760    }
4761
4762    /// Count total tokens used.
4763    pub fn total_tokens(&self) -> u64 {
4764        self.usage.total_tokens()
4765    }
4766
4767    /// Get total usage statistics for the session.
4768    pub fn total_usage(&self) -> Usage {
4769        self.usage.clone()
4770    }
4771
4772    /// Update cumulative usage after an LLM call.
4773    pub fn record_usage(&mut self, turn_usage: Usage) {
4774        self.usage.add(&turn_usage);
4775        self.updated_at = SystemTime::now();
4776    }
4777
4778    /// Append externally-produced user content to the canonical transcript.
4779    pub fn append_external_user_content(&mut self, content: ContentInput) {
4780        self.push(Message::User(UserMessage::with_blocks(
4781            content.into_blocks(),
4782        )));
4783    }
4784
4785    /// Append externally-produced assistant output to the canonical transcript.
4786    pub fn append_external_assistant_blocks(
4787        &mut self,
4788        blocks: Vec<AssistantBlock>,
4789        stop_reason: StopReason,
4790        usage: Usage,
4791    ) {
4792        if !blocks.is_empty() {
4793            self.push(Message::BlockAssistant(BlockAssistantMessage::new(
4794                blocks,
4795                stop_reason,
4796            )));
4797        }
4798        if usage != Usage::default() {
4799            self.record_usage(usage);
4800        }
4801    }
4802
4803    /// Apply an identity-bearing provider realtime transcript event.
4804    ///
4805    /// This is the canonical append authority for provider-managed realtime
4806    /// turns: provider item ids, predecessor links, and content segment ids are
4807    /// persisted in session metadata so duplicate websocket delivery,
4808    /// reconnect replay, and causally equivalent event ordering cannot create
4809    /// duplicate or misordered canonical messages.
4810    pub fn append_realtime_transcript_event(
4811        &mut self,
4812        event: RealtimeTranscriptEvent,
4813    ) -> RealtimeTranscriptApplyOutcome {
4814        let mut state = self.realtime_transcript_state();
4815        let commit =
4816            realtime_transcript_revision::apply_realtime_transcript_event(&mut state, event)
4817                .unwrap_or_else(|err| {
4818                    fail_closed_generated_restore(
4819                        "realtime-transcript",
4820                        <serde_json::Error as serde::de::Error>::custom(err),
4821                    )
4822                });
4823        self.store_realtime_transcript_state(&state);
4824        self.push_batch(commit.messages);
4825        if commit.usage != Usage::default() {
4826            self.record_usage(commit.usage);
4827        }
4828        commit.outcome
4829    }
4830
4831    /// Preview replay/rejection for non-text realtime user content without
4832    /// mutating session state. Used by persistence before blob writes.
4833    #[must_use]
4834    pub fn preflight_realtime_user_content_event(
4835        &self,
4836        event: &RealtimeTranscriptEvent,
4837    ) -> Option<crate::RealtimeUserContentApplyOutcome> {
4838        let state = self.realtime_transcript_state();
4839        realtime_transcript_revision::preflight_realtime_user_content_event(&state, event)
4840            .unwrap_or_else(|err| {
4841                fail_closed_generated_restore(
4842                    "realtime-user-content-preflight",
4843                    <serde_json::Error as serde::de::Error>::custom(err),
4844                )
4845            })
4846    }
4847
4848    /// Return every distinct provider `response_id` currently staged in the
4849    /// realtime-transcript metadata that has at least one **unmaterialized**
4850    /// assistant item and is **not already discarded**.
4851    ///
4852    /// CC4 (Round-4 architectural reconciliation): when the live boundary
4853    /// signals a barge-in (`TurnInterrupted`), the projection sink does not
4854    /// know which provider response_ids have streaming deltas staged in
4855    /// session metadata. This accessor lets the sink fan
4856    /// [`RealtimeTranscriptEvent::AssistantTurnInterrupted`] events out to
4857    /// each in-flight response so staged-but-not-yet-materialized transcript
4858    /// fragments are discarded — preventing them from silently committing
4859    /// when the *next* turn's `AssistantTurnCompleted` (synthesized by the
4860    /// CC2 fix in `signal_turn_completed`) sweeps the materializer.
4861    ///
4862    /// Order is the [`SessionRealtimeTranscriptState::first_seen_order`]
4863    /// projection so callers see deterministic iteration. Items already
4864    /// materialized or skipped are excluded — only response_ids with at
4865    /// least one live unmaterialized assistant item are returned.
4866    #[must_use]
4867    pub fn in_flight_realtime_assistant_response_ids(&self) -> Vec<String> {
4868        let state = self.realtime_transcript_state();
4869        realtime_transcript_revision::in_flight_realtime_assistant_response_ids(&state)
4870    }
4871
4872    /// Durable session-scoped bindings used to make live non-text input retry
4873    /// safe across provider reconnects and lost public receipts.
4874    #[must_use]
4875    pub fn realtime_user_content_identities(&self) -> Vec<RealtimeUserContentIdentity> {
4876        let state = self.realtime_transcript_state();
4877        realtime_transcript_revision::realtime_user_content_identities(&state)
4878    }
4879
4880    /// Return the bounded metadata-only image-blob recovery anchor, if one is
4881    /// durably staged ahead of reducer finalization.
4882    #[must_use]
4883    pub fn pending_realtime_user_content_blob(
4884        &self,
4885    ) -> Option<crate::PendingRealtimeUserContentBlob> {
4886        let state = self.realtime_transcript_state();
4887        realtime_transcript_revision::pending_realtime_user_content_blob(&state)
4888    }
4889
4890    /// Stage or exactly reuse the one-slot durable image-blob recovery anchor
4891    /// through generated SessionDocument authority.
4892    pub fn stage_pending_realtime_user_content_blob(
4893        &mut self,
4894        pending: crate::PendingRealtimeUserContentBlob,
4895    ) -> Result<
4896        crate::generated::session_document::RealtimeUserContentBlobStageDisposition,
4897        realtime_transcript_revision::RealtimeTranscriptShellError,
4898    > {
4899        let mut state = self.realtime_transcript_state();
4900        let disposition = realtime_transcript_revision::stage_pending_realtime_user_content_blob(
4901            &mut state, pending,
4902        )?;
4903        self.store_realtime_transcript_state(&state);
4904        Ok(disposition)
4905    }
4906
4907    pub fn resolve_pending_realtime_user_content_blob_recovery(
4908        &self,
4909        request: Option<&crate::PendingRealtimeUserContentBlob>,
4910        pending_blob_valid: bool,
4911    ) -> Result<
4912        crate::generated::session_document::RealtimeUserContentBlobRecoveryDisposition,
4913        realtime_transcript_revision::RealtimeTranscriptShellError,
4914    > {
4915        let state = self.realtime_transcript_state();
4916        realtime_transcript_revision::resolve_pending_realtime_user_content_blob_recovery(
4917            &state,
4918            request,
4919            pending_blob_valid,
4920        )
4921    }
4922
4923    /// Clear a missing/corrupt occupied anchor only after generated recovery
4924    /// authority classifies a different request as `ClearInvalidBeforeCurrent`.
4925    pub fn clear_invalid_pending_realtime_user_content_blob(
4926        &mut self,
4927        request: Option<&crate::PendingRealtimeUserContentBlob>,
4928    ) -> Result<(), realtime_transcript_revision::RealtimeTranscriptShellError> {
4929        let mut state = self.realtime_transcript_state();
4930        realtime_transcript_revision::clear_invalid_pending_realtime_user_content_blob(
4931            &mut state, request,
4932        )?;
4933        self.store_realtime_transcript_state(&state);
4934        Ok(())
4935    }
4936
4937    /// Durable caller keys whose canonical realtime image was removed by a
4938    /// same-session transcript rewrite. Provider adapters consume these as a
4939    /// pre-send conflict registry on open and refresh.
4940    #[must_use]
4941    pub fn realtime_user_content_tombstones(
4942        &self,
4943    ) -> Vec<crate::realtime_transcript::RealtimeUserContentTombstone> {
4944        let state = self.realtime_transcript_state();
4945        realtime_transcript_revision::realtime_user_content_tombstones(&state)
4946    }
4947
4948    fn realtime_transcript_state(&self) -> SessionRealtimeTranscriptState {
4949        match self.try_realtime_transcript_state() {
4950            Ok(Some(state)) => state,
4951            Ok(None) => SessionRealtimeTranscriptState::default(),
4952            Err(err) => fail_closed_generated_restore("realtime-transcript", err),
4953        }
4954    }
4955
4956    fn try_realtime_transcript_state(
4957        &self,
4958    ) -> Result<Option<SessionRealtimeTranscriptState>, serde_json::Error> {
4959        self.metadata
4960            .get(SESSION_REALTIME_TRANSCRIPT_STATE_KEY)
4961            .map(|value| {
4962                let state = serde_json::from_value(value.clone())?;
4963                realtime_transcript_revision::restore_realtime_transcript_state(state)
4964                    .map_err(<serde_json::Error as serde::de::Error>::custom)
4965            })
4966            .transpose()
4967    }
4968
4969    fn store_realtime_transcript_state(&mut self, state: &SessionRealtimeTranscriptState) {
4970        match serde_json::to_value(state) {
4971            Ok(value) => self.set_metadata_unchecked(SESSION_REALTIME_TRANSCRIPT_STATE_KEY, value),
4972            Err(error) => {
4973                tracing::warn!(error = %error, "failed to serialize realtime transcript state");
4974            }
4975        }
4976    }
4977
4978    fn reconciled_realtime_transcript_metadata_after_rewrite(
4979        &self,
4980        messages: &[Message],
4981    ) -> Result<Option<serde_json::Value>, TranscriptEditError> {
4982        let Some(state) = self
4983            .try_realtime_transcript_state()
4984            .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?
4985        else {
4986            return Ok(None);
4987        };
4988        let state =
4989            realtime_transcript_revision::reconcile_realtime_transcript_state_after_rewrite(
4990                state, messages,
4991            )
4992            .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
4993        serde_json::to_value(state)
4994            .map(Some)
4995            .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))
4996    }
4997
4998    fn apply_authorized_system_prompt(
4999        &mut self,
5000        prompt: session_durable_config_authority::AuthorizedSystemPrompt,
5001    ) {
5002        use crate::types::SystemMessage;
5003
5004        // The typed mutation provenance is carried onto the applied system
5005        // message so the transcript-continuity save-guard recognizes a
5006        // runtime context-append shape from a typed field instead of the
5007        // rendered `[Runtime System Context]` label.
5008        let mutation_kind = prompt.mutation_kind();
5009        let (prompt, _replacing_existing) = prompt.into_parts();
5010        let message = SystemMessage::with_mutation_kind(prompt, mutation_kind);
5011        let inner = Arc::make_mut(&mut self.messages);
5012        // Check if first message is system
5013        if let Some(Message::System(_)) = inner.first() {
5014            inner[0] = Message::System(message);
5015        } else {
5016            inner.insert(0, Message::System(message));
5017        }
5018        self.updated_at = SystemTime::now();
5019        self.refresh_transcript_head_after_message_mutation();
5020    }
5021
5022    /// Set a system prompt through generated durable-config authority.
5023    pub fn set_system_prompt_with_source(
5024        &mut self,
5025        prompt: String,
5026        source: session_durable_config_authority::SessionSystemPromptSource,
5027    ) -> Result<(), session_durable_config_authority::SessionDurableConfigAuthorityError> {
5028        let replacing_existing = matches!(self.messages.first(), Some(Message::System(_)));
5029        let prompt = session_durable_config_authority::authorize_system_prompt_mutation(
5030            prompt,
5031            source,
5032            replacing_existing,
5033        )?;
5034        self.apply_authorized_system_prompt(prompt);
5035        Ok(())
5036    }
5037
5038    /// Set a system prompt (adds or replaces System message at start).
5039    pub fn set_system_prompt(&mut self, prompt: String) {
5040        if let Err(err) = self.set_system_prompt_with_source(
5041            prompt,
5042            session_durable_config_authority::SessionSystemPromptSource::DirectMutation,
5043        ) {
5044            tracing::warn!(error = %err, "generated session durable-config authority rejected system prompt mutation");
5045        }
5046    }
5047
5048    /// Remove transient active-turn steer context from persisted session state.
5049    ///
5050    /// Operator steers accepted into an already-running turn are request-local:
5051    /// they should be visible to that turn's next model boundary, then vanish
5052    /// instead of replaying into later turns after persistence or resume.
5053    pub fn discard_transient_runtime_steer_context(&mut self) -> usize {
5054        let mut removed = 0usize;
5055
5056        let mut state = match self.try_system_context_state() {
5057            Ok(state) => state.unwrap_or_default(),
5058            Err(err) => {
5059                tracing::warn!(
5060                    error = %err,
5061                    "generated system-context authority rejected runtime steer cleanup state"
5062                );
5063                return removed;
5064            }
5065        };
5066
5067        // The typed `source_kind` marker on persisted appends is the authority
5068        // for which rendered prompt blocks are transient runtime steers. Gather
5069        // the runtime-steer appends, then remove their exact rendered blocks
5070        // from the system prompt — no `runtime:steer:` string classification.
5071        let runtime_steer_appends = state
5072            .pending
5073            .iter()
5074            .chain(state.applied.iter())
5075            .filter(|append| append.source_kind.is_runtime_steer())
5076            .cloned()
5077            .collect::<Vec<_>>();
5078        if let Some(Message::System(system)) = self.messages.first() {
5079            let (retained_prompt, removed_blocks) =
5080                system_context_authority::remove_runtime_steer_blocks_for_rendered(
5081                    &system.content,
5082                    &runtime_steer_appends,
5083                );
5084            if removed_blocks > 0 {
5085                removed += removed_blocks;
5086                if let Err(err) = self.set_system_prompt_with_source(
5087                    retained_prompt,
5088                    session_durable_config_authority::SessionSystemPromptSource::RuntimeSteerCleanup,
5089                ) {
5090                    tracing::warn!(
5091                        error = %err,
5092                        "generated session durable-config authority rejected runtime steer prompt cleanup"
5093                    );
5094                }
5095            }
5096        }
5097
5098        removed += system_context_authority::discard_transient_runtime_steer_state(&mut state);
5099
5100        if removed > 0
5101            && let Err(err) = self.set_system_context_state(state)
5102        {
5103            tracing::warn!(
5104                error = %err,
5105                "failed to persist runtime steer context cleanup"
5106            );
5107        }
5108
5109        removed
5110    }
5111
5112    /// Append one or more runtime system-context blocks to the canonical system prompt.
5113    pub fn append_system_context_blocks(&mut self, appends: &[PendingSystemContextAppend]) {
5114        if appends.is_empty() {
5115            return;
5116        }
5117
5118        let current_system_prompt = self
5119            .messages
5120            .first()
5121            .and_then(|message| match message {
5122                Message::System(system) => Some(system.content.as_str()),
5123                _ => None,
5124            })
5125            .unwrap_or_default();
5126        let mut state = match self.try_system_context_state() {
5127            Ok(state) => state.unwrap_or_default(),
5128            Err(err) => {
5129                tracing::warn!(
5130                    error = %err,
5131                    "generated system-context authority rejected applied context state"
5132                );
5133                return;
5134            }
5135        };
5136        let new_appends = system_context_authority::record_applied_system_context_blocks(
5137            &mut state,
5138            appends,
5139            current_system_prompt,
5140        );
5141        if new_appends.is_empty() {
5142            if let Err(err) = self.set_system_context_state(state) {
5143                tracing::warn!(error = %err, "failed to persist applied system-context state");
5144            }
5145            return;
5146        }
5147
5148        let rendered = render_system_context_blocks_joined(&new_appends);
5149
5150        let next = match self.messages.first() {
5151            Some(Message::System(sys)) if !sys.content.is_empty() => {
5152                format!("{}{}{}", sys.content, SYSTEM_CONTEXT_SEPARATOR, rendered)
5153            }
5154            _ => rendered,
5155        };
5156        if let Err(err) = self.set_system_prompt_with_source(
5157            next,
5158            session_durable_config_authority::SessionSystemPromptSource::RuntimeContextAppend,
5159        ) {
5160            tracing::warn!(
5161                error = %err,
5162                "generated session durable-config authority rejected system-context prompt append"
5163            );
5164            return;
5165        }
5166        if let Err(err) = self.set_system_context_state(state) {
5167            tracing::warn!(error = %err, "failed to persist applied system-context state");
5168        }
5169    }
5170
5171    /// Reconcile a resumed session's persisted system prompt with a freshly
5172    /// assembled base prompt.
5173    ///
5174    /// A resumed transcript is durable state: its leading [`Message::System`]
5175    /// carries the base prompt PLUS every runtime system-context append the
5176    /// runtime durably applied (comms rosters, host context — rendered by
5177    /// [`Session::append_system_context_blocks`]). Blind-replacing that
5178    /// message with a re-assembled base prompt discards the runtime-applied
5179    /// context and produces a projection that is no longer a continuation of
5180    /// the persisted transcript revision — the append-only save guard then
5181    /// rejects the very first post-resume persist and the live session is
5182    /// discarded (the upstream cold-restart transcript-loss report).
5183    ///
5184    /// Reconciliation instead of replacement:
5185    /// - If the persisted System content IS the assembled base — identical, or
5186    ///   extended only by [`SYSTEM_CONTEXT_SEPARATOR`]-joined runtime context
5187    ///   appends — the transcript is left untouched (byte-for-byte, including
5188    ///   the typed `mutation_kind`), so the resumed projection digests to the
5189    ///   persisted revision.
5190    /// - If the base genuinely changed, the new System message (new base plus
5191    ///   the reconstructed runtime-append tail, when the persisted tail is
5192    ///   verifiable from the durable applied-append records) is committed
5193    ///   through [`Session::commit_transcript_rewrite`] — the canonical typed
5194    ///   rewrite path — so the first post-resume persist proves a transcript
5195    ///   graph edge from the persisted head instead of failing closed.
5196    pub fn reconcile_resumed_system_prompt(
5197        &mut self,
5198        assembled_base: String,
5199        actor: Option<String>,
5200    ) -> Result<ResumedSystemPromptReconciliation, TranscriptEditError> {
5201        let persisted = match self.messages.first() {
5202            Some(Message::System(system)) => Some((system.content.clone(), system.mutation_kind)),
5203            _ => None,
5204        };
5205
5206        let Some((persisted_content, persisted_mutation_kind)) = persisted else {
5207            if assembled_base.is_empty() {
5208                return Ok(ResumedSystemPromptReconciliation::NoChange);
5209            }
5210            // The persisted transcript never had a system prompt; introducing
5211            // one changes the transcript, so it flows through the same typed
5212            // rewrite path (an insert rewrite over the empty leading span).
5213            self.commit_resume_system_prompt_rewrite(assembled_base, false, actor)?;
5214            return Ok(ResumedSystemPromptReconciliation::RewrittenBase);
5215        };
5216
5217        if persisted_content == assembled_base {
5218            return Ok(ResumedSystemPromptReconciliation::PreservedContinuation);
5219        }
5220
5221        // Byte-exact reconciliation first: when the persisted content splits
5222        // into a VERIFIED base + runtime-appended tail, the expected content
5223        // for this build is `assembled_base + tail` — equal means the base is
5224        // unchanged (preserve untouched), different means the base changed
5225        // (audited rewrite that carries the tail). This runs before the
5226        // structural fast path so a shortened base whose removed remainder
5227        // merely looks like a context tail (the separator is ordinary
5228        // markdown) is applied instead of silently ignored.
5229        if let Some(tail) = self.verified_runtime_context_tail(&persisted_content) {
5230            let expected = compose_system_prompt_with_context_tail(&assembled_base, &tail);
5231            if expected == persisted_content {
5232                return Ok(ResumedSystemPromptReconciliation::PreservedContinuation);
5233            }
5234            self.commit_resume_system_prompt_rewrite(expected, true, actor)?;
5235            return Ok(ResumedSystemPromptReconciliation::RewrittenBase);
5236        }
5237
5238        // No verifiable tail record (rows written before the assembled base
5239        // was recorded, or applied-append state swept by the runtime path).
5240        // The canonical SessionDocumentMachine persist-append admission
5241        // decides — from the structural observations plus the typed mutation
5242        // provenance — whether the persisted prompt is a runtime-context-
5243        // append continuation of the assembled base. Machine refusal fails
5244        // closed into the audited rewrite below.
5245        if persisted_prompt_is_admitted_context_append_continuation(
5246            &assembled_base,
5247            &persisted_content,
5248            persisted_mutation_kind,
5249        ) {
5250            return Ok(ResumedSystemPromptReconciliation::PreservedContinuation);
5251        }
5252
5253        // The base diverged and the runtime-context tail is not
5254        // reconstructible: only the new base can be written. Dropping the
5255        // appended context silently would leave the durable applied/seen
5256        // records claiming those appends are applied — keyed re-sends would
5257        // be deduplicated forever — so clear the orphaned records to keep the
5258        // context restorable by the host.
5259        let dropping_applied_context = persisted_mutation_kind.is_runtime_context_append()
5260            || self
5261                .system_context_state()
5262                .is_some_and(|state| !state.applied.is_empty());
5263        self.commit_resume_system_prompt_rewrite(assembled_base, true, actor)?;
5264        if dropping_applied_context {
5265            tracing::warn!(
5266                session_id = %self.id,
5267                "resume base-prompt refresh dropped an unverifiable runtime system-context tail; \
5268                 clearing applied-append records so keyed re-sends can restore the context"
5269            );
5270            self.clear_applied_system_context_records();
5271        }
5272        Ok(ResumedSystemPromptReconciliation::RewrittenBase)
5273    }
5274
5275    /// Split the persisted System content into a VERIFIED runtime-appended
5276    /// tail (leading [`SYSTEM_CONTEXT_SEPARATOR`] included; empty when the
5277    /// content is exactly a verified base).
5278    ///
5279    /// Verification sources, strongest first: byte-exact against the prior
5280    /// build's recorded assembled base
5281    /// ([`SessionBuildState::assembled_system_prompt`]), then a re-render of
5282    /// the durable applied-append records. `None` means the tail is not
5283    /// reconstructible from durable facts.
5284    fn verified_runtime_context_tail(&self, persisted_content: &str) -> Option<String> {
5285        if let Some(prior_base) = self
5286            .build_state()
5287            .and_then(|state| state.assembled_system_prompt)
5288        {
5289            if persisted_content == prior_base {
5290                return Some(String::new());
5291            }
5292            if let Some(appended) = persisted_content.strip_prefix(prior_base.as_str())
5293                && appended.starts_with(SYSTEM_CONTEXT_SEPARATOR)
5294            {
5295                return Some(appended.to_string());
5296            }
5297            // The record does not split this content (e.g. it predates the
5298            // last prompt mutation); fall through to the render verification.
5299        }
5300        let rendered_tail = self
5301            .system_context_state()
5302            .map(|state| render_system_context_blocks_joined(&state.applied))
5303            .unwrap_or_default();
5304        if rendered_tail.is_empty() {
5305            return None;
5306        }
5307        if persisted_content == rendered_tail {
5308            // The entire persisted prompt is verified runtime context (a
5309            // promptless/empty-base build whose appends compose without a
5310            // separator prefix) — the tail is the whole content, not empty.
5311            return Some(format!("{SYSTEM_CONTEXT_SEPARATOR}{rendered_tail}"));
5312        }
5313        let with_separator = format!("{SYSTEM_CONTEXT_SEPARATOR}{rendered_tail}");
5314        persisted_content
5315            .ends_with(&with_separator)
5316            .then_some(with_separator)
5317    }
5318
5319    /// Commit a resume-time base-prompt refresh through the generated
5320    /// durable-config authority and the canonical typed rewrite path.
5321    fn commit_resume_system_prompt_rewrite(
5322        &mut self,
5323        content: String,
5324        replacing_existing: bool,
5325        actor: Option<String>,
5326    ) -> Result<(), TranscriptEditError> {
5327        let authorized = session_durable_config_authority::authorize_system_prompt_mutation(
5328            content,
5329            session_durable_config_authority::SessionSystemPromptSource::ExplicitBuild,
5330            replacing_existing,
5331        )
5332        .map_err(|err| {
5333            TranscriptEditError::HistoryStateMalformed(format!(
5334                "generated session durable-config authority rejected resume system prompt refresh: {err}"
5335            ))
5336        })?;
5337        let mutation_kind = authorized.mutation_kind();
5338        let (content, _replacing_existing) = authorized.into_parts();
5339        let replacement = Message::System(crate::types::SystemMessage::with_mutation_kind(
5340            content,
5341            mutation_kind,
5342        ));
5343        let end = usize::from(replacing_existing);
5344        self.commit_transcript_rewrite(
5345            TranscriptRewriteSelection::MessageRange { start: 0, end },
5346            vec![replacement],
5347            TranscriptRewriteReason::new(RESUME_SYSTEM_PROMPT_REFRESH_REWRITE_REASON),
5348            actor,
5349            None,
5350        )?;
5351        Ok(())
5352    }
5353
5354    /// Clear applied-append records (and their idempotency keys) after a
5355    /// resume rewrite dropped their rendered blocks from the System prompt,
5356    /// so the same keyed appends re-apply instead of deduplicating forever.
5357    fn clear_applied_system_context_records(&mut self) {
5358        let mut state = match self.try_system_context_state() {
5359            Ok(Some(state)) => state,
5360            Ok(None) => return,
5361            Err(error) => {
5362                tracing::warn!(
5363                    session_id = %self.id,
5364                    error = %error,
5365                    "failed to read system-context state while clearing orphaned applied records"
5366                );
5367                return;
5368            }
5369        };
5370        if state.applied.is_empty() {
5371            return;
5372        }
5373        let dropped_keys: Vec<String> = state
5374            .applied
5375            .iter()
5376            .filter_map(|append| append.idempotency_key.clone())
5377            .collect();
5378        state.applied.clear();
5379        for key in &dropped_keys {
5380            state.seen.remove(key);
5381        }
5382        if let Err(error) = self.set_system_context_state(state) {
5383            tracing::warn!(
5384                session_id = %self.id,
5385                error = %error,
5386                "failed to persist cleared applied system-context records after resume prompt refresh"
5387            );
5388        }
5389    }
5390
5391    /// Get the last assistant message text content.
5392    ///
5393    /// Concatenates both `Text` (display) and `Transcript` (spoken) blocks
5394    /// in document order, since both lanes project to the same human-readable
5395    /// stream. Lane provenance is preserved on the underlying `AssistantBlock`
5396    /// for callers that need it.
5397    pub fn last_assistant_text(&self) -> Option<String> {
5398        self.messages.iter().rev().find_map(|m| match m {
5399            Message::BlockAssistant(a) => {
5400                let mut buf = String::new();
5401                for block in &a.blocks {
5402                    match block {
5403                        crate::types::AssistantBlock::Text { text, .. }
5404                        | crate::types::AssistantBlock::Transcript { text, .. } => {
5405                            buf.push_str(text);
5406                        }
5407                        _ => {}
5408                    }
5409                }
5410                if buf.is_empty() { None } else { Some(buf) }
5411            }
5412            _ => None,
5413        })
5414    }
5415
5416    /// Count tool calls made
5417    pub fn tool_call_count(&self) -> usize {
5418        self.messages
5419            .iter()
5420            .filter_map(|m| match m {
5421                Message::BlockAssistant(a) => Some(
5422                    a.blocks
5423                        .iter()
5424                        .filter(|b| matches!(b, crate::types::AssistantBlock::ToolUse { .. }))
5425                        .count(),
5426                ),
5427                _ => None,
5428            })
5429            .sum()
5430    }
5431
5432    /// Get metadata
5433    pub fn metadata(&self) -> &serde_json::Map<String, serde_json::Value> {
5434        &self.metadata
5435    }
5436
5437    fn set_metadata_unchecked(&mut self, key: &str, value: serde_json::Value) {
5438        // Reapplying an identical durable projection is not a session-content
5439        // mutation. In particular, cold materialization restores the sealed
5440        // SessionMetadata and SessionBuildState before it knows whether the
5441        // values changed; advancing `updated_at` for an exact no-op would
5442        // rotate the checkpoint digest and manufacture a sibling checkpoint
5443        // even though the committed document is unchanged.
5444        if self.metadata.get(key) == Some(&value) {
5445            return;
5446        }
5447        self.metadata.insert(key.to_string(), value);
5448        if key == SESSION_TRANSCRIPT_HISTORY_STATE_KEY {
5449            self.metadata
5450                .remove(SESSION_TRANSCRIPT_HISTORY_CHECKPOINT_DIGEST_KEY);
5451            self.transcript_history_metadata_validation =
5452                TranscriptHistoryMetadataValidation::RequiresValidation;
5453        }
5454        self.updated_at = SystemTime::now();
5455    }
5456
5457    /// Install transcript history that was produced by a typed path which
5458    /// already validated and compacted the graph.
5459    fn set_validated_transcript_history_metadata(&mut self, value: serde_json::Value) {
5460        self.metadata
5461            .insert(SESSION_TRANSCRIPT_HISTORY_STATE_KEY.to_string(), value);
5462        self.metadata
5463            .remove(SESSION_TRANSCRIPT_HISTORY_CHECKPOINT_DIGEST_KEY);
5464        self.transcript_history_metadata_validation =
5465            TranscriptHistoryMetadataValidation::Validated;
5466        self.updated_at = SystemTime::now();
5467    }
5468
5469    #[cfg(test)]
5470    pub(crate) fn set_metadata_unchecked_for_test(&mut self, key: &str, value: serde_json::Value) {
5471        self.set_metadata_unchecked(key, value);
5472    }
5473
5474    fn fork_metadata_projection(&self) -> serde_json::Map<String, serde_json::Value> {
5475        let mut metadata = self.metadata.clone();
5476        metadata.retain(|key, _| !is_session_authority_metadata_key(key));
5477        metadata
5478    }
5479
5480    fn remove_metadata_unchecked(&mut self, key: &str) {
5481        let removed = self.metadata.remove(key).is_some();
5482        let mut changed = removed;
5483        if key == SESSION_TRANSCRIPT_HISTORY_STATE_KEY {
5484            changed |= self
5485                .metadata
5486                .remove(SESSION_TRANSCRIPT_HISTORY_CHECKPOINT_DIGEST_KEY)
5487                .is_some();
5488            self.transcript_history_metadata_validation =
5489                TranscriptHistoryMetadataValidation::Validated;
5490        }
5491        if changed {
5492            self.updated_at = SystemTime::now();
5493        }
5494    }
5495
5496    /// Set a metadata value when the key is not reserved for generated authority.
5497    pub fn try_set_metadata(
5498        &mut self,
5499        key: &str,
5500        value: serde_json::Value,
5501    ) -> Result<(), ReservedSessionMetadataKey> {
5502        if is_session_authority_metadata_key(key) {
5503            return Err(ReservedSessionMetadataKey::new(key));
5504        }
5505        self.set_metadata_unchecked(key, value);
5506        Ok(())
5507    }
5508
5509    /// Set a metadata value.
5510    ///
5511    /// Reserved generated-authority metadata keys fail closed and are left
5512    /// untouched. Use the typed setters for those keys.
5513    pub fn set_metadata(&mut self, key: &str, value: serde_json::Value) {
5514        if let Err(err) = self.try_set_metadata(key, value) {
5515            tracing::warn!(error = %err, "rejected raw session metadata mutation");
5516        }
5517    }
5518
5519    /// Backfill a missing metadata value without changing `updated_at`.
5520    ///
5521    /// This is only for compatibility reads that need to hydrate metadata from
5522    /// an older projection. Semantic metadata mutations must use
5523    /// [`Session::set_metadata`] so the session timestamp advances.
5524    pub fn backfill_metadata_if_absent(&mut self, key: &str, value: serde_json::Value) -> bool {
5525        if is_session_authority_metadata_key(key) {
5526            tracing::warn!(
5527                metadata_key = key,
5528                "rejected raw session metadata backfill for authority key"
5529            );
5530            return false;
5531        }
5532        if self.metadata.contains_key(key) {
5533            false
5534        } else {
5535            self.metadata.insert(key.to_string(), value);
5536            true
5537        }
5538    }
5539
5540    /// Remove a metadata value.
5541    pub fn remove_metadata(&mut self, key: &str) {
5542        if is_session_authority_metadata_key(key) {
5543            tracing::warn!(
5544                metadata_key = key,
5545                "rejected raw session metadata removal for authority key"
5546            );
5547            return;
5548        }
5549        if self.metadata.remove(key).is_some() {
5550            self.updated_at = SystemTime::now();
5551        }
5552    }
5553
5554    /// Store SessionMetadata in the session metadata map.
5555    pub fn set_session_metadata(
5556        &mut self,
5557        metadata: SessionMetadata,
5558    ) -> Result<(), serde_json::Error> {
5559        let metadata =
5560            session_durable_config_authority::authorize_session_metadata_persist(metadata)
5561                .map_err(<serde_json::Error as serde::ser::Error>::custom)?
5562                .into_metadata();
5563        let value = serde_json::to_value(metadata)?;
5564        self.set_metadata_unchecked(SESSION_METADATA_KEY, value);
5565        Ok(())
5566    }
5567
5568    /// Load SessionMetadata from the session metadata map.
5569    ///
5570    /// If the reserved key exists but cannot pass typed generated restore,
5571    /// fail closed instead of treating corrupted machine facts as absent.
5572    pub fn session_metadata(&self) -> Option<SessionMetadata> {
5573        match self.try_session_metadata() {
5574            Ok(metadata) => metadata,
5575            Err(err) => fail_closed_generated_restore("session-metadata", err),
5576        }
5577    }
5578
5579    /// Try to load SessionMetadata through generated restore authority.
5580    pub fn try_session_metadata(&self) -> Result<Option<SessionMetadata>, serde_json::Error> {
5581        try_session_metadata_from_map(&self.metadata)
5582    }
5583
5584    /// Store durable system-context control state in the session metadata map.
5585    pub fn set_system_context_state(
5586        &mut self,
5587        state: SessionSystemContextState,
5588    ) -> Result<(), serde_json::Error> {
5589        let state = system_context_authority::restore_system_context_state(state)
5590            .map_err(<serde_json::Error as serde::ser::Error>::custom)?;
5591        let value = serde_json::to_value(state)?;
5592        self.set_metadata_unchecked(SESSION_SYSTEM_CONTEXT_STATE_KEY, value);
5593        Ok(())
5594    }
5595
5596    /// Try to load durable system-context control state through generated restore authority.
5597    pub fn try_system_context_state(
5598        &self,
5599    ) -> Result<Option<SessionSystemContextState>, serde_json::Error> {
5600        self.metadata
5601            .get(SESSION_SYSTEM_CONTEXT_STATE_KEY)
5602            .map(|value| {
5603                let state = serde_json::from_value(value.clone())?;
5604                system_context_authority::restore_system_context_state(state)
5605                    .map_err(<serde_json::Error as serde::de::Error>::custom)
5606            })
5607            .transpose()
5608    }
5609
5610    /// Load durable system-context control state from the session metadata map.
5611    ///
5612    /// Rejected durable facts fail closed through the generated restore
5613    /// authority. Callers that need the typed rejection must use
5614    /// [`Self::try_system_context_state`].
5615    pub fn system_context_state(&self) -> Option<SessionSystemContextState> {
5616        match self.try_system_context_state() {
5617            Ok(state) => state,
5618            Err(err) => fail_closed_generated_restore("system-context", err),
5619        }
5620    }
5621
5622    /// Store durable deferred-turn control state in the session metadata map.
5623    pub fn set_deferred_turn_state(
5624        &mut self,
5625        state: SessionDeferredTurnState,
5626    ) -> Result<(), serde_json::Error> {
5627        let state = validate_deferred_turn_snapshot(state)
5628            .map_err(<serde_json::Error as serde::ser::Error>::custom)?;
5629        let value = serde_json::to_value(state)?;
5630        self.set_metadata_unchecked(SESSION_DEFERRED_TURN_STATE_KEY, value);
5631        Ok(())
5632    }
5633
5634    /// Try to load durable deferred-turn control state through generated restore authority.
5635    pub fn try_deferred_turn_state(
5636        &self,
5637    ) -> Result<Option<SessionDeferredTurnState>, serde_json::Error> {
5638        self.metadata
5639            .get(SESSION_DEFERRED_TURN_STATE_KEY)
5640            .map(|value| {
5641                let state = serde_json::from_value(value.clone())?;
5642                validate_deferred_turn_snapshot(state)
5643                    .map_err(<serde_json::Error as serde::de::Error>::custom)
5644            })
5645            .transpose()
5646    }
5647
5648    /// Load durable deferred-turn control state from the session metadata map.
5649    ///
5650    /// Rejected durable facts fail closed through the generated restore
5651    /// authority. Callers that need the typed rejection must use
5652    /// [`Self::try_deferred_turn_state`].
5653    pub fn deferred_turn_state(&self) -> Option<SessionDeferredTurnState> {
5654        match self.try_deferred_turn_state() {
5655            Ok(state) => state,
5656            Err(err) => fail_closed_generated_restore("deferred-turn", err),
5657        }
5658    }
5659
5660    /// Realize the typed session lifecycle-terminal projection in the session
5661    /// metadata map.
5662    ///
5663    /// The lifecycle-terminal fact is owned by the canonical
5664    /// [`session_document::SessionDocumentMachine`]; production archive paths
5665    /// call this only to realize a machine-emitted `SessionArchiveResolved`
5666    /// verdict (the value written mirrors the machine's decision — the shell
5667    /// decides nothing here).
5668    pub fn set_lifecycle_terminal(
5669        &mut self,
5670        terminal: SessionLifecycleTerminal,
5671    ) -> Result<(), serde_json::Error> {
5672        let value = serde_json::to_value(terminal)?;
5673        self.set_metadata_unchecked(SESSION_LIFECYCLE_TERMINAL_KEY, value);
5674        Ok(())
5675    }
5676
5677    /// Try to load the typed session lifecycle-terminal fact.
5678    ///
5679    /// Reads the typed [`SESSION_LIFECYCLE_TERMINAL_KEY`]; an absent key means
5680    /// no terminal fact.
5681    pub fn try_lifecycle_terminal(
5682        &self,
5683    ) -> Result<Option<SessionLifecycleTerminal>, serde_json::Error> {
5684        try_lifecycle_terminal_from_map(&self.metadata)
5685    }
5686
5687    /// Load the typed session lifecycle-terminal fact, failing closed on a
5688    /// corrupt typed value.
5689    ///
5690    /// Callers that need the typed rejection must use
5691    /// [`Self::try_lifecycle_terminal`].
5692    pub fn lifecycle_terminal(&self) -> Option<SessionLifecycleTerminal> {
5693        match self.try_lifecycle_terminal() {
5694            Ok(state) => state,
5695            Err(err) => fail_closed_generated_restore("session-lifecycle-terminal", err),
5696        }
5697    }
5698
5699    /// Store recoverable build-only session state in the session metadata map.
5700    pub fn set_build_state(&mut self, state: SessionBuildState) -> Result<(), serde_json::Error> {
5701        let state = session_durable_config_authority::authorize_session_build_state_persist(state)
5702            .map_err(<serde_json::Error as serde::ser::Error>::custom)?
5703            .into_state();
5704        let value = serde_json::to_value(state)?;
5705        self.set_metadata_unchecked(SESSION_BUILD_STATE_KEY, value);
5706        Ok(())
5707    }
5708
5709    /// Load recoverable build-only session state from the session metadata map.
5710    ///
5711    /// If the reserved key exists but cannot pass typed generated restore,
5712    /// fail closed instead of treating corrupted machine facts as absent.
5713    pub fn build_state(&self) -> Option<SessionBuildState> {
5714        match self.try_build_state() {
5715            Ok(state) => state,
5716            Err(err) => fail_closed_generated_restore("session-build-state", err),
5717        }
5718    }
5719
5720    /// Try to load recoverable build-only session state through generated restore authority.
5721    pub fn try_build_state(&self) -> Result<Option<SessionBuildState>, serde_json::Error> {
5722        let Some(value) = self.metadata.get(SESSION_BUILD_STATE_KEY) else {
5723            return Ok(None);
5724        };
5725        let state = serde_json::from_value::<SessionBuildState>(value.clone())?;
5726        session_durable_config_authority::restore_session_build_state(state)
5727            .map(Some)
5728            .map_err(<serde_json::Error as serde::de::Error>::custom)
5729    }
5730
5731    /// Store durable tool-visibility control state in the session metadata map.
5732    pub fn set_tool_visibility_state(
5733        &mut self,
5734        state: AuthorizedSessionToolVisibilityState,
5735    ) -> Result<(), serde_json::Error> {
5736        let value = serde_json::to_value(state.into_state())?;
5737        self.set_metadata_unchecked(SESSION_TOOL_VISIBILITY_STATE_KEY, value);
5738        Ok(())
5739    }
5740
5741    /// Test-only metadata clear for compatibility assertions.
5742    ///
5743    /// Production paths persist an explicit generated-authority projection
5744    /// rather than making durable absence carry semantic default truth.
5745    #[cfg(test)]
5746    pub(crate) fn clear_tool_visibility_state(&mut self) {
5747        self.remove_metadata_unchecked(SESSION_TOOL_VISIBILITY_STATE_KEY);
5748    }
5749
5750    /// Load durable tool-visibility control state from the session metadata map.
5751    pub fn tool_visibility_state(
5752        &self,
5753    ) -> Result<Option<SessionToolVisibilityState>, serde_json::Error> {
5754        self.try_tool_visibility_state()
5755    }
5756
5757    /// Load durable tool-visibility control state while distinguishing absent
5758    /// metadata from malformed canonical metadata.
5759    pub fn try_tool_visibility_state(
5760        &self,
5761    ) -> Result<Option<SessionToolVisibilityState>, serde_json::Error> {
5762        self.metadata
5763            .get(SESSION_TOOL_VISIBILITY_STATE_KEY)
5764            .map(|value| serde_json::from_value(value.clone()))
5765            .transpose()
5766    }
5767
5768    /// Load typed transcript revision state from metadata.
5769    pub fn transcript_history_state(
5770        &self,
5771    ) -> Result<Option<TranscriptHistoryState>, serde_json::Error> {
5772        self.metadata
5773            .get(SESSION_TRANSCRIPT_HISTORY_STATE_KEY)
5774            .map(|value| serde_json::from_value(value.clone()))
5775            .transpose()
5776    }
5777
5778    /// Return the already-validated transcript graph head without cloning and
5779    /// deserializing the full history document again.
5780    ///
5781    /// Store guards use this after typed Session deserialization when they
5782    /// only need to prove live-message/head coherence. Unchecked metadata
5783    /// still crosses the full graph validator before the borrowed head can be
5784    /// observed.
5785    pub(crate) fn validated_transcript_history_head(
5786        &self,
5787    ) -> Result<Option<&str>, TranscriptEditError> {
5788        self.validate_transcript_history_state()?;
5789        let Some(value) = self.metadata.get(SESSION_TRANSCRIPT_HISTORY_STATE_KEY) else {
5790            return Ok(None);
5791        };
5792        value
5793            .get("head")
5794            .and_then(serde_json::Value::as_str)
5795            .map(Some)
5796            .ok_or_else(|| {
5797                TranscriptEditError::HistoryStateMalformed(
5798                    "validated transcript history metadata omitted a string head".to_string(),
5799                )
5800            })
5801    }
5802
5803    /// Load exact compaction projection intents carried to the runtime's
5804    /// atomic-apply outbox by this session snapshot.
5805    pub fn compaction_projection_intents(
5806        &self,
5807    ) -> Result<Vec<crate::memory::CompactionProjectionIntent>, serde_json::Error> {
5808        self.metadata
5809            .get(crate::memory::SESSION_COMPACTION_PROJECTION_INTENTS_KEY)
5810            .map(|value| serde_json::from_value(value.clone()))
5811            .transpose()
5812            .map(Option::unwrap_or_default)
5813    }
5814
5815    /// Load persisted compaction intents only after proving that every
5816    /// already-carried projection ID is backed by this session's validated
5817    /// transcript graph.
5818    ///
5819    /// This is deliberately a validation boundary, not an ID constructor:
5820    /// durable typed rewrite tags and legacy records can confirm an existing
5821    /// identity during recovery but cannot mint a new identity.
5822    pub fn validated_compaction_projection_intents(
5823        &self,
5824    ) -> Result<Vec<crate::memory::CompactionProjectionIntent>, serde_json::Error> {
5825        self.validate_transcript_history_state()
5826            .map_err(|error| <serde_json::Error as serde::ser::Error>::custom(error.to_string()))?;
5827        let intents = self.compaction_projection_intents()?;
5828        if intents.is_empty() {
5829            return Ok(intents);
5830        }
5831        let history = self.transcript_history_state()?;
5832        let commits = history
5833            .as_ref()
5834            .map(|history| history.commits.as_slice())
5835            .unwrap_or_default();
5836        let mut unique = std::collections::HashSet::new();
5837        for intent in &intents {
5838            if intent.projection.session_id() != self.id() {
5839                return Err(<serde_json::Error as serde::ser::Error>::custom(
5840                    "compaction projection outbox intent has a foreign session id",
5841                ));
5842            }
5843            if !unique.insert(intent.projection.clone()) {
5844                return Err(<serde_json::Error as serde::ser::Error>::custom(
5845                    "compaction projection outbox contains a duplicate rewrite identity",
5846                ));
5847            }
5848            let backed = commits.iter().any(|commit| {
5849                intent
5850                    .projection
5851                    .matches_transcript_rewrite(self.id(), commit)
5852            });
5853            if !backed {
5854                return Err(<serde_json::Error as serde::ser::Error>::custom(format!(
5855                    "compaction projection outbox intent {} has no matching TranscriptRewriteCommit",
5856                    intent.projection.revision()
5857                )));
5858            }
5859        }
5860        Ok(intents)
5861    }
5862
5863    /// Record one invisible staged-memory intent only after its exact
5864    /// TranscriptRewriteCommit is present in the session graph.
5865    pub fn add_compaction_projection_intent(
5866        &mut self,
5867        intent: crate::memory::CompactionProjectionIntent,
5868    ) -> Result<(), serde_json::Error> {
5869        if intent.projection.session_id() != self.id() {
5870            return Err(<serde_json::Error as serde::ser::Error>::custom(
5871                "compaction projection intent session does not match snapshot session",
5872            ));
5873        }
5874        self.validate_transcript_history_state()
5875            .map_err(|error| <serde_json::Error as serde::ser::Error>::custom(error.to_string()))?;
5876        let history = self.transcript_history_state()?.ok_or_else(|| {
5877            <serde_json::Error as serde::ser::Error>::custom(
5878                "compaction projection intent requires transcript history state",
5879            )
5880        })?;
5881        let owns_commit = history.commits.iter().any(|commit| {
5882            commit.parent_revision == intent.projection.parent_revision()
5883                && commit.revision == intent.projection.revision()
5884                && intent
5885                    .projection
5886                    .matches_transcript_rewrite(self.id(), commit)
5887        });
5888        if !owns_commit {
5889            return Err(<serde_json::Error as serde::ser::Error>::custom(
5890                "compaction projection intent is not backed by the session transcript graph",
5891            ));
5892        }
5893        let mut intents = self.validated_compaction_projection_intents()?;
5894        if let Some(existing) = intents
5895            .iter()
5896            .find(|existing| existing.projection == intent.projection)
5897        {
5898            if existing == &intent {
5899                return Ok(());
5900            }
5901            return Err(<serde_json::Error as serde::ser::Error>::custom(
5902                "compaction projection intent conflicts with an existing rewrite identity",
5903            ));
5904        }
5905        intents.push(intent);
5906        self.set_metadata_unchecked(
5907            crate::memory::SESSION_COMPACTION_PROJECTION_INTENTS_KEY,
5908            serde_json::to_value(intents)?,
5909        );
5910        Ok(())
5911    }
5912
5913    /// Remove an intent after the runtime outbox has finalized its staged
5914    /// memory batch. Idempotent for repeated recovery finalization.
5915    pub fn complete_compaction_projection_intent(
5916        &mut self,
5917        projection: &crate::memory::CompactionProjectionId,
5918    ) -> Result<Option<crate::memory::CompactionProjectionIntent>, serde_json::Error> {
5919        let mut intents = self.compaction_projection_intents()?;
5920        let Some(position) = intents
5921            .iter()
5922            .position(|intent| &intent.projection == projection)
5923        else {
5924            return Ok(None);
5925        };
5926        let completed = intents.remove(position);
5927        if intents.is_empty() {
5928            self.remove_metadata_unchecked(
5929                crate::memory::SESSION_COMPACTION_PROJECTION_INTENTS_KEY,
5930            );
5931        } else {
5932            self.set_metadata_unchecked(
5933                crate::memory::SESSION_COMPACTION_PROJECTION_INTENTS_KEY,
5934                serde_json::to_value(intents)?,
5935            );
5936        }
5937        Ok(Some(completed))
5938    }
5939
5940    /// Validate the retained transcript revision graph, when present.
5941    pub fn validate_transcript_history_state(&self) -> Result<(), TranscriptEditError> {
5942        if self.transcript_history_metadata_validation
5943            == TranscriptHistoryMetadataValidation::Validated
5944        {
5945            return Ok(());
5946        }
5947        let Some(state) = self
5948            .transcript_history_state()
5949            .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?
5950        else {
5951            return Ok(());
5952        };
5953        validate_transcript_history_state(&state)
5954    }
5955
5956    /// Clear retained transcript revision metadata after a caller has
5957    /// materialized the desired message projection.
5958    pub fn clear_transcript_history_state(&mut self) {
5959        self.remove_metadata_unchecked(SESSION_TRANSCRIPT_HISTORY_STATE_KEY);
5960    }
5961
5962    /// Decode and verify this document's typed checkpoint state.
5963    ///
5964    /// Missing typed metadata is returned as explicit legacy-unverified state.
5965    /// A present malformed stamp or malformed legacy compatibility value is an
5966    /// error and is never laundered into absence.
5967    pub fn try_checkpoint_state(
5968        &self,
5969    ) -> Result<crate::checkpoint::SessionCheckpointState, crate::checkpoint::SessionCheckpointError>
5970    {
5971        let stamp =
5972            match crate::checkpoint::session_checkpoint_metadata_state(&self.id, &self.metadata)? {
5973                crate::checkpoint::SessionCheckpointMetadataState::Stamped(stamp) => stamp,
5974                crate::checkpoint::SessionCheckpointMetadataState::LegacyUnverified {
5975                    legacy_runtime_checkpoint,
5976                } => {
5977                    return Ok(
5978                        crate::checkpoint::SessionCheckpointState::LegacyUnverified {
5979                            legacy_runtime_checkpoint,
5980                        },
5981                    );
5982                }
5983            };
5984        let actual = crate::checkpoint::session_checkpoint_digest(self)?;
5985        if stamp.digest() != &actual {
5986            return Err(crate::checkpoint::SessionCheckpointError::DigestMismatch {
5987                expected: stamp.digest().clone(),
5988                actual,
5989            });
5990        }
5991        Ok(crate::checkpoint::SessionCheckpointState::Verified(stamp))
5992    }
5993
5994    /// Install a prevalidated semantic checkpoint stamp on this exact
5995    /// document without changing its content timestamps.
5996    ///
5997    /// This is a mechanical serialization seam, not target-store write
5998    /// authority. A persistence implementation must still atomically validate
5999    /// its own observation and fencing preconditions before committing the
6000    /// resulting bytes.
6001    pub fn install_checkpoint_stamp(
6002        &mut self,
6003        stamp: crate::checkpoint::SessionCheckpointStamp,
6004    ) -> Result<(), crate::checkpoint::SessionCheckpointError> {
6005        stamp.validate_for_session(&self.id)?;
6006        let actual = crate::checkpoint::session_checkpoint_digest(self)?;
6007        if stamp.digest() != &actual {
6008            return Err(crate::checkpoint::SessionCheckpointError::DigestMismatch {
6009                expected: stamp.digest().clone(),
6010                actual,
6011            });
6012        }
6013        let value = serde_json::to_value(&stamp)?;
6014        self.metadata
6015            .remove(SESSION_RUNTIME_CHECKPOINT_PROVENANCE_KEY);
6016        self.metadata
6017            .insert(SESSION_CHECKPOINT_STAMP_KEY.to_string(), value);
6018        Ok(())
6019    }
6020
6021    /// Fail-closed typed read of intra-turn checkpoint provenance.
6022    pub fn try_has_runtime_checkpoint_provenance(
6023        &self,
6024    ) -> Result<bool, crate::checkpoint::SessionCheckpointError> {
6025        match self.try_checkpoint_state()? {
6026            crate::checkpoint::SessionCheckpointState::Verified(stamp) => Ok(matches!(
6027                stamp.provenance(),
6028                crate::checkpoint::SessionCheckpointProvenance::IntraTurnCheckpoint
6029            )),
6030            crate::checkpoint::SessionCheckpointState::LegacyUnverified { .. } => {
6031                Err(crate::checkpoint::SessionCheckpointError::LegacyCheckpointUnverified)
6032            }
6033        }
6034    }
6035
6036    /// Set the legacy compatibility marker on an untyped projection.
6037    #[deprecated(
6038        note = "legacy compatibility only; typed writers must install an exact checkpoint stamp"
6039    )]
6040    pub fn set_runtime_checkpoint_provenance(
6041        &mut self,
6042    ) -> Result<(), crate::checkpoint::SessionCheckpointError> {
6043        if matches!(
6044            self.try_checkpoint_state()?,
6045            crate::checkpoint::SessionCheckpointState::Verified(_)
6046        ) {
6047            return Err(
6048                crate::checkpoint::SessionCheckpointError::LegacyProvenanceMutationOnTypedCheckpoint,
6049            );
6050        }
6051        self.set_metadata_unchecked(
6052            SESSION_RUNTIME_CHECKPOINT_PROVENANCE_KEY,
6053            serde_json::Value::Bool(true),
6054        );
6055        Ok(())
6056    }
6057
6058    /// Clear the legacy compatibility marker on an untyped projection.
6059    #[deprecated(
6060        note = "legacy compatibility only; typed writers must install an exact run-boundary successor"
6061    )]
6062    pub fn clear_runtime_checkpoint_provenance(
6063        &mut self,
6064    ) -> Result<(), crate::checkpoint::SessionCheckpointError> {
6065        if matches!(
6066            self.try_checkpoint_state()?,
6067            crate::checkpoint::SessionCheckpointState::Verified(_)
6068        ) {
6069            return Err(
6070                crate::checkpoint::SessionCheckpointError::LegacyProvenanceMutationOnTypedCheckpoint,
6071            );
6072        }
6073        self.remove_metadata_unchecked(SESSION_RUNTIME_CHECKPOINT_PROVENANCE_KEY);
6074        Ok(())
6075    }
6076
6077    /// Return the retained immutable body for a transcript revision.
6078    pub fn transcript_revision_body(
6079        &self,
6080        revision: &str,
6081    ) -> Result<Option<TranscriptRevisionBody>, serde_json::Error> {
6082        Ok(self.transcript_history_state()?.and_then(|state| {
6083            state
6084                .revisions
6085                .into_iter()
6086                .find(|body| body.revision == revision)
6087        }))
6088    }
6089
6090    /// Return the ordered messages for a retained transcript revision.
6091    pub fn transcript_revision_messages(
6092        &self,
6093        revision: &str,
6094    ) -> Result<Option<Vec<Message>>, serde_json::Error> {
6095        Ok(self
6096            .transcript_revision_body(revision)?
6097            .map(|body| body.messages))
6098    }
6099
6100    /// Materialize this session projection from a typed transcript history graph.
6101    pub fn apply_transcript_history_state(
6102        &mut self,
6103        mut state: TranscriptHistoryState,
6104    ) -> Result<(), TranscriptEditError> {
6105        state.compact_mechanical_revision_bodies()?;
6106        let head_body = state
6107            .revisions
6108            .iter()
6109            .find(|body| body.revision == state.head)
6110            .ok_or_else(|| {
6111                TranscriptEditError::HistoryStateMalformed(format!(
6112                    "missing transcript head body {}",
6113                    state.head
6114                ))
6115            })?
6116            .clone();
6117        let realtime_state =
6118            self.reconciled_realtime_transcript_metadata_after_rewrite(&head_body.messages)?;
6119        let value = serde_json::to_value(&state)
6120            .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
6121        self.set_validated_transcript_history_metadata(value);
6122        if let Some(value) = realtime_state {
6123            self.set_metadata_unchecked(SESSION_REALTIME_TRANSCRIPT_STATE_KEY, value);
6124        }
6125        let mut updated_at = head_body.created_at;
6126        for commit in &state.commits {
6127            if commit.committed_at > updated_at {
6128                updated_at = commit.committed_at;
6129            }
6130        }
6131        self.messages = Arc::new(head_body.messages);
6132        self.updated_at = updated_at;
6133        Ok(())
6134    }
6135
6136    /// Current transcript head revision. Rows written before transcript
6137    /// revisions derive their implicit head from the current message snapshot.
6138    pub fn transcript_revision(&self) -> Result<String, serde_json::Error> {
6139        if let Some(state) = self.transcript_history_state()? {
6140            Ok(state.head)
6141        } else {
6142            transcript_messages_digest(self.messages())
6143        }
6144    }
6145
6146    /// Monotonic durable generation for same-session transcript rewrites.
6147    /// Ordinary message appends advance the content revision but do not change
6148    /// this value, allowing live config refresh after normal turns while still
6149    /// forcing reopen after a rewrite.
6150    pub fn transcript_rewrite_generation(&self) -> Result<u64, serde_json::Error> {
6151        Ok(self.transcript_history_state()?.map_or(0, |state| {
6152            u64::try_from(state.commits.len()).unwrap_or(u64::MAX)
6153        }))
6154    }
6155
6156    /// Commit a same-session transcript rewrite and advance the transcript head.
6157    pub fn commit_transcript_rewrite(
6158        &mut self,
6159        selection: TranscriptRewriteSelection,
6160        replacement: Vec<Message>,
6161        reason: TranscriptRewriteReason,
6162        actor: Option<String>,
6163        expected_parent_revision: Option<String>,
6164    ) -> Result<TranscriptRewriteCommit, TranscriptEditError> {
6165        let selection = selection.into_current_edit_semantic();
6166        if selection.semantic() == TranscriptRewriteSemantic::Compaction {
6167            return Err(TranscriptEditError::InvalidTranscriptShape(
6168                "typed compaction rewrites require a core-validated compaction witness".to_string(),
6169            ));
6170        }
6171        self.commit_transcript_rewrite_authorized(
6172            selection,
6173            replacement,
6174            reason,
6175            actor,
6176            expected_parent_revision,
6177        )
6178    }
6179
6180    fn commit_transcript_rewrite_authorized(
6181        &mut self,
6182        selection: TranscriptRewriteSelection,
6183        replacement: Vec<Message>,
6184        reason: TranscriptRewriteReason,
6185        actor: Option<String>,
6186        expected_parent_revision: Option<String>,
6187    ) -> Result<TranscriptRewriteCommit, TranscriptEditError> {
6188        let parent_revision = self
6189            .transcript_revision()
6190            .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
6191        if let Some(expected) = expected_parent_revision
6192            && expected != parent_revision
6193        {
6194            return Err(TranscriptEditError::RevisionConflict {
6195                expected,
6196                actual: parent_revision,
6197            });
6198        }
6199
6200        let (start, end) = selection.bounds();
6201        let message_count = self.messages.len();
6202        if start > end || end > message_count {
6203            return Err(TranscriptEditError::InvalidRewriteRange {
6204                start,
6205                end,
6206                message_count,
6207            });
6208        }
6209
6210        let replacement_len = replacement.len();
6211        let mut rewritten = Vec::with_capacity(
6212            start
6213                .saturating_add(replacement_len)
6214                .saturating_add(message_count.saturating_sub(end)),
6215        );
6216        rewritten.extend_from_slice(&self.messages[..start]);
6217        rewritten.extend(replacement);
6218        rewritten.extend_from_slice(&self.messages[end..]);
6219        validate_transcript_tool_result_shape(&rewritten)?;
6220
6221        let original_span_digest = transcript_messages_digest(&self.messages[start..end])
6222            .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
6223        let replacement_digest =
6224            transcript_messages_digest(&rewritten[start..start + replacement_len])
6225                .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
6226        let revision = transcript_messages_digest(&rewritten)
6227            .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
6228        if revision == parent_revision {
6229            return Err(TranscriptEditError::NoOpRewrite { revision });
6230        }
6231        let realtime_state =
6232            self.reconciled_realtime_transcript_metadata_after_rewrite(&rewritten)?;
6233
6234        let commit = TranscriptRewriteCommit {
6235            parent_revision,
6236            revision: revision.clone(),
6237            selection,
6238            original_span_digest,
6239            replacement_digest,
6240            messages_before: message_count,
6241            messages_after: rewritten.len(),
6242            reason,
6243            actor,
6244            committed_at: SystemTime::now(),
6245        };
6246
6247        let mut state = self
6248            .transcript_history_state()
6249            .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?
6250            .unwrap_or_else(|| TranscriptHistoryState {
6251                head: commit.parent_revision.clone(),
6252                commits: Vec::new(),
6253                revisions: Vec::new(),
6254            });
6255        if !state
6256            .revisions
6257            .iter()
6258            .any(|body| body.revision == commit.parent_revision)
6259        {
6260            state.revisions.push(TranscriptRevisionBody {
6261                revision: commit.parent_revision.clone(),
6262                parent_revision: None,
6263                messages: self.messages().to_vec(),
6264                created_at: self.updated_at,
6265            });
6266        }
6267        if !state
6268            .revisions
6269            .iter()
6270            .any(|body| body.revision == commit.revision)
6271        {
6272            state.revisions.push(TranscriptRevisionBody {
6273                revision: commit.revision.clone(),
6274                parent_revision: Some(commit.parent_revision.clone()),
6275                messages: rewritten.clone(),
6276                created_at: commit.committed_at,
6277            });
6278        }
6279        state.head = revision;
6280        state.commits.push(commit.clone());
6281        state.compact_mechanical_revision_bodies()?;
6282        let value = serde_json::to_value(state)
6283            .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
6284        self.set_validated_transcript_history_metadata(value);
6285        if let Some(value) = realtime_state {
6286            self.set_metadata_unchecked(SESSION_REALTIME_TRANSCRIPT_STATE_KEY, value);
6287        }
6288
6289        self.messages = Arc::new(rewritten);
6290        self.updated_at = SystemTime::now();
6291        Ok(commit)
6292    }
6293
6294    fn transcript_history_state_after_message_mutation(
6295        &self,
6296        messages: &[Message],
6297        created_at: SystemTime,
6298    ) -> Result<Option<TranscriptHistoryState>, TranscriptEditError> {
6299        if !self
6300            .metadata
6301            .contains_key(SESSION_TRANSCRIPT_HISTORY_STATE_KEY)
6302        {
6303            return Ok(None);
6304        }
6305        let mut state = self
6306            .transcript_history_state()
6307            .map_err(|error| TranscriptEditError::HistoryStateMalformed(error.to_string()))?
6308            .ok_or_else(|| {
6309                TranscriptEditError::HistoryStateMalformed(
6310                    "transcript history metadata key decoded without state".to_string(),
6311                )
6312            })?;
6313        state.compact_mechanical_revision_bodies()?;
6314        let head = transcript_messages_digest(messages)
6315            .map_err(|error| TranscriptEditError::HistoryStateMalformed(error.to_string()))?;
6316        if !state.revisions.iter().any(|body| body.revision == head) {
6317            state.revisions.push(TranscriptRevisionBody {
6318                revision: head.clone(),
6319                parent_revision: state.commits.last().map(|commit| commit.revision.clone()),
6320                messages: messages.to_vec(),
6321                created_at,
6322            });
6323        }
6324        state.head = head;
6325        state.compact_mechanical_revision_bodies()?;
6326        Ok(Some(state))
6327    }
6328
6329    fn refresh_transcript_head_after_message_mutation(&mut self) {
6330        match self
6331            .transcript_history_state_after_message_mutation(self.messages(), SystemTime::now())
6332        {
6333            Ok(Some(state)) => match serde_json::to_value(state) {
6334                Ok(value) => {
6335                    self.set_validated_transcript_history_metadata(value);
6336                }
6337                Err(error) => {
6338                    tracing::warn!(
6339                        session_id = %self.id,
6340                        error = %error,
6341                        "failed to serialize transcript history state after message mutation"
6342                    );
6343                }
6344            },
6345            Ok(None) => {}
6346            Err(error) => {
6347                tracing::warn!(
6348                    session_id = %self.id,
6349                    error = %error,
6350                    "transcript history state failed validation after message mutation"
6351                );
6352            }
6353        }
6354    }
6355
6356    /// Store typed mob operator authority inside canonical build-state metadata.
6357    ///
6358    /// Store the mob operator authority projection inside build-state metadata.
6359    ///
6360    /// The projection is durable compatibility data only: serialization drops
6361    /// the generated authority seal, so behavior must re-enter generated
6362    /// authority before using restored facts.
6363    pub fn set_mob_tool_authority_context(
6364        &mut self,
6365        authority_context: Option<MobToolAuthorityContext>,
6366    ) -> Result<(), serde_json::Error> {
6367        if let Some(authority_context) = authority_context.as_ref()
6368            && !authority_context.is_generated_authority_context()
6369        {
6370            return Err(<serde_json::Error as serde::de::Error>::custom(
6371                "mob authority context was not minted by generated authority",
6372            ));
6373        }
6374        let mut build_state = self.build_state().ok_or_else(|| {
6375            <serde_json::Error as serde::de::Error>::custom(format!(
6376                "session {} is missing session build state",
6377                self.id
6378            ))
6379        })?;
6380        build_state.mob_tool_authority_context = authority_context;
6381        self.set_build_state(build_state)
6382    }
6383
6384    /// Load the in-memory generated mob operator authority, if still present.
6385    ///
6386    /// Stored/deserialized contexts deliberately fail this check and are not
6387    /// returned as behavior authority.
6388    pub fn mob_tool_authority_context(&self) -> Option<MobToolAuthorityContext> {
6389        self.build_state()
6390            .and_then(|state| state.mob_tool_authority_context)
6391            .filter(MobToolAuthorityContext::is_generated_authority_context)
6392    }
6393
6394    /// Fork the session at a specific message index
6395    ///
6396    /// Creates a new session with a subset of messages. The messages are copied
6397    /// (not shared) since the new session has a different prefix.
6398    pub fn fork_at(&self, index: usize) -> Self {
6399        let now = SystemTime::now();
6400        let truncated = self.messages[..index.min(self.messages.len())].to_vec();
6401        Self {
6402            version: session_version(),
6403            id: SessionId::new(),
6404            messages: Arc::new(truncated),
6405            created_at: now,
6406            updated_at: now,
6407            metadata: self.fork_metadata_projection(),
6408            transcript_history_metadata_validation: TranscriptHistoryMetadataValidation::Validated,
6409            usage: self.usage.clone(),
6410        }
6411    }
6412
6413    /// Fork the session and replace the message at `message_index`.
6414    ///
6415    /// The returned session contains the original prefix before
6416    /// `message_index`, followed by the typed replacement. Later source
6417    /// messages are intentionally omitted so follow-up work continues from the
6418    /// edited branch rather than replaying stale descendants.
6419    pub fn fork_replacing(
6420        &self,
6421        message_index: usize,
6422        replacement: TranscriptReplacement,
6423    ) -> Result<Self, TranscriptEditError> {
6424        let Some(original) = self.messages.get(message_index) else {
6425            return Err(TranscriptEditError::MessageIndexOutOfBounds {
6426                message_index,
6427                message_count: self.messages.len(),
6428            });
6429        };
6430
6431        let replacement_message = match replacement {
6432            TranscriptReplacement::Message { message } => message,
6433            TranscriptReplacement::UserContentBlock { block_index, block } => {
6434                let Message::User(user) = original else {
6435                    return Err(TranscriptEditError::MessageRoleMismatch {
6436                        message_index,
6437                        expected: "user",
6438                        actual: message_role_name(original),
6439                    });
6440                };
6441                if block_index >= user.content.len() {
6442                    return Err(TranscriptEditError::BlockIndexOutOfBounds {
6443                        block_kind: "user content block",
6444                        block_index,
6445                        block_count: user.content.len(),
6446                    });
6447                }
6448                let mut edited = user.clone();
6449                edited.content[block_index] = block;
6450                Message::User(edited)
6451            }
6452            TranscriptReplacement::AssistantBlock { block_index, block } => {
6453                let Message::BlockAssistant(assistant) = original else {
6454                    return Err(TranscriptEditError::MessageRoleMismatch {
6455                        message_index,
6456                        expected: "block_assistant",
6457                        actual: message_role_name(original),
6458                    });
6459                };
6460                if block_index >= assistant.blocks.len() {
6461                    return Err(TranscriptEditError::BlockIndexOutOfBounds {
6462                        block_kind: "assistant block",
6463                        block_index,
6464                        block_count: assistant.blocks.len(),
6465                    });
6466                }
6467                let mut edited = assistant.clone();
6468                edited.blocks[block_index] = block;
6469                Message::BlockAssistant(edited)
6470            }
6471            TranscriptReplacement::ToolResultContentBlock {
6472                result_index,
6473                block_index,
6474                block,
6475            } => {
6476                let Message::ToolResults {
6477                    results,
6478                    created_at,
6479                } = original
6480                else {
6481                    return Err(TranscriptEditError::MessageRoleMismatch {
6482                        message_index,
6483                        expected: "tool_results",
6484                        actual: message_role_name(original),
6485                    });
6486                };
6487                let Some(result) = results.get(result_index) else {
6488                    return Err(TranscriptEditError::BlockIndexOutOfBounds {
6489                        block_kind: "tool result",
6490                        block_index: result_index,
6491                        block_count: results.len(),
6492                    });
6493                };
6494                if block_index >= result.content.len() {
6495                    return Err(TranscriptEditError::BlockIndexOutOfBounds {
6496                        block_kind: "tool result content block",
6497                        block_index,
6498                        block_count: result.content.len(),
6499                    });
6500                }
6501                let mut edited_results = results.clone();
6502                edited_results[result_index].content[block_index] = block;
6503                Message::ToolResults {
6504                    results: edited_results,
6505                    created_at: *created_at,
6506                }
6507            }
6508        };
6509
6510        let mut forked = self.fork_at(message_index);
6511        forked.push(replacement_message);
6512        Ok(forked)
6513    }
6514
6515    /// Fork the entire session (full history)
6516    ///
6517    /// This is O(1) - the new session shares the message buffer via Arc.
6518    /// Copy-on-write occurs when either session mutates its messages.
6519    pub fn fork(&self) -> Self {
6520        let now = SystemTime::now();
6521        Self {
6522            version: session_version(),
6523            id: SessionId::new(),
6524            messages: Arc::clone(&self.messages),
6525            created_at: now,
6526            updated_at: now,
6527            metadata: self.fork_metadata_projection(),
6528            transcript_history_metadata_validation: TranscriptHistoryMetadataValidation::Validated,
6529            usage: self.usage.clone(),
6530        }
6531    }
6532}
6533
6534impl Default for Session {
6535    fn default() -> Self {
6536        Self::new()
6537    }
6538}
6539
6540/// Summary metadata for listing sessions
6541#[derive(Debug, Clone, Serialize, Deserialize)]
6542#[serde(rename_all = "snake_case")]
6543pub struct SessionMeta {
6544    pub id: SessionId,
6545    pub created_at: SystemTime,
6546    pub updated_at: SystemTime,
6547    pub message_count: usize,
6548    pub total_tokens: u64,
6549    #[serde(default)]
6550    pub metadata: serde_json::Map<String, serde_json::Value>,
6551}
6552
6553/// Metadata required to reliably resume a session across interfaces.
6554#[derive(Debug, Clone, Serialize, Deserialize)]
6555#[serde(rename_all = "snake_case")]
6556pub struct SessionMetadata {
6557    /// Per-entity schema version byte.
6558    ///
6559    /// Mandatory on read: a persisted row missing the byte (or carrying a
6560    /// non-current value) fails closed through the generated persistence
6561    /// version authority instead of silently defaulting. Stamped with the
6562    /// current `SESSION_METADATA_SCHEMA_VERSION` on every persist.
6563    pub schema_version: u32,
6564    pub model: String,
6565    pub max_tokens: u32,
6566    #[serde(default = "crate::config::default_structured_output_retries")]
6567    pub structured_output_retries: u32,
6568    pub provider: Provider,
6569    #[serde(default, skip_serializing_if = "Option::is_none")]
6570    pub self_hosted_server_id: Option<String>,
6571    /// Typed provider parameter overrides persisted with the session.
6572    /// Parsed fail-closed at the serde boundary — no JSON bag survives here.
6573    #[serde(default, skip_serializing_if = "Option::is_none")]
6574    pub provider_params: Option<crate::lifecycle::run_primitive::ProviderParamsOverride>,
6575    pub tooling: SessionTooling,
6576    #[serde(default)]
6577    pub keep_alive: bool,
6578    pub comms_name: Option<String>,
6579    /// Friendly metadata for peer discovery (populated when comms is enabled).
6580    #[serde(default, skip_serializing_if = "Option::is_none")]
6581    pub peer_meta: Option<PeerMeta>,
6582    /// Realm identity for cross-surface storage sharing/isolation.
6583    ///
6584    /// Typed [`crate::RealmId`]; the realm slug is validated at the serde
6585    /// boundary. `RealmId` serializes transparently as its slug string, so the
6586    /// durable JSON shape is identical to the prior `Option<String>` form.
6587    #[serde(default, skip_serializing_if = "Option::is_none")]
6588    pub realm_id: Option<crate::RealmId>,
6589    /// Optional process/agent instance identifier within a realm.
6590    #[serde(default, skip_serializing_if = "Option::is_none")]
6591    pub instance_id: Option<String>,
6592    /// Backend pinned by the realm manifest (e.g. "sqlite", "jsonl", "memory").
6593    #[serde(default, skip_serializing_if = "Option::is_none")]
6594    pub backend: Option<String>,
6595    /// Config generation used when this session was created/resumed.
6596    #[serde(default, skip_serializing_if = "Option::is_none")]
6597    pub config_generation: Option<u64>,
6598    /// Realm-scoped auth binding (Phase 3 provider-auth redesign).
6599    ///
6600    /// Persisted intent for the auth/backend binding this session resolved
6601    /// through. On resume, `apply_resumed_session_metadata` writes this
6602    /// back into `AgentBuildConfig.auth_binding` so the same realm
6603    /// binding is re-resolved. Never carries secret material — leases
6604    /// are rebuilt from the active realm connection set at resume time.
6605    /// Older persisted sessions without the field deserialize as `None`
6606    /// (backward compatible via `#[serde(default)]`).
6607    #[serde(default, skip_serializing_if = "Option::is_none")]
6608    pub auth_binding: Option<crate::AuthBindingRef>,
6609    /// Typed durable identity of a mob member, when this session was created by
6610    /// the mob runtime.
6611    ///
6612    /// This is the canonical owner of the `(mob_id, role, member)` identity
6613    /// fact used by mob ownership routing on resume/restart. It replaces the
6614    /// prior recovery-by-string-split of `comms_name` plus a realm
6615    /// format-string check. `comms_name`/`realm_id`/`peer_meta` remain as the
6616    /// transport routing name and discovery metadata.
6617    ///
6618    /// Older persisted sessions without the field deserialize as `None`
6619    /// (backward compatible via `#[serde(default)]`), so old rows read as
6620    /// "no typed binding" rather than failing.
6621    #[serde(default, skip_serializing_if = "Option::is_none")]
6622    pub mob_member_binding: Option<crate::MobMemberBinding>,
6623}
6624
6625/// Canonical durable LLM identity for a session.
6626#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
6627#[serde(rename_all = "snake_case")]
6628pub struct SessionLlmIdentity {
6629    pub model: String,
6630    pub provider: Provider,
6631    #[serde(default, skip_serializing_if = "Option::is_none")]
6632    pub self_hosted_server_id: Option<String>,
6633    /// Typed provider parameter overrides carried on the durable identity.
6634    #[serde(default, skip_serializing_if = "Option::is_none")]
6635    pub provider_params: Option<crate::lifecycle::run_primitive::ProviderParamsOverride>,
6636    /// Realm-scoped auth binding this session resolves credentials
6637    /// through. Carried on the identity so mid-session hot-swaps
6638    /// (`apply_live_session_llm_identity`) re-resolve against the
6639    /// same realm the session was created with — preventing
6640    /// cross-realm credential bleed in multi-tenant setups. Dogma
6641    /// §12 (dynamic policy follows dynamic identity): on swap the
6642    /// factory re-enters `ProviderRuntimeRegistry::resolve` against
6643    /// this binding, not a new synthesized env-default realm.
6644    ///
6645    /// Projection (dogma §1/§13): canonical owner is
6646    /// `SessionMetadata.auth_binding`; this field is the
6647    /// read/write projection used by hot-swap.
6648    #[serde(default, skip_serializing_if = "Option::is_none")]
6649    pub auth_binding: Option<crate::AuthBindingRef>,
6650}
6651
6652/// Typed per-turn override request for a session LLM identity.
6653///
6654/// `provider_params` and `auth_binding` carry the canonical Inherit/Set/Clear
6655/// tri-state via [`TurnMetadataOverride`]: `None` preserves the durable value,
6656/// `Some(Set)` overrides it for this turn, and `Some(Clear)` removes it. The
6657/// illegal "set and clear" fourth state is structurally unrepresentable, so the
6658/// resolver needs no reject branch for it.
6659pub struct SessionLlmIdentityOverride<'a> {
6660    pub model: Option<&'a str>,
6661    pub provider: Option<Provider>,
6662    /// Exact configured route for a self-hosted model. This cannot be inferred
6663    /// from provider/model when multiple local servers expose the same model
6664    /// identifier.
6665    pub self_hosted_server_id: Option<&'a str>,
6666    pub provider_params:
6667        Option<TurnMetadataOverride<&'a crate::lifecycle::run_primitive::ProviderParamsOverride>>,
6668    pub auth_binding: Option<TurnMetadataOverride<&'a crate::AuthBindingRef>>,
6669}
6670
6671#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
6672pub enum SessionLlmIdentityOverrideError {
6673    #[error("provider override requires model on an existing session")]
6674    ProviderRequiresModel,
6675    #[error("{0}")]
6676    ProviderModelMismatch(String),
6677    #[error("self-hosted provider requires a registered model alias; '{model}' is not configured")]
6678    MissingSelfHostedAlias { model: String },
6679    #[error("self_hosted_server_id requires provider 'self_hosted'")]
6680    SelfHostedServerRequiresSelfHostedProvider,
6681    #[error("self_hosted_server_id must not be empty")]
6682    EmptySelfHostedServerId,
6683    #[error(
6684        "self-hosted model '{model}' is configured on server '{configured}', not requested server '{requested}'"
6685    )]
6686    SelfHostedServerMismatch {
6687        model: String,
6688        requested: String,
6689        configured: String,
6690    },
6691}
6692
6693/// Resolve a turn-time model/provider/auth override against the current
6694/// durable session identity.
6695///
6696/// The model registry is the authority for catalog ownership. A model-only
6697/// override follows catalog ownership when the target model is registered;
6698/// uncatalogued models keep the current provider so custom aliases remain
6699/// possible.
6700pub fn resolve_session_llm_identity_override(
6701    current: &SessionLlmIdentity,
6702    registry: &crate::ModelRegistry,
6703    overrides: SessionLlmIdentityOverride<'_>,
6704) -> Result<SessionLlmIdentity, SessionLlmIdentityOverrideError> {
6705    if overrides.provider.is_some() && overrides.model.is_none() {
6706        return Err(SessionLlmIdentityOverrideError::ProviderRequiresModel);
6707    }
6708
6709    let model = overrides
6710        .model
6711        .map(str::to_string)
6712        .unwrap_or_else(|| current.model.clone());
6713    let provider = if let Some(provider) = overrides.provider {
6714        provider
6715    } else if overrides.model.is_some() {
6716        registry
6717            .entry(&model)
6718            .map_or(current.provider, |entry| entry.provider)
6719    } else {
6720        current.provider
6721    };
6722
6723    if (overrides.model.is_some() || overrides.provider.is_some())
6724        && let Some(reason) = registry.provider_override_mismatch_reason(provider, &model)
6725    {
6726        return Err(SessionLlmIdentityOverrideError::ProviderModelMismatch(
6727            reason,
6728        ));
6729    }
6730
6731    let provider_params = match overrides.provider_params {
6732        Some(TurnMetadataOverride::Clear) => None,
6733        Some(TurnMetadataOverride::Set(value)) => Some(value.clone()),
6734        None => current.provider_params.clone(),
6735    };
6736    if overrides.self_hosted_server_id.is_some() && provider != Provider::SelfHosted {
6737        return Err(SessionLlmIdentityOverrideError::SelfHostedServerRequiresSelfHostedProvider);
6738    }
6739    let self_hosted_server_id = if provider == Provider::SelfHosted {
6740        if let Some(requested_server_id) = overrides.self_hosted_server_id {
6741            if requested_server_id.trim().is_empty() {
6742                return Err(SessionLlmIdentityOverrideError::EmptySelfHostedServerId);
6743            }
6744            let entry = registry
6745                .entry_for_provider(Provider::SelfHosted, &model)
6746                .ok_or_else(|| SessionLlmIdentityOverrideError::MissingSelfHostedAlias {
6747                    model: model.clone(),
6748                })?;
6749            let configured_server_id = entry
6750                .self_hosted
6751                .as_ref()
6752                .map(|server| server.server_id.as_str())
6753                .ok_or_else(|| SessionLlmIdentityOverrideError::MissingSelfHostedAlias {
6754                    model: model.clone(),
6755                })?;
6756            if configured_server_id != requested_server_id {
6757                return Err(SessionLlmIdentityOverrideError::SelfHostedServerMismatch {
6758                    model,
6759                    requested: requested_server_id.to_string(),
6760                    configured: configured_server_id.to_string(),
6761                });
6762            }
6763            Some(requested_server_id.to_string())
6764        } else if overrides.model.is_none() {
6765            current.self_hosted_server_id.clone().or_else(|| {
6766                registry
6767                    .entry_for_provider(Provider::SelfHosted, &model)
6768                    .and_then(|entry| entry.self_hosted.as_ref())
6769                    .map(|server| server.server_id.clone())
6770            })
6771        } else {
6772            let entry = registry
6773                .entry_for_provider(Provider::SelfHosted, &model)
6774                .ok_or_else(|| SessionLlmIdentityOverrideError::MissingSelfHostedAlias {
6775                    model: model.clone(),
6776                })?;
6777            entry
6778                .self_hosted
6779                .as_ref()
6780                .map(|server| server.server_id.clone())
6781        }
6782    } else {
6783        None
6784    };
6785
6786    let auth_binding = match overrides.auth_binding {
6787        Some(TurnMetadataOverride::Clear) => None,
6788        Some(TurnMetadataOverride::Set(value)) => Some(value.clone()),
6789        // Inherit: a provider change without an explicit binding drops the
6790        // stale binding; otherwise the durable binding is retained.
6791        None if provider != current.provider => None,
6792        None => current.auth_binding.clone(),
6793    };
6794
6795    Ok(SessionLlmIdentity {
6796        model,
6797        provider,
6798        self_hosted_server_id,
6799        provider_params,
6800        auth_binding,
6801    })
6802}
6803
6804/// Live request policy paired with a session LLM identity hot-swap.
6805///
6806/// `SessionLlmIdentity` is the durable semantic identity. This projection is
6807/// the per-turn request policy the live agent must use for the next LLM call,
6808/// including provider params and provider-native tool defaults resolved for
6809/// the same target model/provider.
6810#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
6811#[serde(rename_all = "snake_case")]
6812pub struct SessionLlmRequestPolicy {
6813    pub model: String,
6814    /// Typed explicit provider parameter overrides for the next LLM call.
6815    #[serde(default, skip_serializing_if = "Option::is_none")]
6816    pub provider_params: Option<crate::lifecycle::run_primitive::ProviderParamsOverride>,
6817    /// Typed provider-native tool defaults resolved for the swapped target.
6818    #[serde(default, skip_serializing_if = "Option::is_none")]
6819    pub provider_tool_defaults: Option<crate::lifecycle::run_primitive::ProviderTag>,
6820}
6821
6822impl SessionMetadata {
6823    /// Return the current durable LLM identity for this session.
6824    pub fn llm_identity(&self) -> SessionLlmIdentity {
6825        SessionLlmIdentity {
6826            model: self.model.clone(),
6827            provider: self.provider,
6828            self_hosted_server_id: self.self_hosted_server_id.clone(),
6829            provider_params: self.provider_params.clone(),
6830            auth_binding: self.auth_binding.clone(),
6831        }
6832    }
6833
6834    /// Overwrite the durable LLM identity while preserving unrelated session metadata.
6835    pub fn apply_llm_identity(&mut self, identity: &SessionLlmIdentity) {
6836        self.model = identity.model.clone();
6837        self.provider = identity.provider;
6838        self.self_hosted_server_id = identity.self_hosted_server_id.clone();
6839        self.provider_params = identity.provider_params.clone();
6840        self.auth_binding = identity.auth_binding.clone();
6841    }
6842}
6843
6844/// Key used to store SessionMetadata in Session metadata map.
6845pub const SESSION_METADATA_KEY: &str = "session_metadata";
6846
6847/// Caller intent for a tool category.
6848///
6849/// Distinguishes "no opinion / didn't exist" (`Inherit`) from explicit
6850/// `Enable` / `Disable` so that resumed sessions don't freeze tool
6851/// availability at the capabilities of the Meerkat version that created them.
6852///
6853/// **Dogma §10:** Inherit, disable, and set are different facts.
6854#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
6855#[serde(rename_all = "snake_case")]
6856pub enum ToolCategoryOverride {
6857    /// No explicit intent — inherit runtime/factory default.
6858    #[default]
6859    Inherit,
6860    /// Explicitly enabled by caller.
6861    Enable,
6862    /// Explicitly disabled by caller.
6863    Disable,
6864}
6865
6866impl ToolCategoryOverride {
6867    /// Resolve this override against a runtime default.
6868    ///
6869    /// - `Enable` → `true`
6870    /// - `Disable` → `false`
6871    /// - `Inherit` → `runtime_default`
6872    #[must_use]
6873    pub fn resolve(self, runtime_default: bool) -> bool {
6874        match self {
6875            Self::Enable => true,
6876            Self::Disable => false,
6877            Self::Inherit => runtime_default,
6878        }
6879    }
6880
6881    /// Convert to `Option<bool>` for feeding `AgentBuildConfig` override fields.
6882    ///
6883    /// - `Enable` → `Some(true)`
6884    /// - `Disable` → `Some(false)`
6885    /// - `Inherit` → `None` (factory default wins)
6886    #[must_use]
6887    pub fn to_override(self) -> Option<bool> {
6888        match self {
6889            Self::Enable => Some(true),
6890            Self::Disable => Some(false),
6891            Self::Inherit => None,
6892        }
6893    }
6894
6895    /// Construct from a resolved effective bool.
6896    ///
6897    /// **Warning:** this collapses `Inherit` into `Enable`/`Disable`. Prefer
6898    /// [`from_override`] when persisting session metadata so that `Inherit`
6899    /// survives across save/resume cycles. Only use `from_effective` in test
6900    /// helpers or when constructing metadata from external sources that only
6901    /// provide a resolved bool.
6902    #[must_use]
6903    pub fn from_effective(enabled: bool) -> Self {
6904        if enabled { Self::Enable } else { Self::Disable }
6905    }
6906
6907    /// Construct from an `Option<bool>` override field, preserving `Inherit`.
6908    ///
6909    /// - `Some(true)` → `Enable`
6910    /// - `Some(false)` → `Disable`
6911    /// - `None` → `Inherit` (factory default was used, no explicit intent)
6912    ///
6913    /// This is the inverse of [`to_override`] and should be used when persisting
6914    /// session tooling metadata so that `Inherit` survives across save/resume
6915    /// cycles.
6916    #[must_use]
6917    pub fn from_override(value: Option<bool>) -> Self {
6918        match value {
6919            Some(true) => Self::Enable,
6920            Some(false) => Self::Disable,
6921            None => Self::Inherit,
6922        }
6923    }
6924}
6925
6926/// Tooling intent captured at session creation time.
6927///
6928/// Fields use [`ToolCategoryOverride`] to distinguish "no opinion" from
6929/// explicit enable/disable (Dogma §10). On resume, `Inherit` falls through
6930/// to the factory's current runtime default, allowing new tool categories
6931/// to become available without re-creating the session.
6932#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
6933#[serde(rename_all = "snake_case")]
6934pub struct SessionTooling {
6935    #[serde(default)]
6936    pub builtins: ToolCategoryOverride,
6937    #[serde(default)]
6938    pub shell: ToolCategoryOverride,
6939    #[serde(default)]
6940    pub comms: ToolCategoryOverride,
6941    /// Mob (multi-agent orchestration) tools.
6942    #[serde(default)]
6943    pub mob: ToolCategoryOverride,
6944    /// Semantic memory.
6945    #[serde(default)]
6946    pub memory: ToolCategoryOverride,
6947    /// Scheduler tools.
6948    #[serde(default)]
6949    pub schedule: ToolCategoryOverride,
6950    /// WorkGraph durable work tools.
6951    #[serde(default)]
6952    pub workgraph: ToolCategoryOverride,
6953    /// Assistant image generation.
6954    #[serde(default)]
6955    pub image_generation: ToolCategoryOverride,
6956    /// Meerkat-owned fallback web search.
6957    #[serde(default)]
6958    pub web_search: ToolCategoryOverride,
6959    /// Effective call-level tool execution policy for this session's builds.
6960    ///
6961    /// Persisted RESOLVED (never `Inherit`): the factory fails the build
6962    /// closed on an unresolved `Inherit` before metadata is written, so this
6963    /// field only ever holds `AllowList`/`DenyList`. Absent means
6964    /// unrestricted. Spawn/fork resolution reads this field as the parent's
6965    /// effective policy when a child requests `Inherit` (transitive
6966    /// containment — a restricted parent cannot mint an unrestricted child
6967    /// by spawning).
6968    #[serde(default, skip_serializing_if = "Option::is_none")]
6969    pub tool_access_policy: Option<crate::ops::ToolAccessPolicy>,
6970    /// Active skills at session creation time (for deterministic resume).
6971    #[serde(default, skip_serializing_if = "Option::is_none")]
6972    pub active_skills: Option<Vec<crate::skills::SkillKey>>,
6973}
6974
6975impl From<&Session> for SessionMeta {
6976    fn from(session: &Session) -> Self {
6977        Self {
6978            id: session.id.clone(),
6979            created_at: session.created_at,
6980            updated_at: session.updated_at,
6981            message_count: session.messages.len(),
6982            total_tokens: session.total_tokens(),
6983            metadata: session.metadata.clone(),
6984        }
6985    }
6986}
6987
6988/// Decode the typed [`SESSION_METADATA_KEY`] fact from a session metadata map
6989/// through the generated restore authority.
6990///
6991/// Canonical single decoder: [`Session::try_session_metadata`] and every
6992/// metadata-only read seam ([`PersistedSessionMetadataView`]) delegate here so
6993/// the full-session and metadata-only decode paths can never drift.
6994///
6995/// Fail-closed: a present-but-corrupt value is an error, never "absent".
6996pub fn try_session_metadata_from_map(
6997    metadata: &serde_json::Map<String, serde_json::Value>,
6998) -> Result<Option<SessionMetadata>, serde_json::Error> {
6999    let Some(value) = metadata.get(SESSION_METADATA_KEY) else {
7000        return Ok(None);
7001    };
7002    let mut metadata = serde_json::from_value::<SessionMetadata>(value.clone())?;
7003    metadata.schema_version =
7004        session_persistence_version_authority::restore_session_metadata_schema_version(
7005            metadata.schema_version,
7006        )
7007        .map_err(<serde_json::Error as serde::de::Error>::custom)?;
7008    session_durable_config_authority::restore_session_metadata(metadata)
7009        .map(Some)
7010        .map_err(<serde_json::Error as serde::de::Error>::custom)
7011}
7012
7013/// Decode the typed [`SESSION_LIFECYCLE_TERMINAL_KEY`] fact from a session
7014/// metadata map.
7015///
7016/// Canonical single decoder: [`Session::try_lifecycle_terminal`] and every
7017/// metadata-only read seam delegate here. An absent key means no terminal
7018/// fact; a present-but-corrupt value fails closed.
7019pub fn try_lifecycle_terminal_from_map(
7020    metadata: &serde_json::Map<String, serde_json::Value>,
7021) -> Result<Option<SessionLifecycleTerminal>, serde_json::Error> {
7022    match metadata.get(SESSION_LIFECYCLE_TERMINAL_KEY) {
7023        Some(value) => serde_json::from_value(value.clone()).map(Some),
7024        None => Ok(None),
7025    }
7026}
7027
7028/// Typed metadata-only view of a persisted session row or snapshot.
7029///
7030/// The metadata read seam's currency (mobkit ask-24 clause 3): carries the
7031/// session identity plus the two typed session-authority metadata facts,
7032/// decoded fail-closed through the canonical map-level decoders. Consumers
7033/// that only need ownership/policy/lifecycle facts read this view instead of
7034/// materializing the full session document.
7035#[derive(Debug, Clone)]
7036pub struct PersistedSessionMetadataView {
7037    pub session_id: SessionId,
7038    pub session_metadata: Option<SessionMetadata>,
7039    pub lifecycle_terminal: Option<SessionLifecycleTerminal>,
7040}
7041
7042impl PersistedSessionMetadataView {
7043    /// Build the view from a persisted metadata map (e.g. a
7044    /// [`SessionMeta`] row projection).
7045    ///
7046    /// Fail-closed: corrupt values under either reserved key are an error,
7047    /// never treated as absent.
7048    pub fn try_from_metadata_map(
7049        session_id: SessionId,
7050        metadata: &serde_json::Map<String, serde_json::Value>,
7051    ) -> Result<Self, serde_json::Error> {
7052        Ok(Self {
7053            session_id,
7054            session_metadata: try_session_metadata_from_map(metadata)?,
7055            lifecycle_terminal: try_lifecycle_terminal_from_map(metadata)?,
7056        })
7057    }
7058
7059    /// Project the view from a fully materialized session document.
7060    pub fn try_from_session(session: &Session) -> Result<Self, serde_json::Error> {
7061        Ok(Self {
7062            session_id: session.id().clone(),
7063            session_metadata: session.try_session_metadata()?,
7064            lifecycle_terminal: session.try_lifecycle_terminal()?,
7065        })
7066    }
7067
7068    /// Typed durable mob member identity carried on the session metadata,
7069    /// if any.
7070    pub fn mob_member_binding(&self) -> Option<&crate::MobMemberBinding> {
7071        self.session_metadata.as_ref()?.mob_member_binding.as_ref()
7072    }
7073}
7074
7075#[cfg(test)]
7076#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
7077mod tests {
7078    use super::*;
7079    use crate::realtime_transcript::RealtimeTranscriptRole;
7080    use crate::types::{
7081        AssistantBlock, BlockAssistantMessage, ContentBlock, StopReason, SystemMessage, Usage,
7082        UserMessage,
7083    };
7084    use std::sync::Arc;
7085
7086    fn exact_boundary_append(key: &str, text: &str) -> PendingSystemContextAppend {
7087        PendingSystemContextAppend {
7088            content: crate::lifecycle::CoreRenderable::text(text.to_string()),
7089            source: Some("test:exact-boundary".to_string()),
7090            idempotency_key: Some(key.to_string()),
7091            source_kind: SystemContextSource::RuntimeSteer,
7092            accepted_at: SystemTime::now(),
7093            peer_response_terminal: None,
7094        }
7095    }
7096
7097    async fn wait_for_exact_boundary_request(handle: &SystemContextStateHandle) {
7098        for _ in 0..1_000 {
7099            let registered = matches!(
7100                &handle.boundary.lock().window,
7101                SystemContextBoundaryWindow::Open {
7102                    request: Some(_),
7103                    ..
7104                }
7105            );
7106            if registered {
7107                return;
7108            }
7109            tokio::task::yield_now().await;
7110        }
7111        panic!("exact boundary request did not register");
7112    }
7113
7114    fn assert_send<T: Send>() {}
7115
7116    #[test]
7117    fn prepared_boundary_authority_is_send_for_owned_commit_handoff() {
7118        assert_send::<PreparedSystemContextBoundary>();
7119        assert_send::<crate::lifecycle::CoreBoundaryStageOutput>();
7120        assert_send::<ModelBoundarySystemContext>();
7121    }
7122
7123    #[tokio::test]
7124    async fn exact_boundary_runner_first_is_typed_unavailable() {
7125        let state = SystemContextStateHandle::new(Default::default()).expect("state");
7126        let run_id = RunId::new();
7127        let _run = state
7128            .begin_boundary_run(run_id.clone())
7129            .expect("open boundary");
7130
7131        let consuming = state
7132            .take_pending_at_exact_boundary(&run_id)
7133            .await
7134            .expect("runner claims open boundary");
7135        assert!(consuming.appends().is_empty());
7136        assert!(
7137            consuming
7138                .consume()
7139                .expect("runner consumes open boundary")
7140                .is_empty()
7141        );
7142        let error = state
7143            .prepare_active_turn_boundary(
7144                &run_id,
7145                vec![exact_boundary_append("runner-first", "too late")],
7146            )
7147            .await
7148            .expect_err("consumed generation cannot mint a preparation");
7149        assert!(matches!(error, CoreBoundaryStageError::Unavailable { .. }));
7150    }
7151
7152    #[tokio::test]
7153    async fn exact_boundary_wrong_run_is_stale_without_claiming_or_mutating_window() {
7154        let state = SystemContextStateHandle::new(Default::default()).expect("state");
7155        let active_run_id = RunId::new();
7156        let wrong_run_id = RunId::new();
7157        let _run = state
7158            .begin_boundary_run(active_run_id.clone())
7159            .expect("open boundary");
7160
7161        let error = state
7162            .prepare_active_turn_boundary(
7163                &wrong_run_id,
7164                vec![exact_boundary_append("wrong-run", "must not stage")],
7165            )
7166            .await
7167            .expect_err("a different run cannot claim the active window");
7168        assert!(matches!(error, CoreBoundaryStageError::Stale { .. }));
7169        assert!(state.snapshot().seen().is_empty());
7170
7171        let consuming = state
7172            .take_pending_at_exact_boundary(&active_run_id)
7173            .await
7174            .expect("the correct run still owns its unclaimed boundary");
7175        assert!(
7176            consuming
7177                .consume()
7178                .expect("consume unchanged active window")
7179                .is_empty()
7180        );
7181    }
7182
7183    #[tokio::test]
7184    async fn exact_boundary_conflicting_batch_is_atomic_and_leaves_window_unclaimed() {
7185        let state = SystemContextStateHandle::new(Default::default()).expect("state");
7186        let run_id = RunId::new();
7187        let _run = state
7188            .begin_boundary_run(run_id.clone())
7189            .expect("open boundary");
7190
7191        let error = state
7192            .prepare_active_turn_boundary(
7193                &run_id,
7194                vec![
7195                    exact_boundary_append("conflict", "first"),
7196                    exact_boundary_append("conflict", "different"),
7197                ],
7198            )
7199            .await
7200            .expect_err("a conflicting batch must fail before registration");
7201        assert!(matches!(error, CoreBoundaryStageError::Fault { .. }));
7202        assert!(state.snapshot().seen().is_empty());
7203
7204        let consuming = state
7205            .take_pending_at_exact_boundary(&run_id)
7206            .await
7207            .expect("failed validation must leave the exact window unclaimed");
7208        assert!(
7209            consuming
7210                .consume()
7211                .expect("consume unchanged active window")
7212                .is_empty()
7213        );
7214    }
7215
7216    #[tokio::test]
7217    async fn exact_boundary_prepare_first_parks_until_commit() {
7218        let state = SystemContextStateHandle::new(Default::default()).expect("state");
7219        let run_id = RunId::new();
7220        let _run = state
7221            .begin_boundary_run(run_id.clone())
7222            .expect("open boundary");
7223
7224        let prepare_state = state.clone();
7225        let prepare_run_id = run_id.clone();
7226        let prepare = tokio::spawn(async move {
7227            prepare_state
7228                .prepare_active_turn_boundary(
7229                    &prepare_run_id,
7230                    vec![exact_boundary_append("prepare-first", "parked context")],
7231                )
7232                .await
7233        });
7234        wait_for_exact_boundary_request(&state).await;
7235
7236        let runner_state = state.clone();
7237        let runner_run_id = run_id.clone();
7238        let runner = tokio::spawn(async move {
7239            runner_state
7240                .take_pending_at_exact_boundary(&runner_run_id)
7241                .await
7242        });
7243        let prepared = prepare
7244            .await
7245            .expect("prepare task")
7246            .expect("prepare must return only after park");
7247        assert_eq!(prepared.expected_run_id(), &run_id);
7248        assert!(prepared.boundary_generation() > 0);
7249        assert!(state.snapshot().pending().is_empty());
7250        assert!(
7251            !runner.is_finished(),
7252            "runner must remain parked before commit"
7253        );
7254
7255        prepared
7256            .into_stage_output(None)
7257            .commit()
7258            .expect("exact commit");
7259        let consuming = runner
7260            .await
7261            .expect("runner task")
7262            .expect("runner resumes after commit");
7263        assert_eq!(state.snapshot().pending().len(), 1);
7264        assert!(state.snapshot().applied().is_empty());
7265        let consumed = consuming.consume().expect("consume at model call seam");
7266        assert_eq!(consumed.len(), 1);
7267        assert_eq!(consumed[0].content.render_text(), "parked context");
7268        assert!(state.snapshot().pending().is_empty());
7269        assert!(state.snapshot().applied().is_empty());
7270    }
7271
7272    #[tokio::test]
7273    async fn exact_boundary_preprocessing_drop_does_not_claim_model_consumption() {
7274        let state = SystemContextStateHandle::new(Default::default()).expect("state");
7275        let run_id = RunId::new();
7276        let _run = state
7277            .begin_boundary_run(run_id.clone())
7278            .expect("open boundary");
7279        let prepare_state = state.clone();
7280        let prepare_run_id = run_id.clone();
7281        let prepare = tokio::spawn(async move {
7282            prepare_state
7283                .prepare_active_turn_boundary(
7284                    &prepare_run_id,
7285                    vec![exact_boundary_append(
7286                        "preprocess-drop",
7287                        "retryable context",
7288                    )],
7289                )
7290                .await
7291        });
7292        wait_for_exact_boundary_request(&state).await;
7293        let runner_state = state.clone();
7294        let runner_run_id = run_id.clone();
7295        let runner = tokio::spawn(async move {
7296            runner_state
7297                .take_pending_at_exact_boundary(&runner_run_id)
7298                .await
7299        });
7300        prepare
7301            .await
7302            .expect("prepare task")
7303            .expect("prepare parks")
7304            .into_stage_output(None)
7305            .commit()
7306            .expect("publish pending candidate");
7307
7308        let consuming = runner
7309            .await
7310            .expect("runner task")
7311            .expect("runner enters preprocessing");
7312        assert_eq!(consuming.appends().len(), 1);
7313        drop(consuming);
7314
7315        let snapshot = state.snapshot();
7316        assert_eq!(snapshot.pending().len(), 1);
7317        assert!(snapshot.applied().is_empty());
7318        assert!(snapshot.seen().contains_key("preprocess-drop"));
7319
7320        // Commit publishes to the exact active turn; it does not claim model
7321        // delivery. A later hard cancel/preprocessing drop owns the following
7322        // cleanup linearization and must not leak the accepted steer to a
7323        // successor run.
7324        assert_eq!(
7325            state
7326                .discard_unapplied_active_turn_pending()
7327                .expect("closed consuming window permits active-turn cleanup"),
7328            1
7329        );
7330        assert!(state.snapshot().pending().is_empty());
7331    }
7332
7333    #[tokio::test]
7334    async fn exact_boundary_drop_aborts_and_preserves_ordinary_pending() {
7335        let state = SystemContextStateHandle::new(Default::default()).expect("state");
7336        state
7337            .stage_append_with_snapshot(
7338                &AppendSystemContextRequest {
7339                    content: crate::lifecycle::CoreRenderable::text("ordinary"),
7340                    source: Some("test:ordinary".to_string()),
7341                    idempotency_key: Some("ordinary".to_string()),
7342                    source_kind: SystemContextSource::Normal,
7343                    peer_response_terminal: None,
7344                },
7345                SystemTime::now(),
7346            )
7347            .expect("ordinary append");
7348        let run_id = RunId::new();
7349        let _run = state
7350            .begin_boundary_run(run_id.clone())
7351            .expect("open boundary");
7352        let prepare_state = state.clone();
7353        let prepare_run_id = run_id.clone();
7354        let prepare = tokio::spawn(async move {
7355            prepare_state
7356                .prepare_active_turn_boundary(
7357                    &prepare_run_id,
7358                    vec![exact_boundary_append("drop-abort", "must not publish")],
7359                )
7360                .await
7361        });
7362        wait_for_exact_boundary_request(&state).await;
7363        let runner_state = state.clone();
7364        let runner_run_id = run_id.clone();
7365        let runner = tokio::spawn(async move {
7366            runner_state
7367                .take_pending_at_exact_boundary(&runner_run_id)
7368                .await
7369        });
7370        let prepared = prepare.await.expect("prepare task").expect("parked");
7371        drop(prepared);
7372        let consuming = runner
7373            .await
7374            .expect("runner task")
7375            .expect("drop abort wakes runner");
7376        let consumed = consuming
7377            .consume()
7378            .expect("consume ordinary pending context");
7379        assert_eq!(consumed.len(), 1);
7380        assert_eq!(consumed[0].content.render_text(), "ordinary");
7381        assert!(!state.snapshot().seen().contains_key("drop-abort"));
7382    }
7383
7384    #[tokio::test]
7385    async fn exact_boundary_duplicate_prepare_cannot_overwrite_generation() {
7386        let state = SystemContextStateHandle::new(Default::default()).expect("state");
7387        let run_id = RunId::new();
7388        let _run = state
7389            .begin_boundary_run(run_id.clone())
7390            .expect("open boundary");
7391        let first_state = state.clone();
7392        let first_run_id = run_id.clone();
7393        let first = tokio::spawn(async move {
7394            first_state
7395                .prepare_active_turn_boundary(
7396                    &first_run_id,
7397                    vec![exact_boundary_append("first", "first")],
7398                )
7399                .await
7400        });
7401        wait_for_exact_boundary_request(&state).await;
7402        let duplicate = state
7403            .prepare_active_turn_boundary(&run_id, vec![exact_boundary_append("second", "second")])
7404            .await
7405            .expect_err("duplicate preparation must fail closed");
7406        assert!(duplicate.is_unavailable());
7407
7408        let runner_state = state.clone();
7409        let runner_run_id = run_id.clone();
7410        let runner = tokio::spawn(async move {
7411            runner_state
7412                .take_pending_at_exact_boundary(&runner_run_id)
7413                .await
7414        });
7415        let prepared = first.await.expect("first task").expect("first parks");
7416        prepared
7417            .into_stage_output(None)
7418            .abort()
7419            .expect("explicit abort");
7420        runner
7421            .await
7422            .expect("runner task")
7423            .expect("runner resumes after abort")
7424            .consume()
7425            .expect("consume ordinary pending after abort");
7426        assert!(!state.snapshot().seen().contains_key("second"));
7427    }
7428
7429    #[tokio::test]
7430    async fn exact_boundary_concurrent_conflict_surfaces_fault_to_runner_and_preparer() {
7431        let state = SystemContextStateHandle::new(Default::default()).expect("state");
7432        let run_id = RunId::new();
7433        let _run = state
7434            .begin_boundary_run(run_id.clone())
7435            .expect("open boundary");
7436        let prepare_state = state.clone();
7437        let prepare_run_id = run_id.clone();
7438        let prepare = tokio::spawn(async move {
7439            prepare_state
7440                .prepare_active_turn_boundary(
7441                    &prepare_run_id,
7442                    vec![exact_boundary_append("shared-key", "prepared context")],
7443                )
7444                .await
7445        });
7446        wait_for_exact_boundary_request(&state).await;
7447
7448        state
7449            .stage_append_with_snapshot(
7450                &AppendSystemContextRequest {
7451                    content: crate::lifecycle::CoreRenderable::text("ordinary conflict"),
7452                    source: Some("test:exact-boundary".to_string()),
7453                    idempotency_key: Some("shared-key".to_string()),
7454                    source_kind: SystemContextSource::Normal,
7455                    peer_response_terminal: None,
7456                },
7457                SystemTime::now(),
7458            )
7459            .expect("ordinary mutation remains legal before the runner parks");
7460
7461        let runner_error = state
7462            .take_pending_at_exact_boundary(&run_id)
7463            .await
7464            .err()
7465            .expect("runner must surface candidate recomputation conflict");
7466        assert!(matches!(runner_error, CoreBoundaryStageError::Fault { .. }));
7467        let prepare_error = prepare
7468            .await
7469            .expect("prepare task")
7470            .expect_err("preparer must receive the same typed failure class");
7471        assert!(matches!(
7472            prepare_error,
7473            CoreBoundaryStageError::Fault { .. }
7474        ));
7475
7476        let snapshot = state.snapshot();
7477        assert_eq!(snapshot.pending().len(), 1);
7478        assert_eq!(
7479            snapshot.pending()[0].content.render_text(),
7480            "ordinary conflict"
7481        );
7482        assert!(snapshot.applied().is_empty());
7483    }
7484
7485    #[tokio::test]
7486    async fn exact_boundary_nonconflicting_open_mutation_is_preserved_in_candidate() {
7487        let state = SystemContextStateHandle::new(Default::default()).expect("state");
7488        let run_id = RunId::new();
7489        let _run = state
7490            .begin_boundary_run(run_id.clone())
7491            .expect("open boundary");
7492        let prepare_state = state.clone();
7493        let prepare_run_id = run_id.clone();
7494        let prepare = tokio::spawn(async move {
7495            prepare_state
7496                .prepare_active_turn_boundary(
7497                    &prepare_run_id,
7498                    vec![exact_boundary_append("prepared", "prepared context")],
7499                )
7500                .await
7501        });
7502        wait_for_exact_boundary_request(&state).await;
7503
7504        state
7505            .stage_append_with_snapshot(
7506                &AppendSystemContextRequest {
7507                    content: crate::lifecycle::CoreRenderable::text("ordinary context"),
7508                    source: Some("test:ordinary".to_string()),
7509                    idempotency_key: Some("ordinary".to_string()),
7510                    source_kind: SystemContextSource::Normal,
7511                    peer_response_terminal: None,
7512                },
7513                SystemTime::now(),
7514            )
7515            .expect("nonconflicting mutation remains legal before park");
7516
7517        let runner_state = state.clone();
7518        let runner_run_id = run_id.clone();
7519        let runner = tokio::spawn(async move {
7520            runner_state
7521                .take_pending_at_exact_boundary(&runner_run_id)
7522                .await
7523        });
7524        let prepared = prepare.await.expect("prepare task").expect("parked");
7525        assert_eq!(prepared.candidate_state().pending().len(), 2);
7526        prepared
7527            .into_stage_output(None)
7528            .commit()
7529            .expect("commit exact candidate");
7530        let consuming = runner
7531            .await
7532            .expect("runner task")
7533            .expect("runner resumes after commit");
7534        let consumed = consuming.consume().expect("consume exact candidate");
7535        assert_eq!(consumed.len(), 2);
7536        assert!(
7537            consumed
7538                .iter()
7539                .any(|append| append.content.render_text() == "ordinary context")
7540        );
7541        assert!(
7542            consumed
7543                .iter()
7544                .any(|append| append.content.render_text() == "prepared context")
7545        );
7546    }
7547
7548    #[tokio::test]
7549    async fn exact_boundary_actor_replacement_rejects_old_commit() {
7550        let actor_a = SystemContextStateHandle::new(Default::default()).expect("actor A");
7551        let run_id = RunId::new();
7552        let _run_a = actor_a
7553            .begin_boundary_run(run_id.clone())
7554            .expect("open A boundary");
7555        let prepare_state = actor_a.clone();
7556        let prepare_run_id = run_id.clone();
7557        let prepare = tokio::spawn(async move {
7558            prepare_state
7559                .prepare_active_turn_boundary(
7560                    &prepare_run_id,
7561                    vec![exact_boundary_append("actor-a", "stale A")],
7562                )
7563                .await
7564        });
7565        wait_for_exact_boundary_request(&actor_a).await;
7566        let runner_state = actor_a.clone();
7567        let runner_run_id = run_id.clone();
7568        let runner = tokio::spawn(async move {
7569            runner_state
7570                .take_pending_at_exact_boundary(&runner_run_id)
7571                .await
7572        });
7573        let prepared_a = prepare.await.expect("prepare task").expect("A parked");
7574
7575        actor_a.revoke_boundary_actor();
7576        let actor_b = SystemContextStateHandle::new(Default::default()).expect("actor B");
7577        let _run_b = actor_b
7578            .begin_boundary_run(run_id.clone())
7579            .expect("replacement opens independently");
7580        let error = prepared_a
7581            .into_stage_output(None)
7582            .commit()
7583            .expect_err("A cannot commit after replacement revoke");
7584        assert!(matches!(error, CoreBoundaryStageError::Stale { .. }));
7585        assert!(runner.await.expect("A runner task").is_err());
7586        assert!(actor_b.snapshot().seen().is_empty());
7587    }
7588
7589    #[tokio::test]
7590    async fn exact_boundary_hard_interrupt_and_concurrent_append_fail_closed() {
7591        let state = SystemContextStateHandle::new(Default::default()).expect("state");
7592        let run_id = RunId::new();
7593        let _run = state
7594            .begin_boundary_run(run_id.clone())
7595            .expect("open boundary");
7596        let prepare_state = state.clone();
7597        let prepare_run_id = run_id.clone();
7598        let prepare = tokio::spawn(async move {
7599            prepare_state
7600                .prepare_active_turn_boundary(
7601                    &prepare_run_id,
7602                    vec![exact_boundary_append("interrupt", "stale")],
7603                )
7604                .await
7605        });
7606        wait_for_exact_boundary_request(&state).await;
7607        let runner_state = state.clone();
7608        let runner_run_id = run_id.clone();
7609        let runner = tokio::spawn(async move {
7610            runner_state
7611                .take_pending_at_exact_boundary(&runner_run_id)
7612                .await
7613        });
7614        let prepared = prepare.await.expect("prepare task").expect("parked");
7615        let concurrent = state.stage_append_with_snapshot(
7616            &AppendSystemContextRequest {
7617                content: crate::lifecycle::CoreRenderable::text("concurrent"),
7618                source: Some("test:concurrent".to_string()),
7619                idempotency_key: Some("concurrent".to_string()),
7620                source_kind: SystemContextSource::Normal,
7621                peer_response_terminal: None,
7622            },
7623            SystemTime::now(),
7624        );
7625        assert!(
7626            concurrent.is_err(),
7627            "parked candidate must not be overwritten"
7628        );
7629        let discard_error = state
7630            .discard_unapplied_active_turn_pending()
7631            .expect_err("parked authority must reject cleanup, not report an empty success");
7632        assert!(matches!(
7633            discard_error,
7634            CoreBoundaryStageError::Fault { .. }
7635        ));
7636        let keyed_discard_error = state
7637            .discard_active_turn_pending_by_keys(&["interrupt".to_string()])
7638            .expect_err("parked authority must reject keyed rollback");
7639        assert!(matches!(
7640            keyed_discard_error,
7641            CoreBoundaryStageError::Fault { .. }
7642        ));
7643
7644        runner.abort();
7645        let _ = runner.await;
7646        let error = prepared
7647            .into_stage_output(None)
7648            .commit()
7649            .expect_err("hard-interrupted parked request cannot commit later");
7650        assert!(matches!(error, CoreBoundaryStageError::Stale { .. }));
7651        assert!(state.snapshot().seen().is_empty());
7652    }
7653
7654    #[tokio::test]
7655    async fn exact_boundary_run_exit_wakes_prepare_before_parking() {
7656        let state = SystemContextStateHandle::new(Default::default()).expect("state");
7657        let run_id = RunId::new();
7658        let run = state
7659            .begin_boundary_run(run_id.clone())
7660            .expect("open boundary");
7661        let prepare_state = state.clone();
7662        let prepare_run_id = run_id.clone();
7663        let prepare = tokio::spawn(async move {
7664            prepare_state
7665                .prepare_active_turn_boundary(
7666                    &prepare_run_id,
7667                    vec![exact_boundary_append("run-exit", "never parks")],
7668                )
7669                .await
7670        });
7671        wait_for_exact_boundary_request(&state).await;
7672        drop(run);
7673        let error = prepare
7674            .await
7675            .expect("prepare task")
7676            .expect_err("run exit must release preparer");
7677        assert!(matches!(
7678            error,
7679            CoreBoundaryStageError::Unavailable { .. } | CoreBoundaryStageError::Stale { .. }
7680        ));
7681    }
7682
7683    fn block_assistant_text(message: &BlockAssistantMessage) -> String {
7684        message
7685            .blocks
7686            .iter()
7687            .filter_map(|block| match block {
7688                AssistantBlock::Text { text, .. } => Some(text.as_str()),
7689                _ => None,
7690            })
7691            .collect()
7692    }
7693
7694    /// Reducer tests enter through the same proof shape as persistent
7695    /// ingestion: a metadata-only anchor is staged first, then a canonical
7696    /// blob-backed event is applied. Blob bytes are verified in
7697    /// PersistentSessionService tests; this helper tests only reducer ownership.
7698    fn append_staged_user_image(
7699        session: &mut Session,
7700        event: &RealtimeTranscriptEvent,
7701    ) -> RealtimeTranscriptApplyOutcome {
7702        let RealtimeTranscriptEvent::UserContentFinal {
7703            idempotency_key,
7704            item_id,
7705            previous_item_id,
7706            content_index,
7707            content,
7708        } = event
7709        else {
7710            panic!("test helper requires user content final")
7711        };
7712        let [ContentBlock::Image { media_type, data }] = content.as_slice() else {
7713            panic!("test helper requires exactly one image")
7714        };
7715        let media_type = crate::image_generation::MediaType::canonical_str(media_type);
7716        let blob_id = match data {
7717            crate::types::ImageData::Inline { data } => {
7718                crate::blob::content_blob_id(&media_type, data)
7719            }
7720            crate::types::ImageData::Blob { blob_id } => blob_id.clone(),
7721        };
7722        let pending = crate::PendingRealtimeUserContentBlob {
7723            idempotency_key: idempotency_key.clone(),
7724            item_id: item_id.clone(),
7725            previous_item_id: previous_item_id.clone(),
7726            content_index: *content_index,
7727            blob_id,
7728            media_type,
7729        };
7730        assert_eq!(
7731            session
7732                .stage_pending_realtime_user_content_blob(pending.clone())
7733                .expect("test pending anchor should stage"),
7734            crate::generated::session_document::RealtimeUserContentBlobStageDisposition::StageNew
7735        );
7736        session.append_realtime_transcript_event(pending.canonical_event())
7737    }
7738
7739    #[test]
7740    fn transcript_digest_is_content_addressed() {
7741        let base_time = crate::types::message_timestamp_now();
7742        let stamped = vec![
7743            Message::User(UserMessage::text("turn one".to_string())),
7744            Message::BlockAssistant(BlockAssistantMessage {
7745                blocks: vec![AssistantBlock::Text {
7746                    text: "answer one".to_string(),
7747                    meta: None,
7748                }],
7749                stop_reason: StopReason::EndTurn,
7750                identity: crate::types::TranscriptMessageIdentity {
7751                    interaction_id: None,
7752                    run_id: Some(crate::lifecycle::RunId::new()),
7753                    objective_id: None,
7754                },
7755                created_at: base_time,
7756            }),
7757        ];
7758        let mut restamped = stamped.clone();
7759        for message in &mut restamped {
7760            match message {
7761                Message::User(user) => {
7762                    user.created_at = base_time + chrono::Duration::hours(2);
7763                }
7764                Message::BlockAssistant(assistant) => {
7765                    assistant.identity = crate::types::TranscriptMessageIdentity {
7766                        interaction_id: None,
7767                        run_id: Some(crate::lifecycle::RunId::new()),
7768                        objective_id: None,
7769                    };
7770                    assistant.created_at = base_time + chrono::Duration::hours(2);
7771                }
7772                _ => {}
7773            }
7774        }
7775        assert_eq!(
7776            transcript_messages_digest(&stamped).expect("digest"),
7777            transcript_messages_digest(&restamped).expect("digest"),
7778            "bookkeeping variance must not fork the transcript revision"
7779        );
7780
7781        let mut content_changed = stamped.clone();
7782        if let Message::User(user) = &mut content_changed[0] {
7783            user.content = vec![ContentBlock::Text {
7784                text: "a different turn".to_string(),
7785            }];
7786        }
7787        assert_ne!(
7788            transcript_messages_digest(&stamped).expect("digest"),
7789            transcript_messages_digest(&content_changed).expect("digest"),
7790            "content changes must fork the transcript revision"
7791        );
7792    }
7793
7794    #[test]
7795    fn public_generic_rewrite_api_rejects_typed_compaction_semantic() {
7796        let mut session = Session::new();
7797        session.push(Message::User(UserMessage::text("old context")));
7798        let error = session
7799            .commit_transcript_rewrite(
7800                TranscriptRewriteSelection::typed_compaction_for_test(0, 1),
7801                vec![Message::User(UserMessage::compaction_summary("summary"))],
7802                TranscriptRewriteReason::new("anything"),
7803                None,
7804                None,
7805            )
7806            .unwrap_err();
7807        assert!(matches!(
7808            error,
7809            TranscriptEditError::InvalidTranscriptShape(_)
7810        ));
7811        assert_eq!(session.messages().len(), 1);
7812    }
7813
7814    #[test]
7815    fn compaction_witness_authorizes_only_the_exact_validated_rebuild() {
7816        let mut session = Session::new();
7817        session.push(Message::User(UserMessage::text("old context one")));
7818        session.push(Message::User(UserMessage::text("old context two")));
7819        let validated = vec![Message::User(UserMessage::compaction_summary(
7820            "validated summary",
7821        ))];
7822        let authority = crate::agent::compact::ValidatedCompactionRewrite::for_test(
7823            session.messages(),
7824            &validated,
7825        )
7826        .unwrap();
7827        let error = session
7828            .replace_messages_for_compaction_internal(
7829                vec![Message::User(UserMessage::compaction_summary(
7830                    "substituted summary",
7831                ))],
7832                &authority,
7833            )
7834            .unwrap_err();
7835        assert!(matches!(
7836            error,
7837            TranscriptEditError::InvalidTranscriptShape(_)
7838        ));
7839        assert_eq!(session.messages().len(), 2);
7840    }
7841
7842    #[test]
7843    fn semantic_marker_prevents_new_generic_compaction_forgery_and_heals_prior_data() {
7844        let mut session = Session::new();
7845        session.push(Message::User(UserMessage::text("old context one")));
7846        session.push(Message::User(UserMessage::text("old context two")));
7847        session
7848            .commit_transcript_rewrite(
7849                TranscriptRewriteSelection::MessageRange { start: 0, end: 2 },
7850                vec![Message::User(UserMessage::compaction_summary("summary"))],
7851                TranscriptRewriteReason::new("compaction"),
7852                None,
7853                None,
7854            )
7855            .unwrap();
7856        let session: Session =
7857            serde_json::from_value(serde_json::to_value(&session).unwrap()).unwrap();
7858        let history = session.transcript_history_state().unwrap().unwrap();
7859        assert_eq!(
7860            history.commits[0].selection.semantic(),
7861            TranscriptRewriteSemantic::Edit,
7862            "new generic rewrites retain an explicit typed edit marker after roundtrip"
7863        );
7864        assert_eq!(history.commits[0].reason.kind, "compaction");
7865
7866        let mut legacy = history;
7867        legacy.commits[0].selection = TranscriptRewriteSelection::MessageRange { start: 0, end: 2 };
7868        let legacy: TranscriptHistoryState =
7869            serde_json::from_value(serde_json::to_value(legacy).unwrap()).unwrap();
7870        assert_eq!(
7871            legacy.commits[0].selection.semantic(),
7872            TranscriptRewriteSemantic::Compaction,
7873            "marker-absent prior data derives compaction from typed transcript evidence"
7874        );
7875
7876        let mut ordinary = Session::new();
7877        ordinary.push(Message::User(UserMessage::text("ordinary old one")));
7878        ordinary.push(Message::User(UserMessage::text("ordinary old two")));
7879        ordinary
7880            .commit_transcript_rewrite(
7881                TranscriptRewriteSelection::MessageRange { start: 0, end: 2 },
7882                vec![Message::User(UserMessage::text("ordinary replacement"))],
7883                TranscriptRewriteReason::new("compaction"),
7884                None,
7885                None,
7886            )
7887            .unwrap();
7888        let history = ordinary.transcript_history_state().unwrap().unwrap();
7889        assert_eq!(
7890            history.commits[0].selection.semantic(),
7891            TranscriptRewriteSemantic::Edit,
7892            "free-form reason must not upgrade an ordinary edit"
7893        );
7894    }
7895
7896    fn legacy_rewrite_fixture() -> (TranscriptRewriteCommit, Vec<Message>, Vec<Message>) {
7897        let parent_messages = vec![
7898            Message::User(UserMessage::text("before rewrite".to_string())),
7899            Message::User(UserMessage::text("retained tail".to_string())),
7900        ];
7901        let revision_messages = vec![
7902            Message::User(UserMessage::text("after rewrite".to_string())),
7903            Message::User(UserMessage::text("retained tail".to_string())),
7904        ];
7905        // Compute the graph strings the way a pre-0.7.14 writer did:
7906        // bookkeeping-inclusive digests.
7907        let parent_revision =
7908            legacy_transcript_messages_digest(&parent_messages).expect("legacy parent digest");
7909        let revision =
7910            legacy_transcript_messages_digest(&revision_messages).expect("legacy revision digest");
7911        let commit = TranscriptRewriteCommit {
7912            parent_revision,
7913            revision,
7914            selection: TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
7915            original_span_digest: legacy_transcript_messages_digest(&parent_messages[0..1])
7916                .expect("legacy span digest"),
7917            replacement_digest: legacy_transcript_messages_digest(&revision_messages[0..1])
7918                .expect("legacy replacement digest"),
7919            messages_before: 2,
7920            messages_after: 2,
7921            reason: TranscriptRewriteReason::new("compaction"),
7922            actor: Some("legacy-test".to_string()),
7923            committed_at: SystemTime::now(),
7924        };
7925        (commit, parent_messages, revision_messages)
7926    }
7927
7928    #[test]
7929    fn legacy_transcript_history_state_heals_to_content_addressed_on_parse() {
7930        let (commit, parent_messages, revision_messages) = legacy_rewrite_fixture();
7931        let state = TranscriptHistoryState {
7932            head: commit.revision.clone(),
7933            commits: vec![commit.clone()],
7934            revisions: vec![
7935                TranscriptRevisionBody {
7936                    revision: commit.parent_revision.clone(),
7937                    parent_revision: None,
7938                    messages: parent_messages.clone(),
7939                    created_at: SystemTime::now(),
7940                },
7941                TranscriptRevisionBody {
7942                    revision: commit.revision.clone(),
7943                    parent_revision: Some(commit.parent_revision),
7944                    messages: revision_messages.clone(),
7945                    created_at: SystemTime::now(),
7946                },
7947            ],
7948        };
7949        let value = serde_json::to_value(&state).expect("serialize legacy state");
7950        let healed: TranscriptHistoryState =
7951            serde_json::from_value(value).expect("parse legacy state");
7952
7953        let content_parent =
7954            transcript_messages_digest(&parent_messages).expect("content parent digest");
7955        let content_revision =
7956            transcript_messages_digest(&revision_messages).expect("content revision digest");
7957        assert_eq!(healed.head, content_revision, "head must re-derive");
7958        assert_eq!(healed.commits[0].parent_revision, content_parent);
7959        assert_eq!(healed.commits[0].revision, content_revision);
7960        assert_eq!(healed.revisions[0].revision, content_parent);
7961        assert_eq!(healed.revisions[1].revision, content_revision);
7962        assert_eq!(
7963            healed.revisions[1].parent_revision.as_deref(),
7964            Some(content_parent.as_str())
7965        );
7966        validate_transcript_history_state(&healed).expect("healed graph must validate");
7967
7968        // A session materialized from the healed graph can extend the chain
7969        // with a current-format rewrite.
7970        let mut session = Session::new();
7971        session
7972            .apply_transcript_history_state(healed)
7973            .expect("apply healed graph");
7974        session
7975            .commit_transcript_rewrite(
7976                TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
7977                vec![Message::User(UserMessage::text(
7978                    "rewritten again".to_string(),
7979                ))],
7980                TranscriptRewriteReason::new("unit-test"),
7981                None,
7982                None,
7983            )
7984            .expect("extend healed graph with a new rewrite");
7985        session
7986            .validate_transcript_history_state()
7987            .expect("extended graph must validate");
7988    }
7989
7990    #[test]
7991    fn legacy_transcript_rewrite_record_heals_on_parse() {
7992        let (commit, parent_messages, revision_messages) = legacy_rewrite_fixture();
7993        let record_value = serde_json::json!({
7994            "commit": commit,
7995            "parent_body": TranscriptRevisionBody {
7996                revision: commit.parent_revision.clone(),
7997                parent_revision: None,
7998                messages: parent_messages,
7999                created_at: SystemTime::now(),
8000            },
8001            "revision_body": TranscriptRevisionBody {
8002                revision: commit.revision.clone(),
8003                parent_revision: Some(commit.parent_revision),
8004                messages: revision_messages.clone(),
8005                created_at: SystemTime::now(),
8006            },
8007        });
8008        let healed: TranscriptRewriteRecord =
8009            serde_json::from_value(record_value).expect("parse legacy record");
8010        assert_eq!(
8011            healed.commit.revision,
8012            transcript_messages_digest(&revision_messages).expect("content digest")
8013        );
8014        // The healed record passes the same validation `new` enforces.
8015        TranscriptRewriteRecord::new(healed.commit, healed.parent_body, healed.revision_body)
8016            .expect("healed record must validate");
8017    }
8018
8019    #[test]
8020    fn corrupt_transcript_history_strings_stay_untouched_and_fail_validation() {
8021        let (commit, parent_messages, _revision_messages) = legacy_rewrite_fixture();
8022        let bogus = "sha256:0000000000000000000000000000000000000000000000000000000000000000";
8023        let state = TranscriptHistoryState {
8024            head: bogus.to_string(),
8025            commits: Vec::new(),
8026            revisions: vec![TranscriptRevisionBody {
8027                revision: bogus.to_string(),
8028                parent_revision: None,
8029                messages: parent_messages,
8030                created_at: SystemTime::now(),
8031            }],
8032        };
8033        let _ = commit;
8034        let value = serde_json::to_value(&state).expect("serialize corrupt state");
8035        let parsed: TranscriptHistoryState =
8036            serde_json::from_value(value).expect("corrupt strings still parse");
8037        assert_eq!(
8038            parsed.head, bogus,
8039            "unverifiable strings must not be rewritten"
8040        );
8041        assert!(
8042            validate_transcript_history_state(&parsed).is_err(),
8043            "corrupt graph must keep failing validation"
8044        );
8045    }
8046
8047    /// K4 invariant: synthetic-notice refresh is ONE atomic transcript edit —
8048    /// after a refresh, at most the replacement notices of that kind exist
8049    /// (no stale notice survives beside a fresh one).
8050    #[test]
8051    fn replace_synthetic_notices_leaves_only_replacements_of_kind() {
8052        use crate::types::{SystemNoticeKind, SystemNoticeMessage};
8053
8054        let mut session = Session::new();
8055        session.push(Message::User(UserMessage::text("hello".to_string())));
8056        session.push(Message::SystemNotice(SystemNoticeMessage::new(
8057            SystemNoticeKind::McpPending,
8058            "stale one",
8059        )));
8060        session.push(Message::SystemNotice(SystemNoticeMessage::new(
8061            SystemNoticeKind::McpPending,
8062            "stale two",
8063        )));
8064        // A notice of another kind must be untouched.
8065        session.push(Message::SystemNotice(SystemNoticeMessage::new(
8066            SystemNoticeKind::BackgroundJob,
8067            "other-kind",
8068        )));
8069
8070        session
8071            .replace_synthetic_notices(
8072                SystemNoticeKind::McpPending,
8073                vec![Message::SystemNotice(SystemNoticeMessage::new(
8074                    SystemNoticeKind::McpPending,
8075                    "fresh",
8076                ))],
8077            )
8078            .expect("notice refresh succeeds");
8079
8080        let mcp_pending: Vec<&SystemNoticeMessage> = session
8081            .messages()
8082            .iter()
8083            .filter_map(|message| match message {
8084                Message::SystemNotice(notice) if notice.kind == SystemNoticeKind::McpPending => {
8085                    Some(notice)
8086                }
8087                _ => None,
8088            })
8089            .collect();
8090        assert_eq!(mcp_pending.len(), 1, "exactly one notice of the kind");
8091        assert_eq!(mcp_pending[0].body.as_deref(), Some("fresh"));
8092        assert!(
8093            session.messages().iter().any(|message| matches!(
8094                message,
8095                Message::SystemNotice(notice) if notice.kind == SystemNoticeKind::BackgroundJob
8096            )),
8097            "other-kind notices are untouched"
8098        );
8099
8100        // Empty replacements = pure strip.
8101        session
8102            .replace_synthetic_notices(SystemNoticeKind::McpPending, Vec::new())
8103            .expect("pure strip succeeds");
8104        assert!(
8105            !session.messages().iter().any(|message| matches!(
8106                message,
8107                Message::SystemNotice(notice) if notice.kind == SystemNoticeKind::McpPending
8108            )),
8109            "empty replacement clears the kind"
8110        );
8111    }
8112
8113    #[test]
8114    fn ordinary_appends_after_rewrite_coalesce_mechanical_revision_bodies() {
8115        let mut session = Session::new();
8116        for message in 0..133 {
8117            session.push(Message::User(UserMessage::text(format!(
8118                "seed message {message}"
8119            ))));
8120        }
8121        let parent = session.transcript_revision().expect("parent revision");
8122        session
8123            .commit_transcript_rewrite(
8124                TranscriptRewriteSelection::MessageRange {
8125                    start: 132,
8126                    end: 133,
8127                },
8128                vec![Message::User(UserMessage::text("edited question"))],
8129                TranscriptRewriteReason::new("unit-test-edit"),
8130                Some("unit-test".to_string()),
8131                Some(parent),
8132            )
8133            .expect("rewrite should commit");
8134
8135        for turn in 0..762 {
8136            session.push(Message::User(UserMessage::text(format!("turn {turn}"))));
8137        }
8138
8139        let state = session
8140            .transcript_history_state()
8141            .expect("history state should decode")
8142            .expect("rewrite should create history state");
8143        assert_eq!(session.messages().len(), 895);
8144        assert_eq!(state.commits.len(), 1, "ordinary appends are not rewrites");
8145        assert_eq!(
8146            state.revisions.len(),
8147            3,
8148            "one real rewrite retains its two audited endpoints plus one live head"
8149        );
8150        let retained_message_entries = state
8151            .revisions
8152            .iter()
8153            .map(|body| body.messages.len())
8154            .sum::<usize>();
8155        assert!(retained_message_entries <= 3 * session.messages().len());
8156
8157        let live_bytes = serde_json::to_vec(session.messages())
8158            .expect("live transcript should serialize")
8159            .len();
8160        let snapshot_bytes = serde_json::to_vec(&session)
8161            .expect("session snapshot should serialize")
8162            .len();
8163        assert!(
8164            snapshot_bytes <= live_bytes.saturating_mul(5).saturating_add(64 * 1024),
8165            "snapshot must remain linear in the live transcript: {snapshot_bytes} bytes for {live_bytes} live bytes"
8166        );
8167    }
8168
8169    #[test]
8170    fn repeated_synthetic_notice_refreshes_do_not_mint_rewrite_commits() {
8171        use crate::types::{SystemNoticeKind, SystemNoticeMessage};
8172
8173        let mut session = Session::new();
8174        session.push(Message::User(UserMessage::text("before".to_string())));
8175        session
8176            .commit_transcript_rewrite(
8177                TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
8178                vec![Message::User(UserMessage::text("after".to_string()))],
8179                TranscriptRewriteReason::new("unit-test-edit"),
8180                Some("unit-test".to_string()),
8181                None,
8182            )
8183            .expect("seed rewrite");
8184
8185        for refresh in 0..64 {
8186            session
8187                .replace_synthetic_notices(
8188                    SystemNoticeKind::McpPending,
8189                    vec![Message::SystemNotice(SystemNoticeMessage::new(
8190                        SystemNoticeKind::McpPending,
8191                        format!("refresh {refresh}"),
8192                    ))],
8193                )
8194                .expect("mechanical refresh");
8195        }
8196
8197        let state = session
8198            .transcript_history_state()
8199            .expect("history state")
8200            .expect("seed rewrite history");
8201        assert_eq!(state.commits.len(), 1);
8202        assert_eq!(session.transcript_rewrite_generation().unwrap(), 1);
8203        assert_eq!(state.revisions.len(), 3);
8204    }
8205
8206    #[test]
8207    fn legacy_append_head_chain_compacts_during_session_restore() {
8208        let mut session = Session::new();
8209        session.push(Message::User(UserMessage::text("seed".to_string())));
8210        session
8211            .commit_transcript_rewrite(
8212                TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
8213                vec![Message::User(UserMessage::text(
8214                    "rewritten seed".to_string(),
8215                ))],
8216                TranscriptRewriteReason::new("unit-test-edit"),
8217                Some("unit-test".to_string()),
8218                None,
8219            )
8220            .expect("seed rewrite");
8221
8222        let mut legacy = session
8223            .transcript_history_state()
8224            .expect("history state")
8225            .expect("seed history");
8226        let mut messages = session.messages().to_vec();
8227        let mut previous_head = legacy.head.clone();
8228        for append in 0..32 {
8229            messages.push(Message::User(UserMessage::text(format!(
8230                "legacy append {append}"
8231            ))));
8232            let revision = transcript_messages_digest(&messages).expect("revision digest");
8233            legacy.revisions.push(TranscriptRevisionBody {
8234                revision: revision.clone(),
8235                parent_revision: Some(previous_head),
8236                messages: messages.clone(),
8237                created_at: SystemTime::now(),
8238            });
8239            previous_head = revision;
8240        }
8241        legacy.head = previous_head;
8242        assert_eq!(legacy.revisions.len(), 34, "fixture matches old shape");
8243
8244        let mut envelope = serde_json::to_value(&session).expect("base envelope");
8245        envelope["messages"] = serde_json::to_value(&messages).expect("legacy live messages");
8246        envelope["metadata"][SESSION_TRANSCRIPT_HISTORY_STATE_KEY] =
8247            serde_json::to_value(&legacy).expect("legacy unbounded history");
8248        for body in envelope["metadata"][SESSION_TRANSCRIPT_HISTORY_STATE_KEY]["revisions"]
8249            .as_array_mut()
8250            .expect("legacy revisions")
8251        {
8252            body.as_object_mut()
8253                .expect("legacy revision body")
8254                .remove("parent_revision");
8255        }
8256        let raw = serde_json::to_vec(&envelope).expect("raw legacy bytes");
8257
8258        let restored: Session = serde_json::from_slice(&raw).expect("legacy restore");
8259        let compact = restored
8260            .transcript_history_state()
8261            .expect("compacted state")
8262            .expect("history retained");
8263        assert_eq!(compact.commits, legacy.commits);
8264        assert_eq!(compact.revisions.len(), 3);
8265        validate_transcript_history_state(&compact).expect("compacted history remains valid");
8266        let repaired = serde_json::to_vec(&restored).expect("repaired snapshot");
8267        assert!(
8268            repaired.len() * 4 < raw.len(),
8269            "repair should shed old bodies"
8270        );
8271    }
8272
8273    #[test]
8274    fn snapshot_compaction_does_not_launder_corrupt_old_body() {
8275        let mut session = Session::new();
8276        session.push(Message::User(UserMessage::text("seed".to_string())));
8277        session
8278            .commit_transcript_rewrite(
8279                TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
8280                vec![Message::User(UserMessage::text("rewritten".to_string()))],
8281                TranscriptRewriteReason::new("unit-test-edit"),
8282                Some("unit-test".to_string()),
8283                None,
8284            )
8285            .expect("seed rewrite");
8286        let mut state = session
8287            .transcript_history_state()
8288            .expect("state")
8289            .expect("history");
8290        state.revisions.push(TranscriptRevisionBody {
8291            revision: "sha256:corrupt-old-body".to_string(),
8292            parent_revision: Some(state.head.clone()),
8293            messages: vec![Message::User(UserMessage::text("tampered".to_string()))],
8294            created_at: SystemTime::now(),
8295        });
8296        session.set_metadata_unchecked_for_test(
8297            SESSION_TRANSCRIPT_HISTORY_STATE_KEY,
8298            serde_json::to_value(state).expect("corrupt history value"),
8299        );
8300
8301        assert!(
8302            serde_json::to_vec(&session).is_err(),
8303            "serialization must fail before pruning a corrupt old body"
8304        );
8305    }
8306
8307    #[test]
8308    fn unchecked_valid_history_is_validated_and_compacted_at_snapshot_boundary() {
8309        let mut session = Session::new();
8310        session.push(Message::User(UserMessage::text("seed".to_string())));
8311        session
8312            .commit_transcript_rewrite(
8313                TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
8314                vec![Message::User(UserMessage::text("rewritten".to_string()))],
8315                TranscriptRewriteReason::new("unit-test-edit"),
8316                Some("unit-test".to_string()),
8317                None,
8318            )
8319            .expect("seed rewrite");
8320        let mut state = session
8321            .transcript_history_state()
8322            .expect("state")
8323            .expect("history");
8324        let mut messages = session.messages().to_vec();
8325        let mut parent = state.head.clone();
8326        for index in 0..8 {
8327            messages.push(Message::User(UserMessage::text(format!(
8328                "legacy append {index}"
8329            ))));
8330            let revision = transcript_messages_digest(&messages).expect("revision digest");
8331            state.revisions.push(TranscriptRevisionBody {
8332                revision: revision.clone(),
8333                parent_revision: Some(parent),
8334                messages: messages.clone(),
8335                created_at: SystemTime::now(),
8336            });
8337            parent = revision;
8338        }
8339        state.head = parent;
8340        session.messages = Arc::new(messages);
8341        session.set_metadata_unchecked_for_test(
8342            SESSION_TRANSCRIPT_HISTORY_STATE_KEY,
8343            serde_json::to_value(state).expect("uncompacted history"),
8344        );
8345        assert_eq!(
8346            session.transcript_history_metadata_validation,
8347            TranscriptHistoryMetadataValidation::RequiresValidation
8348        );
8349
8350        let snapshot = serde_json::to_vec(&session)
8351            .expect("valid unchecked history should serialize after validation");
8352        let snapshot: serde_json::Value = serde_json::from_slice(&snapshot).expect("snapshot JSON");
8353        let compact: TranscriptHistoryState = serde_json::from_value(
8354            snapshot["metadata"][SESSION_TRANSCRIPT_HISTORY_STATE_KEY].clone(),
8355        )
8356        .expect("compacted history");
8357
8358        assert_eq!(
8359            compact.revisions.len(),
8360            3,
8361            "snapshot boundary should retain two audited endpoints plus the live head"
8362        );
8363        validate_transcript_history_state(&compact).expect("compacted history remains valid");
8364    }
8365
8366    #[test]
8367    fn transcript_history_rejects_stale_branch_after_digest_recurrence() {
8368        let mut restored = Session::new();
8369        restored.push(Message::User(UserMessage::text("A".to_string())));
8370        restored
8371            .commit_transcript_rewrite(
8372                TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
8373                vec![Message::User(UserMessage::text("B".to_string()))],
8374                TranscriptRewriteReason::new("to-b"),
8375                Some("unit-test".to_string()),
8376                None,
8377            )
8378            .expect("A to B");
8379        let mut stale_branch = restored.clone();
8380        restored
8381            .commit_transcript_rewrite(
8382                TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
8383                vec![Message::User(UserMessage::text("A".to_string()))],
8384                TranscriptRewriteReason::new("restore-a"),
8385                Some("unit-test".to_string()),
8386                None,
8387            )
8388            .expect("B back to A");
8389        stale_branch
8390            .commit_transcript_rewrite(
8391                TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
8392                vec![Message::User(UserMessage::text("C".to_string()))],
8393                TranscriptRewriteReason::new("stale-b-to-c"),
8394                Some("unit-test".to_string()),
8395                None,
8396            )
8397            .expect("stale B to C is locally valid");
8398
8399        let stale_state = stale_branch
8400            .transcript_history_state()
8401            .expect("stale state")
8402            .expect("stale history");
8403        let stale_commit = stale_state.commits.last().expect("stale commit").clone();
8404        let stale_body = stale_state
8405            .revisions
8406            .iter()
8407            .find(|body| body.revision == stale_commit.revision)
8408            .expect("stale revision body")
8409            .clone();
8410        let mut forged = restored
8411            .transcript_history_state()
8412            .expect("restored state")
8413            .expect("restored history");
8414        forged.commits.push(stale_commit);
8415        forged.revisions.push(stale_body);
8416        forged.head = forged
8417            .commits
8418            .last()
8419            .expect("forged commit")
8420            .revision
8421            .clone();
8422
8423        assert!(
8424            validate_transcript_history_state(&forged).is_err(),
8425            "an old B<-A body edge cannot authorize stale B->C after B->A restored A"
8426        );
8427    }
8428
8429    #[test]
8430    fn transcript_history_rejects_orphan_head_parent_cycle() {
8431        let mut session = Session::new();
8432        session.push(Message::User(UserMessage::text("P".to_string())));
8433        session
8434            .commit_transcript_rewrite(
8435                TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
8436                vec![Message::User(UserMessage::text("Q".to_string()))],
8437                TranscriptRewriteReason::new("valid"),
8438                Some("unit-test".to_string()),
8439                None,
8440            )
8441            .expect("valid seed rewrite");
8442        let mut state = session
8443            .transcript_history_state()
8444            .expect("state")
8445            .expect("history");
8446        let x_messages = vec![Message::User(UserMessage::text("X".to_string()))];
8447        let y_messages = vec![Message::User(UserMessage::text("Y".to_string()))];
8448        let x = transcript_messages_digest(&x_messages).expect("X digest");
8449        let y = transcript_messages_digest(&y_messages).expect("Y digest");
8450        state.revisions.push(TranscriptRevisionBody {
8451            revision: x.clone(),
8452            parent_revision: Some(y.clone()),
8453            messages: x_messages,
8454            created_at: SystemTime::now(),
8455        });
8456        state.revisions.push(TranscriptRevisionBody {
8457            revision: y,
8458            parent_revision: Some(x.clone()),
8459            messages: y_messages,
8460            created_at: SystemTime::now(),
8461        });
8462        state.head = x;
8463        session.set_metadata_unchecked_for_test(
8464            SESSION_TRANSCRIPT_HISTORY_STATE_KEY,
8465            serde_json::to_value(state).expect("cyclic state"),
8466        );
8467
8468        assert!(
8469            serde_json::to_vec(&session).is_err(),
8470            "cyclic orphan head lineage must fail instead of looping"
8471        );
8472    }
8473
8474    #[test]
8475    fn mechanical_append_can_recur_to_an_audited_digest_without_mutating_its_body() {
8476        let a = Message::User(UserMessage::text("A".to_string()));
8477        let b = Message::User(UserMessage::text("B".to_string()));
8478        let mut session = Session::new();
8479        session.push(Message::User(UserMessage::text("X".to_string())));
8480        let first = session
8481            .commit_transcript_rewrite(
8482                TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
8483                vec![a.clone(), b.clone()],
8484                TranscriptRewriteReason::new("to-a-b"),
8485                Some("unit-test".to_string()),
8486                None,
8487            )
8488            .expect("X to [A,B]");
8489        let h_parent = session
8490            .transcript_revision_body(&first.revision)
8491            .expect("H body")
8492            .expect("H retained")
8493            .parent_revision;
8494        session
8495            .commit_transcript_rewrite(
8496                TranscriptRewriteSelection::MessageRange { start: 0, end: 2 },
8497                vec![a],
8498                TranscriptRewriteReason::new("to-a"),
8499                Some("unit-test".to_string()),
8500                None,
8501            )
8502            .expect("[A,B] to [A]");
8503
8504        session.push(b);
8505
8506        let state = session
8507            .transcript_history_state()
8508            .expect("state")
8509            .expect("history");
8510        assert_eq!(state.head, first.revision);
8511        assert_eq!(session.transcript_revision().unwrap(), first.revision);
8512        assert_eq!(
8513            state
8514                .revisions
8515                .iter()
8516                .find(|body| body.revision == first.revision)
8517                .expect("recurred H body")
8518                .parent_revision,
8519            h_parent,
8520            "reusing an audited digest must not rewrite its occurrence metadata"
8521        );
8522        validate_transcript_history_state(&state).expect("recurred mechanical head is valid");
8523    }
8524
8525    /// K4 invariant (fail-closed): an invalid replacement is rejected with a
8526    /// typed fault BEFORE any strip happens — the transcript is unchanged, so
8527    /// a fault can never strand a half-refreshed notice state.
8528    #[test]
8529    fn replace_synthetic_notices_rejects_mismatched_kind_without_mutation() {
8530        use crate::types::{SystemNoticeKind, SystemNoticeMessage};
8531
8532        let mut session = Session::new();
8533        session.push(Message::SystemNotice(SystemNoticeMessage::new(
8534            SystemNoticeKind::McpPending,
8535            "stale",
8536        )));
8537        let before = session.messages().to_vec();
8538
8539        let err = session
8540            .replace_synthetic_notices(
8541                SystemNoticeKind::McpPending,
8542                vec![Message::User(UserMessage::text("not a notice".to_string()))],
8543            )
8544            .expect_err("mismatched replacement must fail typed");
8545        assert!(
8546            matches!(err, TranscriptEditError::InvalidTranscriptShape(_)),
8547            "expected InvalidTranscriptShape, got {err:?}"
8548        );
8549        assert_eq!(
8550            session.messages(),
8551            before.as_slice(),
8552            "fault must leave the transcript unchanged (no partial strip)"
8553        );
8554    }
8555
8556    #[test]
8557    fn replace_synthetic_notices_rejects_malformed_history_atomically() {
8558        use crate::types::{SystemNoticeKind, SystemNoticeMessage};
8559
8560        let mut session = Session::new();
8561        session.push(Message::User(UserMessage::text("before".to_string())));
8562        session
8563            .commit_transcript_rewrite(
8564                TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
8565                vec![Message::User(UserMessage::text("after".to_string()))],
8566                TranscriptRewriteReason::new("unit-test-edit"),
8567                Some("unit-test".to_string()),
8568                None,
8569            )
8570            .expect("seed rewrite");
8571        session.push(Message::SystemNotice(SystemNoticeMessage::new(
8572            SystemNoticeKind::McpPending,
8573            "stale",
8574        )));
8575        let mut state = session
8576            .transcript_history_state()
8577            .expect("state")
8578            .expect("history");
8579        state.revisions[0].messages[0] = Message::User(UserMessage::text("tampered".to_string()));
8580        session.set_metadata_unchecked_for_test(
8581            SESSION_TRANSCRIPT_HISTORY_STATE_KEY,
8582            serde_json::to_value(state).expect("corrupt state"),
8583        );
8584        let before_messages = session.messages.clone();
8585        let before_metadata = session.metadata.clone();
8586        let before_updated_at = session.updated_at;
8587
8588        assert!(
8589            session
8590                .replace_synthetic_notices(SystemNoticeKind::McpPending, Vec::new())
8591                .is_err()
8592        );
8593        assert_eq!(session.messages, before_messages);
8594        assert_eq!(session.metadata, before_metadata);
8595        assert_eq!(session.updated_at, before_updated_at);
8596    }
8597
8598    #[test]
8599    fn replace_synthetic_notices_rejects_durable_notice_kinds() {
8600        use crate::types::SystemNoticeKind;
8601
8602        let mut session = Session::new();
8603        let before = session.messages().to_vec();
8604        assert!(
8605            session
8606                .replace_synthetic_notices(SystemNoticeKind::Comms, Vec::new())
8607                .is_err()
8608        );
8609        assert_eq!(session.messages(), before);
8610    }
8611
8612    #[test]
8613    fn replace_synthetic_notices_preserves_persisted_mcp_pending_notice() {
8614        use crate::types::{SystemNoticeBlock, SystemNoticeKind, SystemNoticeMessage};
8615
8616        let mut session = Session::new();
8617        session.push(Message::SystemNotice(SystemNoticeMessage::with_block(
8618            SystemNoticeKind::McpPending,
8619            Some("persisted pending fact".to_string()),
8620            SystemNoticeBlock::Mcp {
8621                server_id: Some("server".to_string()),
8622                operation: None,
8623                phase: None,
8624                persisted: true,
8625                detail: None,
8626                pending_sources: Vec::new(),
8627            },
8628        )));
8629        let before = session.messages().to_vec();
8630
8631        session
8632            .replace_synthetic_notices(SystemNoticeKind::McpPending, Vec::new())
8633            .expect("synthetic refresh must coexist with a durable notice of the same kind");
8634        assert_eq!(session.messages(), before);
8635    }
8636
8637    #[test]
8638    fn replace_synthetic_notices_replaces_projection_beside_persisted_mcp_fact() {
8639        use crate::types::{SystemNoticeBlock, SystemNoticeKind, SystemNoticeMessage};
8640
8641        let durable = Message::SystemNotice(SystemNoticeMessage::with_block(
8642            SystemNoticeKind::McpPending,
8643            Some("persisted pending fact".to_string()),
8644            SystemNoticeBlock::Mcp {
8645                server_id: Some("server".to_string()),
8646                operation: None,
8647                phase: None,
8648                persisted: true,
8649                detail: None,
8650                pending_sources: Vec::new(),
8651            },
8652        ));
8653        let stale = Message::SystemNotice(SystemNoticeMessage::new(
8654            SystemNoticeKind::McpPending,
8655            "stale synthetic projection",
8656        ));
8657        let fresh = Message::SystemNotice(SystemNoticeMessage::new(
8658            SystemNoticeKind::McpPending,
8659            "fresh synthetic projection",
8660        ));
8661        let mut session = Session::new();
8662        session.push(durable.clone());
8663        session.push(stale);
8664
8665        session
8666            .replace_synthetic_notices(SystemNoticeKind::McpPending, vec![fresh.clone()])
8667            .expect("synthetic refresh beside durable fact");
8668
8669        assert_eq!(session.messages(), &[durable, fresh]);
8670    }
8671
8672    #[test]
8673    fn transcript_rewrite_preserves_full_assistant_block_trace() {
8674        let mut session = Session::new();
8675        session.push(Message::User(UserMessage::text(
8676            "run the trace".to_string(),
8677        )));
8678        session.push(Message::BlockAssistant(BlockAssistantMessage::new(
8679            vec![AssistantBlock::Text {
8680                text: "original assistant trace".to_string(),
8681                meta: None,
8682            }],
8683            StopReason::EndTurn,
8684        )));
8685
8686        let parent_revision = session.transcript_revision().expect("parent revision");
8687        let replacement = vec![
8688            Message::BlockAssistant(BlockAssistantMessage::new(
8689                vec![
8690                    AssistantBlock::Text {
8691                        text: "compacted assistant trace".to_string(),
8692                        meta: None,
8693                    },
8694                    AssistantBlock::ToolUse {
8695                        id: "toolu_trace".to_string(),
8696                        name: "trace_probe".to_string(),
8697                        args: serde_json::value::RawValue::from_string(
8698                            r#"{"path":"N-3"}"#.to_string(),
8699                        )
8700                        .expect("valid tool args"),
8701                        meta: None,
8702                    },
8703                ],
8704                StopReason::ToolUse,
8705            )),
8706            Message::tool_results(vec![ToolResult::new(
8707                "toolu_trace".to_string(),
8708                "trace complete".to_string(),
8709                false,
8710            )]),
8711        ];
8712
8713        let commit = session
8714            .commit_transcript_rewrite(
8715                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
8716                replacement,
8717                TranscriptRewriteReason::new("compaction"),
8718                Some("unit-test".to_string()),
8719                Some(parent_revision.clone()),
8720            )
8721            .expect("rewrite should commit");
8722
8723        assert_eq!(commit.parent_revision, parent_revision);
8724        let current = session
8725            .transcript_revision_messages(&commit.revision)
8726            .expect("history state should decode")
8727            .expect("current revision should be retained");
8728        let Message::BlockAssistant(assistant) = &current[1] else {
8729            panic!("replacement should remain a block assistant message");
8730        };
8731        assert!(assistant.blocks.iter().any(|block| matches!(
8732            block,
8733            AssistantBlock::ToolUse { name, args, .. }
8734                if name == "trace_probe" && args.get().contains("\"N-3\"")
8735        )));
8736
8737        let parent = session
8738            .transcript_revision_messages(&parent_revision)
8739            .expect("history state should decode")
8740            .expect("parent revision should remain retained");
8741        assert!(matches!(
8742            &parent[1],
8743            Message::BlockAssistant(assistant)
8744                if block_assistant_text(assistant).contains("original assistant trace")
8745        ));
8746    }
8747
8748    #[test]
8749    fn transcript_rewrite_rejects_trailing_block_assistant_tool_call() {
8750        let mut session = Session::new();
8751        session.push(Message::User(UserMessage::text("question".to_string())));
8752        session.push(Message::BlockAssistant(BlockAssistantMessage {
8753            blocks: vec![AssistantBlock::Text {
8754                text: "plain answer".to_string(),
8755                meta: None,
8756            }],
8757            stop_reason: StopReason::EndTurn,
8758            identity: crate::types::TranscriptMessageIdentity::default(),
8759            created_at: crate::types::message_timestamp_now(),
8760        }));
8761        let parent_revision = session.transcript_revision().expect("parent revision");
8762
8763        let err = session
8764            .commit_transcript_rewrite(
8765                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
8766                vec![Message::BlockAssistant(BlockAssistantMessage::new(
8767                    vec![AssistantBlock::ToolUse {
8768                        id: "toolu_1".to_string(),
8769                        name: "lookup".to_string(),
8770                        args: serde_json::value::RawValue::from_string("{}".to_string())
8771                            .expect("valid args"),
8772                        meta: None,
8773                    }],
8774                    StopReason::ToolUse,
8775                ))],
8776                TranscriptRewriteReason::new("compaction"),
8777                Some("unit-test".to_string()),
8778                Some(parent_revision),
8779            )
8780            .expect_err("rewrite should reject trailing unresolved block-assistant tool call");
8781        assert!(matches!(
8782            err,
8783            TranscriptEditError::InvalidTranscriptShape(_)
8784        ));
8785    }
8786
8787    #[test]
8788    fn transcript_rewrite_rejects_no_op_self_edge() {
8789        let mut session = Session::new();
8790        session.push(Message::User(UserMessage::text(
8791            "keep this exact transcript".to_string(),
8792        )));
8793        session.push(Message::BlockAssistant(BlockAssistantMessage {
8794            blocks: vec![AssistantBlock::Text {
8795                text: "unchanged".to_string(),
8796                meta: None,
8797            }],
8798            stop_reason: StopReason::EndTurn,
8799            identity: crate::types::TranscriptMessageIdentity::default(),
8800            created_at: crate::types::message_timestamp_now(),
8801        }));
8802
8803        let parent_revision = session.transcript_revision().expect("parent revision");
8804        let err = session
8805            .commit_transcript_rewrite(
8806                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
8807                vec![session.messages()[1].clone()],
8808                TranscriptRewriteReason::new("retry"),
8809                Some("unit-test".to_string()),
8810                Some(parent_revision.clone()),
8811            )
8812            .expect_err("same-content rewrite should not emit a self-edge commit");
8813
8814        assert!(matches!(
8815            err,
8816            TranscriptEditError::NoOpRewrite { revision } if revision == parent_revision
8817        ));
8818        assert!(
8819            session
8820                .transcript_history_state()
8821                .expect("history state should decode")
8822                .is_none()
8823        );
8824    }
8825
8826    #[test]
8827    fn transcript_rewrite_run_boundary_guard_accepts_rewrite_then_append() {
8828        let mut original = Session::new();
8829        original.push(Message::User(UserMessage::text("question".to_string())));
8830        original.push(Message::BlockAssistant(BlockAssistantMessage {
8831            blocks: vec![AssistantBlock::Text {
8832                text: "verbose answer".to_string(),
8833                meta: None,
8834            }],
8835            stop_reason: StopReason::EndTurn,
8836            identity: crate::types::TranscriptMessageIdentity::default(),
8837            created_at: crate::types::message_timestamp_now(),
8838        }));
8839
8840        let parent_revision = original.transcript_revision().expect("parent revision");
8841        let mut incoming = original.clone();
8842        incoming
8843            .commit_transcript_rewrite(
8844                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
8845                vec![Message::BlockAssistant(BlockAssistantMessage {
8846                    blocks: vec![AssistantBlock::Text {
8847                        text: "compact answer".to_string(),
8848                        meta: None,
8849                    }],
8850                    stop_reason: StopReason::EndTurn,
8851                    identity: crate::types::TranscriptMessageIdentity::default(),
8852                    created_at: crate::types::message_timestamp_now(),
8853                })],
8854                TranscriptRewriteReason::new("compaction"),
8855                Some("unit-test".to_string()),
8856                Some(parent_revision),
8857            )
8858            .expect("rewrite should commit");
8859        incoming.push(Message::User(UserMessage::text("follow-up".to_string())));
8860        incoming.push(Message::BlockAssistant(BlockAssistantMessage {
8861            blocks: vec![AssistantBlock::Text {
8862                text: "follow-up answer".to_string(),
8863                meta: None,
8864            }],
8865            stop_reason: StopReason::EndTurn,
8866            identity: crate::types::TranscriptMessageIdentity::default(),
8867            created_at: crate::types::message_timestamp_now(),
8868        }));
8869
8870        crate::session_store::run_boundary_snapshot_save_guard(&incoming, Some(&original))
8871            .expect("rewrite plus appended turn should be a valid run-boundary commit");
8872    }
8873
8874    #[test]
8875    fn transcript_rewrite_rejects_orphaned_tool_results() {
8876        let mut session = Session::new();
8877        session.push(Message::User(UserMessage::text("use a tool".to_string())));
8878        session.push(Message::BlockAssistant(BlockAssistantMessage::new(
8879            vec![AssistantBlock::ToolUse {
8880                id: "toolu_1".to_string(),
8881                name: "lookup".to_string(),
8882                args: serde_json::value::RawValue::from_string("{}".to_string())
8883                    .expect("valid args"),
8884                meta: None,
8885            }],
8886            StopReason::ToolUse,
8887        )));
8888        session.push(Message::tool_results(vec![ToolResult::new(
8889            "toolu_1".to_string(),
8890            "done".to_string(),
8891            false,
8892        )]));
8893        let parent_revision = session.transcript_revision().expect("parent revision");
8894
8895        let err = session
8896            .commit_transcript_rewrite(
8897                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
8898                vec![Message::BlockAssistant(BlockAssistantMessage {
8899                    blocks: vec![AssistantBlock::Text {
8900                        text: "no tool after all".to_string(),
8901                        meta: None,
8902                    }],
8903                    stop_reason: StopReason::EndTurn,
8904                    identity: crate::types::TranscriptMessageIdentity::default(),
8905                    created_at: crate::types::message_timestamp_now(),
8906                })],
8907                TranscriptRewriteReason::new("compaction"),
8908                Some("unit-test".to_string()),
8909                Some(parent_revision),
8910            )
8911            .expect_err("rewrite should reject stranded tool results");
8912        assert!(matches!(
8913            err,
8914            TranscriptEditError::InvalidTranscriptShape(_)
8915        ));
8916    }
8917
8918    #[test]
8919    fn transcript_rewrite_rejects_trailing_assistant_tool_call() {
8920        let mut session = Session::new();
8921        session.push(Message::User(UserMessage::text("question".to_string())));
8922        session.push(Message::BlockAssistant(BlockAssistantMessage {
8923            blocks: vec![AssistantBlock::Text {
8924                text: "plain answer".to_string(),
8925                meta: None,
8926            }],
8927            stop_reason: StopReason::EndTurn,
8928            identity: crate::types::TranscriptMessageIdentity::default(),
8929            created_at: crate::types::message_timestamp_now(),
8930        }));
8931        let parent_revision = session.transcript_revision().expect("parent revision");
8932
8933        let err = session
8934            .commit_transcript_rewrite(
8935                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
8936                vec![Message::BlockAssistant(BlockAssistantMessage {
8937                    blocks: vec![AssistantBlock::ToolUse {
8938                        id: "toolu_1".to_string(),
8939                        name: "lookup".to_string(),
8940                        args: serde_json::value::RawValue::from_string("{}".to_string())
8941                            .expect("valid args"),
8942                        meta: None,
8943                    }],
8944                    stop_reason: StopReason::ToolUse,
8945                    identity: crate::types::TranscriptMessageIdentity::default(),
8946                    created_at: crate::types::message_timestamp_now(),
8947                })],
8948                TranscriptRewriteReason::new("compaction"),
8949                Some("unit-test".to_string()),
8950                Some(parent_revision),
8951            )
8952            .expect_err("rewrite should reject trailing unresolved tool call");
8953        assert!(matches!(
8954            err,
8955            TranscriptEditError::InvalidTranscriptShape(_)
8956        ));
8957    }
8958
8959    #[test]
8960    fn transcript_rewrite_rejects_duplicate_tool_results() {
8961        let mut session = Session::new();
8962        session.push(Message::User(UserMessage::text("use a tool".to_string())));
8963        session.push(Message::BlockAssistant(BlockAssistantMessage {
8964            blocks: vec![AssistantBlock::Text {
8965                text: "plain answer".to_string(),
8966                meta: None,
8967            }],
8968            stop_reason: StopReason::EndTurn,
8969            identity: crate::types::TranscriptMessageIdentity::default(),
8970            created_at: crate::types::message_timestamp_now(),
8971        }));
8972        let parent_revision = session.transcript_revision().expect("parent revision");
8973
8974        let err = session
8975            .commit_transcript_rewrite(
8976                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
8977                vec![
8978                    Message::BlockAssistant(BlockAssistantMessage::new(
8979                        vec![AssistantBlock::ToolUse {
8980                            id: "toolu_1".to_string(),
8981                            name: "lookup".to_string(),
8982                            args: serde_json::value::RawValue::from_string("{}".to_string())
8983                                .expect("valid args"),
8984                            meta: None,
8985                        }],
8986                        StopReason::ToolUse,
8987                    )),
8988                    Message::tool_results(vec![
8989                        ToolResult::new("toolu_1".to_string(), "one".to_string(), false),
8990                        ToolResult::new("toolu_1".to_string(), "two".to_string(), false),
8991                    ]),
8992                ],
8993                TranscriptRewriteReason::new("compaction"),
8994                Some("unit-test".to_string()),
8995                Some(parent_revision),
8996            )
8997            .expect_err("rewrite should reject duplicate tool results");
8998        assert!(matches!(
8999            err,
9000            TranscriptEditError::InvalidTranscriptShape(_)
9001        ));
9002    }
9003
9004    #[test]
9005    fn transcript_rewrite_record_rejects_prefix_or_suffix_tampering() {
9006        let mut session = Session::new();
9007        session.push(Message::System(SystemMessage::new("keep prefix")));
9008        session.push(Message::BlockAssistant(BlockAssistantMessage {
9009            blocks: vec![AssistantBlock::Text {
9010                text: "verbose answer".to_string(),
9011                meta: None,
9012            }],
9013            stop_reason: StopReason::EndTurn,
9014            identity: crate::types::TranscriptMessageIdentity::default(),
9015            created_at: crate::types::message_timestamp_now(),
9016        }));
9017        session.push(Message::User(UserMessage::text("keep suffix".to_string())));
9018
9019        let parent_revision = session.transcript_revision().expect("parent revision");
9020        let commit = session
9021            .commit_transcript_rewrite(
9022                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
9023                vec![Message::BlockAssistant(BlockAssistantMessage {
9024                    blocks: vec![AssistantBlock::Text {
9025                        text: "compact answer".to_string(),
9026                        meta: None,
9027                    }],
9028                    stop_reason: StopReason::EndTurn,
9029                    identity: crate::types::TranscriptMessageIdentity::default(),
9030                    created_at: crate::types::message_timestamp_now(),
9031                })],
9032                TranscriptRewriteReason::new("compaction"),
9033                Some("unit-test".to_string()),
9034                Some(parent_revision),
9035            )
9036            .expect("rewrite should commit");
9037        let state = session
9038            .transcript_history_state()
9039            .expect("history state should decode")
9040            .expect("history state should exist");
9041        let parent_body = state
9042            .revisions
9043            .iter()
9044            .find(|body| body.revision == commit.parent_revision)
9045            .expect("parent body retained")
9046            .clone();
9047        let revision_body = state
9048            .revisions
9049            .iter()
9050            .find(|body| body.revision == commit.revision)
9051            .expect("revision body retained")
9052            .clone();
9053
9054        let mut forged_body = revision_body;
9055        forged_body.messages[0] = Message::System(SystemMessage::new("tampered prefix"));
9056        forged_body.revision =
9057            transcript_messages_digest(&forged_body.messages).expect("forged digest");
9058        let mut forged_commit = commit;
9059        forged_commit.revision = forged_body.revision.clone();
9060        let err = TranscriptRewriteRecord::new(forged_commit, parent_body, forged_body)
9061            .expect_err("record validation must reject changes outside selected span");
9062        assert!(
9063            err.to_string().contains("before the selected span"),
9064            "unexpected error: {err}"
9065        );
9066    }
9067
9068    #[test]
9069    fn transcript_rewrite_replay_allows_normal_turn_revisions_between_rewrites() {
9070        let mut session = Session::new();
9071        session.push(Message::User(UserMessage::text("first".to_string())));
9072        session.push(Message::BlockAssistant(BlockAssistantMessage {
9073            blocks: vec![AssistantBlock::Text {
9074                text: "verbose first answer".to_string(),
9075                meta: None,
9076            }],
9077            stop_reason: StopReason::EndTurn,
9078            identity: crate::types::TranscriptMessageIdentity::default(),
9079            created_at: crate::types::message_timestamp_now(),
9080        }));
9081
9082        let first_parent = session.transcript_revision().expect("first parent");
9083        let first_commit = session
9084            .commit_transcript_rewrite(
9085                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
9086                vec![Message::BlockAssistant(BlockAssistantMessage {
9087                    blocks: vec![AssistantBlock::Text {
9088                        text: "compact first answer".to_string(),
9089                        meta: None,
9090                    }],
9091                    stop_reason: StopReason::EndTurn,
9092                    identity: crate::types::TranscriptMessageIdentity::default(),
9093                    created_at: crate::types::message_timestamp_now(),
9094                })],
9095                TranscriptRewriteReason::new("compaction"),
9096                Some("unit-test".to_string()),
9097                Some(first_parent),
9098            )
9099            .expect("first rewrite");
9100
9101        session.push(Message::User(UserMessage::text("normal turn".to_string())));
9102        session.push(Message::BlockAssistant(BlockAssistantMessage {
9103            blocks: vec![AssistantBlock::Text {
9104                text: "verbose second answer".to_string(),
9105                meta: None,
9106            }],
9107            stop_reason: StopReason::EndTurn,
9108            identity: crate::types::TranscriptMessageIdentity::default(),
9109            created_at: crate::types::message_timestamp_now(),
9110        }));
9111        let bridge_parent = session
9112            .transcript_revision()
9113            .expect("normal turn should advance transcript head");
9114        assert_ne!(bridge_parent, first_commit.revision);
9115        validate_transcript_history_state(
9116            &session
9117                .transcript_history_state()
9118                .expect("history state should decode")
9119                .expect("history state should exist"),
9120        )
9121        .expect("normal turn head may legitimately differ from last rewrite commit");
9122
9123        let second_commit = session
9124            .commit_transcript_rewrite(
9125                TranscriptRewriteSelection::MessageRange { start: 3, end: 4 },
9126                vec![Message::BlockAssistant(BlockAssistantMessage {
9127                    blocks: vec![AssistantBlock::Text {
9128                        text: "compact second answer".to_string(),
9129                        meta: None,
9130                    }],
9131                    stop_reason: StopReason::EndTurn,
9132                    identity: crate::types::TranscriptMessageIdentity::default(),
9133                    created_at: crate::types::message_timestamp_now(),
9134                })],
9135                TranscriptRewriteReason::new("compaction"),
9136                Some("unit-test".to_string()),
9137                Some(bridge_parent.clone()),
9138            )
9139            .expect("second rewrite");
9140
9141        let state = session
9142            .transcript_history_state()
9143            .expect("history state should decode")
9144            .expect("history state should exist");
9145        let records = state.commits.iter().map(|commit| {
9146            let parent_body = state
9147                .revisions
9148                .iter()
9149                .find(|body| body.revision == commit.parent_revision)
9150                .expect("parent body retained")
9151                .clone();
9152            let revision_body = state
9153                .revisions
9154                .iter()
9155                .find(|body| body.revision == commit.revision)
9156                .expect("revision body retained")
9157                .clone();
9158            TranscriptRewriteRecord::new(commit.clone(), parent_body, revision_body)
9159                .expect("record should validate")
9160        });
9161
9162        let replayed = TranscriptHistoryState::from_rewrite_records(records)
9163            .expect("rewrite replay should accept normal-turn bridge revisions")
9164            .expect("rewrite records should exist");
9165        assert_eq!(replayed.head, second_commit.revision);
9166        assert!(
9167            replayed
9168                .revisions
9169                .iter()
9170                .any(|body| body.revision == bridge_parent)
9171        );
9172    }
9173
9174    #[test]
9175    fn transcript_rewrite_replay_rejects_branched_rewrite_records() {
9176        let mut base = Session::new();
9177        base.push(Message::User(UserMessage::text("question".to_string())));
9178        base.push(Message::BlockAssistant(BlockAssistantMessage {
9179            blocks: vec![AssistantBlock::Text {
9180                text: "verbose answer".to_string(),
9181                meta: None,
9182            }],
9183            stop_reason: StopReason::EndTurn,
9184            identity: crate::types::TranscriptMessageIdentity::default(),
9185            created_at: crate::types::message_timestamp_now(),
9186        }));
9187        let parent = base.transcript_revision().expect("parent revision");
9188
9189        let mut first = base.clone();
9190        let first_commit = first
9191            .commit_transcript_rewrite(
9192                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
9193                vec![Message::BlockAssistant(BlockAssistantMessage {
9194                    blocks: vec![AssistantBlock::Text {
9195                        text: "first compact answer".to_string(),
9196                        meta: None,
9197                    }],
9198                    stop_reason: StopReason::EndTurn,
9199                    identity: crate::types::TranscriptMessageIdentity::default(),
9200                    created_at: crate::types::message_timestamp_now(),
9201                })],
9202                TranscriptRewriteReason::new("compaction"),
9203                Some("unit-test".to_string()),
9204                Some(parent.clone()),
9205            )
9206            .expect("first rewrite");
9207        let first_state = first
9208            .transcript_history_state()
9209            .expect("first state decodes")
9210            .expect("first state exists");
9211
9212        let mut second = base;
9213        let second_commit = second
9214            .commit_transcript_rewrite(
9215                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
9216                vec![Message::BlockAssistant(BlockAssistantMessage {
9217                    blocks: vec![AssistantBlock::Text {
9218                        text: "second compact answer".to_string(),
9219                        meta: None,
9220                    }],
9221                    stop_reason: StopReason::EndTurn,
9222                    identity: crate::types::TranscriptMessageIdentity::default(),
9223                    created_at: crate::types::message_timestamp_now(),
9224                })],
9225                TranscriptRewriteReason::new("compaction"),
9226                Some("unit-test".to_string()),
9227                Some(parent),
9228            )
9229            .expect("second rewrite");
9230        let second_state = second
9231            .transcript_history_state()
9232            .expect("second state decodes")
9233            .expect("second state exists");
9234
9235        let record = |state: &TranscriptHistoryState, commit: &TranscriptRewriteCommit| {
9236            let parent_body = state
9237                .revisions
9238                .iter()
9239                .find(|body| body.revision == commit.parent_revision)
9240                .expect("parent body retained")
9241                .clone();
9242            let revision_body = state
9243                .revisions
9244                .iter()
9245                .find(|body| body.revision == commit.revision)
9246                .expect("revision body retained")
9247                .clone();
9248            TranscriptRewriteRecord::new(commit.clone(), parent_body, revision_body)
9249                .expect("record should validate")
9250        };
9251
9252        let err = TranscriptHistoryState::from_rewrite_records(vec![
9253            record(&first_state, &first_commit),
9254            record(&second_state, &second_commit),
9255        ])
9256        .expect_err("branched rewrite records must not replay as a linear source history");
9257        assert!(
9258            err.to_string().contains("does not extend transcript head"),
9259            "unexpected error: {err}"
9260        );
9261    }
9262
9263    #[test]
9264    fn internal_message_rewrites_refresh_transcript_history_head() {
9265        let mut session = Session::new();
9266        session.push(Message::User(UserMessage::text("question".to_string())));
9267        session.push(Message::BlockAssistant(BlockAssistantMessage {
9268            blocks: vec![AssistantBlock::Text {
9269                text: "verbose answer".to_string(),
9270                meta: None,
9271            }],
9272            stop_reason: StopReason::EndTurn,
9273            identity: crate::types::TranscriptMessageIdentity::default(),
9274            created_at: crate::types::message_timestamp_now(),
9275        }));
9276
9277        let parent = session.transcript_revision().expect("parent revision");
9278        session
9279            .commit_transcript_rewrite(
9280                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
9281                vec![Message::BlockAssistant(BlockAssistantMessage {
9282                    blocks: vec![AssistantBlock::Text {
9283                        text: "compact answer".to_string(),
9284                        meta: None,
9285                    }],
9286                    stop_reason: StopReason::EndTurn,
9287                    identity: crate::types::TranscriptMessageIdentity::default(),
9288                    created_at: crate::types::message_timestamp_now(),
9289                })],
9290                TranscriptRewriteReason::new("compaction"),
9291                Some("unit-test".to_string()),
9292                Some(parent),
9293            )
9294            .expect("rewrite should commit");
9295
9296        session.push(Message::User(UserMessage::text(
9297            "notice-bearing turn".to_string(),
9298        )));
9299        let retained = session
9300            .messages()
9301            .iter()
9302            .filter(|message| {
9303                !matches!(
9304                    message,
9305                    Message::User(user)
9306                        if user.content.iter().any(|block| matches!(
9307                            block,
9308                            ContentBlock::Text { text } if text.contains("notice-bearing")
9309                        ))
9310                )
9311            })
9312            .cloned()
9313            .collect();
9314        session
9315            .replace_messages_internal(
9316                retained,
9317                TranscriptRewriteReason::new("synthetic_notice_cleanup"),
9318            )
9319            .expect("retain should commit internal rewrite");
9320        let retained_digest =
9321            transcript_messages_digest(session.messages()).expect("retained digest");
9322        assert_eq!(
9323            session.transcript_revision().expect("retained head"),
9324            retained_digest
9325        );
9326
9327        session
9328            .replace_messages_internal(
9329                vec![
9330                    Message::User(UserMessage::text("compacted question".to_string())),
9331                    Message::BlockAssistant(BlockAssistantMessage {
9332                        blocks: vec![AssistantBlock::Text {
9333                            text: "compacted answer".to_string(),
9334                            meta: None,
9335                        }],
9336                        stop_reason: StopReason::EndTurn,
9337                        identity: crate::types::TranscriptMessageIdentity::default(),
9338                        created_at: crate::types::message_timestamp_now(),
9339                    }),
9340                ],
9341                TranscriptRewriteReason::new("compaction"),
9342            )
9343            .expect("replace should commit internal rewrite");
9344        let replaced_digest =
9345            transcript_messages_digest(session.messages()).expect("replaced digest");
9346        assert_eq!(
9347            session.transcript_revision().expect("replaced head"),
9348            replaced_digest
9349        );
9350        let state = session
9351            .transcript_history_state()
9352            .expect("history state should decode")
9353            .expect("history state should exist");
9354        assert!(
9355            state
9356                .revisions
9357                .iter()
9358                .any(|body| body.revision == replaced_digest)
9359        );
9360        validate_transcript_history_state(&state).expect("history state remains valid");
9361    }
9362
9363    #[test]
9364    fn set_system_prompt_refreshes_transcript_history_head_after_rewrite() {
9365        let mut session = Session::new();
9366        session.push(Message::User(UserMessage::text("question".to_string())));
9367        session.push(Message::BlockAssistant(BlockAssistantMessage {
9368            blocks: vec![AssistantBlock::Text {
9369                text: "verbose answer".to_string(),
9370                meta: None,
9371            }],
9372            stop_reason: StopReason::EndTurn,
9373            identity: crate::types::TranscriptMessageIdentity::default(),
9374            created_at: crate::types::message_timestamp_now(),
9375        }));
9376
9377        let parent = session.transcript_revision().expect("parent revision");
9378        let rewrite = session
9379            .commit_transcript_rewrite(
9380                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
9381                vec![Message::BlockAssistant(BlockAssistantMessage {
9382                    blocks: vec![AssistantBlock::Text {
9383                        text: "compact answer".to_string(),
9384                        meta: None,
9385                    }],
9386                    stop_reason: StopReason::EndTurn,
9387                    identity: crate::types::TranscriptMessageIdentity::default(),
9388                    created_at: crate::types::message_timestamp_now(),
9389                })],
9390                TranscriptRewriteReason::new("compaction"),
9391                Some("unit-test".to_string()),
9392                Some(parent),
9393            )
9394            .expect("rewrite should commit");
9395
9396        session.set_system_prompt("durable system prompt".to_string());
9397
9398        let head = session
9399            .transcript_revision()
9400            .expect("system prompt should refresh transcript head");
9401        assert_ne!(head, rewrite.revision);
9402        assert_eq!(
9403            head,
9404            transcript_messages_digest(session.messages()).expect("current digest")
9405        );
9406        let head_messages = session
9407            .transcript_revision_messages(&head)
9408            .expect("history state should decode")
9409            .expect("refreshed head body should be retained");
9410        assert_eq!(
9411            serde_json::to_value(&head_messages).expect("head serializes"),
9412            serde_json::to_value(session.messages()).expect("session serializes")
9413        );
9414        validate_transcript_history_state(
9415            &session
9416                .transcript_history_state()
9417                .expect("history state should decode")
9418                .expect("history state should exist"),
9419        )
9420        .expect("history state remains valid after system prompt update");
9421    }
9422
9423    #[test]
9424    fn apply_transcript_history_state_uses_latest_commit_time_for_restored_head() {
9425        let mut session = Session::new();
9426        session.push(Message::User(UserMessage::text("question".to_string())));
9427        session.push(Message::BlockAssistant(BlockAssistantMessage {
9428            blocks: vec![AssistantBlock::Text {
9429                text: "verbose answer".to_string(),
9430                meta: None,
9431            }],
9432            stop_reason: StopReason::EndTurn,
9433            identity: crate::types::TranscriptMessageIdentity::default(),
9434            created_at: crate::types::message_timestamp_now(),
9435        }));
9436        let original_messages = session.messages().to_vec();
9437        let parent = session.transcript_revision().expect("parent revision");
9438        let compact = session
9439            .commit_transcript_rewrite(
9440                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
9441                vec![Message::BlockAssistant(BlockAssistantMessage {
9442                    blocks: vec![AssistantBlock::Text {
9443                        text: "compact answer".to_string(),
9444                        meta: None,
9445                    }],
9446                    stop_reason: StopReason::EndTurn,
9447                    identity: crate::types::TranscriptMessageIdentity::default(),
9448                    created_at: crate::types::message_timestamp_now(),
9449                })],
9450                TranscriptRewriteReason::new("compaction"),
9451                Some("unit-test".to_string()),
9452                Some(parent.clone()),
9453            )
9454            .expect("rewrite should commit");
9455
9456        std::thread::sleep(std::time::Duration::from_millis(2));
9457        let restore = session
9458            .commit_transcript_rewrite(
9459                TranscriptRewriteSelection::MessageRange {
9460                    start: 0,
9461                    end: session.messages().len(),
9462                },
9463                original_messages.clone(),
9464                TranscriptRewriteReason::new("restore"),
9465                Some("unit-test".to_string()),
9466                Some(compact.revision),
9467            )
9468            .expect("restore should commit");
9469        assert_eq!(restore.revision, parent);
9470
9471        let state = session
9472            .transcript_history_state()
9473            .expect("history state should decode")
9474            .expect("history state should exist");
9475        let restored_body_created_at = state
9476            .revisions
9477            .iter()
9478            .find(|body| body.revision == restore.revision)
9479            .expect("restored body should be retained")
9480            .created_at;
9481        assert!(
9482            restored_body_created_at < restore.committed_at,
9483            "test requires restore commit to be newer than retained body"
9484        );
9485
9486        let mut replayed = Session::new();
9487        replayed
9488            .apply_transcript_history_state(state)
9489            .expect("replay should materialize restored head");
9490        assert_eq!(
9491            serde_json::to_value(replayed.messages()).expect("replayed serializes"),
9492            serde_json::to_value(&original_messages).expect("original serializes")
9493        );
9494        assert_eq!(replayed.updated_at(), restore.committed_at);
9495    }
9496
9497    #[test]
9498    fn test_session_new() {
9499        let session = Session::new();
9500        assert_eq!(session.version(), SESSION_VERSION);
9501        assert!(session.messages().is_empty());
9502        assert!(session.created_at() <= session.updated_at());
9503    }
9504
9505    #[test]
9506    fn llm_identity_model_override_switches_to_catalog_provider() {
9507        let registry = crate::ModelRegistry::from_config(
9508            &crate::Config::default(),
9509            *crate::model_profile::test_catalog::TEST_CATALOG,
9510        )
9511        .unwrap();
9512        let current = SessionLlmIdentity {
9513            model: "test-anthropic-default".to_string(),
9514            provider: Provider::Anthropic,
9515            self_hosted_server_id: None,
9516            provider_params: None,
9517            auth_binding: Some(crate::AuthBindingRef {
9518                realm: crate::RealmId::parse("tenant_a").unwrap(),
9519                binding: crate::BindingId::parse("anthropic_default").unwrap(),
9520                profile: None,
9521                origin: crate::BindingOrigin::Configured,
9522            }),
9523        };
9524
9525        let resolved = resolve_session_llm_identity_override(
9526            &current,
9527            &registry,
9528            SessionLlmIdentityOverride {
9529                model: Some("test-openai-default"),
9530                provider: None,
9531                self_hosted_server_id: None,
9532                provider_params: None,
9533                auth_binding: None,
9534            },
9535        )
9536        .unwrap();
9537
9538        assert_eq!(resolved.model, "test-openai-default");
9539        assert_eq!(resolved.provider, Provider::OpenAI);
9540        assert!(
9541            resolved.auth_binding.is_none(),
9542            "provider switches must not inherit a binding from the previous provider"
9543        );
9544    }
9545
9546    #[test]
9547    fn llm_identity_model_override_keeps_uncatalogued_model_on_current_provider() {
9548        let registry = crate::ModelRegistry::from_config(
9549            &crate::Config::default(),
9550            *crate::model_profile::test_catalog::TEST_CATALOG,
9551        )
9552        .unwrap();
9553        let current = SessionLlmIdentity {
9554            model: "custom-model".to_string(),
9555            provider: Provider::Anthropic,
9556            self_hosted_server_id: None,
9557            provider_params: None,
9558            auth_binding: None,
9559        };
9560
9561        let resolved = resolve_session_llm_identity_override(
9562            &current,
9563            &registry,
9564            SessionLlmIdentityOverride {
9565                model: Some("uncatalogued-custom-model"),
9566                provider: None,
9567                self_hosted_server_id: None,
9568                provider_params: None,
9569                auth_binding: None,
9570            },
9571        )
9572        .unwrap();
9573
9574        assert_eq!(resolved.model, "uncatalogued-custom-model");
9575        assert_eq!(resolved.provider, Provider::Anthropic);
9576    }
9577
9578    fn self_hosted_registry_with_shared_remote_model() -> crate::ModelRegistry {
9579        use crate::config::{
9580            SelfHostedApiStyle, SelfHostedModelConfig, SelfHostedServerConfig, SelfHostedTransport,
9581        };
9582        use crate::model_profile::catalog::ModelTier;
9583
9584        let mut config = crate::Config::default();
9585        for server_id in ["local-a", "local-b"] {
9586            config.self_hosted.servers.insert(
9587                server_id.to_string(),
9588                SelfHostedServerConfig {
9589                    transport: SelfHostedTransport::OpenAiCompatible,
9590                    base_url: format!("http://{server_id}.test"),
9591                    api_style: SelfHostedApiStyle::Responses,
9592                },
9593            );
9594            config.self_hosted.models.insert(
9595                format!("shared-local-{server_id}"),
9596                SelfHostedModelConfig {
9597                    server: server_id.to_string(),
9598                    remote_model: "shared-local-model".to_string(),
9599                    display_name: "Shared local model".to_string(),
9600                    family: "shared-local".to_string(),
9601                    tier: ModelTier::Supported,
9602                    ..Default::default()
9603                },
9604            );
9605        }
9606        config.self_hosted.default_model = Some("shared-local-local-a".to_string());
9607        crate::ModelRegistry::from_config(
9608            &config,
9609            *crate::model_profile::test_catalog::TEST_CATALOG,
9610        )
9611        .expect("shared local registry")
9612    }
9613
9614    #[test]
9615    fn llm_identity_override_preserves_exact_self_hosted_server_route() {
9616        let registry = self_hosted_registry_with_shared_remote_model();
9617        let current = SessionLlmIdentity {
9618            model: "shared-local-local-a".to_string(),
9619            provider: Provider::SelfHosted,
9620            self_hosted_server_id: Some("local-a".to_string()),
9621            provider_params: None,
9622            auth_binding: None,
9623        };
9624
9625        let resolved = resolve_session_llm_identity_override(
9626            &current,
9627            &registry,
9628            SessionLlmIdentityOverride {
9629                model: Some("shared-local-local-b"),
9630                provider: Some(Provider::SelfHosted),
9631                self_hosted_server_id: Some("local-b"),
9632                provider_params: None,
9633                auth_binding: None,
9634            },
9635        )
9636        .expect("exact configured local route should resolve");
9637
9638        assert_eq!(resolved.model, "shared-local-local-b");
9639        assert_eq!(resolved.provider, Provider::SelfHosted);
9640        assert_eq!(resolved.self_hosted_server_id.as_deref(), Some("local-b"));
9641    }
9642
9643    #[test]
9644    fn llm_identity_override_rejects_self_hosted_server_model_mismatch() {
9645        let registry = self_hosted_registry_with_shared_remote_model();
9646        let current = SessionLlmIdentity {
9647            model: "shared-local-local-a".to_string(),
9648            provider: Provider::SelfHosted,
9649            self_hosted_server_id: Some("local-a".to_string()),
9650            provider_params: None,
9651            auth_binding: None,
9652        };
9653
9654        let error = resolve_session_llm_identity_override(
9655            &current,
9656            &registry,
9657            SessionLlmIdentityOverride {
9658                model: Some("shared-local-local-b"),
9659                provider: Some(Provider::SelfHosted),
9660                self_hosted_server_id: Some("local-a"),
9661                provider_params: None,
9662                auth_binding: None,
9663            },
9664        )
9665        .expect_err("server id must match the requested model alias route");
9666
9667        assert!(matches!(
9668            error,
9669            SessionLlmIdentityOverrideError::SelfHostedServerMismatch {
9670                requested,
9671                configured,
9672                ..
9673            } if requested == "local-a" && configured == "local-b"
9674        ));
9675    }
9676
9677    #[test]
9678    fn realtime_transcript_append_is_idempotent_by_provider_item_and_delta_id() {
9679        let mut session = Session::new();
9680
9681        let user = RealtimeTranscriptEvent::UserTranscriptFinal {
9682            item_id: "item_user".to_string(),
9683            previous_item_id: None,
9684            content_index: 0,
9685            text: "hello".to_string(),
9686        };
9687        assert!(
9688            !session
9689                .append_realtime_transcript_event(user.clone())
9690                .is_inert()
9691        );
9692        assert!(session.append_realtime_transcript_event(user).is_inert());
9693
9694        let delta = RealtimeTranscriptEvent::AssistantTextDelta {
9695            response_id: "resp_assistant".to_string(),
9696            delta_id: "evt_delta_1".to_string(),
9697            item_id: "item_assistant".to_string(),
9698            previous_item_id: Some("item_user".to_string()),
9699            content_index: 0,
9700            delta: "hi".to_string(),
9701        };
9702        assert!(
9703            session
9704                .append_realtime_transcript_event(delta.clone())
9705                .is_inert()
9706        );
9707        assert!(session.append_realtime_transcript_event(delta).is_inert());
9708
9709        let terminal = RealtimeTranscriptEvent::AssistantTurnCompleted {
9710            response_id: "resp_assistant".to_string(),
9711            stop_reason: StopReason::EndTurn,
9712            usage: Usage::default(),
9713        };
9714        assert!(
9715            !session
9716                .append_realtime_transcript_event(terminal.clone())
9717                .is_inert()
9718        );
9719        assert!(
9720            session
9721                .append_realtime_transcript_event(terminal)
9722                .is_inert()
9723        );
9724
9725        assert_eq!(session.messages().len(), 2);
9726        assert!(matches!(
9727            &session.messages()[0],
9728            Message::User(user) if user.text_content() == "hello"
9729        ));
9730        assert!(matches!(
9731            &session.messages()[1],
9732            Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "hi"
9733        ));
9734    }
9735
9736    #[test]
9737    fn realtime_user_image_materializes_once_and_unblocks_causal_assistant() {
9738        let mut session = Session::new();
9739        let image_data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB".to_string();
9740        let image = RealtimeTranscriptEvent::UserContentFinal {
9741            idempotency_key: "image-request-1".to_string(),
9742            item_id: "item_image".to_string(),
9743            previous_item_id: None,
9744            content_index: 0,
9745            content: vec![ContentBlock::Image {
9746                media_type: "image/png".to_string(),
9747                data: crate::types::ImageData::Inline {
9748                    data: image_data.clone(),
9749                },
9750            }],
9751        };
9752
9753        assert!(
9754            !append_staged_user_image(&mut session, &image).is_inert(),
9755            "first image final must materialize canonical user content"
9756        );
9757        let replay = session
9758            .preflight_realtime_user_content_event(&image)
9759            .expect("exact retry should preflight as committed");
9760        assert!(matches!(
9761            replay,
9762            crate::RealtimeUserContentApplyOutcome::AlreadyCommitted(_)
9763        ));
9764
9765        let staged_state = session
9766            .metadata
9767            .get(SESSION_REALTIME_TRANSCRIPT_STATE_KEY)
9768            .expect("realtime state must be persisted");
9769        assert!(
9770            !staged_state.to_string().contains(&image_data),
9771            "materialized image bytes must not remain duplicated in transcript metadata"
9772        );
9773
9774        assert!(
9775            session
9776                .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
9777                    response_id: "resp_image".to_string(),
9778                    delta_id: "delta_image".to_string(),
9779                    item_id: "item_assistant".to_string(),
9780                    previous_item_id: Some("item_image".to_string()),
9781                    content_index: 0,
9782                    delta: "I see red.".to_string(),
9783                })
9784                .is_inert()
9785        );
9786        assert!(
9787            !session
9788                .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
9789                    response_id: "resp_image".to_string(),
9790                    stop_reason: StopReason::EndTurn,
9791                    usage: Usage::default(),
9792                },)
9793                .is_inert(),
9794            "materialized image predecessor must unblock the assistant response"
9795        );
9796
9797        assert_eq!(session.messages().len(), 2);
9798        assert!(matches!(
9799            &session.messages()[0],
9800            Message::User(user)
9801                if matches!(
9802                    user.content.as_slice(),
9803                    [ContentBlock::Image {
9804                        media_type,
9805                        data: crate::types::ImageData::Blob { blob_id },
9806                    }] if media_type == "image/png"
9807                        && blob_id == &crate::blob::content_blob_id("image/png", &image_data)
9808                )
9809        ));
9810        assert!(matches!(
9811            &session.messages()[1],
9812            Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "I see red."
9813        ));
9814    }
9815
9816    #[test]
9817    fn realtime_user_image_identity_is_durable_canonical_and_conflict_safe() {
9818        let mut session = Session::new();
9819        let data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB".to_string();
9820        let initial = RealtimeTranscriptEvent::UserContentFinal {
9821            idempotency_key: "stable-image-key".to_string(),
9822            item_id: "canonical-image-item".to_string(),
9823            previous_item_id: None,
9824            content_index: 0,
9825            content: vec![ContentBlock::Image {
9826                media_type: " image/PNG; charset=binary ".to_string(),
9827                data: crate::types::ImageData::Inline { data: data.clone() },
9828            }],
9829        };
9830        let committed = append_staged_user_image(&mut session, &initial);
9831        let Some(crate::RealtimeUserContentApplyOutcome::Committed(identity)) =
9832            committed.user_content
9833        else {
9834            panic!("first image must commit its durable identity");
9835        };
9836        assert_eq!(identity.item_id, "canonical-image-item");
9837        assert_eq!(identity.media_type, "image/png");
9838
9839        let encoded = serde_json::to_string(&session).expect("session should serialize");
9840        let restored: Session =
9841            serde_json::from_str(&encoded).expect("committed identity should restore");
9842
9843        let replay_event = RealtimeTranscriptEvent::UserContentFinal {
9844            idempotency_key: "stable-image-key".to_string(),
9845            item_id: "ignored-retry-item".to_string(),
9846            previous_item_id: None,
9847            content_index: 0,
9848            content: vec![ContentBlock::Image {
9849                media_type: "image/png".to_string(),
9850                data: crate::types::ImageData::Inline { data: data.clone() },
9851            }],
9852        };
9853        let replay = restored
9854            .preflight_realtime_user_content_event(&replay_event)
9855            .expect("exact retry should preflight");
9856        assert!(matches!(
9857            replay,
9858            crate::RealtimeUserContentApplyOutcome::AlreadyCommitted(
9859                crate::RealtimeUserContentIdentity { ref item_id, .. }
9860            ) if item_id == "canonical-image-item"
9861        ));
9862
9863        let conflict = restored
9864            .preflight_realtime_user_content_event(&RealtimeTranscriptEvent::UserContentFinal {
9865                idempotency_key: "stable-image-key".to_string(),
9866                item_id: "conflicting-item".to_string(),
9867                previous_item_id: None,
9868                content_index: 0,
9869                content: vec![ContentBlock::Image {
9870                    media_type: "image/png".to_string(),
9871                    data: crate::types::ImageData::Inline {
9872                        data: "different-payload".to_string(),
9873                    },
9874                }],
9875            })
9876            .expect("conflicting retry should preflight");
9877        assert!(matches!(
9878            conflict,
9879            crate::RealtimeUserContentApplyOutcome::RejectedConflict { .. }
9880        ));
9881
9882        let item_collision = restored
9883            .preflight_realtime_user_content_event(&RealtimeTranscriptEvent::UserContentFinal {
9884                idempotency_key: "another-key".to_string(),
9885                item_id: "canonical-image-item".to_string(),
9886                previous_item_id: None,
9887                content_index: 0,
9888                content: vec![ContentBlock::Image {
9889                    media_type: "image/png".to_string(),
9890                    data: crate::types::ImageData::Inline { data },
9891                }],
9892            })
9893            .expect("item collision should preflight");
9894        assert!(matches!(
9895            item_collision,
9896            crate::RealtimeUserContentApplyOutcome::RejectedConflict { .. }
9897        ));
9898        assert_eq!(restored.messages().len(), 1);
9899        serde_json::to_string(&restored).expect("rejections must not corrupt durable state");
9900    }
9901
9902    #[test]
9903    fn realtime_user_image_reducer_never_receipts_without_pending_blob_proof() {
9904        for data in [
9905            crate::types::ImageData::Inline {
9906                data: "iVBORw0KGgo=".to_string(),
9907            },
9908            crate::types::ImageData::Blob {
9909                blob_id: crate::blob::content_blob_id("image/png", "iVBORw0KGgo="),
9910            },
9911        ] {
9912            let mut session = Session::new();
9913            let outcome = session.append_realtime_transcript_event(
9914                RealtimeTranscriptEvent::UserContentFinal {
9915                    idempotency_key: "unstaged-image-key".to_string(),
9916                    item_id: "unstaged-image-item".to_string(),
9917                    previous_item_id: None,
9918                    content_index: 0,
9919                    content: vec![ContentBlock::Image {
9920                        media_type: "image/png".to_string(),
9921                        data,
9922                    }],
9923                },
9924            );
9925            assert!(matches!(
9926                outcome.user_content,
9927                Some(crate::RealtimeUserContentApplyOutcome::RejectedInvalidIdentity { .. })
9928            ));
9929            assert!(session.messages().is_empty());
9930            assert!(session.realtime_user_content_identities().is_empty());
9931        }
9932    }
9933
9934    #[test]
9935    fn realtime_user_image_pending_slot_is_generated_bounded_and_recovery_typed() {
9936        use crate::generated::session_document::{
9937            RealtimeUserContentBlobRecoveryDisposition, RealtimeUserContentBlobStageDisposition,
9938        };
9939        let mut session = Session::new();
9940        let pending = crate::PendingRealtimeUserContentBlob {
9941            idempotency_key: "pending-key-a".to_string(),
9942            item_id: "pending-item-a".to_string(),
9943            previous_item_id: None,
9944            content_index: 0,
9945            blob_id: crate::blob::content_blob_id("image/png", "iVBORw0KGgo="),
9946            media_type: "image/png".to_string(),
9947        };
9948        let different = crate::PendingRealtimeUserContentBlob {
9949            idempotency_key: "pending-key-b".to_string(),
9950            item_id: "pending-item-b".to_string(),
9951            previous_item_id: None,
9952            content_index: 0,
9953            blob_id: crate::blob::content_blob_id("image/png", "iVBORw0KGgoB"),
9954            media_type: "image/png".to_string(),
9955        };
9956        assert_eq!(
9957            session
9958                .stage_pending_realtime_user_content_blob(pending.clone())
9959                .expect("empty slot stages"),
9960            RealtimeUserContentBlobStageDisposition::StageNew
9961        );
9962        assert_eq!(
9963            session
9964                .stage_pending_realtime_user_content_blob(pending.clone())
9965                .expect("exact stage retry is idempotent"),
9966            RealtimeUserContentBlobStageDisposition::ReuseExact
9967        );
9968        assert_eq!(
9969            session
9970                .stage_pending_realtime_user_content_blob(different.clone())
9971                .expect("occupied decision is typed"),
9972            RealtimeUserContentBlobStageDisposition::RejectOccupied
9973        );
9974        assert_eq!(
9975            session.pending_realtime_user_content_blob(),
9976            Some(pending.clone())
9977        );
9978        assert_eq!(
9979            session
9980                .resolve_pending_realtime_user_content_blob_recovery(Some(&pending), false)
9981                .expect("exact recovery decision"),
9982            RealtimeUserContentBlobRecoveryDisposition::RetryExact
9983        );
9984        assert_eq!(
9985            session
9986                .resolve_pending_realtime_user_content_blob_recovery(Some(&different), true)
9987                .expect("verified older recovery decision"),
9988            RealtimeUserContentBlobRecoveryDisposition::CommitVerifiedBeforeCurrent
9989        );
9990        assert_eq!(
9991            session
9992                .resolve_pending_realtime_user_content_blob_recovery(Some(&different), false)
9993                .expect("invalid older recovery decision"),
9994            RealtimeUserContentBlobRecoveryDisposition::ClearInvalidBeforeCurrent
9995        );
9996        session
9997            .clear_invalid_pending_realtime_user_content_blob(Some(&different))
9998            .expect("generated clear-invalid disposition authorizes clear");
9999        assert!(session.pending_realtime_user_content_blob().is_none());
10000    }
10001
10002    #[test]
10003    fn transcript_rewrite_tombstones_removed_image_key_and_accepts_new_key() {
10004        let mut session = Session::new();
10005        let data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB".to_string();
10006        let original = RealtimeTranscriptEvent::UserContentFinal {
10007            idempotency_key: "removed-image-key".to_string(),
10008            item_id: "removed-image-item".to_string(),
10009            previous_item_id: None,
10010            content_index: 0,
10011            content: vec![ContentBlock::Image {
10012                media_type: "image/png".to_string(),
10013                data: crate::types::ImageData::Inline { data: data.clone() },
10014            }],
10015        };
10016        assert!(matches!(
10017            append_staged_user_image(&mut session, &original).user_content,
10018            Some(crate::RealtimeUserContentApplyOutcome::Committed(_))
10019        ));
10020
10021        let parent = session.transcript_revision().expect("parent revision");
10022        session
10023            .commit_transcript_rewrite(
10024                TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
10025                vec![Message::User(UserMessage::text("image removed"))],
10026                TranscriptRewriteReason::new("remove-image"),
10027                None,
10028                Some(parent),
10029            )
10030            .expect("rewrite should tombstone removed image identity");
10031
10032        assert!(session.realtime_user_content_identities().is_empty());
10033        assert_eq!(
10034            session.realtime_user_content_tombstones(),
10035            vec![crate::RealtimeUserContentTombstone {
10036                idempotency_key: "removed-image-key".to_string(),
10037            }]
10038        );
10039        assert!(matches!(
10040            session.preflight_realtime_user_content_event(&original),
10041            Some(crate::RealtimeUserContentApplyOutcome::RejectedConflict { .. })
10042        ));
10043        assert!(matches!(
10044            session
10045                .append_realtime_transcript_event(original)
10046                .user_content,
10047            Some(crate::RealtimeUserContentApplyOutcome::RejectedConflict { .. })
10048        ));
10049        assert_eq!(
10050            session.messages().len(),
10051            1,
10052            "stale retry emits no receipt content"
10053        );
10054
10055        let new_image = RealtimeTranscriptEvent::UserContentFinal {
10056            idempotency_key: "new-image-key".to_string(),
10057            item_id: "new-image-item".to_string(),
10058            previous_item_id: None,
10059            content_index: 0,
10060            content: vec![ContentBlock::Image {
10061                media_type: "image/png".to_string(),
10062                data: crate::types::ImageData::Inline { data },
10063            }],
10064        };
10065        assert!(matches!(
10066            append_staged_user_image(&mut session, &new_image).user_content,
10067            Some(crate::RealtimeUserContentApplyOutcome::Committed(_))
10068        ));
10069        assert_eq!(session.messages().len(), 2);
10070
10071        let restored: Session = serde_json::from_str(
10072            &serde_json::to_string(&session).expect("serialize rewritten session"),
10073        )
10074        .expect("cold restore rewritten session");
10075        assert_eq!(restored.realtime_user_content_identities().len(), 1);
10076        assert_eq!(restored.realtime_user_content_tombstones().len(), 1);
10077    }
10078
10079    #[test]
10080    fn transcript_rewrite_retains_only_canonical_image_occurrence_for_exact_replay() {
10081        let mut session = Session::new();
10082        let data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB".to_string();
10083        let original = RealtimeTranscriptEvent::UserContentFinal {
10084            idempotency_key: "retained-image-key".to_string(),
10085            item_id: "retained-image-item".to_string(),
10086            previous_item_id: None,
10087            content_index: 0,
10088            content: vec![ContentBlock::Image {
10089                media_type: "image/png".to_string(),
10090                data: crate::types::ImageData::Inline { data },
10091            }],
10092        };
10093        assert!(matches!(
10094            append_staged_user_image(&mut session, &original).user_content,
10095            Some(crate::RealtimeUserContentApplyOutcome::Committed(_))
10096        ));
10097        let retained_message = session.messages()[0].clone();
10098        let parent = session.transcript_revision().expect("parent revision");
10099        session
10100            .commit_transcript_rewrite(
10101                TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
10102                vec![
10103                    retained_message,
10104                    Message::User(UserMessage::text("new canonical neighbor")),
10105                ],
10106                TranscriptRewriteReason::new("retain-image"),
10107                None,
10108                Some(parent),
10109            )
10110            .expect("rewrite retaining exact inline image should reconcile");
10111
10112        assert!(session.realtime_user_content_tombstones().is_empty());
10113        let replay = session
10114            .preflight_realtime_user_content_event(&original)
10115            .expect("retained image should preflight as exact replay");
10116        assert!(matches!(
10117            replay,
10118            crate::RealtimeUserContentApplyOutcome::AlreadyCommitted(_)
10119        ));
10120        assert_eq!(session.messages().len(), 2);
10121    }
10122
10123    #[test]
10124    fn transcript_rewrite_rejects_atomically_while_image_blob_anchor_is_pending() {
10125        let mut session = Session::new();
10126        session.push(Message::User(UserMessage::text("before rewrite")));
10127        let pending = crate::PendingRealtimeUserContentBlob {
10128            idempotency_key: "pending-rewrite-key".to_string(),
10129            item_id: "pending-rewrite-item".to_string(),
10130            previous_item_id: None,
10131            content_index: 0,
10132            blob_id: crate::blob::content_blob_id("image/png", "pending-bytes"),
10133            media_type: "image/png".to_string(),
10134        };
10135        session
10136            .stage_pending_realtime_user_content_blob(pending.clone())
10137            .expect("stage durable pending anchor");
10138        let parent = session.transcript_revision().expect("parent revision");
10139        let error = session
10140            .commit_transcript_rewrite(
10141                TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
10142                vec![Message::User(UserMessage::text("after rewrite"))],
10143                TranscriptRewriteReason::new("blocked-pending-image"),
10144                None,
10145                Some(parent),
10146            )
10147            .expect_err("rewrite must not cross an unresolved image anchor");
10148        assert!(
10149            error
10150                .to_string()
10151                .contains("history_rewrite_pending_user_content_blob")
10152        );
10153        assert!(matches!(
10154            &session.messages()[0],
10155            Message::User(user) if user.text_content() == "before rewrite"
10156        ));
10157        assert_eq!(session.pending_realtime_user_content_blob(), Some(pending));
10158    }
10159
10160    #[test]
10161    fn realtime_user_image_rejects_noncanonical_blob_and_multiblock_shape() {
10162        let mut session = Session::new();
10163        for (key, content) in [
10164            (
10165                "invalid-blob",
10166                vec![ContentBlock::Image {
10167                    media_type: "image/png".to_string(),
10168                    data: crate::types::ImageData::Blob {
10169                        blob_id: crate::BlobId::new("sha256:not-a-digest"),
10170                    },
10171                }],
10172            ),
10173            (
10174                "multi-block",
10175                vec![
10176                    ContentBlock::Image {
10177                        media_type: "image/png".to_string(),
10178                        data: crate::types::ImageData::Inline {
10179                            data: "payload".to_string(),
10180                        },
10181                    },
10182                    ContentBlock::Text {
10183                        text: "smuggled".to_string(),
10184                    },
10185                ],
10186            ),
10187        ] {
10188            let outcome = session.append_realtime_transcript_event(
10189                RealtimeTranscriptEvent::UserContentFinal {
10190                    idempotency_key: key.to_string(),
10191                    item_id: format!("item-{key}"),
10192                    previous_item_id: None,
10193                    content_index: 0,
10194                    content,
10195                },
10196            );
10197            assert!(matches!(
10198                outcome.user_content,
10199                Some(crate::RealtimeUserContentApplyOutcome::RejectedInvalidIdentity { .. })
10200            ));
10201        }
10202        assert!(session.messages().is_empty());
10203        let encoded = serde_json::to_string(&session).expect("session should serialize");
10204        serde_json::from_str::<Session>(&encoded).expect("rejections must leave restorable state");
10205    }
10206
10207    #[test]
10208    fn realtime_restore_rejects_malformed_causal_graphs_and_accepts_waiting_dag() {
10209        fn restore(
10210            items: serde_json::Value,
10211            first_seen_order: Vec<&str>,
10212        ) -> Result<
10213            crate::realtime_transcript_revision::SessionRealtimeTranscriptState,
10214            crate::realtime_transcript_revision::RealtimeTranscriptShellError,
10215        > {
10216            let state = serde_json::from_value(serde_json::json!({
10217                "items": items,
10218                "first_seen_order": first_seen_order,
10219            }))
10220            .expect("test state shape should deserialize");
10221            crate::realtime_transcript_revision::restore_realtime_transcript_state(state)
10222        }
10223
10224        assert!(
10225            restore(
10226                serde_json::json!({
10227                    "child": { "role": "user", "previous_item_id": "missing" }
10228                }),
10229                vec!["child"],
10230            )
10231            .is_ok(),
10232            "an unmaterialized out-of-order item must survive cold restore until its predecessor arrives"
10233        );
10234        assert!(
10235            restore(
10236                serde_json::json!({
10237                    "child": {
10238                        "role": "user",
10239                        "previous_item_id": "missing",
10240                        "ready": true,
10241                        "materialized": true
10242                    }
10243                }),
10244                vec!["child"],
10245            )
10246            .is_err(),
10247            "a materialized item cannot reference a missing predecessor"
10248        );
10249        assert!(
10250            restore(
10251                serde_json::json!({
10252                    "self": { "role": "user", "previous_item_id": "self" }
10253                }),
10254                vec!["self"],
10255            )
10256            .is_err(),
10257            "self edge must fail cold restore"
10258        );
10259        assert!(
10260            restore(
10261                serde_json::json!({
10262                    "a": { "role": "user", "previous_item_id": "b" },
10263                    "b": { "role": "user", "previous_item_id": "a" }
10264                }),
10265                vec!["a", "b"],
10266            )
10267            .is_err(),
10268            "cycle must fail cold restore"
10269        );
10270        assert!(
10271            restore(
10272                serde_json::json!({
10273                    "root": { "role": "user" },
10274                    "materialized_child": {
10275                        "role": "user",
10276                        "previous_item_id": "root",
10277                        "ready": true,
10278                        "materialized": true
10279                    }
10280                }),
10281                vec!["root", "materialized_child"],
10282            )
10283            .is_err(),
10284            "materialized child cannot have unmaterialized ancestry"
10285        );
10286        assert!(
10287            restore(
10288                serde_json::json!({
10289                    "root": { "role": "user" },
10290                    "waiting_child": { "role": "user", "previous_item_id": "root" }
10291                }),
10292                vec!["waiting_child", "root"],
10293            )
10294            .is_ok(),
10295            "valid acyclic waiting graph should restore even when first-seen order is child-first"
10296        );
10297    }
10298
10299    #[test]
10300    fn realtime_restore_handles_long_waiting_chain_with_bounded_graph_walk() {
10301        const ITEM_COUNT: usize = 4_096;
10302        let mut items = serde_json::Map::new();
10303        let mut order = Vec::with_capacity(ITEM_COUNT);
10304        for index in 0..ITEM_COUNT {
10305            let item_id = format!("item-{index:04}");
10306            let value = if index == 0 {
10307                serde_json::json!({ "role": "user" })
10308            } else {
10309                serde_json::json!({
10310                    "role": "user",
10311                    "previous_item_id": format!("item-{:04}", index - 1),
10312                })
10313            };
10314            order.push(item_id.clone());
10315            items.insert(item_id, value);
10316        }
10317        let state = serde_json::from_value(serde_json::json!({
10318            "items": items,
10319            "first_seen_order": order,
10320        }))
10321        .expect("long-chain fixture should deserialize");
10322        crate::realtime_transcript_revision::restore_realtime_transcript_state(state)
10323            .expect("long valid waiting DAG should restore in one bounded graph walk");
10324    }
10325
10326    /// R5-7: `AssistantTranscriptFinalText` injects authoritative final text
10327    /// into the staged item. Verifies the override semantics: a partial
10328    /// delta is replaced, not concatenated, and the item promotes to the
10329    /// Spoken lane so flush emits `AssistantBlock::Transcript`.
10330    #[test]
10331    fn realtime_transcript_final_text_overrides_partial_delta_and_promotes_to_spoken_lane() {
10332        let mut session = Session::new();
10333
10334        // Partial delta accumulates "incom" — simulating delta loss before
10335        // the final arrives.
10336        assert!(
10337            session
10338                .append_realtime_transcript_event(
10339                    RealtimeTranscriptEvent::AssistantTranscriptDelta {
10340                        response_id: "resp_a".to_string(),
10341                        delta_id: "evt_1".to_string(),
10342                        item_id: "item_a".to_string(),
10343                        previous_item_id: None,
10344                        content_index: 0,
10345                        delta: "incom".to_string(),
10346                    }
10347                )
10348                .is_inert()
10349        );
10350
10351        // Authoritative final text overrides the staged content.
10352        assert!(
10353            session
10354                .append_realtime_transcript_event(
10355                    RealtimeTranscriptEvent::AssistantTranscriptFinalText {
10356                        response_id: "resp_a".to_string(),
10357                        item_id: "item_a".to_string(),
10358                        content_index: 0,
10359                        text: "complete answer".to_string(),
10360                    }
10361                )
10362                .is_inert()
10363        );
10364
10365        // Turn completion drives the flush.
10366        let outcome = session.append_realtime_transcript_event(
10367            RealtimeTranscriptEvent::AssistantTurnCompleted {
10368                response_id: "resp_a".to_string(),
10369                stop_reason: StopReason::EndTurn,
10370                usage: Usage::default(),
10371            },
10372        );
10373        assert!(!outcome.is_inert());
10374
10375        // Verify the materialized block has the final's authoritative text
10376        // (not the partial "incom") and the Spoken lane.
10377        assert_eq!(session.messages().len(), 1);
10378        match &session.messages()[0] {
10379            Message::BlockAssistant(assistant) => {
10380                let mut found_transcript = false;
10381                for block in &assistant.blocks {
10382                    if let AssistantBlock::Transcript { text, .. } = block {
10383                        assert_eq!(text, "complete answer");
10384                        found_transcript = true;
10385                    }
10386                }
10387                assert!(
10388                    found_transcript,
10389                    "AssistantTranscriptFinalText must promote to the Spoken lane and \
10390                     materialize as AssistantBlock::Transcript"
10391                );
10392            }
10393            other => unreachable!("expected BlockAssistant, got {other:?}"),
10394        }
10395    }
10396
10397    /// R5-7: `AssistantTranscriptFinalText` works for final-only providers
10398    /// where no prior delta has staged an item.
10399    #[test]
10400    fn realtime_transcript_final_text_creates_item_when_no_delta_staged() {
10401        let mut session = Session::new();
10402
10403        assert!(
10404            session
10405                .append_realtime_transcript_event(
10406                    RealtimeTranscriptEvent::AssistantTranscriptFinalText {
10407                        response_id: "resp_a".to_string(),
10408                        item_id: "item_a".to_string(),
10409                        content_index: 0,
10410                        text: "spoken-final-only".to_string(),
10411                    }
10412                )
10413                .is_inert()
10414        );
10415
10416        let outcome = session.append_realtime_transcript_event(
10417            RealtimeTranscriptEvent::AssistantTurnCompleted {
10418                response_id: "resp_a".to_string(),
10419                stop_reason: StopReason::EndTurn,
10420                usage: Usage::default(),
10421            },
10422        );
10423        assert!(!outcome.is_inert());
10424
10425        assert_eq!(session.messages().len(), 1);
10426        match &session.messages()[0] {
10427            Message::BlockAssistant(assistant) => {
10428                let has_transcript = assistant.blocks.iter().any(|b| {
10429                    matches!(b, AssistantBlock::Transcript { text, .. } if text == "spoken-final-only")
10430                });
10431                assert!(
10432                    has_transcript,
10433                    "final-only provider path must materialize as Transcript on the Spoken lane"
10434                );
10435            }
10436            other => unreachable!("expected BlockAssistant, got {other:?}"),
10437        }
10438    }
10439
10440    #[test]
10441    fn realtime_transcript_append_orders_causally_equivalent_out_of_order_items() {
10442        let mut session = Session::new();
10443
10444        assert!(
10445            session
10446                .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
10447                    response_id: "resp_assistant".to_string(),
10448                    delta_id: "evt_delta_1".to_string(),
10449                    item_id: "item_assistant".to_string(),
10450                    previous_item_id: Some("item_user".to_string()),
10451                    content_index: 0,
10452                    delta: "answer".to_string(),
10453                })
10454                .is_inert()
10455        );
10456        assert!(
10457            session
10458                .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
10459                    response_id: "resp_assistant".to_string(),
10460                    stop_reason: StopReason::EndTurn,
10461                    usage: Usage::default(),
10462                })
10463                .is_inert()
10464        );
10465
10466        let outcome = session.append_realtime_transcript_event(
10467            RealtimeTranscriptEvent::UserTranscriptFinal {
10468                item_id: "item_user".to_string(),
10469                previous_item_id: None,
10470                content_index: 0,
10471                text: "question".to_string(),
10472            },
10473        );
10474
10475        assert_eq!(outcome.materialized_messages.len(), 2);
10476        assert_eq!(session.messages().len(), 2);
10477        assert!(matches!(
10478            &session.messages()[0],
10479            Message::User(user) if user.text_content() == "question"
10480        ));
10481        assert!(matches!(
10482            &session.messages()[1],
10483            Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "answer"
10484        ));
10485    }
10486
10487    #[test]
10488    fn realtime_transcript_replay_of_seen_provider_items_is_inert() {
10489        let mut session = Session::new();
10490        let events = vec![
10491            RealtimeTranscriptEvent::UserTranscriptFinal {
10492                item_id: "item_user".to_string(),
10493                previous_item_id: None,
10494                content_index: 0,
10495                text: "hello".to_string(),
10496            },
10497            RealtimeTranscriptEvent::AssistantTextDelta {
10498                response_id: "resp_assistant".to_string(),
10499                delta_id: "evt_delta_1".to_string(),
10500                item_id: "item_assistant".to_string(),
10501                previous_item_id: Some("item_user".to_string()),
10502                content_index: 0,
10503                delta: "world".to_string(),
10504            },
10505            RealtimeTranscriptEvent::AssistantTurnCompleted {
10506                response_id: "resp_assistant".to_string(),
10507                stop_reason: StopReason::EndTurn,
10508                usage: Usage::default(),
10509            },
10510        ];
10511
10512        for event in events.iter().cloned() {
10513            let _ = session.append_realtime_transcript_event(event);
10514        }
10515        let first_messages = serde_json::to_value(session.messages()).unwrap();
10516
10517        for event in events {
10518            assert!(session.append_realtime_transcript_event(event).is_inert());
10519        }
10520
10521        assert_eq!(
10522            serde_json::to_value(session.messages()).unwrap(),
10523            first_messages
10524        );
10525    }
10526
10527    #[test]
10528    fn realtime_transcript_user_final_replay_cannot_erase_existing_segment() {
10529        let mut session = Session::new();
10530
10531        let user = RealtimeTranscriptEvent::UserTranscriptFinal {
10532            item_id: "item_user".to_string(),
10533            previous_item_id: None,
10534            content_index: 0,
10535            text: "remember amber lantern".to_string(),
10536        };
10537        assert!(
10538            !session
10539                .append_realtime_transcript_event(user.clone())
10540                .is_inert()
10541        );
10542        let first_messages = serde_json::to_value(session.messages()).unwrap();
10543
10544        assert!(
10545            session
10546                .append_realtime_transcript_event(RealtimeTranscriptEvent::UserTranscriptFinal {
10547                    item_id: "item_user".to_string(),
10548                    previous_item_id: None,
10549                    content_index: 0,
10550                    text: String::new(),
10551                })
10552                .is_inert()
10553        );
10554        assert!(session.append_realtime_transcript_event(user).is_inert());
10555        assert_eq!(
10556            serde_json::to_value(session.messages()).unwrap(),
10557            first_messages
10558        );
10559    }
10560
10561    #[test]
10562    fn realtime_transcript_empty_user_final_can_be_filled_by_later_nonempty_replay() {
10563        let mut session = Session::new();
10564
10565        assert!(
10566            session
10567                .append_realtime_transcript_event(RealtimeTranscriptEvent::UserTranscriptFinal {
10568                    item_id: "item_user".to_string(),
10569                    previous_item_id: None,
10570                    content_index: 0,
10571                    text: String::new(),
10572                })
10573                .is_inert()
10574        );
10575        assert!(session.messages().is_empty());
10576
10577        let outcome = session.append_realtime_transcript_event(
10578            RealtimeTranscriptEvent::UserTranscriptFinal {
10579                item_id: "item_user".to_string(),
10580                previous_item_id: None,
10581                content_index: 0,
10582                text: "remember amber lantern".to_string(),
10583            },
10584        );
10585        assert_eq!(outcome.materialized_messages.len(), 1);
10586        assert_eq!(session.messages().len(), 1);
10587        assert!(matches!(
10588            &session.messages()[0],
10589            Message::User(user) if user.text_content() == "remember amber lantern"
10590        ));
10591    }
10592
10593    #[test]
10594    fn realtime_transcript_skipped_provider_items_preserve_causal_order_without_content() {
10595        let mut session = Session::new();
10596
10597        let assistant_delta = RealtimeTranscriptEvent::AssistantTextDelta {
10598            response_id: "resp_assistant".to_string(),
10599            delta_id: "evt_delta_1".to_string(),
10600            item_id: "item_assistant".to_string(),
10601            previous_item_id: Some("item_tool".to_string()),
10602            content_index: 0,
10603            delta: "done".to_string(),
10604        };
10605        assert!(
10606            session
10607                .append_realtime_transcript_event(assistant_delta.clone())
10608                .is_inert()
10609        );
10610        let assistant_complete = RealtimeTranscriptEvent::AssistantTurnCompleted {
10611            response_id: "resp_assistant".to_string(),
10612            stop_reason: StopReason::EndTurn,
10613            usage: Usage::default(),
10614        };
10615        assert!(
10616            session
10617                .append_realtime_transcript_event(assistant_complete.clone())
10618                .is_inert()
10619        );
10620
10621        let skipped = RealtimeTranscriptEvent::ItemSkipped {
10622            item_id: "item_tool".to_string(),
10623            previous_item_id: Some("item_user".to_string()),
10624        };
10625        assert!(
10626            session
10627                .append_realtime_transcript_event(skipped.clone())
10628                .is_inert(),
10629            "a skipped provider item must not append transcript content"
10630        );
10631        assert!(session.messages().is_empty());
10632
10633        let outcome = session.append_realtime_transcript_event(
10634            RealtimeTranscriptEvent::UserTranscriptFinal {
10635                item_id: "item_user".to_string(),
10636                previous_item_id: None,
10637                content_index: 0,
10638                text: "please use the tool".to_string(),
10639            },
10640        );
10641        assert_eq!(outcome.materialized_messages.len(), 2);
10642        assert_eq!(session.messages().len(), 2);
10643        assert!(matches!(
10644            &session.messages()[0],
10645            Message::User(user) if user.text_content() == "please use the tool"
10646        ));
10647        assert!(matches!(
10648            &session.messages()[1],
10649            Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "done"
10650        ));
10651
10652        let first_messages = serde_json::to_value(session.messages()).unwrap();
10653        assert!(session.append_realtime_transcript_event(skipped).is_inert());
10654        assert!(
10655            session
10656                .append_realtime_transcript_event(assistant_delta)
10657                .is_inert()
10658        );
10659        assert!(
10660            session
10661                .append_realtime_transcript_event(assistant_complete)
10662                .is_inert()
10663        );
10664        assert_eq!(
10665            serde_json::to_value(session.messages()).unwrap(),
10666            first_messages
10667        );
10668    }
10669
10670    #[test]
10671    fn realtime_transcript_interrupted_assistant_item_unblocks_later_provider_items() {
10672        // R5-5 (Round-5): the staged assistant content is a Display-lane item
10673        // (`AssistantTextDelta`). Under the new lane-aware barge-in contract,
10674        // the Display lane survives interruption and materializes. The User
10675        // "Stop." item, gated on the chained Display item being materialized,
10676        // also unblocks. Round-4's "must stay non-canonical" assertion was
10677        // wrong — that contract was lane-blind.
10678        let mut session = Session::new();
10679
10680        let _ = session.append_realtime_transcript_event(
10681            RealtimeTranscriptEvent::UserTranscriptFinal {
10682                item_id: "item_repeat".to_string(),
10683                previous_item_id: None,
10684                content_index: 0,
10685                text: "repeat until stop".to_string(),
10686            },
10687        );
10688        assert!(
10689            session
10690                .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
10691                    response_id: "resp_loop".to_string(),
10692                    delta_id: "evt_loop_1".to_string(),
10693                    item_id: "item_loop".to_string(),
10694                    previous_item_id: Some("item_repeat".to_string()),
10695                    content_index: 0,
10696                    delta: "Looping now".to_string(),
10697                })
10698                .is_inert()
10699        );
10700        assert!(
10701            session
10702                .append_realtime_transcript_event(RealtimeTranscriptEvent::UserTranscriptFinal {
10703                    item_id: "item_stop".to_string(),
10704                    previous_item_id: Some("item_loop".to_string()),
10705                    content_index: 0,
10706                    text: "Stop.".to_string(),
10707                })
10708                .is_inert(),
10709            "the stop turn waits until the interrupted assistant provider item is resolved"
10710        );
10711
10712        let outcome = session.append_realtime_transcript_event(
10713            RealtimeTranscriptEvent::AssistantTurnInterrupted {
10714                response_id: "resp_loop".to_string(),
10715            },
10716        );
10717
10718        // R5-5: materializer commits 2 messages (the retained Display item +
10719        // the unblocked "Stop." User message).
10720        assert_eq!(outcome.materialized_messages.len(), 2);
10721        // Canonical history: User-repeat, BlockAssistant(Display "Looping now"), User-Stop.
10722        assert_eq!(session.messages().len(), 3);
10723        assert!(matches!(
10724            &session.messages()[0],
10725            Message::User(user) if user.text_content() == "repeat until stop"
10726        ));
10727        match &session.messages()[1] {
10728            Message::BlockAssistant(assistant) => {
10729                let text = block_assistant_text(assistant);
10730                assert_eq!(text, "Looping now");
10731            }
10732            other => unreachable!(
10733                "Display lane assistant item must be retained on Interrupted, got {other:?}"
10734            ),
10735        }
10736        assert!(matches!(
10737            &session.messages()[2],
10738            Message::User(user) if user.text_content() == "Stop."
10739        ));
10740    }
10741
10742    #[test]
10743    fn realtime_transcript_late_interrupted_assistant_delta_stays_noncanonical() {
10744        let mut session = Session::new();
10745
10746        let _ = session.append_realtime_transcript_event(
10747            RealtimeTranscriptEvent::UserTranscriptFinal {
10748                item_id: "item_repeat".to_string(),
10749                previous_item_id: None,
10750                content_index: 0,
10751                text: "repeat until stop".to_string(),
10752            },
10753        );
10754        assert!(
10755            session
10756                .append_realtime_transcript_event(RealtimeTranscriptEvent::ItemObserved {
10757                    item_id: "item_loop".to_string(),
10758                    previous_item_id: Some("item_repeat".to_string()),
10759                    role: RealtimeTranscriptRole::Assistant,
10760                    response_id: None,
10761                })
10762                .is_inert(),
10763            "provider can observe an assistant item before the adapter learns its response id"
10764        );
10765        assert!(
10766            session
10767                .append_realtime_transcript_event(
10768                    RealtimeTranscriptEvent::AssistantTurnInterrupted {
10769                        response_id: "resp_loop".to_string(),
10770                    }
10771                )
10772                .is_inert(),
10773            "an interruption can arrive before delayed transcript deltas for the response"
10774        );
10775        assert!(
10776            session
10777                .append_realtime_transcript_event(RealtimeTranscriptEvent::UserTranscriptFinal {
10778                    item_id: "item_stop".to_string(),
10779                    previous_item_id: Some("item_loop".to_string()),
10780                    content_index: 0,
10781                    text: "Stop.".to_string(),
10782                })
10783                .is_inert(),
10784            "the stop turn waits for the provider's interrupted assistant item anchor"
10785        );
10786
10787        let late_delta_outcome =
10788            session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
10789                response_id: "resp_loop".to_string(),
10790                delta_id: "evt_loop_late".to_string(),
10791                item_id: "item_loop".to_string(),
10792                previous_item_id: Some("item_repeat".to_string()),
10793                content_index: 0,
10794                delta: "Looping now".to_string(),
10795            });
10796        assert_eq!(late_delta_outcome.materialized_messages.len(), 1);
10797        assert!(matches!(
10798            &session.messages()[1],
10799            Message::User(user) if user.text_content() == "Stop."
10800        ));
10801        assert!(
10802            session
10803                .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
10804                    response_id: "resp_loop".to_string(),
10805                    stop_reason: StopReason::EndTurn,
10806                    usage: Usage::default(),
10807                })
10808                .is_inert(),
10809            "late completion for an interrupted response must not resurrect its deltas"
10810        );
10811        assert!(
10812            session
10813                .messages()
10814                .iter()
10815                .filter_map(|message| match message {
10816                    Message::BlockAssistant(assistant) => Some(block_assistant_text(assistant)),
10817                    _ => None,
10818                })
10819                .all(|text| !text.contains("Looping now")),
10820            "late interrupted assistant text must remain non-canonical"
10821        );
10822    }
10823
10824    #[test]
10825    fn realtime_transcript_completion_only_finalizes_matching_response() {
10826        let mut session = Session::new();
10827
10828        let _ = session.append_realtime_transcript_event(
10829            RealtimeTranscriptEvent::UserTranscriptFinal {
10830                item_id: "item_user".to_string(),
10831                previous_item_id: None,
10832                content_index: 0,
10833                text: "question".to_string(),
10834            },
10835        );
10836        assert!(
10837            session
10838                .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
10839                    response_id: "resp_a".to_string(),
10840                    delta_id: "evt_a".to_string(),
10841                    item_id: "item_a".to_string(),
10842                    previous_item_id: Some("item_user".to_string()),
10843                    content_index: 0,
10844                    delta: "answer a".to_string(),
10845                })
10846                .is_inert()
10847        );
10848
10849        assert!(
10850            session
10851                .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
10852                    response_id: "resp_b".to_string(),
10853                    stop_reason: StopReason::EndTurn,
10854                    usage: Usage::default(),
10855                })
10856                .is_inert(),
10857            "a completion for another response must not finalize buffered assistant text"
10858        );
10859        assert_eq!(session.messages().len(), 1);
10860
10861        let outcome = session.append_realtime_transcript_event(
10862            RealtimeTranscriptEvent::AssistantTurnCompleted {
10863                response_id: "resp_a".to_string(),
10864                stop_reason: StopReason::EndTurn,
10865                usage: Usage::default(),
10866            },
10867        );
10868        assert_eq!(outcome.materialized_messages.len(), 1);
10869        assert_eq!(session.messages().len(), 2);
10870        assert!(matches!(
10871            &session.messages()[1],
10872            Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "answer a"
10873        ));
10874    }
10875
10876    #[test]
10877    fn realtime_transcript_completion_before_later_delta_is_response_scoped() {
10878        let mut session = Session::new();
10879
10880        let _ = session.append_realtime_transcript_event(
10881            RealtimeTranscriptEvent::UserTranscriptFinal {
10882                item_id: "item_user".to_string(),
10883                previous_item_id: None,
10884                content_index: 0,
10885                text: "question".to_string(),
10886            },
10887        );
10888        assert!(
10889            session
10890                .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
10891                    response_id: "resp_a".to_string(),
10892                    stop_reason: StopReason::EndTurn,
10893                    usage: Usage::default(),
10894                })
10895                .is_inert()
10896        );
10897        assert!(
10898            session
10899                .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
10900                    response_id: "resp_b".to_string(),
10901                    delta_id: "evt_b".to_string(),
10902                    item_id: "item_b".to_string(),
10903                    previous_item_id: Some("item_user".to_string()),
10904                    content_index: 0,
10905                    delta: "wrong response".to_string(),
10906                })
10907                .is_inert(),
10908            "a later delta for another response must not be finalized by resp_a's pending completion"
10909        );
10910
10911        let outcome =
10912            session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
10913                response_id: "resp_a".to_string(),
10914                delta_id: "evt_a".to_string(),
10915                item_id: "item_a".to_string(),
10916                previous_item_id: Some("item_user".to_string()),
10917                content_index: 0,
10918                delta: "right response".to_string(),
10919            });
10920
10921        assert_eq!(outcome.materialized_messages.len(), 1);
10922        assert_eq!(session.messages().len(), 2);
10923        assert!(matches!(
10924            &session.messages()[1],
10925            Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "right response"
10926        ));
10927    }
10928
10929    #[test]
10930    fn realtime_transcript_late_duplicate_completion_cannot_finalize_unrelated_response() {
10931        let mut session = Session::new();
10932
10933        let _ = session.append_realtime_transcript_event(
10934            RealtimeTranscriptEvent::UserTranscriptFinal {
10935                item_id: "item_user".to_string(),
10936                previous_item_id: None,
10937                content_index: 0,
10938                text: "question".to_string(),
10939            },
10940        );
10941        let _ =
10942            session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
10943                response_id: "resp_a".to_string(),
10944                delta_id: "evt_a".to_string(),
10945                item_id: "item_a".to_string(),
10946                previous_item_id: Some("item_user".to_string()),
10947                content_index: 0,
10948                delta: "first".to_string(),
10949            });
10950        let _ = session.append_realtime_transcript_event(
10951            RealtimeTranscriptEvent::AssistantTurnCompleted {
10952                response_id: "resp_a".to_string(),
10953                stop_reason: StopReason::EndTurn,
10954                usage: Usage::default(),
10955            },
10956        );
10957        assert_eq!(session.messages().len(), 2);
10958
10959        assert!(
10960            session
10961                .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
10962                    response_id: "resp_b".to_string(),
10963                    delta_id: "evt_b".to_string(),
10964                    item_id: "item_b".to_string(),
10965                    previous_item_id: Some("item_a".to_string()),
10966                    content_index: 0,
10967                    delta: "second".to_string(),
10968                })
10969                .is_inert()
10970        );
10971        assert!(
10972            session
10973                .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
10974                    response_id: "resp_a".to_string(),
10975                    stop_reason: StopReason::EndTurn,
10976                    usage: Usage::default(),
10977                })
10978                .is_inert(),
10979            "a duplicate late terminal for resp_a must not finalize resp_b"
10980        );
10981        assert_eq!(session.messages().len(), 2);
10982
10983        let outcome = session.append_realtime_transcript_event(
10984            RealtimeTranscriptEvent::AssistantTurnCompleted {
10985                response_id: "resp_b".to_string(),
10986                stop_reason: StopReason::EndTurn,
10987                usage: Usage::default(),
10988            },
10989        );
10990        assert_eq!(outcome.materialized_messages.len(), 1);
10991        assert_eq!(session.messages().len(), 3);
10992    }
10993
10994    #[test]
10995    fn realtime_transcript_interruption_discards_only_matching_response() {
10996        // R5-5: cross-response isolation invariant — Interrupted on resp_a
10997        // does NOT touch resp_b's staged content. Both responses use
10998        // `AssistantTextDelta` (Display lane); under R5-5 resp_a's Display
10999        // item is RETAINED at Interrupted time and resp_b's continues
11000        // unaffected, materializing on its later TurnCompleted.
11001        let mut session = Session::new();
11002
11003        let _ = session.append_realtime_transcript_event(
11004            RealtimeTranscriptEvent::UserTranscriptFinal {
11005                item_id: "item_user".to_string(),
11006                previous_item_id: None,
11007                content_index: 0,
11008                text: "question".to_string(),
11009            },
11010        );
11011        let _ =
11012            session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
11013                response_id: "resp_a".to_string(),
11014                delta_id: "evt_a".to_string(),
11015                item_id: "item_a".to_string(),
11016                previous_item_id: Some("item_user".to_string()),
11017                content_index: 0,
11018                delta: "interrupted display".to_string(),
11019            });
11020        let _ =
11021            session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
11022                response_id: "resp_b".to_string(),
11023                delta_id: "evt_b".to_string(),
11024                item_id: "item_b".to_string(),
11025                previous_item_id: Some("item_user".to_string()),
11026                content_index: 0,
11027                delta: "keep me".to_string(),
11028            });
11029
11030        // R5-5: Interrupted commits the resp_a Display item; resp_b
11031        // remains untouched.
11032        let interrupt_outcome = session.append_realtime_transcript_event(
11033            RealtimeTranscriptEvent::AssistantTurnInterrupted {
11034                response_id: "resp_a".to_string(),
11035            },
11036        );
11037        assert_eq!(
11038            interrupt_outcome.materialized_messages.len(),
11039            1,
11040            "resp_a's Display item commits on Interrupted"
11041        );
11042
11043        let outcome = session.append_realtime_transcript_event(
11044            RealtimeTranscriptEvent::AssistantTurnCompleted {
11045                response_id: "resp_b".to_string(),
11046                stop_reason: StopReason::EndTurn,
11047                usage: Usage::default(),
11048            },
11049        );
11050        assert_eq!(
11051            outcome.materialized_messages.len(),
11052            1,
11053            "resp_b commits on its TurnCompleted, untouched by resp_a's Interrupted"
11054        );
11055
11056        // 1 user + 2 assistant messages.
11057        assert_eq!(session.messages().len(), 3);
11058        assert!(matches!(
11059            &session.messages()[1],
11060            Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "interrupted display"
11061        ));
11062        assert!(matches!(
11063            &session.messages()[2],
11064            Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "keep me"
11065        ));
11066    }
11067
11068    // Performance tests for Arc-based CoW
11069
11070    #[test]
11071    fn test_fork_shares_arc_no_clone() {
11072        let mut session = Session::new();
11073        for i in 0..100 {
11074            session.push(Message::User(UserMessage::text(format!("Message {i}"))));
11075        }
11076
11077        // Fork should share the same Arc, not clone messages
11078        let forked = session.fork();
11079
11080        // Both should point to the same underlying data (Arc refcount > 1)
11081        assert!(Arc::ptr_eq(&session.messages, &forked.messages));
11082        assert_eq!(forked.messages().len(), 100);
11083    }
11084
11085    #[test]
11086    fn test_fork_at_shares_arc_prefix() {
11087        let mut session = Session::new();
11088        for i in 0..100 {
11089            session.push(Message::User(UserMessage::text(format!("Message {i}"))));
11090        }
11091
11092        // Fork at 50 should create new Arc with copied prefix
11093        let forked = session.fork_at(50);
11094        assert_eq!(forked.messages().len(), 50);
11095
11096        // Original should be unchanged
11097        assert_eq!(session.messages().len(), 100);
11098    }
11099
11100    #[test]
11101    fn test_fork_at_resets_transcript_history_state_for_branch_identity() {
11102        let mut session = Session::new();
11103        session.push(Message::User(UserMessage::text(
11104            "summarize this".to_string(),
11105        )));
11106        session.push(Message::BlockAssistant(BlockAssistantMessage::new(
11107            vec![AssistantBlock::Text {
11108                text: "long assistant trace".to_string(),
11109                meta: None,
11110            }],
11111            StopReason::EndTurn,
11112        )));
11113        let parent_revision = session.transcript_revision().expect("parent revision");
11114        session
11115            .commit_transcript_rewrite(
11116                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
11117                vec![Message::BlockAssistant(BlockAssistantMessage::new(
11118                    vec![AssistantBlock::Text {
11119                        text: "compact trace".to_string(),
11120                        meta: None,
11121                    }],
11122                    StopReason::EndTurn,
11123                ))],
11124                TranscriptRewriteReason::new("compaction"),
11125                Some("test".to_string()),
11126                Some(parent_revision),
11127            )
11128            .expect("rewrite should commit");
11129
11130        let source_head = session.transcript_revision().expect("source head");
11131        let mut forked = session.fork_at(1);
11132        assert_ne!(forked.id(), session.id());
11133        assert!(
11134            !forked
11135                .metadata()
11136                .contains_key(SESSION_TRANSCRIPT_HISTORY_STATE_KEY)
11137        );
11138        assert_eq!(
11139            forked.transcript_revision().expect("fork head"),
11140            transcript_messages_digest(forked.messages()).expect("fork digest")
11141        );
11142        assert!(
11143            forked
11144                .transcript_revision_messages(&source_head)
11145                .expect("fork history lookup")
11146                .is_none()
11147        );
11148
11149        let fork_parent = forked.transcript_revision().expect("fork parent");
11150        let commit = forked
11151            .commit_transcript_rewrite(
11152                TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
11153                vec![Message::User(UserMessage::text(
11154                    "branch prompt".to_string(),
11155                ))],
11156                TranscriptRewriteReason::new("branch_edit"),
11157                Some("test".to_string()),
11158                Some(fork_parent.clone()),
11159            )
11160            .expect("fork rewrite should use fork-local parent");
11161        assert_eq!(commit.parent_revision, fork_parent);
11162    }
11163
11164    #[test]
11165    fn test_push_cow_behavior() {
11166        let mut session = Session::new();
11167        session.push(Message::User(UserMessage::text("First".to_string())));
11168
11169        // Fork shares the Arc
11170        let forked = session.fork();
11171        assert!(Arc::ptr_eq(&session.messages, &forked.messages));
11172
11173        // Push on original triggers CoW - original gets new Arc
11174        session.push(Message::User(UserMessage::text("Second".to_string())));
11175
11176        // Now they should have different Arcs
11177        assert!(!Arc::ptr_eq(&session.messages, &forked.messages));
11178        assert_eq!(session.messages().len(), 2);
11179        assert_eq!(forked.messages().len(), 1);
11180    }
11181
11182    // Performance tests for lazy timestamp updates
11183
11184    #[test]
11185    fn test_push_batch_single_timestamp() {
11186        let mut session = Session::new();
11187        let initial_updated = session.updated_at();
11188
11189        // Use push_batch to add multiple messages without repeated syscalls
11190        session.push_batch(vec![
11191            Message::User(UserMessage::text("First".to_string())),
11192            Message::User(UserMessage::text("Second".to_string())),
11193            Message::User(UserMessage::text("Third".to_string())),
11194        ]);
11195
11196        assert_eq!(session.messages().len(), 3);
11197        // Timestamp should have been updated once
11198        assert!(session.updated_at() >= initial_updated);
11199    }
11200
11201    #[test]
11202    fn test_touch_updates_timestamp() {
11203        let mut session = Session::new();
11204        let initial = session.updated_at();
11205
11206        std::thread::sleep(std::time::Duration::from_millis(10));
11207
11208        // Explicit touch to update timestamp
11209        session.touch();
11210
11211        assert!(session.updated_at() > initial);
11212    }
11213
11214    #[test]
11215    fn test_session_push() {
11216        let mut session = Session::new();
11217        let initial_updated = session.updated_at();
11218
11219        // Small delay to ensure time changes
11220        std::thread::sleep(std::time::Duration::from_millis(10));
11221
11222        session.push(Message::User(UserMessage::text("Hello".to_string())));
11223
11224        assert_eq!(session.messages().len(), 1);
11225        assert!(session.updated_at() > initial_updated);
11226    }
11227
11228    #[test]
11229    fn test_session_fork() {
11230        let mut session = Session::new();
11231        session.push(Message::System(SystemMessage::new("System prompt")));
11232        session.push(Message::User(UserMessage::text("Hello".to_string())));
11233        session.push(Message::BlockAssistant(BlockAssistantMessage {
11234            blocks: vec![AssistantBlock::Text {
11235                text: "Hi!".to_string(),
11236                meta: None,
11237            }],
11238            stop_reason: StopReason::EndTurn,
11239            identity: crate::types::TranscriptMessageIdentity::default(),
11240            created_at: crate::types::message_timestamp_now(),
11241        }));
11242
11243        // Fork at index 2 (system + user)
11244        let forked = session.fork_at(2);
11245        assert_eq!(forked.messages().len(), 2);
11246        assert_ne!(forked.id(), session.id());
11247
11248        // Full fork
11249        let full_fork = session.fork();
11250        assert_eq!(full_fork.messages().len(), 3);
11251    }
11252
11253    #[test]
11254    fn test_session_forks_drop_generated_authority_metadata() {
11255        let mut session = Session::new();
11256        session.push(Message::User(UserMessage::text("original")));
11257        session.set_metadata("ordinary", serde_json::json!("keep"));
11258        session
11259            .set_build_state(SessionBuildState::default())
11260            .expect("build state should serialize");
11261        session
11262            .set_system_context_state(SessionSystemContextState::default())
11263            .expect("system-context state should serialize");
11264        session
11265            .set_deferred_turn_state(SessionDeferredTurnState::default())
11266            .expect("deferred-turn state should serialize");
11267        session
11268            .set_tool_visibility_state(
11269                AuthorizedSessionToolVisibilityState::from_generated_authority(
11270                    SessionToolVisibilityState::default(),
11271                ),
11272            )
11273            .expect("visibility state should serialize");
11274        let _ = session.append_realtime_transcript_event(RealtimeTranscriptEvent::ItemObserved {
11275            item_id: "rt-item".to_string(),
11276            previous_item_id: None,
11277            role: RealtimeTranscriptRole::User,
11278            response_id: None,
11279        });
11280        session.metadata.insert(
11281            crate::memory::SESSION_COMPACTION_PROJECTION_INTENTS_KEY.to_string(),
11282            serde_json::json!([{"sealed_projection": "must-not-fork"}]),
11283        );
11284        assert!(
11285            session
11286                .metadata()
11287                .contains_key(SESSION_REALTIME_TRANSCRIPT_STATE_KEY),
11288            "test setup should install realtime transcript authority state"
11289        );
11290
11291        let forked_at = session.fork_at(1);
11292        let full_fork = session.fork();
11293        let replaced = session
11294            .fork_replacing(
11295                0,
11296                TranscriptReplacement::Message {
11297                    message: Message::User(UserMessage::text("replacement")),
11298                },
11299            )
11300            .expect("replacement fork should succeed");
11301
11302        for forked in [&forked_at, &full_fork, &replaced] {
11303            assert_eq!(forked.metadata().get("ordinary").unwrap(), "keep");
11304            assert!(
11305                !forked.metadata().contains_key(SESSION_BUILD_STATE_KEY),
11306                "forked sessions must not raw-copy durable build-state authority"
11307            );
11308            assert!(
11309                !forked
11310                    .metadata()
11311                    .contains_key(SESSION_SYSTEM_CONTEXT_STATE_KEY),
11312                "forked sessions must not raw-copy system-context authority state"
11313            );
11314            assert!(
11315                !forked
11316                    .metadata()
11317                    .contains_key(SESSION_DEFERRED_TURN_STATE_KEY),
11318                "forked sessions must not raw-copy deferred-turn authority state"
11319            );
11320            assert!(
11321                !forked
11322                    .metadata()
11323                    .contains_key(SESSION_TOOL_VISIBILITY_STATE_KEY),
11324                "forked sessions must not raw-copy tool-visibility authority state"
11325            );
11326            assert!(
11327                !forked
11328                    .metadata()
11329                    .contains_key(SESSION_REALTIME_TRANSCRIPT_STATE_KEY),
11330                "forked sessions must not raw-copy realtime transcript authority state"
11331            );
11332            assert!(
11333                !forked
11334                    .metadata()
11335                    .contains_key(crate::memory::SESSION_COMPACTION_PROJECTION_INTENTS_KEY),
11336                "forked sessions must not raw-copy compaction outbox authority"
11337            );
11338        }
11339    }
11340
11341    #[test]
11342    fn test_session_metadata() {
11343        let mut session = Session::new();
11344        session.set_metadata("key", serde_json::json!("value"));
11345
11346        assert_eq!(session.metadata().get("key").unwrap(), "value");
11347    }
11348
11349    #[test]
11350    fn identical_metadata_projection_is_checkpoint_idempotent() {
11351        let mut session = Session::new();
11352        session.set_metadata("key", serde_json::json!({ "value": 1 }));
11353        let updated_at = session.updated_at;
11354        let digest = crate::session_checkpoint_digest(&session)
11355            .expect("checkpoint digest before identical projection");
11356
11357        session.set_metadata("key", serde_json::json!({ "value": 1 }));
11358        session.remove_metadata("already_absent");
11359
11360        assert_eq!(
11361            session.updated_at, updated_at,
11362            "an identical durable projection must not manufacture a content mutation"
11363        );
11364        assert_eq!(
11365            crate::session_checkpoint_digest(&session)
11366                .expect("checkpoint digest after identical projection"),
11367            digest,
11368            "an identical durable projection must not rotate checkpoint authority"
11369        );
11370    }
11371
11372    #[test]
11373    fn session_metadata_realm_id_is_back_read_compatible_string() {
11374        // A typed realm_id serializes as a bare JSON string (byte-identical to
11375        // the prior Option<String> durable shape).
11376        let metadata = SessionMetadata {
11377            schema_version: SESSION_METADATA_SCHEMA_VERSION,
11378            model: "test-model".to_string(),
11379            max_tokens: 1024,
11380            structured_output_retries: 2,
11381            provider: Provider::Other,
11382            self_hosted_server_id: None,
11383            provider_params: None,
11384            tooling: SessionTooling::default(),
11385            keep_alive: false,
11386            comms_name: None,
11387            peer_meta: None,
11388            realm_id: Some(crate::RealmId::parse("env_default").unwrap()),
11389            instance_id: None,
11390            backend: None,
11391            config_generation: None,
11392            auth_binding: None,
11393            mob_member_binding: None,
11394        };
11395        let value = serde_json::to_value(&metadata).unwrap();
11396        assert_eq!(
11397            value.get("realm_id"),
11398            Some(&serde_json::json!("env_default")),
11399            "typed realm_id must serialize as a bare slug string"
11400        );
11401
11402        // A legacy persisted row stored realm_id as a JSON string; it must
11403        // deserialize into the typed RealmId (durable back-read).
11404        let legacy = serde_json::json!({
11405            "schema_version": SESSION_METADATA_SCHEMA_VERSION,
11406            "model": "test-model",
11407            "max_tokens": 1024,
11408            "structured_output_retries": 2,
11409            "provider": "other",
11410            "tooling": SessionTooling::default(),
11411            "keep_alive": false,
11412            "comms_name": null,
11413            "realm_id": "legacy_realm",
11414        });
11415        let restored: SessionMetadata = serde_json::from_value(legacy).unwrap();
11416        assert_eq!(
11417            restored.realm_id.as_ref().map(crate::RealmId::as_str),
11418            Some("legacy_realm")
11419        );
11420    }
11421
11422    /// Ask 6: `SessionTooling.tool_access_policy` is additive — a persisted
11423    /// row without the field back-reads as `None` (unrestricted), `None` is
11424    /// omitted on write (durable shape unchanged for ungated sessions), and a
11425    /// resolved policy round-trips intact.
11426    #[test]
11427    fn session_tooling_tool_access_policy_round_trip_and_absent_default() {
11428        // Absent field back-reads as None.
11429        let legacy = serde_json::json!({});
11430        let restored: SessionTooling = serde_json::from_value(legacy).unwrap();
11431        assert_eq!(restored.tool_access_policy, None);
11432
11433        // None is omitted on write — ungated sessions keep their prior shape.
11434        let value = serde_json::to_value(SessionTooling::default()).unwrap();
11435        assert!(
11436            value.get("tool_access_policy").is_none(),
11437            "None policy must not serialize"
11438        );
11439
11440        // A resolved policy round-trips intact.
11441        let tooling = SessionTooling {
11442            tool_access_policy: Some(crate::ops::ToolAccessPolicy::AllowList(
11443                ["read_file", "send_message"].into_iter().collect(),
11444            )),
11445            ..SessionTooling::default()
11446        };
11447        let value = serde_json::to_value(&tooling).unwrap();
11448        let restored: SessionTooling = serde_json::from_value(value).unwrap();
11449        assert_eq!(restored.tool_access_policy, tooling.tool_access_policy);
11450    }
11451
11452    #[test]
11453    fn lifecycle_terminal_typed_round_trip() {
11454        let mut session = Session::new();
11455        assert_eq!(session.lifecycle_terminal(), None);
11456
11457        session
11458            .set_lifecycle_terminal(SessionLifecycleTerminal::Archived)
11459            .expect("typed terminal write should serialize");
11460        assert_eq!(
11461            session.lifecycle_terminal(),
11462            Some(SessionLifecycleTerminal::Archived)
11463        );
11464        assert!(
11465            session
11466                .lifecycle_terminal()
11467                .is_some_and(SessionLifecycleTerminal::is_archived)
11468        );
11469        // Persisted JSON for the typed key is the snake_case variant string.
11470        assert_eq!(
11471            session
11472                .metadata()
11473                .get(SESSION_LIFECYCLE_TERMINAL_KEY)
11474                .unwrap(),
11475            &serde_json::json!("archived")
11476        );
11477    }
11478
11479    #[test]
11480    fn lifecycle_terminal_key_rejects_raw_mutation() {
11481        let mut session = Session::new();
11482        assert!(
11483            session
11484                .try_set_metadata(
11485                    SESSION_LIFECYCLE_TERMINAL_KEY,
11486                    serde_json::json!("archived")
11487                )
11488                .is_err(),
11489            "the typed lifecycle-terminal key is reserved for session authority"
11490        );
11491    }
11492
11493    #[test]
11494    fn test_session_metadata_backfill_preserves_timestamp() {
11495        let mut session = Session::new();
11496        let initial_updated = session.updated_at();
11497
11498        std::thread::sleep(std::time::Duration::from_millis(10));
11499
11500        assert!(session.backfill_metadata_if_absent("key", serde_json::json!("value")));
11501        assert_eq!(session.metadata().get("key").unwrap(), "value");
11502        assert_eq!(session.updated_at(), initial_updated);
11503        assert!(!session.backfill_metadata_if_absent("key", serde_json::json!("other")));
11504        assert_eq!(session.metadata().get("key").unwrap(), "value");
11505        assert_eq!(session.updated_at(), initial_updated);
11506    }
11507
11508    #[test]
11509    fn test_reserved_generated_authority_metadata_rejects_raw_mutation() {
11510        let mut session = Session::new();
11511
11512        assert!(
11513            session
11514                .try_set_metadata(SESSION_SYSTEM_CONTEXT_STATE_KEY, serde_json::json!({}))
11515                .is_err()
11516        );
11517        assert!(
11518            session
11519                .try_set_metadata(SESSION_METADATA_KEY, serde_json::json!({}))
11520                .is_err()
11521        );
11522        assert!(
11523            session
11524                .try_set_metadata(SESSION_BUILD_STATE_KEY, serde_json::json!({}))
11525                .is_err()
11526        );
11527        let compaction_intents_key = crate::memory::SESSION_COMPACTION_PROJECTION_INTENTS_KEY;
11528        let sealed_compaction_intents =
11529            serde_json::json!([{"sealed_projection": "typed-owner-only"}]);
11530        session.metadata.insert(
11531            compaction_intents_key.to_string(),
11532            sealed_compaction_intents.clone(),
11533        );
11534        assert!(
11535            session
11536                .try_set_metadata(compaction_intents_key, serde_json::json!([]))
11537                .is_err(),
11538            "raw metadata must not overwrite compaction outbox authority"
11539        );
11540        session.remove_metadata(compaction_intents_key);
11541        assert_eq!(
11542            session.metadata().get(compaction_intents_key),
11543            Some(&sealed_compaction_intents),
11544            "raw metadata removal must not erase compaction outbox authority"
11545        );
11546        let mut absent = Session::new();
11547        assert!(
11548            !absent.backfill_metadata_if_absent(
11549                compaction_intents_key,
11550                serde_json::json!([{"forged_projection": true}])
11551            ),
11552            "compatibility backfill must not fabricate compaction outbox authority"
11553        );
11554        assert!(!absent.metadata().contains_key(compaction_intents_key));
11555        session
11556            .set_session_metadata(SessionMetadata {
11557                schema_version: SESSION_METADATA_SCHEMA_VERSION,
11558                model: "test-model".to_string(),
11559                max_tokens: 1024,
11560                structured_output_retries: 2,
11561                provider: Provider::Other,
11562                self_hosted_server_id: None,
11563                provider_params: None,
11564                tooling: SessionTooling::default(),
11565                keep_alive: false,
11566                comms_name: None,
11567                peer_meta: None,
11568                realm_id: None,
11569                instance_id: None,
11570                backend: None,
11571                config_generation: None,
11572                auth_binding: None,
11573                mob_member_binding: None,
11574            })
11575            .expect("typed metadata setter should route through generated authority");
11576        session
11577            .set_build_state(SessionBuildState::default())
11578            .expect("typed build-state setter should route through generated authority");
11579        session.remove_metadata(SESSION_METADATA_KEY);
11580        session.remove_metadata(SESSION_BUILD_STATE_KEY);
11581        assert!(
11582            session.metadata().contains_key(SESSION_METADATA_KEY),
11583            "raw removal must not delete generated-authority session metadata"
11584        );
11585        assert!(
11586            session.metadata().contains_key(SESSION_BUILD_STATE_KEY),
11587            "raw removal must not delete generated-authority build state"
11588        );
11589        session.set_metadata(SESSION_DEFERRED_TURN_STATE_KEY, serde_json::json!({}));
11590        assert!(
11591            !session
11592                .metadata()
11593                .contains_key(SESSION_DEFERRED_TURN_STATE_KEY)
11594        );
11595        assert!(
11596            !session.backfill_metadata_if_absent(
11597                SESSION_SYSTEM_CONTEXT_STATE_KEY,
11598                serde_json::json!({})
11599            )
11600        );
11601
11602        let state = SessionSystemContextState::default();
11603        session
11604            .set_system_context_state(state.clone())
11605            .expect("typed setter should route through generated authority");
11606        session.remove_metadata(SESSION_SYSTEM_CONTEXT_STATE_KEY);
11607        assert_eq!(
11608            session
11609                .try_system_context_state()
11610                .expect("typed state should restore"),
11611            Some(state)
11612        );
11613
11614        session.metadata.insert(
11615            SESSION_SYSTEM_CONTEXT_STATE_KEY.to_string(),
11616            serde_json::json!("not-a-state"),
11617        );
11618        assert!(
11619            session.try_system_context_state().is_err(),
11620            "malformed generated authority state must not decode as absent/default"
11621        );
11622
11623        session.metadata.insert(
11624            SESSION_METADATA_KEY.to_string(),
11625            serde_json::json!("not-metadata"),
11626        );
11627        assert!(
11628            session.try_session_metadata().is_err(),
11629            "malformed session metadata must not decode as absent/default"
11630        );
11631
11632        session.metadata.insert(
11633            SESSION_BUILD_STATE_KEY.to_string(),
11634            serde_json::json!("not-build-state"),
11635        );
11636        assert!(
11637            session.try_build_state().is_err(),
11638            "malformed build state must not decode as absent/default"
11639        );
11640
11641        assert!(
11642            session
11643                .try_set_metadata(SESSION_TOOL_VISIBILITY_STATE_KEY, serde_json::json!({}))
11644                .is_err()
11645        );
11646        session
11647            .set_tool_visibility_state(
11648                AuthorizedSessionToolVisibilityState::from_generated_authority(
11649                    SessionToolVisibilityState::default(),
11650                ),
11651            )
11652            .expect("typed visibility setter should route through typed authority handoff");
11653        session.remove_metadata(SESSION_TOOL_VISIBILITY_STATE_KEY);
11654        assert!(
11655            session
11656                .metadata()
11657                .contains_key(SESSION_TOOL_VISIBILITY_STATE_KEY)
11658        );
11659        session.clear_tool_visibility_state();
11660        assert!(
11661            !session
11662                .metadata()
11663                .contains_key(SESSION_TOOL_VISIBILITY_STATE_KEY)
11664        );
11665        assert!(
11666            session
11667                .try_set_metadata(SESSION_REALTIME_TRANSCRIPT_STATE_KEY, serde_json::json!({}))
11668                .is_err()
11669        );
11670        let _ = session.append_realtime_transcript_event(RealtimeTranscriptEvent::ItemObserved {
11671            item_id: "rt-item".to_string(),
11672            previous_item_id: None,
11673            role: RealtimeTranscriptRole::User,
11674            response_id: None,
11675        });
11676        assert!(
11677            session
11678                .metadata()
11679                .contains_key(SESSION_REALTIME_TRANSCRIPT_STATE_KEY),
11680            "typed realtime transcript append should retain authority to persist its state"
11681        );
11682        session.metadata.insert(
11683            SESSION_REALTIME_TRANSCRIPT_STATE_KEY.to_string(),
11684            serde_json::json!("not-a-state"),
11685        );
11686        assert!(
11687            session.try_realtime_transcript_state().is_err(),
11688            "malformed realtime generated authority state must not decode as absent/default"
11689        );
11690    }
11691
11692    #[test]
11693    fn test_session_mob_tool_authority_context_persists_projection_without_authority_seal() {
11694        let mut session = Session::new();
11695        session
11696            .set_build_state(SessionBuildState::default())
11697            .expect("session build state should serialize");
11698        let authority = MobToolAuthorityContext::generated_for_test(
11699            crate::service::OpaquePrincipalToken::new("opaque-principal"),
11700            false,
11701            false,
11702            false,
11703            std::collections::BTreeSet::from(["mob-a".to_string()]),
11704            std::collections::BTreeMap::new(),
11705            None,
11706            Some("audit-1".to_string()),
11707        );
11708
11709        session
11710            .set_mob_tool_authority_context(Some(authority))
11711            .expect("authority should serialize");
11712        assert!(session.mob_tool_authority_context().is_none());
11713        let stored = session
11714            .build_state()
11715            .and_then(|state| state.mob_tool_authority_context)
11716            .expect("stored projection should deserialize");
11717        assert!(!stored.is_generated_authority_context());
11718        assert!(!stored.can_manage_mob("mob-a"));
11719
11720        session
11721            .set_mob_tool_authority_context(None)
11722            .expect("authority should clear");
11723        assert!(session.mob_tool_authority_context().is_none());
11724    }
11725
11726    #[test]
11727    fn test_session_build_state_rejects_forged_mob_authority_projection() {
11728        let mut session = Session::new();
11729        let authority = MobToolAuthorityContext::generated_for_test(
11730            crate::service::OpaquePrincipalToken::new("opaque-principal"),
11731            false,
11732            false,
11733            false,
11734            std::collections::BTreeSet::from(["mob-a".to_string()]),
11735            std::collections::BTreeMap::new(),
11736            None,
11737            Some("audit-1".to_string()),
11738        );
11739        let forged_projection: MobToolAuthorityContext =
11740            serde_json::from_value(serde_json::to_value(authority).expect("serialize authority"))
11741                .expect("deserialize projection");
11742        assert!(!forged_projection.is_generated_authority_context());
11743
11744        let err = session
11745            .set_build_state(SessionBuildState {
11746                mob_tool_authority_context: Some(forged_projection),
11747                ..Default::default()
11748            })
11749            .expect_err("forged build state must be rejected by generated authority");
11750        // The build-state-persist admission decision now lives in the canonical
11751        // SessionDocumentMachine durable-config region (LUC-524); the rejection
11752        // surfaces with that machine's authority wording.
11753        assert!(
11754            err.to_string()
11755                .contains("generated session document authority rejected"),
11756            "unexpected error: {err}"
11757        );
11758    }
11759
11760    #[test]
11761    fn test_session_tool_visibility_state_roundtrip() {
11762        let mut session = Session::new();
11763        let state = SessionToolVisibilityState {
11764            inherited_base_filter: ToolFilter::Allow(["visible".to_string()].into_iter().collect()),
11765            active_filter: ToolFilter::Allow(
11766                ["visible".to_string(), "missing".to_string()]
11767                    .into_iter()
11768                    .collect(),
11769            ),
11770            staged_filter: ToolFilter::Allow(
11771                ["visible".to_string(), "missing".to_string()]
11772                    .into_iter()
11773                    .collect(),
11774            ),
11775            active_revision: 1,
11776            staged_revision: 2,
11777            ..Default::default()
11778        };
11779
11780        session
11781            .set_tool_visibility_state(
11782                AuthorizedSessionToolVisibilityState::from_generated_authority(state.clone()),
11783            )
11784            .expect("tool visibility state should serialize");
11785        assert_eq!(session.tool_visibility_state().unwrap(), Some(state));
11786    }
11787
11788    #[test]
11789    fn test_session_tool_visibility_state_malformed_returns_error() {
11790        let mut session = Session::new();
11791        session.metadata.insert(
11792            SESSION_TOOL_VISIBILITY_STATE_KEY.to_string(),
11793            serde_json::json!({
11794                "active_filter": {
11795                    "unexpected_filter_kind": ["secret"]
11796                }
11797            }),
11798        );
11799
11800        assert!(
11801            session.tool_visibility_state().is_err(),
11802            "malformed canonical visibility metadata must not decode as absent/default"
11803        );
11804    }
11805
11806    #[test]
11807    fn test_session_serialization() {
11808        let mut session = Session::new();
11809        session.push(Message::User(UserMessage::text("Test".to_string())));
11810
11811        let json = serde_json::to_string(&session).unwrap();
11812        let parsed: Session = serde_json::from_str(&json).unwrap();
11813
11814        assert_eq!(parsed.id(), session.id());
11815        assert_eq!(parsed.messages().len(), 1);
11816        assert_eq!(parsed.version(), SESSION_VERSION);
11817    }
11818
11819    #[test]
11820    fn test_session_meta_from_session() {
11821        let mut session = Session::new();
11822        session.push(Message::User(UserMessage::text("Hello".to_string())));
11823        session.push(Message::BlockAssistant(BlockAssistantMessage {
11824            blocks: vec![AssistantBlock::Text {
11825                text: "Hi!".to_string(),
11826                meta: None,
11827            }],
11828            stop_reason: StopReason::EndTurn,
11829            identity: crate::types::TranscriptMessageIdentity::default(),
11830            created_at: crate::types::message_timestamp_now(),
11831        }));
11832        session.record_usage(Usage {
11833            input_tokens: 10,
11834            output_tokens: 5,
11835            cache_creation_tokens: None,
11836            cache_read_tokens: None,
11837        });
11838
11839        let meta = SessionMeta::from(&session);
11840        assert_eq!(meta.id, *session.id());
11841        assert_eq!(meta.message_count, 2);
11842        assert_eq!(meta.total_tokens, 15);
11843    }
11844
11845    #[test]
11846    fn system_context_state_preserves_applied_runtime_context() {
11847        let accepted_at = SystemTime::UNIX_EPOCH;
11848        let mut state = SessionSystemContextState::default();
11849        state
11850            .stage_append(
11851                &AppendSystemContextRequest {
11852                    content: crate::lifecycle::run_primitive::CoreRenderable::text(
11853                        "Authoritative peer token is birch seventeen.".to_string(),
11854                    ),
11855                    source: Some(
11856                        "peer_response_terminal:analyst:018f6f79-7a82-7c4e-a552-a3b86f9630f1"
11857                            .to_string(),
11858                    ),
11859                    idempotency_key: Some("018f6f79-7a82-7c4e-a552-a3b86f9630f1".to_string()),
11860                    source_kind: SystemContextSource::Normal,
11861                    peer_response_terminal: None,
11862                },
11863                accepted_at,
11864            )
11865            .expect("append should stage");
11866
11867        state.mark_pending_applied();
11868
11869        assert!(state.pending.is_empty());
11870        assert_eq!(state.applied.len(), 1);
11871        assert_eq!(
11872            state.applied[0].content.render_text(),
11873            "Authoritative peer token is birch seventeen."
11874        );
11875        assert_eq!(
11876            state.applied[0].source.as_deref(),
11877            Some("peer_response_terminal:analyst:018f6f79-7a82-7c4e-a552-a3b86f9630f1")
11878        );
11879
11880        let round_tripped: SessionSystemContextState =
11881            serde_json::from_value(serde_json::to_value(&state).expect("serialize state"))
11882                .expect("deserialize state");
11883        assert_eq!(round_tripped.applied, state.applied);
11884    }
11885
11886    #[test]
11887    fn active_turn_system_context_is_discarded_when_not_applied() {
11888        let mut state = SessionSystemContextState::default();
11889        state
11890            .stage_active_turn_append(
11891                &AppendSystemContextRequest {
11892                    content: crate::lifecycle::run_primitive::CoreRenderable::text(
11893                        "only for the active run".to_string(),
11894                    ),
11895                    source: Some("runtime:steer:input-1".to_string()),
11896                    idempotency_key: Some("runtime:steer:input-1".to_string()),
11897                    source_kind: SystemContextSource::RuntimeSteer,
11898                    peer_response_terminal: None,
11899                },
11900                SystemTime::UNIX_EPOCH,
11901            )
11902            .expect("active context should stage");
11903
11904        let discarded = state.discard_unapplied_active_turn_pending();
11905
11906        assert_eq!(discarded.len(), 1);
11907        assert!(state.pending.is_empty());
11908        assert!(state.applied.is_empty());
11909        assert!(state.active_turn_pending_keys.is_empty());
11910        assert!(state.active_turn_pending_indices.is_empty());
11911        assert!(
11912            state.seen.is_empty(),
11913            "discarded active-turn context should not block later idempotency keys"
11914        );
11915    }
11916
11917    #[test]
11918    fn keyless_active_turn_system_context_is_owned_and_discarded() {
11919        let mut state = SessionSystemContextState::default();
11920        state
11921            .stage_active_turn_append(
11922                &AppendSystemContextRequest {
11923                    content: crate::lifecycle::run_primitive::CoreRenderable::text(
11924                        "keyless active-turn context".to_string(),
11925                    ),
11926                    source: Some("test:keyless-active-turn".to_string()),
11927                    idempotency_key: None,
11928                    source_kind: SystemContextSource::RuntimeSteer,
11929                    peer_response_terminal: None,
11930                },
11931                SystemTime::UNIX_EPOCH,
11932            )
11933            .expect("keyless active context should stage");
11934
11935        assert!(state.active_turn_pending_keys.is_empty());
11936        assert_eq!(state.active_turn_pending_len(), 1);
11937        let discarded = state.discard_unapplied_active_turn_pending();
11938
11939        assert_eq!(discarded.len(), 1);
11940        assert!(state.pending.is_empty());
11941        assert_eq!(state.active_turn_pending_len(), 0);
11942    }
11943
11944    #[test]
11945    fn active_turn_system_context_can_roll_back_targeted_keys() {
11946        let mut state = SessionSystemContextState::default();
11947        for key in ["runtime:steer:input-1", "runtime:steer:input-2"] {
11948            state
11949                .stage_active_turn_append(
11950                    &AppendSystemContextRequest {
11951                        content: crate::lifecycle::run_primitive::CoreRenderable::text(format!(
11952                            "context for {key}"
11953                        )),
11954                        source: Some(key.to_string()),
11955                        idempotency_key: Some(key.to_string()),
11956                        source_kind: SystemContextSource::RuntimeSteer,
11957                        peer_response_terminal: None,
11958                    },
11959                    SystemTime::UNIX_EPOCH,
11960                )
11961                .expect("active context should stage");
11962        }
11963
11964        let discarded =
11965            state.discard_active_turn_pending_by_keys(&["runtime:steer:input-1".to_string()]);
11966
11967        assert_eq!(discarded.len(), 1);
11968        assert_eq!(
11969            discarded[0].idempotency_key.as_deref(),
11970            Some("runtime:steer:input-1")
11971        );
11972        assert_eq!(state.pending.len(), 1);
11973        assert_eq!(
11974            state.pending[0].idempotency_key.as_deref(),
11975            Some("runtime:steer:input-2")
11976        );
11977        assert!(!state.seen.contains_key("runtime:steer:input-1"));
11978        assert!(state.seen.contains_key("runtime:steer:input-2"));
11979        assert!(
11980            !state
11981                .active_turn_pending_keys
11982                .contains("runtime:steer:input-1")
11983        );
11984        assert!(
11985            state
11986                .active_turn_pending_keys
11987                .contains("runtime:steer:input-2")
11988        );
11989    }
11990
11991    #[test]
11992    fn active_turn_system_context_is_transient_when_boundary_consumes_it() {
11993        let mut state = SessionSystemContextState::default();
11994        state
11995            .stage_active_turn_append(
11996                &AppendSystemContextRequest {
11997                    content: crate::lifecycle::run_primitive::CoreRenderable::text(
11998                        "visible to this run".to_string(),
11999                    ),
12000                    source: Some("runtime:steer:input-2".to_string()),
12001                    idempotency_key: Some("runtime:steer:input-2".to_string()),
12002                    source_kind: SystemContextSource::RuntimeSteer,
12003                    peer_response_terminal: None,
12004                },
12005                SystemTime::UNIX_EPOCH,
12006            )
12007            .expect("active context should stage");
12008
12009        state.mark_pending_applied();
12010        let discarded = state.discard_unapplied_active_turn_pending();
12011
12012        assert!(discarded.is_empty());
12013        assert!(state.pending.is_empty());
12014        assert!(state.applied.is_empty());
12015        assert!(state.active_turn_pending_keys.is_empty());
12016        assert!(state.active_turn_pending_indices.is_empty());
12017        assert_eq!(
12018            state.seen.get("runtime:steer:input-2"),
12019            None,
12020            "consumed active-turn steer context must not become durable state"
12021        );
12022    }
12023
12024    #[test]
12025    fn discard_transient_runtime_steer_context_removes_steer_via_typed_marker() {
12026        let mut session = Session::new();
12027        // The runtime-steer fact is carried by the typed `source_kind`, not by
12028        // the `source` string. The durable peer fact uses the same `source`
12029        // string scheme but is marked `Normal`, so only the steers are removed.
12030        session.set_system_prompt(format!(
12031            "base{}{}{}{}",
12032            SYSTEM_CONTEXT_SEPARATOR,
12033            render_system_context_block(&PendingSystemContextAppend {
12034                content: crate::lifecycle::run_primitive::CoreRenderable::text(
12035                    "old steer".to_string()
12036                ),
12037                source: Some("steer-source-old".to_string()),
12038                idempotency_key: Some("steer-key-old".to_string()),
12039                source_kind: SystemContextSource::RuntimeSteer,
12040                peer_response_terminal: None,
12041                accepted_at: SystemTime::UNIX_EPOCH,
12042            }),
12043            SYSTEM_CONTEXT_SEPARATOR,
12044            render_system_context_block(&PendingSystemContextAppend {
12045                content: crate::lifecycle::run_primitive::CoreRenderable::text(
12046                    "durable peer fact".to_string()
12047                ),
12048                source: Some("peer_response_terminal:analyst:req".to_string()),
12049                idempotency_key: Some("peer_response_terminal:analyst:req".to_string()),
12050                source_kind: SystemContextSource::Normal,
12051                peer_response_terminal: None,
12052                accepted_at: SystemTime::UNIX_EPOCH,
12053            })
12054        ));
12055        session
12056            .set_system_context_state(SessionSystemContextState {
12057                pending: vec![PendingSystemContextAppend {
12058                    content: crate::lifecycle::run_primitive::CoreRenderable::text(
12059                        "pending steer".to_string(),
12060                    ),
12061                    source: Some("steer-source-pending".to_string()),
12062                    idempotency_key: Some("steer-key-pending".to_string()),
12063                    source_kind: SystemContextSource::RuntimeSteer,
12064                    peer_response_terminal: None,
12065                    accepted_at: SystemTime::UNIX_EPOCH,
12066                }],
12067                applied: vec![
12068                    PendingSystemContextAppend {
12069                        content: crate::lifecycle::run_primitive::CoreRenderable::text(
12070                            "old steer".to_string(),
12071                        ),
12072                        source: Some("steer-source-old".to_string()),
12073                        idempotency_key: Some("steer-key-old".to_string()),
12074                        source_kind: SystemContextSource::RuntimeSteer,
12075                        peer_response_terminal: None,
12076                        accepted_at: SystemTime::UNIX_EPOCH,
12077                    },
12078                    PendingSystemContextAppend {
12079                        content: crate::lifecycle::run_primitive::CoreRenderable::text(
12080                            "durable peer fact".to_string(),
12081                        ),
12082                        source: Some("peer_response_terminal:analyst:req".to_string()),
12083                        idempotency_key: Some("peer_response_terminal:analyst:req".to_string()),
12084                        source_kind: SystemContextSource::Normal,
12085                        peer_response_terminal: None,
12086                        accepted_at: SystemTime::UNIX_EPOCH,
12087                    },
12088                ],
12089                seen: BTreeMap::from([(
12090                    "steer-key-old".to_string(),
12091                    SeenSystemContextKey {
12092                        content: crate::lifecycle::run_primitive::CoreRenderable::text(
12093                            "old steer".to_string(),
12094                        ),
12095                        source: Some("steer-source-old".to_string()),
12096                        source_kind: SystemContextSource::RuntimeSteer,
12097                        state: SeenSystemContextState::Applied,
12098                    },
12099                )]),
12100                active_turn_pending_keys: BTreeSet::from(["steer-key-pending".to_string()]),
12101                active_turn_pending_indices: BTreeSet::from([0]),
12102            })
12103            .expect("system context state should serialize");
12104
12105        let removed = session.discard_transient_runtime_steer_context();
12106
12107        assert!(removed >= 4);
12108        let system_prompt = match session.messages().first() {
12109            Some(Message::System(system)) => system.content.as_str(),
12110            other => panic!("expected system prompt, got {other:?}"),
12111        };
12112        assert!(!system_prompt.contains("old steer"));
12113        assert!(system_prompt.contains("durable peer fact"));
12114        let state = session.system_context_state().unwrap_or_default();
12115        assert!(state.pending.is_empty());
12116        assert_eq!(state.applied.len(), 1);
12117        assert_eq!(state.applied[0].content.render_text(), "durable peer fact");
12118        assert!(state.seen.is_empty());
12119        assert!(state.active_turn_pending_keys.is_empty());
12120    }
12121
12122    #[test]
12123    fn append_system_context_blocks_records_typed_applied_context() {
12124        let append = PendingSystemContextAppend {
12125            content: crate::lifecycle::run_primitive::CoreRenderable::text(
12126                "Authoritative peer token is birch seventeen.".to_string(),
12127            ),
12128            source: Some(
12129                "peer_response_terminal:analyst:018f6f79-7a82-7c4e-a552-a3b86f9630f1".to_string(),
12130            ),
12131            idempotency_key: Some("018f6f79-7a82-7c4e-a552-a3b86f9630f1".to_string()),
12132            source_kind: SystemContextSource::Normal,
12133            peer_response_terminal: None,
12134            accepted_at: SystemTime::UNIX_EPOCH,
12135        };
12136        let mut session = Session::new();
12137
12138        session.append_system_context_blocks(std::slice::from_ref(&append));
12139
12140        let state = session
12141            .system_context_state()
12142            .expect("append should persist typed context state");
12143        assert_eq!(state.applied, vec![append]);
12144    }
12145
12146    fn roster_append() -> PendingSystemContextAppend {
12147        PendingSystemContextAppend {
12148            content: crate::lifecycle::run_primitive::CoreRenderable::text(
12149                "peer roster: lead-1, w-1".to_string(),
12150            ),
12151            source: Some("comms:roster".to_string()),
12152            idempotency_key: Some("comms:roster:v1".to_string()),
12153            source_kind: SystemContextSource::Normal,
12154            peer_response_terminal: None,
12155            accepted_at: SystemTime::UNIX_EPOCH,
12156        }
12157    }
12158
12159    fn resumed_session_with_context_appended_prompt(base: &str) -> Session {
12160        let mut session = Session::new();
12161        session.set_system_prompt(base.to_string());
12162        session.push(Message::User(UserMessage::text("hello".to_string())));
12163        session.append_system_context_blocks(std::slice::from_ref(&roster_append()));
12164        session
12165    }
12166
12167    #[test]
12168    fn reconcile_resumed_system_prompt_preserves_identical_base() {
12169        let mut session = Session::new();
12170        session.set_system_prompt("base prompt".to_string());
12171        session.push(Message::User(UserMessage::text("hello".to_string())));
12172        let digest_before = transcript_messages_digest(session.messages()).unwrap();
12173
12174        let outcome = session
12175            .reconcile_resumed_system_prompt("base prompt".to_string(), None)
12176            .expect("reconcile");
12177
12178        assert_eq!(
12179            outcome,
12180            ResumedSystemPromptReconciliation::PreservedContinuation
12181        );
12182        assert_eq!(
12183            transcript_messages_digest(session.messages()).unwrap(),
12184            digest_before,
12185            "identical base must leave the transcript revision unchanged"
12186        );
12187    }
12188
12189    #[test]
12190    fn reconcile_resumed_system_prompt_preserves_context_appended_base() {
12191        let mut session = resumed_session_with_context_appended_prompt("base prompt");
12192        let digest_before = transcript_messages_digest(session.messages()).unwrap();
12193
12194        let outcome = session
12195            .reconcile_resumed_system_prompt("base prompt".to_string(), None)
12196            .expect("reconcile");
12197
12198        assert_eq!(
12199            outcome,
12200            ResumedSystemPromptReconciliation::PreservedContinuation
12201        );
12202        assert_eq!(
12203            transcript_messages_digest(session.messages()).unwrap(),
12204            digest_before,
12205            "a base extended only by runtime context appends must stay untouched"
12206        );
12207        let system = match session.messages().first() {
12208            Some(Message::System(system)) => system.clone(),
12209            other => panic!("expected system message, got {other:?}"),
12210        };
12211        assert!(system.content.contains("peer roster: lead-1, w-1"));
12212        assert!(
12213            system.mutation_kind.is_runtime_context_append(),
12214            "the persisted mutation provenance must survive reconciliation"
12215        );
12216    }
12217
12218    #[test]
12219    fn reconcile_resumed_system_prompt_rewrites_changed_base_preserving_tail() {
12220        let mut session = resumed_session_with_context_appended_prompt("base prompt");
12221
12222        let outcome = session
12223            .reconcile_resumed_system_prompt("new base prompt".to_string(), None)
12224            .expect("reconcile");
12225
12226        assert_eq!(outcome, ResumedSystemPromptReconciliation::RewrittenBase);
12227        let system_content = match session.messages().first() {
12228            Some(Message::System(system)) => system.content.clone(),
12229            other => panic!("expected system message, got {other:?}"),
12230        };
12231        assert!(
12232            system_content.starts_with("new base prompt"),
12233            "the changed base must be applied: {system_content}"
12234        );
12235        assert!(
12236            system_content.contains("peer roster: lead-1, w-1"),
12237            "the runtime-applied context tail must survive the base change: {system_content}"
12238        );
12239        let state = session
12240            .transcript_history_state()
12241            .expect("history state deserializes")
12242            .expect("rewrite must record transcript history");
12243        assert_eq!(state.commits.len(), 1);
12244        assert_eq!(
12245            state.commits[0].reason.kind,
12246            RESUME_SYSTEM_PROMPT_REFRESH_REWRITE_REASON
12247        );
12248        assert_eq!(
12249            state.head,
12250            transcript_messages_digest(session.messages()).unwrap(),
12251            "the committed head must match the rewritten transcript"
12252        );
12253    }
12254
12255    #[test]
12256    fn reconcile_resumed_system_prompt_inserts_prompt_on_promptless_transcript() {
12257        let mut session = Session::new();
12258        session.push(Message::User(UserMessage::text("hello".to_string())));
12259
12260        let outcome = session
12261            .reconcile_resumed_system_prompt("late prompt".to_string(), None)
12262            .expect("reconcile");
12263
12264        assert_eq!(outcome, ResumedSystemPromptReconciliation::RewrittenBase);
12265        assert!(matches!(
12266            session.messages().first(),
12267            Some(Message::System(system)) if system.content == "late prompt"
12268        ));
12269        let state = session
12270            .transcript_history_state()
12271            .expect("history state deserializes")
12272            .expect("insert must record transcript history");
12273        assert_eq!(state.commits.len(), 1);
12274        assert_eq!(
12275            state.commits[0].reason.kind,
12276            RESUME_SYSTEM_PROMPT_REFRESH_REWRITE_REASON
12277        );
12278    }
12279
12280    fn leading_system_content(session: &Session) -> String {
12281        match session.messages().first() {
12282            Some(Message::System(system)) => system.content.clone(),
12283            other => panic!("expected leading system message, got {other:?}"),
12284        }
12285    }
12286
12287    #[test]
12288    fn reconcile_resumed_system_prompt_preserves_full_context_prompt_from_empty_base() {
12289        // Promptless/empty-base build: appends compose as the WHOLE System
12290        // content with no separator prefix. A resume with a non-empty
12291        // explicit base must carry the verified all-context tail onto the
12292        // new base instead of discarding it as an "empty tail".
12293        let mut session = Session::new();
12294        session.push(Message::User(UserMessage::text("hello".to_string())));
12295        session.append_system_context_blocks(std::slice::from_ref(&roster_append()));
12296        let all_context_content = leading_system_content(&session);
12297        assert!(all_context_content.contains("peer roster: lead-1, w-1"));
12298
12299        let outcome = session
12300            .reconcile_resumed_system_prompt("new base prompt".to_string(), None)
12301            .expect("reconcile");
12302
12303        assert_eq!(outcome, ResumedSystemPromptReconciliation::RewrittenBase);
12304        assert_eq!(
12305            leading_system_content(&session),
12306            format!("new base prompt{SYSTEM_CONTEXT_SEPARATOR}{all_context_content}"),
12307            "the all-context prompt must survive as the runtime tail of the new base"
12308        );
12309    }
12310
12311    #[test]
12312    fn reconcile_resumed_system_prompt_preserves_context_only_prompt_on_empty_base_resume() {
12313        // Empty-base → empty-base resume: the all-context prompt IS the
12314        // expected composition; it must be preserved untouched.
12315        let mut session = Session::new();
12316        session.push(Message::User(UserMessage::text("hello".to_string())));
12317        session.append_system_context_blocks(std::slice::from_ref(&roster_append()));
12318        let digest_before = transcript_messages_digest(session.messages()).unwrap();
12319
12320        let outcome = session
12321            .reconcile_resumed_system_prompt(String::new(), None)
12322            .expect("reconcile");
12323
12324        assert_eq!(
12325            outcome,
12326            ResumedSystemPromptReconciliation::PreservedContinuation
12327        );
12328        assert_eq!(
12329            transcript_messages_digest(session.messages()).unwrap(),
12330            digest_before
12331        );
12332    }
12333
12334    #[test]
12335    fn reconcile_resumed_system_prompt_applies_shortened_base_with_recorded_prior() {
12336        // The separator is ordinary markdown: a base prompt may legitimately
12337        // contain it. Shortening the base must be APPLIED (audited rewrite),
12338        // not silently classified as a preserved context-append continuation.
12339        let full_base = format!("part one{SYSTEM_CONTEXT_SEPARATOR}part two");
12340        let mut session = Session::new();
12341        session.set_system_prompt(full_base.clone());
12342        session.push(Message::User(UserMessage::text("hello".to_string())));
12343        session
12344            .set_build_state(SessionBuildState {
12345                assembled_system_prompt: Some(full_base),
12346                ..Default::default()
12347            })
12348            .expect("build state");
12349
12350        let outcome = session
12351            .reconcile_resumed_system_prompt("part one".to_string(), None)
12352            .expect("reconcile");
12353
12354        assert_eq!(outcome, ResumedSystemPromptReconciliation::RewrittenBase);
12355        assert_eq!(leading_system_content(&session), "part one");
12356    }
12357
12358    #[test]
12359    fn reconcile_resumed_system_prompt_applies_shortened_base_without_context_provenance() {
12360        // No recorded prior base, no applied records, and the persisted
12361        // prompt's mutation provenance is not a runtime context append: the
12362        // machine rejects the structural-extends continuation, so the
12363        // shortened base is applied instead of silently ignored.
12364        let full_base = format!("part one{SYSTEM_CONTEXT_SEPARATOR}part two");
12365        let mut session = Session::new();
12366        session.set_system_prompt(full_base);
12367        session.push(Message::User(UserMessage::text("hello".to_string())));
12368
12369        let outcome = session
12370            .reconcile_resumed_system_prompt("part one".to_string(), None)
12371            .expect("reconcile");
12372
12373        assert_eq!(outcome, ResumedSystemPromptReconciliation::RewrittenBase);
12374        assert_eq!(leading_system_content(&session), "part one");
12375    }
12376
12377    #[test]
12378    fn reconcile_resumed_system_prompt_preserves_appended_prompt_without_applied_records() {
12379        // The runtime persistence path sweeps applied records and pre-0.7.15
12380        // rows have no recorded assembled base. The typed
12381        // RuntimeContextAppend provenance on the persisted message still
12382        // admits the continuation through the machine fast path.
12383        let mut session = resumed_session_with_context_appended_prompt("base prompt");
12384        session
12385            .set_system_context_state(SessionSystemContextState::default())
12386            .expect("sweep applied records");
12387        let digest_before = transcript_messages_digest(session.messages()).unwrap();
12388
12389        let outcome = session
12390            .reconcile_resumed_system_prompt("base prompt".to_string(), None)
12391            .expect("reconcile");
12392
12393        assert_eq!(
12394            outcome,
12395            ResumedSystemPromptReconciliation::PreservedContinuation
12396        );
12397        assert_eq!(
12398            transcript_messages_digest(session.messages()).unwrap(),
12399            digest_before
12400        );
12401    }
12402
12403    #[test]
12404    fn reconcile_resumed_system_prompt_clears_orphaned_applied_records_on_tail_drop() {
12405        let mut session = resumed_session_with_context_appended_prompt("base prompt");
12406        // An out-of-band prompt mutation makes the applied records'
12407        // re-render no longer reproduce the persisted content (and no
12408        // assembled base was recorded): the tail is unverifiable and must be
12409        // dropped by the rewrite.
12410        session.set_system_prompt(format!(
12411            "mutated base{SYSTEM_CONTEXT_SEPARATOR}stale-looking tail"
12412        ));
12413
12414        let outcome = session
12415            .reconcile_resumed_system_prompt("new base prompt".to_string(), None)
12416            .expect("reconcile");
12417
12418        assert_eq!(outcome, ResumedSystemPromptReconciliation::RewrittenBase);
12419        assert_eq!(leading_system_content(&session), "new base prompt");
12420        let state = session.system_context_state().unwrap_or_default();
12421        assert!(
12422            state.applied.is_empty(),
12423            "orphaned applied records must be cleared so the context stays restorable"
12424        );
12425        assert!(
12426            state.seen.is_empty(),
12427            "orphaned idempotency keys must be cleared so keyed re-sends re-apply"
12428        );
12429
12430        // A host re-send of the same keyed append restores the context
12431        // instead of deduplicating against the dropped application.
12432        session.append_system_context_blocks(std::slice::from_ref(&roster_append()));
12433        assert!(
12434            leading_system_content(&session).contains("peer roster: lead-1, w-1"),
12435            "re-sent keyed context must re-apply after the drop"
12436        );
12437    }
12438
12439    #[test]
12440    fn append_system_context_blocks_renders_pre_marked_pending_context() {
12441        let accepted_at = SystemTime::UNIX_EPOCH;
12442        let mut state = SessionSystemContextState::default();
12443        state
12444            .stage_append(
12445                &AppendSystemContextRequest {
12446                    content: crate::lifecycle::run_primitive::CoreRenderable::text(
12447                        "Apply this staged context at the request boundary.".to_string(),
12448                    ),
12449                    source: Some("rpc/session_inject_context".to_string()),
12450                    idempotency_key: Some("ctx-boundary".to_string()),
12451                    source_kind: SystemContextSource::Normal,
12452                    peer_response_terminal: None,
12453                },
12454                accepted_at,
12455            )
12456            .expect("append should stage");
12457        let pending = state.pending.clone();
12458        state.mark_pending_applied();
12459        let mut session = Session::new();
12460        session
12461            .set_system_context_state(state)
12462            .expect("state should serialize");
12463
12464        session.append_system_context_blocks(&pending);
12465
12466        let system_prompt = session
12467            .messages()
12468            .first()
12469            .and_then(|message| match message {
12470                Message::System(system) => Some(system.content.as_str()),
12471                _ => None,
12472            })
12473            .unwrap_or_default();
12474        assert!(system_prompt.contains("Apply this staged context at the request boundary."));
12475        let state = session
12476            .system_context_state()
12477            .expect("append should persist typed context state");
12478        assert_eq!(state.applied.len(), 1);
12479        assert_eq!(
12480            state.seen["ctx-boundary"].state,
12481            SeenSystemContextState::Applied
12482        );
12483    }
12484
12485    #[test]
12486    fn append_system_context_blocks_renders_pre_marked_context_without_idempotency_key() {
12487        let accepted_at = SystemTime::UNIX_EPOCH;
12488        let mut state = SessionSystemContextState::default();
12489        state
12490            .stage_append(
12491                &AppendSystemContextRequest {
12492                    content: crate::lifecycle::run_primitive::CoreRenderable::text(
12493                        "Apply this unkeyed staged context at the request boundary.".to_string(),
12494                    ),
12495                    source: Some("rpc/session_inject_context".to_string()),
12496                    idempotency_key: None,
12497                    source_kind: SystemContextSource::Normal,
12498                    peer_response_terminal: None,
12499                },
12500                accepted_at,
12501            )
12502            .expect("append should stage");
12503        let pending = state.pending.clone();
12504        state.mark_pending_applied();
12505        let mut session = Session::new();
12506        session
12507            .set_system_context_state(state)
12508            .expect("state should serialize");
12509
12510        session.append_system_context_blocks(&pending);
12511
12512        let system_prompt = session
12513            .messages()
12514            .first()
12515            .and_then(|message| match message {
12516                Message::System(system) => Some(system.content.as_str()),
12517                _ => None,
12518            })
12519            .unwrap_or_default();
12520        assert!(
12521            system_prompt.contains("Apply this unkeyed staged context at the request boundary.")
12522        );
12523    }
12524
12525    /// K5 invariant: the typed `CoreRenderable` travels end-to-end through
12526    /// staging — the pending append stores the renderable itself, and the
12527    /// ONE lowering to prompt text happens at the transcript render seam.
12528    #[test]
12529    fn staged_system_context_carries_typed_renderable_to_render_seam() {
12530        use crate::lifecycle::run_primitive::CoreRenderable;
12531
12532        let accepted_at = SystemTime::UNIX_EPOCH;
12533        let mut state = SessionSystemContextState::default();
12534        let renderable = CoreRenderable::Json {
12535            value: serde_json::json!({"alert": "disk-full", "severity": 2}),
12536        };
12537        state
12538            .stage_append(
12539                &AppendSystemContextRequest {
12540                    content: renderable.clone(),
12541                    source: Some("ops/monitor".to_string()),
12542                    idempotency_key: Some("alert-1".to_string()),
12543                    source_kind: SystemContextSource::Normal,
12544                    peer_response_terminal: None,
12545                },
12546                accepted_at,
12547            )
12548            .expect("typed renderable append should stage");
12549
12550        // The pending append owns the typed renderable — no pre-flattened
12551        // text shadow exists anywhere on the staging path.
12552        assert_eq!(state.pending.len(), 1);
12553        assert_eq!(state.pending[0].content, renderable);
12554
12555        // Lowering happens exactly once, at the render seam, via the single
12556        // canonical projection.
12557        let rendered = render_system_context_block(&state.pending[0]);
12558        assert!(rendered.starts_with(SYSTEM_CONTEXT_RENDER_LABEL));
12559        assert!(
12560            rendered.contains(renderable.render_text().trim()),
12561            "render seam must lower via CoreRenderable::render_text: {rendered}"
12562        );
12563    }
12564
12565    #[test]
12566    fn append_system_context_blocks_skips_duplicate_idempotency_key() {
12567        let first = PendingSystemContextAppend {
12568            content: crate::lifecycle::run_primitive::CoreRenderable::text(
12569                "Authoritative peer token is birch seventeen.".to_string(),
12570            ),
12571            source: Some("peer_response_terminal:analyst:req-1".to_string()),
12572            idempotency_key: Some("req-1".to_string()),
12573            source_kind: SystemContextSource::Normal,
12574            peer_response_terminal: None,
12575            accepted_at: SystemTime::UNIX_EPOCH,
12576        };
12577        let duplicate = PendingSystemContextAppend {
12578            accepted_at: SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1),
12579            ..first.clone()
12580        };
12581        let mut session = Session::new();
12582
12583        session.append_system_context_blocks(std::slice::from_ref(&first));
12584        session.append_system_context_blocks(std::slice::from_ref(&duplicate));
12585
12586        let state = session
12587            .system_context_state()
12588            .expect("append should persist typed context state");
12589        assert_eq!(state.applied, vec![first]);
12590        let system_prompt = session
12591            .messages()
12592            .first()
12593            .and_then(|message| match message {
12594                Message::System(system) => Some(system.content.as_str()),
12595                _ => None,
12596            })
12597            .unwrap_or_default();
12598        assert_eq!(
12599            system_prompt
12600                .matches("Authoritative peer token is birch seventeen.")
12601                .count(),
12602            1
12603        );
12604    }
12605
12606    #[test]
12607    fn append_system_context_blocks_skips_conflicting_duplicate_idempotency_key() {
12608        let first = PendingSystemContextAppend {
12609            content: crate::lifecycle::run_primitive::CoreRenderable::text(
12610                "Authoritative peer token is birch seventeen.".to_string(),
12611            ),
12612            source: Some("peer_response_terminal:analyst:req-1".to_string()),
12613            idempotency_key: Some("req-1".to_string()),
12614            source_kind: SystemContextSource::Normal,
12615            peer_response_terminal: None,
12616            accepted_at: SystemTime::UNIX_EPOCH,
12617        };
12618        let conflicting = PendingSystemContextAppend {
12619            content: crate::lifecycle::run_primitive::CoreRenderable::text(
12620                "Conflicting peer token should not reach the prompt.".to_string(),
12621            ),
12622            accepted_at: SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1),
12623            ..first.clone()
12624        };
12625        let mut session = Session::new();
12626
12627        session.append_system_context_blocks(std::slice::from_ref(&first));
12628        session.append_system_context_blocks(std::slice::from_ref(&conflicting));
12629
12630        let state = session
12631            .system_context_state()
12632            .expect("append should persist typed context state");
12633        assert_eq!(state.applied, vec![first]);
12634        let system_prompt = session
12635            .messages()
12636            .first()
12637            .and_then(|message| match message {
12638                Message::System(system) => Some(system.content.as_str()),
12639                _ => None,
12640            })
12641            .unwrap_or_default();
12642        assert!(system_prompt.contains("Authoritative peer token is birch seventeen."));
12643        assert!(!system_prompt.contains("Conflicting peer token should not reach the prompt."));
12644    }
12645
12646    // ------------------------------------------------------------------
12647    // T9/T10: realtime transcript lane materialization.
12648    //
12649    // The display-text lane (`AssistantTextDelta`) materializes as
12650    // `AssistantBlock::Text`; the spoken-transcript lane
12651    // (`AssistantTranscriptDelta`) materializes as
12652    // `AssistantBlock::Transcript { source: TranscriptSource::Spoken }`.
12653    // These regressions pin both flushes and prove the materializer
12654    // dispatches on the per-item `TranscriptLane`.
12655    // ------------------------------------------------------------------
12656
12657    #[test]
12658    fn realtime_transcript_assistant_transcript_delta_materializes_transcript_block() {
12659        let mut session = Session::new();
12660
12661        let delta = RealtimeTranscriptEvent::AssistantTranscriptDelta {
12662            response_id: "resp_spoken".to_string(),
12663            delta_id: "evt_delta_spoken_1".to_string(),
12664            item_id: "item_spoken".to_string(),
12665            previous_item_id: None,
12666            content_index: 0,
12667            delta: "I said hi".to_string(),
12668        };
12669        assert!(
12670            session.append_realtime_transcript_event(delta).is_inert(),
12671            "delta alone is inert until turn-completed flushes"
12672        );
12673
12674        let terminal = RealtimeTranscriptEvent::AssistantTurnCompleted {
12675            response_id: "resp_spoken".to_string(),
12676            stop_reason: StopReason::EndTurn,
12677            usage: Usage::default(),
12678        };
12679        let outcome = session.append_realtime_transcript_event(terminal);
12680        assert_eq!(outcome.materialized_messages.len(), 1);
12681
12682        // T9/T10: must be a Transcript block, NOT Text.
12683        let messages = session.messages();
12684        assert_eq!(messages.len(), 1);
12685        match &messages[0] {
12686            Message::BlockAssistant(assistant) => {
12687                assert_eq!(assistant.blocks.len(), 1);
12688                match &assistant.blocks[0] {
12689                    AssistantBlock::Transcript { text, source, .. } => {
12690                        assert_eq!(text, "I said hi");
12691                        assert_eq!(*source, crate::types::TranscriptSource::Spoken);
12692                    }
12693                    other => unreachable!(
12694                        "AssistantTranscriptDelta must materialize as AssistantBlock::Transcript, got {other:?}"
12695                    ),
12696                }
12697            }
12698            other => unreachable!("expected BlockAssistant message, got {other:?}"),
12699        }
12700    }
12701
12702    #[test]
12703    fn round4_cc4_in_flight_response_ids_lists_distinct_unmaterialized_responses() {
12704        // CC4 (Round-4 architectural reconciliation): the helper that
12705        // powers `signal_turn_interrupt`'s cross-layer fan-out must
12706        // return every distinct provider response_id that has at least
12707        // one unmaterialized assistant item, EXCLUDING already-discarded
12708        // responses and EXCLUDING the user role.
12709        let mut session = Session::new();
12710
12711        // Two transcript-delta items on resp_a (different content_index
12712        // ranges), one on resp_b. resp_c gets a delta and is then
12713        // discarded explicitly via AssistantTurnInterrupted.
12714        for (i, response_id) in [
12715            ("resp_a", "resp_a"),
12716            ("resp_a_extra", "resp_a"),
12717            ("resp_b", "resp_b"),
12718            ("resp_c", "resp_c"),
12719        ]
12720        .iter()
12721        .enumerate()
12722        {
12723            let event = RealtimeTranscriptEvent::AssistantTranscriptDelta {
12724                response_id: response_id.1.to_string(),
12725                delta_id: format!("delta_{i}"),
12726                item_id: response_id.0.to_string(),
12727                previous_item_id: None,
12728                content_index: 0,
12729                delta: "x".to_string(),
12730            };
12731            let _ = session.append_realtime_transcript_event(event);
12732        }
12733
12734        // Discard resp_c — it should not appear in the in-flight list.
12735        let _ = session.append_realtime_transcript_event(
12736            RealtimeTranscriptEvent::AssistantTurnInterrupted {
12737                response_id: "resp_c".to_string(),
12738            },
12739        );
12740
12741        // User-role item should never appear (CC4 only fans interrupts
12742        // to assistant responses).
12743        let _ = session.append_realtime_transcript_event(
12744            RealtimeTranscriptEvent::UserTranscriptFinal {
12745                item_id: "u_item".to_string(),
12746                previous_item_id: None,
12747                content_index: 0,
12748                text: "hi".to_string(),
12749            },
12750        );
12751
12752        let in_flight = session.in_flight_realtime_assistant_response_ids();
12753        assert!(in_flight.contains(&"resp_a".to_string()), "{in_flight:?}");
12754        assert!(in_flight.contains(&"resp_b".to_string()), "{in_flight:?}");
12755        assert!(
12756            !in_flight.contains(&"resp_c".to_string()),
12757            "discarded response must not appear in in_flight: {in_flight:?}"
12758        );
12759        // resp_a appears exactly once even though two items reference it.
12760        assert_eq!(
12761            in_flight.iter().filter(|r| *r == "resp_a").count(),
12762            1,
12763            "distinct response_ids only: {in_flight:?}"
12764        );
12765    }
12766
12767    #[test]
12768    fn round4_cc2_assistant_turn_completed_after_transcript_deltas_materializes_transcript() {
12769        // CC2 (Round-4 architectural reconciliation): once
12770        // `signal_turn_completed` synthesizes
12771        // `RealtimeTranscriptEvent::AssistantTurnCompleted`, the staging
12772        // materializer commits every staged transcript-delta item for
12773        // that response_id as `AssistantBlock::Transcript { Spoken }`.
12774        // This pins the production end-to-end shape the sink relies on.
12775        let mut session = Session::new();
12776
12777        let delta = RealtimeTranscriptEvent::AssistantTranscriptDelta {
12778            response_id: "resp_cc2".to_string(),
12779            delta_id: "delta_cc2_1".to_string(),
12780            item_id: "item_cc2".to_string(),
12781            previous_item_id: None,
12782            content_index: 0,
12783            delta: "hello world".to_string(),
12784        };
12785        assert!(session.append_realtime_transcript_event(delta).is_inert());
12786
12787        // Pre-completion: in-flight list reports resp_cc2.
12788        assert_eq!(
12789            session.in_flight_realtime_assistant_response_ids(),
12790            vec!["resp_cc2".to_string()]
12791        );
12792
12793        let outcome = session.append_realtime_transcript_event(
12794            RealtimeTranscriptEvent::AssistantTurnCompleted {
12795                response_id: "resp_cc2".to_string(),
12796                stop_reason: StopReason::EndTurn,
12797                usage: Usage::default(),
12798            },
12799        );
12800        assert_eq!(outcome.materialized_messages.len(), 1);
12801
12802        // Post-completion: in-flight list is empty (item is materialized).
12803        assert!(
12804            session
12805                .in_flight_realtime_assistant_response_ids()
12806                .is_empty(),
12807            "materialized items must not appear in in_flight_realtime_assistant_response_ids"
12808        );
12809
12810        let messages = session.messages();
12811        let assistant = messages.iter().find_map(|m| match m {
12812            Message::BlockAssistant(a) => Some(a),
12813            _ => None,
12814        });
12815        let assistant = assistant.expect("assistant block message expected");
12816        assert_eq!(assistant.blocks.len(), 1);
12817        assert!(matches!(
12818            &assistant.blocks[0],
12819            AssistantBlock::Transcript {
12820                source: crate::types::TranscriptSource::Spoken,
12821                ..
12822            }
12823        ));
12824    }
12825
12826    #[test]
12827    fn realtime_transcript_assistant_text_delta_still_materializes_text_block() {
12828        // Counter-regression: the display-text lane must continue to
12829        // produce `AssistantBlock::Text` after T9/T10. Prevents an
12830        // accidental cross-lane flip.
12831        let mut session = Session::new();
12832
12833        let delta = RealtimeTranscriptEvent::AssistantTextDelta {
12834            response_id: "resp_display".to_string(),
12835            delta_id: "evt_delta_display_1".to_string(),
12836            item_id: "item_display".to_string(),
12837            previous_item_id: None,
12838            content_index: 0,
12839            delta: "I wrote".to_string(),
12840        };
12841        let _ = session.append_realtime_transcript_event(delta);
12842
12843        let terminal = RealtimeTranscriptEvent::AssistantTurnCompleted {
12844            response_id: "resp_display".to_string(),
12845            stop_reason: StopReason::EndTurn,
12846            usage: Usage::default(),
12847        };
12848        let outcome = session.append_realtime_transcript_event(terminal);
12849        assert_eq!(outcome.materialized_messages.len(), 1);
12850
12851        let messages = session.messages();
12852        match &messages[0] {
12853            Message::BlockAssistant(assistant) => match &assistant.blocks[0] {
12854                AssistantBlock::Text { text, .. } => assert_eq!(text, "I wrote"),
12855                other => unreachable!(
12856                    "AssistantTextDelta must keep materializing AssistantBlock::Text, got {other:?}"
12857                ),
12858            },
12859            other => unreachable!("expected BlockAssistant message, got {other:?}"),
12860        }
12861    }
12862
12863    #[test]
12864    fn round4_cc7_mixed_response_persists_text_and_transcript_in_order() {
12865        // CC7 (Round-4 adversarial-verifier follow-up): a single mixed-modality
12866        // realtime response that emits BOTH display-text deltas
12867        // (`AssistantTextDelta`) AND spoken-transcript deltas
12868        // (`AssistantTranscriptDelta`) under the same response_id must
12869        // materialize as ONE `Message::BlockAssistant` whose `blocks` field
12870        // contains exactly two ordered entries:
12871        //   1. AssistantBlock::Text       (display-text lane)
12872        //   2. AssistantBlock::Transcript { source: Spoken } (spoken lane)
12873        // Pre-fix the materializer emitted one Message::BlockAssistant per
12874        // staged item, splitting the mixed response into two messages.
12875        //
12876        // This test drives the production materializer end-to-end: deltas
12877        // stage in `SessionRealtimeTranscriptState`; `AssistantTurnCompleted`
12878        // triggers the materializer; canonical history is the assertion
12879        // surface — exactly the same code path that
12880        // `SessionServiceProjectionSink::signal_turn_completed` invokes via
12881        // `runtime.append_realtime_transcript_event` in production.
12882        let mut session = Session::new();
12883
12884        // Provider-arrival order: display first, then spoken.
12885        let display_a = RealtimeTranscriptEvent::AssistantTextDelta {
12886            response_id: "resp_mixed_1".to_string(),
12887            delta_id: "delta_disp_1".to_string(),
12888            item_id: "item_display".to_string(),
12889            previous_item_id: None,
12890            content_index: 0,
12891            delta: "Here's the report:".to_string(),
12892        };
12893        assert!(
12894            session
12895                .append_realtime_transcript_event(display_a)
12896                .is_inert()
12897        );
12898
12899        let display_b = RealtimeTranscriptEvent::AssistantTextDelta {
12900            response_id: "resp_mixed_1".to_string(),
12901            delta_id: "delta_disp_2".to_string(),
12902            item_id: "item_display".to_string(),
12903            previous_item_id: None,
12904            content_index: 0,
12905            delta: " (still writing)".to_string(),
12906        };
12907        assert!(
12908            session
12909                .append_realtime_transcript_event(display_b)
12910                .is_inert()
12911        );
12912
12913        // Spoken items chain after the display item to mirror provider
12914        // arrival semantics — `previous_item_id` carries arrival ordering
12915        // that the materializer must preserve as block ordering inside the
12916        // single emitted message.
12917        let spoken_a = RealtimeTranscriptEvent::AssistantTranscriptDelta {
12918            response_id: "resp_mixed_1".to_string(),
12919            delta_id: "delta_spoken_1".to_string(),
12920            item_id: "item_spoken".to_string(),
12921            previous_item_id: Some("item_display".to_string()),
12922            content_index: 0,
12923            delta: "I'm reading the report aloud:".to_string(),
12924        };
12925        assert!(
12926            session
12927                .append_realtime_transcript_event(spoken_a)
12928                .is_inert()
12929        );
12930
12931        let spoken_b = RealtimeTranscriptEvent::AssistantTranscriptDelta {
12932            response_id: "resp_mixed_1".to_string(),
12933            delta_id: "delta_spoken_2".to_string(),
12934            item_id: "item_spoken".to_string(),
12935            previous_item_id: Some("item_display".to_string()),
12936            content_index: 0,
12937            delta: " sentence two.".to_string(),
12938        };
12939        assert!(
12940            session
12941                .append_realtime_transcript_event(spoken_b)
12942                .is_inert()
12943        );
12944
12945        // TurnCompleted triggers the materializer to flush all staged items
12946        // for this response_id into ONE BlockAssistant message.
12947        let outcome = session.append_realtime_transcript_event(
12948            RealtimeTranscriptEvent::AssistantTurnCompleted {
12949                response_id: "resp_mixed_1".to_string(),
12950                stop_reason: StopReason::EndTurn,
12951                usage: Usage {
12952                    input_tokens: 11,
12953                    output_tokens: 22,
12954                    cache_creation_tokens: None,
12955                    cache_read_tokens: None,
12956                },
12957            },
12958        );
12959        // Materializer reports two staged items got materialized.
12960        assert_eq!(outcome.materialized_messages.len(), 2);
12961
12962        // Canonical history MUST contain exactly ONE BlockAssistant message
12963        // (the CC7 fix: mixed lanes interleave into one message, not two).
12964        let messages = session.messages();
12965        let assistants: Vec<&BlockAssistantMessage> = messages
12966            .iter()
12967            .filter_map(|m| match m {
12968                Message::BlockAssistant(a) => Some(a),
12969                _ => None,
12970            })
12971            .collect();
12972        assert_eq!(
12973            assistants.len(),
12974            1,
12975            "mixed display+spoken response under one response_id must produce exactly ONE BlockAssistant message, got: {assistants:?}"
12976        );
12977        let assistant = assistants[0];
12978        assert_eq!(
12979            assistant.blocks.len(),
12980            2,
12981            "mixed response message must carry both blocks: {:?}",
12982            assistant.blocks
12983        );
12984
12985        // Block 0: display-text (concatenated deltas).
12986        match &assistant.blocks[0] {
12987            AssistantBlock::Text { text, .. } => {
12988                assert_eq!(text, "Here's the report: (still writing)");
12989            }
12990            other => unreachable!(
12991                "first block must be AssistantBlock::Text (display lane), got {other:?}"
12992            ),
12993        }
12994        // Block 1: spoken transcript (concatenated deltas), tagged Spoken.
12995        match &assistant.blocks[1] {
12996            AssistantBlock::Transcript { text, source, .. } => {
12997                assert_eq!(text, "I'm reading the report aloud: sentence two.");
12998                assert_eq!(*source, crate::types::TranscriptSource::Spoken);
12999            }
13000            other => unreachable!(
13001                "second block must be AssistantBlock::Transcript {{ source: Spoken }}, got {other:?}"
13002            ),
13003        }
13004
13005        // Usage was recorded once for the turn.
13006        assert_eq!(session.usage.input_tokens, 11);
13007        assert_eq!(session.usage.output_tokens, 22);
13008    }
13009
13010    #[test]
13011    fn round5_r55_mixed_response_barge_in_preserves_display_drops_spoken() {
13012        // R5-5 (Round-5 contract update): barge-in MUST filter staged items
13013        // by lane — `Spoken` is invalidated (the user spoke over the audio
13014        // they were hearing) but `Display` survives as committed history
13015        // (sideband display text from the same response is not "spoken
13016        // over"). Round-4's `round4_cc7_mixed_response_barge_in_discards_*`
13017        // pinned the wrong invariant; this test replaces it.
13018        //
13019        // Architectural decision: `AssistantTurnInterrupted` is terminal for
13020        // the response on the realtime-staging path — any later
13021        // `AssistantTurnCompleted { stop_reason: Cancelled }` short-circuits
13022        // via the `discarded_assistant_response_ids` guard. So the
13023        // Interrupted handler must seed a synthetic
13024        // `assistant_completions` entry (`StopReason::Cancelled`,
13025        // `Usage::default()`) so retained Display items materialize
13026        // immediately rather than stranding forever.
13027        let mut session = Session::new();
13028
13029        let display = RealtimeTranscriptEvent::AssistantTextDelta {
13030            response_id: "resp_mixed_2".to_string(),
13031            delta_id: "delta_disp_1".to_string(),
13032            item_id: "item_display_2".to_string(),
13033            previous_item_id: None,
13034            content_index: 0,
13035            delta: "Working on the report...".to_string(),
13036        };
13037        let _ = session.append_realtime_transcript_event(display);
13038
13039        let spoken = RealtimeTranscriptEvent::AssistantTranscriptDelta {
13040            response_id: "resp_mixed_2".to_string(),
13041            delta_id: "delta_spoken_1".to_string(),
13042            item_id: "item_spoken_2".to_string(),
13043            previous_item_id: Some("item_display_2".to_string()),
13044            content_index: 0,
13045            delta: "I'm reading the report".to_string(),
13046        };
13047        let _ = session.append_realtime_transcript_event(spoken);
13048
13049        // Barge-in arrives BEFORE TurnCompleted. The Display item with
13050        // staged content materializes immediately under the synthetic
13051        // Cancelled completion.
13052        let outcome = session.append_realtime_transcript_event(
13053            RealtimeTranscriptEvent::AssistantTurnInterrupted {
13054                response_id: "resp_mixed_2".to_string(),
13055            },
13056        );
13057        assert_eq!(
13058            outcome.materialized_messages.len(),
13059            1,
13060            "Display lane item must materialize on Interrupted: {outcome:?}"
13061        );
13062
13063        // A late `AssistantTurnCompleted` (the provider's response.done
13064        // emitted after cancel) must be a no-op: the Display item is
13065        // already materialized; the Spoken item was dropped at Interrupted.
13066        let late_completion = session.append_realtime_transcript_event(
13067            RealtimeTranscriptEvent::AssistantTurnCompleted {
13068                response_id: "resp_mixed_2".to_string(),
13069                stop_reason: StopReason::Cancelled,
13070                usage: Usage::default(),
13071            },
13072        );
13073        assert_eq!(
13074            late_completion.materialized_messages.len(),
13075            0,
13076            "post-barge-in TurnCompleted must not resurrect anything"
13077        );
13078
13079        // Canonical history: exactly one BlockAssistant carrying the
13080        // Display text (no Transcript block — Spoken was dropped).
13081        let messages = session.messages();
13082        let assistants: Vec<&BlockAssistantMessage> = messages
13083            .iter()
13084            .filter_map(|m| match m {
13085                Message::BlockAssistant(a) => Some(a),
13086                _ => None,
13087            })
13088            .collect();
13089        assert_eq!(
13090            assistants.len(),
13091            1,
13092            "barge-in must commit exactly one BlockAssistant containing the Display lane: {assistants:?}"
13093        );
13094        let assistant = assistants[0];
13095        assert_eq!(assistant.blocks.len(), 1, "blocks: {:?}", assistant.blocks);
13096        match &assistant.blocks[0] {
13097            AssistantBlock::Text { text, .. } => {
13098                assert_eq!(text, "Working on the report...");
13099            }
13100            other => {
13101                unreachable!("Display lane must materialize as AssistantBlock::Text, got {other:?}")
13102            }
13103        }
13104        // No Transcript block — Spoken lane was dropped.
13105        assert!(
13106            !assistant
13107                .blocks
13108                .iter()
13109                .any(|b| matches!(b, AssistantBlock::Transcript { .. })),
13110            "Spoken lane must be dropped on barge-in"
13111        );
13112
13113        // The in-flight tracker reports the response as no longer in flight
13114        // (the Display item is materialized; the Spoken item is skipped).
13115        assert!(
13116            !session
13117                .in_flight_realtime_assistant_response_ids()
13118                .contains(&"resp_mixed_2".to_string()),
13119            "barged-in response must not appear in in_flight_realtime_assistant_response_ids"
13120        );
13121    }
13122
13123    #[test]
13124    fn round5_r55_barge_in_preserves_display_lane_drops_spoken() {
13125        // R5-5 unit test: pin the lane-filter behavior at the staged-item
13126        // level (no chained predecessor). One Display item, one Spoken item,
13127        // both unchained, both staged before Interrupted.
13128        let mut session = Session::new();
13129
13130        let _ =
13131            session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
13132                response_id: "resp_a".to_string(),
13133                delta_id: "delta_d_1".to_string(),
13134                item_id: "item_display".to_string(),
13135                previous_item_id: None,
13136                content_index: 0,
13137                delta: "display-text".to_string(),
13138            });
13139        let _ = session.append_realtime_transcript_event(
13140            RealtimeTranscriptEvent::AssistantTranscriptDelta {
13141                response_id: "resp_a".to_string(),
13142                delta_id: "delta_s_1".to_string(),
13143                item_id: "item_spoken".to_string(),
13144                previous_item_id: None,
13145                content_index: 0,
13146                delta: "spoken-transcript".to_string(),
13147            },
13148        );
13149
13150        let outcome = session.append_realtime_transcript_event(
13151            RealtimeTranscriptEvent::AssistantTurnInterrupted {
13152                response_id: "resp_a".to_string(),
13153            },
13154        );
13155        // Display materializes, Spoken does not.
13156        assert_eq!(outcome.materialized_messages.len(), 1);
13157
13158        let messages = session.messages();
13159        let assistants: Vec<&BlockAssistantMessage> = messages
13160            .iter()
13161            .filter_map(|m| match m {
13162                Message::BlockAssistant(a) => Some(a),
13163                _ => None,
13164            })
13165            .collect();
13166        assert_eq!(assistants.len(), 1);
13167        // Single Text block (the Display lane) — no Transcript.
13168        assert_eq!(assistants[0].blocks.len(), 1);
13169        match &assistants[0].blocks[0] {
13170            AssistantBlock::Text { text, .. } => assert_eq!(text, "display-text"),
13171            other => unreachable!("expected Text, got {other:?}"),
13172        }
13173    }
13174
13175    #[test]
13176    fn round5_r55_barge_in_finalizes_retained_display_into_committed_block() {
13177        // R5-5: the architectural decision — Interrupted is terminal for the
13178        // response. Display lane must commit at Interrupted time, not wait
13179        // on a hypothetical AssistantTurnCompleted that may never arrive
13180        // (or arrives Cancelled and short-circuits).
13181        let mut session = Session::new();
13182
13183        let _ =
13184            session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
13185                response_id: "resp_a".to_string(),
13186                delta_id: "delta_d_1".to_string(),
13187                item_id: "item_display".to_string(),
13188                previous_item_id: None,
13189                content_index: 0,
13190                delta: "committed-display-text".to_string(),
13191            });
13192
13193        // Pre-condition: nothing committed yet.
13194        assert!(session.messages().is_empty());
13195
13196        let outcome = session.append_realtime_transcript_event(
13197            RealtimeTranscriptEvent::AssistantTurnInterrupted {
13198                response_id: "resp_a".to_string(),
13199            },
13200        );
13201        assert_eq!(
13202            outcome.materialized_messages.len(),
13203            1,
13204            "Interrupted must finalize retained Display lane immediately"
13205        );
13206
13207        // Post-condition: BlockAssistant in canonical history, no Transcript.
13208        let messages = session.messages();
13209        assert_eq!(messages.len(), 1);
13210        match &messages[0] {
13211            Message::BlockAssistant(assistant) => {
13212                assert_eq!(assistant.blocks.len(), 1);
13213                match &assistant.blocks[0] {
13214                    AssistantBlock::Text { text, .. } => {
13215                        assert_eq!(text, "committed-display-text");
13216                    }
13217                    other => unreachable!("expected Text, got {other:?}"),
13218                }
13219            }
13220            other => unreachable!("expected BlockAssistant, got {other:?}"),
13221        }
13222    }
13223
13224    #[test]
13225    fn round5_r56_truncation_promotes_default_lane_item_to_spoken() {
13226        // R5-6: when truncation is the first content-bearing event for an
13227        // item (no prior delta), the staged item's lane MUST be promoted to
13228        // Spoken so the materializer commits as `AssistantBlock::Transcript`.
13229        // Without the explicit promotion, the lane stays `Display` (the
13230        // default) and the heard audio transcript persists as
13231        // `AssistantBlock::Text`.
13232        let mut session = Session::new();
13233
13234        let _ = session.append_realtime_transcript_event(
13235            RealtimeTranscriptEvent::AssistantTranscriptTruncated {
13236                response_id: "resp_a".to_string(),
13237                item_id: "item_a".to_string(),
13238                content_index: 0,
13239                text: "what was actually heard".to_string(),
13240            },
13241        );
13242
13243        let outcome = session.append_realtime_transcript_event(
13244            RealtimeTranscriptEvent::AssistantTurnCompleted {
13245                response_id: "resp_a".to_string(),
13246                stop_reason: StopReason::EndTurn,
13247                usage: Usage::default(),
13248            },
13249        );
13250        assert_eq!(outcome.materialized_messages.len(), 1);
13251
13252        assert_eq!(session.messages().len(), 1);
13253        match &session.messages()[0] {
13254            Message::BlockAssistant(assistant) => {
13255                assert_eq!(assistant.blocks.len(), 1);
13256                match &assistant.blocks[0] {
13257                    AssistantBlock::Transcript { text, source, .. } => {
13258                        assert_eq!(text, "what was actually heard");
13259                        assert_eq!(*source, crate::types::TranscriptSource::Spoken);
13260                    }
13261                    other => unreachable!(
13262                        "truncation-only path must materialize as AssistantBlock::Transcript, got {other:?}"
13263                    ),
13264                }
13265            }
13266            other => unreachable!("expected BlockAssistant, got {other:?}"),
13267        }
13268    }
13269
13270    #[test]
13271    fn round5_r56_truncation_after_display_delta_is_no_op_keeping_display_content() {
13272        // R5-6 edge case: a Display delta arrived first and staged Display
13273        // content; a truncation event arrives for the SAME item id
13274        // (provider bug — truncation only applies to spoken/audio output).
13275        // Contract: the staged Display content must NOT be clobbered by
13276        // the truncation text. `promote_item_lane` keeps the existing
13277        // Display lane and emits a `tracing::warn!`; the truncation arm
13278        // sees the lane stayed Display and skips the segment-write.
13279        let mut session = Session::new();
13280
13281        let _ =
13282            session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
13283                response_id: "resp_a".to_string(),
13284                delta_id: "delta_d_1".to_string(),
13285                item_id: "item_a".to_string(),
13286                previous_item_id: None,
13287                content_index: 0,
13288                delta: "display-text-from-delta".to_string(),
13289            });
13290
13291        let _ = session.append_realtime_transcript_event(
13292            RealtimeTranscriptEvent::AssistantTranscriptTruncated {
13293                response_id: "resp_a".to_string(),
13294                item_id: "item_a".to_string(),
13295                content_index: 0,
13296                text: "spoken-truncation-text".to_string(),
13297            },
13298        );
13299
13300        let _ = session.append_realtime_transcript_event(
13301            RealtimeTranscriptEvent::AssistantTurnCompleted {
13302                response_id: "resp_a".to_string(),
13303                stop_reason: StopReason::EndTurn,
13304                usage: Usage::default(),
13305            },
13306        );
13307
13308        // Display content survives unchanged — the truncation text was
13309        // refused. Materializes as `AssistantBlock::Text` (Display lane).
13310        assert_eq!(session.messages().len(), 1);
13311        match &session.messages()[0] {
13312            Message::BlockAssistant(assistant) => {
13313                assert_eq!(assistant.blocks.len(), 1);
13314                match &assistant.blocks[0] {
13315                    AssistantBlock::Text { text, .. } => {
13316                        assert_eq!(text, "display-text-from-delta");
13317                    }
13318                    other => unreachable!(
13319                        "Display content must survive misrouted truncation, got {other:?}"
13320                    ),
13321                }
13322            }
13323            other => unreachable!("expected BlockAssistant, got {other:?}"),
13324        }
13325    }
13326
13327    /// R5-6 sibling: a Spoken-classified item (transcript-truncation
13328    /// arrived first and locked the lane to Spoken) must reject a later
13329    /// `AssistantTextDelta` rather than silently appending the Display
13330    /// text into the Spoken-locked content_segment. Pre-fix the delta
13331    /// arm called `promote_item_lane` and unconditionally pushed the
13332    /// delta — clobbering the lane invariant. Post-fix the delta is
13333    /// dropped (warn fires) and the Spoken-truncation text survives.
13334    #[test]
13335    fn round5_r56_sibling_display_delta_skipped_on_spoken_item() {
13336        let mut session = Session::new();
13337
13338        // Truncation arrives first and locks the item to the Spoken lane.
13339        let _ = session.append_realtime_transcript_event(
13340            RealtimeTranscriptEvent::AssistantTranscriptTruncated {
13341                response_id: "resp_a".to_string(),
13342                item_id: "item_a".to_string(),
13343                content_index: 0,
13344                text: "what was actually heard".to_string(),
13345            },
13346        );
13347
13348        // A Display delta arrives later for the SAME item id (provider
13349        // lane-classification bug). It MUST be dropped.
13350        let _ =
13351            session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
13352                response_id: "resp_a".to_string(),
13353                delta_id: "delta_d_1".to_string(),
13354                item_id: "item_a".to_string(),
13355                previous_item_id: None,
13356                content_index: 0,
13357                delta: "should-not-appear".to_string(),
13358            });
13359
13360        let _ = session.append_realtime_transcript_event(
13361            RealtimeTranscriptEvent::AssistantTurnCompleted {
13362                response_id: "resp_a".to_string(),
13363                stop_reason: StopReason::EndTurn,
13364                usage: Usage::default(),
13365            },
13366        );
13367
13368        // The Spoken-truncation text survives intact; no Display text
13369        // leaked into the Spoken lane content.
13370        assert_eq!(session.messages().len(), 1);
13371        match &session.messages()[0] {
13372            Message::BlockAssistant(assistant) => {
13373                assert_eq!(assistant.blocks.len(), 1);
13374                match &assistant.blocks[0] {
13375                    AssistantBlock::Transcript { text, source, .. } => {
13376                        assert_eq!(text, "what was actually heard");
13377                        assert_eq!(*source, crate::types::TranscriptSource::Spoken);
13378                    }
13379                    other => unreachable!(
13380                        "Spoken-locked item must materialize as Transcript, got {other:?}"
13381                    ),
13382                }
13383            }
13384            other => unreachable!("expected BlockAssistant, got {other:?}"),
13385        }
13386    }
13387
13388    /// R5-6 sibling: a Display-classified item (a Display delta arrived
13389    /// first and locked the lane to Display) must reject a later
13390    /// `AssistantTranscriptDelta` rather than appending the Spoken text
13391    /// into the Display-locked content_segment. Pre-fix the transcript
13392    /// delta arm called `promote_item_lane` and unconditionally pushed —
13393    /// silently mixing a Spoken stream into a Display block.
13394    #[test]
13395    fn round5_r56_sibling_spoken_delta_skipped_on_display_item() {
13396        let mut session = Session::new();
13397
13398        // Display delta arrives first and locks the item to the Display lane.
13399        let _ =
13400            session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
13401                response_id: "resp_a".to_string(),
13402                delta_id: "delta_d_1".to_string(),
13403                item_id: "item_a".to_string(),
13404                previous_item_id: None,
13405                content_index: 0,
13406                delta: "display-locked-text".to_string(),
13407            });
13408
13409        // A spoken-transcript delta arrives later for the SAME item id
13410        // (provider lane-classification bug). It MUST be dropped.
13411        let _ = session.append_realtime_transcript_event(
13412            RealtimeTranscriptEvent::AssistantTranscriptDelta {
13413                response_id: "resp_a".to_string(),
13414                delta_id: "delta_s_1".to_string(),
13415                item_id: "item_a".to_string(),
13416                previous_item_id: None,
13417                content_index: 0,
13418                delta: "should-not-appear".to_string(),
13419            },
13420        );
13421
13422        let _ = session.append_realtime_transcript_event(
13423            RealtimeTranscriptEvent::AssistantTurnCompleted {
13424                response_id: "resp_a".to_string(),
13425                stop_reason: StopReason::EndTurn,
13426                usage: Usage::default(),
13427            },
13428        );
13429
13430        // The Display text survives intact; no Spoken text leaked in.
13431        assert_eq!(session.messages().len(), 1);
13432        match &session.messages()[0] {
13433            Message::BlockAssistant(assistant) => {
13434                assert_eq!(assistant.blocks.len(), 1);
13435                match &assistant.blocks[0] {
13436                    AssistantBlock::Text { text, .. } => {
13437                        assert_eq!(text, "display-locked-text");
13438                    }
13439                    other => {
13440                        unreachable!("Display-locked item must materialize as Text, got {other:?}")
13441                    }
13442                }
13443            }
13444            other => unreachable!("expected BlockAssistant, got {other:?}"),
13445        }
13446    }
13447
13448    /// R5-7: a late `AssistantTranscriptFinalText` arriving AFTER
13449    /// `AssistantTurnCompleted` already materialized the item must NOT
13450    /// mutate `content_segments` and must NOT rewrite the canonical
13451    /// `Message::BlockAssistant` (append-only history is a stronger
13452    /// invariant than typed text repair). The committed message keeps
13453    /// the delta-accumulated text; the late final is dropped with a
13454    /// warn; the materializer outcome is inert (no new messages).
13455    #[test]
13456    fn round5_r57_late_final_text_after_turn_completed_warns_and_skips() {
13457        let mut session = Session::new();
13458
13459        // Delta accumulates partial text on the Spoken lane.
13460        let _ = session.append_realtime_transcript_event(
13461            RealtimeTranscriptEvent::AssistantTranscriptDelta {
13462                response_id: "resp_a".to_string(),
13463                delta_id: "delta_s_1".to_string(),
13464                item_id: "item_a".to_string(),
13465                previous_item_id: None,
13466                content_index: 0,
13467                delta: "delta-accumulated".to_string(),
13468            },
13469        );
13470
13471        // TurnCompleted materializes the item with the delta-accumulated text.
13472        let commit_outcome = session.append_realtime_transcript_event(
13473            RealtimeTranscriptEvent::AssistantTurnCompleted {
13474                response_id: "resp_a".to_string(),
13475                stop_reason: StopReason::EndTurn,
13476                usage: Usage::default(),
13477            },
13478        );
13479        assert_eq!(commit_outcome.materialized_messages.len(), 1);
13480
13481        // Late FinalText arrives — provider-side ordering bug. It MUST
13482        // be dropped: no canonical message rewrite, no segment mutation,
13483        // outcome is inert.
13484        let late_outcome = session.append_realtime_transcript_event(
13485            RealtimeTranscriptEvent::AssistantTranscriptFinalText {
13486                response_id: "resp_a".to_string(),
13487                item_id: "item_a".to_string(),
13488                content_index: 0,
13489                text: "authoritative-final-that-must-not-land".to_string(),
13490            },
13491        );
13492        assert!(
13493            late_outcome.is_inert(),
13494            "late FinalText after materialization must produce inert outcome"
13495        );
13496
13497        // Canonical history: still one message with the original
13498        // delta-accumulated text — NOT the authoritative final.
13499        assert_eq!(session.messages().len(), 1);
13500        match &session.messages()[0] {
13501            Message::BlockAssistant(assistant) => {
13502                assert_eq!(assistant.blocks.len(), 1);
13503                match &assistant.blocks[0] {
13504                    AssistantBlock::Transcript { text, .. } => {
13505                        assert_eq!(
13506                            text, "delta-accumulated",
13507                            "canonical message must preserve delta-accumulated text; \
13508                             append-only history forbids late FinalText repair"
13509                        );
13510                    }
13511                    other => unreachable!("expected Transcript, got {other:?}"),
13512                }
13513            }
13514            other => unreachable!("expected BlockAssistant, got {other:?}"),
13515        }
13516    }
13517
13518    fn metadata_seam_session_metadata() -> SessionMetadata {
13519        SessionMetadata {
13520            schema_version: SESSION_METADATA_SCHEMA_VERSION,
13521            model: "test-model".to_string(),
13522            max_tokens: 1024,
13523            structured_output_retries: 2,
13524            provider: Provider::Anthropic,
13525            self_hosted_server_id: None,
13526            provider_params: None,
13527            tooling: SessionTooling::default(),
13528            keep_alive: false,
13529            comms_name: Some("team/reviewer/alice".to_string()),
13530            peer_meta: None,
13531            realm_id: None,
13532            instance_id: None,
13533            backend: None,
13534            config_generation: None,
13535            auth_binding: None,
13536            mob_member_binding: Some(crate::MobMemberBinding {
13537                mob_id: "team".to_string(),
13538                role: "reviewer".to_string(),
13539                member: "alice".to_string(),
13540            }),
13541        }
13542    }
13543
13544    /// Lockstep pin: the metadata-only partial decode must read the exact
13545    /// envelope that `SessionSerde` writes. If a field rename or serde-shape
13546    /// change lands on the full envelope without the partial decoder
13547    /// following, this test fails.
13548    #[test]
13549    fn session_metadata_document_lockstep_with_full_envelope() {
13550        let mut session = Session::new();
13551        session.push(Message::User(UserMessage::text("hello".to_string())));
13552        session
13553            .set_session_metadata(metadata_seam_session_metadata())
13554            .expect("session metadata should persist");
13555        session
13556            .set_lifecycle_terminal(SessionLifecycleTerminal::Archived)
13557            .expect("lifecycle terminal should persist");
13558
13559        let bytes = serde_json::to_vec(&session).expect("session should serialize");
13560        let document = session_metadata_document_from_slice(&bytes)
13561            .expect("partial decode must accept the canonical envelope");
13562
13563        assert_eq!(document.session_id(), session.id());
13564        assert_eq!(
13565            document.session_metadata_value(),
13566            session.metadata().get(SESSION_METADATA_KEY),
13567            "partial decode must project the identical raw session-metadata value"
13568        );
13569        assert_eq!(
13570            document.lifecycle_terminal_value(),
13571            session.metadata().get(SESSION_LIFECYCLE_TERMINAL_KEY),
13572            "partial decode must project the identical raw lifecycle-terminal value"
13573        );
13574
13575        let view = document
13576            .try_into_view()
13577            .expect("typed view must decode from the partial document");
13578        let full_view =
13579            PersistedSessionMetadataView::try_from_session(&session).expect("full-session view");
13580        assert_eq!(view.session_id, full_view.session_id);
13581        assert_eq!(
13582            view.session_metadata.as_ref().map(|m| m.model.clone()),
13583            full_view.session_metadata.as_ref().map(|m| m.model.clone())
13584        );
13585        assert_eq!(
13586            view.mob_member_binding(),
13587            full_view.mob_member_binding(),
13588            "typed binding must be identical across the two decode paths"
13589        );
13590        assert_eq!(
13591            view.lifecycle_terminal,
13592            Some(SessionLifecycleTerminal::Archived)
13593        );
13594        assert_eq!(
13595            full_view.lifecycle_terminal,
13596            Some(SessionLifecycleTerminal::Archived)
13597        );
13598    }
13599
13600    /// The metadata-only partial decode fails closed on an unsupported
13601    /// envelope version — same contract as the full deserializer.
13602    #[test]
13603    fn session_metadata_document_fails_closed_on_envelope_version() {
13604        let session = Session::new();
13605        let mut value = serde_json::to_value(&session).expect("session should serialize");
13606        value["version"] = serde_json::json!(SESSION_VERSION + 999);
13607        let bytes = serde_json::to_vec(&value).expect("mangled envelope should serialize");
13608
13609        session_metadata_document_from_slice(&bytes)
13610            .expect_err("an unsupported envelope version must fail the partial decode closed");
13611    }
13612
13613    /// Corrupt values under either reserved key are a read FAULT for the
13614    /// metadata view — never coalesced into "absent".
13615    #[test]
13616    fn persisted_session_metadata_view_fails_closed_on_corrupt_values() {
13617        let session_id = SessionId::new();
13618
13619        let mut corrupt_metadata = serde_json::Map::new();
13620        corrupt_metadata.insert(SESSION_METADATA_KEY.to_string(), serde_json::json!(42));
13621        PersistedSessionMetadataView::try_from_metadata_map(session_id.clone(), &corrupt_metadata)
13622            .expect_err("corrupt session_metadata must fail the view decode closed");
13623
13624        let mut corrupt_terminal = serde_json::Map::new();
13625        corrupt_terminal.insert(
13626            SESSION_LIFECYCLE_TERMINAL_KEY.to_string(),
13627            serde_json::json!("definitely-not-a-terminal"),
13628        );
13629        PersistedSessionMetadataView::try_from_metadata_map(session_id, &corrupt_terminal)
13630            .expect_err("corrupt lifecycle terminal must fail the view decode closed");
13631    }
13632
13633    /// Absent reserved keys decode as typed absence through the view.
13634    #[test]
13635    fn persisted_session_metadata_view_reads_absent_facts_as_none() {
13636        let view = PersistedSessionMetadataView::try_from_metadata_map(
13637            SessionId::new(),
13638            &serde_json::Map::new(),
13639        )
13640        .expect("empty metadata map must decode");
13641        assert!(view.session_metadata.is_none());
13642        assert!(view.lifecycle_terminal.is_none());
13643        assert!(view.mob_member_binding().is_none());
13644    }
13645}