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::error::{DbError, Result};
6use crate::temporal::as_of::NodeAttributes;
7
8/// Full materialized state reconstructed from transaction_log replay (§5.5).
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct MaterializedState {
11    pub seq_anchor: i64,
12    pub timestamp: String,
13    pub concepts: HashMap<String, NodeAttributes>,
14    pub edges: Vec<(String, String, String, String, String)>,
15    /// **Nothing had been recorded yet at `timestamp`** (0.8.0, B5, D-121).
16    ///
17    /// An empty state has two meanings and a caller can act differently on
18    /// them. *Everything was retired by then* is a fact about the data;
19    /// *the ledger had not started* is a fact about the question. Both come
20    /// back as zero concepts and zero edges, so the difference has to be
21    /// carried rather than inferred.
22    ///
23    /// Set only when the log was verified **intact** — see
24    /// `hot_log_reach`. If rows had been archived away, `ts` below the hot
25    /// floor is not "before history", it is "the history is in the other file",
26    /// and that path raises instead of answering.
27    ///
28    /// `#[serde(default)]` so the field is additive: a snapshot written without
29    /// it deserialises with `false`, which is the right answer for any state
30    /// that had rows to fold. Old snapshots cannot actually reach this code —
31    /// the container carries `SCHEMA_VERSION` and v8 refused every v7 file
32    /// (D-043) — but the tolerance costs nothing and the next field to arrive
33    /// may not land in a release that bumps the schema.
34    #[serde(default)]
35    pub predates_recorded_history: bool,
36}
37
38impl MaterializedState {
39    /// The state before any log row has been applied.
40    fn empty(ts: &str) -> Self {
41        Self {
42            seq_anchor: 0,
43            timestamp: ts.to_string(),
44            concepts: HashMap::new(),
45            edges: Vec::new(),
46            predates_recorded_history: false,
47        }
48    }
49}
50
51/// The newest log payload shape this build writes and the highest it can read.
52///
53/// Kept beside the folds because they are the only readers, and bumped in step
54/// with the `json_object('v', …)` literals in `schema::ddl` — a test asserts the
55/// two agree, since nothing else would notice them drifting apart.
56pub(crate) const PAYLOAD_VERSION: u8 = 2;
57
58/// Every fold partitions on `(table_name, entity_id)`, never `entity_id` alone.
59///
60/// The two namespaces are not disjoint and nothing makes them so. A link's
61/// `entity_id` is the synthetic `source|target|type|valid_from`; a concept's is
62/// whatever the caller passed, unvalidated (defect AD). Partitioning on the id
63/// alone therefore lets a concept and a link contend for one window, and
64/// `ROW_NUMBER() = 1` hands the whole partition to whichever has the greater
65/// `seq_id` — so the loser vanishes from the reconstruction while sitting
66/// plainly in both `concepts` and `transaction_log`. Silent, and on the read
67/// path the ledger exists to make trustworthy.
68///
69/// Validating identifiers would make the collision unreachable and is the
70/// durable fix; this makes it harmless regardless, which is the property worth
71/// having at the fold. `table_name` leads the partition because the log is
72/// already indexed on `entity_id` and the discriminator is two values wide.
73const HOT_FOLD: &str = r#"
74    SELECT seq_id, table_name, entity_id, operation, payload
75    FROM (
76        SELECT seq_id, table_name, entity_id, operation, payload,
77               ROW_NUMBER() OVER (PARTITION BY table_name, entity_id ORDER BY seq_id DESC) as rn
78        FROM transaction_log
79        WHERE recorded_at <= ?1
80    ) WHERE rn = 1
81"#;
82
83/// Fold over hot and cold together (§5.5, D-026). Requires `cold` to be ATTACHed.
84///
85/// The hot entry wins for entities present in both files because its `seq_id` is
86/// greater — the same last-writer-wins rule as snapshot composition.
87const COLD_FOLD: &str = r#"
88    SELECT seq_id, table_name, entity_id, operation, payload
89    FROM (
90        SELECT seq_id, table_name, entity_id, operation, payload,
91               ROW_NUMBER() OVER (PARTITION BY table_name, entity_id ORDER BY seq_id DESC) as rn
92        FROM (
93            SELECT seq_id, table_name, entity_id, operation, payload, recorded_at FROM main.transaction_log
94            UNION ALL
95            SELECT seq_id, table_name, entity_id, operation, payload, recorded_at FROM cold.transaction_log
96        ) WHERE recorded_at <= ?1
97    ) WHERE rn = 1
98"#;
99
100/// Fold over the hot log *above a snapshot anchor* (§5.5, D-049).
101///
102/// `seq_id > ?2` is an inequality, and deliberately so: `AUTOINCREMENT` leaves
103/// gaps whenever a transaction rolls back, so successor arithmetic
104/// (`seq_id = :anchor + 1`) would stop at the first gap and silently truncate
105/// the delta. This is the first anchored fold in the crate, which makes it the
106/// first code D-024's rule has ever bound — before this the rule was vacuous,
107/// not satisfied.
108const ANCHORED_HOT_FOLD: &str = r#"
109    SELECT seq_id, table_name, entity_id, operation, payload
110    FROM (
111        SELECT seq_id, table_name, entity_id, operation, payload,
112               ROW_NUMBER() OVER (PARTITION BY table_name, entity_id ORDER BY seq_id DESC) as rn
113        FROM transaction_log
114        WHERE recorded_at <= ?1 AND seq_id > ?2
115    ) WHERE rn = 1
116"#;
117
118/// Fold over hot **and cold** above a snapshot anchor (§5.5, 0.5.5).
119///
120/// The union is what lets composition survive an archive. Rows keep their
121/// `seq_id` when they move to cold — the cold schema declares a plain `INTEGER
122/// PRIMARY KEY` precisely so history is not renumbered — so `seq_id > ?2`
123/// partitions the two files consistently and last-writer-wins across them by the
124/// same rule the unanchored folds use.
125const ANCHORED_COLD_FOLD: &str = r#"
126    SELECT seq_id, table_name, entity_id, operation, payload
127    FROM (
128        SELECT seq_id, table_name, entity_id, operation, payload,
129               ROW_NUMBER() OVER (PARTITION BY table_name, entity_id ORDER BY seq_id DESC) as rn
130        FROM (
131            SELECT seq_id, table_name, entity_id, operation, payload, recorded_at FROM main.transaction_log
132            UNION ALL
133            SELECT seq_id, table_name, entity_id, operation, payload, recorded_at FROM cold.transaction_log
134        ) WHERE recorded_at <= ?1 AND seq_id > ?2
135    ) WHERE rn = 1
136"#;
137
138/// The winning log rows for one fold, before they are applied to a base state.
139///
140/// Absence and disappearance are different facts, and a merge is where the
141/// difference starts to matter. A full fold from nothing can treat "this entity
142/// went away" and "there is no row for it" identically — both end as absence.
143/// Composed onto a snapshot they are opposites: a disappearance must *remove*
144/// the entity the snapshot carries, and skipping it leaves the snapshot's stale
145/// row standing as though nothing had happened. So they are collected rather
146/// than dropped, and the full fold applies them to an empty base, which keeps
147/// one code path for both cases (D-049).
148///
149/// **There is one such set, not two (D-072).** It used to carry `edges_gone`
150/// beside `concepts_gone`, and both were populated only from the `'D'` branch of
151/// [`fold_delta`] — so when that branch became an error, `edges_gone` was left
152/// reachable by nothing. Closing one unreachable path by opening another is not
153/// a fix, so it went too.
154///
155/// The asymmetry is real and worth stating, because "concepts can vanish and
156/// edges cannot" looks like an oversight until you follow it:
157///
158/// * A **concept** disappears by being *retired*, which writes a `'U'` row whose
159///   payload has `retired = 1`. That is a genuine removal from a composed state
160///   and `concepts_gone` carries it.
161/// * An **edge** never disappears. It is retired by asserting a successor over
162///   the same interval key — same `source|target|type|valid_from`, later
163///   `recorded_at` — so the log row is an `'I'` under the *same* `entity_id`, and
164///   last-writer-wins in [`Self::apply_to`] replaces the tuple in place. There is
165///   nothing to remove because nothing left; the interval simply closed.
166///
167/// That is Doctrine III showing through: an edge assertion is immutable and
168/// superseded, never deleted.
169#[derive(Default)]
170struct Delta {
171    concepts: HashMap<String, NodeAttributes>,
172    /// Keyed by `transaction_log.entity_id`: `source|target|type|valid_from`.
173    edges: HashMap<String, (String, String, String, String, String)>,
174    /// Concepts retired as of the fold's instant. See the type's note for why
175    /// there is no edge equivalent.
176    concepts_gone: HashSet<String>,
177    max_seq: i64,
178}
179
180/// The log's `entity_id` for a link, rebuilt from a materialised edge tuple.
181///
182/// Must match `trg_links_log_i`'s
183/// `source_id || '|' || target_id || '|' || edge_type || '|' || valid_from`
184/// exactly, or a delta row will fail to replace the snapshot row it supersedes.
185/// Safe because ULIDs are Crockford base32 and edge types are `[A-Z0-9]+`, so
186/// `|` cannot occur inside a component (§4.3).
187fn edge_key(e: &(String, String, String, String, String)) -> String {
188    format!("{}|{}|{}|{}", e.0, e.1, e.2, e.3)
189}
190
191/// Release a `cold` handle left attached by an earlier call (§5.5, D-044).
192///
193/// Both ATTACH sites pair with an unconditional DETACH on the way out, so in
194/// the normal course this finds nothing and the statement fails harmlessly with
195/// "no such database: cold". It exists for the case the pairing cannot cover: a
196/// panic unwinding between the two, which skips the DETACH no matter which exit
197/// path the `Result` would have taken.
198///
199/// A `Drop` guard is the reflex here and does not work — `execute` is `async`,
200/// and a `Drop` impl cannot await, so it would build a future, discard it, and
201/// leave the handle attached while looking like it had cleaned up. Recovering
202/// on the way *in* needs no destructor, works regardless of how the handle
203/// leaked, and turns permanent poisoning of the connection into one failed
204/// statement nobody sees.
205pub(crate) async fn detach_stale_cold(conn: &libsql::Connection) {
206    let _ = conn.execute("DETACH DATABASE cold", ()).await;
207}
208
209/// Reconstruct database state as believed at past instant `ts` using window-function log fold (§5.5, D-026).
210///
211/// When `ts` predates the hot log's horizon the cold database is ATTACHed for
212/// exactly one fold and DETACHed unconditionally on the way out, error paths
213/// included. ATTACH is not transactional and survives ROLLBACK, so a handle
214/// leaked by an early return would make every later `reconstruct` *and* every
215/// later `archive` fail with "database cold is already in use" — one corrupt
216/// payload would permanently poison the connection. This is the same failure
217/// mode `archive()` carries a note about, and the two now share a shape.
218/// Snapshot composition (§5.5, D-049) applies when `snapshots_dir` holds a
219/// snapshot at or before `ts` and no archive database exists — see
220/// `snapshot_anchor` for why archiving disables it. Otherwise the fold runs
221/// from genesis, which is correct and costs what the whole log costs.
222pub async fn reconstruct(
223    conn: &libsql::Connection,
224    ts: &str,
225    archive_path: Option<&Path>,
226    snapshots_dir: Option<&Path>,
227) -> Result<MaterializedState> {
228    match hot_log_reach(conn, ts, archive_path).await? {
229        HotLogReach::Covers => {
230            if let Some(base) = snapshot_anchor(snapshots_dir, ts).await {
231                let anchor = base.seq_anchor;
232                let delta =
233                    fold_delta(conn, ANCHORED_HOT_FOLD, libsql::params![ts, anchor]).await?;
234                return Ok(delta.apply_to(base, ts));
235            }
236            return fold(conn, ts, HOT_FOLD).await;
237        }
238        HotLogReach::PredatesRecordedHistory => {
239            // Nothing had been recorded by `ts`, and nothing has been removed
240            // from the log, so there is no history anywhere to go looking for.
241            // The empty state is the answer, flagged so a caller can tell it
242            // from a state that is empty because everything was retired.
243            let mut state = MaterializedState::empty(ts);
244            state.predates_recorded_history = true;
245            return Ok(state);
246        }
247        HotLogReach::NeedsArchive => {}
248    }
249
250    // The delta lives in the cold archive database. Both ways of failing to
251    // reach it carry `archive_hint`, which is the message the rejected hot-side
252    // marker was wanted for — see that function for why no marker is needed.
253    //
254    // **Computed inside the error arms, not before them.** `NeedsArchive` is the
255    // ordinary path to a cold fold and usually succeeds; an eager hint would put
256    // an extra query on it for a string almost every caller discards. An
257    // injection probe caught this — `a_failed_cold_reconstruct_still_detaches`
258    // reached the hint on a run that raised nothing from here.
259    let archive = match archive_path {
260        Some(p) => p,
261        None => {
262            return Err(DbError::ReplayCorrupt {
263                seq: 0,
264                reason: format!(
265                    "state at {ts} predates the hot log and no archive path was given; {}",
266                    archive_hint(conn).await
267                ),
268            })
269        }
270    };
271    if !archive.exists() {
272        return Err(DbError::ReplayCorrupt {
273            seq: 0,
274            reason: format!(
275                "archive database file {archive:?} does not exist; {}",
276                archive_hint(conn).await
277            ),
278        });
279    }
280
281    detach_stale_cold(conn).await;
282
283    // Bound, not interpolated: a path is caller data, and hand-rolled quote
284    // doubling is a worse version of what the driver already does correctly.
285    conn.execute(
286        "ATTACH DATABASE ?1 AS cold",
287        libsql::params![archive.to_string_lossy().as_ref()],
288    )
289    .await?;
290
291    // Composition works across the archive boundary because the anchored fold
292    // unions both files; before 0.5.5 it was refused here rather than made to
293    // work, and the refusal was the only thing keeping the answer right.
294    let result = match snapshot_anchor(snapshots_dir, ts).await {
295        Some(base) => {
296            let anchor = base.seq_anchor;
297            fold_delta(conn, ANCHORED_COLD_FOLD, libsql::params![ts, anchor])
298                .await
299                .map(|delta| delta.apply_to(base, ts))
300        }
301        None => fold(conn, ts, COLD_FOLD).await,
302    };
303
304    // Unconditional: see the ATTACH note above.
305    if let Err(e) = conn.execute("DETACH DATABASE cold", ()).await {
306        tracing::warn!("reconstruct: failed to DETACH cold database: {e}");
307    }
308
309    result
310}
311
312/// Fold from genesis and compare against the composed answer (§5.5, T5.3,
313/// D-092).
314///
315/// # The problem this exists for
316///
317/// [`crate::temporal::save_snapshot`] is written by `write_final`, which calls
318/// [`reconstruct`] — and `reconstruct` composes onto the *previous* snapshot
319/// whenever one is usable. So snapshot *n* is derived from snapshot *n−1*, and
320/// there is no periodic full fold anywhere in the chain. An error introduced at
321/// any link is copied forward indefinitely, and every subsequent read agrees
322/// with it, because they are all reading the same descendant.
323///
324/// The project's own open item names the difficulty honestly: a full fold is
325/// exactly the cost snapshots exist to avoid, so this cannot run on every read.
326/// It is a **scheduling** problem, and this function is the thing to schedule.
327///
328/// # It reports; it does not repair
329///
330/// Deliberate, and not merely conservative. Under [Doctrine VI] a snapshot is
331/// derivative and disposable, so the repair is *delete the snapshots* — one
332/// line, available to the caller, and correct without this function's help.
333/// What the caller cannot get for themselves is the knowledge that the chain
334/// diverged, and silently rewriting the file would destroy the only evidence of
335/// a bug in composition. A divergence here is not a corrupt database; it is a
336/// wrong **cache**, and it means composition has a defect worth finding.
337///
338/// # Cost
339///
340/// One fold from genesis over the whole log, plus one composed reconstruction.
341/// That is the expensive path by construction — see [`crate::Database::
342/// verify_snapshot_chain`] for the handle-level entry point and the note on
343/// when to run it.
344///
345/// [Doctrine VI]: ../../../docs/architecture/s0-s3-foundations.md#doctrine-vi
346pub async fn verify_snapshot_chain(
347    conn: &libsql::Connection,
348    ts: &str,
349    archive_path: Option<&Path>,
350    snapshots_dir: &Path,
351) -> Result<ChainCheck> {
352    // The composed answer: what every reader gets today.
353    let composed = reconstruct(conn, ts, archive_path, Some(snapshots_dir)).await?;
354    // The authority: the same instant, with the snapshot directory withheld, so
355    // `snapshot_anchor` finds nothing and the fold runs from genesis. Passing
356    // `None` is what makes this an independent computation rather than a second
357    // call to the thing under test.
358    let folded = reconstruct(conn, ts, archive_path, None).await?;
359    Ok(ChainCheck::compare(ts, &composed, &folded))
360}
361
362/// The result of a [`verify_snapshot_chain`] cross-check.
363///
364/// Carries the disagreements rather than a bool, because "the chain diverged" is
365/// not actionable and "these three concepts differ, and this edge is present in
366/// one and not the other" is. Bounded — see [`ChainCheck::SAMPLE_LIMIT`] — since
367/// a chain that went wrong early can disagree about every row, and a report that
368/// is the size of the database is one nobody reads.
369#[derive(Debug, Clone)]
370pub struct ChainCheck {
371    pub timestamp: String,
372    /// `seq_anchor` of the composed answer and of the genesis fold. These
373    /// **may legitimately differ**: the composed answer anchors at the snapshot
374    /// it started from plus its delta, and the fold anchors at the newest row it
375    /// saw. Reported for diagnosis, never compared.
376    pub composed_anchor: i64,
377    pub folded_anchor: i64,
378    pub composed_concepts: usize,
379    pub folded_concepts: usize,
380    pub composed_edges: usize,
381    pub folded_edges: usize,
382    /// Concept ids present in one and not the other, or whose attributes differ.
383    pub concept_disagreements: Vec<String>,
384    /// Edge keys present in one and not the other.
385    pub edge_disagreements: Vec<String>,
386    /// True when either list was truncated at [`ChainCheck::SAMPLE_LIMIT`].
387    pub truncated: bool,
388}
389
390impl ChainCheck {
391    /// How many disagreements of each kind to carry.
392    pub const SAMPLE_LIMIT: usize = 32;
393
394    pub fn diverged(&self) -> bool {
395        !self.concept_disagreements.is_empty() || !self.edge_disagreements.is_empty()
396    }
397
398    fn compare(ts: &str, composed: &MaterializedState, folded: &MaterializedState) -> Self {
399        let mut concept_disagreements = Vec::new();
400        let mut truncated = false;
401
402        let mut ids: Vec<&String> = composed.concepts.keys().collect();
403        ids.extend(folded.concepts.keys());
404        ids.sort_unstable();
405        ids.dedup();
406        for id in ids {
407            let a = composed.concepts.get(id);
408            let b = folded.concepts.get(id);
409            let same = match (a, b) {
410                (Some(a), Some(b)) => {
411                    a.title == b.title
412                        && a.content == b.content
413                        && a.embedding_model == b.embedding_model
414                }
415                (None, None) => true,
416                _ => false,
417            };
418            if !same {
419                if concept_disagreements.len() < Self::SAMPLE_LIMIT {
420                    concept_disagreements.push(id.clone());
421                } else {
422                    truncated = true;
423                }
424            }
425        }
426
427        // Edges are a `Vec` of tuples with no declared order, so the comparison
428        // is on the set. Comparing the vectors directly would report a
429        // divergence for a reordering, which is not one — and that false
430        // positive is worse than useless here, because the whole point of this
431        // check is that a report means "go and find the bug".
432        let key = |e: &(String, String, String, String, String)| {
433            format!("{}|{}|{}|{}|{}", e.0, e.1, e.2, e.3, e.4)
434        };
435        let ca: HashSet<String> = composed.edges.iter().map(key).collect();
436        let fa: HashSet<String> = folded.edges.iter().map(key).collect();
437        let mut edge_disagreements: Vec<String> = ca.symmetric_difference(&fa).cloned().collect();
438        edge_disagreements.sort_unstable();
439        if edge_disagreements.len() > Self::SAMPLE_LIMIT {
440            edge_disagreements.truncate(Self::SAMPLE_LIMIT);
441            truncated = true;
442        }
443
444        Self {
445            timestamp: ts.to_string(),
446            composed_anchor: composed.seq_anchor,
447            folded_anchor: folded.seq_anchor,
448            composed_concepts: composed.concepts.len(),
449            folded_concepts: folded.concepts.len(),
450            composed_edges: ca.len(),
451            folded_edges: fa.len(),
452            concept_disagreements,
453            edge_disagreements,
454            truncated,
455        }
456    }
457}
458
459impl std::fmt::Display for ChainCheck {
460    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
461        if !self.diverged() {
462            return write!(
463                f,
464                "snapshot chain agrees with a genesis fold at {}: {} concepts, {} edges",
465                self.timestamp, self.folded_concepts, self.folded_edges
466            );
467        }
468        write!(
469            f,
470            "snapshot chain DIVERGED at {}: composed {} concepts / {} edges, \
471             genesis fold {} concepts / {} edges; {} concept and {} edge \
472             disagreements{}. The snapshots are a wrong cache, not a corrupt \
473             ledger — deleting the snapshot directory restores correctness and \
474             loses only speed (Doctrine VI). concepts: {:?} edges: {:?}",
475            self.timestamp,
476            self.composed_concepts,
477            self.composed_edges,
478            self.folded_concepts,
479            self.folded_edges,
480            self.concept_disagreements.len(),
481            self.edge_disagreements.len(),
482            if self.truncated { " (truncated)" } else { "" },
483            self.concept_disagreements,
484            self.edge_disagreements,
485        )
486    }
487}
488
489/// The newest usable snapshot at or before `ts`, or `None` to fold from genesis.
490///
491/// **Composition used to be disabled once an archive database existed, and as of
492/// 0.5.5 it is not.** The reason for the refusal was real: `LOG_ARCHIVABLE`
493/// (§5.7) removes superseded rows scattered through the sequence, so a row above
494/// the anchor and at or before `ts` could be in cold while a newer row for the
495/// same entity — recorded *after* `ts`, invisible to the fold — kept it out of
496/// the hot log. The delta missed it and the snapshot answered with a stale
497/// value. The fix is the one that note named: the cold log is now in the delta,
498/// via [`ANCHORED_COLD_FOLD`], so the archived row is visible again and there is
499/// nothing left to refuse.
500///
501/// Selection loads candidates newest-first and stops at the first whose
502/// timestamp is at or before `ts`, so the common case — `reconstruct(now)` —
503/// reads exactly one file. A snapshot this build cannot read
504/// ([`DbError::SnapshotIncompatible`], D-043) is skipped, not raised: an
505/// incompatible snapshot is an ordinary consequence of upgrading, and the whole
506/// point of distinguishing it from corruption is that the answer is to carry on
507/// without it.
508///
509/// # It runs on a blocking thread, and a lost one costs speed only (0.13.11, W8.1, D-184)
510///
511/// The scan is a directory listing plus one or more full
512/// [`load_snapshot`](super::snapshot::load_snapshot) calls — decompression and
513/// bincode over the whole state, on a worker that has other tasks waiting. The
514/// *whole scan* is offloaded rather than each file, because the loop is
515/// sequential by construction (it stops at the first usable file) and a hop per
516/// candidate would add scheduling to a path whose common case reads exactly one.
517///
518/// A [`tokio::task::JoinError`] means the loader panicked, and the answer is the
519/// same one this function already gives for every other kind of unusable file:
520/// `None`, and fold from genesis. That is not leniency, it is what a snapshot
521/// *is* — derivative and disposable under [Doctrine VI], so the cost of ignoring
522/// one is a slower reconstruction and never a wrong one. It is also a real
523/// improvement over the previous arrangement: inline, a panic in the loader
524/// unwound through [`reconstruct`] and took the caller's task with it, which
525/// meant a single corrupt file could stop a process that had a correct answer
526/// available the whole time. W8.4 fuzzes for exactly those panics; this is what
527/// happens to the ones it has not found yet.
528///
529/// [Doctrine VI]: ../../../docs/architecture/s0-s3-foundations.md#doctrine-vi
530async fn snapshot_anchor(snapshots_dir: Option<&Path>, ts: &str) -> Option<MaterializedState> {
531    let dir = snapshots_dir?.to_path_buf();
532    let ts = ts.to_string();
533    match tokio::task::spawn_blocking(move || newest_usable_snapshot(&dir, &ts)).await {
534        Ok(found) => found,
535        Err(e) => {
536            tracing::warn!("the snapshot scan did not finish ({e}); folding from genesis");
537            None
538        }
539    }
540}
541
542/// The blocking half of [`snapshot_anchor`]: read the directory, load
543/// newest-first, stop at the first snapshot at or before `ts`.
544fn newest_usable_snapshot(dir: &Path, ts: &str) -> Option<MaterializedState> {
545    let mut candidates: Vec<(i64, PathBuf)> = std::fs::read_dir(dir)
546        .ok()?
547        .flatten()
548        .map(|e| e.path())
549        .filter_map(|p| super::snapshot::seq_from_filename(&p).map(|s| (s, p)))
550        .collect();
551    candidates.sort_by_key(|(seq, _)| std::cmp::Reverse(*seq));
552
553    for (_, path) in candidates {
554        match super::snapshot::load_snapshot(&path) {
555            // Sound as a string comparison because every timestamp is the
556            // canonical fixed width (D-029).
557            Ok(state) if state.timestamp.as_str() <= ts => return Some(state),
558            Ok(_) => continue,
559            Err(DbError::SnapshotIncompatible { reason, .. }) => {
560                tracing::warn!("skipping snapshot {path:?}: {reason}");
561                continue;
562            }
563            Err(e) => {
564                tracing::warn!("skipping unreadable snapshot {path:?}: {e}");
565                continue;
566            }
567        }
568    }
569    None
570}
571
572/// Where the answer for `ts` lives.
573///
574/// Three cases, not two (0.8.0, B5, D-121). This used to be a `bool`, and the
575/// missing third case is the whole of B5: *below the log's floor* was folded in
576/// with *the delta is elsewhere*, so a question about a time before the ledger
577/// started came back as [`DbError::ReplayCorrupt`] — the class meaning the
578/// ledger is damaged — naming an archive file the caller had never created.
579enum HotLogReach {
580    /// The hot log holds everything needed at `ts`. Fold it.
581    Covers,
582    /// Nothing had been recorded by `ts`, and nothing has ever been removed
583    /// from the log, so no other file could hold it either. The empty state is
584    /// the correct answer, not a failure to find one.
585    PredatesRecordedHistory,
586    /// The delta is in the cold archive. If it cannot be reached, that is an
587    /// error and stays one.
588    NeedsArchive,
589}
590
591/// Whether the hot log alone can answer for `ts` — a *completeness* test.
592///
593/// **This replaces a reach test that was not one (0.5.5).** The previous version
594/// asked `MIN(recorded_at) <= ts`: whether the hot log stretches back far enough
595/// to contain `ts`. That is a different question from whether it still contains
596/// everything needed to answer at `ts`, and `LOG_ARCHIVABLE` (§5.7) is exactly
597/// what pulls the two apart — it removes *superseded* rows, scattered through
598/// the sequence rather than forming a prefix. One entity archived and another
599/// not is enough: the unarchived one keeps `MIN` pointing before the cutoff
600/// while the archived one's winning row is gone, and the fold silently returns a
601/// state missing an entity. Measured, not theorised — see
602/// `reconstructing_before_the_archive_cutoff_keeps_every_entity`.
603///
604/// The sound test rests on the one guarantee the archive does make: **the newest
605/// row per entity is never archivable**, because archivability requires a later
606/// row to exist. So if `ts` is at or after the newest hot stamp, every entity's
607/// winning row at `ts` is its newest row overall, and every such row is hot.
608/// That covers `reconstruct(now)` — the common case, and the case §5.7 designed
609/// `LOG_ARCHIVABLE` around — and nothing else.
610///
611/// Anything earlier goes to the cold file. That is more ATTACHes than the old
612/// rule performed, and the trade is not close: the old rule was cheaper because
613/// it was answering a question nobody asked.
614///
615/// With no archive database in play the reach test *is* the completeness test —
616/// nothing has been removed, so the hot log is the whole log — and it is kept,
617/// because it is also what distinguishes "before recorded history" from "the
618/// cold file is missing" (D-026).
619async fn hot_log_reach(
620    conn: &libsql::Connection,
621    ts: &str,
622    archive_path: Option<&Path>,
623) -> Result<HotLogReach> {
624    let row = conn
625        .query(
626            "SELECT MIN(recorded_at), MAX(recorded_at) FROM transaction_log",
627            (),
628        )
629        .await?
630        .next()
631        .await?;
632    let (min_recorded_at, max_recorded_at): (Option<String>, Option<String>) = match row {
633        Some(r) => (r.get(0).ok(), r.get(1).ok()),
634        None => (None, None),
635    };
636
637    // Sound as string comparisons because every recorded_at is the canonical
638    // fixed width (D-029).
639    if archive_path.is_some_and(|p| p.exists()) {
640        // An empty hot log beside an archive is the fully-archived case and
641        // covers nothing. It cannot arise from `archive()` itself — the newest
642        // row per entity always stays — but answering "covered" here would make
643        // such a file reconstruct to the empty state with no error at all.
644        return Ok(match max_recorded_at {
645            Some(max_ts) if max_ts.as_str() <= ts => HotLogReach::Covers,
646            _ => HotLogReach::NeedsArchive,
647        });
648    }
649
650    match min_recorded_at {
651        Some(min_ts) if min_ts.as_str() <= ts => Ok(HotLogReach::Covers),
652        // No log at all: a genuinely empty database, and the empty state has
653        // always been the answer here.
654        None => Ok(HotLogReach::PredatesRecordedHistory),
655        // `ts` is below the hot log's floor, and there is no archive file to
656        // consult. Which of the two meanings that has is decided by whether
657        // anything was ever removed from the log — see `hot_log_is_intact`.
658        Some(_) => Ok(if hot_log_is_intact(conn).await? {
659            HotLogReach::PredatesRecordedHistory
660        } else {
661            HotLogReach::NeedsArchive
662        }),
663    }
664}
665
666/// What the caller needs to know when the cold delta cannot be reached —
667/// **assembled from the hot file alone** (0.9.0, C4).
668///
669/// # This is the message the hot-side marker was wanted for
670///
671/// [D-121](../../docs/architecture/s13-decision-register.md) rejected a hot-side
672/// marker recording *archived at* and *horizon*, then left the door open: 0.9.0
673/// was to adopt it "only if it wants the richer message". C4 asked for the
674/// message and found the marker cannot supply it, because the proposed message —
675/// *"this database was archived on X; pass the archive path"* — is **weaker**
676/// than what the hot log already carries:
677///
678/// * *how many rows went* is `MAX(seq_id) - COUNT(*)`, exact for the reason
679///   [`hot_log_is_intact`] gives;
680/// * *how far back the hot file still reaches* is `MIN(seq_id)` and its
681///   `recorded_at` — which is the fact that actually tells a caller whether the
682///   archive is worth fetching, and which a marker's archive **timestamp** does
683///   not give them;
684/// * *that archiving happened at all* is the one bit [`hot_log_is_intact`]
685///   already answers.
686///
687/// The only datum a marker would add is the wall-clock instant of the last
688/// archive run, and no branch and no caller needs it. So the marker is refused
689/// outright rather than deferred again: under
690/// [D-036](../../docs/architecture/s13-decision-register.md) a hot-table addition
691/// lands pre-1.0 or not at all, and a table whose whole content is a timestamp
692/// used in one error string is not worth a rung.
693///
694/// # There is no "nothing was archived" case, and that was settled by injection
695///
696/// This first carried a branch for `removed == 0`, on the reasoning that the
697/// `NeedsArchive` arm is reachable without any archiving. That reasoning was
698/// **wrong about where the cost lands and right about the branch**, and only a
699/// probe told the two apart: replacing the branch body with a panic showed it
700/// firing from `a_failed_cold_reconstruct_still_detaches`, a test that raises
701/// nothing from here — because the hint was being computed *before* the two
702/// arms that use it, on every cold fold. Made lazy, the probe went quiet across
703/// all 27 targets.
704///
705/// So the branch was dead at the use sites: both arms require
706/// [`hot_log_is_intact`] to have returned false, or an archive file to have
707/// existed when `hot_log_reach` looked and to have gone by the time this did.
708/// Rows really were removed in every case that gets here, and the message may
709/// say so without qualification. Deleted rather than kept as a defensive
710/// fallback, for the reason `delete_guarded` records about
711/// `classify_archive_violation`: unreachable code that looks reasonable is
712/// harder to remove later than now.
713///
714/// Best-effort by construction: this runs on the error path, where a second
715/// failure must not replace the diagnosis with its own. A query that does not
716/// answer yields a hint that says so, and the caller still gets the error it came
717/// for.
718async fn archive_hint(conn: &libsql::Connection) -> String {
719    // `COUNT(*)` always returns a row, so `None` here means the query itself
720    // failed and there is nothing to say beyond that.
721    let row = match conn
722        .query(
723            "SELECT COUNT(*), MIN(seq_id), MAX(seq_id), MIN(recorded_at) FROM transaction_log",
724            (),
725        )
726        .await
727    {
728        Ok(mut rows) => rows.next().await.ok().flatten(),
729        Err(_) => None,
730    };
731
732    let Some(row) = row else {
733        return "the hot log could not be inspected for an archive horizon".into();
734    };
735    let count: i64 = row.get(0).unwrap_or(0);
736    if count == 0 {
737        return "the hot log is empty".into();
738    }
739    let min: i64 = row.get(1).unwrap_or(0);
740    let max: i64 = row.get(2).unwrap_or(0);
741    let floor: String = row.get(3).unwrap_or_default();
742    let removed = max - count;
743
744    format!(
745        "{removed} log rows have been archived out of this database; the hot log \
746         now begins at seq_id {min} ({floor})"
747    )
748}
749
750/// Was any row ever removed from `transaction_log`? — answered exactly, from
751/// the hot file alone (0.8.0, B5, D-121).
752///
753/// # Why this question needs answering at all
754///
755/// With `ts` below the hot log's floor and no archive file present, the state
756/// on disk is consistent with two very different histories: **nothing was ever
757/// archived**, in which case the hot log is the whole log and the answer to
758/// *what was believed at `ts`* is "nothing yet"; or **rows were archived and
759/// the cold file is gone**, in which case the answer is unknowable and saying
760/// "nothing" would be inventing one. Before this, the two were conflated and
761/// both raised — which made an ordinary question about a young database report
762/// the ledger as damaged.
763///
764/// # Why `seq_id` settles it, with no marker and no schema change
765///
766/// `transaction_log.seq_id` is `INTEGER PRIMARY KEY AUTOINCREMENT`, so values
767/// are allocated 1, 2, 3, … and **never reused**. A rolled-back transaction
768/// leaves no gap — `sqlite_sequence` rolls back with it, which
769/// [D-049](../../docs/architecture/s13-decision-register.md) established by
770/// measurement after assuming the opposite. So the only thing that can perturb
771/// the sequence is deletion, and `trg_txlog_guard_delete` confines deletion to
772/// an archive session.
773///
774/// Therefore: if nothing was removed, the ids are exactly `1..=MAX` and
775/// `COUNT(*) == MAX(seq_id)` with `MIN(seq_id) == 1`. And conversely — this is
776/// the half that makes it a proof rather than a heuristic — those two equalities
777/// force the set of `COUNT` distinct ids inside `[1, MAX]` to be all of it, so
778/// nothing is missing. The test is exact in both directions, not merely
779/// suggestive.
780///
781/// **It does not depend on the archive removing a contiguous block**, which it
782/// does not: `archive()` removes *superseded* rows scattered through the
783/// sequence. Scattered removal leaves interior gaps, which fails the count
784/// equality; removal from the front raises `MIN` above 1. Removal from the end
785/// cannot happen, because the newest row per entity is never archivable.
786///
787/// # What it deliberately does not claim
788///
789/// Nothing about *when* the archiving happened or *what* went, which is what
790/// the rejected hot-side marker would have carried. It answers one bit, and one
791/// bit is what the branch above needs.
792async fn hot_log_is_intact(conn: &libsql::Connection) -> Result<bool> {
793    let row = conn
794        .query(
795            "SELECT COUNT(*), MIN(seq_id), MAX(seq_id) FROM transaction_log",
796            (),
797        )
798        .await?
799        .next()
800        .await?;
801    let Some(row) = row else {
802        return Ok(true);
803    };
804    let count: i64 = row.get(0).unwrap_or(0);
805    if count == 0 {
806        return Ok(true);
807    }
808    let min: i64 = row.get(1).unwrap_or(0);
809    let max: i64 = row.get(2).unwrap_or(0);
810    Ok(min == 1 && count == max)
811}
812
813/// Whether a connection alone can fold `transaction_log` at `ts` (W7.1, D-174).
814///
815/// The completeness question [`hot_log_reach`] answers, minus the archive file
816/// it does not have. Both callers take a `Connection`, so when the hot log is
817/// short they have nowhere to go and must refuse rather than fold what is left:
818/// [`crate::graph::TraversalBuilder::as_of_recorded`] folds for topology, and
819/// [`crate::temporal::hydrate_attributes`] folds for the text (0.13.16, W9.1,
820/// [D-189](../../docs/architecture/s13-decision-register.md#d-189)). The second
821/// was folding without asking, which is what §3.2 was.
822///
823/// **One bit, and the conservative one.** `hot_log_is_intact` says whether
824/// anything was ever removed, not whether *this* instant survived the removal.
825/// The archive cutoff is not recorded hot-side — that is the marker D-132
826/// refused — so an archived database refuses every instant here, including ones
827/// a fold would have got right. `ts` is taken anyway rather than dropped from the
828/// signature, because the refusal names it and because a cutoff-aware version
829/// would need it.
830pub(crate) async fn hot_log_answers_for(conn: &libsql::Connection, _ts: &str) -> Result<bool> {
831    hot_log_is_intact(conn).await
832}
833
834/// Run one fold query from nothing — the unanchored path.
835async fn fold(conn: &libsql::Connection, ts: &str, query: &str) -> Result<MaterializedState> {
836    let delta = fold_delta(conn, query, libsql::params![ts]).await?;
837    Ok(delta.apply_to(MaterializedState::empty(ts), ts))
838}
839
840/// Run one fold query and collect the winning rows, deletions included.
841async fn fold_delta(
842    conn: &libsql::Connection,
843    query: &str,
844    params: impl libsql::params::IntoParams,
845) -> Result<Delta> {
846    let mut rows = conn.query(query, params).await?;
847    let mut d = Delta::default();
848    let (concepts, edges, max_seq) = (&mut d.concepts, &mut d.edges, &mut d.max_seq);
849
850    while let Some(row) = rows.next().await? {
851        let seq_id: i64 = row.get(0)?;
852        let table_name: String = row.get(1)?;
853        let _entity_id: String = row.get(2)?;
854        let op: String = row.get(3)?;
855        let payload_str: String = row.get(4)?;
856
857        if seq_id > *max_seq {
858            *max_seq = seq_id;
859        }
860
861        // A `'D'` row is corruption, not a tombstone (D-072).
862        //
863        // Doctrine V permits no physical delete outside an archive session, and
864        // the archive *moves* rows rather than logging their removal — so no
865        // trigger in the schema writes a `'D'`, and no code path in the crate
866        // can produce one. This arm used to treat it as a tombstone, which read
867        // as a claim that deletions are recorded and reconstructible. They are
868        // not. Refusing here makes the doctrine enforced at the fold rather than
869        // assumed by it, and is the same call D-060 made for overlap: the layer
870        // that can notice should.
871        //
872        // Retirement is unaffected and is the mechanism that actually removes a
873        // concept from a composed state — see the `retired != 0` branch below,
874        // which is where `concepts_gone` is populated in practice.
875        if op == "D" {
876            return Err(DbError::ReplayCorrupt {
877                seq: seq_id,
878                reason: format!(
879                    "transaction_log carries a 'D' operation for {table_name} \
880                     entity {_entity_id:?}; Doctrine V permits no physical delete \
881                     outside an archive session, and the archive logs none. This \
882                     row was not written by this crate."
883                ),
884            });
885        }
886
887        let payload: serde_json::Value =
888            serde_json::from_str(&payload_str).map_err(|e| DbError::ReplayCorrupt {
889                seq: seq_id,
890                reason: format!("Failed to parse payload JSON: {e}"),
891            })?;
892
893        // v1 and v2 differ by one added field, so v1 folds by reading it as
894        // absent — which is what `Option` already means here. A future shape
895        // that *removes* or *retypes* a field would not be able to share this
896        // path, and would want a match on `v` rather than a ceiling.
897        let v = payload.get("v").and_then(|v| v.as_u64()).unwrap_or(1);
898        if v > PAYLOAD_VERSION as u64 {
899            return Err(DbError::PayloadVersion {
900                got: v as u8,
901                max: PAYLOAD_VERSION,
902            });
903        }
904
905        if table_name == "concepts" {
906            let id = _entity_id;
907            let retired = payload.get("retired").and_then(|r| r.as_i64()).unwrap_or(0);
908            if retired == 0 {
909                let title = payload
910                    .get("title")
911                    .and_then(|s| s.as_str())
912                    .unwrap_or("")
913                    .to_string();
914                let content = payload
915                    .get("content")
916                    .and_then(|s| s.as_str())
917                    .unwrap_or("")
918                    .to_string();
919                let embedding_model = payload
920                    .get("embedding_model")
921                    .and_then(|s| s.as_str())
922                    .map(|s| s.to_string());
923                concepts.insert(
924                    id.clone(),
925                    NodeAttributes {
926                        id,
927                        title,
928                        content,
929                        embedding_model,
930                    },
931                );
932            } else {
933                // Retirement is the application axis (§4.1), and a reconstruction
934                // shows what was visible. Onto a snapshot that means removing
935                // the concept, not declining to add it.
936                d.concepts_gone.insert(id);
937            }
938        } else if table_name == "links" {
939            let src = payload
940                .get("source_id")
941                .and_then(|s| s.as_str())
942                .unwrap_or("")
943                .to_string();
944            let tgt = payload
945                .get("target_id")
946                .and_then(|s| s.as_str())
947                .unwrap_or("")
948                .to_string();
949            let edge_type = payload
950                .get("edge_type")
951                .and_then(|s| s.as_str())
952                .unwrap_or("")
953                .to_string();
954            let vf = payload
955                .get("valid_from")
956                .and_then(|s| s.as_str())
957                .unwrap_or("")
958                .to_string();
959            let vt = payload
960                .get("valid_to")
961                .and_then(|s| s.as_str())
962                .unwrap_or("")
963                .to_string();
964            edges.insert(_entity_id, (src, tgt, edge_type, vf, vt));
965        }
966    }
967
968    Ok(d)
969}
970
971impl Delta {
972    /// Compose onto `base` under last-writer-wins by `seq_id` (§5.5).
973    ///
974    /// The delta is by construction newer than the base — it is the fold of
975    /// everything above the base's anchor — so every row it carries wins, and
976    /// every retirement it carries removes. This is the same rule
977    /// `trg_links_current_sync`'s upsert applies and the same rule the cold
978    /// fold applies; that the three agree is asserted by test rather than by
979    /// this comment (§8).
980    fn apply_to(self, base: MaterializedState, ts: &str) -> MaterializedState {
981        let mut concepts = base.concepts;
982        let mut edges: HashMap<String, (String, String, String, String, String)> =
983            base.edges.into_iter().map(|e| (edge_key(&e), e)).collect();
984
985        for id in self.concepts_gone {
986            concepts.remove(&id);
987        }
988        // No edge equivalent: an edge is superseded in place under the same
989        // `entity_id`, never removed — see [`Delta`] (D-072).
990        concepts.extend(self.concepts);
991        edges.extend(self.edges);
992
993        // Sorted so the result is a function of the state and not of hash
994        // iteration order — `reconstruct` is compared against itself by the
995        // property suite, and two runs must be equal, not merely equivalent.
996        let mut edges: Vec<_> = edges.into_values().collect();
997        edges.sort();
998
999        MaterializedState {
1000            seq_anchor: self.max_seq.max(base.seq_anchor),
1001            timestamp: ts.to_string(),
1002            concepts,
1003            edges,
1004            // A delta was applied, so there was history to fold. `reconstruct`
1005            // sets the flag on the one path that never gets here.
1006            predates_recorded_history: false,
1007        }
1008    }
1009}