Skip to main content

chio_workflow/
receipt.rs

1//! Workflow receipts capturing complete execution traces.
2//!
3//! A `WorkflowReceipt` is a single auditable artifact that captures the
4//! entire execution of a skill, including per-step results, timing,
5//! cost attribution, and the overall outcome.
6
7use chio_core::capability::scope::MonetaryAmount;
8use chio_core::crypto::{Keypair, PublicKey, Signature};
9use serde::{Deserialize, Serialize};
10
11/// Schema identifier for workflow receipts.
12pub const WORKFLOW_RECEIPT_SCHEMA: &str = "chio.workflow-receipt.v1";
13
14/// A signed receipt for a complete skill/workflow execution.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct WorkflowReceipt {
17    /// Unique receipt ID.
18    pub id: String,
19    /// Schema version.
20    pub schema: String,
21    /// Unix timestamp when the workflow started.
22    pub started_at: u64,
23    /// Unix timestamp when the workflow completed.
24    pub completed_at: u64,
25    /// Skill ID from the manifest.
26    pub skill_id: String,
27    /// Skill version from the manifest.
28    pub skill_version: String,
29    /// Agent that executed the workflow.
30    pub agent_id: String,
31    /// Session the workflow ran under.
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub session_id: Option<String>,
34    /// Capability ID that authorized the workflow.
35    pub capability_id: String,
36    /// Overall workflow outcome.
37    pub outcome: WorkflowOutcome,
38    /// Per-step execution records.
39    pub steps: Vec<StepRecord>,
40    /// Total cost of the workflow.
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub total_cost: Option<MonetaryAmount>,
43    /// Total wall-clock duration in milliseconds.
44    pub duration_ms: u64,
45    /// Kernel public key.
46    pub kernel_key: PublicKey,
47    /// Signature over the receipt body.
48    pub signature: Signature,
49    /// Detached vendor co-signatures over the canonical workflow body.
50    #[serde(default, skip_serializing_if = "Vec::is_empty")]
51    pub vendor_signatures: Vec<WorkflowVendorSignature>,
52}
53
54/// The body of a workflow receipt (everything except the signature).
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct WorkflowReceiptBody {
57    pub id: String,
58    pub schema: String,
59    pub started_at: u64,
60    pub completed_at: u64,
61    pub skill_id: String,
62    pub skill_version: String,
63    pub agent_id: String,
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub session_id: Option<String>,
66    pub capability_id: String,
67    pub outcome: WorkflowOutcome,
68    pub steps: Vec<StepRecord>,
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub total_cost: Option<MonetaryAmount>,
71    pub duration_ms: u64,
72    pub kernel_key: PublicKey,
73}
74
75/// Detached vendor signature over a canonical workflow receipt body.
76#[derive(Debug, Clone, Serialize, Deserialize)]
77#[serde(rename_all = "camelCase")]
78pub struct WorkflowVendorSignature {
79    pub vendor_id: String,
80    pub public_key: PublicKey,
81    pub signature: Signature,
82}
83
84/// Required vendor signer accepted by a workflow verifier.
85#[derive(Debug, Clone)]
86pub struct VendorSignatureRequirement {
87    pub vendor_id: String,
88    pub public_key: PublicKey,
89}
90
91/// Verification errors for workflow receipt extensions.
92#[derive(Debug, thiserror::Error, PartialEq, Eq)]
93pub enum WorkflowReceiptError {
94    #[error("vendor signature for {0} is missing")]
95    MissingVendorSignature(String),
96    #[error("vendor signature for {0} uses an unexpected key")]
97    VendorSignatureKeyMismatch(String),
98    #[error("vendor signature for {0} is invalid")]
99    InvalidVendorSignature(String),
100    #[error("canonical signature operation failed: {0}")]
101    Crypto(String),
102}
103
104/// Outcome of a workflow execution.
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106#[serde(tag = "status", rename_all = "snake_case")]
107pub enum WorkflowOutcome {
108    /// All steps completed successfully.
109    Completed,
110    /// The workflow was denied before execution started.
111    Denied { reason: String },
112    /// A step failed, halting the workflow.
113    StepFailed { step_index: usize, reason: String },
114    /// The workflow exceeded its budget envelope.
115    BudgetExceeded {
116        limit_units: u64,
117        spent_units: u64,
118        currency: String,
119    },
120    /// The workflow exceeded its time limit.
121    TimedOut { limit_secs: u64, elapsed_secs: u64 },
122    /// The workflow was cancelled by the agent or operator.
123    Cancelled { reason: String },
124}
125
126/// Record of a single step's execution within a workflow.
127#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct StepRecord {
129    /// Step index in the manifest.
130    pub step_index: usize,
131    /// Tool server.
132    pub server_id: String,
133    /// Tool name.
134    pub tool_name: String,
135    /// Whether this step was allowed.
136    pub allowed: bool,
137    /// Receipt ID for the underlying tool call (if it ran).
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    pub tool_receipt_id: Option<String>,
140    /// Step outcome.
141    pub outcome: StepOutcome,
142    /// Step duration in milliseconds.
143    pub duration_ms: u64,
144    /// Cost attributed to this step.
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub cost: Option<MonetaryAmount>,
147    /// SHA-256 hash of the step's output.
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub output_hash: Option<String>,
150    /// SHA-256 of the bilateral DSSE envelope for this step.
151    #[serde(default, skip_serializing_if = "Option::is_none")]
152    pub bilateral_dsse_sha256: Option<String>,
153    /// Governance receipt that authorizes this step when destructive.
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub governance_receipt_id: Option<String>,
156    /// SHA-256 of the preceding workflow or tool receipt.
157    #[serde(default, skip_serializing_if = "Option::is_none")]
158    pub parent_receipt_sha256: Option<String>,
159    /// Consistency anchor binding the step to a hash chain or external root.
160    #[serde(default, skip_serializing_if = "Option::is_none")]
161    pub consistency_anchor: Option<String>,
162    /// Whether this step performs a destructive action.
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub destructive: Option<bool>,
165}
166
167/// Outcome of a single step.
168#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
169#[serde(rename_all = "snake_case")]
170pub enum StepOutcome {
171    /// Step completed successfully.
172    Success,
173    /// Step was denied by policy.
174    Denied,
175    /// Step failed during execution.
176    Failed,
177    /// Step was skipped (e.g. workflow aborted before reaching it).
178    Skipped,
179}
180
181impl WorkflowReceipt {
182    /// Sign a workflow receipt body.
183    pub fn sign(body: WorkflowReceiptBody, keypair: &Keypair) -> Result<Self, chio_core::Error> {
184        let (signature, _bytes) = keypair.sign_canonical(&body)?;
185        Ok(Self {
186            id: body.id,
187            schema: body.schema,
188            started_at: body.started_at,
189            completed_at: body.completed_at,
190            skill_id: body.skill_id,
191            skill_version: body.skill_version,
192            agent_id: body.agent_id,
193            session_id: body.session_id,
194            capability_id: body.capability_id,
195            outcome: body.outcome,
196            steps: body.steps,
197            total_cost: body.total_cost,
198            duration_ms: body.duration_ms,
199            kernel_key: body.kernel_key,
200            signature,
201            vendor_signatures: Vec::new(),
202        })
203    }
204
205    /// Reconstruct the canonical body signed by the kernel and vendors.
206    #[must_use]
207    pub fn body(&self) -> WorkflowReceiptBody {
208        WorkflowReceiptBody {
209            id: self.id.clone(),
210            schema: self.schema.clone(),
211            started_at: self.started_at,
212            completed_at: self.completed_at,
213            skill_id: self.skill_id.clone(),
214            skill_version: self.skill_version.clone(),
215            agent_id: self.agent_id.clone(),
216            session_id: self.session_id.clone(),
217            capability_id: self.capability_id.clone(),
218            outcome: self.outcome.clone(),
219            steps: self.steps.clone(),
220            total_cost: self.total_cost.clone(),
221            duration_ms: self.duration_ms,
222            kernel_key: self.kernel_key.clone(),
223        }
224    }
225
226    /// Verify the receipt signature.
227    pub fn verify(&self) -> Result<bool, chio_core::Error> {
228        if self.schema != WORKFLOW_RECEIPT_SCHEMA {
229            return Ok(false);
230        }
231        self.kernel_key
232            .verify_canonical(&self.body(), &self.signature)
233    }
234
235    /// Add a detached vendor co-signature over the canonical workflow body.
236    pub fn add_vendor_signature(
237        &mut self,
238        vendor_id: impl Into<String>,
239        keypair: &Keypair,
240    ) -> Result<(), WorkflowReceiptError> {
241        let (signature, _) = keypair
242            .sign_canonical(&self.body())
243            .map_err(|e| WorkflowReceiptError::Crypto(e.to_string()))?;
244        self.vendor_signatures.push(WorkflowVendorSignature {
245            vendor_id: vendor_id.into(),
246            public_key: keypair.public_key(),
247            signature,
248        });
249        Ok(())
250    }
251
252    /// Verify that every required vendor co-signed this workflow body.
253    pub fn verify_vendor_signatures(
254        &self,
255        required: &[VendorSignatureRequirement],
256    ) -> Result<bool, WorkflowReceiptError> {
257        let body = self.body();
258        for requirement in required {
259            let signature = self
260                .vendor_signatures
261                .iter()
262                .find(|sig| sig.vendor_id == requirement.vendor_id)
263                .ok_or_else(|| {
264                    WorkflowReceiptError::MissingVendorSignature(requirement.vendor_id.clone())
265                })?;
266            if signature.public_key != requirement.public_key {
267                return Err(WorkflowReceiptError::VendorSignatureKeyMismatch(
268                    requirement.vendor_id.clone(),
269                ));
270            }
271            let verified = signature
272                .public_key
273                .verify_canonical(&body, &signature.signature)
274                .map_err(|e| WorkflowReceiptError::Crypto(e.to_string()))?;
275            if !verified {
276                return Err(WorkflowReceiptError::InvalidVendorSignature(
277                    requirement.vendor_id.clone(),
278                ));
279            }
280        }
281        Ok(true)
282    }
283
284    /// Count how many steps completed successfully.
285    #[must_use]
286    pub fn successful_steps(&self) -> usize {
287        self.steps
288            .iter()
289            .filter(|s| s.outcome == StepOutcome::Success)
290            .count()
291    }
292
293    /// Check whether the workflow completed successfully.
294    #[must_use]
295    pub fn is_complete(&self) -> bool {
296        self.outcome == WorkflowOutcome::Completed
297    }
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303
304    fn make_step_record(index: usize, outcome: StepOutcome) -> StepRecord {
305        StepRecord {
306            step_index: index,
307            server_id: "srv".to_string(),
308            tool_name: format!("tool_{index}"),
309            allowed: matches!(outcome, StepOutcome::Success | StepOutcome::Failed),
310            tool_receipt_id: Some(format!("rcpt-{index}")),
311            outcome,
312            duration_ms: 100,
313            cost: None,
314            output_hash: None,
315            bilateral_dsse_sha256: None,
316            governance_receipt_id: None,
317            parent_receipt_sha256: None,
318            consistency_anchor: None,
319            destructive: None,
320        }
321    }
322
323    #[test]
324    fn sign_and_verify_workflow_receipt() {
325        let kp = Keypair::generate();
326        let body = WorkflowReceiptBody {
327            id: "wf-1".to_string(),
328            schema: WORKFLOW_RECEIPT_SCHEMA.to_string(),
329            started_at: 1000,
330            completed_at: 2000,
331            skill_id: "search-summarize".to_string(),
332            skill_version: "1.0.0".to_string(),
333            agent_id: "agent-1".to_string(),
334            session_id: Some("sess-1".to_string()),
335            capability_id: "cap-1".to_string(),
336            outcome: WorkflowOutcome::Completed,
337            steps: vec![
338                make_step_record(0, StepOutcome::Success),
339                make_step_record(1, StepOutcome::Success),
340            ],
341            total_cost: Some(MonetaryAmount {
342                units: 150,
343                currency: "USD".to_string(),
344            }),
345            duration_ms: 1000,
346            kernel_key: kp.public_key(),
347        };
348
349        let receipt = WorkflowReceipt::sign(body, &kp).unwrap();
350        assert!(receipt.verify().unwrap());
351        assert!(receipt.is_complete());
352        assert_eq!(receipt.successful_steps(), 2);
353    }
354
355    #[test]
356    fn tampered_receipt_fails() {
357        let kp = Keypair::generate();
358        let body = WorkflowReceiptBody {
359            id: "wf-2".to_string(),
360            schema: WORKFLOW_RECEIPT_SCHEMA.to_string(),
361            started_at: 1000,
362            completed_at: 2000,
363            skill_id: "s".to_string(),
364            skill_version: "1.0".to_string(),
365            agent_id: "a".to_string(),
366            session_id: None,
367            capability_id: "c".to_string(),
368            outcome: WorkflowOutcome::Completed,
369            steps: vec![],
370            total_cost: None,
371            duration_ms: 500,
372            kernel_key: kp.public_key(),
373        };
374
375        let mut receipt = WorkflowReceipt::sign(body, &kp).unwrap();
376        receipt.duration_ms = 999; // tamper
377        assert!(!receipt.verify().unwrap());
378    }
379
380    #[test]
381    fn unsupported_receipt_schema_fails_verification() {
382        let kp = Keypair::generate();
383        let body = WorkflowReceiptBody {
384            id: "wf-unsupported-schema".to_string(),
385            schema: "chio.workflow-receipt.v0".to_string(),
386            started_at: 1000,
387            completed_at: 2000,
388            skill_id: "s".to_string(),
389            skill_version: "1.0".to_string(),
390            agent_id: "a".to_string(),
391            session_id: None,
392            capability_id: "c".to_string(),
393            outcome: WorkflowOutcome::Completed,
394            steps: vec![],
395            total_cost: None,
396            duration_ms: 500,
397            kernel_key: kp.public_key(),
398        };
399
400        let receipt = WorkflowReceipt::sign(body, &kp).unwrap();
401
402        assert!(!receipt.verify().unwrap());
403    }
404
405    #[test]
406    fn partial_failure_stats() {
407        let kp = Keypair::generate();
408        let body = WorkflowReceiptBody {
409            id: "wf-3".to_string(),
410            schema: WORKFLOW_RECEIPT_SCHEMA.to_string(),
411            started_at: 1000,
412            completed_at: 1500,
413            skill_id: "s".to_string(),
414            skill_version: "1.0".to_string(),
415            agent_id: "a".to_string(),
416            session_id: None,
417            capability_id: "c".to_string(),
418            outcome: WorkflowOutcome::StepFailed {
419                step_index: 1,
420                reason: "tool error".to_string(),
421            },
422            steps: vec![
423                make_step_record(0, StepOutcome::Success),
424                make_step_record(1, StepOutcome::Failed),
425                make_step_record(2, StepOutcome::Skipped),
426            ],
427            total_cost: None,
428            duration_ms: 500,
429            kernel_key: kp.public_key(),
430        };
431
432        let receipt = WorkflowReceipt::sign(body, &kp).unwrap();
433        assert!(!receipt.is_complete());
434        assert_eq!(receipt.successful_steps(), 1);
435    }
436
437    #[test]
438    fn workflow_outcome_serialization() {
439        let outcome = WorkflowOutcome::BudgetExceeded {
440            limit_units: 1000,
441            spent_units: 1100,
442            currency: "USD".to_string(),
443        };
444        let json = serde_json::to_string(&outcome).unwrap();
445        let deserialized: WorkflowOutcome = serde_json::from_str(&json).unwrap();
446        assert_eq!(deserialized, outcome);
447    }
448
449    #[test]
450    fn v1_receipt_serialization_omits_v2_fields_when_absent() {
451        let step = make_step_record(0, StepOutcome::Success);
452        let value = serde_json::to_value(&step).unwrap();
453        assert!(value.get("bilateral_dsse_sha256").is_none());
454        assert!(value.get("governance_receipt_id").is_none());
455        assert!(value.get("parent_receipt_sha256").is_none());
456        assert!(value.get("consistency_anchor").is_none());
457        assert!(value.get("destructive").is_none());
458    }
459
460    #[test]
461    fn v2_step_linkage_fields_are_signed_by_kernel() {
462        let kp = Keypair::generate();
463        let mut step = make_step_record(1, StepOutcome::Success);
464        step.bilateral_dsse_sha256 = Some("b".repeat(64));
465        step.governance_receipt_id = Some("gov:buyer:refund:001".to_string());
466        step.parent_receipt_sha256 = Some("a".repeat(64));
467        step.consistency_anchor = Some("anchor:buyer:epoch:42".to_string());
468        step.destructive = Some(true);
469
470        let body = WorkflowReceiptBody {
471            id: "wf-v2".to_string(),
472            schema: WORKFLOW_RECEIPT_SCHEMA.to_string(),
473            started_at: 1000,
474            completed_at: 1001,
475            skill_id: "refund-underwriting".to_string(),
476            skill_version: "0.1.0".to_string(),
477            agent_id: "buyer-agent".to_string(),
478            session_id: None,
479            capability_id: "cap-workflow".to_string(),
480            outcome: WorkflowOutcome::Completed,
481            steps: vec![step],
482            total_cost: None,
483            duration_ms: 1000,
484            kernel_key: kp.public_key(),
485        };
486
487        let mut receipt = WorkflowReceipt::sign(body, &kp).unwrap();
488        assert!(receipt.verify().unwrap());
489        receipt.steps[0].parent_receipt_sha256 = Some("c".repeat(64));
490        assert!(!receipt.verify().unwrap());
491    }
492
493    #[test]
494    fn vendor_cosignatures_verify_required_signers() {
495        let kernel = Keypair::generate();
496        let vendor_a = Keypair::generate();
497        let vendor_b = Keypair::generate();
498        let body = WorkflowReceiptBody {
499            id: "wf-cosigned".to_string(),
500            schema: WORKFLOW_RECEIPT_SCHEMA.to_string(),
501            started_at: 1000,
502            completed_at: 1010,
503            skill_id: "refund-underwriting".to_string(),
504            skill_version: "0.1.0".to_string(),
505            agent_id: "buyer-agent".to_string(),
506            session_id: None,
507            capability_id: "cap-workflow".to_string(),
508            outcome: WorkflowOutcome::Completed,
509            steps: vec![make_step_record(0, StepOutcome::Success)],
510            total_cost: None,
511            duration_ms: 10_000,
512            kernel_key: kernel.public_key(),
513        };
514        let mut receipt = WorkflowReceipt::sign(body, &kernel).unwrap();
515        receipt.add_vendor_signature("vendor-a", &vendor_a).unwrap();
516
517        let required = vec![VendorSignatureRequirement {
518            vendor_id: "vendor-a".to_string(),
519            public_key: vendor_a.public_key(),
520        }];
521        assert!(receipt.verify_vendor_signatures(&required).unwrap());
522
523        let missing = vec![VendorSignatureRequirement {
524            vendor_id: "vendor-b".to_string(),
525            public_key: vendor_b.public_key(),
526        }];
527        assert!(matches!(
528            receipt.verify_vendor_signatures(&missing),
529            Err(WorkflowReceiptError::MissingVendorSignature(id)) if id == "vendor-b"
530        ));
531
532        receipt.vendor_signatures[0].signature = vendor_b.sign(b"not the workflow body");
533        assert!(matches!(
534            receipt.verify_vendor_signatures(&required),
535            Err(WorkflowReceiptError::InvalidVendorSignature(id)) if id == "vendor-a"
536        ));
537    }
538}