1use std::collections::HashMap;
8use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
9use std::sync::Mutex;
10
11use chio_core::capability::scope::MonetaryAmount;
12use chio_core::crypto::Keypair;
13use tracing::debug;
14
15use crate::grant::SkillGrant;
16use crate::manifest::{SkillManifest, SkillStep};
17use crate::receipt::{
18 StepOutcome, StepRecord, WorkflowOutcome, WorkflowReceipt, WorkflowReceiptBody,
19 WORKFLOW_RECEIPT_SCHEMA,
20};
21
22#[derive(Debug, thiserror::Error)]
24pub enum WorkflowError {
25 #[error("skill grant does not authorize skill '{skill_id}' version '{version}'")]
27 UnauthorizedSkill { skill_id: String, version: String },
28
29 #[error("step {step_index} ({server}:{tool}) is not authorized")]
31 UnauthorizedStep {
32 step_index: usize,
33 server: String,
34 tool: String,
35 },
36
37 #[error("step {step_index} is out of order (expected step {expected})")]
39 StepOutOfOrder { step_index: usize, expected: usize },
40
41 #[error("budget exceeded: spent {spent_units} of {limit_units} {currency}")]
43 BudgetExceeded {
44 limit_units: u64,
45 spent_units: u64,
46 currency: String,
47 },
48
49 #[error("budget currency mismatch: expected {expected_currency}, got {actual_currency}")]
51 BudgetCurrencyMismatch {
52 expected_currency: String,
53 actual_currency: String,
54 },
55
56 #[error("time limit exceeded: {elapsed_secs}s of {limit_secs}s allowed")]
58 TimeLimitExceeded { elapsed_secs: u64, limit_secs: u64 },
59
60 #[error("execution limit reached: {limit} executions")]
62 ExecutionLimitReached { limit: u32 },
63
64 #[error("workflow is in invalid state: {0}")]
66 InvalidState(String),
67
68 #[error("receipt signing failed: {0}")]
70 SigningFailed(String),
71}
72
73#[derive(Debug)]
77pub struct WorkflowExecution {
78 pub id: String,
80 pub skill_id: String,
82 pub skill_version: String,
84 pub agent_id: String,
86 pub session_id: Option<String>,
88 pub capability_id: String,
90 limited_execution_reserved: bool,
92 pub started_at: u64,
94 pub step_records: Vec<StepRecord>,
96 pub budget_spent: u64,
98 pub budget_limit: Option<MonetaryAmount>,
100 pub time_limit_secs: Option<u64>,
102 pub active: bool,
104 terminal_outcome: Option<WorkflowOutcome>,
107}
108
109pub struct StepExecutionRecordInput {
110 pub outcome: StepOutcome,
111 pub duration_ms: u64,
112 pub cost: Option<MonetaryAmount>,
113 pub tool_receipt_id: Option<String>,
114 pub output_hash: Option<String>,
115}
116
117#[derive(Debug, Default)]
118struct LimitedExecutionCount {
119 completed: u32,
120 in_flight: u32,
121}
122
123impl WorkflowExecution {
124 #[must_use]
126 pub fn completed_steps(&self) -> usize {
127 self.step_records
128 .iter()
129 .filter(|s| s.outcome == StepOutcome::Success)
130 .count()
131 }
132}
133
134pub struct WorkflowAuthority {
136 signing_key: Keypair,
137 execution_count: AtomicU32,
139 limited_execution_counts: Mutex<HashMap<String, LimitedExecutionCount>>,
141 begin_counter: AtomicU64,
146}
147
148impl WorkflowAuthority {
149 pub fn new(signing_key: Keypair) -> Self {
151 Self {
152 signing_key,
153 execution_count: AtomicU32::new(0),
154 limited_execution_counts: Mutex::new(HashMap::new()),
155 begin_counter: AtomicU64::new(0),
156 }
157 }
158
159 pub fn begin(
163 &self,
164 manifest: &SkillManifest,
165 grant: &SkillGrant,
166 agent_id: String,
167 capability_id: String,
168 session_id: Option<String>,
169 ) -> Result<WorkflowExecution, WorkflowError> {
170 if grant.skill_id != manifest.skill_id || grant.skill_version != manifest.version {
171 return Err(WorkflowError::UnauthorizedSkill {
172 skill_id: manifest.skill_id.clone(),
173 version: manifest.version.clone(),
174 });
175 }
176
177 for step in &manifest.steps {
178 if !grant.authorizes_step(&step.server_id, &step.tool_name) {
179 return Err(WorkflowError::UnauthorizedStep {
180 step_index: step.index,
181 server: step.server_id.clone(),
182 tool: step.tool_name.clone(),
183 });
184 }
185 }
186
187 let limited_execution_reserved =
188 self.reserve_execution(&capability_id, grant.max_executions)?;
189 self.execution_count.fetch_add(1, Ordering::AcqRel);
190
191 let budget_limit = grant
192 .budget_envelope
193 .clone()
194 .or_else(|| manifest.budget_envelope.clone());
195
196 let time_limit_secs = grant.max_duration_secs.or(manifest.max_duration_secs);
197
198 let now = current_unix_secs();
199
200 debug!(
201 skill_id = %manifest.skill_id,
202 agent_id = %agent_id,
203 "beginning workflow execution"
204 );
205
206 let seq = self.begin_counter.fetch_add(1, Ordering::Relaxed);
214 let id = format!("wf-{now}-{seq}-{agent_id}");
215 Ok(WorkflowExecution {
216 id,
217 skill_id: manifest.skill_id.clone(),
218 skill_version: manifest.version.clone(),
219 agent_id,
220 session_id,
221 capability_id,
222 limited_execution_reserved,
223 started_at: now,
224 step_records: Vec::new(),
225 budget_spent: 0,
226 budget_limit,
227 time_limit_secs,
228 active: true,
229 terminal_outcome: None,
230 })
231 }
232
233 pub fn validate_step(
237 &self,
238 execution: &WorkflowExecution,
239 step: &SkillStep,
240 grant: &SkillGrant,
241 ) -> Result<(), WorkflowError> {
242 if !execution.active {
243 return Err(WorkflowError::InvalidState(
244 "workflow is no longer active".to_string(),
245 ));
246 }
247
248 if !grant.authorizes_step(&step.server_id, &step.tool_name) {
249 return Err(WorkflowError::UnauthorizedStep {
250 step_index: step.index,
251 server: step.server_id.clone(),
252 tool: step.tool_name.clone(),
253 });
254 }
255
256 let completed_steps = execution.completed_steps();
257 if !grant.is_step_in_order(step.index, completed_steps) {
258 return Err(WorkflowError::StepOutOfOrder {
259 step_index: step.index,
260 expected: completed_steps,
261 });
262 }
263
264 validate_time_limit(execution)?;
265
266 Ok(())
267 }
268
269 pub fn record_step(
271 &self,
272 execution: &mut WorkflowExecution,
273 step: &SkillStep,
274 input: StepExecutionRecordInput,
275 ) -> Result<(), WorkflowError> {
276 if !execution.active {
277 return Err(WorkflowError::InvalidState(
278 "workflow is no longer active".to_string(),
279 ));
280 }
281
282 let time_limit_exceeded = match validate_time_limit(execution) {
283 Ok(()) => None,
284 Err(WorkflowError::TimeLimitExceeded {
285 elapsed_secs,
286 limit_secs,
287 }) => Some((elapsed_secs, limit_secs)),
288 Err(err) => return Err(err),
289 };
290
291 let budget_currency_mismatch = input.cost.as_ref().and_then(|cost| {
292 if let Some(ref limit) = step.budget_limit {
293 if cost.currency != limit.currency {
294 return Some((
295 limit.currency.clone(),
296 cost.currency.clone(),
297 format!(
298 "step cost currency {} does not match budget currency {}",
299 cost.currency, limit.currency
300 ),
301 ));
302 }
303 }
304 if let Some(ref limit) = execution.budget_limit {
305 if cost.currency != limit.currency {
306 return Some((
307 limit.currency.clone(),
308 cost.currency.clone(),
309 format!(
310 "step cost currency {} does not match budget currency {}",
311 cost.currency, limit.currency
312 ),
313 ));
314 }
315 }
316 None
317 });
318
319 let step_budget_exceeded = if budget_currency_mismatch.is_none() {
320 input.cost.as_ref().and_then(|cost| {
321 step.budget_limit.as_ref().and_then(|limit| {
322 if cost.units > limit.units {
323 Some((limit.units, cost.units, limit.currency.clone()))
324 } else {
325 None
326 }
327 })
328 })
329 } else {
330 None
331 };
332
333 if budget_currency_mismatch.is_none() {
334 if let Some(ref c) = input.cost {
335 execution.budget_spent = execution.budget_spent.saturating_add(c.units);
336 }
337 }
338
339 let record = StepRecord {
342 step_index: step.index,
343 server_id: step.server_id.clone(),
344 tool_name: step.tool_name.clone(),
345 allowed: matches!(input.outcome, StepOutcome::Success | StepOutcome::Failed),
346 tool_receipt_id: input.tool_receipt_id,
347 outcome: input.outcome.clone(),
348 duration_ms: input.duration_ms,
349 cost: input.cost,
350 output_hash: input.output_hash,
351 bilateral_dsse_sha256: None,
352 governance_receipt_id: None,
353 parent_receipt_sha256: None,
354 consistency_anchor: None,
355 destructive: None,
356 };
357
358 execution.step_records.push(record);
359
360 if let Some((expected_currency, actual_currency, reason)) = budget_currency_mismatch {
361 execution.active = false;
362 execution.terminal_outcome = Some(WorkflowOutcome::Denied { reason });
363 return Err(WorkflowError::BudgetCurrencyMismatch {
364 expected_currency,
365 actual_currency,
366 });
367 }
368
369 if let Some((limit_units, spent_units, currency)) = step_budget_exceeded {
370 execution.active = false;
371 execution.terminal_outcome = Some(WorkflowOutcome::BudgetExceeded {
372 limit_units,
373 spent_units,
374 currency: currency.clone(),
375 });
376 execution.budget_limit = Some(MonetaryAmount {
377 units: limit_units,
378 currency: currency.clone(),
379 });
380 return Err(WorkflowError::BudgetExceeded {
381 limit_units,
382 spent_units,
383 currency,
384 });
385 }
386
387 if let Some(ref limit) = execution.budget_limit {
390 if execution.budget_spent > limit.units {
391 execution.active = false;
392 execution.terminal_outcome = Some(WorkflowOutcome::BudgetExceeded {
393 limit_units: limit.units,
394 spent_units: execution.budget_spent,
395 currency: limit.currency.clone(),
396 });
397 return Err(WorkflowError::BudgetExceeded {
398 limit_units: limit.units,
399 spent_units: execution.budget_spent,
400 currency: limit.currency.clone(),
401 });
402 }
403 }
404
405 if let Some((elapsed_secs, limit_secs)) = time_limit_exceeded {
406 execution.active = false;
407 execution.terminal_outcome = Some(WorkflowOutcome::TimedOut {
408 limit_secs,
409 elapsed_secs,
410 });
411 return Err(WorkflowError::TimeLimitExceeded {
412 elapsed_secs,
413 limit_secs,
414 });
415 }
416
417 if input.outcome == StepOutcome::Failed || input.outcome == StepOutcome::Denied {
418 execution.active = false;
419 }
420
421 Ok(())
422 }
423
424 pub fn finalize(
426 &self,
427 mut execution: WorkflowExecution,
428 ) -> Result<WorkflowReceipt, WorkflowError> {
429 execution.active = false;
430 let capability_id = execution.capability_id.clone();
431
432 let completed_at = current_unix_secs();
433 let duration_ms = completed_at
434 .saturating_sub(execution.started_at)
435 .saturating_mul(1000);
436 let has_recorded_failure = execution
437 .step_records
438 .iter()
439 .any(|step| matches!(step.outcome, StepOutcome::Failed | StepOutcome::Denied));
440 if execution.terminal_outcome.is_none() && !has_recorded_failure {
441 if let Some(limit_secs) = execution.time_limit_secs {
442 let elapsed_secs = completed_at.saturating_sub(execution.started_at);
443 if elapsed_secs >= limit_secs {
444 execution.terminal_outcome = Some(WorkflowOutcome::TimedOut {
445 limit_secs,
446 elapsed_secs,
447 });
448 }
449 }
450 }
451
452 let outcome = determine_outcome(&execution);
453
454 let total_cost = if execution.budget_spent > 0 {
455 execution.budget_limit.as_ref().map(|limit| MonetaryAmount {
456 units: execution.budget_spent,
457 currency: limit.currency.clone(),
458 })
459 } else {
460 None
461 };
462
463 let body = WorkflowReceiptBody {
464 id: execution.id.clone(),
465 schema: WORKFLOW_RECEIPT_SCHEMA.to_string(),
466 started_at: execution.started_at,
467 completed_at,
468 skill_id: execution.skill_id,
469 skill_version: execution.skill_version,
470 agent_id: execution.agent_id,
471 session_id: execution.session_id,
472 capability_id: execution.capability_id,
473 outcome,
474 steps: execution.step_records,
475 total_cost,
476 duration_ms,
477 kernel_key: self.signing_key.public_key(),
478 };
479
480 let receipt = WorkflowReceipt::sign(body, &self.signing_key)
481 .map_err(|e| WorkflowError::SigningFailed(e.to_string()))?;
482
483 debug!(
484 receipt_id = %receipt.id,
485 skill_id = %receipt.skill_id,
486 "workflow receipt signed"
487 );
488
489 if execution.limited_execution_reserved {
490 self.complete_execution(&capability_id)?;
491 }
492
493 Ok(receipt)
494 }
495
496 #[must_use]
498 pub fn execution_count(&self) -> u32 {
499 self.execution_count.load(Ordering::Acquire)
500 }
501
502 fn reserve_execution(
503 &self,
504 capability_id: &str,
505 max_executions: Option<u32>,
506 ) -> Result<bool, WorkflowError> {
507 let Some(limit) = max_executions else {
508 return Ok(false);
509 };
510 let mut counts = self
511 .limited_execution_counts
512 .lock()
513 .map_err(|_| WorkflowError::InvalidState("execution counters are poisoned".into()))?;
514 let count = counts.entry(capability_id.to_string()).or_default();
515 if count.completed.saturating_add(count.in_flight) >= limit {
516 return Err(WorkflowError::ExecutionLimitReached { limit });
517 }
518 count.in_flight = count.in_flight.saturating_add(1);
519 Ok(true)
520 }
521
522 fn complete_execution(&self, capability_id: &str) -> Result<(), WorkflowError> {
523 let mut counts = self
524 .limited_execution_counts
525 .lock()
526 .map_err(|_| WorkflowError::InvalidState("execution counters are poisoned".into()))?;
527 let Some(count) = counts.get_mut(capability_id) else {
528 return Ok(());
529 };
530 if count.in_flight > 0 {
531 count.in_flight = count.in_flight.saturating_sub(1);
532 }
533 count.completed = count.completed.saturating_add(1);
534 Ok(())
535 }
536}
537
538fn determine_outcome(execution: &WorkflowExecution) -> WorkflowOutcome {
539 for step in &execution.step_records {
540 if step.outcome == StepOutcome::Failed {
541 return WorkflowOutcome::StepFailed {
542 step_index: step.step_index,
543 reason: "step execution failed".to_string(),
544 };
545 }
546 if step.outcome == StepOutcome::Denied {
547 return WorkflowOutcome::StepFailed {
548 step_index: step.step_index,
549 reason: "step denied by policy".to_string(),
550 };
551 }
552 }
553
554 if let Some(outcome) = execution.terminal_outcome.clone() {
555 return outcome;
556 }
557
558 if let Some(ref limit) = execution.budget_limit {
559 if execution.budget_spent > limit.units {
560 return WorkflowOutcome::BudgetExceeded {
561 limit_units: limit.units,
562 spent_units: execution.budget_spent,
563 currency: limit.currency.clone(),
564 };
565 }
566 }
567
568 WorkflowOutcome::Completed
569}
570
571fn validate_time_limit(execution: &WorkflowExecution) -> Result<(), WorkflowError> {
572 if let Some(limit_secs) = execution.time_limit_secs {
573 let elapsed = current_unix_secs().saturating_sub(execution.started_at);
574 if elapsed >= limit_secs {
575 return Err(WorkflowError::TimeLimitExceeded {
576 elapsed_secs: elapsed,
577 limit_secs,
578 });
579 }
580 }
581 Ok(())
582}
583
584fn current_unix_secs() -> u64 {
585 std::time::SystemTime::now()
586 .duration_since(std::time::UNIX_EPOCH)
587 .map(|d| d.as_secs())
588 .unwrap_or(0)
589}
590
591#[cfg(test)]
592mod tests {
593 use super::*;
594 use crate::manifest::{IoContract, SkillStep};
595
596 fn make_manifest() -> SkillManifest {
597 SkillManifest::new(
598 "search-summarize".to_string(),
599 "1.0.0".to_string(),
600 "Search and Summarize".to_string(),
601 vec![
602 SkillStep {
603 index: 0,
604 server_id: "search-srv".to_string(),
605 tool_name: "search".to_string(),
606 label: Some("Search".to_string()),
607 input_contract: None,
608 output_contract: Some(IoContract {
609 required_fields: vec![],
610 produced_fields: vec!["results".to_string()],
611 optional_fields: vec![],
612 json_schema: None,
613 }),
614 budget_limit: None,
615 retryable: false,
616 max_retries: None,
617 },
618 SkillStep {
619 index: 1,
620 server_id: "llm-srv".to_string(),
621 tool_name: "summarize".to_string(),
622 label: Some("Summarize".to_string()),
623 input_contract: Some(IoContract {
624 required_fields: vec!["results".to_string()],
625 produced_fields: vec![],
626 optional_fields: vec![],
627 json_schema: None,
628 }),
629 output_contract: Some(IoContract {
630 required_fields: vec![],
631 produced_fields: vec!["summary".to_string()],
632 optional_fields: vec![],
633 json_schema: None,
634 }),
635 budget_limit: None,
636 retryable: false,
637 max_retries: None,
638 },
639 ],
640 )
641 }
642
643 fn make_grant() -> SkillGrant {
644 let mut grant = SkillGrant::new(
645 "search-summarize".to_string(),
646 "1.0.0".to_string(),
647 vec![
648 "search-srv:search".to_string(),
649 "llm-srv:summarize".to_string(),
650 ],
651 );
652 grant.budget_envelope = Some(MonetaryAmount {
653 units: 1000,
654 currency: "USD".to_string(),
655 });
656 grant
657 }
658
659 #[test]
660 fn successful_workflow_execution() {
661 let manifest = make_manifest();
662 let grant = make_grant();
663 let authority = WorkflowAuthority::new(Keypair::generate());
664
665 let mut execution = authority
666 .begin(
667 &manifest,
668 &grant,
669 "agent-1".to_string(),
670 "cap-1".to_string(),
671 Some("sess-1".to_string()),
672 )
673 .unwrap();
674
675 authority
677 .validate_step(&execution, &manifest.steps[0], &grant)
678 .unwrap();
679 authority
680 .record_step(
681 &mut execution,
682 &manifest.steps[0],
683 StepExecutionRecordInput {
684 outcome: StepOutcome::Success,
685 duration_ms: 100,
686 cost: Some(MonetaryAmount {
687 units: 50,
688 currency: "USD".to_string(),
689 }),
690 tool_receipt_id: Some("rcpt-0".to_string()),
691 output_hash: None,
692 },
693 )
694 .unwrap();
695
696 authority
698 .validate_step(&execution, &manifest.steps[1], &grant)
699 .unwrap();
700 authority
701 .record_step(
702 &mut execution,
703 &manifest.steps[1],
704 StepExecutionRecordInput {
705 outcome: StepOutcome::Success,
706 duration_ms: 200,
707 cost: Some(MonetaryAmount {
708 units: 100,
709 currency: "USD".to_string(),
710 }),
711 tool_receipt_id: Some("rcpt-1".to_string()),
712 output_hash: None,
713 },
714 )
715 .unwrap();
716
717 let receipt = authority.finalize(execution).unwrap();
718 assert!(receipt.is_complete());
719 assert_eq!(receipt.successful_steps(), 2);
720 assert!(receipt.verify().unwrap());
721 assert_eq!(authority.execution_count(), 1);
722 }
723
724 #[test]
725 fn unauthorized_skill_rejected() {
726 let manifest = make_manifest();
727 let mut grant = make_grant();
728 grant.skill_id = "wrong-skill".to_string();
729
730 let authority = WorkflowAuthority::new(Keypair::generate());
731 let result = authority.begin(
732 &manifest,
733 &grant,
734 "agent-1".to_string(),
735 "cap-1".to_string(),
736 None,
737 );
738 assert!(matches!(
739 result,
740 Err(WorkflowError::UnauthorizedSkill { .. })
741 ));
742 }
743
744 #[test]
745 fn missing_step_authorization_rejected() {
746 let manifest = make_manifest();
747 let grant = SkillGrant::new(
748 "search-summarize".to_string(),
749 "1.0.0".to_string(),
750 vec!["search-srv:search".to_string()],
751 );
753
754 let authority = WorkflowAuthority::new(Keypair::generate());
755 let result = authority.begin(
756 &manifest,
757 &grant,
758 "agent-1".to_string(),
759 "cap-1".to_string(),
760 None,
761 );
762 assert!(matches!(
763 result,
764 Err(WorkflowError::UnauthorizedStep { .. })
765 ));
766 }
767
768 #[test]
769 fn budget_exceeded_during_execution() {
770 let manifest = make_manifest();
771 let mut grant = make_grant();
772 grant.budget_envelope = Some(MonetaryAmount {
773 units: 100,
774 currency: "USD".to_string(),
775 });
776
777 let authority = WorkflowAuthority::new(Keypair::generate());
778 let mut execution = authority
779 .begin(
780 &manifest,
781 &grant,
782 "agent-1".to_string(),
783 "cap-1".to_string(),
784 None,
785 )
786 .unwrap();
787
788 authority
790 .record_step(
791 &mut execution,
792 &manifest.steps[0],
793 StepExecutionRecordInput {
794 outcome: StepOutcome::Success,
795 duration_ms: 100,
796 cost: Some(MonetaryAmount {
797 units: 80,
798 currency: "USD".to_string(),
799 }),
800 tool_receipt_id: None,
801 output_hash: None,
802 },
803 )
804 .unwrap();
805
806 let result = authority.record_step(
808 &mut execution,
809 &manifest.steps[1],
810 StepExecutionRecordInput {
811 outcome: StepOutcome::Success,
812 duration_ms: 100,
813 cost: Some(MonetaryAmount {
814 units: 50,
815 currency: "USD".to_string(),
816 }),
817 tool_receipt_id: None,
818 output_hash: None,
819 },
820 );
821 assert!(matches!(result, Err(WorkflowError::BudgetExceeded { .. })));
822
823 assert_eq!(execution.step_records.len(), 2);
825 assert_eq!(execution.step_records[1].step_index, 1);
826 }
827
828 #[test]
829 fn step_budget_limit_is_enforced_when_recording_step_cost() {
830 let mut manifest = make_manifest();
831 manifest.steps[0].budget_limit = Some(MonetaryAmount {
832 units: 25,
833 currency: "USD".to_string(),
834 });
835 let mut grant = make_grant();
836 grant.budget_envelope = Some(MonetaryAmount {
837 units: 1_000,
838 currency: "USD".to_string(),
839 });
840
841 let authority = WorkflowAuthority::new(Keypair::generate());
842 let mut execution = authority
843 .begin(
844 &manifest,
845 &grant,
846 "agent-1".to_string(),
847 "cap-1".to_string(),
848 None,
849 )
850 .unwrap();
851
852 let result = authority.record_step(
853 &mut execution,
854 &manifest.steps[0],
855 StepExecutionRecordInput {
856 outcome: StepOutcome::Success,
857 duration_ms: 100,
858 cost: Some(MonetaryAmount {
859 units: 50,
860 currency: "USD".to_string(),
861 }),
862 tool_receipt_id: None,
863 output_hash: None,
864 },
865 );
866
867 assert!(matches!(
868 result,
869 Err(WorkflowError::BudgetExceeded {
870 limit_units: 25,
871 spent_units: 50,
872 ..
873 })
874 ));
875 assert_eq!(execution.step_records.len(), 1);
876 assert!(!execution.active);
877
878 let receipt = authority.finalize(execution).unwrap();
879 assert!(matches!(
880 receipt.outcome,
881 WorkflowOutcome::BudgetExceeded {
882 limit_units: 25,
883 spent_units: 50,
884 ..
885 }
886 ));
887 }
888
889 #[test]
890 fn mismatched_budget_currency_fails_without_spending_units() {
891 let manifest = make_manifest();
892 let grant = make_grant();
893 let authority = WorkflowAuthority::new(Keypair::generate());
894 let mut execution = authority
895 .begin(
896 &manifest,
897 &grant,
898 "agent-1".to_string(),
899 "cap-1".to_string(),
900 None,
901 )
902 .unwrap();
903
904 let result = authority.record_step(
905 &mut execution,
906 &manifest.steps[0],
907 StepExecutionRecordInput {
908 outcome: StepOutcome::Success,
909 duration_ms: 100,
910 cost: Some(MonetaryAmount {
911 units: 50,
912 currency: "EUR".to_string(),
913 }),
914 tool_receipt_id: None,
915 output_hash: None,
916 },
917 );
918
919 assert!(matches!(
920 result,
921 Err(WorkflowError::BudgetCurrencyMismatch {
922 expected_currency,
923 actual_currency
924 }) if expected_currency == "USD" && actual_currency == "EUR"
925 ));
926 assert_eq!(execution.budget_spent, 0);
927 assert_eq!(execution.step_records.len(), 1);
928 assert_eq!(execution.step_records[0].step_index, 0);
929 assert_eq!(
930 execution.step_records[0]
931 .cost
932 .as_ref()
933 .map(|cost| cost.currency.as_str()),
934 Some("EUR")
935 );
936 assert!(!execution.active);
937
938 let receipt = authority.finalize(execution).unwrap();
939 assert!(matches!(receipt.outcome, WorkflowOutcome::Denied { .. }));
940 assert_eq!(receipt.steps.len(), 1);
941 assert!(receipt.total_cost.is_none());
942 }
943
944 #[test]
945 fn record_step_rejects_elapsed_time_limit() {
946 let manifest = make_manifest();
947 let mut grant = make_grant();
948 grant.max_duration_secs = Some(1);
949 let authority = WorkflowAuthority::new(Keypair::generate());
950 let mut execution = authority
951 .begin(
952 &manifest,
953 &grant,
954 "agent-1".to_string(),
955 "cap-1".to_string(),
956 None,
957 )
958 .unwrap();
959 execution.started_at = current_unix_secs().saturating_sub(2);
960
961 let result = authority.record_step(
962 &mut execution,
963 &manifest.steps[0],
964 StepExecutionRecordInput {
965 outcome: StepOutcome::Success,
966 duration_ms: 100,
967 cost: None,
968 tool_receipt_id: None,
969 output_hash: None,
970 },
971 );
972
973 assert!(matches!(
974 result,
975 Err(WorkflowError::TimeLimitExceeded { limit_secs: 1, .. })
976 ));
977 assert_eq!(execution.step_records.len(), 1);
978 assert_eq!(execution.step_records[0].step_index, 0);
979 assert!(!execution.active);
980
981 let receipt = authority.finalize(execution).unwrap();
982 assert!(matches!(
983 receipt.outcome,
984 WorkflowOutcome::TimedOut { limit_secs: 1, .. }
985 ));
986 assert_eq!(receipt.steps.len(), 1);
987 }
988
989 #[test]
990 fn step_order_enforcement() {
991 let manifest = make_manifest();
992 let grant = make_grant();
993 let authority = WorkflowAuthority::new(Keypair::generate());
994
995 let execution = authority
996 .begin(
997 &manifest,
998 &grant,
999 "agent-1".to_string(),
1000 "cap-1".to_string(),
1001 None,
1002 )
1003 .unwrap();
1004
1005 let result = authority.validate_step(&execution, &manifest.steps[1], &grant);
1007 assert!(matches!(result, Err(WorkflowError::StepOutOfOrder { .. })));
1008 }
1009
1010 #[test]
1011 fn step_failure_deactivates_workflow() {
1012 let manifest = make_manifest();
1013 let grant = make_grant();
1014 let authority = WorkflowAuthority::new(Keypair::generate());
1015
1016 let mut execution = authority
1017 .begin(
1018 &manifest,
1019 &grant,
1020 "agent-1".to_string(),
1021 "cap-1".to_string(),
1022 None,
1023 )
1024 .unwrap();
1025
1026 authority
1028 .record_step(
1029 &mut execution,
1030 &manifest.steps[0],
1031 StepExecutionRecordInput {
1032 outcome: StepOutcome::Failed,
1033 duration_ms: 50,
1034 cost: None,
1035 tool_receipt_id: None,
1036 output_hash: None,
1037 },
1038 )
1039 .unwrap();
1040
1041 assert!(!execution.active);
1043
1044 let result = authority.validate_step(&execution, &manifest.steps[1], &grant);
1046 assert!(matches!(result, Err(WorkflowError::InvalidState(_))));
1047 }
1048
1049 #[test]
1050 fn failed_step_finalized_late_preserves_step_failed_outcome() {
1051 let manifest = make_manifest();
1052 let mut grant = make_grant();
1053 grant.max_duration_secs = Some(1);
1054 let authority = WorkflowAuthority::new(Keypair::generate());
1055
1056 let mut execution = authority
1057 .begin(
1058 &manifest,
1059 &grant,
1060 "agent-1".to_string(),
1061 "cap-1".to_string(),
1062 None,
1063 )
1064 .unwrap();
1065
1066 authority
1067 .record_step(
1068 &mut execution,
1069 &manifest.steps[0],
1070 StepExecutionRecordInput {
1071 outcome: StepOutcome::Failed,
1072 duration_ms: 50,
1073 cost: None,
1074 tool_receipt_id: Some("tool-receipt-1".to_string()),
1075 output_hash: Some("output-hash-1".to_string()),
1076 },
1077 )
1078 .unwrap();
1079
1080 execution.started_at = current_unix_secs().saturating_sub(2);
1081
1082 let receipt = authority.finalize(execution).unwrap();
1083 assert!(matches!(
1084 receipt.outcome,
1085 WorkflowOutcome::StepFailed { step_index: 0, .. }
1086 ));
1087 assert_eq!(receipt.steps.len(), 1);
1088 assert_eq!(
1089 receipt.steps[0].tool_receipt_id.as_deref(),
1090 Some("tool-receipt-1")
1091 );
1092 }
1093
1094 #[test]
1095 fn single_step_workflow() {
1096 let manifest = SkillManifest::new(
1097 "simple".to_string(),
1098 "1.0.0".to_string(),
1099 "Simple Task".to_string(),
1100 vec![SkillStep {
1101 index: 0,
1102 server_id: "srv".to_string(),
1103 tool_name: "tool".to_string(),
1104 label: Some("Do thing".to_string()),
1105 input_contract: None,
1106 output_contract: None,
1107 budget_limit: None,
1108 retryable: false,
1109 max_retries: None,
1110 }],
1111 );
1112 let grant = SkillGrant::new(
1113 "simple".to_string(),
1114 "1.0.0".to_string(),
1115 vec!["srv:tool".to_string()],
1116 );
1117 let authority = WorkflowAuthority::new(Keypair::generate());
1118
1119 let mut execution = authority
1120 .begin(
1121 &manifest,
1122 &grant,
1123 "agent-1".to_string(),
1124 "cap-1".to_string(),
1125 None,
1126 )
1127 .unwrap();
1128
1129 authority
1130 .validate_step(&execution, &manifest.steps[0], &grant)
1131 .unwrap();
1132 authority
1133 .record_step(
1134 &mut execution,
1135 &manifest.steps[0],
1136 StepExecutionRecordInput {
1137 outcome: StepOutcome::Success,
1138 duration_ms: 50,
1139 cost: None,
1140 tool_receipt_id: None,
1141 output_hash: None,
1142 },
1143 )
1144 .unwrap();
1145
1146 let receipt = authority.finalize(execution).unwrap();
1147 assert!(receipt.is_complete());
1148 assert_eq!(receipt.successful_steps(), 1);
1149 assert!(receipt.verify().unwrap());
1150 }
1151
1152 #[test]
1153 fn execution_limit_denies_reuse_after_finalize() {
1154 let manifest = SkillManifest::new(
1155 "limited".to_string(),
1156 "1.0.0".to_string(),
1157 "Limited".to_string(),
1158 vec![SkillStep {
1159 index: 0,
1160 server_id: "srv".to_string(),
1161 tool_name: "tool".to_string(),
1162 label: None,
1163 input_contract: None,
1164 output_contract: None,
1165 budget_limit: None,
1166 retryable: false,
1167 max_retries: None,
1168 }],
1169 );
1170 let mut grant = SkillGrant::new(
1171 "limited".to_string(),
1172 "1.0.0".to_string(),
1173 vec!["srv:tool".to_string()],
1174 );
1175 grant.max_executions = Some(1);
1176
1177 let authority = WorkflowAuthority::new(Keypair::generate());
1178
1179 let mut execution = authority
1181 .begin(&manifest, &grant, "a".to_string(), "c".to_string(), None)
1182 .unwrap();
1183 authority
1184 .record_step(
1185 &mut execution,
1186 &manifest.steps[0],
1187 StepExecutionRecordInput {
1188 outcome: StepOutcome::Success,
1189 duration_ms: 10,
1190 cost: None,
1191 tool_receipt_id: None,
1192 output_hash: None,
1193 },
1194 )
1195 .unwrap();
1196 authority.finalize(execution).unwrap();
1197
1198 let result = authority.begin(&manifest, &grant, "a".to_string(), "c".to_string(), None);
1200 assert!(matches!(
1201 result,
1202 Err(WorkflowError::ExecutionLimitReached { .. })
1203 ));
1204 }
1205
1206 #[test]
1207 fn execution_limit_reserved_at_begin() {
1208 let manifest = SkillManifest::new(
1209 "limited".to_string(),
1210 "1.0.0".to_string(),
1211 "Limited".to_string(),
1212 vec![SkillStep {
1213 index: 0,
1214 server_id: "srv".to_string(),
1215 tool_name: "tool".to_string(),
1216 label: None,
1217 input_contract: None,
1218 output_contract: None,
1219 budget_limit: None,
1220 retryable: false,
1221 max_retries: None,
1222 }],
1223 );
1224 let mut grant = SkillGrant::new(
1225 "limited".to_string(),
1226 "1.0.0".to_string(),
1227 vec!["srv:tool".to_string()],
1228 );
1229 grant.max_executions = Some(1);
1230
1231 let authority = WorkflowAuthority::new(Keypair::generate());
1232
1233 let _execution = authority
1234 .begin(&manifest, &grant, "a".to_string(), "c".to_string(), None)
1235 .unwrap();
1236
1237 let result = authority.begin(&manifest, &grant, "a".to_string(), "c".to_string(), None);
1238 assert!(
1239 matches!(result, Err(WorkflowError::ExecutionLimitReached { .. })),
1240 "limit must be consumed when begin starts, not only after finalize"
1241 );
1242 }
1243
1244 #[test]
1245 fn execution_limit_tracks_completed_and_in_flight_executions() {
1246 let manifest = SkillManifest::new(
1247 "limited".to_string(),
1248 "1.0.0".to_string(),
1249 "Limited".to_string(),
1250 vec![SkillStep {
1251 index: 0,
1252 server_id: "srv".to_string(),
1253 tool_name: "tool".to_string(),
1254 label: None,
1255 input_contract: None,
1256 output_contract: None,
1257 budget_limit: None,
1258 retryable: false,
1259 max_retries: None,
1260 }],
1261 );
1262 let mut grant = SkillGrant::new(
1263 "limited".to_string(),
1264 "1.0.0".to_string(),
1265 vec!["srv:tool".to_string()],
1266 );
1267 grant.max_executions = Some(2);
1268
1269 let authority = WorkflowAuthority::new(Keypair::generate());
1270
1271 let execution = authority
1272 .begin(
1273 &manifest,
1274 &grant,
1275 "agent-a".to_string(),
1276 "cap-reused".to_string(),
1277 None,
1278 )
1279 .unwrap();
1280
1281 let execution_2 = authority
1282 .begin(
1283 &manifest,
1284 &grant,
1285 "agent-b".to_string(),
1286 "cap-reused".to_string(),
1287 None,
1288 )
1289 .unwrap();
1290
1291 let blocked = authority.begin(
1292 &manifest,
1293 &grant,
1294 "agent-c".to_string(),
1295 "cap-reused".to_string(),
1296 None,
1297 );
1298 assert!(matches!(
1299 blocked,
1300 Err(WorkflowError::ExecutionLimitReached { .. })
1301 ));
1302
1303 authority.finalize(execution).unwrap();
1304
1305 let still_blocked = authority.begin(
1306 &manifest,
1307 &grant,
1308 "agent-d".to_string(),
1309 "cap-reused".to_string(),
1310 None,
1311 );
1312 assert!(matches!(
1313 still_blocked,
1314 Err(WorkflowError::ExecutionLimitReached { .. })
1315 ));
1316
1317 authority.finalize(execution_2).unwrap();
1318
1319 let exhausted = authority.begin(
1320 &manifest,
1321 &grant,
1322 "agent-e".to_string(),
1323 "cap-reused".to_string(),
1324 None,
1325 );
1326 assert!(matches!(
1327 exhausted,
1328 Err(WorkflowError::ExecutionLimitReached { .. })
1329 ));
1330 }
1331
1332 #[test]
1333 fn unlimited_finalize_does_not_release_limited_reservation() {
1334 let manifest = SkillManifest::new(
1335 "limited".to_string(),
1336 "1.0.0".to_string(),
1337 "Limited".to_string(),
1338 vec![SkillStep {
1339 index: 0,
1340 server_id: "srv".to_string(),
1341 tool_name: "tool".to_string(),
1342 label: None,
1343 input_contract: None,
1344 output_contract: None,
1345 budget_limit: None,
1346 retryable: false,
1347 max_retries: None,
1348 }],
1349 );
1350 let unlimited_grant = SkillGrant::new(
1351 "limited".to_string(),
1352 "1.0.0".to_string(),
1353 vec!["srv:tool".to_string()],
1354 );
1355 let mut limited_grant = SkillGrant::new(
1356 "limited".to_string(),
1357 "1.0.0".to_string(),
1358 vec!["srv:tool".to_string()],
1359 );
1360 limited_grant.max_executions = Some(1);
1361
1362 let authority = WorkflowAuthority::new(Keypair::generate());
1363
1364 let unlimited_execution = authority
1365 .begin(
1366 &manifest,
1367 &unlimited_grant,
1368 "agent-a".to_string(),
1369 "cap-shared".to_string(),
1370 None,
1371 )
1372 .unwrap();
1373
1374 authority
1375 .begin(
1376 &manifest,
1377 &limited_grant,
1378 "agent-b".to_string(),
1379 "cap-shared".to_string(),
1380 None,
1381 )
1382 .unwrap();
1383
1384 authority.finalize(unlimited_execution).unwrap();
1385
1386 let result = authority.begin(
1387 &manifest,
1388 &limited_grant,
1389 "agent-b".to_string(),
1390 "cap-shared".to_string(),
1391 None,
1392 );
1393 assert!(matches!(
1394 result,
1395 Err(WorkflowError::ExecutionLimitReached { .. })
1396 ));
1397 assert_eq!(authority.execution_count(), 2);
1398 }
1399
1400 #[test]
1401 fn budget_exactly_at_limit_passes() {
1402 let manifest = make_manifest();
1403 let mut grant = make_grant();
1404 grant.budget_envelope = Some(MonetaryAmount {
1405 units: 100,
1406 currency: "USD".to_string(),
1407 });
1408
1409 let authority = WorkflowAuthority::new(Keypair::generate());
1410 let mut execution = authority
1411 .begin(
1412 &manifest,
1413 &grant,
1414 "agent-1".to_string(),
1415 "cap-1".to_string(),
1416 None,
1417 )
1418 .unwrap();
1419
1420 authority
1422 .record_step(
1423 &mut execution,
1424 &manifest.steps[0],
1425 StepExecutionRecordInput {
1426 outcome: StepOutcome::Success,
1427 duration_ms: 100,
1428 cost: Some(MonetaryAmount {
1429 units: 100,
1430 currency: "USD".to_string(),
1431 }),
1432 tool_receipt_id: None,
1433 output_hash: None,
1434 },
1435 )
1436 .unwrap();
1437
1438 authority
1440 .record_step(
1441 &mut execution,
1442 &manifest.steps[1],
1443 StepExecutionRecordInput {
1444 outcome: StepOutcome::Success,
1445 duration_ms: 100,
1446 cost: Some(MonetaryAmount {
1447 units: 0,
1448 currency: "USD".to_string(),
1449 }),
1450 tool_receipt_id: None,
1451 output_hash: None,
1452 },
1453 )
1454 .unwrap();
1455 }
1456
1457 #[test]
1458 fn record_step_on_inactive_workflow_errors() {
1459 let manifest = make_manifest();
1460 let grant = make_grant();
1461 let authority = WorkflowAuthority::new(Keypair::generate());
1462
1463 let mut execution = authority
1464 .begin(
1465 &manifest,
1466 &grant,
1467 "agent-1".to_string(),
1468 "cap-1".to_string(),
1469 None,
1470 )
1471 .unwrap();
1472
1473 execution.active = false;
1475
1476 let result = authority.record_step(
1477 &mut execution,
1478 &manifest.steps[0],
1479 StepExecutionRecordInput {
1480 outcome: StepOutcome::Success,
1481 duration_ms: 10,
1482 cost: None,
1483 tool_receipt_id: None,
1484 output_hash: None,
1485 },
1486 );
1487 assert!(matches!(result, Err(WorkflowError::InvalidState(_))));
1488 }
1489
1490 #[test]
1491 fn denied_step_deactivates_workflow() {
1492 let manifest = make_manifest();
1493 let grant = make_grant();
1494 let authority = WorkflowAuthority::new(Keypair::generate());
1495
1496 let mut execution = authority
1497 .begin(
1498 &manifest,
1499 &grant,
1500 "agent-1".to_string(),
1501 "cap-1".to_string(),
1502 None,
1503 )
1504 .unwrap();
1505
1506 authority
1507 .record_step(
1508 &mut execution,
1509 &manifest.steps[0],
1510 StepExecutionRecordInput {
1511 outcome: StepOutcome::Denied,
1512 duration_ms: 50,
1513 cost: None,
1514 tool_receipt_id: None,
1515 output_hash: None,
1516 },
1517 )
1518 .unwrap();
1519
1520 assert!(!execution.active);
1521 }
1522
1523 #[test]
1524 fn execution_ids_do_not_collide_within_a_single_second() {
1525 let manifest = make_manifest();
1527 let grant = make_grant();
1528 let authority = WorkflowAuthority::new(Keypair::generate());
1529
1530 let exec_a = authority
1531 .begin(
1532 &manifest,
1533 &grant,
1534 "agent-1".to_string(),
1535 "cap-1".to_string(),
1536 None,
1537 )
1538 .unwrap();
1539 let _ = authority.finalize(exec_a).unwrap();
1542
1543 let exec_b = authority
1544 .begin(
1545 &manifest,
1546 &grant,
1547 "agent-1".to_string(),
1548 "cap-2".to_string(),
1549 None,
1550 )
1551 .unwrap();
1552 let exec_c = authority
1553 .begin(
1554 &manifest,
1555 &grant,
1556 "agent-2".to_string(),
1557 "cap-3".to_string(),
1558 None,
1559 )
1560 .unwrap();
1561
1562 assert_ne!(
1563 exec_b.id, exec_c.id,
1564 "executions started in the same second must not share an id"
1565 );
1566 }
1567
1568 #[test]
1569 fn rapid_begin_calls_produce_distinct_ids_for_same_agent() {
1570 let manifest = make_manifest();
1576 let grant = make_grant();
1577 let authority = WorkflowAuthority::new(Keypair::generate());
1578
1579 let exec_a = authority
1580 .begin(
1581 &manifest,
1582 &grant,
1583 "same-agent".to_string(),
1584 "cap-1".to_string(),
1585 None,
1586 )
1587 .unwrap();
1588 let exec_b = authority
1589 .begin(
1590 &manifest,
1591 &grant,
1592 "same-agent".to_string(),
1593 "cap-2".to_string(),
1594 None,
1595 )
1596 .unwrap();
1597 let exec_c = authority
1598 .begin(
1599 &manifest,
1600 &grant,
1601 "same-agent".to_string(),
1602 "cap-3".to_string(),
1603 None,
1604 )
1605 .unwrap();
1606
1607 assert_ne!(exec_a.id, exec_b.id);
1608 assert_ne!(exec_b.id, exec_c.id);
1609 assert_ne!(exec_a.id, exec_c.id);
1610 }
1611}