Skip to main content

macrame/temporal/
replay.rs

1use serde::{Deserialize, Serialize};
2use std::collections::{HashMap, HashSet};
3use std::path::{Path, PathBuf};
4
5use crate::branch::Ancestor;
6use crate::error::{DbError, Result};
7use crate::temporal::as_of::NodeAttributes;
8
9/// One lineage's belief about one edge, at the instant a fold asked (§15.2).
10///
11/// # Why this is a struct and was a five-tuple until 0.14.5
12///
13/// The tuple had nowhere to put `branch_id`, and that was not a cosmetic
14/// shortfall: [D-216](../../docs/architecture/s13-decision-register.md) widened
15/// the four SQL folds to partition by `(table_name, entity_id, branch_id)` so
16/// two lineages' beliefs about one edge would stay two rows, and then the
17/// composition immediately downstream re-collapsed them, because `edge_key`
18/// composed `source|target|type|valid_from` and the map it fed had one slot per
19/// edge key. The widened partition was handing two rows to a container that
20/// could not hold two. That is [D-221](../../docs/architecture/s13-decision-register.md#d-221),
21/// and this type is its fix.
22///
23/// A struct rather than a six-tuple because the next field to arrive should be
24/// additive, which is why it is also `#[non_exhaustive]` — the same call
25/// [D-207](../../docs/architecture/s13-decision-register.md#d-207) made for
26/// `DbError`, one release earlier, for the same reason. Construct these by
27/// reading a [`MaterializedState`]; the crate is the only writer.
28///
29/// **Ordered by the tuple order of its fields**, so a `Vec<EdgeBelief>` sorts to
30/// a canonical form and two reconstructions of the same instant are *equal*
31/// rather than merely equivalent — a property the snapshot suite compares on.
32///
33/// # Constructing one
34///
35/// `#[non_exhaustive]` means no crate but this one may write the literal, and
36/// [`save_snapshot`](crate::temporal::save_snapshot) is public and takes a
37/// `MaterializedState` — so without a constructor the attribute would not make
38/// the next field additive, it would make a public function uncallable. Use
39/// [`EdgeBelief::new`], which takes the five fields that were the tuple and
40/// defaults the sixth to the trunk, with [`EdgeBelief::on_branch`] for the
41/// rest. That is [`EdgeAssertion::new`](crate::graph::EdgeAssertion::new)'s
42/// shape, deliberately: the two are the same fact travelling in opposite
43/// directions and should not need two idioms.
44#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
45#[non_exhaustive]
46pub struct EdgeBelief {
47    pub source_id: String,
48    pub target_id: String,
49    pub edge_type: String,
50    pub valid_from: String,
51    pub valid_to: String,
52    /// The lineage that holds this belief (0.14.5, D-221).
53    ///
54    /// `#[serde(default = "default_branch")]` so the field is additive at the
55    /// bincode level. It is belt and braces — the snapshot container refuses any
56    /// file whose format version is not this build's, and 0.14.5 bumps it
57    /// precisely so a state written without this field gets a named refusal
58    /// rather than a deserialisation error — but a default that is *right* costs
59    /// nothing and `'main'` is what every pre-v12 row actually carried.
60    #[serde(default = "default_branch")]
61    pub branch_id: String,
62}
63
64fn default_branch() -> String {
65    crate::schema::ddl::MAIN_BRANCH.to_string()
66}
67
68impl EdgeBelief {
69    /// A belief held by the trunk. Use [`Self::on_branch`] for any other.
70    ///
71    /// Five arguments rather than six because `main` is what every belief
72    /// written before 0.14.5 carried, so a caller porting a five-tuple wraps it
73    /// and is correct rather than being asked a question the old shape could
74    /// not have answered.
75    pub fn new(
76        source_id: impl Into<String>,
77        target_id: impl Into<String>,
78        edge_type: impl Into<String>,
79        valid_from: impl Into<String>,
80        valid_to: impl Into<String>,
81    ) -> Self {
82        Self {
83            source_id: source_id.into(),
84            target_id: target_id.into(),
85            edge_type: edge_type.into(),
86            valid_from: valid_from.into(),
87            valid_to: valid_to.into(),
88            branch_id: default_branch(),
89        }
90    }
91
92    /// The lineage holding this belief.
93    ///
94    /// Unchecked against `branches`, because this type is a value and not a
95    /// write: a `MaterializedState` naming a lineage the register has never
96    /// heard of is a snapshot that will disagree with the log, which
97    /// `verify_snapshot_chain` is there to report.
98    pub fn on_branch(mut self, branch_id: impl Into<String>) -> Self {
99        self.branch_id = branch_id.into();
100        self
101    }
102
103    /// The log `entity_id` this belief was folded under.
104    ///
105    /// Must match `trg_links_log_insert`'s
106    /// `source_id || '|' || target_id || '|' || edge_type || '|' || valid_from`
107    /// exactly, or a delta row will fail to replace the snapshot row it
108    /// supersedes. Safe because ULIDs are Crockford base32 and edge types are
109    /// `[A-Z0-9]+`, so `|` cannot occur inside a component (§4.3).
110    ///
111    /// **This is not a unique key across lineages** and must not be used as one
112    /// — see [`Self::belief_key`], which is.
113    pub fn entity_id(&self) -> String {
114        format!(
115            "{}|{}|{}|{}",
116            self.source_id, self.target_id, self.edge_type, self.valid_from
117        )
118    }
119
120    /// What identifies this belief: the edge key **and** the lineage holding it.
121    pub fn belief_key(&self) -> String {
122        format!("{}|{}", self.entity_id(), self.branch_id)
123    }
124}
125
126/// Full materialized state reconstructed from transaction_log replay (§5.5).
127#[derive(Debug, Clone, Serialize, Deserialize)]
128#[non_exhaustive]
129pub struct MaterializedState {
130    pub seq_anchor: i64,
131    pub timestamp: String,
132    pub concepts: HashMap<String, NodeAttributes>,
133    /// Every lineage's belief, each labelled with the lineage holding it.
134    ///
135    /// **Not resolved to one lineage's view**, and deliberately: `reconstruct`
136    /// asks a whole-ledger question — *what did the ledger hold at `ts`* — and
137    /// the ledger held both. Resolving here would require an ancestry, which
138    /// requires a connection this type does not have, and would silently answer
139    /// a narrower question than the one asked. A caller wanting one lineage's
140    /// view uses `graph::TraversalBuilder::on_branch` or
141    /// `temporal::query_as_of_edges_on`, which resolve against the register
142    /// ([D-220](../../docs/architecture/s13-decision-register.md#d-220)).
143    pub edges: Vec<EdgeBelief>,
144    /// **Nothing had been recorded yet at `timestamp`** (0.8.0, B5, D-121).
145    ///
146    /// An empty state has two meanings and a caller can act differently on
147    /// them. *Everything was retired by then* is a fact about the data;
148    /// *the ledger had not started* is a fact about the question. Both come
149    /// back as zero concepts and zero edges, so the difference has to be
150    /// carried rather than inferred.
151    ///
152    /// Set only when the log was verified **intact** — see
153    /// `hot_log_reach`. If rows had been archived away, `ts` below the hot
154    /// floor is not "before history", it is "the history is in the other file",
155    /// and that path raises instead of answering.
156    ///
157    /// `#[serde(default)]` so the field is additive: a snapshot written without
158    /// it deserialises with `false`, which is the right answer for any state
159    /// that had rows to fold. Old snapshots cannot actually reach this code —
160    /// the container carries `SCHEMA_VERSION` and v8 refused every v7 file
161    /// (D-043) — but the tolerance costs nothing and the next field to arrive
162    /// may not land in a release that bumps the schema.
163    #[serde(default)]
164    pub predates_recorded_history: bool,
165}
166
167impl MaterializedState {
168    /// The state before any log row has been applied, at `ts`.
169    ///
170    /// Public since 0.15.13 (W15.3, [D-255]) because the struct became
171    /// `#[non_exhaustive]` in that release and [`save_snapshot`] takes one:
172    /// a caller who writes a snapshot of a state they assembled needs a way in
173    /// that is not the field literal. Assemble from here — the fields are
174    /// `pub` and stay assignable on a value you own.
175    ///
176    /// [`save_snapshot`]: crate::temporal::save_snapshot
177    /// [D-255]: ../../docs/architecture/s13-decision-register.md#d-255
178    pub fn empty(ts: &str) -> Self {
179        Self {
180            seq_anchor: 0,
181            timestamp: ts.to_string(),
182            concepts: HashMap::new(),
183            edges: Vec::new(),
184            predates_recorded_history: false,
185        }
186    }
187}
188
189/// One lineage's view of a fold, from every lineage's beliefs (review C-10).
190///
191/// The nearest lineage holding an edge key wins, and a key no visible lineage
192/// holds is absent. That is `graph::lineage::visible_cte`'s rule — `ROW_NUMBER() OVER
193/// (PARTITION BY the edge key ORDER BY g.dist)`, `rn = 1` — written once more,
194/// in Rust, over a value a caller already has (0.15.17, [D-259]).
195///
196/// # What this is for
197///
198/// Until this release the rule existed **only** as SQL. A caller holding a
199/// [`MaterializedState`] — from [`reconstruct`], or read back from a snapshot —
200/// had every lineage's belief in one `Vec` and no function in the crate that
201/// would finish the question, so the choice was to re-issue the read through
202/// `graph::TraversalBuilder::on_branch` (a different query against the
203/// *projection*, not against the state in hand) or to reimplement the rule.
204/// Reimplementing it is what review C-10 expected someone to do, and the two
205/// copies would have drifted the first time a shape was added.
206///
207/// # Ties, and why there are none to break in practice
208///
209/// A fold emits at most one row per `(edge key, lineage)` and an ancestry names
210/// each lineage once, so no two candidates for a key share a `dist` and the
211/// winner is determined by distance alone. This still breaks ties on the
212/// belief's own ordering rather than on iteration order, because it is a public
213/// pure function: an input the crate did not build should get an answer that is
214/// a function of the input, not of a hash seed.
215///
216/// # The cutoff is not applied here, and cannot be
217///
218/// An ancestor's rows are visible to a descendant only up to
219/// [`Ancestor::cutoff`], and that is a comparison against the row's
220/// `recorded_at` — a column [`EdgeBelief`] does not carry, deliberately, since
221/// a belief is *what was believed* and not *when it was written down*. So the
222/// cutoff belongs to whatever produced the beliefs: [`reconstruct_on`] applies
223/// it by folding each ancestor to its own instant before calling this.
224///
225/// Handing this the unbounded `edges` of a plain [`reconstruct`] therefore
226/// gives the nearest-lineage answer **without** the fork bound — right on a
227/// database whose ancestors have not been written to since the fork, and
228/// quietly wide on one that has. The doc says so rather than the signature,
229/// because a `&[EdgeBelief]` cannot be typed into "already cut".
230///
231/// [D-259]: ../../docs/architecture/s13-decision-register.md#d-259
232pub fn resolve_beliefs(beliefs: &[EdgeBelief], ancestry: &[Ancestor]) -> Vec<EdgeBelief> {
233    let rank: HashMap<&str, i64> = ancestry
234        .iter()
235        .map(|a| (a.branch_id.as_str(), a.dist))
236        .collect();
237
238    let mut best: HashMap<String, (i64, &EdgeBelief)> = HashMap::new();
239    for belief in beliefs {
240        // A lineage outside the ancestry is not an ancestor and not the reader:
241        // a sibling, or a descendant. `visible_cte` drops those by inner-joining
242        // `lineage`, which is the same thing said in SQL.
243        let Some(&dist) = rank.get(belief.branch_id.as_str()) else {
244            continue;
245        };
246        best.entry(belief.entity_id())
247            .and_modify(|held| {
248                if (dist, belief) < (held.0, held.1) {
249                    *held = (dist, belief);
250                }
251            })
252            .or_insert((dist, belief));
253    }
254
255    // Sorted for the same reason `Delta::apply_to` sorts: the answer is a
256    // function of the state, not of hash iteration order.
257    let mut out: Vec<EdgeBelief> = best.into_values().map(|(_, b)| b.clone()).collect();
258    out.sort();
259    out
260}
261
262/// State at `ts` **as one lineage saw it** (0.15.17, [D-259], review C-10).
263///
264/// [`reconstruct`] answers a whole-ledger question — *what did the ledger hold
265/// at `ts`* — and the ledger held every lineage's belief at once. This answers
266/// the narrower one a caller usually means: *what did `branch` hold at `ts`*,
267/// with the ancestry resolved and each ancestor bounded at its fork point.
268///
269/// # How it is assembled
270///
271/// One fold, with the ancestry bound into it: the `JOIN` against the `lineage`
272/// relation drops every lineage the reader cannot see, `recorded_at <=
273/// g.cutoff` bounds each ancestor at its own fork point, and [`resolve_beliefs`]
274/// then picks the nearest holder of each key. The cut is applied to the
275/// window's **input**, which is the part that cannot be done any other way —
276/// see `bounded_hot_fold`.
277///
278/// ## The shape this is not, and the measurement that decided it
279///
280/// The first version folded once per **distinct effective instant** — `min(ts,
281/// cutoff)` for the reader and each ancestor — and kept from each fold the
282/// lineages whose instant it was. That form reuses [`reconstruct`] whole,
283/// snapshot composition included, and the argument for it was that a fork depth
284/// of 1 is two cheap folds where this one is a single expensive one.
285///
286/// Measured (`examples/reconstruct_on_probe.rs`, 400 concepts), the argument
287/// holds at exactly one of the four configurations tried:
288///
289/// | snapshots | fork depth | fold-per-bound | this |
290/// |---|---|---|---|
291/// | off | 1 | 5.6 ms | **2.9 ms** |
292/// | off | 8 | 24.1 ms | **3.2 ms** |
293/// | on | 1 | **2.0 ms** | 2.9 ms |
294/// | on | 8 | 7.5 ms | **3.2 ms** |
295///
296/// Both shapes run in one process against one build, alternating, because the
297/// first version of this comparison ran them in two processes against two
298/// builds and that is thin evidence for reversing a design. The probe also
299/// asserts that the two shapes return the **same edges** before it times them,
300/// and counts the snapshot files on disk rather than trusting that asking for a
301/// cadence produced one — the whole argument for fold-per-bound rests on
302/// composition actually being available.
303///
304/// The per-bound form is linear in fork depth and this one is flat, so the
305/// crossover is at depth 2 with snapshots configured and below depth 1 without
306/// them. Losing about 1 ms at the one point where the other shape wins buys a
307/// cost that does not depend on how deeply a caller has forked, and one code
308/// path instead of two — the same call [D-056]'s guard made earlier in this
309/// release for the same reason.
310///
311/// ## What that costs: no snapshot composition
312///
313/// A snapshot is a materialised state with no `recorded_at` left in it, so
314/// there is nothing for a cutoff to compare against and no way to anchor a
315/// bounded fold on one. This therefore folds from genesis every time, which is
316/// where the flat ~3 ms comes from — and why, on a database with snapshots
317/// configured, this is **4x** [`reconstruct`] rather than 1.2x. The absolute
318/// cost is the same in both configurations; it is `reconstruct` that gets
319/// faster, not this that gets slower.
320///
321/// An unforked database never pays any of it: the shape is `Trunk`, there is
322/// one lineage and nothing to resolve, and this delegates to [`reconstruct`]
323/// unchanged, snapshots and all.
324///
325/// # Concepts need no distance rule, because the tie cannot happen (0.15.18,
326/// [D-260])
327///
328/// [`MaterializedState::concepts`] is keyed by concept id alone, so once a row
329/// is folded there is no lineage left on it to pick a nearest one by. The fold
330/// here *is* narrowed — a lineage outside the ancestry contributes nothing, and
331/// an ancestor's post-cutoff concept writes are cut like its edges — and that
332/// narrowing is all a concept needs, because **two visible lineages cannot both
333/// hold one concept id**.
334///
335/// That is the schema's guarantee and not this function's. `concepts.id` is
336/// `NOT NULL UNIQUE` — identity, not identity-per-lineage — and
337/// `trg_concepts_cross_lineage` turns the index's refusal into
338/// [`DbError::CrossLineage`](crate::DbError::CrossLineage) so it says which
339/// rule was broken; `trg_concepts_branch_immutable` stops a concept being moved
340/// to another lineage afterwards. A branch therefore **inherits** its parent's
341/// concepts and cannot restate them (§15.2, [D-225]), which is the same rule
342/// read from the other side.
343///
344/// The one route past that guard is [`archive_branch`] — it reads the live
345/// table, and archiving moves rows out of it — so archiving a lineage and then
346/// minting its id on the trunk does leave two lineages' rows for one id in
347/// hot-plus-cold history. It still reaches no reader: an archived lineage is
348/// gone from `branches`, so it is in nobody's ancestry, so the `JOIN` above
349/// drops its rows on **both** arms (the cold one joins the union, not each
350/// file). `rehydrate` refuses to bring the concept back while its lineage is
351/// forgotten ([D-253]). `examples/concept_lineage_probe.rs` walks all five
352/// routes and prints which the database refuses.
353///
354/// So this is not a resolution the caller must compensate for. It is a rule
355/// with nothing to decide, and if `concepts` ever gained per-lineage rows —
356/// the overlay design [D-214] defers — it would need one, along with a lineage
357/// on the folded row to apply it to.
358///
359/// Only [`MaterializedState::edges`] gets the distance rule, which is the field
360/// review C-10 named and the only one the rule has ever been needed for.
361///
362/// [D-260]: ../../docs/architecture/s13-decision-register.md#d-260
363/// [D-253]: ../../docs/architecture/s13-decision-register.md#d-253
364/// [D-225]: ../../docs/architecture/s13-decision-register.md#d-225
365/// [D-214]: ../../docs/architecture/s13-decision-register.md#d-214
366/// [`archive_branch`]: crate::Database::archive_branch
367///
368/// # This result is not a snapshot
369///
370/// It is one lineage's *view*, so it is missing beliefs the ledger holds. Do not
371/// pass it to [`save_snapshot`](crate::temporal::save_snapshot): a later
372/// [`reconstruct`] anchoring on it would compose a whole-ledger answer on top of
373/// a partial base and return the other lineages' rows only where something
374/// touched them again. [`seq_anchor`](MaterializedState::seq_anchor) is the
375/// highest `seq_id` among the rows this lineage can *see*, which is the honest
376/// number for what was folded and is not a licence to anchor on it.
377///
378/// [D-056]: ../../docs/architecture/s13-decision-register.md#d-056
379///
380/// # Errors
381///
382/// [`DbError::UnknownBranch`](crate::DbError::UnknownBranch), naming it, when
383/// `branch` is not registered — refused rather than answered for the trunk, for
384/// the reason `graph::lineage::Lineages::shape` gives.
385///
386/// Otherwise the same refusals [`reconstruct`] raises at `ts`, and for the same
387/// reasons: reach is decided by `ts` alone, because the cutoffs are a predicate
388/// inside one query rather than instants of their own.
389///
390/// # Where this narrows silently, named rather than left to be found
391///
392/// An ancestor's inherited row is the *last* one it wrote at or before the fork
393/// point, and the fold finds it in the hot log. `LOG_ARCHIVABLE` archives an
394/// entry once a later one supersedes it for the same entity — so a pre-fork
395/// assertion that the ancestor corrected afterwards is archivable, and once the
396/// retention horizon passes the fork point it can be cold. The reader then
397/// loses an edge it should have inherited, and **nothing raises**, because `ts`
398/// is well inside the hot log and reach was asked about `ts`.
399///
400/// That is not new and not this function's: it is the same degradation
401/// `graph::lineage`'s module docs describe for `links_cut`, reached from the
402/// fold side instead of the projection side, and it is bounded to keys an
403/// ancestor churned after forking. Passing `archive_path` closes it — the cold
404/// arm unions both files before it cuts.
405///
406/// [D-259]: ../../docs/architecture/s13-decision-register.md#d-259
407pub async fn reconstruct_on(
408    conn: &libsql::Connection,
409    ts: &str,
410    branch: &str,
411    archive_path: Option<&Path>,
412    snapshots_dir: Option<&Path>,
413) -> Result<MaterializedState> {
414    let (shape, ancestry) = crate::graph::lineage::resolve_for(conn, Some(branch)).await?;
415    if !shape.binds_branch() {
416        // `Trunk`: one lineage, so every belief in the ledger is this one's and
417        // the resolution is the identity. Delegated rather than run through the
418        // machinery below so that an unforked database pays nothing at all for
419        // this function existing.
420        return reconstruct(conn, ts, archive_path, snapshots_dir).await;
421    }
422
423    // `TrunkOnForked` resolves to its own rows and `resolve_for` leaves its
424    // ancestry empty, because the SQL form for that shape emits no `lineage`
425    // relation to fill. Here the one-row ancestry is what expresses "its own
426    // rows", so it is written out rather than special-cased below.
427    let ancestry = if ancestry.is_empty() {
428        vec![Ancestor {
429            branch_id: branch.to_string(),
430            dist: 0,
431            cutoff: None,
432        }]
433    } else {
434        ancestry
435    };
436
437    // `?1` is the instant; the ancestry block follows it, which is the same
438    // "fixed slots first, ancestry last" layout every read path uses.
439    const ANCESTRY_SLOT: usize = 2;
440    let mut params: Vec<libsql::Value> = vec![ts.into()];
441    params.extend(crate::graph::lineage::ancestry_params(&ancestry));
442    let rows = ancestry.len();
443
444    let state = match hot_log_reach(conn, ts, archive_path).await? {
445        HotLogReach::Covers => {
446            let delta =
447                fold_delta(conn, &bounded_hot_fold(rows, ANCESTRY_SLOT), params.clone()).await?;
448            Some(delta.apply_to(MaterializedState::empty(ts), ts))
449        }
450        HotLogReach::PredatesRecordedHistory => {
451            let mut state = MaterializedState::empty(ts);
452            state.predates_recorded_history = true;
453            Some(state)
454        }
455        HotLogReach::NeedsArchive => None,
456    };
457
458    let mut out = match state {
459        Some(s) => s,
460        // The cold arm, spelled the way `reconstruct` spells it: the same
461        // refusals, the same ATTACH/DETACH pairing, and the same reason the
462        // hint is computed inside the error arms rather than before them.
463        None => {
464            let archive = match archive_path {
465                Some(p) => p,
466                None => {
467                    return Err(DbError::ReplayCorrupt {
468                        seq: 0,
469                        reason: format!(
470                            "state at {ts} predates the hot log and no archive path was given; {}",
471                            archive_hint(conn).await
472                        ),
473                    })
474                }
475            };
476            if !crate::temporal::archive::archive_present(archive) {
477                return Err(DbError::ReplayCorrupt {
478                    seq: 0,
479                    reason: format!(
480                        "archive database file {archive:?} does not exist; {}",
481                        archive_hint(conn).await
482                    ),
483                });
484            }
485
486            detach_stale_cold(conn).await;
487            conn.execute(
488                "ATTACH DATABASE ?1 AS cold",
489                libsql::params![archive.to_string_lossy().as_ref()],
490            )
491            .await?;
492            let cold_shape = cold_lineage(conn).await;
493            let result = fold_delta(
494                conn,
495                &bounded_cold_fold(cold_shape, rows, ANCESTRY_SLOT),
496                params,
497            )
498            .await;
499            if let Err(e) = conn.execute("DETACH DATABASE cold", ()).await {
500                tracing::warn!("reconstruct_on: failed to DETACH cold database: {e}");
501            }
502            result?.apply_to(MaterializedState::empty(ts), ts)
503        }
504    };
505
506    // The fold has already dropped invisible lineages and cut the visible ones.
507    // What is left is the distance rule, which is a function of the rows in
508    // hand and is therefore Rust rather than a second window in the SQL.
509    out.edges = resolve_beliefs(&out.edges, &ancestry);
510    Ok(out)
511}
512
513/// The newest log payload shape this build writes and the highest it can read.
514///
515/// Kept beside the folds because they are the only readers, and bumped in step
516/// with the `json_object('v', …)` literals in `schema::ddl` — a test asserts the
517/// two agree, since nothing else would notice them drifting apart.
518pub(crate) const PAYLOAD_VERSION: u8 = 2;
519
520/// Every fold partitions on `(table_name, entity_id)`, never `entity_id` alone.
521///
522/// The two namespaces are not disjoint and nothing makes them so. A link's
523/// `entity_id` is the synthetic `source|target|type|valid_from`; a concept's is
524/// whatever the caller passed, unvalidated (defect AD). Partitioning on the id
525/// alone therefore lets a concept and a link contend for one window, and
526/// `ROW_NUMBER() = 1` hands the whole partition to whichever has the greater
527/// `seq_id` — so the loser vanishes from the reconstruction while sitting
528/// plainly in both `concepts` and `transaction_log`. Silent, and on the read
529/// path the ledger exists to make trustworthy.
530///
531/// Validating identifiers would make the collision unreachable and is the
532/// durable fix; this makes it harmless regardless, which is the property worth
533/// having at the fold. `table_name` leads the partition because the log is
534/// already indexed on `entity_id` and the discriminator is two values wide.
535const HOT_FOLD: &str = r#"
536    SELECT seq_id, table_name, entity_id, operation, payload, branch_id
537    FROM (
538        SELECT seq_id, table_name, entity_id, operation, payload, branch_id,
539               ROW_NUMBER() OVER (PARTITION BY table_name, entity_id, branch_id ORDER BY seq_id DESC) as rn
540        FROM transaction_log
541        WHERE recorded_at <= ?1
542    ) WHERE rn = 1
543"#;
544
545/// Fold over hot and cold together (§5.5, D-026). Requires `cold` to be ATTACHed.
546///
547/// The hot entry wins for entities present in both files because its `seq_id` is
548/// greater — the same last-writer-wins rule as snapshot composition.
549fn cold_fold(cold_lineage: ColdLineage) -> String {
550    format!(
551        r#"
552    SELECT seq_id, table_name, entity_id, operation, payload, branch_id
553    FROM (
554        SELECT seq_id, table_name, entity_id, operation, payload, branch_id,
555               ROW_NUMBER() OVER (PARTITION BY table_name, entity_id, branch_id ORDER BY seq_id DESC) as rn
556        FROM (
557            SELECT seq_id, table_name, entity_id, operation, payload, recorded_at, branch_id FROM main.transaction_log
558            UNION ALL
559            SELECT seq_id, table_name, entity_id, operation, payload, recorded_at, {cold} FROM cold.transaction_log
560        ) WHERE recorded_at <= ?1
561    ) WHERE rn = 1
562"#,
563        cold = cold_lineage.projection()
564    )
565}
566
567/// The hot fold, narrowed to one lineage's view (0.15.17, [D-259]).
568///
569/// [`HOT_FOLD`] with the ancestry joined in: the `JOIN` drops every lineage the
570/// reader cannot see, and `recorded_at <= g.cutoff` bounds each ancestor at its
571/// own fork point. `?1` is the instant, and the ancestry binds from
572/// `first_slot` — three placeholders per ancestor, the same block
573/// [`crate::graph::lineage::ancestry_values`] emits for the read path.
574///
575/// **The cutoff is inside the window's input, not applied to its output**, and
576/// that is the whole reason this is a fold rather than a filter over
577/// [`reconstruct`]'s answer. If an ancestor wrote a row and then superseded it
578/// after the fork, the reader must see the *earlier* row — so the cut has to
579/// happen before `ROW_NUMBER` picks a winner. A predicate over a finished
580/// `MaterializedState` cannot express that, and would return nothing for that
581/// key instead of returning the row the lineage actually inherited.
582fn bounded_hot_fold(rows: usize, first_slot: usize) -> String {
583    format!(
584        r#"WITH {}
585    SELECT seq_id, table_name, entity_id, operation, payload, branch_id
586    FROM (
587        SELECT tl.seq_id, tl.table_name, tl.entity_id, tl.operation, tl.payload, tl.branch_id,
588               ROW_NUMBER() OVER (PARTITION BY tl.table_name, tl.entity_id, tl.branch_id ORDER BY tl.seq_id DESC) as rn
589        FROM transaction_log tl
590        JOIN lineage g ON g.branch_id = tl.branch_id
591        WHERE tl.recorded_at <= ?1 AND (g.cutoff IS NULL OR tl.recorded_at <= g.cutoff)
592    ) WHERE rn = 1
593"#,
594        crate::graph::lineage::ancestry_values(rows, first_slot, "")
595    )
596}
597
598/// [`bounded_hot_fold`] over hot and cold together. Requires `cold` ATTACHed.
599///
600/// The union is inside the cutoff filter rather than outside it, for the same
601/// reason the plain [`cold_fold`] puts `recorded_at <= ?1` there: a row's file
602/// is not a fact about its lineage, and a bound applied to one file and not the
603/// other would give a different answer depending on when the archive ran.
604fn bounded_cold_fold(cold_lineage: ColdLineage, rows: usize, first_slot: usize) -> String {
605    format!(
606        r#"WITH {lineage}
607    SELECT seq_id, table_name, entity_id, operation, payload, branch_id
608    FROM (
609        SELECT u.seq_id, u.table_name, u.entity_id, u.operation, u.payload, u.branch_id,
610               ROW_NUMBER() OVER (PARTITION BY u.table_name, u.entity_id, u.branch_id ORDER BY u.seq_id DESC) as rn
611        FROM (
612            SELECT tl.seq_id, tl.table_name, tl.entity_id, tl.operation, tl.payload, tl.recorded_at, tl.branch_id
613            FROM main.transaction_log tl
614            UNION ALL
615            SELECT tl.seq_id, tl.table_name, tl.entity_id, tl.operation, tl.payload, tl.recorded_at, {cold}
616            FROM cold.transaction_log tl
617        ) u
618        -- Every column qualified: `u` and `lineage` both carry `branch_id`, and
619        -- an unqualified one here is `ambiguous column name` at runtime rather
620        -- than a compile error. The hot fold does not need this because its
621        -- inner projection names one source.
622        JOIN lineage g ON g.branch_id = u.branch_id
623        WHERE u.recorded_at <= ?1 AND (g.cutoff IS NULL OR u.recorded_at <= g.cutoff)
624    ) WHERE rn = 1
625"#,
626        lineage = crate::graph::lineage::ancestry_values(rows, first_slot, ""),
627        cold = cold_lineage.projection()
628    )
629}
630
631/// Fold over the hot log *above a snapshot anchor* (§5.5, [D-049]).
632///
633/// `seq_id > ?2` is an inequality, and deliberately so: the hot log's ids have
634/// gaps, so successor arithmetic (`seq_id = :anchor + 1`) would stop at the
635/// first one and silently truncate the delta. This is the first anchored fold
636/// in the crate, which makes it the first code [D-024]'s rule has ever bound —
637/// before this the rule was vacuous, not satisfied.
638///
639/// **The gaps come from the archive, not from rollbacks** (0.15.19, review
640/// C-17). This comment used to name a rolled-back transaction as the source,
641/// which is [D-024]'s stated mechanism and is the one thing [D-049] measured
642/// and disproved: `sqlite_sequence` is written *inside* the transaction, so a
643/// rollback takes the allocation with it and the number is reused. What does
644/// leave gaps is `temporal::archive`, which deletes superseded rows from
645/// `transaction_log` — scattered through the sequence rather than forming a
646/// prefix, which is exactly the shape successor arithmetic cannot walk. Same
647/// inequality, and now the same reason the register gives for it; the
648/// gap-tolerance test builds its state by deleting a log row inside a session
649/// marker, which is the real mechanism and not the retracted one.
650///
651/// [D-049]: ../../docs/architecture/s13-decision-register.md#d-049
652/// [D-024]: ../../docs/architecture/s13-decision-register.md#d-024
653const ANCHORED_HOT_FOLD: &str = r#"
654    SELECT seq_id, table_name, entity_id, operation, payload, branch_id
655    FROM (
656        SELECT seq_id, table_name, entity_id, operation, payload, branch_id,
657               ROW_NUMBER() OVER (PARTITION BY table_name, entity_id, branch_id ORDER BY seq_id DESC) as rn
658        FROM transaction_log
659        WHERE recorded_at <= ?1 AND seq_id > ?2
660    ) WHERE rn = 1
661"#;
662
663/// Fold over hot **and cold** above a snapshot anchor (§5.5, 0.5.5).
664///
665/// The union is what lets composition survive an archive. Rows keep their
666/// `seq_id` when they move to cold — the cold schema declares a plain `INTEGER
667/// PRIMARY KEY` precisely so history is not renumbered — so `seq_id > ?2`
668/// partitions the two files consistently and last-writer-wins across them by the
669/// same rule the unanchored folds use.
670fn anchored_cold_fold(cold_lineage: ColdLineage) -> String {
671    format!(
672        r#"
673    SELECT seq_id, table_name, entity_id, operation, payload, branch_id
674    FROM (
675        SELECT seq_id, table_name, entity_id, operation, payload, branch_id,
676               ROW_NUMBER() OVER (PARTITION BY table_name, entity_id, branch_id ORDER BY seq_id DESC) as rn
677        FROM (
678            SELECT seq_id, table_name, entity_id, operation, payload, recorded_at, branch_id FROM main.transaction_log
679            UNION ALL
680            SELECT seq_id, table_name, entity_id, operation, payload, recorded_at, {cold} FROM cold.transaction_log
681        ) WHERE recorded_at <= ?1 AND seq_id > ?2
682    ) WHERE rn = 1
683"#,
684        cold = cold_lineage.projection()
685    )
686}
687
688/// Whether an attached cold file predates the lineage column (§15.2, v12).
689///
690/// Cold files are **read-only media as far as the read path is concerned**.
691/// They get moved (D-026), they can sit on a share, and a fold that upgraded
692/// one in order to read it would be a write on a path callers have every reason
693/// to believe is a read. So the shape is detected and tolerated, never
694/// corrected: the archive *writer* upgrades, and only inside its own
695/// transaction.
696///
697/// Detection is column presence rather than a version stamp, because a cold
698/// file carries no version anyone can trust — it is a file that has been moved.
699#[derive(Debug, Clone, Copy, PartialEq, Eq)]
700enum ColdLineage {
701    /// v12 or later: the file stamps its own rows.
702    Stamped,
703    /// Pre-v12: every row in it was written when only the trunk existed.
704    PreV12,
705}
706
707impl ColdLineage {
708    fn projection(self) -> &'static str {
709        match self {
710            ColdLineage::Stamped => "branch_id",
711            // A literal, not a default: rows written before lineage existed
712            // *were* trunk rows, and saying so is a fact about them rather than
713            // a fallback.
714            ColdLineage::PreV12 => "'main' AS branch_id",
715        }
716    }
717}
718
719/// Ask the attached cold file whether it carries `transaction_log.branch_id`.
720///
721/// Returns [`ColdLineage::PreV12`] when the pragma cannot be read at all. That
722/// is the conservative direction: a fold that guesses "stamped" against a v11
723/// file fails with `no such column`, while a fold that guesses "pre-v12"
724/// against a v12 file reads rows it can still fold — it would mislabel a
725/// branch's rows as trunk, which is why the guess is never made when the pragma
726/// answers.
727async fn cold_lineage(conn: &libsql::Connection) -> ColdLineage {
728    let Ok(mut rows) = conn
729        .query("PRAGMA cold.table_info(transaction_log)", ())
730        .await
731    else {
732        return ColdLineage::PreV12;
733    };
734    while let Ok(Some(row)) = rows.next().await {
735        if row.get::<String>(1).is_ok_and(|name| name == "branch_id") {
736            return ColdLineage::Stamped;
737        }
738    }
739    ColdLineage::PreV12
740}
741
742/// The winning log rows for one fold, before they are applied to a base state.
743///
744/// Absence and disappearance are different facts, and a merge is where the
745/// difference starts to matter. A full fold from nothing can treat "this entity
746/// went away" and "there is no row for it" identically — both end as absence.
747/// Composed onto a snapshot they are opposites: a disappearance must *remove*
748/// the entity the snapshot carries, and skipping it leaves the snapshot's stale
749/// row standing as though nothing had happened. So they are collected rather
750/// than dropped, and the full fold applies them to an empty base, which keeps
751/// one code path for both cases (D-049).
752///
753/// **There is one such set, not two (D-072).** It used to carry `edges_gone`
754/// beside `concepts_gone`, and both were populated only from the `'D'` branch of
755/// [`fold_delta`] — so when that branch became an error, `edges_gone` was left
756/// reachable by nothing. Closing one unreachable path by opening another is not
757/// a fix, so it went too.
758///
759/// The asymmetry is real and worth stating, because "concepts can vanish and
760/// edges cannot" looks like an oversight until you follow it:
761///
762/// * A **concept** disappears by being *retired*, which writes a `'U'` row whose
763///   payload has `retired = 1`. That is a genuine removal from a composed state
764///   and `concepts_gone` carries it.
765/// * An **edge** never disappears. It is retired by asserting a successor over
766///   the same interval key — same `source|target|type|valid_from`, later
767///   `recorded_at` — so the log row is an `'I'` under the *same* `entity_id`, and
768///   last-writer-wins in [`Self::apply_to`] replaces the tuple in place. There is
769///   nothing to remove because nothing left; the interval simply closed.
770///
771/// That is Doctrine III showing through: an edge assertion is immutable and
772/// superseded, never deleted.
773#[derive(Default)]
774struct Delta {
775    concepts: HashMap<String, NodeAttributes>,
776    /// Keyed by `entity_id` **and** `branch_id` — see [`EdgeBelief::belief_key`].
777    ///
778    /// `entity_id` alone was the collapse [D-221](../../docs/architecture/s13-decision-register.md#d-221)
779    /// records: it is the edge key, shared across lineages by design, so an
780    /// ancestor's assertion and a descendant's correction landed in one slot.
781    edges: HashMap<String, EdgeBelief>,
782    /// Concepts retired as of the fold's instant. See the type's note for why
783    /// there is no edge equivalent.
784    concepts_gone: HashSet<String>,
785    max_seq: i64,
786}
787
788/// Release a `cold` handle left attached by an earlier call (§5.5, D-044).
789///
790/// Both ATTACH sites pair with an unconditional DETACH on the way out, so in
791/// the normal course this finds nothing and the statement fails harmlessly with
792/// "no such database: cold". It exists for the case the pairing cannot cover: a
793/// panic unwinding between the two, which skips the DETACH no matter which exit
794/// path the `Result` would have taken.
795///
796/// A `Drop` guard is the reflex here and does not work — `execute` is `async`,
797/// and a `Drop` impl cannot await, so it would build a future, discard it, and
798/// leave the handle attached while looking like it had cleaned up. Recovering
799/// on the way *in* needs no destructor, works regardless of how the handle
800/// leaked, and turns permanent poisoning of the connection into one failed
801/// statement nobody sees.
802pub(crate) async fn detach_stale_cold(conn: &libsql::Connection) {
803    let _ = conn.execute("DETACH DATABASE cold", ()).await;
804}
805
806/// Reconstruct database state as believed at past instant `ts` using window-function log fold (§5.5, D-026).
807///
808/// When `ts` predates the hot log's horizon the cold database is ATTACHed for
809/// exactly one fold and DETACHed unconditionally on the way out, error paths
810/// included. ATTACH is not transactional and survives ROLLBACK, so a handle
811/// leaked by an early return would make every later `reconstruct` *and* every
812/// later `archive` fail with "database cold is already in use" — one corrupt
813/// payload would permanently poison the connection. This is the same failure
814/// mode `archive()` carries a note about, and the two now share a shape.
815/// Snapshot composition (§5.5, D-049) applies when `snapshots_dir` holds a
816/// snapshot at or before `ts` and no archive database exists — see
817/// `snapshot_anchor` for why archiving disables it. Otherwise the fold runs
818/// from genesis, which is correct and costs what the whole log costs.
819pub async fn reconstruct(
820    conn: &libsql::Connection,
821    ts: &str,
822    archive_path: Option<&Path>,
823    snapshots_dir: Option<&Path>,
824) -> Result<MaterializedState> {
825    let base = snapshot_anchor(snapshots_dir, ts).await;
826    reconstruct_from(conn, ts, archive_path, base).await
827}
828
829/// [`reconstruct`] with the anchor chosen by the caller (0.15.19, review C-18).
830///
831/// The whole of `reconstruct` except the one line that picks a base, split out
832/// because [`verify_last_link`] needs to compose onto a **named** snapshot
833/// rather than onto whichever one is newest at `ts` — and picking it is the
834/// only thing the two do differently. Written as a split rather than as a
835/// second copy of the ATTACH bracket for the reason the module keeps
836/// re-learning: a read spelled twice drifts, and the half nobody calls is the
837/// half that drifts first ([D-227]).
838///
839/// `base` of `None` is a fold from genesis.
840///
841/// [D-227]: ../../docs/architecture/s13-decision-register.md#d-227
842async fn reconstruct_from(
843    conn: &libsql::Connection,
844    ts: &str,
845    archive_path: Option<&Path>,
846    base: Option<MaterializedState>,
847) -> Result<MaterializedState> {
848    match hot_log_reach(conn, ts, archive_path).await? {
849        HotLogReach::Covers => {
850            if let Some(base) = base {
851                let anchor = base.seq_anchor;
852                let delta =
853                    fold_delta(conn, ANCHORED_HOT_FOLD, libsql::params![ts, anchor]).await?;
854                return Ok(delta.apply_to(base, ts));
855            }
856            return fold(conn, ts, HOT_FOLD).await;
857        }
858        HotLogReach::PredatesRecordedHistory => {
859            // Nothing had been recorded by `ts`, and nothing has been removed
860            // from the log, so there is no history anywhere to go looking for.
861            // The empty state is the answer, flagged so a caller can tell it
862            // from a state that is empty because everything was retired.
863            let mut state = MaterializedState::empty(ts);
864            state.predates_recorded_history = true;
865            return Ok(state);
866        }
867        HotLogReach::NeedsArchive => {}
868    }
869
870    // The delta lives in the cold archive database. Both ways of failing to
871    // reach it carry `archive_hint`, which is the message the rejected hot-side
872    // marker was wanted for — see that function for why no marker is needed.
873    //
874    // **Computed inside the error arms, not before them.** `NeedsArchive` is the
875    // ordinary path to a cold fold and usually succeeds; an eager hint would put
876    // an extra query on it for a string almost every caller discards. An
877    // injection probe caught this — `a_failed_cold_reconstruct_still_detaches`
878    // reached the hint on a run that raised nothing from here.
879    let archive = match archive_path {
880        Some(p) => p,
881        None => {
882            return Err(DbError::ReplayCorrupt {
883                seq: 0,
884                reason: format!(
885                    "state at {ts} predates the hot log and no archive path was given; {}",
886                    archive_hint(conn).await
887                ),
888            })
889        }
890    };
891    if !crate::temporal::archive::archive_present(archive) {
892        return Err(DbError::ReplayCorrupt {
893            seq: 0,
894            reason: format!(
895                "archive database file {archive:?} does not exist; {}",
896                archive_hint(conn).await
897            ),
898        });
899    }
900
901    detach_stale_cold(conn).await;
902
903    // Bound, not interpolated: a path is caller data, and hand-rolled quote
904    // doubling is a worse version of what the driver already does correctly.
905    conn.execute(
906        "ATTACH DATABASE ?1 AS cold",
907        libsql::params![archive.to_string_lossy().as_ref()],
908    )
909    .await?;
910
911    // Asked once, after the ATTACH and before either fold, because both arms
912    // need it and the answer cannot change while we hold the handle.
913    let cold_shape = cold_lineage(conn).await;
914
915    // Composition works across the archive boundary because the anchored fold
916    // unions both files; before 0.5.5 it was refused here rather than made to
917    // work, and the refusal was the only thing keeping the answer right.
918    let result = match base {
919        Some(base) => {
920            let anchor = base.seq_anchor;
921            fold_delta(
922                conn,
923                &anchored_cold_fold(cold_shape),
924                libsql::params![ts, anchor],
925            )
926            .await
927            .map(|delta| delta.apply_to(base, ts))
928        }
929        None => fold(conn, ts, &cold_fold(cold_shape)).await,
930    };
931
932    // Unconditional: see the ATTACH note above.
933    if let Err(e) = conn.execute("DETACH DATABASE cold", ()).await {
934        tracing::warn!("reconstruct: failed to DETACH cold database: {e}");
935    }
936
937    result
938}
939
940/// Fold from genesis and compare against the composed answer (§5.5, T5.3,
941/// D-092).
942///
943/// # The problem this exists for
944///
945/// [`crate::temporal::save_snapshot`] is written by `write_final`, which calls
946/// [`reconstruct`] — and `reconstruct` composes onto the *previous* snapshot
947/// whenever one is usable. So snapshot *n* is derived from snapshot *n−1*, and
948/// there is no periodic full fold anywhere in the chain. An error introduced at
949/// any link is copied forward indefinitely, and every subsequent read agrees
950/// with it, because they are all reading the same descendant.
951///
952/// The project's own open item names the difficulty honestly: a full fold is
953/// exactly the cost snapshots exist to avoid, so this cannot run on every read.
954/// It is a **scheduling** problem, and this function is the thing to schedule.
955///
956/// # It reports; it does not repair
957///
958/// Deliberate, and not merely conservative. Under [Doctrine VI] a snapshot is
959/// derivative and disposable, so the repair is *delete the snapshots* — one
960/// line, available to the caller, and correct without this function's help.
961/// What the caller cannot get for themselves is the knowledge that the chain
962/// diverged, and silently rewriting the file would destroy the only evidence of
963/// a bug in composition. A divergence here is not a corrupt database; it is a
964/// wrong **cache**, and it means composition has a defect worth finding.
965///
966/// # Cost
967///
968/// One fold from genesis over the whole log, plus one composed reconstruction.
969/// That is the expensive path by construction — see [`crate::Database::
970/// verify_snapshot_chain`] for the handle-level entry point and the note on
971/// when to run it.
972///
973/// [Doctrine VI]: ../../../docs/architecture/s0-s3-foundations.md#doctrine-vi
974pub async fn verify_snapshot_chain(
975    conn: &libsql::Connection,
976    ts: &str,
977    archive_path: Option<&Path>,
978    snapshots_dir: &Path,
979) -> Result<ChainCheck> {
980    // The composed answer: what every reader gets today.
981    let composed = reconstruct(conn, ts, archive_path, Some(snapshots_dir)).await?;
982    // The authority: the same instant, with the snapshot directory withheld, so
983    // `snapshot_anchor` finds nothing and the fold runs from genesis. Passing
984    // `None` is what makes this an independent computation rather than a second
985    // call to the thing under test.
986    let folded = reconstruct(conn, ts, archive_path, None).await?;
987    Ok(ChainCheck::compare(ts, &composed, &folded))
988}
989
990/// Check the **newest link** of the snapshot chain (0.15.19, review C-18).
991///
992/// # What it is for
993///
994/// [`verify_snapshot_chain`] is right and unaffordable: two folds, one of them
995/// from genesis over the whole log. Its own rustdoc calls scheduling it the
996/// open problem, and nothing schedules it, so in practice a composition defect
997/// is copied forward with nothing looking. This is the cheap half of the same
998/// idea — re-derive snapshot *n* from snapshot *n−1* and compare — which costs
999/// one anchored delta and can therefore run whenever a snapshot is written.
1000/// The snapshot cadence does exactly that and logs a divergence.
1001///
1002/// `Ok(None)` when there are not two snapshots to compare, which is a young
1003/// database and not a fault.
1004///
1005/// # What it catches, and what it does not
1006///
1007/// It catches a defect **as it is introduced**: a snapshot that does not
1008/// survive its own serialize/load round trip, an `apply_to` that composes
1009/// differently from how it was composed, or a delta that has stopped covering
1010/// the window between the two anchors. That last one is the practical case —
1011/// rows archived out of the hot log between the two writes, with no archive
1012/// path given here to fold them back in.
1013///
1014/// It does **not** catch a defect inherited from further back. If the chain
1015/// went wrong at link three and every link since has composed faithfully onto
1016/// it, this agrees at every one of them, because both sides descend from the
1017/// same wrong state. Only a genesis fold answers that, which is what
1018/// [`verify_snapshot_chain`] is and why it stays.
1019///
1020/// Pass `archive_path` whenever there is an archive. Without it the delta is
1021/// folded from the hot log alone, and a link spanning an archive session will
1022/// disagree for a reason that is not a defect.
1023///
1024/// # It reports; it does not repair
1025///
1026/// [`verify_snapshot_chain`]'s reasoning, unchanged: under [Doctrine VI] a
1027/// snapshot is derivative, so the repair is *delete the snapshots*, which is
1028/// one line and the caller's to run. Rewriting the file here would destroy the
1029/// only evidence that composition has a bug.
1030///
1031/// [Doctrine VI]: ../../../docs/architecture/s0-s3-foundations.md#doctrine-vi
1032pub async fn verify_last_link(
1033    conn: &libsql::Connection,
1034    archive_path: Option<&Path>,
1035    snapshots_dir: &Path,
1036) -> Result<Option<ChainCheck>> {
1037    let Some((base, newest)) = two_newest_snapshots(snapshots_dir).await else {
1038        return Ok(None);
1039    };
1040    let ts = newest.timestamp.clone();
1041    let redone = reconstruct_from(conn, &ts, archive_path, Some(base)).await?;
1042    // `newest` is the composed answer — the file a reader would be handed —
1043    // and `redone` is the independent one, which is the way round
1044    // `ChainCheck`'s two families of field are named.
1045    Ok(Some(ChainCheck::compare(&ts, &newest, &redone)))
1046}
1047
1048/// The result of a [`verify_snapshot_chain`] cross-check.
1049///
1050/// Carries the disagreements rather than a bool, because "the chain diverged" is
1051/// not actionable and "these three concepts differ, and this edge is present in
1052/// one and not the other" is. Bounded — see [`ChainCheck::SAMPLE_LIMIT`] — since
1053/// a chain that went wrong early can disagree about every row, and a report that
1054/// is the size of the database is one nobody reads.
1055#[derive(Debug, Clone)]
1056#[non_exhaustive]
1057pub struct ChainCheck {
1058    pub timestamp: String,
1059    /// `seq_anchor` of the composed answer and of the genesis fold. These
1060    /// **may legitimately differ**: the composed answer anchors at the snapshot
1061    /// it started from plus its delta, and the fold anchors at the newest row it
1062    /// saw. Reported for diagnosis, never compared.
1063    pub composed_anchor: i64,
1064    pub folded_anchor: i64,
1065    pub composed_concepts: usize,
1066    pub folded_concepts: usize,
1067    pub composed_edges: usize,
1068    pub folded_edges: usize,
1069    /// Concept ids present in one and not the other, or whose attributes differ.
1070    pub concept_disagreements: Vec<String>,
1071    /// Edge keys present in one and not the other.
1072    pub edge_disagreements: Vec<String>,
1073    /// True when either list was truncated at [`ChainCheck::SAMPLE_LIMIT`].
1074    pub truncated: bool,
1075}
1076
1077impl ChainCheck {
1078    /// How many disagreements of each kind to carry.
1079    pub const SAMPLE_LIMIT: usize = 32;
1080
1081    pub fn diverged(&self) -> bool {
1082        !self.concept_disagreements.is_empty() || !self.edge_disagreements.is_empty()
1083    }
1084
1085    fn compare(ts: &str, composed: &MaterializedState, folded: &MaterializedState) -> Self {
1086        let mut concept_disagreements = Vec::new();
1087        let mut truncated = false;
1088
1089        let mut ids: Vec<&String> = composed.concepts.keys().collect();
1090        ids.extend(folded.concepts.keys());
1091        ids.sort_unstable();
1092        ids.dedup();
1093        for id in ids {
1094            let a = composed.concepts.get(id);
1095            let b = folded.concepts.get(id);
1096            let same = match (a, b) {
1097                (Some(a), Some(b)) => {
1098                    a.title == b.title
1099                        && a.content == b.content
1100                        && a.embedding_model == b.embedding_model
1101                }
1102                (None, None) => true,
1103                _ => false,
1104            };
1105            if !same {
1106                if concept_disagreements.len() < Self::SAMPLE_LIMIT {
1107                    concept_disagreements.push(id.clone());
1108                } else {
1109                    truncated = true;
1110                }
1111            }
1112        }
1113
1114        // Edges are a `Vec` of tuples with no declared order, so the comparison
1115        // is on the set. Comparing the vectors directly would report a
1116        // divergence for a reordering, which is not one — and that false
1117        // positive is worse than useless here, because the whole point of this
1118        // check is that a report means "go and find the bug".
1119        // `valid_to` is in the key as well as the identity, because a
1120        // divergence in *what* the two paths believe is exactly what this
1121        // reports — two rows agreeing on the edge and the lineage and
1122        // disagreeing on the interval are a disagreement, not one row.
1123        let key = |e: &EdgeBelief| format!("{}|{}", e.belief_key(), e.valid_to);
1124        let ca: HashSet<String> = composed.edges.iter().map(key).collect();
1125        let fa: HashSet<String> = folded.edges.iter().map(key).collect();
1126        let mut edge_disagreements: Vec<String> = ca.symmetric_difference(&fa).cloned().collect();
1127        edge_disagreements.sort_unstable();
1128        if edge_disagreements.len() > Self::SAMPLE_LIMIT {
1129            edge_disagreements.truncate(Self::SAMPLE_LIMIT);
1130            truncated = true;
1131        }
1132
1133        Self {
1134            timestamp: ts.to_string(),
1135            composed_anchor: composed.seq_anchor,
1136            folded_anchor: folded.seq_anchor,
1137            composed_concepts: composed.concepts.len(),
1138            folded_concepts: folded.concepts.len(),
1139            composed_edges: ca.len(),
1140            folded_edges: fa.len(),
1141            concept_disagreements,
1142            edge_disagreements,
1143            truncated,
1144        }
1145    }
1146}
1147
1148impl std::fmt::Display for ChainCheck {
1149    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1150        if !self.diverged() {
1151            return write!(
1152                f,
1153                "snapshot chain agrees with a genesis fold at {}: {} concepts, {} edges",
1154                self.timestamp, self.folded_concepts, self.folded_edges
1155            );
1156        }
1157        write!(
1158            f,
1159            "snapshot chain DIVERGED at {}: composed {} concepts / {} edges, \
1160             genesis fold {} concepts / {} edges; {} concept and {} edge \
1161             disagreements{}. The snapshots are a wrong cache, not a corrupt \
1162             ledger — deleting the snapshot directory restores correctness and \
1163             loses only speed (Doctrine VI). concepts: {:?} edges: {:?}",
1164            self.timestamp,
1165            self.composed_concepts,
1166            self.composed_edges,
1167            self.folded_concepts,
1168            self.folded_edges,
1169            self.concept_disagreements.len(),
1170            self.edge_disagreements.len(),
1171            if self.truncated { " (truncated)" } else { "" },
1172            self.concept_disagreements,
1173            self.edge_disagreements,
1174        )
1175    }
1176}
1177
1178/// The newest usable snapshot at or before `ts`, or `None` to fold from genesis.
1179///
1180/// **Composition used to be disabled once an archive database existed, and as of
1181/// 0.5.5 it is not.** The reason for the refusal was real: `LOG_ARCHIVABLE`
1182/// (§5.7) removes superseded rows scattered through the sequence, so a row above
1183/// the anchor and at or before `ts` could be in cold while a newer row for the
1184/// same entity — recorded *after* `ts`, invisible to the fold — kept it out of
1185/// the hot log. The delta missed it and the snapshot answered with a stale
1186/// value. The fix is the one that note named: the cold log is now in the delta,
1187/// via [`ANCHORED_COLD_FOLD`], so the archived row is visible again and there is
1188/// nothing left to refuse.
1189///
1190/// Selection loads candidates newest-first and stops at the first whose
1191/// timestamp is at or before `ts`, so the common case — `reconstruct(now)` —
1192/// reads exactly one file. A snapshot this build cannot read
1193/// ([`DbError::SnapshotIncompatible`], D-043) is skipped, not raised: an
1194/// incompatible snapshot is an ordinary consequence of upgrading, and the whole
1195/// point of distinguishing it from corruption is that the answer is to carry on
1196/// without it.
1197///
1198/// # It runs on a blocking thread, and a lost one costs speed only (0.13.11, W8.1, D-184)
1199///
1200/// The scan is a directory listing plus one or more full
1201/// [`load_snapshot`](super::snapshot::load_snapshot) calls — decompression and
1202/// bincode over the whole state, on a worker that has other tasks waiting. The
1203/// *whole scan* is offloaded rather than each file, because the loop is
1204/// sequential by construction (it stops at the first usable file) and a hop per
1205/// candidate would add scheduling to a path whose common case reads exactly one.
1206///
1207/// A [`tokio::task::JoinError`] means the loader panicked, and the answer is the
1208/// same one this function already gives for every other kind of unusable file:
1209/// `None`, and fold from genesis. That is not leniency, it is what a snapshot
1210/// *is* — derivative and disposable under [Doctrine VI], so the cost of ignoring
1211/// one is a slower reconstruction and never a wrong one. It is also a real
1212/// improvement over the previous arrangement: inline, a panic in the loader
1213/// unwound through [`reconstruct`] and took the caller's task with it, which
1214/// meant a single corrupt file could stop a process that had a correct answer
1215/// available the whole time. W8.4 fuzzes for exactly those panics; this is what
1216/// happens to the ones it has not found yet.
1217///
1218/// [Doctrine VI]: ../../../docs/architecture/s0-s3-foundations.md#doctrine-vi
1219async fn snapshot_anchor(snapshots_dir: Option<&Path>, ts: &str) -> Option<MaterializedState> {
1220    let dir = snapshots_dir?.to_path_buf();
1221    let ts = ts.to_string();
1222    match tokio::task::spawn_blocking(move || newest_usable_snapshot(&dir, &ts)).await {
1223        Ok(found) => found,
1224        Err(e) => {
1225            tracing::warn!("the snapshot scan did not finish ({e}); folding from genesis");
1226            None
1227        }
1228    }
1229}
1230
1231/// The blocking half of [`snapshot_anchor`]: read the directory, load
1232/// newest-first, stop at the first snapshot at or before `ts`.
1233fn newest_usable_snapshot(dir: &Path, ts: &str) -> Option<MaterializedState> {
1234    let mut candidates: Vec<(i64, PathBuf)> = std::fs::read_dir(dir)
1235        .ok()?
1236        .flatten()
1237        .map(|e| e.path())
1238        .filter_map(|p| super::snapshot::seq_from_filename(&p).map(|s| (s, p)))
1239        .collect();
1240    candidates.sort_by_key(|(seq, _)| std::cmp::Reverse(*seq));
1241
1242    for (_, path) in candidates {
1243        match super::snapshot::load_snapshot(&path) {
1244            // Sound as a string comparison because every timestamp is the
1245            // canonical fixed width (D-029).
1246            Ok(state) if state.timestamp.as_str() <= ts => return Some(state),
1247            Ok(_) => continue,
1248            Err(DbError::SnapshotIncompatible { reason, .. }) => {
1249                tracing::warn!("skipping snapshot {path:?}: {reason}");
1250                continue;
1251            }
1252            Err(e) => {
1253                tracing::warn!("skipping unreadable snapshot {path:?}: {e}");
1254                continue;
1255            }
1256        }
1257    }
1258    None
1259}
1260
1261/// The two newest snapshots on disk, oldest first (0.15.19, review C-18).
1262///
1263/// `None` when there are not two loadable ones with distinct anchors, which is
1264/// the ordinary state of a young database and not a failure. Unreadable and
1265/// incompatible files are skipped with a warning, exactly as
1266/// [`newest_usable_snapshot`] skips them: this is a check, and a check that
1267/// cannot run should not be the thing that raises.
1268///
1269/// Distinct `seq_anchor`s rather than distinct paths, because two files at one
1270/// anchor describe the same instant and comparing them would test the writer's
1271/// determinism, not the chain's composition.
1272async fn two_newest_snapshots(dir: &Path) -> Option<(MaterializedState, MaterializedState)> {
1273    let dir = dir.to_path_buf();
1274    match tokio::task::spawn_blocking(move || {
1275        let mut candidates: Vec<(i64, PathBuf)> = std::fs::read_dir(&dir)
1276            .ok()?
1277            .flatten()
1278            .map(|e| e.path())
1279            .filter_map(|p| super::snapshot::seq_from_filename(&p).map(|s| (s, p)))
1280            .collect();
1281        candidates.sort_by_key(|(seq, _)| std::cmp::Reverse(*seq));
1282
1283        let mut loaded: Vec<MaterializedState> = Vec::with_capacity(2);
1284        for (_, path) in candidates {
1285            match super::snapshot::load_snapshot(&path) {
1286                Ok(state) => {
1287                    if loaded.iter().any(|s| s.seq_anchor == state.seq_anchor) {
1288                        continue;
1289                    }
1290                    loaded.push(state);
1291                    if loaded.len() == 2 {
1292                        break;
1293                    }
1294                }
1295                Err(e) => tracing::warn!("skipping snapshot {path:?} for the link check: {e}"),
1296            }
1297        }
1298        let older = loaded.pop()?;
1299        let newer = loaded.pop()?;
1300        Some((older, newer))
1301    })
1302    .await
1303    {
1304        Ok(found) => found,
1305        Err(e) => {
1306            tracing::warn!("the snapshot scan for the link check did not finish ({e})");
1307            None
1308        }
1309    }
1310}
1311
1312/// Where the answer for `ts` lives.
1313///
1314/// Three cases, not two (0.8.0, B5, D-121). This used to be a `bool`, and the
1315/// missing third case is the whole of B5: *below the log's floor* was folded in
1316/// with *the delta is elsewhere*, so a question about a time before the ledger
1317/// started came back as [`DbError::ReplayCorrupt`] — the class meaning the
1318/// ledger is damaged — naming an archive file the caller had never created.
1319#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1320enum HotLogReach {
1321    /// The hot log holds everything needed at `ts`. Fold it.
1322    Covers,
1323    /// Nothing had been recorded by `ts`, and nothing has ever been removed
1324    /// from the log, so no other file could hold it either. The empty state is
1325    /// the correct answer, not a failure to find one.
1326    PredatesRecordedHistory,
1327    /// The delta is in the cold archive. If it cannot be reached, that is an
1328    /// error and stays one.
1329    NeedsArchive,
1330}
1331
1332/// Whether the hot log alone can answer for `ts` — a *completeness* test.
1333///
1334/// **This replaces a reach test that was not one (0.5.5).** The previous version
1335/// asked `MIN(recorded_at) <= ts`: whether the hot log stretches back far enough
1336/// to contain `ts`. That is a different question from whether it still contains
1337/// everything needed to answer at `ts`, and `LOG_ARCHIVABLE` (§5.7) is exactly
1338/// what pulls the two apart — it removes *superseded* rows, scattered through
1339/// the sequence rather than forming a prefix. One entity archived and another
1340/// not is enough: the unarchived one keeps `MIN` pointing before the cutoff
1341/// while the archived one's winning row is gone, and the fold silently returns a
1342/// state missing an entity. Measured, not theorised — see
1343/// `reconstructing_before_the_archive_cutoff_keeps_every_entity`.
1344///
1345/// The sound test rests on the one guarantee the archive does make: **the newest
1346/// row per entity is never archivable**, because archivability requires a later
1347/// row to exist. So if `ts` is at or after the newest hot stamp, every entity's
1348/// winning row at `ts` is its newest row overall, and every such row is hot.
1349/// That covers `reconstruct(now)` — the common case, and the case §5.7 designed
1350/// `LOG_ARCHIVABLE` around — and nothing else.
1351///
1352/// Anything earlier goes to the cold file. That is more ATTACHes than the old
1353/// rule performed, and the trade is not close: the old rule was cheaper because
1354/// it was answering a question nobody asked.
1355///
1356/// With no archive database in play the reach test *is* the completeness test —
1357/// nothing has been removed, so the hot log is the whole log — and it is kept,
1358/// because it is also what distinguishes "before recorded history" from "the
1359/// cold file is missing" (D-026).
1360async fn hot_log_reach(
1361    conn: &libsql::Connection,
1362    ts: &str,
1363    archive_path: Option<&Path>,
1364) -> Result<HotLogReach> {
1365    if archive_path.is_some_and(crate::temporal::archive::archive_present) {
1366        // An archive file beside the log is direct evidence that rows may have
1367        // gone, and it is *stronger* evidence than [`hot_log_is_intact`] on one
1368        // case: an empty hot log passes the seq_id test vacuously, so the
1369        // fully-archived database would otherwise report its own emptiness as
1370        // history. It cannot arise from `archive()` itself — the newest row per
1371        // entity always stays — but answering "covered" there would make such a
1372        // file reconstruct to the empty state with no error at all.
1373        return reach_with_rows_removed(conn, ts).await;
1374    }
1375
1376    hot_log_reach_within(conn, ts).await
1377}
1378
1379/// The verdict the **hot file alone** supports (0.15.4, W14.2, review C-2).
1380///
1381/// This is the whole of the reach question minus the one thing an archive path
1382/// adds, and it is a separate function because two callers need exactly it:
1383/// [`hot_log_reach`] when no archive file is present, and
1384/// [`hot_log_answers_for`] on behalf of readers that never had a path to offer.
1385/// Those two used to answer differently — the first on `MIN(recorded_at)`, the
1386/// second on intactness alone — and neither answer was the right one.
1387///
1388/// # Two cases, and the split is intactness rather than the timestamp
1389///
1390/// **Nothing was ever removed.** The hot log is the whole log, so it answers at
1391/// every instant. Above its floor the fold runs; below it, *nothing had been
1392/// recorded yet* is not a failure to find the answer, it is the answer
1393/// ([`HotLogReach::PredatesRecordedHistory`], D-121).
1394///
1395/// **Rows were removed.** Only [`reach_with_rows_removed`]'s rule holds, and it
1396/// is a bound on `ts` from above rather than below. This is the case the old
1397/// `MIN(recorded_at) <= ts` arm got wrong: it asked whether the hot log
1398/// *stretches back* far enough, which is the question 0.5.5 already established
1399/// is not the same as whether it is still *complete*. With no archive path to
1400/// fall through to, a `reconstruct` on an archived database whose cold file was
1401/// not passed folded whatever was left and returned it as history — the silent
1402/// short answer D-189 refused at the two connection-only readers, reachable at
1403/// the one reader that takes a path and was handed `None`. Pinned by
1404/// `reconstructing_without_the_archive_path_refuses_rather_than_folding_a_gap`.
1405async fn hot_log_reach_within(conn: &libsql::Connection, ts: &str) -> Result<HotLogReach> {
1406    // **The cheap arm first, and it is sound before the case split rather than
1407    // inside one of its branches** (0.15.5, W14.4, [D-247]). `MAX <= ts` covers
1408    // under *both* rules: on a log rows were removed from it is
1409    // [`reach_with_rows_removed`]'s argument, and on an intact one
1410    // `MIN <= MAX <= ts` gives the same verdict a step later. So the question
1411    // "were rows removed" — the only expensive one here — does not have to be
1412    // asked at all when the instant is at or after the newest surviving stamp.
1413    //
1414    // Which is where the readers actually ask. `as_of_recorded(now)`,
1415    // `reconstruct(now)` and every read at a recent instant land here, and pay
1416    // one index seek against `idx_txlog_time` instead of a covering scan whose
1417    // cost is the whole hot log. Measured at 500,000 log rows: **3.4 µs against
1418    // 24.2 ms**.
1419    if newest_stamp_covers(conn, ts).await? {
1420        return Ok(HotLogReach::Covers);
1421    }
1422
1423    // Below the newest surviving stamp, and now it matters. An intact log
1424    // answers at every instant; a log rows were taken out of answers at none
1425    // below that stamp, and there is no cheaper exact test than counting —
1426    // `LOG_ARCHIVABLE` removes rows scattered through the sequence, so a gap
1427    // can be anywhere and only `COUNT(*)` finds it (see [`hot_log_is_intact`]).
1428    // This arm is *not* made cheaper by the reordering and pays one extra seek
1429    // for the arm that is: 3.4 µs on top of a scan that starts at 96 µs.
1430    if !hot_log_is_intact(conn).await? {
1431        return Ok(HotLogReach::NeedsArchive);
1432    }
1433
1434    Ok(match oldest_hot_stamp(conn).await? {
1435        // Sound as a string comparison because every recorded_at is the
1436        // canonical fixed width (D-029).
1437        Some(min_ts) if min_ts.as_str() <= ts => HotLogReach::Covers,
1438        // Below the floor of a complete log, or no log at all: either way
1439        // nothing had been recorded by `ts` and the empty state is correct.
1440        _ => HotLogReach::PredatesRecordedHistory,
1441    })
1442}
1443
1444/// The one rule that survives archiving, in the one place both callers read it.
1445///
1446/// `LOG_ARCHIVABLE` requires a later row at the same entity, so **the newest row
1447/// per entity is never archivable**. If `ts` is at or after the newest stamp
1448/// still in the hot log, then every entity's winning row at `ts` is its newest
1449/// row overall, and every such row is hot — the fold is complete without
1450/// knowing anything about what left. That covers `reconstruct(now)`, the common
1451/// case and the one §5.7 designed `LOG_ARCHIVABLE` around, and nothing earlier.
1452///
1453/// Which is also why the two halves of the question have opposite senses. On an
1454/// intact log the test is `MIN <= ts`: *does the log reach back to `ts`*. Once
1455/// rows have gone it is `MAX <= ts`: *is `ts` late enough that nothing missing
1456/// could matter*. Reading the second as a weaker form of the first is the
1457/// mistake 0.5.5 corrected once and W14.2 corrected again in the arm 0.5.5 did
1458/// not reach.
1459async fn reach_with_rows_removed(conn: &libsql::Connection, ts: &str) -> Result<HotLogReach> {
1460    Ok(if newest_stamp_covers(conn, ts).await? {
1461        HotLogReach::Covers
1462    } else {
1463        HotLogReach::NeedsArchive
1464    })
1465}
1466
1467/// The rule itself, as a predicate, because two callers now read it and one of
1468/// them ([`hot_log_reach_within`]'s first arm) is not deciding between the same
1469/// two verdicts.
1470///
1471/// An empty log covers nothing, which is the arm that keeps a fully-archived
1472/// database from reporting its own emptiness as history.
1473async fn newest_stamp_covers(conn: &libsql::Connection, ts: &str) -> Result<bool> {
1474    Ok(newest_hot_stamp(conn)
1475        .await?
1476        .is_some_and(|max_ts| max_ts.as_str() <= ts))
1477}
1478
1479/// The oldest `recorded_at` still in the hot log, or `None` if it is empty.
1480async fn oldest_hot_stamp(conn: &libsql::Connection) -> Result<Option<String>> {
1481    hot_stamp(conn, "MIN").await
1482}
1483
1484/// The newest `recorded_at` still in the hot log, or `None` if it is empty.
1485async fn newest_hot_stamp(conn: &libsql::Connection) -> Result<Option<String>> {
1486    hot_stamp(conn, "MAX").await
1487}
1488
1489/// One aggregate over `transaction_log.recorded_at`, which `idx_txlog_time`
1490/// serves as an index scan of one row at either end.
1491async fn hot_stamp(conn: &libsql::Connection, agg: &str) -> Result<Option<String>> {
1492    let row = conn
1493        .query(
1494            &format!("SELECT {agg}(recorded_at) FROM transaction_log"),
1495            (),
1496        )
1497        .await?
1498        .next()
1499        .await?;
1500    Ok(row.and_then(|r| r.get(0).ok()))
1501}
1502
1503/// What the caller needs to know when the cold delta cannot be reached —
1504/// **assembled from the hot file alone** (0.9.0, C4).
1505///
1506/// # This is the message the hot-side marker was wanted for
1507///
1508/// [D-121](../../docs/architecture/s13-decision-register.md) rejected a hot-side
1509/// marker recording *archived at* and *horizon*, then left the door open: 0.9.0
1510/// was to adopt it "only if it wants the richer message". C4 asked for the
1511/// message and found the marker cannot supply it, because the proposed message —
1512/// *"this database was archived on X; pass the archive path"* — is **weaker**
1513/// than what the hot log already carries:
1514///
1515/// * *how many rows went* is `MAX(seq_id) - COUNT(*)`, exact for the reason
1516///   [`hot_log_is_intact`] gives;
1517/// * *how far back the hot file still reaches* is `MIN(seq_id)` and its
1518///   `recorded_at` — which is the fact that actually tells a caller whether the
1519///   archive is worth fetching, and which a marker's archive **timestamp** does
1520///   not give them;
1521/// * *that archiving happened at all* is the one bit [`hot_log_is_intact`]
1522///   already answers.
1523///
1524/// The only datum a marker would add is the wall-clock instant of the last
1525/// archive run, and no branch and no caller needs it. So the marker is refused
1526/// outright rather than deferred again: under
1527/// [D-036](../../docs/architecture/s13-decision-register.md) a hot-table addition
1528/// lands pre-1.0 or not at all, and a table whose whole content is a timestamp
1529/// used in one error string is not worth a rung.
1530///
1531/// # There is no "nothing was archived" case, and that was settled by injection
1532///
1533/// This first carried a branch for `removed == 0`, on the reasoning that the
1534/// `NeedsArchive` arm is reachable without any archiving. That reasoning was
1535/// **wrong about where the cost lands and right about the branch**, and only a
1536/// probe told the two apart: replacing the branch body with a panic showed it
1537/// firing from `a_failed_cold_reconstruct_still_detaches`, a test that raises
1538/// nothing from here — because the hint was being computed *before* the two
1539/// arms that use it, on every cold fold. Made lazy, the probe went quiet across
1540/// all 27 targets.
1541///
1542/// So the branch was dead at the use sites: both arms require
1543/// [`hot_log_is_intact`] to have returned false, or an archive file to have
1544/// existed when `hot_log_reach` looked and to have gone by the time this did.
1545/// Rows really were removed in every case that gets here, and the message may
1546/// say so without qualification. Deleted rather than kept as a defensive
1547/// fallback, for the reason `delete_guarded` records about
1548/// `classify_archive_violation`: unreachable code that looks reasonable is
1549/// harder to remove later than now.
1550///
1551/// Best-effort by construction: this runs on the error path, where a second
1552/// failure must not replace the diagnosis with its own. A query that does not
1553/// answer yields a hint that says so, and the caller still gets the error it came
1554/// for.
1555async fn archive_hint(conn: &libsql::Connection) -> String {
1556    // `COUNT(*)` always returns a row, so `None` here means the query itself
1557    // failed and there is nothing to say beyond that.
1558    let row = match conn
1559        .query(
1560            "SELECT COUNT(*), MIN(seq_id), MAX(seq_id), MIN(recorded_at) FROM transaction_log",
1561            (),
1562        )
1563        .await
1564    {
1565        Ok(mut rows) => rows.next().await.ok().flatten(),
1566        Err(_) => None,
1567    };
1568
1569    let Some(row) = row else {
1570        return "the hot log could not be inspected for an archive horizon".into();
1571    };
1572    let count: i64 = row.get(0).unwrap_or(0);
1573    if count == 0 {
1574        return "the hot log is empty".into();
1575    }
1576    let min: i64 = row.get(1).unwrap_or(0);
1577    let max: i64 = row.get(2).unwrap_or(0);
1578    let floor: String = row.get(3).unwrap_or_default();
1579    let removed = max - count;
1580
1581    format!(
1582        "{removed} log rows have been archived out of this database; the hot log \
1583         now begins at seq_id {min} ({floor})"
1584    )
1585}
1586
1587/// Was any row ever removed from `transaction_log`? — answered exactly, from
1588/// the hot file alone (0.8.0, B5, D-121).
1589///
1590/// # Why this question needs answering at all
1591///
1592/// With `ts` below the hot log's floor and no archive file present, the state
1593/// on disk is consistent with two very different histories: **nothing was ever
1594/// archived**, in which case the hot log is the whole log and the answer to
1595/// *what was believed at `ts`* is "nothing yet"; or **rows were archived and
1596/// the cold file is gone**, in which case the answer is unknowable and saying
1597/// "nothing" would be inventing one. Before this, the two were conflated and
1598/// both raised — which made an ordinary question about a young database report
1599/// the ledger as damaged.
1600///
1601/// # It was a `COUNT(*)` until 0.15.7, and the count was the whole cost
1602///
1603/// The v15 form was `MIN(seq_id) = 1 AND COUNT(*) = MAX(seq_id)`, and the
1604/// argument for it was a proof rather than a heuristic. `transaction_log.seq_id`
1605/// is `INTEGER PRIMARY KEY AUTOINCREMENT`, so values are allocated 1, 2, 3, …
1606/// and **never reused**; a rolled-back transaction leaves no gap, which
1607/// [D-049](../../docs/architecture/s13-decision-register.md#d-049) established
1608/// by measurement after assuming the opposite; and `trg_txlog_guard_delete`
1609/// confines deletion to an archive session. So if nothing was removed the ids
1610/// are exactly `1..=MAX`, and conversely those two equalities force the `COUNT`
1611/// distinct ids inside `[1, MAX]` to be all of it. Exact in both directions.
1612///
1613/// It was also a scan. `MIN` and `MAX` on the rowid are index seeks and
1614/// `COUNT(*)` is not, so this cost the whole hot log — **0.134 ms at 2,000 rows
1615/// and 32.6 ms at 500,000** — on every recorded-time read below the newest
1616/// surviving stamp, in front of an id-bounded hydration that is flat at 0.14 ms
1617/// however long the log is (`examples/log_integrity_probe.rs`, review C-5,
1618/// [D-247], [D-249]). The one-row read is **0.033 ms and does not move with the
1619/// log**, which is the shape of the change rather than the factor: at 2,000
1620/// rows it is 4x, at half a million it is 930x, and the difference between
1621/// those two is the whole finding.
1622///
1623/// # So the storage writes it down at the moment it becomes true
1624///
1625/// `log_integrity.rows_removed`, maintained by `trg_txlog_mark_gap`, is the
1626/// same bit as a one-row read. A **trigger** rather than the archive code,
1627/// because there is no route to deleting a log row that avoids it — §4.2 admits
1628/// that raw SQL against the file can do what this API refuses, and a bit
1629/// maintained in Rust would be wrong after exactly that, in the direction that
1630/// folds a gap silently.
1631///
1632/// The proof above did not go away; it moved. It is what the v15 → v16 rung
1633/// runs, once, to seed a database that may already have been archived, and what
1634/// `the_bit_agrees_with_the_count_it_replaced` asserts it against.
1635///
1636/// # One state changed hands, and it was wrong before
1637///
1638/// An **empty** hot log used to answer *intact*, unconditionally: `count == 0`
1639/// returned `true`. That conflates a young database with a fully archived one,
1640/// and the second then reported its own emptiness as history — the caller was
1641/// told nothing had been recorded by `ts` when in truth everything had, and was
1642/// told it without an error. [`hot_log_reach`] catches that case when it has an
1643/// archive path to look at; [`hot_log_answers_for`] has none and could not.
1644/// The bit tells them apart on the log alone, which is what a young database
1645/// and an emptied one differ by.
1646///
1647/// # What it deliberately does not claim
1648///
1649/// Nothing about *when* the archiving happened or *what* went, which is what
1650/// the marker [D-132](../../docs/architecture/s13-decision-register.md#d-132)
1651/// refused would have carried, and [D-249] does not revisit that refusal — this
1652/// row answers the guard's own question and holds nothing a message would want.
1653///
1654/// [D-247]: ../../docs/architecture/s13-decision-register.md#d-247
1655/// [D-249]: ../../docs/architecture/s13-decision-register.md#d-249
1656async fn hot_log_is_intact(conn: &libsql::Connection) -> Result<bool> {
1657    let row = conn
1658        .query("SELECT rows_removed FROM log_integrity WHERE id = 1", ())
1659        .await?
1660        .next()
1661        .await?;
1662    // No row is not a state the ladder produces: the rung seeds it and the
1663    // baseline seeds it, and `verify` fails a database missing the table. A
1664    // database that reached here without one is damaged in a way this function
1665    // must not paper over with an optimistic answer.
1666    let Some(row) = row else {
1667        return Ok(false);
1668    };
1669    Ok(row.get::<i64>(0)? == 0)
1670}
1671
1672/// Whether a connection alone can fold `transaction_log` at `ts` (W7.1, D-174).
1673///
1674/// The completeness question [`hot_log_reach`] answers, minus the archive file
1675/// it does not have. Both callers take a `Connection`, so when the hot log is
1676/// short they have nowhere to go and must refuse rather than fold what is left:
1677/// [`crate::graph::TraversalBuilder::as_of_recorded`] folds for topology, and
1678/// [`crate::temporal::hydrate_attributes`] folds for the text (0.13.16, W9.1,
1679/// [D-189](../../docs/architecture/s13-decision-register.md#d-189)). The second
1680/// was folding without asking, which is what §3.2 was.
1681///
1682/// # It ignored `ts` until 0.15.4 (W14.2, review C-2)
1683///
1684/// The body was `hot_log_is_intact(conn)` and the parameter was `_ts`: one bit,
1685/// *was anything ever removed*, with the instant discarded. So the first archive
1686/// session a deployment ever ran took `AttributeMode::AtTime` and every
1687/// `as_of_recorded` traversal away from it permanently, for its whole history
1688/// rather than for the archived part of it — including `as_of_recorded(now)`,
1689/// which is the instant the archive is *guaranteed* to answer.
1690///
1691/// The old comment here justified that as conservative-by-one-bit on the ground
1692/// that the archive cutoff is not recorded hot-side (D-132's refused marker),
1693/// and the ground was sound. The conclusion did not follow: the cutoff is not
1694/// needed. [`reach_with_rows_removed`] decides the same question from the newest
1695/// surviving stamp, which is hot by construction, and [`hot_log_reach`] had been
1696/// computing exactly that verdict per timestamp since 0.5.5 two functions away.
1697/// Both readers now take the three-way verdict and refuse on one arm of it.
1698///
1699/// [`HotLogReach::PredatesRecordedHistory`] is an answer, not a refusal: the
1700/// fold returns the empty state, which is what was believed at an instant before
1701/// anything was recorded. That is also what the old bit did there, so the arm is
1702/// unchanged rather than newly permitted.
1703pub(crate) async fn hot_log_answers_for(conn: &libsql::Connection, ts: &str) -> Result<bool> {
1704    Ok(!matches!(
1705        hot_log_reach_within(conn, ts).await?,
1706        HotLogReach::NeedsArchive
1707    ))
1708}
1709
1710/// Run one fold query from nothing — the unanchored path.
1711async fn fold(conn: &libsql::Connection, ts: &str, query: &str) -> Result<MaterializedState> {
1712    let delta = fold_delta(conn, query, libsql::params![ts]).await?;
1713    Ok(delta.apply_to(MaterializedState::empty(ts), ts))
1714}
1715
1716/// Run one fold query and collect the winning rows, deletions included.
1717async fn fold_delta(
1718    conn: &libsql::Connection,
1719    query: &str,
1720    params: impl libsql::params::IntoParams,
1721) -> Result<Delta> {
1722    let mut rows = conn.query(query, params).await?;
1723    let mut d = Delta::default();
1724    let (concepts, edges, max_seq) = (&mut d.concepts, &mut d.edges, &mut d.max_seq);
1725
1726    while let Some(row) = rows.next().await? {
1727        let seq_id: i64 = row.get(0)?;
1728        let table_name: String = row.get(1)?;
1729        let _entity_id: String = row.get(2)?;
1730        let op: String = row.get(3)?;
1731        let payload_str: String = row.get(4)?;
1732        // Projected by all four folds since 0.14.5. They have partitioned on it
1733        // since D-216; what was missing was carrying it out of the query, which
1734        // is why the correct partition produced a collapsed result anyway.
1735        let branch_id: String = row.get(5)?;
1736
1737        if seq_id > *max_seq {
1738            *max_seq = seq_id;
1739        }
1740
1741        // A `'D'` row is corruption, not a tombstone (D-072).
1742        //
1743        // Doctrine V permits no physical delete outside an archive session, and
1744        // the archive *moves* rows rather than logging their removal — so no
1745        // trigger in the schema writes a `'D'`, and no code path in the crate
1746        // can produce one. This arm used to treat it as a tombstone, which read
1747        // as a claim that deletions are recorded and reconstructible. They are
1748        // not. Refusing here makes the doctrine enforced at the fold rather than
1749        // assumed by it, and is the same call D-060 made for overlap: the layer
1750        // that can notice should.
1751        //
1752        // Retirement is unaffected and is the mechanism that actually removes a
1753        // concept from a composed state — see the `retired != 0` branch below,
1754        // which is where `concepts_gone` is populated in practice.
1755        if op == "D" {
1756            return Err(DbError::ReplayCorrupt {
1757                seq: seq_id,
1758                reason: format!(
1759                    "transaction_log carries a 'D' operation for {table_name} \
1760                     entity {_entity_id:?}; Doctrine V permits no physical delete \
1761                     outside an archive session, and the archive logs none. This \
1762                     row was not written by this crate."
1763                ),
1764            });
1765        }
1766
1767        let payload: serde_json::Value =
1768            serde_json::from_str(&payload_str).map_err(|e| DbError::ReplayCorrupt {
1769                seq: seq_id,
1770                reason: format!("Failed to parse payload JSON: {e}"),
1771            })?;
1772
1773        // v1 and v2 differ by one added field, so v1 folds by reading it as
1774        // absent — which is what `Option` already means here. A future shape
1775        // that *removes* or *retypes* a field would not be able to share this
1776        // path, and would want a match on `v` rather than a ceiling.
1777        let v = payload.get("v").and_then(|v| v.as_u64()).unwrap_or(1);
1778        if v > PAYLOAD_VERSION as u64 {
1779            return Err(DbError::PayloadVersion {
1780                got: v as u8,
1781                max: PAYLOAD_VERSION,
1782            });
1783        }
1784
1785        if table_name == "concepts" {
1786            let id = _entity_id;
1787            let retired = payload.get("retired").and_then(|r| r.as_i64()).unwrap_or(0);
1788            if retired == 0 {
1789                let title = payload
1790                    .get("title")
1791                    .and_then(|s| s.as_str())
1792                    .unwrap_or("")
1793                    .to_string();
1794                let content = payload
1795                    .get("content")
1796                    .and_then(|s| s.as_str())
1797                    .unwrap_or("")
1798                    .to_string();
1799                let embedding_model = payload
1800                    .get("embedding_model")
1801                    .and_then(|s| s.as_str())
1802                    .map(|s| s.to_string());
1803                concepts.insert(
1804                    id.clone(),
1805                    NodeAttributes {
1806                        id,
1807                        title,
1808                        content,
1809                        embedding_model,
1810                    },
1811                );
1812            } else {
1813                // Retirement is the application axis (§4.1), and a reconstruction
1814                // shows what was visible. Onto a snapshot that means removing
1815                // the concept, not declining to add it.
1816                d.concepts_gone.insert(id);
1817            }
1818        } else if table_name == "links" {
1819            let src = payload
1820                .get("source_id")
1821                .and_then(|s| s.as_str())
1822                .unwrap_or("")
1823                .to_string();
1824            let tgt = payload
1825                .get("target_id")
1826                .and_then(|s| s.as_str())
1827                .unwrap_or("")
1828                .to_string();
1829            let edge_type = payload
1830                .get("edge_type")
1831                .and_then(|s| s.as_str())
1832                .unwrap_or("")
1833                .to_string();
1834            let vf = payload
1835                .get("valid_from")
1836                .and_then(|s| s.as_str())
1837                .unwrap_or("")
1838                .to_string();
1839            let vt = payload
1840                .get("valid_to")
1841                .and_then(|s| s.as_str())
1842                .unwrap_or("")
1843                .to_string();
1844            let belief = EdgeBelief {
1845                source_id: src,
1846                target_id: tgt,
1847                edge_type,
1848                valid_from: vf,
1849                valid_to: vt,
1850                branch_id,
1851            };
1852            edges.insert(belief.belief_key(), belief);
1853        }
1854    }
1855
1856    Ok(d)
1857}
1858
1859impl Delta {
1860    /// Compose onto `base` under last-writer-wins by `seq_id` (§5.5).
1861    ///
1862    /// The delta is by construction newer than the base — it is the fold of
1863    /// everything above the base's anchor — so every row it carries wins, and
1864    /// every retirement it carries removes. This is the same rule
1865    /// `trg_links_current_sync`'s upsert applies and the same rule the cold
1866    /// fold applies; that the three agree is asserted by test rather than by
1867    /// this comment (§8).
1868    fn apply_to(self, base: MaterializedState, ts: &str) -> MaterializedState {
1869        let mut concepts = base.concepts;
1870        let mut edges: HashMap<String, EdgeBelief> = base
1871            .edges
1872            .into_iter()
1873            .map(|e| (e.belief_key(), e))
1874            .collect();
1875
1876        for id in self.concepts_gone {
1877            concepts.remove(&id);
1878        }
1879        // No edge equivalent: an edge is superseded in place under the same
1880        // `entity_id`, never removed — see [`Delta`] (D-072).
1881        concepts.extend(self.concepts);
1882        edges.extend(self.edges);
1883
1884        // Sorted so the result is a function of the state and not of hash
1885        // iteration order — `reconstruct` is compared against itself by the
1886        // property suite, and two runs must be equal, not merely equivalent.
1887        let mut edges: Vec<_> = edges.into_values().collect();
1888        edges.sort();
1889
1890        MaterializedState {
1891            seq_anchor: self.max_seq.max(base.seq_anchor),
1892            timestamp: ts.to_string(),
1893            concepts,
1894            edges,
1895            // A delta was applied, so there was history to fold. `reconstruct`
1896            // sets the flag on the one path that never gets here.
1897            predates_recorded_history: false,
1898        }
1899    }
1900}
1901
1902#[cfg(test)]
1903mod reach_table {
1904    //! Every cell of the reach question, named (0.15.5, W14.4, [D-247]).
1905    //!
1906    //! [`hot_log_reach_within`] decides on two facts — whether rows were removed
1907    //! from the log, and where `ts` sits against the stamps that remain — and
1908    //! the order it establishes them in is a **cost** decision, not a
1909    //! correctness one. 0.15.5 reordered it so the cheap fact is enough on the
1910    //! arm the readers actually use. A reordering is exactly the kind of change
1911    //! that is obviously behaviour-preserving until it is not, and the argument
1912    //! for it ("`MAX <= ts` covers under both rules") is short enough to be
1913    //! believed without checking. This table is the checking.
1914    //!
1915    //! Enumerated rather than sampled, because the defects this area has
1916    //! actually produced were all boundary cells: `ts` exactly at the newest
1917    //! stamp (0.15.4), `ts` below the floor of an intact log (0.8.0, D-121),
1918    //! and an empty log that passes the intactness test vacuously (0.5.5).
1919
1920    use super::*;
1921
1922    const A: &str = "1970-01-01T01:00:00.000000Z";
1923    const B: &str = "1970-01-01T02:00:00.000000Z";
1924    const C: &str = "1970-01-01T03:00:00.000000Z";
1925    const BEFORE_A: &str = "1970-01-01T00:30:00.000000Z";
1926    const BETWEEN: &str = "1970-01-01T02:30:00.000000Z";
1927    const AFTER_C: &str = "1970-01-01T04:00:00.000000Z";
1928
1929    /// A log holding one row at each of `A`, `B`, `C`, optionally with `B`'s
1930    /// removed the way an archive removes it — a hole in the middle of the
1931    /// `seq_id` run, leaving the floor and the ceiling where they were.
1932    ///
1933    /// That shape is the point. A gap at the end cannot happen (the newest row
1934    /// per entity is never archivable) and a gap at the front would move `MIN`
1935    /// and make the two rules agree by accident.
1936    async fn log(gapped: bool) -> libsql::Connection {
1937        let db = libsql::Builder::new_local(":memory:")
1938            .build()
1939            .await
1940            .unwrap();
1941        let conn = db.connect().unwrap();
1942        crate::schema::run_migrations(&conn).await.unwrap();
1943        for (i, ts) in [A, B, C].iter().enumerate() {
1944            conn.execute(
1945                "INSERT INTO transaction_log \
1946                 (table_name, entity_id, operation, payload, recorded_at) \
1947                 VALUES ('concepts', ?1, 'upsert', '{}', ?2)",
1948                libsql::params![format!("c{i}").as_str(), *ts],
1949            )
1950            .await
1951            .unwrap();
1952        }
1953        if gapped {
1954            let marker = crate::schema::ddl::ARCHIVE_SESSION_MARKER;
1955            conn.execute(&format!("CREATE TABLE {marker} (x)"), ())
1956                .await
1957                .unwrap();
1958            conn.execute(
1959                "DELETE FROM transaction_log WHERE recorded_at = ?1",
1960                libsql::params![B],
1961            )
1962            .await
1963            .unwrap();
1964            conn.execute(&format!("DROP TABLE {marker}"), ())
1965                .await
1966                .unwrap();
1967        }
1968        conn
1969    }
1970
1971    async fn empty_log() -> libsql::Connection {
1972        let db = libsql::Builder::new_local(":memory:")
1973            .build()
1974            .await
1975            .unwrap();
1976        let conn = db.connect().unwrap();
1977        crate::schema::run_migrations(&conn).await.unwrap();
1978        conn
1979    }
1980
1981    /// An intact log is the whole log, so it answers at every instant: with the
1982    /// fold above its floor, and with the empty state below it.
1983    #[tokio::test]
1984    async fn an_intact_log_answers_everywhere() {
1985        let conn = log(false).await;
1986        for (ts, want) in [
1987            (BEFORE_A, HotLogReach::PredatesRecordedHistory),
1988            (A, HotLogReach::Covers),
1989            (B, HotLogReach::Covers),
1990            (BETWEEN, HotLogReach::Covers),
1991            (C, HotLogReach::Covers),
1992            (AFTER_C, HotLogReach::Covers),
1993        ] {
1994            assert_eq!(
1995                hot_log_reach_within(&conn, ts).await.unwrap(),
1996                want,
1997                "intact log at {ts}"
1998            );
1999            assert!(
2000                hot_log_answers_for(&conn, ts).await.unwrap(),
2001                "an intact log refuses nothing, and refused {ts}"
2002            );
2003        }
2004    }
2005
2006    /// Once a row has gone, the boundary moves to the *newest* surviving stamp
2007    /// and the sense of the comparison inverts.
2008    ///
2009    /// `A` is the case that matters and the one the old rule got wrong: the log
2010    /// still reaches back to it — `MIN(recorded_at)` is `A` — and the answer at
2011    /// `A` is nonetheless in the other file, because the row that won at `A`
2012    /// for the entity whose `B` row went is no longer here to be found.
2013    #[tokio::test]
2014    async fn a_gapped_log_answers_only_from_its_newest_stamp() {
2015        let conn = log(true).await;
2016        for (ts, want) in [
2017            (BEFORE_A, HotLogReach::NeedsArchive),
2018            (A, HotLogReach::NeedsArchive),
2019            (BETWEEN, HotLogReach::NeedsArchive),
2020            (C, HotLogReach::Covers),
2021            (AFTER_C, HotLogReach::Covers),
2022        ] {
2023            assert_eq!(
2024                hot_log_reach_within(&conn, ts).await.unwrap(),
2025                want,
2026                "gapped log at {ts}"
2027            );
2028            assert_eq!(
2029                hot_log_answers_for(&conn, ts).await.unwrap(),
2030                want != HotLogReach::NeedsArchive,
2031                "the boolean guard must agree with the verdict at {ts}"
2032            );
2033        }
2034    }
2035
2036    /// `C` is the newest surviving stamp and must be *answered*, not refused.
2037    ///
2038    /// Split out of the table above rather than left as one row in it, because
2039    /// it is the cell 0.15.4 was about and the cell a `<` instead of a `<=`
2040    /// takes. A boundary that is one row of six is a boundary nobody reads.
2041    #[tokio::test]
2042    async fn the_newest_surviving_stamp_is_answered_and_not_refused() {
2043        let conn = log(true).await;
2044        assert_eq!(
2045            hot_log_reach_within(&conn, C).await.unwrap(),
2046            HotLogReach::Covers,
2047            "at the newest surviving stamp every entity's winning row is its \
2048             newest row, and every one of those is still here"
2049        );
2050    }
2051
2052    /// An empty log is intact vacuously — nothing was removed because nothing
2053    /// is there — and the empty state is the honest answer at every instant.
2054    ///
2055    /// This is the cell that keeps the archive-file check in [`hot_log_reach`]
2056    /// from being folded into intactness: the *same* database with a cold file
2057    /// beside it is fully archived rather than young, and must not answer here.
2058    #[tokio::test]
2059    async fn an_empty_log_predates_everything_rather_than_covering_it() {
2060        let conn = empty_log().await;
2061        for ts in [BEFORE_A, C, AFTER_C] {
2062            assert_eq!(
2063                hot_log_reach_within(&conn, ts).await.unwrap(),
2064                HotLogReach::PredatesRecordedHistory,
2065                "empty log at {ts}"
2066            );
2067        }
2068    }
2069
2070    /// The reordering is a cost change, so the cheap arm must give the verdict
2071    /// the whole case split gives — on both sides of the split.
2072    ///
2073    /// Written as a comparison rather than as two expected values: it is the
2074    /// property the optimisation rests on, and asserting literals here would
2075    /// pass if the property were false and both sides were wrong together.
2076    #[tokio::test]
2077    async fn the_cheap_arm_agrees_with_the_rule_it_short_circuits() {
2078        for gapped in [false, true] {
2079            let conn = log(gapped).await;
2080            for ts in [BEFORE_A, A, BETWEEN, C, AFTER_C] {
2081                if newest_stamp_covers(&conn, ts).await.unwrap() {
2082                    assert_eq!(
2083                        hot_log_reach_within(&conn, ts).await.unwrap(),
2084                        HotLogReach::Covers,
2085                        "the cheap arm claimed {ts} on a gapped={gapped} log and \
2086                         the full rule disagrees"
2087                    );
2088                }
2089            }
2090        }
2091    }
2092
2093    /// Empty the log the way a long-running archive does.
2094    async fn empty_the_log(conn: &libsql::Connection) {
2095        let marker = crate::schema::ddl::ARCHIVE_SESSION_MARKER;
2096        conn.execute(&format!("CREATE TABLE {marker} (x)"), ())
2097            .await
2098            .unwrap();
2099        conn.execute("DELETE FROM transaction_log", ())
2100            .await
2101            .unwrap();
2102        conn.execute(&format!("DROP TABLE {marker}"), ())
2103            .await
2104            .unwrap();
2105    }
2106
2107    /// A log archived down to nothing is not a log nothing was written to
2108    /// (0.15.7, W14.5, [D-249]).
2109    ///
2110    /// This is the cell the module doc names as a defect from 0.5.5 and the one
2111    /// the table did not have: `count = 0` returned *intact* by a separate arm,
2112    /// so a fully archived database was told `PredatesRecordedHistory` —
2113    /// nothing had been recorded by `ts` — at every instant, with its whole
2114    /// history sitting in the archive and no error to say so. [`hot_log_reach`]
2115    /// catches it when it has an archive path to look at; this function has
2116    /// none, and the bit is what it has instead.
2117    #[tokio::test]
2118    async fn a_log_archived_down_to_nothing_asks_for_the_archive() {
2119        let conn = log(false).await;
2120        empty_the_log(&conn).await;
2121
2122        for ts in [BEFORE_A, A, BETWEEN, C, AFTER_C] {
2123            assert_eq!(
2124                hot_log_reach_within(&conn, ts).await.unwrap(),
2125                HotLogReach::NeedsArchive,
2126                "an emptied log answered for {ts} out of its own emptiness"
2127            );
2128            assert!(
2129                !hot_log_answers_for(&conn, ts).await.unwrap(),
2130                "an emptied log claims to answer for {ts}"
2131            );
2132        }
2133
2134        // And the state it must not be confused with, unchanged: a log nothing
2135        // was ever written to still predates history rather than refusing.
2136        let young = empty_log().await;
2137        assert_eq!(
2138            hot_log_reach_within(&young, BEFORE_A).await.unwrap(),
2139            HotLogReach::PredatesRecordedHistory,
2140            "a young log was made to refuse, which is the opposite over-correction"
2141        );
2142    }
2143
2144    /// A database whose integrity row is gone is damaged, and damaged is not
2145    /// intact (0.15.7, W14.5, [D-249]).
2146    ///
2147    /// The ladder seeds the row and `verify` requires the table, so nothing the
2148    /// crate does produces this. Something outside the crate can — §4.2 says
2149    /// so — and the arm that handles it chooses to refuse rather than to assume
2150    /// the happy answer, because the happy answer here is *fold an incomplete
2151    /// log and return it as belief*.
2152    #[tokio::test]
2153    async fn a_log_without_its_integrity_row_is_not_assumed_intact() {
2154        let conn = log(false).await;
2155        conn.execute("DELETE FROM log_integrity", ()).await.unwrap();
2156
2157        assert!(
2158            !hot_log_is_intact(&conn).await.unwrap(),
2159            "a missing integrity row was read as an intact log"
2160        );
2161        assert_eq!(
2162            hot_log_reach_within(&conn, BETWEEN).await.unwrap(),
2163            HotLogReach::NeedsArchive,
2164            "a damaged database was answered from rather than refused"
2165        );
2166    }
2167}