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 /// The archive-session marker exists as **committed** state (0.10.0, W2).
97 ///
98 /// [`ArchiveViolation`] is this guard working. This variant is the guard
99 /// having been silently switched off: while
100 /// `macrame_archive_session` is present, `trg_concepts_guard_delete`,
101 /// `trg_links_guard_delete` and `trg_txlog_guard_delete` all evaluate their
102 /// `WHEN` to false and permit the deletes they exist to refuse, and
103 /// `trg_concepts_log_insert` writes no `transaction_log` row for a concept
104 /// insert. [Doctrine IV] and [Doctrine V] are both suspended, with no error
105 /// and no counter — which is why the condition needs a name of its own.
106 ///
107 /// **It cannot be produced by an archive session, crashed or otherwise.**
108 /// `archive()` and `archive_windowed()` create and drop the marker inside
109 /// the same transaction that does the work, so a commit drops it and a
110 /// rollback discards it; and the check that raises this error —
111 /// `verify` in `src/schema/migrations.rs`, which is private, hence the file
112 /// reference rather than a link — reads committed state, so it cannot see an
113 /// in-flight session. Reaching this
114 /// error therefore means something wrote the table outside the write actor
115 /// — the raw-writer case §4.7 concedes exists.
116 ///
117 /// Not a [`Migration`] error: the schema is intact. What is wrong is the
118 /// database's *contents*, and saying "your schema is wrong" would send the
119 /// reader to the migration ladder for a fault a `DROP TABLE` fixes.
120 ///
121 /// [Doctrine IV]: ../../docs/architecture/s0-s3-foundations.md#doctrine-iv
122 /// [Doctrine V]: ../../docs/architecture/s0-s3-foundations.md#doctrine-v
123 /// [`ArchiveViolation`]: DbError::ArchiveViolation
124 /// [`Migration`]: DbError::Migration
125 #[error(
126 "the archive-session marker table {marker:?} is present as committed \
127 state. While it exists, the delete guards on concepts, links and \
128 transaction_log are disarmed and concept inserts write no \
129 transaction_log row. An archive session creates and drops this table \
130 inside one transaction, so it should never be visible here — \
131 something wrote it outside the write actor. Drop it (DROP TABLE \
132 {marker}) and audit for deletions and missing log rows since it \
133 appeared"
134 )]
135 ArchiveSessionLeaked { marker: String },
136
137 /// A traversal asked about the past without saying which text it wanted
138 /// (T3.2, D-085).
139 ///
140 /// `TraversalBuilder::as_of(ts)` fixes the *topology* at `ts`. Node
141 /// attributes are a second, independent question, and the default answer —
142 /// `AttributeMode::Current` — is live text. That combination returns the
143 /// past's graph wearing the present's titles, which is a legitimate thing to
144 /// want and a terrible thing to get by accident.
145 ///
146 /// It used to be a `tracing::warn!`, which is invisible in any application
147 /// that has not configured a subscriber. This is the same statement as a
148 /// value the caller cannot miss.
149 ///
150 /// Fix by stating the mode: `.attribute_mode(AttributeMode::AtTime)` for the
151 /// past's text, or `.attribute_mode(AttributeMode::Current)` to affirm that
152 /// live text is what was meant.
153 #[error(
154 "traversal as_of({as_of}) did not state an attribute mode: topology at \
155 {as_of} would be returned with attributes as they are *now*. Call \
156 .attribute_mode(AttributeMode::AtTime) for attributes as believed at \
157 {as_of}, or .attribute_mode(AttributeMode::Current) to confirm live \
158 attributes are intended"
159 )]
160 AttributeModeUnstated { as_of: String },
161 /// [`crate::Database::diagnostic_conn`] could not open the file read-only
162 /// (T5.1, D-091).
163 ///
164 /// Its own variant rather than `NotFound`, which renders "node {0} not
165 /// found" — naming the wrong subject is the defect [D-069] was written to
166 /// correct, and a file is not a node.
167 ///
168 /// The case worth the sentence is a missing file:
169 /// `SQLITE_OPEN_READ_ONLY` drops `SQLITE_OPEN_CREATE` with it, so a path
170 /// that does not exist is `SQLITE_CANTOPEN` rather than a fresh empty
171 /// database. That is the right behaviour and an opaque error to receive.
172 ///
173 /// [D-069]: ../../docs/architecture/s13-decision-register.md#d-069
174 #[error("cannot open {path} read-only for diagnostics: {reason}")]
175 DiagnosticConn { path: String, reason: String },
176 /// [`crate::Database::archive_windowed`] was given a window it cannot use
177 /// (T1.1, D-080).
178 ///
179 /// Carries a `reason` rather than the numbers as fields because the two
180 /// cases it covers are not the same shape — a zero-length window never
181 /// advances at all, while a merely narrow one produces a session count that
182 /// has to be quoted against the limit to mean anything. A caller reading
183 /// this needs the sentence, not the struct.
184 ///
185 /// It is an error rather than a silent clamp on purpose. Rounding a
186 /// one-second window up to something workable would archive over boundaries
187 /// the caller did not choose, and the caller cannot see that it happened.
188 #[error("archive window {window:?} is unusable: {reason}")]
189 ArchiveWindow {
190 window: std::time::Duration,
191 reason: String,
192 },
193
194 /// A timestamp that is not in canonical form (§4.1, D-029).
195 ///
196 /// **Distinct from [`Self::ReplayCorrupt`], which is what this used to be
197 /// (Wave 4.5).** `timestamp::normalize` and `timestamp::parse` reported bad
198 /// *caller input* as `ReplayCorrupt { seq: 0 }` — a claim that the ledger is
199 /// damaged, carrying a sequence number that cannot exist because
200 /// `AUTOINCREMENT` starts at 1. The same mistake as defect J: an error that
201 /// names the wrong subject sends a caller to fix the wrong thing.
202 ///
203 /// The value is reported rather than the provenance, because one function
204 /// serves both directions — a caller passing `2026-01-01T00:00:00Z` and a
205 /// stored `recorded_at` that will not parse produce the same complaint about
206 /// the same string. `SystemClock::new` is where the second case is
207 /// interpreted, and it already logs and floors to the wall clock (D-027).
208 #[error("timestamp {value:?} is not canonical: {reason}")]
209 InvalidTimestamp { value: String, reason: String },
210
211 /// An identifier the crate's own encodings cannot represent (D-061).
212 ///
213 /// Distinct from [`Self::NotFound`], and the distinction is defect J: this
214 /// id was refused, not looked up. `validate_id` used to return `NotFound`
215 /// here, which tells a caller the thing is missing and invites them to
216 /// create it — with the same id, which will be refused again.
217 #[error("invalid identifier {id:?}: {reason}")]
218 InvalidId { id: String, reason: String },
219
220 /// Two valid-time intervals for one relationship claim the same instant.
221 ///
222 /// Distinct from [`Self::SingleOpenViolation`], which is the storage layer's
223 /// guard and covers only the *open* sentinel. This is the general case, and
224 /// it is refused at the API rather than by a trigger (D-060): raw SQL against
225 /// the same file can still write an overlap, and §4.2 says so.
226 ///
227 /// The consequence of allowing one is not an error later but a wrong answer:
228 /// `query_as_of_edges` at an instant inside both returns the relationship
229 /// twice, and every weighted algorithm downstream double-counts that edge.
230 ///
231 /// **Boxed, and it is the only variant that is (D-075).** Seven `String`s is
232 /// 168 bytes, which made `DbError` — and therefore every `Result` in the
233 /// crate, on the `Ok` path too — larger than `clippy::result_large_err`'s
234 /// threshold the moment D-060 added it. The other variants are well under.
235 /// Boxing the rarest one keeps the whole error small rather than trimming
236 /// what a caller is told; `matches!(err, OverlappingInterval { .. })` is
237 /// unaffected, which is how every call site uses it.
238 #[error(
239 "edge {} -> {} ({}) already holds [{}, {}), which overlaps the asserted [{}, {})",
240 .overlap.source_id, .overlap.target_id, .overlap.edge_type,
241 .overlap.existing_from, .overlap.existing_to,
242 .overlap.valid_from, .overlap.valid_to
243 )]
244 OverlappingInterval { overlap: Box<Overlap> },
245
246 #[error("links_current drift detected: {n} intervals diverge")]
247 CurrentDrift { n: usize },
248
249 #[error("rebuild verification failed: {n} intervals still diverge")]
250 RebuildFailed { n: usize },
251 /// A chunked shadow rebuild was abandoned rather than committed (T1.2, D-082).
252 ///
253 /// Distinct from [`Self::RebuildFailed`], and the distinction is the whole
254 /// point: `RebuildFailed` means the repair ran and did not repair, which is
255 /// a reason to distrust the ledger. This means the repair **did not run** —
256 /// something invalidated the work in progress and it was discarded before it
257 /// could be swapped in. `links_current` is untouched and whatever was true
258 /// of it before is still true. The action is to retry.
259 #[error("chunked rebuild abandoned: {reason}")]
260 RebuildInterrupted { reason: String },
261
262 // -- 0.4.5: writer-actor containment --
263 #[error("write actor is not running (reopen the Database)")]
264 WriterUnavailable,
265
266 #[error("write actor dropped the response channel mid-request")]
267 WriterDroppedResponder,
268
269 /// The actor's task did not join cleanly at [`crate::Database::close`].
270 ///
271 /// Distinct from [`Self::WriterUnavailable`], which means the channel is
272 /// gone while the handle is still in use. This is the shutdown path telling
273 /// a caller that the write actor panicked — which `close()` used to swallow,
274 /// so a database whose write path had died closed "successfully" (Wave 4.2).
275 #[error("write actor did not shut down cleanly: {0}")]
276 WriterStopped(String),
277
278 // -- 0.5.0: concept integrity --
279 #[error("recorded_at must advance on concept update (got {got}, had {had})")]
280 RecordedAtRegression { got: String, had: String },
281}
282
283pub type Result<T> = std::result::Result<T, DbError>;
284
285/// A guard abort recognised by its message (§4.3).
286#[derive(Debug, Clone, Copy, PartialEq, Eq)]
287pub enum AbortKind {
288 SingleOpenInterval,
289 RecordedAtRegression,
290 DeleteOutsideArchive,
291 /// Not one of our guards — an ordinary engine error.
292 NotAGuard,
293}
294
295/// Recognise a schema guard's `RAISE(ABORT, …)` by its message.
296///
297/// **The only place in the crate that matches on engine error text.** SQLite
298/// reports a `RAISE(ABORT)` as a generic constraint failure carrying the
299/// message, so the message is the only thing distinguishing "you violated the
300/// single-open-interval rule" from "the disk is full" — but matching on it
301/// scattered across call sites means an upstream wording change degrades an
302/// unknown number of typed errors into opaque ones, silently. Concentrated here,
303/// a change breaks one function and the tests that cover it.
304///
305/// The needles are the [`crate::schema::ddl`] constants spliced into the
306/// triggers themselves, so guard and classifier cannot drift.
307pub fn abort_kind(err: &libsql::Error) -> AbortKind {
308 use crate::schema::ddl::{ABORT_DELETE_GUARD, ABORT_MONOTONIC_RA, ABORT_SINGLE_OPEN};
309
310 let text = err.to_string();
311 if text.contains(ABORT_SINGLE_OPEN) {
312 AbortKind::SingleOpenInterval
313 } else if text.contains(ABORT_MONOTONIC_RA) {
314 AbortKind::RecordedAtRegression
315 } else if text.contains(ABORT_DELETE_GUARD) {
316 AbortKind::DeleteOutsideArchive
317 } else {
318 AbortKind::NotAGuard
319 }
320}
321
322/// What a failing statement was trying to do, so a guard abort can name it.
323pub enum WriteOp<'a> {
324 Edge {
325 source_id: &'a str,
326 target_id: &'a str,
327 edge_type: &'a str,
328 },
329 Concept {
330 id: &'a str,
331 recorded_at: &'a str,
332 },
333 Delete {
334 table: &'a str,
335 },
336}
337
338/// Turn an engine error into the typed error §7 specifies, where one applies.
339///
340/// Takes a connection because `RecordedAtRegression` reports the value it
341/// clashed with, and the trigger does not put it in the message. One extra query
342/// on an error path buys an error a caller can act on instead of one they have
343/// to reproduce by hand.
344pub async fn classify(conn: &libsql::Connection, err: libsql::Error, op: WriteOp<'_>) -> DbError {
345 match (abort_kind(&err), op) {
346 (
347 AbortKind::SingleOpenInterval,
348 WriteOp::Edge {
349 source_id,
350 target_id,
351 edge_type,
352 },
353 ) => DbError::SingleOpenViolation {
354 source_id: source_id.to_string(),
355 target_id: target_id.to_string(),
356 edge_type: edge_type.to_string(),
357 },
358 (AbortKind::RecordedAtRegression, WriteOp::Concept { id, recorded_at }) => {
359 let had = current_recorded_at(conn, id).await.unwrap_or_default();
360 DbError::RecordedAtRegression {
361 got: recorded_at.to_string(),
362 had,
363 }
364 }
365 (AbortKind::DeleteOutsideArchive, WriteOp::Delete { table }) => DbError::ArchiveViolation {
366 table: table.to_string(),
367 },
368 // A guard fired for an operation it does not describe. Reporting the raw
369 // error is honest; inventing a typed one from the wrong context is not.
370 _ => DbError::Engine(err),
371 }
372}
373
374async fn current_recorded_at(conn: &libsql::Connection, id: &str) -> Option<String> {
375 conn.query(
376 "SELECT recorded_at FROM concepts WHERE id = ?1",
377 libsql::params![id],
378 )
379 .await
380 .ok()?
381 .next()
382 .await
383 .ok()??
384 .get(0)
385 .ok()
386}
387
388#[cfg(test)]
389mod tests {
390 use super::*;
391
392 /// `DbError` stays under `clippy::result_large_err`'s 128-byte threshold.
393 ///
394 /// Every fallible function in the crate returns `Result<T, DbError>`, so the
395 /// enum's size is paid on the `Ok` path too. D-060 pushed it to 168 bytes with
396 /// one seven-`String` variant and nobody noticed until D-075 read the lint
397 /// output; boxing that variant brought it back. This is the tripwire, because
398 /// the failure mode is a warning in a build log rather than a broken test —
399 /// the kind this cycle has spent its whole length finding.
400 #[test]
401 fn the_error_enum_stays_small_enough_to_return_by_value() {
402 let size = std::mem::size_of::<DbError>();
403 assert!(
404 size <= 128,
405 "DbError is {size} bytes. Some variant has grown past what a Result \n should carry — box it, as OverlappingInterval is boxed (D-075)."
406 );
407 }
408
409 /// The boxed variant still reports both intervals.
410 #[test]
411 fn an_overlap_names_the_asserted_interval_and_the_stored_one() {
412 let err = DbError::OverlappingInterval {
413 overlap: Box::new(Overlap {
414 source_id: "a".into(),
415 target_id: "b".into(),
416 edge_type: "KNOWS".into(),
417 valid_from: "2026-03-01T00:00:00.000000Z".into(),
418 valid_to: "2026-09-01T00:00:00.000000Z".into(),
419 existing_from: "2026-01-01T00:00:00.000000Z".into(),
420 existing_to: "2026-06-01T00:00:00.000000Z".into(),
421 }),
422 };
423 let msg = err.to_string();
424 assert!(msg.contains("a -> b (KNOWS)"), "{msg}");
425 assert!(msg.contains("already holds [2026-01-01"), "{msg}");
426 assert!(msg.contains("asserted [2026-03-01"), "{msg}");
427 }
428}