salvor_server/error.rs
1//! [`ApiError`]: the one error type every handler returns, and the JSON
2//! envelope it serializes to.
3//!
4//! Every failure the control plane reports has the same shape on the wire, so
5//! a thin SDK can decode one thing:
6//!
7//! ```json
8//! { "error": { "code": "unknown_run", "message": "...", "details": { ... } } }
9//! ```
10//!
11//! `code` is a stable machine token (an SDK matches on it); `message` is a
12//! human sentence; `details` is present only when there is structured evidence
13//! to carry. The reconciliation refusal is the original case (the recorded write
14//! intent travels in `details.intent`, mirroring the CLI's report); the fork
15//! endpoint's `write_replay_hazard` carries the same kind of evidence in
16//! `details.writes` (the exact writes a fork would re-fire), which is the
17//! refuse-then-record differentiator on the wire.
18//!
19//! Each variant fixes its own HTTP status, so the status and the body's `code`
20//! never drift: a 404 always carries `unknown_run` or `unknown_agent`, a 409
21//! always carries a conflict or a reconciliation refusal, and so on.
22
23use axum::Json;
24use axum::http::StatusCode;
25use axum::response::{IntoResponse, Response};
26use serde_json::{Value, json};
27
28/// A control-plane error, with the HTTP status and machine code baked in.
29#[derive(Debug)]
30pub enum ApiError {
31 /// A request body was malformed, or a resume input failed validation
32 /// against the recorded schema. HTTP 400.
33 BadRequest(String),
34 /// The bearer token was missing or wrong. HTTP 401.
35 Unauthorized,
36 /// No run exists under the given id. HTTP 404.
37 UnknownRun(String),
38 /// No agent is registered under the given id. HTTP 404.
39 UnknownAgent(String),
40 /// A run already exists at the requested id. HTTP 409.
41 RunExists(String),
42 /// A verb was applied to a run in the wrong state (resuming a finished
43 /// run, resolving a run that has no dangling write). HTTP 409.
44 WrongState(String),
45 /// A client-driven append arrived with no drive token. HTTP 401. The drive
46 /// token is the per-run single-writer lease; every append must present it.
47 MissingDriveToken(String),
48 /// A client-driven append presented a drive token that is not the run's
49 /// current lease. HTTP 403. Only the run's current writer may drive it.
50 InvalidDriveToken(String),
51 /// A client-driven append carried an event kind this endpoint does not
52 /// accept (a model or tool event, which the model-step and tool-step
53 /// endpoints own). HTTP 422.
54 UnsupportedEventKind(String),
55 /// A client-driven append is not the legal next event for the run's log:
56 /// the re-folding append-guard rejected it, or byte-different bytes arrived
57 /// at an already-recorded position. HTTP 409.
58 Divergence(String),
59 /// A request body exceeded the size or count cap. HTTP 413.
60 PayloadTooLarge(String),
61 /// A server-performed model step was requested but no model executor is
62 /// wired on this server (the host injected none). HTTP 503. Recording no
63 /// completion, so the run stays drivable once an executor is present.
64 ModelExecutorUnavailable(String),
65 /// The provider call for a model step failed. HTTP 502. No completion is
66 /// recorded, so the write-ahead intent is left dangling (the legal crash
67 /// story) and the run stays drivable: a retry re-issues the call safely.
68 ModelExecution(String),
69 /// A tool-step named a tool the server's registry does not hold. HTTP 404.
70 /// Nothing is written for a tool the server cannot dispatch, so the step is
71 /// retriable once the tool is registered.
72 UnknownTool(String),
73 /// A server-performed tool step was requested but no tool registry is wired
74 /// on this server (the host injected none). HTTP 503. The mirror of
75 /// [`ModelExecutorUnavailable`](Self::ModelExecutorUnavailable): no intent
76 /// is written, so the run stays drivable once a registry is present.
77 ToolRegistryUnavailable(String),
78 /// The dispatch of a tool-step's tool failed. HTTP 502. No completion is
79 /// recorded, so the write-ahead intent is left dangling (the legal crash
80 /// story) and the run stays drivable-or-reconcilable per the tool's effect.
81 ToolExecution(String),
82 /// A client tried to record its own completion for a client-performed tool
83 /// call this server will not take its word for: the pending intent was
84 /// performed by the server, or the declaration says
85 /// `trust_completion = false`, or the declaration carries no `output_schema`
86 /// to check the report against. HTTP 403.
87 ///
88 /// Nothing is recorded, so the log still ends at the recorded intent. For a
89 /// `Write` that is already `needs_reconciliation` to the pure fold in
90 /// `salvor-replay`, and `POST /v1/client-runs/{id}/resolve` already exists
91 /// to settle it by hand: the refusal reuses machinery rather than inventing
92 /// a state. The message names that endpoint, because it is what the caller
93 /// does next.
94 ClientCompletionRefused(String),
95 /// A submitted graph document failed strict validation. HTTP 400. Carries
96 /// the complete, node/edge-precise error list as evidence, because a graph
97 /// is a control document validated all at once (collect-all, no
98 /// short-circuit), so an author sees every mistake in one response.
99 InvalidGraph {
100 /// The human sentence.
101 message: String,
102 /// The full list of structured validation errors, each naming the node
103 /// or edge at fault.
104 errors: Value,
105 },
106 /// A graph run parked at a `gate` was resumed with an approval that does
107 /// not satisfy the gate's declared `approval_schema`. HTTP 400. Carries the
108 /// gate's node id and the full violation list as evidence, in the same
109 /// collect-all spirit as [`InvalidGraph`](Self::InvalidGraph): an operator
110 /// filling an approval form should see every field that is wrong in one
111 /// response, not one per round trip.
112 ///
113 /// Refused synchronously, before the driver task is spawned, so the log is
114 /// untouched and the run stays parked at that gate.
115 ApprovalSchemaViolation {
116 /// The human sentence.
117 message: String,
118 /// The id of the gate node the run is parked at.
119 node: String,
120 /// Each violation as `{ path, message }`, in a stable order.
121 violations: Value,
122 },
123 /// No graph is stored under the given hash. HTTP 404.
124 UnknownGraph(String),
125 /// A graph-only endpoint (the per-run graph projection) was asked for a run
126 /// whose log is not a graph run (an ordinary agent run has no
127 /// `GraphRunStarted` head). HTTP 409.
128 NotAGraphRun(String),
129 /// A fork was requested from a node the origin never entered (it is not in
130 /// the graph, or the walk routed past it). A fork point must be a node
131 /// boundary the run reached. HTTP 409.
132 InvalidForkNode(String),
133 /// A fork was requested of an origin parked at a dangling write (status
134 /// `NeedsReconciliation`): the origin must be resolved first, since forking
135 /// past an unsettled write would carry that ambiguity into the child. HTTP
136 /// 409. Carries the origin's recorded write intent as evidence, mirroring
137 /// [`NeedsReconciliation`](Self::NeedsReconciliation).
138 OriginNeedsReconciliation {
139 /// The human sentence.
140 message: String,
141 /// The origin's recorded dangling write intent (the same shape a resume
142 /// reconciliation refusal carries).
143 intent: Value,
144 },
145 /// A fork would re-walk a segment containing recorded `Effect::Write` intents
146 /// the operator has not acknowledged. HTTP 409. Carries the exact writes that
147 /// would re-fire as evidence, mirroring
148 /// [`NeedsReconciliation`](Self::NeedsReconciliation)'s use of `details`: the
149 /// refuse-then-record differentiator in one response the operator can read
150 /// and then acknowledge.
151 WriteReplayHazard {
152 /// The human sentence.
153 message: String,
154 /// The unacknowledged writes the fork's re-walked segment would
155 /// re-execute, each `{ seq, tool, input, idempotency_key, recorded_at }`.
156 writes: Value,
157 },
158 /// A run needs human reconciliation and cannot be driven automatically.
159 /// Carries the recorded write intent as evidence. HTTP 409.
160 NeedsReconciliation {
161 /// The human sentence.
162 message: String,
163 /// The recorded intent (tool, input, effect, idempotency key, seq,
164 /// recorded time), so the caller sees exactly what to reconcile.
165 intent: Value,
166 },
167 /// An unexpected internal failure (a store read, an agent build). HTTP
168 /// 500. The message is safe to surface: it names the layer, not a secret.
169 Internal(String),
170}
171
172impl ApiError {
173 /// The HTTP status and stable machine `code` for this error.
174 fn status_and_code(&self) -> (StatusCode, &'static str) {
175 match self {
176 ApiError::BadRequest(_) => (StatusCode::BAD_REQUEST, "bad_request"),
177 ApiError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized"),
178 ApiError::UnknownRun(_) => (StatusCode::NOT_FOUND, "unknown_run"),
179 ApiError::UnknownAgent(_) => (StatusCode::NOT_FOUND, "unknown_agent"),
180 ApiError::RunExists(_) => (StatusCode::CONFLICT, "run_exists"),
181 ApiError::WrongState(_) => (StatusCode::CONFLICT, "wrong_state"),
182 ApiError::InvalidGraph { .. } => (StatusCode::BAD_REQUEST, "invalid_graph"),
183 ApiError::ApprovalSchemaViolation { .. } => {
184 (StatusCode::BAD_REQUEST, "approval_schema_violation")
185 }
186 ApiError::UnknownGraph(_) => (StatusCode::NOT_FOUND, "unknown_graph"),
187 ApiError::NotAGraphRun(_) => (StatusCode::CONFLICT, "not_a_graph_run"),
188 ApiError::InvalidForkNode(_) => (StatusCode::CONFLICT, "invalid_fork_node"),
189 ApiError::OriginNeedsReconciliation { .. } => {
190 (StatusCode::CONFLICT, "origin_needs_reconciliation")
191 }
192 ApiError::WriteReplayHazard { .. } => (StatusCode::CONFLICT, "write_replay_hazard"),
193 ApiError::NeedsReconciliation { .. } => (StatusCode::CONFLICT, "needs_reconciliation"),
194 ApiError::MissingDriveToken(_) => (StatusCode::UNAUTHORIZED, "missing_drive_token"),
195 ApiError::InvalidDriveToken(_) => (StatusCode::FORBIDDEN, "invalid_drive_token"),
196 ApiError::UnsupportedEventKind(_) => {
197 (StatusCode::UNPROCESSABLE_ENTITY, "unsupported_event_kind")
198 }
199 ApiError::Divergence(_) => (StatusCode::CONFLICT, "divergence"),
200 ApiError::PayloadTooLarge(_) => (StatusCode::PAYLOAD_TOO_LARGE, "payload_too_large"),
201 ApiError::ModelExecutorUnavailable(_) => (
202 StatusCode::SERVICE_UNAVAILABLE,
203 "model_executor_unavailable",
204 ),
205 ApiError::ModelExecution(_) => (StatusCode::BAD_GATEWAY, "model_execution"),
206 ApiError::UnknownTool(_) => (StatusCode::NOT_FOUND, "unknown_tool"),
207 ApiError::ToolRegistryUnavailable(_) => {
208 (StatusCode::SERVICE_UNAVAILABLE, "tool_registry_unavailable")
209 }
210 ApiError::ToolExecution(_) => (StatusCode::BAD_GATEWAY, "tool_execution"),
211 ApiError::ClientCompletionRefused(_) => {
212 (StatusCode::FORBIDDEN, "client_completion_refused")
213 }
214 ApiError::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "internal"),
215 }
216 }
217
218 /// The human sentence for this error.
219 fn message(&self) -> String {
220 match self {
221 ApiError::BadRequest(m)
222 | ApiError::UnknownRun(m)
223 | ApiError::UnknownAgent(m)
224 | ApiError::RunExists(m)
225 | ApiError::WrongState(m)
226 | ApiError::Internal(m)
227 | ApiError::MissingDriveToken(m)
228 | ApiError::InvalidDriveToken(m)
229 | ApiError::UnsupportedEventKind(m)
230 | ApiError::Divergence(m)
231 | ApiError::PayloadTooLarge(m)
232 | ApiError::ModelExecutorUnavailable(m)
233 | ApiError::ModelExecution(m)
234 | ApiError::UnknownTool(m)
235 | ApiError::ToolRegistryUnavailable(m)
236 | ApiError::ToolExecution(m)
237 | ApiError::ClientCompletionRefused(m)
238 | ApiError::UnknownGraph(m)
239 | ApiError::NotAGraphRun(m)
240 | ApiError::InvalidForkNode(m)
241 | ApiError::InvalidGraph { message: m, .. }
242 | ApiError::ApprovalSchemaViolation { message: m, .. }
243 | ApiError::OriginNeedsReconciliation { message: m, .. }
244 | ApiError::WriteReplayHazard { message: m, .. }
245 | ApiError::NeedsReconciliation { message: m, .. } => m.clone(),
246 ApiError::Unauthorized => "missing or invalid bearer token".to_owned(),
247 }
248 }
249}
250
251impl IntoResponse for ApiError {
252 fn into_response(self) -> Response {
253 let (status, code) = self.status_and_code();
254 let message = self.message();
255 let mut error = json!({ "code": code, "message": message });
256 match self {
257 ApiError::NeedsReconciliation { intent, .. }
258 | ApiError::OriginNeedsReconciliation { intent, .. } => {
259 error["details"] = json!({ "intent": intent });
260 }
261 ApiError::WriteReplayHazard { writes, .. } => {
262 error["details"] = json!({ "writes": writes });
263 }
264 ApiError::InvalidGraph { errors, .. } => {
265 error["details"] = json!({ "errors": errors });
266 }
267 ApiError::ApprovalSchemaViolation {
268 node, violations, ..
269 } => {
270 error["details"] = json!({ "node": node, "violations": violations });
271 }
272 _ => {}
273 }
274 (status, Json(json!({ "error": error }))).into_response()
275 }
276}