Skip to main content

chio_workflow/
authority.rs

1//! Workflow authority -- validates each step against declared scope and budget.
2//!
3//! The `WorkflowAuthority` manages the lifecycle of a skill execution:
4//! beginning, step validation, step recording, and finalization into a
5//! signed `WorkflowReceipt`.
6
7use 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/// Errors from the workflow authority.
23#[derive(Debug, thiserror::Error)]
24pub enum WorkflowError {
25    /// The skill grant does not authorize the requested skill.
26    #[error("skill grant does not authorize skill '{skill_id}' version '{version}'")]
27    UnauthorizedSkill { skill_id: String, version: String },
28
29    /// A step is not authorized by the skill grant.
30    #[error("step {step_index} ({server}:{tool}) is not authorized")]
31    UnauthorizedStep {
32        step_index: usize,
33        server: String,
34        tool: String,
35    },
36
37    /// A step is out of order (when strict ordering is required).
38    #[error("step {step_index} is out of order (expected step {expected})")]
39    StepOutOfOrder { step_index: usize, expected: usize },
40
41    /// The workflow budget has been exceeded.
42    #[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    /// A step reported cost in a currency that does not match the declared budget.
50    #[error("budget currency mismatch: expected {expected_currency}, got {actual_currency}")]
51    BudgetCurrencyMismatch {
52        expected_currency: String,
53        actual_currency: String,
54    },
55
56    /// The workflow time limit has been exceeded.
57    #[error("time limit exceeded: {elapsed_secs}s of {limit_secs}s allowed")]
58    TimeLimitExceeded { elapsed_secs: u64, limit_secs: u64 },
59
60    /// The maximum number of executions has been reached.
61    #[error("execution limit reached: {limit} executions")]
62    ExecutionLimitReached { limit: u32 },
63
64    /// The workflow is in an invalid state for the requested operation.
65    #[error("workflow is in invalid state: {0}")]
66    InvalidState(String),
67
68    /// Receipt signing failed.
69    #[error("receipt signing failed: {0}")]
70    SigningFailed(String),
71}
72
73/// A workflow execution in progress.
74///
75/// Created by `WorkflowAuthority::begin()` and consumed by `finalize()`.
76#[derive(Debug)]
77pub struct WorkflowExecution {
78    /// Unique execution ID (becomes the receipt ID).
79    pub id: String,
80    /// Skill ID from the manifest.
81    pub skill_id: String,
82    /// Skill version.
83    pub skill_version: String,
84    /// Agent performing the execution.
85    pub agent_id: String,
86    /// Session binding.
87    pub session_id: Option<String>,
88    /// Capability ID.
89    pub capability_id: String,
90    /// Whether this execution reserved capacity from a limited grant.
91    limited_execution_reserved: bool,
92    /// Unix timestamp when execution started.
93    pub started_at: u64,
94    /// Steps completed so far.
95    pub step_records: Vec<StepRecord>,
96    /// Budget spent so far (in policy currency minor units).
97    pub budget_spent: u64,
98    /// Budget limit from grant or manifest.
99    pub budget_limit: Option<MonetaryAmount>,
100    /// Time limit in seconds.
101    pub time_limit_secs: Option<u64>,
102    /// Whether the execution is still active.
103    pub active: bool,
104    /// Explicit terminal outcome for fail-closed aborts that do not map to
105    /// completed step state.
106    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    /// Return the number of completed steps.
125    #[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
134/// Workflow authority that validates and manages skill executions.
135pub struct WorkflowAuthority {
136    signing_key: Keypair,
137    /// Number of workflow executions successfully started.
138    execution_count: AtomicU32,
139    /// Per-capability reservations for grants with an execution limit.
140    limited_execution_counts: Mutex<HashMap<String, LimitedExecutionCount>>,
141    /// Monotonic counter incremented on every `begin()` call. Used to
142    /// disambiguate execution ids when multiple `begin()` invocations land
143    /// in the same wall-clock second on the same authority. Stored as an
144    /// atomic so `begin()` can keep its `&self` receiver.
145    begin_counter: AtomicU64,
146}
147
148impl WorkflowAuthority {
149    /// Create a new workflow authority with the given signing key.
150    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    /// Begin a new workflow execution.
160    ///
161    /// Validates the grant against the manifest before starting.
162    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        // Mix a monotonic per-call counter and the agent id into the id so
207        // that multiple `begin()` calls within the same wall-clock second
208        // on the same authority do not collide. Receipts are downstream-
209        // keyed off this id; collisions would let one execution shadow
210        // another. We use an atomic so `begin()` can keep `&self`, and
211        // increment per call (not per finalize) so back-to-back `begin()`
212        // calls before any `finalize()` still produce distinct ids.
213        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    /// Validate a step before execution.
234    ///
235    /// Checks authorization, ordering, budget, and time constraints.
236    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    /// Record the result of a step execution.
270    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        // Always record the step so the audit trail is complete, even when
340        // the budget is exceeded on this step.
341        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        // Check budget envelope after recording so that finalize() includes
388        // the offending step in the receipt.
389        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    /// Finalize a workflow execution and produce a signed receipt.
425    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    /// Return the number of workflow executions successfully started.
497    #[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        // Validate and record step 0
676        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        // Validate and record step 1
697        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            // Missing llm-srv:summarize
752        );
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        // Step 0 costs 80 out of 100 budget
789        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        // Step 1 costs 50, pushing over budget
807        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        // The offending step should still be recorded for audit completeness.
824        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        // Try step 1 before step 0
1006        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        // Record a failed step
1027        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        // Workflow should be deactivated
1042        assert!(!execution.active);
1043
1044        // Trying to validate next step should fail
1045        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        // First execution should work
1180        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        // Finalize completes the lifetime execution, so reuse is denied.
1199        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        // Spend exactly the limit (100)
1421        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        // Spending 0 more should still be fine (100 <= 100)
1439        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        // Manually deactivate
1474        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        // Regression guard: `begin()` MUST use sub-second resolution so two calls in the same second produce distinct ids; second-resolution ids collide and produce shadowed receipts.
1526        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        // Finalize to bump the execution counter without invoking the
1540        // sleep that would otherwise change `now`.
1541        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        // Two back-to-back `begin()` calls for the same agent within one
1571        // second, with no intervening `finalize()`, must not collide: `now`
1572        // and `execution_count` (incremented only in `finalize()`) are both
1573        // unchanged across the pair. The atomic per-call counter ensures
1574        // every `begin()` produces a fresh id regardless of finalize ordering.
1575        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}