areev_core/error.rs
1use std::fmt;
2
3/// Content-addressed SHA-256 hash (32 bytes, displayed as lowercase hex).
4#[derive(Clone, Copy, PartialEq, Eq, Hash)]
5pub struct Hash([u8; 32]);
6
7impl Hash {
8 /// Create a hash from a fixed-size 32-byte array (compile-time safe).
9 pub fn from_bytes(bytes: &[u8; 32]) -> Self {
10 Hash(*bytes)
11 }
12
13 /// Create a hash from a variable-length byte slice (fallible).
14 pub fn try_from_bytes(bytes: &[u8]) -> Result<Self> {
15 if bytes.len() < 32 {
16 return Err(AreevError::Format(format!(
17 "hash requires 32 bytes, got {}",
18 bytes.len()
19 )));
20 }
21 let mut arr = [0u8; 32];
22 arr.copy_from_slice(&bytes[..32]);
23 Ok(Hash(arr))
24 }
25
26 pub fn from_hex(hex_str: &str) -> Result<Self> {
27 let bytes = hex::decode(hex_str)
28 .map_err(|e| AreevError::Format(format!("invalid hex hash: {}", e)))?;
29 if bytes.len() != 32 {
30 return Err(AreevError::Format(format!(
31 "hash must be 32 bytes, got {}",
32 bytes.len()
33 )));
34 }
35 Ok(Self::from_bytes(&bytes.try_into().unwrap()))
36 }
37
38 pub fn as_bytes(&self) -> &[u8; 32] {
39 &self.0
40 }
41
42 pub fn to_hex(&self) -> String {
43 hex::encode(self.0)
44 }
45}
46
47impl fmt::Debug for Hash {
48 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49 write!(f, "Hash({})", &self.to_hex()[..16])
50 }
51}
52
53impl fmt::Display for Hash {
54 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
55 write!(f, "{}", self.to_hex())
56 }
57}
58
59impl serde::Serialize for Hash {
60 fn serialize<S: serde::Serializer>(
61 &self,
62 serializer: S,
63 ) -> std::result::Result<S::Ok, S::Error> {
64 serializer.serialize_str(&self.to_hex())
65 }
66}
67
68impl<'de> serde::Deserialize<'de> for Hash {
69 fn deserialize<D: serde::Deserializer<'de>>(
70 deserializer: D,
71 ) -> std::result::Result<Self, D::Error> {
72 let s = String::deserialize(deserializer)?;
73 Hash::from_hex(&s).map_err(serde::de::Error::custom)
74 }
75}
76
77/// All errors in areev-core.
78#[derive(Debug)]
79pub enum AreevError {
80 NotFound(Hash),
81 Format(String),
82 Validation(String),
83 Serialization(String),
84 ToolRenderUnsupported(String),
85 Storage(String),
86 /// Another writer holds this memory (single-writer-per-memory is
87 /// enforced, not advisory, on backends that can arbitrate it).
88 StoreBusy(String),
89 /// The connection to a backing store asks for transport encryption this
90 /// build cannot provide. Its own code because the alternative — reporting
91 /// a generic validation failure — reads as a typo in the DSN, when what
92 /// actually happened is that a *refusal to downgrade to plaintext* saved
93 /// the operator from an unencrypted connection they did not ask for.
94 TlsUnavailable(String),
95 /// A write was attempted through a handle opened with `read_only: true`
96 /// (`AreevOptions::read_only`). Refused at the store layer on BOTH
97 /// backends — on postgres this is what stands between a least-privilege
98 /// SELECT-only role and a raw `42501 permission denied`; on the embedded
99 /// backend there is no privilege system to fail against, so the store
100 /// enforces the same contract itself, which is what lets one conformance
101 /// case cover both.
102 ReadOnly(String),
103 /// A read-only open could not verify the schema/tables it expected to
104 /// find (postgres only — the embedded backend bootstraps its own file
105 /// regardless of `read_only`). Distinct from [`Storage`](Self::Storage)
106 /// because the fix differs: "schema absent" needs someone to create and
107 /// migrate it; "schema present but not initialized" needs the owning
108 /// role to open it read-write once to finish bootstrap. A read-only role
109 /// can do neither itself — that is the whole point of the least-privilege
110 /// grant — so the message says which one it is rather than surfacing the
111 /// raw permission-denied Postgres gives for `CREATE SCHEMA`/DDL.
112 ReadOnlyOpenFailed(String),
113 /// A postgres open found the schema absent or stamped at an older schema
114 /// version, and the DSN said `provision=never` — so no advisory lock and
115 /// no DDL were attempted, not even `CREATE SCHEMA`. Distinct from
116 /// [`ReadOnlyOpenFailed`](Self::ReadOnlyOpenFailed): that one is a
117 /// read-only handle discovering it has nothing to read; this is a
118 /// read-WRITE handle that was told never to bootstrap on the request path,
119 /// which is how a deployment guarantees its runtime role holds no `CREATE`
120 /// and its schema changes go through a migration step. Like its sibling,
121 /// the message names WHICH of the two operator actions is needed —
122 /// create the memory, or migrate it forward — because they are different
123 /// jobs.
124 SchemaNotProvisioned(String),
125 /// A destruction was refused because the namespace it names is under a
126 /// legal hold (#278). Its own code rather than [`Validation`](Self::Validation)
127 /// because a host has to be able to record "deferred by hold" — a
128 /// records-retention obligation, reportable and expected — without
129 /// parsing a message, and to distinguish it from a malformed request.
130 ///
131 /// Carries the namespace, the hold's owner and its stated reason, so the
132 /// refusal is itself the evidence a controller needs when answering an
133 /// erasure request on a retention ground.
134 LegalHold(String),
135 /// A BLOCKING open was attempted from inside an async runtime (#322).
136 ///
137 /// [`Areev`](../../areev_store/struct.Areev.html) drives its own
138 /// current-thread Tokio runtime and `block_on`s it, and Tokio refuses to
139 /// start a runtime from a runtime worker — so the open used to panic from
140 /// inside Tokio, several frames below anything the caller wrote, saying
141 /// "Cannot start a runtime from within a runtime" and naming no Areev API
142 /// at all.
143 ///
144 /// A coded error instead: the message names `AsyncAreev` and
145 /// `AsyncFacade`, which are the two supported answers. Raised only on a
146 /// runtime WORKER — an open on the blocking pool (where `AsyncAreev` and
147 /// `AsyncFacade` put theirs) is legal and unaffected.
148 AsyncContext(String),
149 SupersessionConflict(Hash),
150 /// A supersession-chain walk (`Areev::supersession_chain`) did not reach
151 /// a root within the bounded hop count. Real edit histories terminate in
152 /// a handful of hops; exceeding the bound means the `supersedes` links
153 /// are corrupt (e.g. cyclic) rather than merely long, so the walk fails
154 /// loudly instead of looping the process forever.
155 SupersessionChainTooDeep(Hash),
156 /// An approximate-nearest-neighbour index was asked for on a backend that
157 /// has none. Vector recall is an exact scan on the embedded engine — there
158 /// is no ANN structure to build there, and silently doing nothing would
159 /// leave a caller believing its corpus was indexed when its latency is
160 /// still linear. Only the Postgres tier (pgvector HNSW) answers this.
161 AnnIndexUnsupported(String),
162 CryptoError(String),
163 /// An attestation signed by a trusted author key does not verify over
164 /// the hash it names — the grain or the attestation was altered after
165 /// signing. Raised at bundle import (the whole bundle is refused) and by
166 /// `verify --attestations`.
167 AttestationInvalid(String),
168 /// The import policy is `require` and a grain arrived with no valid
169 /// attestation from a trusted author.
170 AttestationRequired(String),
171 /// A signing seed, public key, or trusted-authors document is malformed.
172 SigningKeyInvalid(String),
173 AccumulateRetryExhausted,
174 AccumulateInternal(String),
175 AccumulateBackpressureRejected,
176 Internal(String),
177 /// A verb the session's grants don't cover (`authz::AuthzSet::check`).
178 AuthzDenied(String),
179 /// A principal name no credential authenticates.
180 AuthzUnknownPrincipal(String),
181 /// The credential map failed to load or validate (fail closed).
182 AuthzConfigInvalid(String),
183 /// A presented bearer token matched no credential. Deliberately carries
184 /// no payload: a refused secret must never reach a log line.
185 AuthzTokenUnrecognized,
186}
187
188impl AreevError {
189 /// Stable machine-readable error code in `DOMAIN-Ennn` form (see the
190 /// repo-root `ERROR_CODES.md` registry). Every `Display` string begins
191 /// with this code, so a user who reports the leading token points us at
192 /// the exact variant and subsystem. **Codes are append-only debugging
193 /// handles — never renumber or reuse an existing one.**
194 pub fn code(&self) -> &'static str {
195 match self {
196 Self::NotFound(_) => "MEM-E001",
197 Self::SupersessionConflict(_) => "MEM-E002",
198 Self::SupersessionChainTooDeep(_) => "STO-E006",
199 Self::AnnIndexUnsupported(_) => "STO-E007",
200 Self::ToolRenderUnsupported(_) => "MEM-E110",
201 Self::Format(_) => "FMT-E001",
202 Self::Serialization(_) => "FMT-E002",
203 Self::Validation(_) => "VAL-E001",
204 Self::Storage(_) => "STO-E001",
205 Self::StoreBusy(_) => "STO-E002",
206 Self::TlsUnavailable(_) => "STO-E003",
207 Self::ReadOnly(_) => "STO-E004",
208 Self::ReadOnlyOpenFailed(_) => "STO-E005",
209 Self::SchemaNotProvisioned(_) => "STO-E008",
210 Self::LegalHold(_) => "STO-E009",
211 Self::AsyncContext(_) => "STO-E010",
212 Self::CryptoError(_) => "CRY-E001",
213 Self::AttestationInvalid(_) => "CRY-E002",
214 Self::AttestationRequired(_) => "CRY-E003",
215 Self::SigningKeyInvalid(_) => "CRY-E004",
216 // These originate in CAL ACCUMULATE semantics and bubble up
217 // through the store, so they keep their CAL-domain codes.
218 Self::AccumulateRetryExhausted => "CAL-E083",
219 Self::AccumulateInternal(_) => "CAL-E084",
220 Self::AccumulateBackpressureRejected => "CAL-E085",
221 Self::Internal(_) => "SYS-E001",
222 Self::AuthzDenied(_) => "AUT-E001",
223 Self::AuthzUnknownPrincipal(_) => "AUT-E002",
224 Self::AuthzConfigInvalid(_) => "AUT-E003",
225 Self::AuthzTokenUnrecognized => "AUT-E004",
226 }
227 }
228}
229
230impl std::fmt::Display for AreevError {
231 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
232 // Invariant: every arm's message starts with `self.code()` — pinned by
233 // `code_prefixes_every_display` in the tests below.
234 match self {
235 Self::NotFound(h) => write!(f, "MEM-E001: grain not found: {h}"),
236 Self::SupersessionConflict(h) => write!(f, "MEM-E002: already superseded: {h}"),
237 Self::SupersessionChainTooDeep(h) => write!(
238 f,
239 "STO-E006: supersession chain from {h} did not terminate within the bounded walk — the supersedes links may be cyclic or corrupt"
240 ),
241 Self::AnnIndexUnsupported(m) => write!(
242 f,
243 "STO-E007: no approximate vector index on this backend: {m}"
244 ),
245 Self::ToolRenderUnsupported(m) => write!(f, "MEM-E110: tool render unsupported: {m}"),
246 Self::Format(m) => write!(f, "FMT-E001: format error: {m}"),
247 Self::Serialization(m) => write!(f, "FMT-E002: serialization error: {m}"),
248 Self::Validation(m) => write!(f, "VAL-E001: validation error: {m}"),
249 Self::Storage(m) => write!(f, "STO-E001: storage error: {m}"),
250 Self::StoreBusy(m) => write!(f, "STO-E002: store busy: {m}"),
251 Self::TlsUnavailable(m) => write!(f, "STO-E003: {m}"),
252 Self::ReadOnly(m) => write!(f, "STO-E004: refusing write on a read-only memory: {m}"),
253 Self::ReadOnlyOpenFailed(m) => write!(f, "STO-E005: {m}"),
254 Self::SchemaNotProvisioned(m) => write!(f, "STO-E008: {m}"),
255 Self::LegalHold(m) => write!(f, "STO-E009: {m}"),
256 Self::AsyncContext(m) => write!(f, "STO-E010: {m}"),
257 Self::CryptoError(m) => write!(f, "CRY-E001: crypto error: {m}"),
258 Self::AttestationInvalid(m) => write!(f, "CRY-E002: attestation invalid: {m}"),
259 Self::AttestationRequired(m) => write!(f, "CRY-E003: attestation required: {m}"),
260 Self::SigningKeyInvalid(m) => write!(f, "CRY-E004: signing key invalid: {m}"),
261 Self::AccumulateRetryExhausted => write!(f, "CAL-E083: ACCUMULATE retry budget exhausted"),
262 Self::AccumulateInternal(m) => write!(f, "CAL-E084: ACCUMULATE internal failure: {m}"),
263 Self::AccumulateBackpressureRejected => write!(f, "CAL-E085: ACCUMULATE backpressure: inflight cap exceeded"),
264 Self::Internal(m) => write!(f, "SYS-E001: internal error: {m}"),
265 Self::AuthzDenied(m) => write!(f, "AUT-E001: authorization denied: {m}"),
266 Self::AuthzUnknownPrincipal(p) => write!(f, "AUT-E002: unknown principal: {p}"),
267 Self::AuthzConfigInvalid(m) => write!(f, "AUT-E003: {m}"),
268 Self::AuthzTokenUnrecognized => write!(f, "AUT-E004: token not recognized"),
269 }
270 }
271}
272
273impl std::error::Error for AreevError {}
274
275pub type Result<T> = std::result::Result<T, AreevError>;
276
277#[cfg(test)]
278mod error_code_tests {
279 use super::*;
280
281 /// One representative instance of every variant — extend when adding one.
282 fn all_variants() -> Vec<AreevError> {
283 let h = Hash::from_bytes(&[0u8; 32]);
284 vec![
285 AreevError::NotFound(h),
286 AreevError::SupersessionConflict(h),
287 AreevError::SupersessionChainTooDeep(h),
288 AreevError::AnnIndexUnsupported("x".into()),
289 AreevError::AttestationInvalid("x".into()),
290 AreevError::AttestationRequired("x".into()),
291 AreevError::SigningKeyInvalid("x".into()),
292 AreevError::ToolRenderUnsupported("x".into()),
293 AreevError::Format("x".into()),
294 AreevError::Serialization("x".into()),
295 AreevError::Validation("x".into()),
296 AreevError::Storage("x".into()),
297 AreevError::StoreBusy("x".into()),
298 AreevError::TlsUnavailable("x".into()),
299 AreevError::ReadOnly("x".into()),
300 AreevError::ReadOnlyOpenFailed("x".into()),
301 AreevError::SchemaNotProvisioned("x".into()),
302 AreevError::LegalHold("x".into()),
303 AreevError::CryptoError("x".into()),
304 AreevError::AccumulateRetryExhausted,
305 AreevError::AccumulateInternal("x".into()),
306 AreevError::AccumulateBackpressureRejected,
307 AreevError::Internal("x".into()),
308 AreevError::AuthzDenied("x".into()),
309 AreevError::AuthzUnknownPrincipal("x".into()),
310 AreevError::AuthzConfigInvalid("x".into()),
311 AreevError::AuthzTokenUnrecognized,
312 ]
313 }
314
315 /// The reported code must be the leading token of the message, so a user
316 /// pasting either gives us the same handle.
317 #[test]
318 fn code_prefixes_every_display() {
319 for e in all_variants() {
320 let msg = e.to_string();
321 let code = e.code();
322 assert!(
323 msg.starts_with(&format!("{code}: ")),
324 "`{msg}` must start with its code `{code}`"
325 );
326 }
327 }
328
329 /// Every code matches the `DOMAIN-Ennn` standard (see ERROR_CODES.md):
330 /// a 3-letter uppercase domain, `-E`, then digits.
331 #[test]
332 fn codes_follow_the_repo_standard() {
333 for e in all_variants() {
334 let c = e.code();
335 let (domain, num) = c.split_once("-E").unwrap_or_else(|| panic!("bad code: {c}"));
336 assert_eq!(domain.len(), 3, "{c}: domain must be 3 letters");
337 assert!(domain.chars().all(|ch| ch.is_ascii_uppercase()), "{c}: domain uppercase");
338 assert!(!num.is_empty() && num.chars().all(|ch| ch.is_ascii_digit()), "{c}: numeric suffix");
339 }
340 }
341}