Skip to main content

chio_kernel/
tool_outcome.rs

1//! Durable tool-return and post-return evaluation contracts.
2
3use chio_core::canonical::canonical_json_bytes;
4use chio_core::capability::scope::MonetaryAmount;
5use chio_core::crypto::sha256_hex;
6use chio_core::receipt::metadata::GuardEvidence;
7use chio_core_types::{provider_attempt::ProviderAttemptBindingV1, StoreMutationFence};
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10use thiserror::Error;
11
12use crate::admission_operation::{
13    AdmissionAttachment, AdmissionDigest, AdmissionDispatchCommitBindingV1, AdmissionDispatchState,
14    AdmissionIdentifier, AdmissionOperationCommand, AdmissionOperationId, AdmissionOperationState,
15    AdmissionOperationV1, AdmissionProjectionContext, AdmissionRecoveryLease,
16};
17use crate::dispatch_status::{
18    DispatchStatusQuery, QualifiedDispatchStatusProvider, VerifiedProviderNotAccepted,
19};
20use crate::runtime::ToolCallRequest;
21
22pub const RAW_INVOCATION_OUTCOME_SCHEMA: &str = "chio.raw-invocation-outcome.v1";
23pub const RAW_INVOCATION_OUTCOME_WITH_REQUEST_SCHEMA: &str =
24    "chio.raw-invocation-outcome-with-request.v1";
25pub const TOOL_OUTCOME_SCHEMA: &str = "chio.tool-outcome.v1";
26pub const POST_RETURN_EVALUATION_SCHEMA: &str = "chio.post-return-evaluation.v1";
27pub const POST_RETURN_EXACT_INPUTS_SCHEMA: &str = "chio.post-return-exact-inputs.v1";
28pub const MONETARY_RELEASE_EVIDENCE_SCHEMA: &str = "chio.monetary-release-evidence.v1";
29
30pub const MAX_RAW_INVOCATION_OUTCOME_BYTES: usize = 257 * 1024 * 1024;
31pub const MAX_RESOLVED_OUTPUT_BYTES: usize = 257 * 1024 * 1024;
32pub const MAX_FROZEN_INPUT_BYTES: usize = 1024 * 1024;
33pub const MAX_EVIDENCE_ARTIFACT_BYTES: usize = 256 * 1024;
34pub const MAX_MONETARY_RELEASE_EVIDENCE_BYTES: usize = 4 * 1024 * 1024;
35pub const MAX_STREAM_CHUNKS: usize = 1_048_576;
36pub const MAX_EVALUATION_STEPS: usize = 128;
37pub const MAX_RECEIPT_GUARD_EVIDENCE: usize = 1_024;
38
39const MAX_REASON_BYTES: usize = 1024;
40const I_JSON_MAX_SAFE_INTEGER: u64 = (1 << 53) - 1;
41
42#[derive(Debug, Error, Clone, PartialEq, Eq)]
43pub enum ToolOutcomeError {
44    #[error("canonical JSON failed: {0}")]
45    Canonical(String),
46    #[error("invalid field: {0}")]
47    Invalid(&'static str),
48    #[error("{field} has size {actual}; maximum is {maximum}")]
49    TooLarge {
50        field: &'static str,
51        actual: usize,
52        maximum: usize,
53    },
54    #[error("binding mismatch: {0}")]
55    Binding(&'static str),
56    #[error("qualified release authority unavailable: {0}")]
57    ReleaseAuthorityUnavailable(&'static str),
58    #[error("CAS conflict: expected version {expected}, found {actual}")]
59    Cas { expected: u64, actual: u64 },
60    #[error("transition {transition} is illegal from {state}")]
61    Transition {
62        state: &'static str,
63        transition: &'static str,
64    },
65    #[error("version overflow: {0}")]
66    Overflow(&'static str),
67}
68
69fn canonical<T: Serialize>(value: &T) -> Result<Vec<u8>, ToolOutcomeError> {
70    canonical_json_bytes(value).map_err(|error| ToolOutcomeError::Canonical(error.to_string()))
71}
72
73fn bounded<T: Serialize>(
74    field: &'static str,
75    value: &T,
76    maximum: usize,
77) -> Result<Vec<u8>, ToolOutcomeError> {
78    let bytes = canonical(value)?;
79    if bytes.len() > maximum {
80        return Err(ToolOutcomeError::TooLarge {
81            field,
82            actual: bytes.len(),
83            maximum,
84        });
85    }
86    Ok(bytes)
87}
88
89fn domain_digest<T: Serialize>(
90    domain: &'static str,
91    value: &T,
92) -> Result<AdmissionDigest, ToolOutcomeError> {
93    #[derive(Serialize)]
94    struct Bound<'a, T> {
95        domain: &'static str,
96        value: &'a T,
97    }
98    digest_bytes("derived_digest", &canonical(&Bound { domain, value })?)
99}
100
101fn digest_bytes(field: &'static str, bytes: &[u8]) -> Result<AdmissionDigest, ToolOutcomeError> {
102    AdmissionDigest::try_new(field, sha256_hex(bytes))
103        .map_err(|error| ToolOutcomeError::Canonical(error.to_string()))
104}
105
106fn positive(field: &'static str, value: u64) -> Result<(), ToolOutcomeError> {
107    if value == 0 || value > I_JSON_MAX_SAFE_INTEGER {
108        return Err(ToolOutcomeError::Invalid(field));
109    }
110    Ok(())
111}
112
113fn validate_store_fence(fence: &StoreMutationFence) -> Result<(), ToolOutcomeError> {
114    positive("store_fence.owner_epoch", fence.owner_epoch)?;
115    AdmissionIdentifier::try_new("store_fence.store_uuid", fence.store_uuid.clone())
116        .and_then(|_| AdmissionIdentifier::try_new("store_fence.lease_id", fence.lease_id.clone()))
117        .map_err(|error| ToolOutcomeError::Canonical(error.to_string()))?;
118    Ok(())
119}
120
121fn validate_successor_fence(
122    historical: &StoreMutationFence,
123    current: &StoreMutationFence,
124) -> Result<(), ToolOutcomeError> {
125    validate_store_fence(historical)?;
126    validate_store_fence(current)?;
127    if historical.store_uuid != current.store_uuid
128        || current.owner_epoch < historical.owner_epoch
129        || (current.owner_epoch == historical.owner_epoch && current != historical)
130    {
131        return Err(ToolOutcomeError::Binding("store_fence.lineage"));
132    }
133    Ok(())
134}
135
136fn amount(value: &MonetaryAmount) -> Result<(), ToolOutcomeError> {
137    if value.units > I_JSON_MAX_SAFE_INTEGER
138        || value.currency.len() != 3
139        || !value.currency.bytes().all(|byte| byte.is_ascii_uppercase())
140    {
141        return Err(ToolOutcomeError::Invalid("monetary_amount"));
142    }
143    Ok(())
144}
145
146fn validate_committed_operation(
147    operation: &AdmissionOperationV1,
148    commit: &AdmissionDispatchCommitBindingV1,
149) -> Result<(), ToolOutcomeError> {
150    validate_retained_dispatch_commit(operation, commit)?;
151    if !matches!(
152        operation.dispatch_state(),
153        AdmissionDispatchState::Committed | AdmissionDispatchState::Finalizing
154    ) {
155        return Err(ToolOutcomeError::Binding(
156            "admission_operation.outcome_recording_state",
157        ));
158    }
159    Ok(())
160}
161
162fn validate_registered_provider_attempt(
163    operation: &AdmissionOperationV1,
164    provider_attempt: &ProviderAttemptBindingV1,
165) -> Result<(), ToolOutcomeError> {
166    provider_attempt
167        .validate()
168        .map_err(|_| ToolOutcomeError::Binding("provider_attempt.shape"))?;
169    if operation.provider_attempt() != Some(provider_attempt)
170        || operation
171            .dispatch_commit()
172            .and_then(|commit| commit.provider_attempt.as_ref())
173            != Some(provider_attempt)
174    {
175        return Err(ToolOutcomeError::Binding(
176            "provider_attempt.registered_attempt",
177        ));
178    }
179    Ok(())
180}
181
182fn validate_retained_dispatch_commit(
183    operation: &AdmissionOperationV1,
184    commit: &AdmissionDispatchCommitBindingV1,
185) -> Result<(), ToolOutcomeError> {
186    operation
187        .validate()
188        .map_err(|error| ToolOutcomeError::Canonical(error.to_string()))?;
189    if operation.dispatch_commit() != Some(commit)
190        || !matches!(
191            operation.dispatch_state(),
192            AdmissionDispatchState::Committed
193                | AdmissionDispatchState::Finalizing
194                | AdmissionDispatchState::Terminal
195        )
196        || commit.committed_version == 0
197        || commit.store_fence.owner_epoch == 0
198    {
199        return Err(ToolOutcomeError::Binding(
200            "admission_operation.dispatch_commit",
201        ));
202    }
203    Ok(())
204}
205
206#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
207#[serde(deny_unknown_fields)]
208pub struct ContentAddressedBlobRefV1 {
209    digest: AdmissionDigest,
210    uri: String,
211}
212
213impl ContentAddressedBlobRefV1 {
214    fn new(digest: AdmissionDigest) -> Self {
215        Self {
216            uri: format!("sha256:{}", digest.as_str()),
217            digest,
218        }
219    }
220
221    pub fn digest(&self) -> &AdmissionDigest {
222        &self.digest
223    }
224
225    pub fn uri(&self) -> &str {
226        &self.uri
227    }
228
229    fn validate(&self) -> Result<(), ToolOutcomeError> {
230        if self.uri != format!("sha256:{}", self.digest.as_str()) {
231            return Err(ToolOutcomeError::Binding("blob_ref.uri"));
232        }
233        Ok(())
234    }
235}
236
237#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
238#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
239pub enum InvocationOutputV1 {
240    Value { value: Value },
241    CompleteStream { chunks: Vec<Value> },
242    IncompleteStream { chunks: Vec<Value>, reason: String },
243}
244
245impl InvocationOutputV1 {
246    fn validate(&self) -> Result<(), ToolOutcomeError> {
247        let (chunks, reason) = match self {
248            Self::Value { .. } => return Ok(()),
249            Self::CompleteStream { chunks } => (chunks, None),
250            Self::IncompleteStream { chunks, reason } => (chunks, Some(reason)),
251        };
252        if chunks.len() > MAX_STREAM_CHUNKS {
253            return Err(ToolOutcomeError::TooLarge {
254                field: "output.chunks",
255                actual: chunks.len(),
256                maximum: MAX_STREAM_CHUNKS,
257            });
258        }
259        if reason.is_some_and(|value| {
260            value.is_empty()
261                || value.len() > MAX_REASON_BYTES
262                || value.trim() != value
263                || value.chars().any(char::is_control)
264        }) {
265            return Err(ToolOutcomeError::Invalid("output.reason"));
266        }
267        Ok(())
268    }
269}
270
271#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
272#[serde(deny_unknown_fields)]
273pub struct InvocationStreamLimitsV1 {
274    pub max_total_bytes: u64,
275    pub max_chunks: u64,
276    pub max_duration_secs: u64,
277}
278
279impl InvocationStreamLimitsV1 {
280    pub(crate) fn new(
281        max_total_bytes: u64,
282        max_chunks: u64,
283        max_duration_secs: u64,
284    ) -> Result<Self, ToolOutcomeError> {
285        let limits = Self {
286            max_total_bytes,
287            max_chunks,
288            max_duration_secs,
289        };
290        limits.validate()?;
291        Ok(limits)
292    }
293
294    fn validate(&self) -> Result<(), ToolOutcomeError> {
295        if self.max_total_bytes > I_JSON_MAX_SAFE_INTEGER
296            || self.max_chunks > I_JSON_MAX_SAFE_INTEGER
297            || self.max_duration_secs > I_JSON_MAX_SAFE_INTEGER
298        {
299            return Err(ToolOutcomeError::Invalid("raw.stream_limits"));
300        }
301        Ok(())
302    }
303}
304
305/// The only canonical representation of a returned invocation.
306#[derive(Debug, Clone, PartialEq, Serialize)]
307pub struct RawInvocationOutcomeV1 {
308    schema: &'static str,
309    operation_id: AdmissionOperationId,
310    request_id: AdmissionIdentifier,
311    dispatch_operation_version: u64,
312    dispatch_fence: u64,
313    tool_server: AdmissionIdentifier,
314    tool_name: AdmissionIdentifier,
315    provider_attempt: ProviderAttemptBindingV1,
316    transport_terminal_evidence_digest: AdmissionDigest,
317    matched_grant_index: u64,
318    elapsed_millis: u64,
319    stream_limits: InvocationStreamLimitsV1,
320    output: InvocationOutputV1,
321    reported_cost: Option<MonetaryAmount>,
322    receipt_metadata_snapshot: Option<Value>,
323    pre_invocation_guard_evidence: Vec<GuardEvidence>,
324    #[serde(skip_serializing_if = "Option::is_none")]
325    request_canonical_json: Option<String>,
326}
327
328#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
329#[serde(deny_unknown_fields)]
330pub struct PersistedRawInvocationOutcomeV1 {
331    pub schema: String,
332    pub operation_id: AdmissionOperationId,
333    pub request_id: AdmissionIdentifier,
334    pub dispatch_operation_version: u64,
335    pub dispatch_fence: u64,
336    pub tool_server: AdmissionIdentifier,
337    pub tool_name: AdmissionIdentifier,
338    pub provider_attempt: ProviderAttemptBindingV1,
339    pub transport_terminal_evidence_digest: AdmissionDigest,
340    pub matched_grant_index: u64,
341    pub elapsed_millis: u64,
342    pub stream_limits: InvocationStreamLimitsV1,
343    pub output: InvocationOutputV1,
344    pub reported_cost: Option<MonetaryAmount>,
345    pub receipt_metadata_snapshot: Option<Value>,
346    pub pre_invocation_guard_evidence: Vec<GuardEvidence>,
347    #[serde(default, skip_serializing_if = "Option::is_none")]
348    pub request_canonical_json: Option<String>,
349}
350
351impl RawInvocationOutcomeV1 {
352    #[allow(dead_code)]
353    #[allow(clippy::too_many_arguments)]
354    pub(crate) fn from_committed_dispatch(
355        operation: &AdmissionOperationV1,
356        commit: &AdmissionDispatchCommitBindingV1,
357        tool_server: AdmissionIdentifier,
358        tool_name: AdmissionIdentifier,
359        provider_attempt: ProviderAttemptBindingV1,
360        transport_terminal_evidence_digest: AdmissionDigest,
361        matched_grant_index: u64,
362        elapsed_millis: u64,
363        stream_limits: InvocationStreamLimitsV1,
364        output: InvocationOutputV1,
365        reported_cost: Option<MonetaryAmount>,
366        receipt_metadata_snapshot: Option<Value>,
367        pre_invocation_guard_evidence: Vec<GuardEvidence>,
368    ) -> Result<Self, ToolOutcomeError> {
369        Self::from_committed_dispatch_parts(
370            RAW_INVOCATION_OUTCOME_SCHEMA,
371            operation,
372            commit,
373            tool_server,
374            tool_name,
375            provider_attempt,
376            transport_terminal_evidence_digest,
377            matched_grant_index,
378            elapsed_millis,
379            stream_limits,
380            output,
381            reported_cost,
382            receipt_metadata_snapshot,
383            pre_invocation_guard_evidence,
384            None,
385        )
386    }
387
388    #[allow(clippy::too_many_arguments)]
389    pub(crate) fn from_committed_dispatch_with_request(
390        operation: &AdmissionOperationV1,
391        commit: &AdmissionDispatchCommitBindingV1,
392        tool_server: AdmissionIdentifier,
393        tool_name: AdmissionIdentifier,
394        provider_attempt: ProviderAttemptBindingV1,
395        transport_terminal_evidence_digest: AdmissionDigest,
396        matched_grant_index: u64,
397        elapsed_millis: u64,
398        stream_limits: InvocationStreamLimitsV1,
399        output: InvocationOutputV1,
400        reported_cost: Option<MonetaryAmount>,
401        receipt_metadata_snapshot: Option<Value>,
402        pre_invocation_guard_evidence: Vec<GuardEvidence>,
403        request: &ToolCallRequest,
404    ) -> Result<Self, ToolOutcomeError> {
405        let request_canonical_json = String::from_utf8(canonical(request)?)
406            .map_err(|_| ToolOutcomeError::Invalid("raw.request_canonical_json"))?;
407        Self::from_committed_dispatch_parts(
408            RAW_INVOCATION_OUTCOME_WITH_REQUEST_SCHEMA,
409            operation,
410            commit,
411            tool_server,
412            tool_name,
413            provider_attempt,
414            transport_terminal_evidence_digest,
415            matched_grant_index,
416            elapsed_millis,
417            stream_limits,
418            output,
419            reported_cost,
420            receipt_metadata_snapshot,
421            pre_invocation_guard_evidence,
422            Some(request_canonical_json),
423        )
424    }
425
426    #[allow(clippy::too_many_arguments)]
427    fn from_committed_dispatch_parts(
428        schema: &'static str,
429        operation: &AdmissionOperationV1,
430        commit: &AdmissionDispatchCommitBindingV1,
431        tool_server: AdmissionIdentifier,
432        tool_name: AdmissionIdentifier,
433        provider_attempt: ProviderAttemptBindingV1,
434        transport_terminal_evidence_digest: AdmissionDigest,
435        matched_grant_index: u64,
436        elapsed_millis: u64,
437        stream_limits: InvocationStreamLimitsV1,
438        output: InvocationOutputV1,
439        reported_cost: Option<MonetaryAmount>,
440        receipt_metadata_snapshot: Option<Value>,
441        pre_invocation_guard_evidence: Vec<GuardEvidence>,
442        request_canonical_json: Option<String>,
443    ) -> Result<Self, ToolOutcomeError> {
444        validate_committed_operation(operation, commit)?;
445        validate_registered_provider_attempt(operation, &provider_attempt)?;
446        let raw = Self {
447            schema,
448            operation_id: operation.binding().operation_id().clone(),
449            request_id: operation.replay_key().request_id,
450            dispatch_operation_version: commit.committed_version,
451            dispatch_fence: commit.store_fence.owner_epoch,
452            tool_server,
453            tool_name,
454            provider_attempt,
455            transport_terminal_evidence_digest,
456            matched_grant_index,
457            elapsed_millis,
458            stream_limits,
459            output,
460            reported_cost,
461            receipt_metadata_snapshot,
462            pre_invocation_guard_evidence,
463            request_canonical_json,
464        };
465        raw.canonical_blob()?;
466        Ok(raw)
467    }
468
469    pub fn canonical_blob(&self) -> Result<CanonicalInvocationBlobV1, ToolOutcomeError> {
470        self.canonical_blob_bounded(MAX_RAW_INVOCATION_OUTCOME_BYTES)
471    }
472
473    pub fn to_persisted(&self) -> PersistedRawInvocationOutcomeV1 {
474        PersistedRawInvocationOutcomeV1 {
475            schema: self.schema.to_owned(),
476            operation_id: self.operation_id.clone(),
477            request_id: self.request_id.clone(),
478            dispatch_operation_version: self.dispatch_operation_version,
479            dispatch_fence: self.dispatch_fence,
480            tool_server: self.tool_server.clone(),
481            tool_name: self.tool_name.clone(),
482            provider_attempt: self.provider_attempt.clone(),
483            transport_terminal_evidence_digest: self.transport_terminal_evidence_digest.clone(),
484            matched_grant_index: self.matched_grant_index,
485            elapsed_millis: self.elapsed_millis,
486            stream_limits: self.stream_limits,
487            output: self.output.clone(),
488            reported_cost: self.reported_cost.clone(),
489            receipt_metadata_snapshot: self.receipt_metadata_snapshot.clone(),
490            pre_invocation_guard_evidence: self.pre_invocation_guard_evidence.clone(),
491            request_canonical_json: self.request_canonical_json.clone(),
492        }
493    }
494
495    pub fn from_persisted(
496        value: PersistedRawInvocationOutcomeV1,
497    ) -> Result<Self, ToolOutcomeError> {
498        let schema = match (
499            value.schema.as_str(),
500            value.request_canonical_json.is_some(),
501        ) {
502            (RAW_INVOCATION_OUTCOME_SCHEMA, false) => RAW_INVOCATION_OUTCOME_SCHEMA,
503            (RAW_INVOCATION_OUTCOME_WITH_REQUEST_SCHEMA, true) => {
504                RAW_INVOCATION_OUTCOME_WITH_REQUEST_SCHEMA
505            }
506            _ => return Err(ToolOutcomeError::Invalid("raw.schema")),
507        };
508        if value.request_canonical_json.as_deref() == Some("") {
509            return Err(ToolOutcomeError::Invalid("raw.schema"));
510        }
511        let raw = Self {
512            schema,
513            operation_id: value.operation_id,
514            request_id: value.request_id,
515            dispatch_operation_version: value.dispatch_operation_version,
516            dispatch_fence: value.dispatch_fence,
517            tool_server: value.tool_server,
518            tool_name: value.tool_name,
519            provider_attempt: value.provider_attempt,
520            transport_terminal_evidence_digest: value.transport_terminal_evidence_digest,
521            matched_grant_index: value.matched_grant_index,
522            elapsed_millis: value.elapsed_millis,
523            stream_limits: value.stream_limits,
524            output: value.output,
525            reported_cost: value.reported_cost,
526            receipt_metadata_snapshot: value.receipt_metadata_snapshot,
527            pre_invocation_guard_evidence: value.pre_invocation_guard_evidence,
528            request_canonical_json: value.request_canonical_json,
529        };
530        raw.canonical_blob()?;
531        Ok(raw)
532    }
533
534    pub fn from_canonical_bytes(bytes: &[u8]) -> Result<Self, ToolOutcomeError> {
535        if bytes.len() > MAX_RAW_INVOCATION_OUTCOME_BYTES {
536            return Err(ToolOutcomeError::TooLarge {
537                field: "raw_invocation_outcome",
538                actual: bytes.len(),
539                maximum: MAX_RAW_INVOCATION_OUTCOME_BYTES,
540            });
541        }
542        let persisted: PersistedRawInvocationOutcomeV1 = serde_json::from_slice(bytes)
543            .map_err(|error| ToolOutcomeError::Canonical(error.to_string()))?;
544        let raw = Self::from_persisted(persisted)?;
545        if raw.canonical_blob()?.bytes() != bytes {
546            return Err(ToolOutcomeError::Invalid("raw.noncanonical_bytes"));
547        }
548        Ok(raw)
549    }
550
551    fn canonical_blob_bounded(
552        &self,
553        maximum: usize,
554    ) -> Result<CanonicalInvocationBlobV1, ToolOutcomeError> {
555        positive(
556            "raw.dispatch_operation_version",
557            self.dispatch_operation_version,
558        )?;
559        positive("raw.dispatch_fence", self.dispatch_fence)?;
560        if self.matched_grant_index > I_JSON_MAX_SAFE_INTEGER {
561            return Err(ToolOutcomeError::Invalid("raw.matched_grant_index"));
562        }
563        if self.elapsed_millis > I_JSON_MAX_SAFE_INTEGER {
564            return Err(ToolOutcomeError::Invalid("raw.elapsed_millis"));
565        }
566        self.stream_limits.validate()?;
567        self.output.validate()?;
568        if let Some(cost) = &self.reported_cost {
569            amount(cost)?;
570        }
571        if self.pre_invocation_guard_evidence.len() > MAX_RECEIPT_GUARD_EVIDENCE {
572            return Err(ToolOutcomeError::TooLarge {
573                field: "raw.pre_invocation_guard_evidence",
574                actual: self.pre_invocation_guard_evidence.len(),
575                maximum: MAX_RECEIPT_GUARD_EVIDENCE,
576            });
577        }
578        if let Some(request_canonical_json) = &self.request_canonical_json {
579            let request: ToolCallRequest = serde_json::from_str(request_canonical_json)
580                .map_err(|_| ToolOutcomeError::Invalid("raw.request_canonical_json"))?;
581            if canonical(&request)? != request_canonical_json.as_bytes()
582                || request.request_id != self.request_id.as_str()
583                || request.server_id != self.tool_server.as_str()
584                || request.tool_name != self.tool_name.as_str()
585            {
586                return Err(ToolOutcomeError::Binding("raw.request"));
587            }
588        }
589        CanonicalInvocationBlobV1::new(bounded("raw_invocation_outcome", self, maximum)?)
590    }
591
592    pub(crate) fn output(&self) -> &InvocationOutputV1 {
593        &self.output
594    }
595
596    pub(crate) fn matched_grant_index(&self) -> Result<usize, ToolOutcomeError> {
597        usize::try_from(self.matched_grant_index)
598            .map_err(|_| ToolOutcomeError::Invalid("raw.matched_grant_index"))
599    }
600
601    pub(crate) fn elapsed_millis(&self) -> u64 {
602        self.elapsed_millis
603    }
604
605    pub(crate) fn stream_limits(&self) -> InvocationStreamLimitsV1 {
606        self.stream_limits
607    }
608
609    pub(crate) fn receipt_metadata_snapshot(&self) -> Option<&Value> {
610        self.receipt_metadata_snapshot.as_ref()
611    }
612
613    pub(crate) fn reported_cost(&self) -> Option<&MonetaryAmount> {
614        self.reported_cost.as_ref()
615    }
616
617    pub(crate) fn pre_invocation_guard_evidence(&self) -> &[GuardEvidence] {
618        &self.pre_invocation_guard_evidence
619    }
620
621    pub(crate) fn recovery_request(&self) -> Result<Option<ToolCallRequest>, ToolOutcomeError> {
622        self.request_canonical_json
623            .as_deref()
624            .map(|request| {
625                serde_json::from_str(request)
626                    .map_err(|_| ToolOutcomeError::Invalid("raw.request_canonical_json"))
627            })
628            .transpose()
629    }
630}
631
632#[derive(Debug, Clone, PartialEq, Eq)]
633pub struct CanonicalInvocationBlobV1 {
634    blob_ref: ContentAddressedBlobRefV1,
635    bytes: Vec<u8>,
636}
637
638impl CanonicalInvocationBlobV1 {
639    fn new(bytes: Vec<u8>) -> Result<Self, ToolOutcomeError> {
640        Ok(Self {
641            blob_ref: ContentAddressedBlobRefV1::new(digest_bytes("blob_digest", &bytes)?),
642            bytes,
643        })
644    }
645
646    pub fn blob_ref(&self) -> &ContentAddressedBlobRefV1 {
647        &self.blob_ref
648    }
649
650    pub fn bytes(&self) -> &[u8] {
651        &self.bytes
652    }
653
654    #[allow(dead_code)]
655    fn verify(&self, raw: &RawInvocationOutcomeV1) -> Result<(), ToolOutcomeError> {
656        if *self != raw.canonical_blob()? {
657            return Err(ToolOutcomeError::Binding("raw_invocation_blob"));
658        }
659        Ok(())
660    }
661}
662
663#[derive(Debug, Clone, PartialEq, Eq)]
664pub struct CanonicalResolvedOutputBlobV1 {
665    blob_ref: ContentAddressedBlobRefV1,
666    bytes: Vec<u8>,
667}
668
669impl CanonicalResolvedOutputBlobV1 {
670    pub fn from_signing_preimage(bytes: Vec<u8>) -> Result<Self, ToolOutcomeError> {
671        if bytes.len() > MAX_RESOLVED_OUTPUT_BYTES {
672            return Err(ToolOutcomeError::TooLarge {
673                field: "resolved_output",
674                actual: bytes.len(),
675                maximum: MAX_RESOLVED_OUTPUT_BYTES,
676            });
677        }
678        Ok(Self {
679            blob_ref: ContentAddressedBlobRefV1::new(digest_bytes(
680                "resolved_output_digest",
681                &bytes,
682            )?),
683            bytes,
684        })
685    }
686
687    pub fn blob_ref(&self) -> &ContentAddressedBlobRefV1 {
688        &self.blob_ref
689    }
690
691    pub fn bytes(&self) -> &[u8] {
692        &self.bytes
693    }
694
695    fn verify(
696        &self,
697        expected: &ContentAddressedBlobRefV1,
698        expected_size_bytes: u64,
699    ) -> Result<(), ToolOutcomeError> {
700        if &self.blob_ref != expected
701            || u64::try_from(self.bytes.len()).ok() != Some(expected_size_bytes)
702        {
703            return Err(ToolOutcomeError::Binding("resolved_output_blob"));
704        }
705        Ok(())
706    }
707}
708
709#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
710#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
711pub enum SettlementDispositionV1 {
712    Capture { amount: MonetaryAmount },
713    ContractualZeroCharge { currency: String },
714    NotApplicable,
715}
716
717impl SettlementDispositionV1 {
718    fn validate(&self) -> Result<(), ToolOutcomeError> {
719        match self {
720            Self::Capture { amount: value } => amount(value),
721            Self::ContractualZeroCharge { currency } => amount(&MonetaryAmount {
722                units: 0,
723                currency: currency.clone(),
724            }),
725            Self::NotApplicable => Ok(()),
726        }
727    }
728}
729
730#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
731#[serde(tag = "state", rename_all = "snake_case")]
732pub enum ResolvedToolOutcomeV1 {
733    Returned,
734    Resolved {
735        evaluation_id: AdmissionDigest,
736        resolved_output: ContentAddressedBlobRefV1,
737        resolved_output_size_bytes: u64,
738        terminal_dependency_root_digest: AdmissionDigest,
739        post_guard_decision_digest: AdmissionDigest,
740        pricing_verdict_digest: AdmissionDigest,
741        settlement_disposition: SettlementDispositionV1,
742    },
743    Frozen {
744        evaluation_id: AdmissionDigest,
745        freeze_evidence_digest: AdmissionDigest,
746    },
747}
748
749impl ResolvedToolOutcomeV1 {
750    #[allow(dead_code)]
751    fn name(&self) -> &'static str {
752        match self {
753            Self::Returned => "returned",
754            Self::Resolved { .. } => "resolved",
755            Self::Frozen { .. } => "frozen",
756        }
757    }
758
759    fn validate(&self) -> Result<(), ToolOutcomeError> {
760        match self {
761            Self::Returned => Ok(()),
762            Self::Resolved {
763                resolved_output,
764                resolved_output_size_bytes,
765                settlement_disposition,
766                ..
767            } => {
768                resolved_output.validate()?;
769                if usize::try_from(*resolved_output_size_bytes)
770                    .map_or(true, |size| size > MAX_RESOLVED_OUTPUT_BYTES)
771                {
772                    return Err(ToolOutcomeError::Invalid(
773                        "outcome.resolved_output_size_bytes",
774                    ));
775                }
776                settlement_disposition.validate()
777            }
778            Self::Frozen { .. } => Ok(()),
779        }
780    }
781}
782
783/// Valid records can only be built through insert-once or a CAS transition.
784#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
785pub struct ToolOutcomeRecordV1 {
786    schema: &'static str,
787    outcome_id: AdmissionDigest,
788    operation_id: AdmissionOperationId,
789    request_id: AdmissionIdentifier,
790    dispatch_operation_version: u64,
791    dispatch_fence: u64,
792    dispatch_commit: AdmissionDispatchCommitBindingV1,
793    tool_server: AdmissionIdentifier,
794    tool_name: AdmissionIdentifier,
795    provider_attempt: ProviderAttemptBindingV1,
796    transport_terminal_evidence_digest: AdmissionDigest,
797    raw_output: ContentAddressedBlobRefV1,
798    raw_output_size_bytes: u64,
799    reported_cost: Option<MonetaryAmount>,
800    disposition: ResolvedToolOutcomeV1,
801    recording_fence: StoreMutationFence,
802    recorded_at_unix_ms: u64,
803    version: u64,
804    lifecycle_digest: AdmissionDigest,
805}
806
807#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
808#[serde(deny_unknown_fields)]
809pub struct PersistedToolOutcomeRecordV1 {
810    pub schema: String,
811    pub outcome_id: AdmissionDigest,
812    pub operation_id: AdmissionOperationId,
813    pub request_id: AdmissionIdentifier,
814    pub dispatch_operation_version: u64,
815    pub dispatch_fence: u64,
816    pub dispatch_commit: AdmissionDispatchCommitBindingV1,
817    pub tool_server: AdmissionIdentifier,
818    pub tool_name: AdmissionIdentifier,
819    pub provider_attempt: ProviderAttemptBindingV1,
820    pub transport_terminal_evidence_digest: AdmissionDigest,
821    pub raw_output: ContentAddressedBlobRefV1,
822    pub raw_output_size_bytes: u64,
823    pub reported_cost: Option<MonetaryAmount>,
824    pub disposition: ResolvedToolOutcomeV1,
825    pub recording_fence: StoreMutationFence,
826    pub recorded_at_unix_ms: u64,
827    pub version: u64,
828    pub lifecycle_digest: AdmissionDigest,
829}
830
831#[derive(Serialize)]
832struct OutcomeIdentity<'a> {
833    operation_id: &'a AdmissionOperationId,
834    request_id: &'a AdmissionIdentifier,
835    dispatch_operation_version: u64,
836    dispatch_fence: u64,
837    dispatch_commit: &'a AdmissionDispatchCommitBindingV1,
838    tool_server: &'a AdmissionIdentifier,
839    tool_name: &'a AdmissionIdentifier,
840    provider_attempt: &'a ProviderAttemptBindingV1,
841    transport_terminal_evidence_digest: &'a AdmissionDigest,
842    raw_output: &'a ContentAddressedBlobRefV1,
843    raw_output_size_bytes: u64,
844    reported_cost: &'a Option<MonetaryAmount>,
845}
846
847#[derive(Serialize)]
848struct OutcomeLifecycle<'a> {
849    outcome_id: &'a AdmissionDigest,
850    disposition: &'a ResolvedToolOutcomeV1,
851    recording_fence: &'a StoreMutationFence,
852    recorded_at_unix_ms: u64,
853    version: u64,
854}
855
856fn outcome_lifecycle_digest(
857    outcome_id: &AdmissionDigest,
858    disposition: &ResolvedToolOutcomeV1,
859    recording_fence: &StoreMutationFence,
860    recorded_at_unix_ms: u64,
861    version: u64,
862) -> Result<AdmissionDigest, ToolOutcomeError> {
863    domain_digest(
864        "chio.tool-outcome.lifecycle.v1",
865        &OutcomeLifecycle {
866            outcome_id,
867            disposition,
868            recording_fence,
869            recorded_at_unix_ms,
870            version,
871        },
872    )
873}
874
875impl ToolOutcomeRecordV1 {
876    pub fn to_persisted(&self) -> PersistedToolOutcomeRecordV1 {
877        PersistedToolOutcomeRecordV1 {
878            schema: self.schema.to_owned(),
879            outcome_id: self.outcome_id.clone(),
880            operation_id: self.operation_id.clone(),
881            request_id: self.request_id.clone(),
882            dispatch_operation_version: self.dispatch_operation_version,
883            dispatch_fence: self.dispatch_fence,
884            dispatch_commit: self.dispatch_commit.clone(),
885            tool_server: self.tool_server.clone(),
886            tool_name: self.tool_name.clone(),
887            provider_attempt: self.provider_attempt.clone(),
888            transport_terminal_evidence_digest: self.transport_terminal_evidence_digest.clone(),
889            raw_output: self.raw_output.clone(),
890            raw_output_size_bytes: self.raw_output_size_bytes,
891            reported_cost: self.reported_cost.clone(),
892            disposition: self.disposition.clone(),
893            recording_fence: self.recording_fence.clone(),
894            recorded_at_unix_ms: self.recorded_at_unix_ms,
895            version: self.version,
896            lifecycle_digest: self.lifecycle_digest.clone(),
897        }
898    }
899
900    pub fn from_persisted(value: PersistedToolOutcomeRecordV1) -> Result<Self, ToolOutcomeError> {
901        if value.schema != TOOL_OUTCOME_SCHEMA {
902            return Err(ToolOutcomeError::Invalid("outcome.schema"));
903        }
904        let record = Self {
905            schema: TOOL_OUTCOME_SCHEMA,
906            outcome_id: value.outcome_id,
907            operation_id: value.operation_id,
908            request_id: value.request_id,
909            dispatch_operation_version: value.dispatch_operation_version,
910            dispatch_fence: value.dispatch_fence,
911            dispatch_commit: value.dispatch_commit,
912            tool_server: value.tool_server,
913            tool_name: value.tool_name,
914            provider_attempt: value.provider_attempt,
915            transport_terminal_evidence_digest: value.transport_terminal_evidence_digest,
916            raw_output: value.raw_output,
917            raw_output_size_bytes: value.raw_output_size_bytes,
918            reported_cost: value.reported_cost,
919            disposition: value.disposition,
920            recording_fence: value.recording_fence,
921            recorded_at_unix_ms: value.recorded_at_unix_ms,
922            version: value.version,
923            lifecycle_digest: value.lifecycle_digest,
924        };
925        record.validate()?;
926        Ok(record)
927    }
928
929    #[allow(dead_code)]
930    pub(crate) fn record_tool_returned(
931        operation: &AdmissionOperationV1,
932        raw: &RawInvocationOutcomeV1,
933        blob: &CanonicalInvocationBlobV1,
934        recording_fence: StoreMutationFence,
935        recorded_at_unix_ms: u64,
936    ) -> Result<Self, ToolOutcomeError> {
937        let commit = operation
938            .dispatch_commit()
939            .ok_or(ToolOutcomeError::Binding("outcome.dispatch_commit"))?;
940        validate_committed_operation(operation, commit)?;
941        validate_successor_fence(&commit.store_fence, &recording_fence)?;
942        positive("outcome.recorded_at", recorded_at_unix_ms)?;
943        if raw.operation_id != *operation.binding().operation_id()
944            || raw.request_id != operation.replay_key().request_id
945            || raw.dispatch_operation_version != commit.committed_version
946            || raw.dispatch_fence != commit.store_fence.owner_epoch
947        {
948            return Err(ToolOutcomeError::Binding("raw.admission_operation"));
949        }
950        validate_registered_provider_attempt(operation, &raw.provider_attempt)?;
951        blob.verify(raw)?;
952        let raw_output_size_bytes = u64::try_from(blob.bytes().len())
953            .map_err(|_| ToolOutcomeError::Overflow("raw_output_size_bytes"))?;
954        let identity = OutcomeIdentity {
955            operation_id: &raw.operation_id,
956            request_id: &raw.request_id,
957            dispatch_operation_version: raw.dispatch_operation_version,
958            dispatch_fence: raw.dispatch_fence,
959            dispatch_commit: commit,
960            tool_server: &raw.tool_server,
961            tool_name: &raw.tool_name,
962            provider_attempt: &raw.provider_attempt,
963            transport_terminal_evidence_digest: &raw.transport_terminal_evidence_digest,
964            raw_output: blob.blob_ref(),
965            raw_output_size_bytes,
966            reported_cost: &raw.reported_cost,
967        };
968        let outcome_id = domain_digest("chio.tool-outcome.identity.v1", &identity)?;
969        let disposition = ResolvedToolOutcomeV1::Returned;
970        let version = 1;
971        let lifecycle_digest = outcome_lifecycle_digest(
972            &outcome_id,
973            &disposition,
974            &recording_fence,
975            recorded_at_unix_ms,
976            version,
977        )?;
978        let record = Self {
979            schema: TOOL_OUTCOME_SCHEMA,
980            outcome_id,
981            operation_id: raw.operation_id.clone(),
982            request_id: raw.request_id.clone(),
983            dispatch_operation_version: raw.dispatch_operation_version,
984            dispatch_fence: raw.dispatch_fence,
985            dispatch_commit: commit.clone(),
986            tool_server: raw.tool_server.clone(),
987            tool_name: raw.tool_name.clone(),
988            provider_attempt: raw.provider_attempt.clone(),
989            transport_terminal_evidence_digest: raw.transport_terminal_evidence_digest.clone(),
990            raw_output: blob.blob_ref().clone(),
991            raw_output_size_bytes,
992            reported_cost: raw.reported_cost.clone(),
993            disposition,
994            recording_fence,
995            recorded_at_unix_ms,
996            version,
997            lifecycle_digest,
998        };
999        record.validate()?;
1000        Ok(record)
1001    }
1002
1003    pub fn operation_id(&self) -> &AdmissionOperationId {
1004        &self.operation_id
1005    }
1006
1007    pub fn outcome_id(&self) -> &AdmissionDigest {
1008        &self.outcome_id
1009    }
1010
1011    pub fn version(&self) -> u64 {
1012        self.version
1013    }
1014
1015    pub fn disposition(&self) -> &ResolvedToolOutcomeV1 {
1016        &self.disposition
1017    }
1018
1019    pub fn raw_output_digest(&self) -> &AdmissionDigest {
1020        self.raw_output.digest()
1021    }
1022
1023    pub fn resolved_output_ref(&self) -> Option<(&ContentAddressedBlobRefV1, u64)> {
1024        match &self.disposition {
1025            ResolvedToolOutcomeV1::Resolved {
1026                resolved_output,
1027                resolved_output_size_bytes,
1028                ..
1029            } => Some((resolved_output, *resolved_output_size_bytes)),
1030            ResolvedToolOutcomeV1::Returned | ResolvedToolOutcomeV1::Frozen { .. } => None,
1031        }
1032    }
1033
1034    pub fn recording_fence(&self) -> &StoreMutationFence {
1035        &self.recording_fence
1036    }
1037
1038    pub fn recorded_at_unix_ms(&self) -> u64 {
1039        self.recorded_at_unix_ms
1040    }
1041
1042    pub fn lifecycle_digest(&self) -> &AdmissionDigest {
1043        &self.lifecycle_digest
1044    }
1045
1046    pub fn validate_against(
1047        &self,
1048        operation: &AdmissionOperationV1,
1049    ) -> Result<(), ToolOutcomeError> {
1050        let commit = operation
1051            .dispatch_commit()
1052            .ok_or(ToolOutcomeError::Binding("outcome.dispatch_commit"))?;
1053        validate_retained_dispatch_commit(operation, commit)?;
1054        if self.operation_id != *operation.binding().operation_id()
1055            || self.request_id != operation.replay_key().request_id
1056            || self.dispatch_operation_version != commit.committed_version
1057            || self.dispatch_fence != commit.store_fence.owner_epoch
1058            || self.dispatch_commit != *commit
1059        {
1060            return Err(ToolOutcomeError::Binding("outcome.admission_operation"));
1061        }
1062        validate_registered_provider_attempt(operation, &self.provider_attempt)?;
1063        self.validate()
1064    }
1065
1066    pub fn validate_for_store_insert(
1067        &self,
1068        operation: &AdmissionOperationV1,
1069        blob: &CanonicalInvocationBlobV1,
1070        active_fence: &StoreMutationFence,
1071        trusted_now_unix_ms: u64,
1072    ) -> Result<(), ToolOutcomeError> {
1073        let commit = operation
1074            .dispatch_commit()
1075            .ok_or(ToolOutcomeError::Binding("outcome.dispatch_commit"))?;
1076        validate_committed_operation(operation, commit)?;
1077        self.validate_canonical_blob(operation, blob)?;
1078        validate_store_fence(active_fence)?;
1079        positive("outcome.store_trusted_now", trusted_now_unix_ms)?;
1080        if active_fence != &self.recording_fence || trusted_now_unix_ms < self.recorded_at_unix_ms {
1081            return Err(ToolOutcomeError::Binding("outcome.store_mutation_context"));
1082        }
1083        Ok(())
1084    }
1085
1086    pub fn validate_canonical_blob(
1087        &self,
1088        operation: &AdmissionOperationV1,
1089        blob: &CanonicalInvocationBlobV1,
1090    ) -> Result<(), ToolOutcomeError> {
1091        self.validate_against(operation)?;
1092        let raw = RawInvocationOutcomeV1::from_canonical_bytes(blob.bytes())?;
1093        blob.verify(&raw)?;
1094        let raw = raw.to_persisted();
1095        if raw.operation_id != self.operation_id
1096            || raw.request_id != self.request_id
1097            || raw.dispatch_operation_version != self.dispatch_operation_version
1098            || raw.dispatch_fence != self.dispatch_fence
1099            || raw.tool_server != self.tool_server
1100            || raw.tool_name != self.tool_name
1101            || raw.provider_attempt != self.provider_attempt
1102            || raw.transport_terminal_evidence_digest != self.transport_terminal_evidence_digest
1103            || raw.reported_cost != self.reported_cost
1104            || blob.blob_ref() != &self.raw_output
1105            || u64::try_from(blob.bytes().len()).ok() != Some(self.raw_output_size_bytes)
1106        {
1107            return Err(ToolOutcomeError::Binding("outcome.raw_invocation_blob"));
1108        }
1109        Ok(())
1110    }
1111
1112    fn validate(&self) -> Result<(), ToolOutcomeError> {
1113        positive(
1114            "outcome.dispatch_operation_version",
1115            self.dispatch_operation_version,
1116        )?;
1117        positive("outcome.dispatch_fence", self.dispatch_fence)?;
1118        positive("outcome.recorded_at", self.recorded_at_unix_ms)?;
1119        positive("outcome.version", self.version)?;
1120        positive(
1121            "outcome.dispatch_commit.coordinator_lease_epoch",
1122            self.dispatch_commit.coordinator_lease_epoch,
1123        )?;
1124        validate_store_fence(&self.dispatch_commit.store_fence)?;
1125        validate_successor_fence(&self.dispatch_commit.store_fence, &self.recording_fence)?;
1126        if self.dispatch_commit.committed_version != self.dispatch_operation_version
1127            || self.dispatch_commit.store_fence.owner_epoch != self.dispatch_fence
1128        {
1129            return Err(ToolOutcomeError::Binding("outcome.dispatch_commit"));
1130        }
1131        positive("outcome.raw_output_size_bytes", self.raw_output_size_bytes)?;
1132        if self.raw_output_size_bytes > MAX_RAW_INVOCATION_OUTCOME_BYTES as u64 {
1133            return Err(ToolOutcomeError::TooLarge {
1134                field: "outcome.raw_output_size_bytes",
1135                actual: usize::try_from(self.raw_output_size_bytes).unwrap_or(usize::MAX),
1136                maximum: MAX_RAW_INVOCATION_OUTCOME_BYTES,
1137            });
1138        }
1139        self.raw_output.validate()?;
1140        if let Some(cost) = &self.reported_cost {
1141            amount(cost)?;
1142        }
1143        self.disposition.validate()?;
1144        let expected_version = match &self.disposition {
1145            ResolvedToolOutcomeV1::Returned => 1,
1146            ResolvedToolOutcomeV1::Resolved { .. } | ResolvedToolOutcomeV1::Frozen { .. } => 2,
1147        };
1148        if self.version != expected_version {
1149            return Err(ToolOutcomeError::Binding("outcome.lifecycle_version"));
1150        }
1151        let expected = domain_digest(
1152            "chio.tool-outcome.identity.v1",
1153            &OutcomeIdentity {
1154                operation_id: &self.operation_id,
1155                request_id: &self.request_id,
1156                dispatch_operation_version: self.dispatch_operation_version,
1157                dispatch_fence: self.dispatch_fence,
1158                dispatch_commit: &self.dispatch_commit,
1159                tool_server: &self.tool_server,
1160                tool_name: &self.tool_name,
1161                provider_attempt: &self.provider_attempt,
1162                transport_terminal_evidence_digest: &self.transport_terminal_evidence_digest,
1163                raw_output: &self.raw_output,
1164                raw_output_size_bytes: self.raw_output_size_bytes,
1165                reported_cost: &self.reported_cost,
1166            },
1167        )?;
1168        if self.outcome_id != expected {
1169            return Err(ToolOutcomeError::Binding("outcome.outcome_id"));
1170        }
1171        if self.lifecycle_digest
1172            != outcome_lifecycle_digest(
1173                &self.outcome_id,
1174                &self.disposition,
1175                &self.recording_fence,
1176                self.recorded_at_unix_ms,
1177                self.version,
1178            )?
1179        {
1180            return Err(ToolOutcomeError::Binding("outcome.lifecycle_digest"));
1181        }
1182        Ok(())
1183    }
1184
1185    pub fn same_immutable_outcome(&self, other: &Self) -> bool {
1186        self.outcome_id == other.outcome_id
1187    }
1188
1189    #[allow(dead_code)]
1190    pub(crate) fn transition(
1191        &self,
1192        expected_version: u64,
1193        transition: ToolOutcomeTransitionV1,
1194    ) -> Result<Self, ToolOutcomeError> {
1195        self.validate()?;
1196        if self.version != expected_version {
1197            return Err(ToolOutcomeError::Cas {
1198                expected: expected_version,
1199                actual: self.version,
1200            });
1201        }
1202        if !matches!(self.disposition, ResolvedToolOutcomeV1::Returned) {
1203            return Err(ToolOutcomeError::Transition {
1204                state: self.disposition.name(),
1205                transition: transition.name(),
1206            });
1207        }
1208        let disposition = match transition {
1209            ToolOutcomeTransitionV1::Resolve(evidence) => {
1210                evidence.binds(self)?;
1211                ResolvedToolOutcomeV1::Resolved {
1212                    evaluation_id: evidence.evaluation_id,
1213                    resolved_output: evidence.resolved_output,
1214                    resolved_output_size_bytes: evidence.resolved_output_size_bytes,
1215                    terminal_dependency_root_digest: evidence.terminal_dependency_root_digest,
1216                    post_guard_decision_digest: evidence.post_guard_decision_digest,
1217                    pricing_verdict_digest: evidence.pricing_verdict_digest,
1218                    settlement_disposition: evidence.settlement_disposition,
1219                }
1220            }
1221            ToolOutcomeTransitionV1::Freeze(evidence) => {
1222                evidence.binds(self)?;
1223                ResolvedToolOutcomeV1::Frozen {
1224                    evaluation_id: evidence.evaluation_id,
1225                    freeze_evidence_digest: evidence.freeze_evidence_digest,
1226                }
1227            }
1228        };
1229        let mut next = self.clone();
1230        next.version = next
1231            .version
1232            .checked_add(1)
1233            .ok_or(ToolOutcomeError::Overflow("outcome.version"))?;
1234        next.disposition = disposition;
1235        next.lifecycle_digest = outcome_lifecycle_digest(
1236            &next.outcome_id,
1237            &next.disposition,
1238            &next.recording_fence,
1239            next.recorded_at_unix_ms,
1240            next.version,
1241        )?;
1242        next.validate()?;
1243        Ok(next)
1244    }
1245}
1246
1247#[allow(dead_code)]
1248#[derive(Debug, Clone)]
1249pub(crate) enum ToolOutcomeTransitionV1 {
1250    Resolve(PostReturnTerminalEvidenceV1),
1251    Freeze(PostReturnFreezeEvidenceV1),
1252}
1253
1254impl ToolOutcomeTransitionV1 {
1255    #[allow(dead_code)]
1256    fn name(&self) -> &'static str {
1257        match self {
1258            Self::Resolve(_) => "resolve",
1259            Self::Freeze(_) => "freeze",
1260        }
1261    }
1262}
1263
1264mod post_return;
1265pub use post_return::*;
1266
1267#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1268pub enum ToolOutcomeInsertResultV1 {
1269    Inserted {
1270        outcome: ToolOutcomeRecordV1,
1271        operation: AdmissionOperationV1,
1272    },
1273    ExactReplay {
1274        outcome: ToolOutcomeRecordV1,
1275        operation: AdmissionOperationV1,
1276    },
1277}
1278
1279impl ToolOutcomeInsertResultV1 {
1280    #[must_use]
1281    pub fn outcome(&self) -> &ToolOutcomeRecordV1 {
1282        match self {
1283            Self::Inserted { outcome, .. } | Self::ExactReplay { outcome, .. } => outcome,
1284        }
1285    }
1286
1287    #[must_use]
1288    pub fn operation(&self) -> &AdmissionOperationV1 {
1289        match self {
1290            Self::Inserted { operation, .. } | Self::ExactReplay { operation, .. } => operation,
1291        }
1292    }
1293
1294    #[must_use]
1295    pub fn into_parts(self) -> (ToolOutcomeRecordV1, AdmissionOperationV1) {
1296        match self {
1297            Self::Inserted { outcome, operation } | Self::ExactReplay { outcome, operation } => {
1298                (outcome, operation)
1299            }
1300        }
1301    }
1302}
1303
1304#[derive(Debug, Error, Clone, PartialEq, Eq)]
1305pub enum ToolOutcomeStoreError {
1306    #[error("tool outcome store is unavailable: {0}")]
1307    Unavailable(String),
1308    #[error("tool outcome mutation was fenced")]
1309    Fenced,
1310    #[error("tool outcome was not found")]
1311    NotFound,
1312    #[error("tool outcome insert conflicts with an existing operation")]
1313    Conflict,
1314    #[error("tool outcome CAS conflict")]
1315    CasConflict,
1316    #[error("tool outcome invariant failed: {0}")]
1317    Invariant(String),
1318}
1319
1320/// Storage boundary for insert-once return recording and atomic finalization.
1321pub trait ToolOutcomeStore: Send + Sync {
1322    fn record_tool_returned(
1323        &self,
1324        operation: &AdmissionOperationV1,
1325        recovery_lease: &AdmissionRecoveryLease,
1326        blob: &CanonicalInvocationBlobV1,
1327        record: &ToolOutcomeRecordV1,
1328        active_fence: &StoreMutationFence,
1329        trusted_now_unix_ms: u64,
1330    ) -> Result<ToolOutcomeInsertResultV1, ToolOutcomeStoreError>;
1331
1332    fn lookup_by_operation(
1333        &self,
1334        operation_id: &AdmissionOperationId,
1335    ) -> Result<Option<ToolOutcomeRecordV1>, ToolOutcomeStoreError>;
1336
1337    fn load_raw_invocation_by_operation(
1338        &self,
1339        operation_id: &AdmissionOperationId,
1340    ) -> Result<Option<RawInvocationOutcomeV1>, ToolOutcomeStoreError>;
1341
1342    fn lookup_post_return_evaluation(
1343        &self,
1344        operation_id: &AdmissionOperationId,
1345    ) -> Result<Option<PostReturnEvaluationRecordV1>, ToolOutcomeStoreError>;
1346
1347    fn begin_post_return_evaluation(
1348        &self,
1349        recovery_lease: &AdmissionRecoveryLease,
1350        record: &PostReturnEvaluationRecordV1,
1351        active_fence: &StoreMutationFence,
1352        trusted_now_unix_ms: u64,
1353    ) -> Result<PostReturnEvaluationRecordV1, ToolOutcomeStoreError>;
1354
1355    fn stage_post_return_evaluation(
1356        &self,
1357        operation_id: &AdmissionOperationId,
1358        expected_version: u64,
1359        recovery_lease: &AdmissionRecoveryLease,
1360        next: &PostReturnEvaluationRecordV1,
1361        active_fence: &StoreMutationFence,
1362        trusted_now_unix_ms: u64,
1363    ) -> Result<PostReturnEvaluationRecordV1, ToolOutcomeStoreError>;
1364
1365    /// Atomically commits the matching terminal evaluation and outcome.
1366    #[allow(clippy::too_many_arguments)]
1367    fn finalize_post_return(
1368        &self,
1369        operation_id: &AdmissionOperationId,
1370        expected_evaluation_version: u64,
1371        recovery_lease: &AdmissionRecoveryLease,
1372        terminal_evaluation: &PostReturnEvaluationRecordV1,
1373        expected_outcome_version: u64,
1374        terminal_outcome: &ToolOutcomeRecordV1,
1375        resolved_output: Option<&CanonicalResolvedOutputBlobV1>,
1376        active_fence: &StoreMutationFence,
1377        trusted_now_unix_ms: u64,
1378    ) -> Result<(PostReturnEvaluationRecordV1, ToolOutcomeRecordV1), ToolOutcomeStoreError>;
1379
1380    fn load_resolved_output_by_operation(
1381        &self,
1382        operation_id: &AdmissionOperationId,
1383    ) -> Result<Option<CanonicalResolvedOutputBlobV1>, ToolOutcomeStoreError>;
1384}
1385
1386/// Explicit trust boundary for stores that atomically bind a returned tool
1387/// outcome to the durable admission operation.
1388///
1389/// Implementations must persist the canonical blob and outcome record in the
1390/// same fenced commit that attaches the outcome ID and advances the operation
1391/// from `DispatchCommitted` to `Finalizing`. Exact replay must return the
1392/// already-bound operation without mutating it.
1393pub trait QualifiedToolOutcomeStore: ToolOutcomeStore {}
1394
1395pub fn validate_evaluation_store_successor(
1396    current: &PostReturnEvaluationRecordV1,
1397    next: &PostReturnEvaluationRecordV1,
1398) -> Result<(), ToolOutcomeError> {
1399    current.validate()?;
1400    next.validate()?;
1401    let current_persisted = current.to_persisted();
1402    let next_persisted = next.to_persisted();
1403    if next.version()
1404        != current
1405            .version()
1406            .checked_add(1)
1407            .ok_or(ToolOutcomeError::Overflow("post_return_evaluation.version"))?
1408        || current_persisted.schema != next_persisted.schema
1409        || current_persisted.evaluation_id != next_persisted.evaluation_id
1410        || current_persisted.operation_id != next_persisted.operation_id
1411        || current_persisted.tool_outcome_id != next_persisted.tool_outcome_id
1412        || current_persisted.tool_outcome_version != next_persisted.tool_outcome_version
1413        || current_persisted.raw_output_digest != next_persisted.raw_output_digest
1414        || current_persisted.plan_digest != next_persisted.plan_digest
1415        || current_persisted.trusted_time_unix_ms != next_persisted.trusted_time_unix_ms
1416        || current_persisted.exact_inputs_digest != next_persisted.exact_inputs_digest
1417        || current_persisted.exact_inputs != next_persisted.exact_inputs
1418        || current_persisted.frozen_steps != next_persisted.frozen_steps
1419    {
1420        return Err(ToolOutcomeError::Binding(
1421            "evaluation.store_successor_identity",
1422        ));
1423    }
1424    let valid_transition = match (&current_persisted.state, &next_persisted.state) {
1425        (PostReturnEvaluationStateV1::Evaluating, PostReturnEvaluationStateV1::Evaluating) => {
1426            next_persisted.step_results.len() == current_persisted.step_results.len() + 1
1427                && next_persisted
1428                    .step_results
1429                    .starts_with(&current_persisted.step_results)
1430        }
1431        (
1432            PostReturnEvaluationStateV1::Evaluating,
1433            PostReturnEvaluationStateV1::Resolved { .. }
1434            | PostReturnEvaluationStateV1::Frozen { .. },
1435        ) => next_persisted.step_results == current_persisted.step_results,
1436        _ => false,
1437    };
1438    if !valid_transition {
1439        return Err(ToolOutcomeError::Transition {
1440            state: "post_return_evaluation",
1441            transition: "store_successor",
1442        });
1443    }
1444    Ok(())
1445}
1446
1447pub fn validate_terminal_store_pair(
1448    operation: &AdmissionOperationV1,
1449    current_outcome: &ToolOutcomeRecordV1,
1450    current_evaluation: &PostReturnEvaluationRecordV1,
1451    terminal_evaluation: &PostReturnEvaluationRecordV1,
1452    terminal_outcome: &ToolOutcomeRecordV1,
1453    resolved_output: Option<&CanonicalResolvedOutputBlobV1>,
1454) -> Result<(), ToolOutcomeError> {
1455    current_outcome.validate_against(operation)?;
1456    current_evaluation.validate_against(operation, current_outcome)?;
1457    validate_evaluation_store_successor(current_evaluation, terminal_evaluation)?;
1458    terminal_outcome.validate_against(operation)?;
1459    terminal_evaluation.validate_against(operation, terminal_outcome)?;
1460    let transition = match terminal_evaluation.state() {
1461        PostReturnEvaluationStateV1::Resolved { .. } => {
1462            ToolOutcomeTransitionV1::Resolve(terminal_evaluation.terminal_evidence()?)
1463        }
1464        PostReturnEvaluationStateV1::Frozen { .. } => {
1465            ToolOutcomeTransitionV1::Freeze(terminal_evaluation.freeze_evidence()?)
1466        }
1467        PostReturnEvaluationStateV1::Evaluating => {
1468            return Err(ToolOutcomeError::Invalid("terminal_store_pair.evaluation"));
1469        }
1470    };
1471    let expected = current_outcome.transition(current_outcome.version(), transition)?;
1472    if expected != *terminal_outcome {
1473        return Err(ToolOutcomeError::Binding("terminal_store_pair.outcome"));
1474    }
1475    match terminal_outcome.resolved_output_ref() {
1476        Some((expected, expected_size)) => resolved_output
1477            .ok_or(ToolOutcomeError::Binding(
1478                "terminal_store_pair.resolved_output",
1479            ))?
1480            .verify(expected, expected_size)?,
1481        None if resolved_output.is_none() => {}
1482        None => {
1483            return Err(ToolOutcomeError::Binding(
1484                "terminal_store_pair.unexpected_resolved_output",
1485            ));
1486        }
1487    }
1488    Ok(())
1489}
1490
1491#[allow(dead_code)]
1492pub(crate) fn finalizing_outcome_command(
1493    operation: &AdmissionOperationV1,
1494    recovery_lease: AdmissionRecoveryLease,
1495    outcome_id: AdmissionDigest,
1496) -> Result<AdmissionOperationCommand, ToolOutcomeError> {
1497    AdmissionOperationCommand::new(
1498        operation.binding().operation_id().clone(),
1499        operation.version(),
1500        recovery_lease,
1501        vec![AdmissionAttachment::ToolOutcomeId(outcome_id)],
1502        Some(AdmissionOperationState::Finalizing),
1503        None,
1504        None,
1505    )
1506    .map_err(|error| ToolOutcomeError::Canonical(error.to_string()))
1507}
1508
1509mod release;
1510pub use release::*;
1511
1512#[cfg(any(test, feature = "admission-test-support"))]
1513pub mod test_support;
1514
1515#[cfg(test)]
1516#[path = "tool_outcome_tests.rs"]
1517mod tests;