Skip to main content

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    /// A postgres open was pointed at a memory whose on-disk layout does not
150    /// match the DSN (#353): the DSN names a metadata schema (`?meta_schema=`)
151    /// but the memory schema already carries its engine metadata in-schema
152    /// (the single-schema layout), or the DSN names none and the memory
153    /// schema holds memory tables without a `meta` table — which only a
154    /// paired-layout memory looks like. Either open would silently split the
155    /// engine's bookkeeping (counters, the namespace registry, legal holds,
156    /// retention policies, saved queries) across two places, so it is refused
157    /// before any DDL. A layout change is an explicit migration, never an
158    /// implicit one.
159    LayoutMismatch(String),
160    SupersessionConflict(Hash),
161    /// A supersession-chain walk (`Areev::supersession_chain`) did not reach
162    /// a root within the bounded hop count. Real edit histories terminate in
163    /// a handful of hops; exceeding the bound means the `supersedes` links
164    /// are corrupt (e.g. cyclic) rather than merely long, so the walk fails
165    /// loudly instead of looping the process forever.
166    SupersessionChainTooDeep(Hash),
167    /// An approximate-nearest-neighbour index was asked for on a backend that
168    /// has none. Vector recall is an exact scan on the embedded engine — there
169    /// is no ANN structure to build there, and silently doing nothing would
170    /// leave a caller believing its corpus was indexed when its latency is
171    /// still linear. Only the Postgres tier (pgvector HNSW) answers this.
172    AnnIndexUnsupported(String),
173    CryptoError(String),
174    /// An attestation signed by a trusted author key does not verify over
175    /// the hash it names — the grain or the attestation was altered after
176    /// signing. Raised at bundle import (the whole bundle is refused) and by
177    /// `verify --attestations`.
178    AttestationInvalid(String),
179    /// The import policy is `require` and a grain arrived with no valid
180    /// attestation from a trusted author.
181    AttestationRequired(String),
182    /// A signing seed, public key, or trusted-authors document is malformed.
183    SigningKeyInvalid(String),
184    AccumulateRetryExhausted,
185    AccumulateInternal(String),
186    AccumulateBackpressureRejected,
187    Internal(String),
188    /// A verb the session's grants don't cover (`authz::AuthzSet::check`).
189    AuthzDenied(String),
190    /// A principal name no credential authenticates.
191    AuthzUnknownPrincipal(String),
192    /// The credential map failed to load or validate (fail closed).
193    AuthzConfigInvalid(String),
194    /// A presented bearer token matched no credential. Deliberately carries
195    /// no payload: a refused secret must never reach a log line.
196    AuthzTokenUnrecognized,
197}
198
199impl AreevError {
200    /// Stable machine-readable error code in `DOMAIN-Ennn` form (see the
201    /// repo-root `ERROR_CODES.md` registry). Every `Display` string begins
202    /// with this code, so a user who reports the leading token points us at
203    /// the exact variant and subsystem. **Codes are append-only debugging
204    /// handles — never renumber or reuse an existing one.**
205    pub fn code(&self) -> &'static str {
206        match self {
207            Self::NotFound(_) => "MEM-E001",
208            Self::SupersessionConflict(_) => "MEM-E002",
209            Self::SupersessionChainTooDeep(_) => "STO-E006",
210            Self::AnnIndexUnsupported(_) => "STO-E007",
211            Self::ToolRenderUnsupported(_) => "MEM-E110",
212            Self::Format(_) => "FMT-E001",
213            Self::Serialization(_) => "FMT-E002",
214            Self::Validation(_) => "VAL-E001",
215            Self::Storage(_) => "STO-E001",
216            Self::StoreBusy(_) => "STO-E002",
217            Self::TlsUnavailable(_) => "STO-E003",
218            Self::ReadOnly(_) => "STO-E004",
219            Self::ReadOnlyOpenFailed(_) => "STO-E005",
220            Self::SchemaNotProvisioned(_) => "STO-E008",
221            Self::LegalHold(_) => "STO-E009",
222            Self::AsyncContext(_) => "STO-E010",
223            Self::LayoutMismatch(_) => "STO-E011",
224            Self::CryptoError(_) => "CRY-E001",
225            Self::AttestationInvalid(_) => "CRY-E002",
226            Self::AttestationRequired(_) => "CRY-E003",
227            Self::SigningKeyInvalid(_) => "CRY-E004",
228            // These originate in CAL ACCUMULATE semantics and bubble up
229            // through the store, so they keep their CAL-domain codes.
230            Self::AccumulateRetryExhausted => "CAL-E083",
231            Self::AccumulateInternal(_) => "CAL-E084",
232            Self::AccumulateBackpressureRejected => "CAL-E085",
233            Self::Internal(_) => "SYS-E001",
234            Self::AuthzDenied(_) => "AUT-E001",
235            Self::AuthzUnknownPrincipal(_) => "AUT-E002",
236            Self::AuthzConfigInvalid(_) => "AUT-E003",
237            Self::AuthzTokenUnrecognized => "AUT-E004",
238        }
239    }
240}
241
242impl std::fmt::Display for AreevError {
243    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
244        // Invariant: every arm's message starts with `self.code()` — pinned by
245        // `code_prefixes_every_display` in the tests below.
246        match self {
247            Self::NotFound(h) => write!(f, "MEM-E001: grain not found: {h}"),
248            Self::SupersessionConflict(h) => write!(f, "MEM-E002: already superseded: {h}"),
249            Self::SupersessionChainTooDeep(h) => write!(
250                f,
251                "STO-E006: supersession chain from {h} did not terminate within the bounded walk — the supersedes links may be cyclic or corrupt"
252            ),
253            Self::AnnIndexUnsupported(m) => write!(
254                f,
255                "STO-E007: no approximate vector index on this backend: {m}"
256            ),
257            Self::ToolRenderUnsupported(m) => write!(f, "MEM-E110: tool render unsupported: {m}"),
258            Self::Format(m) => write!(f, "FMT-E001: format error: {m}"),
259            Self::Serialization(m) => write!(f, "FMT-E002: serialization error: {m}"),
260            Self::Validation(m) => write!(f, "VAL-E001: validation error: {m}"),
261            Self::Storage(m) => write!(f, "STO-E001: storage error: {m}"),
262            Self::StoreBusy(m) => write!(f, "STO-E002: store busy: {m}"),
263            Self::TlsUnavailable(m) => write!(f, "STO-E003: {m}"),
264            Self::ReadOnly(m) => write!(f, "STO-E004: refusing write on a read-only memory: {m}"),
265            Self::ReadOnlyOpenFailed(m) => write!(f, "STO-E005: {m}"),
266            Self::SchemaNotProvisioned(m) => write!(f, "STO-E008: {m}"),
267            Self::LegalHold(m) => write!(f, "STO-E009: {m}"),
268            Self::AsyncContext(m) => write!(f, "STO-E010: {m}"),
269            Self::LayoutMismatch(m) => write!(f, "STO-E011: {m}"),
270            Self::CryptoError(m) => write!(f, "CRY-E001: crypto error: {m}"),
271            Self::AttestationInvalid(m) => write!(f, "CRY-E002: attestation invalid: {m}"),
272            Self::AttestationRequired(m) => write!(f, "CRY-E003: attestation required: {m}"),
273            Self::SigningKeyInvalid(m) => write!(f, "CRY-E004: signing key invalid: {m}"),
274            Self::AccumulateRetryExhausted => write!(f, "CAL-E083: ACCUMULATE retry budget exhausted"),
275            Self::AccumulateInternal(m) => write!(f, "CAL-E084: ACCUMULATE internal failure: {m}"),
276            Self::AccumulateBackpressureRejected => write!(f, "CAL-E085: ACCUMULATE backpressure: inflight cap exceeded"),
277            Self::Internal(m) => write!(f, "SYS-E001: internal error: {m}"),
278            Self::AuthzDenied(m) => write!(f, "AUT-E001: authorization denied: {m}"),
279            Self::AuthzUnknownPrincipal(p) => write!(f, "AUT-E002: unknown principal: {p}"),
280            Self::AuthzConfigInvalid(m) => write!(f, "AUT-E003: {m}"),
281            Self::AuthzTokenUnrecognized => write!(f, "AUT-E004: token not recognized"),
282        }
283    }
284}
285
286impl std::error::Error for AreevError {}
287
288pub type Result<T> = std::result::Result<T, AreevError>;
289
290#[cfg(test)]
291mod error_code_tests {
292    use super::*;
293
294    /// One representative instance of every variant — extend when adding one.
295    fn all_variants() -> Vec<AreevError> {
296        let h = Hash::from_bytes(&[0u8; 32]);
297        vec![
298            AreevError::NotFound(h),
299            AreevError::SupersessionConflict(h),
300            AreevError::SupersessionChainTooDeep(h),
301            AreevError::AnnIndexUnsupported("x".into()),
302            AreevError::AttestationInvalid("x".into()),
303            AreevError::AttestationRequired("x".into()),
304            AreevError::SigningKeyInvalid("x".into()),
305            AreevError::ToolRenderUnsupported("x".into()),
306            AreevError::Format("x".into()),
307            AreevError::Serialization("x".into()),
308            AreevError::Validation("x".into()),
309            AreevError::Storage("x".into()),
310            AreevError::StoreBusy("x".into()),
311            AreevError::TlsUnavailable("x".into()),
312            AreevError::ReadOnly("x".into()),
313            AreevError::ReadOnlyOpenFailed("x".into()),
314            AreevError::SchemaNotProvisioned("x".into()),
315            AreevError::LegalHold("x".into()),
316            AreevError::AsyncContext("x".into()),
317            AreevError::LayoutMismatch("x".into()),
318            AreevError::CryptoError("x".into()),
319            AreevError::AccumulateRetryExhausted,
320            AreevError::AccumulateInternal("x".into()),
321            AreevError::AccumulateBackpressureRejected,
322            AreevError::Internal("x".into()),
323            AreevError::AuthzDenied("x".into()),
324            AreevError::AuthzUnknownPrincipal("x".into()),
325            AreevError::AuthzConfigInvalid("x".into()),
326            AreevError::AuthzTokenUnrecognized,
327        ]
328    }
329
330    /// One representative of every `DecideError` variant (the `DEC` domain,
331    /// `crate::decide`) — extend when adding one.
332    fn all_decide_variants() -> Vec<crate::decide::DecideError> {
333        use crate::decide::DecideError;
334        vec![
335            DecideError::NotConfigured("x".into()),
336            DecideError::Provider { provider: "p".into(), status: Some(500), message: "x".into(), retryable: true },
337            DecideError::Malformed("x".into()),
338            DecideError::Deadline("x".into()),
339            DecideError::ChainExhausted(vec![("p".into(), DecideError::Deadline("x".into()))]),
340            DecideError::InvalidQuestion("x".into()),
341            DecideError::RateLimited { provider: "p".into(), retry_after_secs: Some(3) },
342            DecideError::EgressRefused("x".into()),
343        ]
344    }
345
346    /// `(code, Display)` for every coded variant core owns.
347    fn all_codes() -> Vec<(&'static str, String)> {
348        all_variants()
349            .into_iter()
350            .map(|e| (e.code(), e.to_string()))
351            .chain(all_decide_variants().into_iter().map(|e| (e.code(), e.to_string())))
352            .collect()
353    }
354
355    /// The reported code must be the leading token of the message, so a user
356    /// pasting either gives us the same handle.
357    #[test]
358    fn code_prefixes_every_display() {
359        for (code, msg) in all_codes() {
360            assert!(
361                msg.starts_with(&format!("{code}: ")),
362                "`{msg}` must start with its code `{code}`"
363            );
364        }
365    }
366
367    /// No two variants share a code — a reported code must name exactly one
368    /// cause. Covers `AreevError` and `DecideError` together, so a new `DEC`
369    /// code cannot collide with a core one either.
370    #[test]
371    fn codes_are_unique() {
372        let mut seen = std::collections::BTreeMap::new();
373        for (code, msg) in all_codes() {
374            if let Some(prev) = seen.insert(code, msg.clone()) {
375                panic!("{code} is used twice: `{prev}` and `{msg}`");
376            }
377        }
378    }
379
380    /// Every code matches the `DOMAIN-Ennn` standard (see ERROR_CODES.md):
381    /// a 3-letter uppercase domain, `-E`, then digits.
382    #[test]
383    fn codes_follow_the_repo_standard() {
384        for (c, _) in all_codes() {
385            let (domain, num) = c.split_once("-E").unwrap_or_else(|| panic!("bad code: {c}"));
386            assert_eq!(domain.len(), 3, "{c}: domain must be 3 letters");
387            assert!(domain.chars().all(|ch| ch.is_ascii_uppercase()), "{c}: domain uppercase");
388            assert!(!num.is_empty() && num.chars().all(|ch| ch.is_ascii_digit()), "{c}: numeric suffix");
389        }
390    }
391}