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    /// Digest-format generation of the revision strings. Documents stamped
373    /// `>= 2` were written by the content-addressed digest format, so decode
374    /// skips the per-decode legacy-heal probe (a full-transcript hash);
375    /// absent/0 means unknown provenance and the probe runs once — the next
376    /// save persists the marker. A compatibility convenience, not an
377    /// integrity boundary (checkpoint stamps own integrity).
378    #[serde(default, skip_serializing_if = "digest_format_is_unknown")]
379    pub digest_format: u32,
380}
381
382fn digest_format_is_unknown(format: &u32) -> bool {
383    *format == 0
384}
385
386/// The digest-format generation minted by [`transcript_messages_digest`].
387pub(crate) const TRANSCRIPT_DIGEST_FORMAT_CURRENT: u32 = 2;
388
389impl<'de> Deserialize<'de> for TranscriptHistoryState {
390    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
391    where
392        D: Deserializer<'de>,
393    {
394        #[derive(Deserialize)]
395        #[serde(rename_all = "snake_case")]
396        struct Wire {
397            head: String,
398            #[serde(default)]
399            commits: Vec<TranscriptRewriteCommit>,
400            #[serde(default)]
401            revisions: Vec<TranscriptRevisionBody>,
402            #[serde(default)]
403            digest_format: u32,
404        }
405        let wire = Wire::deserialize(deserializer)?;
406        let mut state = TranscriptHistoryState {
407            head: wire.head,
408            commits: wire.commits,
409            revisions: wire.revisions,
410            digest_format: wire.digest_format,
411        };
412        // Pre-parent-pointer v1 snapshots serialized each body as
413        // {created_at,messages,revision}. When every non-root body lacks a
414        // parent, the append order is the only lineage the old format
415        // carried; reconstruct that exact linear order before digest healing
416        // and full validation.
417        if state.revisions.len() > 1
418            && state
419                .revisions
420                .iter()
421                .skip(1)
422                .all(|body| body.parent_revision.is_none())
423        {
424            for index in 1..state.revisions.len() {
425                let parent = state.revisions[index - 1].revision.clone();
426                state.revisions[index].parent_revision = Some(parent);
427            }
428        }
429        // Fast path: a graph stamped with the current digest format skips the
430        // heal probe outright — the probe hashes the full head transcript,
431        // which is decode-hot (every session load). Unstamped graphs (legacy
432        // or pre-marker writers) pay the probe once; their next save
433        // persists the marker.
434        let head_is_current = state.digest_format >= TRANSCRIPT_DIGEST_FORMAT_CURRENT
435            || match state
436                .revisions
437                .iter()
438                .find(|body| body.revision == state.head)
439            {
440                Some(head_body) => {
441                    transcript_messages_digest(&head_body.messages)
442                        .map_err(serde::de::Error::custom)?
443                        == state.head
444                }
445                None => true,
446            };
447        state.digest_format = TRANSCRIPT_DIGEST_FORMAT_CURRENT;
448        if !head_is_current {
449            let TranscriptHistoryState {
450                head,
451                commits,
452                digest_format: _,
453                revisions,
454            } = &mut state;
455            heal_legacy_revision_strings(revisions, commits, Some(head))
456                .map_err(serde::de::Error::custom)?;
457        }
458        heal_legacy_compaction_rewrite_semantics(&mut state.commits, &state.revisions);
459        Ok(state)
460    }
461}
462
463impl TranscriptHistoryState {
464    /// Drop mechanical append-head snapshots while preserving every body that
465    /// is an endpoint of an audited rewrite plus the current live head.
466    ///
467    /// Ordinary appends previously accumulated a complete transcript body on
468    /// every message mutation once any rewrite had occurred. Those bodies are
469    /// not rewrite history and are never selected for restore. Repointing the
470    /// live head directly at the latest rewrite endpoint keeps the existing
471    /// full-body lineage validator intact after the intermediate append heads
472    /// are removed.
473    fn compact_mechanical_revision_bodies(&mut self) -> Result<(), TranscriptEditError> {
474        validate_transcript_history_state(self)?;
475
476        let mut retained = BTreeSet::from([self.head.clone()]);
477        for commit in &self.commits {
478            retained.insert(commit.parent_revision.clone());
479            retained.insert(commit.revision.clone());
480        }
481
482        let head_is_audited_endpoint = self
483            .commits
484            .iter()
485            .any(|commit| commit.parent_revision == self.head || commit.revision == self.head);
486        if !head_is_audited_endpoint
487            && let Some(last_commit) = self
488                .commits
489                .last()
490                .filter(|commit| commit.revision != self.head)
491            && let Some(head_body) = self
492                .revisions
493                .iter_mut()
494                .find(|body| body.revision == self.head)
495        {
496            head_body.parent_revision = Some(last_commit.revision.clone());
497        }
498
499        let mut seen = BTreeSet::new();
500        self.revisions
501            .retain(|body| retained.contains(&body.revision) && seen.insert(body.revision.clone()));
502
503        // The full graph was validated before any pruning, so corrupt bodies
504        // cannot be laundered by dropping them. The transformation changes no
505        // message, revision digest, commit, or audited endpoint: it only
506        // de-duplicates bodies by revision, removes non-endpoint mechanical
507        // bodies, and points an unaudited live head directly at the already
508        // validated latest commit. Re-hashing every retained transcript here
509        // would repeat the dominant snapshot cost without adding evidence.
510        Ok(())
511    }
512}
513
514/// Re-derive pre-0.7.14 (bookkeeping-inclusive) transcript revision strings to
515/// the current content-addressed format at the durable-format parse boundary.
516///
517/// Retained revision bodies carry their full message lists, so every legacy
518/// string can be re-verified against the bytes it was computed from. Only
519/// strings that verify under the legacy digest of their own retained body are
520/// rewritten; anything else is left untouched for the validators to reject
521/// exactly as they would have before.
522fn heal_legacy_revision_strings(
523    revisions: &mut [TranscriptRevisionBody],
524    commits: &mut [TranscriptRewriteCommit],
525    head: Option<&mut String>,
526) -> Result<(), serde_json::Error> {
527    let mut remap: BTreeMap<String, String> = BTreeMap::new();
528    for body in revisions.iter() {
529        let content = transcript_messages_digest(&body.messages)?;
530        if body.revision == content {
531            continue;
532        }
533        if body.revision == legacy_transcript_messages_digest(&body.messages)? {
534            remap.insert(body.revision.clone(), content);
535        }
536    }
537    if remap.is_empty() {
538        return Ok(());
539    }
540    for body in revisions.iter_mut() {
541        if let Some(current) = remap.get(&body.revision) {
542            body.revision = current.clone();
543        }
544        if let Some(parent) = body.parent_revision.as_ref()
545            && let Some(current) = remap.get(parent)
546        {
547            body.parent_revision = Some(current.clone());
548        }
549    }
550    for commit in commits.iter_mut() {
551        if let Some(current) = remap.get(&commit.parent_revision) {
552            commit.parent_revision = current.clone();
553        }
554        if let Some(current) = remap.get(&commit.revision) {
555            commit.revision = current.clone();
556        }
557        heal_legacy_commit_span_digests(commit, revisions)?;
558    }
559    if let Some(head) = head
560        && let Some(current) = remap.get(head.as_str())
561    {
562        *head = current.clone();
563    }
564    Ok(())
565}
566
567/// Re-derive a legacy commit's span digests from its retained bodies.
568///
569/// Span digests are only rewritten when the stored value verifies under the
570/// legacy digest of the same span; malformed commits keep their stored bytes
571/// so [`validate_transcript_rewrite_record`] rejects them unchanged.
572fn heal_legacy_commit_span_digests(
573    commit: &mut TranscriptRewriteCommit,
574    revisions: &[TranscriptRevisionBody],
575) -> Result<(), serde_json::Error> {
576    let Some(parent_body) = revisions
577        .iter()
578        .find(|body| body.revision == commit.parent_revision)
579    else {
580        return Ok(());
581    };
582    let Some(revision_body) = revisions
583        .iter()
584        .find(|body| body.revision == commit.revision)
585    else {
586        return Ok(());
587    };
588    let (start, end) = commit.selection.bounds();
589    if start > end || end > parent_body.messages.len() {
590        return Ok(());
591    }
592    let removed_len = end - start;
593    let Some(retained_len) = commit.messages_before.checked_sub(removed_len) else {
594        return Ok(());
595    };
596    let Some(replacement_len) = commit.messages_after.checked_sub(retained_len) else {
597        return Ok(());
598    };
599    let Some(replacement_end) = start.checked_add(replacement_len) else {
600        return Ok(());
601    };
602    if replacement_end > revision_body.messages.len() {
603        return Ok(());
604    }
605    let original_span = &parent_body.messages[start..end];
606    if commit.original_span_digest == legacy_transcript_messages_digest(original_span)? {
607        commit.original_span_digest = transcript_messages_digest(original_span)?;
608    }
609    let replacement_span = &revision_body.messages[start..replacement_end];
610    if commit.replacement_digest == legacy_transcript_messages_digest(replacement_span)? {
611        commit.replacement_digest = transcript_messages_digest(replacement_span)?;
612    }
613    Ok(())
614}
615
616/// Upgrade pre-semantic-field compaction records from retained typed transcript
617/// evidence, never from the free-form audit reason.
618///
619/// Old compaction commits used the generic `message_range` selection, but their
620/// revision body already carries the runtime-minted `CompactionSummary` role.
621/// A full-transcript, shrinking rewrite with exactly one such summary is the
622/// complete legacy witness. Other edits remain ordinary edits even when their
623/// display reason happens to say "compaction".
624fn heal_legacy_compaction_rewrite_semantics(
625    commits: &mut [TranscriptRewriteCommit],
626    revisions: &[TranscriptRevisionBody],
627) {
628    for commit in commits {
629        if !commit.selection.is_legacy_untyped() {
630            continue;
631        }
632        let (start, end) = commit.selection.bounds();
633        if start != 0
634            || end != commit.messages_before
635            || commit.messages_after >= commit.messages_before
636        {
637            continue;
638        }
639        let Some(parent) = revisions
640            .iter()
641            .find(|body| body.revision == commit.parent_revision)
642        else {
643            continue;
644        };
645        let Some(revision) = revisions
646            .iter()
647            .find(|body| body.revision == commit.revision)
648        else {
649            continue;
650        };
651        if parent.messages.len() != commit.messages_before
652            || revision.messages.len() != commit.messages_after
653        {
654            continue;
655        }
656        let summary_count = revision
657            .messages
658            .iter()
659            .filter(|message| {
660                matches!(message, Message::User(user) if user.transcript_role.is_compaction_summary())
661            })
662            .count();
663        if summary_count == 1 {
664            commit.selection = TranscriptRewriteSelection::migrated_legacy_compaction(start, end);
665        }
666    }
667}
668
669impl TranscriptHistoryState {
670    /// Rebuild transcript revision graph state from append-only rewrite records.
671    pub fn from_rewrite_records<I>(records: I) -> Result<Option<Self>, TranscriptEditError>
672    where
673        I: IntoIterator<Item = TranscriptRewriteRecord>,
674    {
675        let mut state: Option<Self> = None;
676        for record in records {
677            validate_transcript_rewrite_record(
678                &record.commit,
679                &record.parent_body,
680                &record.revision_body,
681            )?;
682            let state = state.get_or_insert_with(|| Self {
683                head: record.commit.parent_revision.clone(),
684                commits: Vec::new(),
685                revisions: Vec::new(),
686                digest_format: TRANSCRIPT_DIGEST_FORMAT_CURRENT,
687            });
688            if record.commit.parent_revision != state.head {
689                if revision_body_extends_head(&record.parent_body, &state.revisions, &state.head)? {
690                    state.head = record.commit.parent_revision.clone();
691                } else {
692                    return Err(TranscriptEditError::HistoryStateMalformed(format!(
693                        "rewrite record parent {} does not extend transcript head {}",
694                        record.commit.parent_revision, state.head
695                    )));
696                }
697            }
698            if !state
699                .revisions
700                .iter()
701                .any(|body| body.revision == record.parent_body.revision)
702            {
703                state.revisions.push(record.parent_body);
704            }
705            if !state
706                .revisions
707                .iter()
708                .any(|body| body.revision == record.revision_body.revision)
709            {
710                state.revisions.push(record.revision_body);
711            }
712            state.head = record.commit.revision.clone();
713            state.commits.push(record.commit);
714        }
715        Ok(state)
716    }
717}
718
719/// Invalid typed transcript edit request.
720#[derive(Debug, Clone, thiserror::Error)]
721pub enum TranscriptEditError {
722    #[error("message index {message_index} out of bounds for {message_count} messages")]
723    MessageIndexOutOfBounds {
724        message_index: usize,
725        message_count: usize,
726    },
727    #[error("{block_kind} index {block_index} out of bounds for {block_count} blocks")]
728    BlockIndexOutOfBounds {
729        block_kind: &'static str,
730        block_index: usize,
731        block_count: usize,
732    },
733    #[error("replacement expected {expected} at message index {message_index}, found {actual}")]
734    MessageRoleMismatch {
735        message_index: usize,
736        expected: &'static str,
737        actual: &'static str,
738    },
739    #[error("invalid transcript rewrite range {start}..{end} for {message_count} messages")]
740    InvalidRewriteRange {
741        start: usize,
742        end: usize,
743        message_count: usize,
744    },
745    #[error("transcript rewrite does not change transcript revision {revision}")]
746    NoOpRewrite { revision: String },
747    #[error("transcript rewrite parent revision mismatch: expected {expected}, actual {actual}")]
748    RevisionConflict { expected: String, actual: String },
749    #[error("transcript history state is malformed: {0}")]
750    HistoryStateMalformed(String),
751    #[error("invalid transcript shape after rewrite: {0}")]
752    InvalidTranscriptShape(String),
753}
754
755fn message_role_name(message: &Message) -> &'static str {
756    match message {
757        Message::System(_) => "system",
758        Message::SystemNotice(_) => "system_notice",
759        Message::User(_) => "user",
760        Message::BlockAssistant(_) => "block_assistant",
761        Message::ToolResults { .. } => "tool_results",
762    }
763}
764
765fn assistant_tool_use_ids(message: &Message) -> Vec<&str> {
766    match message {
767        Message::BlockAssistant(assistant) => assistant
768            .blocks
769            .iter()
770            .filter_map(|block| match block {
771                AssistantBlock::ToolUse { id, .. } => Some(id.as_str()),
772                _ => None,
773            })
774            .collect(),
775        _ => Vec::new(),
776    }
777}
778
779fn validate_transcript_tool_result_shape(messages: &[Message]) -> Result<(), TranscriptEditError> {
780    for (index, message) in messages.iter().enumerate() {
781        if let Message::ToolResults { results, .. } = message {
782            let Some(previous) = index
783                .checked_sub(1)
784                .and_then(|previous| messages.get(previous))
785            else {
786                return Err(TranscriptEditError::InvalidTranscriptShape(format!(
787                    "tool_results at message {index} has no preceding assistant tool-use message"
788                )));
789            };
790            let expected = assistant_tool_use_ids(previous);
791            if expected.is_empty() {
792                return Err(TranscriptEditError::InvalidTranscriptShape(format!(
793                    "tool_results at message {index} follows {}, not an assistant tool-use message",
794                    message_role_name(previous)
795                )));
796            }
797            let actual = results
798                .iter()
799                .map(|result| result.tool_use_id.as_str())
800                .collect::<Vec<_>>();
801            let actual_set = actual.iter().copied().collect::<BTreeSet<_>>();
802            let expected_set = expected.iter().copied().collect::<BTreeSet<_>>();
803            if actual.len() != actual_set.len() {
804                return Err(TranscriptEditError::InvalidTranscriptShape(format!(
805                    "tool_results at message {index} contains duplicate tool ids"
806                )));
807            }
808            if expected.len() != expected_set.len() {
809                return Err(TranscriptEditError::InvalidTranscriptShape(format!(
810                    "assistant tool-use message before tool_results at message {index} contains duplicate tool ids"
811                )));
812            }
813            if actual_set != expected_set {
814                return Err(TranscriptEditError::InvalidTranscriptShape(format!(
815                    "tool_results at message {index} resolve tool ids {actual_set:?}, expected {expected_set:?}"
816                )));
817            }
818        }
819
820        let tool_use_ids = assistant_tool_use_ids(message);
821        if tool_use_ids.is_empty() {
822            continue;
823        }
824        let Some(next) = messages.get(index + 1) else {
825            return Err(TranscriptEditError::InvalidTranscriptShape(format!(
826                "assistant tool-use message {index} has no following tool_results"
827            )));
828        };
829        if !matches!(next, Message::ToolResults { .. }) {
830            return Err(TranscriptEditError::InvalidTranscriptShape(format!(
831                "assistant tool-use message {index} is followed by {}, not tool_results",
832                message_role_name(next)
833            )));
834        }
835    }
836    Ok(())
837}
838
839fn canonicalize_digest_image_blocks(blocks: &mut [crate::types::ContentBlock]) {
840    for block in blocks.iter_mut() {
841        if let crate::types::ContentBlock::Image {
842            media_type,
843            data: crate::types::ImageData::Inline { data },
844        } = block
845        {
846            // An inline image hydrates from its blob's own bytes, so its
847            // content-addressed identity equals the blob id the store minted.
848            let blob_id = crate::blob::content_blob_id(media_type, data);
849            *block = crate::types::ContentBlock::Image {
850                media_type: media_type.clone(),
851                data: crate::types::ImageData::Blob { blob_id },
852            };
853        }
854    }
855}
856
857/// Canonicalize image payloads to their content-addressed blob identity so the
858/// transcript digest is invariant to inline-vs-blob representation.
859///
860/// The same image hydrated inline for model execution and externalized to a
861/// blob for persistence must share one transcript revision; otherwise a live
862/// session and its durable snapshot would appear "diverged" purely because of
863/// image storage form, and a runtime-backed live session would be discarded as
864/// stale mid-turn.
865fn canonicalize_message_images_for_digest(messages: &[Message]) -> Vec<Message> {
866    let mut canonical = messages.to_vec();
867    for message in &mut canonical {
868        match message {
869            Message::User(user) => canonicalize_digest_image_blocks(&mut user.content),
870            Message::ToolResults { results, .. } => {
871                for result in results.iter_mut() {
872                    canonicalize_digest_image_blocks(&mut result.content);
873                }
874            }
875            Message::SystemNotice(notice) => {
876                for block in &mut notice.blocks {
877                    match block {
878                        crate::types::SystemNoticeBlock::Comms { content, .. }
879                        | crate::types::SystemNoticeBlock::ExternalEvent { content, .. } => {
880                            canonicalize_digest_image_blocks(content);
881                        }
882                        _ => {}
883                    }
884                }
885            }
886            _ => {}
887        }
888    }
889    canonical
890}
891
892/// Canonical checkpoint representation of the retained transcript graph.
893///
894/// Revision bodies are content-addressed by `revision`; their cached parent
895/// pointers and construction timestamps are storage bookkeeping. The ordered
896/// commit log remains intact because it is durable audit/selection history.
897pub(crate) fn canonicalize_checkpoint_history_value(
898    value: &serde_json::Value,
899) -> Result<serde_json::Value, serde_json::Error> {
900    let state: TranscriptHistoryState = serde_json::from_value(value.clone())?;
901    let mut revisions = state
902        .revisions
903        .into_iter()
904        .map(|body| {
905            serde_json::json!({
906                "revision": body.revision,
907                "messages": canonicalize_messages_for_digest(&body.messages),
908            })
909        })
910        .collect::<Vec<_>>();
911    revisions.sort_by(|left, right| {
912        left.get("revision")
913            .and_then(serde_json::Value::as_str)
914            .cmp(&right.get("revision").and_then(serde_json::Value::as_str))
915    });
916    Ok(serde_json::json!({
917        "head": state.head,
918        "commits": state.commits,
919        "revisions": revisions,
920    }))
921}
922
923fn canonicalize_checkpoint_deferred_turn_value(
924    value: &serde_json::Value,
925) -> Result<serde_json::Value, serde_json::Error> {
926    let mut state: SessionDeferredTurnState = serde_json::from_value(value.clone())?;
927    if let Some(prompt) = state.pending_initial_prompt_mut_for_blob_rewrite()
928        && let crate::types::ContentInput::Blocks(blocks) = &mut prompt.prompt
929    {
930        canonicalize_digest_image_blocks(blocks);
931    }
932    for pending in state.pending_tool_results_mut_for_blob_rewrite() {
933        for result in &mut pending.results {
934            canonicalize_digest_image_blocks(&mut result.content);
935        }
936    }
937    serde_json::to_value(state)
938}
939
940/// Timestamp sentinel used when erasing construction bookkeeping from the
941/// digest form. `created_at` always serializes, so a fixed value keeps the
942/// canonical bytes deterministic.
943fn digest_timestamp_sentinel() -> crate::types::MessageTimestamp {
944    chrono::DateTime::<chrono::Utc>::UNIX_EPOCH
945}
946
947/// Canonicalize messages to their conversational content before hashing so the
948/// transcript revision is a content address, not a construction record.
949///
950/// Two normalizations compose:
951/// - image payloads collapse to their content-addressed blob identity
952///   ([`canonicalize_message_images_for_digest`]);
953/// - per-construction bookkeeping is erased: [`TranscriptMessageIdentity`]
954///   (run/interaction ids are runtime-binding atoms — a re-created authority
955///   re-stamps them) and `created_at` timestamps. A resume that re-projects
956///   the same conversation through a new runtime authority must digest to the
957///   same revision as the persisted row, or the append-only save guard
958///   strands the session on restart (fails closed with
959///   `TranscriptContinuityViolation`).
960///
961/// Typed semantic facts stay in the digest — `transcript_role`,
962/// `mutation_kind`, `render_metadata`, notice kinds and blocks — because
963/// changing them changes the transcript's meaning.
964fn canonicalize_messages_for_digest(messages: &[Message]) -> Vec<Message> {
965    let mut canonical = canonicalize_message_images_for_digest(messages);
966    for message in &mut canonical {
967        match message {
968            Message::System(system) => {
969                system.created_at = digest_timestamp_sentinel();
970            }
971            Message::SystemNotice(notice) => {
972                notice.created_at = digest_timestamp_sentinel();
973            }
974            Message::User(user) => {
975                user.identity = crate::types::TranscriptMessageIdentity::default();
976                user.created_at = digest_timestamp_sentinel();
977            }
978            Message::BlockAssistant(assistant) => {
979                assistant.identity = crate::types::TranscriptMessageIdentity::default();
980                assistant.created_at = digest_timestamp_sentinel();
981            }
982            Message::ToolResults { created_at, .. } => {
983                *created_at = digest_timestamp_sentinel();
984            }
985        }
986    }
987    canonical
988}
989
990pub fn transcript_messages_digest(messages: &[Message]) -> Result<String, serde_json::Error> {
991    sha256_json_digest(&canonicalize_messages_for_digest(messages))
992}
993
994/// Digest format used by pre-0.7.14 transcript revision strings.
995///
996/// The legacy canonicalization only normalized image payloads, so persisted
997/// revision strings from older stores include construction bookkeeping
998/// (`identity`, `created_at`). This is a durable-format decoder: it exists
999/// solely so [`heal_legacy_revision_strings`] can verify a stored string
1000/// against its retained body before re-deriving it to the current
1001/// content-addressed format. Never mint new revisions with it.
1002fn legacy_transcript_messages_digest(messages: &[Message]) -> Result<String, serde_json::Error> {
1003    sha256_json_digest(&canonicalize_message_images_for_digest(messages))
1004}
1005
1006fn validate_transcript_rewrite_record(
1007    commit: &TranscriptRewriteCommit,
1008    parent_body: &TranscriptRevisionBody,
1009    revision_body: &TranscriptRevisionBody,
1010) -> Result<(), TranscriptEditError> {
1011    if parent_body.revision != commit.parent_revision {
1012        return Err(TranscriptEditError::HistoryStateMalformed(format!(
1013            "parent body revision {} does not match commit parent {}",
1014            parent_body.revision, commit.parent_revision
1015        )));
1016    }
1017    if revision_body.revision != commit.revision {
1018        return Err(TranscriptEditError::HistoryStateMalformed(format!(
1019            "revision body {} does not match commit revision {}",
1020            revision_body.revision, commit.revision
1021        )));
1022    }
1023    if commit.parent_revision == commit.revision {
1024        return Err(TranscriptEditError::NoOpRewrite {
1025            revision: commit.revision.clone(),
1026        });
1027    }
1028    let parent_digest = transcript_messages_digest(&parent_body.messages)
1029        .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
1030    if parent_digest != commit.parent_revision {
1031        return Err(TranscriptEditError::HistoryStateMalformed(format!(
1032            "parent body digest {parent_digest} does not match commit parent {}",
1033            commit.parent_revision
1034        )));
1035    }
1036    let revision_digest = transcript_messages_digest(&revision_body.messages)
1037        .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
1038    if revision_digest != commit.revision {
1039        return Err(TranscriptEditError::HistoryStateMalformed(format!(
1040            "revision body digest {revision_digest} does not match commit revision {}",
1041            commit.revision
1042        )));
1043    }
1044    let (start, end) = commit.selection.bounds();
1045    if start > end || end > parent_body.messages.len() {
1046        return Err(TranscriptEditError::InvalidRewriteRange {
1047            start,
1048            end,
1049            message_count: parent_body.messages.len(),
1050        });
1051    }
1052    if commit.messages_before != parent_body.messages.len()
1053        || commit.messages_after != revision_body.messages.len()
1054    {
1055        return Err(TranscriptEditError::HistoryStateMalformed(format!(
1056            "commit message counts {} -> {} do not match revision bodies {} -> {}",
1057            commit.messages_before,
1058            commit.messages_after,
1059            parent_body.messages.len(),
1060            revision_body.messages.len()
1061        )));
1062    }
1063    let original_span_digest = transcript_messages_digest(&parent_body.messages[start..end])
1064        .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
1065    if original_span_digest != commit.original_span_digest {
1066        return Err(TranscriptEditError::HistoryStateMalformed(format!(
1067            "original span digest {original_span_digest} does not match commit digest {}",
1068            commit.original_span_digest
1069        )));
1070    }
1071    let removed_len = end - start;
1072    let retained_len = commit
1073        .messages_before
1074        .checked_sub(removed_len)
1075        .ok_or_else(|| {
1076            TranscriptEditError::HistoryStateMalformed(
1077                "commit removed more messages than it recorded before rewrite".to_string(),
1078            )
1079        })?;
1080    let replacement_len = commit
1081        .messages_after
1082        .checked_sub(retained_len)
1083        .ok_or_else(|| {
1084            TranscriptEditError::HistoryStateMalformed(
1085                "commit message counts cannot describe a replacement span".to_string(),
1086            )
1087        })?;
1088    let replacement_end = start.checked_add(replacement_len).ok_or_else(|| {
1089        TranscriptEditError::HistoryStateMalformed("replacement span end overflowed".to_string())
1090    })?;
1091    if replacement_end > revision_body.messages.len() {
1092        return Err(TranscriptEditError::InvalidRewriteRange {
1093            start,
1094            end: replacement_end,
1095            message_count: revision_body.messages.len(),
1096        });
1097    }
1098    if commit.selection.semantic() == TranscriptRewriteSemantic::Compaction {
1099        let summary_count = revision_body.messages[start..replacement_end]
1100            .iter()
1101            .filter(|message| {
1102                matches!(message, Message::User(user) if user.transcript_role.is_compaction_summary())
1103            })
1104            .count();
1105        if start != 0
1106            || end != commit.messages_before
1107            || commit.messages_after >= commit.messages_before
1108            || summary_count != 1
1109        {
1110            return Err(TranscriptEditError::HistoryStateMalformed(
1111                "typed compaction rewrite must shrink the full transcript and carry exactly one CompactionSummary"
1112                    .to_string(),
1113            ));
1114        }
1115    }
1116    let parent_prefix_digest = transcript_messages_digest(&parent_body.messages[..start])
1117        .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
1118    let revision_prefix_digest = transcript_messages_digest(&revision_body.messages[..start])
1119        .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
1120    if parent_prefix_digest != revision_prefix_digest {
1121        return Err(TranscriptEditError::HistoryStateMalformed(
1122            "rewrite revision changed messages before the selected span".to_string(),
1123        ));
1124    }
1125    let parent_suffix_digest = transcript_messages_digest(&parent_body.messages[end..])
1126        .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
1127    let revision_suffix_digest =
1128        transcript_messages_digest(&revision_body.messages[replacement_end..])
1129            .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
1130    if parent_suffix_digest != revision_suffix_digest {
1131        return Err(TranscriptEditError::HistoryStateMalformed(
1132            "rewrite revision changed messages after the selected span".to_string(),
1133        ));
1134    }
1135    let replacement_digest =
1136        transcript_messages_digest(&revision_body.messages[start..replacement_end])
1137            .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
1138    if replacement_digest != commit.replacement_digest {
1139        return Err(TranscriptEditError::HistoryStateMalformed(format!(
1140            "replacement span digest {replacement_digest} does not match commit digest {}",
1141            commit.replacement_digest
1142        )));
1143    }
1144    Ok(())
1145}
1146
1147pub(crate) fn validate_transcript_history_state(
1148    state: &TranscriptHistoryState,
1149) -> Result<(), TranscriptEditError> {
1150    if state
1151        .revisions
1152        .iter()
1153        .all(|body| body.revision != state.head)
1154    {
1155        return Err(TranscriptEditError::HistoryStateMalformed(format!(
1156            "missing transcript head body {}",
1157            state.head
1158        )));
1159    }
1160    for body in &state.revisions {
1161        let digest = transcript_messages_digest(&body.messages)
1162            .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
1163        if digest != body.revision {
1164            return Err(TranscriptEditError::HistoryStateMalformed(format!(
1165                "transcript revision body {} has digest {digest}",
1166                body.revision
1167            )));
1168        }
1169    }
1170    for commit in &state.commits {
1171        let parent_body = state
1172            .revisions
1173            .iter()
1174            .find(|body| body.revision == commit.parent_revision)
1175            .ok_or_else(|| {
1176                TranscriptEditError::HistoryStateMalformed(format!(
1177                    "missing parent transcript body {}",
1178                    commit.parent_revision
1179                ))
1180            })?;
1181        let revision_body = state
1182            .revisions
1183            .iter()
1184            .find(|body| body.revision == commit.revision)
1185            .ok_or_else(|| {
1186                TranscriptEditError::HistoryStateMalformed(format!(
1187                    "missing transcript revision body {}",
1188                    commit.revision
1189                ))
1190            })?;
1191        validate_transcript_rewrite_record(commit, parent_body, revision_body)?;
1192    }
1193    let Some(first_commit) = state.commits.first() else {
1194        return Ok(());
1195    };
1196    let mut expected_head = first_commit.parent_revision.clone();
1197    for commit in &state.commits {
1198        let parent_body = state
1199            .revisions
1200            .iter()
1201            .find(|body| body.revision == commit.parent_revision)
1202            .ok_or_else(|| {
1203                TranscriptEditError::HistoryStateMalformed(format!(
1204                    "missing parent transcript body {}",
1205                    commit.parent_revision
1206                ))
1207            })?;
1208        if commit.parent_revision != expected_head
1209            && !revision_body_extends_head(parent_body, &state.revisions, &expected_head)?
1210        {
1211            return Err(TranscriptEditError::HistoryStateMalformed(format!(
1212                "rewrite commit parent {} does not extend transcript head {}",
1213                commit.parent_revision, expected_head
1214            )));
1215        }
1216        expected_head = commit.revision.clone();
1217    }
1218    let head_is_audited_endpoint = state
1219        .commits
1220        .iter()
1221        .any(|commit| commit.parent_revision == state.head || commit.revision == state.head);
1222    let head_extends_latest_commit = if head_is_audited_endpoint {
1223        let Some(head_body) = state
1224            .revisions
1225            .iter()
1226            .find(|body| body.revision == state.head)
1227        else {
1228            return Err(TranscriptEditError::HistoryStateMalformed(format!(
1229                "missing transcript head body {}",
1230                state.head
1231            )));
1232        };
1233        revision_body_extends_head(head_body, &state.revisions, &expected_head)?
1234    } else {
1235        let mut cursor = state.head.as_str();
1236        let mut visited = BTreeSet::new();
1237        while cursor != expected_head {
1238            if !visited.insert(cursor.to_string()) {
1239                break;
1240            }
1241            let Some(head_body) = state.revisions.iter().find(|body| body.revision == cursor)
1242            else {
1243                break;
1244            };
1245            let Some(parent) = head_body.parent_revision.as_deref() else {
1246                break;
1247            };
1248            cursor = parent;
1249        }
1250        cursor == expected_head
1251    };
1252    if !head_extends_latest_commit {
1253        return Err(TranscriptEditError::HistoryStateMalformed(format!(
1254            "transcript head {} does not extend the rewrite chain",
1255            state.head
1256        )));
1257    }
1258    Ok(())
1259}
1260
1261fn revision_body_extends_head(
1262    candidate: &TranscriptRevisionBody,
1263    revisions: &[TranscriptRevisionBody],
1264    head: &str,
1265) -> Result<bool, TranscriptEditError> {
1266    let Some(head_body) = revisions.iter().find(|body| body.revision == head) else {
1267        return Ok(false);
1268    };
1269    if candidate.revision == head {
1270        return Ok(true);
1271    }
1272    if candidate.messages.len() < head_body.messages.len() {
1273        return Ok(false);
1274    }
1275    let prefix_digest = transcript_messages_digest(&candidate.messages[..head_body.messages.len()])
1276        .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
1277    if prefix_digest == head {
1278        return Ok(true);
1279    }
1280
1281    // A resume-time system refresh may replace the single leading System
1282    // projection while preserving (and possibly appending to) the exact
1283    // conversation tail. Prove that content shape directly; a historical
1284    // parent_revision pointer is not occurrence identity and must never, by
1285    // itself, authorize a later commit after a digest has recurred.
1286    let (Some(Message::System(_)), Some(Message::System(_))) =
1287        (candidate.messages.first(), head_body.messages.first())
1288    else {
1289        return Ok(false);
1290    };
1291    let head_tail_len = head_body.messages.len().saturating_sub(1);
1292    if head_tail_len == 0 {
1293        return Ok(true);
1294    }
1295    let candidate_tail = &candidate.messages[1..];
1296    if candidate_tail.len() < head_tail_len {
1297        return Ok(false);
1298    }
1299    let head_tail_digest = transcript_messages_digest(&head_body.messages[1..])
1300        .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
1301    let candidate_tail_prefix_digest = transcript_messages_digest(&candidate_tail[..head_tail_len])
1302        .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
1303    Ok(candidate_tail_prefix_digest == head_tail_digest)
1304}
1305
1306fn sha256_json_digest<T: Serialize + ?Sized>(value: &T) -> Result<String, serde_json::Error> {
1307    crate::checkpoint::record_content_digest_computation();
1308    let bytes = serde_json::to_vec(value)?;
1309    let digest = Sha256::digest(bytes);
1310    let mut out = String::with_capacity(digest.len() * 2);
1311    const HEX: &[u8; 16] = b"0123456789abcdef";
1312    for byte in digest {
1313        out.push(HEX[(byte >> 4) as usize] as char);
1314        out.push(HEX[(byte & 0x0f) as usize] as char);
1315    }
1316    Ok(format!("sha256:{out}"))
1317}
1318
1319/// A conversation session with full history
1320///
1321/// Uses Arc<Vec<Message>> internally for efficient forking (copy-on-write).
1322#[derive(Debug, Clone)]
1323pub struct Session {
1324    /// Persisted envelope format version, validated fail-closed on read by
1325    /// the generated persistence version authority.
1326    version: u32,
1327    /// Unique identifier
1328    id: SessionId,
1329    /// All messages in order (Arc for CoW on fork)
1330    pub(crate) messages: Arc<Vec<Message>>,
1331    /// When the session was created
1332    created_at: SystemTime,
1333    /// When the session was last updated
1334    updated_at: SystemTime,
1335    /// Arbitrary metadata
1336    metadata: serde_json::Map<String, serde_json::Value>,
1337    /// Whether transcript-history metadata has already crossed a validating,
1338    /// compacting authority boundary in this in-memory session.
1339    ///
1340    /// This is derived cache state only, never persisted authority. Typed
1341    /// transcript mutations install validated state; deserialization validates
1342    /// before setting it. Any unchecked history mutation invalidates the cache
1343    /// so serialization retains the fail-closed corrupt-snapshot contract.
1344    transcript_history_metadata_validation: TranscriptHistoryMetadataValidation,
1345    /// Cumulative token usage across all LLM calls in this session
1346    usage: Usage,
1347}
1348
1349#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1350enum TranscriptHistoryMetadataValidation {
1351    Validated,
1352    RequiresValidation,
1353}
1354
1355/// Serde helper for Session serialization (flattens Arc)
1356#[derive(Deserialize)]
1357#[serde(rename_all = "snake_case")]
1358struct SessionSerde {
1359    version: u32,
1360    id: SessionId,
1361    messages: Vec<Message>,
1362    created_at: SystemTime,
1363    updated_at: SystemTime,
1364    #[serde(default)]
1365    metadata: serde_json::Map<String, serde_json::Value>,
1366    #[serde(default)]
1367    usage: Usage,
1368}
1369
1370/// Borrowed serialization view for Session. The persisted shape deliberately
1371/// stays lockstep with `SessionSerde`, but large transcripts and metadata are
1372/// streamed directly instead of being deep-cloned before serde sees them.
1373#[derive(Serialize)]
1374#[serde(rename_all = "snake_case")]
1375struct SessionSerdeRef<'a> {
1376    version: u32,
1377    id: &'a SessionId,
1378    messages: &'a [Message],
1379    created_at: &'a SystemTime,
1380    updated_at: &'a SystemTime,
1381    metadata: &'a serde_json::Map<String, serde_json::Value>,
1382    usage: &'a Usage,
1383}
1384
1385impl Serialize for Session {
1386    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1387    where
1388        S: Serializer,
1389    {
1390        let compacted_metadata = if self.transcript_history_metadata_validation
1391            == TranscriptHistoryMetadataValidation::RequiresValidation
1392        {
1393            let mut metadata = self.metadata.clone();
1394            compact_transcript_history_metadata_for_snapshot(&mut metadata)
1395                .map_err(<S::Error as serde::ser::Error>::custom)?;
1396            Some(metadata)
1397        } else {
1398            None
1399        };
1400        let metadata = compacted_metadata.as_ref().unwrap_or(&self.metadata);
1401        let serde_repr = SessionSerdeRef {
1402            version: self.version,
1403            id: &self.id,
1404            messages: self.messages(),
1405            created_at: &self.created_at,
1406            updated_at: &self.updated_at,
1407            metadata,
1408            usage: &self.usage,
1409        };
1410        serde_repr.serialize(serializer)
1411    }
1412}
1413
1414fn compact_transcript_history_metadata_for_snapshot(
1415    metadata: &mut serde_json::Map<String, serde_json::Value>,
1416) -> Result<(), String> {
1417    let Some(value) = metadata.remove(SESSION_TRANSCRIPT_HISTORY_STATE_KEY) else {
1418        return Ok(());
1419    };
1420    let mut state: TranscriptHistoryState =
1421        serde_json::from_value(value).map_err(|error| error.to_string())?;
1422    state
1423        .compact_mechanical_revision_bodies()
1424        .map_err(|error| error.to_string())?;
1425    metadata.insert(
1426        SESSION_TRANSCRIPT_HISTORY_STATE_KEY.to_string(),
1427        serde_json::to_value(state).map_err(|error| error.to_string())?,
1428    );
1429    Ok(())
1430}
1431
1432impl<'de> Deserialize<'de> for Session {
1433    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1434    where
1435        D: Deserializer<'de>,
1436    {
1437        let serde_repr = SessionSerde::deserialize(deserializer)?;
1438        let version = session_persistence_version_authority::restore_session_envelope_version(
1439            serde_repr.version,
1440        )
1441        .map_err(<D::Error as serde::de::Error>::custom)?;
1442        let mut metadata = serde_repr.metadata;
1443        compact_transcript_history_metadata_for_snapshot(&mut metadata)
1444            .map_err(<D::Error as serde::de::Error>::custom)?;
1445        Ok(Session {
1446            version,
1447            id: serde_repr.id,
1448            messages: Arc::new(serde_repr.messages),
1449            created_at: serde_repr.created_at,
1450            updated_at: serde_repr.updated_at,
1451            metadata,
1452            transcript_history_metadata_validation: TranscriptHistoryMetadataValidation::Validated,
1453            usage: serde_repr.usage,
1454        })
1455    }
1456}
1457
1458/// Serde helper for the metadata-only partial decode of a persisted session
1459/// envelope.
1460///
1461/// LOCKSTEP with [`SessionSerde`]: this struct must decode exactly the field
1462/// names and serde shapes that `SessionSerde` persists for `version`, `id`,
1463/// and `metadata` (`rename_all = "snake_case"`, `#[serde(default)]` on
1464/// `metadata`). The `session_metadata_document_lockstep_with_full_envelope`
1465/// pin test fails if the two drift.
1466#[derive(Deserialize)]
1467#[serde(rename_all = "snake_case")]
1468struct SessionMetadataDocumentSerde {
1469    version: u32,
1470    id: SessionId,
1471    #[serde(default)]
1472    metadata: serde_json::Map<String, serde_json::Value>,
1473}
1474
1475/// Metadata-only projection of a persisted session envelope.
1476///
1477/// Produced by [`session_metadata_document_from_slice`] without materializing
1478/// the transcript. Exposes ONLY the two session-authority facts the metadata
1479/// read seam is allowed to observe ([`SESSION_METADATA_KEY`] and
1480/// [`SESSION_LIFECYCLE_TERMINAL_KEY`]) — deliberately no raw metadata-map
1481/// accessor, so the partial decode can never grow into an untyped side
1482/// channel around [`Session`]'s authority-gated reads.
1483#[derive(Debug, Clone)]
1484pub struct SessionMetadataDocument {
1485    session_id: SessionId,
1486    metadata: serde_json::Map<String, serde_json::Value>,
1487}
1488
1489impl SessionMetadataDocument {
1490    /// Session identity carried by the envelope.
1491    pub fn session_id(&self) -> &SessionId {
1492        &self.session_id
1493    }
1494
1495    /// Raw projected [`SESSION_METADATA_KEY`] value, for divergence
1496    /// comparison against another projection of the same fact.
1497    pub fn session_metadata_value(&self) -> Option<&serde_json::Value> {
1498        self.metadata.get(SESSION_METADATA_KEY)
1499    }
1500
1501    /// Raw projected [`SESSION_LIFECYCLE_TERMINAL_KEY`] value, for divergence
1502    /// comparison against another projection of the same fact.
1503    pub fn lifecycle_terminal_value(&self) -> Option<&serde_json::Value> {
1504        self.metadata.get(SESSION_LIFECYCLE_TERMINAL_KEY)
1505    }
1506
1507    /// Decode typed checkpoint metadata without materializing the transcript.
1508    ///
1509    /// This validates schema and session identity and preserves explicit
1510    /// legacy-unverified state. Digest verification still requires the full
1511    /// document through [`Session::try_checkpoint_state`].
1512    pub fn try_checkpoint_metadata_state(
1513        &self,
1514    ) -> Result<
1515        crate::checkpoint::SessionCheckpointMetadataState,
1516        crate::checkpoint::SessionCheckpointError,
1517    > {
1518        crate::checkpoint::session_checkpoint_metadata_state(&self.session_id, &self.metadata)
1519    }
1520
1521    /// Decode the typed metadata view through the canonical map-level
1522    /// decoders, failing closed on corrupt values.
1523    pub fn try_into_view(self) -> Result<PersistedSessionMetadataView, serde_json::Error> {
1524        PersistedSessionMetadataView::try_from_metadata_map(self.session_id, &self.metadata)
1525    }
1526}
1527
1528/// Partially decode a persisted session envelope into its metadata-only
1529/// document, without materializing the transcript.
1530///
1531/// Fail-closed on the envelope format version through the generated
1532/// persistence version authority — exactly like the full [`Session`]
1533/// deserializer.
1534pub fn session_metadata_document_from_slice(
1535    bytes: &[u8],
1536) -> Result<SessionMetadataDocument, serde_json::Error> {
1537    let serde_repr: SessionMetadataDocumentSerde = serde_json::from_slice(bytes)?;
1538    session_persistence_version_authority::restore_session_envelope_version(serde_repr.version)
1539        .map_err(<serde_json::Error as serde::de::Error>::custom)?;
1540    Ok(SessionMetadataDocument {
1541        session_id: serde_repr.id,
1542        metadata: serde_repr.metadata,
1543    })
1544}
1545
1546impl Session {
1547    /// Rebuild a slim `Session` from persisted head-row parts.
1548    ///
1549    /// Used by [`crate::session_store::SessionHead::into_session`] to
1550    /// materialize a session from an incremental store's head row plus its
1551    /// strand messages. The envelope version is restored fail-closed through
1552    /// the generated persistence version authority, exactly like
1553    /// [`Session::deserialize`].
1554    pub(crate) fn from_head_parts(
1555        version: u32,
1556        id: SessionId,
1557        messages: Vec<Message>,
1558        created_at: SystemTime,
1559        updated_at: SystemTime,
1560        metadata: serde_json::Map<String, serde_json::Value>,
1561        usage: Usage,
1562    ) -> Result<Self, String> {
1563        let version =
1564            session_persistence_version_authority::restore_session_envelope_version(version)
1565                .map_err(|err| err.to_string())?;
1566        Ok(Self {
1567            version,
1568            id,
1569            messages: Arc::new(messages),
1570            created_at,
1571            updated_at,
1572            transcript_history_metadata_validation: if metadata
1573                .contains_key(SESSION_TRANSCRIPT_HISTORY_STATE_KEY)
1574            {
1575                TranscriptHistoryMetadataValidation::RequiresValidation
1576            } else {
1577                TranscriptHistoryMetadataValidation::Validated
1578            },
1579            metadata,
1580            usage,
1581        })
1582    }
1583
1584    /// Build the canonical, storage-representation-invariant document used by
1585    /// the typed checkpoint digest.
1586    pub(crate) fn checkpoint_digest_document(
1587        &self,
1588    ) -> Result<serde_json::Value, serde_json::Error> {
1589        let messages = canonicalize_messages_for_digest(self.messages());
1590        let mut metadata = self.metadata.clone();
1591        if self.transcript_history_metadata_validation
1592            == TranscriptHistoryMetadataValidation::RequiresValidation
1593        {
1594            compact_transcript_history_metadata_for_snapshot(&mut metadata)
1595                .map_err(<serde_json::Error as serde::ser::Error>::custom)?;
1596        }
1597        if let Some(history) = metadata.get_mut(SESSION_TRANSCRIPT_HISTORY_STATE_KEY) {
1598            *history = canonicalize_checkpoint_history_value(history)?;
1599        }
1600        if let Some(deferred) = metadata.get_mut(SESSION_DEFERRED_TURN_STATE_KEY) {
1601            *deferred = canonicalize_checkpoint_deferred_turn_value(deferred)?;
1602        }
1603        serde_json::to_value(SessionSerdeRef {
1604            version: self.version,
1605            id: &self.id,
1606            messages: &messages,
1607            created_at: &self.created_at,
1608            updated_at: &self.updated_at,
1609            metadata: &metadata,
1610            usage: &self.usage,
1611        })
1612    }
1613}
1614
1615/// Metadata key used to store durable system-context control state.
1616pub const SESSION_SYSTEM_CONTEXT_STATE_KEY: &str = "session_system_context_state";
1617
1618/// Metadata key used to store deferred-turn control state.
1619pub const SESSION_DEFERRED_TURN_STATE_KEY: &str = "session_deferred_turn_state";
1620
1621/// Metadata key used to store recoverable build-only session state.
1622pub const SESSION_BUILD_STATE_KEY: &str = "session_build_state";
1623
1624/// Metadata key used to store durable session-local tool visibility intent.
1625pub const SESSION_TOOL_VISIBILITY_STATE_KEY: &str = "session_tool_visibility_state_v1";
1626
1627/// Metadata key used to store the typed session lifecycle-terminal fact.
1628pub const SESSION_LIFECYCLE_TERMINAL_KEY: &str = "session_lifecycle_terminal";
1629
1630/// Single canonical metadata key for the typed session checkpoint stamp.
1631pub const SESSION_CHECKPOINT_STAMP_KEY: &str = "session_checkpoint_stamp_v1";
1632
1633/// Legacy compatibility marker for a session-store row written by the
1634/// pre-typed intra-turn checkpointer.
1635///
1636/// This Boolean is decoded only as explicit legacy-unverified evidence. It
1637/// never grants rollback authority; typed writers and recovery use the exact
1638/// [`crate::checkpoint::SessionCheckpointStamp`] instead.
1639pub const SESSION_RUNTIME_CHECKPOINT_PROVENANCE_KEY: &str =
1640    "session_runtime_checkpoint_provenance_v1";
1641
1642/// Canonical tool name gated by `image_tool_results` capability.
1643pub const VIEW_IMAGE_TOOL_NAME: &str = "view_image";
1644
1645/// Canonical separator between appended runtime system-context blocks.
1646pub const SYSTEM_CONTEXT_SEPARATOR: &str = "\n\n---\n\n";
1647
1648#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1649#[error("metadata key `{key}` is reserved for session authority")]
1650pub struct ReservedSessionMetadataKey {
1651    key: String,
1652}
1653
1654impl ReservedSessionMetadataKey {
1655    fn new(key: &str) -> Self {
1656        Self {
1657            key: key.to_string(),
1658        }
1659    }
1660}
1661
1662fn is_session_authority_metadata_key(key: &str) -> bool {
1663    // Single reserved-key authority: the typed classifier owns the
1664    // session-authority key set (the `session_*` state constants).
1665    crate::surface_metadata::ReservedMetadataKey::is_session_authority(key)
1666}
1667
1668#[allow(clippy::panic)]
1669fn fail_closed_generated_restore(authority: &'static str, err: serde_json::Error) -> ! {
1670    tracing::error!(
1671        authority,
1672        error = %err,
1673        "generated authority rejected durable restore"
1674    );
1675    panic!("generated {authority} authority rejected durable restore: {err}");
1676}
1677
1678/// Shared runtime system-context authority handle.
1679///
1680/// This handle is intentionally narrower than `Arc<Mutex<SessionSystemContextState>>`:
1681/// callers can read snapshots or request generated-authority transitions, but
1682/// cannot replace the machine-owned state by taking a mutable guard.
1683#[derive(Clone)]
1684pub struct SystemContextStateHandle {
1685    inner: Arc<std::sync::Mutex<SessionSystemContextState>>,
1686    boundary: Arc<SystemContextBoundaryCoordinator>,
1687}
1688
1689struct SystemContextBoundaryCoordinator {
1690    incarnation_id: uuid::Uuid,
1691    lifecycle: std::sync::Mutex<SystemContextBoundaryLifecycle>,
1692    notify: tokio::sync::Notify,
1693}
1694
1695struct SystemContextBoundaryLifecycle {
1696    actor_live: bool,
1697    next_generation: u64,
1698    next_request_id: u64,
1699    window: SystemContextBoundaryWindow,
1700}
1701
1702enum SystemContextBoundaryWindow {
1703    Closed,
1704    Open {
1705        run_id: RunId,
1706        generation: u64,
1707        request: Option<RegisteredSystemContextBoundaryRequest>,
1708    },
1709    Parked {
1710        run_id: RunId,
1711        generation: u64,
1712        request_id: u64,
1713        candidate_state: SessionSystemContextState,
1714    },
1715    Resolved {
1716        run_id: RunId,
1717        generation: u64,
1718        request_id: u64,
1719        resolution: SystemContextBoundaryResolution,
1720    },
1721    /// The external prepare authority has resolved (or runner-first won), and
1722    /// the runner is preprocessing the exact request immediately before the
1723    /// model call. Canonical pending state remains unapplied until the runner
1724    /// consumes this witness synchronously at that final call seam.
1725    Consuming {
1726        run_id: RunId,
1727        generation: u64,
1728        request_id: Option<u64>,
1729    },
1730}
1731
1732struct RegisteredSystemContextBoundaryRequest {
1733    request_id: u64,
1734    appends: Vec<(AppendSystemContextRequest, SystemTime)>,
1735}
1736
1737#[derive(Clone)]
1738enum SystemContextBoundaryResolution {
1739    Committed,
1740    Aborted,
1741    Failed(CoreBoundaryStageError),
1742}
1743
1744impl Default for SystemContextBoundaryCoordinator {
1745    fn default() -> Self {
1746        Self {
1747            incarnation_id: uuid::Uuid::new_v4(),
1748            lifecycle: std::sync::Mutex::new(SystemContextBoundaryLifecycle {
1749                actor_live: true,
1750                next_generation: 0,
1751                next_request_id: 0,
1752                window: SystemContextBoundaryWindow::Closed,
1753            }),
1754            notify: tokio::sync::Notify::new(),
1755        }
1756    }
1757}
1758
1759impl SystemContextBoundaryCoordinator {
1760    fn lock(&self) -> std::sync::MutexGuard<'_, SystemContextBoundaryLifecycle> {
1761        self.lifecycle.lock().unwrap_or_else(|poisoned| {
1762            tracing::warn!(
1763                "system-context boundary coordinator lock poisoned; retaining exact authority"
1764            );
1765            poisoned.into_inner()
1766        })
1767    }
1768
1769    fn abort_request(&self, request_id: u64) -> Result<(), CoreBoundaryStageError> {
1770        let mut lifecycle = self.lock();
1771        let parked_owner = match &lifecycle.window {
1772            SystemContextBoundaryWindow::Parked {
1773                run_id,
1774                generation,
1775                request_id: current_request_id,
1776                ..
1777            } if *current_request_id == request_id => Some((run_id.clone(), *generation)),
1778            _ => None,
1779        };
1780        if let Some((run_id, generation)) = parked_owner {
1781            lifecycle.window = SystemContextBoundaryWindow::Resolved {
1782                run_id,
1783                generation,
1784                request_id,
1785                resolution: SystemContextBoundaryResolution::Aborted,
1786            };
1787            drop(lifecycle);
1788            self.notify.notify_waiters();
1789            return Ok(());
1790        }
1791        match &mut lifecycle.window {
1792            SystemContextBoundaryWindow::Open { request, .. }
1793                if request
1794                    .as_ref()
1795                    .is_some_and(|request| request.request_id == request_id) =>
1796            {
1797                *request = None;
1798            }
1799            SystemContextBoundaryWindow::Resolved {
1800                request_id: current_request_id,
1801                ..
1802            } if *current_request_id == request_id => return Ok(()),
1803            _ => {
1804                return Err(CoreBoundaryStageError::stale(format!(
1805                    "boundary request {request_id} no longer owns its actor window"
1806                )));
1807            }
1808        }
1809        drop(lifecycle);
1810        self.notify.notify_waiters();
1811        Ok(())
1812    }
1813
1814    fn close_run(&self, run_id: &RunId) {
1815        let mut lifecycle = self.lock();
1816        let owns_window = match &lifecycle.window {
1817            SystemContextBoundaryWindow::Open {
1818                run_id: current, ..
1819            }
1820            | SystemContextBoundaryWindow::Parked {
1821                run_id: current, ..
1822            }
1823            | SystemContextBoundaryWindow::Resolved {
1824                run_id: current, ..
1825            }
1826            | SystemContextBoundaryWindow::Consuming {
1827                run_id: current, ..
1828            } => current == run_id,
1829            SystemContextBoundaryWindow::Closed => false,
1830        };
1831        if owns_window {
1832            lifecycle.window = SystemContextBoundaryWindow::Closed;
1833            drop(lifecycle);
1834            self.notify.notify_waiters();
1835        }
1836    }
1837
1838    fn revoke_actor(&self) {
1839        let mut lifecycle = self.lock();
1840        lifecycle.actor_live = false;
1841        lifecycle.window = SystemContextBoundaryWindow::Closed;
1842        drop(lifecycle);
1843        self.notify.notify_waiters();
1844    }
1845}
1846
1847/// Run-scoped closure guard for the exact actor's cooperative model boundary.
1848/// Every normal return, error, hard-cancel drop, and task abort closes any
1849/// registered or parked request for this run.
1850#[must_use]
1851pub(crate) struct SystemContextBoundaryRunGuard {
1852    boundary: Arc<SystemContextBoundaryCoordinator>,
1853    run_id: RunId,
1854}
1855
1856impl Drop for SystemContextBoundaryRunGuard {
1857    fn drop(&mut self) {
1858        self.boundary.close_run(&self.run_id);
1859    }
1860}
1861
1862struct PendingSystemContextBoundaryPreparation {
1863    boundary: Arc<SystemContextBoundaryCoordinator>,
1864    request_id: u64,
1865    armed: bool,
1866}
1867
1868impl Drop for PendingSystemContextBoundaryPreparation {
1869    fn drop(&mut self) {
1870        if self.armed {
1871            let _ = self.boundary.abort_request(self.request_id);
1872        }
1873    }
1874}
1875
1876/// Runner-owned witness for the exact model request currently being prepared.
1877///
1878/// External commit only publishes the candidate as canonical pending state; it
1879/// does not claim that the model has consumed it. The runner retains this
1880/// second, actor-local witness across fallible/async request preprocessing and
1881/// marks the pending state applied synchronously at the final LLM call seam.
1882/// Dropping the witness closes the generation without marking anything applied.
1883#[must_use = "model-boundary context must be consumed or dropped before opening another boundary"]
1884pub(crate) struct ModelBoundarySystemContext {
1885    state: SystemContextStateHandle,
1886    run_id: RunId,
1887    generation: u64,
1888    request_id: Option<u64>,
1889    appends: Vec<PendingSystemContextAppend>,
1890    armed: bool,
1891}
1892
1893impl ModelBoundarySystemContext {
1894    pub(crate) fn appends(&self) -> &[PendingSystemContextAppend] {
1895        &self.appends
1896    }
1897
1898    /// Pre-serialize the exact post-consumption metadata state while failure is
1899    /// still harmless. The consuming window rejects concurrent mutation, so
1900    /// this projection remains exact until [`Self::consume`].
1901    pub(crate) fn projected_state_after_consume(&self) -> SessionSystemContextState {
1902        let mut projected = self.state.snapshot();
1903        projected.mark_pending_applied();
1904        projected
1905    }
1906
1907    pub(crate) fn consume(
1908        mut self,
1909    ) -> Result<Vec<PendingSystemContextAppend>, CoreBoundaryStageError> {
1910        self.state.finish_model_boundary_consumption(
1911            &self.run_id,
1912            self.generation,
1913            self.request_id,
1914            true,
1915        )?;
1916        self.armed = false;
1917        Ok(std::mem::take(&mut self.appends))
1918    }
1919}
1920
1921impl Drop for ModelBoundarySystemContext {
1922    fn drop(&mut self) {
1923        if self.armed {
1924            let _ = self.state.finish_model_boundary_consumption(
1925                &self.run_id,
1926                self.generation,
1927                self.request_id,
1928                false,
1929            );
1930            self.armed = false;
1931        }
1932    }
1933}
1934
1935/// Unforgeable exact `{actor incarnation, run, boundary generation}`
1936/// preparation. It is created only by the shared system-context authority
1937/// after the runner has parked at the named boundary.
1938///
1939/// ```compile_fail
1940/// use meerkat_core::PreparedSystemContextBoundary;
1941/// fn cannot_duplicate(authority: &PreparedSystemContextBoundary) {
1942///     let _duplicate = authority.clone();
1943/// }
1944/// ```
1945#[must_use = "prepared system context must be committed or aborted"]
1946pub struct PreparedSystemContextBoundary {
1947    state: SystemContextStateHandle,
1948    expected_run_id: RunId,
1949    generation: u64,
1950    request_id: u64,
1951    candidate_state: SessionSystemContextState,
1952    armed: bool,
1953    // The unique resolution authority may move to an owned commit task, but
1954    // sharing one authority by reference across threads is unnecessary and
1955    // obscures its exactly-once ownership contract.
1956    _not_sync: std::marker::PhantomData<std::cell::Cell<()>>,
1957}
1958
1959impl std::fmt::Debug for PreparedSystemContextBoundary {
1960    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1961        formatter
1962            .debug_struct("PreparedSystemContextBoundary")
1963            .field("actor_incarnation", &self.state.boundary.incarnation_id)
1964            .field("expected_run_id", &self.expected_run_id)
1965            .field("generation", &self.generation)
1966            .field("request_id", &self.request_id)
1967            .finish_non_exhaustive()
1968    }
1969}
1970
1971impl PreparedSystemContextBoundary {
1972    #[must_use]
1973    pub fn expected_run_id(&self) -> &RunId {
1974        &self.expected_run_id
1975    }
1976
1977    #[must_use]
1978    pub fn boundary_generation(&self) -> u64 {
1979        self.generation
1980    }
1981
1982    #[must_use]
1983    pub fn candidate_state(&self) -> &SessionSystemContextState {
1984        &self.candidate_state
1985    }
1986
1987    /// Bind the unforgeable parked authority to its optional durable session
1988    /// snapshot. Surfaces cannot manufacture a successful output without this
1989    /// core-minted preparation value.
1990    pub fn into_stage_output(
1991        self,
1992        session_snapshot: Option<Vec<u8>>,
1993    ) -> crate::lifecycle::CoreBoundaryStageOutput {
1994        crate::lifecycle::CoreBoundaryStageOutput::prepared(session_snapshot, Box::new(self))
1995    }
1996
1997    fn resolve(
1998        &mut self,
1999        resolution: SystemContextBoundaryResolution,
2000    ) -> Result<(), CoreBoundaryStageError> {
2001        if !self.armed {
2002            return Err(CoreBoundaryStageError::stale(
2003                "prepared boundary authority was already resolved",
2004            ));
2005        }
2006        let mut lifecycle = self.state.boundary.lock();
2007        if !lifecycle.actor_live {
2008            self.armed = false;
2009            return Err(CoreBoundaryStageError::stale(format!(
2010                "actor incarnation {} was revoked",
2011                self.state.boundary.incarnation_id
2012            )));
2013        }
2014        let matches_exact = matches!(
2015            &lifecycle.window,
2016            SystemContextBoundaryWindow::Parked {
2017                run_id,
2018                generation,
2019                request_id,
2020                ..
2021            } if run_id == &self.expected_run_id
2022                && *generation == self.generation
2023                && *request_id == self.request_id
2024        );
2025        if !matches_exact {
2026            self.armed = false;
2027            return Err(CoreBoundaryStageError::stale(format!(
2028                "actor/run/boundary witness no longer matches request {}",
2029                self.request_id
2030            )));
2031        }
2032        if matches!(&resolution, SystemContextBoundaryResolution::Committed) {
2033            let mut state = self
2034                .state
2035                .inner
2036                .lock()
2037                .unwrap_or_else(std::sync::PoisonError::into_inner);
2038            *state = self.candidate_state.clone();
2039        }
2040        lifecycle.window = SystemContextBoundaryWindow::Resolved {
2041            run_id: self.expected_run_id.clone(),
2042            generation: self.generation,
2043            request_id: self.request_id,
2044            resolution,
2045        };
2046        self.armed = false;
2047        drop(lifecycle);
2048        self.state.boundary.notify.notify_waiters();
2049        Ok(())
2050    }
2051}
2052
2053impl crate::lifecycle::core_executor::CoreBoundaryStageCommitAuthority
2054    for PreparedSystemContextBoundary
2055{
2056    fn commit(&mut self) -> Result<(), CoreBoundaryStageError> {
2057        self.resolve(SystemContextBoundaryResolution::Committed)
2058    }
2059
2060    fn abort(&mut self) -> Result<(), CoreBoundaryStageError> {
2061        self.resolve(SystemContextBoundaryResolution::Aborted)
2062    }
2063}
2064
2065impl Drop for PreparedSystemContextBoundary {
2066    fn drop(&mut self) {
2067        if self.armed {
2068            let _ = self.state.boundary.abort_request(self.request_id);
2069            self.armed = false;
2070        }
2071    }
2072}
2073
2074impl std::fmt::Debug for SystemContextStateHandle {
2075    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2076        f.debug_struct("SystemContextStateHandle")
2077            .field("inner", &"<Arc<Mutex<SessionSystemContextState>>>")
2078            .field("actor_incarnation", &self.boundary.incarnation_id)
2079            .finish()
2080    }
2081}
2082
2083impl SystemContextStateHandle {
2084    fn boundary_reserves_state(lifecycle: &SystemContextBoundaryLifecycle) -> bool {
2085        matches!(
2086            &lifecycle.window,
2087            SystemContextBoundaryWindow::Parked { .. }
2088                | SystemContextBoundaryWindow::Resolved { .. }
2089                | SystemContextBoundaryWindow::Consuming { .. }
2090        )
2091    }
2092
2093    pub fn new(state: SessionSystemContextState) -> Result<Self, serde_json::Error> {
2094        let state = system_context_authority::restore_system_context_state(state)
2095            .map_err(<serde_json::Error as serde::de::Error>::custom)?;
2096        Ok(Self {
2097            inner: Arc::new(std::sync::Mutex::new(state)),
2098            boundary: Arc::new(SystemContextBoundaryCoordinator::default()),
2099        })
2100    }
2101
2102    /// Open the first exact cooperative model-boundary window for `run_id` and
2103    /// return a guard that closes it on every exit, including future drop.
2104    pub(crate) fn begin_boundary_run(
2105        &self,
2106        run_id: RunId,
2107    ) -> Result<SystemContextBoundaryRunGuard, CoreBoundaryStageError> {
2108        self.open_next_boundary(&run_id)?;
2109        Ok(SystemContextBoundaryRunGuard {
2110            boundary: Arc::clone(&self.boundary),
2111            run_id,
2112        })
2113    }
2114
2115    /// Ensure an exact next-boundary window is open for the active run. Calling
2116    /// this twice before consumption is idempotent; after consumption it mints
2117    /// the next monotonically increasing actor-local generation.
2118    pub(crate) fn open_next_boundary(&self, run_id: &RunId) -> Result<u64, CoreBoundaryStageError> {
2119        let mut lifecycle = self.boundary.lock();
2120        if !lifecycle.actor_live {
2121            return Err(CoreBoundaryStageError::stale(format!(
2122                "actor incarnation {} was revoked",
2123                self.boundary.incarnation_id
2124            )));
2125        }
2126        match &lifecycle.window {
2127            SystemContextBoundaryWindow::Open {
2128                run_id: current,
2129                generation,
2130                ..
2131            } if current == run_id => return Ok(*generation),
2132            SystemContextBoundaryWindow::Parked { .. }
2133            | SystemContextBoundaryWindow::Resolved { .. }
2134            | SystemContextBoundaryWindow::Consuming { .. } => {
2135                return Err(CoreBoundaryStageError::fault(
2136                    "runner attempted to open a new boundary while the prior boundary was unresolved",
2137                ));
2138            }
2139            SystemContextBoundaryWindow::Open {
2140                run_id: current, ..
2141            } => {
2142                return Err(CoreBoundaryStageError::stale(format!(
2143                    "run {run_id} cannot replace still-open boundary owned by {current}"
2144                )));
2145            }
2146            SystemContextBoundaryWindow::Closed => {}
2147        }
2148        lifecycle.next_generation = lifecycle
2149            .next_generation
2150            .checked_add(1)
2151            .ok_or_else(|| CoreBoundaryStageError::fault("boundary generation overflow"))?;
2152        let generation = lifecycle.next_generation;
2153        lifecycle.window = SystemContextBoundaryWindow::Open {
2154            run_id: run_id.clone(),
2155            generation,
2156            request: None,
2157        };
2158        drop(lifecycle);
2159        self.boundary.notify.notify_waiters();
2160        Ok(generation)
2161    }
2162
2163    /// Register context for the exact currently-open generation, then wait
2164    /// until the runner is parked immediately before consuming it. The lock
2165    /// linearization makes runner-first return `Unavailable` and prepare-first
2166    /// park; no snapshot/boolean sampling participates in the verdict.
2167    pub async fn prepare_active_turn_boundary(
2168        &self,
2169        expected_run_id: &RunId,
2170        appends: Vec<PendingSystemContextAppend>,
2171    ) -> Result<PreparedSystemContextBoundary, CoreBoundaryStageError> {
2172        if appends.is_empty() {
2173            return Err(CoreBoundaryStageError::fault(
2174                "boundary preparation requires at least one context append",
2175            ));
2176        }
2177        let stage_inputs = appends
2178            .into_iter()
2179            .map(|append| {
2180                (
2181                    AppendSystemContextRequest {
2182                        content: append.content,
2183                        source: append.source,
2184                        idempotency_key: append.idempotency_key,
2185                        source_kind: append.source_kind,
2186                        peer_response_terminal: append.peer_response_terminal,
2187                    },
2188                    append.accepted_at,
2189                )
2190            })
2191            .collect::<Vec<_>>();
2192
2193        let request_id = {
2194            let mut lifecycle = self.boundary.lock();
2195            if !lifecycle.actor_live {
2196                return Err(CoreBoundaryStageError::stale(format!(
2197                    "actor incarnation {} was revoked",
2198                    self.boundary.incarnation_id
2199                )));
2200            }
2201            let (run_id, request) = match &mut lifecycle.window {
2202                SystemContextBoundaryWindow::Open {
2203                    run_id, request, ..
2204                } => (run_id, request),
2205                SystemContextBoundaryWindow::Closed => {
2206                    return Err(CoreBoundaryStageError::unavailable(format!(
2207                        "run {expected_run_id} has no open cooperative model boundary"
2208                    )));
2209                }
2210                SystemContextBoundaryWindow::Parked { .. }
2211                | SystemContextBoundaryWindow::Resolved { .. }
2212                | SystemContextBoundaryWindow::Consuming { .. } => {
2213                    return Err(CoreBoundaryStageError::unavailable(format!(
2214                        "the open boundary for run {expected_run_id} was already claimed or consumed"
2215                    )));
2216                }
2217            };
2218            if run_id != expected_run_id {
2219                return Err(CoreBoundaryStageError::stale(format!(
2220                    "open boundary belongs to run {run_id}, not {expected_run_id}"
2221                )));
2222            }
2223            if request.is_some() {
2224                return Err(CoreBoundaryStageError::unavailable(format!(
2225                    "the next boundary for run {expected_run_id} already has a preparation"
2226                )));
2227            }
2228            // Validate idempotency/conflict semantics against the exact state
2229            // observed at registration without publishing the candidate.
2230            let state = self
2231                .inner
2232                .lock()
2233                .unwrap_or_else(std::sync::PoisonError::into_inner);
2234            let mut candidate = state.clone();
2235            for (append, accepted_at) in &stage_inputs {
2236                candidate
2237                    .stage_active_turn_append(append, *accepted_at)
2238                    .map_err(|error| CoreBoundaryStageError::fault(error.to_string()))?;
2239            }
2240            drop(state);
2241            lifecycle.next_request_id = lifecycle
2242                .next_request_id
2243                .checked_add(1)
2244                .ok_or_else(|| CoreBoundaryStageError::fault("boundary request id overflow"))?;
2245            let request_id = lifecycle.next_request_id;
2246            let SystemContextBoundaryWindow::Open { request, .. } = &mut lifecycle.window else {
2247                return Err(CoreBoundaryStageError::fault(
2248                    "boundary window changed while registering preparation",
2249                ));
2250            };
2251            *request = Some(RegisteredSystemContextBoundaryRequest {
2252                request_id,
2253                appends: stage_inputs,
2254            });
2255            request_id
2256        };
2257
2258        let mut pending = PendingSystemContextBoundaryPreparation {
2259            boundary: Arc::clone(&self.boundary),
2260            request_id,
2261            armed: true,
2262        };
2263        self.boundary.notify.notify_waiters();
2264
2265        loop {
2266            let notified = self.boundary.notify.notified();
2267            tokio::pin!(notified);
2268            notified.as_mut().enable();
2269            let poll = {
2270                let lifecycle = self.boundary.lock();
2271                if lifecycle.actor_live {
2272                    match &lifecycle.window {
2273                        SystemContextBoundaryWindow::Parked {
2274                            run_id,
2275                            generation,
2276                            request_id: parked_request_id,
2277                            candidate_state,
2278                        } if *parked_request_id == request_id => {
2279                            Ok(Some(PreparedSystemContextBoundary {
2280                                state: self.clone(),
2281                                expected_run_id: run_id.clone(),
2282                                generation: *generation,
2283                                request_id,
2284                                candidate_state: candidate_state.clone(),
2285                                armed: true,
2286                                _not_sync: std::marker::PhantomData,
2287                            }))
2288                        }
2289                        SystemContextBoundaryWindow::Open { request, .. }
2290                            if request
2291                                .as_ref()
2292                                .is_some_and(|request| request.request_id == request_id) =>
2293                        {
2294                            Ok(None)
2295                        }
2296                        SystemContextBoundaryWindow::Resolved {
2297                            request_id: resolved_request_id,
2298                            resolution,
2299                            ..
2300                        } if *resolved_request_id == request_id => match resolution {
2301                            SystemContextBoundaryResolution::Failed(error) => Err(error.clone()),
2302                            SystemContextBoundaryResolution::Committed
2303                            | SystemContextBoundaryResolution::Aborted => {
2304                                Err(CoreBoundaryStageError::stale(format!(
2305                                    "boundary request {request_id} resolved before its authority was delivered"
2306                                )))
2307                            }
2308                        },
2309                        _ => Err(CoreBoundaryStageError::unavailable(format!(
2310                            "run {expected_run_id} ended before boundary request {request_id} parked"
2311                        ))),
2312                    }
2313                } else {
2314                    Err(CoreBoundaryStageError::stale(format!(
2315                        "actor incarnation {} was revoked while preparing boundary",
2316                        self.boundary.incarnation_id
2317                    )))
2318                }
2319            };
2320            match poll {
2321                Ok(Some(prepared)) => {
2322                    pending.armed = false;
2323                    return Ok(prepared);
2324                }
2325                Ok(None) => notified.as_mut().await,
2326                Err(error) => return Err(error),
2327            }
2328        }
2329    }
2330
2331    /// Park at the exact model boundary and return a runner-owned consumption
2332    /// witness. Once a preparation has registered, this future cannot return
2333    /// until its authority commits, aborts, is dropped, or the run/actor closes.
2334    /// Returned pending state is not marked applied until the witness is
2335    /// synchronously consumed at the final LLM call seam.
2336    pub(crate) async fn take_pending_at_exact_boundary(
2337        &self,
2338        run_id: &RunId,
2339    ) -> Result<ModelBoundarySystemContext, CoreBoundaryStageError> {
2340        let parked_request_id;
2341        {
2342            let mut lifecycle = self.boundary.lock();
2343            if !lifecycle.actor_live {
2344                return Err(CoreBoundaryStageError::stale(format!(
2345                    "actor incarnation {} was revoked",
2346                    self.boundary.incarnation_id
2347                )));
2348            }
2349            let (generation, request) = match &mut lifecycle.window {
2350                SystemContextBoundaryWindow::Open {
2351                    run_id: current,
2352                    generation,
2353                    request,
2354                } if current == run_id => (*generation, request.take()),
2355                SystemContextBoundaryWindow::Open {
2356                    run_id: current, ..
2357                } => {
2358                    return Err(CoreBoundaryStageError::stale(format!(
2359                        "runner {run_id} reached boundary owned by {current}"
2360                    )));
2361                }
2362                SystemContextBoundaryWindow::Closed => {
2363                    return Err(CoreBoundaryStageError::unavailable(format!(
2364                        "run {run_id} reached a boundary with no open generation"
2365                    )));
2366                }
2367                SystemContextBoundaryWindow::Parked { .. }
2368                | SystemContextBoundaryWindow::Resolved { .. }
2369                | SystemContextBoundaryWindow::Consuming { .. } => {
2370                    return Err(CoreBoundaryStageError::fault(
2371                        "runner re-entered an unresolved model boundary",
2372                    ));
2373                }
2374            };
2375            if let Some(request) = request {
2376                let RegisteredSystemContextBoundaryRequest {
2377                    request_id,
2378                    appends,
2379                } = request;
2380                let state = self
2381                    .inner
2382                    .lock()
2383                    .unwrap_or_else(std::sync::PoisonError::into_inner);
2384                let mut candidate_state = state.clone();
2385                let candidate_result = appends.into_iter().try_for_each(|(append, accepted_at)| {
2386                    candidate_state
2387                        .stage_active_turn_append(&append, accepted_at)
2388                        .map(|_| ())
2389                });
2390                if let Err(error) = candidate_result {
2391                    drop(state);
2392                    let error = CoreBoundaryStageError::fault(error.to_string());
2393                    lifecycle.window = SystemContextBoundaryWindow::Resolved {
2394                        run_id: run_id.clone(),
2395                        generation,
2396                        request_id,
2397                        resolution: SystemContextBoundaryResolution::Failed(error.clone()),
2398                    };
2399                    drop(lifecycle);
2400                    self.boundary.notify.notify_waiters();
2401                    return Err(error);
2402                }
2403                drop(state);
2404                parked_request_id = request_id;
2405                lifecycle.window = SystemContextBoundaryWindow::Parked {
2406                    run_id: run_id.clone(),
2407                    generation,
2408                    request_id,
2409                    candidate_state,
2410                };
2411            } else {
2412                let state = self
2413                    .inner
2414                    .lock()
2415                    .unwrap_or_else(std::sync::PoisonError::into_inner);
2416                let pending = state.pending().to_vec();
2417                drop(state);
2418                lifecycle.window = SystemContextBoundaryWindow::Consuming {
2419                    run_id: run_id.clone(),
2420                    generation,
2421                    request_id: None,
2422                };
2423                return Ok(ModelBoundarySystemContext {
2424                    state: self.clone(),
2425                    run_id: run_id.clone(),
2426                    generation,
2427                    request_id: None,
2428                    appends: pending,
2429                    armed: true,
2430                });
2431            }
2432        }
2433        self.boundary.notify.notify_waiters();
2434
2435        let request_id = parked_request_id;
2436        struct RunnerParkGuard {
2437            boundary: Arc<SystemContextBoundaryCoordinator>,
2438            request_id: u64,
2439            armed: bool,
2440        }
2441        impl Drop for RunnerParkGuard {
2442            fn drop(&mut self) {
2443                if self.armed {
2444                    let _ = self.boundary.abort_request(self.request_id);
2445                }
2446            }
2447        }
2448        let mut park_guard = RunnerParkGuard {
2449            boundary: Arc::clone(&self.boundary),
2450            request_id,
2451            armed: true,
2452        };
2453
2454        loop {
2455            let notified = self.boundary.notify.notified();
2456            tokio::pin!(notified);
2457            notified.as_mut().enable();
2458            let poll = {
2459                let mut lifecycle = self.boundary.lock();
2460                if lifecycle.actor_live {
2461                    match &lifecycle.window {
2462                        SystemContextBoundaryWindow::Parked {
2463                            request_id: parked_request_id,
2464                            ..
2465                        } if *parked_request_id == request_id => Ok(None),
2466                        SystemContextBoundaryWindow::Resolved {
2467                            run_id: resolved_run_id,
2468                            generation,
2469                            request_id: resolved_request_id,
2470                            resolution,
2471                        } if resolved_run_id == run_id && *resolved_request_id == request_id => {
2472                            let resolution = resolution.clone();
2473                            let generation = *generation;
2474                            if let SystemContextBoundaryResolution::Failed(error) = resolution {
2475                                Err(error)
2476                            } else {
2477                                let pending = {
2478                                    let state = self
2479                                        .inner
2480                                        .lock()
2481                                        .unwrap_or_else(std::sync::PoisonError::into_inner);
2482                                    state.pending().to_vec()
2483                                };
2484                                lifecycle.window = SystemContextBoundaryWindow::Consuming {
2485                                    run_id: run_id.clone(),
2486                                    generation,
2487                                    request_id: Some(request_id),
2488                                };
2489                                Ok(Some((
2490                                    ModelBoundarySystemContext {
2491                                        state: self.clone(),
2492                                        run_id: run_id.clone(),
2493                                        generation,
2494                                        request_id: Some(request_id),
2495                                        appends: pending,
2496                                        armed: true,
2497                                    },
2498                                    resolution,
2499                                )))
2500                            }
2501                        }
2502                        _ => Err(CoreBoundaryStageError::stale(format!(
2503                            "parked boundary request {request_id} lost exact run/generation authority"
2504                        ))),
2505                    }
2506                } else {
2507                    Err(CoreBoundaryStageError::stale(format!(
2508                        "actor incarnation {} was revoked while parked",
2509                        self.boundary.incarnation_id
2510                    )))
2511                }
2512            };
2513            match poll {
2514                Ok(Some((context, resolution))) => {
2515                    park_guard.armed = false;
2516                    self.boundary.notify.notify_waiters();
2517                    if matches!(resolution, SystemContextBoundaryResolution::Aborted) {
2518                        tracing::debug!(
2519                            actor_incarnation = %self.boundary.incarnation_id,
2520                            run_id = %run_id,
2521                            request_id,
2522                            "exact model-boundary preparation aborted; consuming ordinary pending context only"
2523                        );
2524                    }
2525                    return Ok(context);
2526                }
2527                Ok(None) => notified.as_mut().await,
2528                Err(error) => {
2529                    park_guard.armed = false;
2530                    return Err(error);
2531                }
2532            }
2533        }
2534    }
2535
2536    fn finish_model_boundary_consumption(
2537        &self,
2538        run_id: &RunId,
2539        generation: u64,
2540        request_id: Option<u64>,
2541        apply: bool,
2542    ) -> Result<(), CoreBoundaryStageError> {
2543        let mut lifecycle = self.boundary.lock();
2544        if !lifecycle.actor_live {
2545            return Err(CoreBoundaryStageError::stale(format!(
2546                "actor incarnation {} was revoked before model-boundary consumption",
2547                self.boundary.incarnation_id
2548            )));
2549        }
2550        let matches_exact = matches!(
2551            &lifecycle.window,
2552            SystemContextBoundaryWindow::Consuming {
2553                run_id: current_run_id,
2554                generation: current_generation,
2555                request_id: current_request_id,
2556            } if current_run_id == run_id
2557                && *current_generation == generation
2558                && *current_request_id == request_id
2559        );
2560        if !matches_exact {
2561            return Err(CoreBoundaryStageError::stale(format!(
2562                "runner model-boundary witness for run {run_id} generation {generation} is no longer current"
2563            )));
2564        }
2565        if apply {
2566            let mut state = self
2567                .inner
2568                .lock()
2569                .unwrap_or_else(std::sync::PoisonError::into_inner);
2570            state.mark_pending_applied();
2571        }
2572        lifecycle.window = SystemContextBoundaryWindow::Closed;
2573        drop(lifecycle);
2574        self.boundary.notify.notify_waiters();
2575        Ok(())
2576    }
2577
2578    /// Revoke this exact actor allocation. Existing prepared authorities can
2579    /// no longer publish, and all runner/preparer waiters are synchronously
2580    /// released before actor-registry removal awaits anything.
2581    pub fn revoke_boundary_actor(&self) {
2582        self.boundary.revoke_actor();
2583    }
2584
2585    pub fn snapshot(&self) -> SessionSystemContextState {
2586        match self.inner.lock() {
2587            Ok(guard) => guard.clone(),
2588            Err(poisoned) => {
2589                tracing::warn!("system-context state lock poisoned while reading snapshot");
2590                poisoned.into_inner().clone()
2591            }
2592        }
2593    }
2594
2595    pub fn replace_from_generated_restore(
2596        &self,
2597        state: SessionSystemContextState,
2598    ) -> Result<(), serde_json::Error> {
2599        let state = system_context_authority::restore_system_context_state(state)
2600            .map_err(<serde_json::Error as serde::de::Error>::custom)?;
2601        let boundary = self.boundary.lock();
2602        if Self::boundary_reserves_state(&boundary) {
2603            return Err(<serde_json::Error as serde::de::Error>::custom(
2604                "system-context state is reserved by an exact parked boundary",
2605            ));
2606        }
2607        match self.inner.lock() {
2608            Ok(mut guard) => {
2609                *guard = state;
2610            }
2611            Err(poisoned) => {
2612                tracing::warn!("system-context state lock poisoned while restoring state");
2613                *poisoned.into_inner() = state;
2614            }
2615        }
2616        Ok(())
2617    }
2618
2619    pub fn replace_from_generated_restore_if_changed(
2620        &self,
2621        state: SessionSystemContextState,
2622    ) -> Result<bool, serde_json::Error> {
2623        let state = system_context_authority::restore_system_context_state(state)
2624            .map_err(<serde_json::Error as serde::de::Error>::custom)?;
2625        let boundary = self.boundary.lock();
2626        if Self::boundary_reserves_state(&boundary) {
2627            return Err(<serde_json::Error as serde::de::Error>::custom(
2628                "system-context state is reserved by an exact parked boundary",
2629            ));
2630        }
2631        let mut guard = match self.inner.lock() {
2632            Ok(guard) => guard,
2633            Err(poisoned) => {
2634                tracing::warn!(
2635                    "system-context state lock poisoned while replacing generated-restored state"
2636                );
2637                poisoned.into_inner()
2638            }
2639        };
2640        if *guard == state {
2641            return Ok(false);
2642        }
2643        *guard = state;
2644        Ok(true)
2645    }
2646
2647    pub fn replace_from_generated_restore_if_current(
2648        &self,
2649        current: &SessionSystemContextState,
2650        replacement: SessionSystemContextState,
2651    ) -> Result<bool, serde_json::Error> {
2652        let replacement = system_context_authority::restore_system_context_state(replacement)
2653            .map_err(<serde_json::Error as serde::de::Error>::custom)?;
2654        let boundary = self.boundary.lock();
2655        if Self::boundary_reserves_state(&boundary) {
2656            return Err(<serde_json::Error as serde::de::Error>::custom(
2657                "system-context state is reserved by an exact parked boundary",
2658            ));
2659        }
2660        let mut guard = match self.inner.lock() {
2661            Ok(guard) => guard,
2662            Err(poisoned) => {
2663                tracing::warn!(
2664                    "system-context state lock poisoned while conditionally replacing generated-restored state"
2665                );
2666                poisoned.into_inner()
2667            }
2668        };
2669        if *guard != *current {
2670            return Ok(false);
2671        }
2672        *guard = replacement;
2673        Ok(true)
2674    }
2675
2676    pub fn stage_append_with_snapshot(
2677        &self,
2678        req: &AppendSystemContextRequest,
2679        accepted_at: SystemTime,
2680    ) -> Result<
2681        (
2682            crate::service::AppendSystemContextStatus,
2683            SessionSystemContextState,
2684            SessionSystemContextState,
2685        ),
2686        SystemContextStageError,
2687    > {
2688        let boundary = self.boundary.lock();
2689        if Self::boundary_reserves_state(&boundary) {
2690            return Err(SystemContextStageError::InvalidRequest(
2691                "system-context state is reserved by an exact parked boundary".to_string(),
2692            ));
2693        }
2694        let mut guard = match self.inner.lock() {
2695            Ok(guard) => guard,
2696            Err(poisoned) => {
2697                tracing::warn!("system-context state lock poisoned while staging append");
2698                poisoned.into_inner()
2699            }
2700        };
2701        let snapshot = guard.clone();
2702        let status = guard.stage_append(req, accepted_at)?;
2703        let staged = guard.clone();
2704        Ok((status, snapshot, staged))
2705    }
2706
2707    pub fn stage_active_turn_appends_with_snapshot(
2708        &self,
2709        appends: Vec<(AppendSystemContextRequest, SystemTime)>,
2710    ) -> Result<(SessionSystemContextState, SessionSystemContextState), SystemContextStageError>
2711    {
2712        let boundary = self.boundary.lock();
2713        if Self::boundary_reserves_state(&boundary) {
2714            return Err(SystemContextStageError::InvalidRequest(
2715                "system-context state is reserved by an exact parked boundary".to_string(),
2716            ));
2717        }
2718        let mut guard = match self.inner.lock() {
2719            Ok(guard) => guard,
2720            Err(poisoned) => {
2721                tracing::warn!(
2722                    "system-context state lock poisoned while staging active-turn appends"
2723                );
2724                poisoned.into_inner()
2725            }
2726        };
2727        let snapshot = guard.clone();
2728        let mut candidate = snapshot.clone();
2729        for (req, accepted_at) in appends {
2730            candidate.stage_active_turn_append(&req, accepted_at)?;
2731        }
2732        *guard = candidate.clone();
2733        let staged = candidate;
2734        Ok((snapshot, staged))
2735    }
2736
2737    pub fn discard_unapplied_active_turn_pending(&self) -> Result<usize, CoreBoundaryStageError> {
2738        let boundary = self.boundary.lock();
2739        if Self::boundary_reserves_state(&boundary) {
2740            return Err(CoreBoundaryStageError::fault(format!(
2741                "cannot discard active-turn system context while exact actor incarnation {} owns a parked or consuming boundary",
2742                self.boundary.incarnation_id
2743            )));
2744        }
2745        let discarded = match self.inner.lock() {
2746            Ok(mut guard) => guard.discard_unapplied_active_turn_pending(),
2747            Err(poisoned) => {
2748                tracing::warn!(
2749                    "system-context state lock poisoned while discarding active-turn context"
2750                );
2751                poisoned
2752                    .into_inner()
2753                    .discard_unapplied_active_turn_pending()
2754            }
2755        };
2756        Ok(discarded.len())
2757    }
2758
2759    pub fn discard_active_turn_pending_by_keys(
2760        &self,
2761        idempotency_keys: &[String],
2762    ) -> Result<Vec<PendingSystemContextAppend>, CoreBoundaryStageError> {
2763        let boundary = self.boundary.lock();
2764        if Self::boundary_reserves_state(&boundary) {
2765            return Err(CoreBoundaryStageError::fault(format!(
2766                "cannot discard keyed active-turn system context while exact actor incarnation {} owns a parked or consuming boundary",
2767                self.boundary.incarnation_id
2768            )));
2769        }
2770        let discarded = match self.inner.lock() {
2771            Ok(mut guard) => guard.discard_active_turn_pending_by_keys(idempotency_keys),
2772            Err(poisoned) => {
2773                tracing::warn!(
2774                    "system-context state lock poisoned while discarding active-turn pending appends"
2775                );
2776                poisoned
2777                    .into_inner()
2778                    .discard_active_turn_pending_by_keys(idempotency_keys)
2779            }
2780        };
2781        Ok(discarded)
2782    }
2783
2784    pub fn stage_active_turn_append(
2785        &self,
2786        req: &AppendSystemContextRequest,
2787        accepted_at: SystemTime,
2788    ) -> Result<crate::service::AppendSystemContextStatus, SystemContextStageError> {
2789        let boundary = self.boundary.lock();
2790        if Self::boundary_reserves_state(&boundary) {
2791            return Err(SystemContextStageError::InvalidRequest(
2792                "system-context state is reserved by an exact parked boundary".to_string(),
2793            ));
2794        }
2795        match self.inner.lock() {
2796            Ok(mut guard) => guard.stage_active_turn_append(req, accepted_at),
2797            Err(poisoned) => {
2798                tracing::warn!(
2799                    "system-context state lock poisoned while staging active-turn context"
2800                );
2801                poisoned
2802                    .into_inner()
2803                    .stage_active_turn_append(req, accepted_at)
2804            }
2805        }
2806    }
2807}
2808
2809/// Durable control state for runtime system-context append requests.
2810// Cannot derive `Eq`: `PendingSystemContextAppend` carries a typed
2811// `peer_response_terminal` fact whose render payload is a `serde_json::Value`.
2812#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
2813#[serde(rename_all = "snake_case")]
2814pub struct SessionSystemContextState {
2815    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2816    pub(crate) pending: Vec<PendingSystemContextAppend>,
2817    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2818    pub(crate) applied: Vec<PendingSystemContextAppend>,
2819    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
2820    pub(crate) seen: std::collections::BTreeMap<String, SeenSystemContextKey>,
2821    /// Keyed projection used for idempotency-aware rollback. This is not the
2822    /// lifetime owner because active-turn appends may be keyless.
2823    #[serde(default, skip_serializing_if = "std::collections::BTreeSet::is_empty")]
2824    pub(crate) active_turn_pending_keys: std::collections::BTreeSet<String>,
2825    /// Exact positions in `pending` that belong to the active turn.
2826    ///
2827    /// Idempotency keys are optional, so they cannot carry lifetime ownership.
2828    /// The positional witness is durable and independent of deduplication;
2829    /// every pending-queue mutation rebases it atomically with the queue.
2830    #[serde(default, skip_serializing_if = "std::collections::BTreeSet::is_empty")]
2831    pub(crate) active_turn_pending_indices: std::collections::BTreeSet<u64>,
2832}
2833
2834/// Typed provenance class for a runtime system-context append.
2835///
2836/// Canonical replacement for the retired `runtime:steer:` string-prefix
2837/// folklore. The PRODUCER of a runtime-steer append (the runtime input
2838/// projection in `meerkat-runtime`) constructs it with
2839/// [`SystemContextSource::RuntimeSteer`]; everything else is
2840/// [`SystemContextSource::Normal`]. No code reclassifies a `source` string
2841/// into this fact — it is set once at construction and the machine guards the
2842/// typed field.
2843#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
2844#[serde(rename_all = "snake_case")]
2845pub enum SystemContextSource {
2846    /// A durable, non-transient runtime context append (peer responses, etc.).
2847    #[default]
2848    Normal,
2849    /// A transient operator/peer steer append that must not survive past the
2850    /// turn it steers and must not be promoted to the durable applied set.
2851    RuntimeSteer,
2852}
2853
2854impl From<SystemContextSource> for session_document::SystemContextSource {
2855    fn from(value: SystemContextSource) -> Self {
2856        match value {
2857            SystemContextSource::Normal => Self::Normal,
2858            SystemContextSource::RuntimeSteer => Self::RuntimeSteer,
2859        }
2860    }
2861}
2862
2863impl SystemContextSource {
2864    /// Whether this is the default (`Normal`) provenance. Used by
2865    /// `skip_serializing_if` so durable appends serialize without the field.
2866    #[must_use]
2867    pub fn is_normal(&self) -> bool {
2868        matches!(self, Self::Normal)
2869    }
2870
2871    /// Whether this append is a transient runtime steer.
2872    #[must_use]
2873    pub fn is_runtime_steer(&self) -> bool {
2874        matches!(self, Self::RuntimeSteer)
2875    }
2876}
2877
2878/// Pending append request accepted by the control plane but not yet applied at an LLM boundary.
2879// Cannot derive `Eq`: the typed `peer_response_terminal` fact carries a
2880// `serde_json::Value` render payload, which is `PartialEq` but not `Eq`.
2881#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2882#[serde(rename_all = "snake_case")]
2883pub struct PendingSystemContextAppend {
2884    /// Typed renderable append content, carried end-to-end from the surface
2885    /// request ([`AppendSystemContextRequest.content`]). The ONE lowering to
2886    /// model-facing prompt text happens where the transcript consumes the
2887    /// append ([`CoreRenderable::render_text`] inside the render seam) —
2888    /// surfaces never pre-flatten this into a string.
2889    ///
2890    /// [`CoreRenderable::render_text`]: crate::lifecycle::run_primitive::CoreRenderable::render_text
2891    pub content: crate::lifecycle::run_primitive::CoreRenderable,
2892    #[serde(default, skip_serializing_if = "Option::is_none")]
2893    pub source: Option<String>,
2894    #[serde(default, skip_serializing_if = "Option::is_none")]
2895    pub idempotency_key: Option<String>,
2896    /// Typed provenance: whether this append is a transient runtime steer.
2897    #[serde(default, skip_serializing_if = "SystemContextSource::is_normal")]
2898    pub source_kind: SystemContextSource,
2899    /// Typed terminal-peer-response fact this append carries, when the append
2900    /// projects a `PeerResponseTerminalFact`. The producer stamps the typed
2901    /// fact here at construction; realtime/live consumers read the typed fact
2902    /// directly instead of re-parsing the flattened prompt `text`/`source`
2903    /// string (the `peer_response_terminal:` prefix + `Payload:` split). This
2904    /// mirrors the `source_kind` precedent that retired the `runtime:steer:`
2905    /// string-prefix re-derivation.
2906    #[serde(default, skip_serializing_if = "Option::is_none")]
2907    pub peer_response_terminal: Option<crate::handles::PeerResponseTerminalFact>,
2908    pub accepted_at: SystemTime,
2909}
2910
2911/// Typed terminal-lifecycle projection of the canonical
2912/// [`session_document::SessionDocumentMachine`] `session_lifecycle_terminal`
2913/// fact.
2914///
2915/// The machine owns archive lifecycle truth for ALL profiles (LUC-524 R004
2916/// fold): both the runtime-backed and the store-only archive paths drive the
2917/// machine's `ArchiveSessionDocument` input, and this reserved-key field is
2918/// the machine-realized durable projection of the emitted verdict — the shell
2919/// realizes it, it never decides it. `RuntimeState::Retired` is the runtime
2920/// realization of the SAME verdict; the fail-closed realization order (durable
2921/// document commit first, runtime retire second) keeps the two projections
2922/// convergent. A two-variant enum (rather than a bare bool) keeps future
2923/// terminal classes — e.g. `Destroyed` — extending the type rather than the
2924/// call sites.
2925#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2926#[serde(rename_all = "snake_case")]
2927pub enum SessionLifecycleTerminal {
2928    /// The session is live / resumable.
2929    Active,
2930    /// The session has been archived and is terminal.
2931    Archived,
2932}
2933
2934impl SessionLifecycleTerminal {
2935    /// Whether this terminal fact marks the session as archived.
2936    #[must_use]
2937    pub fn is_archived(self) -> bool {
2938        matches!(self, Self::Archived)
2939    }
2940}
2941
2942impl From<SessionLifecycleTerminal> for session_document::SessionDocumentLifecycle {
2943    fn from(value: SessionLifecycleTerminal) -> Self {
2944        match value {
2945            SessionLifecycleTerminal::Active => Self::Active,
2946            SessionLifecycleTerminal::Archived => Self::Archived,
2947        }
2948    }
2949}
2950
2951impl From<session_document::SessionDocumentLifecycle> for SessionLifecycleTerminal {
2952    fn from(value: session_document::SessionDocumentLifecycle) -> Self {
2953        match value {
2954            session_document::SessionDocumentLifecycle::Active => Self::Active,
2955            session_document::SessionDocumentLifecycle::Archived => Self::Archived,
2956        }
2957    }
2958}
2959
2960/// Durable control state for deferred first-turn prompt and staged callback tool results.
2961#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
2962#[serde(rename_all = "snake_case")]
2963pub struct SessionDeferredTurnState {
2964    #[serde(default, skip_serializing_if = "DeferredFirstTurnPhase::is_inactive")]
2965    pub(crate) first_turn_phase: DeferredFirstTurnPhase,
2966    #[serde(default, skip_serializing_if = "Option::is_none")]
2967    pub(crate) pending_initial_prompt: Option<PendingDeferredPrompt>,
2968    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2969    pub(crate) pending_tool_results: Vec<PendingToolResultsMessage>,
2970}
2971
2972/// Canonical lifecycle phase for the session's deferred first turn.
2973#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
2974#[serde(rename_all = "snake_case")]
2975pub enum DeferredFirstTurnPhase {
2976    /// The session was not created in deferred-first-turn mode.
2977    #[default]
2978    Inactive,
2979    /// The session exists durably but the first turn has not started yet.
2980    Pending,
2981    /// The first turn has started; build-only overrides are no longer legal.
2982    Consumed,
2983}
2984
2985impl DeferredFirstTurnPhase {
2986    pub fn is_inactive(&self) -> bool {
2987        matches!(self, Self::Inactive)
2988    }
2989}
2990
2991impl From<DeferredFirstTurnPhase> for session_document::SessionFirstTurnPhase {
2992    fn from(value: DeferredFirstTurnPhase) -> Self {
2993        match value {
2994            DeferredFirstTurnPhase::Inactive => Self::Inactive,
2995            DeferredFirstTurnPhase::Pending => Self::Pending,
2996            DeferredFirstTurnPhase::Consumed => Self::Consumed,
2997        }
2998    }
2999}
3000
3001impl From<session_document::SessionFirstTurnPhase> for DeferredFirstTurnPhase {
3002    fn from(value: session_document::SessionFirstTurnPhase) -> Self {
3003        match value {
3004            session_document::SessionFirstTurnPhase::Inactive => Self::Inactive,
3005            session_document::SessionFirstTurnPhase::Pending => Self::Pending,
3006            session_document::SessionFirstTurnPhase::Consumed => Self::Consumed,
3007        }
3008    }
3009}
3010
3011fn is_default_hook_run_overrides(value: &crate::HookRunOverrides) -> bool {
3012    value == &crate::HookRunOverrides::default()
3013}
3014
3015fn is_default_call_timeout_override(value: &crate::CallTimeoutOverride) -> bool {
3016    value == &crate::CallTimeoutOverride::default()
3017}
3018
3019fn is_tool_filter_all(value: &ToolFilter) -> bool {
3020    matches!(value, ToolFilter::All)
3021}
3022
3023fn is_zero(value: &u64) -> bool {
3024    *value == 0
3025}
3026
3027/// Derive the machine-owned capability base filter from the current image-tool-results support.
3028pub fn capability_base_filter_for_image_tool_results(image_tool_results: bool) -> ToolFilter {
3029    if image_tool_results {
3030        ToolFilter::All
3031    } else {
3032        ToolFilter::Deny([VIEW_IMAGE_TOOL_NAME.to_string()].into_iter().collect())
3033    }
3034}
3035
3036/// Persisted witness for a durable tool-visibility name.
3037///
3038/// `last_seen_provenance` is the single typed identity owner. The formatted
3039/// `stable_owner_key` string is a read-only projection derived on demand via
3040/// [`crate::tool_catalog::stable_owner_key_from_provenance`], never stored
3041/// beside the owner.
3042#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
3043#[serde(rename_all = "snake_case")]
3044pub struct ToolVisibilityWitness {
3045    #[serde(default, skip_serializing_if = "Option::is_none")]
3046    pub last_seen_provenance: Option<ToolProvenance>,
3047}
3048
3049impl ToolVisibilityWitness {
3050    pub fn has_identity_witness(&self) -> bool {
3051        self.last_seen_provenance.is_some()
3052    }
3053}
3054
3055/// Typed authority value for a deferred-tool load request.
3056///
3057/// The public/effect seam carries the requested route name and provenance
3058/// witness as one value. Canonical owners may project this into name-indexed
3059/// maps internally, but callers do not get to make a map key the authority.
3060#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
3061#[serde(rename_all = "snake_case")]
3062pub struct DeferredToolLoadAuthority {
3063    pub name: ToolName,
3064    pub witness: ToolVisibilityWitness,
3065}
3066
3067impl DeferredToolLoadAuthority {
3068    pub fn new(name: impl Into<ToolName>, witness: ToolVisibilityWitness) -> Self {
3069        Self {
3070            name: name.into(),
3071            witness,
3072        }
3073    }
3074
3075    pub fn into_parts(self) -> (ToolName, ToolVisibilityWitness) {
3076        (self.name, self.witness)
3077    }
3078}
3079
3080/// Durable tool-filter intent paired with the witnesses that made the names
3081/// authoritative at capture time.
3082#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
3083#[serde(rename_all = "snake_case")]
3084pub struct WitnessedToolFilter {
3085    pub filter: ToolFilter,
3086    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
3087    pub witnesses: BTreeMap<ToolName, ToolVisibilityWitness>,
3088}
3089
3090impl WitnessedToolFilter {
3091    pub fn new(filter: ToolFilter, witnesses: BTreeMap<ToolName, ToolVisibilityWitness>) -> Self {
3092        Self { filter, witnesses }
3093    }
3094
3095    pub fn into_parts(self) -> (ToolFilter, BTreeMap<ToolName, ToolVisibilityWitness>) {
3096        (self.filter, self.witnesses)
3097    }
3098}
3099
3100/// Opaque parent/composition-authorized inherited tool visibility handoff.
3101///
3102/// The filter and witnesses are intentionally not public fields. Callers that
3103/// need to hand inherited visibility to a child build must obtain this from an
3104/// AgentFactory-minted parent composition authority; they cannot write
3105/// canonical session visibility state directly.
3106#[derive(Debug, Clone, PartialEq, Eq)]
3107pub struct InheritedToolVisibilityAuthority {
3108    filter: ToolFilter,
3109    witnesses: BTreeMap<ToolName, ToolVisibilityWitness>,
3110}
3111
3112impl InheritedToolVisibilityAuthority {
3113    pub(crate) fn from_generated_composition_authority(
3114        filter: ToolFilter,
3115        witnesses: BTreeMap<ToolName, ToolVisibilityWitness>,
3116    ) -> Self {
3117        Self { filter, witnesses }
3118    }
3119
3120    pub fn filter(&self) -> &ToolFilter {
3121        &self.filter
3122    }
3123
3124    pub fn witnesses(&self) -> &BTreeMap<ToolName, ToolVisibilityWitness> {
3125        &self.witnesses
3126    }
3127
3128    pub(crate) fn into_initial_visibility_state(self) -> SessionToolVisibilityState {
3129        SessionToolVisibilityState {
3130            inherited_base_filter: self.filter,
3131            filter_witnesses: self.witnesses,
3132            ..Default::default()
3133        }
3134    }
3135}
3136
3137/// Canonical durable session-local tool visibility intent.
3138#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
3139#[serde(rename_all = "snake_case")]
3140pub struct SessionToolVisibilityState {
3141    #[serde(default, skip_serializing_if = "is_tool_filter_all")]
3142    pub capability_base_filter: ToolFilter,
3143    #[serde(default, skip_serializing_if = "is_tool_filter_all")]
3144    pub inherited_base_filter: ToolFilter,
3145    #[serde(default, skip_serializing_if = "is_tool_filter_all")]
3146    pub active_filter: ToolFilter,
3147    #[serde(default, skip_serializing_if = "is_tool_filter_all")]
3148    pub staged_filter: ToolFilter,
3149    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
3150    pub active_requested_deferred_names: BTreeSet<ToolName>,
3151    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
3152    pub staged_requested_deferred_names: BTreeSet<ToolName>,
3153    #[serde(default, skip_serializing_if = "is_zero")]
3154    pub active_revision: u64,
3155    #[serde(default, skip_serializing_if = "is_zero")]
3156    pub staged_revision: u64,
3157    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
3158    pub requested_witnesses: BTreeMap<ToolName, ToolVisibilityWitness>,
3159    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
3160    pub filter_witnesses: BTreeMap<ToolName, ToolVisibilityWitness>,
3161}
3162
3163impl SessionToolVisibilityState {
3164    /// Deterministic projection of the generated CallingLlm visibility
3165    /// boundary. This is a comparison witness only: semantic promotion still
3166    /// belongs to the generated visibility owner.
3167    #[cfg(test)]
3168    pub(crate) fn projected_boundary_applied(&self) -> Self {
3169        let mut projected = self.clone();
3170        projected.active_filter = self.staged_filter.clone();
3171        projected.active_requested_deferred_names = self.staged_requested_deferred_names.clone();
3172        projected.active_revision = self.staged_revision;
3173        projected
3174    }
3175}
3176
3177/// Generated-authority-approved durable tool visibility projection.
3178///
3179/// Session metadata stores this as a projection of the generated visibility
3180/// owner. Code that only has raw `SessionToolVisibilityState` must first route
3181/// it through a `ToolVisibilityOwner`/`ToolScope` restore path.
3182#[derive(Debug, Clone, PartialEq, Eq)]
3183pub struct AuthorizedSessionToolVisibilityState {
3184    state: SessionToolVisibilityState,
3185}
3186
3187impl AuthorizedSessionToolVisibilityState {
3188    pub(crate) fn from_generated_authority(state: SessionToolVisibilityState) -> Self {
3189        Self { state }
3190    }
3191
3192    pub fn as_state(&self) -> &SessionToolVisibilityState {
3193        &self.state
3194    }
3195
3196    pub fn into_state(self) -> SessionToolVisibilityState {
3197        self.state
3198    }
3199}
3200
3201/// Durable build-only session state required to faithfully recover and rebuild
3202/// a persisted session without surface-local shadow config.
3203#[derive(Debug, Clone, Serialize, Deserialize, Default)]
3204#[serde(rename_all = "snake_case")]
3205pub struct SessionBuildState {
3206    #[serde(
3207        default,
3208        skip_serializing_if = "crate::config::SystemPromptOverride::is_inherit"
3209    )]
3210    pub system_prompt: crate::config::SystemPromptOverride,
3211    #[serde(default, skip_serializing_if = "Option::is_none")]
3212    pub output_schema: Option<crate::OutputSchema>,
3213    #[serde(default, skip_serializing_if = "is_default_hook_run_overrides")]
3214    pub hooks_override: crate::HookRunOverrides,
3215    #[serde(default, skip_serializing_if = "Option::is_none")]
3216    pub budget_limits: Option<crate::BudgetLimits>,
3217    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3218    pub recoverable_tool_defs: Vec<ToolDef>,
3219    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3220    pub silent_comms_intents: Vec<String>,
3221    #[serde(default, skip_serializing_if = "Option::is_none")]
3222    pub max_inline_peer_notifications: Option<i32>,
3223    #[serde(default, skip_serializing_if = "Option::is_none")]
3224    pub app_context: Option<serde_json::Value>,
3225    #[serde(default, skip_serializing_if = "Option::is_none")]
3226    pub additional_instructions: Option<Vec<String>>,
3227    #[serde(default, skip_serializing_if = "Option::is_none")]
3228    pub shell_env: Option<HashMap<String, String>>,
3229    /// Compatibility projection of mob operator authority.
3230    ///
3231    /// `MobToolAuthorityContext` deliberately loses its generated authority
3232    /// seal when serialized; restored behavior must be approved by the
3233    /// generated runtime bridge before this projection can affect tools.
3234    #[serde(default, skip_serializing_if = "Option::is_none")]
3235    pub mob_tool_authority_context: Option<MobToolAuthorityContext>,
3236    #[serde(default, skip_serializing_if = "is_default_call_timeout_override")]
3237    pub call_timeout_override: crate::CallTimeoutOverride,
3238    /// Exact assembled base-prompt bytes the last build applied (or verified)
3239    /// for this session. Runtime system-context appends extend the leading
3240    /// System message past this base; recording the base lets a later resume
3241    /// split the persisted content into `base + appended tail` byte-exactly
3242    /// (see [`Session::reconcile_resumed_system_prompt`]) instead of
3243    /// re-deriving append renders.
3244    #[serde(default, skip_serializing_if = "Option::is_none")]
3245    pub assembled_system_prompt: Option<String>,
3246}
3247
3248/// Deferred create-time prompt staged for the next turn.
3249#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
3250#[serde(rename_all = "snake_case")]
3251pub struct PendingDeferredPrompt {
3252    pub prompt: ContentInput,
3253    pub accepted_at: SystemTime,
3254}
3255
3256/// Staged callback tool results waiting to be admitted on the next turn seam.
3257#[derive(Debug, Clone, Serialize, Deserialize)]
3258#[serde(rename_all = "snake_case")]
3259pub struct PendingToolResultsMessage {
3260    pub results: Vec<ToolResult>,
3261    pub accepted_at: SystemTime,
3262}
3263
3264impl PartialEq for PendingToolResultsMessage {
3265    fn eq(&self, other: &Self) -> bool {
3266        self.accepted_at == other.accepted_at
3267            && serde_json::to_value(&self.results).ok() == serde_json::to_value(&other.results).ok()
3268    }
3269}
3270
3271/// Deferred first-turn inputs consumed at the generated start-turn authority seam.
3272#[derive(Debug, Clone, Default, PartialEq)]
3273pub struct ConsumedDeferredTurnInputs {
3274    pub(crate) restore_first_turn_pending: bool,
3275    pub(crate) pending_initial_prompt: Option<PendingDeferredPrompt>,
3276    pub(crate) pending_tool_results: Vec<PendingToolResultsMessage>,
3277}
3278
3279impl ConsumedDeferredTurnInputs {
3280    pub fn is_empty(&self) -> bool {
3281        !self.restore_first_turn_pending
3282            && self.pending_initial_prompt.is_none()
3283            && self.pending_tool_results.is_empty()
3284    }
3285
3286    pub fn pending_initial_prompt(&self) -> Option<&PendingDeferredPrompt> {
3287        self.pending_initial_prompt.as_ref()
3288    }
3289
3290    pub fn pending_tool_results(&self) -> &[PendingToolResultsMessage] {
3291        &self.pending_tool_results
3292    }
3293}
3294
3295/// Seen idempotency-key entry for system-context append requests.
3296#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
3297#[serde(rename_all = "snake_case")]
3298pub struct SeenSystemContextKey {
3299    /// Typed renderable content of the accepted append for this key.
3300    pub content: crate::lifecycle::run_primitive::CoreRenderable,
3301    #[serde(default, skip_serializing_if = "Option::is_none")]
3302    pub source: Option<String>,
3303    /// Typed provenance carried from the append, so runtime-steer cleanup can
3304    /// match seen entries by the typed marker rather than a `source` prefix.
3305    #[serde(default, skip_serializing_if = "SystemContextSource::is_normal")]
3306    pub source_kind: SystemContextSource,
3307    pub state: SeenSystemContextState,
3308}
3309
3310/// Lifecycle state for an accepted idempotency key.
3311#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
3312#[serde(rename_all = "snake_case")]
3313pub enum SeenSystemContextState {
3314    Pending,
3315    Applied,
3316}
3317
3318impl SessionSystemContextState {
3319    pub fn pending(&self) -> &[PendingSystemContextAppend] {
3320        &self.pending
3321    }
3322
3323    pub fn applied(&self) -> &[PendingSystemContextAppend] {
3324        &self.applied
3325    }
3326
3327    pub fn seen(&self) -> &BTreeMap<String, SeenSystemContextKey> {
3328        &self.seen
3329    }
3330
3331    pub fn active_turn_pending_keys(&self) -> &BTreeSet<String> {
3332        &self.active_turn_pending_keys
3333    }
3334
3335    pub fn pending_len(&self) -> usize {
3336        self.pending.len()
3337    }
3338
3339    pub fn applied_len(&self) -> usize {
3340        self.applied.len()
3341    }
3342
3343    pub fn active_turn_pending_len(&self) -> usize {
3344        if self.active_turn_pending_indices.is_empty() && !self.active_turn_pending_keys.is_empty()
3345        {
3346            return self
3347                .pending
3348                .iter()
3349                .filter(|append| {
3350                    append
3351                        .idempotency_key
3352                        .as_ref()
3353                        .is_some_and(|key| self.active_turn_pending_keys.contains(key))
3354                })
3355                .count();
3356        }
3357        self.active_turn_pending_indices.len()
3358    }
3359
3360    pub fn realtime_projection_appends(&self) -> Vec<PendingSystemContextAppend> {
3361        self.applied
3362            .iter()
3363            .chain(self.pending.iter())
3364            .cloned()
3365            .collect()
3366    }
3367
3368    /// Stage an append request, enforcing per-session idempotency.
3369    pub fn stage_append(
3370        &mut self,
3371        req: &AppendSystemContextRequest,
3372        accepted_at: SystemTime,
3373    ) -> Result<crate::service::AppendSystemContextStatus, SystemContextStageError> {
3374        system_context_authority::stage_append(self, req, accepted_at, false)
3375    }
3376
3377    fn stage_append_with_generated_authority(
3378        &mut self,
3379        req: &AppendSystemContextRequest,
3380        accepted_at: SystemTime,
3381        active_turn_scoped: bool,
3382    ) -> Result<crate::service::AppendSystemContextStatus, SystemContextStageError> {
3383        system_context_authority::stage_append(self, req, accepted_at, active_turn_scoped)
3384    }
3385
3386    /// Stage an append that is scoped to the currently-active turn only.
3387    ///
3388    /// If the active turn reaches another model boundary, normal pending
3389    /// consumption moves it to `applied`. If the turn completes first, callers
3390    /// should discard the still-pending active-turn keys so the context cannot
3391    /// leak into an unrelated later run.
3392    pub fn stage_active_turn_append(
3393        &mut self,
3394        req: &AppendSystemContextRequest,
3395        accepted_at: SystemTime,
3396    ) -> Result<crate::service::AppendSystemContextStatus, SystemContextStageError> {
3397        self.stage_append_with_generated_authority(req, accepted_at, true)
3398    }
3399
3400    /// Mark all currently-pending appends as applied and clear the pending queue.
3401    pub fn mark_pending_applied(&mut self) {
3402        system_context_authority::mark_pending_applied(self);
3403    }
3404
3405    /// Discard active-turn-only appends that were not consumed by the turn's
3406    /// next LLM boundary.
3407    pub fn discard_unapplied_active_turn_pending(&mut self) -> Vec<PendingSystemContextAppend> {
3408        system_context_authority::discard_unapplied_active_turn_pending(self)
3409    }
3410
3411    /// Discard specific active-turn-only appends that are still pending.
3412    ///
3413    /// This is the rollback companion for live-boundary staging. The runtime
3414    /// owns the accepted input, so if that commit fails after the session has
3415    /// staged context, the session-side projection must be removed by the same
3416    /// idempotency keys before the caller reports failure.
3417    pub fn discard_active_turn_pending_by_keys(
3418        &mut self,
3419        idempotency_keys: &[String],
3420    ) -> Vec<PendingSystemContextAppend> {
3421        system_context_authority::discard_active_turn_pending_by_keys(self, idempotency_keys)
3422    }
3423
3424    /// Authorize this snapshot through the canonical
3425    /// [`session_document::SessionDocumentMachine`] system-context restore
3426    /// transition, returning the state unchanged on success.
3427    pub fn restore_from_snapshot(self) -> Result<Self, SystemContextStageError> {
3428        system_context_authority::restore_system_context_state(self)
3429    }
3430
3431    /// Record the machine-authorized applied system-context blocks, returning
3432    /// the appends that are newly applied (and thus need rendering into the
3433    /// system prompt by the caller).
3434    pub fn record_applied_blocks(
3435        &mut self,
3436        appends: &[PendingSystemContextAppend],
3437        current_system_prompt: &str,
3438    ) -> Vec<PendingSystemContextAppend> {
3439        system_context_authority::record_applied_system_context_blocks(
3440            self,
3441            appends,
3442            current_system_prompt,
3443        )
3444    }
3445}
3446
3447/// Per-session registry key for the first-turn region of the
3448/// [`session_document::SessionDocumentMachine`]. Each
3449/// [`SessionDeferredTurnState`] is a single session's projection, so its
3450/// machine instance carries exactly one registry entry under this key.
3451const SESSION_DOCUMENT_FIRST_TURN_KEY: &str = "first_turn";
3452
3453fn usize_to_u64(value: usize) -> u64 {
3454    u64::try_from(value).unwrap_or(u64::MAX)
3455}
3456
3457/// Authorize a durable deferred-turn snapshot through the canonical
3458/// [`session_document::SessionDocumentMachine`] recovery transition.
3459///
3460/// The machine validates that the persisted first-turn phase is a legal
3461/// recovery target and adopts it into its per-session registry, emitting
3462/// `SessionFirstTurnPhaseRecovered`. The snapshot is returned unchanged on
3463/// success; the machine — not this shell — owns the recovery legality.
3464fn validate_deferred_turn_snapshot(
3465    state: SessionDeferredTurnState,
3466) -> Result<SessionDeferredTurnState, session_document::SessionDocumentError> {
3467    let mut authority = session_document::SessionDocumentMachineAuthority::new();
3468    let key = session_document::SessionDocumentKey::new(SESSION_DOCUMENT_FIRST_TURN_KEY);
3469    // The recovery transition fails closed for any illegal first-turn phase
3470    // (its guard admits only the three known phases); a rejection surfaces as
3471    // `Err` here. On success the machine has adopted the snapshot.
3472    authority.recover_session_first_turn_phase(
3473        key,
3474        state.first_turn_phase.into(),
3475        state.pending_initial_prompt.is_some(),
3476        usize_to_u64(state.pending_tool_results.len()),
3477    )?;
3478    Ok(state)
3479}
3480
3481impl SessionDeferredTurnState {
3482    pub fn first_turn_phase(&self) -> DeferredFirstTurnPhase {
3483        self.first_turn_phase
3484    }
3485
3486    pub fn pending_initial_prompt(&self) -> Option<&PendingDeferredPrompt> {
3487        self.pending_initial_prompt.as_ref()
3488    }
3489
3490    pub fn pending_tool_results(&self) -> &[PendingToolResultsMessage] {
3491        &self.pending_tool_results
3492    }
3493
3494    pub fn pending_tool_results_len(&self) -> usize {
3495        self.pending_tool_results.len()
3496    }
3497
3498    pub(crate) fn pending_initial_prompt_mut_for_blob_rewrite(
3499        &mut self,
3500    ) -> Option<&mut PendingDeferredPrompt> {
3501        self.pending_initial_prompt.as_mut()
3502    }
3503
3504    pub(crate) fn pending_tool_results_mut_for_blob_rewrite(
3505        &mut self,
3506    ) -> &mut [PendingToolResultsMessage] {
3507        &mut self.pending_tool_results
3508    }
3509
3510    /// Build a [`SessionDocumentMachineAuthority`] seeded with this session's
3511    /// current durable first-turn projection.
3512    ///
3513    /// The machine owns the canonical first-turn phase + presence/count in its
3514    /// own per-session `Map`; the durable [`SessionDeferredTurnState`] is its
3515    /// projection. We recover the machine-owned registry from that projection
3516    /// before driving an operation so every subsequent decision reads the
3517    /// machine's own state — the shell never passes a phase conclusion as an
3518    /// operation input.
3519    fn document_authority(
3520        &self,
3521    ) -> (
3522        session_document::SessionDocumentMachineAuthority,
3523        session_document::SessionDocumentKey,
3524    ) {
3525        let mut authority = session_document::SessionDocumentMachineAuthority::new();
3526        let key = session_document::SessionDocumentKey::new(SESSION_DOCUMENT_FIRST_TURN_KEY);
3527        if let Err(err) = authority.recover_session_first_turn_phase(
3528            key.clone(),
3529            self.first_turn_phase.into(),
3530            self.pending_initial_prompt.is_some(),
3531            usize_to_u64(self.pending_tool_results.len()),
3532        ) {
3533            tracing::warn!(
3534                error = %err,
3535                "generated session document authority rejected first-turn recovery"
3536            );
3537        }
3538        (authority, key)
3539    }
3540
3541    /// Mirror the machine-resolved first-turn phase from one effect batch onto
3542    /// the durable projection, returning `was_pending` when present.
3543    fn mirror_first_turn_phase(
3544        &mut self,
3545        effects: &[session_document::SessionDocumentEffect],
3546    ) -> Option<bool> {
3547        for effect in effects {
3548            if let session_document::SessionDocumentEffect::SessionFirstTurnPhaseResolved {
3549                phase,
3550                was_pending,
3551            } = effect
3552            {
3553                self.first_turn_phase = (*phase).into();
3554                return Some(*was_pending);
3555            }
3556        }
3557        None
3558    }
3559
3560    /// Mark that this session has a deferred first turn waiting to start.
3561    pub fn mark_initial_turn_pending(&mut self) {
3562        let (mut authority, key) = self.document_authority();
3563        match authority.mark_session_initial_turn_pending(key) {
3564            Ok(effects) => {
3565                self.mirror_first_turn_phase(&effects);
3566            }
3567            Err(err) => tracing::warn!(
3568                error = %err,
3569                "generated session document authority rejected pending mark"
3570            ),
3571        }
3572    }
3573
3574    /// Mark the deferred first turn as started.
3575    ///
3576    /// Returns true when the phase transitioned from `Pending`.
3577    pub fn mark_initial_turn_started(&mut self) -> bool {
3578        let (mut authority, key) = self.document_authority();
3579        match authority.start_session_initial_turn(key) {
3580            Ok(effects) => self.mirror_first_turn_phase(&effects).unwrap_or(false),
3581            Err(err) => {
3582                tracing::warn!(
3583                    error = %err,
3584                    "generated session document authority rejected first-turn start"
3585                );
3586                false
3587            }
3588        }
3589    }
3590
3591    /// Restore the deferred first-turn pending phase after a failed pre-run setup.
3592    pub fn restore_initial_turn_pending(&mut self) {
3593        // The restore-to-pending decision is the machine's
3594        // `RestoreSessionConsumedInputs` transition with phase rollback
3595        // requested; presence/count mirrors are left untouched here because the
3596        // bulky payloads are restored separately by the caller.
3597        let (mut authority, key) = self.document_authority();
3598        match authority.restore_session_consumed_inputs(
3599            key.clone(),
3600            true,
3601            self.pending_initial_prompt.is_some(),
3602            usize_to_u64(self.pending_tool_results.len()),
3603        ) {
3604            Ok(_) => {
3605                // Mirror the machine-owned phase the restore transition wrote
3606                // into its per-session registry rather than re-deriving it.
3607                if let Some(phase) = authority.session_first_turn_phase_for(&key) {
3608                    self.first_turn_phase = phase.into();
3609                }
3610            }
3611            Err(err) => tracing::warn!(
3612                error = %err,
3613                "generated session document authority rejected pending restore"
3614            ),
3615        }
3616    }
3617
3618    /// Whether build-only first-turn overrides are still legal for this session.
3619    pub fn allows_initial_turn_overrides(&self) -> bool {
3620        let (mut authority, key) = self.document_authority();
3621        match authority.resolve_session_first_turn_overrides_allowed(key) {
3622            Ok(effects) => effects
3623                .iter()
3624                .find_map(|effect| {
3625                    match effect {
3626                session_document::SessionDocumentEffect::SessionFirstTurnOverridesResolved {
3627                    allowed,
3628                } => Some(*allowed),
3629                _ => None,
3630            }
3631                })
3632                .unwrap_or(false),
3633            Err(err) => {
3634                tracing::warn!(
3635                    error = %err,
3636                    "generated session document authority rejected override resolution"
3637                );
3638                false
3639            }
3640        }
3641    }
3642
3643    /// Stage the create-time prompt for a later first turn.
3644    pub fn stage_initial_prompt(&mut self, prompt: ContentInput, accepted_at: SystemTime) {
3645        let prompt_has_content = prompt.has_images() || !prompt.text_content().trim().is_empty();
3646        let (mut authority, key) = self.document_authority();
3647        match authority.stage_session_initial_prompt(key, prompt_has_content) {
3648            Ok(effects) => {
3649                let decision = effects.iter().find_map(|effect| {
3650                    match effect {
3651                    session_document::SessionDocumentEffect::SessionInitialPromptStageResolved {
3652                        decision,
3653                    } => Some(*decision),
3654                    _ => None,
3655                }
3656                });
3657                match decision {
3658                    Some(session_document::SessionInitialPromptStageDecision::Store) => {
3659                        self.pending_initial_prompt = Some(PendingDeferredPrompt {
3660                            prompt,
3661                            accepted_at,
3662                        });
3663                    }
3664                    Some(session_document::SessionInitialPromptStageDecision::Clear) => {
3665                        self.pending_initial_prompt = None;
3666                    }
3667                    None => tracing::warn!(
3668                        "generated session document authority returned no prompt-stage decision"
3669                    ),
3670                }
3671            }
3672            Err(err) => tracing::warn!(
3673                error = %err,
3674                "generated session document authority rejected initial prompt stage"
3675            ),
3676        }
3677    }
3678
3679    /// Stage one callback tool-results message for the next turn.
3680    pub fn stage_tool_results(
3681        &mut self,
3682        results: Vec<ToolResult>,
3683        accepted_at: SystemTime,
3684    ) -> usize {
3685        let (mut authority, key) = self.document_authority();
3686        let accepted = match authority.stage_session_tool_results(key, usize_to_u64(results.len()))
3687        {
3688            Ok(effects) => effects.iter().find_map(|effect| match effect {
3689                session_document::SessionDocumentEffect::SessionToolResultsStageResolved {
3690                    accepted_count,
3691                } => Some(*accepted_count),
3692                _ => None,
3693            }),
3694            Err(err) => {
3695                tracing::warn!(
3696                    error = %err,
3697                    "generated session document authority rejected tool-results stage"
3698                );
3699                return 0;
3700            }
3701        };
3702        let Some(accepted) = accepted else {
3703            tracing::warn!(
3704                "generated session document authority returned no tool-results decision"
3705            );
3706            return 0;
3707        };
3708        if accepted == 0 {
3709            return 0;
3710        }
3711        let accepted = usize::try_from(accepted).unwrap_or(usize::MAX);
3712        self.pending_tool_results.push(PendingToolResultsMessage {
3713            results,
3714            accepted_at,
3715        });
3716        accepted
3717    }
3718
3719    /// Whether any callback tool results are currently staged.
3720    pub fn has_pending_tool_results(&self) -> bool {
3721        !self.pending_tool_results.is_empty()
3722    }
3723
3724    /// Start a turn and consume all inputs generated-authorized for that seam.
3725    pub fn consume_for_started_turn(&mut self) -> ConsumedDeferredTurnInputs {
3726        let (mut authority, key) = self.document_authority();
3727        let was_pending = match authority.consume_session_deferred_inputs(key) {
3728            Ok(effects) => self.mirror_first_turn_phase(&effects).unwrap_or(false),
3729            Err(err) => {
3730                tracing::warn!(
3731                    error = %err,
3732                    "generated session document authority rejected started-turn consumption"
3733                );
3734                return ConsumedDeferredTurnInputs::default();
3735            }
3736        };
3737        ConsumedDeferredTurnInputs {
3738            restore_first_turn_pending: was_pending,
3739            pending_initial_prompt: self.pending_initial_prompt.take(),
3740            pending_tool_results: std::mem::take(&mut self.pending_tool_results),
3741        }
3742    }
3743
3744    /// Restore inputs previously consumed by `consume_for_started_turn`.
3745    pub fn restore_consumed_turn_inputs(&mut self, consumed: ConsumedDeferredTurnInputs) {
3746        if consumed.is_empty() {
3747            return;
3748        }
3749        let (mut authority, key) = self.document_authority();
3750        let effects = match authority.restore_session_consumed_inputs(
3751            key,
3752            consumed.restore_first_turn_pending,
3753            consumed.pending_initial_prompt.is_some(),
3754            usize_to_u64(consumed.pending_tool_results.len()),
3755        ) {
3756            Ok(effects) => effects,
3757            Err(err) => {
3758                tracing::warn!(
3759                    error = %err,
3760                    "generated session document authority rejected consumed input restore"
3761                );
3762                return;
3763            }
3764        };
3765        let Some((restore_first_turn_pending, restore_initial_prompt, restore_tool_results)) =
3766            effects.iter().find_map(|effect| match effect {
3767                session_document::SessionDocumentEffect::SessionConsumedInputsRestoreResolved {
3768                    restore_first_turn_pending,
3769                    restore_initial_prompt,
3770                    restore_tool_results,
3771                } => Some((
3772                    *restore_first_turn_pending,
3773                    *restore_initial_prompt,
3774                    *restore_tool_results,
3775                )),
3776                _ => None,
3777            })
3778        else {
3779            tracing::warn!(
3780                "generated session document authority returned no consumed-input restore decision"
3781            );
3782            return;
3783        };
3784        if restore_first_turn_pending {
3785            self.restore_initial_turn_pending();
3786        }
3787        if restore_initial_prompt && self.pending_initial_prompt.is_none() {
3788            self.pending_initial_prompt = consumed.pending_initial_prompt;
3789        }
3790        if restore_tool_results {
3791            let mut restored = consumed.pending_tool_results;
3792            restored.extend(std::mem::take(&mut self.pending_tool_results));
3793            self.pending_tool_results = restored;
3794        }
3795    }
3796}
3797
3798/// Failure when staging a system-context append request.
3799#[derive(Debug, Clone, PartialEq, Eq)]
3800pub enum SystemContextStageError {
3801    InvalidRequest(String),
3802    Conflict {
3803        key: String,
3804        existing_text: String,
3805        existing_source: Option<String>,
3806    },
3807}
3808
3809impl std::fmt::Display for SystemContextStageError {
3810    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3811        match self {
3812            Self::InvalidRequest(message) => {
3813                write!(f, "invalid system-context append request: {message}")
3814            }
3815            Self::Conflict { key, .. } => {
3816                write!(
3817                    f,
3818                    "system-context append conflict for idempotency key `{key}`"
3819                )
3820            }
3821        }
3822    }
3823}
3824
3825impl std::error::Error for SystemContextStageError {}
3826
3827/// Mechanical PRESENTATION helper: render a system-context append into the
3828/// display block string that is concatenated into the model-facing system
3829/// prompt. This is NOT a decision — it builds the `[Runtime System Context]`
3830/// label text for OUTPUT only. The authority for which appends to render and
3831/// whether one is a runtime steer lives in the
3832/// [`session_document::SessionDocumentMachine`]; this function never inspects
3833/// the `source` string to classify anything.
3834fn render_system_context_block(append: &PendingSystemContextAppend) -> String {
3835    let mut rendered = String::from(SYSTEM_CONTEXT_RENDER_LABEL);
3836    if let Some(source) = &append.source {
3837        rendered.push_str("\nsource: ");
3838        rendered.push_str(source);
3839    }
3840    rendered.push_str("\n\n");
3841    // The single CoreRenderable -> prompt-text lowering for system-context
3842    // appends. Surfaces carry the typed renderable through untouched.
3843    rendered.push_str(append.content.render_text().trim());
3844    rendered
3845}
3846
3847/// Display label prefix for a rendered runtime system-context block.
3848///
3849/// PRESENTATION only — this is the human/model-facing heading, not a
3850/// classification key. Nothing reads this back to make a semantic decision.
3851const SYSTEM_CONTEXT_RENDER_LABEL: &str = "[Runtime System Context]";
3852
3853/// Render a sequence of system-context appends into the
3854/// [`SYSTEM_CONTEXT_SEPARATOR`]-joined block text that
3855/// [`Session::append_system_context_blocks`] concatenates onto the system
3856/// prompt. The single composition rule — shared by the append path and the
3857/// resume-time tail verification so the two can never drift apart.
3858fn render_system_context_blocks_joined(appends: &[PendingSystemContextAppend]) -> String {
3859    appends
3860        .iter()
3861        .map(render_system_context_block)
3862        .collect::<Vec<_>>()
3863        .join(SYSTEM_CONTEXT_SEPARATOR)
3864}
3865
3866/// Compose a system prompt from a base and a verified runtime-context tail
3867/// (leading [`SYSTEM_CONTEXT_SEPARATOR`] included; empty = no tail),
3868/// mirroring [`Session::append_system_context_blocks`]' rule that an empty
3869/// base renders the blocks without a separator prefix.
3870fn compose_system_prompt_with_context_tail(base: &str, tail: &str) -> String {
3871    if tail.is_empty() {
3872        return base.to_string();
3873    }
3874    if base.is_empty() {
3875        return tail
3876            .strip_prefix(SYSTEM_CONTEXT_SEPARATOR)
3877            .unwrap_or(tail)
3878            .to_string();
3879    }
3880    format!("{base}{tail}")
3881}
3882
3883/// Drive the canonical [`session_document::SessionDocumentMachine`]
3884/// persist-append admission for the resume fast path: may the persisted
3885/// System prompt be admitted as a runtime-context-append continuation of the
3886/// freshly assembled base?
3887///
3888/// Mirrors the save-guard shell (`session_store::system_context_is_append`):
3889/// this extracts only pure structural observations plus the typed
3890/// [`crate::types::SystemPromptMutationKind`] provenance; the machine owns
3891/// the verdict. A machine error fails closed — the caller falls back to the
3892/// audited rewrite path.
3893fn persisted_prompt_is_admitted_context_append_continuation(
3894    assembled_base: &str,
3895    persisted_content: &str,
3896    persisted_mutation_kind: crate::types::SystemPromptMutationKind,
3897) -> bool {
3898    let content_identical = persisted_content == assembled_base;
3899    let content_extends = persisted_content.starts_with(assembled_base);
3900    let appended_starts_with_separator = content_extends
3901        && persisted_content[assembled_base.len()..].starts_with(SYSTEM_CONTEXT_SEPARATOR);
3902    let mut authority = session_document::SessionDocumentMachineAuthority::new();
3903    match authority.resolve_system_context_persist_append_admission(
3904        true,
3905        content_identical,
3906        content_extends,
3907        appended_starts_with_separator,
3908        persisted_mutation_kind.is_runtime_context_append(),
3909    ) {
3910        Ok(effects) => effects.into_iter().any(|effect| {
3911            matches!(
3912                effect,
3913                session_document::SessionDocumentEffect::SystemContextPersistAppendAdmissionResolved {
3914                    admission: session_document::SystemContextPersistAppendAdmission::Admit,
3915                }
3916            )
3917        }),
3918        Err(error) => {
3919            tracing::warn!(
3920                error = %error,
3921                "session document authority refused resume prompt continuation admission; \
3922                 falling back to audited rewrite"
3923            );
3924            false
3925        }
3926    }
3927}
3928
3929/// Shell adapter that drives the canonical
3930/// [`session_document::SessionDocumentMachine`] system-context region and
3931/// mirrors its emitted decisions onto the bulky `SessionSystemContextState`.
3932///
3933/// The machine owns every SEMANTIC decision (append disposition, per-append
3934/// apply/discard from the typed [`SystemContextSource`] marker, snapshot
3935/// restore legality). This module performs only the mechanical collection
3936/// work — iterating the shell's pending/applied/seen collections and applying
3937/// the machine's per-item verdict. It never decides; in particular it never
3938/// inspects a `source` string to classify a runtime steer.
3939mod system_context_authority {
3940    use super::{
3941        AppendSystemContextRequest, BTreeSet, PendingSystemContextAppend, SeenSystemContextKey,
3942        SeenSystemContextState, SessionSystemContextState, SystemContextSource,
3943        SystemContextStageError, SystemTime, render_system_context_block, session_document,
3944        usize_to_u64,
3945    };
3946    use crate::service::AppendSystemContextStatus;
3947
3948    fn document_authority() -> session_document::SessionDocumentMachineAuthority {
3949        session_document::SessionDocumentMachineAuthority::new()
3950    }
3951
3952    /// Resolve the four-way append disposition through the machine.
3953    fn resolve_append_decision(
3954        trimmed_text_byte_count: u64,
3955        idempotency_key_present: bool,
3956        existing_key_matches: bool,
3957        existing_key_conflicts: bool,
3958        active_turn_scoped: bool,
3959    ) -> Result<session_document::SystemContextAppendDecision, SystemContextStageError> {
3960        let mut authority = document_authority();
3961        let effects = authority
3962            .resolve_system_context_append(
3963                trimmed_text_byte_count,
3964                idempotency_key_present,
3965                existing_key_matches,
3966                existing_key_conflicts,
3967                active_turn_scoped,
3968            )
3969            .map_err(|err| SystemContextStageError::InvalidRequest(err.to_string()))?;
3970        effects
3971            .into_iter()
3972            .find_map(|effect| match effect {
3973                session_document::SessionDocumentEffect::SystemContextAppendResolved {
3974                    decision,
3975                    ..
3976                } => Some(decision),
3977                _ => None,
3978            })
3979            .ok_or_else(|| {
3980                SystemContextStageError::InvalidRequest(
3981                    "generated session document authority returned no append decision".to_string(),
3982                )
3983            })
3984    }
3985
3986    /// Per-pending-append apply verdict, decided by the machine from the typed
3987    /// `source_kind` marker (NOT a `source` string prefix).
3988    fn pending_apply_item(source_kind: SystemContextSource) -> Option<(bool, bool, bool)> {
3989        let mut authority = document_authority();
3990        match authority.resolve_system_context_pending_apply_item(source_kind.into()) {
3991            Ok(effects) => effects.into_iter().find_map(|effect| {
3992                match effect {
3993                session_document::SessionDocumentEffect::SystemContextPendingApplyItemResolved {
3994                    promote_to_applied,
3995                    mark_seen_applied,
3996                    remove_seen,
3997                } => Some((promote_to_applied, mark_seen_applied, remove_seen)),
3998                _ => None,
3999            }
4000            }),
4001            Err(err) => {
4002                tracing::warn!(
4003                    error = %err,
4004                    "generated session document authority rejected system-context apply item"
4005                );
4006                None
4007            }
4008        }
4009    }
4010
4011    /// Per-item transient-steer discard verdict, decided by the machine from
4012    /// the typed `source_kind` marker.
4013    fn steer_cleanup_discards(source_kind: SystemContextSource) -> bool {
4014        let mut authority = document_authority();
4015        match authority.resolve_system_context_steer_cleanup_item(source_kind.into()) {
4016            Ok(effects) => effects
4017                .into_iter()
4018                .find_map(|effect| {
4019                    match effect {
4020                    session_document::SessionDocumentEffect::SystemContextSteerCleanupItemResolved {
4021                        discard,
4022                    } => Some(discard),
4023                    _ => None,
4024                }
4025                })
4026                .unwrap_or(false),
4027            Err(err) => {
4028                tracing::warn!(
4029                    error = %err,
4030                    "generated session document authority rejected system-context steer cleanup item"
4031                );
4032                false
4033            }
4034        }
4035    }
4036
4037    fn discard_pending_where(
4038        state: &mut SessionSystemContextState,
4039        mut should_discard: impl FnMut(&PendingSystemContextAppend, bool) -> bool,
4040    ) -> Vec<PendingSystemContextAppend> {
4041        reconstruct_legacy_active_turn_indices(state);
4042        let active_indices = std::mem::take(&mut state.active_turn_pending_indices);
4043        let pending = std::mem::take(&mut state.pending);
4044        let mut retained = Vec::with_capacity(pending.len());
4045        let mut retained_active_indices = BTreeSet::new();
4046        let mut retained_active_keys = BTreeSet::new();
4047        let mut discarded = Vec::new();
4048
4049        for (index, append) in pending.into_iter().enumerate() {
4050            let is_active_turn = active_indices.contains(&usize_to_u64(index));
4051            if should_discard(&append, is_active_turn) {
4052                discarded.push(append);
4053                continue;
4054            }
4055            if is_active_turn {
4056                retained_active_indices.insert(usize_to_u64(retained.len()));
4057                if let Some(key) = append.idempotency_key.as_ref() {
4058                    retained_active_keys.insert(key.clone());
4059                }
4060            }
4061            retained.push(append);
4062        }
4063
4064        state.pending = retained;
4065        state.active_turn_pending_indices = retained_active_indices;
4066        state.active_turn_pending_keys = retained_active_keys;
4067        discarded
4068    }
4069
4070    fn reconstruct_legacy_active_turn_indices(state: &mut SessionSystemContextState) {
4071        if !state.active_turn_pending_indices.is_empty()
4072            || state.active_turn_pending_keys.is_empty()
4073        {
4074            return;
4075        }
4076        state.active_turn_pending_indices = state
4077            .pending
4078            .iter()
4079            .enumerate()
4080            .filter(|(_index, append)| {
4081                append
4082                    .idempotency_key
4083                    .as_ref()
4084                    .is_some_and(|key| state.active_turn_pending_keys.contains(key))
4085            })
4086            .map(|(index, _append)| usize_to_u64(index))
4087            .collect();
4088    }
4089
4090    pub(super) fn restore_system_context_state(
4091        mut state: SessionSystemContextState,
4092    ) -> Result<SessionSystemContextState, SystemContextStageError> {
4093        // Backward compatibility for snapshots written before active-turn
4094        // membership had an identity independent of idempotency. Keyed
4095        // members can be reconstructed exactly from the pending queue.
4096        reconstruct_legacy_active_turn_indices(&mut state);
4097        let active_indices_are_in_bounds = state
4098            .active_turn_pending_indices
4099            .iter()
4100            .all(|index| usize::try_from(*index).is_ok_and(|index| index < state.pending.len()));
4101        let active_keys_have_indexed_pending = state.active_turn_pending_keys.iter().all(|key| {
4102            state.active_turn_pending_indices.iter().any(|index| {
4103                usize::try_from(*index)
4104                    .ok()
4105                    .and_then(|index| state.pending.get(index))
4106                    .and_then(|append| append.idempotency_key.as_ref())
4107                    == Some(key)
4108            })
4109        });
4110        let indexed_pending_keys_are_active =
4111            state.active_turn_pending_indices.iter().all(|index| {
4112                usize::try_from(*index)
4113                    .ok()
4114                    .and_then(|index| state.pending.get(index))
4115                    .is_some_and(|append| {
4116                        append
4117                            .idempotency_key
4118                            .as_ref()
4119                            .is_none_or(|key| state.active_turn_pending_keys.contains(key))
4120                    })
4121            });
4122        let active_turn_membership_is_consistent = active_indices_are_in_bounds
4123            && active_keys_have_indexed_pending
4124            && indexed_pending_keys_are_active;
4125        let seen_keys_match_known_appends = state.seen.iter().all(|(key, seen)| {
4126            state
4127                .pending
4128                .iter()
4129                .chain(state.applied.iter())
4130                .any(|append| {
4131                    append.idempotency_key.as_ref() == Some(key)
4132                        && seen.content == append.content
4133                        && seen.source.as_deref() == append.source.as_deref()
4134                })
4135        });
4136        let mut authority = document_authority();
4137        authority
4138            .restore_system_context_snapshot(
4139                active_turn_membership_is_consistent,
4140                seen_keys_match_known_appends,
4141            )
4142            .map_err(|err| SystemContextStageError::InvalidRequest(err.to_string()))?;
4143        Ok(state)
4144    }
4145
4146    pub(super) fn stage_append(
4147        state: &mut SessionSystemContextState,
4148        req: &AppendSystemContextRequest,
4149        accepted_at: SystemTime,
4150        active_turn_scoped: bool,
4151    ) -> Result<AppendSystemContextStatus, SystemContextStageError> {
4152        // Emptiness is judged on the canonical text projection; the typed
4153        // renderable itself is what gets stored (lowering happens once, at
4154        // the transcript render seam).
4155        let rendered_text = req.content.render_text();
4156        let rendered_len = rendered_text.trim().len();
4157        let existing = req
4158            .idempotency_key
4159            .as_ref()
4160            .and_then(|key| state.seen.get(key));
4161        let existing_key_matches = existing.is_some_and(|existing| {
4162            existing.content == req.content && existing.source.as_deref() == req.source.as_deref()
4163        });
4164        let existing_key_conflicts = existing.is_some() && !existing_key_matches;
4165        let decision = resolve_append_decision(
4166            usize_to_u64(rendered_len),
4167            req.idempotency_key.is_some(),
4168            existing_key_matches,
4169            existing_key_conflicts,
4170            active_turn_scoped,
4171        )?;
4172
4173        match decision {
4174            session_document::SystemContextAppendDecision::RejectEmpty => {
4175                return Err(SystemContextStageError::InvalidRequest(
4176                    "system context text must not be empty".to_string(),
4177                ));
4178            }
4179            session_document::SystemContextAppendDecision::RejectConflict => {
4180                let Some(key) = req.idempotency_key.as_ref() else {
4181                    return Err(SystemContextStageError::InvalidRequest(
4182                        "generated system-context authority rejected append without a key"
4183                            .to_string(),
4184                    ));
4185                };
4186                let Some(existing) = existing else {
4187                    return Err(SystemContextStageError::InvalidRequest(
4188                        "generated system-context authority rejected append without a conflict"
4189                            .to_string(),
4190                    ));
4191                };
4192                return Err(SystemContextStageError::Conflict {
4193                    key: key.clone(),
4194                    existing_text: existing.content.render_text(),
4195                    existing_source: existing.source.clone(),
4196                });
4197            }
4198            session_document::SystemContextAppendDecision::Duplicate => {
4199                return Ok(AppendSystemContextStatus::Duplicate);
4200            }
4201            session_document::SystemContextAppendDecision::Staged => {}
4202        }
4203
4204        let append = PendingSystemContextAppend {
4205            content: req.content.clone(),
4206            source: req.source.clone(),
4207            idempotency_key: req.idempotency_key.clone(),
4208            source_kind: req.source_kind,
4209            // Carry the typed `PeerResponseTerminalFact` so realtime/live
4210            // consumers read it directly instead of re-parsing the flattened
4211            // prompt text. Mirrors the `source_kind` typed-provenance precedent.
4212            peer_response_terminal: req.peer_response_terminal.clone(),
4213            accepted_at,
4214        };
4215        if let Some(key) = req.idempotency_key.as_ref() {
4216            state.seen.insert(
4217                key.clone(),
4218                SeenSystemContextKey {
4219                    content: append.content.clone(),
4220                    source: append.source.clone(),
4221                    source_kind: append.source_kind,
4222                    state: SeenSystemContextState::Pending,
4223                },
4224            );
4225        }
4226        if active_turn_scoped {
4227            state
4228                .active_turn_pending_indices
4229                .insert(usize_to_u64(state.pending.len()));
4230            if let Some(key) = req.idempotency_key.as_ref() {
4231                state.active_turn_pending_keys.insert(key.clone());
4232            }
4233        }
4234        state.pending.push(append);
4235        Ok(AppendSystemContextStatus::Staged)
4236    }
4237
4238    pub(super) fn mark_pending_applied(state: &mut SessionSystemContextState) {
4239        // Promote pending appends to applied per the machine's per-item
4240        // verdict (keyed on the typed `source_kind`).
4241        let pending = std::mem::take(&mut state.pending);
4242        let mut seen_to_remove = Vec::new();
4243        for append in &pending {
4244            let Some((promote_to_applied, mark_seen_applied, remove_seen)) =
4245                pending_apply_item(append.source_kind)
4246            else {
4247                continue;
4248            };
4249            if promote_to_applied && !state.applied.contains(append) {
4250                state.applied.push(append.clone());
4251            }
4252            if let Some(key) = append.idempotency_key.as_ref() {
4253                if remove_seen {
4254                    seen_to_remove.push(key.clone());
4255                } else if mark_seen_applied && let Some(seen) = state.seen.get_mut(key) {
4256                    seen.state = SeenSystemContextState::Applied;
4257                }
4258            }
4259        }
4260        for key in seen_to_remove {
4261            state.seen.remove(&key);
4262        }
4263        state.active_turn_pending_keys.clear();
4264        state.active_turn_pending_indices.clear();
4265    }
4266
4267    pub(super) fn discard_unapplied_active_turn_pending(
4268        state: &mut SessionSystemContextState,
4269    ) -> Vec<PendingSystemContextAppend> {
4270        reconstruct_legacy_active_turn_indices(state);
4271        if state.active_turn_pending_indices.is_empty() {
4272            return Vec::new();
4273        }
4274        let discarded = discard_pending_where(state, |_append, is_active_turn| is_active_turn);
4275
4276        for append in &discarded {
4277            if let Some(key) = append.idempotency_key.as_ref()
4278                && state
4279                    .seen
4280                    .get(key)
4281                    .is_some_and(|seen| seen.state == SeenSystemContextState::Pending)
4282            {
4283                state.seen.remove(key);
4284            }
4285        }
4286
4287        discarded
4288    }
4289
4290    pub(super) fn discard_active_turn_pending_by_keys(
4291        state: &mut SessionSystemContextState,
4292        idempotency_keys: &[String],
4293    ) -> Vec<PendingSystemContextAppend> {
4294        reconstruct_legacy_active_turn_indices(state);
4295        if idempotency_keys.is_empty() || state.active_turn_pending_indices.is_empty() {
4296            return Vec::new();
4297        }
4298        let requested_keys: BTreeSet<&str> = idempotency_keys.iter().map(String::as_str).collect();
4299        let discarded = discard_pending_where(state, |append, is_active_turn| {
4300            is_active_turn
4301                && append
4302                    .idempotency_key
4303                    .as_ref()
4304                    .is_some_and(|key| requested_keys.contains(key.as_str()))
4305        });
4306
4307        for append in &discarded {
4308            let Some(key) = append.idempotency_key.as_ref() else {
4309                continue;
4310            };
4311            if state
4312                .seen
4313                .get(key)
4314                .is_some_and(|seen| seen.state == SeenSystemContextState::Pending)
4315            {
4316                state.seen.remove(key);
4317            }
4318        }
4319
4320        discarded
4321    }
4322
4323    pub(super) fn discard_transient_runtime_steer_state(
4324        state: &mut SessionSystemContextState,
4325    ) -> usize {
4326        let mut removed = 0usize;
4327
4328        let before_active = state.active_turn_pending_keys.len();
4329        removed += discard_pending_where(state, |append, _is_active_turn| {
4330            steer_cleanup_discards(append.source_kind)
4331        })
4332        .len();
4333
4334        let before_applied = state.applied.len();
4335        state
4336            .applied
4337            .retain(|append| !steer_cleanup_discards(append.source_kind));
4338        removed += before_applied.saturating_sub(state.applied.len());
4339
4340        let before_seen = state.seen.len();
4341        state
4342            .seen
4343            .retain(|_key, seen| !steer_cleanup_discards(seen.source_kind));
4344        removed += before_seen.saturating_sub(state.seen.len());
4345
4346        removed += before_active.saturating_sub(state.active_turn_pending_keys.len());
4347
4348        removed
4349    }
4350
4351    pub(super) fn remove_runtime_steer_blocks_for_rendered(
4352        system_prompt: &str,
4353        runtime_steer_appends: &[PendingSystemContextAppend],
4354    ) -> (String, usize) {
4355        if runtime_steer_appends.is_empty() {
4356            return (system_prompt.to_string(), 0);
4357        }
4358        // Build the set of rendered blocks for the typed runtime-steer appends,
4359        // then remove those exact rendered blocks from the prompt. The typed
4360        // marker is the authority; rendering is mechanical presentation.
4361        let steer_blocks: BTreeSet<String> = runtime_steer_appends
4362            .iter()
4363            .map(render_system_context_block)
4364            .collect();
4365        let parts = system_prompt
4366            .split(super::SYSTEM_CONTEXT_SEPARATOR)
4367            .map(str::to_string)
4368            .collect::<Vec<_>>();
4369        let original_len = parts.len();
4370        let retained = parts
4371            .into_iter()
4372            .filter(|part| !steer_blocks.contains(part))
4373            .collect::<Vec<_>>();
4374        let removed = original_len.saturating_sub(retained.len());
4375        (retained.join(super::SYSTEM_CONTEXT_SEPARATOR), removed)
4376    }
4377
4378    pub(super) fn record_applied_system_context_blocks(
4379        state: &mut SessionSystemContextState,
4380        appends: &[PendingSystemContextAppend],
4381        current_system_prompt: &str,
4382    ) -> Vec<PendingSystemContextAppend> {
4383        let mut new_appends: Vec<PendingSystemContextAppend> = Vec::new();
4384        for append in appends {
4385            if append.content.render_text().trim().is_empty() {
4386                continue;
4387            }
4388            let rendered = render_system_context_block(append);
4389            if let Some(key) = append.idempotency_key.as_ref() {
4390                if let Some(existing) = state.seen.get(key)
4391                    && !seen_system_context_matches(existing, append)
4392                {
4393                    tracing::warn!(
4394                        idempotency_key = %key,
4395                        "skipping conflicting runtime system-context append"
4396                    );
4397                    continue;
4398                }
4399                if let Some(existing) = state
4400                    .applied
4401                    .iter()
4402                    .find(|applied| applied.idempotency_key.as_ref() == Some(key))
4403                    && !pending_system_context_matches(existing, append)
4404                {
4405                    tracing::warn!(
4406                        idempotency_key = %key,
4407                        "skipping conflicting runtime system-context append"
4408                    );
4409                    continue;
4410                }
4411                if let Some(existing) = new_appends
4412                    .iter()
4413                    .find(|pending| pending.idempotency_key.as_ref() == Some(key))
4414                {
4415                    if !pending_system_context_matches(existing, append) {
4416                        tracing::warn!(
4417                            idempotency_key = %key,
4418                            "skipping conflicting runtime system-context append"
4419                        );
4420                    }
4421                    continue;
4422                }
4423                if current_system_prompt.contains(&rendered) {
4424                    record_applied_append(state, append);
4425                    continue;
4426                }
4427            } else if new_appends.contains(append) || current_system_prompt.contains(&rendered) {
4428                continue;
4429            }
4430            record_applied_append(state, append);
4431            new_appends.push(append.clone());
4432        }
4433        new_appends
4434    }
4435
4436    fn record_applied_append(
4437        state: &mut SessionSystemContextState,
4438        append: &PendingSystemContextAppend,
4439    ) {
4440        if let Some(key) = append.idempotency_key.as_ref() {
4441            state.seen.insert(
4442                key.clone(),
4443                SeenSystemContextKey {
4444                    content: append.content.clone(),
4445                    source: append.source.clone(),
4446                    source_kind: append.source_kind,
4447                    state: SeenSystemContextState::Applied,
4448                },
4449            );
4450            if state
4451                .applied
4452                .iter()
4453                .any(|applied| applied.idempotency_key.as_ref() == Some(key))
4454            {
4455                return;
4456            }
4457        } else if state.applied.contains(append) {
4458            return;
4459        }
4460        state.applied.push(append.clone());
4461    }
4462
4463    fn seen_system_context_matches(
4464        seen: &SeenSystemContextKey,
4465        append: &PendingSystemContextAppend,
4466    ) -> bool {
4467        seen.content == append.content && seen.source.as_deref() == append.source.as_deref()
4468    }
4469
4470    fn pending_system_context_matches(
4471        existing: &PendingSystemContextAppend,
4472        append: &PendingSystemContextAppend,
4473    ) -> bool {
4474        existing.content == append.content && existing.source.as_deref() == append.source.as_deref()
4475    }
4476}
4477
4478impl Session {
4479    /// Create a new empty session
4480    pub fn new() -> Self {
4481        let now = SystemTime::now();
4482        Self {
4483            version: session_version(),
4484            id: SessionId::new(),
4485            messages: Arc::new(Vec::new()),
4486            created_at: now,
4487            updated_at: now,
4488            metadata: serde_json::Map::new(),
4489            transcript_history_metadata_validation: TranscriptHistoryMetadataValidation::Validated,
4490            usage: Usage::default(),
4491        }
4492    }
4493
4494    /// Create a session with a specific ID (for loading)
4495    pub fn with_id(id: SessionId) -> Self {
4496        let mut session = Self::new();
4497        session.id = id;
4498        session
4499    }
4500
4501    /// Get the session ID
4502    pub fn id(&self) -> &SessionId {
4503        &self.id
4504    }
4505
4506    /// Get the session version
4507    pub fn version(&self) -> u32 {
4508        self.version
4509    }
4510
4511    /// Get all messages.
4512    pub fn messages(&self) -> &[Message] {
4513        &self.messages
4514    }
4515
4516    /// Replace the message buffer for core-owned internal transcript rewrites.
4517    ///
4518    /// Intentionally `pub(crate)`: cross-crate consumers must route same-session
4519    /// rewrites through transcript-edit APIs so the revision graph remains the
4520    /// semantic owner of message history.
4521    #[allow(dead_code)] // Kept for core-owned optional rewrite paths and focused invariants.
4522    pub(crate) fn replace_messages_internal(
4523        &mut self,
4524        messages: Vec<Message>,
4525        reason: TranscriptRewriteReason,
4526    ) -> Result<Option<TranscriptRewriteCommit>, TranscriptEditError> {
4527        if transcript_messages_digest(self.messages()).ok()
4528            == transcript_messages_digest(&messages).ok()
4529        {
4530            return Ok(None);
4531        }
4532        let commit = self.commit_transcript_rewrite(
4533            TranscriptRewriteSelection::MessageRange {
4534                start: 0,
4535                end: self.messages.len(),
4536            },
4537            messages,
4538            reason,
4539            Some("meerkat-core".to_string()),
4540            None,
4541        )?;
4542        Ok(Some(commit))
4543    }
4544
4545    /// Replace the full transcript under the opaque authority minted by the
4546    /// validated compaction rebuild path.
4547    pub(crate) fn replace_messages_for_compaction_internal(
4548        &mut self,
4549        messages: Vec<Message>,
4550        authority: &crate::agent::compact::ValidatedCompactionRewrite,
4551    ) -> Result<Option<TranscriptRewriteCommit>, TranscriptEditError> {
4552        if transcript_messages_digest(self.messages()).ok()
4553            == transcript_messages_digest(&messages).ok()
4554        {
4555            return Ok(None);
4556        }
4557        if !authority
4558            .authorizes(self.messages(), &messages)
4559            .map_err(|error| TranscriptEditError::HistoryStateMalformed(error.to_string()))?
4560        {
4561            return Err(TranscriptEditError::InvalidTranscriptShape(
4562                "validated compaction witness does not authorize this exact transcript rebuild"
4563                    .to_string(),
4564            ));
4565        }
4566        let summary_count = messages
4567            .iter()
4568            .filter(|message| {
4569                matches!(message, Message::User(user) if user.transcript_role.is_compaction_summary())
4570            })
4571            .count();
4572        if messages.len() >= self.messages.len() || summary_count != 1 {
4573            return Err(TranscriptEditError::InvalidTranscriptShape(
4574                "validated compaction rewrite must shrink the transcript and carry exactly one CompactionSummary"
4575                    .to_string(),
4576            ));
4577        }
4578        let selection =
4579            TranscriptRewriteSelection::validated_compaction(0, self.messages.len(), authority);
4580        let commit = self.commit_transcript_rewrite_authorized(
4581            selection,
4582            messages,
4583            TranscriptRewriteReason::new("compaction"),
4584            Some("meerkat-core".to_string()),
4585            None,
4586        )?;
4587        Ok(Some(commit))
4588    }
4589
4590    /// Atomically refresh the synthetic runtime notices of one kind.
4591    ///
4592    /// This is the ONE transcript authority operation for synthetic-notice
4593    /// refresh: it strips every synthetic `SystemNotice` projection of `kind`
4594    /// while preserving durable notices that share the kind, then appends
4595    /// `replacements` (possibly empty, meaning "no current synthetic notice")
4596    /// as one mechanical projection update. It deliberately does not mint an
4597    /// audited transcript rewrite commit. On a strip fault nothing is pushed
4598    /// and the typed [`TranscriptEditError`] propagates — callers must not
4599    /// re-implement the strip-then-push pair (the swallowed-strip variant
4600    /// leaves a stale notice beside a fresh one: a divergence window).
4601    pub fn replace_synthetic_notices(
4602        &mut self,
4603        kind: crate::types::SystemNoticeKind,
4604        replacements: Vec<Message>,
4605    ) -> Result<(), TranscriptEditError> {
4606        if !kind.is_synthetic_refresh_projection() {
4607            return Err(TranscriptEditError::InvalidTranscriptShape(format!(
4608                "system notice kind {kind:?} is durable transcript content, not a synthetic refresh projection"
4609            )));
4610        }
4611        for (index, message) in replacements.iter().enumerate() {
4612            let matches_kind = matches!(
4613                message,
4614                Message::SystemNotice(notice)
4615                    if notice.kind == kind && notice.is_synthetic_refresh_projection()
4616            );
4617            if !matches_kind {
4618                return Err(TranscriptEditError::InvalidTranscriptShape(format!(
4619                    "replacement {index} for synthetic notice kind {kind:?} is not a system notice of that kind"
4620                )));
4621            }
4622        }
4623
4624        let mut refreshed = self
4625            .messages
4626            .iter()
4627            .filter(|message| {
4628                !matches!(
4629                    message,
4630                    Message::SystemNotice(notice)
4631                        if notice.kind == kind && notice.is_synthetic_refresh_projection()
4632                )
4633            })
4634            .cloned()
4635            .collect::<Vec<_>>();
4636        refreshed.extend(replacements);
4637        if transcript_messages_digest(self.messages()).ok()
4638            == transcript_messages_digest(&refreshed).ok()
4639        {
4640            return Ok(());
4641        }
4642
4643        let realtime_state =
4644            self.reconciled_realtime_transcript_metadata_after_rewrite(&refreshed)?;
4645        let updated_at = SystemTime::now();
4646        let history_state = self
4647            .transcript_history_state_after_message_mutation(&refreshed, updated_at)?
4648            .map(serde_json::to_value)
4649            .transpose()
4650            .map_err(|error| TranscriptEditError::HistoryStateMalformed(error.to_string()))?;
4651
4652        self.messages = Arc::new(refreshed);
4653        self.updated_at = updated_at;
4654        if let Some(value) = realtime_state {
4655            self.set_metadata_unchecked(SESSION_REALTIME_TRANSCRIPT_STATE_KEY, value);
4656        }
4657        if let Some(value) = history_state {
4658            self.set_validated_transcript_history_metadata(value);
4659        }
4660        Ok(())
4661    }
4662
4663    /// Get creation time
4664    pub fn created_at(&self) -> SystemTime {
4665        self.created_at
4666    }
4667
4668    /// Get last update time
4669    pub fn updated_at(&self) -> SystemTime {
4670        self.updated_at
4671    }
4672
4673    /// Add a message to the session
4674    ///
4675    /// Updates the timestamp. For adding multiple messages, prefer `push_batch`.
4676    pub fn push(&mut self, message: Message) {
4677        Arc::make_mut(&mut self.messages).push(message);
4678        self.updated_at = SystemTime::now();
4679        self.refresh_transcript_head_after_message_mutation();
4680    }
4681
4682    /// Add multiple messages in one operation (single timestamp update)
4683    ///
4684    /// More efficient than multiple `push` calls when adding many messages.
4685    pub fn push_batch(&mut self, messages: Vec<Message>) {
4686        if messages.is_empty() {
4687            return;
4688        }
4689        let inner = Arc::make_mut(&mut self.messages);
4690        inner.extend(messages);
4691        self.updated_at = SystemTime::now();
4692        self.refresh_transcript_head_after_message_mutation();
4693    }
4694
4695    /// Rewrite inline media payloads in-place as `BlobRef` pointers.
4696    ///
4697    /// Message count is invariant across this operation — `externalize`
4698    /// only swaps inline image/media bytes for opaque blob references.
4699    /// This is the cross-crate-legitimate rewrite operation that used
4700    /// to require public `messages_mut()`; post-C-H1 callers in
4701    /// `meerkat-session` go through this typed method.
4702    ///
4703    /// Does not touch `updated_at` — externalization is bookkeeping, not
4704    /// a semantic session mutation.
4705    pub async fn externalize_media(
4706        &mut self,
4707        blob_store: &dyn crate::BlobStore,
4708        start: usize,
4709    ) -> Result<(), crate::blob::BlobStoreError> {
4710        let previous_digest = if self
4711            .metadata
4712            .contains_key(SESSION_TRANSCRIPT_HISTORY_STATE_KEY)
4713        {
4714            transcript_messages_digest(self.messages()).ok()
4715        } else {
4716            None
4717        };
4718        let messages = Arc::make_mut(&mut self.messages);
4719        crate::image_content::externalize_messages_from(blob_store, messages, start).await?;
4720        if let Some(previous_digest) = previous_digest
4721            && transcript_messages_digest(self.messages()).ok().as_ref() != Some(&previous_digest)
4722        {
4723            self.refresh_transcript_head_after_message_mutation();
4724        }
4725        Ok(())
4726    }
4727
4728    /// Hydrate user-message images in-place for a realtime provider replay,
4729    /// under an explicit cumulative decoded-byte budget.
4730    ///
4731    /// Realtime reconnect/open is an execution seam, not a historical display
4732    /// read: missing or malformed blobs fail closed, repeated references count
4733    /// independently, and image-bearing tool/system content that the realtime
4734    /// history projector does not consume remains blob-backed.
4735    pub async fn hydrate_realtime_user_images(
4736        &mut self,
4737        blob_store: &dyn crate::BlobStore,
4738        max_decoded_bytes: usize,
4739    ) -> Result<(), crate::image_content::RealtimeUserImageHydrationError> {
4740        self.hydrate_realtime_user_images_with_usage(blob_store, max_decoded_bytes)
4741            .await
4742            .map(|_| ())
4743    }
4744
4745    /// Hydrate realtime user-message images and return the full canonical
4746    /// decoded-byte usage for seed-independent future-image admission.
4747    pub async fn hydrate_realtime_user_images_with_usage(
4748        &mut self,
4749        blob_store: &dyn crate::BlobStore,
4750        max_decoded_bytes: usize,
4751    ) -> Result<usize, crate::image_content::RealtimeUserImageHydrationError> {
4752        let previous_digest = if self
4753            .metadata
4754            .contains_key(SESSION_TRANSCRIPT_HISTORY_STATE_KEY)
4755        {
4756            transcript_messages_digest(self.messages()).ok()
4757        } else {
4758            None
4759        };
4760        let messages = Arc::make_mut(&mut self.messages);
4761        let decoded_total =
4762            crate::image_content::hydrate_user_images_for_realtime_projection_with_usage(
4763                blob_store,
4764                messages,
4765                max_decoded_bytes,
4766            )
4767            .await?;
4768        if let Some(previous_digest) = previous_digest
4769            && transcript_messages_digest(self.messages()).ok().as_ref() != Some(&previous_digest)
4770        {
4771            self.refresh_transcript_head_after_message_mutation();
4772        }
4773        Ok(decoded_total)
4774    }
4775
4776    /// Explicitly update the timestamp
4777    ///
4778    /// Call this after bulk operations that don't update timestamps automatically.
4779    pub fn touch(&mut self) {
4780        self.updated_at = SystemTime::now();
4781    }
4782
4783    /// Get the last N messages
4784    pub fn last_n(&self, n: usize) -> &[Message] {
4785        let start = self.messages.len().saturating_sub(n);
4786        &self.messages[start..]
4787    }
4788
4789    /// Count total tokens used.
4790    pub fn total_tokens(&self) -> u64 {
4791        self.usage.total_tokens()
4792    }
4793
4794    /// Get total usage statistics for the session.
4795    pub fn total_usage(&self) -> Usage {
4796        self.usage.clone()
4797    }
4798
4799    /// Update cumulative usage after an LLM call.
4800    pub fn record_usage(&mut self, turn_usage: Usage) {
4801        self.usage.add(&turn_usage);
4802        self.updated_at = SystemTime::now();
4803    }
4804
4805    /// Append externally-produced user content to the canonical transcript.
4806    pub fn append_external_user_content(&mut self, content: ContentInput) {
4807        self.push(Message::User(UserMessage::with_blocks(
4808            content.into_blocks(),
4809        )));
4810    }
4811
4812    /// Append externally-produced assistant output to the canonical transcript.
4813    pub fn append_external_assistant_blocks(
4814        &mut self,
4815        blocks: Vec<AssistantBlock>,
4816        stop_reason: StopReason,
4817        usage: Usage,
4818    ) {
4819        if !blocks.is_empty() {
4820            self.push(Message::BlockAssistant(BlockAssistantMessage::new(
4821                blocks,
4822                stop_reason,
4823            )));
4824        }
4825        if usage != Usage::default() {
4826            self.record_usage(usage);
4827        }
4828    }
4829
4830    /// Apply an identity-bearing provider realtime transcript event.
4831    ///
4832    /// This is the canonical append authority for provider-managed realtime
4833    /// turns: provider item ids, predecessor links, and content segment ids are
4834    /// persisted in session metadata so duplicate websocket delivery,
4835    /// reconnect replay, and causally equivalent event ordering cannot create
4836    /// duplicate or misordered canonical messages.
4837    pub fn append_realtime_transcript_event(
4838        &mut self,
4839        event: RealtimeTranscriptEvent,
4840    ) -> RealtimeTranscriptApplyOutcome {
4841        let mut state = self.realtime_transcript_state();
4842        let commit =
4843            realtime_transcript_revision::apply_realtime_transcript_event(&mut state, event)
4844                .unwrap_or_else(|err| {
4845                    fail_closed_generated_restore(
4846                        "realtime-transcript",
4847                        <serde_json::Error as serde::de::Error>::custom(err),
4848                    )
4849                });
4850        self.store_realtime_transcript_state(&state);
4851        self.push_batch(commit.messages);
4852        if commit.usage != Usage::default() {
4853            self.record_usage(commit.usage);
4854        }
4855        commit.outcome
4856    }
4857
4858    /// Preview replay/rejection for non-text realtime user content without
4859    /// mutating session state. Used by persistence before blob writes.
4860    #[must_use]
4861    pub fn preflight_realtime_user_content_event(
4862        &self,
4863        event: &RealtimeTranscriptEvent,
4864    ) -> Option<crate::RealtimeUserContentApplyOutcome> {
4865        let state = self.realtime_transcript_state();
4866        realtime_transcript_revision::preflight_realtime_user_content_event(&state, event)
4867            .unwrap_or_else(|err| {
4868                fail_closed_generated_restore(
4869                    "realtime-user-content-preflight",
4870                    <serde_json::Error as serde::de::Error>::custom(err),
4871                )
4872            })
4873    }
4874
4875    /// Return every distinct provider `response_id` currently staged in the
4876    /// realtime-transcript metadata that has at least one **unmaterialized**
4877    /// assistant item and is **not already discarded**.
4878    ///
4879    /// CC4 (Round-4 architectural reconciliation): when the live boundary
4880    /// signals a barge-in (`TurnInterrupted`), the projection sink does not
4881    /// know which provider response_ids have streaming deltas staged in
4882    /// session metadata. This accessor lets the sink fan
4883    /// [`RealtimeTranscriptEvent::AssistantTurnInterrupted`] events out to
4884    /// each in-flight response so staged-but-not-yet-materialized transcript
4885    /// fragments are discarded — preventing them from silently committing
4886    /// when the *next* turn's `AssistantTurnCompleted` (synthesized by the
4887    /// CC2 fix in `signal_turn_completed`) sweeps the materializer.
4888    ///
4889    /// Order is the [`SessionRealtimeTranscriptState::first_seen_order`]
4890    /// projection so callers see deterministic iteration. Items already
4891    /// materialized or skipped are excluded — only response_ids with at
4892    /// least one live unmaterialized assistant item are returned.
4893    #[must_use]
4894    pub fn in_flight_realtime_assistant_response_ids(&self) -> Vec<String> {
4895        let state = self.realtime_transcript_state();
4896        realtime_transcript_revision::in_flight_realtime_assistant_response_ids(&state)
4897    }
4898
4899    /// Durable session-scoped bindings used to make live non-text input retry
4900    /// safe across provider reconnects and lost public receipts.
4901    #[must_use]
4902    pub fn realtime_user_content_identities(&self) -> Vec<RealtimeUserContentIdentity> {
4903        let state = self.realtime_transcript_state();
4904        realtime_transcript_revision::realtime_user_content_identities(&state)
4905    }
4906
4907    /// Return the bounded metadata-only image-blob recovery anchor, if one is
4908    /// durably staged ahead of reducer finalization.
4909    #[must_use]
4910    pub fn pending_realtime_user_content_blob(
4911        &self,
4912    ) -> Option<crate::PendingRealtimeUserContentBlob> {
4913        let state = self.realtime_transcript_state();
4914        realtime_transcript_revision::pending_realtime_user_content_blob(&state)
4915    }
4916
4917    /// Stage or exactly reuse the one-slot durable image-blob recovery anchor
4918    /// through generated SessionDocument authority.
4919    pub fn stage_pending_realtime_user_content_blob(
4920        &mut self,
4921        pending: crate::PendingRealtimeUserContentBlob,
4922    ) -> Result<
4923        crate::generated::session_document::RealtimeUserContentBlobStageDisposition,
4924        realtime_transcript_revision::RealtimeTranscriptShellError,
4925    > {
4926        let mut state = self.realtime_transcript_state();
4927        let disposition = realtime_transcript_revision::stage_pending_realtime_user_content_blob(
4928            &mut state, pending,
4929        )?;
4930        self.store_realtime_transcript_state(&state);
4931        Ok(disposition)
4932    }
4933
4934    pub fn resolve_pending_realtime_user_content_blob_recovery(
4935        &self,
4936        request: Option<&crate::PendingRealtimeUserContentBlob>,
4937        pending_blob_valid: bool,
4938    ) -> Result<
4939        crate::generated::session_document::RealtimeUserContentBlobRecoveryDisposition,
4940        realtime_transcript_revision::RealtimeTranscriptShellError,
4941    > {
4942        let state = self.realtime_transcript_state();
4943        realtime_transcript_revision::resolve_pending_realtime_user_content_blob_recovery(
4944            &state,
4945            request,
4946            pending_blob_valid,
4947        )
4948    }
4949
4950    /// Clear a missing/corrupt occupied anchor only after generated recovery
4951    /// authority classifies a different request as `ClearInvalidBeforeCurrent`.
4952    pub fn clear_invalid_pending_realtime_user_content_blob(
4953        &mut self,
4954        request: Option<&crate::PendingRealtimeUserContentBlob>,
4955    ) -> Result<(), realtime_transcript_revision::RealtimeTranscriptShellError> {
4956        let mut state = self.realtime_transcript_state();
4957        realtime_transcript_revision::clear_invalid_pending_realtime_user_content_blob(
4958            &mut state, request,
4959        )?;
4960        self.store_realtime_transcript_state(&state);
4961        Ok(())
4962    }
4963
4964    /// Durable caller keys whose canonical realtime image was removed by a
4965    /// same-session transcript rewrite. Provider adapters consume these as a
4966    /// pre-send conflict registry on open and refresh.
4967    #[must_use]
4968    pub fn realtime_user_content_tombstones(
4969        &self,
4970    ) -> Vec<crate::realtime_transcript::RealtimeUserContentTombstone> {
4971        let state = self.realtime_transcript_state();
4972        realtime_transcript_revision::realtime_user_content_tombstones(&state)
4973    }
4974
4975    fn realtime_transcript_state(&self) -> SessionRealtimeTranscriptState {
4976        match self.try_realtime_transcript_state() {
4977            Ok(Some(state)) => state,
4978            Ok(None) => SessionRealtimeTranscriptState::default(),
4979            Err(err) => fail_closed_generated_restore("realtime-transcript", err),
4980        }
4981    }
4982
4983    fn try_realtime_transcript_state(
4984        &self,
4985    ) -> Result<Option<SessionRealtimeTranscriptState>, serde_json::Error> {
4986        self.metadata
4987            .get(SESSION_REALTIME_TRANSCRIPT_STATE_KEY)
4988            .map(|value| {
4989                let state = serde_json::from_value(value.clone())?;
4990                realtime_transcript_revision::restore_realtime_transcript_state(state)
4991                    .map_err(<serde_json::Error as serde::de::Error>::custom)
4992            })
4993            .transpose()
4994    }
4995
4996    fn store_realtime_transcript_state(&mut self, state: &SessionRealtimeTranscriptState) {
4997        match serde_json::to_value(state) {
4998            Ok(value) => self.set_metadata_unchecked(SESSION_REALTIME_TRANSCRIPT_STATE_KEY, value),
4999            Err(error) => {
5000                tracing::warn!(error = %error, "failed to serialize realtime transcript state");
5001            }
5002        }
5003    }
5004
5005    fn reconciled_realtime_transcript_metadata_after_rewrite(
5006        &self,
5007        messages: &[Message],
5008    ) -> Result<Option<serde_json::Value>, TranscriptEditError> {
5009        let Some(state) = self
5010            .try_realtime_transcript_state()
5011            .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?
5012        else {
5013            return Ok(None);
5014        };
5015        let state =
5016            realtime_transcript_revision::reconcile_realtime_transcript_state_after_rewrite(
5017                state, messages,
5018            )
5019            .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
5020        serde_json::to_value(state)
5021            .map(Some)
5022            .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))
5023    }
5024
5025    fn apply_authorized_system_prompt(
5026        &mut self,
5027        prompt: session_durable_config_authority::AuthorizedSystemPrompt,
5028    ) {
5029        use crate::types::SystemMessage;
5030
5031        // The typed mutation provenance is carried onto the applied system
5032        // message so the transcript-continuity save-guard recognizes a
5033        // runtime context-append shape from a typed field instead of the
5034        // rendered `[Runtime System Context]` label.
5035        let mutation_kind = prompt.mutation_kind();
5036        let (prompt, _replacing_existing) = prompt.into_parts();
5037        let message = SystemMessage::with_mutation_kind(prompt, mutation_kind);
5038        let inner = Arc::make_mut(&mut self.messages);
5039        // Check if first message is system
5040        if let Some(Message::System(_)) = inner.first() {
5041            inner[0] = Message::System(message);
5042        } else {
5043            inner.insert(0, Message::System(message));
5044        }
5045        self.updated_at = SystemTime::now();
5046        self.refresh_transcript_head_after_message_mutation();
5047    }
5048
5049    /// Set a system prompt through generated durable-config authority.
5050    pub fn set_system_prompt_with_source(
5051        &mut self,
5052        prompt: String,
5053        source: session_durable_config_authority::SessionSystemPromptSource,
5054    ) -> Result<(), session_durable_config_authority::SessionDurableConfigAuthorityError> {
5055        let replacing_existing = matches!(self.messages.first(), Some(Message::System(_)));
5056        let prompt = session_durable_config_authority::authorize_system_prompt_mutation(
5057            prompt,
5058            source,
5059            replacing_existing,
5060        )?;
5061        self.apply_authorized_system_prompt(prompt);
5062        Ok(())
5063    }
5064
5065    /// Set a system prompt (adds or replaces System message at start).
5066    pub fn set_system_prompt(&mut self, prompt: String) {
5067        if let Err(err) = self.set_system_prompt_with_source(
5068            prompt,
5069            session_durable_config_authority::SessionSystemPromptSource::DirectMutation,
5070        ) {
5071            tracing::warn!(error = %err, "generated session durable-config authority rejected system prompt mutation");
5072        }
5073    }
5074
5075    /// Remove transient active-turn steer context from persisted session state.
5076    ///
5077    /// Operator steers accepted into an already-running turn are request-local:
5078    /// they should be visible to that turn's next model boundary, then vanish
5079    /// instead of replaying into later turns after persistence or resume.
5080    pub fn discard_transient_runtime_steer_context(&mut self) -> usize {
5081        let mut removed = 0usize;
5082
5083        let mut state = match self.try_system_context_state() {
5084            Ok(state) => state.unwrap_or_default(),
5085            Err(err) => {
5086                tracing::warn!(
5087                    error = %err,
5088                    "generated system-context authority rejected runtime steer cleanup state"
5089                );
5090                return removed;
5091            }
5092        };
5093
5094        // The typed `source_kind` marker on persisted appends is the authority
5095        // for which rendered prompt blocks are transient runtime steers. Gather
5096        // the runtime-steer appends, then remove their exact rendered blocks
5097        // from the system prompt — no `runtime:steer:` string classification.
5098        let runtime_steer_appends = state
5099            .pending
5100            .iter()
5101            .chain(state.applied.iter())
5102            .filter(|append| append.source_kind.is_runtime_steer())
5103            .cloned()
5104            .collect::<Vec<_>>();
5105        if let Some(Message::System(system)) = self.messages.first() {
5106            let (retained_prompt, removed_blocks) =
5107                system_context_authority::remove_runtime_steer_blocks_for_rendered(
5108                    &system.content,
5109                    &runtime_steer_appends,
5110                );
5111            if removed_blocks > 0 {
5112                removed += removed_blocks;
5113                if let Err(err) = self.set_system_prompt_with_source(
5114                    retained_prompt,
5115                    session_durable_config_authority::SessionSystemPromptSource::RuntimeSteerCleanup,
5116                ) {
5117                    tracing::warn!(
5118                        error = %err,
5119                        "generated session durable-config authority rejected runtime steer prompt cleanup"
5120                    );
5121                }
5122            }
5123        }
5124
5125        removed += system_context_authority::discard_transient_runtime_steer_state(&mut state);
5126
5127        if removed > 0
5128            && let Err(err) = self.set_system_context_state(state)
5129        {
5130            tracing::warn!(
5131                error = %err,
5132                "failed to persist runtime steer context cleanup"
5133            );
5134        }
5135
5136        removed
5137    }
5138
5139    /// Append one or more runtime system-context blocks to the canonical system prompt.
5140    pub fn append_system_context_blocks(&mut self, appends: &[PendingSystemContextAppend]) {
5141        if appends.is_empty() {
5142            return;
5143        }
5144
5145        let current_system_prompt = self
5146            .messages
5147            .first()
5148            .and_then(|message| match message {
5149                Message::System(system) => Some(system.content.as_str()),
5150                _ => None,
5151            })
5152            .unwrap_or_default();
5153        let mut state = match self.try_system_context_state() {
5154            Ok(state) => state.unwrap_or_default(),
5155            Err(err) => {
5156                tracing::warn!(
5157                    error = %err,
5158                    "generated system-context authority rejected applied context state"
5159                );
5160                return;
5161            }
5162        };
5163        let new_appends = system_context_authority::record_applied_system_context_blocks(
5164            &mut state,
5165            appends,
5166            current_system_prompt,
5167        );
5168        if new_appends.is_empty() {
5169            if let Err(err) = self.set_system_context_state(state) {
5170                tracing::warn!(error = %err, "failed to persist applied system-context state");
5171            }
5172            return;
5173        }
5174
5175        let rendered = render_system_context_blocks_joined(&new_appends);
5176
5177        let next = match self.messages.first() {
5178            Some(Message::System(sys)) if !sys.content.is_empty() => {
5179                format!("{}{}{}", sys.content, SYSTEM_CONTEXT_SEPARATOR, rendered)
5180            }
5181            _ => rendered,
5182        };
5183        if let Err(err) = self.set_system_prompt_with_source(
5184            next,
5185            session_durable_config_authority::SessionSystemPromptSource::RuntimeContextAppend,
5186        ) {
5187            tracing::warn!(
5188                error = %err,
5189                "generated session durable-config authority rejected system-context prompt append"
5190            );
5191            return;
5192        }
5193        if let Err(err) = self.set_system_context_state(state) {
5194            tracing::warn!(error = %err, "failed to persist applied system-context state");
5195        }
5196    }
5197
5198    /// Reconcile a resumed session's persisted system prompt with a freshly
5199    /// assembled base prompt.
5200    ///
5201    /// A resumed transcript is durable state: its leading [`Message::System`]
5202    /// carries the base prompt PLUS every runtime system-context append the
5203    /// runtime durably applied (comms rosters, host context — rendered by
5204    /// [`Session::append_system_context_blocks`]). Blind-replacing that
5205    /// message with a re-assembled base prompt discards the runtime-applied
5206    /// context and produces a projection that is no longer a continuation of
5207    /// the persisted transcript revision — the append-only save guard then
5208    /// rejects the very first post-resume persist and the live session is
5209    /// discarded (the upstream cold-restart transcript-loss report).
5210    ///
5211    /// Reconciliation instead of replacement:
5212    /// - If the persisted System content IS the assembled base — identical, or
5213    ///   extended only by [`SYSTEM_CONTEXT_SEPARATOR`]-joined runtime context
5214    ///   appends — the transcript is left untouched (byte-for-byte, including
5215    ///   the typed `mutation_kind`), so the resumed projection digests to the
5216    ///   persisted revision.
5217    /// - If the base genuinely changed, the new System message (new base plus
5218    ///   the reconstructed runtime-append tail, when the persisted tail is
5219    ///   verifiable from the durable applied-append records) is committed
5220    ///   through [`Session::commit_transcript_rewrite`] — the canonical typed
5221    ///   rewrite path — so the first post-resume persist proves a transcript
5222    ///   graph edge from the persisted head instead of failing closed.
5223    pub fn reconcile_resumed_system_prompt(
5224        &mut self,
5225        assembled_base: String,
5226        actor: Option<String>,
5227    ) -> Result<ResumedSystemPromptReconciliation, TranscriptEditError> {
5228        let persisted = match self.messages.first() {
5229            Some(Message::System(system)) => Some((system.content.clone(), system.mutation_kind)),
5230            _ => None,
5231        };
5232
5233        let Some((persisted_content, persisted_mutation_kind)) = persisted else {
5234            if assembled_base.is_empty() {
5235                return Ok(ResumedSystemPromptReconciliation::NoChange);
5236            }
5237            // The persisted transcript never had a system prompt; introducing
5238            // one changes the transcript, so it flows through the same typed
5239            // rewrite path (an insert rewrite over the empty leading span).
5240            self.commit_resume_system_prompt_rewrite(assembled_base, false, actor)?;
5241            return Ok(ResumedSystemPromptReconciliation::RewrittenBase);
5242        };
5243
5244        if persisted_content == assembled_base {
5245            return Ok(ResumedSystemPromptReconciliation::PreservedContinuation);
5246        }
5247
5248        // Byte-exact reconciliation first: when the persisted content splits
5249        // into a VERIFIED base + runtime-appended tail, the expected content
5250        // for this build is `assembled_base + tail` — equal means the base is
5251        // unchanged (preserve untouched), different means the base changed
5252        // (audited rewrite that carries the tail). This runs before the
5253        // structural fast path so a shortened base whose removed remainder
5254        // merely looks like a context tail (the separator is ordinary
5255        // markdown) is applied instead of silently ignored.
5256        if let Some(tail) = self.verified_runtime_context_tail(&persisted_content) {
5257            let expected = compose_system_prompt_with_context_tail(&assembled_base, &tail);
5258            if expected == persisted_content {
5259                return Ok(ResumedSystemPromptReconciliation::PreservedContinuation);
5260            }
5261            self.commit_resume_system_prompt_rewrite(expected, true, actor)?;
5262            return Ok(ResumedSystemPromptReconciliation::RewrittenBase);
5263        }
5264
5265        // No verifiable tail record (rows written before the assembled base
5266        // was recorded, or applied-append state swept by the runtime path).
5267        // The canonical SessionDocumentMachine persist-append admission
5268        // decides — from the structural observations plus the typed mutation
5269        // provenance — whether the persisted prompt is a runtime-context-
5270        // append continuation of the assembled base. Machine refusal fails
5271        // closed into the audited rewrite below.
5272        if persisted_prompt_is_admitted_context_append_continuation(
5273            &assembled_base,
5274            &persisted_content,
5275            persisted_mutation_kind,
5276        ) {
5277            return Ok(ResumedSystemPromptReconciliation::PreservedContinuation);
5278        }
5279
5280        // The base diverged and the runtime-context tail is not
5281        // reconstructible: only the new base can be written. Dropping the
5282        // appended context silently would leave the durable applied/seen
5283        // records claiming those appends are applied — keyed re-sends would
5284        // be deduplicated forever — so clear the orphaned records to keep the
5285        // context restorable by the host.
5286        let dropping_applied_context = persisted_mutation_kind.is_runtime_context_append()
5287            || self
5288                .system_context_state()
5289                .is_some_and(|state| !state.applied.is_empty());
5290        self.commit_resume_system_prompt_rewrite(assembled_base, true, actor)?;
5291        if dropping_applied_context {
5292            tracing::warn!(
5293                session_id = %self.id,
5294                "resume base-prompt refresh dropped an unverifiable runtime system-context tail; \
5295                 clearing applied-append records so keyed re-sends can restore the context"
5296            );
5297            self.clear_applied_system_context_records();
5298        }
5299        Ok(ResumedSystemPromptReconciliation::RewrittenBase)
5300    }
5301
5302    /// Split the persisted System content into a VERIFIED runtime-appended
5303    /// tail (leading [`SYSTEM_CONTEXT_SEPARATOR`] included; empty when the
5304    /// content is exactly a verified base).
5305    ///
5306    /// Verification sources, strongest first: byte-exact against the prior
5307    /// build's recorded assembled base
5308    /// ([`SessionBuildState::assembled_system_prompt`]), then a re-render of
5309    /// the durable applied-append records. `None` means the tail is not
5310    /// reconstructible from durable facts.
5311    fn verified_runtime_context_tail(&self, persisted_content: &str) -> Option<String> {
5312        if let Some(prior_base) = self
5313            .build_state()
5314            .and_then(|state| state.assembled_system_prompt)
5315        {
5316            if persisted_content == prior_base {
5317                return Some(String::new());
5318            }
5319            if let Some(appended) = persisted_content.strip_prefix(prior_base.as_str())
5320                && appended.starts_with(SYSTEM_CONTEXT_SEPARATOR)
5321            {
5322                return Some(appended.to_string());
5323            }
5324            // The record does not split this content (e.g. it predates the
5325            // last prompt mutation); fall through to the render verification.
5326        }
5327        let rendered_tail = self
5328            .system_context_state()
5329            .map(|state| render_system_context_blocks_joined(&state.applied))
5330            .unwrap_or_default();
5331        if rendered_tail.is_empty() {
5332            return None;
5333        }
5334        if persisted_content == rendered_tail {
5335            // The entire persisted prompt is verified runtime context (a
5336            // promptless/empty-base build whose appends compose without a
5337            // separator prefix) — the tail is the whole content, not empty.
5338            return Some(format!("{SYSTEM_CONTEXT_SEPARATOR}{rendered_tail}"));
5339        }
5340        let with_separator = format!("{SYSTEM_CONTEXT_SEPARATOR}{rendered_tail}");
5341        persisted_content
5342            .ends_with(&with_separator)
5343            .then_some(with_separator)
5344    }
5345
5346    /// Commit a resume-time base-prompt refresh through the generated
5347    /// durable-config authority and the canonical typed rewrite path.
5348    fn commit_resume_system_prompt_rewrite(
5349        &mut self,
5350        content: String,
5351        replacing_existing: bool,
5352        actor: Option<String>,
5353    ) -> Result<(), TranscriptEditError> {
5354        let authorized = session_durable_config_authority::authorize_system_prompt_mutation(
5355            content,
5356            session_durable_config_authority::SessionSystemPromptSource::ExplicitBuild,
5357            replacing_existing,
5358        )
5359        .map_err(|err| {
5360            TranscriptEditError::HistoryStateMalformed(format!(
5361                "generated session durable-config authority rejected resume system prompt refresh: {err}"
5362            ))
5363        })?;
5364        let mutation_kind = authorized.mutation_kind();
5365        let (content, _replacing_existing) = authorized.into_parts();
5366        let replacement = Message::System(crate::types::SystemMessage::with_mutation_kind(
5367            content,
5368            mutation_kind,
5369        ));
5370        let end = usize::from(replacing_existing);
5371        self.commit_transcript_rewrite(
5372            TranscriptRewriteSelection::MessageRange { start: 0, end },
5373            vec![replacement],
5374            TranscriptRewriteReason::new(RESUME_SYSTEM_PROMPT_REFRESH_REWRITE_REASON),
5375            actor,
5376            None,
5377        )?;
5378        Ok(())
5379    }
5380
5381    /// Clear applied-append records (and their idempotency keys) after a
5382    /// resume rewrite dropped their rendered blocks from the System prompt,
5383    /// so the same keyed appends re-apply instead of deduplicating forever.
5384    fn clear_applied_system_context_records(&mut self) {
5385        let mut state = match self.try_system_context_state() {
5386            Ok(Some(state)) => state,
5387            Ok(None) => return,
5388            Err(error) => {
5389                tracing::warn!(
5390                    session_id = %self.id,
5391                    error = %error,
5392                    "failed to read system-context state while clearing orphaned applied records"
5393                );
5394                return;
5395            }
5396        };
5397        if state.applied.is_empty() {
5398            return;
5399        }
5400        let dropped_keys: Vec<String> = state
5401            .applied
5402            .iter()
5403            .filter_map(|append| append.idempotency_key.clone())
5404            .collect();
5405        state.applied.clear();
5406        for key in &dropped_keys {
5407            state.seen.remove(key);
5408        }
5409        if let Err(error) = self.set_system_context_state(state) {
5410            tracing::warn!(
5411                session_id = %self.id,
5412                error = %error,
5413                "failed to persist cleared applied system-context records after resume prompt refresh"
5414            );
5415        }
5416    }
5417
5418    /// Get the last assistant message text content.
5419    ///
5420    /// Concatenates both `Text` (display) and `Transcript` (spoken) blocks
5421    /// in document order, since both lanes project to the same human-readable
5422    /// stream. Lane provenance is preserved on the underlying `AssistantBlock`
5423    /// for callers that need it.
5424    pub fn last_assistant_text(&self) -> Option<String> {
5425        self.messages.iter().rev().find_map(|m| match m {
5426            Message::BlockAssistant(a) => {
5427                let mut buf = String::new();
5428                for block in &a.blocks {
5429                    match block {
5430                        crate::types::AssistantBlock::Text { text, .. }
5431                        | crate::types::AssistantBlock::Transcript { text, .. } => {
5432                            buf.push_str(text);
5433                        }
5434                        _ => {}
5435                    }
5436                }
5437                if buf.is_empty() { None } else { Some(buf) }
5438            }
5439            _ => None,
5440        })
5441    }
5442
5443    /// Count tool calls made
5444    pub fn tool_call_count(&self) -> usize {
5445        self.messages
5446            .iter()
5447            .filter_map(|m| match m {
5448                Message::BlockAssistant(a) => Some(
5449                    a.blocks
5450                        .iter()
5451                        .filter(|b| matches!(b, crate::types::AssistantBlock::ToolUse { .. }))
5452                        .count(),
5453                ),
5454                _ => None,
5455            })
5456            .sum()
5457    }
5458
5459    /// Get metadata
5460    pub fn metadata(&self) -> &serde_json::Map<String, serde_json::Value> {
5461        &self.metadata
5462    }
5463
5464    fn set_metadata_unchecked(&mut self, key: &str, value: serde_json::Value) {
5465        // Reapplying an identical durable projection is not a session-content
5466        // mutation. In particular, cold materialization restores the sealed
5467        // SessionMetadata and SessionBuildState before it knows whether the
5468        // values changed; advancing `updated_at` for an exact no-op would
5469        // rotate the checkpoint digest and manufacture a sibling checkpoint
5470        // even though the committed document is unchanged.
5471        if self.metadata.get(key) == Some(&value) {
5472            return;
5473        }
5474        self.metadata.insert(key.to_string(), value);
5475        if key == SESSION_TRANSCRIPT_HISTORY_STATE_KEY {
5476            self.metadata
5477                .remove(SESSION_TRANSCRIPT_HISTORY_CHECKPOINT_DIGEST_KEY);
5478            self.transcript_history_metadata_validation =
5479                TranscriptHistoryMetadataValidation::RequiresValidation;
5480        }
5481        self.updated_at = SystemTime::now();
5482    }
5483
5484    /// Install transcript history that was produced by a typed path which
5485    /// already validated and compacted the graph.
5486    fn set_validated_transcript_history_metadata(&mut self, value: serde_json::Value) {
5487        self.metadata
5488            .insert(SESSION_TRANSCRIPT_HISTORY_STATE_KEY.to_string(), value);
5489        self.metadata
5490            .remove(SESSION_TRANSCRIPT_HISTORY_CHECKPOINT_DIGEST_KEY);
5491        self.transcript_history_metadata_validation =
5492            TranscriptHistoryMetadataValidation::Validated;
5493        self.updated_at = SystemTime::now();
5494    }
5495
5496    #[cfg(test)]
5497    pub(crate) fn set_metadata_unchecked_for_test(&mut self, key: &str, value: serde_json::Value) {
5498        self.set_metadata_unchecked(key, value);
5499    }
5500
5501    fn fork_metadata_projection(&self) -> serde_json::Map<String, serde_json::Value> {
5502        let mut metadata = self.metadata.clone();
5503        metadata.retain(|key, _| !is_session_authority_metadata_key(key));
5504        metadata
5505    }
5506
5507    fn remove_metadata_unchecked(&mut self, key: &str) {
5508        let removed = self.metadata.remove(key).is_some();
5509        let mut changed = removed;
5510        if key == SESSION_TRANSCRIPT_HISTORY_STATE_KEY {
5511            changed |= self
5512                .metadata
5513                .remove(SESSION_TRANSCRIPT_HISTORY_CHECKPOINT_DIGEST_KEY)
5514                .is_some();
5515            self.transcript_history_metadata_validation =
5516                TranscriptHistoryMetadataValidation::Validated;
5517        }
5518        if changed {
5519            self.updated_at = SystemTime::now();
5520        }
5521    }
5522
5523    /// Set a metadata value when the key is not reserved for generated authority.
5524    pub fn try_set_metadata(
5525        &mut self,
5526        key: &str,
5527        value: serde_json::Value,
5528    ) -> Result<(), ReservedSessionMetadataKey> {
5529        if is_session_authority_metadata_key(key) {
5530            return Err(ReservedSessionMetadataKey::new(key));
5531        }
5532        self.set_metadata_unchecked(key, value);
5533        Ok(())
5534    }
5535
5536    /// Set a metadata value.
5537    ///
5538    /// Reserved generated-authority metadata keys fail closed and are left
5539    /// untouched. Use the typed setters for those keys.
5540    pub fn set_metadata(&mut self, key: &str, value: serde_json::Value) {
5541        if let Err(err) = self.try_set_metadata(key, value) {
5542            tracing::warn!(error = %err, "rejected raw session metadata mutation");
5543        }
5544    }
5545
5546    /// Backfill a missing metadata value without changing `updated_at`.
5547    ///
5548    /// This is only for compatibility reads that need to hydrate metadata from
5549    /// an older projection. Semantic metadata mutations must use
5550    /// [`Session::set_metadata`] so the session timestamp advances.
5551    pub fn backfill_metadata_if_absent(&mut self, key: &str, value: serde_json::Value) -> bool {
5552        if is_session_authority_metadata_key(key) {
5553            tracing::warn!(
5554                metadata_key = key,
5555                "rejected raw session metadata backfill for authority key"
5556            );
5557            return false;
5558        }
5559        if self.metadata.contains_key(key) {
5560            false
5561        } else {
5562            self.metadata.insert(key.to_string(), value);
5563            true
5564        }
5565    }
5566
5567    /// Remove a metadata value.
5568    pub fn remove_metadata(&mut self, key: &str) {
5569        if is_session_authority_metadata_key(key) {
5570            tracing::warn!(
5571                metadata_key = key,
5572                "rejected raw session metadata removal for authority key"
5573            );
5574            return;
5575        }
5576        if self.metadata.remove(key).is_some() {
5577            self.updated_at = SystemTime::now();
5578        }
5579    }
5580
5581    /// Store SessionMetadata in the session metadata map.
5582    pub fn set_session_metadata(
5583        &mut self,
5584        metadata: SessionMetadata,
5585    ) -> Result<(), serde_json::Error> {
5586        let metadata =
5587            session_durable_config_authority::authorize_session_metadata_persist(metadata)
5588                .map_err(<serde_json::Error as serde::ser::Error>::custom)?
5589                .into_metadata();
5590        let value = serde_json::to_value(metadata)?;
5591        self.set_metadata_unchecked(SESSION_METADATA_KEY, value);
5592        Ok(())
5593    }
5594
5595    /// Load SessionMetadata from the session metadata map.
5596    ///
5597    /// If the reserved key exists but cannot pass typed generated restore,
5598    /// fail closed instead of treating corrupted machine facts as absent.
5599    pub fn session_metadata(&self) -> Option<SessionMetadata> {
5600        match self.try_session_metadata() {
5601            Ok(metadata) => metadata,
5602            Err(err) => fail_closed_generated_restore("session-metadata", err),
5603        }
5604    }
5605
5606    /// Try to load SessionMetadata through generated restore authority.
5607    pub fn try_session_metadata(&self) -> Result<Option<SessionMetadata>, serde_json::Error> {
5608        try_session_metadata_from_map(&self.metadata)
5609    }
5610
5611    /// Store durable system-context control state in the session metadata map.
5612    pub fn set_system_context_state(
5613        &mut self,
5614        state: SessionSystemContextState,
5615    ) -> Result<(), serde_json::Error> {
5616        let state = system_context_authority::restore_system_context_state(state)
5617            .map_err(<serde_json::Error as serde::ser::Error>::custom)?;
5618        let value = serde_json::to_value(state)?;
5619        self.set_metadata_unchecked(SESSION_SYSTEM_CONTEXT_STATE_KEY, value);
5620        Ok(())
5621    }
5622
5623    /// Try to load durable system-context control state through generated restore authority.
5624    pub fn try_system_context_state(
5625        &self,
5626    ) -> Result<Option<SessionSystemContextState>, serde_json::Error> {
5627        self.metadata
5628            .get(SESSION_SYSTEM_CONTEXT_STATE_KEY)
5629            .map(|value| {
5630                let state = serde_json::from_value(value.clone())?;
5631                system_context_authority::restore_system_context_state(state)
5632                    .map_err(<serde_json::Error as serde::de::Error>::custom)
5633            })
5634            .transpose()
5635    }
5636
5637    /// Load durable system-context control state from the session metadata map.
5638    ///
5639    /// Rejected durable facts fail closed through the generated restore
5640    /// authority. Callers that need the typed rejection must use
5641    /// [`Self::try_system_context_state`].
5642    pub fn system_context_state(&self) -> Option<SessionSystemContextState> {
5643        match self.try_system_context_state() {
5644            Ok(state) => state,
5645            Err(err) => fail_closed_generated_restore("system-context", err),
5646        }
5647    }
5648
5649    /// Store durable deferred-turn control state in the session metadata map.
5650    pub fn set_deferred_turn_state(
5651        &mut self,
5652        state: SessionDeferredTurnState,
5653    ) -> Result<(), serde_json::Error> {
5654        let state = validate_deferred_turn_snapshot(state)
5655            .map_err(<serde_json::Error as serde::ser::Error>::custom)?;
5656        let value = serde_json::to_value(state)?;
5657        self.set_metadata_unchecked(SESSION_DEFERRED_TURN_STATE_KEY, value);
5658        Ok(())
5659    }
5660
5661    /// Try to load durable deferred-turn control state through generated restore authority.
5662    pub fn try_deferred_turn_state(
5663        &self,
5664    ) -> Result<Option<SessionDeferredTurnState>, serde_json::Error> {
5665        self.metadata
5666            .get(SESSION_DEFERRED_TURN_STATE_KEY)
5667            .map(|value| {
5668                let state = serde_json::from_value(value.clone())?;
5669                validate_deferred_turn_snapshot(state)
5670                    .map_err(<serde_json::Error as serde::de::Error>::custom)
5671            })
5672            .transpose()
5673    }
5674
5675    /// Load durable deferred-turn control state from the session metadata map.
5676    ///
5677    /// Rejected durable facts fail closed through the generated restore
5678    /// authority. Callers that need the typed rejection must use
5679    /// [`Self::try_deferred_turn_state`].
5680    pub fn deferred_turn_state(&self) -> Option<SessionDeferredTurnState> {
5681        match self.try_deferred_turn_state() {
5682            Ok(state) => state,
5683            Err(err) => fail_closed_generated_restore("deferred-turn", err),
5684        }
5685    }
5686
5687    /// Realize the typed session lifecycle-terminal projection in the session
5688    /// metadata map.
5689    ///
5690    /// The lifecycle-terminal fact is owned by the canonical
5691    /// [`session_document::SessionDocumentMachine`]; production archive paths
5692    /// call this only to realize a machine-emitted `SessionArchiveResolved`
5693    /// verdict (the value written mirrors the machine's decision — the shell
5694    /// decides nothing here).
5695    pub fn set_lifecycle_terminal(
5696        &mut self,
5697        terminal: SessionLifecycleTerminal,
5698    ) -> Result<(), serde_json::Error> {
5699        let value = serde_json::to_value(terminal)?;
5700        self.set_metadata_unchecked(SESSION_LIFECYCLE_TERMINAL_KEY, value);
5701        Ok(())
5702    }
5703
5704    /// Try to load the typed session lifecycle-terminal fact.
5705    ///
5706    /// Reads the typed [`SESSION_LIFECYCLE_TERMINAL_KEY`]; an absent key means
5707    /// no terminal fact.
5708    pub fn try_lifecycle_terminal(
5709        &self,
5710    ) -> Result<Option<SessionLifecycleTerminal>, serde_json::Error> {
5711        try_lifecycle_terminal_from_map(&self.metadata)
5712    }
5713
5714    /// Load the typed session lifecycle-terminal fact, failing closed on a
5715    /// corrupt typed value.
5716    ///
5717    /// Callers that need the typed rejection must use
5718    /// [`Self::try_lifecycle_terminal`].
5719    pub fn lifecycle_terminal(&self) -> Option<SessionLifecycleTerminal> {
5720        match self.try_lifecycle_terminal() {
5721            Ok(state) => state,
5722            Err(err) => fail_closed_generated_restore("session-lifecycle-terminal", err),
5723        }
5724    }
5725
5726    /// Store recoverable build-only session state in the session metadata map.
5727    pub fn set_build_state(&mut self, state: SessionBuildState) -> Result<(), serde_json::Error> {
5728        let state = session_durable_config_authority::authorize_session_build_state_persist(state)
5729            .map_err(<serde_json::Error as serde::ser::Error>::custom)?
5730            .into_state();
5731        let value = serde_json::to_value(state)?;
5732        self.set_metadata_unchecked(SESSION_BUILD_STATE_KEY, value);
5733        Ok(())
5734    }
5735
5736    /// Load recoverable build-only session state from the session metadata map.
5737    ///
5738    /// If the reserved key exists but cannot pass typed generated restore,
5739    /// fail closed instead of treating corrupted machine facts as absent.
5740    pub fn build_state(&self) -> Option<SessionBuildState> {
5741        match self.try_build_state() {
5742            Ok(state) => state,
5743            Err(err) => fail_closed_generated_restore("session-build-state", err),
5744        }
5745    }
5746
5747    /// Try to load recoverable build-only session state through generated restore authority.
5748    pub fn try_build_state(&self) -> Result<Option<SessionBuildState>, serde_json::Error> {
5749        let Some(value) = self.metadata.get(SESSION_BUILD_STATE_KEY) else {
5750            return Ok(None);
5751        };
5752        let state = serde_json::from_value::<SessionBuildState>(value.clone())?;
5753        session_durable_config_authority::restore_session_build_state(state)
5754            .map(Some)
5755            .map_err(<serde_json::Error as serde::de::Error>::custom)
5756    }
5757
5758    /// Store durable tool-visibility control state in the session metadata map.
5759    pub fn set_tool_visibility_state(
5760        &mut self,
5761        state: AuthorizedSessionToolVisibilityState,
5762    ) -> Result<(), serde_json::Error> {
5763        let value = serde_json::to_value(state.into_state())?;
5764        self.set_metadata_unchecked(SESSION_TOOL_VISIBILITY_STATE_KEY, value);
5765        Ok(())
5766    }
5767
5768    /// Test-only metadata clear for compatibility assertions.
5769    ///
5770    /// Production paths persist an explicit generated-authority projection
5771    /// rather than making durable absence carry semantic default truth.
5772    #[cfg(test)]
5773    pub(crate) fn clear_tool_visibility_state(&mut self) {
5774        self.remove_metadata_unchecked(SESSION_TOOL_VISIBILITY_STATE_KEY);
5775    }
5776
5777    /// Load durable tool-visibility control state from the session metadata map.
5778    pub fn tool_visibility_state(
5779        &self,
5780    ) -> Result<Option<SessionToolVisibilityState>, serde_json::Error> {
5781        self.try_tool_visibility_state()
5782    }
5783
5784    /// Load durable tool-visibility control state while distinguishing absent
5785    /// metadata from malformed canonical metadata.
5786    pub fn try_tool_visibility_state(
5787        &self,
5788    ) -> Result<Option<SessionToolVisibilityState>, serde_json::Error> {
5789        self.metadata
5790            .get(SESSION_TOOL_VISIBILITY_STATE_KEY)
5791            .map(|value| serde_json::from_value(value.clone()))
5792            .transpose()
5793    }
5794
5795    /// Load typed transcript revision state from metadata.
5796    pub fn transcript_history_state(
5797        &self,
5798    ) -> Result<Option<TranscriptHistoryState>, serde_json::Error> {
5799        self.metadata
5800            .get(SESSION_TRANSCRIPT_HISTORY_STATE_KEY)
5801            .map(|value| serde_json::from_value(value.clone()))
5802            .transpose()
5803    }
5804
5805    /// Return the already-validated transcript graph head without cloning and
5806    /// deserializing the full history document again.
5807    ///
5808    /// Store guards use this after typed Session deserialization when they
5809    /// only need to prove live-message/head coherence. Unchecked metadata
5810    /// still crosses the full graph validator before the borrowed head can be
5811    /// observed.
5812    pub(crate) fn validated_transcript_history_head(
5813        &self,
5814    ) -> Result<Option<&str>, TranscriptEditError> {
5815        self.validate_transcript_history_state()?;
5816        let Some(value) = self.metadata.get(SESSION_TRANSCRIPT_HISTORY_STATE_KEY) else {
5817            return Ok(None);
5818        };
5819        value
5820            .get("head")
5821            .and_then(serde_json::Value::as_str)
5822            .map(Some)
5823            .ok_or_else(|| {
5824                TranscriptEditError::HistoryStateMalformed(
5825                    "validated transcript history metadata omitted a string head".to_string(),
5826                )
5827            })
5828    }
5829
5830    /// Load exact compaction projection intents carried to the runtime's
5831    /// atomic-apply outbox by this session snapshot.
5832    pub fn compaction_projection_intents(
5833        &self,
5834    ) -> Result<Vec<crate::memory::CompactionProjectionIntent>, serde_json::Error> {
5835        self.metadata
5836            .get(crate::memory::SESSION_COMPACTION_PROJECTION_INTENTS_KEY)
5837            .map(|value| serde_json::from_value(value.clone()))
5838            .transpose()
5839            .map(Option::unwrap_or_default)
5840    }
5841
5842    /// Load persisted compaction intents only after proving that every
5843    /// already-carried projection ID is backed by this session's validated
5844    /// transcript graph.
5845    ///
5846    /// This is deliberately a validation boundary, not an ID constructor:
5847    /// durable typed rewrite tags and legacy records can confirm an existing
5848    /// identity during recovery but cannot mint a new identity.
5849    pub fn validated_compaction_projection_intents(
5850        &self,
5851    ) -> Result<Vec<crate::memory::CompactionProjectionIntent>, serde_json::Error> {
5852        self.validate_transcript_history_state()
5853            .map_err(|error| <serde_json::Error as serde::ser::Error>::custom(error.to_string()))?;
5854        let intents = self.compaction_projection_intents()?;
5855        if intents.is_empty() {
5856            return Ok(intents);
5857        }
5858        let history = self.transcript_history_state()?;
5859        let commits = history
5860            .as_ref()
5861            .map(|history| history.commits.as_slice())
5862            .unwrap_or_default();
5863        let mut unique = std::collections::HashSet::new();
5864        for intent in &intents {
5865            if intent.projection.session_id() != self.id() {
5866                return Err(<serde_json::Error as serde::ser::Error>::custom(
5867                    "compaction projection outbox intent has a foreign session id",
5868                ));
5869            }
5870            if !unique.insert(intent.projection.clone()) {
5871                return Err(<serde_json::Error as serde::ser::Error>::custom(
5872                    "compaction projection outbox contains a duplicate rewrite identity",
5873                ));
5874            }
5875            let backed = commits.iter().any(|commit| {
5876                intent
5877                    .projection
5878                    .matches_transcript_rewrite(self.id(), commit)
5879            });
5880            if !backed {
5881                return Err(<serde_json::Error as serde::ser::Error>::custom(format!(
5882                    "compaction projection outbox intent {} has no matching TranscriptRewriteCommit",
5883                    intent.projection.revision()
5884                )));
5885            }
5886        }
5887        Ok(intents)
5888    }
5889
5890    /// Record one invisible staged-memory intent only after its exact
5891    /// TranscriptRewriteCommit is present in the session graph.
5892    pub fn add_compaction_projection_intent(
5893        &mut self,
5894        intent: crate::memory::CompactionProjectionIntent,
5895    ) -> Result<(), serde_json::Error> {
5896        if intent.projection.session_id() != self.id() {
5897            return Err(<serde_json::Error as serde::ser::Error>::custom(
5898                "compaction projection intent session does not match snapshot session",
5899            ));
5900        }
5901        self.validate_transcript_history_state()
5902            .map_err(|error| <serde_json::Error as serde::ser::Error>::custom(error.to_string()))?;
5903        let history = self.transcript_history_state()?.ok_or_else(|| {
5904            <serde_json::Error as serde::ser::Error>::custom(
5905                "compaction projection intent requires transcript history state",
5906            )
5907        })?;
5908        let owns_commit = history.commits.iter().any(|commit| {
5909            commit.parent_revision == intent.projection.parent_revision()
5910                && commit.revision == intent.projection.revision()
5911                && intent
5912                    .projection
5913                    .matches_transcript_rewrite(self.id(), commit)
5914        });
5915        if !owns_commit {
5916            return Err(<serde_json::Error as serde::ser::Error>::custom(
5917                "compaction projection intent is not backed by the session transcript graph",
5918            ));
5919        }
5920        let mut intents = self.validated_compaction_projection_intents()?;
5921        if let Some(existing) = intents
5922            .iter()
5923            .find(|existing| existing.projection == intent.projection)
5924        {
5925            if existing == &intent {
5926                return Ok(());
5927            }
5928            return Err(<serde_json::Error as serde::ser::Error>::custom(
5929                "compaction projection intent conflicts with an existing rewrite identity",
5930            ));
5931        }
5932        intents.push(intent);
5933        self.set_metadata_unchecked(
5934            crate::memory::SESSION_COMPACTION_PROJECTION_INTENTS_KEY,
5935            serde_json::to_value(intents)?,
5936        );
5937        Ok(())
5938    }
5939
5940    /// Remove an intent after the runtime outbox has finalized its staged
5941    /// memory batch. Idempotent for repeated recovery finalization.
5942    pub fn complete_compaction_projection_intent(
5943        &mut self,
5944        projection: &crate::memory::CompactionProjectionId,
5945    ) -> Result<Option<crate::memory::CompactionProjectionIntent>, serde_json::Error> {
5946        let mut intents = self.compaction_projection_intents()?;
5947        let Some(position) = intents
5948            .iter()
5949            .position(|intent| &intent.projection == projection)
5950        else {
5951            return Ok(None);
5952        };
5953        let completed = intents.remove(position);
5954        if intents.is_empty() {
5955            self.remove_metadata_unchecked(
5956                crate::memory::SESSION_COMPACTION_PROJECTION_INTENTS_KEY,
5957            );
5958        } else {
5959            self.set_metadata_unchecked(
5960                crate::memory::SESSION_COMPACTION_PROJECTION_INTENTS_KEY,
5961                serde_json::to_value(intents)?,
5962            );
5963        }
5964        Ok(Some(completed))
5965    }
5966
5967    /// Validate the retained transcript revision graph, when present.
5968    pub fn validate_transcript_history_state(&self) -> Result<(), TranscriptEditError> {
5969        if self.transcript_history_metadata_validation
5970            == TranscriptHistoryMetadataValidation::Validated
5971        {
5972            return Ok(());
5973        }
5974        let Some(state) = self
5975            .transcript_history_state()
5976            .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?
5977        else {
5978            return Ok(());
5979        };
5980        validate_transcript_history_state(&state)
5981    }
5982
5983    /// Clear retained transcript revision metadata after a caller has
5984    /// materialized the desired message projection.
5985    pub fn clear_transcript_history_state(&mut self) {
5986        self.remove_metadata_unchecked(SESSION_TRANSCRIPT_HISTORY_STATE_KEY);
5987    }
5988
5989    /// Decode and verify this document's typed checkpoint state.
5990    ///
5991    /// Missing typed metadata is returned as explicit legacy-unverified state.
5992    /// A present malformed stamp or malformed legacy compatibility value is an
5993    /// error and is never laundered into absence.
5994    pub fn try_checkpoint_state(
5995        &self,
5996    ) -> Result<crate::checkpoint::SessionCheckpointState, crate::checkpoint::SessionCheckpointError>
5997    {
5998        let stamp =
5999            match crate::checkpoint::session_checkpoint_metadata_state(&self.id, &self.metadata)? {
6000                crate::checkpoint::SessionCheckpointMetadataState::Stamped(stamp) => stamp,
6001                crate::checkpoint::SessionCheckpointMetadataState::LegacyUnverified {
6002                    legacy_runtime_checkpoint,
6003                } => {
6004                    return Ok(
6005                        crate::checkpoint::SessionCheckpointState::LegacyUnverified {
6006                            legacy_runtime_checkpoint,
6007                        },
6008                    );
6009                }
6010            };
6011        let actual = crate::checkpoint::session_checkpoint_digest(self)?;
6012        if stamp.digest() != &actual {
6013            return Err(crate::checkpoint::SessionCheckpointError::DigestMismatch {
6014                expected: stamp.digest().clone(),
6015                actual,
6016            });
6017        }
6018        crate::checkpoint::record_checkpoint_stamp_verification(self, &actual);
6019        Ok(crate::checkpoint::SessionCheckpointState::Verified(stamp))
6020    }
6021
6022    /// [`Session::try_checkpoint_state`] for steady-state READS of durable
6023    /// documents, skipping the canonical-content digest recomputation when
6024    /// this process already fully verified this exact document shape and
6025    /// stamp digest.
6026    ///
6027    /// Admission into the memo requires one complete verification (here, in
6028    /// `try_checkpoint_state`, or at stamp install time), so the first read
6029    /// after boot still hashes once. Content changes re-key the memo and
6030    /// re-verify: the key carries the stamp digest plus the document's cheap
6031    /// shape (message count, metadata entry count, content timestamps), and
6032    /// every content mutation seam advances at least one of those. Write,
6033    /// adoption, and convergence seams must keep calling
6034    /// [`Session::try_checkpoint_state`]: a cached hit is memoized trust,
6035    /// not a fresh proof of current bytes.
6036    pub fn try_checkpoint_state_cached(
6037        &self,
6038    ) -> Result<crate::checkpoint::SessionCheckpointState, crate::checkpoint::SessionCheckpointError>
6039    {
6040        let stamp =
6041            match crate::checkpoint::session_checkpoint_metadata_state(&self.id, &self.metadata)? {
6042                crate::checkpoint::SessionCheckpointMetadataState::Stamped(stamp) => stamp,
6043                crate::checkpoint::SessionCheckpointMetadataState::LegacyUnverified {
6044                    legacy_runtime_checkpoint,
6045                } => {
6046                    return Ok(
6047                        crate::checkpoint::SessionCheckpointState::LegacyUnverified {
6048                            legacy_runtime_checkpoint,
6049                        },
6050                    );
6051                }
6052            };
6053        if crate::checkpoint::checkpoint_stamp_verification_is_cached(self, stamp.digest()) {
6054            return Ok(crate::checkpoint::SessionCheckpointState::Verified(stamp));
6055        }
6056        let actual = crate::checkpoint::session_checkpoint_digest(self)?;
6057        if stamp.digest() != &actual {
6058            return Err(crate::checkpoint::SessionCheckpointError::DigestMismatch {
6059                expected: stamp.digest().clone(),
6060                actual,
6061            });
6062        }
6063        crate::checkpoint::record_checkpoint_stamp_verification(self, &actual);
6064        Ok(crate::checkpoint::SessionCheckpointState::Verified(stamp))
6065    }
6066
6067    /// Install a prevalidated semantic checkpoint stamp on this exact
6068    /// document without changing its content timestamps.
6069    ///
6070    /// This is a mechanical serialization seam, not target-store write
6071    /// authority. A persistence implementation must still atomically validate
6072    /// its own observation and fencing preconditions before committing the
6073    /// resulting bytes.
6074    pub fn install_checkpoint_stamp(
6075        &mut self,
6076        stamp: crate::checkpoint::SessionCheckpointStamp,
6077    ) -> Result<(), crate::checkpoint::SessionCheckpointError> {
6078        stamp.validate_for_session(&self.id)?;
6079        let actual = crate::checkpoint::session_checkpoint_digest(self)?;
6080        if stamp.digest() != &actual {
6081            return Err(crate::checkpoint::SessionCheckpointError::DigestMismatch {
6082                expected: stamp.digest().clone(),
6083                actual,
6084            });
6085        }
6086        let value = serde_json::to_value(&stamp)?;
6087        self.metadata
6088            .remove(SESSION_RUNTIME_CHECKPOINT_PROVENANCE_KEY);
6089        self.metadata
6090            .insert(SESSION_CHECKPOINT_STAMP_KEY.to_string(), value);
6091        // Recorded after the stamp insertion so the memoized document shape
6092        // matches the persisted (and later reloaded) document exactly.
6093        crate::checkpoint::record_checkpoint_stamp_verification(self, &actual);
6094        Ok(())
6095    }
6096
6097    /// Fail-closed typed read of intra-turn checkpoint provenance.
6098    pub fn try_has_runtime_checkpoint_provenance(
6099        &self,
6100    ) -> Result<bool, crate::checkpoint::SessionCheckpointError> {
6101        match self.try_checkpoint_state()? {
6102            crate::checkpoint::SessionCheckpointState::Verified(stamp) => Ok(matches!(
6103                stamp.provenance(),
6104                crate::checkpoint::SessionCheckpointProvenance::IntraTurnCheckpoint
6105            )),
6106            crate::checkpoint::SessionCheckpointState::LegacyUnverified { .. } => {
6107                Err(crate::checkpoint::SessionCheckpointError::LegacyCheckpointUnverified)
6108            }
6109        }
6110    }
6111
6112    /// Set the legacy compatibility marker on an untyped projection.
6113    #[deprecated(
6114        note = "legacy compatibility only; typed writers must install an exact checkpoint stamp"
6115    )]
6116    pub fn set_runtime_checkpoint_provenance(
6117        &mut self,
6118    ) -> Result<(), crate::checkpoint::SessionCheckpointError> {
6119        if matches!(
6120            self.try_checkpoint_state()?,
6121            crate::checkpoint::SessionCheckpointState::Verified(_)
6122        ) {
6123            return Err(
6124                crate::checkpoint::SessionCheckpointError::LegacyProvenanceMutationOnTypedCheckpoint,
6125            );
6126        }
6127        self.set_metadata_unchecked(
6128            SESSION_RUNTIME_CHECKPOINT_PROVENANCE_KEY,
6129            serde_json::Value::Bool(true),
6130        );
6131        Ok(())
6132    }
6133
6134    /// Clear the legacy compatibility marker on an untyped projection.
6135    #[deprecated(
6136        note = "legacy compatibility only; typed writers must install an exact run-boundary successor"
6137    )]
6138    pub fn clear_runtime_checkpoint_provenance(
6139        &mut self,
6140    ) -> Result<(), crate::checkpoint::SessionCheckpointError> {
6141        if matches!(
6142            self.try_checkpoint_state()?,
6143            crate::checkpoint::SessionCheckpointState::Verified(_)
6144        ) {
6145            return Err(
6146                crate::checkpoint::SessionCheckpointError::LegacyProvenanceMutationOnTypedCheckpoint,
6147            );
6148        }
6149        self.remove_metadata_unchecked(SESSION_RUNTIME_CHECKPOINT_PROVENANCE_KEY);
6150        Ok(())
6151    }
6152
6153    /// Return the retained immutable body for a transcript revision.
6154    pub fn transcript_revision_body(
6155        &self,
6156        revision: &str,
6157    ) -> Result<Option<TranscriptRevisionBody>, serde_json::Error> {
6158        Ok(self.transcript_history_state()?.and_then(|state| {
6159            state
6160                .revisions
6161                .into_iter()
6162                .find(|body| body.revision == revision)
6163        }))
6164    }
6165
6166    /// Return the ordered messages for a retained transcript revision.
6167    pub fn transcript_revision_messages(
6168        &self,
6169        revision: &str,
6170    ) -> Result<Option<Vec<Message>>, serde_json::Error> {
6171        Ok(self
6172            .transcript_revision_body(revision)?
6173            .map(|body| body.messages))
6174    }
6175
6176    /// Materialize this session projection from a typed transcript history graph.
6177    pub fn apply_transcript_history_state(
6178        &mut self,
6179        mut state: TranscriptHistoryState,
6180    ) -> Result<(), TranscriptEditError> {
6181        state.compact_mechanical_revision_bodies()?;
6182        let head_body = state
6183            .revisions
6184            .iter()
6185            .find(|body| body.revision == state.head)
6186            .ok_or_else(|| {
6187                TranscriptEditError::HistoryStateMalformed(format!(
6188                    "missing transcript head body {}",
6189                    state.head
6190                ))
6191            })?
6192            .clone();
6193        let realtime_state =
6194            self.reconciled_realtime_transcript_metadata_after_rewrite(&head_body.messages)?;
6195        let value = serde_json::to_value(&state)
6196            .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
6197        self.set_validated_transcript_history_metadata(value);
6198        if let Some(value) = realtime_state {
6199            self.set_metadata_unchecked(SESSION_REALTIME_TRANSCRIPT_STATE_KEY, value);
6200        }
6201        let mut updated_at = head_body.created_at;
6202        for commit in &state.commits {
6203            if commit.committed_at > updated_at {
6204                updated_at = commit.committed_at;
6205            }
6206        }
6207        self.messages = Arc::new(head_body.messages);
6208        self.updated_at = updated_at;
6209        Ok(())
6210    }
6211
6212    /// Current transcript head revision. Rows written before transcript
6213    /// revisions derive their implicit head from the current message snapshot.
6214    pub fn transcript_revision(&self) -> Result<String, serde_json::Error> {
6215        if let Some(state) = self.transcript_history_state()? {
6216            Ok(state.head)
6217        } else {
6218            transcript_messages_digest(self.messages())
6219        }
6220    }
6221
6222    /// Monotonic durable generation for same-session transcript rewrites.
6223    /// Ordinary message appends advance the content revision but do not change
6224    /// this value, allowing live config refresh after normal turns while still
6225    /// forcing reopen after a rewrite.
6226    pub fn transcript_rewrite_generation(&self) -> Result<u64, serde_json::Error> {
6227        Ok(self.transcript_history_state()?.map_or(0, |state| {
6228            u64::try_from(state.commits.len()).unwrap_or(u64::MAX)
6229        }))
6230    }
6231
6232    /// Commit a same-session transcript rewrite and advance the transcript head.
6233    pub fn commit_transcript_rewrite(
6234        &mut self,
6235        selection: TranscriptRewriteSelection,
6236        replacement: Vec<Message>,
6237        reason: TranscriptRewriteReason,
6238        actor: Option<String>,
6239        expected_parent_revision: Option<String>,
6240    ) -> Result<TranscriptRewriteCommit, TranscriptEditError> {
6241        let selection = selection.into_current_edit_semantic();
6242        if selection.semantic() == TranscriptRewriteSemantic::Compaction {
6243            return Err(TranscriptEditError::InvalidTranscriptShape(
6244                "typed compaction rewrites require a core-validated compaction witness".to_string(),
6245            ));
6246        }
6247        self.commit_transcript_rewrite_authorized(
6248            selection,
6249            replacement,
6250            reason,
6251            actor,
6252            expected_parent_revision,
6253        )
6254    }
6255
6256    fn commit_transcript_rewrite_authorized(
6257        &mut self,
6258        selection: TranscriptRewriteSelection,
6259        replacement: Vec<Message>,
6260        reason: TranscriptRewriteReason,
6261        actor: Option<String>,
6262        expected_parent_revision: Option<String>,
6263    ) -> Result<TranscriptRewriteCommit, TranscriptEditError> {
6264        let parent_revision = self
6265            .transcript_revision()
6266            .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
6267        if let Some(expected) = expected_parent_revision
6268            && expected != parent_revision
6269        {
6270            return Err(TranscriptEditError::RevisionConflict {
6271                expected,
6272                actual: parent_revision,
6273            });
6274        }
6275
6276        let (start, end) = selection.bounds();
6277        let message_count = self.messages.len();
6278        if start > end || end > message_count {
6279            return Err(TranscriptEditError::InvalidRewriteRange {
6280                start,
6281                end,
6282                message_count,
6283            });
6284        }
6285
6286        let replacement_len = replacement.len();
6287        let mut rewritten = Vec::with_capacity(
6288            start
6289                .saturating_add(replacement_len)
6290                .saturating_add(message_count.saturating_sub(end)),
6291        );
6292        rewritten.extend_from_slice(&self.messages[..start]);
6293        rewritten.extend(replacement);
6294        rewritten.extend_from_slice(&self.messages[end..]);
6295        validate_transcript_tool_result_shape(&rewritten)?;
6296
6297        let original_span_digest = transcript_messages_digest(&self.messages[start..end])
6298            .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
6299        let replacement_digest =
6300            transcript_messages_digest(&rewritten[start..start + replacement_len])
6301                .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
6302        let revision = transcript_messages_digest(&rewritten)
6303            .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
6304        if revision == parent_revision {
6305            return Err(TranscriptEditError::NoOpRewrite { revision });
6306        }
6307        let realtime_state =
6308            self.reconciled_realtime_transcript_metadata_after_rewrite(&rewritten)?;
6309
6310        let commit = TranscriptRewriteCommit {
6311            parent_revision,
6312            revision: revision.clone(),
6313            selection,
6314            original_span_digest,
6315            replacement_digest,
6316            messages_before: message_count,
6317            messages_after: rewritten.len(),
6318            reason,
6319            actor,
6320            committed_at: SystemTime::now(),
6321        };
6322
6323        let mut state = self
6324            .transcript_history_state()
6325            .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?
6326            .unwrap_or_else(|| TranscriptHistoryState {
6327                head: commit.parent_revision.clone(),
6328                commits: Vec::new(),
6329                revisions: Vec::new(),
6330                digest_format: TRANSCRIPT_DIGEST_FORMAT_CURRENT,
6331            });
6332        if !state
6333            .revisions
6334            .iter()
6335            .any(|body| body.revision == commit.parent_revision)
6336        {
6337            state.revisions.push(TranscriptRevisionBody {
6338                revision: commit.parent_revision.clone(),
6339                parent_revision: None,
6340                messages: self.messages().to_vec(),
6341                created_at: self.updated_at,
6342            });
6343        }
6344        if !state
6345            .revisions
6346            .iter()
6347            .any(|body| body.revision == commit.revision)
6348        {
6349            state.revisions.push(TranscriptRevisionBody {
6350                revision: commit.revision.clone(),
6351                parent_revision: Some(commit.parent_revision.clone()),
6352                messages: rewritten.clone(),
6353                created_at: commit.committed_at,
6354            });
6355        }
6356        state.head = revision;
6357        state.commits.push(commit.clone());
6358        state.compact_mechanical_revision_bodies()?;
6359        let value = serde_json::to_value(state)
6360            .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
6361        self.set_validated_transcript_history_metadata(value);
6362        if let Some(value) = realtime_state {
6363            self.set_metadata_unchecked(SESSION_REALTIME_TRANSCRIPT_STATE_KEY, value);
6364        }
6365
6366        self.messages = Arc::new(rewritten);
6367        self.updated_at = SystemTime::now();
6368        Ok(commit)
6369    }
6370
6371    fn transcript_history_state_after_message_mutation(
6372        &self,
6373        messages: &[Message],
6374        created_at: SystemTime,
6375    ) -> Result<Option<TranscriptHistoryState>, TranscriptEditError> {
6376        if !self
6377            .metadata
6378            .contains_key(SESSION_TRANSCRIPT_HISTORY_STATE_KEY)
6379        {
6380            return Ok(None);
6381        }
6382        let mut state = self
6383            .transcript_history_state()
6384            .map_err(|error| TranscriptEditError::HistoryStateMalformed(error.to_string()))?
6385            .ok_or_else(|| {
6386                TranscriptEditError::HistoryStateMalformed(
6387                    "transcript history metadata key decoded without state".to_string(),
6388                )
6389            })?;
6390        state.compact_mechanical_revision_bodies()?;
6391        let head = transcript_messages_digest(messages)
6392            .map_err(|error| TranscriptEditError::HistoryStateMalformed(error.to_string()))?;
6393        if !state.revisions.iter().any(|body| body.revision == head) {
6394            state.revisions.push(TranscriptRevisionBody {
6395                revision: head.clone(),
6396                parent_revision: state.commits.last().map(|commit| commit.revision.clone()),
6397                messages: messages.to_vec(),
6398                created_at,
6399            });
6400        }
6401        state.head = head;
6402        state.compact_mechanical_revision_bodies()?;
6403        Ok(Some(state))
6404    }
6405
6406    fn refresh_transcript_head_after_message_mutation(&mut self) {
6407        match self
6408            .transcript_history_state_after_message_mutation(self.messages(), SystemTime::now())
6409        {
6410            Ok(Some(state)) => match serde_json::to_value(state) {
6411                Ok(value) => {
6412                    self.set_validated_transcript_history_metadata(value);
6413                }
6414                Err(error) => {
6415                    tracing::warn!(
6416                        session_id = %self.id,
6417                        error = %error,
6418                        "failed to serialize transcript history state after message mutation"
6419                    );
6420                }
6421            },
6422            Ok(None) => {}
6423            Err(error) => {
6424                tracing::warn!(
6425                    session_id = %self.id,
6426                    error = %error,
6427                    "transcript history state failed validation after message mutation"
6428                );
6429            }
6430        }
6431    }
6432
6433    /// Store typed mob operator authority inside canonical build-state metadata.
6434    ///
6435    /// Store the mob operator authority projection inside build-state metadata.
6436    ///
6437    /// The projection is durable compatibility data only: serialization drops
6438    /// the generated authority seal, so behavior must re-enter generated
6439    /// authority before using restored facts.
6440    pub fn set_mob_tool_authority_context(
6441        &mut self,
6442        authority_context: Option<MobToolAuthorityContext>,
6443    ) -> Result<(), serde_json::Error> {
6444        if let Some(authority_context) = authority_context.as_ref()
6445            && !authority_context.is_generated_authority_context()
6446        {
6447            return Err(<serde_json::Error as serde::de::Error>::custom(
6448                "mob authority context was not minted by generated authority",
6449            ));
6450        }
6451        let mut build_state = self.build_state().ok_or_else(|| {
6452            <serde_json::Error as serde::de::Error>::custom(format!(
6453                "session {} is missing session build state",
6454                self.id
6455            ))
6456        })?;
6457        build_state.mob_tool_authority_context = authority_context;
6458        self.set_build_state(build_state)
6459    }
6460
6461    /// Load the in-memory generated mob operator authority, if still present.
6462    ///
6463    /// Stored/deserialized contexts deliberately fail this check and are not
6464    /// returned as behavior authority.
6465    pub fn mob_tool_authority_context(&self) -> Option<MobToolAuthorityContext> {
6466        self.build_state()
6467            .and_then(|state| state.mob_tool_authority_context)
6468            .filter(MobToolAuthorityContext::is_generated_authority_context)
6469    }
6470
6471    /// Fork the session at a specific message index
6472    ///
6473    /// Creates a new session with a subset of messages. The messages are copied
6474    /// (not shared) since the new session has a different prefix.
6475    pub fn fork_at(&self, index: usize) -> Self {
6476        let now = SystemTime::now();
6477        let truncated = self.messages[..index.min(self.messages.len())].to_vec();
6478        Self {
6479            version: session_version(),
6480            id: SessionId::new(),
6481            messages: Arc::new(truncated),
6482            created_at: now,
6483            updated_at: now,
6484            metadata: self.fork_metadata_projection(),
6485            transcript_history_metadata_validation: TranscriptHistoryMetadataValidation::Validated,
6486            usage: self.usage.clone(),
6487        }
6488    }
6489
6490    /// Fork the session and replace the message at `message_index`.
6491    ///
6492    /// The returned session contains the original prefix before
6493    /// `message_index`, followed by the typed replacement. Later source
6494    /// messages are intentionally omitted so follow-up work continues from the
6495    /// edited branch rather than replaying stale descendants.
6496    pub fn fork_replacing(
6497        &self,
6498        message_index: usize,
6499        replacement: TranscriptReplacement,
6500    ) -> Result<Self, TranscriptEditError> {
6501        let Some(original) = self.messages.get(message_index) else {
6502            return Err(TranscriptEditError::MessageIndexOutOfBounds {
6503                message_index,
6504                message_count: self.messages.len(),
6505            });
6506        };
6507
6508        let replacement_message = match replacement {
6509            TranscriptReplacement::Message { message } => message,
6510            TranscriptReplacement::UserContentBlock { block_index, block } => {
6511                let Message::User(user) = original else {
6512                    return Err(TranscriptEditError::MessageRoleMismatch {
6513                        message_index,
6514                        expected: "user",
6515                        actual: message_role_name(original),
6516                    });
6517                };
6518                if block_index >= user.content.len() {
6519                    return Err(TranscriptEditError::BlockIndexOutOfBounds {
6520                        block_kind: "user content block",
6521                        block_index,
6522                        block_count: user.content.len(),
6523                    });
6524                }
6525                let mut edited = user.clone();
6526                edited.content[block_index] = block;
6527                Message::User(edited)
6528            }
6529            TranscriptReplacement::AssistantBlock { block_index, block } => {
6530                let Message::BlockAssistant(assistant) = original else {
6531                    return Err(TranscriptEditError::MessageRoleMismatch {
6532                        message_index,
6533                        expected: "block_assistant",
6534                        actual: message_role_name(original),
6535                    });
6536                };
6537                if block_index >= assistant.blocks.len() {
6538                    return Err(TranscriptEditError::BlockIndexOutOfBounds {
6539                        block_kind: "assistant block",
6540                        block_index,
6541                        block_count: assistant.blocks.len(),
6542                    });
6543                }
6544                let mut edited = assistant.clone();
6545                edited.blocks[block_index] = block;
6546                Message::BlockAssistant(edited)
6547            }
6548            TranscriptReplacement::ToolResultContentBlock {
6549                result_index,
6550                block_index,
6551                block,
6552            } => {
6553                let Message::ToolResults {
6554                    results,
6555                    created_at,
6556                } = original
6557                else {
6558                    return Err(TranscriptEditError::MessageRoleMismatch {
6559                        message_index,
6560                        expected: "tool_results",
6561                        actual: message_role_name(original),
6562                    });
6563                };
6564                let Some(result) = results.get(result_index) else {
6565                    return Err(TranscriptEditError::BlockIndexOutOfBounds {
6566                        block_kind: "tool result",
6567                        block_index: result_index,
6568                        block_count: results.len(),
6569                    });
6570                };
6571                if block_index >= result.content.len() {
6572                    return Err(TranscriptEditError::BlockIndexOutOfBounds {
6573                        block_kind: "tool result content block",
6574                        block_index,
6575                        block_count: result.content.len(),
6576                    });
6577                }
6578                let mut edited_results = results.clone();
6579                edited_results[result_index].content[block_index] = block;
6580                Message::ToolResults {
6581                    results: edited_results,
6582                    created_at: *created_at,
6583                }
6584            }
6585        };
6586
6587        let mut forked = self.fork_at(message_index);
6588        forked.push(replacement_message);
6589        Ok(forked)
6590    }
6591
6592    /// Fork the entire session (full history)
6593    ///
6594    /// This is O(1) - the new session shares the message buffer via Arc.
6595    /// Copy-on-write occurs when either session mutates its messages.
6596    pub fn fork(&self) -> Self {
6597        let now = SystemTime::now();
6598        Self {
6599            version: session_version(),
6600            id: SessionId::new(),
6601            messages: Arc::clone(&self.messages),
6602            created_at: now,
6603            updated_at: now,
6604            metadata: self.fork_metadata_projection(),
6605            transcript_history_metadata_validation: TranscriptHistoryMetadataValidation::Validated,
6606            usage: self.usage.clone(),
6607        }
6608    }
6609}
6610
6611impl Default for Session {
6612    fn default() -> Self {
6613        Self::new()
6614    }
6615}
6616
6617/// Summary metadata for listing sessions
6618#[derive(Debug, Clone, Serialize, Deserialize)]
6619#[serde(rename_all = "snake_case")]
6620pub struct SessionMeta {
6621    pub id: SessionId,
6622    pub created_at: SystemTime,
6623    pub updated_at: SystemTime,
6624    pub message_count: usize,
6625    pub total_tokens: u64,
6626    #[serde(default)]
6627    pub metadata: serde_json::Map<String, serde_json::Value>,
6628}
6629
6630/// Metadata required to reliably resume a session across interfaces.
6631#[derive(Debug, Clone, Serialize, Deserialize)]
6632#[serde(rename_all = "snake_case")]
6633pub struct SessionMetadata {
6634    /// Per-entity schema version byte.
6635    ///
6636    /// Mandatory on read: a persisted row missing the byte (or carrying a
6637    /// non-current value) fails closed through the generated persistence
6638    /// version authority instead of silently defaulting. Stamped with the
6639    /// current `SESSION_METADATA_SCHEMA_VERSION` on every persist.
6640    pub schema_version: u32,
6641    pub model: String,
6642    pub max_tokens: u32,
6643    #[serde(default = "crate::config::default_structured_output_retries")]
6644    pub structured_output_retries: u32,
6645    pub provider: Provider,
6646    #[serde(default, skip_serializing_if = "Option::is_none")]
6647    pub self_hosted_server_id: Option<String>,
6648    /// Typed provider parameter overrides persisted with the session.
6649    /// Parsed fail-closed at the serde boundary — no JSON bag survives here.
6650    #[serde(default, skip_serializing_if = "Option::is_none")]
6651    pub provider_params: Option<crate::lifecycle::run_primitive::ProviderParamsOverride>,
6652    pub tooling: SessionTooling,
6653    #[serde(default)]
6654    pub keep_alive: bool,
6655    pub comms_name: Option<String>,
6656    /// Friendly metadata for peer discovery (populated when comms is enabled).
6657    #[serde(default, skip_serializing_if = "Option::is_none")]
6658    pub peer_meta: Option<PeerMeta>,
6659    /// Realm identity for cross-surface storage sharing/isolation.
6660    ///
6661    /// Typed [`crate::RealmId`]; the realm slug is validated at the serde
6662    /// boundary. `RealmId` serializes transparently as its slug string, so the
6663    /// durable JSON shape is identical to the prior `Option<String>` form.
6664    #[serde(default, skip_serializing_if = "Option::is_none")]
6665    pub realm_id: Option<crate::RealmId>,
6666    /// Optional process/agent instance identifier within a realm.
6667    #[serde(default, skip_serializing_if = "Option::is_none")]
6668    pub instance_id: Option<String>,
6669    /// Backend pinned by the realm manifest (e.g. "sqlite", "jsonl", "memory").
6670    #[serde(default, skip_serializing_if = "Option::is_none")]
6671    pub backend: Option<String>,
6672    /// Config generation used when this session was created/resumed.
6673    #[serde(default, skip_serializing_if = "Option::is_none")]
6674    pub config_generation: Option<u64>,
6675    /// Realm-scoped auth binding (Phase 3 provider-auth redesign).
6676    ///
6677    /// Persisted intent for the auth/backend binding this session resolved
6678    /// through. On resume, `apply_resumed_session_metadata` writes this
6679    /// back into `AgentBuildConfig.auth_binding` so the same realm
6680    /// binding is re-resolved. Never carries secret material — leases
6681    /// are rebuilt from the active realm connection set at resume time.
6682    /// Older persisted sessions without the field deserialize as `None`
6683    /// (backward compatible via `#[serde(default)]`).
6684    #[serde(default, skip_serializing_if = "Option::is_none")]
6685    pub auth_binding: Option<crate::AuthBindingRef>,
6686    /// Typed durable identity of a mob member, when this session was created by
6687    /// the mob runtime.
6688    ///
6689    /// This is the canonical owner of the `(mob_id, role, member)` identity
6690    /// fact used by mob ownership routing on resume/restart. It replaces the
6691    /// prior recovery-by-string-split of `comms_name` plus a realm
6692    /// format-string check. `comms_name`/`realm_id`/`peer_meta` remain as the
6693    /// transport routing name and discovery metadata.
6694    ///
6695    /// Older persisted sessions without the field deserialize as `None`
6696    /// (backward compatible via `#[serde(default)]`), so old rows read as
6697    /// "no typed binding" rather than failing.
6698    #[serde(default, skip_serializing_if = "Option::is_none")]
6699    pub mob_member_binding: Option<crate::MobMemberBinding>,
6700}
6701
6702/// Canonical durable LLM identity for a session.
6703#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
6704#[serde(rename_all = "snake_case")]
6705pub struct SessionLlmIdentity {
6706    pub model: String,
6707    pub provider: Provider,
6708    #[serde(default, skip_serializing_if = "Option::is_none")]
6709    pub self_hosted_server_id: Option<String>,
6710    /// Typed provider parameter overrides carried on the durable identity.
6711    #[serde(default, skip_serializing_if = "Option::is_none")]
6712    pub provider_params: Option<crate::lifecycle::run_primitive::ProviderParamsOverride>,
6713    /// Realm-scoped auth binding this session resolves credentials
6714    /// through. Carried on the identity so mid-session hot-swaps
6715    /// (`apply_live_session_llm_identity`) re-resolve against the
6716    /// same realm the session was created with — preventing
6717    /// cross-realm credential bleed in multi-tenant setups. Dogma
6718    /// §12 (dynamic policy follows dynamic identity): on swap the
6719    /// factory re-enters `ProviderRuntimeRegistry::resolve` against
6720    /// this binding, not a new synthesized env-default realm.
6721    ///
6722    /// Projection (dogma §1/§13): canonical owner is
6723    /// `SessionMetadata.auth_binding`; this field is the
6724    /// read/write projection used by hot-swap.
6725    #[serde(default, skip_serializing_if = "Option::is_none")]
6726    pub auth_binding: Option<crate::AuthBindingRef>,
6727}
6728
6729/// Typed per-turn override request for a session LLM identity.
6730///
6731/// `provider_params` and `auth_binding` carry the canonical Inherit/Set/Clear
6732/// tri-state via [`TurnMetadataOverride`]: `None` preserves the durable value,
6733/// `Some(Set)` overrides it for this turn, and `Some(Clear)` removes it. The
6734/// illegal "set and clear" fourth state is structurally unrepresentable, so the
6735/// resolver needs no reject branch for it.
6736pub struct SessionLlmIdentityOverride<'a> {
6737    pub model: Option<&'a str>,
6738    pub provider: Option<Provider>,
6739    /// Exact configured route for a self-hosted model. This cannot be inferred
6740    /// from provider/model when multiple local servers expose the same model
6741    /// identifier.
6742    pub self_hosted_server_id: Option<&'a str>,
6743    pub provider_params:
6744        Option<TurnMetadataOverride<&'a crate::lifecycle::run_primitive::ProviderParamsOverride>>,
6745    pub auth_binding: Option<TurnMetadataOverride<&'a crate::AuthBindingRef>>,
6746}
6747
6748#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
6749pub enum SessionLlmIdentityOverrideError {
6750    #[error("provider override requires model on an existing session")]
6751    ProviderRequiresModel,
6752    #[error("{0}")]
6753    ProviderModelMismatch(String),
6754    #[error("self-hosted provider requires a registered model alias; '{model}' is not configured")]
6755    MissingSelfHostedAlias { model: String },
6756    #[error("self_hosted_server_id requires provider 'self_hosted'")]
6757    SelfHostedServerRequiresSelfHostedProvider,
6758    #[error("self_hosted_server_id must not be empty")]
6759    EmptySelfHostedServerId,
6760    #[error(
6761        "self-hosted model '{model}' is configured on server '{configured}', not requested server '{requested}'"
6762    )]
6763    SelfHostedServerMismatch {
6764        model: String,
6765        requested: String,
6766        configured: String,
6767    },
6768}
6769
6770/// Resolve a turn-time model/provider/auth override against the current
6771/// durable session identity.
6772///
6773/// The model registry is the authority for catalog ownership. A model-only
6774/// override follows catalog ownership when the target model is registered;
6775/// uncatalogued models keep the current provider so custom aliases remain
6776/// possible.
6777pub fn resolve_session_llm_identity_override(
6778    current: &SessionLlmIdentity,
6779    registry: &crate::ModelRegistry,
6780    overrides: SessionLlmIdentityOverride<'_>,
6781) -> Result<SessionLlmIdentity, SessionLlmIdentityOverrideError> {
6782    if overrides.provider.is_some() && overrides.model.is_none() {
6783        return Err(SessionLlmIdentityOverrideError::ProviderRequiresModel);
6784    }
6785
6786    let model = overrides
6787        .model
6788        .map(str::to_string)
6789        .unwrap_or_else(|| current.model.clone());
6790    let provider = if let Some(provider) = overrides.provider {
6791        provider
6792    } else if overrides.model.is_some() {
6793        registry
6794            .entry(&model)
6795            .map_or(current.provider, |entry| entry.provider)
6796    } else {
6797        current.provider
6798    };
6799
6800    if (overrides.model.is_some() || overrides.provider.is_some())
6801        && let Some(reason) = registry.provider_override_mismatch_reason(provider, &model)
6802    {
6803        return Err(SessionLlmIdentityOverrideError::ProviderModelMismatch(
6804            reason,
6805        ));
6806    }
6807
6808    let provider_params = match overrides.provider_params {
6809        Some(TurnMetadataOverride::Clear) => None,
6810        Some(TurnMetadataOverride::Set(value)) => Some(value.clone()),
6811        None => current.provider_params.clone(),
6812    };
6813    if overrides.self_hosted_server_id.is_some() && provider != Provider::SelfHosted {
6814        return Err(SessionLlmIdentityOverrideError::SelfHostedServerRequiresSelfHostedProvider);
6815    }
6816    let self_hosted_server_id = if provider == Provider::SelfHosted {
6817        if let Some(requested_server_id) = overrides.self_hosted_server_id {
6818            if requested_server_id.trim().is_empty() {
6819                return Err(SessionLlmIdentityOverrideError::EmptySelfHostedServerId);
6820            }
6821            let entry = registry
6822                .entry_for_provider(Provider::SelfHosted, &model)
6823                .ok_or_else(|| SessionLlmIdentityOverrideError::MissingSelfHostedAlias {
6824                    model: model.clone(),
6825                })?;
6826            let configured_server_id = entry
6827                .self_hosted
6828                .as_ref()
6829                .map(|server| server.server_id.as_str())
6830                .ok_or_else(|| SessionLlmIdentityOverrideError::MissingSelfHostedAlias {
6831                    model: model.clone(),
6832                })?;
6833            if configured_server_id != requested_server_id {
6834                return Err(SessionLlmIdentityOverrideError::SelfHostedServerMismatch {
6835                    model,
6836                    requested: requested_server_id.to_string(),
6837                    configured: configured_server_id.to_string(),
6838                });
6839            }
6840            Some(requested_server_id.to_string())
6841        } else if overrides.model.is_none() {
6842            current.self_hosted_server_id.clone().or_else(|| {
6843                registry
6844                    .entry_for_provider(Provider::SelfHosted, &model)
6845                    .and_then(|entry| entry.self_hosted.as_ref())
6846                    .map(|server| server.server_id.clone())
6847            })
6848        } else {
6849            let entry = registry
6850                .entry_for_provider(Provider::SelfHosted, &model)
6851                .ok_or_else(|| SessionLlmIdentityOverrideError::MissingSelfHostedAlias {
6852                    model: model.clone(),
6853                })?;
6854            entry
6855                .self_hosted
6856                .as_ref()
6857                .map(|server| server.server_id.clone())
6858        }
6859    } else {
6860        None
6861    };
6862
6863    let auth_binding = match overrides.auth_binding {
6864        Some(TurnMetadataOverride::Clear) => None,
6865        Some(TurnMetadataOverride::Set(value)) => Some(value.clone()),
6866        // Inherit: a provider change without an explicit binding drops the
6867        // stale binding; otherwise the durable binding is retained.
6868        None if provider != current.provider => None,
6869        None => current.auth_binding.clone(),
6870    };
6871
6872    Ok(SessionLlmIdentity {
6873        model,
6874        provider,
6875        self_hosted_server_id,
6876        provider_params,
6877        auth_binding,
6878    })
6879}
6880
6881/// Live request policy paired with a session LLM identity hot-swap.
6882///
6883/// `SessionLlmIdentity` is the durable semantic identity. This projection is
6884/// the per-turn request policy the live agent must use for the next LLM call,
6885/// including provider params and provider-native tool defaults resolved for
6886/// the same target model/provider.
6887#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
6888#[serde(rename_all = "snake_case")]
6889pub struct SessionLlmRequestPolicy {
6890    pub model: String,
6891    /// Typed explicit provider parameter overrides for the next LLM call.
6892    #[serde(default, skip_serializing_if = "Option::is_none")]
6893    pub provider_params: Option<crate::lifecycle::run_primitive::ProviderParamsOverride>,
6894    /// Typed provider-native tool defaults resolved for the swapped target.
6895    #[serde(default, skip_serializing_if = "Option::is_none")]
6896    pub provider_tool_defaults: Option<crate::lifecycle::run_primitive::ProviderTag>,
6897}
6898
6899impl SessionMetadata {
6900    /// Return the current durable LLM identity for this session.
6901    pub fn llm_identity(&self) -> SessionLlmIdentity {
6902        SessionLlmIdentity {
6903            model: self.model.clone(),
6904            provider: self.provider,
6905            self_hosted_server_id: self.self_hosted_server_id.clone(),
6906            provider_params: self.provider_params.clone(),
6907            auth_binding: self.auth_binding.clone(),
6908        }
6909    }
6910
6911    /// Overwrite the durable LLM identity while preserving unrelated session metadata.
6912    pub fn apply_llm_identity(&mut self, identity: &SessionLlmIdentity) {
6913        self.model = identity.model.clone();
6914        self.provider = identity.provider;
6915        self.self_hosted_server_id = identity.self_hosted_server_id.clone();
6916        self.provider_params = identity.provider_params.clone();
6917        self.auth_binding = identity.auth_binding.clone();
6918    }
6919}
6920
6921/// Key used to store SessionMetadata in Session metadata map.
6922pub const SESSION_METADATA_KEY: &str = "session_metadata";
6923
6924/// Caller intent for a tool category.
6925///
6926/// Distinguishes "no opinion / didn't exist" (`Inherit`) from explicit
6927/// `Enable` / `Disable` so that resumed sessions don't freeze tool
6928/// availability at the capabilities of the Meerkat version that created them.
6929///
6930/// **Dogma §10:** Inherit, disable, and set are different facts.
6931#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
6932#[serde(rename_all = "snake_case")]
6933pub enum ToolCategoryOverride {
6934    /// No explicit intent — inherit runtime/factory default.
6935    #[default]
6936    Inherit,
6937    /// Explicitly enabled by caller.
6938    Enable,
6939    /// Explicitly disabled by caller.
6940    Disable,
6941}
6942
6943impl ToolCategoryOverride {
6944    /// Resolve this override against a runtime default.
6945    ///
6946    /// - `Enable` → `true`
6947    /// - `Disable` → `false`
6948    /// - `Inherit` → `runtime_default`
6949    #[must_use]
6950    pub fn resolve(self, runtime_default: bool) -> bool {
6951        match self {
6952            Self::Enable => true,
6953            Self::Disable => false,
6954            Self::Inherit => runtime_default,
6955        }
6956    }
6957
6958    /// Convert to `Option<bool>` for feeding `AgentBuildConfig` override fields.
6959    ///
6960    /// - `Enable` → `Some(true)`
6961    /// - `Disable` → `Some(false)`
6962    /// - `Inherit` → `None` (factory default wins)
6963    #[must_use]
6964    pub fn to_override(self) -> Option<bool> {
6965        match self {
6966            Self::Enable => Some(true),
6967            Self::Disable => Some(false),
6968            Self::Inherit => None,
6969        }
6970    }
6971
6972    /// Construct from a resolved effective bool.
6973    ///
6974    /// **Warning:** this collapses `Inherit` into `Enable`/`Disable`. Prefer
6975    /// [`from_override`] when persisting session metadata so that `Inherit`
6976    /// survives across save/resume cycles. Only use `from_effective` in test
6977    /// helpers or when constructing metadata from external sources that only
6978    /// provide a resolved bool.
6979    #[must_use]
6980    pub fn from_effective(enabled: bool) -> Self {
6981        if enabled { Self::Enable } else { Self::Disable }
6982    }
6983
6984    /// Construct from an `Option<bool>` override field, preserving `Inherit`.
6985    ///
6986    /// - `Some(true)` → `Enable`
6987    /// - `Some(false)` → `Disable`
6988    /// - `None` → `Inherit` (factory default was used, no explicit intent)
6989    ///
6990    /// This is the inverse of [`to_override`] and should be used when persisting
6991    /// session tooling metadata so that `Inherit` survives across save/resume
6992    /// cycles.
6993    #[must_use]
6994    pub fn from_override(value: Option<bool>) -> Self {
6995        match value {
6996            Some(true) => Self::Enable,
6997            Some(false) => Self::Disable,
6998            None => Self::Inherit,
6999        }
7000    }
7001}
7002
7003/// Tooling intent captured at session creation time.
7004///
7005/// Fields use [`ToolCategoryOverride`] to distinguish "no opinion" from
7006/// explicit enable/disable (Dogma §10). On resume, `Inherit` falls through
7007/// to the factory's current runtime default, allowing new tool categories
7008/// to become available without re-creating the session.
7009#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
7010#[serde(rename_all = "snake_case")]
7011pub struct SessionTooling {
7012    #[serde(default)]
7013    pub builtins: ToolCategoryOverride,
7014    #[serde(default)]
7015    pub shell: ToolCategoryOverride,
7016    #[serde(default)]
7017    pub comms: ToolCategoryOverride,
7018    /// Mob (multi-agent orchestration) tools.
7019    #[serde(default)]
7020    pub mob: ToolCategoryOverride,
7021    /// Semantic memory.
7022    #[serde(default)]
7023    pub memory: ToolCategoryOverride,
7024    /// Scheduler tools.
7025    #[serde(default)]
7026    pub schedule: ToolCategoryOverride,
7027    /// WorkGraph durable work tools.
7028    #[serde(default)]
7029    pub workgraph: ToolCategoryOverride,
7030    /// Assistant image generation.
7031    #[serde(default)]
7032    pub image_generation: ToolCategoryOverride,
7033    /// Meerkat-owned fallback web search.
7034    #[serde(default)]
7035    pub web_search: ToolCategoryOverride,
7036    /// Effective call-level tool execution policy for this session's builds.
7037    ///
7038    /// Persisted RESOLVED (never `Inherit`): the factory fails the build
7039    /// closed on an unresolved `Inherit` before metadata is written, so this
7040    /// field only ever holds `AllowList`/`DenyList`. Absent means
7041    /// unrestricted. Spawn/fork resolution reads this field as the parent's
7042    /// effective policy when a child requests `Inherit` (transitive
7043    /// containment — a restricted parent cannot mint an unrestricted child
7044    /// by spawning).
7045    #[serde(default, skip_serializing_if = "Option::is_none")]
7046    pub tool_access_policy: Option<crate::ops::ToolAccessPolicy>,
7047    /// Active skills at session creation time (for deterministic resume).
7048    #[serde(default, skip_serializing_if = "Option::is_none")]
7049    pub active_skills: Option<Vec<crate::skills::SkillKey>>,
7050}
7051
7052impl From<&Session> for SessionMeta {
7053    fn from(session: &Session) -> Self {
7054        Self {
7055            id: session.id.clone(),
7056            created_at: session.created_at,
7057            updated_at: session.updated_at,
7058            message_count: session.messages.len(),
7059            total_tokens: session.total_tokens(),
7060            metadata: session.metadata.clone(),
7061        }
7062    }
7063}
7064
7065/// Decode the typed [`SESSION_METADATA_KEY`] fact from a session metadata map
7066/// through the generated restore authority.
7067///
7068/// Canonical single decoder: [`Session::try_session_metadata`] and every
7069/// metadata-only read seam ([`PersistedSessionMetadataView`]) delegate here so
7070/// the full-session and metadata-only decode paths can never drift.
7071///
7072/// Fail-closed: a present-but-corrupt value is an error, never "absent".
7073pub fn try_session_metadata_from_map(
7074    metadata: &serde_json::Map<String, serde_json::Value>,
7075) -> Result<Option<SessionMetadata>, serde_json::Error> {
7076    let Some(value) = metadata.get(SESSION_METADATA_KEY) else {
7077        return Ok(None);
7078    };
7079    let mut metadata = serde_json::from_value::<SessionMetadata>(value.clone())?;
7080    metadata.schema_version =
7081        session_persistence_version_authority::restore_session_metadata_schema_version(
7082            metadata.schema_version,
7083        )
7084        .map_err(<serde_json::Error as serde::de::Error>::custom)?;
7085    session_durable_config_authority::restore_session_metadata(metadata)
7086        .map(Some)
7087        .map_err(<serde_json::Error as serde::de::Error>::custom)
7088}
7089
7090/// Decode the typed [`SESSION_LIFECYCLE_TERMINAL_KEY`] fact from a session
7091/// metadata map.
7092///
7093/// Canonical single decoder: [`Session::try_lifecycle_terminal`] and every
7094/// metadata-only read seam delegate here. An absent key means no terminal
7095/// fact; a present-but-corrupt value fails closed.
7096pub fn try_lifecycle_terminal_from_map(
7097    metadata: &serde_json::Map<String, serde_json::Value>,
7098) -> Result<Option<SessionLifecycleTerminal>, serde_json::Error> {
7099    match metadata.get(SESSION_LIFECYCLE_TERMINAL_KEY) {
7100        Some(value) => serde_json::from_value(value.clone()).map(Some),
7101        None => Ok(None),
7102    }
7103}
7104
7105/// Typed metadata-only view of a persisted session row or snapshot.
7106///
7107/// The metadata read seam's currency (mobkit ask-24 clause 3): carries the
7108/// session identity plus the two typed session-authority metadata facts,
7109/// decoded fail-closed through the canonical map-level decoders. Consumers
7110/// that only need ownership/policy/lifecycle facts read this view instead of
7111/// materializing the full session document.
7112#[derive(Debug, Clone)]
7113pub struct PersistedSessionMetadataView {
7114    pub session_id: SessionId,
7115    pub session_metadata: Option<SessionMetadata>,
7116    pub lifecycle_terminal: Option<SessionLifecycleTerminal>,
7117}
7118
7119impl PersistedSessionMetadataView {
7120    /// Build the view from a persisted metadata map (e.g. a
7121    /// [`SessionMeta`] row projection).
7122    ///
7123    /// Fail-closed: corrupt values under either reserved key are an error,
7124    /// never treated as absent.
7125    pub fn try_from_metadata_map(
7126        session_id: SessionId,
7127        metadata: &serde_json::Map<String, serde_json::Value>,
7128    ) -> Result<Self, serde_json::Error> {
7129        Ok(Self {
7130            session_id,
7131            session_metadata: try_session_metadata_from_map(metadata)?,
7132            lifecycle_terminal: try_lifecycle_terminal_from_map(metadata)?,
7133        })
7134    }
7135
7136    /// Project the view from a fully materialized session document.
7137    pub fn try_from_session(session: &Session) -> Result<Self, serde_json::Error> {
7138        Ok(Self {
7139            session_id: session.id().clone(),
7140            session_metadata: session.try_session_metadata()?,
7141            lifecycle_terminal: session.try_lifecycle_terminal()?,
7142        })
7143    }
7144
7145    /// Typed durable mob member identity carried on the session metadata,
7146    /// if any.
7147    pub fn mob_member_binding(&self) -> Option<&crate::MobMemberBinding> {
7148        self.session_metadata.as_ref()?.mob_member_binding.as_ref()
7149    }
7150}
7151
7152#[cfg(test)]
7153#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
7154mod tests {
7155    use super::*;
7156    use crate::realtime_transcript::RealtimeTranscriptRole;
7157    use crate::types::{
7158        AssistantBlock, BlockAssistantMessage, ContentBlock, StopReason, SystemMessage, Usage,
7159        UserMessage,
7160    };
7161    use std::sync::Arc;
7162
7163    fn exact_boundary_append(key: &str, text: &str) -> PendingSystemContextAppend {
7164        PendingSystemContextAppend {
7165            content: crate::lifecycle::CoreRenderable::text(text.to_string()),
7166            source: Some("test:exact-boundary".to_string()),
7167            idempotency_key: Some(key.to_string()),
7168            source_kind: SystemContextSource::RuntimeSteer,
7169            accepted_at: SystemTime::now(),
7170            peer_response_terminal: None,
7171        }
7172    }
7173
7174    async fn wait_for_exact_boundary_request(handle: &SystemContextStateHandle) {
7175        for _ in 0..1_000 {
7176            let registered = matches!(
7177                &handle.boundary.lock().window,
7178                SystemContextBoundaryWindow::Open {
7179                    request: Some(_),
7180                    ..
7181                }
7182            );
7183            if registered {
7184                return;
7185            }
7186            tokio::task::yield_now().await;
7187        }
7188        panic!("exact boundary request did not register");
7189    }
7190
7191    fn assert_send<T: Send>() {}
7192
7193    #[test]
7194    fn prepared_boundary_authority_is_send_for_owned_commit_handoff() {
7195        assert_send::<PreparedSystemContextBoundary>();
7196        assert_send::<crate::lifecycle::CoreBoundaryStageOutput>();
7197        assert_send::<ModelBoundarySystemContext>();
7198    }
7199
7200    #[tokio::test]
7201    async fn exact_boundary_runner_first_is_typed_unavailable() {
7202        let state = SystemContextStateHandle::new(Default::default()).expect("state");
7203        let run_id = RunId::new();
7204        let _run = state
7205            .begin_boundary_run(run_id.clone())
7206            .expect("open boundary");
7207
7208        let consuming = state
7209            .take_pending_at_exact_boundary(&run_id)
7210            .await
7211            .expect("runner claims open boundary");
7212        assert!(consuming.appends().is_empty());
7213        assert!(
7214            consuming
7215                .consume()
7216                .expect("runner consumes open boundary")
7217                .is_empty()
7218        );
7219        let error = state
7220            .prepare_active_turn_boundary(
7221                &run_id,
7222                vec![exact_boundary_append("runner-first", "too late")],
7223            )
7224            .await
7225            .expect_err("consumed generation cannot mint a preparation");
7226        assert!(matches!(error, CoreBoundaryStageError::Unavailable { .. }));
7227    }
7228
7229    #[tokio::test]
7230    async fn exact_boundary_wrong_run_is_stale_without_claiming_or_mutating_window() {
7231        let state = SystemContextStateHandle::new(Default::default()).expect("state");
7232        let active_run_id = RunId::new();
7233        let wrong_run_id = RunId::new();
7234        let _run = state
7235            .begin_boundary_run(active_run_id.clone())
7236            .expect("open boundary");
7237
7238        let error = state
7239            .prepare_active_turn_boundary(
7240                &wrong_run_id,
7241                vec![exact_boundary_append("wrong-run", "must not stage")],
7242            )
7243            .await
7244            .expect_err("a different run cannot claim the active window");
7245        assert!(matches!(error, CoreBoundaryStageError::Stale { .. }));
7246        assert!(state.snapshot().seen().is_empty());
7247
7248        let consuming = state
7249            .take_pending_at_exact_boundary(&active_run_id)
7250            .await
7251            .expect("the correct run still owns its unclaimed boundary");
7252        assert!(
7253            consuming
7254                .consume()
7255                .expect("consume unchanged active window")
7256                .is_empty()
7257        );
7258    }
7259
7260    #[tokio::test]
7261    async fn exact_boundary_conflicting_batch_is_atomic_and_leaves_window_unclaimed() {
7262        let state = SystemContextStateHandle::new(Default::default()).expect("state");
7263        let run_id = RunId::new();
7264        let _run = state
7265            .begin_boundary_run(run_id.clone())
7266            .expect("open boundary");
7267
7268        let error = state
7269            .prepare_active_turn_boundary(
7270                &run_id,
7271                vec![
7272                    exact_boundary_append("conflict", "first"),
7273                    exact_boundary_append("conflict", "different"),
7274                ],
7275            )
7276            .await
7277            .expect_err("a conflicting batch must fail before registration");
7278        assert!(matches!(error, CoreBoundaryStageError::Fault { .. }));
7279        assert!(state.snapshot().seen().is_empty());
7280
7281        let consuming = state
7282            .take_pending_at_exact_boundary(&run_id)
7283            .await
7284            .expect("failed validation must leave the exact window unclaimed");
7285        assert!(
7286            consuming
7287                .consume()
7288                .expect("consume unchanged active window")
7289                .is_empty()
7290        );
7291    }
7292
7293    #[tokio::test]
7294    async fn exact_boundary_prepare_first_parks_until_commit() {
7295        let state = SystemContextStateHandle::new(Default::default()).expect("state");
7296        let run_id = RunId::new();
7297        let _run = state
7298            .begin_boundary_run(run_id.clone())
7299            .expect("open boundary");
7300
7301        let prepare_state = state.clone();
7302        let prepare_run_id = run_id.clone();
7303        let prepare = tokio::spawn(async move {
7304            prepare_state
7305                .prepare_active_turn_boundary(
7306                    &prepare_run_id,
7307                    vec![exact_boundary_append("prepare-first", "parked context")],
7308                )
7309                .await
7310        });
7311        wait_for_exact_boundary_request(&state).await;
7312
7313        let runner_state = state.clone();
7314        let runner_run_id = run_id.clone();
7315        let runner = tokio::spawn(async move {
7316            runner_state
7317                .take_pending_at_exact_boundary(&runner_run_id)
7318                .await
7319        });
7320        let prepared = prepare
7321            .await
7322            .expect("prepare task")
7323            .expect("prepare must return only after park");
7324        assert_eq!(prepared.expected_run_id(), &run_id);
7325        assert!(prepared.boundary_generation() > 0);
7326        assert!(state.snapshot().pending().is_empty());
7327        assert!(
7328            !runner.is_finished(),
7329            "runner must remain parked before commit"
7330        );
7331
7332        prepared
7333            .into_stage_output(None)
7334            .commit()
7335            .expect("exact commit");
7336        let consuming = runner
7337            .await
7338            .expect("runner task")
7339            .expect("runner resumes after commit");
7340        assert_eq!(state.snapshot().pending().len(), 1);
7341        assert!(state.snapshot().applied().is_empty());
7342        let consumed = consuming.consume().expect("consume at model call seam");
7343        assert_eq!(consumed.len(), 1);
7344        assert_eq!(consumed[0].content.render_text(), "parked context");
7345        assert!(state.snapshot().pending().is_empty());
7346        assert!(state.snapshot().applied().is_empty());
7347    }
7348
7349    #[tokio::test]
7350    async fn exact_boundary_preprocessing_drop_does_not_claim_model_consumption() {
7351        let state = SystemContextStateHandle::new(Default::default()).expect("state");
7352        let run_id = RunId::new();
7353        let _run = state
7354            .begin_boundary_run(run_id.clone())
7355            .expect("open boundary");
7356        let prepare_state = state.clone();
7357        let prepare_run_id = run_id.clone();
7358        let prepare = tokio::spawn(async move {
7359            prepare_state
7360                .prepare_active_turn_boundary(
7361                    &prepare_run_id,
7362                    vec![exact_boundary_append(
7363                        "preprocess-drop",
7364                        "retryable context",
7365                    )],
7366                )
7367                .await
7368        });
7369        wait_for_exact_boundary_request(&state).await;
7370        let runner_state = state.clone();
7371        let runner_run_id = run_id.clone();
7372        let runner = tokio::spawn(async move {
7373            runner_state
7374                .take_pending_at_exact_boundary(&runner_run_id)
7375                .await
7376        });
7377        prepare
7378            .await
7379            .expect("prepare task")
7380            .expect("prepare parks")
7381            .into_stage_output(None)
7382            .commit()
7383            .expect("publish pending candidate");
7384
7385        let consuming = runner
7386            .await
7387            .expect("runner task")
7388            .expect("runner enters preprocessing");
7389        assert_eq!(consuming.appends().len(), 1);
7390        drop(consuming);
7391
7392        let snapshot = state.snapshot();
7393        assert_eq!(snapshot.pending().len(), 1);
7394        assert!(snapshot.applied().is_empty());
7395        assert!(snapshot.seen().contains_key("preprocess-drop"));
7396
7397        // Commit publishes to the exact active turn; it does not claim model
7398        // delivery. A later hard cancel/preprocessing drop owns the following
7399        // cleanup linearization and must not leak the accepted steer to a
7400        // successor run.
7401        assert_eq!(
7402            state
7403                .discard_unapplied_active_turn_pending()
7404                .expect("closed consuming window permits active-turn cleanup"),
7405            1
7406        );
7407        assert!(state.snapshot().pending().is_empty());
7408    }
7409
7410    #[tokio::test]
7411    async fn exact_boundary_drop_aborts_and_preserves_ordinary_pending() {
7412        let state = SystemContextStateHandle::new(Default::default()).expect("state");
7413        state
7414            .stage_append_with_snapshot(
7415                &AppendSystemContextRequest {
7416                    content: crate::lifecycle::CoreRenderable::text("ordinary"),
7417                    source: Some("test:ordinary".to_string()),
7418                    idempotency_key: Some("ordinary".to_string()),
7419                    source_kind: SystemContextSource::Normal,
7420                    peer_response_terminal: None,
7421                },
7422                SystemTime::now(),
7423            )
7424            .expect("ordinary append");
7425        let run_id = RunId::new();
7426        let _run = state
7427            .begin_boundary_run(run_id.clone())
7428            .expect("open boundary");
7429        let prepare_state = state.clone();
7430        let prepare_run_id = run_id.clone();
7431        let prepare = tokio::spawn(async move {
7432            prepare_state
7433                .prepare_active_turn_boundary(
7434                    &prepare_run_id,
7435                    vec![exact_boundary_append("drop-abort", "must not publish")],
7436                )
7437                .await
7438        });
7439        wait_for_exact_boundary_request(&state).await;
7440        let runner_state = state.clone();
7441        let runner_run_id = run_id.clone();
7442        let runner = tokio::spawn(async move {
7443            runner_state
7444                .take_pending_at_exact_boundary(&runner_run_id)
7445                .await
7446        });
7447        let prepared = prepare.await.expect("prepare task").expect("parked");
7448        drop(prepared);
7449        let consuming = runner
7450            .await
7451            .expect("runner task")
7452            .expect("drop abort wakes runner");
7453        let consumed = consuming
7454            .consume()
7455            .expect("consume ordinary pending context");
7456        assert_eq!(consumed.len(), 1);
7457        assert_eq!(consumed[0].content.render_text(), "ordinary");
7458        assert!(!state.snapshot().seen().contains_key("drop-abort"));
7459    }
7460
7461    #[tokio::test]
7462    async fn exact_boundary_duplicate_prepare_cannot_overwrite_generation() {
7463        let state = SystemContextStateHandle::new(Default::default()).expect("state");
7464        let run_id = RunId::new();
7465        let _run = state
7466            .begin_boundary_run(run_id.clone())
7467            .expect("open boundary");
7468        let first_state = state.clone();
7469        let first_run_id = run_id.clone();
7470        let first = tokio::spawn(async move {
7471            first_state
7472                .prepare_active_turn_boundary(
7473                    &first_run_id,
7474                    vec![exact_boundary_append("first", "first")],
7475                )
7476                .await
7477        });
7478        wait_for_exact_boundary_request(&state).await;
7479        let duplicate = state
7480            .prepare_active_turn_boundary(&run_id, vec![exact_boundary_append("second", "second")])
7481            .await
7482            .expect_err("duplicate preparation must fail closed");
7483        assert!(duplicate.is_unavailable());
7484
7485        let runner_state = state.clone();
7486        let runner_run_id = run_id.clone();
7487        let runner = tokio::spawn(async move {
7488            runner_state
7489                .take_pending_at_exact_boundary(&runner_run_id)
7490                .await
7491        });
7492        let prepared = first.await.expect("first task").expect("first parks");
7493        prepared
7494            .into_stage_output(None)
7495            .abort()
7496            .expect("explicit abort");
7497        runner
7498            .await
7499            .expect("runner task")
7500            .expect("runner resumes after abort")
7501            .consume()
7502            .expect("consume ordinary pending after abort");
7503        assert!(!state.snapshot().seen().contains_key("second"));
7504    }
7505
7506    #[tokio::test]
7507    async fn exact_boundary_concurrent_conflict_surfaces_fault_to_runner_and_preparer() {
7508        let state = SystemContextStateHandle::new(Default::default()).expect("state");
7509        let run_id = RunId::new();
7510        let _run = state
7511            .begin_boundary_run(run_id.clone())
7512            .expect("open boundary");
7513        let prepare_state = state.clone();
7514        let prepare_run_id = run_id.clone();
7515        let prepare = tokio::spawn(async move {
7516            prepare_state
7517                .prepare_active_turn_boundary(
7518                    &prepare_run_id,
7519                    vec![exact_boundary_append("shared-key", "prepared context")],
7520                )
7521                .await
7522        });
7523        wait_for_exact_boundary_request(&state).await;
7524
7525        state
7526            .stage_append_with_snapshot(
7527                &AppendSystemContextRequest {
7528                    content: crate::lifecycle::CoreRenderable::text("ordinary conflict"),
7529                    source: Some("test:exact-boundary".to_string()),
7530                    idempotency_key: Some("shared-key".to_string()),
7531                    source_kind: SystemContextSource::Normal,
7532                    peer_response_terminal: None,
7533                },
7534                SystemTime::now(),
7535            )
7536            .expect("ordinary mutation remains legal before the runner parks");
7537
7538        let runner_error = state
7539            .take_pending_at_exact_boundary(&run_id)
7540            .await
7541            .err()
7542            .expect("runner must surface candidate recomputation conflict");
7543        assert!(matches!(runner_error, CoreBoundaryStageError::Fault { .. }));
7544        let prepare_error = prepare
7545            .await
7546            .expect("prepare task")
7547            .expect_err("preparer must receive the same typed failure class");
7548        assert!(matches!(
7549            prepare_error,
7550            CoreBoundaryStageError::Fault { .. }
7551        ));
7552
7553        let snapshot = state.snapshot();
7554        assert_eq!(snapshot.pending().len(), 1);
7555        assert_eq!(
7556            snapshot.pending()[0].content.render_text(),
7557            "ordinary conflict"
7558        );
7559        assert!(snapshot.applied().is_empty());
7560    }
7561
7562    #[tokio::test]
7563    async fn exact_boundary_nonconflicting_open_mutation_is_preserved_in_candidate() {
7564        let state = SystemContextStateHandle::new(Default::default()).expect("state");
7565        let run_id = RunId::new();
7566        let _run = state
7567            .begin_boundary_run(run_id.clone())
7568            .expect("open boundary");
7569        let prepare_state = state.clone();
7570        let prepare_run_id = run_id.clone();
7571        let prepare = tokio::spawn(async move {
7572            prepare_state
7573                .prepare_active_turn_boundary(
7574                    &prepare_run_id,
7575                    vec![exact_boundary_append("prepared", "prepared context")],
7576                )
7577                .await
7578        });
7579        wait_for_exact_boundary_request(&state).await;
7580
7581        state
7582            .stage_append_with_snapshot(
7583                &AppendSystemContextRequest {
7584                    content: crate::lifecycle::CoreRenderable::text("ordinary context"),
7585                    source: Some("test:ordinary".to_string()),
7586                    idempotency_key: Some("ordinary".to_string()),
7587                    source_kind: SystemContextSource::Normal,
7588                    peer_response_terminal: None,
7589                },
7590                SystemTime::now(),
7591            )
7592            .expect("nonconflicting mutation remains legal before park");
7593
7594        let runner_state = state.clone();
7595        let runner_run_id = run_id.clone();
7596        let runner = tokio::spawn(async move {
7597            runner_state
7598                .take_pending_at_exact_boundary(&runner_run_id)
7599                .await
7600        });
7601        let prepared = prepare.await.expect("prepare task").expect("parked");
7602        assert_eq!(prepared.candidate_state().pending().len(), 2);
7603        prepared
7604            .into_stage_output(None)
7605            .commit()
7606            .expect("commit exact candidate");
7607        let consuming = runner
7608            .await
7609            .expect("runner task")
7610            .expect("runner resumes after commit");
7611        let consumed = consuming.consume().expect("consume exact candidate");
7612        assert_eq!(consumed.len(), 2);
7613        assert!(
7614            consumed
7615                .iter()
7616                .any(|append| append.content.render_text() == "ordinary context")
7617        );
7618        assert!(
7619            consumed
7620                .iter()
7621                .any(|append| append.content.render_text() == "prepared context")
7622        );
7623    }
7624
7625    #[tokio::test]
7626    async fn exact_boundary_actor_replacement_rejects_old_commit() {
7627        let actor_a = SystemContextStateHandle::new(Default::default()).expect("actor A");
7628        let run_id = RunId::new();
7629        let _run_a = actor_a
7630            .begin_boundary_run(run_id.clone())
7631            .expect("open A boundary");
7632        let prepare_state = actor_a.clone();
7633        let prepare_run_id = run_id.clone();
7634        let prepare = tokio::spawn(async move {
7635            prepare_state
7636                .prepare_active_turn_boundary(
7637                    &prepare_run_id,
7638                    vec![exact_boundary_append("actor-a", "stale A")],
7639                )
7640                .await
7641        });
7642        wait_for_exact_boundary_request(&actor_a).await;
7643        let runner_state = actor_a.clone();
7644        let runner_run_id = run_id.clone();
7645        let runner = tokio::spawn(async move {
7646            runner_state
7647                .take_pending_at_exact_boundary(&runner_run_id)
7648                .await
7649        });
7650        let prepared_a = prepare.await.expect("prepare task").expect("A parked");
7651
7652        actor_a.revoke_boundary_actor();
7653        let actor_b = SystemContextStateHandle::new(Default::default()).expect("actor B");
7654        let _run_b = actor_b
7655            .begin_boundary_run(run_id.clone())
7656            .expect("replacement opens independently");
7657        let error = prepared_a
7658            .into_stage_output(None)
7659            .commit()
7660            .expect_err("A cannot commit after replacement revoke");
7661        assert!(matches!(error, CoreBoundaryStageError::Stale { .. }));
7662        assert!(runner.await.expect("A runner task").is_err());
7663        assert!(actor_b.snapshot().seen().is_empty());
7664    }
7665
7666    #[tokio::test]
7667    async fn exact_boundary_hard_interrupt_and_concurrent_append_fail_closed() {
7668        let state = SystemContextStateHandle::new(Default::default()).expect("state");
7669        let run_id = RunId::new();
7670        let _run = state
7671            .begin_boundary_run(run_id.clone())
7672            .expect("open boundary");
7673        let prepare_state = state.clone();
7674        let prepare_run_id = run_id.clone();
7675        let prepare = tokio::spawn(async move {
7676            prepare_state
7677                .prepare_active_turn_boundary(
7678                    &prepare_run_id,
7679                    vec![exact_boundary_append("interrupt", "stale")],
7680                )
7681                .await
7682        });
7683        wait_for_exact_boundary_request(&state).await;
7684        let runner_state = state.clone();
7685        let runner_run_id = run_id.clone();
7686        let runner = tokio::spawn(async move {
7687            runner_state
7688                .take_pending_at_exact_boundary(&runner_run_id)
7689                .await
7690        });
7691        let prepared = prepare.await.expect("prepare task").expect("parked");
7692        let concurrent = state.stage_append_with_snapshot(
7693            &AppendSystemContextRequest {
7694                content: crate::lifecycle::CoreRenderable::text("concurrent"),
7695                source: Some("test:concurrent".to_string()),
7696                idempotency_key: Some("concurrent".to_string()),
7697                source_kind: SystemContextSource::Normal,
7698                peer_response_terminal: None,
7699            },
7700            SystemTime::now(),
7701        );
7702        assert!(
7703            concurrent.is_err(),
7704            "parked candidate must not be overwritten"
7705        );
7706        let discard_error = state
7707            .discard_unapplied_active_turn_pending()
7708            .expect_err("parked authority must reject cleanup, not report an empty success");
7709        assert!(matches!(
7710            discard_error,
7711            CoreBoundaryStageError::Fault { .. }
7712        ));
7713        let keyed_discard_error = state
7714            .discard_active_turn_pending_by_keys(&["interrupt".to_string()])
7715            .expect_err("parked authority must reject keyed rollback");
7716        assert!(matches!(
7717            keyed_discard_error,
7718            CoreBoundaryStageError::Fault { .. }
7719        ));
7720
7721        runner.abort();
7722        let _ = runner.await;
7723        let error = prepared
7724            .into_stage_output(None)
7725            .commit()
7726            .expect_err("hard-interrupted parked request cannot commit later");
7727        assert!(matches!(error, CoreBoundaryStageError::Stale { .. }));
7728        assert!(state.snapshot().seen().is_empty());
7729    }
7730
7731    #[tokio::test]
7732    async fn exact_boundary_run_exit_wakes_prepare_before_parking() {
7733        let state = SystemContextStateHandle::new(Default::default()).expect("state");
7734        let run_id = RunId::new();
7735        let run = state
7736            .begin_boundary_run(run_id.clone())
7737            .expect("open boundary");
7738        let prepare_state = state.clone();
7739        let prepare_run_id = run_id.clone();
7740        let prepare = tokio::spawn(async move {
7741            prepare_state
7742                .prepare_active_turn_boundary(
7743                    &prepare_run_id,
7744                    vec![exact_boundary_append("run-exit", "never parks")],
7745                )
7746                .await
7747        });
7748        wait_for_exact_boundary_request(&state).await;
7749        drop(run);
7750        let error = prepare
7751            .await
7752            .expect("prepare task")
7753            .expect_err("run exit must release preparer");
7754        assert!(matches!(
7755            error,
7756            CoreBoundaryStageError::Unavailable { .. } | CoreBoundaryStageError::Stale { .. }
7757        ));
7758    }
7759
7760    fn block_assistant_text(message: &BlockAssistantMessage) -> String {
7761        message
7762            .blocks
7763            .iter()
7764            .filter_map(|block| match block {
7765                AssistantBlock::Text { text, .. } => Some(text.as_str()),
7766                _ => None,
7767            })
7768            .collect()
7769    }
7770
7771    /// Reducer tests enter through the same proof shape as persistent
7772    /// ingestion: a metadata-only anchor is staged first, then a canonical
7773    /// blob-backed event is applied. Blob bytes are verified in
7774    /// PersistentSessionService tests; this helper tests only reducer ownership.
7775    fn append_staged_user_image(
7776        session: &mut Session,
7777        event: &RealtimeTranscriptEvent,
7778    ) -> RealtimeTranscriptApplyOutcome {
7779        let RealtimeTranscriptEvent::UserContentFinal {
7780            idempotency_key,
7781            item_id,
7782            previous_item_id,
7783            content_index,
7784            content,
7785        } = event
7786        else {
7787            panic!("test helper requires user content final")
7788        };
7789        let [ContentBlock::Image { media_type, data }] = content.as_slice() else {
7790            panic!("test helper requires exactly one image")
7791        };
7792        let media_type = crate::image_generation::MediaType::canonical_str(media_type);
7793        let blob_id = match data {
7794            crate::types::ImageData::Inline { data } => {
7795                crate::blob::content_blob_id(&media_type, data)
7796            }
7797            crate::types::ImageData::Blob { blob_id } => blob_id.clone(),
7798        };
7799        let pending = crate::PendingRealtimeUserContentBlob {
7800            idempotency_key: idempotency_key.clone(),
7801            item_id: item_id.clone(),
7802            previous_item_id: previous_item_id.clone(),
7803            content_index: *content_index,
7804            blob_id,
7805            media_type,
7806        };
7807        assert_eq!(
7808            session
7809                .stage_pending_realtime_user_content_blob(pending.clone())
7810                .expect("test pending anchor should stage"),
7811            crate::generated::session_document::RealtimeUserContentBlobStageDisposition::StageNew
7812        );
7813        session.append_realtime_transcript_event(pending.canonical_event())
7814    }
7815
7816    #[test]
7817    fn transcript_digest_is_content_addressed() {
7818        let base_time = crate::types::message_timestamp_now();
7819        let stamped = vec![
7820            Message::User(UserMessage::text("turn one".to_string())),
7821            Message::BlockAssistant(BlockAssistantMessage {
7822                blocks: vec![AssistantBlock::Text {
7823                    text: "answer one".to_string(),
7824                    meta: None,
7825                }],
7826                stop_reason: StopReason::EndTurn,
7827                identity: crate::types::TranscriptMessageIdentity {
7828                    interaction_id: None,
7829                    run_id: Some(crate::lifecycle::RunId::new()),
7830                    objective_id: None,
7831                },
7832                created_at: base_time,
7833            }),
7834        ];
7835        let mut restamped = stamped.clone();
7836        for message in &mut restamped {
7837            match message {
7838                Message::User(user) => {
7839                    user.created_at = base_time + chrono::Duration::hours(2);
7840                }
7841                Message::BlockAssistant(assistant) => {
7842                    assistant.identity = crate::types::TranscriptMessageIdentity {
7843                        interaction_id: None,
7844                        run_id: Some(crate::lifecycle::RunId::new()),
7845                        objective_id: None,
7846                    };
7847                    assistant.created_at = base_time + chrono::Duration::hours(2);
7848                }
7849                _ => {}
7850            }
7851        }
7852        assert_eq!(
7853            transcript_messages_digest(&stamped).expect("digest"),
7854            transcript_messages_digest(&restamped).expect("digest"),
7855            "bookkeeping variance must not fork the transcript revision"
7856        );
7857
7858        let mut content_changed = stamped.clone();
7859        if let Message::User(user) = &mut content_changed[0] {
7860            user.content = vec![ContentBlock::Text {
7861                text: "a different turn".to_string(),
7862            }];
7863        }
7864        assert_ne!(
7865            transcript_messages_digest(&stamped).expect("digest"),
7866            transcript_messages_digest(&content_changed).expect("digest"),
7867            "content changes must fork the transcript revision"
7868        );
7869    }
7870
7871    #[test]
7872    fn public_generic_rewrite_api_rejects_typed_compaction_semantic() {
7873        let mut session = Session::new();
7874        session.push(Message::User(UserMessage::text("old context")));
7875        let error = session
7876            .commit_transcript_rewrite(
7877                TranscriptRewriteSelection::typed_compaction_for_test(0, 1),
7878                vec![Message::User(UserMessage::compaction_summary("summary"))],
7879                TranscriptRewriteReason::new("anything"),
7880                None,
7881                None,
7882            )
7883            .unwrap_err();
7884        assert!(matches!(
7885            error,
7886            TranscriptEditError::InvalidTranscriptShape(_)
7887        ));
7888        assert_eq!(session.messages().len(), 1);
7889    }
7890
7891    #[test]
7892    fn compaction_witness_authorizes_only_the_exact_validated_rebuild() {
7893        let mut session = Session::new();
7894        session.push(Message::User(UserMessage::text("old context one")));
7895        session.push(Message::User(UserMessage::text("old context two")));
7896        let validated = vec![Message::User(UserMessage::compaction_summary(
7897            "validated summary",
7898        ))];
7899        let authority = crate::agent::compact::ValidatedCompactionRewrite::for_test(
7900            session.messages(),
7901            &validated,
7902        )
7903        .unwrap();
7904        let error = session
7905            .replace_messages_for_compaction_internal(
7906                vec![Message::User(UserMessage::compaction_summary(
7907                    "substituted summary",
7908                ))],
7909                &authority,
7910            )
7911            .unwrap_err();
7912        assert!(matches!(
7913            error,
7914            TranscriptEditError::InvalidTranscriptShape(_)
7915        ));
7916        assert_eq!(session.messages().len(), 2);
7917    }
7918
7919    #[test]
7920    fn semantic_marker_prevents_new_generic_compaction_forgery_and_heals_prior_data() {
7921        let mut session = Session::new();
7922        session.push(Message::User(UserMessage::text("old context one")));
7923        session.push(Message::User(UserMessage::text("old context two")));
7924        session
7925            .commit_transcript_rewrite(
7926                TranscriptRewriteSelection::MessageRange { start: 0, end: 2 },
7927                vec![Message::User(UserMessage::compaction_summary("summary"))],
7928                TranscriptRewriteReason::new("compaction"),
7929                None,
7930                None,
7931            )
7932            .unwrap();
7933        let session: Session =
7934            serde_json::from_value(serde_json::to_value(&session).unwrap()).unwrap();
7935        let history = session.transcript_history_state().unwrap().unwrap();
7936        assert_eq!(
7937            history.commits[0].selection.semantic(),
7938            TranscriptRewriteSemantic::Edit,
7939            "new generic rewrites retain an explicit typed edit marker after roundtrip"
7940        );
7941        assert_eq!(history.commits[0].reason.kind, "compaction");
7942
7943        let mut legacy = history;
7944        legacy.commits[0].selection = TranscriptRewriteSelection::MessageRange { start: 0, end: 2 };
7945        let legacy: TranscriptHistoryState =
7946            serde_json::from_value(serde_json::to_value(legacy).unwrap()).unwrap();
7947        assert_eq!(
7948            legacy.commits[0].selection.semantic(),
7949            TranscriptRewriteSemantic::Compaction,
7950            "marker-absent prior data derives compaction from typed transcript evidence"
7951        );
7952
7953        let mut ordinary = Session::new();
7954        ordinary.push(Message::User(UserMessage::text("ordinary old one")));
7955        ordinary.push(Message::User(UserMessage::text("ordinary old two")));
7956        ordinary
7957            .commit_transcript_rewrite(
7958                TranscriptRewriteSelection::MessageRange { start: 0, end: 2 },
7959                vec![Message::User(UserMessage::text("ordinary replacement"))],
7960                TranscriptRewriteReason::new("compaction"),
7961                None,
7962                None,
7963            )
7964            .unwrap();
7965        let history = ordinary.transcript_history_state().unwrap().unwrap();
7966        assert_eq!(
7967            history.commits[0].selection.semantic(),
7968            TranscriptRewriteSemantic::Edit,
7969            "free-form reason must not upgrade an ordinary edit"
7970        );
7971    }
7972
7973    fn legacy_rewrite_fixture() -> (TranscriptRewriteCommit, Vec<Message>, Vec<Message>) {
7974        let parent_messages = vec![
7975            Message::User(UserMessage::text("before rewrite".to_string())),
7976            Message::User(UserMessage::text("retained tail".to_string())),
7977        ];
7978        let revision_messages = vec![
7979            Message::User(UserMessage::text("after rewrite".to_string())),
7980            Message::User(UserMessage::text("retained tail".to_string())),
7981        ];
7982        // Compute the graph strings the way a pre-0.7.14 writer did:
7983        // bookkeeping-inclusive digests.
7984        let parent_revision =
7985            legacy_transcript_messages_digest(&parent_messages).expect("legacy parent digest");
7986        let revision =
7987            legacy_transcript_messages_digest(&revision_messages).expect("legacy revision digest");
7988        let commit = TranscriptRewriteCommit {
7989            parent_revision,
7990            revision,
7991            selection: TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
7992            original_span_digest: legacy_transcript_messages_digest(&parent_messages[0..1])
7993                .expect("legacy span digest"),
7994            replacement_digest: legacy_transcript_messages_digest(&revision_messages[0..1])
7995                .expect("legacy replacement digest"),
7996            messages_before: 2,
7997            messages_after: 2,
7998            reason: TranscriptRewriteReason::new("compaction"),
7999            actor: Some("legacy-test".to_string()),
8000            committed_at: SystemTime::now(),
8001        };
8002        (commit, parent_messages, revision_messages)
8003    }
8004
8005    #[test]
8006    fn legacy_transcript_history_state_heals_to_content_addressed_on_parse() {
8007        let (commit, parent_messages, revision_messages) = legacy_rewrite_fixture();
8008        let state = TranscriptHistoryState {
8009            head: commit.revision.clone(),
8010            digest_format: 0,
8011            commits: vec![commit.clone()],
8012            revisions: vec![
8013                TranscriptRevisionBody {
8014                    revision: commit.parent_revision.clone(),
8015                    parent_revision: None,
8016                    messages: parent_messages.clone(),
8017                    created_at: SystemTime::now(),
8018                },
8019                TranscriptRevisionBody {
8020                    revision: commit.revision.clone(),
8021                    parent_revision: Some(commit.parent_revision),
8022                    messages: revision_messages.clone(),
8023                    created_at: SystemTime::now(),
8024                },
8025            ],
8026        };
8027        let value = serde_json::to_value(&state).expect("serialize legacy state");
8028        let healed: TranscriptHistoryState =
8029            serde_json::from_value(value).expect("parse legacy state");
8030
8031        let content_parent =
8032            transcript_messages_digest(&parent_messages).expect("content parent digest");
8033        let content_revision =
8034            transcript_messages_digest(&revision_messages).expect("content revision digest");
8035        assert_eq!(healed.head, content_revision, "head must re-derive");
8036        assert_eq!(healed.commits[0].parent_revision, content_parent);
8037        assert_eq!(healed.commits[0].revision, content_revision);
8038        assert_eq!(healed.revisions[0].revision, content_parent);
8039        assert_eq!(healed.revisions[1].revision, content_revision);
8040        assert_eq!(
8041            healed.revisions[1].parent_revision.as_deref(),
8042            Some(content_parent.as_str())
8043        );
8044        validate_transcript_history_state(&healed).expect("healed graph must validate");
8045
8046        // A session materialized from the healed graph can extend the chain
8047        // with a current-format rewrite.
8048        let mut session = Session::new();
8049        session
8050            .apply_transcript_history_state(healed)
8051            .expect("apply healed graph");
8052        session
8053            .commit_transcript_rewrite(
8054                TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
8055                vec![Message::User(UserMessage::text(
8056                    "rewritten again".to_string(),
8057                ))],
8058                TranscriptRewriteReason::new("unit-test"),
8059                None,
8060                None,
8061            )
8062            .expect("extend healed graph with a new rewrite");
8063        session
8064            .validate_transcript_history_state()
8065            .expect("extended graph must validate");
8066    }
8067
8068    #[test]
8069    fn legacy_transcript_rewrite_record_heals_on_parse() {
8070        let (commit, parent_messages, revision_messages) = legacy_rewrite_fixture();
8071        let record_value = serde_json::json!({
8072            "commit": commit,
8073            "parent_body": TranscriptRevisionBody {
8074                revision: commit.parent_revision.clone(),
8075                parent_revision: None,
8076                messages: parent_messages,
8077                created_at: SystemTime::now(),
8078            },
8079            "revision_body": TranscriptRevisionBody {
8080                revision: commit.revision.clone(),
8081                parent_revision: Some(commit.parent_revision),
8082                messages: revision_messages.clone(),
8083                created_at: SystemTime::now(),
8084            },
8085        });
8086        let healed: TranscriptRewriteRecord =
8087            serde_json::from_value(record_value).expect("parse legacy record");
8088        assert_eq!(
8089            healed.commit.revision,
8090            transcript_messages_digest(&revision_messages).expect("content digest")
8091        );
8092        // The healed record passes the same validation `new` enforces.
8093        TranscriptRewriteRecord::new(healed.commit, healed.parent_body, healed.revision_body)
8094            .expect("healed record must validate");
8095    }
8096
8097    #[test]
8098    fn corrupt_transcript_history_strings_stay_untouched_and_fail_validation() {
8099        let (commit, parent_messages, _revision_messages) = legacy_rewrite_fixture();
8100        let bogus = "sha256:0000000000000000000000000000000000000000000000000000000000000000";
8101        let state = TranscriptHistoryState {
8102            digest_format: 0,
8103            head: bogus.to_string(),
8104            commits: Vec::new(),
8105            revisions: vec![TranscriptRevisionBody {
8106                revision: bogus.to_string(),
8107                parent_revision: None,
8108                messages: parent_messages,
8109                created_at: SystemTime::now(),
8110            }],
8111        };
8112        let _ = commit;
8113        let value = serde_json::to_value(&state).expect("serialize corrupt state");
8114        let parsed: TranscriptHistoryState =
8115            serde_json::from_value(value).expect("corrupt strings still parse");
8116        assert_eq!(
8117            parsed.head, bogus,
8118            "unverifiable strings must not be rewritten"
8119        );
8120        assert!(
8121            validate_transcript_history_state(&parsed).is_err(),
8122            "corrupt graph must keep failing validation"
8123        );
8124    }
8125
8126    /// K4 invariant: synthetic-notice refresh is ONE atomic transcript edit —
8127    /// after a refresh, at most the replacement notices of that kind exist
8128    /// (no stale notice survives beside a fresh one).
8129    #[test]
8130    fn replace_synthetic_notices_leaves_only_replacements_of_kind() {
8131        use crate::types::{SystemNoticeKind, SystemNoticeMessage};
8132
8133        let mut session = Session::new();
8134        session.push(Message::User(UserMessage::text("hello".to_string())));
8135        session.push(Message::SystemNotice(SystemNoticeMessage::new(
8136            SystemNoticeKind::McpPending,
8137            "stale one",
8138        )));
8139        session.push(Message::SystemNotice(SystemNoticeMessage::new(
8140            SystemNoticeKind::McpPending,
8141            "stale two",
8142        )));
8143        // A notice of another kind must be untouched.
8144        session.push(Message::SystemNotice(SystemNoticeMessage::new(
8145            SystemNoticeKind::BackgroundJob,
8146            "other-kind",
8147        )));
8148
8149        session
8150            .replace_synthetic_notices(
8151                SystemNoticeKind::McpPending,
8152                vec![Message::SystemNotice(SystemNoticeMessage::new(
8153                    SystemNoticeKind::McpPending,
8154                    "fresh",
8155                ))],
8156            )
8157            .expect("notice refresh succeeds");
8158
8159        let mcp_pending: Vec<&SystemNoticeMessage> = session
8160            .messages()
8161            .iter()
8162            .filter_map(|message| match message {
8163                Message::SystemNotice(notice) if notice.kind == SystemNoticeKind::McpPending => {
8164                    Some(notice)
8165                }
8166                _ => None,
8167            })
8168            .collect();
8169        assert_eq!(mcp_pending.len(), 1, "exactly one notice of the kind");
8170        assert_eq!(mcp_pending[0].body.as_deref(), Some("fresh"));
8171        assert!(
8172            session.messages().iter().any(|message| matches!(
8173                message,
8174                Message::SystemNotice(notice) if notice.kind == SystemNoticeKind::BackgroundJob
8175            )),
8176            "other-kind notices are untouched"
8177        );
8178
8179        // Empty replacements = pure strip.
8180        session
8181            .replace_synthetic_notices(SystemNoticeKind::McpPending, Vec::new())
8182            .expect("pure strip succeeds");
8183        assert!(
8184            !session.messages().iter().any(|message| matches!(
8185                message,
8186                Message::SystemNotice(notice) if notice.kind == SystemNoticeKind::McpPending
8187            )),
8188            "empty replacement clears the kind"
8189        );
8190    }
8191
8192    #[test]
8193    fn ordinary_appends_after_rewrite_coalesce_mechanical_revision_bodies() {
8194        let mut session = Session::new();
8195        for message in 0..133 {
8196            session.push(Message::User(UserMessage::text(format!(
8197                "seed message {message}"
8198            ))));
8199        }
8200        let parent = session.transcript_revision().expect("parent revision");
8201        session
8202            .commit_transcript_rewrite(
8203                TranscriptRewriteSelection::MessageRange {
8204                    start: 132,
8205                    end: 133,
8206                },
8207                vec![Message::User(UserMessage::text("edited question"))],
8208                TranscriptRewriteReason::new("unit-test-edit"),
8209                Some("unit-test".to_string()),
8210                Some(parent),
8211            )
8212            .expect("rewrite should commit");
8213
8214        for turn in 0..762 {
8215            session.push(Message::User(UserMessage::text(format!("turn {turn}"))));
8216        }
8217
8218        let state = session
8219            .transcript_history_state()
8220            .expect("history state should decode")
8221            .expect("rewrite should create history state");
8222        assert_eq!(session.messages().len(), 895);
8223        assert_eq!(state.commits.len(), 1, "ordinary appends are not rewrites");
8224        assert_eq!(
8225            state.revisions.len(),
8226            3,
8227            "one real rewrite retains its two audited endpoints plus one live head"
8228        );
8229        let retained_message_entries = state
8230            .revisions
8231            .iter()
8232            .map(|body| body.messages.len())
8233            .sum::<usize>();
8234        assert!(retained_message_entries <= 3 * session.messages().len());
8235
8236        let live_bytes = serde_json::to_vec(session.messages())
8237            .expect("live transcript should serialize")
8238            .len();
8239        let snapshot_bytes = serde_json::to_vec(&session)
8240            .expect("session snapshot should serialize")
8241            .len();
8242        assert!(
8243            snapshot_bytes <= live_bytes.saturating_mul(5).saturating_add(64 * 1024),
8244            "snapshot must remain linear in the live transcript: {snapshot_bytes} bytes for {live_bytes} live bytes"
8245        );
8246    }
8247
8248    #[test]
8249    fn repeated_synthetic_notice_refreshes_do_not_mint_rewrite_commits() {
8250        use crate::types::{SystemNoticeKind, SystemNoticeMessage};
8251
8252        let mut session = Session::new();
8253        session.push(Message::User(UserMessage::text("before".to_string())));
8254        session
8255            .commit_transcript_rewrite(
8256                TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
8257                vec![Message::User(UserMessage::text("after".to_string()))],
8258                TranscriptRewriteReason::new("unit-test-edit"),
8259                Some("unit-test".to_string()),
8260                None,
8261            )
8262            .expect("seed rewrite");
8263
8264        for refresh in 0..64 {
8265            session
8266                .replace_synthetic_notices(
8267                    SystemNoticeKind::McpPending,
8268                    vec![Message::SystemNotice(SystemNoticeMessage::new(
8269                        SystemNoticeKind::McpPending,
8270                        format!("refresh {refresh}"),
8271                    ))],
8272                )
8273                .expect("mechanical refresh");
8274        }
8275
8276        let state = session
8277            .transcript_history_state()
8278            .expect("history state")
8279            .expect("seed rewrite history");
8280        assert_eq!(state.commits.len(), 1);
8281        assert_eq!(session.transcript_rewrite_generation().unwrap(), 1);
8282        assert_eq!(state.revisions.len(), 3);
8283    }
8284
8285    #[test]
8286    fn legacy_append_head_chain_compacts_during_session_restore() {
8287        let mut session = Session::new();
8288        session.push(Message::User(UserMessage::text("seed".to_string())));
8289        session
8290            .commit_transcript_rewrite(
8291                TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
8292                vec![Message::User(UserMessage::text(
8293                    "rewritten seed".to_string(),
8294                ))],
8295                TranscriptRewriteReason::new("unit-test-edit"),
8296                Some("unit-test".to_string()),
8297                None,
8298            )
8299            .expect("seed rewrite");
8300
8301        let mut legacy = session
8302            .transcript_history_state()
8303            .expect("history state")
8304            .expect("seed history");
8305        let mut messages = session.messages().to_vec();
8306        let mut previous_head = legacy.head.clone();
8307        for append in 0..32 {
8308            messages.push(Message::User(UserMessage::text(format!(
8309                "legacy append {append}"
8310            ))));
8311            let revision = transcript_messages_digest(&messages).expect("revision digest");
8312            legacy.revisions.push(TranscriptRevisionBody {
8313                revision: revision.clone(),
8314                parent_revision: Some(previous_head),
8315                messages: messages.clone(),
8316                created_at: SystemTime::now(),
8317            });
8318            previous_head = revision;
8319        }
8320        legacy.head = previous_head;
8321        assert_eq!(legacy.revisions.len(), 34, "fixture matches old shape");
8322
8323        let mut envelope = serde_json::to_value(&session).expect("base envelope");
8324        envelope["messages"] = serde_json::to_value(&messages).expect("legacy live messages");
8325        envelope["metadata"][SESSION_TRANSCRIPT_HISTORY_STATE_KEY] =
8326            serde_json::to_value(&legacy).expect("legacy unbounded history");
8327        for body in envelope["metadata"][SESSION_TRANSCRIPT_HISTORY_STATE_KEY]["revisions"]
8328            .as_array_mut()
8329            .expect("legacy revisions")
8330        {
8331            body.as_object_mut()
8332                .expect("legacy revision body")
8333                .remove("parent_revision");
8334        }
8335        let raw = serde_json::to_vec(&envelope).expect("raw legacy bytes");
8336
8337        let restored: Session = serde_json::from_slice(&raw).expect("legacy restore");
8338        let compact = restored
8339            .transcript_history_state()
8340            .expect("compacted state")
8341            .expect("history retained");
8342        assert_eq!(compact.commits, legacy.commits);
8343        assert_eq!(compact.revisions.len(), 3);
8344        validate_transcript_history_state(&compact).expect("compacted history remains valid");
8345        let repaired = serde_json::to_vec(&restored).expect("repaired snapshot");
8346        assert!(
8347            repaired.len() * 4 < raw.len(),
8348            "repair should shed old bodies"
8349        );
8350    }
8351
8352    #[test]
8353    fn snapshot_compaction_does_not_launder_corrupt_old_body() {
8354        let mut session = Session::new();
8355        session.push(Message::User(UserMessage::text("seed".to_string())));
8356        session
8357            .commit_transcript_rewrite(
8358                TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
8359                vec![Message::User(UserMessage::text("rewritten".to_string()))],
8360                TranscriptRewriteReason::new("unit-test-edit"),
8361                Some("unit-test".to_string()),
8362                None,
8363            )
8364            .expect("seed rewrite");
8365        let mut state = session
8366            .transcript_history_state()
8367            .expect("state")
8368            .expect("history");
8369        state.revisions.push(TranscriptRevisionBody {
8370            revision: "sha256:corrupt-old-body".to_string(),
8371            parent_revision: Some(state.head.clone()),
8372            messages: vec![Message::User(UserMessage::text("tampered".to_string()))],
8373            created_at: SystemTime::now(),
8374        });
8375        session.set_metadata_unchecked_for_test(
8376            SESSION_TRANSCRIPT_HISTORY_STATE_KEY,
8377            serde_json::to_value(state).expect("corrupt history value"),
8378        );
8379
8380        assert!(
8381            serde_json::to_vec(&session).is_err(),
8382            "serialization must fail before pruning a corrupt old body"
8383        );
8384    }
8385
8386    #[test]
8387    fn unchecked_valid_history_is_validated_and_compacted_at_snapshot_boundary() {
8388        let mut session = Session::new();
8389        session.push(Message::User(UserMessage::text("seed".to_string())));
8390        session
8391            .commit_transcript_rewrite(
8392                TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
8393                vec![Message::User(UserMessage::text("rewritten".to_string()))],
8394                TranscriptRewriteReason::new("unit-test-edit"),
8395                Some("unit-test".to_string()),
8396                None,
8397            )
8398            .expect("seed rewrite");
8399        let mut state = session
8400            .transcript_history_state()
8401            .expect("state")
8402            .expect("history");
8403        let mut messages = session.messages().to_vec();
8404        let mut parent = state.head.clone();
8405        for index in 0..8 {
8406            messages.push(Message::User(UserMessage::text(format!(
8407                "legacy append {index}"
8408            ))));
8409            let revision = transcript_messages_digest(&messages).expect("revision digest");
8410            state.revisions.push(TranscriptRevisionBody {
8411                revision: revision.clone(),
8412                parent_revision: Some(parent),
8413                messages: messages.clone(),
8414                created_at: SystemTime::now(),
8415            });
8416            parent = revision;
8417        }
8418        state.head = parent;
8419        session.messages = Arc::new(messages);
8420        session.set_metadata_unchecked_for_test(
8421            SESSION_TRANSCRIPT_HISTORY_STATE_KEY,
8422            serde_json::to_value(state).expect("uncompacted history"),
8423        );
8424        assert_eq!(
8425            session.transcript_history_metadata_validation,
8426            TranscriptHistoryMetadataValidation::RequiresValidation
8427        );
8428
8429        let snapshot = serde_json::to_vec(&session)
8430            .expect("valid unchecked history should serialize after validation");
8431        let snapshot: serde_json::Value = serde_json::from_slice(&snapshot).expect("snapshot JSON");
8432        let compact: TranscriptHistoryState = serde_json::from_value(
8433            snapshot["metadata"][SESSION_TRANSCRIPT_HISTORY_STATE_KEY].clone(),
8434        )
8435        .expect("compacted history");
8436
8437        assert_eq!(
8438            compact.revisions.len(),
8439            3,
8440            "snapshot boundary should retain two audited endpoints plus the live head"
8441        );
8442        validate_transcript_history_state(&compact).expect("compacted history remains valid");
8443    }
8444
8445    #[test]
8446    fn transcript_history_rejects_stale_branch_after_digest_recurrence() {
8447        let mut restored = Session::new();
8448        restored.push(Message::User(UserMessage::text("A".to_string())));
8449        restored
8450            .commit_transcript_rewrite(
8451                TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
8452                vec![Message::User(UserMessage::text("B".to_string()))],
8453                TranscriptRewriteReason::new("to-b"),
8454                Some("unit-test".to_string()),
8455                None,
8456            )
8457            .expect("A to B");
8458        let mut stale_branch = restored.clone();
8459        restored
8460            .commit_transcript_rewrite(
8461                TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
8462                vec![Message::User(UserMessage::text("A".to_string()))],
8463                TranscriptRewriteReason::new("restore-a"),
8464                Some("unit-test".to_string()),
8465                None,
8466            )
8467            .expect("B back to A");
8468        stale_branch
8469            .commit_transcript_rewrite(
8470                TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
8471                vec![Message::User(UserMessage::text("C".to_string()))],
8472                TranscriptRewriteReason::new("stale-b-to-c"),
8473                Some("unit-test".to_string()),
8474                None,
8475            )
8476            .expect("stale B to C is locally valid");
8477
8478        let stale_state = stale_branch
8479            .transcript_history_state()
8480            .expect("stale state")
8481            .expect("stale history");
8482        let stale_commit = stale_state.commits.last().expect("stale commit").clone();
8483        let stale_body = stale_state
8484            .revisions
8485            .iter()
8486            .find(|body| body.revision == stale_commit.revision)
8487            .expect("stale revision body")
8488            .clone();
8489        let mut forged = restored
8490            .transcript_history_state()
8491            .expect("restored state")
8492            .expect("restored history");
8493        forged.commits.push(stale_commit);
8494        forged.revisions.push(stale_body);
8495        forged.head = forged
8496            .commits
8497            .last()
8498            .expect("forged commit")
8499            .revision
8500            .clone();
8501
8502        assert!(
8503            validate_transcript_history_state(&forged).is_err(),
8504            "an old B<-A body edge cannot authorize stale B->C after B->A restored A"
8505        );
8506    }
8507
8508    #[test]
8509    fn transcript_history_rejects_orphan_head_parent_cycle() {
8510        let mut session = Session::new();
8511        session.push(Message::User(UserMessage::text("P".to_string())));
8512        session
8513            .commit_transcript_rewrite(
8514                TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
8515                vec![Message::User(UserMessage::text("Q".to_string()))],
8516                TranscriptRewriteReason::new("valid"),
8517                Some("unit-test".to_string()),
8518                None,
8519            )
8520            .expect("valid seed rewrite");
8521        let mut state = session
8522            .transcript_history_state()
8523            .expect("state")
8524            .expect("history");
8525        let x_messages = vec![Message::User(UserMessage::text("X".to_string()))];
8526        let y_messages = vec![Message::User(UserMessage::text("Y".to_string()))];
8527        let x = transcript_messages_digest(&x_messages).expect("X digest");
8528        let y = transcript_messages_digest(&y_messages).expect("Y digest");
8529        state.revisions.push(TranscriptRevisionBody {
8530            revision: x.clone(),
8531            parent_revision: Some(y.clone()),
8532            messages: x_messages,
8533            created_at: SystemTime::now(),
8534        });
8535        state.revisions.push(TranscriptRevisionBody {
8536            revision: y,
8537            parent_revision: Some(x.clone()),
8538            messages: y_messages,
8539            created_at: SystemTime::now(),
8540        });
8541        state.head = x;
8542        session.set_metadata_unchecked_for_test(
8543            SESSION_TRANSCRIPT_HISTORY_STATE_KEY,
8544            serde_json::to_value(state).expect("cyclic state"),
8545        );
8546
8547        assert!(
8548            serde_json::to_vec(&session).is_err(),
8549            "cyclic orphan head lineage must fail instead of looping"
8550        );
8551    }
8552
8553    #[test]
8554    fn mechanical_append_can_recur_to_an_audited_digest_without_mutating_its_body() {
8555        let a = Message::User(UserMessage::text("A".to_string()));
8556        let b = Message::User(UserMessage::text("B".to_string()));
8557        let mut session = Session::new();
8558        session.push(Message::User(UserMessage::text("X".to_string())));
8559        let first = session
8560            .commit_transcript_rewrite(
8561                TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
8562                vec![a.clone(), b.clone()],
8563                TranscriptRewriteReason::new("to-a-b"),
8564                Some("unit-test".to_string()),
8565                None,
8566            )
8567            .expect("X to [A,B]");
8568        let h_parent = session
8569            .transcript_revision_body(&first.revision)
8570            .expect("H body")
8571            .expect("H retained")
8572            .parent_revision;
8573        session
8574            .commit_transcript_rewrite(
8575                TranscriptRewriteSelection::MessageRange { start: 0, end: 2 },
8576                vec![a],
8577                TranscriptRewriteReason::new("to-a"),
8578                Some("unit-test".to_string()),
8579                None,
8580            )
8581            .expect("[A,B] to [A]");
8582
8583        session.push(b);
8584
8585        let state = session
8586            .transcript_history_state()
8587            .expect("state")
8588            .expect("history");
8589        assert_eq!(state.head, first.revision);
8590        assert_eq!(session.transcript_revision().unwrap(), first.revision);
8591        assert_eq!(
8592            state
8593                .revisions
8594                .iter()
8595                .find(|body| body.revision == first.revision)
8596                .expect("recurred H body")
8597                .parent_revision,
8598            h_parent,
8599            "reusing an audited digest must not rewrite its occurrence metadata"
8600        );
8601        validate_transcript_history_state(&state).expect("recurred mechanical head is valid");
8602    }
8603
8604    /// K4 invariant (fail-closed): an invalid replacement is rejected with a
8605    /// typed fault BEFORE any strip happens — the transcript is unchanged, so
8606    /// a fault can never strand a half-refreshed notice state.
8607    #[test]
8608    fn replace_synthetic_notices_rejects_mismatched_kind_without_mutation() {
8609        use crate::types::{SystemNoticeKind, SystemNoticeMessage};
8610
8611        let mut session = Session::new();
8612        session.push(Message::SystemNotice(SystemNoticeMessage::new(
8613            SystemNoticeKind::McpPending,
8614            "stale",
8615        )));
8616        let before = session.messages().to_vec();
8617
8618        let err = session
8619            .replace_synthetic_notices(
8620                SystemNoticeKind::McpPending,
8621                vec![Message::User(UserMessage::text("not a notice".to_string()))],
8622            )
8623            .expect_err("mismatched replacement must fail typed");
8624        assert!(
8625            matches!(err, TranscriptEditError::InvalidTranscriptShape(_)),
8626            "expected InvalidTranscriptShape, got {err:?}"
8627        );
8628        assert_eq!(
8629            session.messages(),
8630            before.as_slice(),
8631            "fault must leave the transcript unchanged (no partial strip)"
8632        );
8633    }
8634
8635    #[test]
8636    fn replace_synthetic_notices_rejects_malformed_history_atomically() {
8637        use crate::types::{SystemNoticeKind, SystemNoticeMessage};
8638
8639        let mut session = Session::new();
8640        session.push(Message::User(UserMessage::text("before".to_string())));
8641        session
8642            .commit_transcript_rewrite(
8643                TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
8644                vec![Message::User(UserMessage::text("after".to_string()))],
8645                TranscriptRewriteReason::new("unit-test-edit"),
8646                Some("unit-test".to_string()),
8647                None,
8648            )
8649            .expect("seed rewrite");
8650        session.push(Message::SystemNotice(SystemNoticeMessage::new(
8651            SystemNoticeKind::McpPending,
8652            "stale",
8653        )));
8654        let mut state = session
8655            .transcript_history_state()
8656            .expect("state")
8657            .expect("history");
8658        state.revisions[0].messages[0] = Message::User(UserMessage::text("tampered".to_string()));
8659        session.set_metadata_unchecked_for_test(
8660            SESSION_TRANSCRIPT_HISTORY_STATE_KEY,
8661            serde_json::to_value(state).expect("corrupt state"),
8662        );
8663        let before_messages = session.messages.clone();
8664        let before_metadata = session.metadata.clone();
8665        let before_updated_at = session.updated_at;
8666
8667        assert!(
8668            session
8669                .replace_synthetic_notices(SystemNoticeKind::McpPending, Vec::new())
8670                .is_err()
8671        );
8672        assert_eq!(session.messages, before_messages);
8673        assert_eq!(session.metadata, before_metadata);
8674        assert_eq!(session.updated_at, before_updated_at);
8675    }
8676
8677    #[test]
8678    fn replace_synthetic_notices_rejects_durable_notice_kinds() {
8679        use crate::types::SystemNoticeKind;
8680
8681        let mut session = Session::new();
8682        let before = session.messages().to_vec();
8683        assert!(
8684            session
8685                .replace_synthetic_notices(SystemNoticeKind::Comms, Vec::new())
8686                .is_err()
8687        );
8688        assert_eq!(session.messages(), before);
8689    }
8690
8691    #[test]
8692    fn replace_synthetic_notices_preserves_persisted_mcp_pending_notice() {
8693        use crate::types::{SystemNoticeBlock, SystemNoticeKind, SystemNoticeMessage};
8694
8695        let mut session = Session::new();
8696        session.push(Message::SystemNotice(SystemNoticeMessage::with_block(
8697            SystemNoticeKind::McpPending,
8698            Some("persisted pending fact".to_string()),
8699            SystemNoticeBlock::Mcp {
8700                server_id: Some("server".to_string()),
8701                operation: None,
8702                phase: None,
8703                persisted: true,
8704                detail: None,
8705                pending_sources: Vec::new(),
8706            },
8707        )));
8708        let before = session.messages().to_vec();
8709
8710        session
8711            .replace_synthetic_notices(SystemNoticeKind::McpPending, Vec::new())
8712            .expect("synthetic refresh must coexist with a durable notice of the same kind");
8713        assert_eq!(session.messages(), before);
8714    }
8715
8716    #[test]
8717    fn replace_synthetic_notices_replaces_projection_beside_persisted_mcp_fact() {
8718        use crate::types::{SystemNoticeBlock, SystemNoticeKind, SystemNoticeMessage};
8719
8720        let durable = Message::SystemNotice(SystemNoticeMessage::with_block(
8721            SystemNoticeKind::McpPending,
8722            Some("persisted pending fact".to_string()),
8723            SystemNoticeBlock::Mcp {
8724                server_id: Some("server".to_string()),
8725                operation: None,
8726                phase: None,
8727                persisted: true,
8728                detail: None,
8729                pending_sources: Vec::new(),
8730            },
8731        ));
8732        let stale = Message::SystemNotice(SystemNoticeMessage::new(
8733            SystemNoticeKind::McpPending,
8734            "stale synthetic projection",
8735        ));
8736        let fresh = Message::SystemNotice(SystemNoticeMessage::new(
8737            SystemNoticeKind::McpPending,
8738            "fresh synthetic projection",
8739        ));
8740        let mut session = Session::new();
8741        session.push(durable.clone());
8742        session.push(stale);
8743
8744        session
8745            .replace_synthetic_notices(SystemNoticeKind::McpPending, vec![fresh.clone()])
8746            .expect("synthetic refresh beside durable fact");
8747
8748        assert_eq!(session.messages(), &[durable, fresh]);
8749    }
8750
8751    #[test]
8752    fn transcript_rewrite_preserves_full_assistant_block_trace() {
8753        let mut session = Session::new();
8754        session.push(Message::User(UserMessage::text(
8755            "run the trace".to_string(),
8756        )));
8757        session.push(Message::BlockAssistant(BlockAssistantMessage::new(
8758            vec![AssistantBlock::Text {
8759                text: "original assistant trace".to_string(),
8760                meta: None,
8761            }],
8762            StopReason::EndTurn,
8763        )));
8764
8765        let parent_revision = session.transcript_revision().expect("parent revision");
8766        let replacement = vec![
8767            Message::BlockAssistant(BlockAssistantMessage::new(
8768                vec![
8769                    AssistantBlock::Text {
8770                        text: "compacted assistant trace".to_string(),
8771                        meta: None,
8772                    },
8773                    AssistantBlock::ToolUse {
8774                        id: "toolu_trace".to_string(),
8775                        name: "trace_probe".to_string(),
8776                        args: serde_json::value::RawValue::from_string(
8777                            r#"{"path":"N-3"}"#.to_string(),
8778                        )
8779                        .expect("valid tool args"),
8780                        meta: None,
8781                    },
8782                ],
8783                StopReason::ToolUse,
8784            )),
8785            Message::tool_results(vec![ToolResult::new(
8786                "toolu_trace".to_string(),
8787                "trace complete".to_string(),
8788                false,
8789            )]),
8790        ];
8791
8792        let commit = session
8793            .commit_transcript_rewrite(
8794                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
8795                replacement,
8796                TranscriptRewriteReason::new("compaction"),
8797                Some("unit-test".to_string()),
8798                Some(parent_revision.clone()),
8799            )
8800            .expect("rewrite should commit");
8801
8802        assert_eq!(commit.parent_revision, parent_revision);
8803        let current = session
8804            .transcript_revision_messages(&commit.revision)
8805            .expect("history state should decode")
8806            .expect("current revision should be retained");
8807        let Message::BlockAssistant(assistant) = &current[1] else {
8808            panic!("replacement should remain a block assistant message");
8809        };
8810        assert!(assistant.blocks.iter().any(|block| matches!(
8811            block,
8812            AssistantBlock::ToolUse { name, args, .. }
8813                if name == "trace_probe" && args.get().contains("\"N-3\"")
8814        )));
8815
8816        let parent = session
8817            .transcript_revision_messages(&parent_revision)
8818            .expect("history state should decode")
8819            .expect("parent revision should remain retained");
8820        assert!(matches!(
8821            &parent[1],
8822            Message::BlockAssistant(assistant)
8823                if block_assistant_text(assistant).contains("original assistant trace")
8824        ));
8825    }
8826
8827    #[test]
8828    fn transcript_rewrite_rejects_trailing_block_assistant_tool_call() {
8829        let mut session = Session::new();
8830        session.push(Message::User(UserMessage::text("question".to_string())));
8831        session.push(Message::BlockAssistant(BlockAssistantMessage {
8832            blocks: vec![AssistantBlock::Text {
8833                text: "plain answer".to_string(),
8834                meta: None,
8835            }],
8836            stop_reason: StopReason::EndTurn,
8837            identity: crate::types::TranscriptMessageIdentity::default(),
8838            created_at: crate::types::message_timestamp_now(),
8839        }));
8840        let parent_revision = session.transcript_revision().expect("parent revision");
8841
8842        let err = session
8843            .commit_transcript_rewrite(
8844                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
8845                vec![Message::BlockAssistant(BlockAssistantMessage::new(
8846                    vec![AssistantBlock::ToolUse {
8847                        id: "toolu_1".to_string(),
8848                        name: "lookup".to_string(),
8849                        args: serde_json::value::RawValue::from_string("{}".to_string())
8850                            .expect("valid args"),
8851                        meta: None,
8852                    }],
8853                    StopReason::ToolUse,
8854                ))],
8855                TranscriptRewriteReason::new("compaction"),
8856                Some("unit-test".to_string()),
8857                Some(parent_revision),
8858            )
8859            .expect_err("rewrite should reject trailing unresolved block-assistant tool call");
8860        assert!(matches!(
8861            err,
8862            TranscriptEditError::InvalidTranscriptShape(_)
8863        ));
8864    }
8865
8866    #[test]
8867    fn transcript_rewrite_rejects_no_op_self_edge() {
8868        let mut session = Session::new();
8869        session.push(Message::User(UserMessage::text(
8870            "keep this exact transcript".to_string(),
8871        )));
8872        session.push(Message::BlockAssistant(BlockAssistantMessage {
8873            blocks: vec![AssistantBlock::Text {
8874                text: "unchanged".to_string(),
8875                meta: None,
8876            }],
8877            stop_reason: StopReason::EndTurn,
8878            identity: crate::types::TranscriptMessageIdentity::default(),
8879            created_at: crate::types::message_timestamp_now(),
8880        }));
8881
8882        let parent_revision = session.transcript_revision().expect("parent revision");
8883        let err = session
8884            .commit_transcript_rewrite(
8885                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
8886                vec![session.messages()[1].clone()],
8887                TranscriptRewriteReason::new("retry"),
8888                Some("unit-test".to_string()),
8889                Some(parent_revision.clone()),
8890            )
8891            .expect_err("same-content rewrite should not emit a self-edge commit");
8892
8893        assert!(matches!(
8894            err,
8895            TranscriptEditError::NoOpRewrite { revision } if revision == parent_revision
8896        ));
8897        assert!(
8898            session
8899                .transcript_history_state()
8900                .expect("history state should decode")
8901                .is_none()
8902        );
8903    }
8904
8905    #[test]
8906    fn transcript_rewrite_run_boundary_guard_accepts_rewrite_then_append() {
8907        let mut original = Session::new();
8908        original.push(Message::User(UserMessage::text("question".to_string())));
8909        original.push(Message::BlockAssistant(BlockAssistantMessage {
8910            blocks: vec![AssistantBlock::Text {
8911                text: "verbose answer".to_string(),
8912                meta: None,
8913            }],
8914            stop_reason: StopReason::EndTurn,
8915            identity: crate::types::TranscriptMessageIdentity::default(),
8916            created_at: crate::types::message_timestamp_now(),
8917        }));
8918
8919        let parent_revision = original.transcript_revision().expect("parent revision");
8920        let mut incoming = original.clone();
8921        incoming
8922            .commit_transcript_rewrite(
8923                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
8924                vec![Message::BlockAssistant(BlockAssistantMessage {
8925                    blocks: vec![AssistantBlock::Text {
8926                        text: "compact answer".to_string(),
8927                        meta: None,
8928                    }],
8929                    stop_reason: StopReason::EndTurn,
8930                    identity: crate::types::TranscriptMessageIdentity::default(),
8931                    created_at: crate::types::message_timestamp_now(),
8932                })],
8933                TranscriptRewriteReason::new("compaction"),
8934                Some("unit-test".to_string()),
8935                Some(parent_revision),
8936            )
8937            .expect("rewrite should commit");
8938        incoming.push(Message::User(UserMessage::text("follow-up".to_string())));
8939        incoming.push(Message::BlockAssistant(BlockAssistantMessage {
8940            blocks: vec![AssistantBlock::Text {
8941                text: "follow-up answer".to_string(),
8942                meta: None,
8943            }],
8944            stop_reason: StopReason::EndTurn,
8945            identity: crate::types::TranscriptMessageIdentity::default(),
8946            created_at: crate::types::message_timestamp_now(),
8947        }));
8948
8949        crate::session_store::run_boundary_snapshot_save_guard(&incoming, Some(&original))
8950            .expect("rewrite plus appended turn should be a valid run-boundary commit");
8951    }
8952
8953    #[test]
8954    fn transcript_rewrite_rejects_orphaned_tool_results() {
8955        let mut session = Session::new();
8956        session.push(Message::User(UserMessage::text("use a tool".to_string())));
8957        session.push(Message::BlockAssistant(BlockAssistantMessage::new(
8958            vec![AssistantBlock::ToolUse {
8959                id: "toolu_1".to_string(),
8960                name: "lookup".to_string(),
8961                args: serde_json::value::RawValue::from_string("{}".to_string())
8962                    .expect("valid args"),
8963                meta: None,
8964            }],
8965            StopReason::ToolUse,
8966        )));
8967        session.push(Message::tool_results(vec![ToolResult::new(
8968            "toolu_1".to_string(),
8969            "done".to_string(),
8970            false,
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![Message::BlockAssistant(BlockAssistantMessage {
8978                    blocks: vec![AssistantBlock::Text {
8979                        text: "no tool after all".to_string(),
8980                        meta: None,
8981                    }],
8982                    stop_reason: StopReason::EndTurn,
8983                    identity: crate::types::TranscriptMessageIdentity::default(),
8984                    created_at: crate::types::message_timestamp_now(),
8985                })],
8986                TranscriptRewriteReason::new("compaction"),
8987                Some("unit-test".to_string()),
8988                Some(parent_revision),
8989            )
8990            .expect_err("rewrite should reject stranded tool results");
8991        assert!(matches!(
8992            err,
8993            TranscriptEditError::InvalidTranscriptShape(_)
8994        ));
8995    }
8996
8997    #[test]
8998    fn transcript_rewrite_rejects_trailing_assistant_tool_call() {
8999        let mut session = Session::new();
9000        session.push(Message::User(UserMessage::text("question".to_string())));
9001        session.push(Message::BlockAssistant(BlockAssistantMessage {
9002            blocks: vec![AssistantBlock::Text {
9003                text: "plain answer".to_string(),
9004                meta: None,
9005            }],
9006            stop_reason: StopReason::EndTurn,
9007            identity: crate::types::TranscriptMessageIdentity::default(),
9008            created_at: crate::types::message_timestamp_now(),
9009        }));
9010        let parent_revision = session.transcript_revision().expect("parent revision");
9011
9012        let err = session
9013            .commit_transcript_rewrite(
9014                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
9015                vec![Message::BlockAssistant(BlockAssistantMessage {
9016                    blocks: vec![AssistantBlock::ToolUse {
9017                        id: "toolu_1".to_string(),
9018                        name: "lookup".to_string(),
9019                        args: serde_json::value::RawValue::from_string("{}".to_string())
9020                            .expect("valid args"),
9021                        meta: None,
9022                    }],
9023                    stop_reason: StopReason::ToolUse,
9024                    identity: crate::types::TranscriptMessageIdentity::default(),
9025                    created_at: crate::types::message_timestamp_now(),
9026                })],
9027                TranscriptRewriteReason::new("compaction"),
9028                Some("unit-test".to_string()),
9029                Some(parent_revision),
9030            )
9031            .expect_err("rewrite should reject trailing unresolved tool call");
9032        assert!(matches!(
9033            err,
9034            TranscriptEditError::InvalidTranscriptShape(_)
9035        ));
9036    }
9037
9038    #[test]
9039    fn transcript_rewrite_rejects_duplicate_tool_results() {
9040        let mut session = Session::new();
9041        session.push(Message::User(UserMessage::text("use a tool".to_string())));
9042        session.push(Message::BlockAssistant(BlockAssistantMessage {
9043            blocks: vec![AssistantBlock::Text {
9044                text: "plain answer".to_string(),
9045                meta: None,
9046            }],
9047            stop_reason: StopReason::EndTurn,
9048            identity: crate::types::TranscriptMessageIdentity::default(),
9049            created_at: crate::types::message_timestamp_now(),
9050        }));
9051        let parent_revision = session.transcript_revision().expect("parent revision");
9052
9053        let err = session
9054            .commit_transcript_rewrite(
9055                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
9056                vec![
9057                    Message::BlockAssistant(BlockAssistantMessage::new(
9058                        vec![AssistantBlock::ToolUse {
9059                            id: "toolu_1".to_string(),
9060                            name: "lookup".to_string(),
9061                            args: serde_json::value::RawValue::from_string("{}".to_string())
9062                                .expect("valid args"),
9063                            meta: None,
9064                        }],
9065                        StopReason::ToolUse,
9066                    )),
9067                    Message::tool_results(vec![
9068                        ToolResult::new("toolu_1".to_string(), "one".to_string(), false),
9069                        ToolResult::new("toolu_1".to_string(), "two".to_string(), false),
9070                    ]),
9071                ],
9072                TranscriptRewriteReason::new("compaction"),
9073                Some("unit-test".to_string()),
9074                Some(parent_revision),
9075            )
9076            .expect_err("rewrite should reject duplicate tool results");
9077        assert!(matches!(
9078            err,
9079            TranscriptEditError::InvalidTranscriptShape(_)
9080        ));
9081    }
9082
9083    #[test]
9084    fn transcript_rewrite_record_rejects_prefix_or_suffix_tampering() {
9085        let mut session = Session::new();
9086        session.push(Message::System(SystemMessage::new("keep prefix")));
9087        session.push(Message::BlockAssistant(BlockAssistantMessage {
9088            blocks: vec![AssistantBlock::Text {
9089                text: "verbose answer".to_string(),
9090                meta: None,
9091            }],
9092            stop_reason: StopReason::EndTurn,
9093            identity: crate::types::TranscriptMessageIdentity::default(),
9094            created_at: crate::types::message_timestamp_now(),
9095        }));
9096        session.push(Message::User(UserMessage::text("keep suffix".to_string())));
9097
9098        let parent_revision = session.transcript_revision().expect("parent revision");
9099        let commit = session
9100            .commit_transcript_rewrite(
9101                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
9102                vec![Message::BlockAssistant(BlockAssistantMessage {
9103                    blocks: vec![AssistantBlock::Text {
9104                        text: "compact 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                TranscriptRewriteReason::new("compaction"),
9112                Some("unit-test".to_string()),
9113                Some(parent_revision),
9114            )
9115            .expect("rewrite should commit");
9116        let state = session
9117            .transcript_history_state()
9118            .expect("history state should decode")
9119            .expect("history state should exist");
9120        let parent_body = state
9121            .revisions
9122            .iter()
9123            .find(|body| body.revision == commit.parent_revision)
9124            .expect("parent body retained")
9125            .clone();
9126        let revision_body = state
9127            .revisions
9128            .iter()
9129            .find(|body| body.revision == commit.revision)
9130            .expect("revision body retained")
9131            .clone();
9132
9133        let mut forged_body = revision_body;
9134        forged_body.messages[0] = Message::System(SystemMessage::new("tampered prefix"));
9135        forged_body.revision =
9136            transcript_messages_digest(&forged_body.messages).expect("forged digest");
9137        let mut forged_commit = commit;
9138        forged_commit.revision = forged_body.revision.clone();
9139        let err = TranscriptRewriteRecord::new(forged_commit, parent_body, forged_body)
9140            .expect_err("record validation must reject changes outside selected span");
9141        assert!(
9142            err.to_string().contains("before the selected span"),
9143            "unexpected error: {err}"
9144        );
9145    }
9146
9147    #[test]
9148    fn transcript_rewrite_replay_allows_normal_turn_revisions_between_rewrites() {
9149        let mut session = Session::new();
9150        session.push(Message::User(UserMessage::text("first".to_string())));
9151        session.push(Message::BlockAssistant(BlockAssistantMessage {
9152            blocks: vec![AssistantBlock::Text {
9153                text: "verbose first answer".to_string(),
9154                meta: None,
9155            }],
9156            stop_reason: StopReason::EndTurn,
9157            identity: crate::types::TranscriptMessageIdentity::default(),
9158            created_at: crate::types::message_timestamp_now(),
9159        }));
9160
9161        let first_parent = session.transcript_revision().expect("first parent");
9162        let first_commit = session
9163            .commit_transcript_rewrite(
9164                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
9165                vec![Message::BlockAssistant(BlockAssistantMessage {
9166                    blocks: vec![AssistantBlock::Text {
9167                        text: "compact first answer".to_string(),
9168                        meta: None,
9169                    }],
9170                    stop_reason: StopReason::EndTurn,
9171                    identity: crate::types::TranscriptMessageIdentity::default(),
9172                    created_at: crate::types::message_timestamp_now(),
9173                })],
9174                TranscriptRewriteReason::new("compaction"),
9175                Some("unit-test".to_string()),
9176                Some(first_parent),
9177            )
9178            .expect("first rewrite");
9179
9180        session.push(Message::User(UserMessage::text("normal turn".to_string())));
9181        session.push(Message::BlockAssistant(BlockAssistantMessage {
9182            blocks: vec![AssistantBlock::Text {
9183                text: "verbose second answer".to_string(),
9184                meta: None,
9185            }],
9186            stop_reason: StopReason::EndTurn,
9187            identity: crate::types::TranscriptMessageIdentity::default(),
9188            created_at: crate::types::message_timestamp_now(),
9189        }));
9190        let bridge_parent = session
9191            .transcript_revision()
9192            .expect("normal turn should advance transcript head");
9193        assert_ne!(bridge_parent, first_commit.revision);
9194        validate_transcript_history_state(
9195            &session
9196                .transcript_history_state()
9197                .expect("history state should decode")
9198                .expect("history state should exist"),
9199        )
9200        .expect("normal turn head may legitimately differ from last rewrite commit");
9201
9202        let second_commit = session
9203            .commit_transcript_rewrite(
9204                TranscriptRewriteSelection::MessageRange { start: 3, end: 4 },
9205                vec![Message::BlockAssistant(BlockAssistantMessage {
9206                    blocks: vec![AssistantBlock::Text {
9207                        text: "compact second answer".to_string(),
9208                        meta: None,
9209                    }],
9210                    stop_reason: StopReason::EndTurn,
9211                    identity: crate::types::TranscriptMessageIdentity::default(),
9212                    created_at: crate::types::message_timestamp_now(),
9213                })],
9214                TranscriptRewriteReason::new("compaction"),
9215                Some("unit-test".to_string()),
9216                Some(bridge_parent.clone()),
9217            )
9218            .expect("second rewrite");
9219
9220        let state = session
9221            .transcript_history_state()
9222            .expect("history state should decode")
9223            .expect("history state should exist");
9224        let records = state.commits.iter().map(|commit| {
9225            let parent_body = state
9226                .revisions
9227                .iter()
9228                .find(|body| body.revision == commit.parent_revision)
9229                .expect("parent body retained")
9230                .clone();
9231            let revision_body = state
9232                .revisions
9233                .iter()
9234                .find(|body| body.revision == commit.revision)
9235                .expect("revision body retained")
9236                .clone();
9237            TranscriptRewriteRecord::new(commit.clone(), parent_body, revision_body)
9238                .expect("record should validate")
9239        });
9240
9241        let replayed = TranscriptHistoryState::from_rewrite_records(records)
9242            .expect("rewrite replay should accept normal-turn bridge revisions")
9243            .expect("rewrite records should exist");
9244        assert_eq!(replayed.head, second_commit.revision);
9245        assert!(
9246            replayed
9247                .revisions
9248                .iter()
9249                .any(|body| body.revision == bridge_parent)
9250        );
9251    }
9252
9253    #[test]
9254    fn transcript_rewrite_replay_rejects_branched_rewrite_records() {
9255        let mut base = Session::new();
9256        base.push(Message::User(UserMessage::text("question".to_string())));
9257        base.push(Message::BlockAssistant(BlockAssistantMessage {
9258            blocks: vec![AssistantBlock::Text {
9259                text: "verbose answer".to_string(),
9260                meta: None,
9261            }],
9262            stop_reason: StopReason::EndTurn,
9263            identity: crate::types::TranscriptMessageIdentity::default(),
9264            created_at: crate::types::message_timestamp_now(),
9265        }));
9266        let parent = base.transcript_revision().expect("parent revision");
9267
9268        let mut first = base.clone();
9269        let first_commit = first
9270            .commit_transcript_rewrite(
9271                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
9272                vec![Message::BlockAssistant(BlockAssistantMessage {
9273                    blocks: vec![AssistantBlock::Text {
9274                        text: "first compact answer".to_string(),
9275                        meta: None,
9276                    }],
9277                    stop_reason: StopReason::EndTurn,
9278                    identity: crate::types::TranscriptMessageIdentity::default(),
9279                    created_at: crate::types::message_timestamp_now(),
9280                })],
9281                TranscriptRewriteReason::new("compaction"),
9282                Some("unit-test".to_string()),
9283                Some(parent.clone()),
9284            )
9285            .expect("first rewrite");
9286        let first_state = first
9287            .transcript_history_state()
9288            .expect("first state decodes")
9289            .expect("first state exists");
9290
9291        let mut second = base;
9292        let second_commit = second
9293            .commit_transcript_rewrite(
9294                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
9295                vec![Message::BlockAssistant(BlockAssistantMessage {
9296                    blocks: vec![AssistantBlock::Text {
9297                        text: "second compact answer".to_string(),
9298                        meta: None,
9299                    }],
9300                    stop_reason: StopReason::EndTurn,
9301                    identity: crate::types::TranscriptMessageIdentity::default(),
9302                    created_at: crate::types::message_timestamp_now(),
9303                })],
9304                TranscriptRewriteReason::new("compaction"),
9305                Some("unit-test".to_string()),
9306                Some(parent),
9307            )
9308            .expect("second rewrite");
9309        let second_state = second
9310            .transcript_history_state()
9311            .expect("second state decodes")
9312            .expect("second state exists");
9313
9314        let record = |state: &TranscriptHistoryState, commit: &TranscriptRewriteCommit| {
9315            let parent_body = state
9316                .revisions
9317                .iter()
9318                .find(|body| body.revision == commit.parent_revision)
9319                .expect("parent body retained")
9320                .clone();
9321            let revision_body = state
9322                .revisions
9323                .iter()
9324                .find(|body| body.revision == commit.revision)
9325                .expect("revision body retained")
9326                .clone();
9327            TranscriptRewriteRecord::new(commit.clone(), parent_body, revision_body)
9328                .expect("record should validate")
9329        };
9330
9331        let err = TranscriptHistoryState::from_rewrite_records(vec![
9332            record(&first_state, &first_commit),
9333            record(&second_state, &second_commit),
9334        ])
9335        .expect_err("branched rewrite records must not replay as a linear source history");
9336        assert!(
9337            err.to_string().contains("does not extend transcript head"),
9338            "unexpected error: {err}"
9339        );
9340    }
9341
9342    #[test]
9343    fn internal_message_rewrites_refresh_transcript_history_head() {
9344        let mut session = Session::new();
9345        session.push(Message::User(UserMessage::text("question".to_string())));
9346        session.push(Message::BlockAssistant(BlockAssistantMessage {
9347            blocks: vec![AssistantBlock::Text {
9348                text: "verbose answer".to_string(),
9349                meta: None,
9350            }],
9351            stop_reason: StopReason::EndTurn,
9352            identity: crate::types::TranscriptMessageIdentity::default(),
9353            created_at: crate::types::message_timestamp_now(),
9354        }));
9355
9356        let parent = session.transcript_revision().expect("parent revision");
9357        session
9358            .commit_transcript_rewrite(
9359                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
9360                vec![Message::BlockAssistant(BlockAssistantMessage {
9361                    blocks: vec![AssistantBlock::Text {
9362                        text: "compact answer".to_string(),
9363                        meta: None,
9364                    }],
9365                    stop_reason: StopReason::EndTurn,
9366                    identity: crate::types::TranscriptMessageIdentity::default(),
9367                    created_at: crate::types::message_timestamp_now(),
9368                })],
9369                TranscriptRewriteReason::new("compaction"),
9370                Some("unit-test".to_string()),
9371                Some(parent),
9372            )
9373            .expect("rewrite should commit");
9374
9375        session.push(Message::User(UserMessage::text(
9376            "notice-bearing turn".to_string(),
9377        )));
9378        let retained = session
9379            .messages()
9380            .iter()
9381            .filter(|message| {
9382                !matches!(
9383                    message,
9384                    Message::User(user)
9385                        if user.content.iter().any(|block| matches!(
9386                            block,
9387                            ContentBlock::Text { text } if text.contains("notice-bearing")
9388                        ))
9389                )
9390            })
9391            .cloned()
9392            .collect();
9393        session
9394            .replace_messages_internal(
9395                retained,
9396                TranscriptRewriteReason::new("synthetic_notice_cleanup"),
9397            )
9398            .expect("retain should commit internal rewrite");
9399        let retained_digest =
9400            transcript_messages_digest(session.messages()).expect("retained digest");
9401        assert_eq!(
9402            session.transcript_revision().expect("retained head"),
9403            retained_digest
9404        );
9405
9406        session
9407            .replace_messages_internal(
9408                vec![
9409                    Message::User(UserMessage::text("compacted question".to_string())),
9410                    Message::BlockAssistant(BlockAssistantMessage {
9411                        blocks: vec![AssistantBlock::Text {
9412                            text: "compacted answer".to_string(),
9413                            meta: None,
9414                        }],
9415                        stop_reason: StopReason::EndTurn,
9416                        identity: crate::types::TranscriptMessageIdentity::default(),
9417                        created_at: crate::types::message_timestamp_now(),
9418                    }),
9419                ],
9420                TranscriptRewriteReason::new("compaction"),
9421            )
9422            .expect("replace should commit internal rewrite");
9423        let replaced_digest =
9424            transcript_messages_digest(session.messages()).expect("replaced digest");
9425        assert_eq!(
9426            session.transcript_revision().expect("replaced head"),
9427            replaced_digest
9428        );
9429        let state = session
9430            .transcript_history_state()
9431            .expect("history state should decode")
9432            .expect("history state should exist");
9433        assert!(
9434            state
9435                .revisions
9436                .iter()
9437                .any(|body| body.revision == replaced_digest)
9438        );
9439        validate_transcript_history_state(&state).expect("history state remains valid");
9440    }
9441
9442    #[test]
9443    fn set_system_prompt_refreshes_transcript_history_head_after_rewrite() {
9444        let mut session = Session::new();
9445        session.push(Message::User(UserMessage::text("question".to_string())));
9446        session.push(Message::BlockAssistant(BlockAssistantMessage {
9447            blocks: vec![AssistantBlock::Text {
9448                text: "verbose answer".to_string(),
9449                meta: None,
9450            }],
9451            stop_reason: StopReason::EndTurn,
9452            identity: crate::types::TranscriptMessageIdentity::default(),
9453            created_at: crate::types::message_timestamp_now(),
9454        }));
9455
9456        let parent = session.transcript_revision().expect("parent revision");
9457        let rewrite = session
9458            .commit_transcript_rewrite(
9459                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
9460                vec![Message::BlockAssistant(BlockAssistantMessage {
9461                    blocks: vec![AssistantBlock::Text {
9462                        text: "compact answer".to_string(),
9463                        meta: None,
9464                    }],
9465                    stop_reason: StopReason::EndTurn,
9466                    identity: crate::types::TranscriptMessageIdentity::default(),
9467                    created_at: crate::types::message_timestamp_now(),
9468                })],
9469                TranscriptRewriteReason::new("compaction"),
9470                Some("unit-test".to_string()),
9471                Some(parent),
9472            )
9473            .expect("rewrite should commit");
9474
9475        session.set_system_prompt("durable system prompt".to_string());
9476
9477        let head = session
9478            .transcript_revision()
9479            .expect("system prompt should refresh transcript head");
9480        assert_ne!(head, rewrite.revision);
9481        assert_eq!(
9482            head,
9483            transcript_messages_digest(session.messages()).expect("current digest")
9484        );
9485        let head_messages = session
9486            .transcript_revision_messages(&head)
9487            .expect("history state should decode")
9488            .expect("refreshed head body should be retained");
9489        assert_eq!(
9490            serde_json::to_value(&head_messages).expect("head serializes"),
9491            serde_json::to_value(session.messages()).expect("session serializes")
9492        );
9493        validate_transcript_history_state(
9494            &session
9495                .transcript_history_state()
9496                .expect("history state should decode")
9497                .expect("history state should exist"),
9498        )
9499        .expect("history state remains valid after system prompt update");
9500    }
9501
9502    #[test]
9503    fn apply_transcript_history_state_uses_latest_commit_time_for_restored_head() {
9504        let mut session = Session::new();
9505        session.push(Message::User(UserMessage::text("question".to_string())));
9506        session.push(Message::BlockAssistant(BlockAssistantMessage {
9507            blocks: vec![AssistantBlock::Text {
9508                text: "verbose answer".to_string(),
9509                meta: None,
9510            }],
9511            stop_reason: StopReason::EndTurn,
9512            identity: crate::types::TranscriptMessageIdentity::default(),
9513            created_at: crate::types::message_timestamp_now(),
9514        }));
9515        let original_messages = session.messages().to_vec();
9516        let parent = session.transcript_revision().expect("parent revision");
9517        let compact = session
9518            .commit_transcript_rewrite(
9519                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
9520                vec![Message::BlockAssistant(BlockAssistantMessage {
9521                    blocks: vec![AssistantBlock::Text {
9522                        text: "compact answer".to_string(),
9523                        meta: None,
9524                    }],
9525                    stop_reason: StopReason::EndTurn,
9526                    identity: crate::types::TranscriptMessageIdentity::default(),
9527                    created_at: crate::types::message_timestamp_now(),
9528                })],
9529                TranscriptRewriteReason::new("compaction"),
9530                Some("unit-test".to_string()),
9531                Some(parent.clone()),
9532            )
9533            .expect("rewrite should commit");
9534
9535        std::thread::sleep(std::time::Duration::from_millis(2));
9536        let restore = session
9537            .commit_transcript_rewrite(
9538                TranscriptRewriteSelection::MessageRange {
9539                    start: 0,
9540                    end: session.messages().len(),
9541                },
9542                original_messages.clone(),
9543                TranscriptRewriteReason::new("restore"),
9544                Some("unit-test".to_string()),
9545                Some(compact.revision),
9546            )
9547            .expect("restore should commit");
9548        assert_eq!(restore.revision, parent);
9549
9550        let state = session
9551            .transcript_history_state()
9552            .expect("history state should decode")
9553            .expect("history state should exist");
9554        let restored_body_created_at = state
9555            .revisions
9556            .iter()
9557            .find(|body| body.revision == restore.revision)
9558            .expect("restored body should be retained")
9559            .created_at;
9560        assert!(
9561            restored_body_created_at < restore.committed_at,
9562            "test requires restore commit to be newer than retained body"
9563        );
9564
9565        let mut replayed = Session::new();
9566        replayed
9567            .apply_transcript_history_state(state)
9568            .expect("replay should materialize restored head");
9569        assert_eq!(
9570            serde_json::to_value(replayed.messages()).expect("replayed serializes"),
9571            serde_json::to_value(&original_messages).expect("original serializes")
9572        );
9573        assert_eq!(replayed.updated_at(), restore.committed_at);
9574    }
9575
9576    #[test]
9577    fn test_session_new() {
9578        let session = Session::new();
9579        assert_eq!(session.version(), SESSION_VERSION);
9580        assert!(session.messages().is_empty());
9581        assert!(session.created_at() <= session.updated_at());
9582    }
9583
9584    #[test]
9585    fn llm_identity_model_override_switches_to_catalog_provider() {
9586        let registry = crate::ModelRegistry::from_config(
9587            &crate::Config::default(),
9588            *crate::model_profile::test_catalog::TEST_CATALOG,
9589        )
9590        .unwrap();
9591        let current = SessionLlmIdentity {
9592            model: "test-anthropic-default".to_string(),
9593            provider: Provider::Anthropic,
9594            self_hosted_server_id: None,
9595            provider_params: None,
9596            auth_binding: Some(crate::AuthBindingRef {
9597                realm: crate::RealmId::parse("tenant_a").unwrap(),
9598                binding: crate::BindingId::parse("anthropic_default").unwrap(),
9599                profile: None,
9600                origin: crate::BindingOrigin::Configured,
9601            }),
9602        };
9603
9604        let resolved = resolve_session_llm_identity_override(
9605            &current,
9606            &registry,
9607            SessionLlmIdentityOverride {
9608                model: Some("test-openai-default"),
9609                provider: None,
9610                self_hosted_server_id: None,
9611                provider_params: None,
9612                auth_binding: None,
9613            },
9614        )
9615        .unwrap();
9616
9617        assert_eq!(resolved.model, "test-openai-default");
9618        assert_eq!(resolved.provider, Provider::OpenAI);
9619        assert!(
9620            resolved.auth_binding.is_none(),
9621            "provider switches must not inherit a binding from the previous provider"
9622        );
9623    }
9624
9625    #[test]
9626    fn llm_identity_model_override_keeps_uncatalogued_model_on_current_provider() {
9627        let registry = crate::ModelRegistry::from_config(
9628            &crate::Config::default(),
9629            *crate::model_profile::test_catalog::TEST_CATALOG,
9630        )
9631        .unwrap();
9632        let current = SessionLlmIdentity {
9633            model: "custom-model".to_string(),
9634            provider: Provider::Anthropic,
9635            self_hosted_server_id: None,
9636            provider_params: None,
9637            auth_binding: None,
9638        };
9639
9640        let resolved = resolve_session_llm_identity_override(
9641            &current,
9642            &registry,
9643            SessionLlmIdentityOverride {
9644                model: Some("uncatalogued-custom-model"),
9645                provider: None,
9646                self_hosted_server_id: None,
9647                provider_params: None,
9648                auth_binding: None,
9649            },
9650        )
9651        .unwrap();
9652
9653        assert_eq!(resolved.model, "uncatalogued-custom-model");
9654        assert_eq!(resolved.provider, Provider::Anthropic);
9655    }
9656
9657    fn self_hosted_registry_with_shared_remote_model() -> crate::ModelRegistry {
9658        use crate::config::{
9659            SelfHostedApiStyle, SelfHostedModelConfig, SelfHostedServerConfig, SelfHostedTransport,
9660        };
9661        use crate::model_profile::catalog::ModelTier;
9662
9663        let mut config = crate::Config::default();
9664        for server_id in ["local-a", "local-b"] {
9665            config.self_hosted.servers.insert(
9666                server_id.to_string(),
9667                SelfHostedServerConfig {
9668                    transport: SelfHostedTransport::OpenAiCompatible,
9669                    base_url: format!("http://{server_id}.test"),
9670                    api_style: SelfHostedApiStyle::Responses,
9671                },
9672            );
9673            config.self_hosted.models.insert(
9674                format!("shared-local-{server_id}"),
9675                SelfHostedModelConfig {
9676                    server: server_id.to_string(),
9677                    remote_model: "shared-local-model".to_string(),
9678                    display_name: "Shared local model".to_string(),
9679                    family: "shared-local".to_string(),
9680                    tier: ModelTier::Supported,
9681                    ..Default::default()
9682                },
9683            );
9684        }
9685        config.self_hosted.default_model = Some("shared-local-local-a".to_string());
9686        crate::ModelRegistry::from_config(
9687            &config,
9688            *crate::model_profile::test_catalog::TEST_CATALOG,
9689        )
9690        .expect("shared local registry")
9691    }
9692
9693    #[test]
9694    fn llm_identity_override_preserves_exact_self_hosted_server_route() {
9695        let registry = self_hosted_registry_with_shared_remote_model();
9696        let current = SessionLlmIdentity {
9697            model: "shared-local-local-a".to_string(),
9698            provider: Provider::SelfHosted,
9699            self_hosted_server_id: Some("local-a".to_string()),
9700            provider_params: None,
9701            auth_binding: None,
9702        };
9703
9704        let resolved = resolve_session_llm_identity_override(
9705            &current,
9706            &registry,
9707            SessionLlmIdentityOverride {
9708                model: Some("shared-local-local-b"),
9709                provider: Some(Provider::SelfHosted),
9710                self_hosted_server_id: Some("local-b"),
9711                provider_params: None,
9712                auth_binding: None,
9713            },
9714        )
9715        .expect("exact configured local route should resolve");
9716
9717        assert_eq!(resolved.model, "shared-local-local-b");
9718        assert_eq!(resolved.provider, Provider::SelfHosted);
9719        assert_eq!(resolved.self_hosted_server_id.as_deref(), Some("local-b"));
9720    }
9721
9722    #[test]
9723    fn llm_identity_override_rejects_self_hosted_server_model_mismatch() {
9724        let registry = self_hosted_registry_with_shared_remote_model();
9725        let current = SessionLlmIdentity {
9726            model: "shared-local-local-a".to_string(),
9727            provider: Provider::SelfHosted,
9728            self_hosted_server_id: Some("local-a".to_string()),
9729            provider_params: None,
9730            auth_binding: None,
9731        };
9732
9733        let error = resolve_session_llm_identity_override(
9734            &current,
9735            &registry,
9736            SessionLlmIdentityOverride {
9737                model: Some("shared-local-local-b"),
9738                provider: Some(Provider::SelfHosted),
9739                self_hosted_server_id: Some("local-a"),
9740                provider_params: None,
9741                auth_binding: None,
9742            },
9743        )
9744        .expect_err("server id must match the requested model alias route");
9745
9746        assert!(matches!(
9747            error,
9748            SessionLlmIdentityOverrideError::SelfHostedServerMismatch {
9749                requested,
9750                configured,
9751                ..
9752            } if requested == "local-a" && configured == "local-b"
9753        ));
9754    }
9755
9756    #[test]
9757    fn realtime_transcript_append_is_idempotent_by_provider_item_and_delta_id() {
9758        let mut session = Session::new();
9759
9760        let user = RealtimeTranscriptEvent::UserTranscriptFinal {
9761            item_id: "item_user".to_string(),
9762            previous_item_id: None,
9763            content_index: 0,
9764            text: "hello".to_string(),
9765        };
9766        assert!(
9767            !session
9768                .append_realtime_transcript_event(user.clone())
9769                .is_inert()
9770        );
9771        assert!(session.append_realtime_transcript_event(user).is_inert());
9772
9773        let delta = RealtimeTranscriptEvent::AssistantTextDelta {
9774            response_id: "resp_assistant".to_string(),
9775            delta_id: "evt_delta_1".to_string(),
9776            item_id: "item_assistant".to_string(),
9777            previous_item_id: Some("item_user".to_string()),
9778            content_index: 0,
9779            delta: "hi".to_string(),
9780        };
9781        assert!(
9782            session
9783                .append_realtime_transcript_event(delta.clone())
9784                .is_inert()
9785        );
9786        assert!(session.append_realtime_transcript_event(delta).is_inert());
9787
9788        let terminal = RealtimeTranscriptEvent::AssistantTurnCompleted {
9789            response_id: "resp_assistant".to_string(),
9790            stop_reason: StopReason::EndTurn,
9791            usage: Usage::default(),
9792        };
9793        assert!(
9794            !session
9795                .append_realtime_transcript_event(terminal.clone())
9796                .is_inert()
9797        );
9798        assert!(
9799            session
9800                .append_realtime_transcript_event(terminal)
9801                .is_inert()
9802        );
9803
9804        assert_eq!(session.messages().len(), 2);
9805        assert!(matches!(
9806            &session.messages()[0],
9807            Message::User(user) if user.text_content() == "hello"
9808        ));
9809        assert!(matches!(
9810            &session.messages()[1],
9811            Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "hi"
9812        ));
9813    }
9814
9815    #[test]
9816    fn realtime_user_image_materializes_once_and_unblocks_causal_assistant() {
9817        let mut session = Session::new();
9818        let image_data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB".to_string();
9819        let image = RealtimeTranscriptEvent::UserContentFinal {
9820            idempotency_key: "image-request-1".to_string(),
9821            item_id: "item_image".to_string(),
9822            previous_item_id: None,
9823            content_index: 0,
9824            content: vec![ContentBlock::Image {
9825                media_type: "image/png".to_string(),
9826                data: crate::types::ImageData::Inline {
9827                    data: image_data.clone(),
9828                },
9829            }],
9830        };
9831
9832        assert!(
9833            !append_staged_user_image(&mut session, &image).is_inert(),
9834            "first image final must materialize canonical user content"
9835        );
9836        let replay = session
9837            .preflight_realtime_user_content_event(&image)
9838            .expect("exact retry should preflight as committed");
9839        assert!(matches!(
9840            replay,
9841            crate::RealtimeUserContentApplyOutcome::AlreadyCommitted(_)
9842        ));
9843
9844        let staged_state = session
9845            .metadata
9846            .get(SESSION_REALTIME_TRANSCRIPT_STATE_KEY)
9847            .expect("realtime state must be persisted");
9848        assert!(
9849            !staged_state.to_string().contains(&image_data),
9850            "materialized image bytes must not remain duplicated in transcript metadata"
9851        );
9852
9853        assert!(
9854            session
9855                .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
9856                    response_id: "resp_image".to_string(),
9857                    delta_id: "delta_image".to_string(),
9858                    item_id: "item_assistant".to_string(),
9859                    previous_item_id: Some("item_image".to_string()),
9860                    content_index: 0,
9861                    delta: "I see red.".to_string(),
9862                })
9863                .is_inert()
9864        );
9865        assert!(
9866            !session
9867                .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
9868                    response_id: "resp_image".to_string(),
9869                    stop_reason: StopReason::EndTurn,
9870                    usage: Usage::default(),
9871                },)
9872                .is_inert(),
9873            "materialized image predecessor must unblock the assistant response"
9874        );
9875
9876        assert_eq!(session.messages().len(), 2);
9877        assert!(matches!(
9878            &session.messages()[0],
9879            Message::User(user)
9880                if matches!(
9881                    user.content.as_slice(),
9882                    [ContentBlock::Image {
9883                        media_type,
9884                        data: crate::types::ImageData::Blob { blob_id },
9885                    }] if media_type == "image/png"
9886                        && blob_id == &crate::blob::content_blob_id("image/png", &image_data)
9887                )
9888        ));
9889        assert!(matches!(
9890            &session.messages()[1],
9891            Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "I see red."
9892        ));
9893    }
9894
9895    #[test]
9896    fn realtime_user_image_identity_is_durable_canonical_and_conflict_safe() {
9897        let mut session = Session::new();
9898        let data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB".to_string();
9899        let initial = RealtimeTranscriptEvent::UserContentFinal {
9900            idempotency_key: "stable-image-key".to_string(),
9901            item_id: "canonical-image-item".to_string(),
9902            previous_item_id: None,
9903            content_index: 0,
9904            content: vec![ContentBlock::Image {
9905                media_type: " image/PNG; charset=binary ".to_string(),
9906                data: crate::types::ImageData::Inline { data: data.clone() },
9907            }],
9908        };
9909        let committed = append_staged_user_image(&mut session, &initial);
9910        let Some(crate::RealtimeUserContentApplyOutcome::Committed(identity)) =
9911            committed.user_content
9912        else {
9913            panic!("first image must commit its durable identity");
9914        };
9915        assert_eq!(identity.item_id, "canonical-image-item");
9916        assert_eq!(identity.media_type, "image/png");
9917
9918        let encoded = serde_json::to_string(&session).expect("session should serialize");
9919        let restored: Session =
9920            serde_json::from_str(&encoded).expect("committed identity should restore");
9921
9922        let replay_event = RealtimeTranscriptEvent::UserContentFinal {
9923            idempotency_key: "stable-image-key".to_string(),
9924            item_id: "ignored-retry-item".to_string(),
9925            previous_item_id: None,
9926            content_index: 0,
9927            content: vec![ContentBlock::Image {
9928                media_type: "image/png".to_string(),
9929                data: crate::types::ImageData::Inline { data: data.clone() },
9930            }],
9931        };
9932        let replay = restored
9933            .preflight_realtime_user_content_event(&replay_event)
9934            .expect("exact retry should preflight");
9935        assert!(matches!(
9936            replay,
9937            crate::RealtimeUserContentApplyOutcome::AlreadyCommitted(
9938                crate::RealtimeUserContentIdentity { ref item_id, .. }
9939            ) if item_id == "canonical-image-item"
9940        ));
9941
9942        let conflict = restored
9943            .preflight_realtime_user_content_event(&RealtimeTranscriptEvent::UserContentFinal {
9944                idempotency_key: "stable-image-key".to_string(),
9945                item_id: "conflicting-item".to_string(),
9946                previous_item_id: None,
9947                content_index: 0,
9948                content: vec![ContentBlock::Image {
9949                    media_type: "image/png".to_string(),
9950                    data: crate::types::ImageData::Inline {
9951                        data: "different-payload".to_string(),
9952                    },
9953                }],
9954            })
9955            .expect("conflicting retry should preflight");
9956        assert!(matches!(
9957            conflict,
9958            crate::RealtimeUserContentApplyOutcome::RejectedConflict { .. }
9959        ));
9960
9961        let item_collision = restored
9962            .preflight_realtime_user_content_event(&RealtimeTranscriptEvent::UserContentFinal {
9963                idempotency_key: "another-key".to_string(),
9964                item_id: "canonical-image-item".to_string(),
9965                previous_item_id: None,
9966                content_index: 0,
9967                content: vec![ContentBlock::Image {
9968                    media_type: "image/png".to_string(),
9969                    data: crate::types::ImageData::Inline { data },
9970                }],
9971            })
9972            .expect("item collision should preflight");
9973        assert!(matches!(
9974            item_collision,
9975            crate::RealtimeUserContentApplyOutcome::RejectedConflict { .. }
9976        ));
9977        assert_eq!(restored.messages().len(), 1);
9978        serde_json::to_string(&restored).expect("rejections must not corrupt durable state");
9979    }
9980
9981    #[test]
9982    fn realtime_user_image_reducer_never_receipts_without_pending_blob_proof() {
9983        for data in [
9984            crate::types::ImageData::Inline {
9985                data: "iVBORw0KGgo=".to_string(),
9986            },
9987            crate::types::ImageData::Blob {
9988                blob_id: crate::blob::content_blob_id("image/png", "iVBORw0KGgo="),
9989            },
9990        ] {
9991            let mut session = Session::new();
9992            let outcome = session.append_realtime_transcript_event(
9993                RealtimeTranscriptEvent::UserContentFinal {
9994                    idempotency_key: "unstaged-image-key".to_string(),
9995                    item_id: "unstaged-image-item".to_string(),
9996                    previous_item_id: None,
9997                    content_index: 0,
9998                    content: vec![ContentBlock::Image {
9999                        media_type: "image/png".to_string(),
10000                        data,
10001                    }],
10002                },
10003            );
10004            assert!(matches!(
10005                outcome.user_content,
10006                Some(crate::RealtimeUserContentApplyOutcome::RejectedInvalidIdentity { .. })
10007            ));
10008            assert!(session.messages().is_empty());
10009            assert!(session.realtime_user_content_identities().is_empty());
10010        }
10011    }
10012
10013    #[test]
10014    fn realtime_user_image_pending_slot_is_generated_bounded_and_recovery_typed() {
10015        use crate::generated::session_document::{
10016            RealtimeUserContentBlobRecoveryDisposition, RealtimeUserContentBlobStageDisposition,
10017        };
10018        let mut session = Session::new();
10019        let pending = crate::PendingRealtimeUserContentBlob {
10020            idempotency_key: "pending-key-a".to_string(),
10021            item_id: "pending-item-a".to_string(),
10022            previous_item_id: None,
10023            content_index: 0,
10024            blob_id: crate::blob::content_blob_id("image/png", "iVBORw0KGgo="),
10025            media_type: "image/png".to_string(),
10026        };
10027        let different = crate::PendingRealtimeUserContentBlob {
10028            idempotency_key: "pending-key-b".to_string(),
10029            item_id: "pending-item-b".to_string(),
10030            previous_item_id: None,
10031            content_index: 0,
10032            blob_id: crate::blob::content_blob_id("image/png", "iVBORw0KGgoB"),
10033            media_type: "image/png".to_string(),
10034        };
10035        assert_eq!(
10036            session
10037                .stage_pending_realtime_user_content_blob(pending.clone())
10038                .expect("empty slot stages"),
10039            RealtimeUserContentBlobStageDisposition::StageNew
10040        );
10041        assert_eq!(
10042            session
10043                .stage_pending_realtime_user_content_blob(pending.clone())
10044                .expect("exact stage retry is idempotent"),
10045            RealtimeUserContentBlobStageDisposition::ReuseExact
10046        );
10047        assert_eq!(
10048            session
10049                .stage_pending_realtime_user_content_blob(different.clone())
10050                .expect("occupied decision is typed"),
10051            RealtimeUserContentBlobStageDisposition::RejectOccupied
10052        );
10053        assert_eq!(
10054            session.pending_realtime_user_content_blob(),
10055            Some(pending.clone())
10056        );
10057        assert_eq!(
10058            session
10059                .resolve_pending_realtime_user_content_blob_recovery(Some(&pending), false)
10060                .expect("exact recovery decision"),
10061            RealtimeUserContentBlobRecoveryDisposition::RetryExact
10062        );
10063        assert_eq!(
10064            session
10065                .resolve_pending_realtime_user_content_blob_recovery(Some(&different), true)
10066                .expect("verified older recovery decision"),
10067            RealtimeUserContentBlobRecoveryDisposition::CommitVerifiedBeforeCurrent
10068        );
10069        assert_eq!(
10070            session
10071                .resolve_pending_realtime_user_content_blob_recovery(Some(&different), false)
10072                .expect("invalid older recovery decision"),
10073            RealtimeUserContentBlobRecoveryDisposition::ClearInvalidBeforeCurrent
10074        );
10075        session
10076            .clear_invalid_pending_realtime_user_content_blob(Some(&different))
10077            .expect("generated clear-invalid disposition authorizes clear");
10078        assert!(session.pending_realtime_user_content_blob().is_none());
10079    }
10080
10081    #[test]
10082    fn transcript_rewrite_tombstones_removed_image_key_and_accepts_new_key() {
10083        let mut session = Session::new();
10084        let data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB".to_string();
10085        let original = RealtimeTranscriptEvent::UserContentFinal {
10086            idempotency_key: "removed-image-key".to_string(),
10087            item_id: "removed-image-item".to_string(),
10088            previous_item_id: None,
10089            content_index: 0,
10090            content: vec![ContentBlock::Image {
10091                media_type: "image/png".to_string(),
10092                data: crate::types::ImageData::Inline { data: data.clone() },
10093            }],
10094        };
10095        assert!(matches!(
10096            append_staged_user_image(&mut session, &original).user_content,
10097            Some(crate::RealtimeUserContentApplyOutcome::Committed(_))
10098        ));
10099
10100        let parent = session.transcript_revision().expect("parent revision");
10101        session
10102            .commit_transcript_rewrite(
10103                TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
10104                vec![Message::User(UserMessage::text("image removed"))],
10105                TranscriptRewriteReason::new("remove-image"),
10106                None,
10107                Some(parent),
10108            )
10109            .expect("rewrite should tombstone removed image identity");
10110
10111        assert!(session.realtime_user_content_identities().is_empty());
10112        assert_eq!(
10113            session.realtime_user_content_tombstones(),
10114            vec![crate::RealtimeUserContentTombstone {
10115                idempotency_key: "removed-image-key".to_string(),
10116            }]
10117        );
10118        assert!(matches!(
10119            session.preflight_realtime_user_content_event(&original),
10120            Some(crate::RealtimeUserContentApplyOutcome::RejectedConflict { .. })
10121        ));
10122        assert!(matches!(
10123            session
10124                .append_realtime_transcript_event(original)
10125                .user_content,
10126            Some(crate::RealtimeUserContentApplyOutcome::RejectedConflict { .. })
10127        ));
10128        assert_eq!(
10129            session.messages().len(),
10130            1,
10131            "stale retry emits no receipt content"
10132        );
10133
10134        let new_image = RealtimeTranscriptEvent::UserContentFinal {
10135            idempotency_key: "new-image-key".to_string(),
10136            item_id: "new-image-item".to_string(),
10137            previous_item_id: None,
10138            content_index: 0,
10139            content: vec![ContentBlock::Image {
10140                media_type: "image/png".to_string(),
10141                data: crate::types::ImageData::Inline { data },
10142            }],
10143        };
10144        assert!(matches!(
10145            append_staged_user_image(&mut session, &new_image).user_content,
10146            Some(crate::RealtimeUserContentApplyOutcome::Committed(_))
10147        ));
10148        assert_eq!(session.messages().len(), 2);
10149
10150        let restored: Session = serde_json::from_str(
10151            &serde_json::to_string(&session).expect("serialize rewritten session"),
10152        )
10153        .expect("cold restore rewritten session");
10154        assert_eq!(restored.realtime_user_content_identities().len(), 1);
10155        assert_eq!(restored.realtime_user_content_tombstones().len(), 1);
10156    }
10157
10158    #[test]
10159    fn transcript_rewrite_retains_only_canonical_image_occurrence_for_exact_replay() {
10160        let mut session = Session::new();
10161        let data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB".to_string();
10162        let original = RealtimeTranscriptEvent::UserContentFinal {
10163            idempotency_key: "retained-image-key".to_string(),
10164            item_id: "retained-image-item".to_string(),
10165            previous_item_id: None,
10166            content_index: 0,
10167            content: vec![ContentBlock::Image {
10168                media_type: "image/png".to_string(),
10169                data: crate::types::ImageData::Inline { data },
10170            }],
10171        };
10172        assert!(matches!(
10173            append_staged_user_image(&mut session, &original).user_content,
10174            Some(crate::RealtimeUserContentApplyOutcome::Committed(_))
10175        ));
10176        let retained_message = session.messages()[0].clone();
10177        let parent = session.transcript_revision().expect("parent revision");
10178        session
10179            .commit_transcript_rewrite(
10180                TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
10181                vec![
10182                    retained_message,
10183                    Message::User(UserMessage::text("new canonical neighbor")),
10184                ],
10185                TranscriptRewriteReason::new("retain-image"),
10186                None,
10187                Some(parent),
10188            )
10189            .expect("rewrite retaining exact inline image should reconcile");
10190
10191        assert!(session.realtime_user_content_tombstones().is_empty());
10192        let replay = session
10193            .preflight_realtime_user_content_event(&original)
10194            .expect("retained image should preflight as exact replay");
10195        assert!(matches!(
10196            replay,
10197            crate::RealtimeUserContentApplyOutcome::AlreadyCommitted(_)
10198        ));
10199        assert_eq!(session.messages().len(), 2);
10200    }
10201
10202    #[test]
10203    fn transcript_rewrite_rejects_atomically_while_image_blob_anchor_is_pending() {
10204        let mut session = Session::new();
10205        session.push(Message::User(UserMessage::text("before rewrite")));
10206        let pending = crate::PendingRealtimeUserContentBlob {
10207            idempotency_key: "pending-rewrite-key".to_string(),
10208            item_id: "pending-rewrite-item".to_string(),
10209            previous_item_id: None,
10210            content_index: 0,
10211            blob_id: crate::blob::content_blob_id("image/png", "pending-bytes"),
10212            media_type: "image/png".to_string(),
10213        };
10214        session
10215            .stage_pending_realtime_user_content_blob(pending.clone())
10216            .expect("stage durable pending anchor");
10217        let parent = session.transcript_revision().expect("parent revision");
10218        let error = session
10219            .commit_transcript_rewrite(
10220                TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
10221                vec![Message::User(UserMessage::text("after rewrite"))],
10222                TranscriptRewriteReason::new("blocked-pending-image"),
10223                None,
10224                Some(parent),
10225            )
10226            .expect_err("rewrite must not cross an unresolved image anchor");
10227        assert!(
10228            error
10229                .to_string()
10230                .contains("history_rewrite_pending_user_content_blob")
10231        );
10232        assert!(matches!(
10233            &session.messages()[0],
10234            Message::User(user) if user.text_content() == "before rewrite"
10235        ));
10236        assert_eq!(session.pending_realtime_user_content_blob(), Some(pending));
10237    }
10238
10239    #[test]
10240    fn realtime_user_image_rejects_noncanonical_blob_and_multiblock_shape() {
10241        let mut session = Session::new();
10242        for (key, content) in [
10243            (
10244                "invalid-blob",
10245                vec![ContentBlock::Image {
10246                    media_type: "image/png".to_string(),
10247                    data: crate::types::ImageData::Blob {
10248                        blob_id: crate::BlobId::new("sha256:not-a-digest"),
10249                    },
10250                }],
10251            ),
10252            (
10253                "multi-block",
10254                vec![
10255                    ContentBlock::Image {
10256                        media_type: "image/png".to_string(),
10257                        data: crate::types::ImageData::Inline {
10258                            data: "payload".to_string(),
10259                        },
10260                    },
10261                    ContentBlock::Text {
10262                        text: "smuggled".to_string(),
10263                    },
10264                ],
10265            ),
10266        ] {
10267            let outcome = session.append_realtime_transcript_event(
10268                RealtimeTranscriptEvent::UserContentFinal {
10269                    idempotency_key: key.to_string(),
10270                    item_id: format!("item-{key}"),
10271                    previous_item_id: None,
10272                    content_index: 0,
10273                    content,
10274                },
10275            );
10276            assert!(matches!(
10277                outcome.user_content,
10278                Some(crate::RealtimeUserContentApplyOutcome::RejectedInvalidIdentity { .. })
10279            ));
10280        }
10281        assert!(session.messages().is_empty());
10282        let encoded = serde_json::to_string(&session).expect("session should serialize");
10283        serde_json::from_str::<Session>(&encoded).expect("rejections must leave restorable state");
10284    }
10285
10286    #[test]
10287    fn realtime_restore_rejects_malformed_causal_graphs_and_accepts_waiting_dag() {
10288        fn restore(
10289            items: serde_json::Value,
10290            first_seen_order: Vec<&str>,
10291        ) -> Result<
10292            crate::realtime_transcript_revision::SessionRealtimeTranscriptState,
10293            crate::realtime_transcript_revision::RealtimeTranscriptShellError,
10294        > {
10295            let state = serde_json::from_value(serde_json::json!({
10296                "items": items,
10297                "first_seen_order": first_seen_order,
10298            }))
10299            .expect("test state shape should deserialize");
10300            crate::realtime_transcript_revision::restore_realtime_transcript_state(state)
10301        }
10302
10303        assert!(
10304            restore(
10305                serde_json::json!({
10306                    "child": { "role": "user", "previous_item_id": "missing" }
10307                }),
10308                vec!["child"],
10309            )
10310            .is_ok(),
10311            "an unmaterialized out-of-order item must survive cold restore until its predecessor arrives"
10312        );
10313        assert!(
10314            restore(
10315                serde_json::json!({
10316                    "child": {
10317                        "role": "user",
10318                        "previous_item_id": "missing",
10319                        "ready": true,
10320                        "materialized": true
10321                    }
10322                }),
10323                vec!["child"],
10324            )
10325            .is_err(),
10326            "a materialized item cannot reference a missing predecessor"
10327        );
10328        assert!(
10329            restore(
10330                serde_json::json!({
10331                    "self": { "role": "user", "previous_item_id": "self" }
10332                }),
10333                vec!["self"],
10334            )
10335            .is_err(),
10336            "self edge must fail cold restore"
10337        );
10338        assert!(
10339            restore(
10340                serde_json::json!({
10341                    "a": { "role": "user", "previous_item_id": "b" },
10342                    "b": { "role": "user", "previous_item_id": "a" }
10343                }),
10344                vec!["a", "b"],
10345            )
10346            .is_err(),
10347            "cycle must fail cold restore"
10348        );
10349        assert!(
10350            restore(
10351                serde_json::json!({
10352                    "root": { "role": "user" },
10353                    "materialized_child": {
10354                        "role": "user",
10355                        "previous_item_id": "root",
10356                        "ready": true,
10357                        "materialized": true
10358                    }
10359                }),
10360                vec!["root", "materialized_child"],
10361            )
10362            .is_err(),
10363            "materialized child cannot have unmaterialized ancestry"
10364        );
10365        assert!(
10366            restore(
10367                serde_json::json!({
10368                    "root": { "role": "user" },
10369                    "waiting_child": { "role": "user", "previous_item_id": "root" }
10370                }),
10371                vec!["waiting_child", "root"],
10372            )
10373            .is_ok(),
10374            "valid acyclic waiting graph should restore even when first-seen order is child-first"
10375        );
10376    }
10377
10378    #[test]
10379    fn realtime_restore_handles_long_waiting_chain_with_bounded_graph_walk() {
10380        const ITEM_COUNT: usize = 4_096;
10381        let mut items = serde_json::Map::new();
10382        let mut order = Vec::with_capacity(ITEM_COUNT);
10383        for index in 0..ITEM_COUNT {
10384            let item_id = format!("item-{index:04}");
10385            let value = if index == 0 {
10386                serde_json::json!({ "role": "user" })
10387            } else {
10388                serde_json::json!({
10389                    "role": "user",
10390                    "previous_item_id": format!("item-{:04}", index - 1),
10391                })
10392            };
10393            order.push(item_id.clone());
10394            items.insert(item_id, value);
10395        }
10396        let state = serde_json::from_value(serde_json::json!({
10397            "items": items,
10398            "first_seen_order": order,
10399        }))
10400        .expect("long-chain fixture should deserialize");
10401        crate::realtime_transcript_revision::restore_realtime_transcript_state(state)
10402            .expect("long valid waiting DAG should restore in one bounded graph walk");
10403    }
10404
10405    /// R5-7: `AssistantTranscriptFinalText` injects authoritative final text
10406    /// into the staged item. Verifies the override semantics: a partial
10407    /// delta is replaced, not concatenated, and the item promotes to the
10408    /// Spoken lane so flush emits `AssistantBlock::Transcript`.
10409    #[test]
10410    fn realtime_transcript_final_text_overrides_partial_delta_and_promotes_to_spoken_lane() {
10411        let mut session = Session::new();
10412
10413        // Partial delta accumulates "incom" — simulating delta loss before
10414        // the final arrives.
10415        assert!(
10416            session
10417                .append_realtime_transcript_event(
10418                    RealtimeTranscriptEvent::AssistantTranscriptDelta {
10419                        response_id: "resp_a".to_string(),
10420                        delta_id: "evt_1".to_string(),
10421                        item_id: "item_a".to_string(),
10422                        previous_item_id: None,
10423                        content_index: 0,
10424                        delta: "incom".to_string(),
10425                    }
10426                )
10427                .is_inert()
10428        );
10429
10430        // Authoritative final text overrides the staged content.
10431        assert!(
10432            session
10433                .append_realtime_transcript_event(
10434                    RealtimeTranscriptEvent::AssistantTranscriptFinalText {
10435                        response_id: "resp_a".to_string(),
10436                        item_id: "item_a".to_string(),
10437                        content_index: 0,
10438                        text: "complete answer".to_string(),
10439                    }
10440                )
10441                .is_inert()
10442        );
10443
10444        // Turn completion drives the flush.
10445        let outcome = session.append_realtime_transcript_event(
10446            RealtimeTranscriptEvent::AssistantTurnCompleted {
10447                response_id: "resp_a".to_string(),
10448                stop_reason: StopReason::EndTurn,
10449                usage: Usage::default(),
10450            },
10451        );
10452        assert!(!outcome.is_inert());
10453
10454        // Verify the materialized block has the final's authoritative text
10455        // (not the partial "incom") and the Spoken lane.
10456        assert_eq!(session.messages().len(), 1);
10457        match &session.messages()[0] {
10458            Message::BlockAssistant(assistant) => {
10459                let mut found_transcript = false;
10460                for block in &assistant.blocks {
10461                    if let AssistantBlock::Transcript { text, .. } = block {
10462                        assert_eq!(text, "complete answer");
10463                        found_transcript = true;
10464                    }
10465                }
10466                assert!(
10467                    found_transcript,
10468                    "AssistantTranscriptFinalText must promote to the Spoken lane and \
10469                     materialize as AssistantBlock::Transcript"
10470                );
10471            }
10472            other => unreachable!("expected BlockAssistant, got {other:?}"),
10473        }
10474    }
10475
10476    /// R5-7: `AssistantTranscriptFinalText` works for final-only providers
10477    /// where no prior delta has staged an item.
10478    #[test]
10479    fn realtime_transcript_final_text_creates_item_when_no_delta_staged() {
10480        let mut session = Session::new();
10481
10482        assert!(
10483            session
10484                .append_realtime_transcript_event(
10485                    RealtimeTranscriptEvent::AssistantTranscriptFinalText {
10486                        response_id: "resp_a".to_string(),
10487                        item_id: "item_a".to_string(),
10488                        content_index: 0,
10489                        text: "spoken-final-only".to_string(),
10490                    }
10491                )
10492                .is_inert()
10493        );
10494
10495        let outcome = session.append_realtime_transcript_event(
10496            RealtimeTranscriptEvent::AssistantTurnCompleted {
10497                response_id: "resp_a".to_string(),
10498                stop_reason: StopReason::EndTurn,
10499                usage: Usage::default(),
10500            },
10501        );
10502        assert!(!outcome.is_inert());
10503
10504        assert_eq!(session.messages().len(), 1);
10505        match &session.messages()[0] {
10506            Message::BlockAssistant(assistant) => {
10507                let has_transcript = assistant.blocks.iter().any(|b| {
10508                    matches!(b, AssistantBlock::Transcript { text, .. } if text == "spoken-final-only")
10509                });
10510                assert!(
10511                    has_transcript,
10512                    "final-only provider path must materialize as Transcript on the Spoken lane"
10513                );
10514            }
10515            other => unreachable!("expected BlockAssistant, got {other:?}"),
10516        }
10517    }
10518
10519    #[test]
10520    fn realtime_transcript_append_orders_causally_equivalent_out_of_order_items() {
10521        let mut session = Session::new();
10522
10523        assert!(
10524            session
10525                .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
10526                    response_id: "resp_assistant".to_string(),
10527                    delta_id: "evt_delta_1".to_string(),
10528                    item_id: "item_assistant".to_string(),
10529                    previous_item_id: Some("item_user".to_string()),
10530                    content_index: 0,
10531                    delta: "answer".to_string(),
10532                })
10533                .is_inert()
10534        );
10535        assert!(
10536            session
10537                .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
10538                    response_id: "resp_assistant".to_string(),
10539                    stop_reason: StopReason::EndTurn,
10540                    usage: Usage::default(),
10541                })
10542                .is_inert()
10543        );
10544
10545        let outcome = session.append_realtime_transcript_event(
10546            RealtimeTranscriptEvent::UserTranscriptFinal {
10547                item_id: "item_user".to_string(),
10548                previous_item_id: None,
10549                content_index: 0,
10550                text: "question".to_string(),
10551            },
10552        );
10553
10554        assert_eq!(outcome.materialized_messages.len(), 2);
10555        assert_eq!(session.messages().len(), 2);
10556        assert!(matches!(
10557            &session.messages()[0],
10558            Message::User(user) if user.text_content() == "question"
10559        ));
10560        assert!(matches!(
10561            &session.messages()[1],
10562            Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "answer"
10563        ));
10564    }
10565
10566    #[test]
10567    fn realtime_transcript_replay_of_seen_provider_items_is_inert() {
10568        let mut session = Session::new();
10569        let events = vec![
10570            RealtimeTranscriptEvent::UserTranscriptFinal {
10571                item_id: "item_user".to_string(),
10572                previous_item_id: None,
10573                content_index: 0,
10574                text: "hello".to_string(),
10575            },
10576            RealtimeTranscriptEvent::AssistantTextDelta {
10577                response_id: "resp_assistant".to_string(),
10578                delta_id: "evt_delta_1".to_string(),
10579                item_id: "item_assistant".to_string(),
10580                previous_item_id: Some("item_user".to_string()),
10581                content_index: 0,
10582                delta: "world".to_string(),
10583            },
10584            RealtimeTranscriptEvent::AssistantTurnCompleted {
10585                response_id: "resp_assistant".to_string(),
10586                stop_reason: StopReason::EndTurn,
10587                usage: Usage::default(),
10588            },
10589        ];
10590
10591        for event in events.iter().cloned() {
10592            let _ = session.append_realtime_transcript_event(event);
10593        }
10594        let first_messages = serde_json::to_value(session.messages()).unwrap();
10595
10596        for event in events {
10597            assert!(session.append_realtime_transcript_event(event).is_inert());
10598        }
10599
10600        assert_eq!(
10601            serde_json::to_value(session.messages()).unwrap(),
10602            first_messages
10603        );
10604    }
10605
10606    #[test]
10607    fn realtime_transcript_user_final_replay_cannot_erase_existing_segment() {
10608        let mut session = Session::new();
10609
10610        let user = RealtimeTranscriptEvent::UserTranscriptFinal {
10611            item_id: "item_user".to_string(),
10612            previous_item_id: None,
10613            content_index: 0,
10614            text: "remember amber lantern".to_string(),
10615        };
10616        assert!(
10617            !session
10618                .append_realtime_transcript_event(user.clone())
10619                .is_inert()
10620        );
10621        let first_messages = serde_json::to_value(session.messages()).unwrap();
10622
10623        assert!(
10624            session
10625                .append_realtime_transcript_event(RealtimeTranscriptEvent::UserTranscriptFinal {
10626                    item_id: "item_user".to_string(),
10627                    previous_item_id: None,
10628                    content_index: 0,
10629                    text: String::new(),
10630                })
10631                .is_inert()
10632        );
10633        assert!(session.append_realtime_transcript_event(user).is_inert());
10634        assert_eq!(
10635            serde_json::to_value(session.messages()).unwrap(),
10636            first_messages
10637        );
10638    }
10639
10640    #[test]
10641    fn realtime_transcript_empty_user_final_can_be_filled_by_later_nonempty_replay() {
10642        let mut session = Session::new();
10643
10644        assert!(
10645            session
10646                .append_realtime_transcript_event(RealtimeTranscriptEvent::UserTranscriptFinal {
10647                    item_id: "item_user".to_string(),
10648                    previous_item_id: None,
10649                    content_index: 0,
10650                    text: String::new(),
10651                })
10652                .is_inert()
10653        );
10654        assert!(session.messages().is_empty());
10655
10656        let outcome = session.append_realtime_transcript_event(
10657            RealtimeTranscriptEvent::UserTranscriptFinal {
10658                item_id: "item_user".to_string(),
10659                previous_item_id: None,
10660                content_index: 0,
10661                text: "remember amber lantern".to_string(),
10662            },
10663        );
10664        assert_eq!(outcome.materialized_messages.len(), 1);
10665        assert_eq!(session.messages().len(), 1);
10666        assert!(matches!(
10667            &session.messages()[0],
10668            Message::User(user) if user.text_content() == "remember amber lantern"
10669        ));
10670    }
10671
10672    #[test]
10673    fn realtime_transcript_skipped_provider_items_preserve_causal_order_without_content() {
10674        let mut session = Session::new();
10675
10676        let assistant_delta = RealtimeTranscriptEvent::AssistantTextDelta {
10677            response_id: "resp_assistant".to_string(),
10678            delta_id: "evt_delta_1".to_string(),
10679            item_id: "item_assistant".to_string(),
10680            previous_item_id: Some("item_tool".to_string()),
10681            content_index: 0,
10682            delta: "done".to_string(),
10683        };
10684        assert!(
10685            session
10686                .append_realtime_transcript_event(assistant_delta.clone())
10687                .is_inert()
10688        );
10689        let assistant_complete = RealtimeTranscriptEvent::AssistantTurnCompleted {
10690            response_id: "resp_assistant".to_string(),
10691            stop_reason: StopReason::EndTurn,
10692            usage: Usage::default(),
10693        };
10694        assert!(
10695            session
10696                .append_realtime_transcript_event(assistant_complete.clone())
10697                .is_inert()
10698        );
10699
10700        let skipped = RealtimeTranscriptEvent::ItemSkipped {
10701            item_id: "item_tool".to_string(),
10702            previous_item_id: Some("item_user".to_string()),
10703        };
10704        assert!(
10705            session
10706                .append_realtime_transcript_event(skipped.clone())
10707                .is_inert(),
10708            "a skipped provider item must not append transcript content"
10709        );
10710        assert!(session.messages().is_empty());
10711
10712        let outcome = session.append_realtime_transcript_event(
10713            RealtimeTranscriptEvent::UserTranscriptFinal {
10714                item_id: "item_user".to_string(),
10715                previous_item_id: None,
10716                content_index: 0,
10717                text: "please use the tool".to_string(),
10718            },
10719        );
10720        assert_eq!(outcome.materialized_messages.len(), 2);
10721        assert_eq!(session.messages().len(), 2);
10722        assert!(matches!(
10723            &session.messages()[0],
10724            Message::User(user) if user.text_content() == "please use the tool"
10725        ));
10726        assert!(matches!(
10727            &session.messages()[1],
10728            Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "done"
10729        ));
10730
10731        let first_messages = serde_json::to_value(session.messages()).unwrap();
10732        assert!(session.append_realtime_transcript_event(skipped).is_inert());
10733        assert!(
10734            session
10735                .append_realtime_transcript_event(assistant_delta)
10736                .is_inert()
10737        );
10738        assert!(
10739            session
10740                .append_realtime_transcript_event(assistant_complete)
10741                .is_inert()
10742        );
10743        assert_eq!(
10744            serde_json::to_value(session.messages()).unwrap(),
10745            first_messages
10746        );
10747    }
10748
10749    #[test]
10750    fn realtime_transcript_interrupted_assistant_item_unblocks_later_provider_items() {
10751        // R5-5 (Round-5): the staged assistant content is a Display-lane item
10752        // (`AssistantTextDelta`). Under the new lane-aware barge-in contract,
10753        // the Display lane survives interruption and materializes. The User
10754        // "Stop." item, gated on the chained Display item being materialized,
10755        // also unblocks. Round-4's "must stay non-canonical" assertion was
10756        // wrong — that contract was lane-blind.
10757        let mut session = Session::new();
10758
10759        let _ = session.append_realtime_transcript_event(
10760            RealtimeTranscriptEvent::UserTranscriptFinal {
10761                item_id: "item_repeat".to_string(),
10762                previous_item_id: None,
10763                content_index: 0,
10764                text: "repeat until stop".to_string(),
10765            },
10766        );
10767        assert!(
10768            session
10769                .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
10770                    response_id: "resp_loop".to_string(),
10771                    delta_id: "evt_loop_1".to_string(),
10772                    item_id: "item_loop".to_string(),
10773                    previous_item_id: Some("item_repeat".to_string()),
10774                    content_index: 0,
10775                    delta: "Looping now".to_string(),
10776                })
10777                .is_inert()
10778        );
10779        assert!(
10780            session
10781                .append_realtime_transcript_event(RealtimeTranscriptEvent::UserTranscriptFinal {
10782                    item_id: "item_stop".to_string(),
10783                    previous_item_id: Some("item_loop".to_string()),
10784                    content_index: 0,
10785                    text: "Stop.".to_string(),
10786                })
10787                .is_inert(),
10788            "the stop turn waits until the interrupted assistant provider item is resolved"
10789        );
10790
10791        let outcome = session.append_realtime_transcript_event(
10792            RealtimeTranscriptEvent::AssistantTurnInterrupted {
10793                response_id: "resp_loop".to_string(),
10794            },
10795        );
10796
10797        // R5-5: materializer commits 2 messages (the retained Display item +
10798        // the unblocked "Stop." User message).
10799        assert_eq!(outcome.materialized_messages.len(), 2);
10800        // Canonical history: User-repeat, BlockAssistant(Display "Looping now"), User-Stop.
10801        assert_eq!(session.messages().len(), 3);
10802        assert!(matches!(
10803            &session.messages()[0],
10804            Message::User(user) if user.text_content() == "repeat until stop"
10805        ));
10806        match &session.messages()[1] {
10807            Message::BlockAssistant(assistant) => {
10808                let text = block_assistant_text(assistant);
10809                assert_eq!(text, "Looping now");
10810            }
10811            other => unreachable!(
10812                "Display lane assistant item must be retained on Interrupted, got {other:?}"
10813            ),
10814        }
10815        assert!(matches!(
10816            &session.messages()[2],
10817            Message::User(user) if user.text_content() == "Stop."
10818        ));
10819    }
10820
10821    #[test]
10822    fn realtime_transcript_late_interrupted_assistant_delta_stays_noncanonical() {
10823        let mut session = Session::new();
10824
10825        let _ = session.append_realtime_transcript_event(
10826            RealtimeTranscriptEvent::UserTranscriptFinal {
10827                item_id: "item_repeat".to_string(),
10828                previous_item_id: None,
10829                content_index: 0,
10830                text: "repeat until stop".to_string(),
10831            },
10832        );
10833        assert!(
10834            session
10835                .append_realtime_transcript_event(RealtimeTranscriptEvent::ItemObserved {
10836                    item_id: "item_loop".to_string(),
10837                    previous_item_id: Some("item_repeat".to_string()),
10838                    role: RealtimeTranscriptRole::Assistant,
10839                    response_id: None,
10840                })
10841                .is_inert(),
10842            "provider can observe an assistant item before the adapter learns its response id"
10843        );
10844        assert!(
10845            session
10846                .append_realtime_transcript_event(
10847                    RealtimeTranscriptEvent::AssistantTurnInterrupted {
10848                        response_id: "resp_loop".to_string(),
10849                    }
10850                )
10851                .is_inert(),
10852            "an interruption can arrive before delayed transcript deltas for the response"
10853        );
10854        assert!(
10855            session
10856                .append_realtime_transcript_event(RealtimeTranscriptEvent::UserTranscriptFinal {
10857                    item_id: "item_stop".to_string(),
10858                    previous_item_id: Some("item_loop".to_string()),
10859                    content_index: 0,
10860                    text: "Stop.".to_string(),
10861                })
10862                .is_inert(),
10863            "the stop turn waits for the provider's interrupted assistant item anchor"
10864        );
10865
10866        let late_delta_outcome =
10867            session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
10868                response_id: "resp_loop".to_string(),
10869                delta_id: "evt_loop_late".to_string(),
10870                item_id: "item_loop".to_string(),
10871                previous_item_id: Some("item_repeat".to_string()),
10872                content_index: 0,
10873                delta: "Looping now".to_string(),
10874            });
10875        assert_eq!(late_delta_outcome.materialized_messages.len(), 1);
10876        assert!(matches!(
10877            &session.messages()[1],
10878            Message::User(user) if user.text_content() == "Stop."
10879        ));
10880        assert!(
10881            session
10882                .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
10883                    response_id: "resp_loop".to_string(),
10884                    stop_reason: StopReason::EndTurn,
10885                    usage: Usage::default(),
10886                })
10887                .is_inert(),
10888            "late completion for an interrupted response must not resurrect its deltas"
10889        );
10890        assert!(
10891            session
10892                .messages()
10893                .iter()
10894                .filter_map(|message| match message {
10895                    Message::BlockAssistant(assistant) => Some(block_assistant_text(assistant)),
10896                    _ => None,
10897                })
10898                .all(|text| !text.contains("Looping now")),
10899            "late interrupted assistant text must remain non-canonical"
10900        );
10901    }
10902
10903    #[test]
10904    fn realtime_transcript_completion_only_finalizes_matching_response() {
10905        let mut session = Session::new();
10906
10907        let _ = session.append_realtime_transcript_event(
10908            RealtimeTranscriptEvent::UserTranscriptFinal {
10909                item_id: "item_user".to_string(),
10910                previous_item_id: None,
10911                content_index: 0,
10912                text: "question".to_string(),
10913            },
10914        );
10915        assert!(
10916            session
10917                .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
10918                    response_id: "resp_a".to_string(),
10919                    delta_id: "evt_a".to_string(),
10920                    item_id: "item_a".to_string(),
10921                    previous_item_id: Some("item_user".to_string()),
10922                    content_index: 0,
10923                    delta: "answer a".to_string(),
10924                })
10925                .is_inert()
10926        );
10927
10928        assert!(
10929            session
10930                .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
10931                    response_id: "resp_b".to_string(),
10932                    stop_reason: StopReason::EndTurn,
10933                    usage: Usage::default(),
10934                })
10935                .is_inert(),
10936            "a completion for another response must not finalize buffered assistant text"
10937        );
10938        assert_eq!(session.messages().len(), 1);
10939
10940        let outcome = session.append_realtime_transcript_event(
10941            RealtimeTranscriptEvent::AssistantTurnCompleted {
10942                response_id: "resp_a".to_string(),
10943                stop_reason: StopReason::EndTurn,
10944                usage: Usage::default(),
10945            },
10946        );
10947        assert_eq!(outcome.materialized_messages.len(), 1);
10948        assert_eq!(session.messages().len(), 2);
10949        assert!(matches!(
10950            &session.messages()[1],
10951            Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "answer a"
10952        ));
10953    }
10954
10955    #[test]
10956    fn realtime_transcript_completion_before_later_delta_is_response_scoped() {
10957        let mut session = Session::new();
10958
10959        let _ = session.append_realtime_transcript_event(
10960            RealtimeTranscriptEvent::UserTranscriptFinal {
10961                item_id: "item_user".to_string(),
10962                previous_item_id: None,
10963                content_index: 0,
10964                text: "question".to_string(),
10965            },
10966        );
10967        assert!(
10968            session
10969                .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
10970                    response_id: "resp_a".to_string(),
10971                    stop_reason: StopReason::EndTurn,
10972                    usage: Usage::default(),
10973                })
10974                .is_inert()
10975        );
10976        assert!(
10977            session
10978                .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
10979                    response_id: "resp_b".to_string(),
10980                    delta_id: "evt_b".to_string(),
10981                    item_id: "item_b".to_string(),
10982                    previous_item_id: Some("item_user".to_string()),
10983                    content_index: 0,
10984                    delta: "wrong response".to_string(),
10985                })
10986                .is_inert(),
10987            "a later delta for another response must not be finalized by resp_a's pending completion"
10988        );
10989
10990        let outcome =
10991            session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
10992                response_id: "resp_a".to_string(),
10993                delta_id: "evt_a".to_string(),
10994                item_id: "item_a".to_string(),
10995                previous_item_id: Some("item_user".to_string()),
10996                content_index: 0,
10997                delta: "right response".to_string(),
10998            });
10999
11000        assert_eq!(outcome.materialized_messages.len(), 1);
11001        assert_eq!(session.messages().len(), 2);
11002        assert!(matches!(
11003            &session.messages()[1],
11004            Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "right response"
11005        ));
11006    }
11007
11008    #[test]
11009    fn realtime_transcript_late_duplicate_completion_cannot_finalize_unrelated_response() {
11010        let mut session = Session::new();
11011
11012        let _ = session.append_realtime_transcript_event(
11013            RealtimeTranscriptEvent::UserTranscriptFinal {
11014                item_id: "item_user".to_string(),
11015                previous_item_id: None,
11016                content_index: 0,
11017                text: "question".to_string(),
11018            },
11019        );
11020        let _ =
11021            session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
11022                response_id: "resp_a".to_string(),
11023                delta_id: "evt_a".to_string(),
11024                item_id: "item_a".to_string(),
11025                previous_item_id: Some("item_user".to_string()),
11026                content_index: 0,
11027                delta: "first".to_string(),
11028            });
11029        let _ = session.append_realtime_transcript_event(
11030            RealtimeTranscriptEvent::AssistantTurnCompleted {
11031                response_id: "resp_a".to_string(),
11032                stop_reason: StopReason::EndTurn,
11033                usage: Usage::default(),
11034            },
11035        );
11036        assert_eq!(session.messages().len(), 2);
11037
11038        assert!(
11039            session
11040                .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
11041                    response_id: "resp_b".to_string(),
11042                    delta_id: "evt_b".to_string(),
11043                    item_id: "item_b".to_string(),
11044                    previous_item_id: Some("item_a".to_string()),
11045                    content_index: 0,
11046                    delta: "second".to_string(),
11047                })
11048                .is_inert()
11049        );
11050        assert!(
11051            session
11052                .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
11053                    response_id: "resp_a".to_string(),
11054                    stop_reason: StopReason::EndTurn,
11055                    usage: Usage::default(),
11056                })
11057                .is_inert(),
11058            "a duplicate late terminal for resp_a must not finalize resp_b"
11059        );
11060        assert_eq!(session.messages().len(), 2);
11061
11062        let outcome = session.append_realtime_transcript_event(
11063            RealtimeTranscriptEvent::AssistantTurnCompleted {
11064                response_id: "resp_b".to_string(),
11065                stop_reason: StopReason::EndTurn,
11066                usage: Usage::default(),
11067            },
11068        );
11069        assert_eq!(outcome.materialized_messages.len(), 1);
11070        assert_eq!(session.messages().len(), 3);
11071    }
11072
11073    #[test]
11074    fn realtime_transcript_interruption_discards_only_matching_response() {
11075        // R5-5: cross-response isolation invariant — Interrupted on resp_a
11076        // does NOT touch resp_b's staged content. Both responses use
11077        // `AssistantTextDelta` (Display lane); under R5-5 resp_a's Display
11078        // item is RETAINED at Interrupted time and resp_b's continues
11079        // unaffected, materializing on its later TurnCompleted.
11080        let mut session = Session::new();
11081
11082        let _ = session.append_realtime_transcript_event(
11083            RealtimeTranscriptEvent::UserTranscriptFinal {
11084                item_id: "item_user".to_string(),
11085                previous_item_id: None,
11086                content_index: 0,
11087                text: "question".to_string(),
11088            },
11089        );
11090        let _ =
11091            session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
11092                response_id: "resp_a".to_string(),
11093                delta_id: "evt_a".to_string(),
11094                item_id: "item_a".to_string(),
11095                previous_item_id: Some("item_user".to_string()),
11096                content_index: 0,
11097                delta: "interrupted display".to_string(),
11098            });
11099        let _ =
11100            session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
11101                response_id: "resp_b".to_string(),
11102                delta_id: "evt_b".to_string(),
11103                item_id: "item_b".to_string(),
11104                previous_item_id: Some("item_user".to_string()),
11105                content_index: 0,
11106                delta: "keep me".to_string(),
11107            });
11108
11109        // R5-5: Interrupted commits the resp_a Display item; resp_b
11110        // remains untouched.
11111        let interrupt_outcome = session.append_realtime_transcript_event(
11112            RealtimeTranscriptEvent::AssistantTurnInterrupted {
11113                response_id: "resp_a".to_string(),
11114            },
11115        );
11116        assert_eq!(
11117            interrupt_outcome.materialized_messages.len(),
11118            1,
11119            "resp_a's Display item commits on Interrupted"
11120        );
11121
11122        let outcome = session.append_realtime_transcript_event(
11123            RealtimeTranscriptEvent::AssistantTurnCompleted {
11124                response_id: "resp_b".to_string(),
11125                stop_reason: StopReason::EndTurn,
11126                usage: Usage::default(),
11127            },
11128        );
11129        assert_eq!(
11130            outcome.materialized_messages.len(),
11131            1,
11132            "resp_b commits on its TurnCompleted, untouched by resp_a's Interrupted"
11133        );
11134
11135        // 1 user + 2 assistant messages.
11136        assert_eq!(session.messages().len(), 3);
11137        assert!(matches!(
11138            &session.messages()[1],
11139            Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "interrupted display"
11140        ));
11141        assert!(matches!(
11142            &session.messages()[2],
11143            Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "keep me"
11144        ));
11145    }
11146
11147    // Performance tests for Arc-based CoW
11148
11149    #[test]
11150    fn test_fork_shares_arc_no_clone() {
11151        let mut session = Session::new();
11152        for i in 0..100 {
11153            session.push(Message::User(UserMessage::text(format!("Message {i}"))));
11154        }
11155
11156        // Fork should share the same Arc, not clone messages
11157        let forked = session.fork();
11158
11159        // Both should point to the same underlying data (Arc refcount > 1)
11160        assert!(Arc::ptr_eq(&session.messages, &forked.messages));
11161        assert_eq!(forked.messages().len(), 100);
11162    }
11163
11164    #[test]
11165    fn test_fork_at_shares_arc_prefix() {
11166        let mut session = Session::new();
11167        for i in 0..100 {
11168            session.push(Message::User(UserMessage::text(format!("Message {i}"))));
11169        }
11170
11171        // Fork at 50 should create new Arc with copied prefix
11172        let forked = session.fork_at(50);
11173        assert_eq!(forked.messages().len(), 50);
11174
11175        // Original should be unchanged
11176        assert_eq!(session.messages().len(), 100);
11177    }
11178
11179    #[test]
11180    fn test_fork_at_resets_transcript_history_state_for_branch_identity() {
11181        let mut session = Session::new();
11182        session.push(Message::User(UserMessage::text(
11183            "summarize this".to_string(),
11184        )));
11185        session.push(Message::BlockAssistant(BlockAssistantMessage::new(
11186            vec![AssistantBlock::Text {
11187                text: "long assistant trace".to_string(),
11188                meta: None,
11189            }],
11190            StopReason::EndTurn,
11191        )));
11192        let parent_revision = session.transcript_revision().expect("parent revision");
11193        session
11194            .commit_transcript_rewrite(
11195                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
11196                vec![Message::BlockAssistant(BlockAssistantMessage::new(
11197                    vec![AssistantBlock::Text {
11198                        text: "compact trace".to_string(),
11199                        meta: None,
11200                    }],
11201                    StopReason::EndTurn,
11202                ))],
11203                TranscriptRewriteReason::new("compaction"),
11204                Some("test".to_string()),
11205                Some(parent_revision),
11206            )
11207            .expect("rewrite should commit");
11208
11209        let source_head = session.transcript_revision().expect("source head");
11210        let mut forked = session.fork_at(1);
11211        assert_ne!(forked.id(), session.id());
11212        assert!(
11213            !forked
11214                .metadata()
11215                .contains_key(SESSION_TRANSCRIPT_HISTORY_STATE_KEY)
11216        );
11217        assert_eq!(
11218            forked.transcript_revision().expect("fork head"),
11219            transcript_messages_digest(forked.messages()).expect("fork digest")
11220        );
11221        assert!(
11222            forked
11223                .transcript_revision_messages(&source_head)
11224                .expect("fork history lookup")
11225                .is_none()
11226        );
11227
11228        let fork_parent = forked.transcript_revision().expect("fork parent");
11229        let commit = forked
11230            .commit_transcript_rewrite(
11231                TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
11232                vec![Message::User(UserMessage::text(
11233                    "branch prompt".to_string(),
11234                ))],
11235                TranscriptRewriteReason::new("branch_edit"),
11236                Some("test".to_string()),
11237                Some(fork_parent.clone()),
11238            )
11239            .expect("fork rewrite should use fork-local parent");
11240        assert_eq!(commit.parent_revision, fork_parent);
11241    }
11242
11243    #[test]
11244    fn test_push_cow_behavior() {
11245        let mut session = Session::new();
11246        session.push(Message::User(UserMessage::text("First".to_string())));
11247
11248        // Fork shares the Arc
11249        let forked = session.fork();
11250        assert!(Arc::ptr_eq(&session.messages, &forked.messages));
11251
11252        // Push on original triggers CoW - original gets new Arc
11253        session.push(Message::User(UserMessage::text("Second".to_string())));
11254
11255        // Now they should have different Arcs
11256        assert!(!Arc::ptr_eq(&session.messages, &forked.messages));
11257        assert_eq!(session.messages().len(), 2);
11258        assert_eq!(forked.messages().len(), 1);
11259    }
11260
11261    // Performance tests for lazy timestamp updates
11262
11263    #[test]
11264    fn test_push_batch_single_timestamp() {
11265        let mut session = Session::new();
11266        let initial_updated = session.updated_at();
11267
11268        // Use push_batch to add multiple messages without repeated syscalls
11269        session.push_batch(vec![
11270            Message::User(UserMessage::text("First".to_string())),
11271            Message::User(UserMessage::text("Second".to_string())),
11272            Message::User(UserMessage::text("Third".to_string())),
11273        ]);
11274
11275        assert_eq!(session.messages().len(), 3);
11276        // Timestamp should have been updated once
11277        assert!(session.updated_at() >= initial_updated);
11278    }
11279
11280    #[test]
11281    fn test_touch_updates_timestamp() {
11282        let mut session = Session::new();
11283        let initial = session.updated_at();
11284
11285        std::thread::sleep(std::time::Duration::from_millis(10));
11286
11287        // Explicit touch to update timestamp
11288        session.touch();
11289
11290        assert!(session.updated_at() > initial);
11291    }
11292
11293    #[test]
11294    fn test_session_push() {
11295        let mut session = Session::new();
11296        let initial_updated = session.updated_at();
11297
11298        // Small delay to ensure time changes
11299        std::thread::sleep(std::time::Duration::from_millis(10));
11300
11301        session.push(Message::User(UserMessage::text("Hello".to_string())));
11302
11303        assert_eq!(session.messages().len(), 1);
11304        assert!(session.updated_at() > initial_updated);
11305    }
11306
11307    #[test]
11308    fn test_session_fork() {
11309        let mut session = Session::new();
11310        session.push(Message::System(SystemMessage::new("System prompt")));
11311        session.push(Message::User(UserMessage::text("Hello".to_string())));
11312        session.push(Message::BlockAssistant(BlockAssistantMessage {
11313            blocks: vec![AssistantBlock::Text {
11314                text: "Hi!".to_string(),
11315                meta: None,
11316            }],
11317            stop_reason: StopReason::EndTurn,
11318            identity: crate::types::TranscriptMessageIdentity::default(),
11319            created_at: crate::types::message_timestamp_now(),
11320        }));
11321
11322        // Fork at index 2 (system + user)
11323        let forked = session.fork_at(2);
11324        assert_eq!(forked.messages().len(), 2);
11325        assert_ne!(forked.id(), session.id());
11326
11327        // Full fork
11328        let full_fork = session.fork();
11329        assert_eq!(full_fork.messages().len(), 3);
11330    }
11331
11332    #[test]
11333    fn test_session_forks_drop_generated_authority_metadata() {
11334        let mut session = Session::new();
11335        session.push(Message::User(UserMessage::text("original")));
11336        session.set_metadata("ordinary", serde_json::json!("keep"));
11337        session
11338            .set_build_state(SessionBuildState::default())
11339            .expect("build state should serialize");
11340        session
11341            .set_system_context_state(SessionSystemContextState::default())
11342            .expect("system-context state should serialize");
11343        session
11344            .set_deferred_turn_state(SessionDeferredTurnState::default())
11345            .expect("deferred-turn state should serialize");
11346        session
11347            .set_tool_visibility_state(
11348                AuthorizedSessionToolVisibilityState::from_generated_authority(
11349                    SessionToolVisibilityState::default(),
11350                ),
11351            )
11352            .expect("visibility state should serialize");
11353        let _ = session.append_realtime_transcript_event(RealtimeTranscriptEvent::ItemObserved {
11354            item_id: "rt-item".to_string(),
11355            previous_item_id: None,
11356            role: RealtimeTranscriptRole::User,
11357            response_id: None,
11358        });
11359        session.metadata.insert(
11360            crate::memory::SESSION_COMPACTION_PROJECTION_INTENTS_KEY.to_string(),
11361            serde_json::json!([{"sealed_projection": "must-not-fork"}]),
11362        );
11363        assert!(
11364            session
11365                .metadata()
11366                .contains_key(SESSION_REALTIME_TRANSCRIPT_STATE_KEY),
11367            "test setup should install realtime transcript authority state"
11368        );
11369
11370        let forked_at = session.fork_at(1);
11371        let full_fork = session.fork();
11372        let replaced = session
11373            .fork_replacing(
11374                0,
11375                TranscriptReplacement::Message {
11376                    message: Message::User(UserMessage::text("replacement")),
11377                },
11378            )
11379            .expect("replacement fork should succeed");
11380
11381        for forked in [&forked_at, &full_fork, &replaced] {
11382            assert_eq!(forked.metadata().get("ordinary").unwrap(), "keep");
11383            assert!(
11384                !forked.metadata().contains_key(SESSION_BUILD_STATE_KEY),
11385                "forked sessions must not raw-copy durable build-state authority"
11386            );
11387            assert!(
11388                !forked
11389                    .metadata()
11390                    .contains_key(SESSION_SYSTEM_CONTEXT_STATE_KEY),
11391                "forked sessions must not raw-copy system-context authority state"
11392            );
11393            assert!(
11394                !forked
11395                    .metadata()
11396                    .contains_key(SESSION_DEFERRED_TURN_STATE_KEY),
11397                "forked sessions must not raw-copy deferred-turn authority state"
11398            );
11399            assert!(
11400                !forked
11401                    .metadata()
11402                    .contains_key(SESSION_TOOL_VISIBILITY_STATE_KEY),
11403                "forked sessions must not raw-copy tool-visibility authority state"
11404            );
11405            assert!(
11406                !forked
11407                    .metadata()
11408                    .contains_key(SESSION_REALTIME_TRANSCRIPT_STATE_KEY),
11409                "forked sessions must not raw-copy realtime transcript authority state"
11410            );
11411            assert!(
11412                !forked
11413                    .metadata()
11414                    .contains_key(crate::memory::SESSION_COMPACTION_PROJECTION_INTENTS_KEY),
11415                "forked sessions must not raw-copy compaction outbox authority"
11416            );
11417        }
11418    }
11419
11420    #[test]
11421    fn test_session_metadata() {
11422        let mut session = Session::new();
11423        session.set_metadata("key", serde_json::json!("value"));
11424
11425        assert_eq!(session.metadata().get("key").unwrap(), "value");
11426    }
11427
11428    #[test]
11429    fn identical_metadata_projection_is_checkpoint_idempotent() {
11430        let mut session = Session::new();
11431        session.set_metadata("key", serde_json::json!({ "value": 1 }));
11432        let updated_at = session.updated_at;
11433        let digest = crate::session_checkpoint_digest(&session)
11434            .expect("checkpoint digest before identical projection");
11435
11436        session.set_metadata("key", serde_json::json!({ "value": 1 }));
11437        session.remove_metadata("already_absent");
11438
11439        assert_eq!(
11440            session.updated_at, updated_at,
11441            "an identical durable projection must not manufacture a content mutation"
11442        );
11443        assert_eq!(
11444            crate::session_checkpoint_digest(&session)
11445                .expect("checkpoint digest after identical projection"),
11446            digest,
11447            "an identical durable projection must not rotate checkpoint authority"
11448        );
11449    }
11450
11451    #[test]
11452    fn session_metadata_realm_id_is_back_read_compatible_string() {
11453        // A typed realm_id serializes as a bare JSON string (byte-identical to
11454        // the prior Option<String> durable shape).
11455        let metadata = SessionMetadata {
11456            schema_version: SESSION_METADATA_SCHEMA_VERSION,
11457            model: "test-model".to_string(),
11458            max_tokens: 1024,
11459            structured_output_retries: 2,
11460            provider: Provider::Other,
11461            self_hosted_server_id: None,
11462            provider_params: None,
11463            tooling: SessionTooling::default(),
11464            keep_alive: false,
11465            comms_name: None,
11466            peer_meta: None,
11467            realm_id: Some(crate::RealmId::parse("env_default").unwrap()),
11468            instance_id: None,
11469            backend: None,
11470            config_generation: None,
11471            auth_binding: None,
11472            mob_member_binding: None,
11473        };
11474        let value = serde_json::to_value(&metadata).unwrap();
11475        assert_eq!(
11476            value.get("realm_id"),
11477            Some(&serde_json::json!("env_default")),
11478            "typed realm_id must serialize as a bare slug string"
11479        );
11480
11481        // A legacy persisted row stored realm_id as a JSON string; it must
11482        // deserialize into the typed RealmId (durable back-read).
11483        let legacy = serde_json::json!({
11484            "schema_version": SESSION_METADATA_SCHEMA_VERSION,
11485            "model": "test-model",
11486            "max_tokens": 1024,
11487            "structured_output_retries": 2,
11488            "provider": "other",
11489            "tooling": SessionTooling::default(),
11490            "keep_alive": false,
11491            "comms_name": null,
11492            "realm_id": "legacy_realm",
11493        });
11494        let restored: SessionMetadata = serde_json::from_value(legacy).unwrap();
11495        assert_eq!(
11496            restored.realm_id.as_ref().map(crate::RealmId::as_str),
11497            Some("legacy_realm")
11498        );
11499    }
11500
11501    /// Ask 6: `SessionTooling.tool_access_policy` is additive — a persisted
11502    /// row without the field back-reads as `None` (unrestricted), `None` is
11503    /// omitted on write (durable shape unchanged for ungated sessions), and a
11504    /// resolved policy round-trips intact.
11505    #[test]
11506    fn session_tooling_tool_access_policy_round_trip_and_absent_default() {
11507        // Absent field back-reads as None.
11508        let legacy = serde_json::json!({});
11509        let restored: SessionTooling = serde_json::from_value(legacy).unwrap();
11510        assert_eq!(restored.tool_access_policy, None);
11511
11512        // None is omitted on write — ungated sessions keep their prior shape.
11513        let value = serde_json::to_value(SessionTooling::default()).unwrap();
11514        assert!(
11515            value.get("tool_access_policy").is_none(),
11516            "None policy must not serialize"
11517        );
11518
11519        // A resolved policy round-trips intact.
11520        let tooling = SessionTooling {
11521            tool_access_policy: Some(crate::ops::ToolAccessPolicy::AllowList(
11522                ["read_file", "send_message"].into_iter().collect(),
11523            )),
11524            ..SessionTooling::default()
11525        };
11526        let value = serde_json::to_value(&tooling).unwrap();
11527        let restored: SessionTooling = serde_json::from_value(value).unwrap();
11528        assert_eq!(restored.tool_access_policy, tooling.tool_access_policy);
11529    }
11530
11531    #[test]
11532    fn lifecycle_terminal_typed_round_trip() {
11533        let mut session = Session::new();
11534        assert_eq!(session.lifecycle_terminal(), None);
11535
11536        session
11537            .set_lifecycle_terminal(SessionLifecycleTerminal::Archived)
11538            .expect("typed terminal write should serialize");
11539        assert_eq!(
11540            session.lifecycle_terminal(),
11541            Some(SessionLifecycleTerminal::Archived)
11542        );
11543        assert!(
11544            session
11545                .lifecycle_terminal()
11546                .is_some_and(SessionLifecycleTerminal::is_archived)
11547        );
11548        // Persisted JSON for the typed key is the snake_case variant string.
11549        assert_eq!(
11550            session
11551                .metadata()
11552                .get(SESSION_LIFECYCLE_TERMINAL_KEY)
11553                .unwrap(),
11554            &serde_json::json!("archived")
11555        );
11556    }
11557
11558    #[test]
11559    fn lifecycle_terminal_key_rejects_raw_mutation() {
11560        let mut session = Session::new();
11561        assert!(
11562            session
11563                .try_set_metadata(
11564                    SESSION_LIFECYCLE_TERMINAL_KEY,
11565                    serde_json::json!("archived")
11566                )
11567                .is_err(),
11568            "the typed lifecycle-terminal key is reserved for session authority"
11569        );
11570    }
11571
11572    #[test]
11573    fn test_session_metadata_backfill_preserves_timestamp() {
11574        let mut session = Session::new();
11575        let initial_updated = session.updated_at();
11576
11577        std::thread::sleep(std::time::Duration::from_millis(10));
11578
11579        assert!(session.backfill_metadata_if_absent("key", serde_json::json!("value")));
11580        assert_eq!(session.metadata().get("key").unwrap(), "value");
11581        assert_eq!(session.updated_at(), initial_updated);
11582        assert!(!session.backfill_metadata_if_absent("key", serde_json::json!("other")));
11583        assert_eq!(session.metadata().get("key").unwrap(), "value");
11584        assert_eq!(session.updated_at(), initial_updated);
11585    }
11586
11587    #[test]
11588    fn test_reserved_generated_authority_metadata_rejects_raw_mutation() {
11589        let mut session = Session::new();
11590
11591        assert!(
11592            session
11593                .try_set_metadata(SESSION_SYSTEM_CONTEXT_STATE_KEY, serde_json::json!({}))
11594                .is_err()
11595        );
11596        assert!(
11597            session
11598                .try_set_metadata(SESSION_METADATA_KEY, serde_json::json!({}))
11599                .is_err()
11600        );
11601        assert!(
11602            session
11603                .try_set_metadata(SESSION_BUILD_STATE_KEY, serde_json::json!({}))
11604                .is_err()
11605        );
11606        let compaction_intents_key = crate::memory::SESSION_COMPACTION_PROJECTION_INTENTS_KEY;
11607        let sealed_compaction_intents =
11608            serde_json::json!([{"sealed_projection": "typed-owner-only"}]);
11609        session.metadata.insert(
11610            compaction_intents_key.to_string(),
11611            sealed_compaction_intents.clone(),
11612        );
11613        assert!(
11614            session
11615                .try_set_metadata(compaction_intents_key, serde_json::json!([]))
11616                .is_err(),
11617            "raw metadata must not overwrite compaction outbox authority"
11618        );
11619        session.remove_metadata(compaction_intents_key);
11620        assert_eq!(
11621            session.metadata().get(compaction_intents_key),
11622            Some(&sealed_compaction_intents),
11623            "raw metadata removal must not erase compaction outbox authority"
11624        );
11625        let mut absent = Session::new();
11626        assert!(
11627            !absent.backfill_metadata_if_absent(
11628                compaction_intents_key,
11629                serde_json::json!([{"forged_projection": true}])
11630            ),
11631            "compatibility backfill must not fabricate compaction outbox authority"
11632        );
11633        assert!(!absent.metadata().contains_key(compaction_intents_key));
11634        session
11635            .set_session_metadata(SessionMetadata {
11636                schema_version: SESSION_METADATA_SCHEMA_VERSION,
11637                model: "test-model".to_string(),
11638                max_tokens: 1024,
11639                structured_output_retries: 2,
11640                provider: Provider::Other,
11641                self_hosted_server_id: None,
11642                provider_params: None,
11643                tooling: SessionTooling::default(),
11644                keep_alive: false,
11645                comms_name: None,
11646                peer_meta: None,
11647                realm_id: None,
11648                instance_id: None,
11649                backend: None,
11650                config_generation: None,
11651                auth_binding: None,
11652                mob_member_binding: None,
11653            })
11654            .expect("typed metadata setter should route through generated authority");
11655        session
11656            .set_build_state(SessionBuildState::default())
11657            .expect("typed build-state setter should route through generated authority");
11658        session.remove_metadata(SESSION_METADATA_KEY);
11659        session.remove_metadata(SESSION_BUILD_STATE_KEY);
11660        assert!(
11661            session.metadata().contains_key(SESSION_METADATA_KEY),
11662            "raw removal must not delete generated-authority session metadata"
11663        );
11664        assert!(
11665            session.metadata().contains_key(SESSION_BUILD_STATE_KEY),
11666            "raw removal must not delete generated-authority build state"
11667        );
11668        session.set_metadata(SESSION_DEFERRED_TURN_STATE_KEY, serde_json::json!({}));
11669        assert!(
11670            !session
11671                .metadata()
11672                .contains_key(SESSION_DEFERRED_TURN_STATE_KEY)
11673        );
11674        assert!(
11675            !session.backfill_metadata_if_absent(
11676                SESSION_SYSTEM_CONTEXT_STATE_KEY,
11677                serde_json::json!({})
11678            )
11679        );
11680
11681        let state = SessionSystemContextState::default();
11682        session
11683            .set_system_context_state(state.clone())
11684            .expect("typed setter should route through generated authority");
11685        session.remove_metadata(SESSION_SYSTEM_CONTEXT_STATE_KEY);
11686        assert_eq!(
11687            session
11688                .try_system_context_state()
11689                .expect("typed state should restore"),
11690            Some(state)
11691        );
11692
11693        session.metadata.insert(
11694            SESSION_SYSTEM_CONTEXT_STATE_KEY.to_string(),
11695            serde_json::json!("not-a-state"),
11696        );
11697        assert!(
11698            session.try_system_context_state().is_err(),
11699            "malformed generated authority state must not decode as absent/default"
11700        );
11701
11702        session.metadata.insert(
11703            SESSION_METADATA_KEY.to_string(),
11704            serde_json::json!("not-metadata"),
11705        );
11706        assert!(
11707            session.try_session_metadata().is_err(),
11708            "malformed session metadata must not decode as absent/default"
11709        );
11710
11711        session.metadata.insert(
11712            SESSION_BUILD_STATE_KEY.to_string(),
11713            serde_json::json!("not-build-state"),
11714        );
11715        assert!(
11716            session.try_build_state().is_err(),
11717            "malformed build state must not decode as absent/default"
11718        );
11719
11720        assert!(
11721            session
11722                .try_set_metadata(SESSION_TOOL_VISIBILITY_STATE_KEY, serde_json::json!({}))
11723                .is_err()
11724        );
11725        session
11726            .set_tool_visibility_state(
11727                AuthorizedSessionToolVisibilityState::from_generated_authority(
11728                    SessionToolVisibilityState::default(),
11729                ),
11730            )
11731            .expect("typed visibility setter should route through typed authority handoff");
11732        session.remove_metadata(SESSION_TOOL_VISIBILITY_STATE_KEY);
11733        assert!(
11734            session
11735                .metadata()
11736                .contains_key(SESSION_TOOL_VISIBILITY_STATE_KEY)
11737        );
11738        session.clear_tool_visibility_state();
11739        assert!(
11740            !session
11741                .metadata()
11742                .contains_key(SESSION_TOOL_VISIBILITY_STATE_KEY)
11743        );
11744        assert!(
11745            session
11746                .try_set_metadata(SESSION_REALTIME_TRANSCRIPT_STATE_KEY, serde_json::json!({}))
11747                .is_err()
11748        );
11749        let _ = session.append_realtime_transcript_event(RealtimeTranscriptEvent::ItemObserved {
11750            item_id: "rt-item".to_string(),
11751            previous_item_id: None,
11752            role: RealtimeTranscriptRole::User,
11753            response_id: None,
11754        });
11755        assert!(
11756            session
11757                .metadata()
11758                .contains_key(SESSION_REALTIME_TRANSCRIPT_STATE_KEY),
11759            "typed realtime transcript append should retain authority to persist its state"
11760        );
11761        session.metadata.insert(
11762            SESSION_REALTIME_TRANSCRIPT_STATE_KEY.to_string(),
11763            serde_json::json!("not-a-state"),
11764        );
11765        assert!(
11766            session.try_realtime_transcript_state().is_err(),
11767            "malformed realtime generated authority state must not decode as absent/default"
11768        );
11769    }
11770
11771    #[test]
11772    fn test_session_mob_tool_authority_context_persists_projection_without_authority_seal() {
11773        let mut session = Session::new();
11774        session
11775            .set_build_state(SessionBuildState::default())
11776            .expect("session build state should serialize");
11777        let authority = MobToolAuthorityContext::generated_for_test(
11778            crate::service::OpaquePrincipalToken::new("opaque-principal"),
11779            false,
11780            false,
11781            false,
11782            std::collections::BTreeSet::from(["mob-a".to_string()]),
11783            std::collections::BTreeMap::new(),
11784            None,
11785            Some("audit-1".to_string()),
11786        );
11787
11788        session
11789            .set_mob_tool_authority_context(Some(authority))
11790            .expect("authority should serialize");
11791        assert!(session.mob_tool_authority_context().is_none());
11792        let stored = session
11793            .build_state()
11794            .and_then(|state| state.mob_tool_authority_context)
11795            .expect("stored projection should deserialize");
11796        assert!(!stored.is_generated_authority_context());
11797        assert!(!stored.can_manage_mob("mob-a"));
11798
11799        session
11800            .set_mob_tool_authority_context(None)
11801            .expect("authority should clear");
11802        assert!(session.mob_tool_authority_context().is_none());
11803    }
11804
11805    #[test]
11806    fn test_session_build_state_rejects_forged_mob_authority_projection() {
11807        let mut session = Session::new();
11808        let authority = MobToolAuthorityContext::generated_for_test(
11809            crate::service::OpaquePrincipalToken::new("opaque-principal"),
11810            false,
11811            false,
11812            false,
11813            std::collections::BTreeSet::from(["mob-a".to_string()]),
11814            std::collections::BTreeMap::new(),
11815            None,
11816            Some("audit-1".to_string()),
11817        );
11818        let forged_projection: MobToolAuthorityContext =
11819            serde_json::from_value(serde_json::to_value(authority).expect("serialize authority"))
11820                .expect("deserialize projection");
11821        assert!(!forged_projection.is_generated_authority_context());
11822
11823        let err = session
11824            .set_build_state(SessionBuildState {
11825                mob_tool_authority_context: Some(forged_projection),
11826                ..Default::default()
11827            })
11828            .expect_err("forged build state must be rejected by generated authority");
11829        // The build-state-persist admission decision now lives in the canonical
11830        // SessionDocumentMachine durable-config region (LUC-524); the rejection
11831        // surfaces with that machine's authority wording.
11832        assert!(
11833            err.to_string()
11834                .contains("generated session document authority rejected"),
11835            "unexpected error: {err}"
11836        );
11837    }
11838
11839    #[test]
11840    fn test_session_tool_visibility_state_roundtrip() {
11841        let mut session = Session::new();
11842        let state = SessionToolVisibilityState {
11843            inherited_base_filter: ToolFilter::Allow(["visible".to_string()].into_iter().collect()),
11844            active_filter: ToolFilter::Allow(
11845                ["visible".to_string(), "missing".to_string()]
11846                    .into_iter()
11847                    .collect(),
11848            ),
11849            staged_filter: ToolFilter::Allow(
11850                ["visible".to_string(), "missing".to_string()]
11851                    .into_iter()
11852                    .collect(),
11853            ),
11854            active_revision: 1,
11855            staged_revision: 2,
11856            ..Default::default()
11857        };
11858
11859        session
11860            .set_tool_visibility_state(
11861                AuthorizedSessionToolVisibilityState::from_generated_authority(state.clone()),
11862            )
11863            .expect("tool visibility state should serialize");
11864        assert_eq!(session.tool_visibility_state().unwrap(), Some(state));
11865    }
11866
11867    #[test]
11868    fn test_session_tool_visibility_state_malformed_returns_error() {
11869        let mut session = Session::new();
11870        session.metadata.insert(
11871            SESSION_TOOL_VISIBILITY_STATE_KEY.to_string(),
11872            serde_json::json!({
11873                "active_filter": {
11874                    "unexpected_filter_kind": ["secret"]
11875                }
11876            }),
11877        );
11878
11879        assert!(
11880            session.tool_visibility_state().is_err(),
11881            "malformed canonical visibility metadata must not decode as absent/default"
11882        );
11883    }
11884
11885    #[test]
11886    fn test_session_serialization() {
11887        let mut session = Session::new();
11888        session.push(Message::User(UserMessage::text("Test".to_string())));
11889
11890        let json = serde_json::to_string(&session).unwrap();
11891        let parsed: Session = serde_json::from_str(&json).unwrap();
11892
11893        assert_eq!(parsed.id(), session.id());
11894        assert_eq!(parsed.messages().len(), 1);
11895        assert_eq!(parsed.version(), SESSION_VERSION);
11896    }
11897
11898    #[test]
11899    fn test_session_meta_from_session() {
11900        let mut session = Session::new();
11901        session.push(Message::User(UserMessage::text("Hello".to_string())));
11902        session.push(Message::BlockAssistant(BlockAssistantMessage {
11903            blocks: vec![AssistantBlock::Text {
11904                text: "Hi!".to_string(),
11905                meta: None,
11906            }],
11907            stop_reason: StopReason::EndTurn,
11908            identity: crate::types::TranscriptMessageIdentity::default(),
11909            created_at: crate::types::message_timestamp_now(),
11910        }));
11911        session.record_usage(Usage {
11912            input_tokens: 10,
11913            output_tokens: 5,
11914            cache_creation_tokens: None,
11915            cache_read_tokens: None,
11916        });
11917
11918        let meta = SessionMeta::from(&session);
11919        assert_eq!(meta.id, *session.id());
11920        assert_eq!(meta.message_count, 2);
11921        assert_eq!(meta.total_tokens, 15);
11922    }
11923
11924    #[test]
11925    fn system_context_state_preserves_applied_runtime_context() {
11926        let accepted_at = SystemTime::UNIX_EPOCH;
11927        let mut state = SessionSystemContextState::default();
11928        state
11929            .stage_append(
11930                &AppendSystemContextRequest {
11931                    content: crate::lifecycle::run_primitive::CoreRenderable::text(
11932                        "Authoritative peer token is birch seventeen.".to_string(),
11933                    ),
11934                    source: Some(
11935                        "peer_response_terminal:analyst:018f6f79-7a82-7c4e-a552-a3b86f9630f1"
11936                            .to_string(),
11937                    ),
11938                    idempotency_key: Some("018f6f79-7a82-7c4e-a552-a3b86f9630f1".to_string()),
11939                    source_kind: SystemContextSource::Normal,
11940                    peer_response_terminal: None,
11941                },
11942                accepted_at,
11943            )
11944            .expect("append should stage");
11945
11946        state.mark_pending_applied();
11947
11948        assert!(state.pending.is_empty());
11949        assert_eq!(state.applied.len(), 1);
11950        assert_eq!(
11951            state.applied[0].content.render_text(),
11952            "Authoritative peer token is birch seventeen."
11953        );
11954        assert_eq!(
11955            state.applied[0].source.as_deref(),
11956            Some("peer_response_terminal:analyst:018f6f79-7a82-7c4e-a552-a3b86f9630f1")
11957        );
11958
11959        let round_tripped: SessionSystemContextState =
11960            serde_json::from_value(serde_json::to_value(&state).expect("serialize state"))
11961                .expect("deserialize state");
11962        assert_eq!(round_tripped.applied, state.applied);
11963    }
11964
11965    #[test]
11966    fn active_turn_system_context_is_discarded_when_not_applied() {
11967        let mut state = SessionSystemContextState::default();
11968        state
11969            .stage_active_turn_append(
11970                &AppendSystemContextRequest {
11971                    content: crate::lifecycle::run_primitive::CoreRenderable::text(
11972                        "only for the active run".to_string(),
11973                    ),
11974                    source: Some("runtime:steer:input-1".to_string()),
11975                    idempotency_key: Some("runtime:steer:input-1".to_string()),
11976                    source_kind: SystemContextSource::RuntimeSteer,
11977                    peer_response_terminal: None,
11978                },
11979                SystemTime::UNIX_EPOCH,
11980            )
11981            .expect("active context should stage");
11982
11983        let discarded = state.discard_unapplied_active_turn_pending();
11984
11985        assert_eq!(discarded.len(), 1);
11986        assert!(state.pending.is_empty());
11987        assert!(state.applied.is_empty());
11988        assert!(state.active_turn_pending_keys.is_empty());
11989        assert!(state.active_turn_pending_indices.is_empty());
11990        assert!(
11991            state.seen.is_empty(),
11992            "discarded active-turn context should not block later idempotency keys"
11993        );
11994    }
11995
11996    #[test]
11997    fn keyless_active_turn_system_context_is_owned_and_discarded() {
11998        let mut state = SessionSystemContextState::default();
11999        state
12000            .stage_active_turn_append(
12001                &AppendSystemContextRequest {
12002                    content: crate::lifecycle::run_primitive::CoreRenderable::text(
12003                        "keyless active-turn context".to_string(),
12004                    ),
12005                    source: Some("test:keyless-active-turn".to_string()),
12006                    idempotency_key: None,
12007                    source_kind: SystemContextSource::RuntimeSteer,
12008                    peer_response_terminal: None,
12009                },
12010                SystemTime::UNIX_EPOCH,
12011            )
12012            .expect("keyless active context should stage");
12013
12014        assert!(state.active_turn_pending_keys.is_empty());
12015        assert_eq!(state.active_turn_pending_len(), 1);
12016        let discarded = state.discard_unapplied_active_turn_pending();
12017
12018        assert_eq!(discarded.len(), 1);
12019        assert!(state.pending.is_empty());
12020        assert_eq!(state.active_turn_pending_len(), 0);
12021    }
12022
12023    #[test]
12024    fn active_turn_system_context_can_roll_back_targeted_keys() {
12025        let mut state = SessionSystemContextState::default();
12026        for key in ["runtime:steer:input-1", "runtime:steer:input-2"] {
12027            state
12028                .stage_active_turn_append(
12029                    &AppendSystemContextRequest {
12030                        content: crate::lifecycle::run_primitive::CoreRenderable::text(format!(
12031                            "context for {key}"
12032                        )),
12033                        source: Some(key.to_string()),
12034                        idempotency_key: Some(key.to_string()),
12035                        source_kind: SystemContextSource::RuntimeSteer,
12036                        peer_response_terminal: None,
12037                    },
12038                    SystemTime::UNIX_EPOCH,
12039                )
12040                .expect("active context should stage");
12041        }
12042
12043        let discarded =
12044            state.discard_active_turn_pending_by_keys(&["runtime:steer:input-1".to_string()]);
12045
12046        assert_eq!(discarded.len(), 1);
12047        assert_eq!(
12048            discarded[0].idempotency_key.as_deref(),
12049            Some("runtime:steer:input-1")
12050        );
12051        assert_eq!(state.pending.len(), 1);
12052        assert_eq!(
12053            state.pending[0].idempotency_key.as_deref(),
12054            Some("runtime:steer:input-2")
12055        );
12056        assert!(!state.seen.contains_key("runtime:steer:input-1"));
12057        assert!(state.seen.contains_key("runtime:steer:input-2"));
12058        assert!(
12059            !state
12060                .active_turn_pending_keys
12061                .contains("runtime:steer:input-1")
12062        );
12063        assert!(
12064            state
12065                .active_turn_pending_keys
12066                .contains("runtime:steer:input-2")
12067        );
12068    }
12069
12070    #[test]
12071    fn active_turn_system_context_is_transient_when_boundary_consumes_it() {
12072        let mut state = SessionSystemContextState::default();
12073        state
12074            .stage_active_turn_append(
12075                &AppendSystemContextRequest {
12076                    content: crate::lifecycle::run_primitive::CoreRenderable::text(
12077                        "visible to this run".to_string(),
12078                    ),
12079                    source: Some("runtime:steer:input-2".to_string()),
12080                    idempotency_key: Some("runtime:steer:input-2".to_string()),
12081                    source_kind: SystemContextSource::RuntimeSteer,
12082                    peer_response_terminal: None,
12083                },
12084                SystemTime::UNIX_EPOCH,
12085            )
12086            .expect("active context should stage");
12087
12088        state.mark_pending_applied();
12089        let discarded = state.discard_unapplied_active_turn_pending();
12090
12091        assert!(discarded.is_empty());
12092        assert!(state.pending.is_empty());
12093        assert!(state.applied.is_empty());
12094        assert!(state.active_turn_pending_keys.is_empty());
12095        assert!(state.active_turn_pending_indices.is_empty());
12096        assert_eq!(
12097            state.seen.get("runtime:steer:input-2"),
12098            None,
12099            "consumed active-turn steer context must not become durable state"
12100        );
12101    }
12102
12103    #[test]
12104    fn discard_transient_runtime_steer_context_removes_steer_via_typed_marker() {
12105        let mut session = Session::new();
12106        // The runtime-steer fact is carried by the typed `source_kind`, not by
12107        // the `source` string. The durable peer fact uses the same `source`
12108        // string scheme but is marked `Normal`, so only the steers are removed.
12109        session.set_system_prompt(format!(
12110            "base{}{}{}{}",
12111            SYSTEM_CONTEXT_SEPARATOR,
12112            render_system_context_block(&PendingSystemContextAppend {
12113                content: crate::lifecycle::run_primitive::CoreRenderable::text(
12114                    "old steer".to_string()
12115                ),
12116                source: Some("steer-source-old".to_string()),
12117                idempotency_key: Some("steer-key-old".to_string()),
12118                source_kind: SystemContextSource::RuntimeSteer,
12119                peer_response_terminal: None,
12120                accepted_at: SystemTime::UNIX_EPOCH,
12121            }),
12122            SYSTEM_CONTEXT_SEPARATOR,
12123            render_system_context_block(&PendingSystemContextAppend {
12124                content: crate::lifecycle::run_primitive::CoreRenderable::text(
12125                    "durable peer fact".to_string()
12126                ),
12127                source: Some("peer_response_terminal:analyst:req".to_string()),
12128                idempotency_key: Some("peer_response_terminal:analyst:req".to_string()),
12129                source_kind: SystemContextSource::Normal,
12130                peer_response_terminal: None,
12131                accepted_at: SystemTime::UNIX_EPOCH,
12132            })
12133        ));
12134        session
12135            .set_system_context_state(SessionSystemContextState {
12136                pending: vec![PendingSystemContextAppend {
12137                    content: crate::lifecycle::run_primitive::CoreRenderable::text(
12138                        "pending steer".to_string(),
12139                    ),
12140                    source: Some("steer-source-pending".to_string()),
12141                    idempotency_key: Some("steer-key-pending".to_string()),
12142                    source_kind: SystemContextSource::RuntimeSteer,
12143                    peer_response_terminal: None,
12144                    accepted_at: SystemTime::UNIX_EPOCH,
12145                }],
12146                applied: vec![
12147                    PendingSystemContextAppend {
12148                        content: crate::lifecycle::run_primitive::CoreRenderable::text(
12149                            "old steer".to_string(),
12150                        ),
12151                        source: Some("steer-source-old".to_string()),
12152                        idempotency_key: Some("steer-key-old".to_string()),
12153                        source_kind: SystemContextSource::RuntimeSteer,
12154                        peer_response_terminal: None,
12155                        accepted_at: SystemTime::UNIX_EPOCH,
12156                    },
12157                    PendingSystemContextAppend {
12158                        content: crate::lifecycle::run_primitive::CoreRenderable::text(
12159                            "durable peer fact".to_string(),
12160                        ),
12161                        source: Some("peer_response_terminal:analyst:req".to_string()),
12162                        idempotency_key: Some("peer_response_terminal:analyst:req".to_string()),
12163                        source_kind: SystemContextSource::Normal,
12164                        peer_response_terminal: None,
12165                        accepted_at: SystemTime::UNIX_EPOCH,
12166                    },
12167                ],
12168                seen: BTreeMap::from([(
12169                    "steer-key-old".to_string(),
12170                    SeenSystemContextKey {
12171                        content: crate::lifecycle::run_primitive::CoreRenderable::text(
12172                            "old steer".to_string(),
12173                        ),
12174                        source: Some("steer-source-old".to_string()),
12175                        source_kind: SystemContextSource::RuntimeSteer,
12176                        state: SeenSystemContextState::Applied,
12177                    },
12178                )]),
12179                active_turn_pending_keys: BTreeSet::from(["steer-key-pending".to_string()]),
12180                active_turn_pending_indices: BTreeSet::from([0]),
12181            })
12182            .expect("system context state should serialize");
12183
12184        let removed = session.discard_transient_runtime_steer_context();
12185
12186        assert!(removed >= 4);
12187        let system_prompt = match session.messages().first() {
12188            Some(Message::System(system)) => system.content.as_str(),
12189            other => panic!("expected system prompt, got {other:?}"),
12190        };
12191        assert!(!system_prompt.contains("old steer"));
12192        assert!(system_prompt.contains("durable peer fact"));
12193        let state = session.system_context_state().unwrap_or_default();
12194        assert!(state.pending.is_empty());
12195        assert_eq!(state.applied.len(), 1);
12196        assert_eq!(state.applied[0].content.render_text(), "durable peer fact");
12197        assert!(state.seen.is_empty());
12198        assert!(state.active_turn_pending_keys.is_empty());
12199    }
12200
12201    #[test]
12202    fn append_system_context_blocks_records_typed_applied_context() {
12203        let append = PendingSystemContextAppend {
12204            content: crate::lifecycle::run_primitive::CoreRenderable::text(
12205                "Authoritative peer token is birch seventeen.".to_string(),
12206            ),
12207            source: Some(
12208                "peer_response_terminal:analyst:018f6f79-7a82-7c4e-a552-a3b86f9630f1".to_string(),
12209            ),
12210            idempotency_key: Some("018f6f79-7a82-7c4e-a552-a3b86f9630f1".to_string()),
12211            source_kind: SystemContextSource::Normal,
12212            peer_response_terminal: None,
12213            accepted_at: SystemTime::UNIX_EPOCH,
12214        };
12215        let mut session = Session::new();
12216
12217        session.append_system_context_blocks(std::slice::from_ref(&append));
12218
12219        let state = session
12220            .system_context_state()
12221            .expect("append should persist typed context state");
12222        assert_eq!(state.applied, vec![append]);
12223    }
12224
12225    fn roster_append() -> PendingSystemContextAppend {
12226        PendingSystemContextAppend {
12227            content: crate::lifecycle::run_primitive::CoreRenderable::text(
12228                "peer roster: lead-1, w-1".to_string(),
12229            ),
12230            source: Some("comms:roster".to_string()),
12231            idempotency_key: Some("comms:roster:v1".to_string()),
12232            source_kind: SystemContextSource::Normal,
12233            peer_response_terminal: None,
12234            accepted_at: SystemTime::UNIX_EPOCH,
12235        }
12236    }
12237
12238    fn resumed_session_with_context_appended_prompt(base: &str) -> Session {
12239        let mut session = Session::new();
12240        session.set_system_prompt(base.to_string());
12241        session.push(Message::User(UserMessage::text("hello".to_string())));
12242        session.append_system_context_blocks(std::slice::from_ref(&roster_append()));
12243        session
12244    }
12245
12246    #[test]
12247    fn reconcile_resumed_system_prompt_preserves_identical_base() {
12248        let mut session = Session::new();
12249        session.set_system_prompt("base prompt".to_string());
12250        session.push(Message::User(UserMessage::text("hello".to_string())));
12251        let digest_before = transcript_messages_digest(session.messages()).unwrap();
12252
12253        let outcome = session
12254            .reconcile_resumed_system_prompt("base prompt".to_string(), None)
12255            .expect("reconcile");
12256
12257        assert_eq!(
12258            outcome,
12259            ResumedSystemPromptReconciliation::PreservedContinuation
12260        );
12261        assert_eq!(
12262            transcript_messages_digest(session.messages()).unwrap(),
12263            digest_before,
12264            "identical base must leave the transcript revision unchanged"
12265        );
12266    }
12267
12268    #[test]
12269    fn reconcile_resumed_system_prompt_preserves_context_appended_base() {
12270        let mut session = resumed_session_with_context_appended_prompt("base prompt");
12271        let digest_before = transcript_messages_digest(session.messages()).unwrap();
12272
12273        let outcome = session
12274            .reconcile_resumed_system_prompt("base prompt".to_string(), None)
12275            .expect("reconcile");
12276
12277        assert_eq!(
12278            outcome,
12279            ResumedSystemPromptReconciliation::PreservedContinuation
12280        );
12281        assert_eq!(
12282            transcript_messages_digest(session.messages()).unwrap(),
12283            digest_before,
12284            "a base extended only by runtime context appends must stay untouched"
12285        );
12286        let system = match session.messages().first() {
12287            Some(Message::System(system)) => system.clone(),
12288            other => panic!("expected system message, got {other:?}"),
12289        };
12290        assert!(system.content.contains("peer roster: lead-1, w-1"));
12291        assert!(
12292            system.mutation_kind.is_runtime_context_append(),
12293            "the persisted mutation provenance must survive reconciliation"
12294        );
12295    }
12296
12297    #[test]
12298    fn reconcile_resumed_system_prompt_rewrites_changed_base_preserving_tail() {
12299        let mut session = resumed_session_with_context_appended_prompt("base prompt");
12300
12301        let outcome = session
12302            .reconcile_resumed_system_prompt("new base prompt".to_string(), None)
12303            .expect("reconcile");
12304
12305        assert_eq!(outcome, ResumedSystemPromptReconciliation::RewrittenBase);
12306        let system_content = match session.messages().first() {
12307            Some(Message::System(system)) => system.content.clone(),
12308            other => panic!("expected system message, got {other:?}"),
12309        };
12310        assert!(
12311            system_content.starts_with("new base prompt"),
12312            "the changed base must be applied: {system_content}"
12313        );
12314        assert!(
12315            system_content.contains("peer roster: lead-1, w-1"),
12316            "the runtime-applied context tail must survive the base change: {system_content}"
12317        );
12318        let state = session
12319            .transcript_history_state()
12320            .expect("history state deserializes")
12321            .expect("rewrite must record transcript history");
12322        assert_eq!(state.commits.len(), 1);
12323        assert_eq!(
12324            state.commits[0].reason.kind,
12325            RESUME_SYSTEM_PROMPT_REFRESH_REWRITE_REASON
12326        );
12327        assert_eq!(
12328            state.head,
12329            transcript_messages_digest(session.messages()).unwrap(),
12330            "the committed head must match the rewritten transcript"
12331        );
12332    }
12333
12334    #[test]
12335    fn reconcile_resumed_system_prompt_inserts_prompt_on_promptless_transcript() {
12336        let mut session = Session::new();
12337        session.push(Message::User(UserMessage::text("hello".to_string())));
12338
12339        let outcome = session
12340            .reconcile_resumed_system_prompt("late prompt".to_string(), None)
12341            .expect("reconcile");
12342
12343        assert_eq!(outcome, ResumedSystemPromptReconciliation::RewrittenBase);
12344        assert!(matches!(
12345            session.messages().first(),
12346            Some(Message::System(system)) if system.content == "late prompt"
12347        ));
12348        let state = session
12349            .transcript_history_state()
12350            .expect("history state deserializes")
12351            .expect("insert must record transcript history");
12352        assert_eq!(state.commits.len(), 1);
12353        assert_eq!(
12354            state.commits[0].reason.kind,
12355            RESUME_SYSTEM_PROMPT_REFRESH_REWRITE_REASON
12356        );
12357    }
12358
12359    fn leading_system_content(session: &Session) -> String {
12360        match session.messages().first() {
12361            Some(Message::System(system)) => system.content.clone(),
12362            other => panic!("expected leading system message, got {other:?}"),
12363        }
12364    }
12365
12366    #[test]
12367    fn reconcile_resumed_system_prompt_preserves_full_context_prompt_from_empty_base() {
12368        // Promptless/empty-base build: appends compose as the WHOLE System
12369        // content with no separator prefix. A resume with a non-empty
12370        // explicit base must carry the verified all-context tail onto the
12371        // new base instead of discarding it as an "empty tail".
12372        let mut session = Session::new();
12373        session.push(Message::User(UserMessage::text("hello".to_string())));
12374        session.append_system_context_blocks(std::slice::from_ref(&roster_append()));
12375        let all_context_content = leading_system_content(&session);
12376        assert!(all_context_content.contains("peer roster: lead-1, w-1"));
12377
12378        let outcome = session
12379            .reconcile_resumed_system_prompt("new base prompt".to_string(), None)
12380            .expect("reconcile");
12381
12382        assert_eq!(outcome, ResumedSystemPromptReconciliation::RewrittenBase);
12383        assert_eq!(
12384            leading_system_content(&session),
12385            format!("new base prompt{SYSTEM_CONTEXT_SEPARATOR}{all_context_content}"),
12386            "the all-context prompt must survive as the runtime tail of the new base"
12387        );
12388    }
12389
12390    #[test]
12391    fn reconcile_resumed_system_prompt_preserves_context_only_prompt_on_empty_base_resume() {
12392        // Empty-base → empty-base resume: the all-context prompt IS the
12393        // expected composition; it must be preserved untouched.
12394        let mut session = Session::new();
12395        session.push(Message::User(UserMessage::text("hello".to_string())));
12396        session.append_system_context_blocks(std::slice::from_ref(&roster_append()));
12397        let digest_before = transcript_messages_digest(session.messages()).unwrap();
12398
12399        let outcome = session
12400            .reconcile_resumed_system_prompt(String::new(), None)
12401            .expect("reconcile");
12402
12403        assert_eq!(
12404            outcome,
12405            ResumedSystemPromptReconciliation::PreservedContinuation
12406        );
12407        assert_eq!(
12408            transcript_messages_digest(session.messages()).unwrap(),
12409            digest_before
12410        );
12411    }
12412
12413    #[test]
12414    fn reconcile_resumed_system_prompt_applies_shortened_base_with_recorded_prior() {
12415        // The separator is ordinary markdown: a base prompt may legitimately
12416        // contain it. Shortening the base must be APPLIED (audited rewrite),
12417        // not silently classified as a preserved context-append continuation.
12418        let full_base = format!("part one{SYSTEM_CONTEXT_SEPARATOR}part two");
12419        let mut session = Session::new();
12420        session.set_system_prompt(full_base.clone());
12421        session.push(Message::User(UserMessage::text("hello".to_string())));
12422        session
12423            .set_build_state(SessionBuildState {
12424                assembled_system_prompt: Some(full_base),
12425                ..Default::default()
12426            })
12427            .expect("build state");
12428
12429        let outcome = session
12430            .reconcile_resumed_system_prompt("part one".to_string(), None)
12431            .expect("reconcile");
12432
12433        assert_eq!(outcome, ResumedSystemPromptReconciliation::RewrittenBase);
12434        assert_eq!(leading_system_content(&session), "part one");
12435    }
12436
12437    #[test]
12438    fn reconcile_resumed_system_prompt_applies_shortened_base_without_context_provenance() {
12439        // No recorded prior base, no applied records, and the persisted
12440        // prompt's mutation provenance is not a runtime context append: the
12441        // machine rejects the structural-extends continuation, so the
12442        // shortened base is applied instead of silently ignored.
12443        let full_base = format!("part one{SYSTEM_CONTEXT_SEPARATOR}part two");
12444        let mut session = Session::new();
12445        session.set_system_prompt(full_base);
12446        session.push(Message::User(UserMessage::text("hello".to_string())));
12447
12448        let outcome = session
12449            .reconcile_resumed_system_prompt("part one".to_string(), None)
12450            .expect("reconcile");
12451
12452        assert_eq!(outcome, ResumedSystemPromptReconciliation::RewrittenBase);
12453        assert_eq!(leading_system_content(&session), "part one");
12454    }
12455
12456    #[test]
12457    fn reconcile_resumed_system_prompt_preserves_appended_prompt_without_applied_records() {
12458        // The runtime persistence path sweeps applied records and pre-0.7.15
12459        // rows have no recorded assembled base. The typed
12460        // RuntimeContextAppend provenance on the persisted message still
12461        // admits the continuation through the machine fast path.
12462        let mut session = resumed_session_with_context_appended_prompt("base prompt");
12463        session
12464            .set_system_context_state(SessionSystemContextState::default())
12465            .expect("sweep applied records");
12466        let digest_before = transcript_messages_digest(session.messages()).unwrap();
12467
12468        let outcome = session
12469            .reconcile_resumed_system_prompt("base prompt".to_string(), None)
12470            .expect("reconcile");
12471
12472        assert_eq!(
12473            outcome,
12474            ResumedSystemPromptReconciliation::PreservedContinuation
12475        );
12476        assert_eq!(
12477            transcript_messages_digest(session.messages()).unwrap(),
12478            digest_before
12479        );
12480    }
12481
12482    #[test]
12483    fn reconcile_resumed_system_prompt_clears_orphaned_applied_records_on_tail_drop() {
12484        let mut session = resumed_session_with_context_appended_prompt("base prompt");
12485        // An out-of-band prompt mutation makes the applied records'
12486        // re-render no longer reproduce the persisted content (and no
12487        // assembled base was recorded): the tail is unverifiable and must be
12488        // dropped by the rewrite.
12489        session.set_system_prompt(format!(
12490            "mutated base{SYSTEM_CONTEXT_SEPARATOR}stale-looking tail"
12491        ));
12492
12493        let outcome = session
12494            .reconcile_resumed_system_prompt("new base prompt".to_string(), None)
12495            .expect("reconcile");
12496
12497        assert_eq!(outcome, ResumedSystemPromptReconciliation::RewrittenBase);
12498        assert_eq!(leading_system_content(&session), "new base prompt");
12499        let state = session.system_context_state().unwrap_or_default();
12500        assert!(
12501            state.applied.is_empty(),
12502            "orphaned applied records must be cleared so the context stays restorable"
12503        );
12504        assert!(
12505            state.seen.is_empty(),
12506            "orphaned idempotency keys must be cleared so keyed re-sends re-apply"
12507        );
12508
12509        // A host re-send of the same keyed append restores the context
12510        // instead of deduplicating against the dropped application.
12511        session.append_system_context_blocks(std::slice::from_ref(&roster_append()));
12512        assert!(
12513            leading_system_content(&session).contains("peer roster: lead-1, w-1"),
12514            "re-sent keyed context must re-apply after the drop"
12515        );
12516    }
12517
12518    #[test]
12519    fn append_system_context_blocks_renders_pre_marked_pending_context() {
12520        let accepted_at = SystemTime::UNIX_EPOCH;
12521        let mut state = SessionSystemContextState::default();
12522        state
12523            .stage_append(
12524                &AppendSystemContextRequest {
12525                    content: crate::lifecycle::run_primitive::CoreRenderable::text(
12526                        "Apply this staged context at the request boundary.".to_string(),
12527                    ),
12528                    source: Some("rpc/session_inject_context".to_string()),
12529                    idempotency_key: Some("ctx-boundary".to_string()),
12530                    source_kind: SystemContextSource::Normal,
12531                    peer_response_terminal: None,
12532                },
12533                accepted_at,
12534            )
12535            .expect("append should stage");
12536        let pending = state.pending.clone();
12537        state.mark_pending_applied();
12538        let mut session = Session::new();
12539        session
12540            .set_system_context_state(state)
12541            .expect("state should serialize");
12542
12543        session.append_system_context_blocks(&pending);
12544
12545        let system_prompt = session
12546            .messages()
12547            .first()
12548            .and_then(|message| match message {
12549                Message::System(system) => Some(system.content.as_str()),
12550                _ => None,
12551            })
12552            .unwrap_or_default();
12553        assert!(system_prompt.contains("Apply this staged context at the request boundary."));
12554        let state = session
12555            .system_context_state()
12556            .expect("append should persist typed context state");
12557        assert_eq!(state.applied.len(), 1);
12558        assert_eq!(
12559            state.seen["ctx-boundary"].state,
12560            SeenSystemContextState::Applied
12561        );
12562    }
12563
12564    #[test]
12565    fn append_system_context_blocks_renders_pre_marked_context_without_idempotency_key() {
12566        let accepted_at = SystemTime::UNIX_EPOCH;
12567        let mut state = SessionSystemContextState::default();
12568        state
12569            .stage_append(
12570                &AppendSystemContextRequest {
12571                    content: crate::lifecycle::run_primitive::CoreRenderable::text(
12572                        "Apply this unkeyed staged context at the request boundary.".to_string(),
12573                    ),
12574                    source: Some("rpc/session_inject_context".to_string()),
12575                    idempotency_key: None,
12576                    source_kind: SystemContextSource::Normal,
12577                    peer_response_terminal: None,
12578                },
12579                accepted_at,
12580            )
12581            .expect("append should stage");
12582        let pending = state.pending.clone();
12583        state.mark_pending_applied();
12584        let mut session = Session::new();
12585        session
12586            .set_system_context_state(state)
12587            .expect("state should serialize");
12588
12589        session.append_system_context_blocks(&pending);
12590
12591        let system_prompt = session
12592            .messages()
12593            .first()
12594            .and_then(|message| match message {
12595                Message::System(system) => Some(system.content.as_str()),
12596                _ => None,
12597            })
12598            .unwrap_or_default();
12599        assert!(
12600            system_prompt.contains("Apply this unkeyed staged context at the request boundary.")
12601        );
12602    }
12603
12604    /// K5 invariant: the typed `CoreRenderable` travels end-to-end through
12605    /// staging — the pending append stores the renderable itself, and the
12606    /// ONE lowering to prompt text happens at the transcript render seam.
12607    #[test]
12608    fn staged_system_context_carries_typed_renderable_to_render_seam() {
12609        use crate::lifecycle::run_primitive::CoreRenderable;
12610
12611        let accepted_at = SystemTime::UNIX_EPOCH;
12612        let mut state = SessionSystemContextState::default();
12613        let renderable = CoreRenderable::Json {
12614            value: serde_json::json!({"alert": "disk-full", "severity": 2}),
12615        };
12616        state
12617            .stage_append(
12618                &AppendSystemContextRequest {
12619                    content: renderable.clone(),
12620                    source: Some("ops/monitor".to_string()),
12621                    idempotency_key: Some("alert-1".to_string()),
12622                    source_kind: SystemContextSource::Normal,
12623                    peer_response_terminal: None,
12624                },
12625                accepted_at,
12626            )
12627            .expect("typed renderable append should stage");
12628
12629        // The pending append owns the typed renderable — no pre-flattened
12630        // text shadow exists anywhere on the staging path.
12631        assert_eq!(state.pending.len(), 1);
12632        assert_eq!(state.pending[0].content, renderable);
12633
12634        // Lowering happens exactly once, at the render seam, via the single
12635        // canonical projection.
12636        let rendered = render_system_context_block(&state.pending[0]);
12637        assert!(rendered.starts_with(SYSTEM_CONTEXT_RENDER_LABEL));
12638        assert!(
12639            rendered.contains(renderable.render_text().trim()),
12640            "render seam must lower via CoreRenderable::render_text: {rendered}"
12641        );
12642    }
12643
12644    #[test]
12645    fn append_system_context_blocks_skips_duplicate_idempotency_key() {
12646        let first = PendingSystemContextAppend {
12647            content: crate::lifecycle::run_primitive::CoreRenderable::text(
12648                "Authoritative peer token is birch seventeen.".to_string(),
12649            ),
12650            source: Some("peer_response_terminal:analyst:req-1".to_string()),
12651            idempotency_key: Some("req-1".to_string()),
12652            source_kind: SystemContextSource::Normal,
12653            peer_response_terminal: None,
12654            accepted_at: SystemTime::UNIX_EPOCH,
12655        };
12656        let duplicate = PendingSystemContextAppend {
12657            accepted_at: SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1),
12658            ..first.clone()
12659        };
12660        let mut session = Session::new();
12661
12662        session.append_system_context_blocks(std::slice::from_ref(&first));
12663        session.append_system_context_blocks(std::slice::from_ref(&duplicate));
12664
12665        let state = session
12666            .system_context_state()
12667            .expect("append should persist typed context state");
12668        assert_eq!(state.applied, vec![first]);
12669        let system_prompt = session
12670            .messages()
12671            .first()
12672            .and_then(|message| match message {
12673                Message::System(system) => Some(system.content.as_str()),
12674                _ => None,
12675            })
12676            .unwrap_or_default();
12677        assert_eq!(
12678            system_prompt
12679                .matches("Authoritative peer token is birch seventeen.")
12680                .count(),
12681            1
12682        );
12683    }
12684
12685    #[test]
12686    fn append_system_context_blocks_skips_conflicting_duplicate_idempotency_key() {
12687        let first = PendingSystemContextAppend {
12688            content: crate::lifecycle::run_primitive::CoreRenderable::text(
12689                "Authoritative peer token is birch seventeen.".to_string(),
12690            ),
12691            source: Some("peer_response_terminal:analyst:req-1".to_string()),
12692            idempotency_key: Some("req-1".to_string()),
12693            source_kind: SystemContextSource::Normal,
12694            peer_response_terminal: None,
12695            accepted_at: SystemTime::UNIX_EPOCH,
12696        };
12697        let conflicting = PendingSystemContextAppend {
12698            content: crate::lifecycle::run_primitive::CoreRenderable::text(
12699                "Conflicting peer token should not reach the prompt.".to_string(),
12700            ),
12701            accepted_at: SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1),
12702            ..first.clone()
12703        };
12704        let mut session = Session::new();
12705
12706        session.append_system_context_blocks(std::slice::from_ref(&first));
12707        session.append_system_context_blocks(std::slice::from_ref(&conflicting));
12708
12709        let state = session
12710            .system_context_state()
12711            .expect("append should persist typed context state");
12712        assert_eq!(state.applied, vec![first]);
12713        let system_prompt = session
12714            .messages()
12715            .first()
12716            .and_then(|message| match message {
12717                Message::System(system) => Some(system.content.as_str()),
12718                _ => None,
12719            })
12720            .unwrap_or_default();
12721        assert!(system_prompt.contains("Authoritative peer token is birch seventeen."));
12722        assert!(!system_prompt.contains("Conflicting peer token should not reach the prompt."));
12723    }
12724
12725    // ------------------------------------------------------------------
12726    // T9/T10: realtime transcript lane materialization.
12727    //
12728    // The display-text lane (`AssistantTextDelta`) materializes as
12729    // `AssistantBlock::Text`; the spoken-transcript lane
12730    // (`AssistantTranscriptDelta`) materializes as
12731    // `AssistantBlock::Transcript { source: TranscriptSource::Spoken }`.
12732    // These regressions pin both flushes and prove the materializer
12733    // dispatches on the per-item `TranscriptLane`.
12734    // ------------------------------------------------------------------
12735
12736    #[test]
12737    fn realtime_transcript_assistant_transcript_delta_materializes_transcript_block() {
12738        let mut session = Session::new();
12739
12740        let delta = RealtimeTranscriptEvent::AssistantTranscriptDelta {
12741            response_id: "resp_spoken".to_string(),
12742            delta_id: "evt_delta_spoken_1".to_string(),
12743            item_id: "item_spoken".to_string(),
12744            previous_item_id: None,
12745            content_index: 0,
12746            delta: "I said hi".to_string(),
12747        };
12748        assert!(
12749            session.append_realtime_transcript_event(delta).is_inert(),
12750            "delta alone is inert until turn-completed flushes"
12751        );
12752
12753        let terminal = RealtimeTranscriptEvent::AssistantTurnCompleted {
12754            response_id: "resp_spoken".to_string(),
12755            stop_reason: StopReason::EndTurn,
12756            usage: Usage::default(),
12757        };
12758        let outcome = session.append_realtime_transcript_event(terminal);
12759        assert_eq!(outcome.materialized_messages.len(), 1);
12760
12761        // T9/T10: must be a Transcript block, NOT Text.
12762        let messages = session.messages();
12763        assert_eq!(messages.len(), 1);
12764        match &messages[0] {
12765            Message::BlockAssistant(assistant) => {
12766                assert_eq!(assistant.blocks.len(), 1);
12767                match &assistant.blocks[0] {
12768                    AssistantBlock::Transcript { text, source, .. } => {
12769                        assert_eq!(text, "I said hi");
12770                        assert_eq!(*source, crate::types::TranscriptSource::Spoken);
12771                    }
12772                    other => unreachable!(
12773                        "AssistantTranscriptDelta must materialize as AssistantBlock::Transcript, got {other:?}"
12774                    ),
12775                }
12776            }
12777            other => unreachable!("expected BlockAssistant message, got {other:?}"),
12778        }
12779    }
12780
12781    #[test]
12782    fn round4_cc4_in_flight_response_ids_lists_distinct_unmaterialized_responses() {
12783        // CC4 (Round-4 architectural reconciliation): the helper that
12784        // powers `signal_turn_interrupt`'s cross-layer fan-out must
12785        // return every distinct provider response_id that has at least
12786        // one unmaterialized assistant item, EXCLUDING already-discarded
12787        // responses and EXCLUDING the user role.
12788        let mut session = Session::new();
12789
12790        // Two transcript-delta items on resp_a (different content_index
12791        // ranges), one on resp_b. resp_c gets a delta and is then
12792        // discarded explicitly via AssistantTurnInterrupted.
12793        for (i, response_id) in [
12794            ("resp_a", "resp_a"),
12795            ("resp_a_extra", "resp_a"),
12796            ("resp_b", "resp_b"),
12797            ("resp_c", "resp_c"),
12798        ]
12799        .iter()
12800        .enumerate()
12801        {
12802            let event = RealtimeTranscriptEvent::AssistantTranscriptDelta {
12803                response_id: response_id.1.to_string(),
12804                delta_id: format!("delta_{i}"),
12805                item_id: response_id.0.to_string(),
12806                previous_item_id: None,
12807                content_index: 0,
12808                delta: "x".to_string(),
12809            };
12810            let _ = session.append_realtime_transcript_event(event);
12811        }
12812
12813        // Discard resp_c — it should not appear in the in-flight list.
12814        let _ = session.append_realtime_transcript_event(
12815            RealtimeTranscriptEvent::AssistantTurnInterrupted {
12816                response_id: "resp_c".to_string(),
12817            },
12818        );
12819
12820        // User-role item should never appear (CC4 only fans interrupts
12821        // to assistant responses).
12822        let _ = session.append_realtime_transcript_event(
12823            RealtimeTranscriptEvent::UserTranscriptFinal {
12824                item_id: "u_item".to_string(),
12825                previous_item_id: None,
12826                content_index: 0,
12827                text: "hi".to_string(),
12828            },
12829        );
12830
12831        let in_flight = session.in_flight_realtime_assistant_response_ids();
12832        assert!(in_flight.contains(&"resp_a".to_string()), "{in_flight:?}");
12833        assert!(in_flight.contains(&"resp_b".to_string()), "{in_flight:?}");
12834        assert!(
12835            !in_flight.contains(&"resp_c".to_string()),
12836            "discarded response must not appear in in_flight: {in_flight:?}"
12837        );
12838        // resp_a appears exactly once even though two items reference it.
12839        assert_eq!(
12840            in_flight.iter().filter(|r| *r == "resp_a").count(),
12841            1,
12842            "distinct response_ids only: {in_flight:?}"
12843        );
12844    }
12845
12846    #[test]
12847    fn round4_cc2_assistant_turn_completed_after_transcript_deltas_materializes_transcript() {
12848        // CC2 (Round-4 architectural reconciliation): once
12849        // `signal_turn_completed` synthesizes
12850        // `RealtimeTranscriptEvent::AssistantTurnCompleted`, the staging
12851        // materializer commits every staged transcript-delta item for
12852        // that response_id as `AssistantBlock::Transcript { Spoken }`.
12853        // This pins the production end-to-end shape the sink relies on.
12854        let mut session = Session::new();
12855
12856        let delta = RealtimeTranscriptEvent::AssistantTranscriptDelta {
12857            response_id: "resp_cc2".to_string(),
12858            delta_id: "delta_cc2_1".to_string(),
12859            item_id: "item_cc2".to_string(),
12860            previous_item_id: None,
12861            content_index: 0,
12862            delta: "hello world".to_string(),
12863        };
12864        assert!(session.append_realtime_transcript_event(delta).is_inert());
12865
12866        // Pre-completion: in-flight list reports resp_cc2.
12867        assert_eq!(
12868            session.in_flight_realtime_assistant_response_ids(),
12869            vec!["resp_cc2".to_string()]
12870        );
12871
12872        let outcome = session.append_realtime_transcript_event(
12873            RealtimeTranscriptEvent::AssistantTurnCompleted {
12874                response_id: "resp_cc2".to_string(),
12875                stop_reason: StopReason::EndTurn,
12876                usage: Usage::default(),
12877            },
12878        );
12879        assert_eq!(outcome.materialized_messages.len(), 1);
12880
12881        // Post-completion: in-flight list is empty (item is materialized).
12882        assert!(
12883            session
12884                .in_flight_realtime_assistant_response_ids()
12885                .is_empty(),
12886            "materialized items must not appear in in_flight_realtime_assistant_response_ids"
12887        );
12888
12889        let messages = session.messages();
12890        let assistant = messages.iter().find_map(|m| match m {
12891            Message::BlockAssistant(a) => Some(a),
12892            _ => None,
12893        });
12894        let assistant = assistant.expect("assistant block message expected");
12895        assert_eq!(assistant.blocks.len(), 1);
12896        assert!(matches!(
12897            &assistant.blocks[0],
12898            AssistantBlock::Transcript {
12899                source: crate::types::TranscriptSource::Spoken,
12900                ..
12901            }
12902        ));
12903    }
12904
12905    #[test]
12906    fn realtime_transcript_assistant_text_delta_still_materializes_text_block() {
12907        // Counter-regression: the display-text lane must continue to
12908        // produce `AssistantBlock::Text` after T9/T10. Prevents an
12909        // accidental cross-lane flip.
12910        let mut session = Session::new();
12911
12912        let delta = RealtimeTranscriptEvent::AssistantTextDelta {
12913            response_id: "resp_display".to_string(),
12914            delta_id: "evt_delta_display_1".to_string(),
12915            item_id: "item_display".to_string(),
12916            previous_item_id: None,
12917            content_index: 0,
12918            delta: "I wrote".to_string(),
12919        };
12920        let _ = session.append_realtime_transcript_event(delta);
12921
12922        let terminal = RealtimeTranscriptEvent::AssistantTurnCompleted {
12923            response_id: "resp_display".to_string(),
12924            stop_reason: StopReason::EndTurn,
12925            usage: Usage::default(),
12926        };
12927        let outcome = session.append_realtime_transcript_event(terminal);
12928        assert_eq!(outcome.materialized_messages.len(), 1);
12929
12930        let messages = session.messages();
12931        match &messages[0] {
12932            Message::BlockAssistant(assistant) => match &assistant.blocks[0] {
12933                AssistantBlock::Text { text, .. } => assert_eq!(text, "I wrote"),
12934                other => unreachable!(
12935                    "AssistantTextDelta must keep materializing AssistantBlock::Text, got {other:?}"
12936                ),
12937            },
12938            other => unreachable!("expected BlockAssistant message, got {other:?}"),
12939        }
12940    }
12941
12942    #[test]
12943    fn round4_cc7_mixed_response_persists_text_and_transcript_in_order() {
12944        // CC7 (Round-4 adversarial-verifier follow-up): a single mixed-modality
12945        // realtime response that emits BOTH display-text deltas
12946        // (`AssistantTextDelta`) AND spoken-transcript deltas
12947        // (`AssistantTranscriptDelta`) under the same response_id must
12948        // materialize as ONE `Message::BlockAssistant` whose `blocks` field
12949        // contains exactly two ordered entries:
12950        //   1. AssistantBlock::Text       (display-text lane)
12951        //   2. AssistantBlock::Transcript { source: Spoken } (spoken lane)
12952        // Pre-fix the materializer emitted one Message::BlockAssistant per
12953        // staged item, splitting the mixed response into two messages.
12954        //
12955        // This test drives the production materializer end-to-end: deltas
12956        // stage in `SessionRealtimeTranscriptState`; `AssistantTurnCompleted`
12957        // triggers the materializer; canonical history is the assertion
12958        // surface — exactly the same code path that
12959        // `SessionServiceProjectionSink::signal_turn_completed` invokes via
12960        // `runtime.append_realtime_transcript_event` in production.
12961        let mut session = Session::new();
12962
12963        // Provider-arrival order: display first, then spoken.
12964        let display_a = RealtimeTranscriptEvent::AssistantTextDelta {
12965            response_id: "resp_mixed_1".to_string(),
12966            delta_id: "delta_disp_1".to_string(),
12967            item_id: "item_display".to_string(),
12968            previous_item_id: None,
12969            content_index: 0,
12970            delta: "Here's the report:".to_string(),
12971        };
12972        assert!(
12973            session
12974                .append_realtime_transcript_event(display_a)
12975                .is_inert()
12976        );
12977
12978        let display_b = RealtimeTranscriptEvent::AssistantTextDelta {
12979            response_id: "resp_mixed_1".to_string(),
12980            delta_id: "delta_disp_2".to_string(),
12981            item_id: "item_display".to_string(),
12982            previous_item_id: None,
12983            content_index: 0,
12984            delta: " (still writing)".to_string(),
12985        };
12986        assert!(
12987            session
12988                .append_realtime_transcript_event(display_b)
12989                .is_inert()
12990        );
12991
12992        // Spoken items chain after the display item to mirror provider
12993        // arrival semantics — `previous_item_id` carries arrival ordering
12994        // that the materializer must preserve as block ordering inside the
12995        // single emitted message.
12996        let spoken_a = RealtimeTranscriptEvent::AssistantTranscriptDelta {
12997            response_id: "resp_mixed_1".to_string(),
12998            delta_id: "delta_spoken_1".to_string(),
12999            item_id: "item_spoken".to_string(),
13000            previous_item_id: Some("item_display".to_string()),
13001            content_index: 0,
13002            delta: "I'm reading the report aloud:".to_string(),
13003        };
13004        assert!(
13005            session
13006                .append_realtime_transcript_event(spoken_a)
13007                .is_inert()
13008        );
13009
13010        let spoken_b = RealtimeTranscriptEvent::AssistantTranscriptDelta {
13011            response_id: "resp_mixed_1".to_string(),
13012            delta_id: "delta_spoken_2".to_string(),
13013            item_id: "item_spoken".to_string(),
13014            previous_item_id: Some("item_display".to_string()),
13015            content_index: 0,
13016            delta: " sentence two.".to_string(),
13017        };
13018        assert!(
13019            session
13020                .append_realtime_transcript_event(spoken_b)
13021                .is_inert()
13022        );
13023
13024        // TurnCompleted triggers the materializer to flush all staged items
13025        // for this response_id into ONE BlockAssistant message.
13026        let outcome = session.append_realtime_transcript_event(
13027            RealtimeTranscriptEvent::AssistantTurnCompleted {
13028                response_id: "resp_mixed_1".to_string(),
13029                stop_reason: StopReason::EndTurn,
13030                usage: Usage {
13031                    input_tokens: 11,
13032                    output_tokens: 22,
13033                    cache_creation_tokens: None,
13034                    cache_read_tokens: None,
13035                },
13036            },
13037        );
13038        // Materializer reports two staged items got materialized.
13039        assert_eq!(outcome.materialized_messages.len(), 2);
13040
13041        // Canonical history MUST contain exactly ONE BlockAssistant message
13042        // (the CC7 fix: mixed lanes interleave into one message, not two).
13043        let messages = session.messages();
13044        let assistants: Vec<&BlockAssistantMessage> = messages
13045            .iter()
13046            .filter_map(|m| match m {
13047                Message::BlockAssistant(a) => Some(a),
13048                _ => None,
13049            })
13050            .collect();
13051        assert_eq!(
13052            assistants.len(),
13053            1,
13054            "mixed display+spoken response under one response_id must produce exactly ONE BlockAssistant message, got: {assistants:?}"
13055        );
13056        let assistant = assistants[0];
13057        assert_eq!(
13058            assistant.blocks.len(),
13059            2,
13060            "mixed response message must carry both blocks: {:?}",
13061            assistant.blocks
13062        );
13063
13064        // Block 0: display-text (concatenated deltas).
13065        match &assistant.blocks[0] {
13066            AssistantBlock::Text { text, .. } => {
13067                assert_eq!(text, "Here's the report: (still writing)");
13068            }
13069            other => unreachable!(
13070                "first block must be AssistantBlock::Text (display lane), got {other:?}"
13071            ),
13072        }
13073        // Block 1: spoken transcript (concatenated deltas), tagged Spoken.
13074        match &assistant.blocks[1] {
13075            AssistantBlock::Transcript { text, source, .. } => {
13076                assert_eq!(text, "I'm reading the report aloud: sentence two.");
13077                assert_eq!(*source, crate::types::TranscriptSource::Spoken);
13078            }
13079            other => unreachable!(
13080                "second block must be AssistantBlock::Transcript {{ source: Spoken }}, got {other:?}"
13081            ),
13082        }
13083
13084        // Usage was recorded once for the turn.
13085        assert_eq!(session.usage.input_tokens, 11);
13086        assert_eq!(session.usage.output_tokens, 22);
13087    }
13088
13089    #[test]
13090    fn round5_r55_mixed_response_barge_in_preserves_display_drops_spoken() {
13091        // R5-5 (Round-5 contract update): barge-in MUST filter staged items
13092        // by lane — `Spoken` is invalidated (the user spoke over the audio
13093        // they were hearing) but `Display` survives as committed history
13094        // (sideband display text from the same response is not "spoken
13095        // over"). Round-4's `round4_cc7_mixed_response_barge_in_discards_*`
13096        // pinned the wrong invariant; this test replaces it.
13097        //
13098        // Architectural decision: `AssistantTurnInterrupted` is terminal for
13099        // the response on the realtime-staging path — any later
13100        // `AssistantTurnCompleted { stop_reason: Cancelled }` short-circuits
13101        // via the `discarded_assistant_response_ids` guard. So the
13102        // Interrupted handler must seed a synthetic
13103        // `assistant_completions` entry (`StopReason::Cancelled`,
13104        // `Usage::default()`) so retained Display items materialize
13105        // immediately rather than stranding forever.
13106        let mut session = Session::new();
13107
13108        let display = RealtimeTranscriptEvent::AssistantTextDelta {
13109            response_id: "resp_mixed_2".to_string(),
13110            delta_id: "delta_disp_1".to_string(),
13111            item_id: "item_display_2".to_string(),
13112            previous_item_id: None,
13113            content_index: 0,
13114            delta: "Working on the report...".to_string(),
13115        };
13116        let _ = session.append_realtime_transcript_event(display);
13117
13118        let spoken = RealtimeTranscriptEvent::AssistantTranscriptDelta {
13119            response_id: "resp_mixed_2".to_string(),
13120            delta_id: "delta_spoken_1".to_string(),
13121            item_id: "item_spoken_2".to_string(),
13122            previous_item_id: Some("item_display_2".to_string()),
13123            content_index: 0,
13124            delta: "I'm reading the report".to_string(),
13125        };
13126        let _ = session.append_realtime_transcript_event(spoken);
13127
13128        // Barge-in arrives BEFORE TurnCompleted. The Display item with
13129        // staged content materializes immediately under the synthetic
13130        // Cancelled completion.
13131        let outcome = session.append_realtime_transcript_event(
13132            RealtimeTranscriptEvent::AssistantTurnInterrupted {
13133                response_id: "resp_mixed_2".to_string(),
13134            },
13135        );
13136        assert_eq!(
13137            outcome.materialized_messages.len(),
13138            1,
13139            "Display lane item must materialize on Interrupted: {outcome:?}"
13140        );
13141
13142        // A late `AssistantTurnCompleted` (the provider's response.done
13143        // emitted after cancel) must be a no-op: the Display item is
13144        // already materialized; the Spoken item was dropped at Interrupted.
13145        let late_completion = session.append_realtime_transcript_event(
13146            RealtimeTranscriptEvent::AssistantTurnCompleted {
13147                response_id: "resp_mixed_2".to_string(),
13148                stop_reason: StopReason::Cancelled,
13149                usage: Usage::default(),
13150            },
13151        );
13152        assert_eq!(
13153            late_completion.materialized_messages.len(),
13154            0,
13155            "post-barge-in TurnCompleted must not resurrect anything"
13156        );
13157
13158        // Canonical history: exactly one BlockAssistant carrying the
13159        // Display text (no Transcript block — Spoken was dropped).
13160        let messages = session.messages();
13161        let assistants: Vec<&BlockAssistantMessage> = messages
13162            .iter()
13163            .filter_map(|m| match m {
13164                Message::BlockAssistant(a) => Some(a),
13165                _ => None,
13166            })
13167            .collect();
13168        assert_eq!(
13169            assistants.len(),
13170            1,
13171            "barge-in must commit exactly one BlockAssistant containing the Display lane: {assistants:?}"
13172        );
13173        let assistant = assistants[0];
13174        assert_eq!(assistant.blocks.len(), 1, "blocks: {:?}", assistant.blocks);
13175        match &assistant.blocks[0] {
13176            AssistantBlock::Text { text, .. } => {
13177                assert_eq!(text, "Working on the report...");
13178            }
13179            other => {
13180                unreachable!("Display lane must materialize as AssistantBlock::Text, got {other:?}")
13181            }
13182        }
13183        // No Transcript block — Spoken lane was dropped.
13184        assert!(
13185            !assistant
13186                .blocks
13187                .iter()
13188                .any(|b| matches!(b, AssistantBlock::Transcript { .. })),
13189            "Spoken lane must be dropped on barge-in"
13190        );
13191
13192        // The in-flight tracker reports the response as no longer in flight
13193        // (the Display item is materialized; the Spoken item is skipped).
13194        assert!(
13195            !session
13196                .in_flight_realtime_assistant_response_ids()
13197                .contains(&"resp_mixed_2".to_string()),
13198            "barged-in response must not appear in in_flight_realtime_assistant_response_ids"
13199        );
13200    }
13201
13202    #[test]
13203    fn round5_r55_barge_in_preserves_display_lane_drops_spoken() {
13204        // R5-5 unit test: pin the lane-filter behavior at the staged-item
13205        // level (no chained predecessor). One Display item, one Spoken item,
13206        // both unchained, both staged before Interrupted.
13207        let mut session = Session::new();
13208
13209        let _ =
13210            session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
13211                response_id: "resp_a".to_string(),
13212                delta_id: "delta_d_1".to_string(),
13213                item_id: "item_display".to_string(),
13214                previous_item_id: None,
13215                content_index: 0,
13216                delta: "display-text".to_string(),
13217            });
13218        let _ = session.append_realtime_transcript_event(
13219            RealtimeTranscriptEvent::AssistantTranscriptDelta {
13220                response_id: "resp_a".to_string(),
13221                delta_id: "delta_s_1".to_string(),
13222                item_id: "item_spoken".to_string(),
13223                previous_item_id: None,
13224                content_index: 0,
13225                delta: "spoken-transcript".to_string(),
13226            },
13227        );
13228
13229        let outcome = session.append_realtime_transcript_event(
13230            RealtimeTranscriptEvent::AssistantTurnInterrupted {
13231                response_id: "resp_a".to_string(),
13232            },
13233        );
13234        // Display materializes, Spoken does not.
13235        assert_eq!(outcome.materialized_messages.len(), 1);
13236
13237        let messages = session.messages();
13238        let assistants: Vec<&BlockAssistantMessage> = messages
13239            .iter()
13240            .filter_map(|m| match m {
13241                Message::BlockAssistant(a) => Some(a),
13242                _ => None,
13243            })
13244            .collect();
13245        assert_eq!(assistants.len(), 1);
13246        // Single Text block (the Display lane) — no Transcript.
13247        assert_eq!(assistants[0].blocks.len(), 1);
13248        match &assistants[0].blocks[0] {
13249            AssistantBlock::Text { text, .. } => assert_eq!(text, "display-text"),
13250            other => unreachable!("expected Text, got {other:?}"),
13251        }
13252    }
13253
13254    #[test]
13255    fn round5_r55_barge_in_finalizes_retained_display_into_committed_block() {
13256        // R5-5: the architectural decision — Interrupted is terminal for the
13257        // response. Display lane must commit at Interrupted time, not wait
13258        // on a hypothetical AssistantTurnCompleted that may never arrive
13259        // (or arrives Cancelled and short-circuits).
13260        let mut session = Session::new();
13261
13262        let _ =
13263            session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
13264                response_id: "resp_a".to_string(),
13265                delta_id: "delta_d_1".to_string(),
13266                item_id: "item_display".to_string(),
13267                previous_item_id: None,
13268                content_index: 0,
13269                delta: "committed-display-text".to_string(),
13270            });
13271
13272        // Pre-condition: nothing committed yet.
13273        assert!(session.messages().is_empty());
13274
13275        let outcome = session.append_realtime_transcript_event(
13276            RealtimeTranscriptEvent::AssistantTurnInterrupted {
13277                response_id: "resp_a".to_string(),
13278            },
13279        );
13280        assert_eq!(
13281            outcome.materialized_messages.len(),
13282            1,
13283            "Interrupted must finalize retained Display lane immediately"
13284        );
13285
13286        // Post-condition: BlockAssistant in canonical history, no Transcript.
13287        let messages = session.messages();
13288        assert_eq!(messages.len(), 1);
13289        match &messages[0] {
13290            Message::BlockAssistant(assistant) => {
13291                assert_eq!(assistant.blocks.len(), 1);
13292                match &assistant.blocks[0] {
13293                    AssistantBlock::Text { text, .. } => {
13294                        assert_eq!(text, "committed-display-text");
13295                    }
13296                    other => unreachable!("expected Text, got {other:?}"),
13297                }
13298            }
13299            other => unreachable!("expected BlockAssistant, got {other:?}"),
13300        }
13301    }
13302
13303    #[test]
13304    fn round5_r56_truncation_promotes_default_lane_item_to_spoken() {
13305        // R5-6: when truncation is the first content-bearing event for an
13306        // item (no prior delta), the staged item's lane MUST be promoted to
13307        // Spoken so the materializer commits as `AssistantBlock::Transcript`.
13308        // Without the explicit promotion, the lane stays `Display` (the
13309        // default) and the heard audio transcript persists as
13310        // `AssistantBlock::Text`.
13311        let mut session = Session::new();
13312
13313        let _ = session.append_realtime_transcript_event(
13314            RealtimeTranscriptEvent::AssistantTranscriptTruncated {
13315                response_id: "resp_a".to_string(),
13316                item_id: "item_a".to_string(),
13317                content_index: 0,
13318                text: "what was actually heard".to_string(),
13319            },
13320        );
13321
13322        let outcome = session.append_realtime_transcript_event(
13323            RealtimeTranscriptEvent::AssistantTurnCompleted {
13324                response_id: "resp_a".to_string(),
13325                stop_reason: StopReason::EndTurn,
13326                usage: Usage::default(),
13327            },
13328        );
13329        assert_eq!(outcome.materialized_messages.len(), 1);
13330
13331        assert_eq!(session.messages().len(), 1);
13332        match &session.messages()[0] {
13333            Message::BlockAssistant(assistant) => {
13334                assert_eq!(assistant.blocks.len(), 1);
13335                match &assistant.blocks[0] {
13336                    AssistantBlock::Transcript { text, source, .. } => {
13337                        assert_eq!(text, "what was actually heard");
13338                        assert_eq!(*source, crate::types::TranscriptSource::Spoken);
13339                    }
13340                    other => unreachable!(
13341                        "truncation-only path must materialize as AssistantBlock::Transcript, got {other:?}"
13342                    ),
13343                }
13344            }
13345            other => unreachable!("expected BlockAssistant, got {other:?}"),
13346        }
13347    }
13348
13349    #[test]
13350    fn round5_r56_truncation_after_display_delta_is_no_op_keeping_display_content() {
13351        // R5-6 edge case: a Display delta arrived first and staged Display
13352        // content; a truncation event arrives for the SAME item id
13353        // (provider bug — truncation only applies to spoken/audio output).
13354        // Contract: the staged Display content must NOT be clobbered by
13355        // the truncation text. `promote_item_lane` keeps the existing
13356        // Display lane and emits a `tracing::warn!`; the truncation arm
13357        // sees the lane stayed Display and skips the segment-write.
13358        let mut session = Session::new();
13359
13360        let _ =
13361            session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
13362                response_id: "resp_a".to_string(),
13363                delta_id: "delta_d_1".to_string(),
13364                item_id: "item_a".to_string(),
13365                previous_item_id: None,
13366                content_index: 0,
13367                delta: "display-text-from-delta".to_string(),
13368            });
13369
13370        let _ = session.append_realtime_transcript_event(
13371            RealtimeTranscriptEvent::AssistantTranscriptTruncated {
13372                response_id: "resp_a".to_string(),
13373                item_id: "item_a".to_string(),
13374                content_index: 0,
13375                text: "spoken-truncation-text".to_string(),
13376            },
13377        );
13378
13379        let _ = session.append_realtime_transcript_event(
13380            RealtimeTranscriptEvent::AssistantTurnCompleted {
13381                response_id: "resp_a".to_string(),
13382                stop_reason: StopReason::EndTurn,
13383                usage: Usage::default(),
13384            },
13385        );
13386
13387        // Display content survives unchanged — the truncation text was
13388        // refused. Materializes as `AssistantBlock::Text` (Display lane).
13389        assert_eq!(session.messages().len(), 1);
13390        match &session.messages()[0] {
13391            Message::BlockAssistant(assistant) => {
13392                assert_eq!(assistant.blocks.len(), 1);
13393                match &assistant.blocks[0] {
13394                    AssistantBlock::Text { text, .. } => {
13395                        assert_eq!(text, "display-text-from-delta");
13396                    }
13397                    other => unreachable!(
13398                        "Display content must survive misrouted truncation, got {other:?}"
13399                    ),
13400                }
13401            }
13402            other => unreachable!("expected BlockAssistant, got {other:?}"),
13403        }
13404    }
13405
13406    /// R5-6 sibling: a Spoken-classified item (transcript-truncation
13407    /// arrived first and locked the lane to Spoken) must reject a later
13408    /// `AssistantTextDelta` rather than silently appending the Display
13409    /// text into the Spoken-locked content_segment. Pre-fix the delta
13410    /// arm called `promote_item_lane` and unconditionally pushed the
13411    /// delta — clobbering the lane invariant. Post-fix the delta is
13412    /// dropped (warn fires) and the Spoken-truncation text survives.
13413    #[test]
13414    fn round5_r56_sibling_display_delta_skipped_on_spoken_item() {
13415        let mut session = Session::new();
13416
13417        // Truncation arrives first and locks the item to the Spoken lane.
13418        let _ = session.append_realtime_transcript_event(
13419            RealtimeTranscriptEvent::AssistantTranscriptTruncated {
13420                response_id: "resp_a".to_string(),
13421                item_id: "item_a".to_string(),
13422                content_index: 0,
13423                text: "what was actually heard".to_string(),
13424            },
13425        );
13426
13427        // A Display delta arrives later for the SAME item id (provider
13428        // lane-classification bug). It MUST be dropped.
13429        let _ =
13430            session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
13431                response_id: "resp_a".to_string(),
13432                delta_id: "delta_d_1".to_string(),
13433                item_id: "item_a".to_string(),
13434                previous_item_id: None,
13435                content_index: 0,
13436                delta: "should-not-appear".to_string(),
13437            });
13438
13439        let _ = session.append_realtime_transcript_event(
13440            RealtimeTranscriptEvent::AssistantTurnCompleted {
13441                response_id: "resp_a".to_string(),
13442                stop_reason: StopReason::EndTurn,
13443                usage: Usage::default(),
13444            },
13445        );
13446
13447        // The Spoken-truncation text survives intact; no Display text
13448        // leaked into the Spoken lane content.
13449        assert_eq!(session.messages().len(), 1);
13450        match &session.messages()[0] {
13451            Message::BlockAssistant(assistant) => {
13452                assert_eq!(assistant.blocks.len(), 1);
13453                match &assistant.blocks[0] {
13454                    AssistantBlock::Transcript { text, source, .. } => {
13455                        assert_eq!(text, "what was actually heard");
13456                        assert_eq!(*source, crate::types::TranscriptSource::Spoken);
13457                    }
13458                    other => unreachable!(
13459                        "Spoken-locked item must materialize as Transcript, got {other:?}"
13460                    ),
13461                }
13462            }
13463            other => unreachable!("expected BlockAssistant, got {other:?}"),
13464        }
13465    }
13466
13467    /// R5-6 sibling: a Display-classified item (a Display delta arrived
13468    /// first and locked the lane to Display) must reject a later
13469    /// `AssistantTranscriptDelta` rather than appending the Spoken text
13470    /// into the Display-locked content_segment. Pre-fix the transcript
13471    /// delta arm called `promote_item_lane` and unconditionally pushed —
13472    /// silently mixing a Spoken stream into a Display block.
13473    #[test]
13474    fn round5_r56_sibling_spoken_delta_skipped_on_display_item() {
13475        let mut session = Session::new();
13476
13477        // Display delta arrives first and locks the item to the Display lane.
13478        let _ =
13479            session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
13480                response_id: "resp_a".to_string(),
13481                delta_id: "delta_d_1".to_string(),
13482                item_id: "item_a".to_string(),
13483                previous_item_id: None,
13484                content_index: 0,
13485                delta: "display-locked-text".to_string(),
13486            });
13487
13488        // A spoken-transcript delta arrives later for the SAME item id
13489        // (provider lane-classification bug). It MUST be dropped.
13490        let _ = session.append_realtime_transcript_event(
13491            RealtimeTranscriptEvent::AssistantTranscriptDelta {
13492                response_id: "resp_a".to_string(),
13493                delta_id: "delta_s_1".to_string(),
13494                item_id: "item_a".to_string(),
13495                previous_item_id: None,
13496                content_index: 0,
13497                delta: "should-not-appear".to_string(),
13498            },
13499        );
13500
13501        let _ = session.append_realtime_transcript_event(
13502            RealtimeTranscriptEvent::AssistantTurnCompleted {
13503                response_id: "resp_a".to_string(),
13504                stop_reason: StopReason::EndTurn,
13505                usage: Usage::default(),
13506            },
13507        );
13508
13509        // The Display text survives intact; no Spoken text leaked in.
13510        assert_eq!(session.messages().len(), 1);
13511        match &session.messages()[0] {
13512            Message::BlockAssistant(assistant) => {
13513                assert_eq!(assistant.blocks.len(), 1);
13514                match &assistant.blocks[0] {
13515                    AssistantBlock::Text { text, .. } => {
13516                        assert_eq!(text, "display-locked-text");
13517                    }
13518                    other => {
13519                        unreachable!("Display-locked item must materialize as Text, got {other:?}")
13520                    }
13521                }
13522            }
13523            other => unreachable!("expected BlockAssistant, got {other:?}"),
13524        }
13525    }
13526
13527    /// R5-7: a late `AssistantTranscriptFinalText` arriving AFTER
13528    /// `AssistantTurnCompleted` already materialized the item must NOT
13529    /// mutate `content_segments` and must NOT rewrite the canonical
13530    /// `Message::BlockAssistant` (append-only history is a stronger
13531    /// invariant than typed text repair). The committed message keeps
13532    /// the delta-accumulated text; the late final is dropped with a
13533    /// warn; the materializer outcome is inert (no new messages).
13534    #[test]
13535    fn round5_r57_late_final_text_after_turn_completed_warns_and_skips() {
13536        let mut session = Session::new();
13537
13538        // Delta accumulates partial text on the Spoken lane.
13539        let _ = session.append_realtime_transcript_event(
13540            RealtimeTranscriptEvent::AssistantTranscriptDelta {
13541                response_id: "resp_a".to_string(),
13542                delta_id: "delta_s_1".to_string(),
13543                item_id: "item_a".to_string(),
13544                previous_item_id: None,
13545                content_index: 0,
13546                delta: "delta-accumulated".to_string(),
13547            },
13548        );
13549
13550        // TurnCompleted materializes the item with the delta-accumulated text.
13551        let commit_outcome = session.append_realtime_transcript_event(
13552            RealtimeTranscriptEvent::AssistantTurnCompleted {
13553                response_id: "resp_a".to_string(),
13554                stop_reason: StopReason::EndTurn,
13555                usage: Usage::default(),
13556            },
13557        );
13558        assert_eq!(commit_outcome.materialized_messages.len(), 1);
13559
13560        // Late FinalText arrives — provider-side ordering bug. It MUST
13561        // be dropped: no canonical message rewrite, no segment mutation,
13562        // outcome is inert.
13563        let late_outcome = session.append_realtime_transcript_event(
13564            RealtimeTranscriptEvent::AssistantTranscriptFinalText {
13565                response_id: "resp_a".to_string(),
13566                item_id: "item_a".to_string(),
13567                content_index: 0,
13568                text: "authoritative-final-that-must-not-land".to_string(),
13569            },
13570        );
13571        assert!(
13572            late_outcome.is_inert(),
13573            "late FinalText after materialization must produce inert outcome"
13574        );
13575
13576        // Canonical history: still one message with the original
13577        // delta-accumulated text — NOT the authoritative final.
13578        assert_eq!(session.messages().len(), 1);
13579        match &session.messages()[0] {
13580            Message::BlockAssistant(assistant) => {
13581                assert_eq!(assistant.blocks.len(), 1);
13582                match &assistant.blocks[0] {
13583                    AssistantBlock::Transcript { text, .. } => {
13584                        assert_eq!(
13585                            text, "delta-accumulated",
13586                            "canonical message must preserve delta-accumulated text; \
13587                             append-only history forbids late FinalText repair"
13588                        );
13589                    }
13590                    other => unreachable!("expected Transcript, got {other:?}"),
13591                }
13592            }
13593            other => unreachable!("expected BlockAssistant, got {other:?}"),
13594        }
13595    }
13596
13597    fn metadata_seam_session_metadata() -> SessionMetadata {
13598        SessionMetadata {
13599            schema_version: SESSION_METADATA_SCHEMA_VERSION,
13600            model: "test-model".to_string(),
13601            max_tokens: 1024,
13602            structured_output_retries: 2,
13603            provider: Provider::Anthropic,
13604            self_hosted_server_id: None,
13605            provider_params: None,
13606            tooling: SessionTooling::default(),
13607            keep_alive: false,
13608            comms_name: Some("team/reviewer/alice".to_string()),
13609            peer_meta: None,
13610            realm_id: None,
13611            instance_id: None,
13612            backend: None,
13613            config_generation: None,
13614            auth_binding: None,
13615            mob_member_binding: Some(crate::MobMemberBinding {
13616                mob_id: "team".to_string(),
13617                role: "reviewer".to_string(),
13618                member: "alice".to_string(),
13619            }),
13620        }
13621    }
13622
13623    /// Lockstep pin: the metadata-only partial decode must read the exact
13624    /// envelope that `SessionSerde` writes. If a field rename or serde-shape
13625    /// change lands on the full envelope without the partial decoder
13626    /// following, this test fails.
13627    #[test]
13628    fn session_metadata_document_lockstep_with_full_envelope() {
13629        let mut session = Session::new();
13630        session.push(Message::User(UserMessage::text("hello".to_string())));
13631        session
13632            .set_session_metadata(metadata_seam_session_metadata())
13633            .expect("session metadata should persist");
13634        session
13635            .set_lifecycle_terminal(SessionLifecycleTerminal::Archived)
13636            .expect("lifecycle terminal should persist");
13637
13638        let bytes = serde_json::to_vec(&session).expect("session should serialize");
13639        let document = session_metadata_document_from_slice(&bytes)
13640            .expect("partial decode must accept the canonical envelope");
13641
13642        assert_eq!(document.session_id(), session.id());
13643        assert_eq!(
13644            document.session_metadata_value(),
13645            session.metadata().get(SESSION_METADATA_KEY),
13646            "partial decode must project the identical raw session-metadata value"
13647        );
13648        assert_eq!(
13649            document.lifecycle_terminal_value(),
13650            session.metadata().get(SESSION_LIFECYCLE_TERMINAL_KEY),
13651            "partial decode must project the identical raw lifecycle-terminal value"
13652        );
13653
13654        let view = document
13655            .try_into_view()
13656            .expect("typed view must decode from the partial document");
13657        let full_view =
13658            PersistedSessionMetadataView::try_from_session(&session).expect("full-session view");
13659        assert_eq!(view.session_id, full_view.session_id);
13660        assert_eq!(
13661            view.session_metadata.as_ref().map(|m| m.model.clone()),
13662            full_view.session_metadata.as_ref().map(|m| m.model.clone())
13663        );
13664        assert_eq!(
13665            view.mob_member_binding(),
13666            full_view.mob_member_binding(),
13667            "typed binding must be identical across the two decode paths"
13668        );
13669        assert_eq!(
13670            view.lifecycle_terminal,
13671            Some(SessionLifecycleTerminal::Archived)
13672        );
13673        assert_eq!(
13674            full_view.lifecycle_terminal,
13675            Some(SessionLifecycleTerminal::Archived)
13676        );
13677    }
13678
13679    /// The metadata-only partial decode fails closed on an unsupported
13680    /// envelope version — same contract as the full deserializer.
13681    #[test]
13682    fn session_metadata_document_fails_closed_on_envelope_version() {
13683        let session = Session::new();
13684        let mut value = serde_json::to_value(&session).expect("session should serialize");
13685        value["version"] = serde_json::json!(SESSION_VERSION + 999);
13686        let bytes = serde_json::to_vec(&value).expect("mangled envelope should serialize");
13687
13688        session_metadata_document_from_slice(&bytes)
13689            .expect_err("an unsupported envelope version must fail the partial decode closed");
13690    }
13691
13692    /// Corrupt values under either reserved key are a read FAULT for the
13693    /// metadata view — never coalesced into "absent".
13694    #[test]
13695    fn persisted_session_metadata_view_fails_closed_on_corrupt_values() {
13696        let session_id = SessionId::new();
13697
13698        let mut corrupt_metadata = serde_json::Map::new();
13699        corrupt_metadata.insert(SESSION_METADATA_KEY.to_string(), serde_json::json!(42));
13700        PersistedSessionMetadataView::try_from_metadata_map(session_id.clone(), &corrupt_metadata)
13701            .expect_err("corrupt session_metadata must fail the view decode closed");
13702
13703        let mut corrupt_terminal = serde_json::Map::new();
13704        corrupt_terminal.insert(
13705            SESSION_LIFECYCLE_TERMINAL_KEY.to_string(),
13706            serde_json::json!("definitely-not-a-terminal"),
13707        );
13708        PersistedSessionMetadataView::try_from_metadata_map(session_id, &corrupt_terminal)
13709            .expect_err("corrupt lifecycle terminal must fail the view decode closed");
13710    }
13711
13712    /// Absent reserved keys decode as typed absence through the view.
13713    #[test]
13714    fn persisted_session_metadata_view_reads_absent_facts_as_none() {
13715        let view = PersistedSessionMetadataView::try_from_metadata_map(
13716            SessionId::new(),
13717            &serde_json::Map::new(),
13718        )
13719        .expect("empty metadata map must decode");
13720        assert!(view.session_metadata.is_none());
13721        assert!(view.lifecycle_terminal.is_none());
13722        assert!(view.mob_member_binding().is_none());
13723    }
13724}