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}