Skip to main content

macrame/temporal/
archive.rs

1use std::path::Path;
2
3use libsql::TransactionBehavior;
4
5use crate::error::{DbError, Result, WriteOp};
6use crate::schema::ddl::ARCHIVE_SESSION_MARKER;
7
8/// Outcome of one archive session.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct ArchiveReport {
11    pub links_archived: usize,
12    /// Concepts moved to `cold.concepts` (0.9.0, C2). Always `0` before v9,
13    /// where no concept could leave the hot table at all.
14    pub concepts_archived: usize,
15    pub log_entries_archived: usize,
16    /// Oldest `transaction_log.seq_id` still present in the hot file after the
17    /// session, i.e. the new horizon (see glossary). `None` if the hot log is empty.
18    pub horizon: Option<i64>,
19}
20
21/// Schema of the cold database. Deliberately trigger-free and FK-free.
22///
23/// **Corrected 2026-08-07.** This comment used to justify the FK-free part with
24/// *"concepts are never archived (D-022)"*, which stopped being true in 0.9.0
25/// when C2 added `cold.concepts` — the table declared a few lines below. The
26/// reasons that survive are the other two, and they are the load-bearing ones:
27/// a FK from `cold.links` to `concepts` still could not be satisfied, because
28/// the cold file holds only the concepts that have gone cold and `cold.links`
29/// may name any of them; and the delete guards must not exist on a file whose
30/// whole purpose is to receive rows and, on rehydration, to give them back.
31const COLD_SCHEMA: &[&str] = &[
32    // `weight` carries the same CHECK as the hot table (T2.1, D-083). Not
33    // symmetry for its own sake: the cold file is read back by `reconstruct`
34    // through the same `f64` decode, so a text weight is the same panic there
35    // as it is here, and a negative one is the same unsound shortest path.
36    //
37    // The hot table's constraint does not protect this one. Rows arrive by
38    // `INSERT … SELECT` across an ATTACH, which re-checks against *this*
39    // table's constraints — and a cold file may predate the hot file's rung, or
40    // have been written by a version that had neither.
41    //
42    // `IF NOT EXISTS` means an existing cold database keeps whatever definition
43    // it was created with; this constrains new cold files, and the loader guard
44    // is what covers the old ones. That is the same division of labour §4.7
45    // describes, and the reason the guard stays.
46    //
47    // **`branch_id` is in the key since v15** (0.14.15, D-232), and it had to
48    // move with the hot table rather than after it. The hot key admitted the
49    // pair, so archiving became the one operation that could still refuse it:
50    // two lineages' rows about one edge at one `recorded_at` are legal in
51    // `links` and would have collided on the way out, turning a write the crate
52    // now accepts into a maintenance failure the caller cannot act on.
53    // `upgrade_cold_lineage` carries existing cold files across.
54    r#"CREATE TABLE IF NOT EXISTS cold.links (
55        source_id   TEXT NOT NULL,
56        target_id   TEXT NOT NULL,
57        edge_type   TEXT NOT NULL,
58        valid_from  TEXT NOT NULL,
59        recorded_at TEXT NOT NULL,
60        valid_to    TEXT NOT NULL,
61        weight      REAL NOT NULL CHECK (weight >= 0.0 AND weight < 9e999 AND typeof(weight) = 'real'),
62        properties  TEXT NOT NULL,
63        branch_id   TEXT NOT NULL DEFAULT 'main',
64        PRIMARY KEY (source_id, target_id, edge_type, valid_from, recorded_at, branch_id)
65    )"#,
66    // Concepts, as of v9 (C2). Trigger-free and FK-free like `cold.links`, and
67    // for the same reasons -- but note what it does NOT drop.
68    //
69    // **Every column crosses, `content` included.** Archival is a move, not a
70    // rewrite (2.3), and a move that drops a column is a rewrite. The log
71    // payload for a concept carries its `content` (4.3), so a cold concept
72    // whose text had been dropped would contradict `cold.transaction_log` about
73    // itself, and rehydration would return a concept the ledger never recorded:
74    // empty text where the log says there was text. That is the unexplained
75    // absence Doctrine V exists to prevent.
76    //
77    // The tension with D-116 is apparent rather than real. D-116 governs the
78    // *in-memory* `NodeData` representation -- `content` is not loaded by
79    // default because most readers do not want it. This is *on-disk* storage.
80    // Disk carries the text; memory does not populate it until asked. Two
81    // independent defaults, and conflating them would make rehydration lossy to
82    // save a read nobody was performing.
83    //
84    // `rowid_pk` crosses as the record of what the rowid *was*. Restoring it is
85    // C3's problem and not obviously safe: `concepts.rowid_pk` is a plain
86    // INTEGER PRIMARY KEY, so SQLite may reuse a freed value, and archiving the
87    // highest rowids can leave a later insert holding one a cold row still
88    // claims. The column is carried because a move must not lose it.
89    //
90    // **The hazard has two exits and C3 must take one of them explicitly.**
91    // Either reinstate the original `rowid_pk` when it is still free, or assign
92    // a fresh one — and in the second case **update `concepts_fts`'s
93    // `content_rowid` mapping to match**, because the FTS index is
94    // external-content keyed on this column (4.6, D-119). A rehydration that
95    // reassigns the rowid without re-pointing the index leaves the search index
96    // silently describing the wrong row, which is the exact failure `rowid_pk`
97    // was made explicit to prevent. Named here so C3 meets both exits rather
98    // than rediscovering the FTS coupling.
99    r#"CREATE TABLE IF NOT EXISTS cold.concepts (
100        rowid_pk         INTEGER,
101        id               TEXT NOT NULL PRIMARY KEY,
102        title            TEXT NOT NULL,
103        content          TEXT NOT NULL DEFAULT '',
104        embedding_model  TEXT,
105        valid_from       TEXT NOT NULL,
106        valid_to         TEXT NOT NULL,
107        recorded_at      TEXT NOT NULL,
108        retired          INTEGER NOT NULL DEFAULT 0,
109        branch_id        TEXT NOT NULL DEFAULT 'main'
110    )"#,
111    // seq_id is carried over verbatim from the hot log, so it is a plain
112    // INTEGER PRIMARY KEY -- never AUTOINCREMENT, which would renumber history.
113    r#"CREATE TABLE IF NOT EXISTS cold.transaction_log (
114        seq_id      INTEGER PRIMARY KEY,
115        table_name  TEXT NOT NULL,
116        entity_id   TEXT NOT NULL,
117        operation   TEXT NOT NULL,
118        payload     TEXT NOT NULL,
119        recorded_at TEXT NOT NULL,
120        branch_id   TEXT NOT NULL DEFAULT 'main'
121    )"#,
122    "CREATE INDEX IF NOT EXISTS cold.idx_cold_txlog_entity ON transaction_log (entity_id)",
123    "CREATE INDEX IF NOT EXISTS cold.idx_cold_txlog_time ON transaction_log (recorded_at)",
124    // Lineages, as of 0.14.13 (§15.4, D-230). The one cold table that is not a
125    // mirror of a hot one: `branches` carries no `archived_at` and this needs
126    // one, because the hot row's `created_at` says when the lineage began and
127    // nothing on it can say when the ledger stopped knowing about it.
128    //
129    // **This is the table `upgrade_cold_lineage` predicted.** Its note says a
130    // cold row records *that* it belonged to a lineage without recording what
131    // that lineage was, and that "the abandonment arm makes forgetting a branch
132    // an ordinary operation, and a cold row stamped with a name nothing
133    // resolves is the shape that falls out of it". This is what resolves the
134    // name: the cold file carries the lineage record itself, so
135    // `cold.links.branch_id` names a row in the same file rather than a string
136    // whose meaning was left behind in the hot database.
137    //
138    // FK-free like the rest of the cold schema, `parent_id` included — the
139    // parent is normally still hot, which is the whole point of the operation,
140    // so a self-referencing key here would refuse every row this table exists
141    // to hold.
142    r#"CREATE TABLE IF NOT EXISTS cold.branches (
143        branch_id   TEXT NOT NULL PRIMARY KEY,
144        parent_id   TEXT,
145        forked_at   TEXT,
146        created_at  TEXT NOT NULL,
147        archived_at TEXT NOT NULL
148    )"#,
149    r#"CREATE TABLE IF NOT EXISTS cold.archive_horizon (
150        archived_at TEXT NOT NULL,
151        cutoff      TEXT NOT NULL,
152        horizon     INTEGER
153    )"#,
154];
155
156/// A links assertion is archivable when it is older than the cutoff AND it is
157/// either superseded by a later assertion **of its own lineage** for the same
158/// interval key, or it is the current belief for an interval that closed before
159/// the cutoff.
160///
161/// This keeps every row that `links_current` still projects (Doctrine VI: the
162/// materialization must stay rebuildable from `links`) while moving exactly the
163/// "closed intervals, superseded history" the §2 diagram assigns to the cold file.
164///
165/// # `newer.branch_id = links.branch_id`, added at 0.14.12 ([D-229])
166///
167/// Without it this predicate archived rows the ledger still believed. `links_current`
168/// is keyed by `(source, target, type, valid_from, branch_id)` and the four folds in
169/// `temporal::replay` partition by `(table_name, entity_id, branch_id)`, but a link's
170/// `entity_id` is `source|target|type|valid_from` and carries **no lineage**
171/// ([`crate::schema::ddl::CREATE_LINKS_LOG_INSERT`] says why re-keying it was
172/// refused). So "a later assertion for the same interval key" matched **across**
173/// lineages, and a branch asserting at an ancestor's key made the ancestor's own
174/// open, current row look superseded.
175///
176/// Measured before the repair, on a two-row fixture: the trunk asserts `a → b`, a
177/// branch forks and asserts at the same key, one `archive` runs, and the **trunk**
178/// stops reaching `b`. `audit_current` reports **0**, which is why nothing caught
179/// it — `links_current` is honestly re-derived from a `links` table that has been
180/// wrongly pruned, so the projection is correct with respect to what survives and
181/// the drift check has nothing to compare against. Doctrine VI's audit answers
182/// "is the projection the image of the ledger", never "is the ledger complete".
183///
184/// **Exact-branch equality, not ancestry**, and for
185/// [`crate::schema::ddl::CREATE_CONCEPTS_GUARD_LINEAGE`]'s reason. A descendant's
186/// row shadows an ancestor's *for the descendant's reads*; the ancestor still
187/// believes its own row, and Doctrine III is precisely that shadowing never
188/// touches it. A predicate that let a descendant supersede an ancestor would
189/// archive the parent's belief because a child disagreed.
190///
191/// [D-229]: ../../docs/architecture/s13-decision-register.md#d-229
192///
193/// # The closed-interval arm, and the row it must not take
194///
195/// "A closed interval is history" is true of a lineage that holds the only row
196/// at its key, and false of a **shadow**. A branch retires an inherited edge by
197/// writing its own closed row at the ancestor's key — the only cross-lineage
198/// retirement [Doctrine III] permits, because it never touches the parent's row.
199/// Archiving that row does not send history cold; it removes the branch's
200/// disbelief and lets the ancestor's open row win the resolution again.
201///
202/// Measured before the repair: a branch retires `b → c` over `[EPOCH, T1)`, one
203/// archive runs, and at `T2` the branch reaches `c` — an edge it had stopped
204/// believing, restored by a maintenance operation that mints no assertions. That
205/// is the resurrection [`crate::schema::ddl::CREATE_CONCEPTS_LOG_INSERT`] gates
206/// the rehydration insert against, reached down the other path.
207///
208/// So the arm stands down whenever **another lineage holds a hot row at the same
209/// interval key**. Conservative rather than exact: what strictly matters is an
210/// *ancestor's* row surviving this session, and both halves of that are more than
211/// this predicate can see. Ancestry would mean resolving `graph::lineage`'s chain
212/// for every branch, in a whole-database operation that takes no branch
213/// parameter; "surviving this session" is self-referential, since what survives
214/// is the answer this predicate is computing. Leaving rows hot costs file size
215/// and is never wrong, so the rule is the one that needs neither. A key held by
216/// exactly one lineage — every key on a ledger that has never forked — is
217/// unaffected, which the tests measure rather than argue from a column default.
218///
219/// [Doctrine III]: ../../docs/architecture/README.md
220const LINKS_ARCHIVABLE: &str = r#"
221    recorded_at < :cutoff AND (
222        EXISTS (
223            SELECT 1 FROM links newer
224            WHERE newer.source_id   = links.source_id
225              AND newer.target_id   = links.target_id
226              AND newer.edge_type   = links.edge_type
227              AND newer.valid_from  = links.valid_from
228              AND newer.branch_id   = links.branch_id
229              AND newer.recorded_at > links.recorded_at
230        )
231        OR (valid_to <> '9999-12-31T23:59:59.999999Z' AND valid_to <= :cutoff
232            AND NOT EXISTS (
233                SELECT 1 FROM links other
234                WHERE other.source_id  = links.source_id
235                  AND other.target_id  = links.target_id
236                  AND other.edge_type  = links.edge_type
237                  AND other.valid_from = links.valid_from
238                  AND other.branch_id <> links.branch_id
239            ))
240    )
241"#;
242
243/// A log entry is archivable when it is older than the cutoff and a later entry
244/// exists **for the same entity on the same lineage**, i.e. it is superseded.
245/// The newest entry per fold partition always stays hot so that
246/// `reconstruct(now)` never needs the cold file.
247///
248/// # The lineage clause, added at 0.14.12 ([D-229])
249///
250/// The sentence above used to say "per entity", and the four folds in
251/// `temporal::replay` have partitioned by `(table_name, entity_id, branch_id)`
252/// since v12 — so the predicate stopped keeping the newest entry per *partition*
253/// hot the moment lineage arrived, and nothing said so. A branch writing at an
254/// ancestor's edge key made the ancestor's newest entry archivable, which is the
255/// same defect [`LINKS_ARCHIVABLE`] carried, reached from the log side.
256///
257/// It changes nothing for `concepts` entries and that is worth stating rather
258/// than leaving to be rediscovered: a concept's `entity_id` is its `id`, and
259/// [`crate::schema::ddl::CREATE_CONCEPTS_GUARD_LINEAGE`] refuses a branch
260/// restating an inherited one, so every log entry for one concept already
261/// carries one lineage. The clause is a no-op there by construction, not by
262/// accident.
263///
264/// [D-229]: ../../docs/architecture/s13-decision-register.md#d-229
265const LOG_ARCHIVABLE: &str = r#"
266    recorded_at < :cutoff AND EXISTS (
267        SELECT 1 FROM transaction_log newer
268        WHERE newer.entity_id = transaction_log.entity_id
269          AND newer.branch_id = transaction_log.branch_id
270          AND newer.seq_id    > transaction_log.seq_id
271    )
272"#;
273
274/// A concept is archivable when it is `retired`, both its clocks are behind the
275/// cutoff, **and no surviving row of hot `links` mentions it in either
276/// direction** (C1, D-128).
277///
278/// # Why reachability, and not a closed interval
279///
280/// A link assertion has a closed interval, so [`LINKS_ARCHIVABLE`] can ask
281/// whether the interval ended. A concept is an *entity*, and has no closed
282/// state: `retired = 1` says belief in it stopped, which is not the same claim
283/// as "nothing points at it any more". The two `links` foreign keys are what
284/// make that difference matter — archiving a concept physically removes its row,
285/// and a surviving hot link naming it would leave the key unsatisfiable.
286/// `ON DELETE CASCADE` is not the way out, because the rows it would cascade
287/// onto are ledger rows.
288///
289/// So concept archival is **strictly downstream of link archival**: a concept
290/// becomes eligible only once every edge mentioning it has itself gone cold.
291/// Inside a session this predicate is therefore evaluated *after* the `links`
292/// delete and never before it, and the same question asked before and after one
293/// session legitimately gives two different answers. That is a property of the
294/// predicate, not a race.
295///
296/// # The other two foreign keys, and why they are not clauses here
297///
298/// `concepts` also has inbound keys from `analytics_annotations` and from every
299/// registered `embeddings_*` table ([`crate::schema::migrations`] lists all
300/// four). Neither appears above, and the distinction is the point: those hold
301/// **derived** rows. Doctrine VII makes an embedding an artifact of a model
302/// applied to content, and an annotation is the output of an algorithm that read
303/// `concepts` in the first place. A derived row is removed and recomputed; a
304/// ledger row is neither. Making archivability wait on a recomputable artifact
305/// would answer "not yet" forever for any concept that had ever been embedded.
306///
307/// # Both clocks, because one of them is not enough
308///
309/// The specification for this predicate named `valid_to` alone.
310/// `recorded_at < :cutoff` is here as well, mirroring [`LINKS_ARCHIVABLE`]:
311/// a concept retired with its `valid_to` behind the cutoff but *recorded* at or
312/// after it is a fact the session is not meant to touch yet, and archiving it
313/// would send the concept cold while the log entries describing it stayed hot.
314/// That is the same two-clock mismatch the `links_current` compensation carried
315/// until Wave 4.5 (see [`archive_session`]), reached from the other side.
316/// Doctrine II: two clocks, never mixed.
317///
318/// The open sentinel needs no clause of its own — `9999-12-31T23:59:59.999999Z`
319/// sorts above every canonical stamp (D-029), so a concept whose validity is
320/// still open fails `valid_to < :cutoff` for any cutoff a caller can pass.
321const CONCEPTS_ARCHIVABLE: &str = r#"
322    retired = 1
323    AND recorded_at < :cutoff
324    AND valid_to    < :cutoff
325    AND NOT EXISTS (
326        SELECT 1 FROM links
327        WHERE links.source_id = concepts.id
328           OR links.target_id = concepts.id
329    )
330"#;
331
332/// The ids of every concept that `CONCEPTS_ARCHIVABLE` admits at `cutoff`, in
333/// `id` order.
334///
335/// **Read-only, and deliberately available before anything can act on it.**
336/// Concept archival is the one operation in this crate a caller cannot undo
337/// without a cold file to hand, so the predicate that decides it is observable
338/// on its own rather than only as a count in a report after the fact.
339///
340/// The answer is a function of the hot state *now*. Archiving links first will
341/// generally enlarge it — that is the downstream relationship
342/// `CONCEPTS_ARCHIVABLE` describes, not an inconsistency — so a caller
343/// planning a session should ask after the link archive, not before it.
344pub async fn archivable_concepts(conn: &libsql::Connection, cutoff: &str) -> Result<Vec<String>> {
345    let mut rows = conn
346        .query(
347            &format!("SELECT id FROM concepts WHERE {CONCEPTS_ARCHIVABLE} ORDER BY id"),
348            libsql::named_params! {":cutoff": cutoff},
349        )
350        .await?;
351
352    let mut ids = Vec::new();
353    while let Some(row) = rows.next().await? {
354        ids.push(row.get::<String>(0)?);
355    }
356    Ok(ids)
357}
358
359/// Move closed edge intervals and superseded log rows older than `cutoff` into
360/// the cold database at `archive_path` (§5.7, D-012, D-022).
361///
362/// The whole session is one `BEGIN IMMEDIATE … COMMIT` transaction (D-012):
363/// copy-then-delete must be atomic, or a crash between the phases duplicates or
364/// loses rows. The archive-session marker that unlocks the delete guards
365/// (D-008 revised) is created as the first statement of that transaction and
366/// dropped as the last, so it never exists as committed state — commit drops
367/// it, rollback discards it, and there is no crash path that leaves the guards
368/// disarmed.
369///
370/// ATTACH is issued outside the transaction and DETACH is issued unconditionally
371/// on the way out, including on error: ATTACH is not transactional and survives
372/// ROLLBACK, so a leaked handle would make every later archive or cold-DB
373/// reconstruct fail with "database cold is already in use".
374/// `archived_at` is **when the session ran**; `cutoff` is the boundary it used.
375///
376/// Both go into `cold.archive_horizon`, and until Wave 4.5 both columns were
377/// written with the cutoff — so the table recorded that every archive had run at
378/// the instant it was archiving *up to*, which is the one time it certainly did
379/// not run. The two are different clocks (Doctrine II) and the row exists to
380/// carry both: the cutoff says what was moved, `archived_at` says when the
381/// decision was taken, and only the second can answer "how stale is this cold
382/// file". The column was there, correctly named, holding the wrong value.
383pub async fn archive(
384    conn: &libsql::Connection,
385    cutoff: &str,
386    archived_at: &str,
387    archive_path: &Path,
388) -> Result<ArchiveReport> {
389    crate::temporal::replay::detach_stale_cold(conn).await;
390
391    // ATTACH creates the cold file if it does not exist.
392    conn.execute(
393        "ATTACH DATABASE ?1 AS cold",
394        libsql::params![archive_path.to_string_lossy().as_ref()],
395    )
396    .await?;
397
398    let result = archive_session(conn, cutoff, archived_at).await;
399
400    // Unconditional: see the DETACH note above.
401    if let Err(e) = conn.execute("DETACH DATABASE cold", ()).await {
402        tracing::warn!("archive: failed to DETACH cold database: {e}");
403    }
404
405    result
406}
407
408/// Bring an existing cold file up to the v12 shape, inside the session's own
409/// transaction (§15.2, D-217).
410///
411/// # Why this is not `CREATE TABLE IF NOT EXISTS`'s job
412///
413/// It cannot be. [`COLD_SCHEMA`] runs against a file that may already hold
414/// these tables, and `IF NOT EXISTS` on an existing name **keeps the old
415/// definition and reports success** — probe §10. A v11 cold file would sail
416/// through the schema pass and then refuse the first insert with `table
417/// cold.transaction_log has no column named branch_id` (probe §11), which is at
418/// least loud; shorten the column list to avoid the error and the lineage is
419/// dropped in silence instead.
420///
421/// # Why it is safe to do here
422///
423/// Probe §12–13 measured both halves on libSQL: `ALTER TABLE cold.… ADD COLUMN`
424/// is accepted inside `BEGIN IMMEDIATE`, an insert in the same transaction sees
425/// the new column, and **`ROLLBACK` takes the DDL with it** — columns and rows
426/// both revert. So a session that fails partway leaves the cold file exactly as
427/// it found it, which is the property that lets an upgrade ride along with an
428/// archive instead of needing a migration of its own.
429///
430/// Detection is column presence. A cold file carries no version stamp worth
431/// trusting: it is a file whose whole purpose is to be moved (D-026).
432///
433/// No foreign key on these columns, unlike their hot counterparts. `branches`
434/// does not exist in the cold file, and a cold file therefore records *that* a
435/// row belonged to a lineage without recording what that lineage was. Named in
436/// §15.5's carry rather than left to be discovered: the abandonment arm makes
437/// forgetting a branch an ordinary operation, and a cold row stamped with a
438/// name nothing resolves is the shape that falls out of it.
439async fn upgrade_cold_lineage(tx: &libsql::Transaction) -> Result<()> {
440    for table in ["links", "concepts", "transaction_log"] {
441        if !cold_has_branch(tx, table).await? {
442            tx.execute(
443                &format!(
444                    "ALTER TABLE cold.{table} ADD COLUMN branch_id TEXT NOT NULL DEFAULT 'main'"
445                ),
446                (),
447            )
448            .await?;
449        }
450    }
451
452    // The column is not the whole of v15. A cold file written by 0.14.8 through
453    // 0.14.14 has `branch_id` and a key that does not mention it, so it passes
454    // the loop above and still refuses the pair the hot table now accepts —
455    // which would make `archive` the operation that fails on a database nothing
456    // else complains about.
457    //
458    // A rebuild rather than an `ALTER`, because SQLite has no way to add a
459    // column to a primary key; the same reason the hot rung is a rebuild. It is
460    // safe in this transaction for the reason above: probe §12–13 established
461    // that `ROLLBACK` takes cold DDL with it, and this adds `CREATE`, `INSERT
462    // … SELECT`, `DROP` and `RENAME` to the `ADD COLUMN` already covered.
463    // `cold.links` carries no trigger and no index, so nothing else has to be
464    // put back.
465    if !cold_links_keyed_by_lineage(tx).await? {
466        tx.execute(COLD_LINKS_V15, ()).await?;
467        tx.execute(
468            "INSERT INTO cold.links_v15 \
469             (source_id, target_id, edge_type, valid_from, recorded_at, \
470              valid_to, weight, properties, branch_id) \
471             SELECT source_id, target_id, edge_type, valid_from, recorded_at, \
472                    valid_to, weight, properties, branch_id FROM cold.links",
473            (),
474        )
475        .await?;
476        tx.execute("DROP TABLE cold.links", ()).await?;
477        tx.execute("ALTER TABLE cold.links_v15 RENAME TO links", ())
478            .await?;
479    }
480
481    Ok(())
482}
483
484/// The v15 cold ledger, spelled out because the rebuild needs a second name.
485///
486/// Not derived from [`COLD_SCHEMA`] by string surgery: the two would then be
487/// one definition read two ways, and the failure mode of getting that wrong is
488/// a cold file silently rebuilt into a shape the schema pass does not declare.
489const COLD_LINKS_V15: &str = r#"CREATE TABLE cold.links_v15 (
490        source_id   TEXT NOT NULL,
491        target_id   TEXT NOT NULL,
492        edge_type   TEXT NOT NULL,
493        valid_from  TEXT NOT NULL,
494        recorded_at TEXT NOT NULL,
495        valid_to    TEXT NOT NULL,
496        weight      REAL NOT NULL CHECK (weight >= 0.0 AND weight < 9e999 AND typeof(weight) = 'real'),
497        properties  TEXT NOT NULL,
498        branch_id   TEXT NOT NULL DEFAULT 'main',
499        PRIMARY KEY (source_id, target_id, edge_type, valid_from, recorded_at, branch_id)
500    )"#;
501
502/// Whether `cold.links` has `branch_id` **in its primary key**.
503///
504/// `PRAGMA table_info`'s sixth column is the column's 1-based position in the
505/// key, or 0. Asked of the pragma rather than of the stored SQL for
506/// [`cold_has_branch`]'s reason — a cold file is a file this crate may not have
507/// written, and matching its text would be matching someone else's formatting.
508async fn cold_links_keyed_by_lineage(conn: &libsql::Connection) -> Result<bool> {
509    let mut rows = conn.query("PRAGMA cold.table_info(links)", ()).await?;
510    while let Some(row) = rows.next().await? {
511        let named = row.get::<String>(1).is_ok_and(|name| name == "branch_id");
512        if named && row.get::<i64>(5).is_ok_and(|pk| pk > 0) {
513            return Ok(true);
514        }
515    }
516    Ok(false)
517}
518
519/// Whether one cold table already carries `branch_id`.
520///
521/// Split out because rehydration asks the same question for the opposite
522/// reason: the writer asks so it can upgrade, the reader asks so it can
523/// **avoid** upgrading. A cold file may be read-only media or sit on a share,
524/// and a read path that mutates it is a new failure class.
525async fn cold_has_branch(conn: &libsql::Connection, table: &str) -> Result<bool> {
526    let mut rows = conn
527        .query(&format!("PRAGMA cold.table_info({table})"), ())
528        .await?;
529    while let Some(row) = rows.next().await? {
530        if row.get::<String>(1).is_ok_and(|name| name == "branch_id") {
531            return Ok(true);
532        }
533    }
534    Ok(false)
535}
536
537/// `conn` is passed alongside `tx` only so [`delete_guarded`] can hand it to
538/// `classify`, which queries on the error path. Both name the same connection.
539async fn archive_session(
540    conn: &libsql::Connection,
541    cutoff: &str,
542    archived_at: &str,
543) -> Result<ArchiveReport> {
544    for ddl in COLD_SCHEMA {
545        conn.execute(ddl, ()).await?;
546    }
547
548    let tx = conn
549        .transaction_with_behavior(TransactionBehavior::Immediate)
550        .await?;
551
552    // Before the marker, before any insert: an existing cold file may predate
553    // the lineage column, and `IF NOT EXISTS` above will not have added it.
554    upgrade_cold_lineage(&tx).await?;
555
556    // --- archive session opens: the delete guards are now satisfied ---
557    tx.execute(&format!("CREATE TABLE {ARCHIVE_SESSION_MARKER} (x)"), ())
558        .await?;
559
560    let links_archived = tx
561        .execute(
562            &format!(
563                "INSERT OR IGNORE INTO cold.links
564                     (source_id, target_id, edge_type, valid_from, recorded_at,
565                      valid_to, weight, properties, branch_id)
566                 SELECT source_id, target_id, edge_type, valid_from, recorded_at,
567                        valid_to, weight, properties, branch_id
568                 FROM links WHERE {LINKS_ARCHIVABLE}"
569            ),
570            libsql::named_params! {":cutoff": cutoff},
571        )
572        .await? as usize;
573
574    let links_deleted = delete_guarded(
575        &tx,
576        conn,
577        &format!("DELETE FROM links WHERE {LINKS_ARCHIVABLE}"),
578        libsql::named_params! {":cutoff": cutoff},
579        "links",
580    )
581    .await?;
582
583    // links_current is derivative (Doctrine VI) and must equal the latest-belief
584    // projection of whatever remains in links, or audit_current() reports drift
585    // the moment an archive runs. Re-derive it rather than trying to describe
586    // the deletion's shadow: this used to be a hand-written
587    // `DELETE FROM links_current WHERE valid_to <= :cutoff`, which filters on
588    // *valid* time while LINKS_ARCHIVABLE also requires `recorded_at < :cutoff`.
589    // A row closed at the cutoff but recorded at or after it therefore survived
590    // in links and was deleted from links_current — permanent drift no later
591    // audit could explain, from a compensation that had quietly stopped being
592    // the image of the thing it compensated for. Doctrine II: two clocks, never
593    // mixed. Deriving from the definition cannot drift from the definition.
594    //
595    // **Skipped when the DELETE removed nothing (T1.1, D-080).** `links_current`
596    // is a function of `links`, so if `links` did not change its projection did
597    // not either, and there is no drift for a rebuild to repair. This was
598    // harmless while `archive()` was called once against a whole backlog,
599    // because the one session always had work. It stops being harmless the
600    // moment the caller windows: `rebuild_within` costs O(surviving `links`)
601    // regardless of how much the session archived (D-077), so without this a run
602    // of twenty windows over a quiet stretch of history pays twenty full
603    // reprojections to delete nothing — and windowing makes the archive slower
604    // in total than not windowing. `log_entries_archived` deliberately does not
605    // enter into it: archiving the transaction log cannot change `links`.
606    if links_deleted > 0 {
607        crate::integrity::rebuild::rebuild_within(&tx, crate::integrity::rebuild::Verify::No)
608            .await?;
609    }
610
611    // Concepts, and **only now** — after the `links` delete, never before it
612    // ([D-128](../../docs/architecture/s13-decision-register.md)). A concept is
613    // archivable when nothing in hot `links` names it, so evaluating the
614    // predicate before the edges have gone cold archives strictly less than the
615    // session is entitled to. This ordering is the whole content of "concept
616    // archival is downstream of link archival".
617    let concepts_archived = archive_concepts(&tx, conn, cutoff).await?;
618
619    let log_entries_archived = tx
620        .execute(
621            &format!(
622                "INSERT OR IGNORE INTO cold.transaction_log
623                     (seq_id, table_name, entity_id, operation, payload, recorded_at, branch_id)
624                 SELECT seq_id, table_name, entity_id, operation, payload, recorded_at, branch_id
625                 FROM transaction_log WHERE {LOG_ARCHIVABLE}"
626            ),
627            libsql::named_params! {":cutoff": cutoff},
628        )
629        .await? as usize;
630
631    delete_guarded(
632        &tx,
633        conn,
634        &format!("DELETE FROM transaction_log WHERE {LOG_ARCHIVABLE}"),
635        libsql::named_params! {":cutoff": cutoff},
636        "transaction_log",
637    )
638    .await?;
639
640    // Record the new horizon in the cold file so a pre-horizon reconstruct can
641    // tell "archived" from "never existed" (glossary; R14).
642    let horizon: Option<i64> = tx
643        .query("SELECT MIN(seq_id) FROM transaction_log", ())
644        .await?
645        .next()
646        .await?
647        .and_then(|row| row.get(0).ok());
648
649    tx.execute(
650        "INSERT INTO cold.archive_horizon (archived_at, cutoff, horizon) VALUES (?1, ?2, ?3)",
651        libsql::params![archived_at, cutoff, horizon],
652    )
653    .await?;
654
655    // --- archive session closes: the guards re-arm before COMMIT ---
656    tx.execute(&format!("DROP TABLE {ARCHIVE_SESSION_MARKER}"), ())
657        .await?;
658
659    tx.commit().await?;
660
661    Ok(ArchiveReport {
662        links_archived,
663        concepts_archived,
664        log_entries_archived,
665        horizon,
666    })
667}
668
669/// Move every concept [`CONCEPTS_ARCHIVABLE`] admits into `cold.concepts`, and
670/// dispose of its derived rows (C2).
671///
672/// # The partition, which is the decision this function encodes
673///
674/// **Entity data crosses; derivative data does not.** The concept row itself is
675/// moved column for column — a move that drops a column is a rewrite, and
676/// [Doctrine V] does not permit an absence the ledger cannot explain. Its
677/// `analytics_annotations` and `embeddings_*` rows are *deleted* rather than
678/// moved, because [Doctrine VII] makes both recomputable from the content that
679/// did cross. Carrying them would also be unimplementable for the vectors:
680/// `F32_BLOB` and DiskANN are libSQL-specific and the cold file is a plain
681/// database opened through `ATTACH`.
682///
683/// The disposal is not incidental to the move — it is what makes the move
684/// legal. `concepts` has four inbound foreign keys, and the two derived ones
685/// would refuse the `DELETE` outright.
686///
687/// # Why the deletes are not logged, and why that is right
688///
689/// `concepts` carries log triggers on insert and update but **not** on delete —
690/// there was no delete path to log while the guard was unconditional, and there
691/// deliberately still is not. Archival mints no transaction-time facts: the
692/// concept is in the cold file, the log entries describing it are either still
693/// hot or in `cold.transaction_log`, and nothing about what was believed, or
694/// when, has changed. A log entry here would assert that something happened to
695/// the concept at archive time, which is exactly the lie [Doctrine III] forbids.
696async fn archive_concepts(
697    tx: &libsql::Transaction,
698    conn: &libsql::Connection,
699    cutoff: &str,
700) -> Result<usize> {
701    let moved = tx
702        .execute(
703            &format!(
704                "INSERT OR IGNORE INTO cold.concepts
705                     (rowid_pk, id, title, content, embedding_model,
706                      valid_from, valid_to, recorded_at, retired, branch_id)
707                 SELECT rowid_pk, id, title, content, embedding_model,
708                        valid_from, valid_to, recorded_at, retired, branch_id
709                 FROM concepts WHERE {CONCEPTS_ARCHIVABLE}"
710            ),
711            libsql::named_params! {":cutoff": cutoff},
712        )
713        .await? as usize;
714
715    if moved == 0 {
716        return Ok(0);
717    }
718
719    // The derived rows, before the concept they hang off. `embeddings_*` is
720    // enumerated from the catalogue rather than from a list, because the set is
721    // whatever `register_model` has created on *this* database and a hard-coded
722    // list would silently miss a model the caller added.
723    let mut derived: Vec<String> = vec!["analytics_annotations".to_string()];
724    let mut rows = tx
725        .query(
726            "SELECT name FROM sqlite_master WHERE type = 'table' \
727             AND name LIKE 'embeddings\\_%' ESCAPE '\\'",
728            (),
729        )
730        .await?;
731    while let Some(row) = rows.next().await? {
732        derived.push(row.get::<String>(0)?);
733    }
734    drop(rows);
735
736    for table in &derived {
737        tx.execute(
738            &format!(
739                "DELETE FROM {table} WHERE concept_id IN \
740                 (SELECT id FROM cold.concepts)"
741            ),
742            (),
743        )
744        .await?;
745    }
746
747    // `trg_concepts_fts_delete` fires on this and keeps the search index
748    // correct — the capability v8 installed inert and this rung made reachable.
749    let deleted = delete_guarded(
750        tx,
751        conn,
752        &format!("DELETE FROM concepts WHERE {CONCEPTS_ARCHIVABLE}"),
753        libsql::named_params! {":cutoff": cutoff},
754        "concepts",
755    )
756    .await? as usize;
757
758    debug_assert_eq!(
759        moved, deleted,
760        "the predicate selected a different set for the copy than for the delete"
761    );
762
763    Ok(deleted)
764}
765
766/// Move one lineage's whole ledger to the cold file and forget the lineage
767/// (0.14.13, §15.4, [D-230]).
768///
769/// The abandonment arm §15.4 asks for. A conversation tree discards most of what
770/// it grows, and until now the only way to reclaim an abandoned branch's space
771/// was [`archive`], which is indexed by *time* and therefore takes the trunk's
772/// old history along with it — or leaves the branch's recent history behind,
773/// which is the usual case and the reason the arm exists.
774///
775/// # It is all-or-nothing, and that was forced rather than chosen
776///
777/// The road map's justification was that "an abandoned branch's rows are a
778/// contiguous archivable set by construction, which is the cheapest archive
779/// predicate in the crate". `branch_id = :branch` really is the cheapest
780/// predicate in the crate. **Contiguous by construction is false in both of its
781/// senses**, and each refutation moved this design:
782///
783/// 1. *Not closed under `concepts(id)`.* `concepts` is keyed by identity
784///    globally ([D-214]), so a concept minted on a branch may be named by a
785///    trunk edge or a sibling's edge — measured by probe, both succeed. The set
786///    is therefore not FK-closed, and the refusal below is the direct
787///    expression of that: a lineage another lineage's hot edges still depend on
788///    is not abandoned, whatever its author believes.
789/// 2. *Not a prefix of the log.* A branch's `transaction_log` rows are
790///    scattered through the sequence, exactly as `LOG_ARCHIVABLE`'s are, which
791///    is what [`crate::temporal::replay`]'s reach test was rewritten for in
792///    0.5.5.
793///
794/// What follows is a chain with no branch points. The links must go — that is
795/// the operation. If the links go and the log stays, `reconstruct(now)` folds
796/// the log, yields the branch's open edges, and disagrees with `links_current`
797/// about present belief; so the log must go too. But `hot_log_reach`'s
798/// soundness rests on **the newest row per entity is never archivable**, which
799/// is true of a predicate needing a later row to exist and false of one that
800/// takes a whole lineage; so the `branches` row must go as well, which is what
801/// makes a hot fold that omits the lineage *correct rather than silently
802/// short*. Every read and write naming the name then raises
803/// [`DbError::UnknownBranch`] — a refusal, which a caller can act on, in place
804/// of an answer that is quietly missing rows.
805///
806/// The `branches` row moving is why v13 exists: `trg_branches_frozen_delete`
807/// was unconditional, on a docstring that said no session could ever legally
808/// remove a lineage record. See
809/// [`crate::schema::ddl::CREATE_BRANCHES_GUARD_DELETE`].
810///
811/// # What it refuses
812///
813/// * **The trunk.** Every lineage's `parent_id` chain ends there and every
814///   default `branch_id` names it; there is no ledger left after it goes.
815/// * **A name that is not registered** — [`DbError::UnknownBranch`], the same
816///   answer every other branch-taking surface gives, rather than a silent
817///   success archiving nothing.
818/// * **A branch with descendants.** A child reads through its parent, so
819///   archiving the parent would delete rows the child still believes — the same
820///   loss [D-229] repaired in the time-indexed predicates, arrived at from the
821///   other direction.
822/// * **A branch whose concepts another lineage's hot link names.** This is
823///   refutation 1 above, and the refusal is what makes the post-condition
824///   uniform: after this returns `Ok`, nothing hot names the lineage and
825///   nothing hot names anything it minted.
826///
827/// All four are checked **inside the session transaction**, before the marker
828/// is created, so a concurrent fork cannot slip a descendant in between the
829/// check and the delete.
830///
831/// # No `archive_horizon` row, deliberately
832///
833/// That table records a **cutoff** and the horizon it produced. This session
834/// has no cutoff — its boundary is a lineage, not an instant — and writing
835/// `archived_at` into the `cutoff` column would be the Wave 4.5 defect the
836/// column's own comment describes, committed a second time on purpose. What
837/// there is to record is recorded better: `cold.branches` carries the lineage
838/// and when it was forgotten, and the horizon itself is still readable from the
839/// hot log, which is where `archive_hint` reads it from anyway.
840///
841/// [D-230]: ../../docs/architecture/s13-decision-register.md#d-230
842/// [D-229]: ../../docs/architecture/s13-decision-register.md#d-229
843/// [D-214]: ../../docs/architecture/s13-decision-register.md#d-214
844pub async fn archive_branch(
845    conn: &libsql::Connection,
846    branch: &str,
847    archived_at: &str,
848    archive_path: &Path,
849) -> Result<ArchiveReport> {
850    crate::temporal::replay::detach_stale_cold(conn).await;
851
852    conn.execute(
853        "ATTACH DATABASE ?1 AS cold",
854        libsql::params![archive_path.to_string_lossy().as_ref()],
855    )
856    .await?;
857
858    let result = archive_branch_session(conn, branch, archived_at).await;
859
860    // Unconditional, for [`archive`]'s reason: a live `cold` handle makes every
861    // later archive and cold reconstruct fail with "database cold is already in
862    // use", and the refusals above are the *expected* way out of this function.
863    if let Err(e) = conn.execute("DETACH DATABASE cold", ()).await {
864        tracing::warn!("archive_branch: failed to DETACH cold database: {e}");
865    }
866
867    result
868}
869
870fn not_archivable(branch: &str, reason: impl Into<String>) -> DbError {
871    DbError::BranchNotArchivable {
872        branch: branch.to_string(),
873        reason: reason.into(),
874    }
875}
876
877/// Whether `sql` — a `SELECT 1 … WHERE … = :branch` — matches anything.
878async fn any_row(tx: &libsql::Transaction, sql: &str, branch: &str) -> Result<bool> {
879    Ok(tx
880        .query(sql, libsql::named_params! {":branch": branch})
881        .await?
882        .next()
883        .await?
884        .is_some())
885}
886
887/// The four refusals, in the order that makes the message most specific.
888///
889/// Order is not cosmetic. The trunk check comes first because `main` is
890/// registered and childless on a ledger that has never forked, so every later
891/// check would pass it. Registration comes next, because "not registered" is a
892/// better answer than "has no descendants" for a typo. Descendants before
893/// concepts because it is the cheaper query and the commoner mistake.
894async fn refuse_unarchivable_branch(tx: &libsql::Transaction, branch: &str) -> Result<()> {
895    if branch == crate::schema::ddl::MAIN_BRANCH {
896        return Err(not_archivable(
897            branch,
898            "it is the trunk: every lineage's parent chain ends there and every \
899             default branch_id names it, so there is no ledger left after it goes",
900        ));
901    }
902
903    if !any_row(
904        tx,
905        "SELECT 1 FROM branches WHERE branch_id = :branch",
906        branch,
907    )
908    .await?
909    {
910        return Err(DbError::UnknownBranch(branch.to_string()));
911    }
912
913    if any_row(
914        tx,
915        "SELECT 1 FROM branches WHERE parent_id = :branch",
916        branch,
917    )
918    .await?
919    {
920        return Err(not_archivable(
921            branch,
922            "it has descendants, which read through it: archiving it would delete \
923             rows they still believe. Archive the descendants first",
924        ));
925    }
926
927    // The refutation of "contiguous by construction", as a query. A concept is
928    // keyed by identity across the whole ledger (D-214), so an edge on any
929    // lineage may name one minted here.
930    let mut rows = tx
931        .query(
932            "SELECT c.id FROM concepts c
933             WHERE c.branch_id = :branch
934               AND EXISTS (
935                   SELECT 1 FROM links l
936                   WHERE l.branch_id <> :branch
937                     AND (l.source_id = c.id OR l.target_id = c.id)
938               )
939             LIMIT 1",
940            libsql::named_params! {":branch": branch},
941        )
942        .await?;
943    if let Some(row) = rows.next().await? {
944        let id: String = row.get(0)?;
945        return Err(not_archivable(
946            branch,
947            format!(
948                "concept {id} was minted here and a hot edge on another lineage \
949                 names it. A lineage other lineages still depend on is not \
950                 abandoned; retire those edges first"
951            ),
952        ));
953    }
954
955    Ok(())
956}
957
958/// `conn` is passed alongside `tx` for [`delete_guarded`]'s sake, exactly as in
959/// [`archive_session`]. Both name the same connection.
960async fn archive_branch_session(
961    conn: &libsql::Connection,
962    branch: &str,
963    archived_at: &str,
964) -> Result<ArchiveReport> {
965    for ddl in COLD_SCHEMA {
966        conn.execute(ddl, ()).await?;
967    }
968
969    let tx = conn
970        .transaction_with_behavior(TransactionBehavior::Immediate)
971        .await?;
972
973    upgrade_cold_lineage(&tx).await?;
974
975    // Before the marker: a refusal must not be able to leave the guards
976    // disarmed, and these are reads, which need no session.
977    refuse_unarchivable_branch(&tx, branch).await?;
978
979    // --- archive session opens: the delete guards are now satisfied ---
980    tx.execute(&format!("CREATE TABLE {ARCHIVE_SESSION_MARKER} (x)"), ())
981        .await?;
982
983    let links_archived = tx
984        .execute(
985            "INSERT OR IGNORE INTO cold.links
986                 (source_id, target_id, edge_type, valid_from, recorded_at,
987                  valid_to, weight, properties, branch_id)
988             SELECT source_id, target_id, edge_type, valid_from, recorded_at,
989                    valid_to, weight, properties, branch_id
990             FROM links WHERE branch_id = :branch",
991            libsql::named_params! {":branch": branch},
992        )
993        .await? as usize;
994
995    let links_deleted = delete_guarded(
996        &tx,
997        conn,
998        "DELETE FROM links WHERE branch_id = :branch",
999        libsql::named_params! {":branch": branch},
1000        "links",
1001    )
1002    .await?;
1003
1004    // Doctrine VI, and [`archive_session`]'s reasoning verbatim: `links_current`
1005    // is a function of `links`, so it is re-derived rather than described, and
1006    // only when `links` actually changed. It must also happen **before** the
1007    // `branches` row goes: `links_current.branch_id` carries the same foreign
1008    // key its three siblings do, so the projection has to have stopped naming
1009    // the lineage before the lineage can leave.
1010    if links_deleted > 0 {
1011        crate::integrity::rebuild::rebuild_within(&tx, crate::integrity::rebuild::Verify::No)
1012            .await?;
1013    }
1014
1015    // Concepts after links, for [D-128]'s reason turned around: there it was
1016    // that a concept is archivable only once nothing hot names it, so the edges
1017    // must go first. Here the same ordering is a foreign key — `links.source_id`
1018    // and `links.target_id` reference `concepts(id)`, and this lineage's own
1019    // edges are the ones that would refuse the delete.
1020    //
1021    // [D-128]: ../../docs/architecture/s13-decision-register.md#d-128
1022    let concepts_archived = archive_branch_concepts(&tx, conn, branch).await?;
1023
1024    let log_entries_archived = tx
1025        .execute(
1026            "INSERT OR IGNORE INTO cold.transaction_log
1027                 (seq_id, table_name, entity_id, operation, payload, recorded_at, branch_id)
1028             SELECT seq_id, table_name, entity_id, operation, payload, recorded_at, branch_id
1029             FROM transaction_log WHERE branch_id = :branch",
1030            libsql::named_params! {":branch": branch},
1031        )
1032        .await? as usize;
1033
1034    delete_guarded(
1035        &tx,
1036        conn,
1037        "DELETE FROM transaction_log WHERE branch_id = :branch",
1038        libsql::named_params! {":branch": branch},
1039        "transaction_log",
1040    )
1041    .await?;
1042
1043    // Last, because the other three tables' `branch_id` all reference it. The
1044    // `archived_at` is the session's wall clock, not a ledger fact: nothing was
1045    // asserted or retired here, and Doctrine III would refuse it if it were.
1046    tx.execute(
1047        "INSERT OR IGNORE INTO cold.branches
1048             (branch_id, parent_id, forked_at, created_at, archived_at)
1049         SELECT branch_id, parent_id, forked_at, created_at, ?2
1050         FROM branches WHERE branch_id = ?1",
1051        libsql::params![branch, archived_at],
1052    )
1053    .await?;
1054
1055    delete_guarded(
1056        &tx,
1057        conn,
1058        "DELETE FROM branches WHERE branch_id = :branch",
1059        libsql::named_params! {":branch": branch},
1060        "branches",
1061    )
1062    .await?;
1063
1064    let horizon: Option<i64> = tx
1065        .query("SELECT MIN(seq_id) FROM transaction_log", ())
1066        .await?
1067        .next()
1068        .await?
1069        .and_then(|row| row.get(0).ok());
1070
1071    // --- archive session closes: the guards re-arm before COMMIT ---
1072    tx.execute(&format!("DROP TABLE {ARCHIVE_SESSION_MARKER}"), ())
1073        .await?;
1074
1075    tx.commit().await?;
1076
1077    Ok(ArchiveReport {
1078        links_archived,
1079        concepts_archived,
1080        log_entries_archived,
1081        horizon,
1082    })
1083}
1084
1085/// [`archive_concepts`] with the lineage predicate in place of the cutoff.
1086///
1087/// A separate function rather than a parameter on that one, because the two
1088/// share their *shape* and not their argument: `CONCEPTS_ARCHIVABLE` is a
1089/// standing predicate about retirement and reference counts, and this is a
1090/// lineage. The partition it encodes is the same and is the reason both exist —
1091/// **entity data crosses, derivative data does not** — and the derived rows are
1092/// deleted here for the same two reasons: Doctrine VII makes them recomputable,
1093/// and `concepts`' inbound foreign keys would refuse the delete otherwise.
1094///
1095/// No guard on "is anything still referencing this concept": the caller has
1096/// already refused the branch if another lineage's hot link names one of its
1097/// concepts, and this lineage's own links went cold a few statements ago.
1098async fn archive_branch_concepts(
1099    tx: &libsql::Transaction,
1100    conn: &libsql::Connection,
1101    branch: &str,
1102) -> Result<usize> {
1103    let moved = tx
1104        .execute(
1105            "INSERT OR IGNORE INTO cold.concepts
1106                 (rowid_pk, id, title, content, embedding_model,
1107                  valid_from, valid_to, recorded_at, retired, branch_id)
1108             SELECT rowid_pk, id, title, content, embedding_model,
1109                    valid_from, valid_to, recorded_at, retired, branch_id
1110             FROM concepts WHERE branch_id = :branch",
1111            libsql::named_params! {":branch": branch},
1112        )
1113        .await? as usize;
1114
1115    if moved == 0 {
1116        return Ok(0);
1117    }
1118
1119    let mut derived: Vec<String> = vec!["analytics_annotations".to_string()];
1120    let mut rows = tx
1121        .query(
1122            "SELECT name FROM sqlite_master WHERE type = 'table' \
1123             AND name LIKE 'embeddings\\_%' ESCAPE '\\'",
1124            (),
1125        )
1126        .await?;
1127    while let Some(row) = rows.next().await? {
1128        derived.push(row.get::<String>(0)?);
1129    }
1130    drop(rows);
1131
1132    for table in &derived {
1133        tx.execute(
1134            &format!(
1135                "DELETE FROM {table} WHERE concept_id IN \
1136                 (SELECT id FROM concepts WHERE branch_id = :branch)"
1137            ),
1138            libsql::named_params! {":branch": branch},
1139        )
1140        .await?;
1141    }
1142
1143    let deleted = delete_guarded(
1144        tx,
1145        conn,
1146        "DELETE FROM concepts WHERE branch_id = :branch",
1147        libsql::named_params! {":branch": branch},
1148        "concepts",
1149    )
1150    .await? as usize;
1151
1152    debug_assert_eq!(
1153        moved, deleted,
1154        "the lineage selected a different set for the copy than for the delete"
1155    );
1156
1157    Ok(deleted)
1158}
1159
1160/// Outcome of one rehydration (0.9.0, C3).
1161#[derive(Debug, Clone, PartialEq, Eq)]
1162pub struct RehydrateReport {
1163    /// Concepts moved back into the hot table.
1164    pub concepts_rehydrated: usize,
1165    /// Of those, how many could **not** keep their original `rowid_pk` because
1166    /// something else had claimed it while they were cold, and were reassigned
1167    /// with the FTS index re-pointed to match.
1168    ///
1169    /// Reported rather than hidden because it is the one way a rehydrated
1170    /// concept differs from the row that was archived, and a caller comparing
1171    /// rowids across the boundary should be able to see that it happened.
1172    pub rowids_reassigned: usize,
1173}
1174
1175/// Move concepts back from the cold file into the hot tables (§2.3, C3).
1176///
1177/// # Rehydration is a move back, not a write
1178///
1179/// It mints no transaction-time facts and is invisible to both clocks. The
1180/// concept's log entries were never removed, so the ledger already says
1181/// everything true about it; writing a fresh `'I'` would assert the concept was
1182/// *learned* at rehydration time, and — because the fold takes the highest
1183/// `seq_id` per entity — would additionally outrank any later `'U'` that retired
1184/// it. See [`crate::schema::ddl::CREATE_CONCEPTS_LOG_INSERT`], which is
1185/// marker-gated at v10 for exactly this reason. The whole operation therefore
1186/// runs inside a declared archive session, which is what suppresses the trigger.
1187///
1188/// # `rowid_pk`: reinstate, or reassign and re-point the index
1189///
1190/// The common case has no collision — the rowid was freed by archival and
1191/// nothing has claimed it since — and reinstating is the clean move-back with no
1192/// side effects at all. When something *has* taken it, the fallback is a fresh
1193/// `rowid_pk` plus an FTS correction: `concepts_fts` is external-content keyed
1194/// on `rowid_pk` ([D-119]), so a reassignment without re-pointing leaves the
1195/// index describing the wrong row, silently. Both exits are taken here rather
1196/// than one being assumed, and [`RehydrateReport::rowids_reassigned`] reports
1197/// which was used.
1198pub async fn rehydrate(
1199    conn: &libsql::Connection,
1200    ids: &[&str],
1201    archive_path: &Path,
1202) -> Result<RehydrateReport> {
1203    if ids.is_empty() {
1204        return Ok(RehydrateReport {
1205            concepts_rehydrated: 0,
1206            rowids_reassigned: 0,
1207        });
1208    }
1209
1210    crate::temporal::replay::detach_stale_cold(conn).await;
1211    conn.execute(
1212        "ATTACH DATABASE ?1 AS cold",
1213        libsql::params![archive_path.to_string_lossy().as_ref()],
1214    )
1215    .await?;
1216
1217    let result = rehydrate_session(conn, ids).await;
1218
1219    if let Err(e) = conn.execute("DETACH DATABASE cold", ()).await {
1220        tracing::warn!("rehydrate: failed to DETACH cold database: {e}");
1221    }
1222    result
1223}
1224
1225async fn rehydrate_session(conn: &libsql::Connection, ids: &[&str]) -> Result<RehydrateReport> {
1226    let tx = conn
1227        .transaction_with_behavior(TransactionBehavior::Immediate)
1228        .await?;
1229
1230    // The session opens for the same reason the archive's does, plus one more:
1231    // it is what stops `trg_concepts_log_insert` from firing (v10).
1232    tx.execute(&format!("CREATE TABLE {ARCHIVE_SESSION_MARKER} (x)"), ())
1233        .await?;
1234
1235    // Asked once, and never acted on. A cold file that predates v12 is read
1236    // through a literal — `'main'` is what those rows *were*, since they were
1237    // written when only the trunk existed — and left exactly as it was found.
1238    // The archive writer upgrades cold files; the reader must not, because a
1239    // cold file can be read-only media or sit on a share, and a read path that
1240    // mutates one is a new failure class (D-026, §15.2).
1241    let lineage = if cold_has_branch(&tx, "concepts").await? {
1242        "branch_id"
1243    } else {
1244        "'main' AS branch_id"
1245    };
1246
1247    let mut rehydrated = 0usize;
1248    let mut reassigned = 0usize;
1249
1250    for id in ids {
1251        let Some(row) = tx
1252            .query(
1253                &format!(
1254                    "SELECT rowid_pk, id, title, content, embedding_model, \
1255                     valid_from, valid_to, recorded_at, retired, {lineage} \
1256                     FROM cold.concepts WHERE id = ?1"
1257                ),
1258                libsql::params![*id],
1259            )
1260            .await?
1261            .next()
1262            .await?
1263        else {
1264            continue;
1265        };
1266
1267        let old_rowid: i64 = row.get(0)?;
1268        let title: String = row.get(2)?;
1269        let content: String = row.get(3)?;
1270        let model: Option<String> = row.get(4)?;
1271        let valid_from: String = row.get(5)?;
1272        let valid_to: String = row.get(6)?;
1273        let recorded_at: String = row.get(7)?;
1274        let retired: i64 = row.get(8)?;
1275        let branch_id: String = row.get(9)?;
1276
1277        let taken: i64 = tx
1278            .query(
1279                "SELECT COUNT(*) FROM concepts WHERE rowid_pk = ?1",
1280                libsql::params![old_rowid],
1281            )
1282            .await?
1283            .next()
1284            .await?
1285            .expect("COUNT(*) always returns a row")
1286            .get(0)?;
1287
1288        if taken == 0 {
1289            // The clean move back: same row, same rowid, no side effects.
1290            tx.execute(
1291                "INSERT INTO concepts (rowid_pk, id, title, content, embedding_model, \
1292                 valid_from, valid_to, recorded_at, retired, branch_id) \
1293                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
1294                libsql::params![
1295                    old_rowid,
1296                    *id,
1297                    title.clone(),
1298                    content.clone(),
1299                    model,
1300                    valid_from,
1301                    valid_to,
1302                    recorded_at,
1303                    retired,
1304                    branch_id
1305                ],
1306            )
1307            .await?;
1308        } else {
1309            // Something claimed the rowid while this concept was cold. Take a
1310            // fresh one, then correct the index: `concepts_fts` is
1311            // external-content keyed on `rowid_pk`, and its insert trigger will
1312            // have written an entry at the *new* rowid — what has to be undone
1313            // is the stale entry still sitting at the old one, which the archive
1314            // could not remove because the row it described had already gone.
1315            tx.execute(
1316                "INSERT INTO concepts (id, title, content, embedding_model, \
1317                 valid_from, valid_to, recorded_at, retired, branch_id) \
1318                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
1319                libsql::params![
1320                    *id,
1321                    title.clone(),
1322                    content.clone(),
1323                    model,
1324                    valid_from,
1325                    valid_to,
1326                    recorded_at,
1327                    retired,
1328                    branch_id
1329                ],
1330            )
1331            .await?;
1332            tx.execute(
1333                "INSERT INTO concepts_fts (concepts_fts, rowid, title, content) \
1334                 VALUES ('delete', ?1, ?2, ?3)",
1335                libsql::params![old_rowid, title, content],
1336            )
1337            .await?;
1338            reassigned += 1;
1339        }
1340
1341        tx.execute(
1342            "DELETE FROM cold.concepts WHERE id = ?1",
1343            libsql::params![*id],
1344        )
1345        .await?;
1346        rehydrated += 1;
1347    }
1348
1349    tx.execute(&format!("DROP TABLE {ARCHIVE_SESSION_MARKER}"), ())
1350        .await?;
1351    tx.commit().await?;
1352
1353    Ok(RehydrateReport {
1354        concepts_rehydrated: rehydrated,
1355        rowids_reassigned: reassigned,
1356    })
1357}
1358
1359/// Run one of the archive's `DELETE`s, naming the table if a guard refuses it.
1360///
1361/// **This is what closes defect AC, and the shape of the fix is the point.**
1362/// There used to be a second classifier here — `classify_archive_violation` —
1363/// which was defined, delegated correctly to [`crate::error::abort_kind`], and
1364/// called from nowhere, so `DbError::ArchiveViolation` was unreachable by any
1365/// code path in the crate. It was recorded as defect H, marked Fixed by a commit
1366/// that made the *body* delegate rather than making the function *called*, and
1367/// so survived its own repair. It is deleted rather than wired up, because
1368/// [`crate::error::classify`] with [`WriteOp::Delete`] already did exactly what
1369/// it did: the defect was one classifier too many, not one too few.
1370///
1371/// A guard firing here means the marker table is absent or was dropped early —
1372/// the session's invariant broken from inside. That is worth a typed error
1373/// naming the table rather than a raw engine message naming a trigger.
1374async fn delete_guarded(
1375    tx: &libsql::Transaction,
1376    conn: &libsql::Connection,
1377    sql: &str,
1378    params: impl libsql::params::IntoParams,
1379    table: &str,
1380) -> Result<u64> {
1381    match tx.execute(sql, params).await {
1382        Ok(n) => Ok(n),
1383        Err(e) => Err(crate::error::classify(conn, e, WriteOp::Delete { table }).await),
1384    }
1385}