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, HashSet, VecDeque};
35use std::sync::{Arc, Mutex, OnceLock};
36
37mod digest_accumulator;
38
39use digest_accumulator::TranscriptMessages;
40
41/// Current session format version.
42///
43/// The persisted `version` byte is mandatory and fail-closed: a stored row
44/// with a missing or non-current version (including pre-typed-owner v0/v1
45/// rows) is rejected at the serde boundary by the generated persistence
46/// version authority — it never silently defaults or upgrades on read.
47pub use crate::generated::session_persistence_version_authority::SESSION_VERSION;
48
49/// Current `SessionMetadata` schema version. Distinct from `SESSION_VERSION`
50/// so `SessionMetadata` can evolve independently of the Session envelope.
51///
52/// Mandatory and fail-closed on read, same contract as `SESSION_VERSION`.
53pub use crate::generated::session_persistence_version_authority::SESSION_METADATA_SCHEMA_VERSION;
54
55/// Current session format version accepted by generated persistence authority.
56pub fn session_version() -> u32 {
57    session_persistence_version_authority::session_envelope_version()
58}
59
60/// Current `SessionMetadata` schema version accepted by generated persistence authority.
61pub fn session_metadata_schema_version() -> u32 {
62    session_persistence_version_authority::session_metadata_schema_version()
63}
64
65/// Typed transcript replacement used to create an edited fork.
66///
67/// Replacements never mutate the source session in place. The owning service
68/// applies this to a forked prefix, producing a new `SessionId`.
69#[derive(Debug, Clone, Serialize, Deserialize)]
70#[serde(tag = "type", rename_all = "snake_case")]
71pub enum TranscriptReplacement {
72    /// Replace the addressed message with a full canonical message.
73    Message { message: Message },
74    /// Replace one user-message content block.
75    UserContentBlock {
76        block_index: usize,
77        block: ContentBlock,
78    },
79    /// Replace one block in a block-assistant message.
80    AssistantBlock {
81        block_index: usize,
82        block: AssistantBlock,
83    },
84    /// Replace one content block inside one tool-result payload.
85    ToolResultContentBlock {
86        result_index: usize,
87        block_index: usize,
88        block: ContentBlock,
89    },
90}
91
92/// Session metadata key for the typed transcript revision graph head.
93pub const SESSION_TRANSCRIPT_HISTORY_STATE_KEY: &str = "session_transcript_history_state_v1";
94
95/// Storage-representation witness for transcript history that an incremental
96/// session store keeps out of line.
97///
98/// A full session carries [`SESSION_TRANSCRIPT_HISTORY_STATE_KEY`]. A slim
99/// incremental projection carries this digest instead, allowing the typed
100/// checkpoint digest to bind the same semantic history without rehydrating
101/// every retained revision on each read. Only typed store code may author it.
102pub const SESSION_TRANSCRIPT_HISTORY_CHECKPOINT_DIGEST_KEY: &str =
103    "session_transcript_history_checkpoint_digest_v1";
104
105/// A concrete transcript span selected for same-session rewrite.
106#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
107#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
108#[serde(tag = "type", rename_all = "snake_case")]
109pub enum TranscriptRewriteSelection {
110    /// Pre-semantic-marker range retained for source/API compatibility and
111    /// decoding prior durable records. New commits canonicalize this input to
112    /// [`TranscriptRewriteSelection::EditMessageRange`] before persistence.
113    MessageRange { start: usize, end: usize },
114    /// Current typed ordinary-edit semantic.
115    EditMessageRange { range: TranscriptEditRewriteRange },
116    /// Replace a full transcript from a core-validated compaction rebuild.
117    ///
118    /// The range payload has no public constructor. New values are minted only
119    /// by the validated compaction path; deserialization exists solely for the
120    /// durable transcript graph and is revalidated against its retained bodies.
121    CompactionMessageRange { range: CompactionRewriteRange },
122}
123
124/// Opaque current-format range carried by an ordinary transcript edit.
125#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
126#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
127pub struct TranscriptEditRewriteRange {
128    start: usize,
129    end: usize,
130}
131
132/// Opaque range carried by the typed compaction rewrite semantic.
133#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
134#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
135pub struct CompactionRewriteRange {
136    start: usize,
137    end: usize,
138}
139
140/// Canonical semantic class of a transcript rewrite.
141#[derive(Debug, Clone, Copy, PartialEq, Eq)]
142pub enum TranscriptRewriteSemantic {
143    /// Ordinary same-session edit.
144    Edit,
145    /// Core-validated context compaction.
146    Compaction,
147}
148
149impl TranscriptRewriteSelection {
150    /// Return the selected half-open message range without exposing the
151    /// authority-bearing representation used to classify the rewrite.
152    pub fn bounds(&self) -> (usize, usize) {
153        match self {
154            Self::MessageRange { start, end } => (*start, *end),
155            Self::EditMessageRange { range } => (range.start, range.end),
156            Self::CompactionMessageRange { range } => (range.start, range.end),
157        }
158    }
159
160    pub fn semantic(&self) -> TranscriptRewriteSemantic {
161        match self {
162            Self::MessageRange { .. } | Self::EditMessageRange { .. } => {
163                TranscriptRewriteSemantic::Edit
164            }
165            Self::CompactionMessageRange { .. } => TranscriptRewriteSemantic::Compaction,
166        }
167    }
168
169    fn into_current_edit_semantic(self) -> Self {
170        match self {
171            Self::MessageRange { start, end } => Self::EditMessageRange {
172                range: TranscriptEditRewriteRange { start, end },
173            },
174            current => current,
175        }
176    }
177
178    fn is_legacy_untyped(&self) -> bool {
179        matches!(self, Self::MessageRange { .. })
180    }
181
182    fn validated_compaction(
183        start: usize,
184        end: usize,
185        _authority: &crate::agent::compact::ValidatedCompactionRewrite,
186    ) -> Self {
187        Self::CompactionMessageRange {
188            range: CompactionRewriteRange { start, end },
189        }
190    }
191
192    fn migrated_legacy_compaction(start: usize, end: usize) -> Self {
193        Self::CompactionMessageRange {
194            range: CompactionRewriteRange { start, end },
195        }
196    }
197
198    #[cfg(test)]
199    pub(crate) fn typed_compaction_for_test(start: usize, end: usize) -> Self {
200        Self::CompactionMessageRange {
201            range: CompactionRewriteRange { start, end },
202        }
203    }
204}
205
206/// Audit annotation carried with a transcript rewrite commit.
207///
208/// The free-form kind is for review, debugging, and provenance only. It never
209/// classifies a rewrite as compaction; [`TranscriptRewriteSelection`] owns that
210/// semantic through its opaque typed compaction range.
211#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
212#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
213#[serde(rename_all = "snake_case")]
214pub struct TranscriptRewriteReason {
215    pub kind: String,
216    #[serde(default, skip_serializing_if = "Option::is_none")]
217    pub note: Option<String>,
218}
219
220impl TranscriptRewriteReason {
221    pub fn new(kind: impl Into<String>) -> Self {
222        Self {
223            kind: kind.into(),
224            note: None,
225        }
226    }
227}
228
229/// Typed rewrite-commit reason for a resume-time base-prompt refresh
230/// committed by [`Session::reconcile_resumed_system_prompt`].
231pub const RESUME_SYSTEM_PROMPT_REFRESH_REWRITE_REASON: &str = "resume-system-prompt-refresh";
232
233/// Typed outcome of [`Session::reconcile_resumed_system_prompt`].
234#[derive(Debug, Clone, Copy, PartialEq, Eq)]
235pub enum ResumedSystemPromptReconciliation {
236    /// The persisted System message already carries the assembled base prompt
237    /// (identical, or extended only by runtime system-context appends). The
238    /// transcript was left untouched, so the resumed projection digests to
239    /// the persisted revision.
240    PreservedContinuation,
241    /// The assembled base prompt diverged from the persisted System message;
242    /// the replacement was committed as a typed transcript rewrite so the
243    /// first post-resume persist proves a graph edge from the persisted head.
244    RewrittenBase,
245    /// The resumed transcript has no leading System message and the assembled
246    /// prompt is empty — nothing to reconcile.
247    NoChange,
248}
249
250impl std::fmt::Display for TranscriptRewriteReason {
251    /// Human-facing projection consumed by revision-list reads. The typed
252    /// `{kind, note}` audit value is retained; this rendering is derived only
253    /// and never supplies rewrite semantic authority.
254    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
255        match &self.note {
256            Some(note) => write!(f, "{}: {note}", self.kind),
257            None => f.write_str(&self.kind),
258        }
259    }
260}
261
262/// Immutable rewrite commit that advances a session transcript head.
263#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
264#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
265#[serde(rename_all = "snake_case")]
266pub struct TranscriptRewriteCommit {
267    pub parent_revision: String,
268    pub revision: String,
269    pub selection: TranscriptRewriteSelection,
270    pub original_span_digest: String,
271    pub replacement_digest: String,
272    pub messages_before: usize,
273    pub messages_after: usize,
274    pub reason: TranscriptRewriteReason,
275    #[serde(default, skip_serializing_if = "Option::is_none")]
276    pub actor: Option<String>,
277    #[cfg_attr(feature = "schema", schemars(with = "SchemaSystemTime"))]
278    pub committed_at: SystemTime,
279}
280
281/// Immutable transcript revision body retained by the session-local graph.
282#[derive(Debug, Clone, Serialize, Deserialize)]
283#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
284#[serde(rename_all = "snake_case")]
285pub struct TranscriptRevisionBody {
286    pub revision: String,
287    #[serde(default, skip_serializing_if = "Option::is_none")]
288    pub parent_revision: Option<String>,
289    #[cfg_attr(feature = "schema", schemars(with = "Vec<serde_json::Value>"))]
290    pub messages: Vec<Message>,
291    #[cfg_attr(feature = "schema", schemars(with = "SchemaSystemTime"))]
292    pub created_at: SystemTime,
293}
294
295#[cfg(feature = "schema")]
296#[allow(dead_code)]
297#[derive(schemars::JsonSchema)]
298#[schemars(rename = "SystemTime")]
299struct SchemaSystemTime {
300    secs_since_epoch: u64,
301    nanos_since_epoch: u32,
302}
303
304/// Self-contained append-only transcript rewrite record.
305#[derive(Debug, Clone, Serialize)]
306#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
307#[serde(rename_all = "snake_case")]
308pub struct TranscriptRewriteRecord {
309    pub commit: TranscriptRewriteCommit,
310    pub parent_body: TranscriptRevisionBody,
311    pub revision_body: TranscriptRevisionBody,
312}
313
314impl<'de> Deserialize<'de> for TranscriptRewriteRecord {
315    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
316    where
317        D: Deserializer<'de>,
318    {
319        #[derive(Deserialize)]
320        #[serde(rename_all = "snake_case")]
321        struct Wire {
322            commit: TranscriptRewriteCommit,
323            parent_body: TranscriptRevisionBody,
324            revision_body: TranscriptRevisionBody,
325        }
326        let wire = Wire::deserialize(deserializer)?;
327        let mut revisions = vec![wire.parent_body, wire.revision_body];
328        let mut commits = vec![wire.commit];
329        heal_legacy_revision_strings(&mut revisions, &mut commits, None)
330            .map_err(serde::de::Error::custom)?;
331        heal_legacy_compaction_rewrite_semantics(&mut commits, &revisions);
332        let mut revisions = revisions.into_iter();
333        let parent_body = revisions
334            .next()
335            .ok_or_else(|| serde::de::Error::custom("rewrite record lost its parent body"))?;
336        let revision_body = revisions
337            .next()
338            .ok_or_else(|| serde::de::Error::custom("rewrite record lost its revision body"))?;
339        let commit = commits
340            .into_iter()
341            .next()
342            .ok_or_else(|| serde::de::Error::custom("rewrite record lost its commit"))?;
343        Ok(Self {
344            commit,
345            parent_body,
346            revision_body,
347        })
348    }
349}
350
351impl TranscriptRewriteRecord {
352    pub fn new(
353        commit: TranscriptRewriteCommit,
354        parent_body: TranscriptRevisionBody,
355        revision_body: TranscriptRevisionBody,
356    ) -> Result<Self, TranscriptEditError> {
357        validate_transcript_rewrite_record(&commit, &parent_body, &revision_body)?;
358        Ok(Self {
359            commit,
360            parent_body,
361            revision_body,
362        })
363    }
364}
365
366/// Typed session-local transcript revision graph state.
367#[derive(Debug, Clone, Serialize)]
368#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
369#[serde(rename_all = "snake_case")]
370pub struct TranscriptHistoryState {
371    pub head: String,
372    #[serde(default, skip_serializing_if = "Vec::is_empty")]
373    pub commits: Vec<TranscriptRewriteCommit>,
374    #[serde(default, skip_serializing_if = "Vec::is_empty")]
375    pub revisions: Vec<TranscriptRevisionBody>,
376    /// Digest-format generation of the revision strings. Documents stamped
377    /// `>= 2` were written by the content-addressed digest format, so decode
378    /// skips the per-decode legacy-heal probe (a full-transcript hash);
379    /// absent/0 means unknown provenance and the probe runs once — the next
380    /// save persists the marker. A compatibility convenience, not an
381    /// integrity boundary (checkpoint stamps own integrity).
382    #[serde(default, skip_serializing_if = "digest_format_is_unknown")]
383    pub digest_format: u32,
384}
385
386fn digest_format_is_unknown(format: &u32) -> bool {
387    *format == 0
388}
389
390/// The digest-format generation minted by [`transcript_messages_digest`].
391pub(crate) const TRANSCRIPT_DIGEST_FORMAT_CURRENT: u32 = 2;
392
393/// Decode-memo fact: the retained head body's content digest equals the
394/// stored head revision string (the legacy heal probe found nothing to heal).
395const TRANSCRIPT_GRAPH_FACT_HEAL_PROBE_CURRENT: u8 = 1;
396/// Decode-memo fact: [`validate_transcript_history_state`] fully succeeded
397/// for a graph of this exact shape.
398const TRANSCRIPT_GRAPH_FACT_VALIDATED: u8 = 2;
399
400/// Cheap structural identity of a transcript revision graph for the
401/// process-lifetime decode memo.
402///
403/// The key pins everything the decode-time digest work reads EXCEPT retained
404/// message content: the head revision string, every retained body's
405/// content-addressed revision string, parent pointer, and message count, and
406/// the full serialized commit log (span digests, selection bounds, counts).
407/// Message bodies are trusted through their content-addressed revision
408/// strings once one full verification proved them in this process — the same
409/// read-trusts/write-verifies model as the checkpoint-stamp memo. The `fact`
410/// tag namespaces independently proven facts so one can never satisfy a
411/// consult for the other. Hashing here is O(graph structure), never
412/// O(message content), and deliberately does not count as a content-digest
413/// computation.
414fn transcript_graph_shape_key(
415    fact: u8,
416    head: &str,
417    commits: &[TranscriptRewriteCommit],
418    revisions: &[TranscriptRevisionBody],
419) -> Option<String> {
420    let mut hasher = Sha256::new();
421    hasher.update([fact]);
422    hasher.update((head.len() as u64).to_le_bytes());
423    hasher.update(head.as_bytes());
424    hasher.update((revisions.len() as u64).to_le_bytes());
425    for body in revisions {
426        hasher.update((body.revision.len() as u64).to_le_bytes());
427        hasher.update(body.revision.as_bytes());
428        match body.parent_revision.as_deref() {
429            Some(parent) => {
430                hasher.update([1]);
431                hasher.update((parent.len() as u64).to_le_bytes());
432                hasher.update(parent.as_bytes());
433            }
434            None => hasher.update([0]),
435        }
436        hasher.update((body.messages.len() as u64).to_le_bytes());
437    }
438    hasher.update((commits.len() as u64).to_le_bytes());
439    for commit in commits {
440        let bytes = serde_json::to_vec(commit).ok()?;
441        hasher.update((bytes.len() as u64).to_le_bytes());
442        hasher.update(&bytes);
443    }
444    let digest = hasher.finalize();
445    let mut out = String::with_capacity(2 + digest.len() * 2);
446    out.push(char::from(b'0' + fact));
447    out.push(':');
448    const HEX: &[u8; 16] = b"0123456789abcdef";
449    for byte in digest {
450        out.push(HEX[(byte >> 4) as usize] as char);
451        out.push(HEX[(byte & 0x0f) as usize] as char);
452    }
453    Some(out)
454}
455
456/// Process-lifetime bounded memo of decode-time transcript-graph digest
457/// facts (heal-probe outcome, full graph validation).
458///
459/// Marker-less documents (written by pre-marker code) and every decoded
460/// document's graph validation otherwise pay a full canonical-JSON + SHA-256
461/// pass over retained transcript bodies on EVERY decode — O(document) work
462/// per repeat load of unchanged bytes. The memo only ever ADDS the fact "a
463/// graph of this exact shape was proved on this process's decode path":
464/// admission requires one complete verification, so the first decode after
465/// boot always hashes, and changed content re-keys the memo (revision
466/// strings, counts, parents, or commit bytes change) and re-verifies.
467/// Bounded FIFO eviction only forces a redundant re-verification, never a
468/// stale trust decision for a key that was never proved. Write and typed
469/// mutation seams never consult this memo.
470struct BoundedTranscriptGraphDecodeMemo {
471    capacity: usize,
472    entries: HashSet<String>,
473    order: VecDeque<String>,
474}
475
476impl BoundedTranscriptGraphDecodeMemo {
477    fn new(capacity: usize) -> Self {
478        Self {
479            capacity,
480            entries: HashSet::new(),
481            order: VecDeque::new(),
482        }
483    }
484
485    fn contains(&self, key: &str) -> bool {
486        self.entries.contains(key)
487    }
488
489    fn record(&mut self, key: String) {
490        if self.entries.contains(&key) {
491            return;
492        }
493        while self.entries.len() >= self.capacity {
494            let Some(evicted) = self.order.pop_front() else {
495                break;
496            };
497            self.entries.remove(&evicted);
498        }
499        self.order.push_back(key.clone());
500        self.entries.insert(key);
501    }
502}
503
504const TRANSCRIPT_GRAPH_DECODE_MEMO_CAPACITY: usize = 4096;
505
506static TRANSCRIPT_GRAPH_DECODE_MEMO: OnceLock<Mutex<BoundedTranscriptGraphDecodeMemo>> =
507    OnceLock::new();
508
509fn transcript_graph_decode_memo() -> &'static Mutex<BoundedTranscriptGraphDecodeMemo> {
510    TRANSCRIPT_GRAPH_DECODE_MEMO.get_or_init(|| {
511        Mutex::new(BoundedTranscriptGraphDecodeMemo::new(
512            TRANSCRIPT_GRAPH_DECODE_MEMO_CAPACITY,
513        ))
514    })
515}
516
517/// Whether this exact graph-shape fact was already proved on this process's
518/// decode path. A poisoned lock degrades to "not cached": the caller
519/// re-verifies.
520///
521/// Setting `MEERKAT_DISABLE_GRAPH_DECODE_MEMO` (any value) forces every
522/// lookup to miss, reproducing the pre-memo decode cost. It is a diagnostic
523/// kill-switch with exactly two uses: red-first verification of the e2e
524/// gates that assert this memo absorbs repeat decodes (see the marker-less
525/// resume-cost assertion in `meerkat-mob/tests/smoke_mob_idle_burn.rs`),
526/// and ruling the memo in or out when stale memoized trust is suspected.
527/// It must never be set in production — it restores the
528/// O(document)-per-decode verification cost this memo exists to remove.
529fn transcript_graph_fact_is_memoized(key: &str) -> bool {
530    if std::env::var_os("MEERKAT_DISABLE_GRAPH_DECODE_MEMO").is_some() {
531        return false;
532    }
533    transcript_graph_decode_memo()
534        .lock()
535        .map(|memo| memo.contains(key))
536        .unwrap_or(false)
537}
538
539/// Record one completed decode-path proof of this exact graph-shape fact.
540fn record_transcript_graph_fact(key: String) {
541    if let Ok(mut memo) = transcript_graph_decode_memo().lock() {
542        memo.record(key);
543    }
544}
545
546/// Validation trust mode for
547/// [`TranscriptHistoryState::compact_mechanical_revision_bodies_for`].
548#[derive(Debug, Clone, Copy, PartialEq, Eq)]
549enum TranscriptGraphValidationMode {
550    /// Always run the full per-body digest validation. Every write, typed
551    /// mutation, and serialization seam uses this mode: a cached hit is
552    /// memoized trust, not a fresh proof of current bytes.
553    FullVerify,
554    /// Decode path for durable documents: a graph shape whose full
555    /// validation already succeeded on this process's decode path may skip
556    /// the per-body digest re-verification. First sight still verifies
557    /// fully and admits the shape into the bounded decode memo.
558    DecodeMemoized,
559}
560
561impl<'de> Deserialize<'de> for TranscriptHistoryState {
562    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
563    where
564        D: Deserializer<'de>,
565    {
566        #[derive(Deserialize)]
567        #[serde(rename_all = "snake_case")]
568        struct Wire {
569            head: String,
570            #[serde(default)]
571            commits: Vec<TranscriptRewriteCommit>,
572            #[serde(default)]
573            revisions: Vec<TranscriptRevisionBody>,
574            #[serde(default)]
575            digest_format: u32,
576        }
577        let wire = Wire::deserialize(deserializer)?;
578        let mut state = TranscriptHistoryState {
579            head: wire.head,
580            commits: wire.commits,
581            revisions: wire.revisions,
582            digest_format: wire.digest_format,
583        };
584        // Pre-parent-pointer v1 snapshots serialized each body as
585        // {created_at,messages,revision}. When every non-root body lacks a
586        // parent, the append order is the only lineage the old format
587        // carried; reconstruct that exact linear order before digest healing
588        // and full validation.
589        if state.revisions.len() > 1
590            && state
591                .revisions
592                .iter()
593                .skip(1)
594                .all(|body| body.parent_revision.is_none())
595        {
596            for index in 1..state.revisions.len() {
597                let parent = state.revisions[index - 1].revision.clone();
598                state.revisions[index].parent_revision = Some(parent);
599            }
600        }
601        // Fast path: a graph stamped with the current digest format skips the
602        // heal probe outright — the probe hashes the full head transcript,
603        // which is decode-hot (every session load). Unstamped graphs (legacy
604        // or pre-marker writers) pay the probe once per process per shape
605        // (the bounded decode memo absorbs repeat decodes of unchanged
606        // marker-less bytes); their next save persists the marker.
607        let head_is_current = state.digest_format >= TRANSCRIPT_DIGEST_FORMAT_CURRENT
608            || match state
609                .revisions
610                .iter()
611                .find(|body| body.revision == state.head)
612            {
613                Some(head_body) => {
614                    let probe_key = transcript_graph_shape_key(
615                        TRANSCRIPT_GRAPH_FACT_HEAL_PROBE_CURRENT,
616                        &state.head,
617                        &state.commits,
618                        &state.revisions,
619                    );
620                    if probe_key
621                        .as_deref()
622                        .is_some_and(transcript_graph_fact_is_memoized)
623                    {
624                        true
625                    } else {
626                        let current = transcript_messages_digest(&head_body.messages)
627                            .map_err(serde::de::Error::custom)?
628                            == state.head;
629                        // Only the idempotent outcome is memoizable: a
630                        // stale-format head must keep healing on every
631                        // decode until a save persists the healed strings.
632                        if current && let Some(key) = probe_key {
633                            record_transcript_graph_fact(key);
634                        }
635                        current
636                    }
637                }
638                None => true,
639            };
640        state.digest_format = TRANSCRIPT_DIGEST_FORMAT_CURRENT;
641        if !head_is_current {
642            let TranscriptHistoryState {
643                head,
644                commits,
645                digest_format: _,
646                revisions,
647            } = &mut state;
648            heal_legacy_revision_strings(revisions, commits, Some(head))
649                .map_err(serde::de::Error::custom)?;
650        }
651        heal_legacy_compaction_rewrite_semantics(&mut state.commits, &state.revisions);
652        Ok(state)
653    }
654}
655
656impl TranscriptHistoryState {
657    /// Drop mechanical append-head snapshots while preserving every body that
658    /// is an endpoint of an audited rewrite plus the current live head.
659    ///
660    /// Ordinary appends previously accumulated a complete transcript body on
661    /// every message mutation once any rewrite had occurred. Those bodies are
662    /// not rewrite history and are never selected for restore. Repointing the
663    /// live head directly at the latest rewrite endpoint keeps the existing
664    /// full-body lineage validator intact after the intermediate append heads
665    /// are removed.
666    fn compact_mechanical_revision_bodies(&mut self) -> Result<(), TranscriptEditError> {
667        self.compact_mechanical_revision_bodies_for(TranscriptGraphValidationMode::FullVerify)
668    }
669
670    /// [`Self::compact_mechanical_revision_bodies`] with an explicit
671    /// validation trust mode. Only the durable-document decode seam passes
672    /// [`TranscriptGraphValidationMode::DecodeMemoized`]; typed mutation and
673    /// serialization seams keep the unconditional full validation.
674    ///
675    /// MERGE NOTE (class2 integration): this composes the decode memo (which
676    /// absorbs repeat decodes of unchanged marker-less documents) with the
677    /// extracted pruning half below (which the append fast path calls with
678    /// its own O(1) validity proof, skipping validation entirely). Both
679    /// mechanisms are load-bearing; neither replaces the other.
680    fn compact_mechanical_revision_bodies_for(
681        &mut self,
682        mode: TranscriptGraphValidationMode,
683    ) -> Result<(), TranscriptEditError> {
684        let validated_key = match mode {
685            TranscriptGraphValidationMode::FullVerify => None,
686            TranscriptGraphValidationMode::DecodeMemoized => transcript_graph_shape_key(
687                TRANSCRIPT_GRAPH_FACT_VALIDATED,
688                &self.head,
689                &self.commits,
690                &self.revisions,
691            ),
692        };
693        let already_proved = validated_key
694            .as_deref()
695            .is_some_and(transcript_graph_fact_is_memoized);
696        if !already_proved {
697            validate_transcript_history_state(self)?;
698            if let Some(key) = validated_key {
699                record_transcript_graph_fact(key);
700            }
701        }
702        self.prune_mechanical_revision_bodies();
703        Ok(())
704    }
705
706    /// The pruning half of [`Self::compact_mechanical_revision_bodies`],
707    /// without the full graph validation.
708    ///
709    /// Callable ONLY when the graph's validity is already established: pruning
710    /// drops bodies, so running it over an unvalidated graph could launder a
711    /// corrupt body out of sight. The append fast path in
712    /// `transcript_history_state_after_message_mutation` is the one caller,
713    /// and it proves the two facts that pruning needs (previously validated
714    /// graph, new head extends the previous head) before calling.
715    fn prune_mechanical_revision_bodies(&mut self) {
716        let mut retained = BTreeSet::from([self.head.clone()]);
717        for commit in &self.commits {
718            retained.insert(commit.parent_revision.clone());
719            retained.insert(commit.revision.clone());
720        }
721
722        let head_is_audited_endpoint = self
723            .commits
724            .iter()
725            .any(|commit| commit.parent_revision == self.head || commit.revision == self.head);
726        if !head_is_audited_endpoint
727            && let Some(last_commit) = self
728                .commits
729                .last()
730                .filter(|commit| commit.revision != self.head)
731            && let Some(head_body) = self
732                .revisions
733                .iter_mut()
734                .find(|body| body.revision == self.head)
735        {
736            head_body.parent_revision = Some(last_commit.revision.clone());
737        }
738
739        let mut seen = BTreeSet::new();
740        self.revisions
741            .retain(|body| retained.contains(&body.revision) && seen.insert(body.revision.clone()));
742
743        // The full graph was validated before any pruning, so corrupt bodies
744        // cannot be laundered by dropping them. The transformation changes no
745        // message, revision digest, commit, or audited endpoint: it only
746        // de-duplicates bodies by revision, removes non-endpoint mechanical
747        // bodies, and points an unaudited live head directly at the already
748        // validated latest commit. Re-hashing every retained transcript here
749        // would repeat the dominant snapshot cost without adding evidence.
750    }
751}
752
753/// Shape of a message mutation, as known by the seam that performed it.
754///
755/// The transcript-head refresh is the only consumer: an append can reuse the
756/// already-validated graph, while any other shape re-enters full validation.
757#[derive(Debug, Clone, Copy, PartialEq, Eq)]
758enum TranscriptMutationShape {
759    /// Messages were appended to the end of the live transcript; every
760    /// retained prefix is unchanged.
761    Appended,
762    /// The transcript was replaced or rewritten in place.
763    Rewritten,
764}
765
766/// Re-derive pre-0.7.14 (bookkeeping-inclusive) transcript revision strings to
767/// the current content-addressed format at the durable-format parse boundary.
768///
769/// Retained revision bodies carry their full message lists, so every legacy
770/// string can be re-verified against the bytes it was computed from. Only
771/// strings that verify under the legacy digest of their own retained body are
772/// rewritten; anything else is left untouched for the validators to reject
773/// exactly as they would have before.
774fn heal_legacy_revision_strings(
775    revisions: &mut [TranscriptRevisionBody],
776    commits: &mut [TranscriptRewriteCommit],
777    head: Option<&mut String>,
778) -> Result<(), serde_json::Error> {
779    let mut remap: BTreeMap<String, String> = BTreeMap::new();
780    for body in revisions.iter() {
781        let content = transcript_messages_digest(&body.messages)?;
782        if body.revision == content {
783            continue;
784        }
785        if body.revision == legacy_transcript_messages_digest(&body.messages)? {
786            remap.insert(body.revision.clone(), content);
787        }
788    }
789    if remap.is_empty() {
790        return Ok(());
791    }
792    for body in revisions.iter_mut() {
793        if let Some(current) = remap.get(&body.revision) {
794            body.revision = current.clone();
795        }
796        if let Some(parent) = body.parent_revision.as_ref()
797            && let Some(current) = remap.get(parent)
798        {
799            body.parent_revision = Some(current.clone());
800        }
801    }
802    for commit in commits.iter_mut() {
803        if let Some(current) = remap.get(&commit.parent_revision) {
804            commit.parent_revision = current.clone();
805        }
806        if let Some(current) = remap.get(&commit.revision) {
807            commit.revision = current.clone();
808        }
809        heal_legacy_commit_span_digests(commit, revisions)?;
810    }
811    if let Some(head) = head
812        && let Some(current) = remap.get(head.as_str())
813    {
814        *head = current.clone();
815    }
816    Ok(())
817}
818
819/// Re-derive a legacy commit's span digests from its retained bodies.
820///
821/// Span digests are only rewritten when the stored value verifies under the
822/// legacy digest of the same span; malformed commits keep their stored bytes
823/// so [`validate_transcript_rewrite_record`] rejects them unchanged.
824fn heal_legacy_commit_span_digests(
825    commit: &mut TranscriptRewriteCommit,
826    revisions: &[TranscriptRevisionBody],
827) -> Result<(), serde_json::Error> {
828    let Some(parent_body) = revisions
829        .iter()
830        .find(|body| body.revision == commit.parent_revision)
831    else {
832        return Ok(());
833    };
834    let Some(revision_body) = revisions
835        .iter()
836        .find(|body| body.revision == commit.revision)
837    else {
838        return Ok(());
839    };
840    let (start, end) = commit.selection.bounds();
841    if start > end || end > parent_body.messages.len() {
842        return Ok(());
843    }
844    let removed_len = end - start;
845    let Some(retained_len) = commit.messages_before.checked_sub(removed_len) else {
846        return Ok(());
847    };
848    let Some(replacement_len) = commit.messages_after.checked_sub(retained_len) else {
849        return Ok(());
850    };
851    let Some(replacement_end) = start.checked_add(replacement_len) else {
852        return Ok(());
853    };
854    if replacement_end > revision_body.messages.len() {
855        return Ok(());
856    }
857    let original_span = &parent_body.messages[start..end];
858    if commit.original_span_digest == legacy_transcript_messages_digest(original_span)? {
859        commit.original_span_digest = transcript_messages_digest(original_span)?;
860    }
861    let replacement_span = &revision_body.messages[start..replacement_end];
862    if commit.replacement_digest == legacy_transcript_messages_digest(replacement_span)? {
863        commit.replacement_digest = transcript_messages_digest(replacement_span)?;
864    }
865    Ok(())
866}
867
868/// Upgrade pre-semantic-field compaction records from retained typed transcript
869/// evidence, never from the free-form audit reason.
870///
871/// Old compaction commits used the generic `message_range` selection, but their
872/// revision body already carries the runtime-minted `CompactionSummary` role.
873/// A full-transcript, shrinking rewrite with exactly one such summary is the
874/// complete legacy witness. Other edits remain ordinary edits even when their
875/// display reason happens to say "compaction".
876fn heal_legacy_compaction_rewrite_semantics(
877    commits: &mut [TranscriptRewriteCommit],
878    revisions: &[TranscriptRevisionBody],
879) {
880    for commit in commits {
881        if !commit.selection.is_legacy_untyped() {
882            continue;
883        }
884        let (start, end) = commit.selection.bounds();
885        if start != 0
886            || end != commit.messages_before
887            || commit.messages_after >= commit.messages_before
888        {
889            continue;
890        }
891        let Some(parent) = revisions
892            .iter()
893            .find(|body| body.revision == commit.parent_revision)
894        else {
895            continue;
896        };
897        let Some(revision) = revisions
898            .iter()
899            .find(|body| body.revision == commit.revision)
900        else {
901            continue;
902        };
903        if parent.messages.len() != commit.messages_before
904            || revision.messages.len() != commit.messages_after
905        {
906            continue;
907        }
908        let summary_count = revision
909            .messages
910            .iter()
911            .filter(|message| {
912                matches!(message, Message::User(user) if user.transcript_role.is_compaction_summary())
913            })
914            .count();
915        if summary_count == 1 {
916            commit.selection = TranscriptRewriteSelection::migrated_legacy_compaction(start, end);
917        }
918    }
919}
920
921impl TranscriptHistoryState {
922    /// Rebuild transcript revision graph state from append-only rewrite records.
923    pub fn from_rewrite_records<I>(records: I) -> Result<Option<Self>, TranscriptEditError>
924    where
925        I: IntoIterator<Item = TranscriptRewriteRecord>,
926    {
927        let mut state: Option<Self> = None;
928        for record in records {
929            validate_transcript_rewrite_record(
930                &record.commit,
931                &record.parent_body,
932                &record.revision_body,
933            )?;
934            let state = state.get_or_insert_with(|| Self {
935                head: record.commit.parent_revision.clone(),
936                commits: Vec::new(),
937                revisions: Vec::new(),
938                digest_format: TRANSCRIPT_DIGEST_FORMAT_CURRENT,
939            });
940            if record.commit.parent_revision != state.head {
941                if revision_body_extends_head(&record.parent_body, &state.revisions, &state.head)? {
942                    state.head = record.commit.parent_revision.clone();
943                } else {
944                    return Err(TranscriptEditError::HistoryStateMalformed(format!(
945                        "rewrite record parent {} does not extend transcript head {}",
946                        record.commit.parent_revision, state.head
947                    )));
948                }
949            }
950            if !state
951                .revisions
952                .iter()
953                .any(|body| body.revision == record.parent_body.revision)
954            {
955                state.revisions.push(record.parent_body);
956            }
957            if !state
958                .revisions
959                .iter()
960                .any(|body| body.revision == record.revision_body.revision)
961            {
962                state.revisions.push(record.revision_body);
963            }
964            state.head = record.commit.revision.clone();
965            state.commits.push(record.commit);
966        }
967        Ok(state)
968    }
969}
970
971/// Invalid typed transcript edit request.
972#[derive(Debug, Clone, thiserror::Error)]
973pub enum TranscriptEditError {
974    #[error("message index {message_index} out of bounds for {message_count} messages")]
975    MessageIndexOutOfBounds {
976        message_index: usize,
977        message_count: usize,
978    },
979    #[error("{block_kind} index {block_index} out of bounds for {block_count} blocks")]
980    BlockIndexOutOfBounds {
981        block_kind: &'static str,
982        block_index: usize,
983        block_count: usize,
984    },
985    #[error("replacement expected {expected} at message index {message_index}, found {actual}")]
986    MessageRoleMismatch {
987        message_index: usize,
988        expected: &'static str,
989        actual: &'static str,
990    },
991    #[error("invalid transcript rewrite range {start}..{end} for {message_count} messages")]
992    InvalidRewriteRange {
993        start: usize,
994        end: usize,
995        message_count: usize,
996    },
997    #[error("transcript rewrite does not change transcript revision {revision}")]
998    NoOpRewrite { revision: String },
999    #[error("transcript rewrite parent revision mismatch: expected {expected}, actual {actual}")]
1000    RevisionConflict { expected: String, actual: String },
1001    #[error("transcript history state is malformed: {0}")]
1002    HistoryStateMalformed(String),
1003    #[error("invalid transcript shape after rewrite: {0}")]
1004    InvalidTranscriptShape(String),
1005}
1006
1007fn message_role_name(message: &Message) -> &'static str {
1008    match message {
1009        Message::System(_) => "system",
1010        Message::SystemNotice(_) => "system_notice",
1011        Message::User(_) => "user",
1012        Message::BlockAssistant(_) => "block_assistant",
1013        Message::ToolResults { .. } => "tool_results",
1014    }
1015}
1016
1017fn assistant_tool_use_ids(message: &Message) -> Vec<&str> {
1018    match message {
1019        Message::BlockAssistant(assistant) => assistant
1020            .blocks
1021            .iter()
1022            .filter_map(|block| match block {
1023                AssistantBlock::ToolUse { id, .. } => Some(id.as_str()),
1024                _ => None,
1025            })
1026            .collect(),
1027        _ => Vec::new(),
1028    }
1029}
1030
1031fn validate_transcript_tool_result_shape(messages: &[Message]) -> Result<(), TranscriptEditError> {
1032    for (index, message) in messages.iter().enumerate() {
1033        if let Message::ToolResults { results, .. } = message {
1034            let Some(previous) = index
1035                .checked_sub(1)
1036                .and_then(|previous| messages.get(previous))
1037            else {
1038                return Err(TranscriptEditError::InvalidTranscriptShape(format!(
1039                    "tool_results at message {index} has no preceding assistant tool-use message"
1040                )));
1041            };
1042            let expected = assistant_tool_use_ids(previous);
1043            if expected.is_empty() {
1044                return Err(TranscriptEditError::InvalidTranscriptShape(format!(
1045                    "tool_results at message {index} follows {}, not an assistant tool-use message",
1046                    message_role_name(previous)
1047                )));
1048            }
1049            let actual = results
1050                .iter()
1051                .map(|result| result.tool_use_id.as_str())
1052                .collect::<Vec<_>>();
1053            let actual_set = actual.iter().copied().collect::<BTreeSet<_>>();
1054            let expected_set = expected.iter().copied().collect::<BTreeSet<_>>();
1055            if actual.len() != actual_set.len() {
1056                return Err(TranscriptEditError::InvalidTranscriptShape(format!(
1057                    "tool_results at message {index} contains duplicate tool ids"
1058                )));
1059            }
1060            if expected.len() != expected_set.len() {
1061                return Err(TranscriptEditError::InvalidTranscriptShape(format!(
1062                    "assistant tool-use message before tool_results at message {index} contains duplicate tool ids"
1063                )));
1064            }
1065            if actual_set != expected_set {
1066                return Err(TranscriptEditError::InvalidTranscriptShape(format!(
1067                    "tool_results at message {index} resolve tool ids {actual_set:?}, expected {expected_set:?}"
1068                )));
1069            }
1070        }
1071
1072        let tool_use_ids = assistant_tool_use_ids(message);
1073        if tool_use_ids.is_empty() {
1074            continue;
1075        }
1076        let Some(next) = messages.get(index + 1) else {
1077            return Err(TranscriptEditError::InvalidTranscriptShape(format!(
1078                "assistant tool-use message {index} has no following tool_results"
1079            )));
1080        };
1081        if !matches!(next, Message::ToolResults { .. }) {
1082            return Err(TranscriptEditError::InvalidTranscriptShape(format!(
1083                "assistant tool-use message {index} is followed by {}, not tool_results",
1084                message_role_name(next)
1085            )));
1086        }
1087    }
1088    Ok(())
1089}
1090
1091fn canonicalize_digest_image_blocks(blocks: &mut [crate::types::ContentBlock]) {
1092    for block in blocks.iter_mut() {
1093        if let crate::types::ContentBlock::Image {
1094            media_type,
1095            data: crate::types::ImageData::Inline { data },
1096        } = block
1097        {
1098            // An inline image hydrates from its blob's own bytes, so its
1099            // content-addressed identity equals the blob id the store minted.
1100            let blob_id = crate::blob::content_blob_id(media_type, data);
1101            *block = crate::types::ContentBlock::Image {
1102                media_type: media_type.clone(),
1103                data: crate::types::ImageData::Blob { blob_id },
1104            };
1105        }
1106    }
1107}
1108
1109/// Canonicalize image payloads to their content-addressed blob identity so the
1110/// transcript digest is invariant to inline-vs-blob representation.
1111///
1112/// The same image hydrated inline for model execution and externalized to a
1113/// blob for persistence must share one transcript revision; otherwise a live
1114/// session and its durable snapshot would appear "diverged" purely because of
1115/// image storage form, and a runtime-backed live session would be discarded as
1116/// stale mid-turn.
1117fn canonicalize_message_images_for_digest(messages: &[Message]) -> Vec<Message> {
1118    let mut canonical = messages.to_vec();
1119    for message in &mut canonical {
1120        canonicalize_message_images_for_digest_in_place(message);
1121    }
1122    canonical
1123}
1124
1125fn canonicalize_message_images_for_digest_in_place(message: &mut Message) {
1126    match message {
1127        Message::User(user) => canonicalize_digest_image_blocks(&mut user.content),
1128        Message::ToolResults { results, .. } => {
1129            for result in results.iter_mut() {
1130                canonicalize_digest_image_blocks(&mut result.content);
1131            }
1132        }
1133        Message::SystemNotice(notice) => {
1134            for block in &mut notice.blocks {
1135                match block {
1136                    crate::types::SystemNoticeBlock::Comms { content, .. }
1137                    | crate::types::SystemNoticeBlock::ExternalEvent { content, .. } => {
1138                        canonicalize_digest_image_blocks(content);
1139                    }
1140                    _ => {}
1141                }
1142            }
1143        }
1144        _ => {}
1145    }
1146}
1147
1148/// Canonical checkpoint representation of the retained transcript graph.
1149///
1150/// Revision bodies are content-addressed by `revision`; their cached parent
1151/// pointers and construction timestamps are storage bookkeeping. The ordered
1152/// commit log remains intact because it is durable audit/selection history.
1153pub(crate) fn canonicalize_checkpoint_history_value(
1154    value: &serde_json::Value,
1155) -> Result<serde_json::Value, serde_json::Error> {
1156    let state: TranscriptHistoryState = serde_json::from_value(value.clone())?;
1157    let mut revisions = state
1158        .revisions
1159        .into_iter()
1160        .map(|body| {
1161            serde_json::json!({
1162                "revision": body.revision,
1163                "messages": canonicalize_messages_for_digest(&body.messages),
1164            })
1165        })
1166        .collect::<Vec<_>>();
1167    revisions.sort_by(|left, right| {
1168        left.get("revision")
1169            .and_then(serde_json::Value::as_str)
1170            .cmp(&right.get("revision").and_then(serde_json::Value::as_str))
1171    });
1172    Ok(serde_json::json!({
1173        "head": state.head,
1174        "commits": state.commits,
1175        "revisions": revisions,
1176    }))
1177}
1178
1179/// Cached canonical byte segments for incremental history-witness assembly.
1180///
1181/// Entries are content-addressed: body chunks are keyed by the revision
1182/// string (which IS the digest of the body's canonical messages, so a key
1183/// determines its bytes), and the commits segment is keyed by
1184/// `(count, last revision)` — an append-only audit log evolving inside one
1185/// session instance cannot repeat that pair with different earlier entries.
1186/// The debug/test cross-check plus release sampling in
1187/// [`Session::assemble_transcript_history_witness`] backstop both keying
1188/// arguments with recompute-and-compare.
1189#[derive(Debug, Default)]
1190pub(crate) struct HistoryWitnessAssemblyCache {
1191    inner: std::sync::Mutex<HistoryWitnessAssemblyCacheInner>,
1192}
1193
1194#[derive(Debug, Default, Clone)]
1195struct HistoryWitnessAssemblyCacheInner {
1196    /// Canonical bytes of the commits array, keyed by (count, last revision).
1197    commits: Option<(usize, String, std::sync::Arc<[u8]>)>,
1198    /// Canonical `{"messages":…,"revision":…}` chunk per retained body.
1199    bodies: std::collections::HashMap<String, std::sync::Arc<[u8]>>,
1200}
1201
1202impl Clone for HistoryWitnessAssemblyCache {
1203    fn clone(&self) -> Self {
1204        Self {
1205            inner: std::sync::Mutex::new(self.locked().clone()),
1206        }
1207    }
1208}
1209
1210impl HistoryWitnessAssemblyCache {
1211    fn locked(&self) -> std::sync::MutexGuard<'_, HistoryWitnessAssemblyCacheInner> {
1212        self.inner
1213            .lock()
1214            .unwrap_or_else(std::sync::PoisonError::into_inner)
1215    }
1216}
1217
1218/// Shared parsed form of the current transcript-history graph.
1219///
1220/// Guards and the per-append head refresh need the TYPED graph; parsing the
1221/// metadata value is O(graph), and a turn boundary parsed it twice (incoming
1222/// and previous) plus once more per append. The typed installer caches the
1223/// exact state it just serialized; readers share it by `Arc`. Cleared by
1224/// every write to the history key, exactly like the witness memo.
1225#[derive(Debug, Default)]
1226pub(crate) struct SharedTranscriptHistoryState {
1227    inner: std::sync::Mutex<Option<std::sync::Arc<TranscriptHistoryState>>>,
1228}
1229
1230impl Clone for SharedTranscriptHistoryState {
1231    fn clone(&self) -> Self {
1232        Self {
1233            inner: std::sync::Mutex::new(self.locked().clone()),
1234        }
1235    }
1236}
1237
1238impl SharedTranscriptHistoryState {
1239    fn locked(&self) -> std::sync::MutexGuard<'_, Option<std::sync::Arc<TranscriptHistoryState>>> {
1240        self.inner
1241            .lock()
1242            .unwrap_or_else(std::sync::PoisonError::into_inner)
1243    }
1244
1245    fn clear(&self) {
1246        *self.locked() = None;
1247    }
1248
1249    fn set(&self, state: std::sync::Arc<TranscriptHistoryState>) {
1250        *self.locked() = Some(state);
1251    }
1252
1253    fn get(&self) -> Option<std::sync::Arc<TranscriptHistoryState>> {
1254        self.locked().clone()
1255    }
1256}
1257
1258/// Canonical witness chunk of one retained revision body: the
1259/// `write_canonical_json` form of `{"messages": canonicalized, "revision": r}`
1260/// — exactly the per-element bytes `canonicalize_checkpoint_history_value`
1261/// produces for this body.
1262fn canonical_history_body_chunk(body_value: &serde_json::Value) -> Option<Vec<u8>> {
1263    let body: TranscriptRevisionBody = serde_json::from_value(body_value.clone()).ok()?;
1264    let canonical = serde_json::json!({
1265        "revision": body.revision,
1266        "messages": canonicalize_messages_for_digest(&body.messages),
1267    });
1268    let mut bytes = Vec::new();
1269    crate::checkpoint::write_canonical_json(&canonical, &mut bytes).ok()?;
1270    Some(bytes)
1271}
1272
1273fn canonicalize_checkpoint_deferred_turn_value(
1274    value: &serde_json::Value,
1275) -> Result<serde_json::Value, serde_json::Error> {
1276    let mut state: SessionDeferredTurnState = serde_json::from_value(value.clone())?;
1277    if let Some(prompt) = state.pending_initial_prompt_mut_for_blob_rewrite()
1278        && let crate::types::ContentInput::Blocks(blocks) = &mut prompt.prompt
1279    {
1280        canonicalize_digest_image_blocks(blocks);
1281    }
1282    for pending in state.pending_tool_results_mut_for_blob_rewrite() {
1283        for result in &mut pending.results {
1284            canonicalize_digest_image_blocks(&mut result.content);
1285        }
1286    }
1287    serde_json::to_value(state)
1288}
1289
1290/// Timestamp sentinel used when erasing construction bookkeeping from the
1291/// digest form. `created_at` always serializes, so a fixed value keeps the
1292/// canonical bytes deterministic.
1293fn digest_timestamp_sentinel() -> crate::types::MessageTimestamp {
1294    chrono::DateTime::<chrono::Utc>::UNIX_EPOCH
1295}
1296
1297/// Canonicalize messages to their conversational content before hashing so the
1298/// transcript revision is a content address, not a construction record.
1299///
1300/// Two normalizations compose:
1301/// - image payloads collapse to their content-addressed blob identity
1302///   ([`canonicalize_message_images_for_digest`]);
1303/// - per-construction bookkeeping is erased: [`TranscriptMessageIdentity`]
1304///   (run/interaction ids are runtime-binding atoms — a re-created authority
1305///   re-stamps them) and `created_at` timestamps. A resume that re-projects
1306///   the same conversation through a new runtime authority must digest to the
1307///   same revision as the persisted row, or the append-only save guard
1308///   strands the session on restart (fails closed with
1309///   `TranscriptContinuityViolation`).
1310///
1311/// Typed semantic facts stay in the digest — `transcript_role`,
1312/// `mutation_kind`, `render_metadata`, notice kinds and blocks — because
1313/// changing them changes the transcript's meaning.
1314fn canonicalize_messages_for_digest(messages: &[Message]) -> Vec<Message> {
1315    let mut canonical = canonicalize_message_images_for_digest(messages);
1316    for message in &mut canonical {
1317        erase_message_construction_bookkeeping(message);
1318    }
1319    canonical
1320}
1321
1322fn erase_message_construction_bookkeeping(message: &mut Message) {
1323    match message {
1324        Message::System(system) => {
1325            system.created_at = digest_timestamp_sentinel();
1326        }
1327        Message::SystemNotice(notice) => {
1328            notice.created_at = digest_timestamp_sentinel();
1329        }
1330        Message::User(user) => {
1331            user.identity = crate::types::TranscriptMessageIdentity::default();
1332            user.created_at = digest_timestamp_sentinel();
1333        }
1334        Message::BlockAssistant(assistant) => {
1335            assistant.identity = crate::types::TranscriptMessageIdentity::default();
1336            assistant.created_at = digest_timestamp_sentinel();
1337        }
1338        Message::ToolResults { created_at, .. } => {
1339            *created_at = digest_timestamp_sentinel();
1340        }
1341    }
1342}
1343
1344/// Per-message projection of [`canonicalize_messages_for_digest`].
1345///
1346/// Transcript canonicalization is element-wise, so the identity byte stream a
1347/// transcript digest hashes is `"[" + json(c(m0)) + "," + json(c(m1)) + ... +
1348/// "]"`. [`digest_accumulator`] folds exactly these per-message bytes, which
1349/// is why an incremental midstate reproduces the format-2 digest value
1350/// unchanged. `canonicalize_messages_for_digest_is_element_wise` pins the
1351/// equivalence.
1352pub(crate) fn canonicalize_message_for_digest(message: &Message) -> Message {
1353    let mut canonical = message.clone();
1354    canonicalize_message_images_for_digest_in_place(&mut canonical);
1355    erase_message_construction_bookkeeping(&mut canonical);
1356    canonical
1357}
1358
1359pub fn transcript_messages_digest(messages: &[Message]) -> Result<String, serde_json::Error> {
1360    sha256_json_digest(&canonicalize_messages_for_digest(messages))
1361}
1362
1363/// Full transcript digest that does NOT bump the content-digest budget
1364/// counter.
1365///
1366/// Reserved for the debug-build witness cross-check: that recompute is
1367/// verification scaffolding, not production work, so counting it would make
1368/// the digest-budget regression tests measure the cross-check instead of the
1369/// path being budgeted.
1370pub(crate) fn transcript_messages_digest_uncounted(
1371    messages: &[Message],
1372) -> Result<String, serde_json::Error> {
1373    let canonical = canonicalize_messages_for_digest(messages);
1374    let bytes = serde_json::to_vec(&canonical)?;
1375    Ok(format!("sha256:{:x}", Sha256::digest(bytes)))
1376}
1377
1378/// Digest format used by pre-0.7.14 transcript revision strings.
1379///
1380/// The legacy canonicalization only normalized image payloads, so persisted
1381/// revision strings from older stores include construction bookkeeping
1382/// (`identity`, `created_at`). This is a durable-format decoder: it exists
1383/// solely so [`heal_legacy_revision_strings`] can verify a stored string
1384/// against its retained body before re-deriving it to the current
1385/// content-addressed format. Never mint new revisions with it.
1386fn legacy_transcript_messages_digest(messages: &[Message]) -> Result<String, serde_json::Error> {
1387    sha256_json_digest(&canonicalize_message_images_for_digest(messages))
1388}
1389
1390fn validate_transcript_rewrite_record(
1391    commit: &TranscriptRewriteCommit,
1392    parent_body: &TranscriptRevisionBody,
1393    revision_body: &TranscriptRevisionBody,
1394) -> Result<(), TranscriptEditError> {
1395    if parent_body.revision != commit.parent_revision {
1396        return Err(TranscriptEditError::HistoryStateMalformed(format!(
1397            "parent body revision {} does not match commit parent {}",
1398            parent_body.revision, commit.parent_revision
1399        )));
1400    }
1401    if revision_body.revision != commit.revision {
1402        return Err(TranscriptEditError::HistoryStateMalformed(format!(
1403            "revision body {} does not match commit revision {}",
1404            revision_body.revision, commit.revision
1405        )));
1406    }
1407    if commit.parent_revision == commit.revision {
1408        return Err(TranscriptEditError::NoOpRewrite {
1409            revision: commit.revision.clone(),
1410        });
1411    }
1412    let parent_digest = transcript_messages_digest(&parent_body.messages)
1413        .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
1414    if parent_digest != commit.parent_revision {
1415        return Err(TranscriptEditError::HistoryStateMalformed(format!(
1416            "parent body digest {parent_digest} does not match commit parent {}",
1417            commit.parent_revision
1418        )));
1419    }
1420    let revision_digest = transcript_messages_digest(&revision_body.messages)
1421        .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
1422    if revision_digest != commit.revision {
1423        return Err(TranscriptEditError::HistoryStateMalformed(format!(
1424            "revision body digest {revision_digest} does not match commit revision {}",
1425            commit.revision
1426        )));
1427    }
1428    let (start, end) = commit.selection.bounds();
1429    if start > end || end > parent_body.messages.len() {
1430        return Err(TranscriptEditError::InvalidRewriteRange {
1431            start,
1432            end,
1433            message_count: parent_body.messages.len(),
1434        });
1435    }
1436    if commit.messages_before != parent_body.messages.len()
1437        || commit.messages_after != revision_body.messages.len()
1438    {
1439        return Err(TranscriptEditError::HistoryStateMalformed(format!(
1440            "commit message counts {} -> {} do not match revision bodies {} -> {}",
1441            commit.messages_before,
1442            commit.messages_after,
1443            parent_body.messages.len(),
1444            revision_body.messages.len()
1445        )));
1446    }
1447    let original_span_digest = transcript_messages_digest(&parent_body.messages[start..end])
1448        .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
1449    if original_span_digest != commit.original_span_digest {
1450        return Err(TranscriptEditError::HistoryStateMalformed(format!(
1451            "original span digest {original_span_digest} does not match commit digest {}",
1452            commit.original_span_digest
1453        )));
1454    }
1455    let removed_len = end - start;
1456    let retained_len = commit
1457        .messages_before
1458        .checked_sub(removed_len)
1459        .ok_or_else(|| {
1460            TranscriptEditError::HistoryStateMalformed(
1461                "commit removed more messages than it recorded before rewrite".to_string(),
1462            )
1463        })?;
1464    let replacement_len = commit
1465        .messages_after
1466        .checked_sub(retained_len)
1467        .ok_or_else(|| {
1468            TranscriptEditError::HistoryStateMalformed(
1469                "commit message counts cannot describe a replacement span".to_string(),
1470            )
1471        })?;
1472    let replacement_end = start.checked_add(replacement_len).ok_or_else(|| {
1473        TranscriptEditError::HistoryStateMalformed("replacement span end overflowed".to_string())
1474    })?;
1475    if replacement_end > revision_body.messages.len() {
1476        return Err(TranscriptEditError::InvalidRewriteRange {
1477            start,
1478            end: replacement_end,
1479            message_count: revision_body.messages.len(),
1480        });
1481    }
1482    if commit.selection.semantic() == TranscriptRewriteSemantic::Compaction {
1483        let summary_count = revision_body.messages[start..replacement_end]
1484            .iter()
1485            .filter(|message| {
1486                matches!(message, Message::User(user) if user.transcript_role.is_compaction_summary())
1487            })
1488            .count();
1489        if start != 0
1490            || end != commit.messages_before
1491            || commit.messages_after >= commit.messages_before
1492            || summary_count != 1
1493        {
1494            return Err(TranscriptEditError::HistoryStateMalformed(
1495                "typed compaction rewrite must shrink the full transcript and carry exactly one CompactionSummary"
1496                    .to_string(),
1497            ));
1498        }
1499    }
1500    let parent_prefix_digest = transcript_messages_digest(&parent_body.messages[..start])
1501        .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
1502    let revision_prefix_digest = transcript_messages_digest(&revision_body.messages[..start])
1503        .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
1504    if parent_prefix_digest != revision_prefix_digest {
1505        return Err(TranscriptEditError::HistoryStateMalformed(
1506            "rewrite revision changed messages before the selected span".to_string(),
1507        ));
1508    }
1509    let parent_suffix_digest = transcript_messages_digest(&parent_body.messages[end..])
1510        .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
1511    let revision_suffix_digest =
1512        transcript_messages_digest(&revision_body.messages[replacement_end..])
1513            .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
1514    if parent_suffix_digest != revision_suffix_digest {
1515        return Err(TranscriptEditError::HistoryStateMalformed(
1516            "rewrite revision changed messages after the selected span".to_string(),
1517        ));
1518    }
1519    let replacement_digest =
1520        transcript_messages_digest(&revision_body.messages[start..replacement_end])
1521            .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
1522    if replacement_digest != commit.replacement_digest {
1523        return Err(TranscriptEditError::HistoryStateMalformed(format!(
1524            "replacement span digest {replacement_digest} does not match commit digest {}",
1525            commit.replacement_digest
1526        )));
1527    }
1528    Ok(())
1529}
1530
1531pub(crate) fn validate_transcript_history_state(
1532    state: &TranscriptHistoryState,
1533) -> Result<(), TranscriptEditError> {
1534    if state
1535        .revisions
1536        .iter()
1537        .all(|body| body.revision != state.head)
1538    {
1539        return Err(TranscriptEditError::HistoryStateMalformed(format!(
1540            "missing transcript head body {}",
1541            state.head
1542        )));
1543    }
1544    for body in &state.revisions {
1545        let digest = transcript_messages_digest(&body.messages)
1546            .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
1547        if digest != body.revision {
1548            return Err(TranscriptEditError::HistoryStateMalformed(format!(
1549                "transcript revision body {} has digest {digest}",
1550                body.revision
1551            )));
1552        }
1553    }
1554    for commit in &state.commits {
1555        let parent_body = state
1556            .revisions
1557            .iter()
1558            .find(|body| body.revision == commit.parent_revision)
1559            .ok_or_else(|| {
1560                TranscriptEditError::HistoryStateMalformed(format!(
1561                    "missing parent transcript body {}",
1562                    commit.parent_revision
1563                ))
1564            })?;
1565        let revision_body = state
1566            .revisions
1567            .iter()
1568            .find(|body| body.revision == commit.revision)
1569            .ok_or_else(|| {
1570                TranscriptEditError::HistoryStateMalformed(format!(
1571                    "missing transcript revision body {}",
1572                    commit.revision
1573                ))
1574            })?;
1575        validate_transcript_rewrite_record(commit, parent_body, revision_body)?;
1576    }
1577    let Some(first_commit) = state.commits.first() else {
1578        return Ok(());
1579    };
1580    let mut expected_head = first_commit.parent_revision.clone();
1581    for commit in &state.commits {
1582        let parent_body = state
1583            .revisions
1584            .iter()
1585            .find(|body| body.revision == commit.parent_revision)
1586            .ok_or_else(|| {
1587                TranscriptEditError::HistoryStateMalformed(format!(
1588                    "missing parent transcript body {}",
1589                    commit.parent_revision
1590                ))
1591            })?;
1592        if commit.parent_revision != expected_head
1593            && !revision_body_extends_head(parent_body, &state.revisions, &expected_head)?
1594        {
1595            return Err(TranscriptEditError::HistoryStateMalformed(format!(
1596                "rewrite commit parent {} does not extend transcript head {}",
1597                commit.parent_revision, expected_head
1598            )));
1599        }
1600        expected_head = commit.revision.clone();
1601    }
1602    let head_is_audited_endpoint = state
1603        .commits
1604        .iter()
1605        .any(|commit| commit.parent_revision == state.head || commit.revision == state.head);
1606    let head_extends_latest_commit = if head_is_audited_endpoint {
1607        let Some(head_body) = state
1608            .revisions
1609            .iter()
1610            .find(|body| body.revision == state.head)
1611        else {
1612            return Err(TranscriptEditError::HistoryStateMalformed(format!(
1613                "missing transcript head body {}",
1614                state.head
1615            )));
1616        };
1617        revision_body_extends_head(head_body, &state.revisions, &expected_head)?
1618    } else {
1619        let mut cursor = state.head.as_str();
1620        let mut visited = BTreeSet::new();
1621        while cursor != expected_head {
1622            if !visited.insert(cursor.to_string()) {
1623                break;
1624            }
1625            let Some(head_body) = state.revisions.iter().find(|body| body.revision == cursor)
1626            else {
1627                break;
1628            };
1629            let Some(parent) = head_body.parent_revision.as_deref() else {
1630                break;
1631            };
1632            cursor = parent;
1633        }
1634        cursor == expected_head
1635    };
1636    if !head_extends_latest_commit {
1637        return Err(TranscriptEditError::HistoryStateMalformed(format!(
1638            "transcript head {} does not extend the rewrite chain",
1639            state.head
1640        )));
1641    }
1642    Ok(())
1643}
1644
1645fn revision_body_extends_head(
1646    candidate: &TranscriptRevisionBody,
1647    revisions: &[TranscriptRevisionBody],
1648    head: &str,
1649) -> Result<bool, TranscriptEditError> {
1650    let Some(head_body) = revisions.iter().find(|body| body.revision == head) else {
1651        return Ok(false);
1652    };
1653    if candidate.revision == head {
1654        return Ok(true);
1655    }
1656    if candidate.messages.len() < head_body.messages.len() {
1657        return Ok(false);
1658    }
1659    let prefix_digest = transcript_messages_digest(&candidate.messages[..head_body.messages.len()])
1660        .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
1661    if prefix_digest == head {
1662        return Ok(true);
1663    }
1664
1665    // A resume-time system refresh may replace the single leading System
1666    // projection while preserving (and possibly appending to) the exact
1667    // conversation tail. Prove that content shape directly; a historical
1668    // parent_revision pointer is not occurrence identity and must never, by
1669    // itself, authorize a later commit after a digest has recurred.
1670    let (Some(Message::System(_)), Some(Message::System(_))) =
1671        (candidate.messages.first(), head_body.messages.first())
1672    else {
1673        return Ok(false);
1674    };
1675    let head_tail_len = head_body.messages.len().saturating_sub(1);
1676    if head_tail_len == 0 {
1677        return Ok(true);
1678    }
1679    let candidate_tail = &candidate.messages[1..];
1680    if candidate_tail.len() < head_tail_len {
1681        return Ok(false);
1682    }
1683    let head_tail_digest = transcript_messages_digest(&head_body.messages[1..])
1684        .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
1685    let candidate_tail_prefix_digest = transcript_messages_digest(&candidate_tail[..head_tail_len])
1686        .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
1687    Ok(candidate_tail_prefix_digest == head_tail_digest)
1688}
1689
1690use digest_accumulator::take_verification_sample as digest_accumulator_take_verification_sample;
1691
1692fn sha256_json_digest<T: Serialize + ?Sized>(value: &T) -> Result<String, serde_json::Error> {
1693    crate::checkpoint::record_content_digest_computation();
1694    let bytes = serde_json::to_vec(value)?;
1695    crate::checkpoint::record_content_digest_bytes(bytes.len() as u64);
1696    let digest = Sha256::digest(bytes);
1697    let mut out = String::with_capacity(digest.len() * 2);
1698    const HEX: &[u8; 16] = b"0123456789abcdef";
1699    for byte in digest {
1700        out.push(HEX[(byte >> 4) as usize] as char);
1701        out.push(HEX[(byte & 0x0f) as usize] as char);
1702    }
1703    Ok(format!("sha256:{out}"))
1704}
1705
1706/// A conversation session with full history
1707///
1708/// Uses Arc<Vec<Message>> internally for efficient forking (copy-on-write).
1709/// Process-local derived caches for the transcript-history graph.
1710///
1711/// Grouped behind ONE pointer deliberately. `Session` is embedded throughout
1712/// the agent's nested async state machine, whose futures compose sizes
1713/// additively, so every inline byte here is paid again at each spawn depth —
1714/// and the CLI's full-tools spawn runs against a literal 2 MB production stack
1715/// budget, pinned by
1716/// `tools_full_with_explicit_auth_binding_can_spawn_within_production_stack_budget`.
1717/// Holding these three inline grew `Session` from 136 to 528 bytes and
1718/// overflowed that stack. None is persisted or part of a session's identity;
1719/// all are rebuildable from the metadata graph.
1720#[derive(Debug, Default, Clone)]
1721pub(crate) struct SessionHistoryCaches {
1722    /// Memoized canonical witness of the transcript-history graph.
1723    witness: std::sync::OnceLock<String>,
1724    /// Content-addressed canonical chunks for incremental witness assembly.
1725    assembly: HistoryWitnessAssemblyCache,
1726    /// Shared parsed form of the current history graph.
1727    shared_state: SharedTranscriptHistoryState,
1728}
1729
1730#[derive(Debug, Clone)]
1731pub struct Session {
1732    /// Persisted envelope format version, validated fail-closed on read by
1733    /// the generated persistence version authority.
1734    version: u32,
1735    /// Unique identifier
1736    id: SessionId,
1737    /// All messages in order (Arc for CoW on fork) plus the incremental
1738    /// transcript-digest accumulator that owns them.
1739    ///
1740    /// The buffer is deliberately wrapped: [`TranscriptMessages`] exposes no
1741    /// `DerefMut`, so every message mutation must name one of its typed
1742    /// mutators, and each mutator states whether the retained digest midstate
1743    /// survives. That makes the accumulator's invalidation set exhaustive by
1744    /// construction instead of by convention.
1745    pub(crate) messages: TranscriptMessages,
1746    /// When the session was created
1747    created_at: SystemTime,
1748    /// When the session was last updated
1749    updated_at: SystemTime,
1750    /// Arbitrary metadata
1751    metadata: serde_json::Map<String, serde_json::Value>,
1752    /// Memoized canonical witness of the transcript-history graph currently
1753    /// under [`SESSION_TRANSCRIPT_HISTORY_STATE_KEY`].
1754    ///
1755    /// Deriving it canonicalizes and hashes every retained revision body, and
1756    /// it is derived up to four times per save boundary (head projection,
1757    /// stamp mint, stamp install, intra-turn projection) from the same
1758    /// unchanged graph. This is pure derived state: the three methods that
1759    /// write the history key clear it, so it can never outlive the value it
1760    /// describes.
1761    history_caches: Box<SessionHistoryCaches>,
1762    /// Cached canonical byte segments of the transcript-history graph
1763    /// (commits segment, per-retained-body chunks) for incremental witness
1764    /// assembly. Content-addressed; survives head-only append updates that
1765    /// clear the witness memo above.
1766    /// Shared parsed form of the current history graph; see
1767    /// [`SharedTranscriptHistoryState`].
1768    /// Whether transcript-history metadata has already crossed a validating,
1769    /// compacting authority boundary in this in-memory session.
1770    ///
1771    /// This is derived cache state only, never persisted authority. Typed
1772    /// transcript mutations install validated state; deserialization validates
1773    /// before setting it. Any unchecked history mutation invalidates the cache
1774    /// so serialization retains the fail-closed corrupt-snapshot contract.
1775    transcript_history_metadata_validation: TranscriptHistoryMetadataValidation,
1776    /// Cumulative token usage across all LLM calls in this session
1777    usage: Usage,
1778}
1779
1780#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1781enum TranscriptHistoryMetadataValidation {
1782    Validated,
1783    RequiresValidation,
1784}
1785
1786/// Serde helper for Session serialization (flattens Arc)
1787#[derive(Deserialize)]
1788#[serde(rename_all = "snake_case")]
1789struct SessionSerde {
1790    version: u32,
1791    id: SessionId,
1792    messages: Vec<Message>,
1793    created_at: SystemTime,
1794    updated_at: SystemTime,
1795    #[serde(default)]
1796    metadata: serde_json::Map<String, serde_json::Value>,
1797    #[serde(default)]
1798    usage: Usage,
1799}
1800
1801/// Borrowed serialization view for Session. The persisted shape deliberately
1802/// stays lockstep with `SessionSerde`, but large transcripts and metadata are
1803/// streamed directly instead of being deep-cloned before serde sees them.
1804#[derive(Serialize)]
1805#[serde(rename_all = "snake_case")]
1806struct SessionSerdeRef<'a> {
1807    version: u32,
1808    id: &'a SessionId,
1809    messages: &'a [Message],
1810    created_at: &'a SystemTime,
1811    updated_at: &'a SystemTime,
1812    metadata: &'a serde_json::Map<String, serde_json::Value>,
1813    usage: &'a Usage,
1814}
1815
1816impl Serialize for Session {
1817    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1818    where
1819        S: Serializer,
1820    {
1821        let compacted_metadata = if self.transcript_history_metadata_validation
1822            == TranscriptHistoryMetadataValidation::RequiresValidation
1823        {
1824            let mut metadata = self.metadata.clone();
1825            compact_transcript_history_metadata_for_snapshot(
1826                &mut metadata,
1827                TranscriptGraphValidationMode::FullVerify,
1828            )
1829            .map_err(<S::Error as serde::ser::Error>::custom)?;
1830            Some(metadata)
1831        } else {
1832            None
1833        };
1834        let metadata = compacted_metadata.as_ref().unwrap_or(&self.metadata);
1835        let serde_repr = SessionSerdeRef {
1836            version: self.version,
1837            id: &self.id,
1838            messages: self.messages(),
1839            created_at: &self.created_at,
1840            updated_at: &self.updated_at,
1841            metadata,
1842            usage: &self.usage,
1843        };
1844        serde_repr.serialize(serializer)
1845    }
1846}
1847
1848fn compact_transcript_history_metadata_for_snapshot(
1849    metadata: &mut serde_json::Map<String, serde_json::Value>,
1850    mode: TranscriptGraphValidationMode,
1851) -> Result<(), String> {
1852    let Some(value) = metadata.remove(SESSION_TRANSCRIPT_HISTORY_STATE_KEY) else {
1853        return Ok(());
1854    };
1855    let mut state: TranscriptHistoryState =
1856        serde_json::from_value(value).map_err(|error| error.to_string())?;
1857    state
1858        .compact_mechanical_revision_bodies_for(mode)
1859        .map_err(|error| error.to_string())?;
1860    metadata.insert(
1861        SESSION_TRANSCRIPT_HISTORY_STATE_KEY.to_string(),
1862        serde_json::to_value(state).map_err(|error| error.to_string())?,
1863    );
1864    Ok(())
1865}
1866
1867impl<'de> Deserialize<'de> for Session {
1868    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1869    where
1870        D: Deserializer<'de>,
1871    {
1872        let serde_repr = SessionSerde::deserialize(deserializer)?;
1873        let version = session_persistence_version_authority::restore_session_envelope_version(
1874            serde_repr.version,
1875        )
1876        .map_err(<D::Error as serde::de::Error>::custom)?;
1877        let mut metadata = serde_repr.metadata;
1878        // Durable-document decode seam: repeat decodes of an unchanged graph
1879        // shape skip the per-body digest re-verification via the bounded
1880        // process-lifetime decode memo (first sight still verifies fully).
1881        compact_transcript_history_metadata_for_snapshot(
1882            &mut metadata,
1883            TranscriptGraphValidationMode::DecodeMemoized,
1884        )
1885        .map_err(<D::Error as serde::de::Error>::custom)?;
1886        Ok(Session {
1887            version,
1888            id: serde_repr.id,
1889            messages: TranscriptMessages::from_vec(serde_repr.messages),
1890            created_at: serde_repr.created_at,
1891            updated_at: serde_repr.updated_at,
1892            metadata,
1893            history_caches: Box::default(),
1894            transcript_history_metadata_validation: TranscriptHistoryMetadataValidation::Validated,
1895            usage: serde_repr.usage,
1896        })
1897    }
1898}
1899
1900/// Serde helper for the metadata-only partial decode of a persisted session
1901/// envelope.
1902///
1903/// LOCKSTEP with [`SessionSerde`]: this struct must decode exactly the field
1904/// names and serde shapes that `SessionSerde` persists for `version`, `id`,
1905/// and `metadata` (`rename_all = "snake_case"`, `#[serde(default)]` on
1906/// `metadata`). The `session_metadata_document_lockstep_with_full_envelope`
1907/// pin test fails if the two drift.
1908#[derive(Deserialize)]
1909#[serde(rename_all = "snake_case")]
1910struct SessionMetadataDocumentSerde {
1911    version: u32,
1912    id: SessionId,
1913    #[serde(default)]
1914    metadata: serde_json::Map<String, serde_json::Value>,
1915}
1916
1917/// Metadata-only projection of a persisted session envelope.
1918///
1919/// Produced by [`session_metadata_document_from_slice`] without materializing
1920/// the transcript. Exposes ONLY the two session-authority facts the metadata
1921/// read seam is allowed to observe ([`SESSION_METADATA_KEY`] and
1922/// [`SESSION_LIFECYCLE_TERMINAL_KEY`]) — deliberately no raw metadata-map
1923/// accessor, so the partial decode can never grow into an untyped side
1924/// channel around [`Session`]'s authority-gated reads.
1925#[derive(Debug, Clone)]
1926pub struct SessionMetadataDocument {
1927    session_id: SessionId,
1928    metadata: serde_json::Map<String, serde_json::Value>,
1929}
1930
1931impl SessionMetadataDocument {
1932    /// Session identity carried by the envelope.
1933    pub fn session_id(&self) -> &SessionId {
1934        &self.session_id
1935    }
1936
1937    /// Raw projected [`SESSION_METADATA_KEY`] value, for divergence
1938    /// comparison against another projection of the same fact.
1939    pub fn session_metadata_value(&self) -> Option<&serde_json::Value> {
1940        self.metadata.get(SESSION_METADATA_KEY)
1941    }
1942
1943    /// Raw projected [`SESSION_LIFECYCLE_TERMINAL_KEY`] value, for divergence
1944    /// comparison against another projection of the same fact.
1945    pub fn lifecycle_terminal_value(&self) -> Option<&serde_json::Value> {
1946        self.metadata.get(SESSION_LIFECYCLE_TERMINAL_KEY)
1947    }
1948
1949    /// Decode typed checkpoint metadata without materializing the transcript.
1950    ///
1951    /// This validates schema and session identity and preserves explicit
1952    /// legacy-unverified state. Digest verification still requires the full
1953    /// document through [`Session::try_checkpoint_state`].
1954    pub fn try_checkpoint_metadata_state(
1955        &self,
1956    ) -> Result<
1957        crate::checkpoint::SessionCheckpointMetadataState,
1958        crate::checkpoint::SessionCheckpointError,
1959    > {
1960        crate::checkpoint::session_checkpoint_metadata_state(&self.session_id, &self.metadata)
1961    }
1962
1963    /// Decode the typed metadata view through the canonical map-level
1964    /// decoders, failing closed on corrupt values.
1965    pub fn try_into_view(self) -> Result<PersistedSessionMetadataView, serde_json::Error> {
1966        PersistedSessionMetadataView::try_from_metadata_map(self.session_id, &self.metadata)
1967    }
1968}
1969
1970/// Partially decode a persisted session envelope into its metadata-only
1971/// document, without materializing the transcript.
1972///
1973/// Fail-closed on the envelope format version through the generated
1974/// persistence version authority — exactly like the full [`Session`]
1975/// deserializer.
1976pub fn session_metadata_document_from_slice(
1977    bytes: &[u8],
1978) -> Result<SessionMetadataDocument, serde_json::Error> {
1979    let serde_repr: SessionMetadataDocumentSerde = serde_json::from_slice(bytes)?;
1980    session_persistence_version_authority::restore_session_envelope_version(serde_repr.version)
1981        .map_err(<serde_json::Error as serde::de::Error>::custom)?;
1982    Ok(SessionMetadataDocument {
1983        session_id: serde_repr.id,
1984        metadata: serde_repr.metadata,
1985    })
1986}
1987
1988impl Session {
1989    /// Rebuild a slim `Session` from persisted head-row parts.
1990    ///
1991    /// Used by [`crate::session_store::SessionHead::into_session`] to
1992    /// materialize a session from an incremental store's head row plus its
1993    /// strand messages. The envelope version is restored fail-closed through
1994    /// the generated persistence version authority, exactly like
1995    /// [`Session::deserialize`].
1996    pub(crate) fn from_head_parts(
1997        version: u32,
1998        id: SessionId,
1999        messages: Vec<Message>,
2000        created_at: SystemTime,
2001        updated_at: SystemTime,
2002        metadata: serde_json::Map<String, serde_json::Value>,
2003        usage: Usage,
2004    ) -> Result<Self, String> {
2005        let version =
2006            session_persistence_version_authority::restore_session_envelope_version(version)
2007                .map_err(|err| err.to_string())?;
2008        Ok(Self {
2009            version,
2010            id,
2011            messages: TranscriptMessages::from_vec(messages),
2012            created_at,
2013            updated_at,
2014            transcript_history_metadata_validation: if metadata
2015                .contains_key(SESSION_TRANSCRIPT_HISTORY_STATE_KEY)
2016            {
2017                TranscriptHistoryMetadataValidation::RequiresValidation
2018            } else {
2019                TranscriptHistoryMetadataValidation::Validated
2020            },
2021            metadata,
2022            history_caches: Box::default(),
2023            usage,
2024        })
2025    }
2026
2027    /// Build the canonical, storage-representation-invariant document used by
2028    /// the typed checkpoint digest.
2029    pub(crate) fn checkpoint_digest_document(
2030        &self,
2031    ) -> Result<serde_json::Value, serde_json::Error> {
2032        let messages = canonicalize_messages_for_digest(self.messages());
2033        let mut metadata = self.metadata.clone();
2034        if self.transcript_history_metadata_validation
2035            == TranscriptHistoryMetadataValidation::RequiresValidation
2036        {
2037            compact_transcript_history_metadata_for_snapshot(
2038                &mut metadata,
2039                TranscriptGraphValidationMode::FullVerify,
2040            )
2041            .map_err(<serde_json::Error as serde::ser::Error>::custom)?;
2042        }
2043        if let Some(history) = metadata.get_mut(SESSION_TRANSCRIPT_HISTORY_STATE_KEY) {
2044            *history = canonicalize_checkpoint_history_value(history)?;
2045        }
2046        if let Some(deferred) = metadata.get_mut(SESSION_DEFERRED_TURN_STATE_KEY) {
2047            *deferred = canonicalize_checkpoint_deferred_turn_value(deferred)?;
2048        }
2049        serde_json::to_value(SessionSerdeRef {
2050            version: self.version,
2051            id: &self.id,
2052            messages: &messages,
2053            created_at: &self.created_at,
2054            updated_at: &self.updated_at,
2055            metadata: &metadata,
2056            usage: &self.usage,
2057        })
2058    }
2059}
2060
2061/// Metadata key used to store durable system-context control state.
2062pub const SESSION_SYSTEM_CONTEXT_STATE_KEY: &str = "session_system_context_state";
2063
2064/// Metadata key used to store deferred-turn control state.
2065pub const SESSION_DEFERRED_TURN_STATE_KEY: &str = "session_deferred_turn_state";
2066
2067/// Metadata key for a mixed local/external callback batch whose completed
2068/// sibling outcomes must remain hidden until the external callback result can
2069/// complete the provider-adjacent `ToolResults` set.
2070pub(crate) const SESSION_PENDING_CALLBACK_BATCH_KEY: &str = "session_pending_callback_batch_v1";
2071
2072/// Metadata key used to store recoverable build-only session state.
2073pub const SESSION_BUILD_STATE_KEY: &str = "session_build_state";
2074
2075/// Metadata key used to store durable session-local tool visibility intent.
2076pub const SESSION_TOOL_VISIBILITY_STATE_KEY: &str = "session_tool_visibility_state_v1";
2077
2078/// Metadata key used to store the typed session lifecycle-terminal fact.
2079pub const SESSION_LIFECYCLE_TERMINAL_KEY: &str = "session_lifecycle_terminal";
2080
2081/// Single canonical metadata key for the typed session checkpoint stamp.
2082pub const SESSION_CHECKPOINT_STAMP_KEY: &str = "session_checkpoint_stamp_v1";
2083
2084/// Legacy compatibility marker for a session-store row written by the
2085/// pre-typed intra-turn checkpointer.
2086///
2087/// This Boolean is decoded only as explicit legacy-unverified evidence. It
2088/// never grants rollback authority; typed writers and recovery use the exact
2089/// [`crate::checkpoint::SessionCheckpointStamp`] instead.
2090pub const SESSION_RUNTIME_CHECKPOINT_PROVENANCE_KEY: &str =
2091    "session_runtime_checkpoint_provenance_v1";
2092
2093/// Canonical tool name gated by `image_tool_results` capability.
2094pub const VIEW_IMAGE_TOOL_NAME: &str = "view_image";
2095
2096/// Canonical separator between appended runtime system-context blocks.
2097pub const SYSTEM_CONTEXT_SEPARATOR: &str = "\n\n---\n\n";
2098
2099#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
2100#[error("metadata key `{key}` is reserved for session authority")]
2101pub struct ReservedSessionMetadataKey {
2102    key: String,
2103}
2104
2105impl ReservedSessionMetadataKey {
2106    fn new(key: &str) -> Self {
2107        Self {
2108            key: key.to_string(),
2109        }
2110    }
2111}
2112
2113fn is_session_authority_metadata_key(key: &str) -> bool {
2114    // Single reserved-key authority: the typed classifier owns the
2115    // session-authority key set (the `session_*` state constants).
2116    crate::surface_metadata::ReservedMetadataKey::is_session_authority(key)
2117}
2118
2119#[allow(clippy::panic)]
2120fn fail_closed_generated_restore(authority: &'static str, err: serde_json::Error) -> ! {
2121    tracing::error!(
2122        authority,
2123        error = %err,
2124        "generated authority rejected durable restore"
2125    );
2126    panic!("generated {authority} authority rejected durable restore: {err}");
2127}
2128
2129/// Shared runtime system-context authority handle.
2130///
2131/// This handle is intentionally narrower than `Arc<Mutex<SessionSystemContextState>>`:
2132/// callers can read snapshots or request generated-authority transitions, but
2133/// cannot replace the machine-owned state by taking a mutable guard.
2134#[derive(Clone)]
2135pub struct SystemContextStateHandle {
2136    inner: Arc<std::sync::Mutex<SessionSystemContextState>>,
2137    boundary: Arc<SystemContextBoundaryCoordinator>,
2138}
2139
2140struct SystemContextBoundaryCoordinator {
2141    incarnation_id: uuid::Uuid,
2142    lifecycle: std::sync::Mutex<SystemContextBoundaryLifecycle>,
2143    notify: tokio::sync::Notify,
2144}
2145
2146struct SystemContextBoundaryLifecycle {
2147    actor_live: bool,
2148    next_generation: u64,
2149    next_request_id: u64,
2150    window: SystemContextBoundaryWindow,
2151}
2152
2153enum SystemContextBoundaryWindow {
2154    Closed,
2155    Open {
2156        run_id: RunId,
2157        generation: u64,
2158        request: Option<RegisteredSystemContextBoundaryRequest>,
2159    },
2160    Parked {
2161        run_id: RunId,
2162        generation: u64,
2163        request_id: u64,
2164        candidate_state: SessionSystemContextState,
2165    },
2166    Resolved {
2167        run_id: RunId,
2168        generation: u64,
2169        request_id: u64,
2170        resolution: SystemContextBoundaryResolution,
2171    },
2172    /// The external prepare authority has resolved (or runner-first won), and
2173    /// the runner is preprocessing the exact request immediately before the
2174    /// model call. Canonical pending state remains unapplied until the runner
2175    /// consumes this witness synchronously at that final call seam.
2176    Consuming {
2177        run_id: RunId,
2178        generation: u64,
2179        request_id: Option<u64>,
2180    },
2181}
2182
2183struct RegisteredSystemContextBoundaryRequest {
2184    request_id: u64,
2185    appends: Vec<(AppendSystemContextRequest, SystemTime)>,
2186}
2187
2188#[derive(Clone)]
2189enum SystemContextBoundaryResolution {
2190    Committed,
2191    Aborted,
2192    Failed(CoreBoundaryStageError),
2193}
2194
2195impl Default for SystemContextBoundaryCoordinator {
2196    fn default() -> Self {
2197        Self {
2198            incarnation_id: uuid::Uuid::new_v4(),
2199            lifecycle: std::sync::Mutex::new(SystemContextBoundaryLifecycle {
2200                actor_live: true,
2201                next_generation: 0,
2202                next_request_id: 0,
2203                window: SystemContextBoundaryWindow::Closed,
2204            }),
2205            notify: tokio::sync::Notify::new(),
2206        }
2207    }
2208}
2209
2210impl SystemContextBoundaryCoordinator {
2211    fn lock(&self) -> std::sync::MutexGuard<'_, SystemContextBoundaryLifecycle> {
2212        self.lifecycle.lock().unwrap_or_else(|poisoned| {
2213            tracing::warn!(
2214                "system-context boundary coordinator lock poisoned; retaining exact authority"
2215            );
2216            poisoned.into_inner()
2217        })
2218    }
2219
2220    fn abort_request(&self, request_id: u64) -> Result<(), CoreBoundaryStageError> {
2221        let mut lifecycle = self.lock();
2222        let parked_owner = match &lifecycle.window {
2223            SystemContextBoundaryWindow::Parked {
2224                run_id,
2225                generation,
2226                request_id: current_request_id,
2227                ..
2228            } if *current_request_id == request_id => Some((run_id.clone(), *generation)),
2229            _ => None,
2230        };
2231        if let Some((run_id, generation)) = parked_owner {
2232            lifecycle.window = SystemContextBoundaryWindow::Resolved {
2233                run_id,
2234                generation,
2235                request_id,
2236                resolution: SystemContextBoundaryResolution::Aborted,
2237            };
2238            drop(lifecycle);
2239            self.notify.notify_waiters();
2240            return Ok(());
2241        }
2242        match &mut lifecycle.window {
2243            SystemContextBoundaryWindow::Open { request, .. }
2244                if request
2245                    .as_ref()
2246                    .is_some_and(|request| request.request_id == request_id) =>
2247            {
2248                *request = None;
2249            }
2250            SystemContextBoundaryWindow::Resolved {
2251                request_id: current_request_id,
2252                ..
2253            } if *current_request_id == request_id => return Ok(()),
2254            _ => {
2255                return Err(CoreBoundaryStageError::stale(format!(
2256                    "boundary request {request_id} no longer owns its actor window"
2257                )));
2258            }
2259        }
2260        drop(lifecycle);
2261        self.notify.notify_waiters();
2262        Ok(())
2263    }
2264
2265    fn close_run(&self, run_id: &RunId) {
2266        let mut lifecycle = self.lock();
2267        let owns_window = match &lifecycle.window {
2268            SystemContextBoundaryWindow::Open {
2269                run_id: current, ..
2270            }
2271            | SystemContextBoundaryWindow::Parked {
2272                run_id: current, ..
2273            }
2274            | SystemContextBoundaryWindow::Resolved {
2275                run_id: current, ..
2276            }
2277            | SystemContextBoundaryWindow::Consuming {
2278                run_id: current, ..
2279            } => current == run_id,
2280            SystemContextBoundaryWindow::Closed => false,
2281        };
2282        if owns_window {
2283            lifecycle.window = SystemContextBoundaryWindow::Closed;
2284            drop(lifecycle);
2285            self.notify.notify_waiters();
2286        }
2287    }
2288
2289    fn revoke_actor(&self) {
2290        let mut lifecycle = self.lock();
2291        lifecycle.actor_live = false;
2292        lifecycle.window = SystemContextBoundaryWindow::Closed;
2293        drop(lifecycle);
2294        self.notify.notify_waiters();
2295    }
2296}
2297
2298/// Run-scoped closure guard for the exact actor's cooperative model boundary.
2299/// Every normal return, error, hard-cancel drop, and task abort closes any
2300/// registered or parked request for this run.
2301#[must_use]
2302pub(crate) struct SystemContextBoundaryRunGuard {
2303    boundary: Arc<SystemContextBoundaryCoordinator>,
2304    run_id: RunId,
2305}
2306
2307impl Drop for SystemContextBoundaryRunGuard {
2308    fn drop(&mut self) {
2309        self.boundary.close_run(&self.run_id);
2310    }
2311}
2312
2313struct PendingSystemContextBoundaryPreparation {
2314    boundary: Arc<SystemContextBoundaryCoordinator>,
2315    request_id: u64,
2316    armed: bool,
2317}
2318
2319impl Drop for PendingSystemContextBoundaryPreparation {
2320    fn drop(&mut self) {
2321        if self.armed {
2322            let _ = self.boundary.abort_request(self.request_id);
2323        }
2324    }
2325}
2326
2327/// Runner-owned witness for the exact model request currently being prepared.
2328///
2329/// External commit only publishes the candidate as canonical pending state; it
2330/// does not claim that the model has consumed it. The runner retains this
2331/// second, actor-local witness across fallible/async request preprocessing and
2332/// marks the pending state applied synchronously at the final LLM call seam.
2333/// Dropping the witness closes the generation without marking anything applied.
2334#[must_use = "model-boundary context must be consumed or dropped before opening another boundary"]
2335pub(crate) struct ModelBoundarySystemContext {
2336    state: SystemContextStateHandle,
2337    run_id: RunId,
2338    generation: u64,
2339    request_id: Option<u64>,
2340    appends: Vec<PendingSystemContextAppend>,
2341    armed: bool,
2342}
2343
2344impl ModelBoundarySystemContext {
2345    pub(crate) fn appends(&self) -> &[PendingSystemContextAppend] {
2346        &self.appends
2347    }
2348
2349    /// Pre-serialize the exact post-consumption metadata state while failure is
2350    /// still harmless. The consuming window rejects concurrent mutation, so
2351    /// this projection remains exact until [`Self::consume`].
2352    pub(crate) fn projected_state_after_consume(&self) -> SessionSystemContextState {
2353        let mut projected = self.state.snapshot();
2354        projected.mark_pending_applied();
2355        projected
2356    }
2357
2358    pub(crate) fn consume(
2359        mut self,
2360    ) -> Result<Vec<PendingSystemContextAppend>, CoreBoundaryStageError> {
2361        self.state.finish_model_boundary_consumption(
2362            &self.run_id,
2363            self.generation,
2364            self.request_id,
2365            true,
2366        )?;
2367        self.armed = false;
2368        Ok(std::mem::take(&mut self.appends))
2369    }
2370}
2371
2372impl Drop for ModelBoundarySystemContext {
2373    fn drop(&mut self) {
2374        if self.armed {
2375            let _ = self.state.finish_model_boundary_consumption(
2376                &self.run_id,
2377                self.generation,
2378                self.request_id,
2379                false,
2380            );
2381            self.armed = false;
2382        }
2383    }
2384}
2385
2386/// Unforgeable exact `{actor incarnation, run, boundary generation}`
2387/// preparation. It is created only by the shared system-context authority
2388/// after the runner has parked at the named boundary.
2389///
2390/// ```compile_fail
2391/// use meerkat_core::PreparedSystemContextBoundary;
2392/// fn cannot_duplicate(authority: &PreparedSystemContextBoundary) {
2393///     let _duplicate = authority.clone();
2394/// }
2395/// ```
2396#[must_use = "prepared system context must be committed or aborted"]
2397pub struct PreparedSystemContextBoundary {
2398    state: SystemContextStateHandle,
2399    expected_run_id: RunId,
2400    generation: u64,
2401    request_id: u64,
2402    candidate_state: SessionSystemContextState,
2403    armed: bool,
2404    // The unique resolution authority may move to an owned commit task, but
2405    // sharing one authority by reference across threads is unnecessary and
2406    // obscures its exactly-once ownership contract.
2407    _not_sync: std::marker::PhantomData<std::cell::Cell<()>>,
2408}
2409
2410impl std::fmt::Debug for PreparedSystemContextBoundary {
2411    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2412        formatter
2413            .debug_struct("PreparedSystemContextBoundary")
2414            .field("actor_incarnation", &self.state.boundary.incarnation_id)
2415            .field("expected_run_id", &self.expected_run_id)
2416            .field("generation", &self.generation)
2417            .field("request_id", &self.request_id)
2418            .finish_non_exhaustive()
2419    }
2420}
2421
2422impl PreparedSystemContextBoundary {
2423    #[must_use]
2424    pub fn expected_run_id(&self) -> &RunId {
2425        &self.expected_run_id
2426    }
2427
2428    #[must_use]
2429    pub fn boundary_generation(&self) -> u64 {
2430        self.generation
2431    }
2432
2433    #[must_use]
2434    pub fn candidate_state(&self) -> &SessionSystemContextState {
2435        &self.candidate_state
2436    }
2437
2438    /// Bind the unforgeable parked authority to its optional durable session
2439    /// snapshot. Surfaces cannot manufacture a successful output without this
2440    /// core-minted preparation value.
2441    pub fn into_stage_output(
2442        self,
2443        session_snapshot: Option<Vec<u8>>,
2444    ) -> crate::lifecycle::CoreBoundaryStageOutput {
2445        crate::lifecycle::CoreBoundaryStageOutput::prepared(session_snapshot, Box::new(self))
2446    }
2447
2448    fn resolve(
2449        &mut self,
2450        resolution: SystemContextBoundaryResolution,
2451    ) -> Result<(), CoreBoundaryStageError> {
2452        if !self.armed {
2453            return Err(CoreBoundaryStageError::stale(
2454                "prepared boundary authority was already resolved",
2455            ));
2456        }
2457        let mut lifecycle = self.state.boundary.lock();
2458        if !lifecycle.actor_live {
2459            self.armed = false;
2460            return Err(CoreBoundaryStageError::stale(format!(
2461                "actor incarnation {} was revoked",
2462                self.state.boundary.incarnation_id
2463            )));
2464        }
2465        let matches_exact = matches!(
2466            &lifecycle.window,
2467            SystemContextBoundaryWindow::Parked {
2468                run_id,
2469                generation,
2470                request_id,
2471                ..
2472            } if run_id == &self.expected_run_id
2473                && *generation == self.generation
2474                && *request_id == self.request_id
2475        );
2476        if !matches_exact {
2477            self.armed = false;
2478            return Err(CoreBoundaryStageError::stale(format!(
2479                "actor/run/boundary witness no longer matches request {}",
2480                self.request_id
2481            )));
2482        }
2483        if matches!(&resolution, SystemContextBoundaryResolution::Committed) {
2484            let mut state = self
2485                .state
2486                .inner
2487                .lock()
2488                .unwrap_or_else(std::sync::PoisonError::into_inner);
2489            *state = self.candidate_state.clone();
2490        }
2491        lifecycle.window = SystemContextBoundaryWindow::Resolved {
2492            run_id: self.expected_run_id.clone(),
2493            generation: self.generation,
2494            request_id: self.request_id,
2495            resolution,
2496        };
2497        self.armed = false;
2498        drop(lifecycle);
2499        self.state.boundary.notify.notify_waiters();
2500        Ok(())
2501    }
2502}
2503
2504impl crate::lifecycle::core_executor::CoreBoundaryStageCommitAuthority
2505    for PreparedSystemContextBoundary
2506{
2507    fn commit(&mut self) -> Result<(), CoreBoundaryStageError> {
2508        self.resolve(SystemContextBoundaryResolution::Committed)
2509    }
2510
2511    fn abort(&mut self) -> Result<(), CoreBoundaryStageError> {
2512        self.resolve(SystemContextBoundaryResolution::Aborted)
2513    }
2514}
2515
2516impl Drop for PreparedSystemContextBoundary {
2517    fn drop(&mut self) {
2518        if self.armed {
2519            let _ = self.state.boundary.abort_request(self.request_id);
2520            self.armed = false;
2521        }
2522    }
2523}
2524
2525impl std::fmt::Debug for SystemContextStateHandle {
2526    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2527        f.debug_struct("SystemContextStateHandle")
2528            .field("inner", &"<Arc<Mutex<SessionSystemContextState>>>")
2529            .field("actor_incarnation", &self.boundary.incarnation_id)
2530            .finish()
2531    }
2532}
2533
2534impl SystemContextStateHandle {
2535    fn boundary_reserves_state(lifecycle: &SystemContextBoundaryLifecycle) -> bool {
2536        matches!(
2537            &lifecycle.window,
2538            SystemContextBoundaryWindow::Parked { .. }
2539                | SystemContextBoundaryWindow::Resolved { .. }
2540                | SystemContextBoundaryWindow::Consuming { .. }
2541        )
2542    }
2543
2544    pub fn new(state: SessionSystemContextState) -> Result<Self, serde_json::Error> {
2545        let state = system_context_authority::restore_system_context_state(state)
2546            .map_err(<serde_json::Error as serde::de::Error>::custom)?;
2547        Ok(Self {
2548            inner: Arc::new(std::sync::Mutex::new(state)),
2549            boundary: Arc::new(SystemContextBoundaryCoordinator::default()),
2550        })
2551    }
2552
2553    /// Open the first exact cooperative model-boundary window for `run_id` and
2554    /// return a guard that closes it on every exit, including future drop.
2555    pub(crate) fn begin_boundary_run(
2556        &self,
2557        run_id: RunId,
2558    ) -> Result<SystemContextBoundaryRunGuard, CoreBoundaryStageError> {
2559        self.open_next_boundary(&run_id)?;
2560        Ok(SystemContextBoundaryRunGuard {
2561            boundary: Arc::clone(&self.boundary),
2562            run_id,
2563        })
2564    }
2565
2566    /// Ensure an exact next-boundary window is open for the active run. Calling
2567    /// this twice before consumption is idempotent; after consumption it mints
2568    /// the next monotonically increasing actor-local generation.
2569    pub(crate) fn open_next_boundary(&self, run_id: &RunId) -> Result<u64, CoreBoundaryStageError> {
2570        let mut lifecycle = self.boundary.lock();
2571        if !lifecycle.actor_live {
2572            return Err(CoreBoundaryStageError::stale(format!(
2573                "actor incarnation {} was revoked",
2574                self.boundary.incarnation_id
2575            )));
2576        }
2577        match &lifecycle.window {
2578            SystemContextBoundaryWindow::Open {
2579                run_id: current,
2580                generation,
2581                ..
2582            } if current == run_id => return Ok(*generation),
2583            SystemContextBoundaryWindow::Parked { .. }
2584            | SystemContextBoundaryWindow::Resolved { .. }
2585            | SystemContextBoundaryWindow::Consuming { .. } => {
2586                return Err(CoreBoundaryStageError::fault(
2587                    "runner attempted to open a new boundary while the prior boundary was unresolved",
2588                ));
2589            }
2590            SystemContextBoundaryWindow::Open {
2591                run_id: current, ..
2592            } => {
2593                return Err(CoreBoundaryStageError::stale(format!(
2594                    "run {run_id} cannot replace still-open boundary owned by {current}"
2595                )));
2596            }
2597            SystemContextBoundaryWindow::Closed => {}
2598        }
2599        lifecycle.next_generation = lifecycle
2600            .next_generation
2601            .checked_add(1)
2602            .ok_or_else(|| CoreBoundaryStageError::fault("boundary generation overflow"))?;
2603        let generation = lifecycle.next_generation;
2604        lifecycle.window = SystemContextBoundaryWindow::Open {
2605            run_id: run_id.clone(),
2606            generation,
2607            request: None,
2608        };
2609        drop(lifecycle);
2610        self.boundary.notify.notify_waiters();
2611        Ok(generation)
2612    }
2613
2614    /// Register context for the exact currently-open generation, then wait
2615    /// until the runner is parked immediately before consuming it. The lock
2616    /// linearization makes runner-first return `Unavailable` and prepare-first
2617    /// park; no snapshot/boolean sampling participates in the verdict.
2618    pub async fn prepare_active_turn_boundary(
2619        &self,
2620        expected_run_id: &RunId,
2621        appends: Vec<PendingSystemContextAppend>,
2622    ) -> Result<PreparedSystemContextBoundary, CoreBoundaryStageError> {
2623        if appends.is_empty() {
2624            return Err(CoreBoundaryStageError::fault(
2625                "boundary preparation requires at least one context append",
2626            ));
2627        }
2628        let stage_inputs = appends
2629            .into_iter()
2630            .map(|append| {
2631                (
2632                    AppendSystemContextRequest {
2633                        content: append.content,
2634                        source: append.source,
2635                        idempotency_key: append.idempotency_key,
2636                        source_kind: append.source_kind,
2637                        peer_response_terminal: append.peer_response_terminal,
2638                    },
2639                    append.accepted_at,
2640                )
2641            })
2642            .collect::<Vec<_>>();
2643
2644        let request_id = {
2645            let mut lifecycle = self.boundary.lock();
2646            if !lifecycle.actor_live {
2647                return Err(CoreBoundaryStageError::stale(format!(
2648                    "actor incarnation {} was revoked",
2649                    self.boundary.incarnation_id
2650                )));
2651            }
2652            let (run_id, request) = match &mut lifecycle.window {
2653                SystemContextBoundaryWindow::Open {
2654                    run_id, request, ..
2655                } => (run_id, request),
2656                SystemContextBoundaryWindow::Closed => {
2657                    return Err(CoreBoundaryStageError::unavailable(format!(
2658                        "run {expected_run_id} has no open cooperative model boundary"
2659                    )));
2660                }
2661                SystemContextBoundaryWindow::Parked { .. }
2662                | SystemContextBoundaryWindow::Resolved { .. }
2663                | SystemContextBoundaryWindow::Consuming { .. } => {
2664                    return Err(CoreBoundaryStageError::unavailable(format!(
2665                        "the open boundary for run {expected_run_id} was already claimed or consumed"
2666                    )));
2667                }
2668            };
2669            if run_id != expected_run_id {
2670                return Err(CoreBoundaryStageError::stale(format!(
2671                    "open boundary belongs to run {run_id}, not {expected_run_id}"
2672                )));
2673            }
2674            if request.is_some() {
2675                return Err(CoreBoundaryStageError::unavailable(format!(
2676                    "the next boundary for run {expected_run_id} already has a preparation"
2677                )));
2678            }
2679            // Validate idempotency/conflict semantics against the exact state
2680            // observed at registration without publishing the candidate.
2681            let state = self
2682                .inner
2683                .lock()
2684                .unwrap_or_else(std::sync::PoisonError::into_inner);
2685            let mut candidate = state.clone();
2686            for (append, accepted_at) in &stage_inputs {
2687                candidate
2688                    .stage_active_turn_append(append, *accepted_at)
2689                    .map_err(|error| CoreBoundaryStageError::fault(error.to_string()))?;
2690            }
2691            drop(state);
2692            lifecycle.next_request_id = lifecycle
2693                .next_request_id
2694                .checked_add(1)
2695                .ok_or_else(|| CoreBoundaryStageError::fault("boundary request id overflow"))?;
2696            let request_id = lifecycle.next_request_id;
2697            let SystemContextBoundaryWindow::Open { request, .. } = &mut lifecycle.window else {
2698                return Err(CoreBoundaryStageError::fault(
2699                    "boundary window changed while registering preparation",
2700                ));
2701            };
2702            *request = Some(RegisteredSystemContextBoundaryRequest {
2703                request_id,
2704                appends: stage_inputs,
2705            });
2706            request_id
2707        };
2708
2709        let mut pending = PendingSystemContextBoundaryPreparation {
2710            boundary: Arc::clone(&self.boundary),
2711            request_id,
2712            armed: true,
2713        };
2714        self.boundary.notify.notify_waiters();
2715
2716        loop {
2717            let notified = self.boundary.notify.notified();
2718            tokio::pin!(notified);
2719            notified.as_mut().enable();
2720            let poll = {
2721                let lifecycle = self.boundary.lock();
2722                if lifecycle.actor_live {
2723                    match &lifecycle.window {
2724                        SystemContextBoundaryWindow::Parked {
2725                            run_id,
2726                            generation,
2727                            request_id: parked_request_id,
2728                            candidate_state,
2729                        } if *parked_request_id == request_id => {
2730                            Ok(Some(PreparedSystemContextBoundary {
2731                                state: self.clone(),
2732                                expected_run_id: run_id.clone(),
2733                                generation: *generation,
2734                                request_id,
2735                                candidate_state: candidate_state.clone(),
2736                                armed: true,
2737                                _not_sync: std::marker::PhantomData,
2738                            }))
2739                        }
2740                        SystemContextBoundaryWindow::Open { request, .. }
2741                            if request
2742                                .as_ref()
2743                                .is_some_and(|request| request.request_id == request_id) =>
2744                        {
2745                            Ok(None)
2746                        }
2747                        SystemContextBoundaryWindow::Resolved {
2748                            request_id: resolved_request_id,
2749                            resolution,
2750                            ..
2751                        } if *resolved_request_id == request_id => match resolution {
2752                            SystemContextBoundaryResolution::Failed(error) => Err(error.clone()),
2753                            SystemContextBoundaryResolution::Committed
2754                            | SystemContextBoundaryResolution::Aborted => {
2755                                Err(CoreBoundaryStageError::stale(format!(
2756                                    "boundary request {request_id} resolved before its authority was delivered"
2757                                )))
2758                            }
2759                        },
2760                        _ => Err(CoreBoundaryStageError::unavailable(format!(
2761                            "run {expected_run_id} ended before boundary request {request_id} parked"
2762                        ))),
2763                    }
2764                } else {
2765                    Err(CoreBoundaryStageError::stale(format!(
2766                        "actor incarnation {} was revoked while preparing boundary",
2767                        self.boundary.incarnation_id
2768                    )))
2769                }
2770            };
2771            match poll {
2772                Ok(Some(prepared)) => {
2773                    pending.armed = false;
2774                    return Ok(prepared);
2775                }
2776                Ok(None) => notified.as_mut().await,
2777                Err(error) => return Err(error),
2778            }
2779        }
2780    }
2781
2782    /// Park at the exact model boundary and return a runner-owned consumption
2783    /// witness. Once a preparation has registered, this future cannot return
2784    /// until its authority commits, aborts, is dropped, or the run/actor closes.
2785    /// Returned pending state is not marked applied until the witness is
2786    /// synchronously consumed at the final LLM call seam.
2787    pub(crate) async fn take_pending_at_exact_boundary(
2788        &self,
2789        run_id: &RunId,
2790    ) -> Result<ModelBoundarySystemContext, CoreBoundaryStageError> {
2791        let parked_request_id;
2792        {
2793            let mut lifecycle = self.boundary.lock();
2794            if !lifecycle.actor_live {
2795                return Err(CoreBoundaryStageError::stale(format!(
2796                    "actor incarnation {} was revoked",
2797                    self.boundary.incarnation_id
2798                )));
2799            }
2800            let (generation, request) = match &mut lifecycle.window {
2801                SystemContextBoundaryWindow::Open {
2802                    run_id: current,
2803                    generation,
2804                    request,
2805                } if current == run_id => (*generation, request.take()),
2806                SystemContextBoundaryWindow::Open {
2807                    run_id: current, ..
2808                } => {
2809                    return Err(CoreBoundaryStageError::stale(format!(
2810                        "runner {run_id} reached boundary owned by {current}"
2811                    )));
2812                }
2813                SystemContextBoundaryWindow::Closed => {
2814                    return Err(CoreBoundaryStageError::unavailable(format!(
2815                        "run {run_id} reached a boundary with no open generation"
2816                    )));
2817                }
2818                SystemContextBoundaryWindow::Parked { .. }
2819                | SystemContextBoundaryWindow::Resolved { .. }
2820                | SystemContextBoundaryWindow::Consuming { .. } => {
2821                    return Err(CoreBoundaryStageError::fault(
2822                        "runner re-entered an unresolved model boundary",
2823                    ));
2824                }
2825            };
2826            if let Some(request) = request {
2827                let RegisteredSystemContextBoundaryRequest {
2828                    request_id,
2829                    appends,
2830                } = request;
2831                let state = self
2832                    .inner
2833                    .lock()
2834                    .unwrap_or_else(std::sync::PoisonError::into_inner);
2835                let mut candidate_state = state.clone();
2836                let candidate_result = appends.into_iter().try_for_each(|(append, accepted_at)| {
2837                    candidate_state
2838                        .stage_active_turn_append(&append, accepted_at)
2839                        .map(|_| ())
2840                });
2841                if let Err(error) = candidate_result {
2842                    drop(state);
2843                    let error = CoreBoundaryStageError::fault(error.to_string());
2844                    lifecycle.window = SystemContextBoundaryWindow::Resolved {
2845                        run_id: run_id.clone(),
2846                        generation,
2847                        request_id,
2848                        resolution: SystemContextBoundaryResolution::Failed(error.clone()),
2849                    };
2850                    drop(lifecycle);
2851                    self.boundary.notify.notify_waiters();
2852                    return Err(error);
2853                }
2854                drop(state);
2855                parked_request_id = request_id;
2856                lifecycle.window = SystemContextBoundaryWindow::Parked {
2857                    run_id: run_id.clone(),
2858                    generation,
2859                    request_id,
2860                    candidate_state,
2861                };
2862            } else {
2863                let state = self
2864                    .inner
2865                    .lock()
2866                    .unwrap_or_else(std::sync::PoisonError::into_inner);
2867                let pending = state.pending().to_vec();
2868                drop(state);
2869                lifecycle.window = SystemContextBoundaryWindow::Consuming {
2870                    run_id: run_id.clone(),
2871                    generation,
2872                    request_id: None,
2873                };
2874                return Ok(ModelBoundarySystemContext {
2875                    state: self.clone(),
2876                    run_id: run_id.clone(),
2877                    generation,
2878                    request_id: None,
2879                    appends: pending,
2880                    armed: true,
2881                });
2882            }
2883        }
2884        self.boundary.notify.notify_waiters();
2885
2886        let request_id = parked_request_id;
2887        struct RunnerParkGuard {
2888            boundary: Arc<SystemContextBoundaryCoordinator>,
2889            request_id: u64,
2890            armed: bool,
2891        }
2892        impl Drop for RunnerParkGuard {
2893            fn drop(&mut self) {
2894                if self.armed {
2895                    let _ = self.boundary.abort_request(self.request_id);
2896                }
2897            }
2898        }
2899        let mut park_guard = RunnerParkGuard {
2900            boundary: Arc::clone(&self.boundary),
2901            request_id,
2902            armed: true,
2903        };
2904
2905        loop {
2906            let notified = self.boundary.notify.notified();
2907            tokio::pin!(notified);
2908            notified.as_mut().enable();
2909            let poll = {
2910                let mut lifecycle = self.boundary.lock();
2911                if lifecycle.actor_live {
2912                    match &lifecycle.window {
2913                        SystemContextBoundaryWindow::Parked {
2914                            request_id: parked_request_id,
2915                            ..
2916                        } if *parked_request_id == request_id => Ok(None),
2917                        SystemContextBoundaryWindow::Resolved {
2918                            run_id: resolved_run_id,
2919                            generation,
2920                            request_id: resolved_request_id,
2921                            resolution,
2922                        } if resolved_run_id == run_id && *resolved_request_id == request_id => {
2923                            let resolution = resolution.clone();
2924                            let generation = *generation;
2925                            if let SystemContextBoundaryResolution::Failed(error) = resolution {
2926                                Err(error)
2927                            } else {
2928                                let pending = {
2929                                    let state = self
2930                                        .inner
2931                                        .lock()
2932                                        .unwrap_or_else(std::sync::PoisonError::into_inner);
2933                                    state.pending().to_vec()
2934                                };
2935                                lifecycle.window = SystemContextBoundaryWindow::Consuming {
2936                                    run_id: run_id.clone(),
2937                                    generation,
2938                                    request_id: Some(request_id),
2939                                };
2940                                Ok(Some((
2941                                    ModelBoundarySystemContext {
2942                                        state: self.clone(),
2943                                        run_id: run_id.clone(),
2944                                        generation,
2945                                        request_id: Some(request_id),
2946                                        appends: pending,
2947                                        armed: true,
2948                                    },
2949                                    resolution,
2950                                )))
2951                            }
2952                        }
2953                        _ => Err(CoreBoundaryStageError::stale(format!(
2954                            "parked boundary request {request_id} lost exact run/generation authority"
2955                        ))),
2956                    }
2957                } else {
2958                    Err(CoreBoundaryStageError::stale(format!(
2959                        "actor incarnation {} was revoked while parked",
2960                        self.boundary.incarnation_id
2961                    )))
2962                }
2963            };
2964            match poll {
2965                Ok(Some((context, resolution))) => {
2966                    park_guard.armed = false;
2967                    self.boundary.notify.notify_waiters();
2968                    if matches!(resolution, SystemContextBoundaryResolution::Aborted) {
2969                        tracing::debug!(
2970                            actor_incarnation = %self.boundary.incarnation_id,
2971                            run_id = %run_id,
2972                            request_id,
2973                            "exact model-boundary preparation aborted; consuming ordinary pending context only"
2974                        );
2975                    }
2976                    return Ok(context);
2977                }
2978                Ok(None) => notified.as_mut().await,
2979                Err(error) => {
2980                    park_guard.armed = false;
2981                    return Err(error);
2982                }
2983            }
2984        }
2985    }
2986
2987    fn finish_model_boundary_consumption(
2988        &self,
2989        run_id: &RunId,
2990        generation: u64,
2991        request_id: Option<u64>,
2992        apply: bool,
2993    ) -> Result<(), CoreBoundaryStageError> {
2994        let mut lifecycle = self.boundary.lock();
2995        if !lifecycle.actor_live {
2996            return Err(CoreBoundaryStageError::stale(format!(
2997                "actor incarnation {} was revoked before model-boundary consumption",
2998                self.boundary.incarnation_id
2999            )));
3000        }
3001        let matches_exact = matches!(
3002            &lifecycle.window,
3003            SystemContextBoundaryWindow::Consuming {
3004                run_id: current_run_id,
3005                generation: current_generation,
3006                request_id: current_request_id,
3007            } if current_run_id == run_id
3008                && *current_generation == generation
3009                && *current_request_id == request_id
3010        );
3011        if !matches_exact {
3012            return Err(CoreBoundaryStageError::stale(format!(
3013                "runner model-boundary witness for run {run_id} generation {generation} is no longer current"
3014            )));
3015        }
3016        if apply {
3017            let mut state = self
3018                .inner
3019                .lock()
3020                .unwrap_or_else(std::sync::PoisonError::into_inner);
3021            state.mark_pending_applied();
3022        }
3023        lifecycle.window = SystemContextBoundaryWindow::Closed;
3024        drop(lifecycle);
3025        self.boundary.notify.notify_waiters();
3026        Ok(())
3027    }
3028
3029    /// Revoke this exact actor allocation. Existing prepared authorities can
3030    /// no longer publish, and all runner/preparer waiters are synchronously
3031    /// released before actor-registry removal awaits anything.
3032    pub fn revoke_boundary_actor(&self) {
3033        self.boundary.revoke_actor();
3034    }
3035
3036    pub fn snapshot(&self) -> SessionSystemContextState {
3037        match self.inner.lock() {
3038            Ok(guard) => guard.clone(),
3039            Err(poisoned) => {
3040                tracing::warn!("system-context state lock poisoned while reading snapshot");
3041                poisoned.into_inner().clone()
3042            }
3043        }
3044    }
3045
3046    pub fn replace_from_generated_restore(
3047        &self,
3048        state: SessionSystemContextState,
3049    ) -> Result<(), serde_json::Error> {
3050        let state = system_context_authority::restore_system_context_state(state)
3051            .map_err(<serde_json::Error as serde::de::Error>::custom)?;
3052        let boundary = self.boundary.lock();
3053        if Self::boundary_reserves_state(&boundary) {
3054            return Err(<serde_json::Error as serde::de::Error>::custom(
3055                "system-context state is reserved by an exact parked boundary",
3056            ));
3057        }
3058        match self.inner.lock() {
3059            Ok(mut guard) => {
3060                *guard = state;
3061            }
3062            Err(poisoned) => {
3063                tracing::warn!("system-context state lock poisoned while restoring state");
3064                *poisoned.into_inner() = state;
3065            }
3066        }
3067        Ok(())
3068    }
3069
3070    pub fn replace_from_generated_restore_if_changed(
3071        &self,
3072        state: SessionSystemContextState,
3073    ) -> Result<bool, serde_json::Error> {
3074        let state = system_context_authority::restore_system_context_state(state)
3075            .map_err(<serde_json::Error as serde::de::Error>::custom)?;
3076        let boundary = self.boundary.lock();
3077        if Self::boundary_reserves_state(&boundary) {
3078            return Err(<serde_json::Error as serde::de::Error>::custom(
3079                "system-context state is reserved by an exact parked boundary",
3080            ));
3081        }
3082        let mut guard = match self.inner.lock() {
3083            Ok(guard) => guard,
3084            Err(poisoned) => {
3085                tracing::warn!(
3086                    "system-context state lock poisoned while replacing generated-restored state"
3087                );
3088                poisoned.into_inner()
3089            }
3090        };
3091        if *guard == state {
3092            return Ok(false);
3093        }
3094        *guard = state;
3095        Ok(true)
3096    }
3097
3098    pub fn replace_from_generated_restore_if_current(
3099        &self,
3100        current: &SessionSystemContextState,
3101        replacement: SessionSystemContextState,
3102    ) -> Result<bool, serde_json::Error> {
3103        let replacement = system_context_authority::restore_system_context_state(replacement)
3104            .map_err(<serde_json::Error as serde::de::Error>::custom)?;
3105        let boundary = self.boundary.lock();
3106        if Self::boundary_reserves_state(&boundary) {
3107            return Err(<serde_json::Error as serde::de::Error>::custom(
3108                "system-context state is reserved by an exact parked boundary",
3109            ));
3110        }
3111        let mut guard = match self.inner.lock() {
3112            Ok(guard) => guard,
3113            Err(poisoned) => {
3114                tracing::warn!(
3115                    "system-context state lock poisoned while conditionally replacing generated-restored state"
3116                );
3117                poisoned.into_inner()
3118            }
3119        };
3120        if *guard != *current {
3121            return Ok(false);
3122        }
3123        *guard = replacement;
3124        Ok(true)
3125    }
3126
3127    pub fn stage_append_with_snapshot(
3128        &self,
3129        req: &AppendSystemContextRequest,
3130        accepted_at: SystemTime,
3131    ) -> Result<
3132        (
3133            crate::service::AppendSystemContextStatus,
3134            SessionSystemContextState,
3135            SessionSystemContextState,
3136        ),
3137        SystemContextStageError,
3138    > {
3139        let boundary = self.boundary.lock();
3140        if Self::boundary_reserves_state(&boundary) {
3141            return Err(SystemContextStageError::InvalidRequest(
3142                "system-context state is reserved by an exact parked boundary".to_string(),
3143            ));
3144        }
3145        let mut guard = match self.inner.lock() {
3146            Ok(guard) => guard,
3147            Err(poisoned) => {
3148                tracing::warn!("system-context state lock poisoned while staging append");
3149                poisoned.into_inner()
3150            }
3151        };
3152        let snapshot = guard.clone();
3153        let status = guard.stage_append(req, accepted_at)?;
3154        let staged = guard.clone();
3155        Ok((status, snapshot, staged))
3156    }
3157
3158    pub fn stage_active_turn_appends_with_snapshot(
3159        &self,
3160        appends: Vec<(AppendSystemContextRequest, SystemTime)>,
3161    ) -> Result<(SessionSystemContextState, SessionSystemContextState), SystemContextStageError>
3162    {
3163        let boundary = self.boundary.lock();
3164        if Self::boundary_reserves_state(&boundary) {
3165            return Err(SystemContextStageError::InvalidRequest(
3166                "system-context state is reserved by an exact parked boundary".to_string(),
3167            ));
3168        }
3169        let mut guard = match self.inner.lock() {
3170            Ok(guard) => guard,
3171            Err(poisoned) => {
3172                tracing::warn!(
3173                    "system-context state lock poisoned while staging active-turn appends"
3174                );
3175                poisoned.into_inner()
3176            }
3177        };
3178        let snapshot = guard.clone();
3179        let mut candidate = snapshot.clone();
3180        for (req, accepted_at) in appends {
3181            candidate.stage_active_turn_append(&req, accepted_at)?;
3182        }
3183        *guard = candidate.clone();
3184        let staged = candidate;
3185        Ok((snapshot, staged))
3186    }
3187
3188    pub fn discard_unapplied_active_turn_pending(&self) -> Result<usize, CoreBoundaryStageError> {
3189        let boundary = self.boundary.lock();
3190        if Self::boundary_reserves_state(&boundary) {
3191            return Err(CoreBoundaryStageError::fault(format!(
3192                "cannot discard active-turn system context while exact actor incarnation {} owns a parked or consuming boundary",
3193                self.boundary.incarnation_id
3194            )));
3195        }
3196        let discarded = match self.inner.lock() {
3197            Ok(mut guard) => guard.discard_unapplied_active_turn_pending(),
3198            Err(poisoned) => {
3199                tracing::warn!(
3200                    "system-context state lock poisoned while discarding active-turn context"
3201                );
3202                poisoned
3203                    .into_inner()
3204                    .discard_unapplied_active_turn_pending()
3205            }
3206        };
3207        Ok(discarded.len())
3208    }
3209
3210    pub fn discard_active_turn_pending_by_keys(
3211        &self,
3212        idempotency_keys: &[String],
3213    ) -> Result<Vec<PendingSystemContextAppend>, CoreBoundaryStageError> {
3214        let boundary = self.boundary.lock();
3215        if Self::boundary_reserves_state(&boundary) {
3216            return Err(CoreBoundaryStageError::fault(format!(
3217                "cannot discard keyed active-turn system context while exact actor incarnation {} owns a parked or consuming boundary",
3218                self.boundary.incarnation_id
3219            )));
3220        }
3221        let discarded = match self.inner.lock() {
3222            Ok(mut guard) => guard.discard_active_turn_pending_by_keys(idempotency_keys),
3223            Err(poisoned) => {
3224                tracing::warn!(
3225                    "system-context state lock poisoned while discarding active-turn pending appends"
3226                );
3227                poisoned
3228                    .into_inner()
3229                    .discard_active_turn_pending_by_keys(idempotency_keys)
3230            }
3231        };
3232        Ok(discarded)
3233    }
3234
3235    pub fn stage_active_turn_append(
3236        &self,
3237        req: &AppendSystemContextRequest,
3238        accepted_at: SystemTime,
3239    ) -> Result<crate::service::AppendSystemContextStatus, SystemContextStageError> {
3240        let boundary = self.boundary.lock();
3241        if Self::boundary_reserves_state(&boundary) {
3242            return Err(SystemContextStageError::InvalidRequest(
3243                "system-context state is reserved by an exact parked boundary".to_string(),
3244            ));
3245        }
3246        match self.inner.lock() {
3247            Ok(mut guard) => guard.stage_active_turn_append(req, accepted_at),
3248            Err(poisoned) => {
3249                tracing::warn!(
3250                    "system-context state lock poisoned while staging active-turn context"
3251                );
3252                poisoned
3253                    .into_inner()
3254                    .stage_active_turn_append(req, accepted_at)
3255            }
3256        }
3257    }
3258}
3259
3260/// Durable control state for runtime system-context append requests.
3261// Cannot derive `Eq`: `PendingSystemContextAppend` carries a typed
3262// `peer_response_terminal` fact whose render payload is a `serde_json::Value`.
3263#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
3264#[serde(rename_all = "snake_case")]
3265pub struct SessionSystemContextState {
3266    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3267    pub(crate) pending: Vec<PendingSystemContextAppend>,
3268    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3269    pub(crate) applied: Vec<PendingSystemContextAppend>,
3270    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
3271    pub(crate) seen: std::collections::BTreeMap<String, SeenSystemContextKey>,
3272    /// Keyed projection used for idempotency-aware rollback. This is not the
3273    /// lifetime owner because active-turn appends may be keyless.
3274    #[serde(default, skip_serializing_if = "std::collections::BTreeSet::is_empty")]
3275    pub(crate) active_turn_pending_keys: std::collections::BTreeSet<String>,
3276    /// Exact positions in `pending` that belong to the active turn.
3277    ///
3278    /// Idempotency keys are optional, so they cannot carry lifetime ownership.
3279    /// The positional witness is durable and independent of deduplication;
3280    /// every pending-queue mutation rebases it atomically with the queue.
3281    #[serde(default, skip_serializing_if = "std::collections::BTreeSet::is_empty")]
3282    pub(crate) active_turn_pending_indices: std::collections::BTreeSet<u64>,
3283}
3284
3285/// Typed provenance class for a runtime system-context append.
3286///
3287/// Canonical replacement for the retired `runtime:steer:` string-prefix
3288/// folklore. The PRODUCER of a runtime-steer append (the runtime input
3289/// projection in `meerkat-runtime`) constructs it with
3290/// [`SystemContextSource::RuntimeSteer`]; everything else is
3291/// [`SystemContextSource::Normal`]. No code reclassifies a `source` string
3292/// into this fact — it is set once at construction and the machine guards the
3293/// typed field.
3294#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
3295#[serde(rename_all = "snake_case")]
3296pub enum SystemContextSource {
3297    /// A durable, non-transient runtime context append (peer responses, etc.).
3298    #[default]
3299    Normal,
3300    /// A transient operator/peer steer append that must not survive past the
3301    /// turn it steers and must not be promoted to the durable applied set.
3302    RuntimeSteer,
3303}
3304
3305impl From<SystemContextSource> for session_document::SystemContextSource {
3306    fn from(value: SystemContextSource) -> Self {
3307        match value {
3308            SystemContextSource::Normal => Self::Normal,
3309            SystemContextSource::RuntimeSteer => Self::RuntimeSteer,
3310        }
3311    }
3312}
3313
3314impl SystemContextSource {
3315    /// Whether this is the default (`Normal`) provenance. Used by
3316    /// `skip_serializing_if` so durable appends serialize without the field.
3317    #[must_use]
3318    pub fn is_normal(&self) -> bool {
3319        matches!(self, Self::Normal)
3320    }
3321
3322    /// Whether this append is a transient runtime steer.
3323    #[must_use]
3324    pub fn is_runtime_steer(&self) -> bool {
3325        matches!(self, Self::RuntimeSteer)
3326    }
3327}
3328
3329/// Pending append request accepted by the control plane but not yet applied at an LLM boundary.
3330// Cannot derive `Eq`: the typed `peer_response_terminal` fact carries a
3331// `serde_json::Value` render payload, which is `PartialEq` but not `Eq`.
3332#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
3333#[serde(rename_all = "snake_case")]
3334pub struct PendingSystemContextAppend {
3335    /// Typed renderable append content, carried end-to-end from the surface
3336    /// request ([`AppendSystemContextRequest.content`]). The ONE lowering to
3337    /// model-facing prompt text happens where the transcript consumes the
3338    /// append ([`CoreRenderable::render_text`] inside the render seam) —
3339    /// surfaces never pre-flatten this into a string.
3340    ///
3341    /// [`CoreRenderable::render_text`]: crate::lifecycle::run_primitive::CoreRenderable::render_text
3342    pub content: crate::lifecycle::run_primitive::CoreRenderable,
3343    #[serde(default, skip_serializing_if = "Option::is_none")]
3344    pub source: Option<String>,
3345    #[serde(default, skip_serializing_if = "Option::is_none")]
3346    pub idempotency_key: Option<String>,
3347    /// Typed provenance: whether this append is a transient runtime steer.
3348    #[serde(default, skip_serializing_if = "SystemContextSource::is_normal")]
3349    pub source_kind: SystemContextSource,
3350    /// Typed terminal-peer-response fact this append carries, when the append
3351    /// projects a `PeerResponseTerminalFact`. The producer stamps the typed
3352    /// fact here at construction; realtime/live consumers read the typed fact
3353    /// directly instead of re-parsing the flattened prompt `text`/`source`
3354    /// string (the `peer_response_terminal:` prefix + `Payload:` split). This
3355    /// mirrors the `source_kind` precedent that retired the `runtime:steer:`
3356    /// string-prefix re-derivation.
3357    #[serde(default, skip_serializing_if = "Option::is_none")]
3358    pub peer_response_terminal: Option<crate::handles::PeerResponseTerminalFact>,
3359    pub accepted_at: SystemTime,
3360}
3361
3362/// Typed terminal-lifecycle projection of the canonical
3363/// [`session_document::SessionDocumentMachine`] `session_lifecycle_terminal`
3364/// fact.
3365///
3366/// The machine owns archive lifecycle truth for ALL profiles (LUC-524 R004
3367/// fold): both the runtime-backed and the store-only archive paths drive the
3368/// machine's `ArchiveSessionDocument` input, and this reserved-key field is
3369/// the machine-realized durable projection of the emitted verdict — the shell
3370/// realizes it, it never decides it. `RuntimeState::Retired` is the runtime
3371/// realization of the SAME verdict; the fail-closed realization order (durable
3372/// document commit first, runtime retire second) keeps the two projections
3373/// convergent. A two-variant enum (rather than a bare bool) keeps future
3374/// terminal classes — e.g. `Destroyed` — extending the type rather than the
3375/// call sites.
3376#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
3377#[serde(rename_all = "snake_case")]
3378pub enum SessionLifecycleTerminal {
3379    /// The session is live / resumable.
3380    Active,
3381    /// The session has been archived and is terminal.
3382    Archived,
3383}
3384
3385impl SessionLifecycleTerminal {
3386    /// Whether this terminal fact marks the session as archived.
3387    #[must_use]
3388    pub fn is_archived(self) -> bool {
3389        matches!(self, Self::Archived)
3390    }
3391}
3392
3393impl From<SessionLifecycleTerminal> for session_document::SessionDocumentLifecycle {
3394    fn from(value: SessionLifecycleTerminal) -> Self {
3395        match value {
3396            SessionLifecycleTerminal::Active => Self::Active,
3397            SessionLifecycleTerminal::Archived => Self::Archived,
3398        }
3399    }
3400}
3401
3402impl From<session_document::SessionDocumentLifecycle> for SessionLifecycleTerminal {
3403    fn from(value: session_document::SessionDocumentLifecycle) -> Self {
3404        match value {
3405            session_document::SessionDocumentLifecycle::Active => Self::Active,
3406            session_document::SessionDocumentLifecycle::Archived => Self::Archived,
3407        }
3408    }
3409}
3410
3411/// Durable control state for deferred first-turn prompt and staged callback tool results.
3412#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
3413#[serde(rename_all = "snake_case")]
3414pub struct SessionDeferredTurnState {
3415    #[serde(default, skip_serializing_if = "DeferredFirstTurnPhase::is_inactive")]
3416    pub(crate) first_turn_phase: DeferredFirstTurnPhase,
3417    #[serde(default, skip_serializing_if = "Option::is_none")]
3418    pub(crate) pending_initial_prompt: Option<PendingDeferredPrompt>,
3419    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3420    pub(crate) pending_tool_results: Vec<PendingToolResultsMessage>,
3421}
3422
3423/// Canonical lifecycle phase for the session's deferred first turn.
3424#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
3425#[serde(rename_all = "snake_case")]
3426pub enum DeferredFirstTurnPhase {
3427    /// The session was not created in deferred-first-turn mode.
3428    #[default]
3429    Inactive,
3430    /// The session exists durably but the first turn has not started yet.
3431    Pending,
3432    /// The first turn has started; build-only overrides are no longer legal.
3433    Consumed,
3434}
3435
3436impl DeferredFirstTurnPhase {
3437    pub fn is_inactive(&self) -> bool {
3438        matches!(self, Self::Inactive)
3439    }
3440}
3441
3442impl From<DeferredFirstTurnPhase> for session_document::SessionFirstTurnPhase {
3443    fn from(value: DeferredFirstTurnPhase) -> Self {
3444        match value {
3445            DeferredFirstTurnPhase::Inactive => Self::Inactive,
3446            DeferredFirstTurnPhase::Pending => Self::Pending,
3447            DeferredFirstTurnPhase::Consumed => Self::Consumed,
3448        }
3449    }
3450}
3451
3452impl From<session_document::SessionFirstTurnPhase> for DeferredFirstTurnPhase {
3453    fn from(value: session_document::SessionFirstTurnPhase) -> Self {
3454        match value {
3455            session_document::SessionFirstTurnPhase::Inactive => Self::Inactive,
3456            session_document::SessionFirstTurnPhase::Pending => Self::Pending,
3457            session_document::SessionFirstTurnPhase::Consumed => Self::Consumed,
3458        }
3459    }
3460}
3461
3462fn is_default_hook_run_overrides(value: &crate::HookRunOverrides) -> bool {
3463    value == &crate::HookRunOverrides::default()
3464}
3465
3466fn is_default_call_timeout_override(value: &crate::CallTimeoutOverride) -> bool {
3467    value == &crate::CallTimeoutOverride::default()
3468}
3469
3470fn is_tool_filter_all(value: &ToolFilter) -> bool {
3471    matches!(value, ToolFilter::All)
3472}
3473
3474fn is_zero(value: &u64) -> bool {
3475    *value == 0
3476}
3477
3478/// Derive the machine-owned capability base filter from the current image-tool-results support.
3479pub fn capability_base_filter_for_image_tool_results(image_tool_results: bool) -> ToolFilter {
3480    if image_tool_results {
3481        ToolFilter::All
3482    } else {
3483        ToolFilter::Deny([VIEW_IMAGE_TOOL_NAME.to_string()].into_iter().collect())
3484    }
3485}
3486
3487/// Persisted witness for a durable tool-visibility name.
3488///
3489/// `last_seen_provenance` is the single typed identity owner. The formatted
3490/// `stable_owner_key` string is a read-only projection derived on demand via
3491/// [`crate::tool_catalog::stable_owner_key_from_provenance`], never stored
3492/// beside the owner.
3493#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
3494#[serde(rename_all = "snake_case")]
3495pub struct ToolVisibilityWitness {
3496    #[serde(default, skip_serializing_if = "Option::is_none")]
3497    pub last_seen_provenance: Option<ToolProvenance>,
3498}
3499
3500impl ToolVisibilityWitness {
3501    pub fn has_identity_witness(&self) -> bool {
3502        self.last_seen_provenance.is_some()
3503    }
3504}
3505
3506/// Typed authority value for a deferred-tool load request.
3507///
3508/// The public/effect seam carries the requested route name and provenance
3509/// witness as one value. Canonical owners may project this into name-indexed
3510/// maps internally, but callers do not get to make a map key the authority.
3511#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
3512#[serde(rename_all = "snake_case")]
3513pub struct DeferredToolLoadAuthority {
3514    pub name: ToolName,
3515    pub witness: ToolVisibilityWitness,
3516}
3517
3518impl DeferredToolLoadAuthority {
3519    pub fn new(name: impl Into<ToolName>, witness: ToolVisibilityWitness) -> Self {
3520        Self {
3521            name: name.into(),
3522            witness,
3523        }
3524    }
3525
3526    pub fn into_parts(self) -> (ToolName, ToolVisibilityWitness) {
3527        (self.name, self.witness)
3528    }
3529}
3530
3531/// Durable tool-filter intent paired with the witnesses that made the names
3532/// authoritative at capture time.
3533#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
3534#[serde(rename_all = "snake_case")]
3535pub struct WitnessedToolFilter {
3536    pub filter: ToolFilter,
3537    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
3538    pub witnesses: BTreeMap<ToolName, ToolVisibilityWitness>,
3539}
3540
3541impl WitnessedToolFilter {
3542    pub fn new(filter: ToolFilter, witnesses: BTreeMap<ToolName, ToolVisibilityWitness>) -> Self {
3543        Self { filter, witnesses }
3544    }
3545
3546    pub fn into_parts(self) -> (ToolFilter, BTreeMap<ToolName, ToolVisibilityWitness>) {
3547        (self.filter, self.witnesses)
3548    }
3549}
3550
3551/// Opaque parent/composition-authorized inherited tool visibility handoff.
3552///
3553/// The filter and witnesses are intentionally not public fields. Callers that
3554/// need to hand inherited visibility to a child build must obtain this from an
3555/// AgentFactory-minted parent composition authority; they cannot write
3556/// canonical session visibility state directly.
3557#[derive(Debug, Clone, PartialEq, Eq)]
3558pub struct InheritedToolVisibilityAuthority {
3559    filter: ToolFilter,
3560    witnesses: BTreeMap<ToolName, ToolVisibilityWitness>,
3561}
3562
3563impl InheritedToolVisibilityAuthority {
3564    pub(crate) fn from_generated_composition_authority(
3565        filter: ToolFilter,
3566        witnesses: BTreeMap<ToolName, ToolVisibilityWitness>,
3567    ) -> Self {
3568        Self { filter, witnesses }
3569    }
3570
3571    pub fn filter(&self) -> &ToolFilter {
3572        &self.filter
3573    }
3574
3575    pub fn witnesses(&self) -> &BTreeMap<ToolName, ToolVisibilityWitness> {
3576        &self.witnesses
3577    }
3578
3579    pub(crate) fn into_initial_visibility_state(self) -> SessionToolVisibilityState {
3580        SessionToolVisibilityState {
3581            inherited_base_filter: self.filter,
3582            filter_witnesses: self.witnesses,
3583            ..Default::default()
3584        }
3585    }
3586}
3587
3588/// Canonical durable session-local tool visibility intent.
3589#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
3590#[serde(rename_all = "snake_case")]
3591pub struct SessionToolVisibilityState {
3592    #[serde(default, skip_serializing_if = "is_tool_filter_all")]
3593    pub capability_base_filter: ToolFilter,
3594    #[serde(default, skip_serializing_if = "is_tool_filter_all")]
3595    pub inherited_base_filter: ToolFilter,
3596    #[serde(default, skip_serializing_if = "is_tool_filter_all")]
3597    pub active_filter: ToolFilter,
3598    #[serde(default, skip_serializing_if = "is_tool_filter_all")]
3599    pub staged_filter: ToolFilter,
3600    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
3601    pub active_requested_deferred_names: BTreeSet<ToolName>,
3602    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
3603    pub staged_requested_deferred_names: BTreeSet<ToolName>,
3604    #[serde(default, skip_serializing_if = "is_zero")]
3605    pub active_revision: u64,
3606    #[serde(default, skip_serializing_if = "is_zero")]
3607    pub staged_revision: u64,
3608    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
3609    pub requested_witnesses: BTreeMap<ToolName, ToolVisibilityWitness>,
3610    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
3611    pub filter_witnesses: BTreeMap<ToolName, ToolVisibilityWitness>,
3612}
3613
3614impl SessionToolVisibilityState {
3615    /// Deterministic projection of the generated CallingLlm visibility
3616    /// boundary. This is a comparison witness only: semantic promotion still
3617    /// belongs to the generated visibility owner.
3618    #[cfg(test)]
3619    pub(crate) fn projected_boundary_applied(&self) -> Self {
3620        let mut projected = self.clone();
3621        projected.active_filter = self.staged_filter.clone();
3622        projected.active_requested_deferred_names = self.staged_requested_deferred_names.clone();
3623        projected.active_revision = self.staged_revision;
3624        projected
3625    }
3626}
3627
3628/// Generated-authority-approved durable tool visibility projection.
3629///
3630/// Session metadata stores this as a projection of the generated visibility
3631/// owner. Code that only has raw `SessionToolVisibilityState` must first route
3632/// it through a `ToolVisibilityOwner`/`ToolScope` restore path.
3633#[derive(Debug, Clone, PartialEq, Eq)]
3634pub struct AuthorizedSessionToolVisibilityState {
3635    state: SessionToolVisibilityState,
3636}
3637
3638impl AuthorizedSessionToolVisibilityState {
3639    pub(crate) fn from_generated_authority(state: SessionToolVisibilityState) -> Self {
3640        Self { state }
3641    }
3642
3643    pub fn as_state(&self) -> &SessionToolVisibilityState {
3644        &self.state
3645    }
3646
3647    pub fn into_state(self) -> SessionToolVisibilityState {
3648        self.state
3649    }
3650}
3651
3652/// Durable build-only session state required to faithfully recover and rebuild
3653/// a persisted session without surface-local shadow config.
3654#[derive(Debug, Clone, Serialize, Deserialize, Default)]
3655#[serde(rename_all = "snake_case")]
3656pub struct SessionBuildState {
3657    #[serde(
3658        default,
3659        skip_serializing_if = "crate::config::SystemPromptOverride::is_inherit"
3660    )]
3661    pub system_prompt: crate::config::SystemPromptOverride,
3662    #[serde(default, skip_serializing_if = "Option::is_none")]
3663    pub output_schema: Option<crate::OutputSchema>,
3664    #[serde(default, skip_serializing_if = "is_default_hook_run_overrides")]
3665    pub hooks_override: crate::HookRunOverrides,
3666    #[serde(default, skip_serializing_if = "Option::is_none")]
3667    pub budget_limits: Option<crate::BudgetLimits>,
3668    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3669    pub recoverable_tool_defs: Vec<ToolDef>,
3670    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3671    pub silent_comms_intents: Vec<String>,
3672    #[serde(default, skip_serializing_if = "Option::is_none")]
3673    pub max_inline_peer_notifications: Option<i32>,
3674    #[serde(default, skip_serializing_if = "Option::is_none")]
3675    pub app_context: Option<serde_json::Value>,
3676    #[serde(default, skip_serializing_if = "Option::is_none")]
3677    pub additional_instructions: Option<Vec<String>>,
3678    #[serde(default, skip_serializing_if = "Option::is_none")]
3679    pub shell_env: Option<HashMap<String, String>>,
3680    /// Compatibility projection of mob operator authority.
3681    ///
3682    /// `MobToolAuthorityContext` deliberately loses its generated authority
3683    /// seal when serialized; restored behavior must be approved by the
3684    /// generated runtime bridge before this projection can affect tools.
3685    #[serde(default, skip_serializing_if = "Option::is_none")]
3686    pub mob_tool_authority_context: Option<MobToolAuthorityContext>,
3687    #[serde(default, skip_serializing_if = "is_default_call_timeout_override")]
3688    pub call_timeout_override: crate::CallTimeoutOverride,
3689    /// Exact assembled base-prompt bytes the last build applied (or verified)
3690    /// for this session. Runtime system-context appends extend the leading
3691    /// System message past this base; recording the base lets a later resume
3692    /// split the persisted content into `base + appended tail` byte-exactly
3693    /// (see [`Session::reconcile_resumed_system_prompt`]) instead of
3694    /// re-deriving append renders.
3695    #[serde(default, skip_serializing_if = "Option::is_none")]
3696    pub assembled_system_prompt: Option<String>,
3697}
3698
3699/// Deferred create-time prompt staged for the next turn.
3700#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
3701#[serde(rename_all = "snake_case")]
3702pub struct PendingDeferredPrompt {
3703    pub prompt: ContentInput,
3704    pub accepted_at: SystemTime,
3705}
3706
3707/// Staged callback tool results waiting to be admitted on the next turn seam.
3708#[derive(Debug, Clone, Serialize, Deserialize)]
3709#[serde(rename_all = "snake_case")]
3710pub struct PendingToolResultsMessage {
3711    pub results: Vec<ToolResult>,
3712    pub accepted_at: SystemTime,
3713}
3714
3715/// Typed refusal at the deferred callback-result ingress seam.
3716#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
3717pub enum DeferredToolResultsIngressError {
3718    #[error("callback result ingress contains duplicate tool id '{0}'")]
3719    DuplicateToolUseId(String),
3720    #[error("callback result for tool id '{0}' conflicts with its staged payload")]
3721    ConflictingRedelivery(String),
3722    #[error("callback result tool id '{0}' is outside the staged pending set")]
3723    WrongToolUseId(String),
3724}
3725
3726/// Durable staging record for one assistant tool-use batch that contains one
3727/// or more external callbacks and optional locally completed siblings.
3728///
3729/// Nothing in this record is provider-visible until the callback result is
3730/// admitted. The completed results and transcript-producing effects are
3731/// published together as one complete adjacent batch.
3732#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
3733#[serde(rename_all = "snake_case")]
3734pub(crate) struct PendingCallbackToolBatch {
3735    pub run_id: RunId,
3736    pub tool_use_order: Vec<String>,
3737    pub pending_tool_use_ids: Vec<String>,
3738    pub completed_results: Vec<ToolResult>,
3739    pub session_effects: Vec<crate::ops::SessionEffect>,
3740    pub async_ops: Vec<crate::ops::AsyncOpRef>,
3741}
3742
3743#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
3744#[serde(tag = "state", rename_all = "snake_case")]
3745enum CallbackToolBatchState {
3746    Pending {
3747        batch: PendingCallbackToolBatch,
3748    },
3749    Applied {
3750        tool_use_order: Vec<String>,
3751        results: Vec<ToolResult>,
3752        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3753        async_ops: Vec<crate::ops::AsyncOpRef>,
3754        #[serde(default, skip_serializing_if = "Vec::is_empty")]
3755        post_tool_messages: Vec<Message>,
3756        #[serde(default)]
3757        post_tool_messages_applied: bool,
3758    },
3759}
3760
3761pub(crate) enum ResolvedPendingCallbackToolResults {
3762    NoState,
3763    Pending {
3764        batch: PendingCallbackToolBatch,
3765        ordered_results: Vec<ToolResult>,
3766    },
3767    AlreadyApplied {
3768        async_ops: Vec<crate::ops::AsyncOpRef>,
3769    },
3770}
3771
3772/// Admission verdict for callback results presented at a session-service
3773/// boundary before they enter deferred-turn state.
3774#[derive(Debug, Clone, PartialEq, Eq)]
3775#[doc(hidden)]
3776pub enum CallbackResultIngress {
3777    /// The session has no durable callback batch; legacy callers may use their
3778    /// ordinary deferred-input policy.
3779    NoPendingBatch,
3780    /// The exact result set belongs to the pending batch.
3781    Pending { pending_tool_use_ids: Vec<String> },
3782    /// The identical callback payload was already committed.
3783    AlreadyApplied,
3784}
3785
3786/// Typed failures at the durable callback-batch staging/apply seam.
3787#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
3788pub(crate) enum PendingCallbackBatchError {
3789    #[error("a pending callback batch is already staged")]
3790    AlreadyStaged,
3791    #[error("no pending callback batch is staged")]
3792    Missing,
3793    #[error("pending callback batch is malformed: {0}")]
3794    Malformed(String),
3795    #[error("callback results contain duplicate tool id '{0}'")]
3796    DuplicateResult(String),
3797    #[error("mob authority replacement cannot cross a durable callback staging boundary")]
3798    NonDurableAuthorityEffect,
3799    #[error("callback result ids {actual:?} do not match pending ids {expected:?}")]
3800    ResultSetMismatch {
3801        expected: BTreeSet<String>,
3802        actual: BTreeSet<String>,
3803    },
3804    #[error("callback result redelivery conflicts with the already applied payload")]
3805    ConflictingRedelivery,
3806}
3807
3808fn unique_tool_results(
3809    results: Vec<ToolResult>,
3810) -> Result<BTreeMap<String, ToolResult>, PendingCallbackBatchError> {
3811    let mut by_id = BTreeMap::new();
3812    for result in results {
3813        let id = result.tool_use_id.clone();
3814        if by_id.insert(id.clone(), result).is_some() {
3815            return Err(PendingCallbackBatchError::DuplicateResult(id));
3816        }
3817    }
3818    Ok(by_id)
3819}
3820
3821fn validate_pending_callback_batch(
3822    messages: &[Message],
3823    batch: &PendingCallbackToolBatch,
3824) -> Result<(), PendingCallbackBatchError> {
3825    let Some(assistant) = messages.last() else {
3826        return Err(PendingCallbackBatchError::Malformed(
3827            "staged callback batch has no assistant transcript tail".to_string(),
3828        ));
3829    };
3830    let assistant_order = assistant_tool_use_ids(assistant)
3831        .into_iter()
3832        .map(str::to_string)
3833        .collect::<Vec<_>>();
3834    if assistant_order != batch.tool_use_order {
3835        return Err(PendingCallbackBatchError::Malformed(format!(
3836            "assistant tool ids {assistant_order:?} do not match staged order {:?}",
3837            batch.tool_use_order
3838        )));
3839    }
3840    let assistant_set = assistant_order.iter().cloned().collect::<BTreeSet<_>>();
3841    if assistant_set.len() != assistant_order.len() {
3842        return Err(PendingCallbackBatchError::Malformed(
3843            "assistant tool-use batch contains duplicate ids".to_string(),
3844        ));
3845    }
3846    let pending_set = batch
3847        .pending_tool_use_ids
3848        .iter()
3849        .cloned()
3850        .collect::<BTreeSet<_>>();
3851    if pending_set.len() != batch.pending_tool_use_ids.len() || pending_set.is_empty() {
3852        return Err(PendingCallbackBatchError::Malformed(
3853            "staged callback batch must contain at least one unique pending tool id".to_string(),
3854        ));
3855    }
3856    let completed = unique_tool_results(batch.completed_results.clone())?;
3857    let completed_set = completed.keys().cloned().collect::<BTreeSet<_>>();
3858    if !pending_set.is_disjoint(&completed_set)
3859        || pending_set
3860            .union(&completed_set)
3861            .cloned()
3862            .collect::<BTreeSet<_>>()
3863            != assistant_set
3864    {
3865        return Err(PendingCallbackBatchError::Malformed(format!(
3866            "pending ids {pending_set:?} plus completed ids {completed_set:?} do not partition assistant ids {assistant_set:?}"
3867        )));
3868    }
3869    if batch.session_effects.iter().any(|effect| {
3870        matches!(
3871            effect,
3872            crate::ops::SessionEffect::ReplaceMobToolAuthorityContext { .. }
3873        )
3874    }) {
3875        return Err(PendingCallbackBatchError::NonDurableAuthorityEffect);
3876    }
3877    Ok(())
3878}
3879
3880impl PartialEq for PendingToolResultsMessage {
3881    fn eq(&self, other: &Self) -> bool {
3882        self.accepted_at == other.accepted_at
3883            && serde_json::to_value(&self.results).ok() == serde_json::to_value(&other.results).ok()
3884    }
3885}
3886
3887/// Deferred first-turn inputs consumed at the generated start-turn authority seam.
3888#[derive(Debug, Clone, Default, PartialEq)]
3889pub struct ConsumedDeferredTurnInputs {
3890    pub(crate) restore_first_turn_pending: bool,
3891    pub(crate) pending_initial_prompt: Option<PendingDeferredPrompt>,
3892    pub(crate) pending_tool_results: Vec<PendingToolResultsMessage>,
3893}
3894
3895impl ConsumedDeferredTurnInputs {
3896    pub fn is_empty(&self) -> bool {
3897        !self.restore_first_turn_pending
3898            && self.pending_initial_prompt.is_none()
3899            && self.pending_tool_results.is_empty()
3900    }
3901
3902    pub fn pending_initial_prompt(&self) -> Option<&PendingDeferredPrompt> {
3903        self.pending_initial_prompt.as_ref()
3904    }
3905
3906    pub fn pending_tool_results(&self) -> &[PendingToolResultsMessage] {
3907        &self.pending_tool_results
3908    }
3909}
3910
3911/// Seen idempotency-key entry for system-context append requests.
3912#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
3913#[serde(rename_all = "snake_case")]
3914pub struct SeenSystemContextKey {
3915    /// Typed renderable content of the accepted append for this key.
3916    pub content: crate::lifecycle::run_primitive::CoreRenderable,
3917    #[serde(default, skip_serializing_if = "Option::is_none")]
3918    pub source: Option<String>,
3919    /// Typed provenance carried from the append, so runtime-steer cleanup can
3920    /// match seen entries by the typed marker rather than a `source` prefix.
3921    #[serde(default, skip_serializing_if = "SystemContextSource::is_normal")]
3922    pub source_kind: SystemContextSource,
3923    pub state: SeenSystemContextState,
3924}
3925
3926/// Lifecycle state for an accepted idempotency key.
3927#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
3928#[serde(rename_all = "snake_case")]
3929pub enum SeenSystemContextState {
3930    Pending,
3931    Applied,
3932}
3933
3934impl SessionSystemContextState {
3935    pub fn pending(&self) -> &[PendingSystemContextAppend] {
3936        &self.pending
3937    }
3938
3939    pub fn applied(&self) -> &[PendingSystemContextAppend] {
3940        &self.applied
3941    }
3942
3943    pub fn seen(&self) -> &BTreeMap<String, SeenSystemContextKey> {
3944        &self.seen
3945    }
3946
3947    pub fn active_turn_pending_keys(&self) -> &BTreeSet<String> {
3948        &self.active_turn_pending_keys
3949    }
3950
3951    pub fn pending_len(&self) -> usize {
3952        self.pending.len()
3953    }
3954
3955    pub fn applied_len(&self) -> usize {
3956        self.applied.len()
3957    }
3958
3959    pub fn active_turn_pending_len(&self) -> usize {
3960        if self.active_turn_pending_indices.is_empty() && !self.active_turn_pending_keys.is_empty()
3961        {
3962            return self
3963                .pending
3964                .iter()
3965                .filter(|append| {
3966                    append
3967                        .idempotency_key
3968                        .as_ref()
3969                        .is_some_and(|key| self.active_turn_pending_keys.contains(key))
3970                })
3971                .count();
3972        }
3973        self.active_turn_pending_indices.len()
3974    }
3975
3976    pub fn realtime_projection_appends(&self) -> Vec<PendingSystemContextAppend> {
3977        self.applied
3978            .iter()
3979            .chain(self.pending.iter())
3980            .cloned()
3981            .collect()
3982    }
3983
3984    /// Stage an append request, enforcing per-session idempotency.
3985    pub fn stage_append(
3986        &mut self,
3987        req: &AppendSystemContextRequest,
3988        accepted_at: SystemTime,
3989    ) -> Result<crate::service::AppendSystemContextStatus, SystemContextStageError> {
3990        system_context_authority::stage_append(self, req, accepted_at, false)
3991    }
3992
3993    fn stage_append_with_generated_authority(
3994        &mut self,
3995        req: &AppendSystemContextRequest,
3996        accepted_at: SystemTime,
3997        active_turn_scoped: bool,
3998    ) -> Result<crate::service::AppendSystemContextStatus, SystemContextStageError> {
3999        system_context_authority::stage_append(self, req, accepted_at, active_turn_scoped)
4000    }
4001
4002    /// Stage an append that is scoped to the currently-active turn only.
4003    ///
4004    /// If the active turn reaches another model boundary, normal pending
4005    /// consumption moves it to `applied`. If the turn completes first, callers
4006    /// should discard the still-pending active-turn keys so the context cannot
4007    /// leak into an unrelated later run.
4008    pub fn stage_active_turn_append(
4009        &mut self,
4010        req: &AppendSystemContextRequest,
4011        accepted_at: SystemTime,
4012    ) -> Result<crate::service::AppendSystemContextStatus, SystemContextStageError> {
4013        self.stage_append_with_generated_authority(req, accepted_at, true)
4014    }
4015
4016    /// Mark all currently-pending appends as applied and clear the pending queue.
4017    pub fn mark_pending_applied(&mut self) {
4018        system_context_authority::mark_pending_applied(self);
4019    }
4020
4021    /// Discard active-turn-only appends that were not consumed by the turn's
4022    /// next LLM boundary.
4023    pub fn discard_unapplied_active_turn_pending(&mut self) -> Vec<PendingSystemContextAppend> {
4024        system_context_authority::discard_unapplied_active_turn_pending(self)
4025    }
4026
4027    /// Discard specific active-turn-only appends that are still pending.
4028    ///
4029    /// This is the rollback companion for live-boundary staging. The runtime
4030    /// owns the accepted input, so if that commit fails after the session has
4031    /// staged context, the session-side projection must be removed by the same
4032    /// idempotency keys before the caller reports failure.
4033    pub fn discard_active_turn_pending_by_keys(
4034        &mut self,
4035        idempotency_keys: &[String],
4036    ) -> Vec<PendingSystemContextAppend> {
4037        system_context_authority::discard_active_turn_pending_by_keys(self, idempotency_keys)
4038    }
4039
4040    /// Authorize this snapshot through the canonical
4041    /// [`session_document::SessionDocumentMachine`] system-context restore
4042    /// transition, returning the state unchanged on success.
4043    pub fn restore_from_snapshot(self) -> Result<Self, SystemContextStageError> {
4044        system_context_authority::restore_system_context_state(self)
4045    }
4046
4047    /// Record the machine-authorized applied system-context blocks, returning
4048    /// the appends that are newly applied (and thus need rendering into the
4049    /// system prompt by the caller).
4050    pub fn record_applied_blocks(
4051        &mut self,
4052        appends: &[PendingSystemContextAppend],
4053        current_system_prompt: &str,
4054    ) -> Vec<PendingSystemContextAppend> {
4055        system_context_authority::record_applied_system_context_blocks(
4056            self,
4057            appends,
4058            current_system_prompt,
4059        )
4060    }
4061}
4062
4063/// Per-session registry key for the first-turn region of the
4064/// [`session_document::SessionDocumentMachine`]. Each
4065/// [`SessionDeferredTurnState`] is a single session's projection, so its
4066/// machine instance carries exactly one registry entry under this key.
4067const SESSION_DOCUMENT_FIRST_TURN_KEY: &str = "first_turn";
4068
4069fn usize_to_u64(value: usize) -> u64 {
4070    u64::try_from(value).unwrap_or(u64::MAX)
4071}
4072
4073/// Authorize a durable deferred-turn snapshot through the canonical
4074/// [`session_document::SessionDocumentMachine`] recovery transition.
4075///
4076/// The machine validates that the persisted first-turn phase is a legal
4077/// recovery target and adopts it into its per-session registry, emitting
4078/// `SessionFirstTurnPhaseRecovered`. The snapshot is returned unchanged on
4079/// success; the machine — not this shell — owns the recovery legality.
4080fn validate_deferred_turn_snapshot(
4081    state: SessionDeferredTurnState,
4082) -> Result<SessionDeferredTurnState, session_document::SessionDocumentError> {
4083    let mut authority = session_document::SessionDocumentMachineAuthority::new();
4084    let key = session_document::SessionDocumentKey::new(SESSION_DOCUMENT_FIRST_TURN_KEY);
4085    // The recovery transition fails closed for any illegal first-turn phase
4086    // (its guard admits only the three known phases); a rejection surfaces as
4087    // `Err` here. On success the machine has adopted the snapshot.
4088    authority.recover_session_first_turn_phase(
4089        key,
4090        state.first_turn_phase.into(),
4091        state.pending_initial_prompt.is_some(),
4092        usize_to_u64(state.pending_tool_results.len()),
4093    )?;
4094    Ok(state)
4095}
4096
4097impl SessionDeferredTurnState {
4098    pub fn first_turn_phase(&self) -> DeferredFirstTurnPhase {
4099        self.first_turn_phase
4100    }
4101
4102    pub fn pending_initial_prompt(&self) -> Option<&PendingDeferredPrompt> {
4103        self.pending_initial_prompt.as_ref()
4104    }
4105
4106    pub fn pending_tool_results(&self) -> &[PendingToolResultsMessage] {
4107        &self.pending_tool_results
4108    }
4109
4110    pub fn pending_tool_results_len(&self) -> usize {
4111        self.pending_tool_results.len()
4112    }
4113
4114    pub(crate) fn pending_initial_prompt_mut_for_blob_rewrite(
4115        &mut self,
4116    ) -> Option<&mut PendingDeferredPrompt> {
4117        self.pending_initial_prompt.as_mut()
4118    }
4119
4120    pub(crate) fn pending_tool_results_mut_for_blob_rewrite(
4121        &mut self,
4122    ) -> &mut [PendingToolResultsMessage] {
4123        &mut self.pending_tool_results
4124    }
4125
4126    /// Build a [`SessionDocumentMachineAuthority`] seeded with this session's
4127    /// current durable first-turn projection.
4128    ///
4129    /// The machine owns the canonical first-turn phase + presence/count in its
4130    /// own per-session `Map`; the durable [`SessionDeferredTurnState`] is its
4131    /// projection. We recover the machine-owned registry from that projection
4132    /// before driving an operation so every subsequent decision reads the
4133    /// machine's own state — the shell never passes a phase conclusion as an
4134    /// operation input.
4135    fn document_authority(
4136        &self,
4137    ) -> (
4138        session_document::SessionDocumentMachineAuthority,
4139        session_document::SessionDocumentKey,
4140    ) {
4141        let mut authority = session_document::SessionDocumentMachineAuthority::new();
4142        let key = session_document::SessionDocumentKey::new(SESSION_DOCUMENT_FIRST_TURN_KEY);
4143        if let Err(err) = authority.recover_session_first_turn_phase(
4144            key.clone(),
4145            self.first_turn_phase.into(),
4146            self.pending_initial_prompt.is_some(),
4147            usize_to_u64(self.pending_tool_results.len()),
4148        ) {
4149            tracing::warn!(
4150                error = %err,
4151                "generated session document authority rejected first-turn recovery"
4152            );
4153        }
4154        (authority, key)
4155    }
4156
4157    /// Mirror the machine-resolved first-turn phase from one effect batch onto
4158    /// the durable projection, returning `was_pending` when present.
4159    fn mirror_first_turn_phase(
4160        &mut self,
4161        effects: &[session_document::SessionDocumentEffect],
4162    ) -> Option<bool> {
4163        for effect in effects {
4164            if let session_document::SessionDocumentEffect::SessionFirstTurnPhaseResolved {
4165                phase,
4166                was_pending,
4167            } = effect
4168            {
4169                self.first_turn_phase = (*phase).into();
4170                return Some(*was_pending);
4171            }
4172        }
4173        None
4174    }
4175
4176    /// Mark that this session has a deferred first turn waiting to start.
4177    pub fn mark_initial_turn_pending(&mut self) {
4178        let (mut authority, key) = self.document_authority();
4179        match authority.mark_session_initial_turn_pending(key) {
4180            Ok(effects) => {
4181                self.mirror_first_turn_phase(&effects);
4182            }
4183            Err(err) => tracing::warn!(
4184                error = %err,
4185                "generated session document authority rejected pending mark"
4186            ),
4187        }
4188    }
4189
4190    /// Mark the deferred first turn as started.
4191    ///
4192    /// Returns true when the phase transitioned from `Pending`.
4193    pub fn mark_initial_turn_started(&mut self) -> bool {
4194        let (mut authority, key) = self.document_authority();
4195        match authority.start_session_initial_turn(key) {
4196            Ok(effects) => self.mirror_first_turn_phase(&effects).unwrap_or(false),
4197            Err(err) => {
4198                tracing::warn!(
4199                    error = %err,
4200                    "generated session document authority rejected first-turn start"
4201                );
4202                false
4203            }
4204        }
4205    }
4206
4207    /// Restore the deferred first-turn pending phase after a failed pre-run setup.
4208    pub fn restore_initial_turn_pending(&mut self) {
4209        // The restore-to-pending decision is the machine's
4210        // `RestoreSessionConsumedInputs` transition with phase rollback
4211        // requested; presence/count mirrors are left untouched here because the
4212        // bulky payloads are restored separately by the caller.
4213        let (mut authority, key) = self.document_authority();
4214        match authority.restore_session_consumed_inputs(
4215            key.clone(),
4216            true,
4217            self.pending_initial_prompt.is_some(),
4218            usize_to_u64(self.pending_tool_results.len()),
4219        ) {
4220            Ok(_) => {
4221                // Mirror the machine-owned phase the restore transition wrote
4222                // into its per-session registry rather than re-deriving it.
4223                if let Some(phase) = authority.session_first_turn_phase_for(&key) {
4224                    self.first_turn_phase = phase.into();
4225                }
4226            }
4227            Err(err) => tracing::warn!(
4228                error = %err,
4229                "generated session document authority rejected pending restore"
4230            ),
4231        }
4232    }
4233
4234    /// Whether build-only first-turn overrides are still legal for this session.
4235    pub fn allows_initial_turn_overrides(&self) -> bool {
4236        let (mut authority, key) = self.document_authority();
4237        match authority.resolve_session_first_turn_overrides_allowed(key) {
4238            Ok(effects) => effects
4239                .iter()
4240                .find_map(|effect| {
4241                    match effect {
4242                session_document::SessionDocumentEffect::SessionFirstTurnOverridesResolved {
4243                    allowed,
4244                } => Some(*allowed),
4245                _ => None,
4246            }
4247                })
4248                .unwrap_or(false),
4249            Err(err) => {
4250                tracing::warn!(
4251                    error = %err,
4252                    "generated session document authority rejected override resolution"
4253                );
4254                false
4255            }
4256        }
4257    }
4258
4259    /// Stage the create-time prompt for a later first turn.
4260    pub fn stage_initial_prompt(&mut self, prompt: ContentInput, accepted_at: SystemTime) {
4261        let prompt_has_content = prompt.has_images() || !prompt.text_content().trim().is_empty();
4262        let (mut authority, key) = self.document_authority();
4263        match authority.stage_session_initial_prompt(key, prompt_has_content) {
4264            Ok(effects) => {
4265                let decision = effects.iter().find_map(|effect| {
4266                    match effect {
4267                    session_document::SessionDocumentEffect::SessionInitialPromptStageResolved {
4268                        decision,
4269                    } => Some(*decision),
4270                    _ => None,
4271                }
4272                });
4273                match decision {
4274                    Some(session_document::SessionInitialPromptStageDecision::Store) => {
4275                        self.pending_initial_prompt = Some(PendingDeferredPrompt {
4276                            prompt,
4277                            accepted_at,
4278                        });
4279                    }
4280                    Some(session_document::SessionInitialPromptStageDecision::Clear) => {
4281                        self.pending_initial_prompt = None;
4282                    }
4283                    None => tracing::warn!(
4284                        "generated session document authority returned no prompt-stage decision"
4285                    ),
4286                }
4287            }
4288            Err(err) => tracing::warn!(
4289                error = %err,
4290                "generated session document authority rejected initial prompt stage"
4291            ),
4292        }
4293    }
4294
4295    /// Stage one callback tool-results message for the next turn.
4296    pub fn try_stage_tool_results(
4297        &mut self,
4298        results: Vec<ToolResult>,
4299        accepted_at: SystemTime,
4300    ) -> Result<usize, DeferredToolResultsIngressError> {
4301        let mut incoming_by_id = BTreeMap::new();
4302        for result in &results {
4303            if incoming_by_id
4304                .insert(result.tool_use_id.clone(), result)
4305                .is_some()
4306            {
4307                return Err(DeferredToolResultsIngressError::DuplicateToolUseId(
4308                    result.tool_use_id.clone(),
4309                ));
4310            }
4311        }
4312
4313        let mut staged_by_id = BTreeMap::new();
4314        for pending in &self.pending_tool_results {
4315            for result in &pending.results {
4316                match staged_by_id.insert(result.tool_use_id.clone(), result) {
4317                    Some(previous) if previous != result => {
4318                        return Err(DeferredToolResultsIngressError::ConflictingRedelivery(
4319                            result.tool_use_id.clone(),
4320                        ));
4321                    }
4322                    _ => {}
4323                }
4324            }
4325        }
4326        if !staged_by_id.is_empty() {
4327            for (id, incoming) in &incoming_by_id {
4328                match staged_by_id.get(id) {
4329                    Some(staged) if *staged == *incoming => {}
4330                    Some(_) => {
4331                        return Err(DeferredToolResultsIngressError::ConflictingRedelivery(
4332                            id.clone(),
4333                        ));
4334                    }
4335                    None => {
4336                        return Err(DeferredToolResultsIngressError::WrongToolUseId(id.clone()));
4337                    }
4338                }
4339            }
4340            return Ok(0);
4341        }
4342
4343        let (mut authority, key) = self.document_authority();
4344        let accepted = match authority.stage_session_tool_results(key, usize_to_u64(results.len()))
4345        {
4346            Ok(effects) => effects.iter().find_map(|effect| match effect {
4347                session_document::SessionDocumentEffect::SessionToolResultsStageResolved {
4348                    accepted_count,
4349                } => Some(*accepted_count),
4350                _ => None,
4351            }),
4352            Err(err) => {
4353                tracing::warn!(
4354                    error = %err,
4355                    "generated session document authority rejected tool-results stage"
4356                );
4357                return Ok(0);
4358            }
4359        };
4360        let Some(accepted) = accepted else {
4361            tracing::warn!(
4362                "generated session document authority returned no tool-results decision"
4363            );
4364            return Ok(0);
4365        };
4366        if accepted == 0 {
4367            return Ok(0);
4368        }
4369        let accepted = usize::try_from(accepted).unwrap_or(usize::MAX);
4370        self.pending_tool_results.push(PendingToolResultsMessage {
4371            results,
4372            accepted_at,
4373        });
4374        Ok(accepted)
4375    }
4376
4377    /// Compatibility projection for callers that cannot surface a typed
4378    /// ingress refusal. Public session-service ingress uses
4379    /// [`Self::try_stage_tool_results`] and preserves the error.
4380    pub fn stage_tool_results(
4381        &mut self,
4382        results: Vec<ToolResult>,
4383        accepted_at: SystemTime,
4384    ) -> usize {
4385        match self.try_stage_tool_results(results, accepted_at) {
4386            Ok(accepted) => accepted,
4387            Err(error) => {
4388                tracing::warn!(%error, "deferred callback-result ingress was rejected");
4389                0
4390            }
4391        }
4392    }
4393
4394    /// Whether any callback tool results are currently staged.
4395    pub fn has_pending_tool_results(&self) -> bool {
4396        !self.pending_tool_results.is_empty()
4397    }
4398
4399    /// Start a turn and consume all inputs generated-authorized for that seam.
4400    pub fn consume_for_started_turn(&mut self) -> ConsumedDeferredTurnInputs {
4401        let (mut authority, key) = self.document_authority();
4402        let was_pending = match authority.consume_session_deferred_inputs(key) {
4403            Ok(effects) => self.mirror_first_turn_phase(&effects).unwrap_or(false),
4404            Err(err) => {
4405                tracing::warn!(
4406                    error = %err,
4407                    "generated session document authority rejected started-turn consumption"
4408                );
4409                return ConsumedDeferredTurnInputs::default();
4410            }
4411        };
4412        ConsumedDeferredTurnInputs {
4413            restore_first_turn_pending: was_pending,
4414            pending_initial_prompt: self.pending_initial_prompt.take(),
4415            pending_tool_results: std::mem::take(&mut self.pending_tool_results),
4416        }
4417    }
4418
4419    /// Restore inputs previously consumed by `consume_for_started_turn`.
4420    pub fn restore_consumed_turn_inputs(&mut self, consumed: ConsumedDeferredTurnInputs) {
4421        if consumed.is_empty() {
4422            return;
4423        }
4424        let (mut authority, key) = self.document_authority();
4425        let effects = match authority.restore_session_consumed_inputs(
4426            key,
4427            consumed.restore_first_turn_pending,
4428            consumed.pending_initial_prompt.is_some(),
4429            usize_to_u64(consumed.pending_tool_results.len()),
4430        ) {
4431            Ok(effects) => effects,
4432            Err(err) => {
4433                tracing::warn!(
4434                    error = %err,
4435                    "generated session document authority rejected consumed input restore"
4436                );
4437                return;
4438            }
4439        };
4440        let Some((restore_first_turn_pending, restore_initial_prompt, restore_tool_results)) =
4441            effects.iter().find_map(|effect| match effect {
4442                session_document::SessionDocumentEffect::SessionConsumedInputsRestoreResolved {
4443                    restore_first_turn_pending,
4444                    restore_initial_prompt,
4445                    restore_tool_results,
4446                } => Some((
4447                    *restore_first_turn_pending,
4448                    *restore_initial_prompt,
4449                    *restore_tool_results,
4450                )),
4451                _ => None,
4452            })
4453        else {
4454            tracing::warn!(
4455                "generated session document authority returned no consumed-input restore decision"
4456            );
4457            return;
4458        };
4459        if restore_first_turn_pending {
4460            self.restore_initial_turn_pending();
4461        }
4462        if restore_initial_prompt && self.pending_initial_prompt.is_none() {
4463            self.pending_initial_prompt = consumed.pending_initial_prompt;
4464        }
4465        if restore_tool_results {
4466            let mut restored = consumed.pending_tool_results;
4467            restored.extend(std::mem::take(&mut self.pending_tool_results));
4468            self.pending_tool_results = restored;
4469        }
4470    }
4471}
4472
4473/// Failure when staging a system-context append request.
4474#[derive(Debug, Clone, PartialEq, Eq)]
4475pub enum SystemContextStageError {
4476    InvalidRequest(String),
4477    Conflict {
4478        key: String,
4479        existing_text: String,
4480        existing_source: Option<String>,
4481    },
4482}
4483
4484impl std::fmt::Display for SystemContextStageError {
4485    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
4486        match self {
4487            Self::InvalidRequest(message) => {
4488                write!(f, "invalid system-context append request: {message}")
4489            }
4490            Self::Conflict { key, .. } => {
4491                write!(
4492                    f,
4493                    "system-context append conflict for idempotency key `{key}`"
4494                )
4495            }
4496        }
4497    }
4498}
4499
4500impl std::error::Error for SystemContextStageError {}
4501
4502/// Mechanical PRESENTATION helper: render a system-context append into the
4503/// display block string that is concatenated into the model-facing system
4504/// prompt. This is NOT a decision — it builds the `[Runtime System Context]`
4505/// label text for OUTPUT only. The authority for which appends to render and
4506/// whether one is a runtime steer lives in the
4507/// [`session_document::SessionDocumentMachine`]; this function never inspects
4508/// the `source` string to classify anything.
4509fn render_system_context_block(append: &PendingSystemContextAppend) -> String {
4510    let mut rendered = String::from(SYSTEM_CONTEXT_RENDER_LABEL);
4511    if let Some(source) = &append.source {
4512        rendered.push_str("\nsource: ");
4513        rendered.push_str(source);
4514    }
4515    rendered.push_str("\n\n");
4516    // The single CoreRenderable -> prompt-text lowering for system-context
4517    // appends. Surfaces carry the typed renderable through untouched.
4518    rendered.push_str(append.content.render_text().trim());
4519    rendered
4520}
4521
4522/// Display label prefix for a rendered runtime system-context block.
4523///
4524/// PRESENTATION only — this is the human/model-facing heading, not a
4525/// classification key. Nothing reads this back to make a semantic decision.
4526const SYSTEM_CONTEXT_RENDER_LABEL: &str = "[Runtime System Context]";
4527
4528/// Render a sequence of system-context appends into the
4529/// [`SYSTEM_CONTEXT_SEPARATOR`]-joined block text that
4530/// [`Session::append_system_context_blocks`] concatenates onto the system
4531/// prompt. The single composition rule — shared by the append path and the
4532/// resume-time tail verification so the two can never drift apart.
4533fn render_system_context_blocks_joined(appends: &[PendingSystemContextAppend]) -> String {
4534    appends
4535        .iter()
4536        .map(render_system_context_block)
4537        .collect::<Vec<_>>()
4538        .join(SYSTEM_CONTEXT_SEPARATOR)
4539}
4540
4541/// Compose a system prompt from a base and a verified runtime-context tail
4542/// (leading [`SYSTEM_CONTEXT_SEPARATOR`] included; empty = no tail),
4543/// mirroring [`Session::append_system_context_blocks`]' rule that an empty
4544/// base renders the blocks without a separator prefix.
4545fn compose_system_prompt_with_context_tail(base: &str, tail: &str) -> String {
4546    if tail.is_empty() {
4547        return base.to_string();
4548    }
4549    if base.is_empty() {
4550        return tail
4551            .strip_prefix(SYSTEM_CONTEXT_SEPARATOR)
4552            .unwrap_or(tail)
4553            .to_string();
4554    }
4555    format!("{base}{tail}")
4556}
4557
4558/// Drive the canonical [`session_document::SessionDocumentMachine`]
4559/// persist-append admission for the resume fast path: may the persisted
4560/// System prompt be admitted as a runtime-context-append continuation of the
4561/// freshly assembled base?
4562///
4563/// Mirrors the save-guard shell (`session_store::system_context_is_append`):
4564/// this extracts only pure structural observations plus the typed
4565/// [`crate::types::SystemPromptMutationKind`] provenance; the machine owns
4566/// the verdict. A machine error fails closed — the caller falls back to the
4567/// audited rewrite path.
4568fn persisted_prompt_is_admitted_context_append_continuation(
4569    assembled_base: &str,
4570    persisted_content: &str,
4571    persisted_mutation_kind: crate::types::SystemPromptMutationKind,
4572) -> bool {
4573    let content_identical = persisted_content == assembled_base;
4574    let content_extends = persisted_content.starts_with(assembled_base);
4575    let appended_starts_with_separator = content_extends
4576        && persisted_content[assembled_base.len()..].starts_with(SYSTEM_CONTEXT_SEPARATOR);
4577    let mut authority = session_document::SessionDocumentMachineAuthority::new();
4578    match authority.resolve_system_context_persist_append_admission(
4579        true,
4580        content_identical,
4581        content_extends,
4582        appended_starts_with_separator,
4583        persisted_mutation_kind.is_runtime_context_append(),
4584    ) {
4585        Ok(effects) => effects.into_iter().any(|effect| {
4586            matches!(
4587                effect,
4588                session_document::SessionDocumentEffect::SystemContextPersistAppendAdmissionResolved {
4589                    admission: session_document::SystemContextPersistAppendAdmission::Admit,
4590                }
4591            )
4592        }),
4593        Err(error) => {
4594            tracing::warn!(
4595                error = %error,
4596                "session document authority refused resume prompt continuation admission; \
4597                 falling back to audited rewrite"
4598            );
4599            false
4600        }
4601    }
4602}
4603
4604/// Shell adapter that drives the canonical
4605/// [`session_document::SessionDocumentMachine`] system-context region and
4606/// mirrors its emitted decisions onto the bulky `SessionSystemContextState`.
4607///
4608/// The machine owns every SEMANTIC decision (append disposition, per-append
4609/// apply/discard from the typed [`SystemContextSource`] marker, snapshot
4610/// restore legality). This module performs only the mechanical collection
4611/// work — iterating the shell's pending/applied/seen collections and applying
4612/// the machine's per-item verdict. It never decides; in particular it never
4613/// inspects a `source` string to classify a runtime steer.
4614mod system_context_authority {
4615    use super::{
4616        AppendSystemContextRequest, BTreeSet, PendingSystemContextAppend, SeenSystemContextKey,
4617        SeenSystemContextState, SessionSystemContextState, SystemContextSource,
4618        SystemContextStageError, SystemTime, render_system_context_block, session_document,
4619        usize_to_u64,
4620    };
4621    use crate::service::AppendSystemContextStatus;
4622
4623    fn document_authority() -> session_document::SessionDocumentMachineAuthority {
4624        session_document::SessionDocumentMachineAuthority::new()
4625    }
4626
4627    /// Resolve the four-way append disposition through the machine.
4628    fn resolve_append_decision(
4629        trimmed_text_byte_count: u64,
4630        idempotency_key_present: bool,
4631        existing_key_matches: bool,
4632        existing_key_conflicts: bool,
4633        active_turn_scoped: bool,
4634    ) -> Result<session_document::SystemContextAppendDecision, SystemContextStageError> {
4635        let mut authority = document_authority();
4636        let effects = authority
4637            .resolve_system_context_append(
4638                trimmed_text_byte_count,
4639                idempotency_key_present,
4640                existing_key_matches,
4641                existing_key_conflicts,
4642                active_turn_scoped,
4643            )
4644            .map_err(|err| SystemContextStageError::InvalidRequest(err.to_string()))?;
4645        effects
4646            .into_iter()
4647            .find_map(|effect| match effect {
4648                session_document::SessionDocumentEffect::SystemContextAppendResolved {
4649                    decision,
4650                    ..
4651                } => Some(decision),
4652                _ => None,
4653            })
4654            .ok_or_else(|| {
4655                SystemContextStageError::InvalidRequest(
4656                    "generated session document authority returned no append decision".to_string(),
4657                )
4658            })
4659    }
4660
4661    /// Per-pending-append apply verdict, decided by the machine from the typed
4662    /// `source_kind` marker (NOT a `source` string prefix).
4663    fn pending_apply_item(source_kind: SystemContextSource) -> Option<(bool, bool, bool)> {
4664        let mut authority = document_authority();
4665        match authority.resolve_system_context_pending_apply_item(source_kind.into()) {
4666            Ok(effects) => effects.into_iter().find_map(|effect| {
4667                match effect {
4668                session_document::SessionDocumentEffect::SystemContextPendingApplyItemResolved {
4669                    promote_to_applied,
4670                    mark_seen_applied,
4671                    remove_seen,
4672                } => Some((promote_to_applied, mark_seen_applied, remove_seen)),
4673                _ => None,
4674            }
4675            }),
4676            Err(err) => {
4677                tracing::warn!(
4678                    error = %err,
4679                    "generated session document authority rejected system-context apply item"
4680                );
4681                None
4682            }
4683        }
4684    }
4685
4686    /// Per-item transient-steer discard verdict, decided by the machine from
4687    /// the typed `source_kind` marker.
4688    fn steer_cleanup_discards(source_kind: SystemContextSource) -> bool {
4689        let mut authority = document_authority();
4690        match authority.resolve_system_context_steer_cleanup_item(source_kind.into()) {
4691            Ok(effects) => effects
4692                .into_iter()
4693                .find_map(|effect| {
4694                    match effect {
4695                    session_document::SessionDocumentEffect::SystemContextSteerCleanupItemResolved {
4696                        discard,
4697                    } => Some(discard),
4698                    _ => None,
4699                }
4700                })
4701                .unwrap_or(false),
4702            Err(err) => {
4703                tracing::warn!(
4704                    error = %err,
4705                    "generated session document authority rejected system-context steer cleanup item"
4706                );
4707                false
4708            }
4709        }
4710    }
4711
4712    fn discard_pending_where(
4713        state: &mut SessionSystemContextState,
4714        mut should_discard: impl FnMut(&PendingSystemContextAppend, bool) -> bool,
4715    ) -> Vec<PendingSystemContextAppend> {
4716        reconstruct_legacy_active_turn_indices(state);
4717        let active_indices = std::mem::take(&mut state.active_turn_pending_indices);
4718        let pending = std::mem::take(&mut state.pending);
4719        let mut retained = Vec::with_capacity(pending.len());
4720        let mut retained_active_indices = BTreeSet::new();
4721        let mut retained_active_keys = BTreeSet::new();
4722        let mut discarded = Vec::new();
4723
4724        for (index, append) in pending.into_iter().enumerate() {
4725            let is_active_turn = active_indices.contains(&usize_to_u64(index));
4726            if should_discard(&append, is_active_turn) {
4727                discarded.push(append);
4728                continue;
4729            }
4730            if is_active_turn {
4731                retained_active_indices.insert(usize_to_u64(retained.len()));
4732                if let Some(key) = append.idempotency_key.as_ref() {
4733                    retained_active_keys.insert(key.clone());
4734                }
4735            }
4736            retained.push(append);
4737        }
4738
4739        state.pending = retained;
4740        state.active_turn_pending_indices = retained_active_indices;
4741        state.active_turn_pending_keys = retained_active_keys;
4742        discarded
4743    }
4744
4745    fn reconstruct_legacy_active_turn_indices(state: &mut SessionSystemContextState) {
4746        if !state.active_turn_pending_indices.is_empty()
4747            || state.active_turn_pending_keys.is_empty()
4748        {
4749            return;
4750        }
4751        state.active_turn_pending_indices = state
4752            .pending
4753            .iter()
4754            .enumerate()
4755            .filter(|(_index, append)| {
4756                append
4757                    .idempotency_key
4758                    .as_ref()
4759                    .is_some_and(|key| state.active_turn_pending_keys.contains(key))
4760            })
4761            .map(|(index, _append)| usize_to_u64(index))
4762            .collect();
4763    }
4764
4765    pub(super) fn restore_system_context_state(
4766        mut state: SessionSystemContextState,
4767    ) -> Result<SessionSystemContextState, SystemContextStageError> {
4768        // Backward compatibility for snapshots written before active-turn
4769        // membership had an identity independent of idempotency. Keyed
4770        // members can be reconstructed exactly from the pending queue.
4771        reconstruct_legacy_active_turn_indices(&mut state);
4772        let active_indices_are_in_bounds = state
4773            .active_turn_pending_indices
4774            .iter()
4775            .all(|index| usize::try_from(*index).is_ok_and(|index| index < state.pending.len()));
4776        let active_keys_have_indexed_pending = state.active_turn_pending_keys.iter().all(|key| {
4777            state.active_turn_pending_indices.iter().any(|index| {
4778                usize::try_from(*index)
4779                    .ok()
4780                    .and_then(|index| state.pending.get(index))
4781                    .and_then(|append| append.idempotency_key.as_ref())
4782                    == Some(key)
4783            })
4784        });
4785        let indexed_pending_keys_are_active =
4786            state.active_turn_pending_indices.iter().all(|index| {
4787                usize::try_from(*index)
4788                    .ok()
4789                    .and_then(|index| state.pending.get(index))
4790                    .is_some_and(|append| {
4791                        append
4792                            .idempotency_key
4793                            .as_ref()
4794                            .is_none_or(|key| state.active_turn_pending_keys.contains(key))
4795                    })
4796            });
4797        let active_turn_membership_is_consistent = active_indices_are_in_bounds
4798            && active_keys_have_indexed_pending
4799            && indexed_pending_keys_are_active;
4800        let seen_keys_match_known_appends = state.seen.iter().all(|(key, seen)| {
4801            state
4802                .pending
4803                .iter()
4804                .chain(state.applied.iter())
4805                .any(|append| {
4806                    append.idempotency_key.as_ref() == Some(key)
4807                        && seen.content == append.content
4808                        && seen.source.as_deref() == append.source.as_deref()
4809                })
4810        });
4811        let mut authority = document_authority();
4812        authority
4813            .restore_system_context_snapshot(
4814                active_turn_membership_is_consistent,
4815                seen_keys_match_known_appends,
4816            )
4817            .map_err(|err| SystemContextStageError::InvalidRequest(err.to_string()))?;
4818        Ok(state)
4819    }
4820
4821    pub(super) fn stage_append(
4822        state: &mut SessionSystemContextState,
4823        req: &AppendSystemContextRequest,
4824        accepted_at: SystemTime,
4825        active_turn_scoped: bool,
4826    ) -> Result<AppendSystemContextStatus, SystemContextStageError> {
4827        // Emptiness is judged on the canonical text projection; the typed
4828        // renderable itself is what gets stored (lowering happens once, at
4829        // the transcript render seam).
4830        let rendered_text = req.content.render_text();
4831        let rendered_len = rendered_text.trim().len();
4832        let existing = req
4833            .idempotency_key
4834            .as_ref()
4835            .and_then(|key| state.seen.get(key));
4836        let existing_key_matches = existing.is_some_and(|existing| {
4837            existing.content == req.content && existing.source.as_deref() == req.source.as_deref()
4838        });
4839        let existing_key_conflicts = existing.is_some() && !existing_key_matches;
4840        let decision = resolve_append_decision(
4841            usize_to_u64(rendered_len),
4842            req.idempotency_key.is_some(),
4843            existing_key_matches,
4844            existing_key_conflicts,
4845            active_turn_scoped,
4846        )?;
4847
4848        match decision {
4849            session_document::SystemContextAppendDecision::RejectEmpty => {
4850                return Err(SystemContextStageError::InvalidRequest(
4851                    "system context text must not be empty".to_string(),
4852                ));
4853            }
4854            session_document::SystemContextAppendDecision::RejectConflict => {
4855                let Some(key) = req.idempotency_key.as_ref() else {
4856                    return Err(SystemContextStageError::InvalidRequest(
4857                        "generated system-context authority rejected append without a key"
4858                            .to_string(),
4859                    ));
4860                };
4861                let Some(existing) = existing else {
4862                    return Err(SystemContextStageError::InvalidRequest(
4863                        "generated system-context authority rejected append without a conflict"
4864                            .to_string(),
4865                    ));
4866                };
4867                return Err(SystemContextStageError::Conflict {
4868                    key: key.clone(),
4869                    existing_text: existing.content.render_text(),
4870                    existing_source: existing.source.clone(),
4871                });
4872            }
4873            session_document::SystemContextAppendDecision::Duplicate => {
4874                return Ok(AppendSystemContextStatus::Duplicate);
4875            }
4876            session_document::SystemContextAppendDecision::Staged => {}
4877        }
4878
4879        let append = PendingSystemContextAppend {
4880            content: req.content.clone(),
4881            source: req.source.clone(),
4882            idempotency_key: req.idempotency_key.clone(),
4883            source_kind: req.source_kind,
4884            // Carry the typed `PeerResponseTerminalFact` so realtime/live
4885            // consumers read it directly instead of re-parsing the flattened
4886            // prompt text. Mirrors the `source_kind` typed-provenance precedent.
4887            peer_response_terminal: req.peer_response_terminal.clone(),
4888            accepted_at,
4889        };
4890        if let Some(key) = req.idempotency_key.as_ref() {
4891            state.seen.insert(
4892                key.clone(),
4893                SeenSystemContextKey {
4894                    content: append.content.clone(),
4895                    source: append.source.clone(),
4896                    source_kind: append.source_kind,
4897                    state: SeenSystemContextState::Pending,
4898                },
4899            );
4900        }
4901        if active_turn_scoped {
4902            state
4903                .active_turn_pending_indices
4904                .insert(usize_to_u64(state.pending.len()));
4905            if let Some(key) = req.idempotency_key.as_ref() {
4906                state.active_turn_pending_keys.insert(key.clone());
4907            }
4908        }
4909        state.pending.push(append);
4910        Ok(AppendSystemContextStatus::Staged)
4911    }
4912
4913    pub(super) fn mark_pending_applied(state: &mut SessionSystemContextState) {
4914        // Promote pending appends to applied per the machine's per-item
4915        // verdict (keyed on the typed `source_kind`).
4916        let pending = std::mem::take(&mut state.pending);
4917        let mut seen_to_remove = Vec::new();
4918        for append in &pending {
4919            let Some((promote_to_applied, mark_seen_applied, remove_seen)) =
4920                pending_apply_item(append.source_kind)
4921            else {
4922                continue;
4923            };
4924            if promote_to_applied && !state.applied.contains(append) {
4925                state.applied.push(append.clone());
4926            }
4927            if let Some(key) = append.idempotency_key.as_ref() {
4928                if remove_seen {
4929                    seen_to_remove.push(key.clone());
4930                } else if mark_seen_applied && let Some(seen) = state.seen.get_mut(key) {
4931                    seen.state = SeenSystemContextState::Applied;
4932                }
4933            }
4934        }
4935        for key in seen_to_remove {
4936            state.seen.remove(&key);
4937        }
4938        state.active_turn_pending_keys.clear();
4939        state.active_turn_pending_indices.clear();
4940    }
4941
4942    pub(super) fn discard_unapplied_active_turn_pending(
4943        state: &mut SessionSystemContextState,
4944    ) -> Vec<PendingSystemContextAppend> {
4945        reconstruct_legacy_active_turn_indices(state);
4946        if state.active_turn_pending_indices.is_empty() {
4947            return Vec::new();
4948        }
4949        let discarded = discard_pending_where(state, |_append, is_active_turn| is_active_turn);
4950
4951        for append in &discarded {
4952            if let Some(key) = append.idempotency_key.as_ref()
4953                && state
4954                    .seen
4955                    .get(key)
4956                    .is_some_and(|seen| seen.state == SeenSystemContextState::Pending)
4957            {
4958                state.seen.remove(key);
4959            }
4960        }
4961
4962        discarded
4963    }
4964
4965    pub(super) fn discard_active_turn_pending_by_keys(
4966        state: &mut SessionSystemContextState,
4967        idempotency_keys: &[String],
4968    ) -> Vec<PendingSystemContextAppend> {
4969        reconstruct_legacy_active_turn_indices(state);
4970        if idempotency_keys.is_empty() || state.active_turn_pending_indices.is_empty() {
4971            return Vec::new();
4972        }
4973        let requested_keys: BTreeSet<&str> = idempotency_keys.iter().map(String::as_str).collect();
4974        let discarded = discard_pending_where(state, |append, is_active_turn| {
4975            is_active_turn
4976                && append
4977                    .idempotency_key
4978                    .as_ref()
4979                    .is_some_and(|key| requested_keys.contains(key.as_str()))
4980        });
4981
4982        for append in &discarded {
4983            let Some(key) = append.idempotency_key.as_ref() else {
4984                continue;
4985            };
4986            if state
4987                .seen
4988                .get(key)
4989                .is_some_and(|seen| seen.state == SeenSystemContextState::Pending)
4990            {
4991                state.seen.remove(key);
4992            }
4993        }
4994
4995        discarded
4996    }
4997
4998    pub(super) fn discard_transient_runtime_steer_state(
4999        state: &mut SessionSystemContextState,
5000    ) -> usize {
5001        let mut removed = 0usize;
5002
5003        let before_active = state.active_turn_pending_keys.len();
5004        removed += discard_pending_where(state, |append, _is_active_turn| {
5005            steer_cleanup_discards(append.source_kind)
5006        })
5007        .len();
5008
5009        let before_applied = state.applied.len();
5010        state
5011            .applied
5012            .retain(|append| !steer_cleanup_discards(append.source_kind));
5013        removed += before_applied.saturating_sub(state.applied.len());
5014
5015        let before_seen = state.seen.len();
5016        state
5017            .seen
5018            .retain(|_key, seen| !steer_cleanup_discards(seen.source_kind));
5019        removed += before_seen.saturating_sub(state.seen.len());
5020
5021        removed += before_active.saturating_sub(state.active_turn_pending_keys.len());
5022
5023        removed
5024    }
5025
5026    pub(super) fn remove_runtime_steer_blocks_for_rendered(
5027        system_prompt: &str,
5028        runtime_steer_appends: &[PendingSystemContextAppend],
5029    ) -> (String, usize) {
5030        if runtime_steer_appends.is_empty() {
5031            return (system_prompt.to_string(), 0);
5032        }
5033        // Build the set of rendered blocks for the typed runtime-steer appends,
5034        // then remove those exact rendered blocks from the prompt. The typed
5035        // marker is the authority; rendering is mechanical presentation.
5036        let steer_blocks: BTreeSet<String> = runtime_steer_appends
5037            .iter()
5038            .map(render_system_context_block)
5039            .collect();
5040        let parts = system_prompt
5041            .split(super::SYSTEM_CONTEXT_SEPARATOR)
5042            .map(str::to_string)
5043            .collect::<Vec<_>>();
5044        let original_len = parts.len();
5045        let retained = parts
5046            .into_iter()
5047            .filter(|part| !steer_blocks.contains(part))
5048            .collect::<Vec<_>>();
5049        let removed = original_len.saturating_sub(retained.len());
5050        (retained.join(super::SYSTEM_CONTEXT_SEPARATOR), removed)
5051    }
5052
5053    pub(super) fn record_applied_system_context_blocks(
5054        state: &mut SessionSystemContextState,
5055        appends: &[PendingSystemContextAppend],
5056        current_system_prompt: &str,
5057    ) -> Vec<PendingSystemContextAppend> {
5058        let mut new_appends: Vec<PendingSystemContextAppend> = Vec::new();
5059        for append in appends {
5060            if append.content.render_text().trim().is_empty() {
5061                continue;
5062            }
5063            let rendered = render_system_context_block(append);
5064            if let Some(key) = append.idempotency_key.as_ref() {
5065                if let Some(existing) = state.seen.get(key)
5066                    && !seen_system_context_matches(existing, append)
5067                {
5068                    tracing::warn!(
5069                        idempotency_key = %key,
5070                        "skipping conflicting runtime system-context append"
5071                    );
5072                    continue;
5073                }
5074                if let Some(existing) = state
5075                    .applied
5076                    .iter()
5077                    .find(|applied| applied.idempotency_key.as_ref() == Some(key))
5078                    && !pending_system_context_matches(existing, append)
5079                {
5080                    tracing::warn!(
5081                        idempotency_key = %key,
5082                        "skipping conflicting runtime system-context append"
5083                    );
5084                    continue;
5085                }
5086                if let Some(existing) = new_appends
5087                    .iter()
5088                    .find(|pending| pending.idempotency_key.as_ref() == Some(key))
5089                {
5090                    if !pending_system_context_matches(existing, append) {
5091                        tracing::warn!(
5092                            idempotency_key = %key,
5093                            "skipping conflicting runtime system-context append"
5094                        );
5095                    }
5096                    continue;
5097                }
5098                if current_system_prompt.contains(&rendered) {
5099                    record_applied_append(state, append);
5100                    continue;
5101                }
5102            } else if new_appends.contains(append) || current_system_prompt.contains(&rendered) {
5103                continue;
5104            }
5105            record_applied_append(state, append);
5106            new_appends.push(append.clone());
5107        }
5108        new_appends
5109    }
5110
5111    fn record_applied_append(
5112        state: &mut SessionSystemContextState,
5113        append: &PendingSystemContextAppend,
5114    ) {
5115        if let Some(key) = append.idempotency_key.as_ref() {
5116            state.seen.insert(
5117                key.clone(),
5118                SeenSystemContextKey {
5119                    content: append.content.clone(),
5120                    source: append.source.clone(),
5121                    source_kind: append.source_kind,
5122                    state: SeenSystemContextState::Applied,
5123                },
5124            );
5125            if state
5126                .applied
5127                .iter()
5128                .any(|applied| applied.idempotency_key.as_ref() == Some(key))
5129            {
5130                return;
5131            }
5132        } else if state.applied.contains(append) {
5133            return;
5134        }
5135        state.applied.push(append.clone());
5136    }
5137
5138    fn seen_system_context_matches(
5139        seen: &SeenSystemContextKey,
5140        append: &PendingSystemContextAppend,
5141    ) -> bool {
5142        seen.content == append.content && seen.source.as_deref() == append.source.as_deref()
5143    }
5144
5145    fn pending_system_context_matches(
5146        existing: &PendingSystemContextAppend,
5147        append: &PendingSystemContextAppend,
5148    ) -> bool {
5149        existing.content == append.content && existing.source.as_deref() == append.source.as_deref()
5150    }
5151}
5152
5153impl Session {
5154    /// Validate callback-result ingress against the exact durable callback
5155    /// batch without mutating transcript or deferred-turn state.
5156    #[doc(hidden)]
5157    pub fn classify_callback_result_ingress(
5158        &self,
5159        incoming: &[ToolResult],
5160    ) -> Result<CallbackResultIngress, crate::error::AgentError> {
5161        match self
5162            .resolve_pending_callback_tool_results(incoming.to_vec())
5163            .map_err(|error| {
5164                crate::error::AgentError::ConfigError(format!(
5165                    "callback result ingress was rejected: {error}"
5166                ))
5167            })? {
5168            ResolvedPendingCallbackToolResults::NoState => {
5169                Ok(CallbackResultIngress::NoPendingBatch)
5170            }
5171            ResolvedPendingCallbackToolResults::AlreadyApplied { .. } => {
5172                Ok(CallbackResultIngress::AlreadyApplied)
5173            }
5174            ResolvedPendingCallbackToolResults::Pending { batch, .. } => {
5175                Ok(CallbackResultIngress::Pending {
5176                    pending_tool_use_ids: batch.pending_tool_use_ids,
5177                })
5178            }
5179        }
5180    }
5181
5182    /// Create a new empty session
5183    pub fn new() -> Self {
5184        let now = SystemTime::now();
5185        Self {
5186            version: session_version(),
5187            id: SessionId::new(),
5188            messages: TranscriptMessages::default(),
5189            created_at: now,
5190            updated_at: now,
5191            metadata: serde_json::Map::new(),
5192            history_caches: Box::default(),
5193            transcript_history_metadata_validation: TranscriptHistoryMetadataValidation::Validated,
5194            usage: Usage::default(),
5195        }
5196    }
5197
5198    /// Create a session with a specific ID (for loading)
5199    pub fn with_id(id: SessionId) -> Self {
5200        let mut session = Self::new();
5201        session.id = id;
5202        session
5203    }
5204
5205    /// Get the session ID
5206    pub fn id(&self) -> &SessionId {
5207        &self.id
5208    }
5209
5210    /// Get the session version
5211    pub fn version(&self) -> u32 {
5212        self.version
5213    }
5214
5215    /// Get all messages.
5216    pub fn messages(&self) -> &[Message] {
5217        &self.messages
5218    }
5219
5220    /// Format-2 content digest of the live transcript.
5221    ///
5222    /// Byte-identical to `transcript_messages_digest(session.messages())` —
5223    /// same canonicalization, same bytes, same string — but served from the
5224    /// session's retained SHA-256 midstate when one covers the current buffer,
5225    /// so an ordinary append costs O(delta) instead of O(document). Prefer
5226    /// this over the free function anywhere a `Session` is in hand; the free
5227    /// function stays for slices that no session owns (revision bodies,
5228    /// candidate vectors).
5229    pub fn transcript_content_digest(&self) -> Result<String, serde_json::Error> {
5230        self.messages.digest()
5231    }
5232
5233    /// Format-2 content digest of the first `count` live messages.
5234    ///
5235    /// Served from the boundary ring when a previous full digest was taken at
5236    /// exactly that count — which is the save-guard prefix question — and by
5237    /// full recompute otherwise.
5238    pub fn transcript_prefix_digest(&self, count: usize) -> Result<String, serde_json::Error> {
5239        if count > self.messages.len() {
5240            // Fail closed rather than silently digesting a shorter prefix: a
5241            // caller asking past the end has lost track of which row it is
5242            // comparing against, and answering with a different prefix's
5243            // digest would launder that into a continuity verdict.
5244            return Err(<serde_json::Error as serde::ser::Error>::custom(format!(
5245                "transcript prefix digest requested for {count} messages but the transcript has {}",
5246                self.messages.len()
5247            )));
5248        }
5249        if let Some(witness) = self.messages.prefix_digest_witness(count) {
5250            return Ok(witness);
5251        }
5252        transcript_messages_digest(&self.messages[..count])
5253    }
5254
5255    /// Number of non-append transcript mutations this in-memory session has
5256    /// applied. Diagnostics and regression tests only.
5257    #[doc(hidden)]
5258    #[must_use]
5259    pub fn transcript_mutation_epoch(&self) -> u64 {
5260        self.messages.mutation_epoch()
5261    }
5262
5263    /// Replace the message buffer for core-owned internal transcript rewrites.
5264    ///
5265    /// Intentionally `pub(crate)`: cross-crate consumers must route same-session
5266    /// rewrites through transcript-edit APIs so the revision graph remains the
5267    /// semantic owner of message history.
5268    #[allow(dead_code)] // Kept for core-owned optional rewrite paths and focused invariants.
5269    pub(crate) fn replace_messages_internal(
5270        &mut self,
5271        messages: Vec<Message>,
5272        reason: TranscriptRewriteReason,
5273    ) -> Result<Option<TranscriptRewriteCommit>, TranscriptEditError> {
5274        if transcript_messages_digest(self.messages()).ok()
5275            == transcript_messages_digest(&messages).ok()
5276        {
5277            return Ok(None);
5278        }
5279        let commit = self.commit_transcript_rewrite(
5280            TranscriptRewriteSelection::MessageRange {
5281                start: 0,
5282                end: self.messages.len(),
5283            },
5284            messages,
5285            reason,
5286            Some("meerkat-core".to_string()),
5287            None,
5288        )?;
5289        Ok(Some(commit))
5290    }
5291
5292    /// Replace the full transcript under the opaque authority minted by the
5293    /// validated compaction rebuild path.
5294    pub(crate) fn replace_messages_for_compaction_internal(
5295        &mut self,
5296        messages: Vec<Message>,
5297        authority: &crate::agent::compact::ValidatedCompactionRewrite,
5298    ) -> Result<Option<TranscriptRewriteCommit>, TranscriptEditError> {
5299        if transcript_messages_digest(self.messages()).ok()
5300            == transcript_messages_digest(&messages).ok()
5301        {
5302            return Ok(None);
5303        }
5304        if !authority
5305            .authorizes(self.messages(), &messages)
5306            .map_err(|error| TranscriptEditError::HistoryStateMalformed(error.to_string()))?
5307        {
5308            return Err(TranscriptEditError::InvalidTranscriptShape(
5309                "validated compaction witness does not authorize this exact transcript rebuild"
5310                    .to_string(),
5311            ));
5312        }
5313        let summary_count = messages
5314            .iter()
5315            .filter(|message| {
5316                matches!(message, Message::User(user) if user.transcript_role.is_compaction_summary())
5317            })
5318            .count();
5319        if messages.len() >= self.messages.len() || summary_count != 1 {
5320            return Err(TranscriptEditError::InvalidTranscriptShape(
5321                "validated compaction rewrite must shrink the transcript and carry exactly one CompactionSummary"
5322                    .to_string(),
5323            ));
5324        }
5325        let selection =
5326            TranscriptRewriteSelection::validated_compaction(0, self.messages.len(), authority);
5327        let commit = self.commit_transcript_rewrite_authorized(
5328            selection,
5329            messages,
5330            TranscriptRewriteReason::new("compaction"),
5331            Some("meerkat-core".to_string()),
5332            None,
5333        )?;
5334        Ok(Some(commit))
5335    }
5336
5337    /// Atomically refresh the synthetic runtime notices of one kind.
5338    ///
5339    /// This is the ONE transcript authority operation for synthetic-notice
5340    /// refresh: it strips every synthetic `SystemNotice` projection of `kind`
5341    /// while preserving durable notices that share the kind, then appends
5342    /// `replacements` (possibly empty, meaning "no current synthetic notice")
5343    /// as one mechanical projection update. It deliberately does not mint an
5344    /// audited transcript rewrite commit. On a strip fault nothing is pushed
5345    /// and the typed [`TranscriptEditError`] propagates — callers must not
5346    /// re-implement the strip-then-push pair (the swallowed-strip variant
5347    /// leaves a stale notice beside a fresh one: a divergence window).
5348    pub fn replace_synthetic_notices(
5349        &mut self,
5350        kind: crate::types::SystemNoticeKind,
5351        replacements: Vec<Message>,
5352    ) -> Result<(), TranscriptEditError> {
5353        if !kind.is_synthetic_refresh_projection() {
5354            return Err(TranscriptEditError::InvalidTranscriptShape(format!(
5355                "system notice kind {kind:?} is durable transcript content, not a synthetic refresh projection"
5356            )));
5357        }
5358        for (index, message) in replacements.iter().enumerate() {
5359            let matches_kind = matches!(
5360                message,
5361                Message::SystemNotice(notice)
5362                    if notice.kind == kind && notice.is_synthetic_refresh_projection()
5363            );
5364            if !matches_kind {
5365                return Err(TranscriptEditError::InvalidTranscriptShape(format!(
5366                    "replacement {index} for synthetic notice kind {kind:?} is not a system notice of that kind"
5367                )));
5368            }
5369        }
5370
5371        let mut refreshed = self
5372            .messages
5373            .iter()
5374            .filter(|message| {
5375                !matches!(
5376                    message,
5377                    Message::SystemNotice(notice)
5378                        if notice.kind == kind && notice.is_synthetic_refresh_projection()
5379                )
5380            })
5381            .cloned()
5382            .collect::<Vec<_>>();
5383        refreshed.extend(replacements);
5384        let refreshed_digest = transcript_messages_digest(&refreshed)
5385            .map_err(|error| TranscriptEditError::HistoryStateMalformed(error.to_string()))?;
5386        if self.messages.digest().ok().as_deref() == Some(refreshed_digest.as_str()) {
5387            return Ok(());
5388        }
5389
5390        let realtime_state =
5391            self.reconciled_realtime_transcript_metadata_after_rewrite(&refreshed)?;
5392        let updated_at = SystemTime::now();
5393        let history_state = self
5394            .transcript_history_state_after_message_mutation(
5395                &refreshed,
5396                refreshed_digest,
5397                updated_at,
5398                TranscriptMutationShape::Rewritten,
5399            )?
5400            .map(serde_json::to_value)
5401            .transpose()
5402            .map_err(|error| TranscriptEditError::HistoryStateMalformed(error.to_string()))?;
5403
5404        // SEAM 1 (non-append): synthetic notices are stripped from anywhere in
5405        // the vector, so the retained midstate and prefix ring are discarded.
5406        self.messages.replace(refreshed);
5407        self.updated_at = updated_at;
5408        if let Some(value) = realtime_state {
5409            self.set_metadata_unchecked(SESSION_REALTIME_TRANSCRIPT_STATE_KEY, value);
5410        }
5411        if let Some(value) = history_state {
5412            self.set_validated_transcript_history_metadata(value);
5413        }
5414        Ok(())
5415    }
5416
5417    /// Get creation time
5418    pub fn created_at(&self) -> SystemTime {
5419        self.created_at
5420    }
5421
5422    /// Get last update time
5423    pub fn updated_at(&self) -> SystemTime {
5424        self.updated_at
5425    }
5426
5427    /// Add a message to the session
5428    ///
5429    /// Updates the timestamp. For adding multiple messages, prefer `push_batch`.
5430    pub fn push(&mut self, message: Message) {
5431        // SEAM 2 (append): the accumulator folds only the appended bytes.
5432        self.messages.push(message);
5433        self.updated_at = SystemTime::now();
5434        self.refresh_transcript_head_after_message_mutation(TranscriptMutationShape::Appended);
5435    }
5436
5437    /// Add multiple messages in one operation (single timestamp update)
5438    ///
5439    /// More efficient than multiple `push` calls when adding many messages.
5440    pub fn push_batch(&mut self, messages: Vec<Message>) {
5441        if messages.is_empty() {
5442            return;
5443        }
5444        // SEAM 3 (append): the accumulator folds only the appended batch.
5445        self.messages.extend_batch(messages);
5446        self.updated_at = SystemTime::now();
5447        self.refresh_transcript_head_after_message_mutation(TranscriptMutationShape::Appended);
5448    }
5449
5450    /// Rewrite inline media payloads in-place as `BlobRef` pointers.
5451    ///
5452    /// Message count is invariant across this operation — `externalize`
5453    /// only swaps inline image/media bytes for opaque blob references.
5454    /// This is the cross-crate-legitimate rewrite operation that used
5455    /// to require public `messages_mut()`; post-C-H1 callers in
5456    /// `meerkat-session` go through this typed method.
5457    ///
5458    /// Does not touch `updated_at` — externalization is bookkeeping, not
5459    /// a semantic session mutation.
5460    pub async fn externalize_media(
5461        &mut self,
5462        blob_store: &dyn crate::BlobStore,
5463        start: usize,
5464    ) -> Result<(), crate::blob::BlobStoreError> {
5465        // SEAM 4 (in-place media scan): the scan reports the lowest mutated
5466        // index. `None` means the buffer is byte-identical, so the retained
5467        // midstate stays valid AND no transcript-head refresh is owed — which
5468        // is what deletes the two full transcript digests this paid on EVERY
5469        // boundary save of a history-bearing session, images or not.
5470        let previous_digest = if self
5471            .metadata
5472            .contains_key(SESSION_TRANSCRIPT_HISTORY_STATE_KEY)
5473        {
5474            self.messages.digest().ok()
5475        } else {
5476            None
5477        };
5478        let buffer = self.messages.begin_in_place_scan();
5479        let lowest_mutated = match crate::image_content::externalize_messages_from_reporting_lowest(
5480            blob_store, buffer, start,
5481        )
5482        .await
5483        {
5484            Ok(lowest_mutated) => lowest_mutated,
5485            Err(error) => {
5486                // The scan may have externalized part of the buffer before
5487                // failing; fail safe by discarding the parked midstate.
5488                self.messages.finish_in_place_scan(Some(start));
5489                return Err(error);
5490            }
5491        };
5492        self.messages.finish_in_place_scan(lowest_mutated);
5493        if lowest_mutated.is_some()
5494            && let Some(previous_digest) = previous_digest
5495            && self.messages.digest().ok().as_ref() != Some(&previous_digest)
5496        {
5497            self.refresh_transcript_head_after_message_mutation(TranscriptMutationShape::Rewritten);
5498        }
5499        Ok(())
5500    }
5501
5502    /// Hydrate user-message images in-place for a realtime provider replay,
5503    /// under an explicit cumulative decoded-byte budget.
5504    ///
5505    /// Realtime reconnect/open is an execution seam, not a historical display
5506    /// read: missing or malformed blobs fail closed, repeated references count
5507    /// independently, and image-bearing tool/system content that the realtime
5508    /// history projector does not consume remains blob-backed.
5509    pub async fn hydrate_realtime_user_images(
5510        &mut self,
5511        blob_store: &dyn crate::BlobStore,
5512        max_decoded_bytes: usize,
5513    ) -> Result<(), crate::image_content::RealtimeUserImageHydrationError> {
5514        self.hydrate_realtime_user_images_with_usage(blob_store, max_decoded_bytes)
5515            .await
5516            .map(|_| ())
5517    }
5518
5519    /// Hydrate realtime user-message images and return the full canonical
5520    /// decoded-byte usage for seed-independent future-image admission.
5521    pub async fn hydrate_realtime_user_images_with_usage(
5522        &mut self,
5523        blob_store: &dyn crate::BlobStore,
5524        max_decoded_bytes: usize,
5525    ) -> Result<usize, crate::image_content::RealtimeUserImageHydrationError> {
5526        let previous_digest = if self
5527            .metadata
5528            .contains_key(SESSION_TRANSCRIPT_HISTORY_STATE_KEY)
5529        {
5530            self.messages.digest().ok()
5531        } else {
5532            None
5533        };
5534        // SEAM 5 (in-place media scan): same contract as `externalize_media`.
5535        let buffer = self.messages.begin_in_place_scan();
5536        let (decoded_total, lowest_mutated) =
5537            match crate::image_content::hydrate_user_images_for_realtime_projection_reporting_lowest(
5538                blob_store,
5539                buffer,
5540                max_decoded_bytes,
5541            )
5542            .await
5543            {
5544                Ok(outcome) => outcome,
5545                Err(error) => {
5546                    self.messages.finish_in_place_scan(Some(0));
5547                    return Err(error);
5548                }
5549            };
5550        self.messages.finish_in_place_scan(lowest_mutated);
5551        if lowest_mutated.is_some()
5552            && let Some(previous_digest) = previous_digest
5553            && self.messages.digest().ok().as_ref() != Some(&previous_digest)
5554        {
5555            self.refresh_transcript_head_after_message_mutation(TranscriptMutationShape::Rewritten);
5556        }
5557        Ok(decoded_total)
5558    }
5559
5560    /// Explicitly update the timestamp
5561    ///
5562    /// Call this after bulk operations that don't update timestamps automatically.
5563    pub fn touch(&mut self) {
5564        self.updated_at = SystemTime::now();
5565    }
5566
5567    /// Get the last N messages
5568    pub fn last_n(&self, n: usize) -> &[Message] {
5569        let start = self.messages.len().saturating_sub(n);
5570        &self.messages[start..]
5571    }
5572
5573    /// Count total tokens used.
5574    pub fn total_tokens(&self) -> u64 {
5575        self.usage.total_tokens()
5576    }
5577
5578    /// Get total usage statistics for the session.
5579    pub fn total_usage(&self) -> Usage {
5580        self.usage.clone()
5581    }
5582
5583    /// Update cumulative usage after an LLM call.
5584    pub fn record_usage(&mut self, turn_usage: Usage) {
5585        self.usage.add(&turn_usage);
5586        self.updated_at = SystemTime::now();
5587    }
5588
5589    /// Append externally-produced user content to the canonical transcript.
5590    pub fn append_external_user_content(&mut self, content: ContentInput) {
5591        self.push(Message::User(UserMessage::with_blocks(
5592            content.into_blocks(),
5593        )));
5594    }
5595
5596    /// Append externally-produced assistant output to the canonical transcript.
5597    pub fn append_external_assistant_blocks(
5598        &mut self,
5599        blocks: Vec<AssistantBlock>,
5600        stop_reason: StopReason,
5601        usage: Usage,
5602    ) {
5603        if !blocks.is_empty() {
5604            self.push(Message::BlockAssistant(BlockAssistantMessage::new(
5605                blocks,
5606                stop_reason,
5607            )));
5608        }
5609        if usage != Usage::default() {
5610            self.record_usage(usage);
5611        }
5612    }
5613
5614    /// Apply an identity-bearing provider realtime transcript event.
5615    ///
5616    /// This is the canonical append authority for provider-managed realtime
5617    /// turns: provider item ids, predecessor links, and content segment ids are
5618    /// persisted in session metadata so duplicate websocket delivery,
5619    /// reconnect replay, and causally equivalent event ordering cannot create
5620    /// duplicate or misordered canonical messages.
5621    pub fn append_realtime_transcript_event(
5622        &mut self,
5623        event: RealtimeTranscriptEvent,
5624    ) -> RealtimeTranscriptApplyOutcome {
5625        let mut state = self.realtime_transcript_state();
5626        let commit =
5627            realtime_transcript_revision::apply_realtime_transcript_event(&mut state, event)
5628                .unwrap_or_else(|err| {
5629                    fail_closed_generated_restore(
5630                        "realtime-transcript",
5631                        <serde_json::Error as serde::de::Error>::custom(err),
5632                    )
5633                });
5634        self.store_realtime_transcript_state(&state);
5635        self.push_batch(commit.messages);
5636        if commit.usage != Usage::default() {
5637            self.record_usage(commit.usage);
5638        }
5639        commit.outcome
5640    }
5641
5642    /// Preview replay/rejection for non-text realtime user content without
5643    /// mutating session state. Used by persistence before blob writes.
5644    #[must_use]
5645    pub fn preflight_realtime_user_content_event(
5646        &self,
5647        event: &RealtimeTranscriptEvent,
5648    ) -> Option<crate::RealtimeUserContentApplyOutcome> {
5649        let state = self.realtime_transcript_state();
5650        realtime_transcript_revision::preflight_realtime_user_content_event(&state, event)
5651            .unwrap_or_else(|err| {
5652                fail_closed_generated_restore(
5653                    "realtime-user-content-preflight",
5654                    <serde_json::Error as serde::de::Error>::custom(err),
5655                )
5656            })
5657    }
5658
5659    /// Return every distinct provider `response_id` currently staged in the
5660    /// realtime-transcript metadata that has at least one **unmaterialized**
5661    /// assistant item and is **not already discarded**.
5662    ///
5663    /// CC4 (Round-4 architectural reconciliation): when the live boundary
5664    /// signals a barge-in (`TurnInterrupted`), the projection sink does not
5665    /// know which provider response_ids have streaming deltas staged in
5666    /// session metadata. This accessor lets the sink fan
5667    /// [`RealtimeTranscriptEvent::AssistantTurnInterrupted`] events out to
5668    /// each in-flight response so staged-but-not-yet-materialized transcript
5669    /// fragments are discarded — preventing them from silently committing
5670    /// when the *next* turn's `AssistantTurnCompleted` (synthesized by the
5671    /// CC2 fix in `signal_turn_completed`) sweeps the materializer.
5672    ///
5673    /// Order is the [`SessionRealtimeTranscriptState::first_seen_order`]
5674    /// projection so callers see deterministic iteration. Items already
5675    /// materialized or skipped are excluded — only response_ids with at
5676    /// least one live unmaterialized assistant item are returned.
5677    #[must_use]
5678    pub fn in_flight_realtime_assistant_response_ids(&self) -> Vec<String> {
5679        let state = self.realtime_transcript_state();
5680        realtime_transcript_revision::in_flight_realtime_assistant_response_ids(&state)
5681    }
5682
5683    /// Durable session-scoped bindings used to make live non-text input retry
5684    /// safe across provider reconnects and lost public receipts.
5685    #[must_use]
5686    pub fn realtime_user_content_identities(&self) -> Vec<RealtimeUserContentIdentity> {
5687        let state = self.realtime_transcript_state();
5688        realtime_transcript_revision::realtime_user_content_identities(&state)
5689    }
5690
5691    /// Return the bounded metadata-only image-blob recovery anchor, if one is
5692    /// durably staged ahead of reducer finalization.
5693    #[must_use]
5694    pub fn pending_realtime_user_content_blob(
5695        &self,
5696    ) -> Option<crate::PendingRealtimeUserContentBlob> {
5697        let state = self.realtime_transcript_state();
5698        realtime_transcript_revision::pending_realtime_user_content_blob(&state)
5699    }
5700
5701    /// Stage or exactly reuse the one-slot durable image-blob recovery anchor
5702    /// through generated SessionDocument authority.
5703    pub fn stage_pending_realtime_user_content_blob(
5704        &mut self,
5705        pending: crate::PendingRealtimeUserContentBlob,
5706    ) -> Result<
5707        crate::generated::session_document::RealtimeUserContentBlobStageDisposition,
5708        realtime_transcript_revision::RealtimeTranscriptShellError,
5709    > {
5710        let mut state = self.realtime_transcript_state();
5711        let disposition = realtime_transcript_revision::stage_pending_realtime_user_content_blob(
5712            &mut state, pending,
5713        )?;
5714        self.store_realtime_transcript_state(&state);
5715        Ok(disposition)
5716    }
5717
5718    pub fn resolve_pending_realtime_user_content_blob_recovery(
5719        &self,
5720        request: Option<&crate::PendingRealtimeUserContentBlob>,
5721        pending_blob_valid: bool,
5722    ) -> Result<
5723        crate::generated::session_document::RealtimeUserContentBlobRecoveryDisposition,
5724        realtime_transcript_revision::RealtimeTranscriptShellError,
5725    > {
5726        let state = self.realtime_transcript_state();
5727        realtime_transcript_revision::resolve_pending_realtime_user_content_blob_recovery(
5728            &state,
5729            request,
5730            pending_blob_valid,
5731        )
5732    }
5733
5734    /// Clear a missing/corrupt occupied anchor only after generated recovery
5735    /// authority classifies a different request as `ClearInvalidBeforeCurrent`.
5736    pub fn clear_invalid_pending_realtime_user_content_blob(
5737        &mut self,
5738        request: Option<&crate::PendingRealtimeUserContentBlob>,
5739    ) -> Result<(), realtime_transcript_revision::RealtimeTranscriptShellError> {
5740        let mut state = self.realtime_transcript_state();
5741        realtime_transcript_revision::clear_invalid_pending_realtime_user_content_blob(
5742            &mut state, request,
5743        )?;
5744        self.store_realtime_transcript_state(&state);
5745        Ok(())
5746    }
5747
5748    /// Durable caller keys whose canonical realtime image was removed by a
5749    /// same-session transcript rewrite. Provider adapters consume these as a
5750    /// pre-send conflict registry on open and refresh.
5751    #[must_use]
5752    pub fn realtime_user_content_tombstones(
5753        &self,
5754    ) -> Vec<crate::realtime_transcript::RealtimeUserContentTombstone> {
5755        let state = self.realtime_transcript_state();
5756        realtime_transcript_revision::realtime_user_content_tombstones(&state)
5757    }
5758
5759    fn realtime_transcript_state(&self) -> SessionRealtimeTranscriptState {
5760        match self.try_realtime_transcript_state() {
5761            Ok(Some(state)) => state,
5762            Ok(None) => SessionRealtimeTranscriptState::default(),
5763            Err(err) => fail_closed_generated_restore("realtime-transcript", err),
5764        }
5765    }
5766
5767    fn try_realtime_transcript_state(
5768        &self,
5769    ) -> Result<Option<SessionRealtimeTranscriptState>, serde_json::Error> {
5770        self.metadata
5771            .get(SESSION_REALTIME_TRANSCRIPT_STATE_KEY)
5772            .map(|value| {
5773                let state = serde_json::from_value(value.clone())?;
5774                realtime_transcript_revision::restore_realtime_transcript_state(state)
5775                    .map_err(<serde_json::Error as serde::de::Error>::custom)
5776            })
5777            .transpose()
5778    }
5779
5780    fn store_realtime_transcript_state(&mut self, state: &SessionRealtimeTranscriptState) {
5781        match serde_json::to_value(state) {
5782            Ok(value) => self.set_metadata_unchecked(SESSION_REALTIME_TRANSCRIPT_STATE_KEY, value),
5783            Err(error) => {
5784                tracing::warn!(error = %error, "failed to serialize realtime transcript state");
5785            }
5786        }
5787    }
5788
5789    fn reconciled_realtime_transcript_metadata_after_rewrite(
5790        &self,
5791        messages: &[Message],
5792    ) -> Result<Option<serde_json::Value>, TranscriptEditError> {
5793        let Some(state) = self
5794            .try_realtime_transcript_state()
5795            .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?
5796        else {
5797            return Ok(None);
5798        };
5799        let state =
5800            realtime_transcript_revision::reconcile_realtime_transcript_state_after_rewrite(
5801                state, messages,
5802            )
5803            .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
5804        serde_json::to_value(state)
5805            .map(Some)
5806            .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))
5807    }
5808
5809    fn apply_authorized_system_prompt(
5810        &mut self,
5811        prompt: session_durable_config_authority::AuthorizedSystemPrompt,
5812    ) {
5813        use crate::types::SystemMessage;
5814
5815        // The typed mutation provenance is carried onto the applied system
5816        // message so the transcript-continuity save-guard recognizes a
5817        // runtime context-append shape from a typed field instead of the
5818        // rendered `[Runtime System Context]` label.
5819        let mutation_kind = prompt.mutation_kind();
5820        let (prompt, _replacing_existing) = prompt.into_parts();
5821        let message = SystemMessage::with_mutation_kind(prompt, mutation_kind);
5822        // SEAM 6 (non-append): index 0 is replaced or the vector is shifted, so
5823        // neither the midstate nor any retained prefix survives.
5824        let inner = self.messages.mutate_in_place();
5825        // Check if first message is system
5826        if let Some(Message::System(_)) = inner.first() {
5827            inner[0] = Message::System(message);
5828        } else {
5829            inner.insert(0, Message::System(message));
5830        }
5831        self.updated_at = SystemTime::now();
5832        self.refresh_transcript_head_after_message_mutation(TranscriptMutationShape::Rewritten);
5833    }
5834
5835    /// Set a system prompt through generated durable-config authority.
5836    pub fn set_system_prompt_with_source(
5837        &mut self,
5838        prompt: String,
5839        source: session_durable_config_authority::SessionSystemPromptSource,
5840    ) -> Result<(), session_durable_config_authority::SessionDurableConfigAuthorityError> {
5841        let replacing_existing = matches!(self.messages.first(), Some(Message::System(_)));
5842        let prompt = session_durable_config_authority::authorize_system_prompt_mutation(
5843            prompt,
5844            source,
5845            replacing_existing,
5846        )?;
5847        self.apply_authorized_system_prompt(prompt);
5848        Ok(())
5849    }
5850
5851    /// Set a system prompt (adds or replaces System message at start).
5852    pub fn set_system_prompt(&mut self, prompt: String) {
5853        if let Err(err) = self.set_system_prompt_with_source(
5854            prompt,
5855            session_durable_config_authority::SessionSystemPromptSource::DirectMutation,
5856        ) {
5857            tracing::warn!(error = %err, "generated session durable-config authority rejected system prompt mutation");
5858        }
5859    }
5860
5861    /// Remove transient active-turn steer context from persisted session state.
5862    ///
5863    /// Operator steers accepted into an already-running turn are request-local:
5864    /// they should be visible to that turn's next model boundary, then vanish
5865    /// instead of replaying into later turns after persistence or resume.
5866    pub fn discard_transient_runtime_steer_context(&mut self) -> usize {
5867        let mut removed = 0usize;
5868
5869        let mut state = match self.try_system_context_state() {
5870            Ok(state) => state.unwrap_or_default(),
5871            Err(err) => {
5872                tracing::warn!(
5873                    error = %err,
5874                    "generated system-context authority rejected runtime steer cleanup state"
5875                );
5876                return removed;
5877            }
5878        };
5879
5880        // The typed `source_kind` marker on persisted appends is the authority
5881        // for which rendered prompt blocks are transient runtime steers. Gather
5882        // the runtime-steer appends, then remove their exact rendered blocks
5883        // from the system prompt — no `runtime:steer:` string classification.
5884        let runtime_steer_appends = state
5885            .pending
5886            .iter()
5887            .chain(state.applied.iter())
5888            .filter(|append| append.source_kind.is_runtime_steer())
5889            .cloned()
5890            .collect::<Vec<_>>();
5891        if let Some(Message::System(system)) = self.messages.first() {
5892            let (retained_prompt, removed_blocks) =
5893                system_context_authority::remove_runtime_steer_blocks_for_rendered(
5894                    &system.content,
5895                    &runtime_steer_appends,
5896                );
5897            if removed_blocks > 0 {
5898                removed += removed_blocks;
5899                if let Err(err) = self.set_system_prompt_with_source(
5900                    retained_prompt,
5901                    session_durable_config_authority::SessionSystemPromptSource::RuntimeSteerCleanup,
5902                ) {
5903                    tracing::warn!(
5904                        error = %err,
5905                        "generated session durable-config authority rejected runtime steer prompt cleanup"
5906                    );
5907                }
5908            }
5909        }
5910
5911        removed += system_context_authority::discard_transient_runtime_steer_state(&mut state);
5912
5913        if removed > 0
5914            && let Err(err) = self.set_system_context_state(state)
5915        {
5916            tracing::warn!(
5917                error = %err,
5918                "failed to persist runtime steer context cleanup"
5919            );
5920        }
5921
5922        removed
5923    }
5924
5925    /// Append one or more runtime system-context blocks to the canonical system prompt.
5926    pub fn append_system_context_blocks(&mut self, appends: &[PendingSystemContextAppend]) {
5927        if appends.is_empty() {
5928            return;
5929        }
5930
5931        let current_system_prompt = self
5932            .messages
5933            .first()
5934            .and_then(|message| match message {
5935                Message::System(system) => Some(system.content.as_str()),
5936                _ => None,
5937            })
5938            .unwrap_or_default();
5939        let mut state = match self.try_system_context_state() {
5940            Ok(state) => state.unwrap_or_default(),
5941            Err(err) => {
5942                tracing::warn!(
5943                    error = %err,
5944                    "generated system-context authority rejected applied context state"
5945                );
5946                return;
5947            }
5948        };
5949        let new_appends = system_context_authority::record_applied_system_context_blocks(
5950            &mut state,
5951            appends,
5952            current_system_prompt,
5953        );
5954        if new_appends.is_empty() {
5955            if let Err(err) = self.set_system_context_state(state) {
5956                tracing::warn!(error = %err, "failed to persist applied system-context state");
5957            }
5958            return;
5959        }
5960
5961        let rendered = render_system_context_blocks_joined(&new_appends);
5962
5963        let next = match self.messages.first() {
5964            Some(Message::System(sys)) if !sys.content.is_empty() => {
5965                format!("{}{}{}", sys.content, SYSTEM_CONTEXT_SEPARATOR, rendered)
5966            }
5967            _ => rendered,
5968        };
5969        if let Err(err) = self.set_system_prompt_with_source(
5970            next,
5971            session_durable_config_authority::SessionSystemPromptSource::RuntimeContextAppend,
5972        ) {
5973            tracing::warn!(
5974                error = %err,
5975                "generated session durable-config authority rejected system-context prompt append"
5976            );
5977            return;
5978        }
5979        if let Err(err) = self.set_system_context_state(state) {
5980            tracing::warn!(error = %err, "failed to persist applied system-context state");
5981        }
5982    }
5983
5984    /// Reconcile a resumed session's persisted system prompt with a freshly
5985    /// assembled base prompt.
5986    ///
5987    /// A resumed transcript is durable state: its leading [`Message::System`]
5988    /// carries the base prompt PLUS every runtime system-context append the
5989    /// runtime durably applied (comms rosters, host context — rendered by
5990    /// [`Session::append_system_context_blocks`]). Blind-replacing that
5991    /// message with a re-assembled base prompt discards the runtime-applied
5992    /// context and produces a projection that is no longer a continuation of
5993    /// the persisted transcript revision — the append-only save guard then
5994    /// rejects the very first post-resume persist and the live session is
5995    /// discarded (the upstream cold-restart transcript-loss report).
5996    ///
5997    /// Reconciliation instead of replacement:
5998    /// - If the persisted System content IS the assembled base — identical, or
5999    ///   extended only by [`SYSTEM_CONTEXT_SEPARATOR`]-joined runtime context
6000    ///   appends — the transcript is left untouched (byte-for-byte, including
6001    ///   the typed `mutation_kind`), so the resumed projection digests to the
6002    ///   persisted revision.
6003    /// - If the base genuinely changed, the new System message (new base plus
6004    ///   the reconstructed runtime-append tail, when the persisted tail is
6005    ///   verifiable from the durable applied-append records) is committed
6006    ///   through [`Session::commit_transcript_rewrite`] — the canonical typed
6007    ///   rewrite path — so the first post-resume persist proves a transcript
6008    ///   graph edge from the persisted head instead of failing closed.
6009    pub fn reconcile_resumed_system_prompt(
6010        &mut self,
6011        assembled_base: String,
6012        actor: Option<String>,
6013    ) -> Result<ResumedSystemPromptReconciliation, TranscriptEditError> {
6014        let persisted = match self.messages.first() {
6015            Some(Message::System(system)) => Some((system.content.clone(), system.mutation_kind)),
6016            _ => None,
6017        };
6018
6019        let Some((persisted_content, persisted_mutation_kind)) = persisted else {
6020            if assembled_base.is_empty() {
6021                return Ok(ResumedSystemPromptReconciliation::NoChange);
6022            }
6023            // The persisted transcript never had a system prompt; introducing
6024            // one changes the transcript, so it flows through the same typed
6025            // rewrite path (an insert rewrite over the empty leading span).
6026            self.commit_resume_system_prompt_rewrite(assembled_base, false, actor)?;
6027            return Ok(ResumedSystemPromptReconciliation::RewrittenBase);
6028        };
6029
6030        if persisted_content == assembled_base {
6031            return Ok(ResumedSystemPromptReconciliation::PreservedContinuation);
6032        }
6033
6034        // Byte-exact reconciliation first: when the persisted content splits
6035        // into a VERIFIED base + runtime-appended tail, the expected content
6036        // for this build is `assembled_base + tail` — equal means the base is
6037        // unchanged (preserve untouched), different means the base changed
6038        // (audited rewrite that carries the tail). This runs before the
6039        // structural fast path so a shortened base whose removed remainder
6040        // merely looks like a context tail (the separator is ordinary
6041        // markdown) is applied instead of silently ignored.
6042        if let Some(tail) = self.verified_runtime_context_tail(&persisted_content) {
6043            let expected = compose_system_prompt_with_context_tail(&assembled_base, &tail);
6044            if expected == persisted_content {
6045                return Ok(ResumedSystemPromptReconciliation::PreservedContinuation);
6046            }
6047            self.commit_resume_system_prompt_rewrite(expected, true, actor)?;
6048            return Ok(ResumedSystemPromptReconciliation::RewrittenBase);
6049        }
6050
6051        // No verifiable tail record (rows written before the assembled base
6052        // was recorded, or applied-append state swept by the runtime path).
6053        // The canonical SessionDocumentMachine persist-append admission
6054        // decides — from the structural observations plus the typed mutation
6055        // provenance — whether the persisted prompt is a runtime-context-
6056        // append continuation of the assembled base. Machine refusal fails
6057        // closed into the audited rewrite below.
6058        if persisted_prompt_is_admitted_context_append_continuation(
6059            &assembled_base,
6060            &persisted_content,
6061            persisted_mutation_kind,
6062        ) {
6063            return Ok(ResumedSystemPromptReconciliation::PreservedContinuation);
6064        }
6065
6066        // The base diverged and the runtime-context tail is not
6067        // reconstructible: only the new base can be written. Dropping the
6068        // appended context silently would leave the durable applied/seen
6069        // records claiming those appends are applied — keyed re-sends would
6070        // be deduplicated forever — so clear the orphaned records to keep the
6071        // context restorable by the host.
6072        let dropping_applied_context = persisted_mutation_kind.is_runtime_context_append()
6073            || self
6074                .system_context_state()
6075                .is_some_and(|state| !state.applied.is_empty());
6076        self.commit_resume_system_prompt_rewrite(assembled_base, true, actor)?;
6077        if dropping_applied_context {
6078            tracing::warn!(
6079                session_id = %self.id,
6080                "resume base-prompt refresh dropped an unverifiable runtime system-context tail; \
6081                 clearing applied-append records so keyed re-sends can restore the context"
6082            );
6083            self.clear_applied_system_context_records();
6084        }
6085        Ok(ResumedSystemPromptReconciliation::RewrittenBase)
6086    }
6087
6088    /// Split the persisted System content into a VERIFIED runtime-appended
6089    /// tail (leading [`SYSTEM_CONTEXT_SEPARATOR`] included; empty when the
6090    /// content is exactly a verified base).
6091    ///
6092    /// Verification sources, strongest first: byte-exact against the prior
6093    /// build's recorded assembled base
6094    /// ([`SessionBuildState::assembled_system_prompt`]), then a re-render of
6095    /// the durable applied-append records. `None` means the tail is not
6096    /// reconstructible from durable facts.
6097    fn verified_runtime_context_tail(&self, persisted_content: &str) -> Option<String> {
6098        if let Some(prior_base) = self
6099            .build_state()
6100            .and_then(|state| state.assembled_system_prompt)
6101        {
6102            if persisted_content == prior_base {
6103                return Some(String::new());
6104            }
6105            if let Some(appended) = persisted_content.strip_prefix(prior_base.as_str())
6106                && appended.starts_with(SYSTEM_CONTEXT_SEPARATOR)
6107            {
6108                return Some(appended.to_string());
6109            }
6110            // The record does not split this content (e.g. it predates the
6111            // last prompt mutation); fall through to the render verification.
6112        }
6113        let rendered_tail = self
6114            .system_context_state()
6115            .map(|state| render_system_context_blocks_joined(&state.applied))
6116            .unwrap_or_default();
6117        if rendered_tail.is_empty() {
6118            return None;
6119        }
6120        if persisted_content == rendered_tail {
6121            // The entire persisted prompt is verified runtime context (a
6122            // promptless/empty-base build whose appends compose without a
6123            // separator prefix) — the tail is the whole content, not empty.
6124            return Some(format!("{SYSTEM_CONTEXT_SEPARATOR}{rendered_tail}"));
6125        }
6126        let with_separator = format!("{SYSTEM_CONTEXT_SEPARATOR}{rendered_tail}");
6127        persisted_content
6128            .ends_with(&with_separator)
6129            .then_some(with_separator)
6130    }
6131
6132    /// Commit a resume-time base-prompt refresh through the generated
6133    /// durable-config authority and the canonical typed rewrite path.
6134    fn commit_resume_system_prompt_rewrite(
6135        &mut self,
6136        content: String,
6137        replacing_existing: bool,
6138        actor: Option<String>,
6139    ) -> Result<(), TranscriptEditError> {
6140        let authorized = session_durable_config_authority::authorize_system_prompt_mutation(
6141            content,
6142            session_durable_config_authority::SessionSystemPromptSource::ExplicitBuild,
6143            replacing_existing,
6144        )
6145        .map_err(|err| {
6146            TranscriptEditError::HistoryStateMalformed(format!(
6147                "generated session durable-config authority rejected resume system prompt refresh: {err}"
6148            ))
6149        })?;
6150        let mutation_kind = authorized.mutation_kind();
6151        let (content, _replacing_existing) = authorized.into_parts();
6152        let replacement = Message::System(crate::types::SystemMessage::with_mutation_kind(
6153            content,
6154            mutation_kind,
6155        ));
6156        let end = usize::from(replacing_existing);
6157        self.commit_transcript_rewrite(
6158            TranscriptRewriteSelection::MessageRange { start: 0, end },
6159            vec![replacement],
6160            TranscriptRewriteReason::new(RESUME_SYSTEM_PROMPT_REFRESH_REWRITE_REASON),
6161            actor,
6162            None,
6163        )?;
6164        Ok(())
6165    }
6166
6167    /// Clear applied-append records (and their idempotency keys) after a
6168    /// resume rewrite dropped their rendered blocks from the System prompt,
6169    /// so the same keyed appends re-apply instead of deduplicating forever.
6170    fn clear_applied_system_context_records(&mut self) {
6171        let mut state = match self.try_system_context_state() {
6172            Ok(Some(state)) => state,
6173            Ok(None) => return,
6174            Err(error) => {
6175                tracing::warn!(
6176                    session_id = %self.id,
6177                    error = %error,
6178                    "failed to read system-context state while clearing orphaned applied records"
6179                );
6180                return;
6181            }
6182        };
6183        if state.applied.is_empty() {
6184            return;
6185        }
6186        let dropped_keys: Vec<String> = state
6187            .applied
6188            .iter()
6189            .filter_map(|append| append.idempotency_key.clone())
6190            .collect();
6191        state.applied.clear();
6192        for key in &dropped_keys {
6193            state.seen.remove(key);
6194        }
6195        if let Err(error) = self.set_system_context_state(state) {
6196            tracing::warn!(
6197                session_id = %self.id,
6198                error = %error,
6199                "failed to persist cleared applied system-context records after resume prompt refresh"
6200            );
6201        }
6202    }
6203
6204    /// Get the last assistant message text content.
6205    ///
6206    /// Concatenates both `Text` (display) and `Transcript` (spoken) blocks
6207    /// in document order, since both lanes project to the same human-readable
6208    /// stream. Lane provenance is preserved on the underlying `AssistantBlock`
6209    /// for callers that need it.
6210    pub fn last_assistant_text(&self) -> Option<String> {
6211        self.messages.iter().rev().find_map(|m| match m {
6212            Message::BlockAssistant(a) => {
6213                let mut buf = String::new();
6214                for block in &a.blocks {
6215                    match block {
6216                        crate::types::AssistantBlock::Text { text, .. }
6217                        | crate::types::AssistantBlock::Transcript { text, .. } => {
6218                            buf.push_str(text);
6219                        }
6220                        _ => {}
6221                    }
6222                }
6223                if buf.is_empty() { None } else { Some(buf) }
6224            }
6225            _ => None,
6226        })
6227    }
6228
6229    /// Count tool calls made
6230    pub fn tool_call_count(&self) -> usize {
6231        self.messages
6232            .iter()
6233            .filter_map(|m| match m {
6234                Message::BlockAssistant(a) => Some(
6235                    a.blocks
6236                        .iter()
6237                        .filter(|b| matches!(b, crate::types::AssistantBlock::ToolUse { .. }))
6238                        .count(),
6239                ),
6240                _ => None,
6241            })
6242            .sum()
6243    }
6244
6245    /// Get metadata
6246    pub fn metadata(&self) -> &serde_json::Map<String, serde_json::Value> {
6247        &self.metadata
6248    }
6249
6250    /// Memoized canonical witness of the current transcript-history graph.
6251    ///
6252    /// `None` means "not derived yet in this session instance", never
6253    /// "absent": the caller derives and records it. Cleared by every write to
6254    /// [`SESSION_TRANSCRIPT_HISTORY_STATE_KEY`].
6255    pub(crate) fn cached_transcript_history_witness(&self) -> Option<&str> {
6256        self.history_caches.witness.get().map(String::as_str)
6257    }
6258
6259    /// Record the canonical witness derived from the CURRENT history value.
6260    pub(crate) fn record_transcript_history_witness(&self, witness: &str) {
6261        let _ = self.history_caches.witness.set(witness.to_string());
6262    }
6263
6264    /// Assemble the transcript-history checkpoint witness incrementally:
6265    /// byte-identical to `canonical_value_digest(canonicalize_checkpoint_
6266    /// history_value(history))`, served as ONE raw SHA-256 pass over cached
6267    /// canonical segments instead of a clone + parse + canonicalize + write
6268    /// pass over the whole graph.
6269    ///
6270    /// `None` on any structural surprise — the caller falls back to the full
6271    /// canonicalization path, which is also where malformed graphs get their
6272    /// typed errors. Only graphs installed by an in-process typed path
6273    /// (`Validated`) are assembled, so the legacy heal/reconstruction steps
6274    /// the full parse performs are known no-ops for every assembled graph.
6275    ///
6276    /// The irreducible residual is the hash itself: the canonical form
6277    /// interleaves the per-append-changing `head` BEFORE the retained bodies
6278    /// (`commits` < `head` < `revisions`), and SHA-256 is sequential, so any
6279    /// head change forces re-hashing every byte after it. Removing that pass
6280    /// requires a digest-format change and is out of phase-1 scope.
6281    pub(crate) fn assemble_transcript_history_witness(
6282        &self,
6283        history: &serde_json::Value,
6284    ) -> Option<crate::checkpoint::SessionCheckpointDigest> {
6285        use sha2::Digest as _;
6286        if self.transcript_history_metadata_validation
6287            != TranscriptHistoryMetadataValidation::Validated
6288        {
6289            return None;
6290        }
6291        let object = history.as_object()?;
6292        let head = object.get("head")?.as_str()?;
6293        let commits_value = object.get("commits")?;
6294        let commits_array = commits_value.as_array()?;
6295        let revisions = object.get("revisions")?.as_array()?;
6296
6297        let commits_last = match commits_array.last() {
6298            Some(commit) => commit.get("revision")?.as_str()?.to_string(),
6299            None => String::new(),
6300        };
6301        let mut cache = self.history_caches.assembly.locked();
6302        let commits_bytes = match &cache.commits {
6303            Some((count, last, bytes))
6304                if *count == commits_array.len() && *last == commits_last =>
6305            {
6306                std::sync::Arc::clone(bytes)
6307            }
6308            _ => {
6309                let typed: Vec<TranscriptRewriteCommit> =
6310                    serde_json::from_value(commits_value.clone()).ok()?;
6311                let value = serde_json::to_value(&typed).ok()?;
6312                let mut bytes = Vec::new();
6313                crate::checkpoint::write_canonical_json(&value, &mut bytes).ok()?;
6314                let bytes: std::sync::Arc<[u8]> = bytes.into();
6315                cache.commits = Some((
6316                    commits_array.len(),
6317                    commits_last,
6318                    std::sync::Arc::clone(&bytes),
6319                ));
6320                bytes
6321            }
6322        };
6323
6324        // (revision, chunk) pairs; `None` bytes = the live head body, which
6325        // streams from the accumulator's retained sorted transcript bytes.
6326        let mut chunks: Vec<(&str, Option<std::sync::Arc<[u8]>>)> =
6327            Vec::with_capacity(revisions.len());
6328        for body_value in revisions {
6329            let revision = body_value.get("revision")?.as_str()?;
6330            if revision == head {
6331                let body_messages = body_value.get("messages")?.as_array()?;
6332                if body_messages.len() != self.messages.len() {
6333                    return None;
6334                }
6335                chunks.push((revision, None));
6336                continue;
6337            }
6338            let bytes = match cache.bodies.get(revision) {
6339                Some(bytes) => std::sync::Arc::clone(bytes),
6340                None => {
6341                    let bytes = canonical_history_body_chunk(body_value)?;
6342                    let bytes: std::sync::Arc<[u8]> = bytes.into();
6343                    cache
6344                        .bodies
6345                        .insert(revision.to_string(), std::sync::Arc::clone(&bytes));
6346                    bytes
6347                }
6348            };
6349            chunks.push((revision, Some(bytes)));
6350        }
6351        // Bound the cache to bodies the current graph retains.
6352        if cache.bodies.len() > revisions.len() {
6353            let live = revisions
6354                .iter()
6355                .filter_map(|body| body.get("revision").and_then(serde_json::Value::as_str))
6356                .collect::<std::collections::HashSet<_>>();
6357            cache
6358                .bodies
6359                .retain(|revision, _| live.contains(revision.as_str()));
6360        }
6361        drop(cache);
6362
6363        // Stable sort by revision string: exactly the ordering
6364        // `canonicalize_checkpoint_history_value` applies to the array.
6365        chunks.sort_by(|left, right| left.0.cmp(right.0));
6366
6367        let mut hasher = Sha256::new();
6368        let mut hashed_bytes = 0u64;
6369        let mut absorb = |hasher: &mut Sha256, bytes: &[u8]| {
6370            hashed_bytes += bytes.len() as u64;
6371            hasher.update(bytes);
6372        };
6373        absorb(&mut hasher, b"{\"commits\":");
6374        absorb(&mut hasher, &commits_bytes);
6375        absorb(&mut hasher, b",\"head\":");
6376        absorb(&mut hasher, serde_json::to_string(head).ok()?.as_bytes());
6377        absorb(&mut hasher, b",\"revisions\":[");
6378        for (index, (revision, bytes)) in chunks.iter().enumerate() {
6379            if index > 0 {
6380                absorb(&mut hasher, b",");
6381            }
6382            match bytes {
6383                Some(bytes) => absorb(&mut hasher, bytes),
6384                None => {
6385                    absorb(&mut hasher, b"{\"messages\":");
6386                    if !self.messages.hash_sorted_canonical_into(&mut hasher) {
6387                        return None;
6388                    }
6389                    absorb(&mut hasher, b",\"revision\":");
6390                    absorb(
6391                        &mut hasher,
6392                        serde_json::to_string(revision).ok()?.as_bytes(),
6393                    );
6394                    absorb(&mut hasher, b"}");
6395                }
6396            }
6397        }
6398        absorb(&mut hasher, b"]}");
6399        // One whole-graph hash pass: counted as one budget pass with the
6400        // bytes it actually hashed (the sorted-stream bytes count inside
6401        // `hash_sorted_canonical_into`).
6402        crate::checkpoint::record_content_digest_computation();
6403        crate::checkpoint::record_content_digest_bytes(hashed_bytes);
6404        let digest = crate::checkpoint::SessionCheckpointDigest::from_assembled(format!(
6405            "sha256:{:x}",
6406            hasher.finalize()
6407        ));
6408
6409        if digest_accumulator_take_verification_sample() {
6410            let recomputed =
6411                crate::checkpoint::session_checkpoint_history_digest_uncounted(history).ok()?;
6412            assert_eq!(
6413                digest, recomputed,
6414                "incremental history-witness assembly diverged from the canonical \
6415                 derivation: a cached segment or the sorted transcript stream is \
6416                 stale, or a keying assumption (content-addressed bodies, \
6417                 append-only commits) was violated"
6418            );
6419        }
6420        Some(digest)
6421    }
6422
6423    fn set_metadata_unchecked(&mut self, key: &str, value: serde_json::Value) {
6424        // Reapplying an identical durable projection is not a session-content
6425        // mutation. In particular, cold materialization restores the sealed
6426        // SessionMetadata and SessionBuildState before it knows whether the
6427        // values changed; advancing `updated_at` for an exact no-op would
6428        // rotate the checkpoint digest and manufacture a sibling checkpoint
6429        // even though the committed document is unchanged.
6430        if self.metadata.get(key) == Some(&value) {
6431            return;
6432        }
6433        self.metadata.insert(key.to_string(), value);
6434        if key == SESSION_TRANSCRIPT_HISTORY_STATE_KEY {
6435            self.metadata
6436                .remove(SESSION_TRANSCRIPT_HISTORY_CHECKPOINT_DIGEST_KEY);
6437            self.history_caches.witness = std::sync::OnceLock::new();
6438            self.history_caches.shared_state.clear();
6439            self.transcript_history_metadata_validation =
6440                TranscriptHistoryMetadataValidation::RequiresValidation;
6441        }
6442        self.updated_at = SystemTime::now();
6443    }
6444
6445    /// Install transcript history that was produced by a typed path which
6446    /// already validated and compacted the graph.
6447    fn set_validated_transcript_history_metadata(&mut self, value: serde_json::Value) {
6448        self.metadata
6449            .insert(SESSION_TRANSCRIPT_HISTORY_STATE_KEY.to_string(), value);
6450        self.metadata
6451            .remove(SESSION_TRANSCRIPT_HISTORY_CHECKPOINT_DIGEST_KEY);
6452        self.history_caches.witness = std::sync::OnceLock::new();
6453        self.history_caches.shared_state.clear();
6454        self.transcript_history_metadata_validation =
6455            TranscriptHistoryMetadataValidation::Validated;
6456        self.updated_at = SystemTime::now();
6457    }
6458
6459    /// [`Self::set_validated_transcript_history_metadata`] when the caller
6460    /// also holds the typed state it just serialized: caches the parsed form
6461    /// so guards and the next append refresh skip the O(graph) reparse.
6462    fn set_validated_transcript_history_metadata_with_state(
6463        &mut self,
6464        value: serde_json::Value,
6465        state: std::sync::Arc<TranscriptHistoryState>,
6466    ) {
6467        self.set_validated_transcript_history_metadata(value);
6468        self.history_caches.shared_state.set(state);
6469    }
6470
6471    #[cfg(test)]
6472    pub(crate) fn set_metadata_unchecked_for_test(&mut self, key: &str, value: serde_json::Value) {
6473        self.set_metadata_unchecked(key, value);
6474    }
6475
6476    fn fork_metadata_projection(&self) -> serde_json::Map<String, serde_json::Value> {
6477        let mut metadata = self.metadata.clone();
6478        metadata.retain(|key, _| !is_session_authority_metadata_key(key));
6479        metadata
6480    }
6481
6482    fn remove_metadata_unchecked(&mut self, key: &str) {
6483        let removed = self.metadata.remove(key).is_some();
6484        let mut changed = removed;
6485        if key == SESSION_TRANSCRIPT_HISTORY_STATE_KEY {
6486            changed |= self
6487                .metadata
6488                .remove(SESSION_TRANSCRIPT_HISTORY_CHECKPOINT_DIGEST_KEY)
6489                .is_some();
6490            self.history_caches.witness = std::sync::OnceLock::new();
6491            self.history_caches.shared_state.clear();
6492            self.transcript_history_metadata_validation =
6493                TranscriptHistoryMetadataValidation::Validated;
6494        }
6495        if changed {
6496            self.updated_at = SystemTime::now();
6497        }
6498    }
6499
6500    /// Set a metadata value when the key is not reserved for generated authority.
6501    pub fn try_set_metadata(
6502        &mut self,
6503        key: &str,
6504        value: serde_json::Value,
6505    ) -> Result<(), ReservedSessionMetadataKey> {
6506        if is_session_authority_metadata_key(key) {
6507            return Err(ReservedSessionMetadataKey::new(key));
6508        }
6509        self.set_metadata_unchecked(key, value);
6510        Ok(())
6511    }
6512
6513    /// Set a metadata value.
6514    ///
6515    /// Reserved generated-authority metadata keys fail closed and are left
6516    /// untouched. Use the typed setters for those keys.
6517    pub fn set_metadata(&mut self, key: &str, value: serde_json::Value) {
6518        if let Err(err) = self.try_set_metadata(key, value) {
6519            tracing::warn!(error = %err, "rejected raw session metadata mutation");
6520        }
6521    }
6522
6523    /// Backfill a missing metadata value without changing `updated_at`.
6524    ///
6525    /// This is only for compatibility reads that need to hydrate metadata from
6526    /// an older projection. Semantic metadata mutations must use
6527    /// [`Session::set_metadata`] so the session timestamp advances.
6528    pub fn backfill_metadata_if_absent(&mut self, key: &str, value: serde_json::Value) -> bool {
6529        if is_session_authority_metadata_key(key) {
6530            tracing::warn!(
6531                metadata_key = key,
6532                "rejected raw session metadata backfill for authority key"
6533            );
6534            return false;
6535        }
6536        if self.metadata.contains_key(key) {
6537            false
6538        } else {
6539            self.metadata.insert(key.to_string(), value);
6540            true
6541        }
6542    }
6543
6544    /// Remove a metadata value.
6545    pub fn remove_metadata(&mut self, key: &str) {
6546        if is_session_authority_metadata_key(key) {
6547            tracing::warn!(
6548                metadata_key = key,
6549                "rejected raw session metadata removal for authority key"
6550            );
6551            return;
6552        }
6553        if self.metadata.remove(key).is_some() {
6554            self.updated_at = SystemTime::now();
6555        }
6556    }
6557
6558    /// Store SessionMetadata in the session metadata map.
6559    pub fn set_session_metadata(
6560        &mut self,
6561        metadata: SessionMetadata,
6562    ) -> Result<(), serde_json::Error> {
6563        let metadata =
6564            session_durable_config_authority::authorize_session_metadata_persist(metadata)
6565                .map_err(<serde_json::Error as serde::ser::Error>::custom)?
6566                .into_metadata();
6567        let value = serde_json::to_value(metadata)?;
6568        self.set_metadata_unchecked(SESSION_METADATA_KEY, value);
6569        Ok(())
6570    }
6571
6572    /// Load SessionMetadata from the session metadata map.
6573    ///
6574    /// If the reserved key exists but cannot pass typed generated restore,
6575    /// fail closed instead of treating corrupted machine facts as absent.
6576    pub fn session_metadata(&self) -> Option<SessionMetadata> {
6577        match self.try_session_metadata() {
6578            Ok(metadata) => metadata,
6579            Err(err) => fail_closed_generated_restore("session-metadata", err),
6580        }
6581    }
6582
6583    /// Try to load SessionMetadata through generated restore authority.
6584    pub fn try_session_metadata(&self) -> Result<Option<SessionMetadata>, serde_json::Error> {
6585        try_session_metadata_from_map(&self.metadata)
6586    }
6587
6588    /// Store durable system-context control state in the session metadata map.
6589    pub fn set_system_context_state(
6590        &mut self,
6591        state: SessionSystemContextState,
6592    ) -> Result<(), serde_json::Error> {
6593        let state = system_context_authority::restore_system_context_state(state)
6594            .map_err(<serde_json::Error as serde::ser::Error>::custom)?;
6595        let value = serde_json::to_value(state)?;
6596        self.set_metadata_unchecked(SESSION_SYSTEM_CONTEXT_STATE_KEY, value);
6597        Ok(())
6598    }
6599
6600    /// Try to load durable system-context control state through generated restore authority.
6601    pub fn try_system_context_state(
6602        &self,
6603    ) -> Result<Option<SessionSystemContextState>, serde_json::Error> {
6604        self.metadata
6605            .get(SESSION_SYSTEM_CONTEXT_STATE_KEY)
6606            .map(|value| {
6607                let state = serde_json::from_value(value.clone())?;
6608                system_context_authority::restore_system_context_state(state)
6609                    .map_err(<serde_json::Error as serde::de::Error>::custom)
6610            })
6611            .transpose()
6612    }
6613
6614    /// Load durable system-context control state from the session metadata map.
6615    ///
6616    /// Rejected durable facts fail closed through the generated restore
6617    /// authority. Callers that need the typed rejection must use
6618    /// [`Self::try_system_context_state`].
6619    pub fn system_context_state(&self) -> Option<SessionSystemContextState> {
6620        match self.try_system_context_state() {
6621            Ok(state) => state,
6622            Err(err) => fail_closed_generated_restore("system-context", err),
6623        }
6624    }
6625
6626    /// Store durable deferred-turn control state in the session metadata map.
6627    pub fn set_deferred_turn_state(
6628        &mut self,
6629        state: SessionDeferredTurnState,
6630    ) -> Result<(), serde_json::Error> {
6631        let state = validate_deferred_turn_snapshot(state)
6632            .map_err(<serde_json::Error as serde::ser::Error>::custom)?;
6633        let value = serde_json::to_value(state)?;
6634        self.set_metadata_unchecked(SESSION_DEFERRED_TURN_STATE_KEY, value);
6635        Ok(())
6636    }
6637
6638    /// Try to load durable deferred-turn control state through generated restore authority.
6639    pub fn try_deferred_turn_state(
6640        &self,
6641    ) -> Result<Option<SessionDeferredTurnState>, serde_json::Error> {
6642        self.metadata
6643            .get(SESSION_DEFERRED_TURN_STATE_KEY)
6644            .map(|value| {
6645                let state = serde_json::from_value(value.clone())?;
6646                validate_deferred_turn_snapshot(state)
6647                    .map_err(<serde_json::Error as serde::de::Error>::custom)
6648            })
6649            .transpose()
6650    }
6651
6652    /// Load durable deferred-turn control state from the session metadata map.
6653    ///
6654    /// Rejected durable facts fail closed through the generated restore
6655    /// authority. Callers that need the typed rejection must use
6656    /// [`Self::try_deferred_turn_state`].
6657    pub fn deferred_turn_state(&self) -> Option<SessionDeferredTurnState> {
6658        match self.try_deferred_turn_state() {
6659            Ok(state) => state,
6660            Err(err) => fail_closed_generated_restore("deferred-turn", err),
6661        }
6662    }
6663
6664    /// Stage an external-callback batch without publishing any
6665    /// provider-visible tool results or sibling transcript effects.
6666    pub(crate) fn stage_pending_callback_tool_batch(
6667        &mut self,
6668        batch: PendingCallbackToolBatch,
6669    ) -> Result<(), PendingCallbackBatchError> {
6670        if matches!(
6671            self.callback_tool_batch_state()?,
6672            Some(CallbackToolBatchState::Pending { .. })
6673        ) {
6674            return Err(PendingCallbackBatchError::AlreadyStaged);
6675        }
6676        validate_pending_callback_batch(self.messages(), &batch)?;
6677        let value = serde_json::to_value(CallbackToolBatchState::Pending { batch })
6678            .map_err(|error| PendingCallbackBatchError::Malformed(error.to_string()))?;
6679        self.set_metadata_unchecked(SESSION_PENDING_CALLBACK_BATCH_KEY, value);
6680        Ok(())
6681    }
6682
6683    fn callback_tool_batch_state(
6684        &self,
6685    ) -> Result<Option<CallbackToolBatchState>, PendingCallbackBatchError> {
6686        self.metadata
6687            .get(SESSION_PENDING_CALLBACK_BATCH_KEY)
6688            .map(|value| {
6689                serde_json::from_value(value.clone())
6690                    .map_err(|error| PendingCallbackBatchError::Malformed(error.to_string()))
6691            })
6692            .transpose()
6693    }
6694
6695    /// Restore the typed callback batch. A corrupt durable record is a typed
6696    /// refusal, never "no pending callback".
6697    pub(crate) fn pending_callback_tool_batch(
6698        &self,
6699    ) -> Result<Option<PendingCallbackToolBatch>, PendingCallbackBatchError> {
6700        match self.callback_tool_batch_state()? {
6701            Some(CallbackToolBatchState::Pending { batch }) => {
6702                validate_pending_callback_batch(self.messages(), &batch)?;
6703                Ok(Some(batch))
6704            }
6705            Some(CallbackToolBatchState::Applied { .. }) | None => Ok(None),
6706        }
6707    }
6708
6709    /// Validate external callback results and combine them with staged sibling
6710    /// results in the original assistant tool-use order, without mutation.
6711    pub(crate) fn resolve_pending_callback_tool_results(
6712        &self,
6713        incoming: Vec<ToolResult>,
6714    ) -> Result<ResolvedPendingCallbackToolResults, PendingCallbackBatchError> {
6715        let Some(state) = self.callback_tool_batch_state()? else {
6716            return Ok(ResolvedPendingCallbackToolResults::NoState);
6717        };
6718        let batch = match state {
6719            CallbackToolBatchState::Pending { batch } => batch,
6720            CallbackToolBatchState::Applied {
6721                tool_use_order,
6722                results,
6723                async_ops,
6724                ..
6725            } => {
6726                let incoming_by_id = unique_tool_results(incoming)?;
6727                let expected = tool_use_order.iter().cloned().collect::<BTreeSet<_>>();
6728                let actual = incoming_by_id.keys().cloned().collect::<BTreeSet<_>>();
6729                if actual != expected {
6730                    return Err(PendingCallbackBatchError::ResultSetMismatch { expected, actual });
6731                }
6732                let delivered = tool_use_order
6733                    .iter()
6734                    .map(|id| incoming_by_id.get(id).cloned())
6735                    .collect::<Option<Vec<_>>>()
6736                    .ok_or_else(|| {
6737                        PendingCallbackBatchError::Malformed(
6738                            "applied callback receipt is missing an ordered result".to_string(),
6739                        )
6740                    })?;
6741                return if delivered == results {
6742                    Ok(ResolvedPendingCallbackToolResults::AlreadyApplied { async_ops })
6743                } else {
6744                    Err(PendingCallbackBatchError::ConflictingRedelivery)
6745                };
6746            }
6747        };
6748        validate_pending_callback_batch(self.messages(), &batch)?;
6749        let incoming_by_id = unique_tool_results(incoming)?;
6750        let expected = batch
6751            .pending_tool_use_ids
6752            .iter()
6753            .cloned()
6754            .collect::<BTreeSet<_>>();
6755        let actual = incoming_by_id.keys().cloned().collect::<BTreeSet<_>>();
6756        if actual != expected {
6757            return Err(PendingCallbackBatchError::ResultSetMismatch { expected, actual });
6758        }
6759        let mut all_by_id = unique_tool_results(batch.completed_results.clone())?;
6760        all_by_id.extend(incoming_by_id);
6761        let ordered = batch
6762            .tool_use_order
6763            .iter()
6764            .map(|id| {
6765                all_by_id.remove(id).ok_or_else(|| {
6766                    PendingCallbackBatchError::Malformed(format!(
6767                        "no result is available for assistant tool id '{id}'"
6768                    ))
6769                })
6770            })
6771            .collect::<Result<Vec<_>, _>>()?;
6772        if !all_by_id.is_empty() {
6773            return Err(PendingCallbackBatchError::Malformed(format!(
6774                "results contain ids absent from assistant tool-use order: {:?}",
6775                all_by_id.keys().collect::<Vec<_>>()
6776            )));
6777        }
6778        Ok(ResolvedPendingCallbackToolResults::Pending {
6779            batch,
6780            ordered_results: ordered,
6781        })
6782    }
6783
6784    /// Publish the already-resolved full `ToolResults` set and any
6785    /// transcript-producing sibling effects as one adjacent message batch,
6786    /// then replace the durable staging record with an idempotency receipt.
6787    pub(crate) fn commit_pending_callback_tool_results(
6788        &mut self,
6789        batch: &PendingCallbackToolBatch,
6790        ordered_results: Vec<ToolResult>,
6791        post_tool_messages: Vec<Message>,
6792    ) -> Result<(), PendingCallbackBatchError> {
6793        let current = self
6794            .pending_callback_tool_batch()?
6795            .ok_or(PendingCallbackBatchError::Missing)?;
6796        if &current != batch {
6797            return Err(PendingCallbackBatchError::Malformed(
6798                "pending callback batch changed between prepare and commit".to_string(),
6799            ));
6800        }
6801        let actual_order = ordered_results
6802            .iter()
6803            .map(|result| result.tool_use_id.clone())
6804            .collect::<Vec<_>>();
6805        if actual_order != batch.tool_use_order {
6806            return Err(PendingCallbackBatchError::Malformed(format!(
6807                "resolved result order {actual_order:?} does not match assistant order {:?}",
6808                batch.tool_use_order
6809            )));
6810        }
6811        self.push(Message::tool_results(ordered_results.clone()));
6812        let pending_ids = batch
6813            .pending_tool_use_ids
6814            .iter()
6815            .cloned()
6816            .collect::<BTreeSet<_>>();
6817        let applied_callback_results = ordered_results
6818            .into_iter()
6819            .filter(|result| pending_ids.contains(&result.tool_use_id))
6820            .collect();
6821        let value = serde_json::to_value(CallbackToolBatchState::Applied {
6822            tool_use_order: batch.pending_tool_use_ids.clone(),
6823            results: applied_callback_results,
6824            async_ops: batch.async_ops.clone(),
6825            post_tool_messages,
6826            post_tool_messages_applied: false,
6827        })
6828        .map_err(|error| PendingCallbackBatchError::Malformed(error.to_string()))?;
6829        self.set_metadata_unchecked(SESSION_PENDING_CALLBACK_BATCH_KEY, value);
6830        Ok(())
6831    }
6832
6833    /// Apply callback-staged post-tool transcript effects only after the
6834    /// ToolResults tail has been admitted as a pending continuation. This
6835    /// preserves provider adjacency and prevents the effects from hiding the
6836    /// continuation boundary from session admission.
6837    pub(crate) fn apply_pending_callback_resume_effects(
6838        &mut self,
6839    ) -> Result<Vec<crate::event::AssistantImageEvent>, PendingCallbackBatchError> {
6840        let Some(CallbackToolBatchState::Applied {
6841            tool_use_order,
6842            results,
6843            async_ops,
6844            post_tool_messages,
6845            post_tool_messages_applied,
6846        }) = self.callback_tool_batch_state()?
6847        else {
6848            return Ok(Vec::new());
6849        };
6850        if post_tool_messages_applied {
6851            return Ok(Vec::new());
6852        }
6853        let image_events = post_tool_messages
6854            .iter()
6855            .filter_map(|message| match message {
6856                Message::BlockAssistant(assistant) => Some(assistant.blocks.as_slice()),
6857                _ => None,
6858            })
6859            .flatten()
6860            .filter_map(crate::event::AssistantImageEvent::from_assistant_block)
6861            .collect::<Vec<_>>();
6862        let applied_state = CallbackToolBatchState::Applied {
6863            tool_use_order,
6864            results,
6865            async_ops,
6866            post_tool_messages: post_tool_messages.clone(),
6867            post_tool_messages_applied: true,
6868        };
6869        let value = serde_json::to_value(applied_state)
6870            .map_err(|error| PendingCallbackBatchError::Malformed(error.to_string()))?;
6871        self.push_batch(post_tool_messages);
6872        self.set_metadata_unchecked(SESSION_PENDING_CALLBACK_BATCH_KEY, value);
6873        Ok(image_events)
6874    }
6875
6876    /// Realize the typed session lifecycle-terminal projection in the session
6877    /// metadata map.
6878    ///
6879    /// The lifecycle-terminal fact is owned by the canonical
6880    /// [`session_document::SessionDocumentMachine`]; production archive paths
6881    /// call this only to realize a machine-emitted `SessionArchiveResolved`
6882    /// verdict (the value written mirrors the machine's decision — the shell
6883    /// decides nothing here).
6884    pub fn set_lifecycle_terminal(
6885        &mut self,
6886        terminal: SessionLifecycleTerminal,
6887    ) -> Result<(), serde_json::Error> {
6888        let value = serde_json::to_value(terminal)?;
6889        self.set_metadata_unchecked(SESSION_LIFECYCLE_TERMINAL_KEY, value);
6890        Ok(())
6891    }
6892
6893    /// Try to load the typed session lifecycle-terminal fact.
6894    ///
6895    /// Reads the typed [`SESSION_LIFECYCLE_TERMINAL_KEY`]; an absent key means
6896    /// no terminal fact.
6897    pub fn try_lifecycle_terminal(
6898        &self,
6899    ) -> Result<Option<SessionLifecycleTerminal>, serde_json::Error> {
6900        try_lifecycle_terminal_from_map(&self.metadata)
6901    }
6902
6903    /// Load the typed session lifecycle-terminal fact, failing closed on a
6904    /// corrupt typed value.
6905    ///
6906    /// Callers that need the typed rejection must use
6907    /// [`Self::try_lifecycle_terminal`].
6908    pub fn lifecycle_terminal(&self) -> Option<SessionLifecycleTerminal> {
6909        match self.try_lifecycle_terminal() {
6910            Ok(state) => state,
6911            Err(err) => fail_closed_generated_restore("session-lifecycle-terminal", err),
6912        }
6913    }
6914
6915    /// Store recoverable build-only session state in the session metadata map.
6916    pub fn set_build_state(&mut self, state: SessionBuildState) -> Result<(), serde_json::Error> {
6917        let state = session_durable_config_authority::authorize_session_build_state_persist(state)
6918            .map_err(<serde_json::Error as serde::ser::Error>::custom)?
6919            .into_state();
6920        let value = serde_json::to_value(state)?;
6921        self.set_metadata_unchecked(SESSION_BUILD_STATE_KEY, value);
6922        Ok(())
6923    }
6924
6925    /// Load recoverable build-only session state from the session metadata map.
6926    ///
6927    /// If the reserved key exists but cannot pass typed generated restore,
6928    /// fail closed instead of treating corrupted machine facts as absent.
6929    pub fn build_state(&self) -> Option<SessionBuildState> {
6930        match self.try_build_state() {
6931            Ok(state) => state,
6932            Err(err) => fail_closed_generated_restore("session-build-state", err),
6933        }
6934    }
6935
6936    /// Try to load recoverable build-only session state through generated restore authority.
6937    pub fn try_build_state(&self) -> Result<Option<SessionBuildState>, serde_json::Error> {
6938        let Some(value) = self.metadata.get(SESSION_BUILD_STATE_KEY) else {
6939            return Ok(None);
6940        };
6941        let state = serde_json::from_value::<SessionBuildState>(value.clone())?;
6942        session_durable_config_authority::restore_session_build_state(state)
6943            .map(Some)
6944            .map_err(<serde_json::Error as serde::de::Error>::custom)
6945    }
6946
6947    /// Store durable tool-visibility control state in the session metadata map.
6948    pub fn set_tool_visibility_state(
6949        &mut self,
6950        state: AuthorizedSessionToolVisibilityState,
6951    ) -> Result<(), serde_json::Error> {
6952        let value = serde_json::to_value(state.into_state())?;
6953        self.set_metadata_unchecked(SESSION_TOOL_VISIBILITY_STATE_KEY, value);
6954        Ok(())
6955    }
6956
6957    /// Test-only metadata clear for compatibility assertions.
6958    ///
6959    /// Production paths persist an explicit generated-authority projection
6960    /// rather than making durable absence carry semantic default truth.
6961    #[cfg(test)]
6962    pub(crate) fn clear_tool_visibility_state(&mut self) {
6963        self.remove_metadata_unchecked(SESSION_TOOL_VISIBILITY_STATE_KEY);
6964    }
6965
6966    /// Load durable tool-visibility control state from the session metadata map.
6967    pub fn tool_visibility_state(
6968        &self,
6969    ) -> Result<Option<SessionToolVisibilityState>, serde_json::Error> {
6970        self.try_tool_visibility_state()
6971    }
6972
6973    /// Load durable tool-visibility control state while distinguishing absent
6974    /// metadata from malformed canonical metadata.
6975    pub fn try_tool_visibility_state(
6976        &self,
6977    ) -> Result<Option<SessionToolVisibilityState>, serde_json::Error> {
6978        self.metadata
6979            .get(SESSION_TOOL_VISIBILITY_STATE_KEY)
6980            .map(|value| serde_json::from_value(value.clone()))
6981            .transpose()
6982    }
6983
6984    /// Load typed transcript revision state from metadata.
6985    pub fn transcript_history_state(
6986        &self,
6987    ) -> Result<Option<TranscriptHistoryState>, serde_json::Error> {
6988        self.metadata
6989            .get(SESSION_TRANSCRIPT_HISTORY_STATE_KEY)
6990            .map(|value| serde_json::from_value(value.clone()))
6991            .transpose()
6992    }
6993
6994    /// [`Self::transcript_history_state`] served from the per-instance
6995    /// shared cache: one parse per graph value, shared by `Arc` thereafter.
6996    /// Every write to the history key clears the cache.
6997    pub(crate) fn transcript_history_state_shared(
6998        &self,
6999    ) -> Result<Option<std::sync::Arc<TranscriptHistoryState>>, serde_json::Error> {
7000        if let Some(state) = self.history_caches.shared_state.get() {
7001            return Ok(Some(state));
7002        }
7003        let Some(state) = self.transcript_history_state()? else {
7004            return Ok(None);
7005        };
7006        let state = std::sync::Arc::new(state);
7007        self.history_caches
7008            .shared_state
7009            .set(std::sync::Arc::clone(&state));
7010        Ok(Some(state))
7011    }
7012
7013    /// Return the already-validated transcript graph head without cloning and
7014    /// deserializing the full history document again.
7015    ///
7016    /// Store guards use this after typed Session deserialization when they
7017    /// only need to prove live-message/head coherence. Unchecked metadata
7018    /// still crosses the full graph validator before the borrowed head can be
7019    /// observed.
7020    pub(crate) fn validated_transcript_history_head(
7021        &self,
7022    ) -> Result<Option<&str>, TranscriptEditError> {
7023        self.validate_transcript_history_state()?;
7024        let Some(value) = self.metadata.get(SESSION_TRANSCRIPT_HISTORY_STATE_KEY) else {
7025            return Ok(None);
7026        };
7027        value
7028            .get("head")
7029            .and_then(serde_json::Value::as_str)
7030            .map(Some)
7031            .ok_or_else(|| {
7032                TranscriptEditError::HistoryStateMalformed(
7033                    "validated transcript history metadata omitted a string head".to_string(),
7034                )
7035            })
7036    }
7037
7038    /// Load exact compaction projection intents carried to the runtime's
7039    /// atomic-apply outbox by this session snapshot.
7040    pub fn compaction_projection_intents(
7041        &self,
7042    ) -> Result<Vec<crate::memory::CompactionProjectionIntent>, serde_json::Error> {
7043        self.metadata
7044            .get(crate::memory::SESSION_COMPACTION_PROJECTION_INTENTS_KEY)
7045            .map(|value| serde_json::from_value(value.clone()))
7046            .transpose()
7047            .map(Option::unwrap_or_default)
7048    }
7049
7050    /// Load persisted compaction intents only after proving that every
7051    /// already-carried projection ID is backed by this session's validated
7052    /// transcript graph.
7053    ///
7054    /// This is deliberately a validation boundary, not an ID constructor:
7055    /// durable typed rewrite tags and legacy records can confirm an existing
7056    /// identity during recovery but cannot mint a new identity.
7057    pub fn validated_compaction_projection_intents(
7058        &self,
7059    ) -> Result<Vec<crate::memory::CompactionProjectionIntent>, serde_json::Error> {
7060        self.validate_transcript_history_state()
7061            .map_err(|error| <serde_json::Error as serde::ser::Error>::custom(error.to_string()))?;
7062        let intents = self.compaction_projection_intents()?;
7063        if intents.is_empty() {
7064            return Ok(intents);
7065        }
7066        let history = self.transcript_history_state()?;
7067        let commits = history
7068            .as_ref()
7069            .map(|history| history.commits.as_slice())
7070            .unwrap_or_default();
7071        let mut unique = std::collections::HashSet::new();
7072        for intent in &intents {
7073            if intent.projection.session_id() != self.id() {
7074                return Err(<serde_json::Error as serde::ser::Error>::custom(
7075                    "compaction projection outbox intent has a foreign session id",
7076                ));
7077            }
7078            if !unique.insert(intent.projection.clone()) {
7079                return Err(<serde_json::Error as serde::ser::Error>::custom(
7080                    "compaction projection outbox contains a duplicate rewrite identity",
7081                ));
7082            }
7083            let backed = commits.iter().any(|commit| {
7084                intent
7085                    .projection
7086                    .matches_transcript_rewrite(self.id(), commit)
7087            });
7088            if !backed {
7089                return Err(<serde_json::Error as serde::ser::Error>::custom(format!(
7090                    "compaction projection outbox intent {} has no matching TranscriptRewriteCommit",
7091                    intent.projection.revision()
7092                )));
7093            }
7094        }
7095        Ok(intents)
7096    }
7097
7098    /// Record one invisible staged-memory intent only after its exact
7099    /// TranscriptRewriteCommit is present in the session graph.
7100    pub fn add_compaction_projection_intent(
7101        &mut self,
7102        intent: crate::memory::CompactionProjectionIntent,
7103    ) -> Result<(), serde_json::Error> {
7104        if intent.projection.session_id() != self.id() {
7105            return Err(<serde_json::Error as serde::ser::Error>::custom(
7106                "compaction projection intent session does not match snapshot session",
7107            ));
7108        }
7109        self.validate_transcript_history_state()
7110            .map_err(|error| <serde_json::Error as serde::ser::Error>::custom(error.to_string()))?;
7111        let history = self.transcript_history_state()?.ok_or_else(|| {
7112            <serde_json::Error as serde::ser::Error>::custom(
7113                "compaction projection intent requires transcript history state",
7114            )
7115        })?;
7116        let owns_commit = history.commits.iter().any(|commit| {
7117            commit.parent_revision == intent.projection.parent_revision()
7118                && commit.revision == intent.projection.revision()
7119                && intent
7120                    .projection
7121                    .matches_transcript_rewrite(self.id(), commit)
7122        });
7123        if !owns_commit {
7124            return Err(<serde_json::Error as serde::ser::Error>::custom(
7125                "compaction projection intent is not backed by the session transcript graph",
7126            ));
7127        }
7128        let mut intents = self.validated_compaction_projection_intents()?;
7129        if let Some(existing) = intents
7130            .iter()
7131            .find(|existing| existing.projection == intent.projection)
7132        {
7133            if existing == &intent {
7134                return Ok(());
7135            }
7136            return Err(<serde_json::Error as serde::ser::Error>::custom(
7137                "compaction projection intent conflicts with an existing rewrite identity",
7138            ));
7139        }
7140        intents.push(intent);
7141        self.set_metadata_unchecked(
7142            crate::memory::SESSION_COMPACTION_PROJECTION_INTENTS_KEY,
7143            serde_json::to_value(intents)?,
7144        );
7145        Ok(())
7146    }
7147
7148    /// Remove an intent after the runtime outbox has finalized its staged
7149    /// memory batch. Idempotent for repeated recovery finalization.
7150    pub fn complete_compaction_projection_intent(
7151        &mut self,
7152        projection: &crate::memory::CompactionProjectionId,
7153    ) -> Result<Option<crate::memory::CompactionProjectionIntent>, serde_json::Error> {
7154        let mut intents = self.compaction_projection_intents()?;
7155        let Some(position) = intents
7156            .iter()
7157            .position(|intent| &intent.projection == projection)
7158        else {
7159            return Ok(None);
7160        };
7161        let completed = intents.remove(position);
7162        if intents.is_empty() {
7163            self.remove_metadata_unchecked(
7164                crate::memory::SESSION_COMPACTION_PROJECTION_INTENTS_KEY,
7165            );
7166        } else {
7167            self.set_metadata_unchecked(
7168                crate::memory::SESSION_COMPACTION_PROJECTION_INTENTS_KEY,
7169                serde_json::to_value(intents)?,
7170            );
7171        }
7172        Ok(Some(completed))
7173    }
7174
7175    /// Validate the retained transcript revision graph, when present.
7176    pub fn validate_transcript_history_state(&self) -> Result<(), TranscriptEditError> {
7177        if self.transcript_history_metadata_validation
7178            == TranscriptHistoryMetadataValidation::Validated
7179        {
7180            return Ok(());
7181        }
7182        let Some(state) = self
7183            .transcript_history_state()
7184            .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?
7185        else {
7186            return Ok(());
7187        };
7188        validate_transcript_history_state(&state)
7189    }
7190
7191    /// Clear retained transcript revision metadata after a caller has
7192    /// materialized the desired message projection.
7193    pub fn clear_transcript_history_state(&mut self) {
7194        self.remove_metadata_unchecked(SESSION_TRANSCRIPT_HISTORY_STATE_KEY);
7195    }
7196
7197    /// Decode and verify this document's typed checkpoint state.
7198    ///
7199    /// Missing typed metadata is returned as explicit legacy-unverified state.
7200    /// A present malformed stamp or malformed legacy compatibility value is an
7201    /// error and is never laundered into absence.
7202    pub fn try_checkpoint_state(
7203        &self,
7204    ) -> Result<crate::checkpoint::SessionCheckpointState, crate::checkpoint::SessionCheckpointError>
7205    {
7206        let stamp =
7207            match crate::checkpoint::session_checkpoint_metadata_state(&self.id, &self.metadata)? {
7208                crate::checkpoint::SessionCheckpointMetadataState::Stamped(stamp) => stamp,
7209                crate::checkpoint::SessionCheckpointMetadataState::LegacyUnverified {
7210                    legacy_runtime_checkpoint,
7211                } => {
7212                    return Ok(
7213                        crate::checkpoint::SessionCheckpointState::LegacyUnverified {
7214                            legacy_runtime_checkpoint,
7215                        },
7216                    );
7217                }
7218            };
7219        let actual = crate::checkpoint::session_checkpoint_digest(self)?;
7220        if stamp.digest() != &actual {
7221            return Err(crate::checkpoint::SessionCheckpointError::DigestMismatch {
7222                expected: stamp.digest().clone(),
7223                actual,
7224            });
7225        }
7226        crate::checkpoint::record_checkpoint_stamp_verification(self, &actual);
7227        Ok(crate::checkpoint::SessionCheckpointState::Verified(stamp))
7228    }
7229
7230    /// [`Session::try_checkpoint_state`] for steady-state READS of durable
7231    /// documents, skipping the canonical-content digest recomputation when
7232    /// this process already fully verified this exact document shape and
7233    /// stamp digest.
7234    ///
7235    /// Admission into the memo requires one complete verification (here, in
7236    /// `try_checkpoint_state`, or at stamp install time), so the first read
7237    /// after boot still hashes once. Content changes re-key the memo and
7238    /// re-verify: the key carries the stamp digest plus the document's cheap
7239    /// shape (message count, metadata entry count, content timestamps), and
7240    /// every content mutation seam advances at least one of those. Write,
7241    /// adoption, and convergence seams must keep calling
7242    /// [`Session::try_checkpoint_state`]: a cached hit is memoized trust,
7243    /// not a fresh proof of current bytes.
7244    pub fn try_checkpoint_state_cached(
7245        &self,
7246    ) -> Result<crate::checkpoint::SessionCheckpointState, crate::checkpoint::SessionCheckpointError>
7247    {
7248        let stamp =
7249            match crate::checkpoint::session_checkpoint_metadata_state(&self.id, &self.metadata)? {
7250                crate::checkpoint::SessionCheckpointMetadataState::Stamped(stamp) => stamp,
7251                crate::checkpoint::SessionCheckpointMetadataState::LegacyUnverified {
7252                    legacy_runtime_checkpoint,
7253                } => {
7254                    return Ok(
7255                        crate::checkpoint::SessionCheckpointState::LegacyUnverified {
7256                            legacy_runtime_checkpoint,
7257                        },
7258                    );
7259                }
7260            };
7261        if crate::checkpoint::checkpoint_stamp_verification_is_cached(self, stamp.digest()) {
7262            return Ok(crate::checkpoint::SessionCheckpointState::Verified(stamp));
7263        }
7264        let actual = crate::checkpoint::session_checkpoint_digest(self)?;
7265        if stamp.digest() != &actual {
7266            return Err(crate::checkpoint::SessionCheckpointError::DigestMismatch {
7267                expected: stamp.digest().clone(),
7268                actual,
7269            });
7270        }
7271        crate::checkpoint::record_checkpoint_stamp_verification(self, &actual);
7272        Ok(crate::checkpoint::SessionCheckpointState::Verified(stamp))
7273    }
7274
7275    /// Install a prevalidated semantic checkpoint stamp on this exact
7276    /// document without changing its content timestamps.
7277    ///
7278    /// This is a mechanical serialization seam, not target-store write
7279    /// authority. A persistence implementation must still atomically validate
7280    /// its own observation and fencing preconditions before committing the
7281    /// resulting bytes.
7282    pub fn install_checkpoint_stamp(
7283        &mut self,
7284        stamp: crate::checkpoint::SessionCheckpointStamp,
7285    ) -> Result<(), crate::checkpoint::SessionCheckpointError> {
7286        stamp.validate_for_session(&self.id)?;
7287        // Fast path: this exact document shape was already proved to carry this
7288        // exact digest in this process — which is the case for the dominant
7289        // caller, a mint immediately followed by an install of the stamp it
7290        // just minted from the same unmutated document. The slow path is
7291        // unchanged: a foreign or stale stamp re-derives the canonical digest
7292        // and fails closed on mismatch.
7293        let actual =
7294            if crate::checkpoint::checkpoint_stamp_verification_is_cached(self, stamp.digest()) {
7295                stamp.digest().clone()
7296            } else {
7297                let actual = crate::checkpoint::session_checkpoint_digest(self)?;
7298                if stamp.digest() != &actual {
7299                    return Err(crate::checkpoint::SessionCheckpointError::DigestMismatch {
7300                        expected: stamp.digest().clone(),
7301                        actual,
7302                    });
7303                }
7304                actual
7305            };
7306        let value = serde_json::to_value(&stamp)?;
7307        self.metadata
7308            .remove(SESSION_RUNTIME_CHECKPOINT_PROVENANCE_KEY);
7309        self.metadata
7310            .insert(SESSION_CHECKPOINT_STAMP_KEY.to_string(), value);
7311        // Recorded after the stamp insertion so the memoized document shape
7312        // matches the persisted (and later reloaded) document exactly.
7313        crate::checkpoint::record_checkpoint_stamp_verification(self, &actual);
7314        Ok(())
7315    }
7316
7317    /// Fail-closed typed read of intra-turn checkpoint provenance.
7318    pub fn try_has_runtime_checkpoint_provenance(
7319        &self,
7320    ) -> Result<bool, crate::checkpoint::SessionCheckpointError> {
7321        match self.try_checkpoint_state()? {
7322            crate::checkpoint::SessionCheckpointState::Verified(stamp) => Ok(matches!(
7323                stamp.provenance(),
7324                crate::checkpoint::SessionCheckpointProvenance::IntraTurnCheckpoint
7325            )),
7326            crate::checkpoint::SessionCheckpointState::LegacyUnverified { .. } => {
7327                Err(crate::checkpoint::SessionCheckpointError::LegacyCheckpointUnverified)
7328            }
7329        }
7330    }
7331
7332    /// Set the legacy compatibility marker on an untyped projection.
7333    #[deprecated(
7334        note = "legacy compatibility only; typed writers must install an exact checkpoint stamp"
7335    )]
7336    pub fn set_runtime_checkpoint_provenance(
7337        &mut self,
7338    ) -> Result<(), crate::checkpoint::SessionCheckpointError> {
7339        if matches!(
7340            self.try_checkpoint_state()?,
7341            crate::checkpoint::SessionCheckpointState::Verified(_)
7342        ) {
7343            return Err(
7344                crate::checkpoint::SessionCheckpointError::LegacyProvenanceMutationOnTypedCheckpoint,
7345            );
7346        }
7347        self.set_metadata_unchecked(
7348            SESSION_RUNTIME_CHECKPOINT_PROVENANCE_KEY,
7349            serde_json::Value::Bool(true),
7350        );
7351        Ok(())
7352    }
7353
7354    /// Clear the legacy compatibility marker on an untyped projection.
7355    #[deprecated(
7356        note = "legacy compatibility only; typed writers must install an exact run-boundary successor"
7357    )]
7358    pub fn clear_runtime_checkpoint_provenance(
7359        &mut self,
7360    ) -> Result<(), crate::checkpoint::SessionCheckpointError> {
7361        if matches!(
7362            self.try_checkpoint_state()?,
7363            crate::checkpoint::SessionCheckpointState::Verified(_)
7364        ) {
7365            return Err(
7366                crate::checkpoint::SessionCheckpointError::LegacyProvenanceMutationOnTypedCheckpoint,
7367            );
7368        }
7369        self.remove_metadata_unchecked(SESSION_RUNTIME_CHECKPOINT_PROVENANCE_KEY);
7370        Ok(())
7371    }
7372
7373    /// Return the retained immutable body for a transcript revision.
7374    pub fn transcript_revision_body(
7375        &self,
7376        revision: &str,
7377    ) -> Result<Option<TranscriptRevisionBody>, serde_json::Error> {
7378        Ok(self.transcript_history_state()?.and_then(|state| {
7379            state
7380                .revisions
7381                .into_iter()
7382                .find(|body| body.revision == revision)
7383        }))
7384    }
7385
7386    /// Return the ordered messages for a retained transcript revision.
7387    pub fn transcript_revision_messages(
7388        &self,
7389        revision: &str,
7390    ) -> Result<Option<Vec<Message>>, serde_json::Error> {
7391        Ok(self
7392            .transcript_revision_body(revision)?
7393            .map(|body| body.messages))
7394    }
7395
7396    /// Materialize this session projection from a typed transcript history graph.
7397    pub fn apply_transcript_history_state(
7398        &mut self,
7399        mut state: TranscriptHistoryState,
7400    ) -> Result<(), TranscriptEditError> {
7401        state.compact_mechanical_revision_bodies()?;
7402        let head_body = state
7403            .revisions
7404            .iter()
7405            .find(|body| body.revision == state.head)
7406            .ok_or_else(|| {
7407                TranscriptEditError::HistoryStateMalformed(format!(
7408                    "missing transcript head body {}",
7409                    state.head
7410                ))
7411            })?
7412            .clone();
7413        let realtime_state =
7414            self.reconciled_realtime_transcript_metadata_after_rewrite(&head_body.messages)?;
7415        let value = serde_json::to_value(&state)
7416            .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
7417        self.set_validated_transcript_history_metadata(value);
7418        if let Some(value) = realtime_state {
7419            self.set_metadata_unchecked(SESSION_REALTIME_TRANSCRIPT_STATE_KEY, value);
7420        }
7421        let mut updated_at = head_body.created_at;
7422        for commit in &state.commits {
7423            if commit.committed_at > updated_at {
7424                updated_at = commit.committed_at;
7425            }
7426        }
7427        // SEAM 7 (non-append): the projection adopts a graph head body.
7428        self.messages.replace(head_body.messages);
7429        self.updated_at = updated_at;
7430        Ok(())
7431    }
7432
7433    /// Current transcript head revision. Rows written before transcript
7434    /// revisions derive their implicit head from the current message snapshot.
7435    pub fn transcript_revision(&self) -> Result<String, serde_json::Error> {
7436        if let Some(state) = self.transcript_history_state()? {
7437            Ok(state.head)
7438        } else {
7439            transcript_messages_digest(self.messages())
7440        }
7441    }
7442
7443    /// Monotonic durable generation for same-session transcript rewrites.
7444    /// Ordinary message appends advance the content revision but do not change
7445    /// this value, allowing live config refresh after normal turns while still
7446    /// forcing reopen after a rewrite.
7447    pub fn transcript_rewrite_generation(&self) -> Result<u64, serde_json::Error> {
7448        Ok(self.transcript_history_state()?.map_or(0, |state| {
7449            u64::try_from(state.commits.len()).unwrap_or(u64::MAX)
7450        }))
7451    }
7452
7453    /// Commit a same-session transcript rewrite and advance the transcript head.
7454    pub fn commit_transcript_rewrite(
7455        &mut self,
7456        selection: TranscriptRewriteSelection,
7457        replacement: Vec<Message>,
7458        reason: TranscriptRewriteReason,
7459        actor: Option<String>,
7460        expected_parent_revision: Option<String>,
7461    ) -> Result<TranscriptRewriteCommit, TranscriptEditError> {
7462        let selection = selection.into_current_edit_semantic();
7463        if selection.semantic() == TranscriptRewriteSemantic::Compaction {
7464            return Err(TranscriptEditError::InvalidTranscriptShape(
7465                "typed compaction rewrites require a core-validated compaction witness".to_string(),
7466            ));
7467        }
7468        self.commit_transcript_rewrite_authorized(
7469            selection,
7470            replacement,
7471            reason,
7472            actor,
7473            expected_parent_revision,
7474        )
7475    }
7476
7477    fn commit_transcript_rewrite_authorized(
7478        &mut self,
7479        selection: TranscriptRewriteSelection,
7480        replacement: Vec<Message>,
7481        reason: TranscriptRewriteReason,
7482        actor: Option<String>,
7483        expected_parent_revision: Option<String>,
7484    ) -> Result<TranscriptRewriteCommit, TranscriptEditError> {
7485        let parent_revision = self
7486            .transcript_revision()
7487            .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
7488        if let Some(expected) = expected_parent_revision
7489            && expected != parent_revision
7490        {
7491            return Err(TranscriptEditError::RevisionConflict {
7492                expected,
7493                actual: parent_revision,
7494            });
7495        }
7496
7497        let (start, end) = selection.bounds();
7498        let message_count = self.messages.len();
7499        if start > end || end > message_count {
7500            return Err(TranscriptEditError::InvalidRewriteRange {
7501                start,
7502                end,
7503                message_count,
7504            });
7505        }
7506
7507        let replacement_len = replacement.len();
7508        let mut rewritten = Vec::with_capacity(
7509            start
7510                .saturating_add(replacement_len)
7511                .saturating_add(message_count.saturating_sub(end)),
7512        );
7513        rewritten.extend_from_slice(&self.messages[..start]);
7514        rewritten.extend(replacement);
7515        rewritten.extend_from_slice(&self.messages[end..]);
7516        validate_transcript_tool_result_shape(&rewritten)?;
7517
7518        let original_span_digest = transcript_messages_digest(&self.messages[start..end])
7519            .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
7520        let replacement_digest =
7521            transcript_messages_digest(&rewritten[start..start + replacement_len])
7522                .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
7523        let revision = transcript_messages_digest(&rewritten)
7524            .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
7525        if revision == parent_revision {
7526            return Err(TranscriptEditError::NoOpRewrite { revision });
7527        }
7528        let realtime_state =
7529            self.reconciled_realtime_transcript_metadata_after_rewrite(&rewritten)?;
7530
7531        let commit = TranscriptRewriteCommit {
7532            parent_revision,
7533            revision: revision.clone(),
7534            selection,
7535            original_span_digest,
7536            replacement_digest,
7537            messages_before: message_count,
7538            messages_after: rewritten.len(),
7539            reason,
7540            actor,
7541            committed_at: SystemTime::now(),
7542        };
7543
7544        let mut state = self
7545            .transcript_history_state()
7546            .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?
7547            .unwrap_or_else(|| TranscriptHistoryState {
7548                head: commit.parent_revision.clone(),
7549                commits: Vec::new(),
7550                revisions: Vec::new(),
7551                digest_format: TRANSCRIPT_DIGEST_FORMAT_CURRENT,
7552            });
7553        if !state
7554            .revisions
7555            .iter()
7556            .any(|body| body.revision == commit.parent_revision)
7557        {
7558            state.revisions.push(TranscriptRevisionBody {
7559                revision: commit.parent_revision.clone(),
7560                parent_revision: None,
7561                messages: self.messages().to_vec(),
7562                created_at: self.updated_at,
7563            });
7564        }
7565        if !state
7566            .revisions
7567            .iter()
7568            .any(|body| body.revision == commit.revision)
7569        {
7570            state.revisions.push(TranscriptRevisionBody {
7571                revision: commit.revision.clone(),
7572                parent_revision: Some(commit.parent_revision.clone()),
7573                messages: rewritten.clone(),
7574                created_at: commit.committed_at,
7575            });
7576        }
7577        state.head = revision;
7578        state.commits.push(commit.clone());
7579        state.compact_mechanical_revision_bodies()?;
7580        let value = serde_json::to_value(state)
7581            .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
7582        self.set_validated_transcript_history_metadata(value);
7583        if let Some(value) = realtime_state {
7584            self.set_metadata_unchecked(SESSION_REALTIME_TRANSCRIPT_STATE_KEY, value);
7585        }
7586
7587        // SEAM 8 (non-append): an audited rewrite replaces a mid-vector span.
7588        self.messages.replace(rewritten);
7589        self.updated_at = SystemTime::now();
7590        Ok(commit)
7591    }
7592
7593    fn transcript_history_state_after_message_mutation(
7594        &self,
7595        messages: &[Message],
7596        head: String,
7597        created_at: SystemTime,
7598        shape: TranscriptMutationShape,
7599    ) -> Result<Option<TranscriptHistoryState>, TranscriptEditError> {
7600        if !self
7601            .metadata
7602            .contains_key(SESSION_TRANSCRIPT_HISTORY_STATE_KEY)
7603        {
7604            return Ok(None);
7605        }
7606        let mut state = self
7607            .transcript_history_state_shared()
7608            .map_err(|error| TranscriptEditError::HistoryStateMalformed(error.to_string()))?
7609            .map(|state| (*state).clone())
7610            .ok_or_else(|| {
7611                TranscriptEditError::HistoryStateMalformed(
7612                    "transcript history metadata key decoded without state".to_string(),
7613                )
7614            })?;
7615
7616        // Fast path: an APPEND onto a graph that a validating authority
7617        // installed. The two full graph validations this used to pay per
7618        // appended batch re-proved nothing that changed — every retained body
7619        // is untouched, every commit's inputs are untouched, and the one new
7620        // fact ("the new head body extends the previous head") is proved in
7621        // O(1) from a retained prefix midstate instead of by re-hashing the
7622        // whole graph. Anything that does not prove cleanly falls through to
7623        // the full validating path below; correctness never depends on the
7624        // fast path being taken.
7625        if shape == TranscriptMutationShape::Appended
7626            && self.transcript_history_metadata_validation
7627                == TranscriptHistoryMetadataValidation::Validated
7628            && let Some(previous_head_body) = state
7629                .revisions
7630                .iter()
7631                .find(|body| body.revision == state.head)
7632            && previous_head_body.messages.len() <= messages.len()
7633            && self
7634                .messages
7635                .prefix_digest_witness(previous_head_body.messages.len())
7636                .as_deref()
7637                == Some(state.head.as_str())
7638        {
7639            if !state.revisions.iter().any(|body| body.revision == head) {
7640                state.revisions.push(TranscriptRevisionBody {
7641                    revision: head.clone(),
7642                    parent_revision: state.commits.last().map(|commit| commit.revision.clone()),
7643                    messages: messages.to_vec(),
7644                    created_at,
7645                });
7646            }
7647            state.head = head;
7648            state.prune_mechanical_revision_bodies();
7649            return Ok(Some(state));
7650        }
7651
7652        state.compact_mechanical_revision_bodies()?;
7653        if !state.revisions.iter().any(|body| body.revision == head) {
7654            state.revisions.push(TranscriptRevisionBody {
7655                revision: head.clone(),
7656                parent_revision: state.commits.last().map(|commit| commit.revision.clone()),
7657                messages: messages.to_vec(),
7658                created_at,
7659            });
7660        }
7661        state.head = head;
7662        state.compact_mechanical_revision_bodies()?;
7663        Ok(Some(state))
7664    }
7665
7666    fn refresh_transcript_head_after_message_mutation(&mut self, shape: TranscriptMutationShape) {
7667        if !self
7668            .metadata
7669            .contains_key(SESSION_TRANSCRIPT_HISTORY_STATE_KEY)
7670        {
7671            return;
7672        }
7673        let head = match self.messages.digest() {
7674            Ok(head) => head,
7675            Err(error) => {
7676                tracing::warn!(
7677                    session_id = %self.id,
7678                    error = %error,
7679                    "failed to digest transcript after message mutation"
7680                );
7681                return;
7682            }
7683        };
7684        match self.transcript_history_state_after_message_mutation(
7685            self.messages(),
7686            head,
7687            SystemTime::now(),
7688            shape,
7689        ) {
7690            Ok(Some(state)) => match serde_json::to_value(&state) {
7691                Ok(value) => {
7692                    self.set_validated_transcript_history_metadata_with_state(
7693                        value,
7694                        std::sync::Arc::new(state),
7695                    );
7696                }
7697                Err(error) => {
7698                    tracing::warn!(
7699                        session_id = %self.id,
7700                        error = %error,
7701                        "failed to serialize transcript history state after message mutation"
7702                    );
7703                }
7704            },
7705            Ok(None) => {}
7706            Err(error) => {
7707                tracing::warn!(
7708                    session_id = %self.id,
7709                    error = %error,
7710                    "transcript history state failed validation after message mutation"
7711                );
7712            }
7713        }
7714    }
7715
7716    /// Store typed mob operator authority inside canonical build-state metadata.
7717    ///
7718    /// Store the mob operator authority projection inside build-state metadata.
7719    ///
7720    /// The projection is durable compatibility data only: serialization drops
7721    /// the generated authority seal, so behavior must re-enter generated
7722    /// authority before using restored facts.
7723    pub fn set_mob_tool_authority_context(
7724        &mut self,
7725        authority_context: Option<MobToolAuthorityContext>,
7726    ) -> Result<(), serde_json::Error> {
7727        if let Some(authority_context) = authority_context.as_ref()
7728            && !authority_context.is_generated_authority_context()
7729        {
7730            return Err(<serde_json::Error as serde::de::Error>::custom(
7731                "mob authority context was not minted by generated authority",
7732            ));
7733        }
7734        let mut build_state = self.build_state().ok_or_else(|| {
7735            <serde_json::Error as serde::de::Error>::custom(format!(
7736                "session {} is missing session build state",
7737                self.id
7738            ))
7739        })?;
7740        build_state.mob_tool_authority_context = authority_context;
7741        self.set_build_state(build_state)
7742    }
7743
7744    /// Load the in-memory generated mob operator authority, if still present.
7745    ///
7746    /// Stored/deserialized contexts deliberately fail this check and are not
7747    /// returned as behavior authority.
7748    pub fn mob_tool_authority_context(&self) -> Option<MobToolAuthorityContext> {
7749        self.build_state()
7750            .and_then(|state| state.mob_tool_authority_context)
7751            .filter(MobToolAuthorityContext::is_generated_authority_context)
7752    }
7753
7754    /// Fork the session at a specific message index
7755    ///
7756    /// Creates a new session with a subset of messages. The messages are copied
7757    /// (not shared) since the new session has a different prefix.
7758    pub fn fork_at(&self, index: usize) -> Self {
7759        let now = SystemTime::now();
7760        let truncated = self.messages[..index.min(self.messages.len())].to_vec();
7761        Self {
7762            version: session_version(),
7763            id: SessionId::new(),
7764            messages: TranscriptMessages::from_vec(truncated),
7765            created_at: now,
7766            updated_at: now,
7767            metadata: self.fork_metadata_projection(),
7768            history_caches: Box::default(),
7769            transcript_history_metadata_validation: TranscriptHistoryMetadataValidation::Validated,
7770            usage: self.usage.clone(),
7771        }
7772    }
7773
7774    /// Fork the session and replace the message at `message_index`.
7775    ///
7776    /// The returned session contains the original prefix before
7777    /// `message_index`, followed by the typed replacement. Later source
7778    /// messages are intentionally omitted so follow-up work continues from the
7779    /// edited branch rather than replaying stale descendants.
7780    pub fn fork_replacing(
7781        &self,
7782        message_index: usize,
7783        replacement: TranscriptReplacement,
7784    ) -> Result<Self, TranscriptEditError> {
7785        let Some(original) = self.messages.get(message_index) else {
7786            return Err(TranscriptEditError::MessageIndexOutOfBounds {
7787                message_index,
7788                message_count: self.messages.len(),
7789            });
7790        };
7791
7792        let replacement_message = match replacement {
7793            TranscriptReplacement::Message { message } => message,
7794            TranscriptReplacement::UserContentBlock { block_index, block } => {
7795                let Message::User(user) = original else {
7796                    return Err(TranscriptEditError::MessageRoleMismatch {
7797                        message_index,
7798                        expected: "user",
7799                        actual: message_role_name(original),
7800                    });
7801                };
7802                if block_index >= user.content.len() {
7803                    return Err(TranscriptEditError::BlockIndexOutOfBounds {
7804                        block_kind: "user content block",
7805                        block_index,
7806                        block_count: user.content.len(),
7807                    });
7808                }
7809                let mut edited = user.clone();
7810                edited.content[block_index] = block;
7811                Message::User(edited)
7812            }
7813            TranscriptReplacement::AssistantBlock { block_index, block } => {
7814                let Message::BlockAssistant(assistant) = original else {
7815                    return Err(TranscriptEditError::MessageRoleMismatch {
7816                        message_index,
7817                        expected: "block_assistant",
7818                        actual: message_role_name(original),
7819                    });
7820                };
7821                if block_index >= assistant.blocks.len() {
7822                    return Err(TranscriptEditError::BlockIndexOutOfBounds {
7823                        block_kind: "assistant block",
7824                        block_index,
7825                        block_count: assistant.blocks.len(),
7826                    });
7827                }
7828                let mut edited = assistant.clone();
7829                edited.blocks[block_index] = block;
7830                Message::BlockAssistant(edited)
7831            }
7832            TranscriptReplacement::ToolResultContentBlock {
7833                result_index,
7834                block_index,
7835                block,
7836            } => {
7837                let Message::ToolResults {
7838                    results,
7839                    created_at,
7840                } = original
7841                else {
7842                    return Err(TranscriptEditError::MessageRoleMismatch {
7843                        message_index,
7844                        expected: "tool_results",
7845                        actual: message_role_name(original),
7846                    });
7847                };
7848                let Some(result) = results.get(result_index) else {
7849                    return Err(TranscriptEditError::BlockIndexOutOfBounds {
7850                        block_kind: "tool result",
7851                        block_index: result_index,
7852                        block_count: results.len(),
7853                    });
7854                };
7855                if block_index >= result.content.len() {
7856                    return Err(TranscriptEditError::BlockIndexOutOfBounds {
7857                        block_kind: "tool result content block",
7858                        block_index,
7859                        block_count: result.content.len(),
7860                    });
7861                }
7862                let mut edited_results = results.clone();
7863                edited_results[result_index].content[block_index] = block;
7864                Message::ToolResults {
7865                    results: edited_results,
7866                    created_at: *created_at,
7867                }
7868            }
7869        };
7870
7871        let mut forked = self.fork_at(message_index);
7872        forked.push(replacement_message);
7873        Ok(forked)
7874    }
7875
7876    /// Fork the entire session (full history)
7877    ///
7878    /// This is O(1) - the new session shares the message buffer via Arc.
7879    /// Copy-on-write occurs when either session mutates its messages.
7880    pub fn fork(&self) -> Self {
7881        let now = SystemTime::now();
7882        Self {
7883            version: session_version(),
7884            id: SessionId::new(),
7885            messages: self.messages.clone(),
7886            created_at: now,
7887            updated_at: now,
7888            metadata: self.fork_metadata_projection(),
7889            history_caches: Box::default(),
7890            transcript_history_metadata_validation: TranscriptHistoryMetadataValidation::Validated,
7891            usage: self.usage.clone(),
7892        }
7893    }
7894}
7895
7896impl Default for Session {
7897    fn default() -> Self {
7898        Self::new()
7899    }
7900}
7901
7902/// Summary metadata for listing sessions
7903#[derive(Debug, Clone, Serialize, Deserialize)]
7904#[serde(rename_all = "snake_case")]
7905pub struct SessionMeta {
7906    pub id: SessionId,
7907    pub created_at: SystemTime,
7908    pub updated_at: SystemTime,
7909    pub message_count: usize,
7910    pub total_tokens: u64,
7911    #[serde(default)]
7912    pub metadata: serde_json::Map<String, serde_json::Value>,
7913}
7914
7915/// Metadata required to reliably resume a session across interfaces.
7916#[derive(Debug, Clone, Serialize, Deserialize)]
7917#[serde(rename_all = "snake_case")]
7918pub struct SessionMetadata {
7919    /// Per-entity schema version byte.
7920    ///
7921    /// Mandatory on read: a persisted row missing the byte (or carrying a
7922    /// non-current value) fails closed through the generated persistence
7923    /// version authority instead of silently defaulting. Stamped with the
7924    /// current `SESSION_METADATA_SCHEMA_VERSION` on every persist.
7925    pub schema_version: u32,
7926    pub model: String,
7927    pub max_tokens: u32,
7928    #[serde(default = "crate::config::default_structured_output_retries")]
7929    pub structured_output_retries: u32,
7930    pub provider: Provider,
7931    #[serde(default, skip_serializing_if = "Option::is_none")]
7932    pub self_hosted_server_id: Option<String>,
7933    /// Typed provider parameter overrides persisted with the session.
7934    /// Parsed fail-closed at the serde boundary — no JSON bag survives here.
7935    #[serde(default, skip_serializing_if = "Option::is_none")]
7936    pub provider_params: Option<crate::lifecycle::run_primitive::ProviderParamsOverride>,
7937    pub tooling: SessionTooling,
7938    #[serde(default)]
7939    pub keep_alive: bool,
7940    pub comms_name: Option<String>,
7941    /// Friendly metadata for peer discovery (populated when comms is enabled).
7942    #[serde(default, skip_serializing_if = "Option::is_none")]
7943    pub peer_meta: Option<PeerMeta>,
7944    /// Realm identity for cross-surface storage sharing/isolation.
7945    ///
7946    /// Typed [`crate::RealmId`]; the realm slug is validated at the serde
7947    /// boundary. `RealmId` serializes transparently as its slug string, so the
7948    /// durable JSON shape is identical to the prior `Option<String>` form.
7949    #[serde(default, skip_serializing_if = "Option::is_none")]
7950    pub realm_id: Option<crate::RealmId>,
7951    /// Optional process/agent instance identifier within a realm.
7952    #[serde(default, skip_serializing_if = "Option::is_none")]
7953    pub instance_id: Option<String>,
7954    /// Backend pinned by the realm manifest (e.g. "sqlite", "jsonl", "memory").
7955    #[serde(default, skip_serializing_if = "Option::is_none")]
7956    pub backend: Option<String>,
7957    /// Config generation used when this session was created/resumed.
7958    #[serde(default, skip_serializing_if = "Option::is_none")]
7959    pub config_generation: Option<u64>,
7960    /// Realm-scoped auth binding (Phase 3 provider-auth redesign).
7961    ///
7962    /// Persisted intent for the auth/backend binding this session resolved
7963    /// through. On resume, `apply_resumed_session_metadata` writes this
7964    /// back into `AgentBuildConfig.auth_binding` so the same realm
7965    /// binding is re-resolved. Never carries secret material — leases
7966    /// are rebuilt from the active realm connection set at resume time.
7967    /// Older persisted sessions without the field deserialize as `None`
7968    /// (backward compatible via `#[serde(default)]`).
7969    #[serde(default, skip_serializing_if = "Option::is_none")]
7970    pub auth_binding: Option<crate::AuthBindingRef>,
7971    /// Typed durable identity of a mob member, when this session was created by
7972    /// the mob runtime.
7973    ///
7974    /// This is the canonical owner of the `(mob_id, role, member)` identity
7975    /// fact used by mob ownership routing on resume/restart. It replaces the
7976    /// prior recovery-by-string-split of `comms_name` plus a realm
7977    /// format-string check. `comms_name`/`realm_id`/`peer_meta` remain as the
7978    /// transport routing name and discovery metadata.
7979    ///
7980    /// Older persisted sessions without the field deserialize as `None`
7981    /// (backward compatible via `#[serde(default)]`), so old rows read as
7982    /// "no typed binding" rather than failing.
7983    #[serde(default, skip_serializing_if = "Option::is_none")]
7984    pub mob_member_binding: Option<crate::MobMemberBinding>,
7985}
7986
7987/// Canonical durable LLM identity for a session.
7988#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
7989#[serde(rename_all = "snake_case")]
7990pub struct SessionLlmIdentity {
7991    pub model: String,
7992    pub provider: Provider,
7993    #[serde(default, skip_serializing_if = "Option::is_none")]
7994    pub self_hosted_server_id: Option<String>,
7995    /// Typed provider parameter overrides carried on the durable identity.
7996    #[serde(default, skip_serializing_if = "Option::is_none")]
7997    pub provider_params: Option<crate::lifecycle::run_primitive::ProviderParamsOverride>,
7998    /// Realm-scoped auth binding this session resolves credentials
7999    /// through. Carried on the identity so mid-session hot-swaps
8000    /// (`apply_live_session_llm_identity`) re-resolve against the
8001    /// same realm the session was created with — preventing
8002    /// cross-realm credential bleed in multi-tenant setups. Dogma
8003    /// §12 (dynamic policy follows dynamic identity): on swap the
8004    /// factory re-enters `ProviderRuntimeRegistry::resolve` against
8005    /// this binding, not a new synthesized env-default realm.
8006    ///
8007    /// Projection (dogma §1/§13): canonical owner is
8008    /// `SessionMetadata.auth_binding`; this field is the
8009    /// read/write projection used by hot-swap.
8010    #[serde(default, skip_serializing_if = "Option::is_none")]
8011    pub auth_binding: Option<crate::AuthBindingRef>,
8012}
8013
8014/// Typed per-turn override request for a session LLM identity.
8015///
8016/// `provider_params` and `auth_binding` carry the canonical Inherit/Set/Clear
8017/// tri-state via [`TurnMetadataOverride`]: `None` preserves the durable value,
8018/// `Some(Set)` overrides it for this turn, and `Some(Clear)` removes it. The
8019/// illegal "set and clear" fourth state is structurally unrepresentable, so the
8020/// resolver needs no reject branch for it.
8021pub struct SessionLlmIdentityOverride<'a> {
8022    pub model: Option<&'a str>,
8023    pub provider: Option<Provider>,
8024    /// Exact configured route for a self-hosted model. This cannot be inferred
8025    /// from provider/model when multiple local servers expose the same model
8026    /// identifier.
8027    pub self_hosted_server_id: Option<&'a str>,
8028    pub provider_params:
8029        Option<TurnMetadataOverride<&'a crate::lifecycle::run_primitive::ProviderParamsOverride>>,
8030    pub auth_binding: Option<TurnMetadataOverride<&'a crate::AuthBindingRef>>,
8031}
8032
8033#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
8034pub enum SessionLlmIdentityOverrideError {
8035    #[error("provider override requires model on an existing session")]
8036    ProviderRequiresModel,
8037    #[error("{0}")]
8038    ProviderModelMismatch(String),
8039    #[error("self-hosted provider requires a registered model alias; '{model}' is not configured")]
8040    MissingSelfHostedAlias { model: String },
8041    #[error("self_hosted_server_id requires provider 'self_hosted'")]
8042    SelfHostedServerRequiresSelfHostedProvider,
8043    #[error("self_hosted_server_id must not be empty")]
8044    EmptySelfHostedServerId,
8045    #[error(
8046        "self-hosted model '{model}' is configured on server '{configured}', not requested server '{requested}'"
8047    )]
8048    SelfHostedServerMismatch {
8049        model: String,
8050        requested: String,
8051        configured: String,
8052    },
8053}
8054
8055/// Resolve a turn-time model/provider/auth override against the current
8056/// durable session identity.
8057///
8058/// The model registry is the authority for catalog ownership. A model-only
8059/// override follows catalog ownership when the target model is registered;
8060/// uncatalogued models keep the current provider so custom aliases remain
8061/// possible.
8062pub fn resolve_session_llm_identity_override(
8063    current: &SessionLlmIdentity,
8064    registry: &crate::ModelRegistry,
8065    overrides: SessionLlmIdentityOverride<'_>,
8066) -> Result<SessionLlmIdentity, SessionLlmIdentityOverrideError> {
8067    if overrides.provider.is_some() && overrides.model.is_none() {
8068        return Err(SessionLlmIdentityOverrideError::ProviderRequiresModel);
8069    }
8070
8071    let model = overrides
8072        .model
8073        .map(str::to_string)
8074        .unwrap_or_else(|| current.model.clone());
8075    let provider = if let Some(provider) = overrides.provider {
8076        provider
8077    } else if overrides.model.is_some() {
8078        registry
8079            .entry(&model)
8080            .map_or(current.provider, |entry| entry.provider)
8081    } else {
8082        current.provider
8083    };
8084
8085    if (overrides.model.is_some() || overrides.provider.is_some())
8086        && let Some(reason) = registry.provider_override_mismatch_reason(provider, &model)
8087    {
8088        return Err(SessionLlmIdentityOverrideError::ProviderModelMismatch(
8089            reason,
8090        ));
8091    }
8092
8093    let provider_params = match overrides.provider_params {
8094        Some(TurnMetadataOverride::Clear) => None,
8095        Some(TurnMetadataOverride::Set(value)) => Some(value.clone()),
8096        None => current.provider_params.clone(),
8097    };
8098    if overrides.self_hosted_server_id.is_some() && provider != Provider::SelfHosted {
8099        return Err(SessionLlmIdentityOverrideError::SelfHostedServerRequiresSelfHostedProvider);
8100    }
8101    let self_hosted_server_id = if provider == Provider::SelfHosted {
8102        if let Some(requested_server_id) = overrides.self_hosted_server_id {
8103            if requested_server_id.trim().is_empty() {
8104                return Err(SessionLlmIdentityOverrideError::EmptySelfHostedServerId);
8105            }
8106            let entry = registry
8107                .entry_for_provider(Provider::SelfHosted, &model)
8108                .ok_or_else(|| SessionLlmIdentityOverrideError::MissingSelfHostedAlias {
8109                    model: model.clone(),
8110                })?;
8111            let configured_server_id = entry
8112                .self_hosted
8113                .as_ref()
8114                .map(|server| server.server_id.as_str())
8115                .ok_or_else(|| SessionLlmIdentityOverrideError::MissingSelfHostedAlias {
8116                    model: model.clone(),
8117                })?;
8118            if configured_server_id != requested_server_id {
8119                return Err(SessionLlmIdentityOverrideError::SelfHostedServerMismatch {
8120                    model,
8121                    requested: requested_server_id.to_string(),
8122                    configured: configured_server_id.to_string(),
8123                });
8124            }
8125            Some(requested_server_id.to_string())
8126        } else if overrides.model.is_none() {
8127            current.self_hosted_server_id.clone().or_else(|| {
8128                registry
8129                    .entry_for_provider(Provider::SelfHosted, &model)
8130                    .and_then(|entry| entry.self_hosted.as_ref())
8131                    .map(|server| server.server_id.clone())
8132            })
8133        } else {
8134            let entry = registry
8135                .entry_for_provider(Provider::SelfHosted, &model)
8136                .ok_or_else(|| SessionLlmIdentityOverrideError::MissingSelfHostedAlias {
8137                    model: model.clone(),
8138                })?;
8139            entry
8140                .self_hosted
8141                .as_ref()
8142                .map(|server| server.server_id.clone())
8143        }
8144    } else {
8145        None
8146    };
8147
8148    let auth_binding = match overrides.auth_binding {
8149        Some(TurnMetadataOverride::Clear) => None,
8150        Some(TurnMetadataOverride::Set(value)) => Some(value.clone()),
8151        // Inherit: a provider change without an explicit binding drops the
8152        // stale binding; otherwise the durable binding is retained.
8153        None if provider != current.provider => None,
8154        None => current.auth_binding.clone(),
8155    };
8156
8157    Ok(SessionLlmIdentity {
8158        model,
8159        provider,
8160        self_hosted_server_id,
8161        provider_params,
8162        auth_binding,
8163    })
8164}
8165
8166/// Live request policy paired with a session LLM identity hot-swap.
8167///
8168/// `SessionLlmIdentity` is the durable semantic identity. This projection is
8169/// the per-turn request policy the live agent must use for the next LLM call,
8170/// including provider params and provider-native tool defaults resolved for
8171/// the same target model/provider.
8172#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
8173#[serde(rename_all = "snake_case")]
8174pub struct SessionLlmRequestPolicy {
8175    pub model: String,
8176    /// Typed explicit provider parameter overrides for the next LLM call.
8177    #[serde(default, skip_serializing_if = "Option::is_none")]
8178    pub provider_params: Option<crate::lifecycle::run_primitive::ProviderParamsOverride>,
8179    /// Typed provider-native tool defaults resolved for the swapped target.
8180    #[serde(default, skip_serializing_if = "Option::is_none")]
8181    pub provider_tool_defaults: Option<crate::lifecycle::run_primitive::ProviderTag>,
8182}
8183
8184impl SessionMetadata {
8185    /// Return the current durable LLM identity for this session.
8186    pub fn llm_identity(&self) -> SessionLlmIdentity {
8187        SessionLlmIdentity {
8188            model: self.model.clone(),
8189            provider: self.provider,
8190            self_hosted_server_id: self.self_hosted_server_id.clone(),
8191            provider_params: self.provider_params.clone(),
8192            auth_binding: self.auth_binding.clone(),
8193        }
8194    }
8195
8196    /// Overwrite the durable LLM identity while preserving unrelated session metadata.
8197    pub fn apply_llm_identity(&mut self, identity: &SessionLlmIdentity) {
8198        self.model = identity.model.clone();
8199        self.provider = identity.provider;
8200        self.self_hosted_server_id = identity.self_hosted_server_id.clone();
8201        self.provider_params = identity.provider_params.clone();
8202        self.auth_binding = identity.auth_binding.clone();
8203    }
8204}
8205
8206/// Key used to store SessionMetadata in Session metadata map.
8207pub const SESSION_METADATA_KEY: &str = "session_metadata";
8208
8209/// Caller intent for a tool category.
8210///
8211/// Distinguishes "no opinion / didn't exist" (`Inherit`) from explicit
8212/// `Enable` / `Disable` so that resumed sessions don't freeze tool
8213/// availability at the capabilities of the Meerkat version that created them.
8214///
8215/// **Dogma §10:** Inherit, disable, and set are different facts.
8216#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
8217#[serde(rename_all = "snake_case")]
8218pub enum ToolCategoryOverride {
8219    /// No explicit intent — inherit runtime/factory default.
8220    #[default]
8221    Inherit,
8222    /// Explicitly enabled by caller.
8223    Enable,
8224    /// Explicitly disabled by caller.
8225    Disable,
8226}
8227
8228impl ToolCategoryOverride {
8229    /// Resolve this override against a runtime default.
8230    ///
8231    /// - `Enable` → `true`
8232    /// - `Disable` → `false`
8233    /// - `Inherit` → `runtime_default`
8234    #[must_use]
8235    pub fn resolve(self, runtime_default: bool) -> bool {
8236        match self {
8237            Self::Enable => true,
8238            Self::Disable => false,
8239            Self::Inherit => runtime_default,
8240        }
8241    }
8242
8243    /// Convert to `Option<bool>` for feeding `AgentBuildConfig` override fields.
8244    ///
8245    /// - `Enable` → `Some(true)`
8246    /// - `Disable` → `Some(false)`
8247    /// - `Inherit` → `None` (factory default wins)
8248    #[must_use]
8249    pub fn to_override(self) -> Option<bool> {
8250        match self {
8251            Self::Enable => Some(true),
8252            Self::Disable => Some(false),
8253            Self::Inherit => None,
8254        }
8255    }
8256
8257    /// Construct from a resolved effective bool.
8258    ///
8259    /// **Warning:** this collapses `Inherit` into `Enable`/`Disable`. Prefer
8260    /// [`from_override`] when persisting session metadata so that `Inherit`
8261    /// survives across save/resume cycles. Only use `from_effective` in test
8262    /// helpers or when constructing metadata from external sources that only
8263    /// provide a resolved bool.
8264    #[must_use]
8265    pub fn from_effective(enabled: bool) -> Self {
8266        if enabled { Self::Enable } else { Self::Disable }
8267    }
8268
8269    /// Construct from an `Option<bool>` override field, preserving `Inherit`.
8270    ///
8271    /// - `Some(true)` → `Enable`
8272    /// - `Some(false)` → `Disable`
8273    /// - `None` → `Inherit` (factory default was used, no explicit intent)
8274    ///
8275    /// This is the inverse of [`to_override`] and should be used when persisting
8276    /// session tooling metadata so that `Inherit` survives across save/resume
8277    /// cycles.
8278    #[must_use]
8279    pub fn from_override(value: Option<bool>) -> Self {
8280        match value {
8281            Some(true) => Self::Enable,
8282            Some(false) => Self::Disable,
8283            None => Self::Inherit,
8284        }
8285    }
8286}
8287
8288/// Tooling intent captured at session creation time.
8289///
8290/// Fields use [`ToolCategoryOverride`] to distinguish "no opinion" from
8291/// explicit enable/disable (Dogma §10). On resume, `Inherit` falls through
8292/// to the factory's current runtime default, allowing new tool categories
8293/// to become available without re-creating the session.
8294#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
8295#[serde(rename_all = "snake_case")]
8296pub struct SessionTooling {
8297    #[serde(default)]
8298    pub builtins: ToolCategoryOverride,
8299    #[serde(default)]
8300    pub shell: ToolCategoryOverride,
8301    #[serde(default)]
8302    pub comms: ToolCategoryOverride,
8303    /// Mob (multi-agent orchestration) tools.
8304    #[serde(default)]
8305    pub mob: ToolCategoryOverride,
8306    /// Semantic memory.
8307    #[serde(default)]
8308    pub memory: ToolCategoryOverride,
8309    /// Scheduler tools.
8310    #[serde(default)]
8311    pub schedule: ToolCategoryOverride,
8312    /// WorkGraph durable work tools.
8313    #[serde(default)]
8314    pub workgraph: ToolCategoryOverride,
8315    /// Assistant image generation.
8316    #[serde(default)]
8317    pub image_generation: ToolCategoryOverride,
8318    /// Meerkat-owned fallback web search.
8319    #[serde(default)]
8320    pub web_search: ToolCategoryOverride,
8321    /// Effective call-level tool execution policy for this session's builds.
8322    ///
8323    /// Persisted RESOLVED (never `Inherit`): the factory fails the build
8324    /// closed on an unresolved `Inherit` before metadata is written, so this
8325    /// field only ever holds `AllowList`/`DenyList`. Absent means
8326    /// unrestricted. Spawn/fork resolution reads this field as the parent's
8327    /// effective policy when a child requests `Inherit` (transitive
8328    /// containment — a restricted parent cannot mint an unrestricted child
8329    /// by spawning).
8330    #[serde(default, skip_serializing_if = "Option::is_none")]
8331    pub tool_access_policy: Option<crate::ops::ToolAccessPolicy>,
8332    /// Active skills at session creation time (for deterministic resume).
8333    #[serde(default, skip_serializing_if = "Option::is_none")]
8334    pub active_skills: Option<Vec<crate::skills::SkillKey>>,
8335}
8336
8337impl From<&Session> for SessionMeta {
8338    fn from(session: &Session) -> Self {
8339        Self {
8340            id: session.id.clone(),
8341            created_at: session.created_at,
8342            updated_at: session.updated_at,
8343            message_count: session.messages.len(),
8344            total_tokens: session.total_tokens(),
8345            metadata: session.metadata.clone(),
8346        }
8347    }
8348}
8349
8350/// Decode the typed [`SESSION_METADATA_KEY`] fact from a session metadata map
8351/// through the generated restore authority.
8352///
8353/// Canonical single decoder: [`Session::try_session_metadata`] and every
8354/// metadata-only read seam ([`PersistedSessionMetadataView`]) delegate here so
8355/// the full-session and metadata-only decode paths can never drift.
8356///
8357/// Fail-closed: a present-but-corrupt value is an error, never "absent".
8358pub fn try_session_metadata_from_map(
8359    metadata: &serde_json::Map<String, serde_json::Value>,
8360) -> Result<Option<SessionMetadata>, serde_json::Error> {
8361    let Some(value) = metadata.get(SESSION_METADATA_KEY) else {
8362        return Ok(None);
8363    };
8364    let mut metadata = serde_json::from_value::<SessionMetadata>(value.clone())?;
8365    metadata.schema_version =
8366        session_persistence_version_authority::restore_session_metadata_schema_version(
8367            metadata.schema_version,
8368        )
8369        .map_err(<serde_json::Error as serde::de::Error>::custom)?;
8370    session_durable_config_authority::restore_session_metadata(metadata)
8371        .map(Some)
8372        .map_err(<serde_json::Error as serde::de::Error>::custom)
8373}
8374
8375/// Decode the typed [`SESSION_LIFECYCLE_TERMINAL_KEY`] fact from a session
8376/// metadata map.
8377///
8378/// Canonical single decoder: [`Session::try_lifecycle_terminal`] and every
8379/// metadata-only read seam delegate here. An absent key means no terminal
8380/// fact; a present-but-corrupt value fails closed.
8381pub fn try_lifecycle_terminal_from_map(
8382    metadata: &serde_json::Map<String, serde_json::Value>,
8383) -> Result<Option<SessionLifecycleTerminal>, serde_json::Error> {
8384    match metadata.get(SESSION_LIFECYCLE_TERMINAL_KEY) {
8385        Some(value) => serde_json::from_value(value.clone()).map(Some),
8386        None => Ok(None),
8387    }
8388}
8389
8390/// Typed metadata-only view of a persisted session row or snapshot.
8391///
8392/// The metadata read seam's currency (mobkit ask-24 clause 3): carries the
8393/// session identity plus the two typed session-authority metadata facts,
8394/// decoded fail-closed through the canonical map-level decoders. Consumers
8395/// that only need ownership/policy/lifecycle facts read this view instead of
8396/// materializing the full session document.
8397#[derive(Debug, Clone)]
8398pub struct PersistedSessionMetadataView {
8399    pub session_id: SessionId,
8400    pub session_metadata: Option<SessionMetadata>,
8401    pub lifecycle_terminal: Option<SessionLifecycleTerminal>,
8402}
8403
8404impl PersistedSessionMetadataView {
8405    /// Build the view from a persisted metadata map (e.g. a
8406    /// [`SessionMeta`] row projection).
8407    ///
8408    /// Fail-closed: corrupt values under either reserved key are an error,
8409    /// never treated as absent.
8410    pub fn try_from_metadata_map(
8411        session_id: SessionId,
8412        metadata: &serde_json::Map<String, serde_json::Value>,
8413    ) -> Result<Self, serde_json::Error> {
8414        Ok(Self {
8415            session_id,
8416            session_metadata: try_session_metadata_from_map(metadata)?,
8417            lifecycle_terminal: try_lifecycle_terminal_from_map(metadata)?,
8418        })
8419    }
8420
8421    /// Project the view from a fully materialized session document.
8422    pub fn try_from_session(session: &Session) -> Result<Self, serde_json::Error> {
8423        Ok(Self {
8424            session_id: session.id().clone(),
8425            session_metadata: session.try_session_metadata()?,
8426            lifecycle_terminal: session.try_lifecycle_terminal()?,
8427        })
8428    }
8429
8430    /// Typed durable mob member identity carried on the session metadata,
8431    /// if any.
8432    pub fn mob_member_binding(&self) -> Option<&crate::MobMemberBinding> {
8433        self.session_metadata.as_ref()?.mob_member_binding.as_ref()
8434    }
8435}
8436
8437#[cfg(test)]
8438#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
8439mod tests {
8440
8441    /// The append fast path in `transcript_history_state_after_message_mutation`
8442    /// must be a pure cost optimization: the graph it installs has to be the
8443    /// same graph the full validating path installs.
8444    ///
8445    /// Control session takes the slow path (its history-validation flag is
8446    /// flipped back to `RequiresValidation` before every append, which is the
8447    /// state an unchecked metadata write leaves behind); the subject takes the
8448    /// fast path. Both must agree on head, commits, and the retained
8449    /// (revision, messages) set — only body construction timestamps differ,
8450    /// and those are storage bookkeeping the canonical digest erases.
8451    #[test]
8452    fn append_fast_path_installs_the_same_graph_as_the_validating_path()
8453    -> Result<(), Box<dyn std::error::Error>> {
8454        fn seeded() -> Result<Session, Box<dyn std::error::Error>> {
8455            let mut session = Session::new();
8456            session.push(Message::User(UserMessage::text("A".to_string())));
8457            session.push(Message::User(UserMessage::text("B".to_string())));
8458            session.commit_transcript_rewrite(
8459                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
8460                vec![Message::User(UserMessage::text("B2".to_string()))],
8461                TranscriptRewriteReason::new("unit-test"),
8462                Some("unit-test".to_string()),
8463                None,
8464            )?;
8465            Ok(session)
8466        }
8467
8468        let mut subject = seeded()?;
8469        let mut control = subject.clone();
8470
8471        for index in 0..4 {
8472            let message = Message::User(UserMessage::text(format!("append {index}")));
8473            subject.push(message.clone());
8474
8475            // Force the control down the full validating path.
8476            let value = control
8477                .metadata()
8478                .get(SESSION_TRANSCRIPT_HISTORY_STATE_KEY)
8479                .cloned()
8480                .ok_or_else(|| std::io::Error::other("control history missing"))?;
8481            control.transcript_history_metadata_validation =
8482                TranscriptHistoryMetadataValidation::RequiresValidation;
8483            control.set_validated_transcript_history_metadata(value);
8484            control.transcript_history_metadata_validation =
8485                TranscriptHistoryMetadataValidation::RequiresValidation;
8486            control.push(message);
8487
8488            let subject_state = subject
8489                .transcript_history_state()?
8490                .ok_or_else(|| std::io::Error::other("subject history missing"))?;
8491            let control_state = control
8492                .transcript_history_state()?
8493                .ok_or_else(|| std::io::Error::other("control history missing"))?;
8494            assert_eq!(subject_state.head, control_state.head, "head at {index}");
8495            assert_eq!(
8496                subject_state.commits, control_state.commits,
8497                "commits at {index}"
8498            );
8499            let project = |state: &TranscriptHistoryState| {
8500                let mut bodies = state
8501                    .revisions
8502                    .iter()
8503                    .map(|body| (body.revision.clone(), body.messages.clone()))
8504                    .collect::<Vec<_>>();
8505                bodies.sort_by(|left, right| left.0.cmp(&right.0));
8506                bodies
8507            };
8508            assert_eq!(
8509                project(&subject_state),
8510                project(&control_state),
8511                "retained bodies at {index}"
8512            );
8513            subject.validate_transcript_history_state()?;
8514        }
8515        Ok(())
8516    }
8517    use super::*;
8518    use crate::realtime_transcript::RealtimeTranscriptRole;
8519    use crate::types::{
8520        AssistantBlock, BlockAssistantMessage, ContentBlock, StopReason, SystemMessage, Usage,
8521        UserMessage,
8522    };
8523    use std::sync::Arc;
8524
8525    fn exact_boundary_append(key: &str, text: &str) -> PendingSystemContextAppend {
8526        PendingSystemContextAppend {
8527            content: crate::lifecycle::CoreRenderable::text(text.to_string()),
8528            source: Some("test:exact-boundary".to_string()),
8529            idempotency_key: Some(key.to_string()),
8530            source_kind: SystemContextSource::RuntimeSteer,
8531            accepted_at: SystemTime::now(),
8532            peer_response_terminal: None,
8533        }
8534    }
8535
8536    async fn wait_for_exact_boundary_request(handle: &SystemContextStateHandle) {
8537        for _ in 0..1_000 {
8538            let registered = matches!(
8539                &handle.boundary.lock().window,
8540                SystemContextBoundaryWindow::Open {
8541                    request: Some(_),
8542                    ..
8543                }
8544            );
8545            if registered {
8546                return;
8547            }
8548            tokio::task::yield_now().await;
8549        }
8550        panic!("exact boundary request did not register");
8551    }
8552
8553    fn assert_send<T: Send>() {}
8554
8555    #[test]
8556    fn prepared_boundary_authority_is_send_for_owned_commit_handoff() {
8557        assert_send::<PreparedSystemContextBoundary>();
8558        assert_send::<crate::lifecycle::CoreBoundaryStageOutput>();
8559        assert_send::<ModelBoundarySystemContext>();
8560    }
8561
8562    #[tokio::test]
8563    async fn exact_boundary_runner_first_is_typed_unavailable() {
8564        let state = SystemContextStateHandle::new(Default::default()).expect("state");
8565        let run_id = RunId::new();
8566        let _run = state
8567            .begin_boundary_run(run_id.clone())
8568            .expect("open boundary");
8569
8570        let consuming = state
8571            .take_pending_at_exact_boundary(&run_id)
8572            .await
8573            .expect("runner claims open boundary");
8574        assert!(consuming.appends().is_empty());
8575        assert!(
8576            consuming
8577                .consume()
8578                .expect("runner consumes open boundary")
8579                .is_empty()
8580        );
8581        let error = state
8582            .prepare_active_turn_boundary(
8583                &run_id,
8584                vec![exact_boundary_append("runner-first", "too late")],
8585            )
8586            .await
8587            .expect_err("consumed generation cannot mint a preparation");
8588        assert!(matches!(error, CoreBoundaryStageError::Unavailable { .. }));
8589    }
8590
8591    #[tokio::test]
8592    async fn exact_boundary_wrong_run_is_stale_without_claiming_or_mutating_window() {
8593        let state = SystemContextStateHandle::new(Default::default()).expect("state");
8594        let active_run_id = RunId::new();
8595        let wrong_run_id = RunId::new();
8596        let _run = state
8597            .begin_boundary_run(active_run_id.clone())
8598            .expect("open boundary");
8599
8600        let error = state
8601            .prepare_active_turn_boundary(
8602                &wrong_run_id,
8603                vec![exact_boundary_append("wrong-run", "must not stage")],
8604            )
8605            .await
8606            .expect_err("a different run cannot claim the active window");
8607        assert!(matches!(error, CoreBoundaryStageError::Stale { .. }));
8608        assert!(state.snapshot().seen().is_empty());
8609
8610        let consuming = state
8611            .take_pending_at_exact_boundary(&active_run_id)
8612            .await
8613            .expect("the correct run still owns its unclaimed boundary");
8614        assert!(
8615            consuming
8616                .consume()
8617                .expect("consume unchanged active window")
8618                .is_empty()
8619        );
8620    }
8621
8622    #[tokio::test]
8623    async fn exact_boundary_conflicting_batch_is_atomic_and_leaves_window_unclaimed() {
8624        let state = SystemContextStateHandle::new(Default::default()).expect("state");
8625        let run_id = RunId::new();
8626        let _run = state
8627            .begin_boundary_run(run_id.clone())
8628            .expect("open boundary");
8629
8630        let error = state
8631            .prepare_active_turn_boundary(
8632                &run_id,
8633                vec![
8634                    exact_boundary_append("conflict", "first"),
8635                    exact_boundary_append("conflict", "different"),
8636                ],
8637            )
8638            .await
8639            .expect_err("a conflicting batch must fail before registration");
8640        assert!(matches!(error, CoreBoundaryStageError::Fault { .. }));
8641        assert!(state.snapshot().seen().is_empty());
8642
8643        let consuming = state
8644            .take_pending_at_exact_boundary(&run_id)
8645            .await
8646            .expect("failed validation must leave the exact window unclaimed");
8647        assert!(
8648            consuming
8649                .consume()
8650                .expect("consume unchanged active window")
8651                .is_empty()
8652        );
8653    }
8654
8655    #[tokio::test]
8656    async fn exact_boundary_prepare_first_parks_until_commit() {
8657        let state = SystemContextStateHandle::new(Default::default()).expect("state");
8658        let run_id = RunId::new();
8659        let _run = state
8660            .begin_boundary_run(run_id.clone())
8661            .expect("open boundary");
8662
8663        let prepare_state = state.clone();
8664        let prepare_run_id = run_id.clone();
8665        let prepare = tokio::spawn(async move {
8666            prepare_state
8667                .prepare_active_turn_boundary(
8668                    &prepare_run_id,
8669                    vec![exact_boundary_append("prepare-first", "parked context")],
8670                )
8671                .await
8672        });
8673        wait_for_exact_boundary_request(&state).await;
8674
8675        let runner_state = state.clone();
8676        let runner_run_id = run_id.clone();
8677        let runner = tokio::spawn(async move {
8678            runner_state
8679                .take_pending_at_exact_boundary(&runner_run_id)
8680                .await
8681        });
8682        let prepared = prepare
8683            .await
8684            .expect("prepare task")
8685            .expect("prepare must return only after park");
8686        assert_eq!(prepared.expected_run_id(), &run_id);
8687        assert!(prepared.boundary_generation() > 0);
8688        assert!(state.snapshot().pending().is_empty());
8689        assert!(
8690            !runner.is_finished(),
8691            "runner must remain parked before commit"
8692        );
8693
8694        prepared
8695            .into_stage_output(None)
8696            .commit()
8697            .expect("exact commit");
8698        let consuming = runner
8699            .await
8700            .expect("runner task")
8701            .expect("runner resumes after commit");
8702        assert_eq!(state.snapshot().pending().len(), 1);
8703        assert!(state.snapshot().applied().is_empty());
8704        let consumed = consuming.consume().expect("consume at model call seam");
8705        assert_eq!(consumed.len(), 1);
8706        assert_eq!(consumed[0].content.render_text(), "parked context");
8707        assert!(state.snapshot().pending().is_empty());
8708        assert!(state.snapshot().applied().is_empty());
8709    }
8710
8711    #[tokio::test]
8712    async fn exact_boundary_preprocessing_drop_does_not_claim_model_consumption() {
8713        let state = SystemContextStateHandle::new(Default::default()).expect("state");
8714        let run_id = RunId::new();
8715        let _run = state
8716            .begin_boundary_run(run_id.clone())
8717            .expect("open boundary");
8718        let prepare_state = state.clone();
8719        let prepare_run_id = run_id.clone();
8720        let prepare = tokio::spawn(async move {
8721            prepare_state
8722                .prepare_active_turn_boundary(
8723                    &prepare_run_id,
8724                    vec![exact_boundary_append(
8725                        "preprocess-drop",
8726                        "retryable context",
8727                    )],
8728                )
8729                .await
8730        });
8731        wait_for_exact_boundary_request(&state).await;
8732        let runner_state = state.clone();
8733        let runner_run_id = run_id.clone();
8734        let runner = tokio::spawn(async move {
8735            runner_state
8736                .take_pending_at_exact_boundary(&runner_run_id)
8737                .await
8738        });
8739        prepare
8740            .await
8741            .expect("prepare task")
8742            .expect("prepare parks")
8743            .into_stage_output(None)
8744            .commit()
8745            .expect("publish pending candidate");
8746
8747        let consuming = runner
8748            .await
8749            .expect("runner task")
8750            .expect("runner enters preprocessing");
8751        assert_eq!(consuming.appends().len(), 1);
8752        drop(consuming);
8753
8754        let snapshot = state.snapshot();
8755        assert_eq!(snapshot.pending().len(), 1);
8756        assert!(snapshot.applied().is_empty());
8757        assert!(snapshot.seen().contains_key("preprocess-drop"));
8758
8759        // Commit publishes to the exact active turn; it does not claim model
8760        // delivery. A later hard cancel/preprocessing drop owns the following
8761        // cleanup linearization and must not leak the accepted steer to a
8762        // successor run.
8763        assert_eq!(
8764            state
8765                .discard_unapplied_active_turn_pending()
8766                .expect("closed consuming window permits active-turn cleanup"),
8767            1
8768        );
8769        assert!(state.snapshot().pending().is_empty());
8770    }
8771
8772    #[tokio::test]
8773    async fn exact_boundary_drop_aborts_and_preserves_ordinary_pending() {
8774        let state = SystemContextStateHandle::new(Default::default()).expect("state");
8775        state
8776            .stage_append_with_snapshot(
8777                &AppendSystemContextRequest {
8778                    content: crate::lifecycle::CoreRenderable::text("ordinary"),
8779                    source: Some("test:ordinary".to_string()),
8780                    idempotency_key: Some("ordinary".to_string()),
8781                    source_kind: SystemContextSource::Normal,
8782                    peer_response_terminal: None,
8783                },
8784                SystemTime::now(),
8785            )
8786            .expect("ordinary append");
8787        let run_id = RunId::new();
8788        let _run = state
8789            .begin_boundary_run(run_id.clone())
8790            .expect("open boundary");
8791        let prepare_state = state.clone();
8792        let prepare_run_id = run_id.clone();
8793        let prepare = tokio::spawn(async move {
8794            prepare_state
8795                .prepare_active_turn_boundary(
8796                    &prepare_run_id,
8797                    vec![exact_boundary_append("drop-abort", "must not publish")],
8798                )
8799                .await
8800        });
8801        wait_for_exact_boundary_request(&state).await;
8802        let runner_state = state.clone();
8803        let runner_run_id = run_id.clone();
8804        let runner = tokio::spawn(async move {
8805            runner_state
8806                .take_pending_at_exact_boundary(&runner_run_id)
8807                .await
8808        });
8809        let prepared = prepare.await.expect("prepare task").expect("parked");
8810        drop(prepared);
8811        let consuming = runner
8812            .await
8813            .expect("runner task")
8814            .expect("drop abort wakes runner");
8815        let consumed = consuming
8816            .consume()
8817            .expect("consume ordinary pending context");
8818        assert_eq!(consumed.len(), 1);
8819        assert_eq!(consumed[0].content.render_text(), "ordinary");
8820        assert!(!state.snapshot().seen().contains_key("drop-abort"));
8821    }
8822
8823    #[tokio::test]
8824    async fn exact_boundary_duplicate_prepare_cannot_overwrite_generation() {
8825        let state = SystemContextStateHandle::new(Default::default()).expect("state");
8826        let run_id = RunId::new();
8827        let _run = state
8828            .begin_boundary_run(run_id.clone())
8829            .expect("open boundary");
8830        let first_state = state.clone();
8831        let first_run_id = run_id.clone();
8832        let first = tokio::spawn(async move {
8833            first_state
8834                .prepare_active_turn_boundary(
8835                    &first_run_id,
8836                    vec![exact_boundary_append("first", "first")],
8837                )
8838                .await
8839        });
8840        wait_for_exact_boundary_request(&state).await;
8841        let duplicate = state
8842            .prepare_active_turn_boundary(&run_id, vec![exact_boundary_append("second", "second")])
8843            .await
8844            .expect_err("duplicate preparation must fail closed");
8845        assert!(duplicate.is_unavailable());
8846
8847        let runner_state = state.clone();
8848        let runner_run_id = run_id.clone();
8849        let runner = tokio::spawn(async move {
8850            runner_state
8851                .take_pending_at_exact_boundary(&runner_run_id)
8852                .await
8853        });
8854        let prepared = first.await.expect("first task").expect("first parks");
8855        prepared
8856            .into_stage_output(None)
8857            .abort()
8858            .expect("explicit abort");
8859        runner
8860            .await
8861            .expect("runner task")
8862            .expect("runner resumes after abort")
8863            .consume()
8864            .expect("consume ordinary pending after abort");
8865        assert!(!state.snapshot().seen().contains_key("second"));
8866    }
8867
8868    #[tokio::test]
8869    async fn exact_boundary_concurrent_conflict_surfaces_fault_to_runner_and_preparer() {
8870        let state = SystemContextStateHandle::new(Default::default()).expect("state");
8871        let run_id = RunId::new();
8872        let _run = state
8873            .begin_boundary_run(run_id.clone())
8874            .expect("open boundary");
8875        let prepare_state = state.clone();
8876        let prepare_run_id = run_id.clone();
8877        let prepare = tokio::spawn(async move {
8878            prepare_state
8879                .prepare_active_turn_boundary(
8880                    &prepare_run_id,
8881                    vec![exact_boundary_append("shared-key", "prepared context")],
8882                )
8883                .await
8884        });
8885        wait_for_exact_boundary_request(&state).await;
8886
8887        state
8888            .stage_append_with_snapshot(
8889                &AppendSystemContextRequest {
8890                    content: crate::lifecycle::CoreRenderable::text("ordinary conflict"),
8891                    source: Some("test:exact-boundary".to_string()),
8892                    idempotency_key: Some("shared-key".to_string()),
8893                    source_kind: SystemContextSource::Normal,
8894                    peer_response_terminal: None,
8895                },
8896                SystemTime::now(),
8897            )
8898            .expect("ordinary mutation remains legal before the runner parks");
8899
8900        let runner_error = state
8901            .take_pending_at_exact_boundary(&run_id)
8902            .await
8903            .err()
8904            .expect("runner must surface candidate recomputation conflict");
8905        assert!(matches!(runner_error, CoreBoundaryStageError::Fault { .. }));
8906        let prepare_error = prepare
8907            .await
8908            .expect("prepare task")
8909            .expect_err("preparer must receive the same typed failure class");
8910        assert!(matches!(
8911            prepare_error,
8912            CoreBoundaryStageError::Fault { .. }
8913        ));
8914
8915        let snapshot = state.snapshot();
8916        assert_eq!(snapshot.pending().len(), 1);
8917        assert_eq!(
8918            snapshot.pending()[0].content.render_text(),
8919            "ordinary conflict"
8920        );
8921        assert!(snapshot.applied().is_empty());
8922    }
8923
8924    #[tokio::test]
8925    async fn exact_boundary_nonconflicting_open_mutation_is_preserved_in_candidate() {
8926        let state = SystemContextStateHandle::new(Default::default()).expect("state");
8927        let run_id = RunId::new();
8928        let _run = state
8929            .begin_boundary_run(run_id.clone())
8930            .expect("open boundary");
8931        let prepare_state = state.clone();
8932        let prepare_run_id = run_id.clone();
8933        let prepare = tokio::spawn(async move {
8934            prepare_state
8935                .prepare_active_turn_boundary(
8936                    &prepare_run_id,
8937                    vec![exact_boundary_append("prepared", "prepared context")],
8938                )
8939                .await
8940        });
8941        wait_for_exact_boundary_request(&state).await;
8942
8943        state
8944            .stage_append_with_snapshot(
8945                &AppendSystemContextRequest {
8946                    content: crate::lifecycle::CoreRenderable::text("ordinary context"),
8947                    source: Some("test:ordinary".to_string()),
8948                    idempotency_key: Some("ordinary".to_string()),
8949                    source_kind: SystemContextSource::Normal,
8950                    peer_response_terminal: None,
8951                },
8952                SystemTime::now(),
8953            )
8954            .expect("nonconflicting mutation remains legal before park");
8955
8956        let runner_state = state.clone();
8957        let runner_run_id = run_id.clone();
8958        let runner = tokio::spawn(async move {
8959            runner_state
8960                .take_pending_at_exact_boundary(&runner_run_id)
8961                .await
8962        });
8963        let prepared = prepare.await.expect("prepare task").expect("parked");
8964        assert_eq!(prepared.candidate_state().pending().len(), 2);
8965        prepared
8966            .into_stage_output(None)
8967            .commit()
8968            .expect("commit exact candidate");
8969        let consuming = runner
8970            .await
8971            .expect("runner task")
8972            .expect("runner resumes after commit");
8973        let consumed = consuming.consume().expect("consume exact candidate");
8974        assert_eq!(consumed.len(), 2);
8975        assert!(
8976            consumed
8977                .iter()
8978                .any(|append| append.content.render_text() == "ordinary context")
8979        );
8980        assert!(
8981            consumed
8982                .iter()
8983                .any(|append| append.content.render_text() == "prepared context")
8984        );
8985    }
8986
8987    #[tokio::test]
8988    async fn exact_boundary_actor_replacement_rejects_old_commit() {
8989        let actor_a = SystemContextStateHandle::new(Default::default()).expect("actor A");
8990        let run_id = RunId::new();
8991        let _run_a = actor_a
8992            .begin_boundary_run(run_id.clone())
8993            .expect("open A boundary");
8994        let prepare_state = actor_a.clone();
8995        let prepare_run_id = run_id.clone();
8996        let prepare = tokio::spawn(async move {
8997            prepare_state
8998                .prepare_active_turn_boundary(
8999                    &prepare_run_id,
9000                    vec![exact_boundary_append("actor-a", "stale A")],
9001                )
9002                .await
9003        });
9004        wait_for_exact_boundary_request(&actor_a).await;
9005        let runner_state = actor_a.clone();
9006        let runner_run_id = run_id.clone();
9007        let runner = tokio::spawn(async move {
9008            runner_state
9009                .take_pending_at_exact_boundary(&runner_run_id)
9010                .await
9011        });
9012        let prepared_a = prepare.await.expect("prepare task").expect("A parked");
9013
9014        actor_a.revoke_boundary_actor();
9015        let actor_b = SystemContextStateHandle::new(Default::default()).expect("actor B");
9016        let _run_b = actor_b
9017            .begin_boundary_run(run_id.clone())
9018            .expect("replacement opens independently");
9019        let error = prepared_a
9020            .into_stage_output(None)
9021            .commit()
9022            .expect_err("A cannot commit after replacement revoke");
9023        assert!(matches!(error, CoreBoundaryStageError::Stale { .. }));
9024        assert!(runner.await.expect("A runner task").is_err());
9025        assert!(actor_b.snapshot().seen().is_empty());
9026    }
9027
9028    #[tokio::test]
9029    async fn exact_boundary_hard_interrupt_and_concurrent_append_fail_closed() {
9030        let state = SystemContextStateHandle::new(Default::default()).expect("state");
9031        let run_id = RunId::new();
9032        let _run = state
9033            .begin_boundary_run(run_id.clone())
9034            .expect("open boundary");
9035        let prepare_state = state.clone();
9036        let prepare_run_id = run_id.clone();
9037        let prepare = tokio::spawn(async move {
9038            prepare_state
9039                .prepare_active_turn_boundary(
9040                    &prepare_run_id,
9041                    vec![exact_boundary_append("interrupt", "stale")],
9042                )
9043                .await
9044        });
9045        wait_for_exact_boundary_request(&state).await;
9046        let runner_state = state.clone();
9047        let runner_run_id = run_id.clone();
9048        let runner = tokio::spawn(async move {
9049            runner_state
9050                .take_pending_at_exact_boundary(&runner_run_id)
9051                .await
9052        });
9053        let prepared = prepare.await.expect("prepare task").expect("parked");
9054        let concurrent = state.stage_append_with_snapshot(
9055            &AppendSystemContextRequest {
9056                content: crate::lifecycle::CoreRenderable::text("concurrent"),
9057                source: Some("test:concurrent".to_string()),
9058                idempotency_key: Some("concurrent".to_string()),
9059                source_kind: SystemContextSource::Normal,
9060                peer_response_terminal: None,
9061            },
9062            SystemTime::now(),
9063        );
9064        assert!(
9065            concurrent.is_err(),
9066            "parked candidate must not be overwritten"
9067        );
9068        let discard_error = state
9069            .discard_unapplied_active_turn_pending()
9070            .expect_err("parked authority must reject cleanup, not report an empty success");
9071        assert!(matches!(
9072            discard_error,
9073            CoreBoundaryStageError::Fault { .. }
9074        ));
9075        let keyed_discard_error = state
9076            .discard_active_turn_pending_by_keys(&["interrupt".to_string()])
9077            .expect_err("parked authority must reject keyed rollback");
9078        assert!(matches!(
9079            keyed_discard_error,
9080            CoreBoundaryStageError::Fault { .. }
9081        ));
9082
9083        runner.abort();
9084        let _ = runner.await;
9085        let error = prepared
9086            .into_stage_output(None)
9087            .commit()
9088            .expect_err("hard-interrupted parked request cannot commit later");
9089        assert!(matches!(error, CoreBoundaryStageError::Stale { .. }));
9090        assert!(state.snapshot().seen().is_empty());
9091    }
9092
9093    #[tokio::test]
9094    async fn exact_boundary_run_exit_wakes_prepare_before_parking() {
9095        let state = SystemContextStateHandle::new(Default::default()).expect("state");
9096        let run_id = RunId::new();
9097        let run = state
9098            .begin_boundary_run(run_id.clone())
9099            .expect("open boundary");
9100        let prepare_state = state.clone();
9101        let prepare_run_id = run_id.clone();
9102        let prepare = tokio::spawn(async move {
9103            prepare_state
9104                .prepare_active_turn_boundary(
9105                    &prepare_run_id,
9106                    vec![exact_boundary_append("run-exit", "never parks")],
9107                )
9108                .await
9109        });
9110        wait_for_exact_boundary_request(&state).await;
9111        drop(run);
9112        let error = prepare
9113            .await
9114            .expect("prepare task")
9115            .expect_err("run exit must release preparer");
9116        assert!(matches!(
9117            error,
9118            CoreBoundaryStageError::Unavailable { .. } | CoreBoundaryStageError::Stale { .. }
9119        ));
9120    }
9121
9122    fn block_assistant_text(message: &BlockAssistantMessage) -> String {
9123        message
9124            .blocks
9125            .iter()
9126            .filter_map(|block| match block {
9127                AssistantBlock::Text { text, .. } => Some(text.as_str()),
9128                _ => None,
9129            })
9130            .collect()
9131    }
9132
9133    /// Reducer tests enter through the same proof shape as persistent
9134    /// ingestion: a metadata-only anchor is staged first, then a canonical
9135    /// blob-backed event is applied. Blob bytes are verified in
9136    /// PersistentSessionService tests; this helper tests only reducer ownership.
9137    fn append_staged_user_image(
9138        session: &mut Session,
9139        event: &RealtimeTranscriptEvent,
9140    ) -> RealtimeTranscriptApplyOutcome {
9141        let RealtimeTranscriptEvent::UserContentFinal {
9142            idempotency_key,
9143            item_id,
9144            previous_item_id,
9145            content_index,
9146            content,
9147        } = event
9148        else {
9149            panic!("test helper requires user content final")
9150        };
9151        let [ContentBlock::Image { media_type, data }] = content.as_slice() else {
9152            panic!("test helper requires exactly one image")
9153        };
9154        let media_type = crate::image_generation::MediaType::canonical_str(media_type);
9155        let blob_id = match data {
9156            crate::types::ImageData::Inline { data } => {
9157                crate::blob::content_blob_id(&media_type, data)
9158            }
9159            crate::types::ImageData::Blob { blob_id } => blob_id.clone(),
9160        };
9161        let pending = crate::PendingRealtimeUserContentBlob {
9162            idempotency_key: idempotency_key.clone(),
9163            item_id: item_id.clone(),
9164            previous_item_id: previous_item_id.clone(),
9165            content_index: *content_index,
9166            blob_id,
9167            media_type,
9168        };
9169        assert_eq!(
9170            session
9171                .stage_pending_realtime_user_content_blob(pending.clone())
9172                .expect("test pending anchor should stage"),
9173            crate::generated::session_document::RealtimeUserContentBlobStageDisposition::StageNew
9174        );
9175        session.append_realtime_transcript_event(pending.canonical_event())
9176    }
9177
9178    #[test]
9179    fn transcript_digest_is_content_addressed() {
9180        let base_time = crate::types::message_timestamp_now();
9181        let stamped = vec![
9182            Message::User(UserMessage::text("turn one".to_string())),
9183            Message::BlockAssistant(BlockAssistantMessage {
9184                blocks: vec![AssistantBlock::Text {
9185                    text: "answer one".to_string(),
9186                    meta: None,
9187                }],
9188                stop_reason: StopReason::EndTurn,
9189                identity: crate::types::TranscriptMessageIdentity {
9190                    interaction_id: None,
9191                    run_id: Some(crate::lifecycle::RunId::new()),
9192                    objective_id: None,
9193                },
9194                created_at: base_time,
9195            }),
9196        ];
9197        let mut restamped = stamped.clone();
9198        for message in &mut restamped {
9199            match message {
9200                Message::User(user) => {
9201                    user.created_at = base_time + chrono::Duration::hours(2);
9202                }
9203                Message::BlockAssistant(assistant) => {
9204                    assistant.identity = crate::types::TranscriptMessageIdentity {
9205                        interaction_id: None,
9206                        run_id: Some(crate::lifecycle::RunId::new()),
9207                        objective_id: None,
9208                    };
9209                    assistant.created_at = base_time + chrono::Duration::hours(2);
9210                }
9211                _ => {}
9212            }
9213        }
9214        assert_eq!(
9215            transcript_messages_digest(&stamped).expect("digest"),
9216            transcript_messages_digest(&restamped).expect("digest"),
9217            "bookkeeping variance must not fork the transcript revision"
9218        );
9219
9220        let mut content_changed = stamped.clone();
9221        if let Message::User(user) = &mut content_changed[0] {
9222            user.content = vec![ContentBlock::Text {
9223                text: "a different turn".to_string(),
9224            }];
9225        }
9226        assert_ne!(
9227            transcript_messages_digest(&stamped).expect("digest"),
9228            transcript_messages_digest(&content_changed).expect("digest"),
9229            "content changes must fork the transcript revision"
9230        );
9231    }
9232
9233    #[test]
9234    fn public_generic_rewrite_api_rejects_typed_compaction_semantic() {
9235        let mut session = Session::new();
9236        session.push(Message::User(UserMessage::text("old context")));
9237        let error = session
9238            .commit_transcript_rewrite(
9239                TranscriptRewriteSelection::typed_compaction_for_test(0, 1),
9240                vec![Message::User(UserMessage::compaction_summary("summary"))],
9241                TranscriptRewriteReason::new("anything"),
9242                None,
9243                None,
9244            )
9245            .unwrap_err();
9246        assert!(matches!(
9247            error,
9248            TranscriptEditError::InvalidTranscriptShape(_)
9249        ));
9250        assert_eq!(session.messages().len(), 1);
9251    }
9252
9253    #[test]
9254    fn compaction_witness_authorizes_only_the_exact_validated_rebuild() {
9255        let mut session = Session::new();
9256        session.push(Message::User(UserMessage::text("old context one")));
9257        session.push(Message::User(UserMessage::text("old context two")));
9258        let validated = vec![Message::User(UserMessage::compaction_summary(
9259            "validated summary",
9260        ))];
9261        let authority = crate::agent::compact::ValidatedCompactionRewrite::for_test(
9262            session.messages(),
9263            &validated,
9264        )
9265        .unwrap();
9266        let error = session
9267            .replace_messages_for_compaction_internal(
9268                vec![Message::User(UserMessage::compaction_summary(
9269                    "substituted summary",
9270                ))],
9271                &authority,
9272            )
9273            .unwrap_err();
9274        assert!(matches!(
9275            error,
9276            TranscriptEditError::InvalidTranscriptShape(_)
9277        ));
9278        assert_eq!(session.messages().len(), 2);
9279    }
9280
9281    #[test]
9282    fn semantic_marker_prevents_new_generic_compaction_forgery_and_heals_prior_data() {
9283        let mut session = Session::new();
9284        session.push(Message::User(UserMessage::text("old context one")));
9285        session.push(Message::User(UserMessage::text("old context two")));
9286        session
9287            .commit_transcript_rewrite(
9288                TranscriptRewriteSelection::MessageRange { start: 0, end: 2 },
9289                vec![Message::User(UserMessage::compaction_summary("summary"))],
9290                TranscriptRewriteReason::new("compaction"),
9291                None,
9292                None,
9293            )
9294            .unwrap();
9295        let session: Session =
9296            serde_json::from_value(serde_json::to_value(&session).unwrap()).unwrap();
9297        let history = session.transcript_history_state().unwrap().unwrap();
9298        assert_eq!(
9299            history.commits[0].selection.semantic(),
9300            TranscriptRewriteSemantic::Edit,
9301            "new generic rewrites retain an explicit typed edit marker after roundtrip"
9302        );
9303        assert_eq!(history.commits[0].reason.kind, "compaction");
9304
9305        let mut legacy = history;
9306        legacy.commits[0].selection = TranscriptRewriteSelection::MessageRange { start: 0, end: 2 };
9307        let legacy: TranscriptHistoryState =
9308            serde_json::from_value(serde_json::to_value(legacy).unwrap()).unwrap();
9309        assert_eq!(
9310            legacy.commits[0].selection.semantic(),
9311            TranscriptRewriteSemantic::Compaction,
9312            "marker-absent prior data derives compaction from typed transcript evidence"
9313        );
9314
9315        let mut ordinary = Session::new();
9316        ordinary.push(Message::User(UserMessage::text("ordinary old one")));
9317        ordinary.push(Message::User(UserMessage::text("ordinary old two")));
9318        ordinary
9319            .commit_transcript_rewrite(
9320                TranscriptRewriteSelection::MessageRange { start: 0, end: 2 },
9321                vec![Message::User(UserMessage::text("ordinary replacement"))],
9322                TranscriptRewriteReason::new("compaction"),
9323                None,
9324                None,
9325            )
9326            .unwrap();
9327        let history = ordinary.transcript_history_state().unwrap().unwrap();
9328        assert_eq!(
9329            history.commits[0].selection.semantic(),
9330            TranscriptRewriteSemantic::Edit,
9331            "free-form reason must not upgrade an ordinary edit"
9332        );
9333    }
9334
9335    /// HomeCore cutover regression: documents written by pre-marker code
9336    /// (`digest_format` absent, current-format digests) previously re-paid
9337    /// the decode-time heal probe plus the full per-body graph validation —
9338    /// a canonical-JSON + SHA-256 pass over the whole retained transcript —
9339    /// on EVERY decode. Repeat decodes of unchanged bytes must be absorbed
9340    /// by the bounded process-lifetime decode memo after the first
9341    /// full-verify decode.
9342    #[test]
9343    fn marker_less_document_decode_memoizes_probe_and_graph_validation() {
9344        use crate::checkpoint::session_content_digest_computations;
9345
9346        // Fixture content is unique to this test so no sibling test's decode
9347        // can pre-warm the process-lifetime memo for this graph shape.
9348        let mut session = Session::new();
9349        session.push(Message::User(UserMessage::text(
9350            "marker-less-memo old context one",
9351        )));
9352        session.push(Message::User(UserMessage::text(
9353            "marker-less-memo old context two",
9354        )));
9355        session
9356            .commit_transcript_rewrite(
9357                TranscriptRewriteSelection::MessageRange { start: 0, end: 2 },
9358                vec![Message::User(UserMessage::text(
9359                    "marker-less-memo replacement",
9360                ))],
9361                TranscriptRewriteReason::new("edit"),
9362                None,
9363                None,
9364            )
9365            .unwrap();
9366        let mut document = serde_json::to_value(&session).unwrap();
9367        // Forge the pre-marker writer's shape (the HomeCore 0.8.4-migrated
9368        // fleet): identical current-format digests, marker absent.
9369        document["metadata"][SESSION_TRANSCRIPT_HISTORY_STATE_KEY]
9370            .as_object_mut()
9371            .unwrap()
9372            .remove("digest_format")
9373            .expect("fixture blob was written with the marker");
9374
9375        let before_first = session_content_digest_computations();
9376        let first: Session = serde_json::from_value(document.clone()).unwrap();
9377        let after_first = session_content_digest_computations();
9378        assert!(
9379            after_first > before_first,
9380            "first decode of a marker-less document must fully verify \
9381             (heal probe + per-body graph validation)"
9382        );
9383        // Decode stamps the in-memory state, so any re-save persists the
9384        // marker (the mobkit stamping verb relies on exactly this).
9385        let restamped = serde_json::to_value(&first).unwrap();
9386        assert_eq!(
9387            restamped["metadata"][SESSION_TRANSCRIPT_HISTORY_STATE_KEY]["digest_format"],
9388            serde_json::json!(TRANSCRIPT_DIGEST_FORMAT_CURRENT),
9389            "decoding a marker-less document must stamp the current digest format"
9390        );
9391
9392        for _ in 0..3 {
9393            let repeat: Session = serde_json::from_value(document.clone()).unwrap();
9394            assert_eq!(repeat.messages().len(), first.messages().len());
9395        }
9396        assert_eq!(
9397            session_content_digest_computations(),
9398            after_first,
9399            "repeat decodes of unchanged marker-less bytes must not recompute \
9400             content digests (O(1) per repeat load within a process)"
9401        );
9402
9403        // The marker-stamped spelling of the SAME graph shares the proof:
9404        // the probe is format-gated and the graph validation is memoized on
9405        // shape, which deliberately excludes the compatibility marker.
9406        let stamped_repeat: Session = serde_json::from_value(restamped).unwrap();
9407        assert_eq!(stamped_repeat.messages().len(), first.messages().len());
9408        assert_eq!(
9409            session_content_digest_computations(),
9410            after_first,
9411            "repeat decode of the marker-stamped spelling must not recompute digests"
9412        );
9413
9414        // Changed content re-keys the memo and re-verifies: appending a
9415        // message to the retained head body (message count changes the
9416        // shape key) must be caught by full validation, never laundered
9417        // through memoized trust.
9418        let mut tampered = document.clone();
9419        let head = tampered["metadata"][SESSION_TRANSCRIPT_HISTORY_STATE_KEY]["head"]
9420            .as_str()
9421            .unwrap()
9422            .to_string();
9423        let bodies = tampered["metadata"][SESSION_TRANSCRIPT_HISTORY_STATE_KEY]["revisions"]
9424            .as_array_mut()
9425            .unwrap();
9426        let head_body = bodies
9427            .iter_mut()
9428            .find(|body| body["revision"].as_str() == Some(head.as_str()))
9429            .unwrap();
9430        let smuggled = head_body["messages"][0].clone();
9431        head_body["messages"].as_array_mut().unwrap().push(smuggled);
9432        let digests_before_tampered = session_content_digest_computations();
9433        let error = serde_json::from_value::<Session>(tampered).unwrap_err();
9434        assert!(
9435            error.to_string().contains("digest"),
9436            "tampered head body must fail digest validation, got: {error}"
9437        );
9438        assert!(
9439            session_content_digest_computations() > digests_before_tampered,
9440            "a changed graph shape must re-verify, not hit the memo"
9441        );
9442    }
9443
9444    fn legacy_rewrite_fixture() -> (TranscriptRewriteCommit, Vec<Message>, Vec<Message>) {
9445        let parent_messages = vec![
9446            Message::User(UserMessage::text("before rewrite".to_string())),
9447            Message::User(UserMessage::text("retained tail".to_string())),
9448        ];
9449        let revision_messages = vec![
9450            Message::User(UserMessage::text("after rewrite".to_string())),
9451            Message::User(UserMessage::text("retained tail".to_string())),
9452        ];
9453        // Compute the graph strings the way a pre-0.7.14 writer did:
9454        // bookkeeping-inclusive digests.
9455        let parent_revision =
9456            legacy_transcript_messages_digest(&parent_messages).expect("legacy parent digest");
9457        let revision =
9458            legacy_transcript_messages_digest(&revision_messages).expect("legacy revision digest");
9459        let commit = TranscriptRewriteCommit {
9460            parent_revision,
9461            revision,
9462            selection: TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
9463            original_span_digest: legacy_transcript_messages_digest(&parent_messages[0..1])
9464                .expect("legacy span digest"),
9465            replacement_digest: legacy_transcript_messages_digest(&revision_messages[0..1])
9466                .expect("legacy replacement digest"),
9467            messages_before: 2,
9468            messages_after: 2,
9469            reason: TranscriptRewriteReason::new("compaction"),
9470            actor: Some("legacy-test".to_string()),
9471            committed_at: SystemTime::now(),
9472        };
9473        (commit, parent_messages, revision_messages)
9474    }
9475
9476    #[test]
9477    fn legacy_transcript_history_state_heals_to_content_addressed_on_parse() {
9478        let (commit, parent_messages, revision_messages) = legacy_rewrite_fixture();
9479        let state = TranscriptHistoryState {
9480            head: commit.revision.clone(),
9481            digest_format: 0,
9482            commits: vec![commit.clone()],
9483            revisions: vec![
9484                TranscriptRevisionBody {
9485                    revision: commit.parent_revision.clone(),
9486                    parent_revision: None,
9487                    messages: parent_messages.clone(),
9488                    created_at: SystemTime::now(),
9489                },
9490                TranscriptRevisionBody {
9491                    revision: commit.revision.clone(),
9492                    parent_revision: Some(commit.parent_revision),
9493                    messages: revision_messages.clone(),
9494                    created_at: SystemTime::now(),
9495                },
9496            ],
9497        };
9498        let value = serde_json::to_value(&state).expect("serialize legacy state");
9499        let healed: TranscriptHistoryState =
9500            serde_json::from_value(value).expect("parse legacy state");
9501
9502        let content_parent =
9503            transcript_messages_digest(&parent_messages).expect("content parent digest");
9504        let content_revision =
9505            transcript_messages_digest(&revision_messages).expect("content revision digest");
9506        assert_eq!(healed.head, content_revision, "head must re-derive");
9507        assert_eq!(healed.commits[0].parent_revision, content_parent);
9508        assert_eq!(healed.commits[0].revision, content_revision);
9509        assert_eq!(healed.revisions[0].revision, content_parent);
9510        assert_eq!(healed.revisions[1].revision, content_revision);
9511        assert_eq!(
9512            healed.revisions[1].parent_revision.as_deref(),
9513            Some(content_parent.as_str())
9514        );
9515        validate_transcript_history_state(&healed).expect("healed graph must validate");
9516
9517        // A session materialized from the healed graph can extend the chain
9518        // with a current-format rewrite.
9519        let mut session = Session::new();
9520        session
9521            .apply_transcript_history_state(healed)
9522            .expect("apply healed graph");
9523        session
9524            .commit_transcript_rewrite(
9525                TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
9526                vec![Message::User(UserMessage::text(
9527                    "rewritten again".to_string(),
9528                ))],
9529                TranscriptRewriteReason::new("unit-test"),
9530                None,
9531                None,
9532            )
9533            .expect("extend healed graph with a new rewrite");
9534        session
9535            .validate_transcript_history_state()
9536            .expect("extended graph must validate");
9537    }
9538
9539    #[test]
9540    fn legacy_transcript_rewrite_record_heals_on_parse() {
9541        let (commit, parent_messages, revision_messages) = legacy_rewrite_fixture();
9542        let record_value = serde_json::json!({
9543            "commit": commit,
9544            "parent_body": TranscriptRevisionBody {
9545                revision: commit.parent_revision.clone(),
9546                parent_revision: None,
9547                messages: parent_messages,
9548                created_at: SystemTime::now(),
9549            },
9550            "revision_body": TranscriptRevisionBody {
9551                revision: commit.revision.clone(),
9552                parent_revision: Some(commit.parent_revision),
9553                messages: revision_messages.clone(),
9554                created_at: SystemTime::now(),
9555            },
9556        });
9557        let healed: TranscriptRewriteRecord =
9558            serde_json::from_value(record_value).expect("parse legacy record");
9559        assert_eq!(
9560            healed.commit.revision,
9561            transcript_messages_digest(&revision_messages).expect("content digest")
9562        );
9563        // The healed record passes the same validation `new` enforces.
9564        TranscriptRewriteRecord::new(healed.commit, healed.parent_body, healed.revision_body)
9565            .expect("healed record must validate");
9566    }
9567
9568    #[test]
9569    fn corrupt_transcript_history_strings_stay_untouched_and_fail_validation() {
9570        let (commit, parent_messages, _revision_messages) = legacy_rewrite_fixture();
9571        let bogus = "sha256:0000000000000000000000000000000000000000000000000000000000000000";
9572        let state = TranscriptHistoryState {
9573            digest_format: 0,
9574            head: bogus.to_string(),
9575            commits: Vec::new(),
9576            revisions: vec![TranscriptRevisionBody {
9577                revision: bogus.to_string(),
9578                parent_revision: None,
9579                messages: parent_messages,
9580                created_at: SystemTime::now(),
9581            }],
9582        };
9583        let _ = commit;
9584        let value = serde_json::to_value(&state).expect("serialize corrupt state");
9585        let parsed: TranscriptHistoryState =
9586            serde_json::from_value(value).expect("corrupt strings still parse");
9587        assert_eq!(
9588            parsed.head, bogus,
9589            "unverifiable strings must not be rewritten"
9590        );
9591        assert!(
9592            validate_transcript_history_state(&parsed).is_err(),
9593            "corrupt graph must keep failing validation"
9594        );
9595    }
9596
9597    /// K4 invariant: synthetic-notice refresh is ONE atomic transcript edit —
9598    /// after a refresh, at most the replacement notices of that kind exist
9599    /// (no stale notice survives beside a fresh one).
9600    #[test]
9601    fn replace_synthetic_notices_leaves_only_replacements_of_kind() {
9602        use crate::types::{SystemNoticeKind, SystemNoticeMessage};
9603
9604        let mut session = Session::new();
9605        session.push(Message::User(UserMessage::text("hello".to_string())));
9606        session.push(Message::SystemNotice(SystemNoticeMessage::new(
9607            SystemNoticeKind::McpPending,
9608            "stale one",
9609        )));
9610        session.push(Message::SystemNotice(SystemNoticeMessage::new(
9611            SystemNoticeKind::McpPending,
9612            "stale two",
9613        )));
9614        // A notice of another kind must be untouched.
9615        session.push(Message::SystemNotice(SystemNoticeMessage::new(
9616            SystemNoticeKind::BackgroundJob,
9617            "other-kind",
9618        )));
9619
9620        session
9621            .replace_synthetic_notices(
9622                SystemNoticeKind::McpPending,
9623                vec![Message::SystemNotice(SystemNoticeMessage::new(
9624                    SystemNoticeKind::McpPending,
9625                    "fresh",
9626                ))],
9627            )
9628            .expect("notice refresh succeeds");
9629
9630        let mcp_pending: Vec<&SystemNoticeMessage> = session
9631            .messages()
9632            .iter()
9633            .filter_map(|message| match message {
9634                Message::SystemNotice(notice) if notice.kind == SystemNoticeKind::McpPending => {
9635                    Some(notice)
9636                }
9637                _ => None,
9638            })
9639            .collect();
9640        assert_eq!(mcp_pending.len(), 1, "exactly one notice of the kind");
9641        assert_eq!(mcp_pending[0].body.as_deref(), Some("fresh"));
9642        assert!(
9643            session.messages().iter().any(|message| matches!(
9644                message,
9645                Message::SystemNotice(notice) if notice.kind == SystemNoticeKind::BackgroundJob
9646            )),
9647            "other-kind notices are untouched"
9648        );
9649
9650        // Empty replacements = pure strip.
9651        session
9652            .replace_synthetic_notices(SystemNoticeKind::McpPending, Vec::new())
9653            .expect("pure strip succeeds");
9654        assert!(
9655            !session.messages().iter().any(|message| matches!(
9656                message,
9657                Message::SystemNotice(notice) if notice.kind == SystemNoticeKind::McpPending
9658            )),
9659            "empty replacement clears the kind"
9660        );
9661    }
9662
9663    #[test]
9664    fn ordinary_appends_after_rewrite_coalesce_mechanical_revision_bodies() {
9665        let mut session = Session::new();
9666        for message in 0..133 {
9667            session.push(Message::User(UserMessage::text(format!(
9668                "seed message {message}"
9669            ))));
9670        }
9671        let parent = session.transcript_revision().expect("parent revision");
9672        session
9673            .commit_transcript_rewrite(
9674                TranscriptRewriteSelection::MessageRange {
9675                    start: 132,
9676                    end: 133,
9677                },
9678                vec![Message::User(UserMessage::text("edited question"))],
9679                TranscriptRewriteReason::new("unit-test-edit"),
9680                Some("unit-test".to_string()),
9681                Some(parent),
9682            )
9683            .expect("rewrite should commit");
9684
9685        for turn in 0..762 {
9686            session.push(Message::User(UserMessage::text(format!("turn {turn}"))));
9687        }
9688
9689        let state = session
9690            .transcript_history_state()
9691            .expect("history state should decode")
9692            .expect("rewrite should create history state");
9693        assert_eq!(session.messages().len(), 895);
9694        assert_eq!(state.commits.len(), 1, "ordinary appends are not rewrites");
9695        assert_eq!(
9696            state.revisions.len(),
9697            3,
9698            "one real rewrite retains its two audited endpoints plus one live head"
9699        );
9700        let retained_message_entries = state
9701            .revisions
9702            .iter()
9703            .map(|body| body.messages.len())
9704            .sum::<usize>();
9705        assert!(retained_message_entries <= 3 * session.messages().len());
9706
9707        let live_bytes = serde_json::to_vec(session.messages())
9708            .expect("live transcript should serialize")
9709            .len();
9710        let snapshot_bytes = serde_json::to_vec(&session)
9711            .expect("session snapshot should serialize")
9712            .len();
9713        assert!(
9714            snapshot_bytes <= live_bytes.saturating_mul(5).saturating_add(64 * 1024),
9715            "snapshot must remain linear in the live transcript: {snapshot_bytes} bytes for {live_bytes} live bytes"
9716        );
9717    }
9718
9719    #[test]
9720    fn repeated_synthetic_notice_refreshes_do_not_mint_rewrite_commits() {
9721        use crate::types::{SystemNoticeKind, SystemNoticeMessage};
9722
9723        let mut session = Session::new();
9724        session.push(Message::User(UserMessage::text("before".to_string())));
9725        session
9726            .commit_transcript_rewrite(
9727                TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
9728                vec![Message::User(UserMessage::text("after".to_string()))],
9729                TranscriptRewriteReason::new("unit-test-edit"),
9730                Some("unit-test".to_string()),
9731                None,
9732            )
9733            .expect("seed rewrite");
9734
9735        for refresh in 0..64 {
9736            session
9737                .replace_synthetic_notices(
9738                    SystemNoticeKind::McpPending,
9739                    vec![Message::SystemNotice(SystemNoticeMessage::new(
9740                        SystemNoticeKind::McpPending,
9741                        format!("refresh {refresh}"),
9742                    ))],
9743                )
9744                .expect("mechanical refresh");
9745        }
9746
9747        let state = session
9748            .transcript_history_state()
9749            .expect("history state")
9750            .expect("seed rewrite history");
9751        assert_eq!(state.commits.len(), 1);
9752        assert_eq!(session.transcript_rewrite_generation().unwrap(), 1);
9753        assert_eq!(state.revisions.len(), 3);
9754    }
9755
9756    #[test]
9757    fn legacy_append_head_chain_compacts_during_session_restore() {
9758        let mut session = Session::new();
9759        session.push(Message::User(UserMessage::text("seed".to_string())));
9760        session
9761            .commit_transcript_rewrite(
9762                TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
9763                vec![Message::User(UserMessage::text(
9764                    "rewritten seed".to_string(),
9765                ))],
9766                TranscriptRewriteReason::new("unit-test-edit"),
9767                Some("unit-test".to_string()),
9768                None,
9769            )
9770            .expect("seed rewrite");
9771
9772        let mut legacy = session
9773            .transcript_history_state()
9774            .expect("history state")
9775            .expect("seed history");
9776        let mut messages = session.messages().to_vec();
9777        let mut previous_head = legacy.head.clone();
9778        for append in 0..32 {
9779            messages.push(Message::User(UserMessage::text(format!(
9780                "legacy append {append}"
9781            ))));
9782            let revision = transcript_messages_digest(&messages).expect("revision digest");
9783            legacy.revisions.push(TranscriptRevisionBody {
9784                revision: revision.clone(),
9785                parent_revision: Some(previous_head),
9786                messages: messages.clone(),
9787                created_at: SystemTime::now(),
9788            });
9789            previous_head = revision;
9790        }
9791        legacy.head = previous_head;
9792        assert_eq!(legacy.revisions.len(), 34, "fixture matches old shape");
9793
9794        let mut envelope = serde_json::to_value(&session).expect("base envelope");
9795        envelope["messages"] = serde_json::to_value(&messages).expect("legacy live messages");
9796        envelope["metadata"][SESSION_TRANSCRIPT_HISTORY_STATE_KEY] =
9797            serde_json::to_value(&legacy).expect("legacy unbounded history");
9798        for body in envelope["metadata"][SESSION_TRANSCRIPT_HISTORY_STATE_KEY]["revisions"]
9799            .as_array_mut()
9800            .expect("legacy revisions")
9801        {
9802            body.as_object_mut()
9803                .expect("legacy revision body")
9804                .remove("parent_revision");
9805        }
9806        let raw = serde_json::to_vec(&envelope).expect("raw legacy bytes");
9807
9808        let restored: Session = serde_json::from_slice(&raw).expect("legacy restore");
9809        let compact = restored
9810            .transcript_history_state()
9811            .expect("compacted state")
9812            .expect("history retained");
9813        assert_eq!(compact.commits, legacy.commits);
9814        assert_eq!(compact.revisions.len(), 3);
9815        validate_transcript_history_state(&compact).expect("compacted history remains valid");
9816        let repaired = serde_json::to_vec(&restored).expect("repaired snapshot");
9817        assert!(
9818            repaired.len() * 4 < raw.len(),
9819            "repair should shed old bodies"
9820        );
9821    }
9822
9823    #[test]
9824    fn snapshot_compaction_does_not_launder_corrupt_old_body() {
9825        let mut session = Session::new();
9826        session.push(Message::User(UserMessage::text("seed".to_string())));
9827        session
9828            .commit_transcript_rewrite(
9829                TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
9830                vec![Message::User(UserMessage::text("rewritten".to_string()))],
9831                TranscriptRewriteReason::new("unit-test-edit"),
9832                Some("unit-test".to_string()),
9833                None,
9834            )
9835            .expect("seed rewrite");
9836        let mut state = session
9837            .transcript_history_state()
9838            .expect("state")
9839            .expect("history");
9840        state.revisions.push(TranscriptRevisionBody {
9841            revision: "sha256:corrupt-old-body".to_string(),
9842            parent_revision: Some(state.head.clone()),
9843            messages: vec![Message::User(UserMessage::text("tampered".to_string()))],
9844            created_at: SystemTime::now(),
9845        });
9846        session.set_metadata_unchecked_for_test(
9847            SESSION_TRANSCRIPT_HISTORY_STATE_KEY,
9848            serde_json::to_value(state).expect("corrupt history value"),
9849        );
9850
9851        assert!(
9852            serde_json::to_vec(&session).is_err(),
9853            "serialization must fail before pruning a corrupt old body"
9854        );
9855    }
9856
9857    #[test]
9858    fn unchecked_valid_history_is_validated_and_compacted_at_snapshot_boundary() {
9859        let mut session = Session::new();
9860        session.push(Message::User(UserMessage::text("seed".to_string())));
9861        session
9862            .commit_transcript_rewrite(
9863                TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
9864                vec![Message::User(UserMessage::text("rewritten".to_string()))],
9865                TranscriptRewriteReason::new("unit-test-edit"),
9866                Some("unit-test".to_string()),
9867                None,
9868            )
9869            .expect("seed rewrite");
9870        let mut state = session
9871            .transcript_history_state()
9872            .expect("state")
9873            .expect("history");
9874        let mut messages = session.messages().to_vec();
9875        let mut parent = state.head.clone();
9876        for index in 0..8 {
9877            messages.push(Message::User(UserMessage::text(format!(
9878                "legacy append {index}"
9879            ))));
9880            let revision = transcript_messages_digest(&messages).expect("revision digest");
9881            state.revisions.push(TranscriptRevisionBody {
9882                revision: revision.clone(),
9883                parent_revision: Some(parent),
9884                messages: messages.clone(),
9885                created_at: SystemTime::now(),
9886            });
9887            parent = revision;
9888        }
9889        state.head = parent;
9890        session.messages.replace(messages);
9891        session.set_metadata_unchecked_for_test(
9892            SESSION_TRANSCRIPT_HISTORY_STATE_KEY,
9893            serde_json::to_value(state).expect("uncompacted history"),
9894        );
9895        assert_eq!(
9896            session.transcript_history_metadata_validation,
9897            TranscriptHistoryMetadataValidation::RequiresValidation
9898        );
9899
9900        let snapshot = serde_json::to_vec(&session)
9901            .expect("valid unchecked history should serialize after validation");
9902        let snapshot: serde_json::Value = serde_json::from_slice(&snapshot).expect("snapshot JSON");
9903        let compact: TranscriptHistoryState = serde_json::from_value(
9904            snapshot["metadata"][SESSION_TRANSCRIPT_HISTORY_STATE_KEY].clone(),
9905        )
9906        .expect("compacted history");
9907
9908        assert_eq!(
9909            compact.revisions.len(),
9910            3,
9911            "snapshot boundary should retain two audited endpoints plus the live head"
9912        );
9913        validate_transcript_history_state(&compact).expect("compacted history remains valid");
9914    }
9915
9916    #[test]
9917    fn transcript_history_rejects_stale_branch_after_digest_recurrence() {
9918        let mut restored = Session::new();
9919        restored.push(Message::User(UserMessage::text("A".to_string())));
9920        restored
9921            .commit_transcript_rewrite(
9922                TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
9923                vec![Message::User(UserMessage::text("B".to_string()))],
9924                TranscriptRewriteReason::new("to-b"),
9925                Some("unit-test".to_string()),
9926                None,
9927            )
9928            .expect("A to B");
9929        let mut stale_branch = restored.clone();
9930        restored
9931            .commit_transcript_rewrite(
9932                TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
9933                vec![Message::User(UserMessage::text("A".to_string()))],
9934                TranscriptRewriteReason::new("restore-a"),
9935                Some("unit-test".to_string()),
9936                None,
9937            )
9938            .expect("B back to A");
9939        stale_branch
9940            .commit_transcript_rewrite(
9941                TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
9942                vec![Message::User(UserMessage::text("C".to_string()))],
9943                TranscriptRewriteReason::new("stale-b-to-c"),
9944                Some("unit-test".to_string()),
9945                None,
9946            )
9947            .expect("stale B to C is locally valid");
9948
9949        let stale_state = stale_branch
9950            .transcript_history_state()
9951            .expect("stale state")
9952            .expect("stale history");
9953        let stale_commit = stale_state.commits.last().expect("stale commit").clone();
9954        let stale_body = stale_state
9955            .revisions
9956            .iter()
9957            .find(|body| body.revision == stale_commit.revision)
9958            .expect("stale revision body")
9959            .clone();
9960        let mut forged = restored
9961            .transcript_history_state()
9962            .expect("restored state")
9963            .expect("restored history");
9964        forged.commits.push(stale_commit);
9965        forged.revisions.push(stale_body);
9966        forged.head = forged
9967            .commits
9968            .last()
9969            .expect("forged commit")
9970            .revision
9971            .clone();
9972
9973        assert!(
9974            validate_transcript_history_state(&forged).is_err(),
9975            "an old B<-A body edge cannot authorize stale B->C after B->A restored A"
9976        );
9977    }
9978
9979    #[test]
9980    fn transcript_history_rejects_orphan_head_parent_cycle() {
9981        let mut session = Session::new();
9982        session.push(Message::User(UserMessage::text("P".to_string())));
9983        session
9984            .commit_transcript_rewrite(
9985                TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
9986                vec![Message::User(UserMessage::text("Q".to_string()))],
9987                TranscriptRewriteReason::new("valid"),
9988                Some("unit-test".to_string()),
9989                None,
9990            )
9991            .expect("valid seed rewrite");
9992        let mut state = session
9993            .transcript_history_state()
9994            .expect("state")
9995            .expect("history");
9996        let x_messages = vec![Message::User(UserMessage::text("X".to_string()))];
9997        let y_messages = vec![Message::User(UserMessage::text("Y".to_string()))];
9998        let x = transcript_messages_digest(&x_messages).expect("X digest");
9999        let y = transcript_messages_digest(&y_messages).expect("Y digest");
10000        state.revisions.push(TranscriptRevisionBody {
10001            revision: x.clone(),
10002            parent_revision: Some(y.clone()),
10003            messages: x_messages,
10004            created_at: SystemTime::now(),
10005        });
10006        state.revisions.push(TranscriptRevisionBody {
10007            revision: y,
10008            parent_revision: Some(x.clone()),
10009            messages: y_messages,
10010            created_at: SystemTime::now(),
10011        });
10012        state.head = x;
10013        session.set_metadata_unchecked_for_test(
10014            SESSION_TRANSCRIPT_HISTORY_STATE_KEY,
10015            serde_json::to_value(state).expect("cyclic state"),
10016        );
10017
10018        assert!(
10019            serde_json::to_vec(&session).is_err(),
10020            "cyclic orphan head lineage must fail instead of looping"
10021        );
10022    }
10023
10024    #[test]
10025    fn mechanical_append_can_recur_to_an_audited_digest_without_mutating_its_body() {
10026        let a = Message::User(UserMessage::text("A".to_string()));
10027        let b = Message::User(UserMessage::text("B".to_string()));
10028        let mut session = Session::new();
10029        session.push(Message::User(UserMessage::text("X".to_string())));
10030        let first = session
10031            .commit_transcript_rewrite(
10032                TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
10033                vec![a.clone(), b.clone()],
10034                TranscriptRewriteReason::new("to-a-b"),
10035                Some("unit-test".to_string()),
10036                None,
10037            )
10038            .expect("X to [A,B]");
10039        let h_parent = session
10040            .transcript_revision_body(&first.revision)
10041            .expect("H body")
10042            .expect("H retained")
10043            .parent_revision;
10044        session
10045            .commit_transcript_rewrite(
10046                TranscriptRewriteSelection::MessageRange { start: 0, end: 2 },
10047                vec![a],
10048                TranscriptRewriteReason::new("to-a"),
10049                Some("unit-test".to_string()),
10050                None,
10051            )
10052            .expect("[A,B] to [A]");
10053
10054        session.push(b);
10055
10056        let state = session
10057            .transcript_history_state()
10058            .expect("state")
10059            .expect("history");
10060        assert_eq!(state.head, first.revision);
10061        assert_eq!(session.transcript_revision().unwrap(), first.revision);
10062        assert_eq!(
10063            state
10064                .revisions
10065                .iter()
10066                .find(|body| body.revision == first.revision)
10067                .expect("recurred H body")
10068                .parent_revision,
10069            h_parent,
10070            "reusing an audited digest must not rewrite its occurrence metadata"
10071        );
10072        validate_transcript_history_state(&state).expect("recurred mechanical head is valid");
10073    }
10074
10075    /// K4 invariant (fail-closed): an invalid replacement is rejected with a
10076    /// typed fault BEFORE any strip happens — the transcript is unchanged, so
10077    /// a fault can never strand a half-refreshed notice state.
10078    #[test]
10079    fn replace_synthetic_notices_rejects_mismatched_kind_without_mutation() {
10080        use crate::types::{SystemNoticeKind, SystemNoticeMessage};
10081
10082        let mut session = Session::new();
10083        session.push(Message::SystemNotice(SystemNoticeMessage::new(
10084            SystemNoticeKind::McpPending,
10085            "stale",
10086        )));
10087        let before = session.messages().to_vec();
10088
10089        let err = session
10090            .replace_synthetic_notices(
10091                SystemNoticeKind::McpPending,
10092                vec![Message::User(UserMessage::text("not a notice".to_string()))],
10093            )
10094            .expect_err("mismatched replacement must fail typed");
10095        assert!(
10096            matches!(err, TranscriptEditError::InvalidTranscriptShape(_)),
10097            "expected InvalidTranscriptShape, got {err:?}"
10098        );
10099        assert_eq!(
10100            session.messages(),
10101            before.as_slice(),
10102            "fault must leave the transcript unchanged (no partial strip)"
10103        );
10104    }
10105
10106    #[test]
10107    fn replace_synthetic_notices_rejects_malformed_history_atomically() {
10108        use crate::types::{SystemNoticeKind, SystemNoticeMessage};
10109
10110        let mut session = Session::new();
10111        session.push(Message::User(UserMessage::text("before".to_string())));
10112        session
10113            .commit_transcript_rewrite(
10114                TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
10115                vec![Message::User(UserMessage::text("after".to_string()))],
10116                TranscriptRewriteReason::new("unit-test-edit"),
10117                Some("unit-test".to_string()),
10118                None,
10119            )
10120            .expect("seed rewrite");
10121        session.push(Message::SystemNotice(SystemNoticeMessage::new(
10122            SystemNoticeKind::McpPending,
10123            "stale",
10124        )));
10125        let mut state = session
10126            .transcript_history_state()
10127            .expect("state")
10128            .expect("history");
10129        state.revisions[0].messages[0] = Message::User(UserMessage::text("tampered".to_string()));
10130        session.set_metadata_unchecked_for_test(
10131            SESSION_TRANSCRIPT_HISTORY_STATE_KEY,
10132            serde_json::to_value(state).expect("corrupt state"),
10133        );
10134        let before_messages = session.messages.clone();
10135        let before_metadata = session.metadata.clone();
10136        let before_updated_at = session.updated_at;
10137
10138        assert!(
10139            session
10140                .replace_synthetic_notices(SystemNoticeKind::McpPending, Vec::new())
10141                .is_err()
10142        );
10143        assert_eq!(session.messages(), before_messages.as_slice());
10144        assert_eq!(session.metadata, before_metadata);
10145        assert_eq!(session.updated_at, before_updated_at);
10146    }
10147
10148    #[test]
10149    fn replace_synthetic_notices_rejects_durable_notice_kinds() {
10150        use crate::types::SystemNoticeKind;
10151
10152        let mut session = Session::new();
10153        let before = session.messages().to_vec();
10154        assert!(
10155            session
10156                .replace_synthetic_notices(SystemNoticeKind::Comms, Vec::new())
10157                .is_err()
10158        );
10159        assert_eq!(session.messages(), before);
10160    }
10161
10162    #[test]
10163    fn replace_synthetic_notices_preserves_persisted_mcp_pending_notice() {
10164        use crate::types::{SystemNoticeBlock, SystemNoticeKind, SystemNoticeMessage};
10165
10166        let mut session = Session::new();
10167        session.push(Message::SystemNotice(SystemNoticeMessage::with_block(
10168            SystemNoticeKind::McpPending,
10169            Some("persisted pending fact".to_string()),
10170            SystemNoticeBlock::Mcp {
10171                server_id: Some("server".to_string()),
10172                operation: None,
10173                phase: None,
10174                persisted: true,
10175                detail: None,
10176                pending_sources: Vec::new(),
10177            },
10178        )));
10179        let before = session.messages().to_vec();
10180
10181        session
10182            .replace_synthetic_notices(SystemNoticeKind::McpPending, Vec::new())
10183            .expect("synthetic refresh must coexist with a durable notice of the same kind");
10184        assert_eq!(session.messages(), before);
10185    }
10186
10187    #[test]
10188    fn replace_synthetic_notices_replaces_projection_beside_persisted_mcp_fact() {
10189        use crate::types::{SystemNoticeBlock, SystemNoticeKind, SystemNoticeMessage};
10190
10191        let durable = Message::SystemNotice(SystemNoticeMessage::with_block(
10192            SystemNoticeKind::McpPending,
10193            Some("persisted pending fact".to_string()),
10194            SystemNoticeBlock::Mcp {
10195                server_id: Some("server".to_string()),
10196                operation: None,
10197                phase: None,
10198                persisted: true,
10199                detail: None,
10200                pending_sources: Vec::new(),
10201            },
10202        ));
10203        let stale = Message::SystemNotice(SystemNoticeMessage::new(
10204            SystemNoticeKind::McpPending,
10205            "stale synthetic projection",
10206        ));
10207        let fresh = Message::SystemNotice(SystemNoticeMessage::new(
10208            SystemNoticeKind::McpPending,
10209            "fresh synthetic projection",
10210        ));
10211        let mut session = Session::new();
10212        session.push(durable.clone());
10213        session.push(stale);
10214
10215        session
10216            .replace_synthetic_notices(SystemNoticeKind::McpPending, vec![fresh.clone()])
10217            .expect("synthetic refresh beside durable fact");
10218
10219        assert_eq!(session.messages(), &[durable, fresh]);
10220    }
10221
10222    #[test]
10223    fn transcript_rewrite_preserves_full_assistant_block_trace() {
10224        let mut session = Session::new();
10225        session.push(Message::User(UserMessage::text(
10226            "run the trace".to_string(),
10227        )));
10228        session.push(Message::BlockAssistant(BlockAssistantMessage::new(
10229            vec![AssistantBlock::Text {
10230                text: "original assistant trace".to_string(),
10231                meta: None,
10232            }],
10233            StopReason::EndTurn,
10234        )));
10235
10236        let parent_revision = session.transcript_revision().expect("parent revision");
10237        let replacement = vec![
10238            Message::BlockAssistant(BlockAssistantMessage::new(
10239                vec![
10240                    AssistantBlock::Text {
10241                        text: "compacted assistant trace".to_string(),
10242                        meta: None,
10243                    },
10244                    AssistantBlock::ToolUse {
10245                        id: "toolu_trace".to_string(),
10246                        name: "trace_probe".to_string(),
10247                        args: serde_json::value::RawValue::from_string(
10248                            r#"{"path":"N-3"}"#.to_string(),
10249                        )
10250                        .expect("valid tool args"),
10251                        meta: None,
10252                    },
10253                ],
10254                StopReason::ToolUse,
10255            )),
10256            Message::tool_results(vec![ToolResult::new(
10257                "toolu_trace".to_string(),
10258                "trace complete".to_string(),
10259                false,
10260            )]),
10261        ];
10262
10263        let commit = session
10264            .commit_transcript_rewrite(
10265                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
10266                replacement,
10267                TranscriptRewriteReason::new("compaction"),
10268                Some("unit-test".to_string()),
10269                Some(parent_revision.clone()),
10270            )
10271            .expect("rewrite should commit");
10272
10273        assert_eq!(commit.parent_revision, parent_revision);
10274        let current = session
10275            .transcript_revision_messages(&commit.revision)
10276            .expect("history state should decode")
10277            .expect("current revision should be retained");
10278        let Message::BlockAssistant(assistant) = &current[1] else {
10279            panic!("replacement should remain a block assistant message");
10280        };
10281        assert!(assistant.blocks.iter().any(|block| matches!(
10282            block,
10283            AssistantBlock::ToolUse { name, args, .. }
10284                if name == "trace_probe" && args.get().contains("\"N-3\"")
10285        )));
10286
10287        let parent = session
10288            .transcript_revision_messages(&parent_revision)
10289            .expect("history state should decode")
10290            .expect("parent revision should remain retained");
10291        assert!(matches!(
10292            &parent[1],
10293            Message::BlockAssistant(assistant)
10294                if block_assistant_text(assistant).contains("original assistant trace")
10295        ));
10296    }
10297
10298    #[test]
10299    fn transcript_rewrite_rejects_trailing_block_assistant_tool_call() {
10300        let mut session = Session::new();
10301        session.push(Message::User(UserMessage::text("question".to_string())));
10302        session.push(Message::BlockAssistant(BlockAssistantMessage {
10303            blocks: vec![AssistantBlock::Text {
10304                text: "plain answer".to_string(),
10305                meta: None,
10306            }],
10307            stop_reason: StopReason::EndTurn,
10308            identity: crate::types::TranscriptMessageIdentity::default(),
10309            created_at: crate::types::message_timestamp_now(),
10310        }));
10311        let parent_revision = session.transcript_revision().expect("parent revision");
10312
10313        let err = session
10314            .commit_transcript_rewrite(
10315                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
10316                vec![Message::BlockAssistant(BlockAssistantMessage::new(
10317                    vec![AssistantBlock::ToolUse {
10318                        id: "toolu_1".to_string(),
10319                        name: "lookup".to_string(),
10320                        args: serde_json::value::RawValue::from_string("{}".to_string())
10321                            .expect("valid args"),
10322                        meta: None,
10323                    }],
10324                    StopReason::ToolUse,
10325                ))],
10326                TranscriptRewriteReason::new("compaction"),
10327                Some("unit-test".to_string()),
10328                Some(parent_revision),
10329            )
10330            .expect_err("rewrite should reject trailing unresolved block-assistant tool call");
10331        assert!(matches!(
10332            err,
10333            TranscriptEditError::InvalidTranscriptShape(_)
10334        ));
10335    }
10336
10337    #[test]
10338    fn transcript_rewrite_rejects_no_op_self_edge() {
10339        let mut session = Session::new();
10340        session.push(Message::User(UserMessage::text(
10341            "keep this exact transcript".to_string(),
10342        )));
10343        session.push(Message::BlockAssistant(BlockAssistantMessage {
10344            blocks: vec![AssistantBlock::Text {
10345                text: "unchanged".to_string(),
10346                meta: None,
10347            }],
10348            stop_reason: StopReason::EndTurn,
10349            identity: crate::types::TranscriptMessageIdentity::default(),
10350            created_at: crate::types::message_timestamp_now(),
10351        }));
10352
10353        let parent_revision = session.transcript_revision().expect("parent revision");
10354        let err = session
10355            .commit_transcript_rewrite(
10356                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
10357                vec![session.messages()[1].clone()],
10358                TranscriptRewriteReason::new("retry"),
10359                Some("unit-test".to_string()),
10360                Some(parent_revision.clone()),
10361            )
10362            .expect_err("same-content rewrite should not emit a self-edge commit");
10363
10364        assert!(matches!(
10365            err,
10366            TranscriptEditError::NoOpRewrite { revision } if revision == parent_revision
10367        ));
10368        assert!(
10369            session
10370                .transcript_history_state()
10371                .expect("history state should decode")
10372                .is_none()
10373        );
10374    }
10375
10376    #[test]
10377    fn transcript_rewrite_run_boundary_guard_accepts_rewrite_then_append() {
10378        let mut original = Session::new();
10379        original.push(Message::User(UserMessage::text("question".to_string())));
10380        original.push(Message::BlockAssistant(BlockAssistantMessage {
10381            blocks: vec![AssistantBlock::Text {
10382                text: "verbose answer".to_string(),
10383                meta: None,
10384            }],
10385            stop_reason: StopReason::EndTurn,
10386            identity: crate::types::TranscriptMessageIdentity::default(),
10387            created_at: crate::types::message_timestamp_now(),
10388        }));
10389
10390        let parent_revision = original.transcript_revision().expect("parent revision");
10391        let mut incoming = original.clone();
10392        incoming
10393            .commit_transcript_rewrite(
10394                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
10395                vec![Message::BlockAssistant(BlockAssistantMessage {
10396                    blocks: vec![AssistantBlock::Text {
10397                        text: "compact answer".to_string(),
10398                        meta: None,
10399                    }],
10400                    stop_reason: StopReason::EndTurn,
10401                    identity: crate::types::TranscriptMessageIdentity::default(),
10402                    created_at: crate::types::message_timestamp_now(),
10403                })],
10404                TranscriptRewriteReason::new("compaction"),
10405                Some("unit-test".to_string()),
10406                Some(parent_revision),
10407            )
10408            .expect("rewrite should commit");
10409        incoming.push(Message::User(UserMessage::text("follow-up".to_string())));
10410        incoming.push(Message::BlockAssistant(BlockAssistantMessage {
10411            blocks: vec![AssistantBlock::Text {
10412                text: "follow-up answer".to_string(),
10413                meta: None,
10414            }],
10415            stop_reason: StopReason::EndTurn,
10416            identity: crate::types::TranscriptMessageIdentity::default(),
10417            created_at: crate::types::message_timestamp_now(),
10418        }));
10419
10420        crate::session_store::run_boundary_snapshot_save_guard(&incoming, Some(&original))
10421            .expect("rewrite plus appended turn should be a valid run-boundary commit");
10422    }
10423
10424    #[test]
10425    fn transcript_rewrite_rejects_orphaned_tool_results() {
10426        let mut session = Session::new();
10427        session.push(Message::User(UserMessage::text("use a tool".to_string())));
10428        session.push(Message::BlockAssistant(BlockAssistantMessage::new(
10429            vec![AssistantBlock::ToolUse {
10430                id: "toolu_1".to_string(),
10431                name: "lookup".to_string(),
10432                args: serde_json::value::RawValue::from_string("{}".to_string())
10433                    .expect("valid args"),
10434                meta: None,
10435            }],
10436            StopReason::ToolUse,
10437        )));
10438        session.push(Message::tool_results(vec![ToolResult::new(
10439            "toolu_1".to_string(),
10440            "done".to_string(),
10441            false,
10442        )]));
10443        let parent_revision = session.transcript_revision().expect("parent revision");
10444
10445        let err = session
10446            .commit_transcript_rewrite(
10447                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
10448                vec![Message::BlockAssistant(BlockAssistantMessage {
10449                    blocks: vec![AssistantBlock::Text {
10450                        text: "no tool after all".to_string(),
10451                        meta: None,
10452                    }],
10453                    stop_reason: StopReason::EndTurn,
10454                    identity: crate::types::TranscriptMessageIdentity::default(),
10455                    created_at: crate::types::message_timestamp_now(),
10456                })],
10457                TranscriptRewriteReason::new("compaction"),
10458                Some("unit-test".to_string()),
10459                Some(parent_revision),
10460            )
10461            .expect_err("rewrite should reject stranded tool results");
10462        assert!(matches!(
10463            err,
10464            TranscriptEditError::InvalidTranscriptShape(_)
10465        ));
10466    }
10467
10468    #[test]
10469    fn transcript_rewrite_rejects_trailing_assistant_tool_call() {
10470        let mut session = Session::new();
10471        session.push(Message::User(UserMessage::text("question".to_string())));
10472        session.push(Message::BlockAssistant(BlockAssistantMessage {
10473            blocks: vec![AssistantBlock::Text {
10474                text: "plain answer".to_string(),
10475                meta: None,
10476            }],
10477            stop_reason: StopReason::EndTurn,
10478            identity: crate::types::TranscriptMessageIdentity::default(),
10479            created_at: crate::types::message_timestamp_now(),
10480        }));
10481        let parent_revision = session.transcript_revision().expect("parent revision");
10482
10483        let err = session
10484            .commit_transcript_rewrite(
10485                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
10486                vec![Message::BlockAssistant(BlockAssistantMessage {
10487                    blocks: vec![AssistantBlock::ToolUse {
10488                        id: "toolu_1".to_string(),
10489                        name: "lookup".to_string(),
10490                        args: serde_json::value::RawValue::from_string("{}".to_string())
10491                            .expect("valid args"),
10492                        meta: None,
10493                    }],
10494                    stop_reason: StopReason::ToolUse,
10495                    identity: crate::types::TranscriptMessageIdentity::default(),
10496                    created_at: crate::types::message_timestamp_now(),
10497                })],
10498                TranscriptRewriteReason::new("compaction"),
10499                Some("unit-test".to_string()),
10500                Some(parent_revision),
10501            )
10502            .expect_err("rewrite should reject trailing unresolved tool call");
10503        assert!(matches!(
10504            err,
10505            TranscriptEditError::InvalidTranscriptShape(_)
10506        ));
10507    }
10508
10509    #[test]
10510    fn transcript_rewrite_rejects_duplicate_tool_results() {
10511        let mut session = Session::new();
10512        session.push(Message::User(UserMessage::text("use a tool".to_string())));
10513        session.push(Message::BlockAssistant(BlockAssistantMessage {
10514            blocks: vec![AssistantBlock::Text {
10515                text: "plain answer".to_string(),
10516                meta: None,
10517            }],
10518            stop_reason: StopReason::EndTurn,
10519            identity: crate::types::TranscriptMessageIdentity::default(),
10520            created_at: crate::types::message_timestamp_now(),
10521        }));
10522        let parent_revision = session.transcript_revision().expect("parent revision");
10523
10524        let err = session
10525            .commit_transcript_rewrite(
10526                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
10527                vec![
10528                    Message::BlockAssistant(BlockAssistantMessage::new(
10529                        vec![AssistantBlock::ToolUse {
10530                            id: "toolu_1".to_string(),
10531                            name: "lookup".to_string(),
10532                            args: serde_json::value::RawValue::from_string("{}".to_string())
10533                                .expect("valid args"),
10534                            meta: None,
10535                        }],
10536                        StopReason::ToolUse,
10537                    )),
10538                    Message::tool_results(vec![
10539                        ToolResult::new("toolu_1".to_string(), "one".to_string(), false),
10540                        ToolResult::new("toolu_1".to_string(), "two".to_string(), false),
10541                    ]),
10542                ],
10543                TranscriptRewriteReason::new("compaction"),
10544                Some("unit-test".to_string()),
10545                Some(parent_revision),
10546            )
10547            .expect_err("rewrite should reject duplicate tool results");
10548        assert!(matches!(
10549            err,
10550            TranscriptEditError::InvalidTranscriptShape(_)
10551        ));
10552    }
10553
10554    #[test]
10555    fn transcript_rewrite_record_rejects_prefix_or_suffix_tampering() {
10556        let mut session = Session::new();
10557        session.push(Message::System(SystemMessage::new("keep prefix")));
10558        session.push(Message::BlockAssistant(BlockAssistantMessage {
10559            blocks: vec![AssistantBlock::Text {
10560                text: "verbose answer".to_string(),
10561                meta: None,
10562            }],
10563            stop_reason: StopReason::EndTurn,
10564            identity: crate::types::TranscriptMessageIdentity::default(),
10565            created_at: crate::types::message_timestamp_now(),
10566        }));
10567        session.push(Message::User(UserMessage::text("keep suffix".to_string())));
10568
10569        let parent_revision = session.transcript_revision().expect("parent revision");
10570        let commit = session
10571            .commit_transcript_rewrite(
10572                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
10573                vec![Message::BlockAssistant(BlockAssistantMessage {
10574                    blocks: vec![AssistantBlock::Text {
10575                        text: "compact answer".to_string(),
10576                        meta: None,
10577                    }],
10578                    stop_reason: StopReason::EndTurn,
10579                    identity: crate::types::TranscriptMessageIdentity::default(),
10580                    created_at: crate::types::message_timestamp_now(),
10581                })],
10582                TranscriptRewriteReason::new("compaction"),
10583                Some("unit-test".to_string()),
10584                Some(parent_revision),
10585            )
10586            .expect("rewrite should commit");
10587        let state = session
10588            .transcript_history_state()
10589            .expect("history state should decode")
10590            .expect("history state should exist");
10591        let parent_body = state
10592            .revisions
10593            .iter()
10594            .find(|body| body.revision == commit.parent_revision)
10595            .expect("parent body retained")
10596            .clone();
10597        let revision_body = state
10598            .revisions
10599            .iter()
10600            .find(|body| body.revision == commit.revision)
10601            .expect("revision body retained")
10602            .clone();
10603
10604        let mut forged_body = revision_body;
10605        forged_body.messages[0] = Message::System(SystemMessage::new("tampered prefix"));
10606        forged_body.revision =
10607            transcript_messages_digest(&forged_body.messages).expect("forged digest");
10608        let mut forged_commit = commit;
10609        forged_commit.revision = forged_body.revision.clone();
10610        let err = TranscriptRewriteRecord::new(forged_commit, parent_body, forged_body)
10611            .expect_err("record validation must reject changes outside selected span");
10612        assert!(
10613            err.to_string().contains("before the selected span"),
10614            "unexpected error: {err}"
10615        );
10616    }
10617
10618    #[test]
10619    fn transcript_rewrite_replay_allows_normal_turn_revisions_between_rewrites() {
10620        let mut session = Session::new();
10621        session.push(Message::User(UserMessage::text("first".to_string())));
10622        session.push(Message::BlockAssistant(BlockAssistantMessage {
10623            blocks: vec![AssistantBlock::Text {
10624                text: "verbose first answer".to_string(),
10625                meta: None,
10626            }],
10627            stop_reason: StopReason::EndTurn,
10628            identity: crate::types::TranscriptMessageIdentity::default(),
10629            created_at: crate::types::message_timestamp_now(),
10630        }));
10631
10632        let first_parent = session.transcript_revision().expect("first parent");
10633        let first_commit = session
10634            .commit_transcript_rewrite(
10635                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
10636                vec![Message::BlockAssistant(BlockAssistantMessage {
10637                    blocks: vec![AssistantBlock::Text {
10638                        text: "compact first answer".to_string(),
10639                        meta: None,
10640                    }],
10641                    stop_reason: StopReason::EndTurn,
10642                    identity: crate::types::TranscriptMessageIdentity::default(),
10643                    created_at: crate::types::message_timestamp_now(),
10644                })],
10645                TranscriptRewriteReason::new("compaction"),
10646                Some("unit-test".to_string()),
10647                Some(first_parent),
10648            )
10649            .expect("first rewrite");
10650
10651        session.push(Message::User(UserMessage::text("normal turn".to_string())));
10652        session.push(Message::BlockAssistant(BlockAssistantMessage {
10653            blocks: vec![AssistantBlock::Text {
10654                text: "verbose second answer".to_string(),
10655                meta: None,
10656            }],
10657            stop_reason: StopReason::EndTurn,
10658            identity: crate::types::TranscriptMessageIdentity::default(),
10659            created_at: crate::types::message_timestamp_now(),
10660        }));
10661        let bridge_parent = session
10662            .transcript_revision()
10663            .expect("normal turn should advance transcript head");
10664        assert_ne!(bridge_parent, first_commit.revision);
10665        validate_transcript_history_state(
10666            &session
10667                .transcript_history_state()
10668                .expect("history state should decode")
10669                .expect("history state should exist"),
10670        )
10671        .expect("normal turn head may legitimately differ from last rewrite commit");
10672
10673        let second_commit = session
10674            .commit_transcript_rewrite(
10675                TranscriptRewriteSelection::MessageRange { start: 3, end: 4 },
10676                vec![Message::BlockAssistant(BlockAssistantMessage {
10677                    blocks: vec![AssistantBlock::Text {
10678                        text: "compact second answer".to_string(),
10679                        meta: None,
10680                    }],
10681                    stop_reason: StopReason::EndTurn,
10682                    identity: crate::types::TranscriptMessageIdentity::default(),
10683                    created_at: crate::types::message_timestamp_now(),
10684                })],
10685                TranscriptRewriteReason::new("compaction"),
10686                Some("unit-test".to_string()),
10687                Some(bridge_parent.clone()),
10688            )
10689            .expect("second rewrite");
10690
10691        let state = session
10692            .transcript_history_state()
10693            .expect("history state should decode")
10694            .expect("history state should exist");
10695        let records = state.commits.iter().map(|commit| {
10696            let parent_body = state
10697                .revisions
10698                .iter()
10699                .find(|body| body.revision == commit.parent_revision)
10700                .expect("parent body retained")
10701                .clone();
10702            let revision_body = state
10703                .revisions
10704                .iter()
10705                .find(|body| body.revision == commit.revision)
10706                .expect("revision body retained")
10707                .clone();
10708            TranscriptRewriteRecord::new(commit.clone(), parent_body, revision_body)
10709                .expect("record should validate")
10710        });
10711
10712        let replayed = TranscriptHistoryState::from_rewrite_records(records)
10713            .expect("rewrite replay should accept normal-turn bridge revisions")
10714            .expect("rewrite records should exist");
10715        assert_eq!(replayed.head, second_commit.revision);
10716        assert!(
10717            replayed
10718                .revisions
10719                .iter()
10720                .any(|body| body.revision == bridge_parent)
10721        );
10722    }
10723
10724    #[test]
10725    fn transcript_rewrite_replay_rejects_branched_rewrite_records() {
10726        let mut base = Session::new();
10727        base.push(Message::User(UserMessage::text("question".to_string())));
10728        base.push(Message::BlockAssistant(BlockAssistantMessage {
10729            blocks: vec![AssistantBlock::Text {
10730                text: "verbose answer".to_string(),
10731                meta: None,
10732            }],
10733            stop_reason: StopReason::EndTurn,
10734            identity: crate::types::TranscriptMessageIdentity::default(),
10735            created_at: crate::types::message_timestamp_now(),
10736        }));
10737        let parent = base.transcript_revision().expect("parent revision");
10738
10739        let mut first = base.clone();
10740        let first_commit = first
10741            .commit_transcript_rewrite(
10742                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
10743                vec![Message::BlockAssistant(BlockAssistantMessage {
10744                    blocks: vec![AssistantBlock::Text {
10745                        text: "first compact answer".to_string(),
10746                        meta: None,
10747                    }],
10748                    stop_reason: StopReason::EndTurn,
10749                    identity: crate::types::TranscriptMessageIdentity::default(),
10750                    created_at: crate::types::message_timestamp_now(),
10751                })],
10752                TranscriptRewriteReason::new("compaction"),
10753                Some("unit-test".to_string()),
10754                Some(parent.clone()),
10755            )
10756            .expect("first rewrite");
10757        let first_state = first
10758            .transcript_history_state()
10759            .expect("first state decodes")
10760            .expect("first state exists");
10761
10762        let mut second = base;
10763        let second_commit = second
10764            .commit_transcript_rewrite(
10765                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
10766                vec![Message::BlockAssistant(BlockAssistantMessage {
10767                    blocks: vec![AssistantBlock::Text {
10768                        text: "second compact answer".to_string(),
10769                        meta: None,
10770                    }],
10771                    stop_reason: StopReason::EndTurn,
10772                    identity: crate::types::TranscriptMessageIdentity::default(),
10773                    created_at: crate::types::message_timestamp_now(),
10774                })],
10775                TranscriptRewriteReason::new("compaction"),
10776                Some("unit-test".to_string()),
10777                Some(parent),
10778            )
10779            .expect("second rewrite");
10780        let second_state = second
10781            .transcript_history_state()
10782            .expect("second state decodes")
10783            .expect("second state exists");
10784
10785        let record = |state: &TranscriptHistoryState, commit: &TranscriptRewriteCommit| {
10786            let parent_body = state
10787                .revisions
10788                .iter()
10789                .find(|body| body.revision == commit.parent_revision)
10790                .expect("parent body retained")
10791                .clone();
10792            let revision_body = state
10793                .revisions
10794                .iter()
10795                .find(|body| body.revision == commit.revision)
10796                .expect("revision body retained")
10797                .clone();
10798            TranscriptRewriteRecord::new(commit.clone(), parent_body, revision_body)
10799                .expect("record should validate")
10800        };
10801
10802        let err = TranscriptHistoryState::from_rewrite_records(vec![
10803            record(&first_state, &first_commit),
10804            record(&second_state, &second_commit),
10805        ])
10806        .expect_err("branched rewrite records must not replay as a linear source history");
10807        assert!(
10808            err.to_string().contains("does not extend transcript head"),
10809            "unexpected error: {err}"
10810        );
10811    }
10812
10813    #[test]
10814    fn internal_message_rewrites_refresh_transcript_history_head() {
10815        let mut session = Session::new();
10816        session.push(Message::User(UserMessage::text("question".to_string())));
10817        session.push(Message::BlockAssistant(BlockAssistantMessage {
10818            blocks: vec![AssistantBlock::Text {
10819                text: "verbose answer".to_string(),
10820                meta: None,
10821            }],
10822            stop_reason: StopReason::EndTurn,
10823            identity: crate::types::TranscriptMessageIdentity::default(),
10824            created_at: crate::types::message_timestamp_now(),
10825        }));
10826
10827        let parent = session.transcript_revision().expect("parent revision");
10828        session
10829            .commit_transcript_rewrite(
10830                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
10831                vec![Message::BlockAssistant(BlockAssistantMessage {
10832                    blocks: vec![AssistantBlock::Text {
10833                        text: "compact answer".to_string(),
10834                        meta: None,
10835                    }],
10836                    stop_reason: StopReason::EndTurn,
10837                    identity: crate::types::TranscriptMessageIdentity::default(),
10838                    created_at: crate::types::message_timestamp_now(),
10839                })],
10840                TranscriptRewriteReason::new("compaction"),
10841                Some("unit-test".to_string()),
10842                Some(parent),
10843            )
10844            .expect("rewrite should commit");
10845
10846        session.push(Message::User(UserMessage::text(
10847            "notice-bearing turn".to_string(),
10848        )));
10849        let retained = session
10850            .messages()
10851            .iter()
10852            .filter(|message| {
10853                !matches!(
10854                    message,
10855                    Message::User(user)
10856                        if user.content.iter().any(|block| matches!(
10857                            block,
10858                            ContentBlock::Text { text } if text.contains("notice-bearing")
10859                        ))
10860                )
10861            })
10862            .cloned()
10863            .collect();
10864        session
10865            .replace_messages_internal(
10866                retained,
10867                TranscriptRewriteReason::new("synthetic_notice_cleanup"),
10868            )
10869            .expect("retain should commit internal rewrite");
10870        let retained_digest =
10871            transcript_messages_digest(session.messages()).expect("retained digest");
10872        assert_eq!(
10873            session.transcript_revision().expect("retained head"),
10874            retained_digest
10875        );
10876
10877        session
10878            .replace_messages_internal(
10879                vec![
10880                    Message::User(UserMessage::text("compacted question".to_string())),
10881                    Message::BlockAssistant(BlockAssistantMessage {
10882                        blocks: vec![AssistantBlock::Text {
10883                            text: "compacted answer".to_string(),
10884                            meta: None,
10885                        }],
10886                        stop_reason: StopReason::EndTurn,
10887                        identity: crate::types::TranscriptMessageIdentity::default(),
10888                        created_at: crate::types::message_timestamp_now(),
10889                    }),
10890                ],
10891                TranscriptRewriteReason::new("compaction"),
10892            )
10893            .expect("replace should commit internal rewrite");
10894        let replaced_digest =
10895            transcript_messages_digest(session.messages()).expect("replaced digest");
10896        assert_eq!(
10897            session.transcript_revision().expect("replaced head"),
10898            replaced_digest
10899        );
10900        let state = session
10901            .transcript_history_state()
10902            .expect("history state should decode")
10903            .expect("history state should exist");
10904        assert!(
10905            state
10906                .revisions
10907                .iter()
10908                .any(|body| body.revision == replaced_digest)
10909        );
10910        validate_transcript_history_state(&state).expect("history state remains valid");
10911    }
10912
10913    #[test]
10914    fn set_system_prompt_refreshes_transcript_history_head_after_rewrite() {
10915        let mut session = Session::new();
10916        session.push(Message::User(UserMessage::text("question".to_string())));
10917        session.push(Message::BlockAssistant(BlockAssistantMessage {
10918            blocks: vec![AssistantBlock::Text {
10919                text: "verbose answer".to_string(),
10920                meta: None,
10921            }],
10922            stop_reason: StopReason::EndTurn,
10923            identity: crate::types::TranscriptMessageIdentity::default(),
10924            created_at: crate::types::message_timestamp_now(),
10925        }));
10926
10927        let parent = session.transcript_revision().expect("parent revision");
10928        let rewrite = session
10929            .commit_transcript_rewrite(
10930                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
10931                vec![Message::BlockAssistant(BlockAssistantMessage {
10932                    blocks: vec![AssistantBlock::Text {
10933                        text: "compact answer".to_string(),
10934                        meta: None,
10935                    }],
10936                    stop_reason: StopReason::EndTurn,
10937                    identity: crate::types::TranscriptMessageIdentity::default(),
10938                    created_at: crate::types::message_timestamp_now(),
10939                })],
10940                TranscriptRewriteReason::new("compaction"),
10941                Some("unit-test".to_string()),
10942                Some(parent),
10943            )
10944            .expect("rewrite should commit");
10945
10946        session.set_system_prompt("durable system prompt".to_string());
10947
10948        let head = session
10949            .transcript_revision()
10950            .expect("system prompt should refresh transcript head");
10951        assert_ne!(head, rewrite.revision);
10952        assert_eq!(
10953            head,
10954            transcript_messages_digest(session.messages()).expect("current digest")
10955        );
10956        let head_messages = session
10957            .transcript_revision_messages(&head)
10958            .expect("history state should decode")
10959            .expect("refreshed head body should be retained");
10960        assert_eq!(
10961            serde_json::to_value(&head_messages).expect("head serializes"),
10962            serde_json::to_value(session.messages()).expect("session serializes")
10963        );
10964        validate_transcript_history_state(
10965            &session
10966                .transcript_history_state()
10967                .expect("history state should decode")
10968                .expect("history state should exist"),
10969        )
10970        .expect("history state remains valid after system prompt update");
10971    }
10972
10973    #[test]
10974    fn apply_transcript_history_state_uses_latest_commit_time_for_restored_head() {
10975        let mut session = Session::new();
10976        session.push(Message::User(UserMessage::text("question".to_string())));
10977        session.push(Message::BlockAssistant(BlockAssistantMessage {
10978            blocks: vec![AssistantBlock::Text {
10979                text: "verbose answer".to_string(),
10980                meta: None,
10981            }],
10982            stop_reason: StopReason::EndTurn,
10983            identity: crate::types::TranscriptMessageIdentity::default(),
10984            created_at: crate::types::message_timestamp_now(),
10985        }));
10986        let original_messages = session.messages().to_vec();
10987        let parent = session.transcript_revision().expect("parent revision");
10988        let compact = session
10989            .commit_transcript_rewrite(
10990                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
10991                vec![Message::BlockAssistant(BlockAssistantMessage {
10992                    blocks: vec![AssistantBlock::Text {
10993                        text: "compact answer".to_string(),
10994                        meta: None,
10995                    }],
10996                    stop_reason: StopReason::EndTurn,
10997                    identity: crate::types::TranscriptMessageIdentity::default(),
10998                    created_at: crate::types::message_timestamp_now(),
10999                })],
11000                TranscriptRewriteReason::new("compaction"),
11001                Some("unit-test".to_string()),
11002                Some(parent.clone()),
11003            )
11004            .expect("rewrite should commit");
11005
11006        std::thread::sleep(std::time::Duration::from_millis(2));
11007        let restore = session
11008            .commit_transcript_rewrite(
11009                TranscriptRewriteSelection::MessageRange {
11010                    start: 0,
11011                    end: session.messages().len(),
11012                },
11013                original_messages.clone(),
11014                TranscriptRewriteReason::new("restore"),
11015                Some("unit-test".to_string()),
11016                Some(compact.revision),
11017            )
11018            .expect("restore should commit");
11019        assert_eq!(restore.revision, parent);
11020
11021        let state = session
11022            .transcript_history_state()
11023            .expect("history state should decode")
11024            .expect("history state should exist");
11025        let restored_body_created_at = state
11026            .revisions
11027            .iter()
11028            .find(|body| body.revision == restore.revision)
11029            .expect("restored body should be retained")
11030            .created_at;
11031        assert!(
11032            restored_body_created_at < restore.committed_at,
11033            "test requires restore commit to be newer than retained body"
11034        );
11035
11036        let mut replayed = Session::new();
11037        replayed
11038            .apply_transcript_history_state(state)
11039            .expect("replay should materialize restored head");
11040        assert_eq!(
11041            serde_json::to_value(replayed.messages()).expect("replayed serializes"),
11042            serde_json::to_value(&original_messages).expect("original serializes")
11043        );
11044        assert_eq!(replayed.updated_at(), restore.committed_at);
11045    }
11046
11047    #[test]
11048    fn test_session_new() {
11049        let session = Session::new();
11050        assert_eq!(session.version(), SESSION_VERSION);
11051        assert!(session.messages().is_empty());
11052        assert!(session.created_at() <= session.updated_at());
11053    }
11054
11055    #[test]
11056    fn llm_identity_model_override_switches_to_catalog_provider() {
11057        let registry = crate::ModelRegistry::from_config(
11058            &crate::Config::default(),
11059            *crate::model_profile::test_catalog::TEST_CATALOG,
11060        )
11061        .unwrap();
11062        let current = SessionLlmIdentity {
11063            model: "test-anthropic-default".to_string(),
11064            provider: Provider::Anthropic,
11065            self_hosted_server_id: None,
11066            provider_params: None,
11067            auth_binding: Some(crate::AuthBindingRef {
11068                realm: crate::RealmId::parse("tenant_a").unwrap(),
11069                binding: crate::BindingId::parse("anthropic_default").unwrap(),
11070                profile: None,
11071                origin: crate::BindingOrigin::Configured,
11072            }),
11073        };
11074
11075        let resolved = resolve_session_llm_identity_override(
11076            &current,
11077            &registry,
11078            SessionLlmIdentityOverride {
11079                model: Some("test-openai-default"),
11080                provider: None,
11081                self_hosted_server_id: None,
11082                provider_params: None,
11083                auth_binding: None,
11084            },
11085        )
11086        .unwrap();
11087
11088        assert_eq!(resolved.model, "test-openai-default");
11089        assert_eq!(resolved.provider, Provider::OpenAI);
11090        assert!(
11091            resolved.auth_binding.is_none(),
11092            "provider switches must not inherit a binding from the previous provider"
11093        );
11094    }
11095
11096    #[test]
11097    fn llm_identity_model_override_keeps_uncatalogued_model_on_current_provider() {
11098        let registry = crate::ModelRegistry::from_config(
11099            &crate::Config::default(),
11100            *crate::model_profile::test_catalog::TEST_CATALOG,
11101        )
11102        .unwrap();
11103        let current = SessionLlmIdentity {
11104            model: "custom-model".to_string(),
11105            provider: Provider::Anthropic,
11106            self_hosted_server_id: None,
11107            provider_params: None,
11108            auth_binding: None,
11109        };
11110
11111        let resolved = resolve_session_llm_identity_override(
11112            &current,
11113            &registry,
11114            SessionLlmIdentityOverride {
11115                model: Some("uncatalogued-custom-model"),
11116                provider: None,
11117                self_hosted_server_id: None,
11118                provider_params: None,
11119                auth_binding: None,
11120            },
11121        )
11122        .unwrap();
11123
11124        assert_eq!(resolved.model, "uncatalogued-custom-model");
11125        assert_eq!(resolved.provider, Provider::Anthropic);
11126    }
11127
11128    fn self_hosted_registry_with_shared_remote_model() -> crate::ModelRegistry {
11129        use crate::config::{
11130            SelfHostedApiStyle, SelfHostedModelConfig, SelfHostedServerConfig, SelfHostedTransport,
11131        };
11132        use crate::model_profile::catalog::ModelTier;
11133
11134        let mut config = crate::Config::default();
11135        for server_id in ["local-a", "local-b"] {
11136            config.self_hosted.servers.insert(
11137                server_id.to_string(),
11138                SelfHostedServerConfig {
11139                    transport: SelfHostedTransport::OpenAiCompatible,
11140                    base_url: format!("http://{server_id}.test"),
11141                    api_style: SelfHostedApiStyle::Responses,
11142                },
11143            );
11144            config.self_hosted.models.insert(
11145                format!("shared-local-{server_id}"),
11146                SelfHostedModelConfig {
11147                    server: server_id.to_string(),
11148                    remote_model: "shared-local-model".to_string(),
11149                    display_name: "Shared local model".to_string(),
11150                    family: "shared-local".to_string(),
11151                    tier: ModelTier::Supported,
11152                    ..Default::default()
11153                },
11154            );
11155        }
11156        config.self_hosted.default_model = Some("shared-local-local-a".to_string());
11157        crate::ModelRegistry::from_config(
11158            &config,
11159            *crate::model_profile::test_catalog::TEST_CATALOG,
11160        )
11161        .expect("shared local registry")
11162    }
11163
11164    #[test]
11165    fn llm_identity_override_preserves_exact_self_hosted_server_route() {
11166        let registry = self_hosted_registry_with_shared_remote_model();
11167        let current = SessionLlmIdentity {
11168            model: "shared-local-local-a".to_string(),
11169            provider: Provider::SelfHosted,
11170            self_hosted_server_id: Some("local-a".to_string()),
11171            provider_params: None,
11172            auth_binding: None,
11173        };
11174
11175        let resolved = resolve_session_llm_identity_override(
11176            &current,
11177            &registry,
11178            SessionLlmIdentityOverride {
11179                model: Some("shared-local-local-b"),
11180                provider: Some(Provider::SelfHosted),
11181                self_hosted_server_id: Some("local-b"),
11182                provider_params: None,
11183                auth_binding: None,
11184            },
11185        )
11186        .expect("exact configured local route should resolve");
11187
11188        assert_eq!(resolved.model, "shared-local-local-b");
11189        assert_eq!(resolved.provider, Provider::SelfHosted);
11190        assert_eq!(resolved.self_hosted_server_id.as_deref(), Some("local-b"));
11191    }
11192
11193    #[test]
11194    fn llm_identity_override_rejects_self_hosted_server_model_mismatch() {
11195        let registry = self_hosted_registry_with_shared_remote_model();
11196        let current = SessionLlmIdentity {
11197            model: "shared-local-local-a".to_string(),
11198            provider: Provider::SelfHosted,
11199            self_hosted_server_id: Some("local-a".to_string()),
11200            provider_params: None,
11201            auth_binding: None,
11202        };
11203
11204        let error = resolve_session_llm_identity_override(
11205            &current,
11206            &registry,
11207            SessionLlmIdentityOverride {
11208                model: Some("shared-local-local-b"),
11209                provider: Some(Provider::SelfHosted),
11210                self_hosted_server_id: Some("local-a"),
11211                provider_params: None,
11212                auth_binding: None,
11213            },
11214        )
11215        .expect_err("server id must match the requested model alias route");
11216
11217        assert!(matches!(
11218            error,
11219            SessionLlmIdentityOverrideError::SelfHostedServerMismatch {
11220                requested,
11221                configured,
11222                ..
11223            } if requested == "local-a" && configured == "local-b"
11224        ));
11225    }
11226
11227    #[test]
11228    fn realtime_transcript_append_is_idempotent_by_provider_item_and_delta_id() {
11229        let mut session = Session::new();
11230
11231        let user = RealtimeTranscriptEvent::UserTranscriptFinal {
11232            item_id: "item_user".to_string(),
11233            previous_item_id: None,
11234            content_index: 0,
11235            text: "hello".to_string(),
11236        };
11237        assert!(
11238            !session
11239                .append_realtime_transcript_event(user.clone())
11240                .is_inert()
11241        );
11242        assert!(session.append_realtime_transcript_event(user).is_inert());
11243
11244        let delta = RealtimeTranscriptEvent::AssistantTextDelta {
11245            response_id: "resp_assistant".to_string(),
11246            delta_id: "evt_delta_1".to_string(),
11247            item_id: "item_assistant".to_string(),
11248            previous_item_id: Some("item_user".to_string()),
11249            content_index: 0,
11250            delta: "hi".to_string(),
11251        };
11252        assert!(
11253            session
11254                .append_realtime_transcript_event(delta.clone())
11255                .is_inert()
11256        );
11257        assert!(session.append_realtime_transcript_event(delta).is_inert());
11258
11259        let terminal = RealtimeTranscriptEvent::AssistantTurnCompleted {
11260            response_id: "resp_assistant".to_string(),
11261            stop_reason: StopReason::EndTurn,
11262            usage: Usage::default(),
11263        };
11264        assert!(
11265            !session
11266                .append_realtime_transcript_event(terminal.clone())
11267                .is_inert()
11268        );
11269        assert!(
11270            session
11271                .append_realtime_transcript_event(terminal)
11272                .is_inert()
11273        );
11274
11275        assert_eq!(session.messages().len(), 2);
11276        assert!(matches!(
11277            &session.messages()[0],
11278            Message::User(user) if user.text_content() == "hello"
11279        ));
11280        assert!(matches!(
11281            &session.messages()[1],
11282            Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "hi"
11283        ));
11284    }
11285
11286    #[test]
11287    fn realtime_user_image_materializes_once_and_unblocks_causal_assistant() {
11288        let mut session = Session::new();
11289        let image_data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB".to_string();
11290        let image = RealtimeTranscriptEvent::UserContentFinal {
11291            idempotency_key: "image-request-1".to_string(),
11292            item_id: "item_image".to_string(),
11293            previous_item_id: None,
11294            content_index: 0,
11295            content: vec![ContentBlock::Image {
11296                media_type: "image/png".to_string(),
11297                data: crate::types::ImageData::Inline {
11298                    data: image_data.clone(),
11299                },
11300            }],
11301        };
11302
11303        assert!(
11304            !append_staged_user_image(&mut session, &image).is_inert(),
11305            "first image final must materialize canonical user content"
11306        );
11307        let replay = session
11308            .preflight_realtime_user_content_event(&image)
11309            .expect("exact retry should preflight as committed");
11310        assert!(matches!(
11311            replay,
11312            crate::RealtimeUserContentApplyOutcome::AlreadyCommitted(_)
11313        ));
11314
11315        let staged_state = session
11316            .metadata
11317            .get(SESSION_REALTIME_TRANSCRIPT_STATE_KEY)
11318            .expect("realtime state must be persisted");
11319        assert!(
11320            !staged_state.to_string().contains(&image_data),
11321            "materialized image bytes must not remain duplicated in transcript metadata"
11322        );
11323
11324        assert!(
11325            session
11326                .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
11327                    response_id: "resp_image".to_string(),
11328                    delta_id: "delta_image".to_string(),
11329                    item_id: "item_assistant".to_string(),
11330                    previous_item_id: Some("item_image".to_string()),
11331                    content_index: 0,
11332                    delta: "I see red.".to_string(),
11333                })
11334                .is_inert()
11335        );
11336        assert!(
11337            !session
11338                .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
11339                    response_id: "resp_image".to_string(),
11340                    stop_reason: StopReason::EndTurn,
11341                    usage: Usage::default(),
11342                },)
11343                .is_inert(),
11344            "materialized image predecessor must unblock the assistant response"
11345        );
11346
11347        assert_eq!(session.messages().len(), 2);
11348        assert!(matches!(
11349            &session.messages()[0],
11350            Message::User(user)
11351                if matches!(
11352                    user.content.as_slice(),
11353                    [ContentBlock::Image {
11354                        media_type,
11355                        data: crate::types::ImageData::Blob { blob_id },
11356                    }] if media_type == "image/png"
11357                        && blob_id == &crate::blob::content_blob_id("image/png", &image_data)
11358                )
11359        ));
11360        assert!(matches!(
11361            &session.messages()[1],
11362            Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "I see red."
11363        ));
11364    }
11365
11366    #[test]
11367    fn realtime_user_image_identity_is_durable_canonical_and_conflict_safe() {
11368        let mut session = Session::new();
11369        let data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB".to_string();
11370        let initial = RealtimeTranscriptEvent::UserContentFinal {
11371            idempotency_key: "stable-image-key".to_string(),
11372            item_id: "canonical-image-item".to_string(),
11373            previous_item_id: None,
11374            content_index: 0,
11375            content: vec![ContentBlock::Image {
11376                media_type: " image/PNG; charset=binary ".to_string(),
11377                data: crate::types::ImageData::Inline { data: data.clone() },
11378            }],
11379        };
11380        let committed = append_staged_user_image(&mut session, &initial);
11381        let Some(crate::RealtimeUserContentApplyOutcome::Committed(identity)) =
11382            committed.user_content
11383        else {
11384            panic!("first image must commit its durable identity");
11385        };
11386        assert_eq!(identity.item_id, "canonical-image-item");
11387        assert_eq!(identity.media_type, "image/png");
11388
11389        let encoded = serde_json::to_string(&session).expect("session should serialize");
11390        let restored: Session =
11391            serde_json::from_str(&encoded).expect("committed identity should restore");
11392
11393        let replay_event = RealtimeTranscriptEvent::UserContentFinal {
11394            idempotency_key: "stable-image-key".to_string(),
11395            item_id: "ignored-retry-item".to_string(),
11396            previous_item_id: None,
11397            content_index: 0,
11398            content: vec![ContentBlock::Image {
11399                media_type: "image/png".to_string(),
11400                data: crate::types::ImageData::Inline { data: data.clone() },
11401            }],
11402        };
11403        let replay = restored
11404            .preflight_realtime_user_content_event(&replay_event)
11405            .expect("exact retry should preflight");
11406        assert!(matches!(
11407            replay,
11408            crate::RealtimeUserContentApplyOutcome::AlreadyCommitted(
11409                crate::RealtimeUserContentIdentity { ref item_id, .. }
11410            ) if item_id == "canonical-image-item"
11411        ));
11412
11413        let conflict = restored
11414            .preflight_realtime_user_content_event(&RealtimeTranscriptEvent::UserContentFinal {
11415                idempotency_key: "stable-image-key".to_string(),
11416                item_id: "conflicting-item".to_string(),
11417                previous_item_id: None,
11418                content_index: 0,
11419                content: vec![ContentBlock::Image {
11420                    media_type: "image/png".to_string(),
11421                    data: crate::types::ImageData::Inline {
11422                        data: "different-payload".to_string(),
11423                    },
11424                }],
11425            })
11426            .expect("conflicting retry should preflight");
11427        assert!(matches!(
11428            conflict,
11429            crate::RealtimeUserContentApplyOutcome::RejectedConflict { .. }
11430        ));
11431
11432        let item_collision = restored
11433            .preflight_realtime_user_content_event(&RealtimeTranscriptEvent::UserContentFinal {
11434                idempotency_key: "another-key".to_string(),
11435                item_id: "canonical-image-item".to_string(),
11436                previous_item_id: None,
11437                content_index: 0,
11438                content: vec![ContentBlock::Image {
11439                    media_type: "image/png".to_string(),
11440                    data: crate::types::ImageData::Inline { data },
11441                }],
11442            })
11443            .expect("item collision should preflight");
11444        assert!(matches!(
11445            item_collision,
11446            crate::RealtimeUserContentApplyOutcome::RejectedConflict { .. }
11447        ));
11448        assert_eq!(restored.messages().len(), 1);
11449        serde_json::to_string(&restored).expect("rejections must not corrupt durable state");
11450    }
11451
11452    #[test]
11453    fn realtime_user_image_reducer_never_receipts_without_pending_blob_proof() {
11454        for data in [
11455            crate::types::ImageData::Inline {
11456                data: "iVBORw0KGgo=".to_string(),
11457            },
11458            crate::types::ImageData::Blob {
11459                blob_id: crate::blob::content_blob_id("image/png", "iVBORw0KGgo="),
11460            },
11461        ] {
11462            let mut session = Session::new();
11463            let outcome = session.append_realtime_transcript_event(
11464                RealtimeTranscriptEvent::UserContentFinal {
11465                    idempotency_key: "unstaged-image-key".to_string(),
11466                    item_id: "unstaged-image-item".to_string(),
11467                    previous_item_id: None,
11468                    content_index: 0,
11469                    content: vec![ContentBlock::Image {
11470                        media_type: "image/png".to_string(),
11471                        data,
11472                    }],
11473                },
11474            );
11475            assert!(matches!(
11476                outcome.user_content,
11477                Some(crate::RealtimeUserContentApplyOutcome::RejectedInvalidIdentity { .. })
11478            ));
11479            assert!(session.messages().is_empty());
11480            assert!(session.realtime_user_content_identities().is_empty());
11481        }
11482    }
11483
11484    #[test]
11485    fn realtime_user_image_pending_slot_is_generated_bounded_and_recovery_typed() {
11486        use crate::generated::session_document::{
11487            RealtimeUserContentBlobRecoveryDisposition, RealtimeUserContentBlobStageDisposition,
11488        };
11489        let mut session = Session::new();
11490        let pending = crate::PendingRealtimeUserContentBlob {
11491            idempotency_key: "pending-key-a".to_string(),
11492            item_id: "pending-item-a".to_string(),
11493            previous_item_id: None,
11494            content_index: 0,
11495            blob_id: crate::blob::content_blob_id("image/png", "iVBORw0KGgo="),
11496            media_type: "image/png".to_string(),
11497        };
11498        let different = crate::PendingRealtimeUserContentBlob {
11499            idempotency_key: "pending-key-b".to_string(),
11500            item_id: "pending-item-b".to_string(),
11501            previous_item_id: None,
11502            content_index: 0,
11503            blob_id: crate::blob::content_blob_id("image/png", "iVBORw0KGgoB"),
11504            media_type: "image/png".to_string(),
11505        };
11506        assert_eq!(
11507            session
11508                .stage_pending_realtime_user_content_blob(pending.clone())
11509                .expect("empty slot stages"),
11510            RealtimeUserContentBlobStageDisposition::StageNew
11511        );
11512        assert_eq!(
11513            session
11514                .stage_pending_realtime_user_content_blob(pending.clone())
11515                .expect("exact stage retry is idempotent"),
11516            RealtimeUserContentBlobStageDisposition::ReuseExact
11517        );
11518        assert_eq!(
11519            session
11520                .stage_pending_realtime_user_content_blob(different.clone())
11521                .expect("occupied decision is typed"),
11522            RealtimeUserContentBlobStageDisposition::RejectOccupied
11523        );
11524        assert_eq!(
11525            session.pending_realtime_user_content_blob(),
11526            Some(pending.clone())
11527        );
11528        assert_eq!(
11529            session
11530                .resolve_pending_realtime_user_content_blob_recovery(Some(&pending), false)
11531                .expect("exact recovery decision"),
11532            RealtimeUserContentBlobRecoveryDisposition::RetryExact
11533        );
11534        assert_eq!(
11535            session
11536                .resolve_pending_realtime_user_content_blob_recovery(Some(&different), true)
11537                .expect("verified older recovery decision"),
11538            RealtimeUserContentBlobRecoveryDisposition::CommitVerifiedBeforeCurrent
11539        );
11540        assert_eq!(
11541            session
11542                .resolve_pending_realtime_user_content_blob_recovery(Some(&different), false)
11543                .expect("invalid older recovery decision"),
11544            RealtimeUserContentBlobRecoveryDisposition::ClearInvalidBeforeCurrent
11545        );
11546        session
11547            .clear_invalid_pending_realtime_user_content_blob(Some(&different))
11548            .expect("generated clear-invalid disposition authorizes clear");
11549        assert!(session.pending_realtime_user_content_blob().is_none());
11550    }
11551
11552    #[test]
11553    fn transcript_rewrite_tombstones_removed_image_key_and_accepts_new_key() {
11554        let mut session = Session::new();
11555        let data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB".to_string();
11556        let original = RealtimeTranscriptEvent::UserContentFinal {
11557            idempotency_key: "removed-image-key".to_string(),
11558            item_id: "removed-image-item".to_string(),
11559            previous_item_id: None,
11560            content_index: 0,
11561            content: vec![ContentBlock::Image {
11562                media_type: "image/png".to_string(),
11563                data: crate::types::ImageData::Inline { data: data.clone() },
11564            }],
11565        };
11566        assert!(matches!(
11567            append_staged_user_image(&mut session, &original).user_content,
11568            Some(crate::RealtimeUserContentApplyOutcome::Committed(_))
11569        ));
11570
11571        let parent = session.transcript_revision().expect("parent revision");
11572        session
11573            .commit_transcript_rewrite(
11574                TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
11575                vec![Message::User(UserMessage::text("image removed"))],
11576                TranscriptRewriteReason::new("remove-image"),
11577                None,
11578                Some(parent),
11579            )
11580            .expect("rewrite should tombstone removed image identity");
11581
11582        assert!(session.realtime_user_content_identities().is_empty());
11583        assert_eq!(
11584            session.realtime_user_content_tombstones(),
11585            vec![crate::RealtimeUserContentTombstone {
11586                idempotency_key: "removed-image-key".to_string(),
11587            }]
11588        );
11589        assert!(matches!(
11590            session.preflight_realtime_user_content_event(&original),
11591            Some(crate::RealtimeUserContentApplyOutcome::RejectedConflict { .. })
11592        ));
11593        assert!(matches!(
11594            session
11595                .append_realtime_transcript_event(original)
11596                .user_content,
11597            Some(crate::RealtimeUserContentApplyOutcome::RejectedConflict { .. })
11598        ));
11599        assert_eq!(
11600            session.messages().len(),
11601            1,
11602            "stale retry emits no receipt content"
11603        );
11604
11605        let new_image = RealtimeTranscriptEvent::UserContentFinal {
11606            idempotency_key: "new-image-key".to_string(),
11607            item_id: "new-image-item".to_string(),
11608            previous_item_id: None,
11609            content_index: 0,
11610            content: vec![ContentBlock::Image {
11611                media_type: "image/png".to_string(),
11612                data: crate::types::ImageData::Inline { data },
11613            }],
11614        };
11615        assert!(matches!(
11616            append_staged_user_image(&mut session, &new_image).user_content,
11617            Some(crate::RealtimeUserContentApplyOutcome::Committed(_))
11618        ));
11619        assert_eq!(session.messages().len(), 2);
11620
11621        let restored: Session = serde_json::from_str(
11622            &serde_json::to_string(&session).expect("serialize rewritten session"),
11623        )
11624        .expect("cold restore rewritten session");
11625        assert_eq!(restored.realtime_user_content_identities().len(), 1);
11626        assert_eq!(restored.realtime_user_content_tombstones().len(), 1);
11627    }
11628
11629    #[test]
11630    fn transcript_rewrite_retains_only_canonical_image_occurrence_for_exact_replay() {
11631        let mut session = Session::new();
11632        let data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB".to_string();
11633        let original = RealtimeTranscriptEvent::UserContentFinal {
11634            idempotency_key: "retained-image-key".to_string(),
11635            item_id: "retained-image-item".to_string(),
11636            previous_item_id: None,
11637            content_index: 0,
11638            content: vec![ContentBlock::Image {
11639                media_type: "image/png".to_string(),
11640                data: crate::types::ImageData::Inline { data },
11641            }],
11642        };
11643        assert!(matches!(
11644            append_staged_user_image(&mut session, &original).user_content,
11645            Some(crate::RealtimeUserContentApplyOutcome::Committed(_))
11646        ));
11647        let retained_message = session.messages()[0].clone();
11648        let parent = session.transcript_revision().expect("parent revision");
11649        session
11650            .commit_transcript_rewrite(
11651                TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
11652                vec![
11653                    retained_message,
11654                    Message::User(UserMessage::text("new canonical neighbor")),
11655                ],
11656                TranscriptRewriteReason::new("retain-image"),
11657                None,
11658                Some(parent),
11659            )
11660            .expect("rewrite retaining exact inline image should reconcile");
11661
11662        assert!(session.realtime_user_content_tombstones().is_empty());
11663        let replay = session
11664            .preflight_realtime_user_content_event(&original)
11665            .expect("retained image should preflight as exact replay");
11666        assert!(matches!(
11667            replay,
11668            crate::RealtimeUserContentApplyOutcome::AlreadyCommitted(_)
11669        ));
11670        assert_eq!(session.messages().len(), 2);
11671    }
11672
11673    #[test]
11674    fn transcript_rewrite_rejects_atomically_while_image_blob_anchor_is_pending() {
11675        let mut session = Session::new();
11676        session.push(Message::User(UserMessage::text("before rewrite")));
11677        let pending = crate::PendingRealtimeUserContentBlob {
11678            idempotency_key: "pending-rewrite-key".to_string(),
11679            item_id: "pending-rewrite-item".to_string(),
11680            previous_item_id: None,
11681            content_index: 0,
11682            blob_id: crate::blob::content_blob_id("image/png", "pending-bytes"),
11683            media_type: "image/png".to_string(),
11684        };
11685        session
11686            .stage_pending_realtime_user_content_blob(pending.clone())
11687            .expect("stage durable pending anchor");
11688        let parent = session.transcript_revision().expect("parent revision");
11689        let error = session
11690            .commit_transcript_rewrite(
11691                TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
11692                vec![Message::User(UserMessage::text("after rewrite"))],
11693                TranscriptRewriteReason::new("blocked-pending-image"),
11694                None,
11695                Some(parent),
11696            )
11697            .expect_err("rewrite must not cross an unresolved image anchor");
11698        assert!(
11699            error
11700                .to_string()
11701                .contains("history_rewrite_pending_user_content_blob")
11702        );
11703        assert!(matches!(
11704            &session.messages()[0],
11705            Message::User(user) if user.text_content() == "before rewrite"
11706        ));
11707        assert_eq!(session.pending_realtime_user_content_blob(), Some(pending));
11708    }
11709
11710    #[test]
11711    fn realtime_user_image_rejects_noncanonical_blob_and_multiblock_shape() {
11712        let mut session = Session::new();
11713        for (key, content) in [
11714            (
11715                "invalid-blob",
11716                vec![ContentBlock::Image {
11717                    media_type: "image/png".to_string(),
11718                    data: crate::types::ImageData::Blob {
11719                        blob_id: crate::BlobId::new("sha256:not-a-digest"),
11720                    },
11721                }],
11722            ),
11723            (
11724                "multi-block",
11725                vec![
11726                    ContentBlock::Image {
11727                        media_type: "image/png".to_string(),
11728                        data: crate::types::ImageData::Inline {
11729                            data: "payload".to_string(),
11730                        },
11731                    },
11732                    ContentBlock::Text {
11733                        text: "smuggled".to_string(),
11734                    },
11735                ],
11736            ),
11737        ] {
11738            let outcome = session.append_realtime_transcript_event(
11739                RealtimeTranscriptEvent::UserContentFinal {
11740                    idempotency_key: key.to_string(),
11741                    item_id: format!("item-{key}"),
11742                    previous_item_id: None,
11743                    content_index: 0,
11744                    content,
11745                },
11746            );
11747            assert!(matches!(
11748                outcome.user_content,
11749                Some(crate::RealtimeUserContentApplyOutcome::RejectedInvalidIdentity { .. })
11750            ));
11751        }
11752        assert!(session.messages().is_empty());
11753        let encoded = serde_json::to_string(&session).expect("session should serialize");
11754        serde_json::from_str::<Session>(&encoded).expect("rejections must leave restorable state");
11755    }
11756
11757    #[test]
11758    fn realtime_restore_rejects_malformed_causal_graphs_and_accepts_waiting_dag() {
11759        fn restore(
11760            items: serde_json::Value,
11761            first_seen_order: Vec<&str>,
11762        ) -> Result<
11763            crate::realtime_transcript_revision::SessionRealtimeTranscriptState,
11764            crate::realtime_transcript_revision::RealtimeTranscriptShellError,
11765        > {
11766            let state = serde_json::from_value(serde_json::json!({
11767                "items": items,
11768                "first_seen_order": first_seen_order,
11769            }))
11770            .expect("test state shape should deserialize");
11771            crate::realtime_transcript_revision::restore_realtime_transcript_state(state)
11772        }
11773
11774        assert!(
11775            restore(
11776                serde_json::json!({
11777                    "child": { "role": "user", "previous_item_id": "missing" }
11778                }),
11779                vec!["child"],
11780            )
11781            .is_ok(),
11782            "an unmaterialized out-of-order item must survive cold restore until its predecessor arrives"
11783        );
11784        assert!(
11785            restore(
11786                serde_json::json!({
11787                    "child": {
11788                        "role": "user",
11789                        "previous_item_id": "missing",
11790                        "ready": true,
11791                        "materialized": true
11792                    }
11793                }),
11794                vec!["child"],
11795            )
11796            .is_err(),
11797            "a materialized item cannot reference a missing predecessor"
11798        );
11799        assert!(
11800            restore(
11801                serde_json::json!({
11802                    "self": { "role": "user", "previous_item_id": "self" }
11803                }),
11804                vec!["self"],
11805            )
11806            .is_err(),
11807            "self edge must fail cold restore"
11808        );
11809        assert!(
11810            restore(
11811                serde_json::json!({
11812                    "a": { "role": "user", "previous_item_id": "b" },
11813                    "b": { "role": "user", "previous_item_id": "a" }
11814                }),
11815                vec!["a", "b"],
11816            )
11817            .is_err(),
11818            "cycle must fail cold restore"
11819        );
11820        assert!(
11821            restore(
11822                serde_json::json!({
11823                    "root": { "role": "user" },
11824                    "materialized_child": {
11825                        "role": "user",
11826                        "previous_item_id": "root",
11827                        "ready": true,
11828                        "materialized": true
11829                    }
11830                }),
11831                vec!["root", "materialized_child"],
11832            )
11833            .is_err(),
11834            "materialized child cannot have unmaterialized ancestry"
11835        );
11836        assert!(
11837            restore(
11838                serde_json::json!({
11839                    "root": { "role": "user" },
11840                    "waiting_child": { "role": "user", "previous_item_id": "root" }
11841                }),
11842                vec!["waiting_child", "root"],
11843            )
11844            .is_ok(),
11845            "valid acyclic waiting graph should restore even when first-seen order is child-first"
11846        );
11847    }
11848
11849    #[test]
11850    fn realtime_restore_handles_long_waiting_chain_with_bounded_graph_walk() {
11851        const ITEM_COUNT: usize = 4_096;
11852        let mut items = serde_json::Map::new();
11853        let mut order = Vec::with_capacity(ITEM_COUNT);
11854        for index in 0..ITEM_COUNT {
11855            let item_id = format!("item-{index:04}");
11856            let value = if index == 0 {
11857                serde_json::json!({ "role": "user" })
11858            } else {
11859                serde_json::json!({
11860                    "role": "user",
11861                    "previous_item_id": format!("item-{:04}", index - 1),
11862                })
11863            };
11864            order.push(item_id.clone());
11865            items.insert(item_id, value);
11866        }
11867        let state = serde_json::from_value(serde_json::json!({
11868            "items": items,
11869            "first_seen_order": order,
11870        }))
11871        .expect("long-chain fixture should deserialize");
11872        crate::realtime_transcript_revision::restore_realtime_transcript_state(state)
11873            .expect("long valid waiting DAG should restore in one bounded graph walk");
11874    }
11875
11876    /// R5-7: `AssistantTranscriptFinalText` injects authoritative final text
11877    /// into the staged item. Verifies the override semantics: a partial
11878    /// delta is replaced, not concatenated, and the item promotes to the
11879    /// Spoken lane so flush emits `AssistantBlock::Transcript`.
11880    #[test]
11881    fn realtime_transcript_final_text_overrides_partial_delta_and_promotes_to_spoken_lane() {
11882        let mut session = Session::new();
11883
11884        // Partial delta accumulates "incom" — simulating delta loss before
11885        // the final arrives.
11886        assert!(
11887            session
11888                .append_realtime_transcript_event(
11889                    RealtimeTranscriptEvent::AssistantTranscriptDelta {
11890                        response_id: "resp_a".to_string(),
11891                        delta_id: "evt_1".to_string(),
11892                        item_id: "item_a".to_string(),
11893                        previous_item_id: None,
11894                        content_index: 0,
11895                        delta: "incom".to_string(),
11896                    }
11897                )
11898                .is_inert()
11899        );
11900
11901        // Authoritative final text overrides the staged content.
11902        assert!(
11903            session
11904                .append_realtime_transcript_event(
11905                    RealtimeTranscriptEvent::AssistantTranscriptFinalText {
11906                        response_id: "resp_a".to_string(),
11907                        item_id: "item_a".to_string(),
11908                        content_index: 0,
11909                        text: "complete answer".to_string(),
11910                    }
11911                )
11912                .is_inert()
11913        );
11914
11915        // Turn completion drives the flush.
11916        let outcome = session.append_realtime_transcript_event(
11917            RealtimeTranscriptEvent::AssistantTurnCompleted {
11918                response_id: "resp_a".to_string(),
11919                stop_reason: StopReason::EndTurn,
11920                usage: Usage::default(),
11921            },
11922        );
11923        assert!(!outcome.is_inert());
11924
11925        // Verify the materialized block has the final's authoritative text
11926        // (not the partial "incom") and the Spoken lane.
11927        assert_eq!(session.messages().len(), 1);
11928        match &session.messages()[0] {
11929            Message::BlockAssistant(assistant) => {
11930                let mut found_transcript = false;
11931                for block in &assistant.blocks {
11932                    if let AssistantBlock::Transcript { text, .. } = block {
11933                        assert_eq!(text, "complete answer");
11934                        found_transcript = true;
11935                    }
11936                }
11937                assert!(
11938                    found_transcript,
11939                    "AssistantTranscriptFinalText must promote to the Spoken lane and \
11940                     materialize as AssistantBlock::Transcript"
11941                );
11942            }
11943            other => unreachable!("expected BlockAssistant, got {other:?}"),
11944        }
11945    }
11946
11947    /// R5-7: `AssistantTranscriptFinalText` works for final-only providers
11948    /// where no prior delta has staged an item.
11949    #[test]
11950    fn realtime_transcript_final_text_creates_item_when_no_delta_staged() {
11951        let mut session = Session::new();
11952
11953        assert!(
11954            session
11955                .append_realtime_transcript_event(
11956                    RealtimeTranscriptEvent::AssistantTranscriptFinalText {
11957                        response_id: "resp_a".to_string(),
11958                        item_id: "item_a".to_string(),
11959                        content_index: 0,
11960                        text: "spoken-final-only".to_string(),
11961                    }
11962                )
11963                .is_inert()
11964        );
11965
11966        let outcome = session.append_realtime_transcript_event(
11967            RealtimeTranscriptEvent::AssistantTurnCompleted {
11968                response_id: "resp_a".to_string(),
11969                stop_reason: StopReason::EndTurn,
11970                usage: Usage::default(),
11971            },
11972        );
11973        assert!(!outcome.is_inert());
11974
11975        assert_eq!(session.messages().len(), 1);
11976        match &session.messages()[0] {
11977            Message::BlockAssistant(assistant) => {
11978                let has_transcript = assistant.blocks.iter().any(|b| {
11979                    matches!(b, AssistantBlock::Transcript { text, .. } if text == "spoken-final-only")
11980                });
11981                assert!(
11982                    has_transcript,
11983                    "final-only provider path must materialize as Transcript on the Spoken lane"
11984                );
11985            }
11986            other => unreachable!("expected BlockAssistant, got {other:?}"),
11987        }
11988    }
11989
11990    #[test]
11991    fn realtime_transcript_append_orders_causally_equivalent_out_of_order_items() {
11992        let mut session = Session::new();
11993
11994        assert!(
11995            session
11996                .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
11997                    response_id: "resp_assistant".to_string(),
11998                    delta_id: "evt_delta_1".to_string(),
11999                    item_id: "item_assistant".to_string(),
12000                    previous_item_id: Some("item_user".to_string()),
12001                    content_index: 0,
12002                    delta: "answer".to_string(),
12003                })
12004                .is_inert()
12005        );
12006        assert!(
12007            session
12008                .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
12009                    response_id: "resp_assistant".to_string(),
12010                    stop_reason: StopReason::EndTurn,
12011                    usage: Usage::default(),
12012                })
12013                .is_inert()
12014        );
12015
12016        let outcome = session.append_realtime_transcript_event(
12017            RealtimeTranscriptEvent::UserTranscriptFinal {
12018                item_id: "item_user".to_string(),
12019                previous_item_id: None,
12020                content_index: 0,
12021                text: "question".to_string(),
12022            },
12023        );
12024
12025        assert_eq!(outcome.materialized_messages.len(), 2);
12026        assert_eq!(session.messages().len(), 2);
12027        assert!(matches!(
12028            &session.messages()[0],
12029            Message::User(user) if user.text_content() == "question"
12030        ));
12031        assert!(matches!(
12032            &session.messages()[1],
12033            Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "answer"
12034        ));
12035    }
12036
12037    #[test]
12038    fn realtime_transcript_replay_of_seen_provider_items_is_inert() {
12039        let mut session = Session::new();
12040        let events = vec![
12041            RealtimeTranscriptEvent::UserTranscriptFinal {
12042                item_id: "item_user".to_string(),
12043                previous_item_id: None,
12044                content_index: 0,
12045                text: "hello".to_string(),
12046            },
12047            RealtimeTranscriptEvent::AssistantTextDelta {
12048                response_id: "resp_assistant".to_string(),
12049                delta_id: "evt_delta_1".to_string(),
12050                item_id: "item_assistant".to_string(),
12051                previous_item_id: Some("item_user".to_string()),
12052                content_index: 0,
12053                delta: "world".to_string(),
12054            },
12055            RealtimeTranscriptEvent::AssistantTurnCompleted {
12056                response_id: "resp_assistant".to_string(),
12057                stop_reason: StopReason::EndTurn,
12058                usage: Usage::default(),
12059            },
12060        ];
12061
12062        for event in events.iter().cloned() {
12063            let _ = session.append_realtime_transcript_event(event);
12064        }
12065        let first_messages = serde_json::to_value(session.messages()).unwrap();
12066
12067        for event in events {
12068            assert!(session.append_realtime_transcript_event(event).is_inert());
12069        }
12070
12071        assert_eq!(
12072            serde_json::to_value(session.messages()).unwrap(),
12073            first_messages
12074        );
12075    }
12076
12077    #[test]
12078    fn realtime_transcript_user_final_replay_cannot_erase_existing_segment() {
12079        let mut session = Session::new();
12080
12081        let user = RealtimeTranscriptEvent::UserTranscriptFinal {
12082            item_id: "item_user".to_string(),
12083            previous_item_id: None,
12084            content_index: 0,
12085            text: "remember amber lantern".to_string(),
12086        };
12087        assert!(
12088            !session
12089                .append_realtime_transcript_event(user.clone())
12090                .is_inert()
12091        );
12092        let first_messages = serde_json::to_value(session.messages()).unwrap();
12093
12094        assert!(
12095            session
12096                .append_realtime_transcript_event(RealtimeTranscriptEvent::UserTranscriptFinal {
12097                    item_id: "item_user".to_string(),
12098                    previous_item_id: None,
12099                    content_index: 0,
12100                    text: String::new(),
12101                })
12102                .is_inert()
12103        );
12104        assert!(session.append_realtime_transcript_event(user).is_inert());
12105        assert_eq!(
12106            serde_json::to_value(session.messages()).unwrap(),
12107            first_messages
12108        );
12109    }
12110
12111    #[test]
12112    fn realtime_transcript_empty_user_final_can_be_filled_by_later_nonempty_replay() {
12113        let mut session = Session::new();
12114
12115        assert!(
12116            session
12117                .append_realtime_transcript_event(RealtimeTranscriptEvent::UserTranscriptFinal {
12118                    item_id: "item_user".to_string(),
12119                    previous_item_id: None,
12120                    content_index: 0,
12121                    text: String::new(),
12122                })
12123                .is_inert()
12124        );
12125        assert!(session.messages().is_empty());
12126
12127        let outcome = session.append_realtime_transcript_event(
12128            RealtimeTranscriptEvent::UserTranscriptFinal {
12129                item_id: "item_user".to_string(),
12130                previous_item_id: None,
12131                content_index: 0,
12132                text: "remember amber lantern".to_string(),
12133            },
12134        );
12135        assert_eq!(outcome.materialized_messages.len(), 1);
12136        assert_eq!(session.messages().len(), 1);
12137        assert!(matches!(
12138            &session.messages()[0],
12139            Message::User(user) if user.text_content() == "remember amber lantern"
12140        ));
12141    }
12142
12143    #[test]
12144    fn realtime_transcript_skipped_provider_items_preserve_causal_order_without_content() {
12145        let mut session = Session::new();
12146
12147        let assistant_delta = RealtimeTranscriptEvent::AssistantTextDelta {
12148            response_id: "resp_assistant".to_string(),
12149            delta_id: "evt_delta_1".to_string(),
12150            item_id: "item_assistant".to_string(),
12151            previous_item_id: Some("item_tool".to_string()),
12152            content_index: 0,
12153            delta: "done".to_string(),
12154        };
12155        assert!(
12156            session
12157                .append_realtime_transcript_event(assistant_delta.clone())
12158                .is_inert()
12159        );
12160        let assistant_complete = RealtimeTranscriptEvent::AssistantTurnCompleted {
12161            response_id: "resp_assistant".to_string(),
12162            stop_reason: StopReason::EndTurn,
12163            usage: Usage::default(),
12164        };
12165        assert!(
12166            session
12167                .append_realtime_transcript_event(assistant_complete.clone())
12168                .is_inert()
12169        );
12170
12171        let skipped = RealtimeTranscriptEvent::ItemSkipped {
12172            item_id: "item_tool".to_string(),
12173            previous_item_id: Some("item_user".to_string()),
12174        };
12175        assert!(
12176            session
12177                .append_realtime_transcript_event(skipped.clone())
12178                .is_inert(),
12179            "a skipped provider item must not append transcript content"
12180        );
12181        assert!(session.messages().is_empty());
12182
12183        let outcome = session.append_realtime_transcript_event(
12184            RealtimeTranscriptEvent::UserTranscriptFinal {
12185                item_id: "item_user".to_string(),
12186                previous_item_id: None,
12187                content_index: 0,
12188                text: "please use the tool".to_string(),
12189            },
12190        );
12191        assert_eq!(outcome.materialized_messages.len(), 2);
12192        assert_eq!(session.messages().len(), 2);
12193        assert!(matches!(
12194            &session.messages()[0],
12195            Message::User(user) if user.text_content() == "please use the tool"
12196        ));
12197        assert!(matches!(
12198            &session.messages()[1],
12199            Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "done"
12200        ));
12201
12202        let first_messages = serde_json::to_value(session.messages()).unwrap();
12203        assert!(session.append_realtime_transcript_event(skipped).is_inert());
12204        assert!(
12205            session
12206                .append_realtime_transcript_event(assistant_delta)
12207                .is_inert()
12208        );
12209        assert!(
12210            session
12211                .append_realtime_transcript_event(assistant_complete)
12212                .is_inert()
12213        );
12214        assert_eq!(
12215            serde_json::to_value(session.messages()).unwrap(),
12216            first_messages
12217        );
12218    }
12219
12220    #[test]
12221    fn realtime_transcript_interrupted_assistant_item_unblocks_later_provider_items() {
12222        // R5-5 (Round-5): the staged assistant content is a Display-lane item
12223        // (`AssistantTextDelta`). Under the new lane-aware barge-in contract,
12224        // the Display lane survives interruption and materializes. The User
12225        // "Stop." item, gated on the chained Display item being materialized,
12226        // also unblocks. Round-4's "must stay non-canonical" assertion was
12227        // wrong — that contract was lane-blind.
12228        let mut session = Session::new();
12229
12230        let _ = session.append_realtime_transcript_event(
12231            RealtimeTranscriptEvent::UserTranscriptFinal {
12232                item_id: "item_repeat".to_string(),
12233                previous_item_id: None,
12234                content_index: 0,
12235                text: "repeat until stop".to_string(),
12236            },
12237        );
12238        assert!(
12239            session
12240                .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
12241                    response_id: "resp_loop".to_string(),
12242                    delta_id: "evt_loop_1".to_string(),
12243                    item_id: "item_loop".to_string(),
12244                    previous_item_id: Some("item_repeat".to_string()),
12245                    content_index: 0,
12246                    delta: "Looping now".to_string(),
12247                })
12248                .is_inert()
12249        );
12250        assert!(
12251            session
12252                .append_realtime_transcript_event(RealtimeTranscriptEvent::UserTranscriptFinal {
12253                    item_id: "item_stop".to_string(),
12254                    previous_item_id: Some("item_loop".to_string()),
12255                    content_index: 0,
12256                    text: "Stop.".to_string(),
12257                })
12258                .is_inert(),
12259            "the stop turn waits until the interrupted assistant provider item is resolved"
12260        );
12261
12262        let outcome = session.append_realtime_transcript_event(
12263            RealtimeTranscriptEvent::AssistantTurnInterrupted {
12264                response_id: "resp_loop".to_string(),
12265            },
12266        );
12267
12268        // R5-5: materializer commits 2 messages (the retained Display item +
12269        // the unblocked "Stop." User message).
12270        assert_eq!(outcome.materialized_messages.len(), 2);
12271        // Canonical history: User-repeat, BlockAssistant(Display "Looping now"), User-Stop.
12272        assert_eq!(session.messages().len(), 3);
12273        assert!(matches!(
12274            &session.messages()[0],
12275            Message::User(user) if user.text_content() == "repeat until stop"
12276        ));
12277        match &session.messages()[1] {
12278            Message::BlockAssistant(assistant) => {
12279                let text = block_assistant_text(assistant);
12280                assert_eq!(text, "Looping now");
12281            }
12282            other => unreachable!(
12283                "Display lane assistant item must be retained on Interrupted, got {other:?}"
12284            ),
12285        }
12286        assert!(matches!(
12287            &session.messages()[2],
12288            Message::User(user) if user.text_content() == "Stop."
12289        ));
12290    }
12291
12292    #[test]
12293    fn realtime_transcript_late_interrupted_assistant_delta_stays_noncanonical() {
12294        let mut session = Session::new();
12295
12296        let _ = session.append_realtime_transcript_event(
12297            RealtimeTranscriptEvent::UserTranscriptFinal {
12298                item_id: "item_repeat".to_string(),
12299                previous_item_id: None,
12300                content_index: 0,
12301                text: "repeat until stop".to_string(),
12302            },
12303        );
12304        assert!(
12305            session
12306                .append_realtime_transcript_event(RealtimeTranscriptEvent::ItemObserved {
12307                    item_id: "item_loop".to_string(),
12308                    previous_item_id: Some("item_repeat".to_string()),
12309                    role: RealtimeTranscriptRole::Assistant,
12310                    response_id: None,
12311                })
12312                .is_inert(),
12313            "provider can observe an assistant item before the adapter learns its response id"
12314        );
12315        assert!(
12316            session
12317                .append_realtime_transcript_event(
12318                    RealtimeTranscriptEvent::AssistantTurnInterrupted {
12319                        response_id: "resp_loop".to_string(),
12320                    }
12321                )
12322                .is_inert(),
12323            "an interruption can arrive before delayed transcript deltas for the response"
12324        );
12325        assert!(
12326            session
12327                .append_realtime_transcript_event(RealtimeTranscriptEvent::UserTranscriptFinal {
12328                    item_id: "item_stop".to_string(),
12329                    previous_item_id: Some("item_loop".to_string()),
12330                    content_index: 0,
12331                    text: "Stop.".to_string(),
12332                })
12333                .is_inert(),
12334            "the stop turn waits for the provider's interrupted assistant item anchor"
12335        );
12336
12337        let late_delta_outcome =
12338            session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
12339                response_id: "resp_loop".to_string(),
12340                delta_id: "evt_loop_late".to_string(),
12341                item_id: "item_loop".to_string(),
12342                previous_item_id: Some("item_repeat".to_string()),
12343                content_index: 0,
12344                delta: "Looping now".to_string(),
12345            });
12346        assert_eq!(late_delta_outcome.materialized_messages.len(), 1);
12347        assert!(matches!(
12348            &session.messages()[1],
12349            Message::User(user) if user.text_content() == "Stop."
12350        ));
12351        assert!(
12352            session
12353                .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
12354                    response_id: "resp_loop".to_string(),
12355                    stop_reason: StopReason::EndTurn,
12356                    usage: Usage::default(),
12357                })
12358                .is_inert(),
12359            "late completion for an interrupted response must not resurrect its deltas"
12360        );
12361        assert!(
12362            session
12363                .messages()
12364                .iter()
12365                .filter_map(|message| match message {
12366                    Message::BlockAssistant(assistant) => Some(block_assistant_text(assistant)),
12367                    _ => None,
12368                })
12369                .all(|text| !text.contains("Looping now")),
12370            "late interrupted assistant text must remain non-canonical"
12371        );
12372    }
12373
12374    #[test]
12375    fn realtime_transcript_completion_only_finalizes_matching_response() {
12376        let mut session = Session::new();
12377
12378        let _ = session.append_realtime_transcript_event(
12379            RealtimeTranscriptEvent::UserTranscriptFinal {
12380                item_id: "item_user".to_string(),
12381                previous_item_id: None,
12382                content_index: 0,
12383                text: "question".to_string(),
12384            },
12385        );
12386        assert!(
12387            session
12388                .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
12389                    response_id: "resp_a".to_string(),
12390                    delta_id: "evt_a".to_string(),
12391                    item_id: "item_a".to_string(),
12392                    previous_item_id: Some("item_user".to_string()),
12393                    content_index: 0,
12394                    delta: "answer a".to_string(),
12395                })
12396                .is_inert()
12397        );
12398
12399        assert!(
12400            session
12401                .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
12402                    response_id: "resp_b".to_string(),
12403                    stop_reason: StopReason::EndTurn,
12404                    usage: Usage::default(),
12405                })
12406                .is_inert(),
12407            "a completion for another response must not finalize buffered assistant text"
12408        );
12409        assert_eq!(session.messages().len(), 1);
12410
12411        let outcome = session.append_realtime_transcript_event(
12412            RealtimeTranscriptEvent::AssistantTurnCompleted {
12413                response_id: "resp_a".to_string(),
12414                stop_reason: StopReason::EndTurn,
12415                usage: Usage::default(),
12416            },
12417        );
12418        assert_eq!(outcome.materialized_messages.len(), 1);
12419        assert_eq!(session.messages().len(), 2);
12420        assert!(matches!(
12421            &session.messages()[1],
12422            Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "answer a"
12423        ));
12424    }
12425
12426    #[test]
12427    fn realtime_transcript_completion_before_later_delta_is_response_scoped() {
12428        let mut session = Session::new();
12429
12430        let _ = session.append_realtime_transcript_event(
12431            RealtimeTranscriptEvent::UserTranscriptFinal {
12432                item_id: "item_user".to_string(),
12433                previous_item_id: None,
12434                content_index: 0,
12435                text: "question".to_string(),
12436            },
12437        );
12438        assert!(
12439            session
12440                .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
12441                    response_id: "resp_a".to_string(),
12442                    stop_reason: StopReason::EndTurn,
12443                    usage: Usage::default(),
12444                })
12445                .is_inert()
12446        );
12447        assert!(
12448            session
12449                .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
12450                    response_id: "resp_b".to_string(),
12451                    delta_id: "evt_b".to_string(),
12452                    item_id: "item_b".to_string(),
12453                    previous_item_id: Some("item_user".to_string()),
12454                    content_index: 0,
12455                    delta: "wrong response".to_string(),
12456                })
12457                .is_inert(),
12458            "a later delta for another response must not be finalized by resp_a's pending completion"
12459        );
12460
12461        let outcome =
12462            session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
12463                response_id: "resp_a".to_string(),
12464                delta_id: "evt_a".to_string(),
12465                item_id: "item_a".to_string(),
12466                previous_item_id: Some("item_user".to_string()),
12467                content_index: 0,
12468                delta: "right response".to_string(),
12469            });
12470
12471        assert_eq!(outcome.materialized_messages.len(), 1);
12472        assert_eq!(session.messages().len(), 2);
12473        assert!(matches!(
12474            &session.messages()[1],
12475            Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "right response"
12476        ));
12477    }
12478
12479    #[test]
12480    fn realtime_transcript_late_duplicate_completion_cannot_finalize_unrelated_response() {
12481        let mut session = Session::new();
12482
12483        let _ = session.append_realtime_transcript_event(
12484            RealtimeTranscriptEvent::UserTranscriptFinal {
12485                item_id: "item_user".to_string(),
12486                previous_item_id: None,
12487                content_index: 0,
12488                text: "question".to_string(),
12489            },
12490        );
12491        let _ =
12492            session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
12493                response_id: "resp_a".to_string(),
12494                delta_id: "evt_a".to_string(),
12495                item_id: "item_a".to_string(),
12496                previous_item_id: Some("item_user".to_string()),
12497                content_index: 0,
12498                delta: "first".to_string(),
12499            });
12500        let _ = session.append_realtime_transcript_event(
12501            RealtimeTranscriptEvent::AssistantTurnCompleted {
12502                response_id: "resp_a".to_string(),
12503                stop_reason: StopReason::EndTurn,
12504                usage: Usage::default(),
12505            },
12506        );
12507        assert_eq!(session.messages().len(), 2);
12508
12509        assert!(
12510            session
12511                .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
12512                    response_id: "resp_b".to_string(),
12513                    delta_id: "evt_b".to_string(),
12514                    item_id: "item_b".to_string(),
12515                    previous_item_id: Some("item_a".to_string()),
12516                    content_index: 0,
12517                    delta: "second".to_string(),
12518                })
12519                .is_inert()
12520        );
12521        assert!(
12522            session
12523                .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
12524                    response_id: "resp_a".to_string(),
12525                    stop_reason: StopReason::EndTurn,
12526                    usage: Usage::default(),
12527                })
12528                .is_inert(),
12529            "a duplicate late terminal for resp_a must not finalize resp_b"
12530        );
12531        assert_eq!(session.messages().len(), 2);
12532
12533        let outcome = session.append_realtime_transcript_event(
12534            RealtimeTranscriptEvent::AssistantTurnCompleted {
12535                response_id: "resp_b".to_string(),
12536                stop_reason: StopReason::EndTurn,
12537                usage: Usage::default(),
12538            },
12539        );
12540        assert_eq!(outcome.materialized_messages.len(), 1);
12541        assert_eq!(session.messages().len(), 3);
12542    }
12543
12544    #[test]
12545    fn realtime_transcript_interruption_discards_only_matching_response() {
12546        // R5-5: cross-response isolation invariant — Interrupted on resp_a
12547        // does NOT touch resp_b's staged content. Both responses use
12548        // `AssistantTextDelta` (Display lane); under R5-5 resp_a's Display
12549        // item is RETAINED at Interrupted time and resp_b's continues
12550        // unaffected, materializing on its later TurnCompleted.
12551        let mut session = Session::new();
12552
12553        let _ = session.append_realtime_transcript_event(
12554            RealtimeTranscriptEvent::UserTranscriptFinal {
12555                item_id: "item_user".to_string(),
12556                previous_item_id: None,
12557                content_index: 0,
12558                text: "question".to_string(),
12559            },
12560        );
12561        let _ =
12562            session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
12563                response_id: "resp_a".to_string(),
12564                delta_id: "evt_a".to_string(),
12565                item_id: "item_a".to_string(),
12566                previous_item_id: Some("item_user".to_string()),
12567                content_index: 0,
12568                delta: "interrupted display".to_string(),
12569            });
12570        let _ =
12571            session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
12572                response_id: "resp_b".to_string(),
12573                delta_id: "evt_b".to_string(),
12574                item_id: "item_b".to_string(),
12575                previous_item_id: Some("item_user".to_string()),
12576                content_index: 0,
12577                delta: "keep me".to_string(),
12578            });
12579
12580        // R5-5: Interrupted commits the resp_a Display item; resp_b
12581        // remains untouched.
12582        let interrupt_outcome = session.append_realtime_transcript_event(
12583            RealtimeTranscriptEvent::AssistantTurnInterrupted {
12584                response_id: "resp_a".to_string(),
12585            },
12586        );
12587        assert_eq!(
12588            interrupt_outcome.materialized_messages.len(),
12589            1,
12590            "resp_a's Display item commits on Interrupted"
12591        );
12592
12593        let outcome = session.append_realtime_transcript_event(
12594            RealtimeTranscriptEvent::AssistantTurnCompleted {
12595                response_id: "resp_b".to_string(),
12596                stop_reason: StopReason::EndTurn,
12597                usage: Usage::default(),
12598            },
12599        );
12600        assert_eq!(
12601            outcome.materialized_messages.len(),
12602            1,
12603            "resp_b commits on its TurnCompleted, untouched by resp_a's Interrupted"
12604        );
12605
12606        // 1 user + 2 assistant messages.
12607        assert_eq!(session.messages().len(), 3);
12608        assert!(matches!(
12609            &session.messages()[1],
12610            Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "interrupted display"
12611        ));
12612        assert!(matches!(
12613            &session.messages()[2],
12614            Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "keep me"
12615        ));
12616    }
12617
12618    // Performance tests for Arc-based CoW
12619
12620    #[test]
12621    fn test_fork_shares_arc_no_clone() {
12622        let mut session = Session::new();
12623        for i in 0..100 {
12624            session.push(Message::User(UserMessage::text(format!("Message {i}"))));
12625        }
12626
12627        // Fork should share the same Arc, not clone messages
12628        let forked = session.fork();
12629
12630        // Both should point to the same underlying data (Arc refcount > 1)
12631        assert!(Arc::ptr_eq(session.messages.arc(), forked.messages.arc()));
12632        assert_eq!(forked.messages().len(), 100);
12633    }
12634
12635    #[test]
12636    fn test_fork_at_shares_arc_prefix() {
12637        let mut session = Session::new();
12638        for i in 0..100 {
12639            session.push(Message::User(UserMessage::text(format!("Message {i}"))));
12640        }
12641
12642        // Fork at 50 should create new Arc with copied prefix
12643        let forked = session.fork_at(50);
12644        assert_eq!(forked.messages().len(), 50);
12645
12646        // Original should be unchanged
12647        assert_eq!(session.messages().len(), 100);
12648    }
12649
12650    #[test]
12651    fn test_fork_at_resets_transcript_history_state_for_branch_identity() {
12652        let mut session = Session::new();
12653        session.push(Message::User(UserMessage::text(
12654            "summarize this".to_string(),
12655        )));
12656        session.push(Message::BlockAssistant(BlockAssistantMessage::new(
12657            vec![AssistantBlock::Text {
12658                text: "long assistant trace".to_string(),
12659                meta: None,
12660            }],
12661            StopReason::EndTurn,
12662        )));
12663        let parent_revision = session.transcript_revision().expect("parent revision");
12664        session
12665            .commit_transcript_rewrite(
12666                TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
12667                vec![Message::BlockAssistant(BlockAssistantMessage::new(
12668                    vec![AssistantBlock::Text {
12669                        text: "compact trace".to_string(),
12670                        meta: None,
12671                    }],
12672                    StopReason::EndTurn,
12673                ))],
12674                TranscriptRewriteReason::new("compaction"),
12675                Some("test".to_string()),
12676                Some(parent_revision),
12677            )
12678            .expect("rewrite should commit");
12679
12680        let source_head = session.transcript_revision().expect("source head");
12681        let mut forked = session.fork_at(1);
12682        assert_ne!(forked.id(), session.id());
12683        assert!(
12684            !forked
12685                .metadata()
12686                .contains_key(SESSION_TRANSCRIPT_HISTORY_STATE_KEY)
12687        );
12688        assert_eq!(
12689            forked.transcript_revision().expect("fork head"),
12690            transcript_messages_digest(forked.messages()).expect("fork digest")
12691        );
12692        assert!(
12693            forked
12694                .transcript_revision_messages(&source_head)
12695                .expect("fork history lookup")
12696                .is_none()
12697        );
12698
12699        let fork_parent = forked.transcript_revision().expect("fork parent");
12700        let commit = forked
12701            .commit_transcript_rewrite(
12702                TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
12703                vec![Message::User(UserMessage::text(
12704                    "branch prompt".to_string(),
12705                ))],
12706                TranscriptRewriteReason::new("branch_edit"),
12707                Some("test".to_string()),
12708                Some(fork_parent.clone()),
12709            )
12710            .expect("fork rewrite should use fork-local parent");
12711        assert_eq!(commit.parent_revision, fork_parent);
12712    }
12713
12714    #[test]
12715    fn test_push_cow_behavior() {
12716        let mut session = Session::new();
12717        session.push(Message::User(UserMessage::text("First".to_string())));
12718
12719        // Fork shares the Arc
12720        let forked = session.fork();
12721        assert!(Arc::ptr_eq(session.messages.arc(), forked.messages.arc()));
12722
12723        // Push on original triggers CoW - original gets new Arc
12724        session.push(Message::User(UserMessage::text("Second".to_string())));
12725
12726        // Now they should have different Arcs
12727        assert!(!Arc::ptr_eq(session.messages.arc(), forked.messages.arc()));
12728        assert_eq!(session.messages().len(), 2);
12729        assert_eq!(forked.messages().len(), 1);
12730    }
12731
12732    // Performance tests for lazy timestamp updates
12733
12734    #[test]
12735    fn test_push_batch_single_timestamp() {
12736        let mut session = Session::new();
12737        let initial_updated = session.updated_at();
12738
12739        // Use push_batch to add multiple messages without repeated syscalls
12740        session.push_batch(vec![
12741            Message::User(UserMessage::text("First".to_string())),
12742            Message::User(UserMessage::text("Second".to_string())),
12743            Message::User(UserMessage::text("Third".to_string())),
12744        ]);
12745
12746        assert_eq!(session.messages().len(), 3);
12747        // Timestamp should have been updated once
12748        assert!(session.updated_at() >= initial_updated);
12749    }
12750
12751    #[test]
12752    fn test_touch_updates_timestamp() {
12753        let mut session = Session::new();
12754        let initial = session.updated_at();
12755
12756        std::thread::sleep(std::time::Duration::from_millis(10));
12757
12758        // Explicit touch to update timestamp
12759        session.touch();
12760
12761        assert!(session.updated_at() > initial);
12762    }
12763
12764    #[test]
12765    fn test_session_push() {
12766        let mut session = Session::new();
12767        let initial_updated = session.updated_at();
12768
12769        // Small delay to ensure time changes
12770        std::thread::sleep(std::time::Duration::from_millis(10));
12771
12772        session.push(Message::User(UserMessage::text("Hello".to_string())));
12773
12774        assert_eq!(session.messages().len(), 1);
12775        assert!(session.updated_at() > initial_updated);
12776    }
12777
12778    #[test]
12779    fn test_session_fork() {
12780        let mut session = Session::new();
12781        session.push(Message::System(SystemMessage::new("System prompt")));
12782        session.push(Message::User(UserMessage::text("Hello".to_string())));
12783        session.push(Message::BlockAssistant(BlockAssistantMessage {
12784            blocks: vec![AssistantBlock::Text {
12785                text: "Hi!".to_string(),
12786                meta: None,
12787            }],
12788            stop_reason: StopReason::EndTurn,
12789            identity: crate::types::TranscriptMessageIdentity::default(),
12790            created_at: crate::types::message_timestamp_now(),
12791        }));
12792
12793        // Fork at index 2 (system + user)
12794        let forked = session.fork_at(2);
12795        assert_eq!(forked.messages().len(), 2);
12796        assert_ne!(forked.id(), session.id());
12797
12798        // Full fork
12799        let full_fork = session.fork();
12800        assert_eq!(full_fork.messages().len(), 3);
12801    }
12802
12803    #[test]
12804    fn test_session_forks_drop_generated_authority_metadata() {
12805        let mut session = Session::new();
12806        session.push(Message::User(UserMessage::text("original")));
12807        session.set_metadata("ordinary", serde_json::json!("keep"));
12808        session
12809            .set_build_state(SessionBuildState::default())
12810            .expect("build state should serialize");
12811        session
12812            .set_system_context_state(SessionSystemContextState::default())
12813            .expect("system-context state should serialize");
12814        session
12815            .set_deferred_turn_state(SessionDeferredTurnState::default())
12816            .expect("deferred-turn state should serialize");
12817        session
12818            .set_tool_visibility_state(
12819                AuthorizedSessionToolVisibilityState::from_generated_authority(
12820                    SessionToolVisibilityState::default(),
12821                ),
12822            )
12823            .expect("visibility state should serialize");
12824        let _ = session.append_realtime_transcript_event(RealtimeTranscriptEvent::ItemObserved {
12825            item_id: "rt-item".to_string(),
12826            previous_item_id: None,
12827            role: RealtimeTranscriptRole::User,
12828            response_id: None,
12829        });
12830        session.metadata.insert(
12831            crate::memory::SESSION_COMPACTION_PROJECTION_INTENTS_KEY.to_string(),
12832            serde_json::json!([{"sealed_projection": "must-not-fork"}]),
12833        );
12834        assert!(
12835            session
12836                .metadata()
12837                .contains_key(SESSION_REALTIME_TRANSCRIPT_STATE_KEY),
12838            "test setup should install realtime transcript authority state"
12839        );
12840
12841        let forked_at = session.fork_at(1);
12842        let full_fork = session.fork();
12843        let replaced = session
12844            .fork_replacing(
12845                0,
12846                TranscriptReplacement::Message {
12847                    message: Message::User(UserMessage::text("replacement")),
12848                },
12849            )
12850            .expect("replacement fork should succeed");
12851
12852        for forked in [&forked_at, &full_fork, &replaced] {
12853            assert_eq!(forked.metadata().get("ordinary").unwrap(), "keep");
12854            assert!(
12855                !forked.metadata().contains_key(SESSION_BUILD_STATE_KEY),
12856                "forked sessions must not raw-copy durable build-state authority"
12857            );
12858            assert!(
12859                !forked
12860                    .metadata()
12861                    .contains_key(SESSION_SYSTEM_CONTEXT_STATE_KEY),
12862                "forked sessions must not raw-copy system-context authority state"
12863            );
12864            assert!(
12865                !forked
12866                    .metadata()
12867                    .contains_key(SESSION_DEFERRED_TURN_STATE_KEY),
12868                "forked sessions must not raw-copy deferred-turn authority state"
12869            );
12870            assert!(
12871                !forked
12872                    .metadata()
12873                    .contains_key(SESSION_TOOL_VISIBILITY_STATE_KEY),
12874                "forked sessions must not raw-copy tool-visibility authority state"
12875            );
12876            assert!(
12877                !forked
12878                    .metadata()
12879                    .contains_key(SESSION_REALTIME_TRANSCRIPT_STATE_KEY),
12880                "forked sessions must not raw-copy realtime transcript authority state"
12881            );
12882            assert!(
12883                !forked
12884                    .metadata()
12885                    .contains_key(crate::memory::SESSION_COMPACTION_PROJECTION_INTENTS_KEY),
12886                "forked sessions must not raw-copy compaction outbox authority"
12887            );
12888        }
12889    }
12890
12891    #[test]
12892    fn test_session_metadata() {
12893        let mut session = Session::new();
12894        session.set_metadata("key", serde_json::json!("value"));
12895
12896        assert_eq!(session.metadata().get("key").unwrap(), "value");
12897    }
12898
12899    #[test]
12900    fn identical_metadata_projection_is_checkpoint_idempotent() {
12901        let mut session = Session::new();
12902        session.set_metadata("key", serde_json::json!({ "value": 1 }));
12903        let updated_at = session.updated_at;
12904        let digest = crate::session_checkpoint_digest(&session)
12905            .expect("checkpoint digest before identical projection");
12906
12907        session.set_metadata("key", serde_json::json!({ "value": 1 }));
12908        session.remove_metadata("already_absent");
12909
12910        assert_eq!(
12911            session.updated_at, updated_at,
12912            "an identical durable projection must not manufacture a content mutation"
12913        );
12914        assert_eq!(
12915            crate::session_checkpoint_digest(&session)
12916                .expect("checkpoint digest after identical projection"),
12917            digest,
12918            "an identical durable projection must not rotate checkpoint authority"
12919        );
12920    }
12921
12922    #[test]
12923    fn session_metadata_realm_id_is_back_read_compatible_string() {
12924        // A typed realm_id serializes as a bare JSON string (byte-identical to
12925        // the prior Option<String> durable shape).
12926        let metadata = SessionMetadata {
12927            schema_version: SESSION_METADATA_SCHEMA_VERSION,
12928            model: "test-model".to_string(),
12929            max_tokens: 1024,
12930            structured_output_retries: 2,
12931            provider: Provider::Other,
12932            self_hosted_server_id: None,
12933            provider_params: None,
12934            tooling: SessionTooling::default(),
12935            keep_alive: false,
12936            comms_name: None,
12937            peer_meta: None,
12938            realm_id: Some(crate::RealmId::parse("env_default").unwrap()),
12939            instance_id: None,
12940            backend: None,
12941            config_generation: None,
12942            auth_binding: None,
12943            mob_member_binding: None,
12944        };
12945        let value = serde_json::to_value(&metadata).unwrap();
12946        assert_eq!(
12947            value.get("realm_id"),
12948            Some(&serde_json::json!("env_default")),
12949            "typed realm_id must serialize as a bare slug string"
12950        );
12951
12952        // A legacy persisted row stored realm_id as a JSON string; it must
12953        // deserialize into the typed RealmId (durable back-read).
12954        let legacy = serde_json::json!({
12955            "schema_version": SESSION_METADATA_SCHEMA_VERSION,
12956            "model": "test-model",
12957            "max_tokens": 1024,
12958            "structured_output_retries": 2,
12959            "provider": "other",
12960            "tooling": SessionTooling::default(),
12961            "keep_alive": false,
12962            "comms_name": null,
12963            "realm_id": "legacy_realm",
12964        });
12965        let restored: SessionMetadata = serde_json::from_value(legacy).unwrap();
12966        assert_eq!(
12967            restored.realm_id.as_ref().map(crate::RealmId::as_str),
12968            Some("legacy_realm")
12969        );
12970    }
12971
12972    /// Ask 6: `SessionTooling.tool_access_policy` is additive — a persisted
12973    /// row without the field back-reads as `None` (unrestricted), `None` is
12974    /// omitted on write (durable shape unchanged for ungated sessions), and a
12975    /// resolved policy round-trips intact.
12976    #[test]
12977    fn session_tooling_tool_access_policy_round_trip_and_absent_default() {
12978        // Absent field back-reads as None.
12979        let legacy = serde_json::json!({});
12980        let restored: SessionTooling = serde_json::from_value(legacy).unwrap();
12981        assert_eq!(restored.tool_access_policy, None);
12982
12983        // None is omitted on write — ungated sessions keep their prior shape.
12984        let value = serde_json::to_value(SessionTooling::default()).unwrap();
12985        assert!(
12986            value.get("tool_access_policy").is_none(),
12987            "None policy must not serialize"
12988        );
12989
12990        // A resolved policy round-trips intact.
12991        let tooling = SessionTooling {
12992            tool_access_policy: Some(crate::ops::ToolAccessPolicy::AllowList(
12993                ["read_file", "send_message"].into_iter().collect(),
12994            )),
12995            ..SessionTooling::default()
12996        };
12997        let value = serde_json::to_value(&tooling).unwrap();
12998        let restored: SessionTooling = serde_json::from_value(value).unwrap();
12999        assert_eq!(restored.tool_access_policy, tooling.tool_access_policy);
13000    }
13001
13002    #[test]
13003    fn lifecycle_terminal_typed_round_trip() {
13004        let mut session = Session::new();
13005        assert_eq!(session.lifecycle_terminal(), None);
13006
13007        session
13008            .set_lifecycle_terminal(SessionLifecycleTerminal::Archived)
13009            .expect("typed terminal write should serialize");
13010        assert_eq!(
13011            session.lifecycle_terminal(),
13012            Some(SessionLifecycleTerminal::Archived)
13013        );
13014        assert!(
13015            session
13016                .lifecycle_terminal()
13017                .is_some_and(SessionLifecycleTerminal::is_archived)
13018        );
13019        // Persisted JSON for the typed key is the snake_case variant string.
13020        assert_eq!(
13021            session
13022                .metadata()
13023                .get(SESSION_LIFECYCLE_TERMINAL_KEY)
13024                .unwrap(),
13025            &serde_json::json!("archived")
13026        );
13027    }
13028
13029    #[test]
13030    fn lifecycle_terminal_key_rejects_raw_mutation() {
13031        let mut session = Session::new();
13032        assert!(
13033            session
13034                .try_set_metadata(
13035                    SESSION_LIFECYCLE_TERMINAL_KEY,
13036                    serde_json::json!("archived")
13037                )
13038                .is_err(),
13039            "the typed lifecycle-terminal key is reserved for session authority"
13040        );
13041    }
13042
13043    #[test]
13044    fn test_session_metadata_backfill_preserves_timestamp() {
13045        let mut session = Session::new();
13046        let initial_updated = session.updated_at();
13047
13048        std::thread::sleep(std::time::Duration::from_millis(10));
13049
13050        assert!(session.backfill_metadata_if_absent("key", serde_json::json!("value")));
13051        assert_eq!(session.metadata().get("key").unwrap(), "value");
13052        assert_eq!(session.updated_at(), initial_updated);
13053        assert!(!session.backfill_metadata_if_absent("key", serde_json::json!("other")));
13054        assert_eq!(session.metadata().get("key").unwrap(), "value");
13055        assert_eq!(session.updated_at(), initial_updated);
13056    }
13057
13058    #[test]
13059    fn test_reserved_generated_authority_metadata_rejects_raw_mutation() {
13060        let mut session = Session::new();
13061
13062        assert!(
13063            session
13064                .try_set_metadata(SESSION_SYSTEM_CONTEXT_STATE_KEY, serde_json::json!({}))
13065                .is_err()
13066        );
13067        assert!(
13068            session
13069                .try_set_metadata(SESSION_METADATA_KEY, serde_json::json!({}))
13070                .is_err()
13071        );
13072        assert!(
13073            session
13074                .try_set_metadata(SESSION_BUILD_STATE_KEY, serde_json::json!({}))
13075                .is_err()
13076        );
13077        let compaction_intents_key = crate::memory::SESSION_COMPACTION_PROJECTION_INTENTS_KEY;
13078        let sealed_compaction_intents =
13079            serde_json::json!([{"sealed_projection": "typed-owner-only"}]);
13080        session.metadata.insert(
13081            compaction_intents_key.to_string(),
13082            sealed_compaction_intents.clone(),
13083        );
13084        assert!(
13085            session
13086                .try_set_metadata(compaction_intents_key, serde_json::json!([]))
13087                .is_err(),
13088            "raw metadata must not overwrite compaction outbox authority"
13089        );
13090        session.remove_metadata(compaction_intents_key);
13091        assert_eq!(
13092            session.metadata().get(compaction_intents_key),
13093            Some(&sealed_compaction_intents),
13094            "raw metadata removal must not erase compaction outbox authority"
13095        );
13096        let mut absent = Session::new();
13097        assert!(
13098            !absent.backfill_metadata_if_absent(
13099                compaction_intents_key,
13100                serde_json::json!([{"forged_projection": true}])
13101            ),
13102            "compatibility backfill must not fabricate compaction outbox authority"
13103        );
13104        assert!(!absent.metadata().contains_key(compaction_intents_key));
13105        session
13106            .set_session_metadata(SessionMetadata {
13107                schema_version: SESSION_METADATA_SCHEMA_VERSION,
13108                model: "test-model".to_string(),
13109                max_tokens: 1024,
13110                structured_output_retries: 2,
13111                provider: Provider::Other,
13112                self_hosted_server_id: None,
13113                provider_params: None,
13114                tooling: SessionTooling::default(),
13115                keep_alive: false,
13116                comms_name: None,
13117                peer_meta: None,
13118                realm_id: None,
13119                instance_id: None,
13120                backend: None,
13121                config_generation: None,
13122                auth_binding: None,
13123                mob_member_binding: None,
13124            })
13125            .expect("typed metadata setter should route through generated authority");
13126        session
13127            .set_build_state(SessionBuildState::default())
13128            .expect("typed build-state setter should route through generated authority");
13129        session.remove_metadata(SESSION_METADATA_KEY);
13130        session.remove_metadata(SESSION_BUILD_STATE_KEY);
13131        assert!(
13132            session.metadata().contains_key(SESSION_METADATA_KEY),
13133            "raw removal must not delete generated-authority session metadata"
13134        );
13135        assert!(
13136            session.metadata().contains_key(SESSION_BUILD_STATE_KEY),
13137            "raw removal must not delete generated-authority build state"
13138        );
13139        session.set_metadata(SESSION_DEFERRED_TURN_STATE_KEY, serde_json::json!({}));
13140        assert!(
13141            !session
13142                .metadata()
13143                .contains_key(SESSION_DEFERRED_TURN_STATE_KEY)
13144        );
13145        assert!(
13146            !session.backfill_metadata_if_absent(
13147                SESSION_SYSTEM_CONTEXT_STATE_KEY,
13148                serde_json::json!({})
13149            )
13150        );
13151
13152        let state = SessionSystemContextState::default();
13153        session
13154            .set_system_context_state(state.clone())
13155            .expect("typed setter should route through generated authority");
13156        session.remove_metadata(SESSION_SYSTEM_CONTEXT_STATE_KEY);
13157        assert_eq!(
13158            session
13159                .try_system_context_state()
13160                .expect("typed state should restore"),
13161            Some(state)
13162        );
13163
13164        session.metadata.insert(
13165            SESSION_SYSTEM_CONTEXT_STATE_KEY.to_string(),
13166            serde_json::json!("not-a-state"),
13167        );
13168        assert!(
13169            session.try_system_context_state().is_err(),
13170            "malformed generated authority state must not decode as absent/default"
13171        );
13172
13173        session.metadata.insert(
13174            SESSION_METADATA_KEY.to_string(),
13175            serde_json::json!("not-metadata"),
13176        );
13177        assert!(
13178            session.try_session_metadata().is_err(),
13179            "malformed session metadata must not decode as absent/default"
13180        );
13181
13182        session.metadata.insert(
13183            SESSION_BUILD_STATE_KEY.to_string(),
13184            serde_json::json!("not-build-state"),
13185        );
13186        assert!(
13187            session.try_build_state().is_err(),
13188            "malformed build state must not decode as absent/default"
13189        );
13190
13191        assert!(
13192            session
13193                .try_set_metadata(SESSION_TOOL_VISIBILITY_STATE_KEY, serde_json::json!({}))
13194                .is_err()
13195        );
13196        session
13197            .set_tool_visibility_state(
13198                AuthorizedSessionToolVisibilityState::from_generated_authority(
13199                    SessionToolVisibilityState::default(),
13200                ),
13201            )
13202            .expect("typed visibility setter should route through typed authority handoff");
13203        session.remove_metadata(SESSION_TOOL_VISIBILITY_STATE_KEY);
13204        assert!(
13205            session
13206                .metadata()
13207                .contains_key(SESSION_TOOL_VISIBILITY_STATE_KEY)
13208        );
13209        session.clear_tool_visibility_state();
13210        assert!(
13211            !session
13212                .metadata()
13213                .contains_key(SESSION_TOOL_VISIBILITY_STATE_KEY)
13214        );
13215        assert!(
13216            session
13217                .try_set_metadata(SESSION_REALTIME_TRANSCRIPT_STATE_KEY, serde_json::json!({}))
13218                .is_err()
13219        );
13220        let _ = session.append_realtime_transcript_event(RealtimeTranscriptEvent::ItemObserved {
13221            item_id: "rt-item".to_string(),
13222            previous_item_id: None,
13223            role: RealtimeTranscriptRole::User,
13224            response_id: None,
13225        });
13226        assert!(
13227            session
13228                .metadata()
13229                .contains_key(SESSION_REALTIME_TRANSCRIPT_STATE_KEY),
13230            "typed realtime transcript append should retain authority to persist its state"
13231        );
13232        session.metadata.insert(
13233            SESSION_REALTIME_TRANSCRIPT_STATE_KEY.to_string(),
13234            serde_json::json!("not-a-state"),
13235        );
13236        assert!(
13237            session.try_realtime_transcript_state().is_err(),
13238            "malformed realtime generated authority state must not decode as absent/default"
13239        );
13240    }
13241
13242    #[test]
13243    fn test_session_mob_tool_authority_context_persists_projection_without_authority_seal() {
13244        let mut session = Session::new();
13245        session
13246            .set_build_state(SessionBuildState::default())
13247            .expect("session build state should serialize");
13248        let authority = MobToolAuthorityContext::generated_for_test(
13249            crate::service::OpaquePrincipalToken::new("opaque-principal"),
13250            false,
13251            false,
13252            false,
13253            std::collections::BTreeSet::from(["mob-a".to_string()]),
13254            std::collections::BTreeMap::new(),
13255            None,
13256            Some("audit-1".to_string()),
13257        );
13258
13259        session
13260            .set_mob_tool_authority_context(Some(authority))
13261            .expect("authority should serialize");
13262        assert!(session.mob_tool_authority_context().is_none());
13263        let stored = session
13264            .build_state()
13265            .and_then(|state| state.mob_tool_authority_context)
13266            .expect("stored projection should deserialize");
13267        assert!(!stored.is_generated_authority_context());
13268        assert!(!stored.can_manage_mob("mob-a"));
13269
13270        session
13271            .set_mob_tool_authority_context(None)
13272            .expect("authority should clear");
13273        assert!(session.mob_tool_authority_context().is_none());
13274    }
13275
13276    #[test]
13277    fn test_session_build_state_rejects_forged_mob_authority_projection() {
13278        let mut session = Session::new();
13279        let authority = MobToolAuthorityContext::generated_for_test(
13280            crate::service::OpaquePrincipalToken::new("opaque-principal"),
13281            false,
13282            false,
13283            false,
13284            std::collections::BTreeSet::from(["mob-a".to_string()]),
13285            std::collections::BTreeMap::new(),
13286            None,
13287            Some("audit-1".to_string()),
13288        );
13289        let forged_projection: MobToolAuthorityContext =
13290            serde_json::from_value(serde_json::to_value(authority).expect("serialize authority"))
13291                .expect("deserialize projection");
13292        assert!(!forged_projection.is_generated_authority_context());
13293
13294        let err = session
13295            .set_build_state(SessionBuildState {
13296                mob_tool_authority_context: Some(forged_projection),
13297                ..Default::default()
13298            })
13299            .expect_err("forged build state must be rejected by generated authority");
13300        // The build-state-persist admission decision now lives in the canonical
13301        // SessionDocumentMachine durable-config region (LUC-524); the rejection
13302        // surfaces with that machine's authority wording.
13303        assert!(
13304            err.to_string()
13305                .contains("generated session document authority rejected"),
13306            "unexpected error: {err}"
13307        );
13308    }
13309
13310    #[test]
13311    fn test_session_tool_visibility_state_roundtrip() {
13312        let mut session = Session::new();
13313        let state = SessionToolVisibilityState {
13314            inherited_base_filter: ToolFilter::Allow(["visible".to_string()].into_iter().collect()),
13315            active_filter: ToolFilter::Allow(
13316                ["visible".to_string(), "missing".to_string()]
13317                    .into_iter()
13318                    .collect(),
13319            ),
13320            staged_filter: ToolFilter::Allow(
13321                ["visible".to_string(), "missing".to_string()]
13322                    .into_iter()
13323                    .collect(),
13324            ),
13325            active_revision: 1,
13326            staged_revision: 2,
13327            ..Default::default()
13328        };
13329
13330        session
13331            .set_tool_visibility_state(
13332                AuthorizedSessionToolVisibilityState::from_generated_authority(state.clone()),
13333            )
13334            .expect("tool visibility state should serialize");
13335        assert_eq!(session.tool_visibility_state().unwrap(), Some(state));
13336    }
13337
13338    #[test]
13339    fn test_session_tool_visibility_state_malformed_returns_error() {
13340        let mut session = Session::new();
13341        session.metadata.insert(
13342            SESSION_TOOL_VISIBILITY_STATE_KEY.to_string(),
13343            serde_json::json!({
13344                "active_filter": {
13345                    "unexpected_filter_kind": ["secret"]
13346                }
13347            }),
13348        );
13349
13350        assert!(
13351            session.tool_visibility_state().is_err(),
13352            "malformed canonical visibility metadata must not decode as absent/default"
13353        );
13354    }
13355
13356    #[test]
13357    fn test_session_serialization() {
13358        let mut session = Session::new();
13359        session.push(Message::User(UserMessage::text("Test".to_string())));
13360
13361        let json = serde_json::to_string(&session).unwrap();
13362        let parsed: Session = serde_json::from_str(&json).unwrap();
13363
13364        assert_eq!(parsed.id(), session.id());
13365        assert_eq!(parsed.messages().len(), 1);
13366        assert_eq!(parsed.version(), SESSION_VERSION);
13367    }
13368
13369    #[test]
13370    fn test_session_meta_from_session() {
13371        let mut session = Session::new();
13372        session.push(Message::User(UserMessage::text("Hello".to_string())));
13373        session.push(Message::BlockAssistant(BlockAssistantMessage {
13374            blocks: vec![AssistantBlock::Text {
13375                text: "Hi!".to_string(),
13376                meta: None,
13377            }],
13378            stop_reason: StopReason::EndTurn,
13379            identity: crate::types::TranscriptMessageIdentity::default(),
13380            created_at: crate::types::message_timestamp_now(),
13381        }));
13382        session.record_usage(Usage {
13383            input_tokens: 10,
13384            output_tokens: 5,
13385            cache_creation_tokens: None,
13386            cache_read_tokens: None,
13387        });
13388
13389        let meta = SessionMeta::from(&session);
13390        assert_eq!(meta.id, *session.id());
13391        assert_eq!(meta.message_count, 2);
13392        assert_eq!(meta.total_tokens, 15);
13393    }
13394
13395    #[test]
13396    fn deferred_tool_result_redelivery_is_idempotent_per_exact_payload() {
13397        let mut state = SessionDeferredTurnState::default();
13398        let results = vec![
13399            ToolResult::new("callback-a".to_string(), "a".to_string(), false),
13400            ToolResult::new("callback-b".to_string(), "b".to_string(), false),
13401        ];
13402        assert_eq!(
13403            state.stage_tool_results(results.clone(), SystemTime::UNIX_EPOCH),
13404            2
13405        );
13406        let before = serde_json::to_value(&state).expect("serialize staged state");
13407
13408        assert_eq!(
13409            state.stage_tool_results(
13410                results,
13411                SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1),
13412            ),
13413            0,
13414            "identical redelivery must coalesce without restaging"
13415        );
13416        assert_eq!(
13417            serde_json::to_value(&state).expect("serialize redelivered state"),
13418            before,
13419            "duplicate ingress must be a byte-identical no-op"
13420        );
13421    }
13422
13423    #[test]
13424    fn deferred_tool_result_conflict_and_wrong_id_fail_without_poison_after_replay() {
13425        let mut state = SessionDeferredTurnState::default();
13426        state
13427            .try_stage_tool_results(
13428                vec![ToolResult::new(
13429                    "callback-a".to_string(),
13430                    "approved".to_string(),
13431                    false,
13432                )],
13433                SystemTime::UNIX_EPOCH,
13434            )
13435            .expect("first callback payload should stage");
13436        let mut replayed: SessionDeferredTurnState = serde_json::from_value(
13437            serde_json::to_value(&state).expect("serialize deferred callback state"),
13438        )
13439        .expect("restore deferred callback state");
13440        let before = serde_json::to_value(&replayed).expect("serialize replayed state");
13441
13442        assert!(matches!(
13443            replayed.try_stage_tool_results(
13444                vec![ToolResult::new(
13445                    "callback-a".to_string(),
13446                    "denied".to_string(),
13447                    false,
13448                )],
13449                SystemTime::UNIX_EPOCH,
13450            ),
13451            Err(DeferredToolResultsIngressError::ConflictingRedelivery(id))
13452                if id == "callback-a"
13453        ));
13454        assert_eq!(serde_json::to_value(&replayed).unwrap(), before);
13455
13456        assert!(matches!(
13457            replayed.try_stage_tool_results(
13458                vec![ToolResult::new(
13459                    "callback-b".to_string(),
13460                    "wrong".to_string(),
13461                    false,
13462                )],
13463                SystemTime::UNIX_EPOCH,
13464            ),
13465            Err(DeferredToolResultsIngressError::WrongToolUseId(id))
13466                if id == "callback-b"
13467        ));
13468        assert_eq!(
13469            serde_json::to_value(&replayed).unwrap(),
13470            before,
13471            "typed ingress refusals must leave the valid pending continuation intact"
13472        );
13473    }
13474
13475    #[test]
13476    fn system_context_state_preserves_applied_runtime_context() {
13477        let accepted_at = SystemTime::UNIX_EPOCH;
13478        let mut state = SessionSystemContextState::default();
13479        state
13480            .stage_append(
13481                &AppendSystemContextRequest {
13482                    content: crate::lifecycle::run_primitive::CoreRenderable::text(
13483                        "Authoritative peer token is birch seventeen.".to_string(),
13484                    ),
13485                    source: Some(
13486                        "peer_response_terminal:analyst:018f6f79-7a82-7c4e-a552-a3b86f9630f1"
13487                            .to_string(),
13488                    ),
13489                    idempotency_key: Some("018f6f79-7a82-7c4e-a552-a3b86f9630f1".to_string()),
13490                    source_kind: SystemContextSource::Normal,
13491                    peer_response_terminal: None,
13492                },
13493                accepted_at,
13494            )
13495            .expect("append should stage");
13496
13497        state.mark_pending_applied();
13498
13499        assert!(state.pending.is_empty());
13500        assert_eq!(state.applied.len(), 1);
13501        assert_eq!(
13502            state.applied[0].content.render_text(),
13503            "Authoritative peer token is birch seventeen."
13504        );
13505        assert_eq!(
13506            state.applied[0].source.as_deref(),
13507            Some("peer_response_terminal:analyst:018f6f79-7a82-7c4e-a552-a3b86f9630f1")
13508        );
13509
13510        let round_tripped: SessionSystemContextState =
13511            serde_json::from_value(serde_json::to_value(&state).expect("serialize state"))
13512                .expect("deserialize state");
13513        assert_eq!(round_tripped.applied, state.applied);
13514    }
13515
13516    #[test]
13517    fn active_turn_system_context_is_discarded_when_not_applied() {
13518        let mut state = SessionSystemContextState::default();
13519        state
13520            .stage_active_turn_append(
13521                &AppendSystemContextRequest {
13522                    content: crate::lifecycle::run_primitive::CoreRenderable::text(
13523                        "only for the active run".to_string(),
13524                    ),
13525                    source: Some("runtime:steer:input-1".to_string()),
13526                    idempotency_key: Some("runtime:steer:input-1".to_string()),
13527                    source_kind: SystemContextSource::RuntimeSteer,
13528                    peer_response_terminal: None,
13529                },
13530                SystemTime::UNIX_EPOCH,
13531            )
13532            .expect("active context should stage");
13533
13534        let discarded = state.discard_unapplied_active_turn_pending();
13535
13536        assert_eq!(discarded.len(), 1);
13537        assert!(state.pending.is_empty());
13538        assert!(state.applied.is_empty());
13539        assert!(state.active_turn_pending_keys.is_empty());
13540        assert!(state.active_turn_pending_indices.is_empty());
13541        assert!(
13542            state.seen.is_empty(),
13543            "discarded active-turn context should not block later idempotency keys"
13544        );
13545    }
13546
13547    #[test]
13548    fn keyless_active_turn_system_context_is_owned_and_discarded() {
13549        let mut state = SessionSystemContextState::default();
13550        state
13551            .stage_active_turn_append(
13552                &AppendSystemContextRequest {
13553                    content: crate::lifecycle::run_primitive::CoreRenderable::text(
13554                        "keyless active-turn context".to_string(),
13555                    ),
13556                    source: Some("test:keyless-active-turn".to_string()),
13557                    idempotency_key: None,
13558                    source_kind: SystemContextSource::RuntimeSteer,
13559                    peer_response_terminal: None,
13560                },
13561                SystemTime::UNIX_EPOCH,
13562            )
13563            .expect("keyless active context should stage");
13564
13565        assert!(state.active_turn_pending_keys.is_empty());
13566        assert_eq!(state.active_turn_pending_len(), 1);
13567        let discarded = state.discard_unapplied_active_turn_pending();
13568
13569        assert_eq!(discarded.len(), 1);
13570        assert!(state.pending.is_empty());
13571        assert_eq!(state.active_turn_pending_len(), 0);
13572    }
13573
13574    #[test]
13575    fn active_turn_system_context_can_roll_back_targeted_keys() {
13576        let mut state = SessionSystemContextState::default();
13577        for key in ["runtime:steer:input-1", "runtime:steer:input-2"] {
13578            state
13579                .stage_active_turn_append(
13580                    &AppendSystemContextRequest {
13581                        content: crate::lifecycle::run_primitive::CoreRenderable::text(format!(
13582                            "context for {key}"
13583                        )),
13584                        source: Some(key.to_string()),
13585                        idempotency_key: Some(key.to_string()),
13586                        source_kind: SystemContextSource::RuntimeSteer,
13587                        peer_response_terminal: None,
13588                    },
13589                    SystemTime::UNIX_EPOCH,
13590                )
13591                .expect("active context should stage");
13592        }
13593
13594        let discarded =
13595            state.discard_active_turn_pending_by_keys(&["runtime:steer:input-1".to_string()]);
13596
13597        assert_eq!(discarded.len(), 1);
13598        assert_eq!(
13599            discarded[0].idempotency_key.as_deref(),
13600            Some("runtime:steer:input-1")
13601        );
13602        assert_eq!(state.pending.len(), 1);
13603        assert_eq!(
13604            state.pending[0].idempotency_key.as_deref(),
13605            Some("runtime:steer:input-2")
13606        );
13607        assert!(!state.seen.contains_key("runtime:steer:input-1"));
13608        assert!(state.seen.contains_key("runtime:steer:input-2"));
13609        assert!(
13610            !state
13611                .active_turn_pending_keys
13612                .contains("runtime:steer:input-1")
13613        );
13614        assert!(
13615            state
13616                .active_turn_pending_keys
13617                .contains("runtime:steer:input-2")
13618        );
13619    }
13620
13621    #[test]
13622    fn active_turn_system_context_is_transient_when_boundary_consumes_it() {
13623        let mut state = SessionSystemContextState::default();
13624        state
13625            .stage_active_turn_append(
13626                &AppendSystemContextRequest {
13627                    content: crate::lifecycle::run_primitive::CoreRenderable::text(
13628                        "visible to this run".to_string(),
13629                    ),
13630                    source: Some("runtime:steer:input-2".to_string()),
13631                    idempotency_key: Some("runtime:steer:input-2".to_string()),
13632                    source_kind: SystemContextSource::RuntimeSteer,
13633                    peer_response_terminal: None,
13634                },
13635                SystemTime::UNIX_EPOCH,
13636            )
13637            .expect("active context should stage");
13638
13639        state.mark_pending_applied();
13640        let discarded = state.discard_unapplied_active_turn_pending();
13641
13642        assert!(discarded.is_empty());
13643        assert!(state.pending.is_empty());
13644        assert!(state.applied.is_empty());
13645        assert!(state.active_turn_pending_keys.is_empty());
13646        assert!(state.active_turn_pending_indices.is_empty());
13647        assert_eq!(
13648            state.seen.get("runtime:steer:input-2"),
13649            None,
13650            "consumed active-turn steer context must not become durable state"
13651        );
13652    }
13653
13654    #[test]
13655    fn discard_transient_runtime_steer_context_removes_steer_via_typed_marker() {
13656        let mut session = Session::new();
13657        // The runtime-steer fact is carried by the typed `source_kind`, not by
13658        // the `source` string. The durable peer fact uses the same `source`
13659        // string scheme but is marked `Normal`, so only the steers are removed.
13660        session.set_system_prompt(format!(
13661            "base{}{}{}{}",
13662            SYSTEM_CONTEXT_SEPARATOR,
13663            render_system_context_block(&PendingSystemContextAppend {
13664                content: crate::lifecycle::run_primitive::CoreRenderable::text(
13665                    "old steer".to_string()
13666                ),
13667                source: Some("steer-source-old".to_string()),
13668                idempotency_key: Some("steer-key-old".to_string()),
13669                source_kind: SystemContextSource::RuntimeSteer,
13670                peer_response_terminal: None,
13671                accepted_at: SystemTime::UNIX_EPOCH,
13672            }),
13673            SYSTEM_CONTEXT_SEPARATOR,
13674            render_system_context_block(&PendingSystemContextAppend {
13675                content: crate::lifecycle::run_primitive::CoreRenderable::text(
13676                    "durable peer fact".to_string()
13677                ),
13678                source: Some("peer_response_terminal:analyst:req".to_string()),
13679                idempotency_key: Some("peer_response_terminal:analyst:req".to_string()),
13680                source_kind: SystemContextSource::Normal,
13681                peer_response_terminal: None,
13682                accepted_at: SystemTime::UNIX_EPOCH,
13683            })
13684        ));
13685        session
13686            .set_system_context_state(SessionSystemContextState {
13687                pending: vec![PendingSystemContextAppend {
13688                    content: crate::lifecycle::run_primitive::CoreRenderable::text(
13689                        "pending steer".to_string(),
13690                    ),
13691                    source: Some("steer-source-pending".to_string()),
13692                    idempotency_key: Some("steer-key-pending".to_string()),
13693                    source_kind: SystemContextSource::RuntimeSteer,
13694                    peer_response_terminal: None,
13695                    accepted_at: SystemTime::UNIX_EPOCH,
13696                }],
13697                applied: vec![
13698                    PendingSystemContextAppend {
13699                        content: crate::lifecycle::run_primitive::CoreRenderable::text(
13700                            "old steer".to_string(),
13701                        ),
13702                        source: Some("steer-source-old".to_string()),
13703                        idempotency_key: Some("steer-key-old".to_string()),
13704                        source_kind: SystemContextSource::RuntimeSteer,
13705                        peer_response_terminal: None,
13706                        accepted_at: SystemTime::UNIX_EPOCH,
13707                    },
13708                    PendingSystemContextAppend {
13709                        content: crate::lifecycle::run_primitive::CoreRenderable::text(
13710                            "durable peer fact".to_string(),
13711                        ),
13712                        source: Some("peer_response_terminal:analyst:req".to_string()),
13713                        idempotency_key: Some("peer_response_terminal:analyst:req".to_string()),
13714                        source_kind: SystemContextSource::Normal,
13715                        peer_response_terminal: None,
13716                        accepted_at: SystemTime::UNIX_EPOCH,
13717                    },
13718                ],
13719                seen: BTreeMap::from([(
13720                    "steer-key-old".to_string(),
13721                    SeenSystemContextKey {
13722                        content: crate::lifecycle::run_primitive::CoreRenderable::text(
13723                            "old steer".to_string(),
13724                        ),
13725                        source: Some("steer-source-old".to_string()),
13726                        source_kind: SystemContextSource::RuntimeSteer,
13727                        state: SeenSystemContextState::Applied,
13728                    },
13729                )]),
13730                active_turn_pending_keys: BTreeSet::from(["steer-key-pending".to_string()]),
13731                active_turn_pending_indices: BTreeSet::from([0]),
13732            })
13733            .expect("system context state should serialize");
13734
13735        let removed = session.discard_transient_runtime_steer_context();
13736
13737        assert!(removed >= 4);
13738        let system_prompt = match session.messages().first() {
13739            Some(Message::System(system)) => system.content.as_str(),
13740            other => panic!("expected system prompt, got {other:?}"),
13741        };
13742        assert!(!system_prompt.contains("old steer"));
13743        assert!(system_prompt.contains("durable peer fact"));
13744        let state = session.system_context_state().unwrap_or_default();
13745        assert!(state.pending.is_empty());
13746        assert_eq!(state.applied.len(), 1);
13747        assert_eq!(state.applied[0].content.render_text(), "durable peer fact");
13748        assert!(state.seen.is_empty());
13749        assert!(state.active_turn_pending_keys.is_empty());
13750    }
13751
13752    #[test]
13753    fn append_system_context_blocks_records_typed_applied_context() {
13754        let append = PendingSystemContextAppend {
13755            content: crate::lifecycle::run_primitive::CoreRenderable::text(
13756                "Authoritative peer token is birch seventeen.".to_string(),
13757            ),
13758            source: Some(
13759                "peer_response_terminal:analyst:018f6f79-7a82-7c4e-a552-a3b86f9630f1".to_string(),
13760            ),
13761            idempotency_key: Some("018f6f79-7a82-7c4e-a552-a3b86f9630f1".to_string()),
13762            source_kind: SystemContextSource::Normal,
13763            peer_response_terminal: None,
13764            accepted_at: SystemTime::UNIX_EPOCH,
13765        };
13766        let mut session = Session::new();
13767
13768        session.append_system_context_blocks(std::slice::from_ref(&append));
13769
13770        let state = session
13771            .system_context_state()
13772            .expect("append should persist typed context state");
13773        assert_eq!(state.applied, vec![append]);
13774    }
13775
13776    fn roster_append() -> PendingSystemContextAppend {
13777        PendingSystemContextAppend {
13778            content: crate::lifecycle::run_primitive::CoreRenderable::text(
13779                "peer roster: lead-1, w-1".to_string(),
13780            ),
13781            source: Some("comms:roster".to_string()),
13782            idempotency_key: Some("comms:roster:v1".to_string()),
13783            source_kind: SystemContextSource::Normal,
13784            peer_response_terminal: None,
13785            accepted_at: SystemTime::UNIX_EPOCH,
13786        }
13787    }
13788
13789    fn resumed_session_with_context_appended_prompt(base: &str) -> Session {
13790        let mut session = Session::new();
13791        session.set_system_prompt(base.to_string());
13792        session.push(Message::User(UserMessage::text("hello".to_string())));
13793        session.append_system_context_blocks(std::slice::from_ref(&roster_append()));
13794        session
13795    }
13796
13797    #[test]
13798    fn reconcile_resumed_system_prompt_preserves_identical_base() {
13799        let mut session = Session::new();
13800        session.set_system_prompt("base prompt".to_string());
13801        session.push(Message::User(UserMessage::text("hello".to_string())));
13802        let digest_before = transcript_messages_digest(session.messages()).unwrap();
13803
13804        let outcome = session
13805            .reconcile_resumed_system_prompt("base prompt".to_string(), None)
13806            .expect("reconcile");
13807
13808        assert_eq!(
13809            outcome,
13810            ResumedSystemPromptReconciliation::PreservedContinuation
13811        );
13812        assert_eq!(
13813            transcript_messages_digest(session.messages()).unwrap(),
13814            digest_before,
13815            "identical base must leave the transcript revision unchanged"
13816        );
13817    }
13818
13819    #[test]
13820    fn reconcile_resumed_system_prompt_preserves_context_appended_base() {
13821        let mut session = resumed_session_with_context_appended_prompt("base prompt");
13822        let digest_before = transcript_messages_digest(session.messages()).unwrap();
13823
13824        let outcome = session
13825            .reconcile_resumed_system_prompt("base prompt".to_string(), None)
13826            .expect("reconcile");
13827
13828        assert_eq!(
13829            outcome,
13830            ResumedSystemPromptReconciliation::PreservedContinuation
13831        );
13832        assert_eq!(
13833            transcript_messages_digest(session.messages()).unwrap(),
13834            digest_before,
13835            "a base extended only by runtime context appends must stay untouched"
13836        );
13837        let system = match session.messages().first() {
13838            Some(Message::System(system)) => system.clone(),
13839            other => panic!("expected system message, got {other:?}"),
13840        };
13841        assert!(system.content.contains("peer roster: lead-1, w-1"));
13842        assert!(
13843            system.mutation_kind.is_runtime_context_append(),
13844            "the persisted mutation provenance must survive reconciliation"
13845        );
13846    }
13847
13848    #[test]
13849    fn reconcile_resumed_system_prompt_rewrites_changed_base_preserving_tail() {
13850        let mut session = resumed_session_with_context_appended_prompt("base prompt");
13851
13852        let outcome = session
13853            .reconcile_resumed_system_prompt("new base prompt".to_string(), None)
13854            .expect("reconcile");
13855
13856        assert_eq!(outcome, ResumedSystemPromptReconciliation::RewrittenBase);
13857        let system_content = match session.messages().first() {
13858            Some(Message::System(system)) => system.content.clone(),
13859            other => panic!("expected system message, got {other:?}"),
13860        };
13861        assert!(
13862            system_content.starts_with("new base prompt"),
13863            "the changed base must be applied: {system_content}"
13864        );
13865        assert!(
13866            system_content.contains("peer roster: lead-1, w-1"),
13867            "the runtime-applied context tail must survive the base change: {system_content}"
13868        );
13869        let state = session
13870            .transcript_history_state()
13871            .expect("history state deserializes")
13872            .expect("rewrite must record transcript history");
13873        assert_eq!(state.commits.len(), 1);
13874        assert_eq!(
13875            state.commits[0].reason.kind,
13876            RESUME_SYSTEM_PROMPT_REFRESH_REWRITE_REASON
13877        );
13878        assert_eq!(
13879            state.head,
13880            transcript_messages_digest(session.messages()).unwrap(),
13881            "the committed head must match the rewritten transcript"
13882        );
13883    }
13884
13885    #[test]
13886    fn reconcile_resumed_system_prompt_inserts_prompt_on_promptless_transcript() {
13887        let mut session = Session::new();
13888        session.push(Message::User(UserMessage::text("hello".to_string())));
13889
13890        let outcome = session
13891            .reconcile_resumed_system_prompt("late prompt".to_string(), None)
13892            .expect("reconcile");
13893
13894        assert_eq!(outcome, ResumedSystemPromptReconciliation::RewrittenBase);
13895        assert!(matches!(
13896            session.messages().first(),
13897            Some(Message::System(system)) if system.content == "late prompt"
13898        ));
13899        let state = session
13900            .transcript_history_state()
13901            .expect("history state deserializes")
13902            .expect("insert must record transcript history");
13903        assert_eq!(state.commits.len(), 1);
13904        assert_eq!(
13905            state.commits[0].reason.kind,
13906            RESUME_SYSTEM_PROMPT_REFRESH_REWRITE_REASON
13907        );
13908    }
13909
13910    fn leading_system_content(session: &Session) -> String {
13911        match session.messages().first() {
13912            Some(Message::System(system)) => system.content.clone(),
13913            other => panic!("expected leading system message, got {other:?}"),
13914        }
13915    }
13916
13917    #[test]
13918    fn reconcile_resumed_system_prompt_preserves_full_context_prompt_from_empty_base() {
13919        // Promptless/empty-base build: appends compose as the WHOLE System
13920        // content with no separator prefix. A resume with a non-empty
13921        // explicit base must carry the verified all-context tail onto the
13922        // new base instead of discarding it as an "empty tail".
13923        let mut session = Session::new();
13924        session.push(Message::User(UserMessage::text("hello".to_string())));
13925        session.append_system_context_blocks(std::slice::from_ref(&roster_append()));
13926        let all_context_content = leading_system_content(&session);
13927        assert!(all_context_content.contains("peer roster: lead-1, w-1"));
13928
13929        let outcome = session
13930            .reconcile_resumed_system_prompt("new base prompt".to_string(), None)
13931            .expect("reconcile");
13932
13933        assert_eq!(outcome, ResumedSystemPromptReconciliation::RewrittenBase);
13934        assert_eq!(
13935            leading_system_content(&session),
13936            format!("new base prompt{SYSTEM_CONTEXT_SEPARATOR}{all_context_content}"),
13937            "the all-context prompt must survive as the runtime tail of the new base"
13938        );
13939    }
13940
13941    #[test]
13942    fn reconcile_resumed_system_prompt_preserves_context_only_prompt_on_empty_base_resume() {
13943        // Empty-base → empty-base resume: the all-context prompt IS the
13944        // expected composition; it must be preserved untouched.
13945        let mut session = Session::new();
13946        session.push(Message::User(UserMessage::text("hello".to_string())));
13947        session.append_system_context_blocks(std::slice::from_ref(&roster_append()));
13948        let digest_before = transcript_messages_digest(session.messages()).unwrap();
13949
13950        let outcome = session
13951            .reconcile_resumed_system_prompt(String::new(), None)
13952            .expect("reconcile");
13953
13954        assert_eq!(
13955            outcome,
13956            ResumedSystemPromptReconciliation::PreservedContinuation
13957        );
13958        assert_eq!(
13959            transcript_messages_digest(session.messages()).unwrap(),
13960            digest_before
13961        );
13962    }
13963
13964    #[test]
13965    fn reconcile_resumed_system_prompt_applies_shortened_base_with_recorded_prior() {
13966        // The separator is ordinary markdown: a base prompt may legitimately
13967        // contain it. Shortening the base must be APPLIED (audited rewrite),
13968        // not silently classified as a preserved context-append continuation.
13969        let full_base = format!("part one{SYSTEM_CONTEXT_SEPARATOR}part two");
13970        let mut session = Session::new();
13971        session.set_system_prompt(full_base.clone());
13972        session.push(Message::User(UserMessage::text("hello".to_string())));
13973        session
13974            .set_build_state(SessionBuildState {
13975                assembled_system_prompt: Some(full_base),
13976                ..Default::default()
13977            })
13978            .expect("build state");
13979
13980        let outcome = session
13981            .reconcile_resumed_system_prompt("part one".to_string(), None)
13982            .expect("reconcile");
13983
13984        assert_eq!(outcome, ResumedSystemPromptReconciliation::RewrittenBase);
13985        assert_eq!(leading_system_content(&session), "part one");
13986    }
13987
13988    #[test]
13989    fn reconcile_resumed_system_prompt_applies_shortened_base_without_context_provenance() {
13990        // No recorded prior base, no applied records, and the persisted
13991        // prompt's mutation provenance is not a runtime context append: the
13992        // machine rejects the structural-extends continuation, so the
13993        // shortened base is applied instead of silently ignored.
13994        let full_base = format!("part one{SYSTEM_CONTEXT_SEPARATOR}part two");
13995        let mut session = Session::new();
13996        session.set_system_prompt(full_base);
13997        session.push(Message::User(UserMessage::text("hello".to_string())));
13998
13999        let outcome = session
14000            .reconcile_resumed_system_prompt("part one".to_string(), None)
14001            .expect("reconcile");
14002
14003        assert_eq!(outcome, ResumedSystemPromptReconciliation::RewrittenBase);
14004        assert_eq!(leading_system_content(&session), "part one");
14005    }
14006
14007    #[test]
14008    fn reconcile_resumed_system_prompt_preserves_appended_prompt_without_applied_records() {
14009        // The runtime persistence path sweeps applied records and pre-0.7.15
14010        // rows have no recorded assembled base. The typed
14011        // RuntimeContextAppend provenance on the persisted message still
14012        // admits the continuation through the machine fast path.
14013        let mut session = resumed_session_with_context_appended_prompt("base prompt");
14014        session
14015            .set_system_context_state(SessionSystemContextState::default())
14016            .expect("sweep applied records");
14017        let digest_before = transcript_messages_digest(session.messages()).unwrap();
14018
14019        let outcome = session
14020            .reconcile_resumed_system_prompt("base prompt".to_string(), None)
14021            .expect("reconcile");
14022
14023        assert_eq!(
14024            outcome,
14025            ResumedSystemPromptReconciliation::PreservedContinuation
14026        );
14027        assert_eq!(
14028            transcript_messages_digest(session.messages()).unwrap(),
14029            digest_before
14030        );
14031    }
14032
14033    #[test]
14034    fn reconcile_resumed_system_prompt_clears_orphaned_applied_records_on_tail_drop() {
14035        let mut session = resumed_session_with_context_appended_prompt("base prompt");
14036        // An out-of-band prompt mutation makes the applied records'
14037        // re-render no longer reproduce the persisted content (and no
14038        // assembled base was recorded): the tail is unverifiable and must be
14039        // dropped by the rewrite.
14040        session.set_system_prompt(format!(
14041            "mutated base{SYSTEM_CONTEXT_SEPARATOR}stale-looking tail"
14042        ));
14043
14044        let outcome = session
14045            .reconcile_resumed_system_prompt("new base prompt".to_string(), None)
14046            .expect("reconcile");
14047
14048        assert_eq!(outcome, ResumedSystemPromptReconciliation::RewrittenBase);
14049        assert_eq!(leading_system_content(&session), "new base prompt");
14050        let state = session.system_context_state().unwrap_or_default();
14051        assert!(
14052            state.applied.is_empty(),
14053            "orphaned applied records must be cleared so the context stays restorable"
14054        );
14055        assert!(
14056            state.seen.is_empty(),
14057            "orphaned idempotency keys must be cleared so keyed re-sends re-apply"
14058        );
14059
14060        // A host re-send of the same keyed append restores the context
14061        // instead of deduplicating against the dropped application.
14062        session.append_system_context_blocks(std::slice::from_ref(&roster_append()));
14063        assert!(
14064            leading_system_content(&session).contains("peer roster: lead-1, w-1"),
14065            "re-sent keyed context must re-apply after the drop"
14066        );
14067    }
14068
14069    #[test]
14070    fn append_system_context_blocks_renders_pre_marked_pending_context() {
14071        let accepted_at = SystemTime::UNIX_EPOCH;
14072        let mut state = SessionSystemContextState::default();
14073        state
14074            .stage_append(
14075                &AppendSystemContextRequest {
14076                    content: crate::lifecycle::run_primitive::CoreRenderable::text(
14077                        "Apply this staged context at the request boundary.".to_string(),
14078                    ),
14079                    source: Some("rpc/session_inject_context".to_string()),
14080                    idempotency_key: Some("ctx-boundary".to_string()),
14081                    source_kind: SystemContextSource::Normal,
14082                    peer_response_terminal: None,
14083                },
14084                accepted_at,
14085            )
14086            .expect("append should stage");
14087        let pending = state.pending.clone();
14088        state.mark_pending_applied();
14089        let mut session = Session::new();
14090        session
14091            .set_system_context_state(state)
14092            .expect("state should serialize");
14093
14094        session.append_system_context_blocks(&pending);
14095
14096        let system_prompt = session
14097            .messages()
14098            .first()
14099            .and_then(|message| match message {
14100                Message::System(system) => Some(system.content.as_str()),
14101                _ => None,
14102            })
14103            .unwrap_or_default();
14104        assert!(system_prompt.contains("Apply this staged context at the request boundary."));
14105        let state = session
14106            .system_context_state()
14107            .expect("append should persist typed context state");
14108        assert_eq!(state.applied.len(), 1);
14109        assert_eq!(
14110            state.seen["ctx-boundary"].state,
14111            SeenSystemContextState::Applied
14112        );
14113    }
14114
14115    #[test]
14116    fn append_system_context_blocks_renders_pre_marked_context_without_idempotency_key() {
14117        let accepted_at = SystemTime::UNIX_EPOCH;
14118        let mut state = SessionSystemContextState::default();
14119        state
14120            .stage_append(
14121                &AppendSystemContextRequest {
14122                    content: crate::lifecycle::run_primitive::CoreRenderable::text(
14123                        "Apply this unkeyed staged context at the request boundary.".to_string(),
14124                    ),
14125                    source: Some("rpc/session_inject_context".to_string()),
14126                    idempotency_key: None,
14127                    source_kind: SystemContextSource::Normal,
14128                    peer_response_terminal: None,
14129                },
14130                accepted_at,
14131            )
14132            .expect("append should stage");
14133        let pending = state.pending.clone();
14134        state.mark_pending_applied();
14135        let mut session = Session::new();
14136        session
14137            .set_system_context_state(state)
14138            .expect("state should serialize");
14139
14140        session.append_system_context_blocks(&pending);
14141
14142        let system_prompt = session
14143            .messages()
14144            .first()
14145            .and_then(|message| match message {
14146                Message::System(system) => Some(system.content.as_str()),
14147                _ => None,
14148            })
14149            .unwrap_or_default();
14150        assert!(
14151            system_prompt.contains("Apply this unkeyed staged context at the request boundary.")
14152        );
14153    }
14154
14155    /// K5 invariant: the typed `CoreRenderable` travels end-to-end through
14156    /// staging — the pending append stores the renderable itself, and the
14157    /// ONE lowering to prompt text happens at the transcript render seam.
14158    #[test]
14159    fn staged_system_context_carries_typed_renderable_to_render_seam() {
14160        use crate::lifecycle::run_primitive::CoreRenderable;
14161
14162        let accepted_at = SystemTime::UNIX_EPOCH;
14163        let mut state = SessionSystemContextState::default();
14164        let renderable = CoreRenderable::Json {
14165            value: serde_json::json!({"alert": "disk-full", "severity": 2}),
14166        };
14167        state
14168            .stage_append(
14169                &AppendSystemContextRequest {
14170                    content: renderable.clone(),
14171                    source: Some("ops/monitor".to_string()),
14172                    idempotency_key: Some("alert-1".to_string()),
14173                    source_kind: SystemContextSource::Normal,
14174                    peer_response_terminal: None,
14175                },
14176                accepted_at,
14177            )
14178            .expect("typed renderable append should stage");
14179
14180        // The pending append owns the typed renderable — no pre-flattened
14181        // text shadow exists anywhere on the staging path.
14182        assert_eq!(state.pending.len(), 1);
14183        assert_eq!(state.pending[0].content, renderable);
14184
14185        // Lowering happens exactly once, at the render seam, via the single
14186        // canonical projection.
14187        let rendered = render_system_context_block(&state.pending[0]);
14188        assert!(rendered.starts_with(SYSTEM_CONTEXT_RENDER_LABEL));
14189        assert!(
14190            rendered.contains(renderable.render_text().trim()),
14191            "render seam must lower via CoreRenderable::render_text: {rendered}"
14192        );
14193    }
14194
14195    #[test]
14196    fn append_system_context_blocks_skips_duplicate_idempotency_key() {
14197        let first = PendingSystemContextAppend {
14198            content: crate::lifecycle::run_primitive::CoreRenderable::text(
14199                "Authoritative peer token is birch seventeen.".to_string(),
14200            ),
14201            source: Some("peer_response_terminal:analyst:req-1".to_string()),
14202            idempotency_key: Some("req-1".to_string()),
14203            source_kind: SystemContextSource::Normal,
14204            peer_response_terminal: None,
14205            accepted_at: SystemTime::UNIX_EPOCH,
14206        };
14207        let duplicate = PendingSystemContextAppend {
14208            accepted_at: SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1),
14209            ..first.clone()
14210        };
14211        let mut session = Session::new();
14212
14213        session.append_system_context_blocks(std::slice::from_ref(&first));
14214        session.append_system_context_blocks(std::slice::from_ref(&duplicate));
14215
14216        let state = session
14217            .system_context_state()
14218            .expect("append should persist typed context state");
14219        assert_eq!(state.applied, vec![first]);
14220        let system_prompt = session
14221            .messages()
14222            .first()
14223            .and_then(|message| match message {
14224                Message::System(system) => Some(system.content.as_str()),
14225                _ => None,
14226            })
14227            .unwrap_or_default();
14228        assert_eq!(
14229            system_prompt
14230                .matches("Authoritative peer token is birch seventeen.")
14231                .count(),
14232            1
14233        );
14234    }
14235
14236    #[test]
14237    fn append_system_context_blocks_skips_conflicting_duplicate_idempotency_key() {
14238        let first = PendingSystemContextAppend {
14239            content: crate::lifecycle::run_primitive::CoreRenderable::text(
14240                "Authoritative peer token is birch seventeen.".to_string(),
14241            ),
14242            source: Some("peer_response_terminal:analyst:req-1".to_string()),
14243            idempotency_key: Some("req-1".to_string()),
14244            source_kind: SystemContextSource::Normal,
14245            peer_response_terminal: None,
14246            accepted_at: SystemTime::UNIX_EPOCH,
14247        };
14248        let conflicting = PendingSystemContextAppend {
14249            content: crate::lifecycle::run_primitive::CoreRenderable::text(
14250                "Conflicting peer token should not reach the prompt.".to_string(),
14251            ),
14252            accepted_at: SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1),
14253            ..first.clone()
14254        };
14255        let mut session = Session::new();
14256
14257        session.append_system_context_blocks(std::slice::from_ref(&first));
14258        session.append_system_context_blocks(std::slice::from_ref(&conflicting));
14259
14260        let state = session
14261            .system_context_state()
14262            .expect("append should persist typed context state");
14263        assert_eq!(state.applied, vec![first]);
14264        let system_prompt = session
14265            .messages()
14266            .first()
14267            .and_then(|message| match message {
14268                Message::System(system) => Some(system.content.as_str()),
14269                _ => None,
14270            })
14271            .unwrap_or_default();
14272        assert!(system_prompt.contains("Authoritative peer token is birch seventeen."));
14273        assert!(!system_prompt.contains("Conflicting peer token should not reach the prompt."));
14274    }
14275
14276    // ------------------------------------------------------------------
14277    // T9/T10: realtime transcript lane materialization.
14278    //
14279    // The display-text lane (`AssistantTextDelta`) materializes as
14280    // `AssistantBlock::Text`; the spoken-transcript lane
14281    // (`AssistantTranscriptDelta`) materializes as
14282    // `AssistantBlock::Transcript { source: TranscriptSource::Spoken }`.
14283    // These regressions pin both flushes and prove the materializer
14284    // dispatches on the per-item `TranscriptLane`.
14285    // ------------------------------------------------------------------
14286
14287    #[test]
14288    fn realtime_transcript_assistant_transcript_delta_materializes_transcript_block() {
14289        let mut session = Session::new();
14290
14291        let delta = RealtimeTranscriptEvent::AssistantTranscriptDelta {
14292            response_id: "resp_spoken".to_string(),
14293            delta_id: "evt_delta_spoken_1".to_string(),
14294            item_id: "item_spoken".to_string(),
14295            previous_item_id: None,
14296            content_index: 0,
14297            delta: "I said hi".to_string(),
14298        };
14299        assert!(
14300            session.append_realtime_transcript_event(delta).is_inert(),
14301            "delta alone is inert until turn-completed flushes"
14302        );
14303
14304        let terminal = RealtimeTranscriptEvent::AssistantTurnCompleted {
14305            response_id: "resp_spoken".to_string(),
14306            stop_reason: StopReason::EndTurn,
14307            usage: Usage::default(),
14308        };
14309        let outcome = session.append_realtime_transcript_event(terminal);
14310        assert_eq!(outcome.materialized_messages.len(), 1);
14311
14312        // T9/T10: must be a Transcript block, NOT Text.
14313        let messages = session.messages();
14314        assert_eq!(messages.len(), 1);
14315        match &messages[0] {
14316            Message::BlockAssistant(assistant) => {
14317                assert_eq!(assistant.blocks.len(), 1);
14318                match &assistant.blocks[0] {
14319                    AssistantBlock::Transcript { text, source, .. } => {
14320                        assert_eq!(text, "I said hi");
14321                        assert_eq!(*source, crate::types::TranscriptSource::Spoken);
14322                    }
14323                    other => unreachable!(
14324                        "AssistantTranscriptDelta must materialize as AssistantBlock::Transcript, got {other:?}"
14325                    ),
14326                }
14327            }
14328            other => unreachable!("expected BlockAssistant message, got {other:?}"),
14329        }
14330    }
14331
14332    #[test]
14333    fn round4_cc4_in_flight_response_ids_lists_distinct_unmaterialized_responses() {
14334        // CC4 (Round-4 architectural reconciliation): the helper that
14335        // powers `signal_turn_interrupt`'s cross-layer fan-out must
14336        // return every distinct provider response_id that has at least
14337        // one unmaterialized assistant item, EXCLUDING already-discarded
14338        // responses and EXCLUDING the user role.
14339        let mut session = Session::new();
14340
14341        // Two transcript-delta items on resp_a (different content_index
14342        // ranges), one on resp_b. resp_c gets a delta and is then
14343        // discarded explicitly via AssistantTurnInterrupted.
14344        for (i, response_id) in [
14345            ("resp_a", "resp_a"),
14346            ("resp_a_extra", "resp_a"),
14347            ("resp_b", "resp_b"),
14348            ("resp_c", "resp_c"),
14349        ]
14350        .iter()
14351        .enumerate()
14352        {
14353            let event = RealtimeTranscriptEvent::AssistantTranscriptDelta {
14354                response_id: response_id.1.to_string(),
14355                delta_id: format!("delta_{i}"),
14356                item_id: response_id.0.to_string(),
14357                previous_item_id: None,
14358                content_index: 0,
14359                delta: "x".to_string(),
14360            };
14361            let _ = session.append_realtime_transcript_event(event);
14362        }
14363
14364        // Discard resp_c — it should not appear in the in-flight list.
14365        let _ = session.append_realtime_transcript_event(
14366            RealtimeTranscriptEvent::AssistantTurnInterrupted {
14367                response_id: "resp_c".to_string(),
14368            },
14369        );
14370
14371        // User-role item should never appear (CC4 only fans interrupts
14372        // to assistant responses).
14373        let _ = session.append_realtime_transcript_event(
14374            RealtimeTranscriptEvent::UserTranscriptFinal {
14375                item_id: "u_item".to_string(),
14376                previous_item_id: None,
14377                content_index: 0,
14378                text: "hi".to_string(),
14379            },
14380        );
14381
14382        let in_flight = session.in_flight_realtime_assistant_response_ids();
14383        assert!(in_flight.contains(&"resp_a".to_string()), "{in_flight:?}");
14384        assert!(in_flight.contains(&"resp_b".to_string()), "{in_flight:?}");
14385        assert!(
14386            !in_flight.contains(&"resp_c".to_string()),
14387            "discarded response must not appear in in_flight: {in_flight:?}"
14388        );
14389        // resp_a appears exactly once even though two items reference it.
14390        assert_eq!(
14391            in_flight.iter().filter(|r| *r == "resp_a").count(),
14392            1,
14393            "distinct response_ids only: {in_flight:?}"
14394        );
14395    }
14396
14397    #[test]
14398    fn round4_cc2_assistant_turn_completed_after_transcript_deltas_materializes_transcript() {
14399        // CC2 (Round-4 architectural reconciliation): once
14400        // `signal_turn_completed` synthesizes
14401        // `RealtimeTranscriptEvent::AssistantTurnCompleted`, the staging
14402        // materializer commits every staged transcript-delta item for
14403        // that response_id as `AssistantBlock::Transcript { Spoken }`.
14404        // This pins the production end-to-end shape the sink relies on.
14405        let mut session = Session::new();
14406
14407        let delta = RealtimeTranscriptEvent::AssistantTranscriptDelta {
14408            response_id: "resp_cc2".to_string(),
14409            delta_id: "delta_cc2_1".to_string(),
14410            item_id: "item_cc2".to_string(),
14411            previous_item_id: None,
14412            content_index: 0,
14413            delta: "hello world".to_string(),
14414        };
14415        assert!(session.append_realtime_transcript_event(delta).is_inert());
14416
14417        // Pre-completion: in-flight list reports resp_cc2.
14418        assert_eq!(
14419            session.in_flight_realtime_assistant_response_ids(),
14420            vec!["resp_cc2".to_string()]
14421        );
14422
14423        let outcome = session.append_realtime_transcript_event(
14424            RealtimeTranscriptEvent::AssistantTurnCompleted {
14425                response_id: "resp_cc2".to_string(),
14426                stop_reason: StopReason::EndTurn,
14427                usage: Usage::default(),
14428            },
14429        );
14430        assert_eq!(outcome.materialized_messages.len(), 1);
14431
14432        // Post-completion: in-flight list is empty (item is materialized).
14433        assert!(
14434            session
14435                .in_flight_realtime_assistant_response_ids()
14436                .is_empty(),
14437            "materialized items must not appear in in_flight_realtime_assistant_response_ids"
14438        );
14439
14440        let messages = session.messages();
14441        let assistant = messages.iter().find_map(|m| match m {
14442            Message::BlockAssistant(a) => Some(a),
14443            _ => None,
14444        });
14445        let assistant = assistant.expect("assistant block message expected");
14446        assert_eq!(assistant.blocks.len(), 1);
14447        assert!(matches!(
14448            &assistant.blocks[0],
14449            AssistantBlock::Transcript {
14450                source: crate::types::TranscriptSource::Spoken,
14451                ..
14452            }
14453        ));
14454    }
14455
14456    #[test]
14457    fn realtime_transcript_assistant_text_delta_still_materializes_text_block() {
14458        // Counter-regression: the display-text lane must continue to
14459        // produce `AssistantBlock::Text` after T9/T10. Prevents an
14460        // accidental cross-lane flip.
14461        let mut session = Session::new();
14462
14463        let delta = RealtimeTranscriptEvent::AssistantTextDelta {
14464            response_id: "resp_display".to_string(),
14465            delta_id: "evt_delta_display_1".to_string(),
14466            item_id: "item_display".to_string(),
14467            previous_item_id: None,
14468            content_index: 0,
14469            delta: "I wrote".to_string(),
14470        };
14471        let _ = session.append_realtime_transcript_event(delta);
14472
14473        let terminal = RealtimeTranscriptEvent::AssistantTurnCompleted {
14474            response_id: "resp_display".to_string(),
14475            stop_reason: StopReason::EndTurn,
14476            usage: Usage::default(),
14477        };
14478        let outcome = session.append_realtime_transcript_event(terminal);
14479        assert_eq!(outcome.materialized_messages.len(), 1);
14480
14481        let messages = session.messages();
14482        match &messages[0] {
14483            Message::BlockAssistant(assistant) => match &assistant.blocks[0] {
14484                AssistantBlock::Text { text, .. } => assert_eq!(text, "I wrote"),
14485                other => unreachable!(
14486                    "AssistantTextDelta must keep materializing AssistantBlock::Text, got {other:?}"
14487                ),
14488            },
14489            other => unreachable!("expected BlockAssistant message, got {other:?}"),
14490        }
14491    }
14492
14493    #[test]
14494    fn round4_cc7_mixed_response_persists_text_and_transcript_in_order() {
14495        // CC7 (Round-4 adversarial-verifier follow-up): a single mixed-modality
14496        // realtime response that emits BOTH display-text deltas
14497        // (`AssistantTextDelta`) AND spoken-transcript deltas
14498        // (`AssistantTranscriptDelta`) under the same response_id must
14499        // materialize as ONE `Message::BlockAssistant` whose `blocks` field
14500        // contains exactly two ordered entries:
14501        //   1. AssistantBlock::Text       (display-text lane)
14502        //   2. AssistantBlock::Transcript { source: Spoken } (spoken lane)
14503        // Pre-fix the materializer emitted one Message::BlockAssistant per
14504        // staged item, splitting the mixed response into two messages.
14505        //
14506        // This test drives the production materializer end-to-end: deltas
14507        // stage in `SessionRealtimeTranscriptState`; `AssistantTurnCompleted`
14508        // triggers the materializer; canonical history is the assertion
14509        // surface — exactly the same code path that
14510        // `SessionServiceProjectionSink::signal_turn_completed` invokes via
14511        // `runtime.append_realtime_transcript_event` in production.
14512        let mut session = Session::new();
14513
14514        // Provider-arrival order: display first, then spoken.
14515        let display_a = RealtimeTranscriptEvent::AssistantTextDelta {
14516            response_id: "resp_mixed_1".to_string(),
14517            delta_id: "delta_disp_1".to_string(),
14518            item_id: "item_display".to_string(),
14519            previous_item_id: None,
14520            content_index: 0,
14521            delta: "Here's the report:".to_string(),
14522        };
14523        assert!(
14524            session
14525                .append_realtime_transcript_event(display_a)
14526                .is_inert()
14527        );
14528
14529        let display_b = RealtimeTranscriptEvent::AssistantTextDelta {
14530            response_id: "resp_mixed_1".to_string(),
14531            delta_id: "delta_disp_2".to_string(),
14532            item_id: "item_display".to_string(),
14533            previous_item_id: None,
14534            content_index: 0,
14535            delta: " (still writing)".to_string(),
14536        };
14537        assert!(
14538            session
14539                .append_realtime_transcript_event(display_b)
14540                .is_inert()
14541        );
14542
14543        // Spoken items chain after the display item to mirror provider
14544        // arrival semantics — `previous_item_id` carries arrival ordering
14545        // that the materializer must preserve as block ordering inside the
14546        // single emitted message.
14547        let spoken_a = RealtimeTranscriptEvent::AssistantTranscriptDelta {
14548            response_id: "resp_mixed_1".to_string(),
14549            delta_id: "delta_spoken_1".to_string(),
14550            item_id: "item_spoken".to_string(),
14551            previous_item_id: Some("item_display".to_string()),
14552            content_index: 0,
14553            delta: "I'm reading the report aloud:".to_string(),
14554        };
14555        assert!(
14556            session
14557                .append_realtime_transcript_event(spoken_a)
14558                .is_inert()
14559        );
14560
14561        let spoken_b = RealtimeTranscriptEvent::AssistantTranscriptDelta {
14562            response_id: "resp_mixed_1".to_string(),
14563            delta_id: "delta_spoken_2".to_string(),
14564            item_id: "item_spoken".to_string(),
14565            previous_item_id: Some("item_display".to_string()),
14566            content_index: 0,
14567            delta: " sentence two.".to_string(),
14568        };
14569        assert!(
14570            session
14571                .append_realtime_transcript_event(spoken_b)
14572                .is_inert()
14573        );
14574
14575        // TurnCompleted triggers the materializer to flush all staged items
14576        // for this response_id into ONE BlockAssistant message.
14577        let outcome = session.append_realtime_transcript_event(
14578            RealtimeTranscriptEvent::AssistantTurnCompleted {
14579                response_id: "resp_mixed_1".to_string(),
14580                stop_reason: StopReason::EndTurn,
14581                usage: Usage {
14582                    input_tokens: 11,
14583                    output_tokens: 22,
14584                    cache_creation_tokens: None,
14585                    cache_read_tokens: None,
14586                },
14587            },
14588        );
14589        // Materializer reports two staged items got materialized.
14590        assert_eq!(outcome.materialized_messages.len(), 2);
14591
14592        // Canonical history MUST contain exactly ONE BlockAssistant message
14593        // (the CC7 fix: mixed lanes interleave into one message, not two).
14594        let messages = session.messages();
14595        let assistants: Vec<&BlockAssistantMessage> = messages
14596            .iter()
14597            .filter_map(|m| match m {
14598                Message::BlockAssistant(a) => Some(a),
14599                _ => None,
14600            })
14601            .collect();
14602        assert_eq!(
14603            assistants.len(),
14604            1,
14605            "mixed display+spoken response under one response_id must produce exactly ONE BlockAssistant message, got: {assistants:?}"
14606        );
14607        let assistant = assistants[0];
14608        assert_eq!(
14609            assistant.blocks.len(),
14610            2,
14611            "mixed response message must carry both blocks: {:?}",
14612            assistant.blocks
14613        );
14614
14615        // Block 0: display-text (concatenated deltas).
14616        match &assistant.blocks[0] {
14617            AssistantBlock::Text { text, .. } => {
14618                assert_eq!(text, "Here's the report: (still writing)");
14619            }
14620            other => unreachable!(
14621                "first block must be AssistantBlock::Text (display lane), got {other:?}"
14622            ),
14623        }
14624        // Block 1: spoken transcript (concatenated deltas), tagged Spoken.
14625        match &assistant.blocks[1] {
14626            AssistantBlock::Transcript { text, source, .. } => {
14627                assert_eq!(text, "I'm reading the report aloud: sentence two.");
14628                assert_eq!(*source, crate::types::TranscriptSource::Spoken);
14629            }
14630            other => unreachable!(
14631                "second block must be AssistantBlock::Transcript {{ source: Spoken }}, got {other:?}"
14632            ),
14633        }
14634
14635        // Usage was recorded once for the turn.
14636        assert_eq!(session.usage.input_tokens, 11);
14637        assert_eq!(session.usage.output_tokens, 22);
14638    }
14639
14640    #[test]
14641    fn round5_r55_mixed_response_barge_in_preserves_display_drops_spoken() {
14642        // R5-5 (Round-5 contract update): barge-in MUST filter staged items
14643        // by lane — `Spoken` is invalidated (the user spoke over the audio
14644        // they were hearing) but `Display` survives as committed history
14645        // (sideband display text from the same response is not "spoken
14646        // over"). Round-4's `round4_cc7_mixed_response_barge_in_discards_*`
14647        // pinned the wrong invariant; this test replaces it.
14648        //
14649        // Architectural decision: `AssistantTurnInterrupted` is terminal for
14650        // the response on the realtime-staging path — any later
14651        // `AssistantTurnCompleted { stop_reason: Cancelled }` short-circuits
14652        // via the `discarded_assistant_response_ids` guard. So the
14653        // Interrupted handler must seed a synthetic
14654        // `assistant_completions` entry (`StopReason::Cancelled`,
14655        // `Usage::default()`) so retained Display items materialize
14656        // immediately rather than stranding forever.
14657        let mut session = Session::new();
14658
14659        let display = RealtimeTranscriptEvent::AssistantTextDelta {
14660            response_id: "resp_mixed_2".to_string(),
14661            delta_id: "delta_disp_1".to_string(),
14662            item_id: "item_display_2".to_string(),
14663            previous_item_id: None,
14664            content_index: 0,
14665            delta: "Working on the report...".to_string(),
14666        };
14667        let _ = session.append_realtime_transcript_event(display);
14668
14669        let spoken = RealtimeTranscriptEvent::AssistantTranscriptDelta {
14670            response_id: "resp_mixed_2".to_string(),
14671            delta_id: "delta_spoken_1".to_string(),
14672            item_id: "item_spoken_2".to_string(),
14673            previous_item_id: Some("item_display_2".to_string()),
14674            content_index: 0,
14675            delta: "I'm reading the report".to_string(),
14676        };
14677        let _ = session.append_realtime_transcript_event(spoken);
14678
14679        // Barge-in arrives BEFORE TurnCompleted. The Display item with
14680        // staged content materializes immediately under the synthetic
14681        // Cancelled completion.
14682        let outcome = session.append_realtime_transcript_event(
14683            RealtimeTranscriptEvent::AssistantTurnInterrupted {
14684                response_id: "resp_mixed_2".to_string(),
14685            },
14686        );
14687        assert_eq!(
14688            outcome.materialized_messages.len(),
14689            1,
14690            "Display lane item must materialize on Interrupted: {outcome:?}"
14691        );
14692
14693        // A late `AssistantTurnCompleted` (the provider's response.done
14694        // emitted after cancel) must be a no-op: the Display item is
14695        // already materialized; the Spoken item was dropped at Interrupted.
14696        let late_completion = session.append_realtime_transcript_event(
14697            RealtimeTranscriptEvent::AssistantTurnCompleted {
14698                response_id: "resp_mixed_2".to_string(),
14699                stop_reason: StopReason::Cancelled,
14700                usage: Usage::default(),
14701            },
14702        );
14703        assert_eq!(
14704            late_completion.materialized_messages.len(),
14705            0,
14706            "post-barge-in TurnCompleted must not resurrect anything"
14707        );
14708
14709        // Canonical history: exactly one BlockAssistant carrying the
14710        // Display text (no Transcript block — Spoken was dropped).
14711        let messages = session.messages();
14712        let assistants: Vec<&BlockAssistantMessage> = messages
14713            .iter()
14714            .filter_map(|m| match m {
14715                Message::BlockAssistant(a) => Some(a),
14716                _ => None,
14717            })
14718            .collect();
14719        assert_eq!(
14720            assistants.len(),
14721            1,
14722            "barge-in must commit exactly one BlockAssistant containing the Display lane: {assistants:?}"
14723        );
14724        let assistant = assistants[0];
14725        assert_eq!(assistant.blocks.len(), 1, "blocks: {:?}", assistant.blocks);
14726        match &assistant.blocks[0] {
14727            AssistantBlock::Text { text, .. } => {
14728                assert_eq!(text, "Working on the report...");
14729            }
14730            other => {
14731                unreachable!("Display lane must materialize as AssistantBlock::Text, got {other:?}")
14732            }
14733        }
14734        // No Transcript block — Spoken lane was dropped.
14735        assert!(
14736            !assistant
14737                .blocks
14738                .iter()
14739                .any(|b| matches!(b, AssistantBlock::Transcript { .. })),
14740            "Spoken lane must be dropped on barge-in"
14741        );
14742
14743        // The in-flight tracker reports the response as no longer in flight
14744        // (the Display item is materialized; the Spoken item is skipped).
14745        assert!(
14746            !session
14747                .in_flight_realtime_assistant_response_ids()
14748                .contains(&"resp_mixed_2".to_string()),
14749            "barged-in response must not appear in in_flight_realtime_assistant_response_ids"
14750        );
14751    }
14752
14753    #[test]
14754    fn round5_r55_barge_in_preserves_display_lane_drops_spoken() {
14755        // R5-5 unit test: pin the lane-filter behavior at the staged-item
14756        // level (no chained predecessor). One Display item, one Spoken item,
14757        // both unchained, both staged before Interrupted.
14758        let mut session = Session::new();
14759
14760        let _ =
14761            session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
14762                response_id: "resp_a".to_string(),
14763                delta_id: "delta_d_1".to_string(),
14764                item_id: "item_display".to_string(),
14765                previous_item_id: None,
14766                content_index: 0,
14767                delta: "display-text".to_string(),
14768            });
14769        let _ = session.append_realtime_transcript_event(
14770            RealtimeTranscriptEvent::AssistantTranscriptDelta {
14771                response_id: "resp_a".to_string(),
14772                delta_id: "delta_s_1".to_string(),
14773                item_id: "item_spoken".to_string(),
14774                previous_item_id: None,
14775                content_index: 0,
14776                delta: "spoken-transcript".to_string(),
14777            },
14778        );
14779
14780        let outcome = session.append_realtime_transcript_event(
14781            RealtimeTranscriptEvent::AssistantTurnInterrupted {
14782                response_id: "resp_a".to_string(),
14783            },
14784        );
14785        // Display materializes, Spoken does not.
14786        assert_eq!(outcome.materialized_messages.len(), 1);
14787
14788        let messages = session.messages();
14789        let assistants: Vec<&BlockAssistantMessage> = messages
14790            .iter()
14791            .filter_map(|m| match m {
14792                Message::BlockAssistant(a) => Some(a),
14793                _ => None,
14794            })
14795            .collect();
14796        assert_eq!(assistants.len(), 1);
14797        // Single Text block (the Display lane) — no Transcript.
14798        assert_eq!(assistants[0].blocks.len(), 1);
14799        match &assistants[0].blocks[0] {
14800            AssistantBlock::Text { text, .. } => assert_eq!(text, "display-text"),
14801            other => unreachable!("expected Text, got {other:?}"),
14802        }
14803    }
14804
14805    #[test]
14806    fn round5_r55_barge_in_finalizes_retained_display_into_committed_block() {
14807        // R5-5: the architectural decision — Interrupted is terminal for the
14808        // response. Display lane must commit at Interrupted time, not wait
14809        // on a hypothetical AssistantTurnCompleted that may never arrive
14810        // (or arrives Cancelled and short-circuits).
14811        let mut session = Session::new();
14812
14813        let _ =
14814            session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
14815                response_id: "resp_a".to_string(),
14816                delta_id: "delta_d_1".to_string(),
14817                item_id: "item_display".to_string(),
14818                previous_item_id: None,
14819                content_index: 0,
14820                delta: "committed-display-text".to_string(),
14821            });
14822
14823        // Pre-condition: nothing committed yet.
14824        assert!(session.messages().is_empty());
14825
14826        let outcome = session.append_realtime_transcript_event(
14827            RealtimeTranscriptEvent::AssistantTurnInterrupted {
14828                response_id: "resp_a".to_string(),
14829            },
14830        );
14831        assert_eq!(
14832            outcome.materialized_messages.len(),
14833            1,
14834            "Interrupted must finalize retained Display lane immediately"
14835        );
14836
14837        // Post-condition: BlockAssistant in canonical history, no Transcript.
14838        let messages = session.messages();
14839        assert_eq!(messages.len(), 1);
14840        match &messages[0] {
14841            Message::BlockAssistant(assistant) => {
14842                assert_eq!(assistant.blocks.len(), 1);
14843                match &assistant.blocks[0] {
14844                    AssistantBlock::Text { text, .. } => {
14845                        assert_eq!(text, "committed-display-text");
14846                    }
14847                    other => unreachable!("expected Text, got {other:?}"),
14848                }
14849            }
14850            other => unreachable!("expected BlockAssistant, got {other:?}"),
14851        }
14852    }
14853
14854    #[test]
14855    fn round5_r56_truncation_promotes_default_lane_item_to_spoken() {
14856        // R5-6: when truncation is the first content-bearing event for an
14857        // item (no prior delta), the staged item's lane MUST be promoted to
14858        // Spoken so the materializer commits as `AssistantBlock::Transcript`.
14859        // Without the explicit promotion, the lane stays `Display` (the
14860        // default) and the heard audio transcript persists as
14861        // `AssistantBlock::Text`.
14862        let mut session = Session::new();
14863
14864        let _ = session.append_realtime_transcript_event(
14865            RealtimeTranscriptEvent::AssistantTranscriptTruncated {
14866                response_id: "resp_a".to_string(),
14867                item_id: "item_a".to_string(),
14868                content_index: 0,
14869                text: "what was actually heard".to_string(),
14870            },
14871        );
14872
14873        let outcome = session.append_realtime_transcript_event(
14874            RealtimeTranscriptEvent::AssistantTurnCompleted {
14875                response_id: "resp_a".to_string(),
14876                stop_reason: StopReason::EndTurn,
14877                usage: Usage::default(),
14878            },
14879        );
14880        assert_eq!(outcome.materialized_messages.len(), 1);
14881
14882        assert_eq!(session.messages().len(), 1);
14883        match &session.messages()[0] {
14884            Message::BlockAssistant(assistant) => {
14885                assert_eq!(assistant.blocks.len(), 1);
14886                match &assistant.blocks[0] {
14887                    AssistantBlock::Transcript { text, source, .. } => {
14888                        assert_eq!(text, "what was actually heard");
14889                        assert_eq!(*source, crate::types::TranscriptSource::Spoken);
14890                    }
14891                    other => unreachable!(
14892                        "truncation-only path must materialize as AssistantBlock::Transcript, got {other:?}"
14893                    ),
14894                }
14895            }
14896            other => unreachable!("expected BlockAssistant, got {other:?}"),
14897        }
14898    }
14899
14900    #[test]
14901    fn round5_r56_truncation_after_display_delta_is_no_op_keeping_display_content() {
14902        // R5-6 edge case: a Display delta arrived first and staged Display
14903        // content; a truncation event arrives for the SAME item id
14904        // (provider bug — truncation only applies to spoken/audio output).
14905        // Contract: the staged Display content must NOT be clobbered by
14906        // the truncation text. `promote_item_lane` keeps the existing
14907        // Display lane and emits a `tracing::warn!`; the truncation arm
14908        // sees the lane stayed Display and skips the segment-write.
14909        let mut session = Session::new();
14910
14911        let _ =
14912            session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
14913                response_id: "resp_a".to_string(),
14914                delta_id: "delta_d_1".to_string(),
14915                item_id: "item_a".to_string(),
14916                previous_item_id: None,
14917                content_index: 0,
14918                delta: "display-text-from-delta".to_string(),
14919            });
14920
14921        let _ = session.append_realtime_transcript_event(
14922            RealtimeTranscriptEvent::AssistantTranscriptTruncated {
14923                response_id: "resp_a".to_string(),
14924                item_id: "item_a".to_string(),
14925                content_index: 0,
14926                text: "spoken-truncation-text".to_string(),
14927            },
14928        );
14929
14930        let _ = session.append_realtime_transcript_event(
14931            RealtimeTranscriptEvent::AssistantTurnCompleted {
14932                response_id: "resp_a".to_string(),
14933                stop_reason: StopReason::EndTurn,
14934                usage: Usage::default(),
14935            },
14936        );
14937
14938        // Display content survives unchanged — the truncation text was
14939        // refused. Materializes as `AssistantBlock::Text` (Display lane).
14940        assert_eq!(session.messages().len(), 1);
14941        match &session.messages()[0] {
14942            Message::BlockAssistant(assistant) => {
14943                assert_eq!(assistant.blocks.len(), 1);
14944                match &assistant.blocks[0] {
14945                    AssistantBlock::Text { text, .. } => {
14946                        assert_eq!(text, "display-text-from-delta");
14947                    }
14948                    other => unreachable!(
14949                        "Display content must survive misrouted truncation, got {other:?}"
14950                    ),
14951                }
14952            }
14953            other => unreachable!("expected BlockAssistant, got {other:?}"),
14954        }
14955    }
14956
14957    /// R5-6 sibling: a Spoken-classified item (transcript-truncation
14958    /// arrived first and locked the lane to Spoken) must reject a later
14959    /// `AssistantTextDelta` rather than silently appending the Display
14960    /// text into the Spoken-locked content_segment. Pre-fix the delta
14961    /// arm called `promote_item_lane` and unconditionally pushed the
14962    /// delta — clobbering the lane invariant. Post-fix the delta is
14963    /// dropped (warn fires) and the Spoken-truncation text survives.
14964    #[test]
14965    fn round5_r56_sibling_display_delta_skipped_on_spoken_item() {
14966        let mut session = Session::new();
14967
14968        // Truncation arrives first and locks the item to the Spoken lane.
14969        let _ = session.append_realtime_transcript_event(
14970            RealtimeTranscriptEvent::AssistantTranscriptTruncated {
14971                response_id: "resp_a".to_string(),
14972                item_id: "item_a".to_string(),
14973                content_index: 0,
14974                text: "what was actually heard".to_string(),
14975            },
14976        );
14977
14978        // A Display delta arrives later for the SAME item id (provider
14979        // lane-classification bug). It MUST be dropped.
14980        let _ =
14981            session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
14982                response_id: "resp_a".to_string(),
14983                delta_id: "delta_d_1".to_string(),
14984                item_id: "item_a".to_string(),
14985                previous_item_id: None,
14986                content_index: 0,
14987                delta: "should-not-appear".to_string(),
14988            });
14989
14990        let _ = session.append_realtime_transcript_event(
14991            RealtimeTranscriptEvent::AssistantTurnCompleted {
14992                response_id: "resp_a".to_string(),
14993                stop_reason: StopReason::EndTurn,
14994                usage: Usage::default(),
14995            },
14996        );
14997
14998        // The Spoken-truncation text survives intact; no Display text
14999        // leaked into the Spoken lane content.
15000        assert_eq!(session.messages().len(), 1);
15001        match &session.messages()[0] {
15002            Message::BlockAssistant(assistant) => {
15003                assert_eq!(assistant.blocks.len(), 1);
15004                match &assistant.blocks[0] {
15005                    AssistantBlock::Transcript { text, source, .. } => {
15006                        assert_eq!(text, "what was actually heard");
15007                        assert_eq!(*source, crate::types::TranscriptSource::Spoken);
15008                    }
15009                    other => unreachable!(
15010                        "Spoken-locked item must materialize as Transcript, got {other:?}"
15011                    ),
15012                }
15013            }
15014            other => unreachable!("expected BlockAssistant, got {other:?}"),
15015        }
15016    }
15017
15018    /// R5-6 sibling: a Display-classified item (a Display delta arrived
15019    /// first and locked the lane to Display) must reject a later
15020    /// `AssistantTranscriptDelta` rather than appending the Spoken text
15021    /// into the Display-locked content_segment. Pre-fix the transcript
15022    /// delta arm called `promote_item_lane` and unconditionally pushed —
15023    /// silently mixing a Spoken stream into a Display block.
15024    #[test]
15025    fn round5_r56_sibling_spoken_delta_skipped_on_display_item() {
15026        let mut session = Session::new();
15027
15028        // Display delta arrives first and locks the item to the Display lane.
15029        let _ =
15030            session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
15031                response_id: "resp_a".to_string(),
15032                delta_id: "delta_d_1".to_string(),
15033                item_id: "item_a".to_string(),
15034                previous_item_id: None,
15035                content_index: 0,
15036                delta: "display-locked-text".to_string(),
15037            });
15038
15039        // A spoken-transcript delta arrives later for the SAME item id
15040        // (provider lane-classification bug). It MUST be dropped.
15041        let _ = session.append_realtime_transcript_event(
15042            RealtimeTranscriptEvent::AssistantTranscriptDelta {
15043                response_id: "resp_a".to_string(),
15044                delta_id: "delta_s_1".to_string(),
15045                item_id: "item_a".to_string(),
15046                previous_item_id: None,
15047                content_index: 0,
15048                delta: "should-not-appear".to_string(),
15049            },
15050        );
15051
15052        let _ = session.append_realtime_transcript_event(
15053            RealtimeTranscriptEvent::AssistantTurnCompleted {
15054                response_id: "resp_a".to_string(),
15055                stop_reason: StopReason::EndTurn,
15056                usage: Usage::default(),
15057            },
15058        );
15059
15060        // The Display text survives intact; no Spoken text leaked in.
15061        assert_eq!(session.messages().len(), 1);
15062        match &session.messages()[0] {
15063            Message::BlockAssistant(assistant) => {
15064                assert_eq!(assistant.blocks.len(), 1);
15065                match &assistant.blocks[0] {
15066                    AssistantBlock::Text { text, .. } => {
15067                        assert_eq!(text, "display-locked-text");
15068                    }
15069                    other => {
15070                        unreachable!("Display-locked item must materialize as Text, got {other:?}")
15071                    }
15072                }
15073            }
15074            other => unreachable!("expected BlockAssistant, got {other:?}"),
15075        }
15076    }
15077
15078    /// R5-7: a late `AssistantTranscriptFinalText` arriving AFTER
15079    /// `AssistantTurnCompleted` already materialized the item must NOT
15080    /// mutate `content_segments` and must NOT rewrite the canonical
15081    /// `Message::BlockAssistant` (append-only history is a stronger
15082    /// invariant than typed text repair). The committed message keeps
15083    /// the delta-accumulated text; the late final is dropped with a
15084    /// warn; the materializer outcome is inert (no new messages).
15085    #[test]
15086    fn round5_r57_late_final_text_after_turn_completed_warns_and_skips() {
15087        let mut session = Session::new();
15088
15089        // Delta accumulates partial text on the Spoken lane.
15090        let _ = session.append_realtime_transcript_event(
15091            RealtimeTranscriptEvent::AssistantTranscriptDelta {
15092                response_id: "resp_a".to_string(),
15093                delta_id: "delta_s_1".to_string(),
15094                item_id: "item_a".to_string(),
15095                previous_item_id: None,
15096                content_index: 0,
15097                delta: "delta-accumulated".to_string(),
15098            },
15099        );
15100
15101        // TurnCompleted materializes the item with the delta-accumulated text.
15102        let commit_outcome = session.append_realtime_transcript_event(
15103            RealtimeTranscriptEvent::AssistantTurnCompleted {
15104                response_id: "resp_a".to_string(),
15105                stop_reason: StopReason::EndTurn,
15106                usage: Usage::default(),
15107            },
15108        );
15109        assert_eq!(commit_outcome.materialized_messages.len(), 1);
15110
15111        // Late FinalText arrives — provider-side ordering bug. It MUST
15112        // be dropped: no canonical message rewrite, no segment mutation,
15113        // outcome is inert.
15114        let late_outcome = session.append_realtime_transcript_event(
15115            RealtimeTranscriptEvent::AssistantTranscriptFinalText {
15116                response_id: "resp_a".to_string(),
15117                item_id: "item_a".to_string(),
15118                content_index: 0,
15119                text: "authoritative-final-that-must-not-land".to_string(),
15120            },
15121        );
15122        assert!(
15123            late_outcome.is_inert(),
15124            "late FinalText after materialization must produce inert outcome"
15125        );
15126
15127        // Canonical history: still one message with the original
15128        // delta-accumulated text — NOT the authoritative final.
15129        assert_eq!(session.messages().len(), 1);
15130        match &session.messages()[0] {
15131            Message::BlockAssistant(assistant) => {
15132                assert_eq!(assistant.blocks.len(), 1);
15133                match &assistant.blocks[0] {
15134                    AssistantBlock::Transcript { text, .. } => {
15135                        assert_eq!(
15136                            text, "delta-accumulated",
15137                            "canonical message must preserve delta-accumulated text; \
15138                             append-only history forbids late FinalText repair"
15139                        );
15140                    }
15141                    other => unreachable!("expected Transcript, got {other:?}"),
15142                }
15143            }
15144            other => unreachable!("expected BlockAssistant, got {other:?}"),
15145        }
15146    }
15147
15148    fn metadata_seam_session_metadata() -> SessionMetadata {
15149        SessionMetadata {
15150            schema_version: SESSION_METADATA_SCHEMA_VERSION,
15151            model: "test-model".to_string(),
15152            max_tokens: 1024,
15153            structured_output_retries: 2,
15154            provider: Provider::Anthropic,
15155            self_hosted_server_id: None,
15156            provider_params: None,
15157            tooling: SessionTooling::default(),
15158            keep_alive: false,
15159            comms_name: Some("team/reviewer/alice".to_string()),
15160            peer_meta: None,
15161            realm_id: None,
15162            instance_id: None,
15163            backend: None,
15164            config_generation: None,
15165            auth_binding: None,
15166            mob_member_binding: Some(crate::MobMemberBinding {
15167                mob_id: "team".to_string(),
15168                role: "reviewer".to_string(),
15169                member: "alice".to_string(),
15170            }),
15171        }
15172    }
15173
15174    /// Lockstep pin: the metadata-only partial decode must read the exact
15175    /// envelope that `SessionSerde` writes. If a field rename or serde-shape
15176    /// change lands on the full envelope without the partial decoder
15177    /// following, this test fails.
15178    #[test]
15179    fn session_metadata_document_lockstep_with_full_envelope() {
15180        let mut session = Session::new();
15181        session.push(Message::User(UserMessage::text("hello".to_string())));
15182        session
15183            .set_session_metadata(metadata_seam_session_metadata())
15184            .expect("session metadata should persist");
15185        session
15186            .set_lifecycle_terminal(SessionLifecycleTerminal::Archived)
15187            .expect("lifecycle terminal should persist");
15188
15189        let bytes = serde_json::to_vec(&session).expect("session should serialize");
15190        let document = session_metadata_document_from_slice(&bytes)
15191            .expect("partial decode must accept the canonical envelope");
15192
15193        assert_eq!(document.session_id(), session.id());
15194        assert_eq!(
15195            document.session_metadata_value(),
15196            session.metadata().get(SESSION_METADATA_KEY),
15197            "partial decode must project the identical raw session-metadata value"
15198        );
15199        assert_eq!(
15200            document.lifecycle_terminal_value(),
15201            session.metadata().get(SESSION_LIFECYCLE_TERMINAL_KEY),
15202            "partial decode must project the identical raw lifecycle-terminal value"
15203        );
15204
15205        let view = document
15206            .try_into_view()
15207            .expect("typed view must decode from the partial document");
15208        let full_view =
15209            PersistedSessionMetadataView::try_from_session(&session).expect("full-session view");
15210        assert_eq!(view.session_id, full_view.session_id);
15211        assert_eq!(
15212            view.session_metadata.as_ref().map(|m| m.model.clone()),
15213            full_view.session_metadata.as_ref().map(|m| m.model.clone())
15214        );
15215        assert_eq!(
15216            view.mob_member_binding(),
15217            full_view.mob_member_binding(),
15218            "typed binding must be identical across the two decode paths"
15219        );
15220        assert_eq!(
15221            view.lifecycle_terminal,
15222            Some(SessionLifecycleTerminal::Archived)
15223        );
15224        assert_eq!(
15225            full_view.lifecycle_terminal,
15226            Some(SessionLifecycleTerminal::Archived)
15227        );
15228    }
15229
15230    /// The metadata-only partial decode fails closed on an unsupported
15231    /// envelope version — same contract as the full deserializer.
15232    #[test]
15233    fn session_metadata_document_fails_closed_on_envelope_version() {
15234        let session = Session::new();
15235        let mut value = serde_json::to_value(&session).expect("session should serialize");
15236        value["version"] = serde_json::json!(SESSION_VERSION + 999);
15237        let bytes = serde_json::to_vec(&value).expect("mangled envelope should serialize");
15238
15239        session_metadata_document_from_slice(&bytes)
15240            .expect_err("an unsupported envelope version must fail the partial decode closed");
15241    }
15242
15243    /// Corrupt values under either reserved key are a read FAULT for the
15244    /// metadata view — never coalesced into "absent".
15245    #[test]
15246    fn persisted_session_metadata_view_fails_closed_on_corrupt_values() {
15247        let session_id = SessionId::new();
15248
15249        let mut corrupt_metadata = serde_json::Map::new();
15250        corrupt_metadata.insert(SESSION_METADATA_KEY.to_string(), serde_json::json!(42));
15251        PersistedSessionMetadataView::try_from_metadata_map(session_id.clone(), &corrupt_metadata)
15252            .expect_err("corrupt session_metadata must fail the view decode closed");
15253
15254        let mut corrupt_terminal = serde_json::Map::new();
15255        corrupt_terminal.insert(
15256            SESSION_LIFECYCLE_TERMINAL_KEY.to_string(),
15257            serde_json::json!("definitely-not-a-terminal"),
15258        );
15259        PersistedSessionMetadataView::try_from_metadata_map(session_id, &corrupt_terminal)
15260            .expect_err("corrupt lifecycle terminal must fail the view decode closed");
15261    }
15262
15263    /// Absent reserved keys decode as typed absence through the view.
15264    #[test]
15265    fn persisted_session_metadata_view_reads_absent_facts_as_none() {
15266        let view = PersistedSessionMetadataView::try_from_metadata_map(
15267            SessionId::new(),
15268            &serde_json::Map::new(),
15269        )
15270        .expect("empty metadata map must decode");
15271        assert!(view.session_metadata.is_none());
15272        assert!(view.lifecycle_terminal.is_none());
15273        assert!(view.mob_member_binding().is_none());
15274    }
15275}