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//! separately implemented version; an old tool spelling never changes meaning
6//! merely because this process is newer.
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 = 1;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(rename_all = "snake_case")]
19pub enum SessionCompatibilityStatus {
20    Compatible,
21    LegacyCompatible,
22    Conflict,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26pub struct SessionCompatibilityConflict {
27    pub code: String,
28    pub message: String,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32pub struct SessionCompatibilityReport {
33    pub session_version: Option<u32>,
34    pub current_session_version: u32,
35    pub status: SessionCompatibilityStatus,
36    pub history_readable: bool,
37    pub execution_allowed: bool,
38    pub conflicts: Vec<SessionCompatibilityConflict>,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct SessionCompatibilityError {
43    pub compatibility: SessionCompatibilityReport,
44}
45
46impl fmt::Display for SessionCompatibilityError {
47    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
48        f.write_str("session_execution_incompatible: ")?;
49        for (index, conflict) in self.compatibility.conflicts.iter().enumerate() {
50            if index != 0 {
51                f.write_str("; ")?;
52            }
53            write!(f, "{}: {}", conflict.code, conflict.message)?;
54        }
55        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.")
56    }
57}
58
59impl std::error::Error for SessionCompatibilityError {}
60
61fn supported(version: Option<u32>) -> bool {
62    version.is_none_or(|version| version == CURRENT_SESSION_VERSION)
63}
64
65impl Session {
66    /// Called only by the storage overlay: Main owns the behavior stamp, while
67    /// a conflicting runtime observation must remain visible to execution.
68    /// Absence and v1 have the same existing semantics, so that pair is safe.
69    pub fn observe_main_session_version(&mut self, main_version: Option<u32>) {
70        self.observed_runtime_session_version = Some(self.session_version);
71        self.session_version = main_version;
72    }
73
74    pub fn execution_compatibility(&self) -> SessionCompatibilityReport {
75        let mut conflicts = Vec::new();
76        if let Some(version) = self
77            .session_version
78            .filter(|_| !supported(self.session_version))
79        {
80            conflicts.push(SessionCompatibilityConflict {
81                code: "unsupported_session_version".into(),
82                message: format!("Session behavior version {version} is not supported; this runtime supports version {CURRENT_SESSION_VERSION} and compatible legacy records."),
83            });
84        }
85        if let Some(runtime_version) = self.observed_runtime_session_version {
86            if runtime_version != self.session_version
87                && !(supported(runtime_version) && supported(self.session_version))
88            {
89                conflicts.push(SessionCompatibilityConflict {
90                    code: "session_version_mismatch".into(),
91                    message: format!("Canonical Session behavior {:?} and retained runtime behavior {:?} conflict.", self.session_version, runtime_version),
92                });
93            }
94        }
95        if let Some(AgentRuntimeState { version, .. }) = &self.agent_runtime_state {
96            if *version != AGENT_RUNTIME_STATE_VERSION {
97                conflicts.push(SessionCompatibilityConflict {
98                    code: "unsupported_runtime_state_version".into(),
99                    message: format!("Retained execution-state schema {version} is not supported; expected {AGENT_RUNTIME_STATE_VERSION}."),
100                });
101            }
102        }
103        if let Some(context) = &self.model_context_state {
104            if context.schema_version != MODEL_CONTEXT_SCHEMA_VERSION {
105                conflicts.push(SessionCompatibilityConflict {
106                    code: "unsupported_model_context_version".into(),
107                    message: format!("Retained model-context schema {} is not supported; expected {MODEL_CONTEXT_SCHEMA_VERSION}.", context.schema_version),
108                });
109            }
110        }
111        let execution_allowed = conflicts.is_empty();
112        SessionCompatibilityReport {
113            session_version: self.session_version,
114            current_session_version: CURRENT_SESSION_VERSION,
115            status: if !execution_allowed {
116                SessionCompatibilityStatus::Conflict
117            } else if self.session_version.is_none() {
118                SessionCompatibilityStatus::LegacyCompatible
119            } else {
120                SessionCompatibilityStatus::Compatible
121            },
122            history_readable: true,
123            execution_allowed,
124            conflicts,
125        }
126    }
127
128    pub fn check_execution_compatibility(&self) -> Result<(), SessionCompatibilityError> {
129        let compatibility = self.execution_compatibility();
130        if compatibility.execution_allowed {
131            Ok(())
132        } else {
133            Err(SessionCompatibilityError { compatibility })
134        }
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use crate::{Message, SessionAuthorityIdentity};
142
143    #[test]
144    fn missing_stamp_is_assessed_and_compatible_without_mutation() {
145        let mut session = Session::new("legacy", "model");
146        session.session_version = None;
147        session.add_message(Message::user("keep history"));
148        let before = serde_json::to_value(&session).unwrap();
149        let report = session.execution_compatibility();
150        assert_eq!(report.status, SessionCompatibilityStatus::LegacyCompatible);
151        assert!(report.execution_allowed && report.history_readable);
152        session.check_execution_compatibility().unwrap();
153        assert_eq!(serde_json::to_value(&session).unwrap(), before);
154    }
155
156    #[test]
157    fn current_and_legacy_contracts_preserve_mode_and_identity_boundaries() {
158        for version in [None, Some(CURRENT_SESSION_VERSION)] {
159            for ultra in [false, true] {
160                for supervisor in [false, true] {
161                    let mut session = Session::new(
162                        if supervisor {
163                            crate::DEFAULT_SUPERVISOR_SESSION_ID
164                        } else {
165                            "root"
166                        },
167                        "model",
168                    );
169                    session.session_version = version;
170                    session.set_root_orchestration_only(ultra).unwrap();
171                    if supervisor {
172                        session.authority_identity = SessionAuthorityIdentity::Supervisor {
173                            incarnation_id: uuid::Uuid::new_v4(),
174                        };
175                    }
176                    let before = serde_json::to_value(&session).unwrap();
177                    assert!(session.execution_compatibility().execution_allowed);
178                    session.check_execution_compatibility().unwrap();
179                    assert_eq!(serde_json::to_value(&session).unwrap(), before);
180                    assert_eq!(
181                        session.allows_model_tool_execution("Bash"),
182                        !ultra && !supervisor
183                    );
184                    let mut child = Session::new_child_of("child", &session, "model", "child");
185                    child.session_version = version;
186                    assert!(child.execution_compatibility().execution_allowed);
187                    assert!(child.allows_model_tool_execution("Bash"));
188                }
189            }
190        }
191    }
192
193    #[test]
194    fn unsupported_versions_are_diagnostics_not_deserialization_failures() {
195        for version in [0, 2, u32::MAX] {
196            let mut session = Session::new("future", "model");
197            session.session_version = Some(version);
198            let before = serde_json::to_value(&session).unwrap();
199            let reloaded: Session = serde_json::from_value(before.clone()).unwrap();
200            let report = reloaded.execution_compatibility();
201            assert!(report.history_readable);
202            assert!(!report.execution_allowed);
203            assert_eq!(report.conflicts[0].code, "unsupported_session_version");
204            assert!(reloaded
205                .check_execution_compatibility()
206                .unwrap_err()
207                .to_string()
208                .contains("session_execution_incompatible"));
209            assert_eq!(serde_json::to_value(reloaded).unwrap(), before);
210        }
211    }
212
213    #[test]
214    fn runtime_overlay_cannot_hide_an_unsupported_main_or_side_stamp() {
215        for (main, runtime) in [(Some(99), None), (Some(1), Some(99)), (None, Some(99))] {
216            let mut side = Session::new("pair", "model");
217            side.session_version = runtime;
218            side.observe_main_session_version(main);
219            assert_eq!(side.session_version, main);
220            assert!(!side.execution_compatibility().execution_allowed);
221        }
222        for (main, runtime) in [(None, Some(1)), (Some(1), None), (None, None)] {
223            let mut side = Session::new("pair", "model");
224            side.session_version = runtime;
225            side.observe_main_session_version(main);
226            assert_eq!(side.session_version, main);
227            assert!(side.execution_compatibility().execution_allowed);
228        }
229    }
230
231    #[test]
232    fn unsupported_retained_model_context_is_refused_without_reset() {
233        let mut session = Session::new("future-context", "model");
234        session.session_version = None;
235        let context = super::super::ModelContextState {
236            schema_version: 99,
237            state_revision: 7,
238            prefix_epoch: 3,
239            ..Default::default()
240        };
241        session.model_context_state = Some(context);
242        let before = serde_json::to_value(&session).unwrap();
243        let report = session.execution_compatibility();
244        assert!(!report.execution_allowed);
245        assert_eq!(
246            report.conflicts[0].code,
247            "unsupported_model_context_version"
248        );
249        assert_eq!(serde_json::to_value(session).unwrap(), before);
250    }
251
252    #[test]
253    fn unsupported_retained_runtime_state_is_refused_without_reset() {
254        let mut session = Session::new("future-runtime", "model");
255        session.session_version = None;
256        let mut state = AgentRuntimeState::new("retained-execution");
257        state.version = 99;
258        session.agent_runtime_state = Some(state);
259        let before = serde_json::to_value(&session).unwrap();
260        let report = session.execution_compatibility();
261        assert!(!report.execution_allowed);
262        assert_eq!(
263            report.conflicts[0].code,
264            "unsupported_runtime_state_version"
265        );
266        assert_eq!(serde_json::to_value(session).unwrap(), before);
267    }
268}