Skip to main content

exo_catapult/
error.rs

1// Copyright 2026 Exochain Foundation
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at:
6//
7//     https://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14//
15// SPDX-License-Identifier: Apache-2.0
16
17//! Catapult-specific errors.
18use thiserror::Error;
19use uuid::Uuid;
20
21use crate::{oda::OdaSlot, phase::OperationalPhase};
22
23/// Errors returned by Catapult franchise operations.
24#[derive(Debug, Error)]
25pub enum CatapultError {
26    #[error("franchise not found: {0}")]
27    FranchiseNotFound(Uuid),
28    #[error("newco not found: {0}")]
29    NewcoNotFound(Uuid),
30    #[error("invalid phase transition: {from:?} -> {to:?}")]
31    InvalidPhaseTransition {
32        from: OperationalPhase,
33        to: OperationalPhase,
34    },
35    #[error("roster incomplete for phase {phase:?}: need {needed}, have {have}")]
36    RosterIncomplete {
37        phase: OperationalPhase,
38        needed: usize,
39        have: usize,
40    },
41    #[error("agent slot already filled: {0}")]
42    SlotAlreadyFilled(OdaSlot),
43    #[error("agent slot empty: {0}")]
44    SlotEmpty(OdaSlot),
45    #[error("budget exceeded: spent={spent_cents} limit={limit_cents}")]
46    BudgetExceeded { spent_cents: u64, limit_cents: u64 },
47    #[error("heartbeat timeout: agent {agent_did} last seen {elapsed_ms}ms ago")]
48    HeartbeatTimeout { agent_did: String, elapsed_ms: u64 },
49    #[error("goal not found: {0}")]
50    GoalNotFound(Uuid),
51    #[error("duplicate goal: {0}")]
52    DuplicateGoal(Uuid),
53    #[error("franchise already exists: {0}")]
54    FranchiseAlreadyExists(Uuid),
55    #[error("newco already exists: {0}")]
56    NewcoAlreadyExists(Uuid),
57    #[error("invalid catapult agent: {reason}")]
58    InvalidAgent { reason: String },
59    #[error("invalid budget policy: {reason}")]
60    InvalidBudgetPolicy { reason: String },
61    #[error("invalid cost event: {reason}")]
62    InvalidCostEvent { reason: String },
63    #[error("invalid goal: {reason}")]
64    InvalidGoal { reason: String },
65    #[error("invalid heartbeat record: {reason}")]
66    InvalidHeartbeat { reason: String },
67    #[error("invalid franchise blueprint: {reason}")]
68    InvalidFranchiseBlueprint { reason: String },
69    #[error("invalid newco: {reason}")]
70    InvalidNewco { reason: String },
71    #[error("invalid franchise receipt: {reason}")]
72    InvalidReceipt { reason: String },
73    #[error("franchise receipt serialization failed: {reason}")]
74    ReceiptSerializationFailed { reason: String },
75    #[error("franchise receipt chain broken at index {index}")]
76    ReceiptChainBroken { index: usize },
77}
78
79/// Convenience alias for results with [`CatapultError`].
80pub type Result<T> = std::result::Result<T, CatapultError>;
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85    #[test]
86    fn all_display() {
87        let es: Vec<CatapultError> = vec![
88            CatapultError::FranchiseNotFound(Uuid::nil()),
89            CatapultError::NewcoNotFound(Uuid::nil()),
90            CatapultError::InvalidPhaseTransition {
91                from: OperationalPhase::Assessment,
92                to: OperationalPhase::Execution,
93            },
94            CatapultError::RosterIncomplete {
95                phase: OperationalPhase::Execution,
96                needed: 12,
97                have: 2,
98            },
99            CatapultError::SlotAlreadyFilled(OdaSlot::VentureCommander),
100            CatapultError::SlotEmpty(OdaSlot::VentureCommander),
101            CatapultError::BudgetExceeded {
102                spent_cents: 100,
103                limit_cents: 50,
104            },
105            CatapultError::HeartbeatTimeout {
106                agent_did: "did:exo:test".into(),
107                elapsed_ms: 600_000,
108            },
109            CatapultError::GoalNotFound(Uuid::nil()),
110            CatapultError::DuplicateGoal(Uuid::nil()),
111            CatapultError::FranchiseAlreadyExists(Uuid::nil()),
112            CatapultError::NewcoAlreadyExists(Uuid::nil()),
113            CatapultError::InvalidAgent {
114                reason: "bad agent".into(),
115            },
116            CatapultError::InvalidBudgetPolicy {
117                reason: "bad policy".into(),
118            },
119            CatapultError::InvalidCostEvent {
120                reason: "bad cost".into(),
121            },
122            CatapultError::InvalidGoal {
123                reason: "bad goal".into(),
124            },
125            CatapultError::InvalidHeartbeat {
126                reason: "bad heartbeat".into(),
127            },
128            CatapultError::InvalidFranchiseBlueprint {
129                reason: "bad blueprint".into(),
130            },
131            CatapultError::InvalidNewco {
132                reason: "bad newco".into(),
133            },
134            CatapultError::InvalidReceipt {
135                reason: "bad receipt".into(),
136            },
137            CatapultError::ReceiptSerializationFailed {
138                reason: "bad cbor".into(),
139            },
140            CatapultError::ReceiptChainBroken { index: 3 },
141        ];
142        for e in &es {
143            assert!(!e.to_string().is_empty());
144        }
145    }
146}