Skip to main content

bamboo_domain/session/
compatibility.rs

1//! Read-only assessment of the behavior contract retained by one Session.
2//!
3//! A legacy record without a stamp uses the existing supported contract. This
4//! is neither a migration nor an authority grant. New tool semantics require a
5//! retained operation contract. Fresh spawn calls use the current Root entry;
6//! historical operations keep their frozen owner and semantic provenance.
7
8use serde::{Deserialize, Serialize};
9use std::fmt;
10
11use super::{
12    AgentRuntimeState, Session, AGENT_RUNTIME_STATE_VERSION, MODEL_CONTEXT_SCHEMA_VERSION,
13};
14
15pub const CURRENT_SESSION_VERSION: u32 = 2;
16
17/// Missing/v1 retain the same historical operation contract. v2 originally
18/// introduced independent Root spawning; retained snapshots remain distinct
19/// even though fresh spawn routing now uses the Root entry for all three.
20pub fn same_session_tool_contract(left: Option<u32>, right: Option<u32>) -> bool {
21    matches!(
22        (left, right),
23        (None | Some(1), None | Some(1)) | (Some(2), Some(2))
24    )
25}
26
27pub fn independent_root_spawn_reference(reference: &str) -> bool {
28    let name = reference
29        .trim()
30        .rsplit("::")
31        .next()
32        .unwrap_or(reference)
33        .trim();
34    name.eq_ignore_ascii_case("spawn_session") || name.eq_ignore_ascii_case("spawnSession")
35}
36
37/// Fresh calls use an installed independent-Root entry for every supported
38/// Session version. Exact registrations still win; no Root owner means
39/// unavailable, never a fallback to SubAgent. Retained approval replay uses its
40/// frozen operation owner instead of this fresh-call resolver.
41pub fn resolve_session_tool_reference(
42    version: Option<u32>,
43    reference: &str,
44    mut contains: impl FnMut(&str) -> bool,
45) -> Option<String> {
46    if !supported(version) {
47        return None;
48    }
49    if independent_root_spawn_reference(reference) {
50        let exact = reference.trim();
51        if contains(exact) {
52            return Some(exact.to_string());
53        }
54        let unqualified = exact.rsplit("::").next().unwrap_or(exact).trim();
55        if unqualified != exact && contains(unqualified) {
56            return Some(unqualified.to_string());
57        }
58        return contains("spawn_session").then(|| "spawn_session".to_string());
59    }
60    crate::resolve_tool_reference_name(reference, contains)
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(rename_all = "snake_case")]
65pub enum SessionCompatibilityStatus {
66    Compatible,
67    LegacyCompatible,
68    Conflict,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72pub struct SessionCompatibilityConflict {
73    pub code: String,
74    pub message: String,
75}
76
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
78pub struct SessionCompatibilityReport {
79    pub session_version: Option<u32>,
80    pub current_session_version: u32,
81    pub status: SessionCompatibilityStatus,
82    pub history_readable: bool,
83    pub execution_allowed: bool,
84    pub conflicts: Vec<SessionCompatibilityConflict>,
85}
86
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct SessionCompatibilityError {
89    pub compatibility: SessionCompatibilityReport,
90}
91
92impl fmt::Display for SessionCompatibilityError {
93    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
94        f.write_str("session_execution_incompatible: ")?;
95        for (index, conflict) in self.compatibility.conflicts.iter().enumerate() {
96            if index != 0 {
97                f.write_str("; ")?;
98            }
99            write!(f, "{}: {}", conflict.code, conflict.message)?;
100        }
101        f.write_str(" Read the existing history with a compatible runtime, or start a new session with selected context and an explicit workspace. No state was migrated.")
102    }
103}
104
105impl std::error::Error for SessionCompatibilityError {}
106
107fn supported(version: Option<u32>) -> bool {
108    matches!(version, None | Some(1) | Some(2))
109}
110
111impl Session {
112    /// Historical v2 stamp used to assess retained operation ambiguity and
113    /// snapshot compatibility. Fresh tool routing is independent of this flag.
114    pub fn independent_root_spawn_contract(&self) -> bool {
115        self.session_version == Some(2)
116    }
117
118    /// Called only by the storage overlay: Main owns the behavior stamp, while
119    /// a conflicting runtime observation must remain visible to execution.
120    /// Absence and v1 have the same existing semantics, so that pair is safe.
121    pub fn observe_main_session_version(&mut self, main_version: Option<u32>) {
122        self.observed_runtime_session_version = Some(self.session_version);
123        self.session_version = main_version;
124    }
125
126    pub fn execution_compatibility(&self) -> SessionCompatibilityReport {
127        let mut conflicts = Vec::new();
128        if let Some(version) = self
129            .session_version
130            .filter(|_| !supported(self.session_version))
131        {
132            conflicts.push(SessionCompatibilityConflict {
133                code: "unsupported_session_version".into(),
134                message: format!("Session behavior version {version} is not supported; this runtime supports versions 1 and {CURRENT_SESSION_VERSION} and compatible legacy records."),
135            });
136        }
137        if let Some(runtime_version) = self.observed_runtime_session_version {
138            if runtime_version != self.session_version
139                && !same_session_tool_contract(runtime_version, self.session_version)
140            {
141                conflicts.push(SessionCompatibilityConflict {
142                    code: "session_version_mismatch".into(),
143                    message: format!("Canonical Session behavior {:?} and retained runtime behavior {:?} conflict.", self.session_version, runtime_version),
144                });
145            }
146        }
147        if let Some(AgentRuntimeState { version, .. }) = &self.agent_runtime_state {
148            if *version != AGENT_RUNTIME_STATE_VERSION {
149                conflicts.push(SessionCompatibilityConflict {
150                    code: "unsupported_runtime_state_version".into(),
151                    message: format!("Retained execution-state schema {version} is not supported; expected {AGENT_RUNTIME_STATE_VERSION}."),
152                });
153            }
154        }
155        if let Some(context) = &self.model_context_state {
156            if context.schema_version != MODEL_CONTEXT_SCHEMA_VERSION {
157                conflicts.push(SessionCompatibilityConflict {
158                    code: "unsupported_model_context_version".into(),
159                    message: format!("Retained model-context schema {} is not supported; expected {MODEL_CONTEXT_SCHEMA_VERSION}.", context.schema_version),
160                });
161            }
162        }
163        let execution_allowed = conflicts.is_empty();
164        SessionCompatibilityReport {
165            session_version: self.session_version,
166            current_session_version: CURRENT_SESSION_VERSION,
167            status: if !execution_allowed {
168                SessionCompatibilityStatus::Conflict
169            } else if self.session_version.is_none() {
170                SessionCompatibilityStatus::LegacyCompatible
171            } else {
172                SessionCompatibilityStatus::Compatible
173            },
174            history_readable: true,
175            execution_allowed,
176            conflicts,
177        }
178    }
179
180    pub fn check_execution_compatibility(&self) -> Result<(), SessionCompatibilityError> {
181        let compatibility = self.execution_compatibility();
182        if compatibility.execution_allowed {
183            Ok(())
184        } else {
185            Err(SessionCompatibilityError { compatibility })
186        }
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193    use crate::{Message, SessionAuthorityIdentity};
194
195    #[test]
196    fn missing_stamp_is_assessed_and_compatible_without_mutation() {
197        let mut session = Session::new("legacy", "model");
198        session.session_version = None;
199        session.add_message(Message::user("keep history"));
200        let before = serde_json::to_value(&session).unwrap();
201        let report = session.execution_compatibility();
202        assert_eq!(report.status, SessionCompatibilityStatus::LegacyCompatible);
203        assert!(report.execution_allowed && report.history_readable);
204        session.check_execution_compatibility().unwrap();
205        assert_eq!(serde_json::to_value(&session).unwrap(), before);
206    }
207
208    #[test]
209    fn current_and_legacy_contracts_preserve_mode_and_identity_boundaries() {
210        for version in [None, Some(CURRENT_SESSION_VERSION)] {
211            for ultra in [false, true] {
212                for supervisor in [false, true] {
213                    let mut session = Session::new(
214                        if supervisor {
215                            crate::DEFAULT_SUPERVISOR_SESSION_ID
216                        } else {
217                            "root"
218                        },
219                        "model",
220                    );
221                    session.session_version = version;
222                    session.set_root_orchestration_only(ultra).unwrap();
223                    if supervisor {
224                        session.authority_identity = SessionAuthorityIdentity::Supervisor {
225                            incarnation_id: uuid::Uuid::new_v4(),
226                        };
227                    }
228                    let before = serde_json::to_value(&session).unwrap();
229                    assert!(session.execution_compatibility().execution_allowed);
230                    session.check_execution_compatibility().unwrap();
231                    assert_eq!(serde_json::to_value(&session).unwrap(), before);
232                    assert_eq!(
233                        session.allows_model_tool_execution("Bash"),
234                        !ultra && !supervisor
235                    );
236                    let mut child = Session::new_child_of("child", &session, "model", "child");
237                    child.session_version = version;
238                    assert!(child.execution_compatibility().execution_allowed);
239                    assert!(child.allows_model_tool_execution("Bash"));
240                }
241            }
242        }
243    }
244
245    #[test]
246    fn unsupported_versions_are_diagnostics_not_deserialization_failures() {
247        for version in [0, CURRENT_SESSION_VERSION + 1, u32::MAX] {
248            let mut session = Session::new("future", "model");
249            session.session_version = Some(version);
250            let before = serde_json::to_value(&session).unwrap();
251            let reloaded: Session = serde_json::from_value(before.clone()).unwrap();
252            let report = reloaded.execution_compatibility();
253            assert!(report.history_readable);
254            assert!(!report.execution_allowed);
255            assert_eq!(report.conflicts[0].code, "unsupported_session_version");
256            assert!(reloaded
257                .check_execution_compatibility()
258                .unwrap_err()
259                .to_string()
260                .contains("session_execution_incompatible"));
261            assert_eq!(serde_json::to_value(reloaded).unwrap(), before);
262        }
263    }
264
265    #[test]
266    fn runtime_overlay_cannot_hide_an_unsupported_main_or_side_stamp() {
267        for (main, runtime) in [(Some(99), None), (Some(1), Some(99)), (None, Some(99))] {
268            let mut side = Session::new("pair", "model");
269            side.session_version = runtime;
270            side.observe_main_session_version(main);
271            assert_eq!(side.session_version, main);
272            assert!(!side.execution_compatibility().execution_allowed);
273        }
274        for (main, runtime) in [(None, Some(1)), (Some(1), None), (None, None)] {
275            let mut side = Session::new("pair", "model");
276            side.session_version = runtime;
277            side.observe_main_session_version(main);
278            assert_eq!(side.session_version, main);
279            assert!(side.execution_compatibility().execution_allowed);
280        }
281    }
282
283    #[test]
284    fn unsupported_retained_model_context_is_refused_without_reset() {
285        let mut session = Session::new("future-context", "model");
286        session.session_version = None;
287        let context = super::super::ModelContextState {
288            schema_version: 99,
289            state_revision: 7,
290            prefix_epoch: 3,
291            ..Default::default()
292        };
293        session.model_context_state = Some(context);
294        let before = serde_json::to_value(&session).unwrap();
295        let report = session.execution_compatibility();
296        assert!(!report.execution_allowed);
297        assert_eq!(
298            report.conflicts[0].code,
299            "unsupported_model_context_version"
300        );
301        assert_eq!(serde_json::to_value(session).unwrap(), before);
302    }
303
304    #[test]
305    fn unsupported_retained_runtime_state_is_refused_without_reset() {
306        let mut session = Session::new("future-runtime", "model");
307        session.session_version = None;
308        let mut state = AgentRuntimeState::new("retained-execution");
309        state.version = 99;
310        session.agent_runtime_state = Some(state);
311        let before = serde_json::to_value(&session).unwrap();
312        let report = session.execution_compatibility();
313        assert!(!report.execution_allowed);
314        assert_eq!(
315            report.conflicts[0].code,
316            "unsupported_runtime_state_version"
317        );
318        assert_eq!(serde_json::to_value(session).unwrap(), before);
319    }
320}
321
322#[cfg(test)]
323#[path = "versioned_spawn_tests.rs"]
324mod versioned_spawn_tests;