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