areev_run_core/error.rs
1//! The `RUN` error domain (governed-agents §6.9). Codes are **append-only**
2//! from day one, same rule as every other domain (`ERROR_CODES.md`): never
3//! renumber, never reuse. The code is the leading token of `Display`, and
4//! `code()` returns it — both pinned by tests below and by the workspace
5//! uniqueness/format tests.
6
7use std::fmt;
8
9/// A runtime failure with a stable `RUN-Ennn` code.
10#[derive(Debug, Clone, PartialEq)]
11pub enum RunError {
12 /// RUN-E001 — the run drained with no terminal node reaching success;
13 /// names the decision node whose conditions all evaluated false.
14 Stalled { node: String },
15 /// RUN-E002 — an unbounded cycle: a strongly-connected component with no
16 /// `max_cycles` edge.
17 UnboundedCycle { nodes: Vec<String> },
18 /// RUN-E003 — node(s) unreachable from the entry.
19 Unreachable { nodes: Vec<String> },
20 /// RUN-E004 — a binding or pinned hash does not resolve (or a journal
21 /// ref is absent without an erasure receipt).
22 UnresolvedRef { what: String },
23 /// RUN-E005 — a condition failed validation at load.
24 InvalidCondition { edge: String, why: String },
25 /// RUN-E006 — an abstract node with no tool-calling LLM configured.
26 NoToolLlm { node: String },
27 /// RUN-E007 — a budget axis is exhausted.
28 BudgetExhausted { axis: BudgetAxis },
29 /// RUN-E008 — a dangling intent on resume with `on_dangling = fail`.
30 DanglingIntent { key: String },
31 /// RUN-E009 — replay divergence: a re-derived decision, counter, or
32 /// checkpoint disagrees with the journal.
33 ReplayDivergence { what: String },
34 /// RUN-E010 — checkpoint does not match the run manifest.
35 ManifestMismatch { why: String },
36 /// RUN-E011 — a response names no pending Client ask.
37 UnknownAsk { tool_call_id: String },
38 /// RUN-E012 — a run operation the session's rights do not cover.
39 Unauthorized { what: String },
40 /// RUN-E013 — the run was canceled.
41 Canceled { by: String, reason: String },
42 /// RUN-E014 — a reducer violated its laws (debug sampling).
43 ReducerLawViolation { key: String },
44 /// RUN-E015 — a checkpoint exceeds the size cap after spill.
45 CheckpointTooLarge { bytes: usize, cap: usize },
46 /// RUN-E016 — the journal is tainted: forked supersession tips, or a
47 /// resume of a copied file without `--fork` / the owner nonce.
48 Tainted { why: String },
49 /// RUN-E017 — a retention floor or legal hold refuses a destruction.
50 RetentionRefused { why: String },
51 /// RUN-E018 — code execution refused; names the failed gate condition.
52 CodeExecRefused { condition: String },
53 /// RUN-E019 — the workflow failed structural validation (V1/V2 shape
54 /// errors that are not one of the specific codes above).
55 InvalidPlan { why: String },
56 /// RUN-E020 — a store operation failed under the runtime (wraps the
57 /// underlying `DOMAIN-Ennn` message, which stays in the detail).
58 Storage { detail: String },
59 /// RUN-E021 — the run lease was lost mid-flight: another driver took this
60 /// run over while we were advancing it.
61 ///
62 /// Before this existed, two drivers advancing one run last-write-wins in
63 /// the journal — silently. The doc comment on `Tainted` claimed forked tips
64 /// were detected; they were not. Refusing here fails safe *by prevention*
65 /// rather than by after-the-fact detection.
66 ///
67 /// Note the code: the design proposal called this RUN-E017, but that is
68 /// `RetentionRefused` and codes are append-only.
69 LeaseLost { run_id: String },
70 /// RUN-E022 — a host command's outbound call was refused: a destination
71 /// outside the run's allowlist, a method its grant does not permit, a
72 /// credential it may not spend, a request header it may not set —
73 /// undeclared, or one the broker owns (#105) — or a CAS blob read without
74 /// the `blob` capability (#106). One code for "the broker said no",
75 /// whichever of its doors was knocked on.
76 ///
77 /// The trigger evaluator reports the same condition as `TRG-E009`, the way
78 /// a storage failure is `TRG-E010` there and `RUN-E020` here. One
79 /// condition, one code per subsystem that reports it.
80 EgressRefused { destination: String },
81 /// RUN-E023 — an anonymization policy covers this run's namespace but
82 /// cannot replay, so the model boundary would make `verify` diverge.
83 ///
84 /// Session scope numbers tokens by order of appearance, so the same input
85 /// yields different placeholders on a replay. Only value-derived (HMAC)
86 /// tokens are stable across handles and processes — that is D8 — and the
87 /// key for them is HKDF-derived from the page key, which is why this also
88 /// means an encrypted memory.
89 AnonReplayUnsafe { ns: String, scope: String },
90 /// RUN-E024 — an abstract node's transcript is over `llm_context_tokens`
91 /// and there is nothing left to fold: the node's input and the kept tail
92 /// alone exceed the ceiling, or a fold already ran and did not help.
93 ///
94 /// Deliberately NOT a retry and not a second fold. A fold that cannot find
95 /// a foldable middle will never find one by trying again, and looping on it
96 /// would spend the effect budget summarizing summaries. The honest answer
97 /// is a failed node naming the ceiling it could not fit under — raise it,
98 /// bound the tool results (`llm_tool_result_chars`), or split the node.
99 /// `ceiling` is `None` when no ceiling was configured and the PROVIDER
100 /// refused the transcript — the limit was learned from the rejection
101 /// rather than predicted.
102 ContextExceeded { node: String, tokens: u64, ceiling: Option<u64> },
103 /// RUN-E025 — the model configuration this run STARTED under is not the
104 /// one on offer now (#287).
105 ///
106 /// Raised before the lease is taken and before any grain is written: a
107 /// resume that would have finished a parked run on a different model,
108 /// provider or region simply does not begin. `areev run fork` is the
109 /// sanctioned way through — a fork writes a new manifest carrying the new
110 /// pin and records what it forked from, so the change is a recorded
111 /// decision rather than an undocumented drift.
112 ModelMismatch { pinned: String, offered: String },
113 /// RUN-E026 — this run was written by a scheduler generation whose
114 /// decisions differ from this build's (#288).
115 ///
116 /// A patch upgrade must not strand parked approval runs, so the VERSION
117 /// string is not what is compared — only the scheduler epoch, which moves
118 /// exactly when a change makes an existing journal replay differently.
119 EngineMismatch { written_by: String, epoch: u32, this_epoch: u32 },
120 /// RUN-E027 — starting this run would exceed a concurrency cap (#296).
121 ///
122 /// RETRYABLE by nature: the cap is a backstop beneath the host's own
123 /// dispatcher, not a verdict on the run. Nothing is written under the run
124 /// id, so the same id starts once a slot frees, and a trigger firing
125 /// refused here leaves its item unconsumed (the #129 rule for RUN-E018).
126 ConcurrencyLimit { scope: String, limit: u32 },
127 /// RUN-E028 — a Tool declares a brokered-transfer ceiling
128 /// (`runtime_limits.max_response_bytes` / `max_request_bytes`) that is
129 /// malformed, zero, or above the 32 MiB hard maximum (#339).
130 ///
131 /// Refused at run start — before any upstream I/O — and never clamped: a
132 /// tool that declared more than the host will carry would otherwise fail
133 /// on the first document between the two sizes, with nothing pointing at
134 /// its own declaration.
135 TransferLimitInvalid { node: String, detail: String },
136 /// RUN-E029 — a pause was asked of a run that cannot be paused (#344): it
137 /// already finished (completed, failed, stalled, canceled, or out of
138 /// budget), or a cancel is pending against it — cancel wins over pause.
139 ///
140 /// Pausing an already-paused (or already-pause-requested) run is NOT this
141 /// error; it is idempotent and answers with the standing request.
142 NotPausable { run_id: String, why: String },
143 /// RUN-E030 — a plan binds a decision node (a Tool Definition whose
144 /// `executor_uri` is `areev://decide`) and this host has no decision
145 /// backend installed.
146 ///
147 /// Refused at run start (the V7 freeze) and on resume — before the lease
148 /// and before any grain is written — naming the node, so a run never
149 /// starts down a path whose branch point cannot be answered here. The
150 /// scheduler's OPTIONAL asks (the decision-guided fold, the tool-offer
151 /// narrowing) never raise this: without a backend they are not asked.
152 NoDecider { node: String },
153 /// RUN-E031 — a declared `op: query` read (#370) cannot be served: at
154 /// run start the saved query is not defined or its parameters do not fit
155 /// the declaration; at dispatch the body pinned at run start is missing
156 /// from the manifest or no longer matches the hash recorded beside it.
157 ///
158 /// The run reads the body PINNED at start, never the live `qry:` row, so
159 /// a body superseded or dropped mid-run does not change a running run;
160 /// this code is what a pin that cannot be honoured fails with instead of
161 /// quietly reading something else.
162 SavedQueryUnavailable { node: String, name: String, why: String },
163}
164
165/// The budget axes (§6.7). `Supersteps` is the global backstop too.
166#[derive(
167 Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize,
168)]
169pub enum BudgetAxis {
170 Supersteps,
171 Tokens,
172 Usd,
173 WallMs,
174 Storage,
175 /// Settled effects across the WHOLE run (#295). `--max-effects` bounds
176 /// one node ATTEMPT; a run's total was bounded only by
177 /// nodes × retries × cycles × fan-out × that number.
178 Effects,
179 /// Host tool calls across the whole run (#295) — plan-bound or
180 /// model-issued. Client asks and memory reads do not count: an ask is a
181 /// person, and a memory read is not an outbound call.
182 ToolCalls,
183}
184
185impl BudgetAxis {
186 pub fn as_str(&self) -> &'static str {
187 match self {
188 Self::Supersteps => "supersteps",
189 Self::Tokens => "tokens",
190 Self::Usd => "usd",
191 Self::WallMs => "wall_ms",
192 Self::Storage => "storage",
193 Self::Effects => "effects",
194 Self::ToolCalls => "tool_calls",
195 }
196 }
197}
198
199impl RunError {
200 /// The stable `RUN-Ennn` code — a permanent debugging handle.
201 pub fn code(&self) -> &'static str {
202 match self {
203 Self::Stalled { .. } => "RUN-E001",
204 Self::UnboundedCycle { .. } => "RUN-E002",
205 Self::Unreachable { .. } => "RUN-E003",
206 Self::UnresolvedRef { .. } => "RUN-E004",
207 Self::InvalidCondition { .. } => "RUN-E005",
208 Self::NoToolLlm { .. } => "RUN-E006",
209 Self::BudgetExhausted { .. } => "RUN-E007",
210 Self::DanglingIntent { .. } => "RUN-E008",
211 Self::ReplayDivergence { .. } => "RUN-E009",
212 Self::ManifestMismatch { .. } => "RUN-E010",
213 Self::UnknownAsk { .. } => "RUN-E011",
214 Self::Unauthorized { .. } => "RUN-E012",
215 Self::Canceled { .. } => "RUN-E013",
216 Self::ReducerLawViolation { .. } => "RUN-E014",
217 Self::CheckpointTooLarge { .. } => "RUN-E015",
218 Self::Tainted { .. } => "RUN-E016",
219 Self::RetentionRefused { .. } => "RUN-E017",
220 Self::CodeExecRefused { .. } => "RUN-E018",
221 Self::InvalidPlan { .. } => "RUN-E019",
222 Self::Storage { .. } => "RUN-E020",
223 Self::LeaseLost { .. } => "RUN-E021",
224 Self::EgressRefused { .. } => "RUN-E022",
225 Self::AnonReplayUnsafe { .. } => "RUN-E023",
226 Self::ContextExceeded { .. } => "RUN-E024",
227 Self::ModelMismatch { .. } => "RUN-E025",
228 Self::EngineMismatch { .. } => "RUN-E026",
229 Self::ConcurrencyLimit { .. } => "RUN-E027",
230 Self::TransferLimitInvalid { .. } => "RUN-E028",
231 Self::NotPausable { .. } => "RUN-E029",
232 Self::NoDecider { .. } => "RUN-E030",
233 Self::SavedQueryUnavailable { .. } => "RUN-E031",
234 }
235 }
236}
237
238impl fmt::Display for RunError {
239 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
240 let code = self.code();
241 match self {
242 Self::Stalled { node } => write!(
243 f,
244 "{code}: run stalled — no terminal node succeeded; every \
245 condition on decision node '{node}' evaluated false (add an \
246 unconditional default edge, or check the state it tests)"
247 ),
248 Self::UnboundedCycle { nodes } => write!(
249 f,
250 "{code}: unbounded cycle through [{}] — every cycle must carry \
251 at least one edge with max_cycles",
252 nodes.join(", ")
253 ),
254 Self::Unreachable { nodes } => write!(
255 f,
256 "{code}: node(s) unreachable from the entry: [{}]",
257 nodes.join(", ")
258 ),
259 Self::UnresolvedRef { what } => {
260 write!(f, "{code}: unresolved reference: {what}")
261 }
262 Self::InvalidCondition { edge, why } => {
263 write!(f, "{code}: condition on edge {edge} is invalid: {why}")
264 }
265 Self::NoToolLlm { node } => write!(
266 f,
267 "{code}: node '{node}' is abstract (no binding, no matching \
268 tool definition) and no tool-calling LLM is configured"
269 ),
270 Self::BudgetExhausted { axis } => write!(
271 f,
272 "{code}: budget exhausted on axis '{}' — the run is \
273 checkpointed and resumable after the manifest budget is \
274 raised",
275 axis.as_str()
276 ),
277 Self::DanglingIntent { key } => write!(
278 f,
279 "{code}: dangling intent {key} (crash between dispatch and \
280 result) and on_dangling = fail — re-dispatch requires an \
281 idempotency-honoring executor"
282 ),
283 Self::ReplayDivergence { what } => write!(
284 f,
285 "{code}: replay divergence — {what}. The scheduler, a \
286 condition evaluator, or a reducer is impure, or the journal \
287 was modified"
288 ),
289 Self::ManifestMismatch { why } => {
290 write!(f, "{code}: checkpoint does not match the run manifest: {why}")
291 }
292 Self::UnknownAsk { tool_call_id } => write!(
293 f,
294 "{code}: no pending Client ask with tool_call_id \
295 '{tool_call_id}' (already settled, expired, or never asked)"
296 ),
297 Self::Unauthorized { what } => {
298 write!(f, "{code}: not authorized: {what}")
299 }
300 Self::Canceled { by, reason } => {
301 write!(f, "{code}: run canceled by {by}: {reason}")
302 }
303 Self::ReducerLawViolation { key } => write!(
304 f,
305 "{code}: reducer for key '{key}' violated its laws (must be \
306 pure and batching-invariant) — caught by debug sampling"
307 ),
308 Self::CheckpointTooLarge { bytes, cap } => write!(
309 f,
310 "{code}: checkpoint is {bytes} bytes after spill (cap {cap})"
311 ),
312 Self::Tainted { why } => write!(f, "{code}: journal tainted: {why}"),
313 Self::RetentionRefused { why } => {
314 write!(f, "{code}: destruction refused: {why}")
315 }
316 Self::CodeExecRefused { condition } => write!(
317 f,
318 "{code}: code execution refused — failed condition: {condition}"
319 ),
320 Self::InvalidPlan { why } => write!(f, "{code}: invalid workflow: {why}"),
321 Self::Storage { detail } => write!(f, "{code}: store failure: {detail}"),
322 Self::EgressRefused { destination } => write!(
323 f,
324 "{code}: outbound call to '{destination}' was refused by this run's egress policy"
325 ),
326 Self::AnonReplayUnsafe { ns, scope } => write!(
327 f,
328 "{code}: namespace '{ns}' has an anonymization policy with scope \
329 '{scope}', which cannot be replayed — an abstract node would \
330 pseudonymize differently on every run and verify would diverge. \
331 Use scope \"memory\" (value-derived tokens, which needs an \
332 encrypted memory), or run this plan without abstract nodes"
333 ),
334 Self::LeaseLost { run_id } => write!(
335 f,
336 "{code}: lease on run '{run_id}' was lost — another driver took it over \
337 while this one was advancing it; this driver's writes are refused"
338 ),
339 Self::ContextExceeded { node, tokens, ceiling } => write!(
340 f,
341 "{code}: node '{node}' has a transcript the model reported at \
342 {tokens} prompt tokens against {}, and nothing is left to \
343 fold — the node's input and the kept tail alone exceed it. \
344 Raise --llm-context-tokens, bound oversized tool results \
345 (--llm-tool-result-chars), or split the node",
346 match ceiling {
347 Some(c) => format!("a ceiling of {c}"),
348 None => "the provider's own limit, which it refused the \
349 request against"
350 .to_string(),
351 }
352 ),
353 Self::ModelMismatch { pinned, offered } => write!(
354 f,
355 "{code}: this run started under {pinned} and is being resumed \
356 under {offered} — a parked run must finish on the \
357 configuration it started on. Resume under the pinned model, \
358 or `areev run fork` to continue under the new one (the fork \
359 records both)"
360 ),
361 Self::EngineMismatch { written_by, epoch, this_epoch } => write!(
362 f,
363 "{code}: this run was written by engine {written_by} at \
364 scheduler epoch {epoch}, and this build is at epoch \
365 {this_epoch} — the two schedulers do not make the same \
366 decisions, so resuming would produce a journal neither can \
367 verify whole. Run it on an engine at epoch {epoch}, or \
368 `areev run fork` to continue under this one"
369 ),
370 Self::ConcurrencyLimit { scope, limit } => write!(
371 f,
372 "{code}: {limit} concurrent runs already executing for \
373 {scope} — nothing was written, so this run id is still free. \
374 Retry once a slot frees, or raise the cap"
375 ),
376 Self::TransferLimitInvalid { node, detail } => write!(
377 f,
378 "{code}: node '{node}' declares an invalid transfer ceiling: \
379 {detail}. Declare 1..=33554432 bytes (32 MiB), or omit it for \
380 the 1048576-byte (1 MiB) default"
381 ),
382 Self::NotPausable { run_id, why } => write!(
383 f,
384 "{code}: run '{run_id}' cannot be paused: {why}. A pause only \
385 applies to a run that can still advance"
386 ),
387 Self::NoDecider { node } => write!(
388 f,
389 "{code}: node '{node}' is a decision node (executor_uri \
390 \"areev://decide\") and this host has no decision backend — \
391 configure one (`--decide <chain>` / `--decide-cmd`, \
392 $AREEV_DECIDE, or `Runner::with_decider`), or bind the node to \
393 an ordinary tool"
394 ),
395 Self::SavedQueryUnavailable { node, name, why } => write!(
396 f,
397 "{code}: read '{node}' names saved query \"{name}\", which cannot be \
398 served: {why}"
399 ),
400 }
401 }
402}
403
404impl std::error::Error for RunError {}
405
406pub type Result<T> = std::result::Result<T, RunError>;
407
408#[cfg(test)]
409mod tests {
410 use super::*;
411
412 fn representatives() -> Vec<RunError> {
413 vec![
414 RunError::Stalled { node: "n".into() },
415 RunError::UnboundedCycle { nodes: vec!["a".into()] },
416 RunError::Unreachable { nodes: vec!["b".into()] },
417 RunError::UnresolvedRef { what: "x".into() },
418 RunError::InvalidCondition { edge: "a->b".into(), why: "w".into() },
419 RunError::NoToolLlm { node: "n".into() },
420 RunError::BudgetExhausted { axis: BudgetAxis::Usd },
421 RunError::DanglingIntent { key: "k".into() },
422 RunError::ReplayDivergence { what: "w".into() },
423 RunError::ManifestMismatch { why: "w".into() },
424 RunError::UnknownAsk { tool_call_id: "t".into() },
425 RunError::Unauthorized { what: "w".into() },
426 RunError::Canceled { by: "u".into(), reason: "r".into() },
427 RunError::ReducerLawViolation { key: "k".into() },
428 RunError::CheckpointTooLarge { bytes: 2, cap: 1 },
429 RunError::Tainted { why: "w".into() },
430 RunError::RetentionRefused { why: "w".into() },
431 RunError::CodeExecRefused { condition: "c".into() },
432 RunError::InvalidPlan { why: "w".into() },
433 RunError::Storage { detail: "d".into() },
434 RunError::LeaseLost { run_id: "r".into() },
435 RunError::EgressRefused { destination: "https://x/".into() },
436 RunError::AnonReplayUnsafe { ns: "n".into(), scope: "session".into() },
437 RunError::ContextExceeded { node: "n".into(), tokens: 9, ceiling: Some(8) },
438 RunError::TransferLimitInvalid { node: "n".into(), detail: "d".into() },
439 RunError::NotPausable { run_id: "r".into(), why: "w".into() },
440 RunError::NoDecider { node: "n".into() },
441 RunError::SavedQueryUnavailable {
442 node: "n".into(),
443 name: "q".into(),
444 why: "w".into(),
445 },
446 ]
447 }
448
449 /// The workspace rule: the code is the LEADING token of Display, unique
450 /// per variant, format `RUN-Ennn`.
451 #[test]
452 fn codes_lead_display_and_are_unique() {
453 let mut seen = std::collections::BTreeSet::new();
454 for e in representatives() {
455 let code = e.code();
456 assert!(
457 code.starts_with("RUN-E") && code.len() == 8,
458 "bad code format: {code}"
459 );
460 assert!(
461 e.to_string().starts_with(&format!("{code}: ")),
462 "code must lead Display: {e}"
463 );
464 assert!(seen.insert(code), "duplicate code {code}");
465 }
466 assert_eq!(seen.len(), 28);
467 }
468}