Skip to main content

areev_trigger/
error.rs

1//! `TRG-Ennn` error codes.
2//!
3//! Every user-facing error carries a stable code as the **leading token of its
4//! `Display` string**, plus a `code()` method, so a reported code alone locates
5//! the variant. Codes are **append-only** — never renumber or reuse one.
6
7use std::fmt;
8
9#[derive(Debug)]
10pub enum TriggerError {
11    /// TRG-E001 — the declaration cannot fire as written.
12    Malformed { what: String },
13    /// TRG-E002 — `workflow` does not resolve to a Workflow grain.
14    UnresolvedWorkflow { hash: String, why: String },
15    /// TRG-E003 — a trigger is due but the host configured no connector command.
16    ///
17    /// Loud on purpose, matching how a host tool with no `--tool-cmd` refuses
18    /// rather than quietly succeeding: a poll that silently does nothing looks
19    /// exactly like a source with no new items.
20    NoConnector { trigger: String, connector: String },
21    /// TRG-E004 — the connector failed: non-zero exit, timeout, or non-JSON.
22    ConnectorFailed { trigger: String, detail: String },
23    /// TRG-E005 — claim lost: the lease expired and another evaluator took over.
24    ClaimLost { trigger: String },
25    /// TRG-E006 — the cron expression is invalid, or names a timezone this
26    /// release refuses.
27    BadSchedule { expr: String, why: String },
28    /// TRG-E007 — unknown `trigger render` target.
29    UnknownTarget { target: String },
30    /// TRG-E008 — a composite predicate references a member it does not declare.
31    UnknownMember { trigger: String, member: String },
32    /// TRG-E009 — the connector tried to reach a host outside its allowlist.
33    EgressRefused { trigger: String, host: String },
34    /// TRG-E010 — the store refused or failed underneath us.
35    Storage { detail: String },
36    /// TRG-E011 — a connector's blob payload violated the contract:
37    /// undecodable `b64`, a dangling `"@N"` reference, or a per-item /
38    /// per-response budget overrun. The WHOLE poll fails with the cursor
39    /// unmoved — refusing loudly beats truncating, because a silently
40    /// dropped attachment is an invoice posting without evidence.
41    BlobContract { trigger: String, detail: String },
42    /// TRG-E012 — the trigger names its connector as a GRAIN (#185) and this
43    /// host will not run it: no pin, an unreadable Definition or blob, a
44    /// Definition carrying no code, a declared runtime with no sandbox, or a
45    /// blob-reading module on an evaluator wired no memory locator.
46    ///
47    /// Separate from `TRG-E003` because the fix is different: E003 says
48    /// *configure a connector*, this one says *this specific code is not
49    /// authorized here* — and the authorization deliberately does not travel
50    /// in the file, so the address to pin is what the message carries.
51    ConnectorCode { trigger: String, detail: String },
52}
53
54impl TriggerError {
55    pub fn code(&self) -> &'static str {
56        match self {
57            TriggerError::Malformed { .. } => "TRG-E001",
58            TriggerError::UnresolvedWorkflow { .. } => "TRG-E002",
59            TriggerError::NoConnector { .. } => "TRG-E003",
60            TriggerError::ConnectorFailed { .. } => "TRG-E004",
61            TriggerError::ClaimLost { .. } => "TRG-E005",
62            TriggerError::BadSchedule { .. } => "TRG-E006",
63            TriggerError::UnknownTarget { .. } => "TRG-E007",
64            TriggerError::UnknownMember { .. } => "TRG-E008",
65            TriggerError::EgressRefused { .. } => "TRG-E009",
66            TriggerError::Storage { .. } => "TRG-E010",
67            TriggerError::BlobContract { .. } => "TRG-E011",
68            TriggerError::ConnectorCode { .. } => "TRG-E012",
69        }
70    }
71}
72
73impl fmt::Display for TriggerError {
74    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
75        match self {
76            TriggerError::Malformed { what } => {
77                write!(f, "TRG-E001: malformed trigger declaration: {what}")
78            }
79            TriggerError::UnresolvedWorkflow { hash, why } => {
80                write!(f, "TRG-E002: workflow {hash} does not resolve: {why}")
81            }
82            TriggerError::NoConnector { trigger, connector } => write!(
83                f,
84                "TRG-E003: trigger {trigger} is due but no connector command is configured \
85                 for '{connector}' — pass --connector-cmd, or the poll would silently do nothing"
86            ),
87            TriggerError::ConnectorFailed { trigger, detail } => {
88                write!(f, "TRG-E004: connector for trigger {trigger} failed: {detail}")
89            }
90            TriggerError::ClaimLost { trigger } => write!(
91                f,
92                "TRG-E005: claim on trigger {trigger} was lost — its lease expired and another \
93                 evaluator took over; this firing's release was refused"
94            ),
95            TriggerError::BadSchedule { expr, why } => {
96                write!(f, "TRG-E006: schedule {expr:?} is unusable: {why}")
97            }
98            TriggerError::UnknownTarget { target } => {
99                write!(f, "TRG-E007: unknown render target '{target}'")
100            }
101            TriggerError::UnknownMember { trigger, member } => write!(
102                f,
103                "TRG-E008: composite trigger {trigger} references member '{member}' \
104                 which it does not declare"
105            ),
106            TriggerError::EgressRefused { trigger, host } => write!(
107                f,
108                "TRG-E009: connector for trigger {trigger} tried to reach '{host}', \
109                 which is outside its declared allowed_outbound_hosts"
110            ),
111            TriggerError::Storage { detail } => write!(f, "TRG-E010: store: {detail}"),
112            TriggerError::BlobContract { trigger, detail } => write!(
113                f,
114                "TRG-E011: connector blob contract violated for trigger {trigger}: {detail} — \
115                 the poll was refused whole and the cursor left unmoved"
116            ),
117            TriggerError::ConnectorCode { trigger, detail } => write!(
118                f,
119                "TRG-E012: trigger {trigger} cannot run its connector code: {detail}"
120            ),
121        }
122    }
123}
124
125impl std::error::Error for TriggerError {}
126
127pub type Result<T> = std::result::Result<T, TriggerError>;
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    fn all() -> Vec<TriggerError> {
134        vec![
135            TriggerError::Malformed { what: "x".into() },
136            TriggerError::UnresolvedWorkflow { hash: "h".into(), why: "w".into() },
137            TriggerError::NoConnector { trigger: "t".into(), connector: "c".into() },
138            TriggerError::ConnectorFailed { trigger: "t".into(), detail: "d".into() },
139            TriggerError::ClaimLost { trigger: "t".into() },
140            TriggerError::BadSchedule { expr: "e".into(), why: "w".into() },
141            TriggerError::UnknownTarget { target: "t".into() },
142            TriggerError::UnknownMember { trigger: "t".into(), member: "m".into() },
143            TriggerError::EgressRefused { trigger: "t".into(), host: "h".into() },
144            TriggerError::Storage { detail: "d".into() },
145            TriggerError::BlobContract { trigger: "t".into(), detail: "d".into() },
146        ]
147    }
148
149    #[test]
150    fn the_code_leads_the_display_string() {
151        // A reported code alone has to locate the variant, so it must be the
152        // first token — not buried mid-sentence where a log grep misses it.
153        for e in all() {
154            let s = e.to_string();
155            assert!(s.starts_with(e.code()), "{s} should start with {}", e.code());
156        }
157    }
158
159    #[test]
160    fn codes_are_unique_and_contiguous() {
161        let codes: Vec<&str> = all().iter().map(|e| e.code()).collect();
162        let mut sorted = codes.clone();
163        sorted.sort_unstable();
164        sorted.dedup();
165        assert_eq!(sorted.len(), codes.len(), "codes must be unique");
166        for (i, c) in codes.iter().enumerate() {
167            assert_eq!(*c, format!("TRG-E{:03}", i + 1), "codes are append-only and dense");
168        }
169    }
170}