Skip to main content

meerkat_core/
tool_execution.rs

1//! Internal tool-execution declarations and pre-dispatch resolution.
2//!
3//! Execution metadata lives beside the internal tool catalog. It is not part
4//! of provider-facing [`crate::ToolDef`] serialization.
5
6use std::collections::BTreeSet;
7use std::fmt::Write as _;
8use std::sync::Arc;
9use std::time::Duration;
10
11use sha2::{Digest, Sha256};
12
13/// Execution class declared by a tool identity.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
15pub enum ToolExecutionMode {
16    Fast,
17    Streaming,
18    Detached,
19}
20
21/// Owner of one deadline that contributes to a resolved tool call.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
23pub enum ToolDeadlineOwner {
24    CoreToolDispatch,
25    Dispatcher,
26    ToolInternal,
27    StreamingAbsolute,
28    MobkitPublicCallback,
29    SdkCallbackCancellation,
30    GatewayWire,
31    DetachedSubmission,
32    DirectCaller,
33}
34
35impl ToolDeadlineOwner {
36    pub const fn as_str(self) -> &'static str {
37        match self {
38            Self::CoreToolDispatch => "core tool dispatch",
39            Self::Dispatcher => "dispatcher",
40            Self::ToolInternal => "tool internal",
41            Self::StreamingAbsolute => "streaming absolute",
42            Self::MobkitPublicCallback => "mobkit public callback",
43            Self::SdkCallbackCancellation => "sdk callback cancellation",
44            Self::GatewayWire => "gateway wire",
45            Self::DetachedSubmission => "detached submission",
46            Self::DirectCaller => "direct caller",
47        }
48    }
49}
50
51/// One finite or unbounded deadline in declaration order.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub struct ToolDeadlineContributor {
54    owner: ToolDeadlineOwner,
55    timeout: Option<Duration>,
56}
57
58impl ToolDeadlineContributor {
59    pub const fn finite(owner: ToolDeadlineOwner, timeout: Duration) -> Self {
60        Self {
61            owner,
62            timeout: Some(timeout),
63        }
64    }
65
66    pub const fn unbounded(owner: ToolDeadlineOwner) -> Self {
67        Self {
68            owner,
69            timeout: None,
70        }
71    }
72
73    pub const fn owner(&self) -> ToolDeadlineOwner {
74        self.owner
75    }
76
77    pub const fn timeout(&self) -> Option<Duration> {
78        self.timeout
79    }
80}
81
82/// Invalid deadline-chain declaration.
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub enum DeadlineChainError {
85    Empty,
86    Zero { owner: ToolDeadlineOwner },
87}
88
89impl std::fmt::Display for DeadlineChainError {
90    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91        match self {
92            Self::Empty => formatter.write_str("tool deadline chain must not be empty"),
93            Self::Zero { owner } => write!(
94                formatter,
95                "tool deadline from '{}' must be greater than zero",
96                owner.as_str()
97            ),
98        }
99    }
100}
101
102impl std::error::Error for DeadlineChainError {}
103
104/// A resolved deadline chain did not retain its upstream chain as an ordered
105/// prefix.
106///
107/// Resolvers may only append contributors. Replacing, removing, or reordering
108/// an upstream contributor can silently widen a caller-owned deadline and is
109/// therefore rejected before dispatch.
110#[derive(Debug, Clone, PartialEq, Eq)]
111pub enum DeadlineChainExtensionError {
112    ShorterThanUpstream {
113        upstream_len: usize,
114        resolved_len: usize,
115    },
116    ContributorMismatch {
117        index: usize,
118        expected: ToolDeadlineContributor,
119        actual: ToolDeadlineContributor,
120    },
121}
122
123impl std::fmt::Display for DeadlineChainExtensionError {
124    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
125        match self {
126            Self::ShorterThanUpstream {
127                upstream_len,
128                resolved_len,
129            } => write!(
130                formatter,
131                "resolved tool deadline chain has {resolved_len} contributors but its upstream chain has {upstream_len}"
132            ),
133            Self::ContributorMismatch {
134                index,
135                expected,
136                actual,
137            } => write!(
138                formatter,
139                "resolved tool deadline contributor {index} replaced upstream owner '{}' ({:?}) with '{}' ({:?})",
140                expected.owner().as_str(),
141                expected.timeout(),
142                actual.owner().as_str(),
143                actual.timeout()
144            ),
145        }
146    }
147}
148
149impl std::error::Error for DeadlineChainExtensionError {}
150
151/// Ordered deadline chain resolved before dispatch.
152#[derive(Debug, Clone, PartialEq, Eq)]
153pub struct ToolDeadlineChain {
154    contributors: Vec<ToolDeadlineContributor>,
155}
156
157impl ToolDeadlineChain {
158    pub fn new(contributors: Vec<ToolDeadlineContributor>) -> Result<Self, DeadlineChainError> {
159        if contributors.is_empty() {
160            return Err(DeadlineChainError::Empty);
161        }
162        if let Some(contributor) = contributors
163            .iter()
164            .find(|contributor| contributor.timeout == Some(Duration::ZERO))
165        {
166            return Err(DeadlineChainError::Zero {
167                owner: contributor.owner,
168            });
169        }
170        Ok(Self { contributors })
171    }
172
173    pub fn contributors(&self) -> &[ToolDeadlineContributor] {
174        &self.contributors
175    }
176
177    pub fn winner(&self) -> Option<&ToolDeadlineContributor> {
178        self.contributors
179            .iter()
180            .filter(|contributor| contributor.timeout.is_some())
181            .min_by_key(|contributor| contributor.timeout)
182    }
183
184    pub fn effective_timeout(&self) -> Option<Duration> {
185        self.winner().and_then(ToolDeadlineContributor::timeout)
186    }
187
188    pub fn diagnostic(&self) -> String {
189        let mut diagnostic = String::new();
190        match self.effective_timeout() {
191            Some(timeout) => {
192                let _ = writeln!(
193                    diagnostic,
194                    "effective deadline: {}",
195                    format_duration(timeout)
196                );
197            }
198            None => diagnostic.push_str("effective deadline: unbounded\n"),
199        }
200        diagnostic.push_str("contributors:\n");
201        for contributor in &self.contributors {
202            let limit = contributor
203                .timeout
204                .map_or_else(|| "unbounded".to_string(), format_duration);
205            let _ = writeln!(diagnostic, "  {}: {limit}", contributor.owner.as_str());
206        }
207        diagnostic.push_str("winner: ");
208        diagnostic.push_str(
209            self.winner()
210                .map(|winner| winner.owner.as_str())
211                .unwrap_or("unbounded"),
212        );
213        diagnostic
214    }
215
216    pub fn with_contributor(
217        &self,
218        contributor: ToolDeadlineContributor,
219    ) -> Result<Self, DeadlineChainError> {
220        let mut contributors = self.contributors.clone();
221        contributors.push(contributor);
222        Self::new(contributors)
223    }
224
225    /// Verify that `upstream` is an exact ordered prefix of this chain.
226    pub fn validate_extends(&self, upstream: &Self) -> Result<(), DeadlineChainExtensionError> {
227        if self.contributors.len() < upstream.contributors.len() {
228            return Err(DeadlineChainExtensionError::ShorterThanUpstream {
229                upstream_len: upstream.contributors.len(),
230                resolved_len: self.contributors.len(),
231            });
232        }
233        for (index, expected) in upstream.contributors.iter().copied().enumerate() {
234            let actual = self.contributors[index];
235            if actual != expected {
236                return Err(DeadlineChainExtensionError::ContributorMismatch {
237                    index,
238                    expected,
239                    actual,
240                });
241            }
242        }
243        Ok(())
244    }
245}
246
247fn format_duration(duration: Duration) -> String {
248    if duration.subsec_nanos() == 0 {
249        format!("{}s", duration.as_secs())
250    } else if duration.as_millis() > 0 {
251        format!("{}ms", duration.as_millis())
252    } else if duration.as_micros() > 0 {
253        format!("{}us", duration.as_micros())
254    } else {
255        format!("{}ns", duration.as_nanos())
256    }
257}
258
259/// Restart behavior declared by a detached runner.
260#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
261pub enum RestartClass {
262    Adoptable,
263    CheckpointResumable,
264    Replayable,
265    NonResumable,
266}
267
268/// Stable submission-deduplication scope.
269#[derive(Debug, Clone, Copy, PartialEq, Eq)]
270pub enum IdempotencyScope {
271    ToolCall,
272    InteractionAndArguments,
273    HostSemanticKey,
274}
275
276/// Stable runner identity and version.
277#[derive(Debug, Clone, PartialEq, Eq)]
278pub struct RunnerIdentity {
279    name: String,
280    version: String,
281}
282
283impl RunnerIdentity {
284    pub fn new(
285        name: impl Into<String>,
286        version: impl Into<String>,
287    ) -> Result<Self, ToolExecutionDeclarationError> {
288        let name = name.into();
289        let version = version.into();
290        if name.trim().is_empty() {
291            return Err(ToolExecutionDeclarationError::EmptyRunnerName);
292        }
293        if version.trim().is_empty() {
294            return Err(ToolExecutionDeclarationError::EmptyRunnerVersion);
295        }
296        Ok(Self { name, version })
297    }
298
299    pub fn name(&self) -> &str {
300        &self.name
301    }
302
303    pub fn version(&self) -> &str {
304        &self.version
305    }
306}
307
308/// Whether one resolved-plan facet applies to the selected execution mode.
309///
310/// This is intentionally not represented as `Option<T>`: callers must handle
311/// the semantic distinction between a facet that does not apply and one whose
312/// applicable value is empty.
313#[derive(Debug, Clone, Copy, PartialEq, Eq)]
314pub enum ToolExecutionApplicability<T> {
315    NotApplicable,
316    Applicable(T),
317}
318
319/// Non-secret credential context to resolve afresh for an execution attempt.
320#[derive(Debug, Clone, PartialEq, Eq)]
321pub enum ToolCredentialContextRef {
322    /// Resolve the current owning realm/profile using the required scopes.
323    OwningProfile { required_scopes: BTreeSet<String> },
324    /// Resolve a specific typed auth binding. The binding is an identity
325    /// reference only; it does not contain secret material.
326    AuthBinding {
327        auth_binding: crate::AuthBindingRef,
328        required_scopes: BTreeSet<String>,
329    },
330}
331
332/// Where the selected execution mode commits its canonical output.
333#[derive(Debug, Clone, Copy, PartialEq, Eq)]
334pub enum ToolOutputPolicy {
335    InlineTerminal,
336    StreamingEvents,
337    DurableJobResult,
338}
339
340/// Where the selected execution mode publishes non-terminal progress.
341#[derive(Debug, Clone, Copy, PartialEq, Eq)]
342pub enum ToolProgressPolicy {
343    StreamingEvents,
344    DurableJobEvents,
345}
346
347/// Turn-local identity of one live logical catalog binding.
348///
349/// Projection allocation identity is deliberately absent: dispatchers may
350/// rebuild equivalent `Arc<ToolDef>` values on every catalog read. The
351/// semantic declaration is paired with a chain of process-local authority
352/// instances and their live epochs. Mutable authorities must advance their
353/// epoch when a logical binding is replaced, even by identical metadata.
354///
355/// This type intentionally has no serialization implementation. It is
356/// pre-dispatch TOCTOU fencing only, never durable job, restart, or recovery
357/// authority. A reconstructed dispatcher must resolve a fresh plan.
358///
359/// ```compile_fail
360/// fn requires_durable_serialization<T: serde::Serialize>() {}
361/// requires_durable_serialization::<meerkat_core::EphemeralToolBindingFingerprint>();
362/// ```
363#[derive(Clone)]
364pub struct EphemeralToolBindingFingerprint {
365    tool_name: crate::ToolName,
366    description: String,
367    input_schema: serde_json::Value,
368    provenance: Option<crate::ToolProvenance>,
369    plane: crate::ToolPlaneClass,
370    callability: crate::ToolCallability,
371    deferred_eligibility: crate::ToolCatalogDeferredEligibility,
372    execution: ToolExecutionContract,
373    authority_chain: Vec<(usize, u64)>,
374}
375
376impl std::fmt::Debug for EphemeralToolBindingFingerprint {
377    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
378        formatter
379            .debug_struct("EphemeralToolBindingFingerprint")
380            .field("tool_name", &self.tool_name)
381            .field("authority_depth", &self.authority_chain.len())
382            .finish_non_exhaustive()
383    }
384}
385
386impl PartialEq for EphemeralToolBindingFingerprint {
387    fn eq(&self, other: &Self) -> bool {
388        self.tool_name == other.tool_name
389            && self.description == other.description
390            && self.input_schema == other.input_schema
391            && self.provenance == other.provenance
392            && self.plane == other.plane
393            && self.callability == other.callability
394            && self.deferred_eligibility == other.deferred_eligibility
395            && self.execution == other.execution
396            && self.authority_chain == other.authority_chain
397    }
398}
399
400impl Eq for EphemeralToolBindingFingerprint {}
401
402pub fn ephemeral_tool_catalog_binding_fingerprint(
403    entry: &crate::ToolCatalogEntry,
404) -> EphemeralToolBindingFingerprint {
405    EphemeralToolBindingFingerprint {
406        tool_name: entry.tool.name.clone(),
407        description: entry.tool.description.clone(),
408        input_schema: entry.tool.input_schema.clone(),
409        provenance: entry.tool.provenance.clone(),
410        plane: entry.plane,
411        callability: entry.callability,
412        deferred_eligibility: entry.deferred_eligibility.clone(),
413        execution: entry.execution.clone(),
414        authority_chain: Vec::new(),
415    }
416}
417
418impl EphemeralToolBindingFingerprint {
419    #[must_use]
420    pub fn with_live_authority(mut self, authority_instance: usize, epoch: u64) -> Self {
421        self.authority_chain.push((authority_instance, epoch));
422        self
423    }
424
425    #[must_use]
426    pub fn with_dependency(mut self, dependency: &Self) -> Self {
427        self.authority_chain
428            .extend(dependency.authority_chain.iter().copied());
429        self
430    }
431}
432
433/// Opaque, non-provider-facing witness for the dispatcher owner selected
434/// during plan resolution.
435///
436/// A composite dispatcher can attach this witness and require the same live
437/// owner/binding object at dispatch. The witness is valid only for the current
438/// pre-dispatch resolution flow. It must be discarded on restart, recovery,
439/// dispatcher reconstruction, or turn replay; those paths must resolve again.
440/// It is not a job fence, attempt token, or durable authority.
441#[derive(Debug, Clone, PartialEq, Eq)]
442pub struct ToolExecutionOwnerWitness {
443    authority_key: String,
444    owner_key: String,
445    binding_fingerprint: EphemeralToolBindingFingerprint,
446}
447
448impl ToolExecutionOwnerWitness {
449    pub fn new(
450        authority_key: impl Into<String>,
451        owner_key: impl Into<String>,
452        binding_fingerprint: EphemeralToolBindingFingerprint,
453    ) -> Result<Self, ToolExecutionDeclarationError> {
454        let authority_key = authority_key.into();
455        let owner_key = owner_key.into();
456        if authority_key.trim().is_empty() {
457            return Err(ToolExecutionDeclarationError::EmptyOwnerWitnessAuthorityKey);
458        }
459        if owner_key.trim().is_empty() {
460            return Err(ToolExecutionDeclarationError::EmptyOwnerWitnessKey);
461        }
462        Ok(Self {
463            authority_key,
464            owner_key,
465            binding_fingerprint,
466        })
467    }
468
469    pub fn authority_key(&self) -> &str {
470        &self.authority_key
471    }
472
473    pub fn owner_key(&self) -> &str {
474        &self.owner_key
475    }
476
477    pub const fn binding_fingerprint(&self) -> &EphemeralToolBindingFingerprint {
478        &self.binding_fingerprint
479    }
480}
481
482/// Liveness policy for a streaming tool implementation.
483#[derive(Debug, Clone, PartialEq, Eq)]
484pub struct StreamingToolExecutionPolicy {
485    inactivity_timeout: Duration,
486    absolute_timeout: Duration,
487}
488
489impl StreamingToolExecutionPolicy {
490    pub fn new(
491        inactivity_timeout: Duration,
492        absolute_timeout: Duration,
493    ) -> Result<Self, ToolExecutionDeclarationError> {
494        if inactivity_timeout.is_zero() {
495            return Err(ToolExecutionDeclarationError::ZeroStreamingInactivity);
496        }
497        if absolute_timeout.is_zero() {
498            return Err(ToolExecutionDeclarationError::ZeroStreamingAbsolute);
499        }
500        if inactivity_timeout > absolute_timeout {
501            return Err(ToolExecutionDeclarationError::StreamingInactivityExceedsAbsolute);
502        }
503        Ok(Self {
504            inactivity_timeout,
505            absolute_timeout,
506        })
507    }
508
509    pub const fn inactivity_timeout(&self) -> Duration {
510        self.inactivity_timeout
511    }
512
513    pub const fn absolute_timeout(&self) -> Duration {
514        self.absolute_timeout
515    }
516}
517
518/// Submission and restart declaration for a detached tool implementation.
519#[derive(Debug, Clone, PartialEq, Eq)]
520pub struct DetachedToolExecutionPolicy {
521    runner: RunnerIdentity,
522    restart_class: RestartClass,
523    idempotency_scope: IdempotencyScope,
524    submission_timeout: Duration,
525    credential_scopes: BTreeSet<String>,
526}
527
528impl DetachedToolExecutionPolicy {
529    pub fn new(
530        runner: RunnerIdentity,
531        restart_class: RestartClass,
532        idempotency_scope: IdempotencyScope,
533        submission_timeout: Duration,
534    ) -> Result<Self, ToolExecutionDeclarationError> {
535        if submission_timeout.is_zero() {
536            return Err(ToolExecutionDeclarationError::ZeroDetachedSubmissionDeadline);
537        }
538        Ok(Self {
539            runner,
540            restart_class,
541            idempotency_scope,
542            submission_timeout,
543            credential_scopes: BTreeSet::new(),
544        })
545    }
546
547    #[must_use]
548    pub fn with_credential_scopes<I, S>(mut self, scopes: I) -> Self
549    where
550        I: IntoIterator<Item = S>,
551        S: Into<String>,
552    {
553        self.credential_scopes.extend(
554            scopes
555                .into_iter()
556                .map(Into::into)
557                .filter(|scope: &String| !scope.trim().is_empty()),
558        );
559        self
560    }
561
562    pub fn runner(&self) -> &RunnerIdentity {
563        &self.runner
564    }
565
566    pub const fn restart_class(&self) -> RestartClass {
567        self.restart_class
568    }
569
570    pub const fn idempotency_scope(&self) -> IdempotencyScope {
571        self.idempotency_scope
572    }
573
574    pub const fn submission_timeout(&self) -> Duration {
575        self.submission_timeout
576    }
577
578    pub fn credential_scopes(&self) -> &BTreeSet<String> {
579        &self.credential_scopes
580    }
581}
582
583/// Invalid streaming or detached policy declaration.
584#[derive(Debug, Clone, PartialEq, Eq)]
585pub enum ToolExecutionDeclarationError {
586    EmptyRunnerName,
587    EmptyRunnerVersion,
588    EmptyOwnerWitnessAuthorityKey,
589    EmptyOwnerWitnessKey,
590    ZeroStreamingInactivity,
591    ZeroStreamingAbsolute,
592    StreamingInactivityExceedsAbsolute,
593    ZeroDetachedSubmissionDeadline,
594}
595
596impl std::fmt::Display for ToolExecutionDeclarationError {
597    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
598        match self {
599            Self::EmptyRunnerName => formatter.write_str("detached runner name must not be empty"),
600            Self::EmptyRunnerVersion => {
601                formatter.write_str("detached runner version must not be empty")
602            }
603            Self::EmptyOwnerWitnessAuthorityKey => {
604                formatter.write_str("tool execution owner witness authority key must not be empty")
605            }
606            Self::EmptyOwnerWitnessKey => {
607                formatter.write_str("tool execution owner witness key must not be empty")
608            }
609            Self::ZeroStreamingInactivity => {
610                formatter.write_str("streaming inactivity timeout must be greater than zero")
611            }
612            Self::ZeroStreamingAbsolute => {
613                formatter.write_str("streaming absolute timeout must be greater than zero")
614            }
615            Self::StreamingInactivityExceedsAbsolute => formatter
616                .write_str("streaming inactivity timeout must not exceed the absolute timeout"),
617            Self::ZeroDetachedSubmissionDeadline => {
618                formatter.write_str("detached submission timeout must be greater than zero")
619            }
620        }
621    }
622}
623
624impl std::error::Error for ToolExecutionDeclarationError {}
625
626/// Invalid combination of supported modes and mode-specific policies.
627#[derive(Debug, Clone, PartialEq, Eq)]
628pub enum ToolExecutionContractError {
629    NoSupportedModes,
630    DefaultModeUnsupported {
631        default_mode: ToolExecutionMode,
632    },
633    MissingStreamingPolicy,
634    UnexpectedStreamingPolicy,
635    MissingDetachedPolicy,
636    UnexpectedDetachedPolicy,
637    RequestedModeUnsupported {
638        requested_mode: ToolExecutionMode,
639    },
640    RequestedRestartClassUnsupported {
641        restart_class: RestartClass,
642    },
643    ResolvedPlanFacetMismatch {
644        mode: ToolExecutionMode,
645        facet: &'static str,
646    },
647}
648
649impl std::fmt::Display for ToolExecutionContractError {
650    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
651        match self {
652            Self::NoSupportedModes => {
653                formatter.write_str("tool execution contract must support at least one mode")
654            }
655            Self::DefaultModeUnsupported { default_mode } => write!(
656                formatter,
657                "default tool execution mode {default_mode:?} is not supported"
658            ),
659            Self::MissingStreamingPolicy => {
660                formatter.write_str("streaming mode requires a streaming policy")
661            }
662            Self::UnexpectedStreamingPolicy => {
663                formatter.write_str("streaming policy requires streaming mode support")
664            }
665            Self::MissingDetachedPolicy => {
666                formatter.write_str("detached mode requires a detached policy")
667            }
668            Self::UnexpectedDetachedPolicy => {
669                formatter.write_str("detached policy requires detached mode support")
670            }
671            Self::RequestedModeUnsupported { requested_mode } => write!(
672                formatter,
673                "requested tool execution mode {requested_mode:?} is not supported"
674            ),
675            Self::RequestedRestartClassUnsupported { restart_class } => write!(
676                formatter,
677                "requested detached restart class {restart_class:?} is not supported"
678            ),
679            Self::ResolvedPlanFacetMismatch { mode, facet } => write!(
680                formatter,
681                "resolved {mode:?} tool execution plan does not match advertised facet '{facet}'"
682            ),
683        }
684    }
685}
686
687impl std::error::Error for ToolExecutionContractError {}
688
689/// Typed, caller-owned facts used during pre-dispatch plan resolution.
690#[derive(Debug, Clone, PartialEq, Eq)]
691pub struct ToolExecutionResolutionContext {
692    deadlines: ToolDeadlineChain,
693}
694
695impl ToolExecutionResolutionContext {
696    pub const fn new(deadlines: ToolDeadlineChain) -> Self {
697        Self { deadlines }
698    }
699
700    pub fn deadlines(&self) -> &ToolDeadlineChain {
701        &self.deadlines
702    }
703
704    pub fn with_deadline(
705        &self,
706        contributor: ToolDeadlineContributor,
707    ) -> Result<Self, DeadlineChainError> {
708        self.deadlines.with_contributor(contributor).map(Self::new)
709    }
710
711    /// Verify that a resolver only appended deadlines to this caller-owned
712    /// chain.
713    pub fn validate_resolved_plan(
714        &self,
715        plan: &ResolvedToolExecutionPlan,
716    ) -> Result<(), ToolExecutionResolutionError> {
717        plan.deadlines
718            .validate_extends(&self.deadlines)
719            .map_err(ToolExecutionResolutionError::DeadlineExtension)
720    }
721}
722
723/// Failure to resolve an exact pre-dispatch tool execution plan.
724#[derive(Debug, Clone, PartialEq, Eq)]
725pub enum ToolExecutionResolutionError {
726    NotFound {
727        tool_name: String,
728    },
729    Unavailable {
730        tool_name: String,
731        reason: crate::tool_catalog::ToolUnavailableReason,
732    },
733    AccessDenied {
734        tool_name: String,
735    },
736    InvalidArguments {
737        tool_name: String,
738        reason: String,
739    },
740    Deadline(DeadlineChainError),
741    DeadlineExtension(DeadlineChainExtensionError),
742    OwnerWitnessAlreadyAssigned {
743        existing: Box<ToolExecutionOwnerWitness>,
744        attempted: Box<ToolExecutionOwnerWitness>,
745    },
746    ResolvedCallMismatch {
747        tool_name: String,
748    },
749    RootDispatcherChanged {
750        tool_name: String,
751    },
752    Declaration(ToolExecutionDeclarationError),
753    Contract(ToolExecutionContractError),
754}
755
756impl std::fmt::Display for ToolExecutionResolutionError {
757    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
758        match self {
759            Self::NotFound { tool_name } => {
760                write!(
761                    formatter,
762                    "tool '{tool_name}' is not present in the catalog"
763                )
764            }
765            Self::Unavailable { tool_name, reason } => {
766                write!(formatter, "tool '{tool_name}' is unavailable: {reason}")
767            }
768            Self::AccessDenied { tool_name } => {
769                write!(
770                    formatter,
771                    "tool '{tool_name}' is denied by execution policy"
772                )
773            }
774            Self::InvalidArguments { tool_name, reason } => {
775                write!(
776                    formatter,
777                    "tool '{tool_name}' arguments cannot resolve an execution plan: {reason}"
778                )
779            }
780            Self::Deadline(error) => std::fmt::Display::fmt(error, formatter),
781            Self::DeadlineExtension(error) => {
782                write!(
783                    formatter,
784                    "tool deadline resolution replaced an upstream deadline: {error}"
785                )
786            }
787            Self::OwnerWitnessAlreadyAssigned {
788                existing,
789                attempted,
790            } => write!(
791                formatter,
792                "tool execution authority '{}' already selected owner '{}'; refusing replacement by '{}'",
793                existing.authority_key(),
794                existing.owner_key(),
795                attempted.owner_key(),
796            ),
797            Self::ResolvedCallMismatch { tool_name } => write!(
798                formatter,
799                "resolved execution plan for tool '{tool_name}' was dispatched with a different call identity"
800            ),
801            Self::RootDispatcherChanged { tool_name } => write!(
802                formatter,
803                "resolved execution plan for tool '{tool_name}' was dispatched through a different dispatcher instance"
804            ),
805            Self::Declaration(error) => std::fmt::Display::fmt(error, formatter),
806            Self::Contract(error) => std::fmt::Display::fmt(error, formatter),
807        }
808    }
809}
810
811impl std::error::Error for ToolExecutionResolutionError {}
812
813impl From<DeadlineChainError> for ToolExecutionResolutionError {
814    fn from(error: DeadlineChainError) -> Self {
815        Self::Deadline(error)
816    }
817}
818
819impl From<ToolExecutionContractError> for ToolExecutionResolutionError {
820    fn from(error: ToolExecutionContractError) -> Self {
821        Self::Contract(error)
822    }
823}
824
825impl From<ToolExecutionDeclarationError> for ToolExecutionResolutionError {
826    fn from(error: ToolExecutionDeclarationError) -> Self {
827        Self::Declaration(error)
828    }
829}
830
831impl From<ToolExecutionResolutionError> for crate::error::ToolError {
832    fn from(error: ToolExecutionResolutionError) -> Self {
833        match error {
834            ToolExecutionResolutionError::NotFound { tool_name } => Self::not_found(tool_name),
835            ToolExecutionResolutionError::Unavailable { tool_name, reason } => {
836                Self::unavailable(tool_name, reason)
837            }
838            ToolExecutionResolutionError::AccessDenied { tool_name } => {
839                Self::access_denied(tool_name)
840            }
841            ToolExecutionResolutionError::InvalidArguments { tool_name, reason } => {
842                Self::invalid_arguments(tool_name, reason)
843            }
844            ToolExecutionResolutionError::Deadline(error) => {
845                Self::execution_failed(format!("tool deadline resolution failed: {error}"))
846            }
847            ToolExecutionResolutionError::DeadlineExtension(error) => Self::execution_failed(
848                format!("tool deadline resolution replaced an upstream deadline: {error}"),
849            ),
850            ToolExecutionResolutionError::OwnerWitnessAlreadyAssigned {
851                existing,
852                attempted,
853            } => Self::execution_failed(format!(
854                "tool execution authority '{}' already selected owner '{}'; refusing replacement by '{}'",
855                existing.authority_key(),
856                existing.owner_key(),
857                attempted.owner_key(),
858            )),
859            ToolExecutionResolutionError::ResolvedCallMismatch { tool_name }
860            | ToolExecutionResolutionError::RootDispatcherChanged { tool_name } => {
861                Self::unavailable(
862                    tool_name,
863                    crate::ToolUnavailableReason::ExecutionOwnerChanged,
864                )
865            }
866            ToolExecutionResolutionError::Declaration(error) => {
867                Self::execution_failed(format!("tool execution declaration failed: {error}"))
868            }
869            ToolExecutionResolutionError::Contract(error) => Self::execution_failed(format!(
870                "tool execution contract resolution failed: {error}"
871            )),
872        }
873    }
874}
875
876/// Internal execution declaration attached to a catalog entry.
877#[derive(Debug, Clone, PartialEq, Eq)]
878pub struct ToolExecutionContract {
879    supported_modes: BTreeSet<ToolExecutionMode>,
880    default_mode: ToolExecutionMode,
881    streaming_policy: Option<StreamingToolExecutionPolicy>,
882    detached_policy: Option<DetachedToolExecutionPolicy>,
883    detached_restart_classes: BTreeSet<RestartClass>,
884}
885
886impl Default for ToolExecutionContract {
887    fn default() -> Self {
888        Self {
889            supported_modes: BTreeSet::from([ToolExecutionMode::Fast]),
890            default_mode: ToolExecutionMode::Fast,
891            streaming_policy: None,
892            detached_policy: None,
893            detached_restart_classes: BTreeSet::new(),
894        }
895    }
896}
897
898impl ToolExecutionContract {
899    pub fn new(
900        supported_modes: BTreeSet<ToolExecutionMode>,
901        default_mode: ToolExecutionMode,
902        streaming_policy: Option<StreamingToolExecutionPolicy>,
903        detached_policy: Option<DetachedToolExecutionPolicy>,
904    ) -> Result<Self, ToolExecutionContractError> {
905        if supported_modes.is_empty() {
906            return Err(ToolExecutionContractError::NoSupportedModes);
907        }
908        if !supported_modes.contains(&default_mode) {
909            return Err(ToolExecutionContractError::DefaultModeUnsupported { default_mode });
910        }
911        match (
912            supported_modes.contains(&ToolExecutionMode::Streaming),
913            streaming_policy.is_some(),
914        ) {
915            (true, false) => return Err(ToolExecutionContractError::MissingStreamingPolicy),
916            (false, true) => return Err(ToolExecutionContractError::UnexpectedStreamingPolicy),
917            _ => {}
918        }
919        match (
920            supported_modes.contains(&ToolExecutionMode::Detached),
921            detached_policy.is_some(),
922        ) {
923            (true, false) => return Err(ToolExecutionContractError::MissingDetachedPolicy),
924            (false, true) => return Err(ToolExecutionContractError::UnexpectedDetachedPolicy),
925            _ => {}
926        }
927        let detached_restart_classes = detached_policy
928            .as_ref()
929            .map(|policy| BTreeSet::from([policy.restart_class()]))
930            .unwrap_or_default();
931        Ok(Self {
932            supported_modes,
933            default_mode,
934            streaming_policy,
935            detached_policy,
936            detached_restart_classes,
937        })
938    }
939
940    pub fn supported_modes(&self) -> &BTreeSet<ToolExecutionMode> {
941        &self.supported_modes
942    }
943
944    pub const fn default_mode(&self) -> ToolExecutionMode {
945        self.default_mode
946    }
947
948    pub fn streaming_policy(&self) -> Option<&StreamingToolExecutionPolicy> {
949        self.streaming_policy.as_ref()
950    }
951
952    pub fn detached_policy(&self) -> Option<&DetachedToolExecutionPolicy> {
953        self.detached_policy.as_ref()
954    }
955
956    pub fn detached_restart_classes(&self) -> &BTreeSet<RestartClass> {
957        &self.detached_restart_classes
958    }
959
960    pub fn with_detached_restart_classes(
961        mut self,
962        restart_classes: BTreeSet<RestartClass>,
963    ) -> Result<Self, ToolExecutionContractError> {
964        let policy = self
965            .detached_policy
966            .as_ref()
967            .ok_or(ToolExecutionContractError::MissingDetachedPolicy)?;
968        if !restart_classes.contains(&policy.restart_class()) {
969            return Err(
970                ToolExecutionContractError::RequestedRestartClassUnsupported {
971                    restart_class: policy.restart_class(),
972                },
973            );
974        }
975        self.detached_restart_classes = restart_classes;
976        Ok(self)
977    }
978
979    pub fn resolve_default(
980        &self,
981        deadlines: ToolDeadlineChain,
982    ) -> Result<ResolvedToolExecutionPlan, ToolExecutionContractError> {
983        self.resolve(self.default_mode, deadlines)
984    }
985
986    pub fn resolve(
987        &self,
988        mode: ToolExecutionMode,
989        mut deadlines: ToolDeadlineChain,
990    ) -> Result<ResolvedToolExecutionPlan, ToolExecutionContractError> {
991        if !self.supported_modes.contains(&mode) {
992            return Err(ToolExecutionContractError::RequestedModeUnsupported {
993                requested_mode: mode,
994            });
995        }
996        let (
997            kind,
998            runner,
999            restart_class,
1000            idempotency_scope,
1001            credential_context_refs,
1002            output_policy,
1003            progress_policy,
1004        ) = match mode {
1005            ToolExecutionMode::Fast => (
1006                ResolvedExecutionKind::Fast,
1007                ToolExecutionApplicability::NotApplicable,
1008                ToolExecutionApplicability::NotApplicable,
1009                ToolExecutionApplicability::NotApplicable,
1010                ToolExecutionApplicability::NotApplicable,
1011                ToolExecutionApplicability::Applicable(ToolOutputPolicy::InlineTerminal),
1012                ToolExecutionApplicability::NotApplicable,
1013            ),
1014            ToolExecutionMode::Streaming => {
1015                let policy = self
1016                    .streaming_policy
1017                    .clone()
1018                    .ok_or(ToolExecutionContractError::MissingStreamingPolicy)?;
1019                deadlines.contributors.push(ToolDeadlineContributor::finite(
1020                    ToolDeadlineOwner::StreamingAbsolute,
1021                    policy.absolute_timeout(),
1022                ));
1023                (
1024                    ResolvedExecutionKind::Streaming(policy),
1025                    ToolExecutionApplicability::NotApplicable,
1026                    ToolExecutionApplicability::NotApplicable,
1027                    ToolExecutionApplicability::NotApplicable,
1028                    ToolExecutionApplicability::NotApplicable,
1029                    ToolExecutionApplicability::Applicable(ToolOutputPolicy::StreamingEvents),
1030                    ToolExecutionApplicability::Applicable(ToolProgressPolicy::StreamingEvents),
1031                )
1032            }
1033            ToolExecutionMode::Detached => {
1034                let policy = self
1035                    .detached_policy
1036                    .clone()
1037                    .ok_or(ToolExecutionContractError::MissingDetachedPolicy)?;
1038                deadlines.contributors.push(ToolDeadlineContributor::finite(
1039                    ToolDeadlineOwner::DetachedSubmission,
1040                    policy.submission_timeout(),
1041                ));
1042                (
1043                    ResolvedExecutionKind::Detached(policy.clone()),
1044                    ToolExecutionApplicability::Applicable(policy.runner().clone()),
1045                    ToolExecutionApplicability::Applicable(policy.restart_class()),
1046                    ToolExecutionApplicability::Applicable(policy.idempotency_scope()),
1047                    ToolExecutionApplicability::Applicable(vec![
1048                        ToolCredentialContextRef::OwningProfile {
1049                            required_scopes: policy.credential_scopes().clone(),
1050                        },
1051                    ]),
1052                    ToolExecutionApplicability::Applicable(ToolOutputPolicy::DurableJobResult),
1053                    ToolExecutionApplicability::Applicable(ToolProgressPolicy::DurableJobEvents),
1054                )
1055            }
1056        };
1057        Ok(ResolvedToolExecutionPlan {
1058            deadlines,
1059            kind,
1060            runner,
1061            restart_class,
1062            idempotency_scope,
1063            credential_context_refs,
1064            output_policy,
1065            progress_policy,
1066            owner_witnesses: Vec::new(),
1067            resolved_call: None,
1068            root_dispatcher: None,
1069        })
1070    }
1071
1072    pub fn resolve_detached_with_restart_class(
1073        &self,
1074        restart_class: RestartClass,
1075        deadlines: ToolDeadlineChain,
1076    ) -> Result<ResolvedToolExecutionPlan, ToolExecutionContractError> {
1077        if !self.detached_restart_classes.contains(&restart_class) {
1078            return Err(
1079                ToolExecutionContractError::RequestedRestartClassUnsupported { restart_class },
1080            );
1081        }
1082        let mut policy = self
1083            .detached_policy
1084            .clone()
1085            .ok_or(ToolExecutionContractError::MissingDetachedPolicy)?;
1086        policy.restart_class = restart_class;
1087        let mut plan = self.resolve(ToolExecutionMode::Detached, deadlines)?;
1088        plan.kind = ResolvedExecutionKind::Detached(policy);
1089        plan.restart_class = ToolExecutionApplicability::Applicable(restart_class);
1090        Ok(plan)
1091    }
1092
1093    /// Validate that a resolver's selected mode and every mode-derived facet
1094    /// stay within this advertised catalog contract.
1095    ///
1096    /// Hybrid resolvers remain free to select any advertised mode from typed
1097    /// arguments. They may append owner witnesses and additional deadline
1098    /// contributors, but cannot invent a mode, runner, restart/idempotency
1099    /// declaration, credential context, or output/progress policy that the
1100    /// catalog did not advertise.
1101    pub fn validate_resolved_plan(
1102        &self,
1103        plan: &ResolvedToolExecutionPlan,
1104    ) -> Result<(), ToolExecutionContractError> {
1105        let mode = plan.mode();
1106        if !self.supported_modes.contains(&mode) {
1107            return Err(ToolExecutionContractError::RequestedModeUnsupported {
1108                requested_mode: mode,
1109            });
1110        }
1111        let comparison_deadline = ToolDeadlineChain {
1112            contributors: vec![ToolDeadlineContributor::unbounded(
1113                ToolDeadlineOwner::Dispatcher,
1114            )],
1115        };
1116        let expected = if mode == ToolExecutionMode::Detached {
1117            let ToolExecutionApplicability::Applicable(restart_class) = plan.restart_class else {
1118                return Err(ToolExecutionContractError::ResolvedPlanFacetMismatch {
1119                    mode,
1120                    facet: "restart_class",
1121                });
1122            };
1123            self.resolve_detached_with_restart_class(restart_class, comparison_deadline)?
1124        } else {
1125            self.resolve(mode, comparison_deadline)?
1126        };
1127
1128        macro_rules! require_facet {
1129            ($field:ident) => {
1130                if plan.$field != expected.$field {
1131                    return Err(ToolExecutionContractError::ResolvedPlanFacetMismatch {
1132                        mode,
1133                        facet: stringify!($field),
1134                    });
1135                }
1136            };
1137        }
1138
1139        require_facet!(kind);
1140        require_facet!(runner);
1141        require_facet!(restart_class);
1142        require_facet!(idempotency_scope);
1143        require_facet!(credential_context_refs);
1144        require_facet!(output_policy);
1145        require_facet!(progress_policy);
1146        Ok(())
1147    }
1148}
1149
1150/// Mode-specific part of a resolved execution plan.
1151#[derive(Debug, Clone, PartialEq, Eq)]
1152pub enum ResolvedExecutionKind {
1153    Fast,
1154    Streaming(StreamingToolExecutionPolicy),
1155    Detached(DetachedToolExecutionPolicy),
1156}
1157
1158/// Exact execution and deadline plan selected before tool dispatch.
1159#[derive(Clone)]
1160pub struct ResolvedToolExecutionPlan {
1161    deadlines: ToolDeadlineChain,
1162    kind: ResolvedExecutionKind,
1163    runner: ToolExecutionApplicability<RunnerIdentity>,
1164    restart_class: ToolExecutionApplicability<RestartClass>,
1165    idempotency_scope: ToolExecutionApplicability<IdempotencyScope>,
1166    credential_context_refs: ToolExecutionApplicability<Vec<ToolCredentialContextRef>>,
1167    output_policy: ToolExecutionApplicability<ToolOutputPolicy>,
1168    progress_policy: ToolExecutionApplicability<ToolProgressPolicy>,
1169    owner_witnesses: Vec<ToolExecutionOwnerWitness>,
1170    resolved_call: Option<ResolvedToolCallIdentity>,
1171    root_dispatcher: Option<RootDispatcherLease>,
1172}
1173
1174#[derive(Debug, Clone, PartialEq, Eq)]
1175struct ResolvedToolCallIdentity {
1176    tool_use_id: String,
1177    tool_name: crate::ToolName,
1178    canonical_arguments_sha256: [u8; 32],
1179}
1180
1181trait ErasedRootDispatcherLease: Send + Sync {
1182    fn data_ptr(&self) -> *const ();
1183}
1184
1185struct TypedRootDispatcherLease<T: ?Sized + Send + Sync + 'static> {
1186    dispatcher: Arc<T>,
1187}
1188
1189impl<T: ?Sized + Send + Sync + 'static> ErasedRootDispatcherLease for TypedRootDispatcherLease<T> {
1190    fn data_ptr(&self) -> *const () {
1191        Arc::as_ptr(&self.dispatcher).cast::<()>()
1192    }
1193}
1194
1195#[derive(Clone)]
1196struct RootDispatcherLease(Arc<dyn ErasedRootDispatcherLease>);
1197
1198impl RootDispatcherLease {
1199    fn new<T: ?Sized + Send + Sync + 'static>(dispatcher: Arc<T>) -> Self {
1200        Self(Arc::new(TypedRootDispatcherLease { dispatcher }))
1201    }
1202
1203    fn matches<T: ?Sized + Send + Sync + 'static>(&self, dispatcher: &Arc<T>) -> bool {
1204        self.0.data_ptr() == Arc::as_ptr(dispatcher).cast::<()>()
1205    }
1206}
1207
1208impl std::fmt::Debug for ResolvedToolExecutionPlan {
1209    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1210        formatter
1211            .debug_struct("ResolvedToolExecutionPlan")
1212            .field("deadlines", &self.deadlines)
1213            .field("kind", &self.kind)
1214            .field("runner", &self.runner)
1215            .field("restart_class", &self.restart_class)
1216            .field("idempotency_scope", &self.idempotency_scope)
1217            .field("credential_context_refs", &self.credential_context_refs)
1218            .field("output_policy", &self.output_policy)
1219            .field("progress_policy", &self.progress_policy)
1220            .field("owner_witnesses", &self.owner_witnesses)
1221            .field("resolved_call", &self.resolved_call)
1222            .field("has_root_dispatcher_lease", &self.root_dispatcher.is_some())
1223            .finish()
1224    }
1225}
1226
1227impl PartialEq for ResolvedToolExecutionPlan {
1228    fn eq(&self, other: &Self) -> bool {
1229        self.deadlines == other.deadlines
1230            && self.kind == other.kind
1231            && self.runner == other.runner
1232            && self.restart_class == other.restart_class
1233            && self.idempotency_scope == other.idempotency_scope
1234            && self.credential_context_refs == other.credential_context_refs
1235            && self.output_policy == other.output_policy
1236            && self.progress_policy == other.progress_policy
1237            && self.owner_witnesses == other.owner_witnesses
1238            && self.resolved_call == other.resolved_call
1239            && match (&self.root_dispatcher, &other.root_dispatcher) {
1240                (Some(left), Some(right)) => left.0.data_ptr() == right.0.data_ptr(),
1241                (None, None) => true,
1242                (Some(_), None) | (None, Some(_)) => false,
1243            }
1244    }
1245}
1246
1247impl Eq for ResolvedToolExecutionPlan {}
1248
1249impl ResolvedToolExecutionPlan {
1250    pub const fn mode(&self) -> ToolExecutionMode {
1251        match self.kind {
1252            ResolvedExecutionKind::Fast => ToolExecutionMode::Fast,
1253            ResolvedExecutionKind::Streaming(_) => ToolExecutionMode::Streaming,
1254            ResolvedExecutionKind::Detached(_) => ToolExecutionMode::Detached,
1255        }
1256    }
1257
1258    pub fn deadlines(&self) -> &ToolDeadlineChain {
1259        &self.deadlines
1260    }
1261
1262    pub fn kind(&self) -> &ResolvedExecutionKind {
1263        &self.kind
1264    }
1265
1266    pub fn runner(&self) -> &ToolExecutionApplicability<RunnerIdentity> {
1267        &self.runner
1268    }
1269
1270    pub const fn restart_class(&self) -> ToolExecutionApplicability<RestartClass> {
1271        match self.restart_class {
1272            ToolExecutionApplicability::NotApplicable => ToolExecutionApplicability::NotApplicable,
1273            ToolExecutionApplicability::Applicable(value) => {
1274                ToolExecutionApplicability::Applicable(value)
1275            }
1276        }
1277    }
1278
1279    pub const fn idempotency_scope(&self) -> ToolExecutionApplicability<IdempotencyScope> {
1280        match self.idempotency_scope {
1281            ToolExecutionApplicability::NotApplicable => ToolExecutionApplicability::NotApplicable,
1282            ToolExecutionApplicability::Applicable(value) => {
1283                ToolExecutionApplicability::Applicable(value)
1284            }
1285        }
1286    }
1287
1288    pub fn credential_context_refs(
1289        &self,
1290    ) -> &ToolExecutionApplicability<Vec<ToolCredentialContextRef>> {
1291        &self.credential_context_refs
1292    }
1293
1294    pub const fn output_policy(&self) -> &ToolExecutionApplicability<ToolOutputPolicy> {
1295        &self.output_policy
1296    }
1297
1298    pub const fn progress_policy(&self) -> &ToolExecutionApplicability<ToolProgressPolicy> {
1299        &self.progress_policy
1300    }
1301
1302    pub fn owner_witness(&self, authority_key: &str) -> Option<&ToolExecutionOwnerWitness> {
1303        self.owner_witnesses
1304            .iter()
1305            .find(|witness| witness.authority_key() == authority_key)
1306    }
1307
1308    pub fn owner_witnesses(&self) -> &[ToolExecutionOwnerWitness] {
1309        &self.owner_witnesses
1310    }
1311
1312    /// Canonical argument digest bound into this plan at the fenced root
1313    /// resolution seam. Detached execution uses this exact digest for durable
1314    /// submission identity instead of re-canonicalizing arguments later.
1315    pub fn canonical_arguments_sha256(&self) -> Option<[u8; 32]> {
1316        self.resolved_call
1317            .as_ref()
1318            .map(|resolved| resolved.canonical_arguments_sha256)
1319    }
1320
1321    pub fn with_owner_witness(
1322        mut self,
1323        witness: ToolExecutionOwnerWitness,
1324    ) -> Result<Self, ToolExecutionResolutionError> {
1325        if let Some(existing) = self.owner_witness(witness.authority_key()).cloned() {
1326            return Err(ToolExecutionResolutionError::OwnerWitnessAlreadyAssigned {
1327                existing: Box::new(existing),
1328                attempted: Box::new(witness),
1329            });
1330        }
1331        self.owner_witnesses.push(witness);
1332        Ok(self)
1333    }
1334
1335    pub(crate) fn bind_root_dispatch<T: ?Sized + Send + Sync + 'static>(
1336        mut self,
1337        dispatcher: Arc<T>,
1338        call: crate::ToolCallView<'_>,
1339    ) -> Result<Self, ToolExecutionResolutionError> {
1340        self.resolved_call = Some(ResolvedToolCallIdentity::from_call(call)?);
1341        self.root_dispatcher = Some(RootDispatcherLease::new(dispatcher));
1342        Ok(self)
1343    }
1344
1345    pub(crate) fn validate_root_dispatch<T: ?Sized + Send + Sync + 'static>(
1346        &self,
1347        dispatcher: &Arc<T>,
1348        call: crate::ToolCallView<'_>,
1349    ) -> Result<(), ToolExecutionResolutionError> {
1350        let Some(root_dispatcher) = self.root_dispatcher.as_ref() else {
1351            return Err(ToolExecutionResolutionError::RootDispatcherChanged {
1352                tool_name: call.name.to_string(),
1353            });
1354        };
1355        if !root_dispatcher.matches(dispatcher) {
1356            return Err(ToolExecutionResolutionError::RootDispatcherChanged {
1357                tool_name: call.name.to_string(),
1358            });
1359        }
1360        let actual = ResolvedToolCallIdentity::from_call(call)?;
1361        if self.resolved_call.as_ref() != Some(&actual) {
1362            return Err(ToolExecutionResolutionError::ResolvedCallMismatch {
1363                tool_name: call.name.to_string(),
1364            });
1365        }
1366        Ok(())
1367    }
1368}
1369
1370impl ResolvedToolCallIdentity {
1371    fn from_call(call: crate::ToolCallView<'_>) -> Result<Self, ToolExecutionResolutionError> {
1372        let arguments: serde_json::Value =
1373            serde_json::from_str(call.args.get()).map_err(|error| {
1374                ToolExecutionResolutionError::InvalidArguments {
1375                    tool_name: call.name.to_string(),
1376                    reason: error.to_string(),
1377                }
1378            })?;
1379        let mut canonical = Vec::new();
1380        write_canonical_json(&arguments, &mut canonical).map_err(|error| {
1381            ToolExecutionResolutionError::InvalidArguments {
1382                tool_name: call.name.to_string(),
1383                reason: error.to_string(),
1384            }
1385        })?;
1386        Ok(Self {
1387            tool_use_id: call.id.to_string(),
1388            tool_name: call.name.into(),
1389            canonical_arguments_sha256: Sha256::digest(canonical).into(),
1390        })
1391    }
1392}
1393
1394fn write_canonical_json(
1395    value: &serde_json::Value,
1396    output: &mut Vec<u8>,
1397) -> Result<(), serde_json::Error> {
1398    match value {
1399        serde_json::Value::Object(object) => {
1400            output.push(b'{');
1401            let mut keys: Vec<_> = object.keys().collect();
1402            keys.sort_unstable();
1403            for (index, key) in keys.into_iter().enumerate() {
1404                if index > 0 {
1405                    output.push(b',');
1406                }
1407                serde_json::to_writer(&mut *output, key)?;
1408                output.push(b':');
1409                write_canonical_json(&object[key], output)?;
1410            }
1411            output.push(b'}');
1412        }
1413        serde_json::Value::Array(array) => {
1414            output.push(b'[');
1415            for (index, item) in array.iter().enumerate() {
1416                if index > 0 {
1417                    output.push(b',');
1418                }
1419                write_canonical_json(item, output)?;
1420            }
1421            output.push(b']');
1422        }
1423        scalar => serde_json::to_writer(output, scalar)?,
1424    }
1425    Ok(())
1426}
1427
1428#[cfg(test)]
1429#[allow(clippy::expect_used)]
1430mod tests {
1431    use super::*;
1432    use std::collections::BTreeSet;
1433    use std::sync::Arc;
1434    use std::time::Duration;
1435
1436    fn finite(owner: ToolDeadlineOwner, seconds: u64) -> ToolDeadlineContributor {
1437        ToolDeadlineContributor::finite(owner, Duration::from_secs(seconds))
1438    }
1439
1440    fn ephemeral_fingerprint(label: &str) -> EphemeralToolBindingFingerprint {
1441        ephemeral_tool_catalog_binding_fingerprint(&crate::ToolCatalogEntry::session_inline(
1442            Arc::new(crate::ToolDef::new(
1443                format!("test_{label}"),
1444                label,
1445                serde_json::json!({"type": "object"}),
1446            )),
1447            true,
1448        ))
1449    }
1450
1451    #[test]
1452    fn deadline_chain_rejects_empty_and_zero_deadlines() {
1453        assert_eq!(
1454            ToolDeadlineChain::new(Vec::new()),
1455            Err(DeadlineChainError::Empty)
1456        );
1457        assert_eq!(
1458            ToolDeadlineChain::new(vec![finite(ToolDeadlineOwner::CoreToolDispatch, 0,)]),
1459            Err(DeadlineChainError::Zero {
1460                owner: ToolDeadlineOwner::CoreToolDispatch,
1461            })
1462        );
1463    }
1464
1465    #[test]
1466    fn deadline_chain_retains_every_contributor_and_selects_narrowest() {
1467        let chain = ToolDeadlineChain::new(vec![
1468            finite(ToolDeadlineOwner::CoreToolDispatch, 600),
1469            finite(ToolDeadlineOwner::MobkitPublicCallback, 120),
1470            finite(ToolDeadlineOwner::SdkCallbackCancellation, 125),
1471            finite(ToolDeadlineOwner::GatewayWire, 130),
1472        ])
1473        .expect("valid deadline chain");
1474
1475        assert_eq!(chain.contributors().len(), 4);
1476        assert_eq!(chain.effective_timeout(), Some(Duration::from_secs(120)));
1477        assert_eq!(
1478            chain.winner().map(ToolDeadlineContributor::owner),
1479            Some(ToolDeadlineOwner::MobkitPublicCallback)
1480        );
1481        assert_eq!(
1482            chain.diagnostic(),
1483            "effective deadline: 120s\ncontributors:\n  core tool dispatch: 600s\n  mobkit public callback: 120s\n  sdk callback cancellation: 125s\n  gateway wire: 130s\nwinner: mobkit public callback"
1484        );
1485    }
1486
1487    #[test]
1488    fn equal_deadlines_use_declaration_order_as_deterministic_tie_breaker() {
1489        let chain = ToolDeadlineChain::new(vec![
1490            finite(ToolDeadlineOwner::Dispatcher, 30),
1491            finite(ToolDeadlineOwner::ToolInternal, 30),
1492        ])
1493        .expect("valid deadline chain");
1494
1495        assert_eq!(
1496            chain.winner().map(ToolDeadlineContributor::owner),
1497            Some(ToolDeadlineOwner::Dispatcher)
1498        );
1499    }
1500
1501    #[test]
1502    fn unbounded_contributors_are_diagnostic_but_never_win_a_finite_chain() {
1503        let chain = ToolDeadlineChain::new(vec![
1504            ToolDeadlineContributor::unbounded(ToolDeadlineOwner::DirectCaller),
1505            finite(ToolDeadlineOwner::ToolInternal, 30),
1506        ])
1507        .expect("valid deadline chain");
1508
1509        assert_eq!(chain.effective_timeout(), Some(Duration::from_secs(30)));
1510        assert_eq!(
1511            chain.winner().map(ToolDeadlineContributor::owner),
1512            Some(ToolDeadlineOwner::ToolInternal)
1513        );
1514        assert!(chain.diagnostic().contains("direct caller: unbounded"));
1515    }
1516
1517    #[test]
1518    fn deadline_diagnostic_never_rounds_a_nonzero_timeout_to_zero() {
1519        let chain = ToolDeadlineChain::new(vec![ToolDeadlineContributor::finite(
1520            ToolDeadlineOwner::ToolInternal,
1521            Duration::from_nanos(1),
1522        )])
1523        .expect("valid deadline chain");
1524
1525        assert!(chain.diagnostic().contains("effective deadline: 1ns"));
1526        assert!(chain.diagnostic().contains("tool internal: 1ns"));
1527        assert!(!chain.diagnostic().contains("0ms"));
1528    }
1529
1530    #[test]
1531    fn execution_contract_default_is_fast_only() {
1532        let contract = ToolExecutionContract::default();
1533
1534        assert_eq!(contract.default_mode(), ToolExecutionMode::Fast);
1535        assert_eq!(
1536            contract.supported_modes(),
1537            &BTreeSet::from([ToolExecutionMode::Fast])
1538        );
1539        assert!(contract.streaming_policy().is_none());
1540        assert!(contract.detached_policy().is_none());
1541    }
1542
1543    #[test]
1544    fn execution_contract_rejects_incoherent_mode_policies() {
1545        let streaming =
1546            StreamingToolExecutionPolicy::new(Duration::from_secs(5), Duration::from_secs(60))
1547                .expect("valid streaming policy");
1548        let detached = DetachedToolExecutionPolicy::new(
1549            RunnerIdentity::new("homecore.security_scan", "v1").expect("valid runner"),
1550            RestartClass::NonResumable,
1551            IdempotencyScope::InteractionAndArguments,
1552            Duration::from_secs(10),
1553        )
1554        .expect("valid detached policy");
1555
1556        assert_eq!(
1557            ToolExecutionContract::new(BTreeSet::new(), ToolExecutionMode::Fast, None, None,),
1558            Err(ToolExecutionContractError::NoSupportedModes)
1559        );
1560        assert_eq!(
1561            ToolExecutionContract::new(
1562                BTreeSet::from([ToolExecutionMode::Fast]),
1563                ToolExecutionMode::Detached,
1564                None,
1565                None,
1566            ),
1567            Err(ToolExecutionContractError::DefaultModeUnsupported {
1568                default_mode: ToolExecutionMode::Detached,
1569            })
1570        );
1571        assert_eq!(
1572            ToolExecutionContract::new(
1573                BTreeSet::from([ToolExecutionMode::Fast]),
1574                ToolExecutionMode::Fast,
1575                Some(streaming),
1576                None,
1577            ),
1578            Err(ToolExecutionContractError::UnexpectedStreamingPolicy)
1579        );
1580        assert_eq!(
1581            ToolExecutionContract::new(
1582                BTreeSet::from([ToolExecutionMode::Streaming]),
1583                ToolExecutionMode::Streaming,
1584                None,
1585                None,
1586            ),
1587            Err(ToolExecutionContractError::MissingStreamingPolicy)
1588        );
1589        assert_eq!(
1590            ToolExecutionContract::new(
1591                BTreeSet::from([ToolExecutionMode::Fast]),
1592                ToolExecutionMode::Fast,
1593                None,
1594                Some(detached),
1595            ),
1596            Err(ToolExecutionContractError::UnexpectedDetachedPolicy)
1597        );
1598        assert_eq!(
1599            ToolExecutionContract::new(
1600                BTreeSet::from([ToolExecutionMode::Detached]),
1601                ToolExecutionMode::Detached,
1602                None,
1603                None,
1604            ),
1605            Err(ToolExecutionContractError::MissingDetachedPolicy)
1606        );
1607    }
1608
1609    #[test]
1610    fn detached_resolution_carries_typed_runner_restart_and_idempotency() {
1611        let detached = DetachedToolExecutionPolicy::new(
1612            RunnerIdentity::new("homecore.security_scan", "v1").expect("valid runner"),
1613            RestartClass::NonResumable,
1614            IdempotencyScope::InteractionAndArguments,
1615            Duration::from_secs(10),
1616        )
1617        .expect("valid detached policy")
1618        .with_credential_scopes(["network"]);
1619        let contract = ToolExecutionContract::new(
1620            BTreeSet::from([ToolExecutionMode::Detached]),
1621            ToolExecutionMode::Detached,
1622            None,
1623            Some(detached.clone()),
1624        )
1625        .expect("valid detached contract");
1626        let deadlines =
1627            ToolDeadlineChain::new(vec![finite(ToolDeadlineOwner::CoreToolDispatch, 600)])
1628                .expect("valid upstream deadline");
1629
1630        let plan = contract
1631            .resolve_default(deadlines)
1632            .expect("default mode resolves");
1633
1634        assert_eq!(plan.mode(), ToolExecutionMode::Detached);
1635        assert_eq!(
1636            plan.deadlines().contributors(),
1637            &[
1638                finite(ToolDeadlineOwner::CoreToolDispatch, 600),
1639                finite(ToolDeadlineOwner::DetachedSubmission, 10),
1640            ]
1641        );
1642        assert_eq!(plan.kind(), &ResolvedExecutionKind::Detached(detached));
1643    }
1644
1645    #[test]
1646    fn mode_resolution_appends_its_own_absolute_deadline() {
1647        let upstream =
1648            ToolDeadlineChain::new(vec![finite(ToolDeadlineOwner::CoreToolDispatch, 600)])
1649                .expect("valid upstream deadline");
1650        let streaming =
1651            StreamingToolExecutionPolicy::new(Duration::from_secs(5), Duration::from_secs(60))
1652                .expect("valid streaming policy");
1653        let streaming_contract = ToolExecutionContract::new(
1654            BTreeSet::from([ToolExecutionMode::Streaming]),
1655            ToolExecutionMode::Streaming,
1656            Some(streaming),
1657            None,
1658        )
1659        .expect("valid streaming contract");
1660
1661        let streaming_plan = streaming_contract
1662            .resolve_default(upstream.clone())
1663            .expect("streaming mode resolves");
1664        assert_eq!(
1665            streaming_plan.deadlines().contributors(),
1666            &[
1667                finite(ToolDeadlineOwner::CoreToolDispatch, 600),
1668                finite(ToolDeadlineOwner::StreamingAbsolute, 60),
1669            ]
1670        );
1671
1672        let detached = DetachedToolExecutionPolicy::new(
1673            RunnerIdentity::new("homecore.security_scan", "v1").expect("valid runner"),
1674            RestartClass::NonResumable,
1675            IdempotencyScope::InteractionAndArguments,
1676            Duration::from_secs(10),
1677        )
1678        .expect("valid detached policy");
1679        let detached_contract = ToolExecutionContract::new(
1680            BTreeSet::from([ToolExecutionMode::Detached]),
1681            ToolExecutionMode::Detached,
1682            None,
1683            Some(detached),
1684        )
1685        .expect("valid detached contract");
1686
1687        let detached_plan = detached_contract
1688            .resolve_default(upstream)
1689            .expect("detached mode resolves");
1690        assert_eq!(
1691            detached_plan.deadlines().contributors(),
1692            &[
1693                finite(ToolDeadlineOwner::CoreToolDispatch, 600),
1694                finite(ToolDeadlineOwner::DetachedSubmission, 10),
1695            ]
1696        );
1697    }
1698
1699    #[test]
1700    fn advertised_contract_rejects_an_unadvertised_resolved_mode() {
1701        let upstream =
1702            ToolDeadlineChain::new(vec![finite(ToolDeadlineOwner::CoreToolDispatch, 600)])
1703                .expect("valid upstream deadline");
1704        let detached = DetachedToolExecutionPolicy::new(
1705            RunnerIdentity::new("dishonest.runner", "v1").expect("valid runner"),
1706            RestartClass::NonResumable,
1707            IdempotencyScope::ToolCall,
1708            Duration::from_secs(10),
1709        )
1710        .expect("valid detached policy");
1711        let detached_contract = ToolExecutionContract::new(
1712            BTreeSet::from([ToolExecutionMode::Detached]),
1713            ToolExecutionMode::Detached,
1714            None,
1715            Some(detached),
1716        )
1717        .expect("valid detached contract");
1718        let detached_plan = detached_contract
1719            .resolve_default(upstream)
1720            .expect("detached plan resolves");
1721
1722        assert_eq!(
1723            ToolExecutionContract::default().validate_resolved_plan(&detached_plan),
1724            Err(ToolExecutionContractError::RequestedModeUnsupported {
1725                requested_mode: ToolExecutionMode::Detached,
1726            })
1727        );
1728    }
1729
1730    #[test]
1731    fn advertised_contract_rejects_forged_mode_derived_facets() {
1732        let contract = ToolExecutionContract::default();
1733        let mut plan = contract
1734            .resolve_default(
1735                ToolDeadlineChain::new(vec![finite(ToolDeadlineOwner::CoreToolDispatch, 600)])
1736                    .expect("valid deadline"),
1737            )
1738            .expect("fast plan resolves");
1739        plan.output_policy =
1740            ToolExecutionApplicability::Applicable(ToolOutputPolicy::DurableJobResult);
1741
1742        assert_eq!(
1743            contract.validate_resolved_plan(&plan),
1744            Err(ToolExecutionContractError::ResolvedPlanFacetMismatch {
1745                mode: ToolExecutionMode::Fast,
1746                facet: "output_policy",
1747            })
1748        );
1749    }
1750
1751    #[test]
1752    fn logical_binding_fingerprint_ignores_projection_arc_rebuild() {
1753        let tool = || {
1754            std::sync::Arc::new(crate::ToolDef::new(
1755                "stable_tool",
1756                "stable declaration",
1757                serde_json::json!({"type": "object", "properties": {}}),
1758            ))
1759        };
1760        let original = crate::ToolCatalogEntry::session_inline(tool(), true);
1761        let rebuilt = crate::ToolCatalogEntry::session_inline(tool(), true);
1762        let changed = crate::ToolCatalogEntry::session_inline(
1763            std::sync::Arc::new(crate::ToolDef::new(
1764                "stable_tool",
1765                "replacement declaration",
1766                serde_json::json!({"type": "object", "properties": {}}),
1767            )),
1768            true,
1769        );
1770
1771        assert_eq!(
1772            ephemeral_tool_catalog_binding_fingerprint(&original),
1773            ephemeral_tool_catalog_binding_fingerprint(&rebuilt),
1774            "equivalent projection allocations are one live logical binding"
1775        );
1776        assert_ne!(
1777            ephemeral_tool_catalog_binding_fingerprint(&original),
1778            ephemeral_tool_catalog_binding_fingerprint(&changed),
1779            "name reuse with a different declaration must be fenced"
1780        );
1781    }
1782
1783    #[test]
1784    fn resolution_context_rejects_replaced_or_reordered_upstream_deadlines() {
1785        let upstream = ToolDeadlineChain::new(vec![
1786            finite(ToolDeadlineOwner::CoreToolDispatch, 600),
1787            finite(ToolDeadlineOwner::Dispatcher, 30),
1788        ])
1789        .expect("valid upstream deadline");
1790        let context = ToolExecutionResolutionContext::new(upstream.clone());
1791        let valid_plan = ToolExecutionContract::default()
1792            .resolve_default(
1793                upstream
1794                    .with_contributor(finite(ToolDeadlineOwner::ToolInternal, 10))
1795                    .expect("valid extension"),
1796            )
1797            .expect("default contract resolves");
1798        context
1799            .validate_resolved_plan(&valid_plan)
1800            .expect("ordered extension is valid");
1801
1802        let replaced_plan = ToolExecutionContract::default()
1803            .resolve_default(
1804                ToolDeadlineChain::new(vec![
1805                    finite(ToolDeadlineOwner::CoreToolDispatch, 600),
1806                    finite(ToolDeadlineOwner::ToolInternal, 10),
1807                ])
1808                .expect("valid but replaced chain"),
1809            )
1810            .expect("default contract resolves");
1811        assert_eq!(
1812            context.validate_resolved_plan(&replaced_plan),
1813            Err(ToolExecutionResolutionError::DeadlineExtension(
1814                DeadlineChainExtensionError::ContributorMismatch {
1815                    index: 1,
1816                    expected: finite(ToolDeadlineOwner::Dispatcher, 30),
1817                    actual: finite(ToolDeadlineOwner::ToolInternal, 10),
1818                }
1819            ))
1820        );
1821
1822        let shorter_plan = ToolExecutionContract::default()
1823            .resolve_default(
1824                ToolDeadlineChain::new(vec![finite(ToolDeadlineOwner::CoreToolDispatch, 600)])
1825                    .expect("valid but shorter chain"),
1826            )
1827            .expect("default contract resolves");
1828        assert_eq!(
1829            context.validate_resolved_plan(&shorter_plan),
1830            Err(ToolExecutionResolutionError::DeadlineExtension(
1831                DeadlineChainExtensionError::ShorterThanUpstream {
1832                    upstream_len: 2,
1833                    resolved_len: 1,
1834                }
1835            ))
1836        );
1837    }
1838
1839    #[test]
1840    fn resolved_plan_exposes_explicit_mode_applicability_and_owner_witness() {
1841        let detached = DetachedToolExecutionPolicy::new(
1842            RunnerIdentity::new("homecore.security_scan", "v1").expect("valid runner"),
1843            RestartClass::NonResumable,
1844            IdempotencyScope::InteractionAndArguments,
1845            Duration::from_secs(10),
1846        )
1847        .expect("valid detached policy")
1848        .with_credential_scopes(["network"]);
1849        let contract = ToolExecutionContract::new(
1850            BTreeSet::from([ToolExecutionMode::Detached]),
1851            ToolExecutionMode::Detached,
1852            None,
1853            Some(detached),
1854        )
1855        .expect("valid detached contract");
1856        let witness = ToolExecutionOwnerWitness::new(
1857            "dynamic-composite:1",
1858            "security_scan",
1859            ephemeral_fingerprint("security_scan"),
1860        )
1861        .expect("valid owner witness");
1862        let plan = contract
1863            .resolve_default(
1864                ToolDeadlineChain::new(vec![finite(ToolDeadlineOwner::CoreToolDispatch, 600)])
1865                    .expect("valid deadline"),
1866            )
1867            .expect("detached contract resolves")
1868            .with_owner_witness(witness.clone())
1869            .expect("first owner witness is accepted");
1870
1871        assert_eq!(
1872            plan.runner(),
1873            &ToolExecutionApplicability::Applicable(
1874                RunnerIdentity::new("homecore.security_scan", "v1").expect("valid runner")
1875            )
1876        );
1877        assert_eq!(
1878            plan.restart_class(),
1879            ToolExecutionApplicability::Applicable(RestartClass::NonResumable)
1880        );
1881        assert_eq!(
1882            plan.idempotency_scope(),
1883            ToolExecutionApplicability::Applicable(IdempotencyScope::InteractionAndArguments)
1884        );
1885        assert_eq!(
1886            plan.credential_context_refs(),
1887            &ToolExecutionApplicability::Applicable(vec![
1888                ToolCredentialContextRef::OwningProfile {
1889                    required_scopes: BTreeSet::from(["network".to_string()]),
1890                },
1891            ])
1892        );
1893        assert_eq!(
1894            plan.output_policy(),
1895            &ToolExecutionApplicability::Applicable(ToolOutputPolicy::DurableJobResult)
1896        );
1897        assert_eq!(
1898            plan.progress_policy(),
1899            &ToolExecutionApplicability::Applicable(ToolProgressPolicy::DurableJobEvents)
1900        );
1901        assert_eq!(plan.owner_witness("dynamic-composite:1"), Some(&witness));
1902
1903        let fast = ToolExecutionContract::default()
1904            .resolve_default(
1905                ToolDeadlineChain::new(vec![finite(ToolDeadlineOwner::CoreToolDispatch, 600)])
1906                    .expect("valid deadline"),
1907            )
1908            .expect("fast contract resolves");
1909        assert_eq!(fast.runner(), &ToolExecutionApplicability::NotApplicable);
1910        assert_eq!(
1911            fast.credential_context_refs(),
1912            &ToolExecutionApplicability::NotApplicable
1913        );
1914        assert_eq!(
1915            fast.output_policy(),
1916            &ToolExecutionApplicability::Applicable(ToolOutputPolicy::InlineTerminal)
1917        );
1918        assert_eq!(
1919            fast.progress_policy(),
1920            &ToolExecutionApplicability::NotApplicable
1921        );
1922    }
1923
1924    #[test]
1925    fn detached_contract_can_advertise_and_validate_call_resolved_restart_classes() {
1926        let policy = DetachedToolExecutionPolicy::new(
1927            RunnerIdentity::new("meerkat.monitor_script", "v1").expect("runner"),
1928            RestartClass::NonResumable,
1929            IdempotencyScope::ToolCall,
1930            Duration::from_secs(30),
1931        )
1932        .expect("policy");
1933        let contract = ToolExecutionContract::new(
1934            BTreeSet::from([ToolExecutionMode::Detached]),
1935            ToolExecutionMode::Detached,
1936            None,
1937            Some(policy),
1938        )
1939        .expect("contract")
1940        .with_detached_restart_classes(BTreeSet::from([
1941            RestartClass::Replayable,
1942            RestartClass::CheckpointResumable,
1943            RestartClass::NonResumable,
1944        ]))
1945        .expect("restart set");
1946        let deadlines =
1947            ToolDeadlineChain::new(vec![finite(ToolDeadlineOwner::CoreToolDispatch, 600)])
1948                .expect("deadlines");
1949        let resolved = contract
1950            .resolve_detached_with_restart_class(
1951                RestartClass::CheckpointResumable,
1952                deadlines.clone(),
1953            )
1954            .expect("resolved");
1955        assert_eq!(
1956            resolved.restart_class(),
1957            ToolExecutionApplicability::Applicable(RestartClass::CheckpointResumable)
1958        );
1959        contract
1960            .validate_resolved_plan(&resolved)
1961            .expect("resolved class remains within the advertised contract");
1962        assert_eq!(
1963            contract.resolve_detached_with_restart_class(RestartClass::Adoptable, deadlines),
1964            Err(
1965                ToolExecutionContractError::RequestedRestartClassUnsupported {
1966                    restart_class: RestartClass::Adoptable
1967                }
1968            )
1969        );
1970    }
1971
1972    #[test]
1973    fn owner_witness_rejects_blank_owner_keys() {
1974        assert_eq!(
1975            ToolExecutionOwnerWitness::new(
1976                "authority:a",
1977                "  ",
1978                ephemeral_fingerprint("blank-owner"),
1979            ),
1980            Err(ToolExecutionDeclarationError::EmptyOwnerWitnessKey)
1981        );
1982
1983        assert_eq!(
1984            ToolExecutionOwnerWitness::new(
1985                "  ",
1986                "owner:a",
1987                ephemeral_fingerprint("blank-authority"),
1988            ),
1989            Err(ToolExecutionDeclarationError::EmptyOwnerWitnessAuthorityKey)
1990        );
1991
1992        let original = ToolExecutionOwnerWitness::new(
1993            "authority:a",
1994            "owner:a",
1995            ephemeral_fingerprint("original"),
1996        )
1997        .expect("valid witness");
1998        let nested = ToolExecutionOwnerWitness::new(
1999            "authority:b",
2000            "owner:b",
2001            ephemeral_fingerprint("nested"),
2002        )
2003        .expect("valid witness");
2004        let attempted = ToolExecutionOwnerWitness::new(
2005            "authority:a",
2006            "owner:c",
2007            ephemeral_fingerprint("attempted"),
2008        )
2009        .expect("valid witness");
2010        let plan = ToolExecutionContract::default()
2011            .resolve_default(
2012                ToolDeadlineChain::new(vec![finite(ToolDeadlineOwner::CoreToolDispatch, 600)])
2013                    .expect("valid deadline"),
2014            )
2015            .expect("fast contract resolves")
2016            .with_owner_witness(original.clone())
2017            .expect("first witness is accepted")
2018            .with_owner_witness(nested.clone())
2019            .expect("a nested authority may append its own witness");
2020        assert_eq!(plan.owner_witness("authority:a"), Some(&original));
2021        assert_eq!(plan.owner_witness("authority:b"), Some(&nested));
2022        assert_eq!(
2023            plan.with_owner_witness(attempted.clone()),
2024            Err(ToolExecutionResolutionError::OwnerWitnessAlreadyAssigned {
2025                existing: Box::new(original),
2026                attempted: Box::new(attempted),
2027            })
2028        );
2029    }
2030}