1use 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#[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#[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
1320pub 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 #[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
1386pub 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 (¤t_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(¤t_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;