cairn_mod/cli/error.rs
1//! Unified error taxonomy for the CLI handlers + the exit-code
2//! contract (criterion G / §F9's "specific exit codes per error
3//! class").
4//!
5//! Exit codes are a durable part of the CLI contract — scripts that
6//! branch on them will depend on the numbers below. Add new classes
7//! by appending new codes; renumbering is breaking.
8
9use std::net::SocketAddr;
10
11use thiserror::Error;
12
13use super::pds::PdsError;
14use super::session::SessionError;
15use crate::signing_key::KeyLoadError;
16
17/// Exit code taxonomy. Matches criterion G from the #16 plan.
18pub mod code {
19 /// Clean exit.
20 pub const SUCCESS: i32 = 0;
21 /// Usage / argument parsing. clap exits with 2 directly — this
22 /// constant exists so hand-written handlers agree.
23 pub const USAGE: i32 = 2;
24 /// Session file absent, permissions wrong, version mismatch.
25 pub const SESSION: i32 = 3;
26 /// Transport-level failure: unreachable host, TLS error, timeout.
27 pub const NETWORK: i32 = 4;
28 /// PDS rejected credentials OR Cairn returned 401.
29 pub const AUTH: i32 = 5;
30 /// Cairn returned 403.
31 pub const FORBIDDEN: i32 = 6;
32 /// Cairn returned some other 4xx.
33 pub const SERVER_4XX: i32 = 7;
34 /// Cairn returned 5xx.
35 pub const SERVER_5XX: i32 = 8;
36 /// Internal error (malformed response body, unexpected state).
37 pub const INTERNAL: i32 = 10;
38 /// `cairn serve` could not acquire the single-instance lease —
39 /// another Cairn process holds it with a fresh heartbeat (§F5).
40 /// Distinct from INTERNAL so systemd-restart-loop logic can
41 /// branch ("another instance is running, don't restart") vs.
42 /// genuine internal failure.
43 pub const LEASE_CONFLICT: i32 = 11;
44 /// Service record verify (§F1, #8): local `[labeler]` config
45 /// differs from the published `app.bsky.labeler.service` record
46 /// on the PDS. Operator action required: reconcile via
47 /// `cairn publish-service-record`.
48 pub const SERVICE_RECORD_DRIFT: i32 = 12;
49 /// Service record verify (§F1, #8): no record published yet.
50 /// Operator action required: run
51 /// `cairn publish-service-record` for the first time.
52 pub const SERVICE_RECORD_ABSENT: i32 = 13;
53 /// Service record verify (§F1, #8): PDS unreachable during
54 /// the verify fetch. Transient infra issue; orchestrators
55 /// should retry rather than treating as a config drift.
56 pub const SERVICE_RECORD_UNREACHABLE: i32 = 14;
57 /// `cairn audit verify` (#41) detected a hash-chain divergence:
58 /// the chain walked successfully up to row N-1, then row N's
59 /// recomputed hash did not match its stored row_hash. Distinct
60 /// exit code so monitoring/CI can branch on integrity-failure
61 /// (chain broken — operational alert) vs. generic CLI error.
62 pub const AUDIT_DIVERGENCE: i32 = 15;
63}
64
65/// Error sources the CLI dispatcher knows about. The human-readable
66/// `Display` output is what lands on stderr; [`CliError::exit_code`]
67/// maps variants onto the [`code`] table.
68#[derive(Debug, Error)]
69pub enum CliError {
70 /// Session-file failure (absent, permissions, ownership,
71 /// version mismatch, malformed JSON).
72 #[error("{0}")]
73 Session(#[from] SessionError),
74 /// PDS interaction failure (network, unauthorized, swap-race,
75 /// unexpected status, malformed response).
76 #[error("{0}")]
77 Pds(#[from] PdsError),
78 /// Authed command ran with no session file present.
79 #[error("not logged in; run `cairn login --cairn-server ... --pds ... --handle ...`")]
80 NotLoggedIn,
81 /// Transport-level failure contacting a Cairn server URL
82 /// (timeout, DNS, TLS, connection refused).
83 #[error("network error contacting {url}: {source}")]
84 Http {
85 /// URL that failed.
86 url: String,
87 /// Underlying reqwest error.
88 #[source]
89 source: reqwest::Error,
90 },
91 /// Cairn returned a non-2xx HTTP status.
92 #[error("Cairn at {url} returned {status}: {body}")]
93 CairnStatus {
94 /// URL that returned the error.
95 url: String,
96 /// HTTP status code.
97 status: u16,
98 /// Response body for operator-side diagnostics.
99 body: String,
100 },
101 /// Cairn returned a 2xx response but the body didn't parse as
102 /// the expected shape.
103 #[error("could not parse Cairn response from {url}: {source}")]
104 MalformedResponse {
105 /// URL whose response failed to parse.
106 url: String,
107 /// Underlying serde_json error.
108 #[source]
109 source: serde_json::Error,
110 },
111 /// Argument-shape failure — malformed CLI inputs, config
112 /// validation failures.
113 #[error("{0}")]
114 Config(String),
115 /// Signing key file couldn't be loaded (§5.1 invariant failure,
116 /// bad hex, wrong length, env-override attempt). Full cause lives
117 /// on the nested `KeyLoadError`; `Display` never includes the key
118 /// material because the key bytes are never decoded when an error
119 /// path runs, and `KeyLoadError::Display` only formats paths +
120 /// metadata.
121 #[error("signing key load failed: {0}")]
122 KeyLoad(#[from] KeyLoadError),
123 /// SQLite migrations failed at startup. String-only rather than
124 /// wrapping sqlx's error type to avoid leaking schema internals
125 /// into the error taxonomy.
126 #[error("migrations failed: {0}")]
127 MigrationFailed(String),
128 /// Another Cairn instance holds the single-instance lease (§F5).
129 /// Distinct variant so operators + systemd can branch on the
130 /// dedicated [`code::LEASE_CONFLICT`] exit code.
131 #[error("another Cairn instance holds the lease (instance_id={instance_id}, age={age_secs}s)")]
132 LeaseConflict {
133 /// Instance identifier held by the rival process.
134 instance_id: String,
135 /// Seconds since the rival last heartbeated.
136 age_secs: u64,
137 },
138 /// `TcpListener::bind` failed — port in use, permission denied,
139 /// malformed addr, etc.
140 #[error("could not bind {addr}: {source}")]
141 BindFailed {
142 /// Address we attempted to bind.
143 addr: SocketAddr,
144 /// Underlying I/O error.
145 #[source]
146 source: std::io::Error,
147 },
148 /// Anything in the startup sequence that doesn't fit the buckets
149 /// above (writer spawn, auth-context init). Rare at runtime;
150 /// surfaces as INTERNAL.
151 #[error("startup failure: {0}")]
152 Startup(String),
153 /// `cairn serve` startup verify (§F1, #8): the published
154 /// `app.bsky.labeler.service` record on the operator's PDS
155 /// differs from what the local `[labeler]` config block would
156 /// render. Reconcile via `cairn publish-service-record`.
157 #[error(
158 "service record verification failed: local [labeler] config differs from the published record at {pds_url}/{service_did}.\n\nDifferences:\n{summary}\n\nThe local config and the published record must agree before `cairn serve` will start. To reconcile, review the differences above, then on the operator's host run:\n\n cairn publish-service-record --config <path>\n\nto update the PDS record."
159 )]
160 ServiceRecordDrift {
161 /// PDS URL the verify check fetched from.
162 pds_url: String,
163 /// Labeler service DID whose record was fetched.
164 service_did: String,
165 /// Per-field drift summary built by `serve::verify`. Not
166 /// raw JSON — a human-readable enumeration of the fields
167 /// that differ.
168 summary: String,
169 },
170 /// `cairn serve` startup verify (§F1, #8): no
171 /// `app.bsky.labeler.service` record exists on the operator's
172 /// PDS for the labeler's DID. The labeler hasn't published
173 /// its declaration yet.
174 #[error(
175 "service record verification failed: app.bsky.labeler.service record not found at {pds_url} for repo {service_did}.\n\nThis labeler has not yet published its declaration to the PDS. On the operator's host (where operator credentials are configured), run:\n\n cairn publish-service-record --config <path>\n\nthen restart `cairn serve`."
176 )]
177 ServiceRecordAbsent {
178 /// PDS URL the verify check fetched from.
179 pds_url: String,
180 /// Labeler service DID whose record was searched.
181 service_did: String,
182 },
183 /// `cairn serve` startup verify (§F1, #8): could not reach
184 /// the PDS to perform the verify fetch. Transient infra
185 /// issue, distinct from drift / absent — orchestrators should
186 /// retry.
187 #[error(
188 "service record verification failed: could not reach PDS at {pds_url} to fetch app.bsky.labeler.service.\n\nUnderlying error: {cause}\n\nThis is likely a transient infrastructure issue (PDS down, network flaky, rate limit). Verify the PDS is reachable then retry. If the issue persists, check `operator.pds_url` in your config and confirm the labeler DID's home PDS."
189 )]
190 ServiceRecordUnreachable {
191 /// PDS URL the verify check tried to fetch from.
192 pds_url: String,
193 /// Underlying error message (transport, status, etc.).
194 cause: String,
195 },
196 /// `cairn audit verify` (#41) detected a hash-chain divergence.
197 /// The walk recomputed `row_id`'s row_hash from its content +
198 /// the running prev_hash and got `expected_hash`; the row's
199 /// stored `actual_hash` did not match. Distinct exit code
200 /// ([`code::AUDIT_DIVERGENCE`]) so monitoring/CI can branch on
201 /// integrity-failure vs. generic CLI error.
202 ///
203 /// The dispatcher prints the verify outcome (human or JSON) to
204 /// stdout *before* returning this error, so operators see the
205 /// structured report on stdout and the same message on stderr
206 /// alongside the non-zero exit. Stdout-only consumers
207 /// (`cairn audit verify --json`) get the JSON line; the stderr
208 /// echo is redundant for them but harmless.
209 #[error(
210 "audit chain divergence at {table}:{row_id}: expected {expected_hash}, found {actual_hash} ({attested_rows_before_divergence} row(s) verified before divergence)"
211 )]
212 AuditDivergence {
213 /// SQL-table name of the divergent row — `"audit_log"` or
214 /// `"pds_admin_audit"`. Added in #88 alongside the unified
215 /// chain walker so operators can correlate
216 /// `(table, row_id)` to a specific row across both tables
217 /// in v1.7+ deployments.
218 table: &'static str,
219 /// Primary key of the divergent row, scoped to the table
220 /// named by `table`.
221 row_id: i64,
222 /// Hex-encoded SHA-256 the chain says this row's row_hash
223 /// should be (recomputed from the running prev_hash + the
224 /// row's stored content).
225 expected_hash: String,
226 /// Hex-encoded SHA-256 actually stored in the row's
227 /// row_hash column.
228 actual_hash: String,
229 /// Number of rows whose hashes verified before this one —
230 /// the truncation point operators reconcile from. Counts
231 /// across both tables in the unified chain.
232 attested_rows_before_divergence: i64,
233 },
234}
235
236impl CliError {
237 /// Exit code per criterion G. Kept close to the Display output
238 /// so script authors can correlate message prefix with code.
239 pub fn exit_code(&self) -> i32 {
240 match self {
241 CliError::Session(SessionError::Io(_)) => code::SESSION,
242 CliError::Session(_) => code::SESSION,
243 CliError::NotLoggedIn => code::SESSION,
244 CliError::Pds(PdsError::Network { .. }) => code::NETWORK,
245 CliError::Pds(PdsError::Unauthorized { .. }) => code::AUTH,
246 CliError::Pds(PdsError::InvalidUrl { .. }) => code::USAGE,
247 CliError::Pds(PdsError::MalformedResponse { .. }) => code::INTERNAL,
248 CliError::Pds(PdsError::SwapRace { .. }) => code::INTERNAL,
249 CliError::Pds(PdsError::UnexpectedStatus { status, .. }) => match *status {
250 401 => code::AUTH,
251 403 => code::FORBIDDEN,
252 400..=499 => code::SERVER_4XX,
253 500..=599 => code::SERVER_5XX,
254 _ => code::INTERNAL,
255 },
256 CliError::Http { .. } => code::NETWORK,
257 CliError::CairnStatus { status, .. } => match *status {
258 401 => code::AUTH,
259 403 => code::FORBIDDEN,
260 400..=499 => code::SERVER_4XX,
261 500..=599 => code::SERVER_5XX,
262 _ => code::INTERNAL,
263 },
264 CliError::MalformedResponse { .. } => code::INTERNAL,
265 CliError::Config(_) => code::USAGE,
266 CliError::KeyLoad(_) => code::INTERNAL,
267 CliError::MigrationFailed(_) => code::INTERNAL,
268 CliError::LeaseConflict { .. } => code::LEASE_CONFLICT,
269 CliError::BindFailed { .. } => code::NETWORK,
270 CliError::Startup(_) => code::INTERNAL,
271 CliError::ServiceRecordDrift { .. } => code::SERVICE_RECORD_DRIFT,
272 CliError::ServiceRecordAbsent { .. } => code::SERVICE_RECORD_ABSENT,
273 CliError::ServiceRecordUnreachable { .. } => code::SERVICE_RECORD_UNREACHABLE,
274 CliError::AuditDivergence { .. } => code::AUDIT_DIVERGENCE,
275 }
276 }
277}