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}