Skip to main content

bamboo_domain/session/
tool_authority.rs

1//! Durable Root tool authority, separate from prompt or permission suggestions.
2
3use super::{model_context::ModelContextResetReason, Session, SessionKind};
4
5/// Root product policy, independent of a model call's `ReasoningEffort`.
6/// Ultra delegates planning/execution and keeps verification at the Root.
7/// It is projected from existing durable Root authority, never persisted twice.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
9#[serde(rename_all = "lowercase")]
10pub enum RootThinkingMode {
11    Standard,
12    Ultra,
13}
14
15#[derive(Debug, thiserror::Error, PartialEq, Eq)]
16#[error("thinking_mode and root orchestration enabled selection conflict")]
17pub struct RootThinkingModeConflict;
18
19impl RootThinkingMode {
20    /// Optional DTO field: omission is allowed, explicit null is not a mode.
21    pub fn deserialize_selection<'de, D>(deserializer: D) -> Result<Option<Self>, D::Error>
22    where
23        D: serde::Deserializer<'de>,
24    {
25        <Self as serde::Deserialize>::deserialize(deserializer).map(Some)
26    }
27
28    pub fn from_enabled(enabled: bool) -> Self {
29        if enabled {
30            Self::Ultra
31        } else {
32            Self::Standard
33        }
34    }
35
36    pub fn enabled(self) -> bool {
37        self == Self::Ultra
38    }
39
40    /// Normalize canonical and legacy selectors before existing admission.
41    pub fn resolve_selection(
42        mode: Option<Self>,
43        enabled: Option<bool>,
44    ) -> Result<Option<bool>, RootThinkingModeConflict> {
45        if let (Some(mode), Some(enabled)) = (mode, enabled) {
46            if mode.enabled() != enabled {
47                return Err(RootThinkingModeConflict);
48            }
49        }
50        Ok(mode.map(Self::enabled).or(enabled))
51    }
52}
53
54/// Exact execution identities admitted for an orchestration-only Root.
55/// Unknown tools, external providers, and legacy aliases do not enter this
56/// list; callers first resolve a registered execution identity.
57pub const ROOT_ORCHESTRATION_TOOLS: [&str; 16] = [
58    "SubAgent",
59    "Plan",
60    "Task",
61    "session_history_current",
62    "session_message",
63    "Read",
64    "Grep",
65    "Glob",
66    "GetFileInfo",
67    "ViewImage",
68    "work_overview",
69    "work_search",
70    "work_inspect",
71    "work_changes",
72    "work_update",
73    "work_dispatch",
74];
75
76pub fn orchestration_only_allows_execution_name(execution_name: &str) -> bool {
77    ROOT_ORCHESTRATION_TOOLS.contains(&execution_name)
78}
79
80/// Exact management tools with their own live Supervisor/target authority gate.
81/// The history executor restricts Supervisor reads to this scope too.
82pub const SUPERVISOR_ORCHESTRATION_TOOLS: [&str; 2] = ["session_control", "session_history"];
83
84pub fn orchestration_only_allows_execution_name_for_supervisor(
85    execution_name: &str,
86    executing_supervisor: bool,
87) -> bool {
88    orchestration_only_allows_execution_name(execution_name)
89        || (executing_supervisor && SUPERVISOR_ORCHESTRATION_TOOLS.contains(&execution_name))
90}
91
92#[derive(Debug, thiserror::Error, PartialEq, Eq)]
93pub enum RootToolAuthorityError {
94    #[error("only a Root Session may select orchestration-only tool authority")]
95    NotRoot,
96    #[error("Root tool authority revision cannot advance")]
97    RevisionOverflow,
98    #[error("Root tool authority snapshot is stale or inconsistent")]
99    StaleSnapshot,
100    #[error("orchestration-only Root cannot enter while legacy Plan mode is active")]
101    LegacyPlanActive,
102    #[error("orchestration-only Root cannot use a selected Skill or Workflow")]
103    WorkflowSelected,
104}
105
106impl Session {
107    pub fn root_thinking_mode(&self) -> RootThinkingMode {
108        RootThinkingMode::from_enabled(self.root_orchestration_only_enabled())
109    }
110
111    pub fn root_orchestration_only_enabled(&self) -> bool {
112        self.kind == SessionKind::Root
113            && self.parent_session_id.is_none()
114            && self.root_orchestration_only
115    }
116
117    /// Called only by a host-owned selection, never by a model tool.
118    pub fn set_root_orchestration_only(
119        &mut self,
120        enabled: bool,
121    ) -> Result<(), RootToolAuthorityError> {
122        if self.kind != SessionKind::Root || self.parent_session_id.is_some() {
123            return Err(RootToolAuthorityError::NotRoot);
124        }
125        if enabled {
126            self.validate_root_orchestration_compatibility()?;
127        }
128        if self.root_orchestration_only == enabled {
129            return Ok(());
130        }
131        let next = self
132            .root_tool_authority_revision
133            .checked_add(1)
134            .ok_or(RootToolAuthorityError::RevisionOverflow)?;
135        self.root_orchestration_only = enabled;
136        self.root_tool_authority_revision = next;
137        if self.model_context_state.is_some() {
138            self.reset_model_context_epoch(ModelContextResetReason::CacheScopeChanged);
139        }
140        Ok(())
141    }
142
143    /// Keep the narrow tool catalog usable when an old turn or a new request
144    /// carries a mode that requires tools outside the Root allowlist.
145    pub fn validate_root_orchestration_compatibility(&self) -> Result<(), RootToolAuthorityError> {
146        if self
147            .agent_runtime_state
148            .as_ref()
149            .is_some_and(|state| state.plan_mode.is_some())
150        {
151            return Err(RootToolAuthorityError::LegacyPlanActive);
152        }
153        if self.selected_skill_ids().is_some_and(|ids| !ids.is_empty()) {
154            return Err(RootToolAuthorityError::WorkflowSelected);
155        }
156        Ok(())
157    }
158
159    /// Derived model posture, not a persisted selection or permission grant.
160    /// A trusted Supervisor orchestrates in both thinking modes. Children and
161    /// ordinary Roots retain their own independent mode and permissions.
162    pub fn model_orchestration_only_enabled(&self) -> bool {
163        self.root_orchestration_only_enabled() || self.supervisor_incarnation_id().is_some()
164    }
165
166    /// Defense in depth at both catalog projection and final dispatch.
167    pub fn allows_model_tool_execution(&self, execution_name: &str) -> bool {
168        !self.model_orchestration_only_enabled()
169            || orchestration_only_allows_execution_name_for_supervisor(
170                execution_name,
171                self.supervisor_incarnation_id().is_some(),
172            )
173    }
174
175    /// Refresh a running snapshot from the durable control plane before tool
176    /// dispatch. An older snapshot can never loosen a selected Root policy.
177    pub fn adopt_root_tool_authority_from(
178        &mut self,
179        latest: &Session,
180    ) -> Result<(), RootToolAuthorityError> {
181        // A tool policy revision is meaningful only within the same Session
182        // lifetime. A deleted Root ID can be recreated with a fresh birth, so
183        // matching the ID and revision alone would authorize an old SDK run.
184        fn root_id(session: &Session) -> &str {
185            if session.kind == SessionKind::Root && session.root_session_id.is_empty() {
186                session.id.as_str()
187            } else {
188                session.root_session_id.as_str()
189            }
190        }
191        if self.id != latest.id
192            || self.kind != latest.kind
193            || self.created_at != latest.created_at
194            || self.authority_identity != latest.authority_identity
195            || self.parent_session_id != latest.parent_session_id
196            || root_id(self) != root_id(latest)
197            || self.spawn_depth != latest.spawn_depth
198            || (self.root_orchestration_only && self.root_tool_authority_revision == 0)
199            || (latest.root_orchestration_only && latest.root_tool_authority_revision == 0)
200            || latest.root_tool_authority_revision < self.root_tool_authority_revision
201            || (latest.root_tool_authority_revision == self.root_tool_authority_revision
202                && latest.root_orchestration_only != self.root_orchestration_only)
203            || latest.root_mode_transition_epoch < self.root_mode_transition_epoch
204            || (latest.root_mode_transition_epoch == self.root_mode_transition_epoch
205                && latest.root_mode_operations != self.root_mode_operations)
206        {
207            return Err(RootToolAuthorityError::StaleSnapshot);
208        }
209        let selections = latest.root_tool_authority_revision - self.root_tool_authority_revision;
210        self.root_orchestration_only = latest.root_orchestration_only;
211        self.root_tool_authority_revision = latest.root_tool_authority_revision;
212        self.root_mode_transition_epoch = latest.root_mode_transition_epoch;
213        self.root_mode_operations = latest.root_mode_operations.clone();
214        if selections > 0 {
215            // A running snapshot may miss several selections, including a
216            // round trip to the same mode. Adopt the durable invalidation;
217            // resetting the local ledger once could retain an older epoch.
218            self.model_context_state = latest.model_context_state.clone();
219            self.provider_transcript.invalidate_repeated(
220                super::provider_transcript::ProviderTranscriptResetReason::CacheScopeChanged,
221                selections,
222            );
223        }
224        Ok(())
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231    use crate::session::{AgentRuntimeState, ModelContextState, PlanModeState, PlanModeStatus};
232
233    #[test]
234    fn supervisor_delegation_first_does_not_depend_on_ultra_or_leak_to_children() {
235        use crate::{SessionAuthorityIdentity, DEFAULT_SUPERVISOR_SESSION_ID};
236        let mut supervisor = Session::new(DEFAULT_SUPERVISOR_SESSION_ID, "model");
237        supervisor.authority_identity = SessionAuthorityIdentity::Supervisor {
238            incarnation_id: uuid::Uuid::new_v4(),
239        };
240        for ultra in [false, true, false] {
241            supervisor.set_root_orchestration_only(ultra).unwrap();
242            let restored: Session =
243                serde_json::from_value(serde_json::to_value(&supervisor).unwrap()).unwrap();
244            for name in ROOT_ORCHESTRATION_TOOLS
245                .into_iter()
246                .chain(SUPERVISOR_ORCHESTRATION_TOOLS)
247            {
248                assert!(
249                    restored.allows_model_tool_execution(name),
250                    "{ultra}: {name}"
251                );
252            }
253            for name in [
254                "exec_command",
255                "write_stdin",
256                "Bash",
257                "Edit",
258                "Write",
259                "apply_patch",
260                "load_skill",
261                "workflow_run",
262                "mcp__vendor__read",
263                "default::Read",
264            ] {
265                assert!(
266                    !restored.allows_model_tool_execution(name),
267                    "Supervisor {ultra}: {name}"
268                );
269            }
270            let child = Session::new_child_of("worker", &restored, "model", "worker");
271            assert!(child.authority_identity.is_ordinary());
272            assert!(child.allows_model_tool_execution("Edit"));
273            assert!(child.allows_model_tool_execution("exec_command"));
274        }
275        let mut ordinary = Session::new(DEFAULT_SUPERVISOR_SESSION_ID, "model");
276        ordinary.metadata.insert("role".into(), "supervisor".into());
277        assert!(ordinary.allows_model_tool_execution("Edit"));
278        ordinary.set_root_orchestration_only(true).unwrap();
279        assert!(!ordinary.allows_model_tool_execution("session_control"));
280        assert!(!ordinary.allows_model_tool_execution("session_history"));
281    }
282
283    #[test]
284    fn trusted_supervisor_session_management_survives_thinking_mode_selection() {
285        use crate::{SessionAuthorityIdentity, DEFAULT_SUPERVISOR_SESSION_ID};
286        let mut supervisor = Session::new(DEFAULT_SUPERVISOR_SESSION_ID, "model");
287        supervisor.authority_identity = SessionAuthorityIdentity::Supervisor {
288            incarnation_id: uuid::Uuid::new_v4(),
289        };
290        for ultra in [false, true] {
291            supervisor.set_root_orchestration_only(ultra).unwrap();
292            let restored: Session =
293                serde_json::from_value(serde_json::to_value(&supervisor).unwrap()).unwrap();
294            for name in ["session_control", "session_history"] {
295                assert!(
296                    restored.allows_model_tool_execution(name),
297                    "trusted Supervisor lost {name} in {:?}",
298                    restored.root_thinking_mode()
299                );
300            }
301        }
302    }
303
304    #[test]
305    fn ultra_management_exception_requires_canonical_supervisor_and_exact_tool() {
306        use crate::{SessionAuthorityIdentity, DEFAULT_SUPERVISOR_SESSION_ID};
307        let mut supervisor = Session::new(DEFAULT_SUPERVISOR_SESSION_ID, "model");
308        supervisor.authority_identity = SessionAuthorityIdentity::Supervisor {
309            incarnation_id: uuid::Uuid::new_v4(),
310        };
311        supervisor.set_root_orchestration_only(true).unwrap();
312        for mutation in [
313            |s: &mut Session| s.authority_identity = SessionAuthorityIdentity::Ordinary,
314            |s: &mut Session| s.id = "other".into(),
315            |s: &mut Session| s.root_session_id = "other".into(),
316            |s: &mut Session| s.spawn_depth = 1,
317            |s: &mut Session| {
318                s.authority_identity = SessionAuthorityIdentity::Supervisor {
319                    incarnation_id: uuid::Uuid::nil(),
320                }
321            },
322        ] {
323            let mut invalid = supervisor.clone();
324            mutation(&mut invalid);
325            invalid.metadata.insert("role".into(), "supervisor".into());
326            for name in ["session_control", "session_history"] {
327                assert!(!invalid.allows_model_tool_execution(name));
328            }
329        }
330        for name in [
331            "default::session_control",
332            "vendor::session_control",
333            "exec_command",
334            "Write",
335        ] {
336            assert!(!supervisor.allows_model_tool_execution(name), "{name}");
337        }
338        let child = Session::new_child_of("child", &supervisor, "model", "child");
339        assert_eq!(child.supervisor_incarnation_id(), None);
340        let mut ordinary = Session::new(DEFAULT_SUPERVISOR_SESSION_ID, "model");
341        ordinary.set_root_orchestration_only(true).unwrap();
342        ordinary.metadata.insert("role".into(), "supervisor".into());
343        for name in ["session_control", "session_history"] {
344            assert!(!ordinary.allows_model_tool_execution(name));
345        }
346    }
347
348    #[test]
349    fn thinking_mode_is_a_derived_root_policy_independent_of_effort() {
350        use crate::ReasoningEffort;
351        let mut root = Session::new("ultra-root", "model");
352        root.reasoning_effort = Some(ReasoningEffort::Max);
353        root.set_root_orchestration_prompt_enabled(true);
354        assert_eq!(root.root_thinking_mode(), RootThinkingMode::Standard);
355        root.set_root_orchestration_only(true).unwrap();
356        let persisted = serde_json::to_value(&root).unwrap();
357        assert!(
358            persisted.get("thinking_mode").is_none(),
359            "no second persisted authority"
360        );
361        let reloaded: Session = serde_json::from_value(persisted.clone()).unwrap();
362        assert_eq!(reloaded.root_thinking_mode(), RootThinkingMode::Ultra);
363        assert_eq!(reloaded.reasoning_effort, Some(ReasoningEffort::Max));
364        assert_eq!(
365            serde_json::to_value(&reloaded).unwrap(),
366            persisted,
367            "projection never writes"
368        );
369        let mut child = Session::new_child_of("ultra-child", &reloaded, "model", "child");
370        assert_eq!(child.root_thinking_mode(), RootThinkingMode::Standard);
371        assert_eq!(child.reasoning_effort, None);
372        child.root_orchestration_only = true;
373        assert_eq!(
374            child.root_thinking_mode(),
375            RootThinkingMode::Standard,
376            "raw child bool has no authority"
377        );
378        assert_eq!(
379            child.set_root_orchestration_only(true),
380            Err(RootToolAuthorityError::NotRoot)
381        );
382        assert_eq!(
383            serde_json::to_string(&RootThinkingMode::Ultra).unwrap(),
384            "\"ultra\""
385        );
386        assert_eq!(
387            serde_json::from_str::<RootThinkingMode>("\"standard\"").unwrap(),
388            RootThinkingMode::Standard
389        );
390        assert!(serde_json::from_str::<RootThinkingMode>("\"max\"").is_err());
391        assert!(serde_json::from_str::<ReasoningEffort>("\"ultra\"").is_err());
392    }
393
394    #[test]
395    fn canonical_and_legacy_thinking_selectors_share_one_boolean_request() {
396        assert_eq!(RootThinkingMode::resolve_selection(None, None), Ok(None));
397        for enabled in [false, true] {
398            let mode = RootThinkingMode::from_enabled(enabled);
399            assert_eq!(
400                RootThinkingMode::resolve_selection(Some(mode), None),
401                Ok(Some(enabled))
402            );
403            assert_eq!(
404                RootThinkingMode::resolve_selection(None, Some(enabled)),
405                Ok(Some(enabled))
406            );
407            assert_eq!(
408                RootThinkingMode::resolve_selection(Some(mode), Some(enabled)),
409                Ok(Some(enabled))
410            );
411            assert_eq!(
412                RootThinkingMode::resolve_selection(Some(mode), Some(!enabled)),
413                Err(RootThinkingModeConflict)
414            );
415        }
416    }
417
418    #[test]
419    fn root_selection_is_persistent_and_child_does_not_inherit_it() {
420        let mut root = Session::new("root", "model");
421        root.set_root_orchestration_only(true).unwrap();
422        assert_eq!(root.root_tool_authority_revision, 1);
423        let reloaded: Session =
424            serde_json::from_str(&serde_json::to_string(&root).unwrap()).unwrap();
425        assert!(reloaded.root_orchestration_only_enabled());
426        let mut child = Session::new_child_of("child", &reloaded, "model", "child");
427        assert!(!child.root_orchestration_only_enabled());
428        assert_eq!(child.root_tool_authority_revision, 0);
429        assert_eq!(
430            child.set_root_orchestration_only(false),
431            Err(RootToolAuthorityError::NotRoot)
432        );
433        assert!(child.allows_model_tool_execution("Bash"));
434    }
435
436    #[test]
437    fn root_allowlist_is_exact_and_denies_unknown_tools() {
438        let mut root = Session::new("root", "model");
439        root.set_root_orchestration_only(true).unwrap();
440        for tool in ROOT_ORCHESTRATION_TOOLS {
441            assert!(root.allows_model_tool_execution(tool), "{tool}");
442        }
443        for tool in [
444            "exec_command",
445            "write_stdin",
446            "Bash",
447            "Edit",
448            "Write",
449            "WebFetch",
450            "workflow_run",
451            "mcp__x__read",
452            "read_file",
453            "default::Read",
454        ] {
455            assert!(!root.allows_model_tool_execution(tool), "{tool}");
456        }
457        assert_eq!(root.root_tool_authority_revision, 1);
458        root.set_root_orchestration_only(true).unwrap();
459        assert_eq!(root.root_tool_authority_revision, 1);
460        root.set_root_orchestration_only(false).unwrap();
461        assert_eq!(root.root_tool_authority_revision, 2);
462        assert!(root.allows_model_tool_execution("Bash"));
463    }
464
465    #[test]
466    fn durable_refresh_adopts_tightening_and_rejects_stale_loosen() {
467        let mut running = Session::new("root", "model");
468        let mut durable = running.clone();
469        durable.set_root_orchestration_only(true).unwrap();
470        running.adopt_root_tool_authority_from(&durable).unwrap();
471        assert!(!running.allows_model_tool_execution("Bash"));
472        assert_eq!(
473            durable.adopt_root_tool_authority_from(&Session::new("root", "model")),
474            Err(RootToolAuthorityError::StaleSnapshot)
475        );
476        durable.set_root_orchestration_only(false).unwrap();
477        running.adopt_root_tool_authority_from(&durable).unwrap();
478        assert!(running.allows_model_tool_execution("Bash"));
479    }
480
481    #[test]
482    fn durable_refresh_rejects_a_different_birth_identity_or_lineage() {
483        let running = Session::new("root", "model");
484        let mut other_birth = running.clone();
485        other_birth.created_at += chrono::Duration::microseconds(1);
486        assert_eq!(
487            running.clone().adopt_root_tool_authority_from(&other_birth),
488            Err(RootToolAuthorityError::StaleSnapshot)
489        );
490
491        let mut other_identity = running.clone();
492        other_identity.authority_identity = super::super::SessionAuthorityIdentity::Supervisor {
493            incarnation_id: uuid::Uuid::new_v4(),
494        };
495        assert_eq!(
496            running
497                .clone()
498                .adopt_root_tool_authority_from(&other_identity),
499            Err(RootToolAuthorityError::StaleSnapshot)
500        );
501
502        let mut other_lineage = running.clone();
503        other_lineage.root_session_id = "another-root".into();
504        assert_eq!(
505            running
506                .clone()
507                .adopt_root_tool_authority_from(&other_lineage),
508            Err(RootToolAuthorityError::StaleSnapshot)
509        );
510        other_lineage = running.clone();
511        other_lineage.spawn_depth = 1;
512        assert_eq!(
513            running
514                .clone()
515                .adopt_root_tool_authority_from(&other_lineage),
516            Err(RootToolAuthorityError::StaleSnapshot)
517        );
518
519        // Legacy Roots can omit this derived field without changing lineage.
520        let mut legacy = running.clone();
521        legacy.root_session_id.clear();
522        legacy.adopt_root_tool_authority_from(&running).unwrap();
523    }
524
525    #[test]
526    fn root_selection_rejects_legacy_plan_or_workflow_that_needs_other_tools() {
527        let mut root = Session::new("root", "model");
528        let runtime = AgentRuntimeState {
529            plan_mode: Some(PlanModeState {
530                entered_at: chrono::Utc::now(),
531                pre_permission_mode: "default".to_string(),
532                plan_file_path: None,
533                status: PlanModeStatus::Exploring,
534            }),
535            ..AgentRuntimeState::default()
536        };
537        root.agent_runtime_state = Some(runtime);
538        assert_eq!(
539            root.set_root_orchestration_only(true),
540            Err(RootToolAuthorityError::LegacyPlanActive)
541        );
542        root.agent_runtime_state = None;
543        root.set_selected_skill_ids(vec!["workflow".to_string()]);
544        assert_eq!(
545            root.set_root_orchestration_only(true),
546            Err(RootToolAuthorityError::WorkflowSelected)
547        );
548        root.clear_selected_skill_ids();
549        root.set_root_orchestration_only(true).unwrap();
550        root.set_selected_skill_ids(vec!["workflow".to_string()]);
551        assert_eq!(
552            root.validate_root_orchestration_compatibility(),
553            Err(RootToolAuthorityError::WorkflowSelected)
554        );
555    }
556
557    #[test]
558    fn live_authority_adoption_resets_prior_model_context_epoch() {
559        for enabled in [false, true] {
560            let mut running = Session::new("root", "model");
561            if !enabled {
562                running.set_root_orchestration_only(true).unwrap();
563            }
564            running.model_context_state = Some(ModelContextState {
565                prefix_epoch: 4,
566                cache_scope_sha256: Some("prior-provider-scope".to_string()),
567                ..ModelContextState::default()
568            });
569            let mut durable = running.clone();
570            durable.set_root_orchestration_only(enabled).unwrap();
571            running.adopt_root_tool_authority_from(&durable).unwrap();
572            let context = running.model_context_state.as_ref().unwrap();
573            assert_eq!(context.prefix_epoch, 5);
574            assert!(context.cache_scope_sha256.is_none());
575            assert_eq!(
576                context.last_reset_reason,
577                Some(ModelContextResetReason::CacheScopeChanged)
578            );
579        }
580    }
581
582    #[test]
583    fn live_authority_adoption_preserves_all_missed_mode_resets() {
584        let mut running = Session::new("root", "model");
585        running.model_context_state = Some(ModelContextState {
586            prefix_epoch: 4,
587            cache_scope_sha256: Some("old scope".into()),
588            ..Default::default()
589        });
590        running
591            .activate_provider_transcript_route(
592                super::super::provider_transcript::ProviderFamily::OpenAi,
593                super::super::provider_transcript::ProviderProtocol::OpenAiResponsesV1,
594                &"a".repeat(64),
595            )
596            .unwrap();
597        let mut durable = running.clone();
598        durable.set_root_orchestration_only(true).unwrap();
599        // A prepared round between selections makes the second reset a new
600        // ledger boundary rather than the existing pending-reset coalescing.
601        durable
602            .model_context_state
603            .as_mut()
604            .unwrap()
605            .cache_scope_sha256 = Some("new scope".into());
606        durable.set_root_orchestration_only(false).unwrap();
607        assert_eq!(running.root_thinking_mode(), durable.root_thinking_mode());
608        running.adopt_root_tool_authority_from(&durable).unwrap();
609        assert_eq!(running.model_context_state, durable.model_context_state);
610        assert_eq!(
611            running.model_context_state.as_ref().unwrap().prefix_epoch,
612            6
613        );
614        assert_eq!(running.provider_transcript, durable.provider_transcript);
615        // A recovery-only epoch advance must preserve current owner work.
616        running
617            .model_context_state
618            .as_mut()
619            .unwrap()
620            .cache_scope_sha256 = Some("owner scope".into());
621        let owner_context = running.model_context_state.clone();
622        durable.root_mode_transition_epoch += 1;
623        running.adopt_root_tool_authority_from(&durable).unwrap();
624        assert_eq!(running.model_context_state, owner_context);
625    }
626}