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