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