macrame/error.rs
1use thiserror::Error;
2
3/// The two intervals of a [`DbError::OverlappingInterval`], boxed out of the
4/// error enum (D-075).
5///
6/// Both are reported because neither alone identifies the conflict: the caller
7/// knows what they asserted and not what it collided with, and a message naming
8/// only the stored interval reads as though the assertion were the innocent one.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct Overlap {
11 pub source_id: String,
12 pub target_id: String,
13 pub edge_type: String,
14 /// The interval the caller asserted.
15 pub valid_from: String,
16 pub valid_to: String,
17 /// The interval already stored that it collides with.
18 pub existing_from: String,
19 pub existing_to: String,
20}
21
22/// Central error type for the Macrame bitemporal ledger database.
23#[derive(Debug, Error)]
24pub enum DbError {
25 #[error("engine: {0}")]
26 Engine(#[from] libsql::Error),
27
28 #[error("migration to v{to} failed: {reason}")]
29 Migration { to: u32, reason: String },
30
31 #[error("invalid edge type {0} (must match [A-Z0-9]+)")]
32 InvalidEdgeType(String),
33
34 // NOTE: the spec (§7) names these fields `source` / `target`. `source` is a
35 // reserved field name for thiserror (it is inferred as the error source and
36 // requires `std::error::Error`), so the schema column names are used instead.
37 #[error(
38 "{source_id} -> {target_id} ({edge_type}) already has an open interval; retire it first"
39 )]
40 SingleOpenViolation {
41 source_id: String,
42 target_id: String,
43 edge_type: String,
44 },
45
46 #[error("node {0} not found")]
47 NotFound(String),
48
49 #[error("embedding dim {got}, expected {expected} for model {model}")]
50 DimMismatch {
51 got: usize,
52 expected: usize,
53 model: String,
54 },
55
56 /// A model name is spliced into DDL and queries as a table identifier, and
57 /// identifiers cannot be bound as parameters. Validating the name is what
58 /// makes that splice safe, so an invalid one is refused rather than escaped.
59 #[error("invalid embedding model name {0:?}: expected [a-z][a-z0-9_]* up to 48 characters")]
60 InvalidModelName(String),
61
62 #[error("embedding model {model} is not registered (no {table} table)")]
63 ModelNotRegistered { model: String, table: String },
64
65 #[error("subgraph exceeds budget ({n} > {budget})")]
66 SubgraphTooLarge { n: usize, budget: usize },
67
68 /// Dijkstra and A* settle a node permanently the first time they pop it,
69 /// which is only sound when no later edge can reduce the distance — that is,
70 /// when weights are non-negative. `links.weight` is a bare `REAL NOT NULL`
71 /// with no CHECK, so the guarantee has to be established at load time. The
72 /// alternative is a shortest-path result that is quietly just a path.
73 #[error("edge {source_id} -> {target_id} has weight {weight}, which shortest-path analytics cannot use")]
74 NegativeEdgeWeight {
75 source_id: String,
76 target_id: String,
77 weight: f64,
78 },
79
80 #[error("replay corrupt at seq {seq}: {reason}")]
81 ReplayCorrupt { seq: i64, reason: String },
82
83 /// A snapshot this build cannot read. Distinct from [`Self::ReplayCorrupt`]
84 /// on purpose: corruption is a fault to report, an incompatible snapshot is
85 /// the ordinary consequence of an upgrade, and the correct response is to
86 /// discard the file and fold from the log instead (D-043).
87 #[error("snapshot {path} is not readable by this build: {reason}")]
88 SnapshotIncompatible { path: String, reason: String },
89
90 #[error("payload v{got} unsupported (max {max})")]
91 PayloadVersion { got: u8, max: u8 },
92
93 #[error("physical delete blocked outside archive session ({table})")]
94 ArchiveViolation { table: String },
95
96 /// A traversal asked about the past without saying which text it wanted
97 /// (T3.2, D-085).
98 ///
99 /// `TraversalBuilder::as_of(ts)` fixes the *topology* at `ts`. Node
100 /// attributes are a second, independent question, and the default answer —
101 /// `AttributeMode::Current` — is live text. That combination returns the
102 /// past's graph wearing the present's titles, which is a legitimate thing to
103 /// want and a terrible thing to get by accident.
104 ///
105 /// It used to be a `tracing::warn!`, which is invisible in any application
106 /// that has not configured a subscriber. This is the same statement as a
107 /// value the caller cannot miss.
108 ///
109 /// Fix by stating the mode: `.attribute_mode(AttributeMode::AtTime)` for the
110 /// past's text, or `.attribute_mode(AttributeMode::Current)` to affirm that
111 /// live text is what was meant.
112 #[error(
113 "traversal as_of({as_of}) did not state an attribute mode: topology at \
114 {as_of} would be returned with attributes as they are *now*. Call \
115 .attribute_mode(AttributeMode::AtTime) for attributes as believed at \
116 {as_of}, or .attribute_mode(AttributeMode::Current) to confirm live \
117 attributes are intended"
118 )]
119 AttributeModeUnstated { as_of: String },
120 /// [`crate::Database::diagnostic_conn`] could not open the file read-only
121 /// (T5.1, D-091).
122 ///
123 /// Its own variant rather than `NotFound`, which renders "node {0} not
124 /// found" — naming the wrong subject is the defect [D-069] was written to
125 /// correct, and a file is not a node.
126 ///
127 /// The case worth the sentence is a missing file:
128 /// `SQLITE_OPEN_READ_ONLY` drops `SQLITE_OPEN_CREATE` with it, so a path
129 /// that does not exist is `SQLITE_CANTOPEN` rather than a fresh empty
130 /// database. That is the right behaviour and an opaque error to receive.
131 ///
132 /// [D-069]: ../../docs/architecture/s13-decision-register.md#d-069
133 #[error("cannot open {path} read-only for diagnostics: {reason}")]
134 DiagnosticConn { path: String, reason: String },
135 /// [`crate::Database::archive_windowed`] was given a window it cannot use
136 /// (T1.1, D-080).
137 ///
138 /// Carries a `reason` rather than the numbers as fields because the two
139 /// cases it covers are not the same shape — a zero-length window never
140 /// advances at all, while a merely narrow one produces a session count that
141 /// has to be quoted against the limit to mean anything. A caller reading
142 /// this needs the sentence, not the struct.
143 ///
144 /// It is an error rather than a silent clamp on purpose. Rounding a
145 /// one-second window up to something workable would archive over boundaries
146 /// the caller did not choose, and the caller cannot see that it happened.
147 #[error("archive window {window:?} is unusable: {reason}")]
148 ArchiveWindow {
149 window: std::time::Duration,
150 reason: String,
151 },
152
153 /// A timestamp that is not in canonical form (§4.1, D-029).
154 ///
155 /// **Distinct from [`Self::ReplayCorrupt`], which is what this used to be
156 /// (Wave 4.5).** `timestamp::normalize` and `timestamp::parse` reported bad
157 /// *caller input* as `ReplayCorrupt { seq: 0 }` — a claim that the ledger is
158 /// damaged, carrying a sequence number that cannot exist because
159 /// `AUTOINCREMENT` starts at 1. The same mistake as defect J: an error that
160 /// names the wrong subject sends a caller to fix the wrong thing.
161 ///
162 /// The value is reported rather than the provenance, because one function
163 /// serves both directions — a caller passing `2026-01-01T00:00:00Z` and a
164 /// stored `recorded_at` that will not parse produce the same complaint about
165 /// the same string. `SystemClock::new` is where the second case is
166 /// interpreted, and it already logs and floors to the wall clock (D-027).
167 #[error("timestamp {value:?} is not canonical: {reason}")]
168 InvalidTimestamp { value: String, reason: String },
169
170 /// An identifier the crate's own encodings cannot represent (D-061).
171 ///
172 /// Distinct from [`Self::NotFound`], and the distinction is defect J: this
173 /// id was refused, not looked up. `validate_id` used to return `NotFound`
174 /// here, which tells a caller the thing is missing and invites them to
175 /// create it — with the same id, which will be refused again.
176 #[error("invalid identifier {id:?}: {reason}")]
177 InvalidId { id: String, reason: String },
178
179 /// Two valid-time intervals for one relationship claim the same instant.
180 ///
181 /// Distinct from [`Self::SingleOpenViolation`], which is the storage layer's
182 /// guard and covers only the *open* sentinel. This is the general case, and
183 /// it is refused at the API rather than by a trigger (D-060): raw SQL against
184 /// the same file can still write an overlap, and §4.2 says so.
185 ///
186 /// The consequence of allowing one is not an error later but a wrong answer:
187 /// `query_as_of_edges` at an instant inside both returns the relationship
188 /// twice, and every weighted algorithm downstream double-counts that edge.
189 ///
190 /// **Boxed, and it is the only variant that is (D-075).** Seven `String`s is
191 /// 168 bytes, which made `DbError` — and therefore every `Result` in the
192 /// crate, on the `Ok` path too — larger than `clippy::result_large_err`'s
193 /// threshold the moment D-060 added it. The other variants are well under.
194 /// Boxing the rarest one keeps the whole error small rather than trimming
195 /// what a caller is told; `matches!(err, OverlappingInterval { .. })` is
196 /// unaffected, which is how every call site uses it.
197 #[error(
198 "edge {} -> {} ({}) already holds [{}, {}), which overlaps the asserted [{}, {})",
199 .overlap.source_id, .overlap.target_id, .overlap.edge_type,
200 .overlap.existing_from, .overlap.existing_to,
201 .overlap.valid_from, .overlap.valid_to
202 )]
203 OverlappingInterval { overlap: Box<Overlap> },
204
205 #[error("links_current drift detected: {n} intervals diverge")]
206 CurrentDrift { n: usize },
207
208 #[error("rebuild verification failed: {n} intervals still diverge")]
209 RebuildFailed { n: usize },
210 /// A chunked shadow rebuild was abandoned rather than committed (T1.2, D-082).
211 ///
212 /// Distinct from [`Self::RebuildFailed`], and the distinction is the whole
213 /// point: `RebuildFailed` means the repair ran and did not repair, which is
214 /// a reason to distrust the ledger. This means the repair **did not run** —
215 /// something invalidated the work in progress and it was discarded before it
216 /// could be swapped in. `links_current` is untouched and whatever was true
217 /// of it before is still true. The action is to retry.
218 #[error("chunked rebuild abandoned: {reason}")]
219 RebuildInterrupted { reason: String },
220
221 // -- 0.4.5: writer-actor containment --
222 #[error("write actor is not running (reopen the Database)")]
223 WriterUnavailable,
224
225 #[error("write actor dropped the response channel mid-request")]
226 WriterDroppedResponder,
227
228 /// The actor's task did not join cleanly at [`crate::Database::close`].
229 ///
230 /// Distinct from [`Self::WriterUnavailable`], which means the channel is
231 /// gone while the handle is still in use. This is the shutdown path telling
232 /// a caller that the write actor panicked — which `close()` used to swallow,
233 /// so a database whose write path had died closed "successfully" (Wave 4.2).
234 #[error("write actor did not shut down cleanly: {0}")]
235 WriterStopped(String),
236
237 // -- 0.5.0: concept integrity --
238 #[error("recorded_at must advance on concept update (got {got}, had {had})")]
239 RecordedAtRegression { got: String, had: String },
240}
241
242pub type Result<T> = std::result::Result<T, DbError>;
243
244/// A guard abort recognised by its message (§4.3).
245#[derive(Debug, Clone, Copy, PartialEq, Eq)]
246pub enum AbortKind {
247 SingleOpenInterval,
248 RecordedAtRegression,
249 DeleteOutsideArchive,
250 /// Not one of our guards — an ordinary engine error.
251 NotAGuard,
252}
253
254/// Recognise a schema guard's `RAISE(ABORT, …)` by its message.
255///
256/// **The only place in the crate that matches on engine error text.** SQLite
257/// reports a `RAISE(ABORT)` as a generic constraint failure carrying the
258/// message, so the message is the only thing distinguishing "you violated the
259/// single-open-interval rule" from "the disk is full" — but matching on it
260/// scattered across call sites means an upstream wording change degrades an
261/// unknown number of typed errors into opaque ones, silently. Concentrated here,
262/// a change breaks one function and the tests that cover it.
263///
264/// The needles are the [`crate::schema::ddl`] constants spliced into the
265/// triggers themselves, so guard and classifier cannot drift.
266pub fn abort_kind(err: &libsql::Error) -> AbortKind {
267 use crate::schema::ddl::{ABORT_DELETE_GUARD, ABORT_MONOTONIC_RA, ABORT_SINGLE_OPEN};
268
269 let text = err.to_string();
270 if text.contains(ABORT_SINGLE_OPEN) {
271 AbortKind::SingleOpenInterval
272 } else if text.contains(ABORT_MONOTONIC_RA) {
273 AbortKind::RecordedAtRegression
274 } else if text.contains(ABORT_DELETE_GUARD) {
275 AbortKind::DeleteOutsideArchive
276 } else {
277 AbortKind::NotAGuard
278 }
279}
280
281/// What a failing statement was trying to do, so a guard abort can name it.
282pub enum WriteOp<'a> {
283 Edge {
284 source_id: &'a str,
285 target_id: &'a str,
286 edge_type: &'a str,
287 },
288 Concept {
289 id: &'a str,
290 recorded_at: &'a str,
291 },
292 Delete {
293 table: &'a str,
294 },
295}
296
297/// Turn an engine error into the typed error §7 specifies, where one applies.
298///
299/// Takes a connection because `RecordedAtRegression` reports the value it
300/// clashed with, and the trigger does not put it in the message. One extra query
301/// on an error path buys an error a caller can act on instead of one they have
302/// to reproduce by hand.
303pub async fn classify(conn: &libsql::Connection, err: libsql::Error, op: WriteOp<'_>) -> DbError {
304 match (abort_kind(&err), op) {
305 (
306 AbortKind::SingleOpenInterval,
307 WriteOp::Edge {
308 source_id,
309 target_id,
310 edge_type,
311 },
312 ) => DbError::SingleOpenViolation {
313 source_id: source_id.to_string(),
314 target_id: target_id.to_string(),
315 edge_type: edge_type.to_string(),
316 },
317 (AbortKind::RecordedAtRegression, WriteOp::Concept { id, recorded_at }) => {
318 let had = current_recorded_at(conn, id).await.unwrap_or_default();
319 DbError::RecordedAtRegression {
320 got: recorded_at.to_string(),
321 had,
322 }
323 }
324 (AbortKind::DeleteOutsideArchive, WriteOp::Delete { table }) => DbError::ArchiveViolation {
325 table: table.to_string(),
326 },
327 // A guard fired for an operation it does not describe. Reporting the raw
328 // error is honest; inventing a typed one from the wrong context is not.
329 _ => DbError::Engine(err),
330 }
331}
332
333async fn current_recorded_at(conn: &libsql::Connection, id: &str) -> Option<String> {
334 conn.query(
335 "SELECT recorded_at FROM concepts WHERE id = ?1",
336 libsql::params![id],
337 )
338 .await
339 .ok()?
340 .next()
341 .await
342 .ok()??
343 .get(0)
344 .ok()
345}
346
347#[cfg(test)]
348mod tests {
349 use super::*;
350
351 /// `DbError` stays under `clippy::result_large_err`'s 128-byte threshold.
352 ///
353 /// Every fallible function in the crate returns `Result<T, DbError>`, so the
354 /// enum's size is paid on the `Ok` path too. D-060 pushed it to 168 bytes with
355 /// one seven-`String` variant and nobody noticed until D-075 read the lint
356 /// output; boxing that variant brought it back. This is the tripwire, because
357 /// the failure mode is a warning in a build log rather than a broken test —
358 /// the kind this cycle has spent its whole length finding.
359 #[test]
360 fn the_error_enum_stays_small_enough_to_return_by_value() {
361 let size = std::mem::size_of::<DbError>();
362 assert!(
363 size <= 128,
364 "DbError is {size} bytes. Some variant has grown past what a Result \n should carry — box it, as OverlappingInterval is boxed (D-075)."
365 );
366 }
367
368 /// The boxed variant still reports both intervals.
369 #[test]
370 fn an_overlap_names_the_asserted_interval_and_the_stored_one() {
371 let err = DbError::OverlappingInterval {
372 overlap: Box::new(Overlap {
373 source_id: "a".into(),
374 target_id: "b".into(),
375 edge_type: "KNOWS".into(),
376 valid_from: "2026-03-01T00:00:00.000000Z".into(),
377 valid_to: "2026-09-01T00:00:00.000000Z".into(),
378 existing_from: "2026-01-01T00:00:00.000000Z".into(),
379 existing_to: "2026-06-01T00:00:00.000000Z".into(),
380 }),
381 };
382 let msg = err.to_string();
383 assert!(msg.contains("a -> b (KNOWS)"), "{msg}");
384 assert!(msg.contains("already holds [2026-01-01"), "{msg}");
385 assert!(msg.contains("asserted [2026-03-01"), "{msg}");
386 }
387}