Skip to main content

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 other interval reads as though the assertion were the innocent one.
9#[derive(Debug, Clone, PartialEq, Eq)]
10#[non_exhaustive]
11pub struct Overlap {
12    pub source_id: String,
13    pub target_id: String,
14    pub edge_type: String,
15    /// The interval the caller asserted.
16    pub valid_from: String,
17    pub valid_to: String,
18    /// The interval it collides with — see [`Overlap::within_batch`] for where
19    /// that one is, because it is not always in the database.
20    pub existing_from: String,
21    pub existing_to: String,
22    /// Whether the collision is with another edge in the *same call* (0.13.7,
23    /// D-180).
24    ///
25    /// Two guards raise this one error. `reject_overlapping_interval` compares
26    /// the assertion against committed rows; `reject_overlaps_within` compares
27    /// a batch against itself, before the transaction opens, and nothing it
28    /// names is in the database — the batch is refused whole, so nothing it
29    /// names ever will be. A caller told an edge "already holds" an interval
30    /// goes looking for a row that is not there.
31    pub within_batch: bool,
32}
33
34impl Overlap {
35    /// The collision, as the edge it is about and the two intervals.
36    ///
37    /// Grouped rather than taken as eight strings, because the grouping is the
38    /// struct's actual shape — D-075 boxed it out of the enum precisely
39    /// because it is two intervals — and eight positional `String`s is a
40    /// signature in which `existing_from` and `valid_from` can be swapped
41    /// without the compiler noticing. Added in 0.15.13 with
42    /// `#[non_exhaustive]` (W15.3, [D-255]): the crate raises this error and no
43    /// caller has to build one, but our own binding builds a sample of every
44    /// variant to check its error mapping, and a type nothing outside the crate
45    /// can construct is a type nothing outside the crate can test against.
46    ///
47    /// [D-255]: ../docs/architecture/s13-decision-register.md#d-255
48    pub fn new(
49        source_id: impl Into<String>,
50        target_id: impl Into<String>,
51        edge_type: impl Into<String>,
52        asserted: (impl Into<String>, impl Into<String>),
53        existing: (impl Into<String>, impl Into<String>),
54        within_batch: bool,
55    ) -> Self {
56        Self {
57            source_id: source_id.into(),
58            target_id: target_id.into(),
59            edge_type: edge_type.into(),
60            valid_from: asserted.0.into(),
61            valid_to: asserted.1.into(),
62            existing_from: existing.0.into(),
63            existing_to: existing.1.into(),
64            within_batch,
65        }
66    }
67
68    /// The message's closing clause: where the second interval came from.
69    ///
70    /// A method rather than two `#[error]` strings, because one variant gets
71    /// one format string, and rather than a `String` because this is on a
72    /// `Display` path.
73    pub fn provenance(&self) -> &'static str {
74        if self.within_batch {
75            "this same batch also asserts"
76        } else {
77            "is already recorded"
78        }
79    }
80}
81
82/// Which instants a traversal stated, for the one error that has to name them
83/// (0.13.10, W7.7, D-183).
84///
85/// Three cases and never zero. [`DbError::AttributeModeUnstated`] exists
86/// *because* an instant was set, so a fourth case carrying neither would be a
87/// state no construction site can reach — [D-177]'s objection to a `Result`
88/// that cannot fail, in a different shape. [`Self::new`] returns an `Option`
89/// and the `None` is the ordinary live traversal, resolved before any error
90/// exists.
91///
92/// [D-177]: ../docs/architecture/s13-decision-register.md#d-177
93#[derive(Debug, Clone, PartialEq, Eq)]
94#[non_exhaustive]
95pub enum StatedInstants {
96    /// `as_of_valid` alone — *what was true then*.
97    Valid(String),
98    /// `as_of_recorded` alone — *what we believed then*.
99    Recorded(String),
100    /// Both, which is the bitemporal cell: *what did we believe at `recorded`
101    /// about what was true at `valid`*.
102    Both {
103        /// The valid-time instant.
104        valid: String,
105        /// The transaction-time instant.
106        recorded: String,
107    },
108}
109
110impl StatedInstants {
111    /// `None` when neither axis was set, which is not an error and not this
112    /// type's business to describe.
113    pub fn new(valid: Option<&str>, recorded: Option<&str>) -> Option<Self> {
114        match (valid, recorded) {
115            (Some(v), Some(r)) => Some(Self::Both {
116                valid: v.to_string(),
117                recorded: r.to_string(),
118            }),
119            (Some(v), None) => Some(Self::Valid(v.to_string())),
120            (None, Some(r)) => Some(Self::Recorded(r.to_string())),
121            (None, None) => None,
122        }
123    }
124
125    /// The valid-time instant, if this traversal stated one.
126    pub fn valid(&self) -> Option<&str> {
127        match self {
128            Self::Valid(v) | Self::Both { valid: v, .. } => Some(v),
129            Self::Recorded(_) => None,
130        }
131    }
132
133    /// The transaction-time instant, if this traversal stated one.
134    pub fn recorded(&self) -> Option<&str> {
135        match self {
136            Self::Recorded(r) | Self::Both { recorded: r, .. } => Some(r),
137            Self::Valid(_) => None,
138        }
139    }
140}
141
142/// Rendered as the **calls that produce them**, which is the whole point.
143///
144/// A message reading `as_of(2020-06-01)` names a method that has not existed
145/// since 0.12.17 ([D-174](../docs/architecture/s13-decision-register.md#d-174)),
146/// so a caller who goes looking for it finds nothing — and, worse, is not told
147/// which of the two axes the instant they set landed on. `as_of_valid(…)` and
148/// `as_of_recorded(…)` are what a caller typed and what a caller can change.
149impl std::fmt::Display for StatedInstants {
150    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
151        match self {
152            Self::Valid(v) => write!(f, "as_of_valid({v})"),
153            Self::Recorded(r) => write!(f, "as_of_recorded({r})"),
154            Self::Both { valid, recorded } => {
155                write!(f, "as_of_valid({valid}) with as_of_recorded({recorded})")
156            }
157        }
158    }
159}
160
161/// Central error type for the Macrame bitemporal ledger database.
162#[derive(Debug, Error)]
163#[non_exhaustive]
164pub enum DbError {
165    #[error("engine: {0}")]
166    Engine(#[from] libsql::Error),
167
168    #[error("migration to v{to} failed: {reason}")]
169    Migration { to: u32, reason: String },
170
171    #[error("invalid edge type {0} (must match [A-Z0-9]+)")]
172    InvalidEdgeType(String),
173
174    // NOTE: the spec (§7) names these fields `source` / `target`. `source` is a
175    // reserved field name for thiserror (it is inferred as the error source and
176    // requires `std::error::Error`), so the schema column names are used instead.
177    #[error(
178        "{source_id} -> {target_id} ({edge_type}) already has an open interval; retire it first"
179    )]
180    SingleOpenViolation {
181        source_id: String,
182        target_id: String,
183        edge_type: String,
184    },
185
186    #[error("node {0} not found")]
187    NotFound(String),
188
189    #[error("embedding dim {got}, expected {expected} for model {model}")]
190    DimMismatch {
191        got: usize,
192        expected: usize,
193        model: String,
194    },
195
196    /// A model name is spliced into DDL and queries as a table identifier, and
197    /// identifiers cannot be bound as parameters. Validating the name is what
198    /// makes that splice safe, so an invalid one is refused rather than escaped.
199    #[error("invalid embedding model name {0:?}: expected [a-z][a-z0-9_]* up to 48 characters")]
200    InvalidModelName(String),
201
202    #[error("embedding model {model} is not registered (no {table} table)")]
203    ModelNotRegistered { model: String, table: String },
204
205    /// `branches` is append-only under two unconditional triggers, so a name is
206    /// written once and can never be corrected. The rule is deliberately not
207    /// [`Self::InvalidModelName`]'s — a `branch_id` is always a bound value and
208    /// never a spliced identifier, so what is refused here is the pair of names
209    /// that read as one another rather than the ones that would break SQL.
210    #[error(
211        "invalid branch id {0:?}: expected 1-128 characters, \
212         no control characters, no leading or trailing whitespace"
213    )]
214    InvalidBranchId(String),
215
216    /// Named rather than left to the foreign key, because the caller asked
217    /// about a branch and a constraint violation would answer about a column.
218    #[error("branch {0} is not registered")]
219    UnknownBranch(String),
220
221    /// Refused rather than ignored, which is the interesting half: an
222    /// `INSERT OR IGNORE` would return a handle to a lineage with a *different*
223    /// parent and fork point than the caller asked for, which is D-069's
224    /// right-looking answer to a question nobody asked.
225    #[error("branch {0} already exists")]
226    BranchExists(String),
227
228    /// [`crate::Database::archive_branch`] refused (0.14.13, §15.4, D-230).
229    ///
230    /// Four conditions share one variant because they share one answer: the
231    /// lineage stays, and the caller has to change something about the ledger
232    /// before asking again. The `reason` says which — the trunk, a lineage with
233    /// descendants, or a lineage whose concepts another lineage's hot edges
234    /// still name.
235    ///
236    /// The fourth condition, a name that is not registered, is deliberately
237    /// **not** here: it is [`Self::UnknownBranch`], the same answer every other
238    /// branch-taking surface gives, because a typo should read the same way
239    /// wherever it is made.
240    #[error("branch {branch} cannot be archived: {reason}")]
241    BranchNotArchivable { branch: String, reason: String },
242
243    /// A rehydrate needs a lineage [`crate::Database::archive_branch`] forgot
244    /// (0.15.11, W15.1, review C-3, [D-230]).
245    ///
246    /// The two halves were built two waves apart and neither knew about the
247    /// other. `archive_branch` moves a lineage's `branches` row into the cold
248    /// file; [`crate::Database::rehydrate`] reinstates a concept row still
249    /// carrying the `branch_id` it was minted on. `concepts.branch_id`
250    /// references `branches(branch_id)` and foreign keys are on, so the insert
251    /// cannot land — and until this variant existed it did not land as
252    /// [`ErrorKind::Engine`], reading `FOREIGN KEY constraint failed`. That
253    /// message names neither the lineage, nor the concept, nor the remedy, and
254    /// attributes the refusal to `concepts` when the missing row is a branch's.
255    ///
256    /// # The other half of D-230's bargain
257    ///
258    /// The alternative was to reinstate the `branches` row inside the same
259    /// session, which would make the rehydrate succeed. It is refused because
260    /// [D-230]'s stance is that an archived lineage is *forgotten*: bringing
261    /// the name back here would register a lineage with none of its links —
262    /// known again, and answering differently than it did before it left,
263    /// without the caller having asked for either.
264    ///
265    /// The remedy is therefore the caller's, and the cold file holds what it
266    /// needs: `cold.branches` keeps the row's `parent_id`, `forked_at` and the
267    /// instant it was archived, so [`crate::Database::fork`] can re-register
268    /// the lineage on its original terms, after which the rehydrate succeeds.
269    /// Doing that deliberately is the difference this variant exists to make.
270    ///
271    /// [D-230]: ../../docs/architecture/s13-decision-register.md#d-230
272    #[error(
273        "concept {concept} was minted on branch {branch}, which the ledger has \
274         forgotten; re-register the lineage before rehydrating the concept"
275    )]
276    BranchArchived { branch: String, concept: String },
277
278    /// The cross-row half of the fork-point invariant, which no `CHECK` can see
279    /// (§15.2): fork points must not decrease down a root path.
280    ///
281    /// A branch cut *before* its parent was is a branch that inherits nothing
282    /// whatever from the parent it names — every row that parent wrote falls
283    /// past the child's cutoff — so its `parent_id` and its visible history say
284    /// different things, silently.
285    ///
286    /// Compared against the parent's `forked_at` and not its `created_at`,
287    /// which the schema comment originally called for: `created_at` on the
288    /// trunk is stamped from `SystemTime::now()` during migration, before the
289    /// database's clock exists, so it is not on the same timeline as anything
290    /// else. See [`Database::fork`](crate::Database::fork).
291    #[error(
292        "branch {branch} would fork from {parent} at {forked_at}, \
293         before {parent} itself was cut at {parent_forked_at}"
294    )]
295    ForkPrecedesParent {
296        branch: String,
297        parent: String,
298        forked_at: String,
299        parent_forked_at: String,
300    },
301
302    /// A branch tried to restate a concept another lineage already holds
303    /// (§15.2, v12, D-225).
304    ///
305    /// # A guard that existed for three releases with nothing able to fire it
306    ///
307    /// `trg_concepts_cross_lineage` has been in the schema since v12 and
308    /// [`AbortKind::CrossLineage`] has recognised it since, but [`classify`]
309    /// had no arm for that kind, so it fell through to
310    /// [`DbError::Engine`] — the opaque variant every other guard exists to
311    /// avoid. Nothing was wrong with that until 0.14.8, because until 0.14.8
312    /// no write in this crate could name a lineage and no caller could reach
313    /// the trigger. It is the same shape D-224 found in a comment and D-223
314    /// found in a filter: **machinery written for an unbuilt caller is
315    /// exercised by nothing**, so a gap in it is invisible in a green suite.
316    ///
317    /// `held_by` is read back on the error path rather than parsed out of the
318    /// abort message, for [`RecordedAtRegression`](Self::RecordedAtRegression)'s
319    /// reason: the trigger cannot put it in the text, and the database knows it.
320    #[error(
321        "concept {id} belongs to lineage {held_by} and {attempted} may not \
322         restate it; a branch inherits concepts"
323    )]
324    CrossLineage {
325        id: String,
326        held_by: String,
327        attempted: String,
328    },
329
330    /// A write reached a [`BranchView`](crate::branch::BranchView) carrying a
331    /// different lineage's name (§15.4, 0.14.9, D-226).
332    ///
333    /// # Why this is refused rather than overwritten
334    ///
335    /// The view exists to spare a caller from threading a `BranchId` through
336    /// every call, so the obvious reading is that it should simply *stamp* its
337    /// own lineage on whatever it is handed. That is right for an assertion
338    /// that names none — which is the shape a caller building through the view
339    /// produces, and the one this does not refuse. It is wrong for an assertion
340    /// that names a **different** lineage, because that assertion is evidence
341    /// the caller believed something about where the write was going, and
342    /// silently relabelling it discards the belief instead of contradicting it.
343    ///
344    /// The failure this catches is holding two views and passing one's
345    /// assertion to the other, which nothing in the type system prevents:
346    /// `BranchView` is `Clone` and both views have the same methods, so the
347    /// mistake reads correctly at the call site and produces rows on the wrong
348    /// lineage. On a ledger where a lineage is what a belief *means*, that is
349    /// not a misfiled row — it is an assertion attributed to the wrong belief.
350    #[error("view of branch {view} was handed a write naming {named}")]
351    BranchMismatch {
352        /// The lineage the view carries.
353        view: String,
354        /// The lineage the assertion named.
355        named: String,
356    },
357
358    #[error("subgraph exceeds budget ({n} > {budget})")]
359    SubgraphTooLarge { n: usize, budget: usize },
360
361    /// Dijkstra and A* settle a node permanently the first time they pop it,
362    /// which is only sound when no later edge can reduce the distance — that is,
363    /// when weights are non-negative. `links.weight` is a bare `REAL NOT NULL`
364    /// with no CHECK, so the guarantee has to be established at load time. The
365    /// alternative is a shortest-path result that is quietly just a path.
366    #[error("edge {source_id} -> {target_id} has weight {weight}, which shortest-path analytics cannot use")]
367    NegativeEdgeWeight {
368        source_id: String,
369        target_id: String,
370        weight: f64,
371    },
372
373    #[error("replay corrupt at seq {seq}: {reason}")]
374    ReplayCorrupt { seq: i64, reason: String },
375
376    /// A snapshot this build cannot read. Distinct from [`Self::ReplayCorrupt`]
377    /// on purpose: corruption is a fault to report, an incompatible snapshot is
378    /// the ordinary consequence of an upgrade, and the correct response is to
379    /// discard the file and fold from the log instead (D-043).
380    #[error("snapshot {path} is not readable by this build: {reason}")]
381    SnapshotIncompatible { path: String, reason: String },
382
383    /// A snapshot that is damaged rather than foreign (0.13.12, W8.2, D-185).
384    ///
385    /// The third case in the same family, and it needed its own name for the
386    /// reason [D-069] gives: an error that names the wrong subject sends a
387    /// caller to fix the wrong thing.
388    ///
389    /// * [`Self::SnapshotIncompatible`] — *a different build wrote this*.
390    ///   Ordinary after an upgrade.
391    /// * [`Self::ReplayCorrupt`] — **the ledger is damaged.** The log is the
392    ///   only authority in this system and this is the worst thing it can say.
393    /// * This — *the cache is damaged.* The ledger is untouched. Deleting the
394    ///   file restores correctness and costs a slower reconstruction, because
395    ///   [Doctrine VI] makes a snapshot derivative and disposable.
396    ///
397    /// Every failure of `load_snapshot` used to be `ReplayCorrupt { seq: 0 }`,
398    /// which claimed the ledger was damaged and carried a sequence number that
399    /// cannot exist — `AUTOINCREMENT` starts at 1. That is the same placeholder
400    /// [D-069] removed from `InvalidTimestamp`, left in place here because
401    /// nothing had cause to look at it.
402    ///
403    /// It carries the path and not a `seq`, because a snapshot is identified by
404    /// its file. The reason names the check that failed, and the checks are
405    /// ordered so that the earliest possible one fires: declared length, then
406    /// checksum, then the decompressed size against what the header declared.
407    ///
408    /// [D-069]: ../docs/architecture/s13-decision-register.md#d-069
409    /// [Doctrine VI]: ../docs/architecture/s0-s3-foundations.md#doctrine-vi
410    #[error("snapshot {path} is damaged: {reason}")]
411    SnapshotCorrupt { path: String, reason: String },
412
413    /// A snapshot that could not be **written** (0.14.23, W12.23, C-2, [D-240]).
414    ///
415    /// The fourth case in the family above and the last one missing, which is
416    /// why it is worth saying what the other three had in common: each names
417    /// the subject a caller has to go and fix. Every failure inside
418    /// `save_snapshot` — the directory, the serialization, the compression, the
419    /// temp file, the write, the flush, the rename, and the directory flush
420    /// after it — used to be [`Self::ReplayCorrupt`], which says **the ledger
421    /// is damaged**, the worst thing this system can say. A full disk said it.
422    ///
423    /// * [`Self::SnapshotIncompatible`] — *a different build wrote this*.
424    /// * [`Self::SnapshotCorrupt`] — *the cache is damaged*, delete the file.
425    /// * [`Self::ReplayCorrupt`] — **the ledger is damaged**.
426    /// * This — *the cache could not be written*. **Nothing is damaged and
427    ///   nothing is lost**: [Doctrine VI] makes a snapshot derivative, so the
428    ///   next start folds from the previous anchor and the whole cost is a
429    ///   slower start. The subject is the filesystem.
430    ///
431    /// The read half of this correction shipped at 0.13.12: `load_snapshot`
432    /// stopped answering `ReplayCorrupt { seq: 0 }` for a damaged file, for
433    /// exactly this reason ([D-185](../docs/architecture/s13-decision-register.md#d-185)).
434    /// The write half kept it for ten releases.
435    ///
436    /// **One variant covers the directory flush as well**, and that is
437    /// [D-186](../docs/architecture/s13-decision-register.md#d-186)'s decision
438    /// rather than a simplification here: a failed `sync_directory` leaves the
439    /// snapshot at its final name and readable, unable only to promise the name
440    /// survives a power loss, and D-186 already placed it in "the same class the
441    /// file's own `sync_all` failure already returns". `reason` names which step
442    /// failed.
443    ///
444    /// [D-240]: ../docs/architecture/s13-decision-register.md#d-240
445    /// [Doctrine VI]: ../docs/architecture/s0-s3-foundations.md#doctrine-vi
446    #[error("snapshot {path} could not be saved: {reason}")]
447    SnapshotWriteFailed { path: String, reason: String },
448
449    #[error("payload v{got} unsupported (max {max})")]
450    PayloadVersion { got: u8, max: u8 },
451
452    #[error("physical delete blocked outside archive session ({table})")]
453    ArchiveViolation { table: String },
454
455    /// The archive-session marker exists as **committed** state (0.10.0, W2).
456    ///
457    /// [`ArchiveViolation`] is this guard working. This variant is the guard
458    /// having been silently switched off: while
459    /// `macrame_archive_session` is present, `trg_concepts_guard_delete`,
460    /// `trg_links_guard_delete` and `trg_txlog_guard_delete` all evaluate their
461    /// `WHEN` to false and permit the deletes they exist to refuse, and
462    /// `trg_concepts_log_insert` writes no `transaction_log` row for a concept
463    /// insert. [Doctrine IV] and [Doctrine V] are both suspended, with no error
464    /// and no counter — which is why the condition needs a name of its own.
465    ///
466    /// **It cannot be produced by an archive session, crashed or otherwise.**
467    /// `archive()` and `archive_windowed()` create and drop the marker inside
468    /// the same transaction that does the work, so a commit drops it and a
469    /// rollback discards it; and the check that raises this error —
470    /// `verify` in `src/schema/migrations.rs`, which is private, hence the file
471    /// reference rather than a link — reads committed state, so it cannot see an
472    /// in-flight session. Reaching this
473    /// error therefore means something wrote the table outside the write actor
474    /// — the raw-writer case §4.7 concedes exists.
475    ///
476    /// Not a [`Migration`] error: the schema is intact. What is wrong is the
477    /// database's *contents*, and saying "your schema is wrong" would send the
478    /// reader to the migration ladder for a fault a `DROP TABLE` fixes.
479    ///
480    /// [Doctrine IV]: ../../docs/architecture/s0-s3-foundations.md#doctrine-iv
481    /// [Doctrine V]: ../../docs/architecture/s0-s3-foundations.md#doctrine-v
482    /// [`ArchiveViolation`]: DbError::ArchiveViolation
483    /// [`Migration`]: DbError::Migration
484    #[error(
485        "the archive-session marker table {marker:?} is present as committed \
486         state. While it exists, the delete guards on concepts, links and \
487         transaction_log are disarmed and concept inserts write no \
488         transaction_log row. An archive session creates and drops this table \
489         inside one transaction, so it should never be visible here — \
490         something wrote it outside the write actor. Drop it (DROP TABLE \
491         {marker}) and audit for deletions and missing log rows since it \
492         appeared"
493    )]
494    ArchiveSessionLeaked { marker: String },
495
496    /// A traversal asked about the past without saying which text it wanted
497    /// (T3.2, D-085).
498    ///
499    /// An instant on either axis fixes the *topology*. Node attributes are a
500    /// second, independent question, and the default answer —
501    /// `AttributeMode::Current` — is live text. That combination returns the
502    /// past's graph wearing the present's titles, which is a legitimate thing to
503    /// want and a terrible thing to get by accident.
504    ///
505    /// It used to be a `tracing::warn!`, which is invisible in any application
506    /// that has not configured a subscriber. This is the same statement as a
507    /// value the caller cannot miss.
508    ///
509    /// Fix by stating the mode: `.attribute_mode(AttributeMode::AtTime)` for the
510    /// past's text, or `.attribute_mode(AttributeMode::Current)` to affirm that
511    /// live text is what was meant.
512    ///
513    /// # It carries [`StatedInstants`] rather than one string (0.13.10, W7.7, D-183)
514    ///
515    /// The field was `as_of: String` and the message rendered it as
516    /// `as_of(…)` — a method removed in 0.12.17 when
517    /// [D-174](../docs/architecture/s13-decision-register.md#d-174) split the
518    /// axes. Both instants collapsed into it through an `.or()`, so a caller who
519    /// set `as_of_recorded` was told about `as_of`, a caller who set both was
520    /// told about one of them, and neither was told which clock they had asked
521    /// about. Naming the axis is the whole remedy this error offers.
522    #[error(
523        "traversal {instants} did not state an attribute mode: that topology \
524         would be returned with attributes as they are *now*. Call \
525         .attribute_mode(AttributeMode::AtTime) for attributes as believed at \
526         the stated instant, or .attribute_mode(AttributeMode::Current) to \
527         confirm live attributes are intended"
528    )]
529    AttributeModeUnstated { instants: StatedInstants },
530    /// [`crate::Database::diagnostic_conn`] could not open the file read-only
531    /// (T5.1, D-091).
532    ///
533    /// Its own variant rather than `NotFound`, which renders "node {0} not
534    /// found" — naming the wrong subject is the defect [D-069] was written to
535    /// correct, and a file is not a node.
536    ///
537    /// The case worth the sentence is a missing file:
538    /// `SQLITE_OPEN_READ_ONLY` drops `SQLITE_OPEN_CREATE` with it, so a path
539    /// that does not exist is `SQLITE_CANTOPEN` rather than a fresh empty
540    /// database. That is the right behaviour and an opaque error to receive.
541    ///
542    /// [D-069]: ../../docs/architecture/s13-decision-register.md#d-069
543    #[error("cannot open {path} read-only for diagnostics: {reason}")]
544    DiagnosticConn { path: String, reason: String },
545    /// [`crate::Database::archive_windowed`] was given a window it cannot use
546    /// (T1.1, D-080).
547    ///
548    /// Carries a `reason` rather than the numbers as fields because the two
549    /// cases it covers are not the same shape — a zero-length window never
550    /// advances at all, while a merely narrow one produces a session count that
551    /// has to be quoted against the limit to mean anything. A caller reading
552    /// this needs the sentence, not the struct.
553    ///
554    /// It is an error rather than a silent clamp on purpose. Rounding a
555    /// one-second window up to something workable would archive over boundaries
556    /// the caller did not choose, and the caller cannot see that it happened.
557    #[error("archive window {window:?} is unusable: {reason}")]
558    ArchiveWindow {
559        window: std::time::Duration,
560        reason: String,
561    },
562
563    /// A search asked for decay without saying what age is measured from
564    /// (0.13.20, W9.5, D-193).
565    ///
566    /// Decay ranks a hit by how old the thing it matched is, and *old* is only
567    /// meaningful relative to an instant. The crate does not read a wall clock
568    /// on a read path — that is what makes the suite's `FakeClock` able to
569    /// pin these answers at all — so the instant has to be stated, and the one
570    /// to state is the one the search is already bounded by.
571    ///
572    /// Refusing rather than defaulting to *now*: a default here would silently
573    /// make every decayed search a search about the present, which is exactly
574    /// the class of quiet substitution F-35 and
575    /// [D-175](../docs/architecture/s13-decision-register.md#d-175) were about.
576    #[error(
577        "a half-life was given without a valid-time instant to measure age          from: decay ranks a hit by how old what it matched is, and \"old\" is          relative to the instant the search reads at. State it —          `as_of_valid(t)` on the same search — or drop the half-life"
578    )]
579    HalfLifeWithoutInstant,
580
581    /// A read named a transaction-time instant the hot log can no longer answer
582    /// for (0.13.2, W7.1, D-174; extended 0.13.16, W9.1, D-189).
583    ///
584    /// A transaction-time read folds `transaction_log`, and
585    /// [`crate::Database::archive`] removes superseded rows from it. Once
586    /// anything has been archived, an instant below the cutoff is not *before
587    /// history*, it is *history that is in the other file* — and these readers
588    /// take a connection, not an archive path, so they cannot go and get it.
589    ///
590    /// **Two surfaces fold the log and both raise this.**
591    /// [`crate::graph::TraversalBuilder::as_of_recorded`] folds it for topology;
592    /// [`crate::temporal::hydrate_attributes`] folds it for the text under
593    /// [`crate::graph::AttributeMode::AtTime`]. The second was added in 0.13.16
594    /// (W9.1), where it had been returning a quietly shorter `Vec` — §3.2 of the
595    /// review, and the same silence in the same wave as the first.
596    ///
597    /// **Scoped to the instants that were actually lost (0.15.4, W14.2,
598    /// D-246).** The test was `hot_log_is_intact` — whether anything was *ever*
599    /// removed — with the instant discarded, so the first archive session a
600    /// deployment ran took `as_of_recorded` and `AttributeMode::AtTime` away for
601    /// its whole history rather than for the archived part, `as_of_recorded(now)`
602    /// included. That was justified on the ground that the archive cutoff is not
603    /// recorded hot-side (D-132's refused marker), which is true and does not
604    /// imply it: the cutoff is not needed. `LOG_ARCHIVABLE` requires a later row
605    /// at the same entity, so the newest row per entity is never archivable, and
606    /// an instant at or after the newest *surviving* stamp is answered entirely
607    /// by rows that are still hot. Below that stamp the refusal stands, and the
608    /// reason it stands is unchanged: answering from a partial fold returns
609    /// *nearly* the right topology, and on a ledger that is the worst failure
610    /// available.
611    ///
612    /// [`crate::temporal::reconstruct`] takes the archive path and answers the
613    /// same question, which is why the message names it.
614    #[error(
615        "transaction-time instant {ts} cannot be answered from the hot log: rows \
616         have been archived out of it and this read has no archive path. Use \
617         macrame::temporal::reconstruct(conn, ts, archive_path, snapshots_dir), \
618         which does"
619    )]
620    RecordedInstantUnreachable { ts: String },
621
622    /// A timestamp that is not in canonical form (§4.1, D-029).
623    ///
624    /// **Distinct from [`Self::ReplayCorrupt`], which is what this used to be
625    /// (Wave 4.5).** `timestamp::normalize` and `timestamp::parse` reported bad
626    /// *caller input* as `ReplayCorrupt { seq: 0 }` — a claim that the ledger is
627    /// damaged, carrying a sequence number that cannot exist because
628    /// `AUTOINCREMENT` starts at 1. The same mistake as defect J: an error that
629    /// names the wrong subject sends a caller to fix the wrong thing.
630    ///
631    /// The value is reported rather than the provenance, because one function
632    /// serves both directions — a caller passing `2026-01-01T00:00:00Z` and a
633    /// stored `recorded_at` that will not parse produce the same complaint about
634    /// the same string. `SystemClock::new` is where the second case is
635    /// interpreted, and it already logs and floors to the wall clock (D-027).
636    #[error("timestamp {value:?} is not canonical: {reason}")]
637    InvalidTimestamp { value: String, reason: String },
638
639    /// An identifier the crate's own encodings cannot represent (D-061).
640    ///
641    /// Distinct from [`Self::NotFound`], and the distinction is defect J: this
642    /// id was refused, not looked up. `validate_id` used to return `NotFound`
643    /// here, which tells a caller the thing is missing and invites them to
644    /// create it — with the same id, which will be refused again.
645    #[error("invalid identifier {id:?}: {reason}")]
646    InvalidId { id: String, reason: String },
647
648    /// Two valid-time intervals for one relationship claim the same instant.
649    ///
650    /// Distinct from [`Self::SingleOpenViolation`], which is the storage layer's
651    /// guard and covers only the *open* sentinel. This is the general case, and
652    /// it is refused at the API rather than by a trigger (D-060): raw SQL against
653    /// the same file can still write an overlap, and §4.2 says so.
654    ///
655    /// The consequence of allowing one is not an error later but a wrong answer:
656    /// `query_as_of_edges` at an instant inside both returns the relationship
657    /// twice, and every weighted algorithm downstream double-counts that edge.
658    ///
659    /// **Boxed, and it is the only variant that is (D-075).** Seven `String`s is
660    /// 168 bytes, which made `DbError` — and therefore every `Result` in the
661    /// crate, on the `Ok` path too — larger than `clippy::result_large_err`'s
662    /// threshold the moment D-060 added it. The other variants are well under.
663    /// Boxing the rarest one keeps the whole error small rather than trimming
664    /// what a caller is told; `matches!(err, OverlappingInterval { .. })` is
665    /// unaffected, which is how every call site uses it.
666    #[error(
667        "edge {} -> {} ({}): the asserted [{}, {}) overlaps [{}, {}), which {}",
668        .overlap.source_id, .overlap.target_id, .overlap.edge_type,
669        .overlap.valid_from, .overlap.valid_to,
670        .overlap.existing_from, .overlap.existing_to,
671        .overlap.provenance()
672    )]
673    OverlappingInterval { overlap: Box<Overlap> },
674
675    #[error("links_current drift detected: {n} intervals diverge")]
676    CurrentDrift { n: usize },
677
678    #[error("rebuild verification failed: {n} intervals still diverge")]
679    RebuildFailed { n: usize },
680    /// A chunked shadow rebuild was abandoned rather than committed (T1.2, D-082).
681    ///
682    /// Distinct from [`Self::RebuildFailed`], and the distinction is the whole
683    /// point: `RebuildFailed` means the repair ran and did not repair, which is
684    /// a reason to distrust the ledger. This means the repair **did not run** —
685    /// something invalidated the work in progress and it was discarded before it
686    /// could be swapped in. `links_current` is untouched and whatever was true
687    /// of it before is still true. The action is to retry.
688    #[error("chunked rebuild abandoned: {reason}")]
689    RebuildInterrupted { reason: String },
690
691    // -- 0.4.5: writer-actor containment --
692    #[error("write actor is not running (reopen the Database)")]
693    WriterUnavailable,
694
695    #[error("write actor dropped the response channel mid-request")]
696    WriterDroppedResponder,
697
698    /// The actor's task did not join cleanly at [`crate::Database::close`].
699    ///
700    /// Distinct from [`Self::WriterUnavailable`], which means the channel is
701    /// gone while the handle is still in use. This is the shutdown path telling
702    /// a caller that the write actor panicked — which `close()` used to swallow,
703    /// so a database whose write path had died closed "successfully" (Wave 4.2).
704    #[error("write actor did not shut down cleanly: {0}")]
705    WriterStopped(String),
706
707    // -- 0.5.0: concept integrity --
708    #[error("recorded_at must advance on concept update (got {got}, had {had})")]
709    RecordedAtRegression { got: String, had: String },
710
711    /// The stored transaction-time floor is in the future (0.13.5, W7.4, §3.4).
712    ///
713    /// The clock is raised to `MAX(recorded_at)` at open so that stamps stay
714    /// strictly increasing across restarts. That makes a single row from the
715    /// future — a skewed host, a bad import, a fixture that escaped — this
716    /// process's floor, and every stamp it issues lands at or after it. Those
717    /// rows are then written, so the next open reads the same floor back: the
718    /// damage is permanent, and it spreads.
719    ///
720    /// Refused at open rather than absorbed, which is where the crate can still
721    /// tell the difference between a stamp it wrote and one it did not.
722    /// `macrame::FutureStampPolicy` widens or waives the bound; waiving it
723    /// opens the file to be *read*, and does not repair it.
724    // The message names the *knob* rather than the Rust spelling of it,
725    // because it crosses to Python verbatim and a caller there cannot write a
726    // `Tuning` literal. `future_stamps` and `allow` are the two words that mean
727    // the same thing on both surfaces.
728    #[error(
729        "the newest recorded_at in this database is {stamp}, past the limit \
730         {limit}. The clock floor is taken from it, so opening would stamp \
731         every later write at or after it — permanently, since the next open \
732         reads those rows back. Set the future_stamps policy to allow to open \
733         it and inspect it; that inherits the floor rather than repairing it"
734    )]
735    FutureRecordedAt { stamp: String, limit: String },
736
737    /// A chunked bulk write stopped because its caller asked it to (0.13.8,
738    /// W7.6, [D-181]).
739    ///
740    /// Not a failure of the ledger, and the only [`DbError`] a caller can
741    /// *cause on purpose*. Nothing is rolled back: the chunks that committed
742    /// before the token was seen are committed, which is the same per-chunk
743    /// boundary [`crate::Database::bulk_import`] already documents. How many
744    /// rows those were is on [`BulkInterrupted::written`], the error this
745    /// arrives inside.
746    ///
747    /// It carries no count of its own precisely so that there is one place to
748    /// read the count from, whether the stop was a cancellation or a
749    /// constraint.
750    ///
751    /// [D-181]: ../../docs/architecture/s13-decision-register.md#d-181
752    #[error("the bulk write was cancelled between chunks")]
753    BulkCancelled,
754}
755
756/// What kind of failure a [`DbError`] is, as a value (0.14.25, §14.1 C-3,
757/// [D-242]).
758///
759/// # Why this exists
760///
761/// [`DbError`] is `#[non_exhaustive]` ([D-207]), so a downstream `match` needs
762/// a wildcard arm and can never be checked for completeness by the compiler.
763/// That was a deliberate trade — a ledger that will certainly add error
764/// variants after 1.0 cannot make each addition a major version — and its
765/// price was paid by callers, who lost the one guarantee that told them they
766/// had considered everything.
767///
768/// This buys part of it back, and the part it buys back is **inside this
769/// crate**: [`DbError::kind`] is one exhaustive match with no wildcard, so a
770/// variant added without a classification does not compile. The decision moves
771/// to the person adding the variant, at the line that needs it, which is
772/// exactly what [`crate::DbError`]'s binding lost in 0.13.34.
773///
774/// # The taxonomy is not new
775///
776/// These twelve names are the hierarchy the Python bindings have published
777/// since they existed — seven groups a caller can catch as a set, and five
778/// failures that belong to no group. Inventing a second, Rust-only taxonomy
779/// here would be [D-227]'s finding again: *a surface that spells its own
780/// version of a shared thing misses every repair made to the shared thing,
781/// quietly*. `binding_parity_tests` pins the two spellings together.
782///
783/// # What it is not
784///
785/// It is not a replacement for matching on [`DbError`] itself. A caller who
786/// needs the `path` a snapshot failed to write still matches the variant; this
787/// answers the coarser question — *whose problem is this, and can I retry it* —
788/// and, being `Copy + Eq + Hash`, answers it somewhere a [`DbError`] cannot go:
789/// a metrics key, a log field, a counter.
790///
791/// It is `#[non_exhaustive]` for the same reason [`DbError`] is. An exhaustive
792/// `ErrorKind` would give downstream its compile-time completeness back, and
793/// would do it by making a genuinely new *category* of failure a major
794/// version — which is the trap [D-207] rejected by name, one level up: the
795/// category would then not get added, and the ledger would report the wrong
796/// kind rather than a new one.
797///
798/// [D-207]: ../../docs/architecture/s13-decision-register.md#d-207
799/// [D-227]: ../../docs/architecture/s13-decision-register.md#d-227
800/// [D-242]: ../../docs/architecture/s13-decision-register.md#d-242
801#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
802#[non_exhaustive]
803pub enum ErrorKind {
804    /// The ledger's own invariants: overlap, drift, a leaked archive session,
805    /// a rebuild that failed or was interrupted. Something is wrong with the
806    /// data or with a repair of it, and no retry fixes it.
807    Integrity,
808    /// The caller's input was refused before anything was attempted.
809    Validation,
810    /// Embeddings and the model registry.
811    Vector,
812    /// Time, snapshots and the archive — the bitemporal machinery.
813    Temporal,
814    /// The write actor could not take, keep, or answer the request.
815    Writer,
816    /// A bound the caller set, or one the crate sets on the caller's behalf.
817    Budget,
818    /// Lineage: a branch that does not exist, cannot be forked, or may not be
819    /// named from where the caller is standing.
820    Branch,
821    /// A chunked bulk write stopped between chunks. Distinct from every other
822    /// kind because **part of it landed** — see [`BulkInterrupted`].
823    Cancelled,
824    /// The read-only diagnostic connection.
825    Diagnostic,
826    /// libSQL itself, passed through.
827    Engine,
828    /// Schema migration.
829    Migration,
830    /// The thing asked for is not there.
831    NotFound,
832}
833
834impl ErrorKind {
835    /// A stable name, for logs and metrics labels.
836    ///
837    /// Stable in the sense that matters for a label: these strings are part of
838    /// the public surface from 1.0 and will not be re-spelled. New kinds may
839    /// appear, which is what `#[non_exhaustive]` says.
840    pub fn as_str(self) -> &'static str {
841        match self {
842            Self::Integrity => "integrity",
843            Self::Validation => "validation",
844            Self::Vector => "vector",
845            Self::Temporal => "temporal",
846            Self::Writer => "writer",
847            Self::Budget => "budget",
848            Self::Branch => "branch",
849            Self::Cancelled => "cancelled",
850            Self::Diagnostic => "diagnostic",
851            Self::Engine => "engine",
852            Self::Migration => "migration",
853            Self::NotFound => "not_found",
854        }
855    }
856}
857
858impl std::fmt::Display for ErrorKind {
859    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
860        f.write_str(self.as_str())
861    }
862}
863
864impl DbError {
865    /// This error's [`ErrorKind`].
866    ///
867    /// **The match below has no wildcard arm, and that is the whole point**
868    /// ([D-242], §14.1 C-3). `DbError` is `#[non_exhaustive]`, so no `match`
869    /// outside this crate can be checked for completeness — but inside it, the
870    /// compiler still checks. A variant added without a line here fails to
871    /// build, which is the guarantee [D-207] traded away for the binding and
872    /// could not get back there.
873    ///
874    /// [D-207]: ../../docs/architecture/s13-decision-register.md#d-207
875    /// [D-242]: ../../docs/architecture/s13-decision-register.md#d-242
876    pub fn kind(&self) -> ErrorKind {
877        match self {
878            Self::Engine(_) => ErrorKind::Engine,
879            Self::Migration { .. } => ErrorKind::Migration,
880            Self::NotFound { .. } => ErrorKind::NotFound,
881            Self::DiagnosticConn { .. } => ErrorKind::Diagnostic,
882            Self::BulkCancelled => ErrorKind::Cancelled,
883
884            Self::ArchiveSessionLeaked { .. }
885            | Self::CurrentDrift { .. }
886            | Self::FutureRecordedAt { .. }
887            | Self::NegativeEdgeWeight { .. }
888            | Self::OverlappingInterval { .. }
889            | Self::RebuildFailed { .. }
890            | Self::RebuildInterrupted { .. }
891            | Self::RecordedAtRegression { .. }
892            | Self::SingleOpenViolation { .. } => ErrorKind::Integrity,
893
894            Self::AttributeModeUnstated { .. }
895            | Self::HalfLifeWithoutInstant
896            | Self::InvalidBranchId { .. }
897            | Self::InvalidEdgeType { .. }
898            | Self::InvalidId { .. }
899            | Self::InvalidModelName { .. }
900            | Self::InvalidTimestamp { .. } => ErrorKind::Validation,
901
902            Self::DimMismatch { .. } | Self::ModelNotRegistered { .. } => ErrorKind::Vector,
903
904            Self::ArchiveViolation { .. }
905            | Self::ArchiveWindow { .. }
906            | Self::PayloadVersion { .. }
907            | Self::RecordedInstantUnreachable { .. }
908            | Self::ReplayCorrupt { .. }
909            | Self::SnapshotCorrupt { .. }
910            | Self::SnapshotIncompatible { .. }
911            | Self::SnapshotWriteFailed { .. } => ErrorKind::Temporal,
912
913            Self::WriterDroppedResponder
914            | Self::WriterStopped { .. }
915            | Self::WriterUnavailable { .. } => ErrorKind::Writer,
916
917            Self::SubgraphTooLarge { .. } => ErrorKind::Budget,
918
919            Self::BranchArchived { .. }
920            | Self::BranchExists { .. }
921            | Self::BranchMismatch { .. }
922            | Self::BranchNotArchivable { .. }
923            | Self::CrossLineage { .. }
924            | Self::ForkPrecedesParent { .. }
925            | Self::UnknownBranch { .. } => ErrorKind::Branch,
926        }
927    }
928}
929
930pub type Result<T> = std::result::Result<T, DbError>;
931
932/// A chunked bulk write that stopped partway, and how much of it landed
933/// (0.13.8, W7.6, [D-181]).
934///
935/// The four chunked paths — [`crate::Database::bulk_import`],
936/// [`write_concepts`], [`upsert_embeddings`] and
937/// [`write_analytics_annotations`] — are atomic per chunk and not overall, so a
938/// failure at row 19,000 of 20,000 leaves the first 18,000-odd rows committed.
939/// Until 0.13.8 they returned a bare [`DbError`] and the caller was told only
940/// that it failed: the count was computed, used to size the next chunk, and
941/// dropped on the floor at the `?`. A caller who then retried the whole batch
942/// re-wrote everything that had already landed, and one who skipped it lost the
943/// tail.
944///
945/// This is why those four return `Result<usize, BulkInterrupted>` rather than
946/// [`Result`]. `From<BulkInterrupted> for DbError` exists so `?` still works in
947/// a function returning [`Result`] — that conversion is how a caller says the
948/// count does not interest them, and it says so at the call site instead of
949/// silently.
950///
951/// [`write_concepts`]: crate::Database::write_concepts
952/// [`upsert_embeddings`]: crate::Database::upsert_embeddings
953/// [`write_analytics_annotations`]: crate::Database::write_analytics_annotations
954/// [D-181]: ../../docs/architecture/s13-decision-register.md#d-181
955#[derive(Debug)]
956#[non_exhaustive]
957pub struct BulkInterrupted {
958    /// Rows the chunks that finished before the stop committed, and which are
959    /// still committed. Zero is an ordinary value: the first chunk can fail.
960    pub written: usize,
961    /// Why it stopped. [`DbError::BulkCancelled`] if the caller asked;
962    /// otherwise whatever the failing chunk raised, unchanged — this is not a
963    /// new error, it is the same one with the count attached.
964    pub cause: DbError,
965}
966
967impl std::fmt::Display for BulkInterrupted {
968    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
969        write!(
970            f,
971            "{} ({} row(s) committed before the stop, and still committed)",
972            self.cause, self.written
973        )
974    }
975}
976
977impl std::error::Error for BulkInterrupted {
978    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
979        Some(&self.cause)
980    }
981}
982
983impl From<BulkInterrupted> for DbError {
984    /// Discards `written`. That is the point: a caller writing `?` into a
985    /// function returning [`Result`] has decided the partial count is not
986    /// something they will act on, and this puts that decision at the place it
987    /// is taken rather than inside the crate.
988    fn from(e: BulkInterrupted) -> Self {
989        e.cause
990    }
991}
992
993impl BulkInterrupted {
994    /// Whether the stop was the caller's own cancellation rather than a fault.
995    pub fn was_cancelled(&self) -> bool {
996        matches!(self.cause, DbError::BulkCancelled)
997    }
998}
999
1000/// What the four chunked bulk paths return (0.13.8, W7.6).
1001pub type BulkResult<T> = std::result::Result<T, BulkInterrupted>;
1002
1003/// A guard abort recognised by its message (§4.3).
1004#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1005#[non_exhaustive]
1006pub enum AbortKind {
1007    SingleOpenInterval,
1008    RecordedAtRegression,
1009    DeleteOutsideArchive,
1010    /// A concept id already held by a different lineage (v12, §15.2, D-214).
1011    CrossLineage,
1012    /// An `UPDATE` that would move a concept between lineages (v12, D-214).
1013    BranchImmutable,
1014    /// Any write to `branches` other than an insert (v12, §15.2).
1015    BranchesFrozen,
1016    /// Not one of our guards — an ordinary engine error.
1017    NotAGuard,
1018}
1019
1020/// `SQLITE_CONSTRAINT_TRIGGER` — `SQLITE_CONSTRAINT | (7 << 8)`.
1021///
1022/// The code a `RAISE(ABORT, …)` inside a trigger body carries. Measured on
1023/// libSQL 0.9.30 rather than taken from the header: the probe
1024/// `examples/abort_code_probe.rs` fires one of the crate's own guards and
1025/// prints `SqliteFailure(1811, "…")`, against 1299 for a `NOT NULL` failure
1026/// the engine raises itself.
1027const SQLITE_CONSTRAINT_TRIGGER: std::ffi::c_int = 1811;
1028
1029/// Recognise a schema guard's `RAISE(ABORT, …)`.
1030///
1031/// **The only place in the crate that matches on engine error text**, and since
1032/// 0.15.19 the text is reached only after the *code* says a trigger raised it.
1033/// SQLite flattens every `RAISE(ABORT)` into one constraint failure, so the
1034/// message remains the only thing distinguishing "you violated the
1035/// single-open-interval rule" from "you violated the cross-lineage rule" — but
1036/// it no longer has to distinguish a guard from an unrelated failure.
1037///
1038/// # Code first, then text (0.15.19, review C-20)
1039///
1040/// `libsql::Error::SqliteFailure(1811, msg)` is what a guard produces, and
1041/// `msg` is the crate's own abort string **verbatim** — the probe above prints
1042/// it, and the "libSQL prefix" this was thought to depend on is `Display`'s,
1043/// not the message's. So two things improve at once. The needle is matched
1044/// against the engine's message field rather than against a rendered string,
1045/// which removes `Display` as a layer that can change underneath the
1046/// classification; and a non-trigger error that happens to quote one of these
1047/// strings — a caller's own text echoed back in a `NOT NULL` message, say —
1048/// can no longer be read as a guard.
1049///
1050/// The free-text arm stays as the fallback, because not every route to this
1051/// function produces `SqliteFailure`: an error wrapped or re-rendered on the
1052/// way here would otherwise degrade every guard to
1053/// [`AbortKind::NotAGuard`] at once, which is the silent failure this function
1054/// exists to concentrate rather than to cause.
1055///
1056/// The needles are the [`crate::schema::ddl`] constants spliced into the
1057/// triggers themselves, so guard and classifier cannot drift.
1058pub fn abort_kind(err: &libsql::Error) -> AbortKind {
1059    use crate::schema::ddl::{
1060        ABORT_BRANCHES_FROZEN, ABORT_BRANCH_IMMUTABLE, ABORT_CROSS_LINEAGE, ABORT_DELETE_GUARD,
1061        ABORT_MONOTONIC_RA, ABORT_SINGLE_OPEN,
1062    };
1063
1064    let text = match err {
1065        libsql::Error::SqliteFailure(code, msg) if *code == SQLITE_CONSTRAINT_TRIGGER => {
1066            msg.clone()
1067        }
1068        // Not a trigger abort by its code. Only a variant that carries no code
1069        // at all can still be one of our guards, so an engine failure with a
1070        // *different* code is refused here rather than sent to the text arm.
1071        libsql::Error::SqliteFailure(..) => return AbortKind::NotAGuard,
1072        other => other.to_string(),
1073    };
1074    if text.contains(ABORT_SINGLE_OPEN) {
1075        AbortKind::SingleOpenInterval
1076    } else if text.contains(ABORT_MONOTONIC_RA) {
1077        AbortKind::RecordedAtRegression
1078    } else if text.contains(ABORT_DELETE_GUARD) {
1079        AbortKind::DeleteOutsideArchive
1080    } else if text.contains(ABORT_CROSS_LINEAGE) {
1081        AbortKind::CrossLineage
1082    } else if text.contains(ABORT_BRANCH_IMMUTABLE) {
1083        AbortKind::BranchImmutable
1084    } else if text.contains(ABORT_BRANCHES_FROZEN) {
1085        AbortKind::BranchesFrozen
1086    } else {
1087        AbortKind::NotAGuard
1088    }
1089}
1090
1091/// What a failing statement was trying to do, so a guard abort can name it.
1092#[non_exhaustive]
1093pub enum WriteOp<'a> {
1094    Edge {
1095        source_id: &'a str,
1096        target_id: &'a str,
1097        edge_type: &'a str,
1098    },
1099    Concept {
1100        id: &'a str,
1101        recorded_at: &'a str,
1102        /// The lineage the upsert named, for `DbError::CrossLineage` (0.14.8).
1103        /// It cannot be read back after the abort, because the row it would
1104        /// have been on was never written.
1105        branch: &'a str,
1106    },
1107    Delete {
1108        table: &'a str,
1109    },
1110    /// A derived annotation (0.13.3, W7.2, [`crate::Annotation`]).
1111    ///
1112    /// The only [`WriteOp`] whose failure is not a `RAISE(ABORT)`.
1113    /// `analytics_annotations` carries no triggers at all — that is why it is
1114    /// the cheapest bulk table and why its chunk ceiling is the largest
1115    /// (D-058) — so the guard vocabulary [`abort_kind`] speaks has nothing to
1116    /// say about it. What it does carry is a foreign key onto `concepts`, and
1117    /// that is the failure a caller can actually cause.
1118    Annotation {
1119        concept_id: &'a str,
1120    },
1121}
1122
1123/// `SQLITE_CONSTRAINT_FOREIGNKEY` — `SQLITE_CONSTRAINT | (3 << 8)`.
1124///
1125/// libSQL reports statement failures through
1126/// `libsql::Error::SqliteFailure(extended_error_code(…), …)`, so this is the
1127/// *extended* code and discriminates a foreign-key failure from the CHECK,
1128/// PRIMARY KEY and NOT NULL failures that share primary code 19. Matching the
1129/// primary code would classify a malformed `computed_at` — a different bug with
1130/// a different fix — as a missing concept.
1131const SQLITE_CONSTRAINT_FOREIGNKEY: std::ffi::c_int = 787;
1132
1133/// Recognise a foreign-key failure by its result code, not by its message.
1134///
1135/// The deliberate counterpart to [`abort_kind`]. That function matches text
1136/// because it has no alternative: SQLite flattens every `RAISE(ABORT)` into one
1137/// generic constraint failure and the message is the only thing left. A foreign
1138/// key is enforced by the engine itself and carries a code of its own, so
1139/// nothing here depends on wording — an upstream message change cannot degrade
1140/// this classification, which is exactly the failure mode `abort_kind`'s
1141/// rustdoc warns about and cannot escape.
1142fn is_foreign_key_violation(err: &libsql::Error) -> bool {
1143    matches!(err, libsql::Error::SqliteFailure(code, _) if *code == SQLITE_CONSTRAINT_FOREIGNKEY)
1144}
1145
1146/// Turn an engine error into the typed error §7 specifies, where one applies.
1147///
1148/// Takes a connection because `RecordedAtRegression` reports the value it
1149/// clashed with, and the trigger does not put it in the message. One extra query
1150/// on an error path buys an error a caller can act on instead of one they have
1151/// to reproduce by hand.
1152pub async fn classify(conn: &libsql::Connection, err: libsql::Error, op: WriteOp<'_>) -> DbError {
1153    match (abort_kind(&err), op) {
1154        (
1155            AbortKind::SingleOpenInterval,
1156            WriteOp::Edge {
1157                source_id,
1158                target_id,
1159                edge_type,
1160            },
1161        ) => DbError::SingleOpenViolation {
1162            source_id: source_id.to_string(),
1163            target_id: target_id.to_string(),
1164            edge_type: edge_type.to_string(),
1165        },
1166        (
1167            AbortKind::RecordedAtRegression,
1168            WriteOp::Concept {
1169                id, recorded_at, ..
1170            },
1171        ) => {
1172            let had = current_recorded_at(conn, id).await.unwrap_or_default();
1173            DbError::RecordedAtRegression {
1174                got: recorded_at.to_string(),
1175                had,
1176            }
1177        }
1178        (AbortKind::DeleteOutsideArchive, WriteOp::Delete { table }) => DbError::ArchiveViolation {
1179            table: table.to_string(),
1180        },
1181        (AbortKind::CrossLineage, WriteOp::Concept { id, branch, .. }) => DbError::CrossLineage {
1182            id: id.to_string(),
1183            held_by: lineage_of_concept(conn, id).await,
1184            attempted: branch.to_string(),
1185        },
1186        // An annotation naming a concept that is not there. The engine says
1187        // "FOREIGN KEY constraint failed" and no more — not which row, and a
1188        // rejected chunk may hold up to `chunk_rows::ANNOTATIONS` of them. The
1189        // typed error names the concept, which is the fact the database
1190        // actually knows and the one the caller has to act on.
1191        (_, WriteOp::Annotation { concept_id }) if is_foreign_key_violation(&err) => {
1192            DbError::NotFound(concept_id.to_string())
1193        }
1194        // The same treatment for an edge, which W7.2 left out because its scope
1195        // was the annotation path (C-1, D-176). `links` declares **two** keys
1196        // into `concepts`, so unlike the annotation case the message does not
1197        // even narrow it to one column: an unqualified "FOREIGN KEY constraint
1198        // failed" is all a caller gets for a batch that may have named the
1199        // wrong source, the wrong target, or both.
1200        //
1201        // Which one is missing is a question the database can answer, so it is
1202        // asked rather than guessed. The source is reported when both are
1203        // absent — one name a caller can act on beats a compound message that
1204        // has to be parsed.
1205        (
1206            _,
1207            WriteOp::Edge {
1208                source_id,
1209                target_id,
1210                ..
1211            },
1212        ) if is_foreign_key_violation(&err) => {
1213            DbError::NotFound(missing_endpoint(conn, source_id, target_id).await)
1214        }
1215        // A guard fired for an operation it does not describe. Reporting the raw
1216        // error is honest; inventing a typed one from the wrong context is not.
1217        _ => DbError::Engine(err),
1218    }
1219}
1220
1221/// Who holds a concept id, for [`DbError::CrossLineage`].
1222///
1223/// The refused lineage is not read back — the row was never written — so it
1224/// comes from [`WriteOp::Concept`], which is the only place it survives the
1225/// abort. Falls back to `"?"` rather than guessing when the read fails, for
1226/// [`missing_endpoint`]'s reason: a classifier that can fail twice is worse than
1227/// one that answers approximately.
1228async fn lineage_of_concept(conn: &libsql::Connection, id: &str) -> String {
1229    let unknown = || "?".to_string();
1230    let Ok(mut rows) = conn
1231        .query(
1232            "SELECT branch_id FROM concepts WHERE id = ?1",
1233            libsql::params![id],
1234        )
1235        .await
1236    else {
1237        return unknown();
1238    };
1239    match rows.next().await {
1240        Ok(Some(row)) => row.get::<String>(0).unwrap_or_else(|_| unknown()),
1241        _ => unknown(),
1242    }
1243}
1244
1245/// Which endpoint of a refused edge is not in `concepts` (C-1).
1246///
1247/// One query on an error path, for the reason [`classify`]'s own rustdoc gives:
1248/// it buys an error a caller can act on instead of one they have to reproduce
1249/// by hand. Falls back to the source id if the query itself fails, because a
1250/// classifier that can fail twice is worse than one that answers approximately.
1251///
1252/// # The concepts path, and why it still needs no arm of its own
1253///
1254/// C-1 names `links` **and** `concepts`. Since v12 `concepts` carries an
1255/// outbound key — `branch_id` into `branches` (§15.2) — and **since 0.14.8 a
1256/// caller can choose what goes in it**, which is the condition this paragraph
1257/// used to say would need an arm "with a different column".
1258///
1259/// It still does not, and the reason moved rather than held: the write path
1260/// checks every lineage a write names *before* it opens the transaction
1261/// (`connection::check_lineages`), so an unregistered branch comes back as
1262/// [`DbError::UnknownBranch`] naming the branch, and the foreign key never
1263/// fires. An arm here would be a classification for a state the API cannot
1264/// reach — defect Q's shape, a typed error no code path can produce — and the
1265/// honest place for the refusal is the one that can say *branch* rather than
1266/// *constraint*.
1267async fn missing_endpoint(conn: &libsql::Connection, source_id: &str, target_id: &str) -> String {
1268    for id in [source_id, target_id] {
1269        let Ok(mut rows) = conn
1270            .query("SELECT 1 FROM concepts WHERE id = ?1", libsql::params![id])
1271            .await
1272        else {
1273            return source_id.to_string();
1274        };
1275        if !matches!(rows.next().await, Ok(Some(_))) {
1276            return id.to_string();
1277        }
1278    }
1279    source_id.to_string()
1280}
1281
1282async fn current_recorded_at(conn: &libsql::Connection, id: &str) -> Option<String> {
1283    conn.query(
1284        "SELECT recorded_at FROM concepts WHERE id = ?1",
1285        libsql::params![id],
1286    )
1287    .await
1288    .ok()?
1289    .next()
1290    .await
1291    .ok()??
1292    .get(0)
1293    .ok()
1294}
1295
1296#[cfg(test)]
1297mod tests {
1298    use super::*;
1299
1300    /// `DbError` stays under `clippy::result_large_err`'s 128-byte threshold.
1301    ///
1302    /// Every fallible function in the crate returns `Result<T, DbError>`, so the
1303    /// enum's size is paid on the `Ok` path too. D-060 pushed it to 168 bytes with
1304    /// one seven-`String` variant and nobody noticed until D-075 read the lint
1305    /// output; boxing that variant brought it back. This is the tripwire, because
1306    /// the failure mode is a warning in a build log rather than a broken test —
1307    /// the kind this cycle has spent its whole length finding.
1308    #[test]
1309    fn the_error_enum_stays_small_enough_to_return_by_value() {
1310        let size = std::mem::size_of::<DbError>();
1311        assert!(
1312            size <= 128,
1313            "DbError is {size} bytes. Some variant has grown past what a Result \n             should carry — box it, as OverlappingInterval is boxed (D-075)."
1314        );
1315    }
1316
1317    fn sample_overlap(within_batch: bool) -> DbError {
1318        DbError::OverlappingInterval {
1319            overlap: Box::new(Overlap {
1320                source_id: "a".into(),
1321                target_id: "b".into(),
1322                edge_type: "KNOWS".into(),
1323                valid_from: "2026-03-01T00:00:00.000000Z".into(),
1324                valid_to: "2026-09-01T00:00:00.000000Z".into(),
1325                existing_from: "2026-01-01T00:00:00.000000Z".into(),
1326                existing_to: "2026-06-01T00:00:00.000000Z".into(),
1327                within_batch,
1328            }),
1329        }
1330    }
1331
1332    /// The boxed variant still reports both intervals.
1333    #[test]
1334    fn an_overlap_names_the_asserted_interval_and_the_other_one() {
1335        let msg = sample_overlap(false).to_string();
1336        assert!(msg.contains("a -> b (KNOWS)"), "{msg}");
1337        assert!(msg.contains("asserted [2026-03-01"), "{msg}");
1338        assert!(msg.contains("[2026-01-01"), "{msg}");
1339    }
1340
1341    /// One error, two guards, and only one of them is talking about the
1342    /// database (0.13.7, D-180).
1343    ///
1344    /// `reject_overlaps_within` refuses the batch *before* the transaction
1345    /// opens, so the interval it names is not stored and will not become
1346    /// stored. Saying the edge "already holds" it sent a caller looking for a
1347    /// row that was never written.
1348    #[test]
1349    fn an_overlap_says_which_side_of_the_write_the_other_interval_is_on() {
1350        let stored = sample_overlap(false).to_string();
1351        assert!(stored.contains("is already recorded"), "{stored}");
1352        assert!(!stored.contains("batch"), "{stored}");
1353
1354        let in_batch = sample_overlap(true).to_string();
1355        assert!(
1356            in_batch.contains("this same batch also asserts"),
1357            "{in_batch}"
1358        );
1359        assert!(!in_batch.contains("recorded"), "{in_batch}");
1360    }
1361
1362    /// The count is the whole reason this type exists, so it has to be in the
1363    /// sentence a caller sees, not only in a field they have to know about
1364    /// (0.13.8, W7.6).
1365    #[test]
1366    fn a_partial_bulk_failure_says_how_much_landed() {
1367        let e = BulkInterrupted {
1368            written: 18_935,
1369            cause: DbError::NotFound("ghost".into()),
1370        };
1371        let text = e.to_string();
1372        assert!(text.contains("node ghost not found"), "{text}");
1373        assert!(text.contains("18935"), "{text}");
1374    }
1375
1376    /// Cancellation is not a fault, and the type says which it was without the
1377    /// caller matching on a variant.
1378    #[test]
1379    fn cancellation_is_distinguishable_from_a_failure() {
1380        assert!(BulkInterrupted {
1381            written: 7,
1382            cause: DbError::BulkCancelled,
1383        }
1384        .was_cancelled());
1385        assert!(!BulkInterrupted {
1386            written: 7,
1387            cause: DbError::WriterUnavailable,
1388        }
1389        .was_cancelled());
1390    }
1391
1392    /// `?` into a `Result<_, DbError>` keeps the cause and drops the count.
1393    /// Both halves of that are deliberate; this pins them.
1394    #[test]
1395    fn converting_to_a_db_error_keeps_the_cause_and_loses_the_count() {
1396        let e = BulkInterrupted {
1397            written: 400,
1398            cause: DbError::SingleOpenViolation {
1399                source_id: "a".into(),
1400                target_id: "b".into(),
1401                edge_type: "KNOWS".into(),
1402            },
1403        };
1404        let cause: DbError = e.into();
1405        assert!(matches!(cause, DbError::SingleOpenViolation { .. }));
1406        assert!(!cause.to_string().contains("400"));
1407    }
1408
1409    /// The error chain reaches the cause, so `anyhow`-style reporters print
1410    /// both lines rather than only the wrapper's.
1411    #[test]
1412    fn the_cause_is_reachable_as_an_error_source() {
1413        use std::error::Error;
1414        let e = BulkInterrupted {
1415            written: 1,
1416            cause: DbError::BulkCancelled,
1417        };
1418        assert_eq!(
1419            e.source().map(ToString::to_string).as_deref(),
1420            Some("the bulk write was cancelled between chunks")
1421        );
1422    }
1423
1424    /// The kind is a property of the variant, not of what it is carrying
1425    /// (0.14.25, C-3, [D-242]).
1426    ///
1427    /// [D-242]: ../../docs/architecture/s13-decision-register.md#d-242
1428    #[test]
1429    fn the_kind_is_the_same_whatever_the_fields_say() {
1430        let a = DbError::SnapshotWriteFailed {
1431            path: "snapshots/1.snap.zst".into(),
1432            reason: "no space left on device".into(),
1433        };
1434        let b = DbError::SnapshotWriteFailed {
1435            path: "elsewhere".into(),
1436            reason: "read-only file system".into(),
1437        };
1438        assert_eq!(a.kind(), b.kind());
1439        assert_eq!(a.kind(), ErrorKind::Temporal);
1440    }
1441
1442    /// A snapshot that could not be written and a damaged ledger are the same
1443    /// *kind*, and that is deliberate: [D-240] split them so a caller learns
1444    /// which subject is broken, and the kind answers the coarser question. The
1445    /// discriminant does not replace matching the variant, and this test is
1446    /// where that is written down rather than assumed.
1447    ///
1448    /// [D-240]: ../../docs/architecture/s13-decision-register.md#d-240
1449    #[test]
1450    fn the_kind_is_coarser_than_the_variant_and_says_so() {
1451        let unwritten = DbError::SnapshotWriteFailed {
1452            path: "p".into(),
1453            reason: "r".into(),
1454        };
1455        let damaged = DbError::ReplayCorrupt {
1456            seq: 7,
1457            reason: "r".into(),
1458        };
1459        assert_eq!(unwritten.kind(), damaged.kind());
1460        assert_ne!(unwritten.to_string(), damaged.to_string());
1461    }
1462
1463    /// Two kinds never share a name, or a metrics label collapses two
1464    /// populations into one bar.
1465    #[test]
1466    fn no_two_kinds_spell_themselves_the_same_way() {
1467        use std::collections::BTreeSet;
1468        let kinds = [
1469            ErrorKind::Integrity,
1470            ErrorKind::Validation,
1471            ErrorKind::Vector,
1472            ErrorKind::Temporal,
1473            ErrorKind::Writer,
1474            ErrorKind::Budget,
1475            ErrorKind::Branch,
1476            ErrorKind::Cancelled,
1477            ErrorKind::Diagnostic,
1478            ErrorKind::Engine,
1479            ErrorKind::Migration,
1480            ErrorKind::NotFound,
1481        ];
1482        let names: BTreeSet<&str> = kinds.iter().map(|k| k.as_str()).collect();
1483        assert_eq!(
1484            names.len(),
1485            kinds.len(),
1486            "two kinds share a label: {names:?}"
1487        );
1488        for kind in kinds {
1489            assert_eq!(kind.to_string(), kind.as_str(), "Display and as_str differ");
1490        }
1491    }
1492
1493    /// It goes where a `DbError` cannot: a key.
1494    #[test]
1495    fn a_kind_can_be_counted() {
1496        use std::collections::HashMap;
1497        let mut seen: HashMap<ErrorKind, usize> = HashMap::new();
1498        for err in [
1499            DbError::WriterStopped("a write".into()),
1500            DbError::WriterDroppedResponder,
1501            DbError::BulkCancelled,
1502        ] {
1503            *seen.entry(err.kind()).or_default() += 1;
1504        }
1505        assert_eq!(seen[&ErrorKind::Writer], 2);
1506        assert_eq!(seen[&ErrorKind::Cancelled], 1);
1507    }
1508}