1use chio_core::capability::scope::MonetaryAmount;
8use chio_core::crypto::{Keypair, PublicKey, Signature};
9use serde::{Deserialize, Serialize};
10
11pub const WORKFLOW_RECEIPT_SCHEMA: &str = "chio.workflow-receipt.v1";
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct WorkflowReceipt {
17 pub id: String,
19 pub schema: String,
21 pub started_at: u64,
23 pub completed_at: u64,
25 pub skill_id: String,
27 pub skill_version: String,
29 pub agent_id: String,
31 #[serde(default, skip_serializing_if = "Option::is_none")]
33 pub session_id: Option<String>,
34 pub capability_id: String,
36 pub outcome: WorkflowOutcome,
38 pub steps: Vec<StepRecord>,
40 #[serde(default, skip_serializing_if = "Option::is_none")]
42 pub total_cost: Option<MonetaryAmount>,
43 pub duration_ms: u64,
45 pub kernel_key: PublicKey,
47 pub signature: Signature,
49 #[serde(default, skip_serializing_if = "Vec::is_empty")]
51 pub vendor_signatures: Vec<WorkflowVendorSignature>,
52}
53
54#[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#[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#[derive(Debug, Clone)]
86pub struct VendorSignatureRequirement {
87 pub vendor_id: String,
88 pub public_key: PublicKey,
89}
90
91#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106#[serde(tag = "status", rename_all = "snake_case")]
107pub enum WorkflowOutcome {
108 Completed,
110 Denied { reason: String },
112 StepFailed { step_index: usize, reason: String },
114 BudgetExceeded {
116 limit_units: u64,
117 spent_units: u64,
118 currency: String,
119 },
120 TimedOut { limit_secs: u64, elapsed_secs: u64 },
122 Cancelled { reason: String },
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct StepRecord {
129 pub step_index: usize,
131 pub server_id: String,
133 pub tool_name: String,
135 pub allowed: bool,
137 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub tool_receipt_id: Option<String>,
140 pub outcome: StepOutcome,
142 pub duration_ms: u64,
144 #[serde(default, skip_serializing_if = "Option::is_none")]
146 pub cost: Option<MonetaryAmount>,
147 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub output_hash: Option<String>,
150 #[serde(default, skip_serializing_if = "Option::is_none")]
152 pub bilateral_dsse_sha256: Option<String>,
153 #[serde(default, skip_serializing_if = "Option::is_none")]
155 pub governance_receipt_id: Option<String>,
156 #[serde(default, skip_serializing_if = "Option::is_none")]
158 pub parent_receipt_sha256: Option<String>,
159 #[serde(default, skip_serializing_if = "Option::is_none")]
161 pub consistency_anchor: Option<String>,
162 #[serde(default, skip_serializing_if = "Option::is_none")]
164 pub destructive: Option<bool>,
165}
166
167#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
169#[serde(rename_all = "snake_case")]
170pub enum StepOutcome {
171 Success,
173 Denied,
175 Failed,
177 Skipped,
179}
180
181impl WorkflowReceipt {
182 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 #[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 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 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 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 #[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 #[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; 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}