cairn_mod/error.rs
1//! Crate-wide error type.
2
3use thiserror::Error;
4
5/// Top-level error type for the `cairn-mod` library.
6#[derive(Error, Debug)]
7#[non_exhaustive]
8pub enum Error {
9 /// Configuration loading or validation failed.
10 #[error("config error: {0}")]
11 Config(String),
12
13 /// I/O failure (filesystem, stdio, etc.).
14 #[error(transparent)]
15 Io(#[from] std::io::Error),
16
17 /// Figment config-source failure (TOML parse, env-var extraction).
18 #[error(transparent)]
19 Figment(Box<figment::Error>),
20
21 /// SQLx query or connection failure.
22 #[error(transparent)]
23 Sqlx(#[from] sqlx::Error),
24
25 /// Embedded migration runner failed.
26 #[error(transparent)]
27 Migrate(#[from] sqlx::migrate::MigrateError),
28
29 /// DAG-CBOR encode / decode failure from `proto-blue-lex-cbor`.
30 #[error(transparent)]
31 Cbor(#[from] proto_blue_lex_cbor::CborError),
32
33 /// Cryptographic primitive failure (signing, hashing, key parse).
34 #[error(transparent)]
35 Crypto(#[from] proto_blue_crypto::CryptoError),
36
37 /// Invariant violation specific to label signing (e.g. missing `sig`,
38 /// wrong algorithm in the multibase key, sig-length mismatch).
39 #[error("signing: {0}")]
40 Signing(String),
41
42 /// Another Cairn instance holds a fresh lease on the same SQLite file
43 /// (§F5 single-instance invariant). Refusing to start — the other
44 /// instance must shut down or its lease must age past 60s.
45 #[error(
46 "server_instance_lease held by another instance (instance_id={instance_id}, last heartbeat {age_secs}s ago)"
47 )]
48 LeaseHeld {
49 /// Instance identifier of the rival process holding the lease.
50 instance_id: String,
51 /// Seconds since that instance last heartbeated.
52 age_secs: u64,
53 },
54
55 /// Negating a label that does not currently apply. Either the tuple
56 /// `(src, uri, val)` has no apply events, or the most recent event for
57 /// the tuple is already a negation (§F6).
58 #[error("no applied label for ({src}, {uri}, {val}) — nothing to negate")]
59 LabelNotFound {
60 /// Labeler DID (Cairn's own service DID for emissions).
61 src: String,
62 /// Subject AT-URI or DID.
63 uri: String,
64 /// Label value.
65 val: String,
66 },
67
68 /// `resolveReport` (§F12) targeted a report id that doesn't exist.
69 /// Surface via the handler as `ReportNotFound` (declared lexicon error).
70 #[error("report not found: id={id}")]
71 ReportNotFound {
72 /// Report primary key that didn't resolve.
73 id: i64,
74 },
75
76 /// `resolveReport` (§F12) targeted a report that is already resolved.
77 /// Surface via the handler as `InvalidRequest` with a generic message
78 /// (no timestamps or resolver DID per the anti-leak principle).
79 #[error("report already resolved: id={id}")]
80 ReportAlreadyResolved {
81 /// Report primary key that was already resolved.
82 id: i64,
83 },
84
85 /// `recordAction` (§F20, #51) carried a reason identifier that
86 /// isn't declared in the operator's `[moderation_reasons]`
87 /// vocabulary. Handler surfaces this as `InvalidReason` (400).
88 #[error("reason {0:?} is not declared in [moderation_reasons]")]
89 ReasonNotFound(String),
90
91 /// `recordAction` (§F20, #51) was called with `type=temp_suspension`
92 /// but no `duration`. Handler surfaces this as `DurationRequired`
93 /// (400). Required because `expires_at` on the row is computed
94 /// from `effective_at + duration`; without one the row would
95 /// silently behave as `indef_suspension`.
96 #[error("recordAction: temp_suspension requires duration")]
97 DurationRequiredForTempSuspension,
98
99 /// `recordAction` (§F20, #51) was called with a non-temp_suspension
100 /// `type` plus a `duration`. Handler surfaces this as
101 /// `DurationNotAllowed` (400). Strict reject (rather than silent
102 /// drop) so an operator who meant `temp_suspension` doesn't end
103 /// up with an `indef_suspension` whose duration string is lost.
104 #[error("recordAction: duration is only valid for temp_suspension")]
105 DurationOnlyForTempSuspension,
106
107 /// `revokeAction` (§F20, #51) targeted a `subject_actions.id` that
108 /// doesn't exist. Handler surfaces this as `ActionNotFound` (404).
109 #[error("subject_actions row not found: id={0}")]
110 ActionNotFound(i64),
111
112 /// `revokeAction` (§F20, #51) targeted a row whose `revoked_at`
113 /// is already non-NULL. Handler surfaces this as
114 /// `ActionAlreadyRevoked` (400). The schema trigger forbids
115 /// re-revocation; the recorder catches it before the UPDATE for
116 /// a clean lexicon error.
117 #[error("subject_actions row already revoked: id={0}")]
118 ActionAlreadyRevoked(i64),
119
120 /// `recordAction` (§F20, #51) was called with an `at://`-URI
121 /// `subject` whose repo DID does not match a separate
122 /// subject_did context. Reserved for future use; v1.4 derives
123 /// subject_did from the URI directly so this variant is
124 /// currently unreachable in practice but kept for the lexicon
125 /// error surface.
126 #[error("recordAction: subject_uri repo DID does not match subject_did")]
127 SubjectUriMismatch,
128
129 /// `get_or_recompute_strike_count` (§F20, #55) was called with
130 /// a `subject_did` that has no `subject_strike_state` row —
131 /// i.e., no actions have ever been recorded against this
132 /// subject. Same semantic as the `SubjectNotFound` lexicon
133 /// error from the read endpoints; the typed variant here lets
134 /// v1.5+ consumers branch on "never-actioned" cleanly (e.g.,
135 /// `unwrap_or(0)` for optimistic "never-actioned == 0 strikes"
136 /// semantics, or surface as a domain-specific error otherwise).
137 #[error("no strike-state cache row for subject {0:?}")]
138 StrikeCacheMissing(String),
139
140 /// `confirmPendingAction` (§F22, #74) targeted a
141 /// `pending_policy_actions.id` that doesn't exist. Handler
142 /// surfaces this as `PendingActionNotFound` (404).
143 #[error("pending_policy_actions row not found: id={0}")]
144 PendingActionNotFound(i64),
145
146 /// `confirmPendingAction` / `dismissPendingAction` (§F22, #74)
147 /// targeted a row whose `resolution` is already non-NULL.
148 /// Handler surfaces this as `PendingAlreadyResolved` (400).
149 /// The schema trigger forbids a second NULL → non-NULL
150 /// transition; the recorder catches it before the UPDATE for a
151 /// clean lexicon error.
152 #[error("pending_policy_actions row already resolved: id={0}")]
153 PendingAlreadyResolved(i64),
154
155 /// `confirmPendingAction` (§F22, #74) was called for a subject
156 /// that already carries an unrevoked Takedown — the action
157 /// would be redundant and conceptually undefined under the
158 /// terminal-action posture. v1.6's auto-dismissal-on-takedown
159 /// (#76) should resolve all pendings on a takedown, so this
160 /// state is unreachable in steady state; the variant exists to
161 /// close the race between a takedown landing and an in-flight
162 /// confirm.
163 #[error("subject {0:?} is already taken down; confirm is not permitted")]
164 SubjectTakendown(String),
165}
166
167impl From<figment::Error> for Error {
168 fn from(e: figment::Error) -> Self {
169 Self::Figment(Box::new(e))
170 }
171}
172
173/// Crate-wide `Result` alias using [`enum@Error`] as the error type.
174pub type Result<T> = std::result::Result<T, Error>;