Skip to main content

macrame/temporal/
archive.rs

1use std::path::Path;
2
3use libsql::TransactionBehavior;
4
5use crate::error::{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    r#"CREATE TABLE IF NOT EXISTS cold.links (
47        source_id   TEXT NOT NULL,
48        target_id   TEXT NOT NULL,
49        edge_type   TEXT NOT NULL,
50        valid_from  TEXT NOT NULL,
51        recorded_at TEXT NOT NULL,
52        valid_to    TEXT NOT NULL,
53        weight      REAL NOT NULL CHECK (weight >= 0.0 AND weight < 9e999 AND typeof(weight) = 'real'),
54        properties  TEXT NOT NULL,
55        PRIMARY KEY (source_id, target_id, edge_type, valid_from, recorded_at)
56    )"#,
57    // Concepts, as of v9 (C2). Trigger-free and FK-free like `cold.links`, and
58    // for the same reasons -- but note what it does NOT drop.
59    //
60    // **Every column crosses, `content` included.** Archival is a move, not a
61    // rewrite (2.3), and a move that drops a column is a rewrite. The log
62    // payload for a concept carries its `content` (4.3), so a cold concept
63    // whose text had been dropped would contradict `cold.transaction_log` about
64    // itself, and rehydration would return a concept the ledger never recorded:
65    // empty text where the log says there was text. That is the unexplained
66    // absence Doctrine V exists to prevent.
67    //
68    // The tension with D-116 is apparent rather than real. D-116 governs the
69    // *in-memory* `NodeData` representation -- `content` is not loaded by
70    // default because most readers do not want it. This is *on-disk* storage.
71    // Disk carries the text; memory does not populate it until asked. Two
72    // independent defaults, and conflating them would make rehydration lossy to
73    // save a read nobody was performing.
74    //
75    // `rowid_pk` crosses as the record of what the rowid *was*. Restoring it is
76    // C3's problem and not obviously safe: `concepts.rowid_pk` is a plain
77    // INTEGER PRIMARY KEY, so SQLite may reuse a freed value, and archiving the
78    // highest rowids can leave a later insert holding one a cold row still
79    // claims. The column is carried because a move must not lose it.
80    //
81    // **The hazard has two exits and C3 must take one of them explicitly.**
82    // Either reinstate the original `rowid_pk` when it is still free, or assign
83    // a fresh one — and in the second case **update `concepts_fts`'s
84    // `content_rowid` mapping to match**, because the FTS index is
85    // external-content keyed on this column (4.6, D-119). A rehydration that
86    // reassigns the rowid without re-pointing the index leaves the search index
87    // silently describing the wrong row, which is the exact failure `rowid_pk`
88    // was made explicit to prevent. Named here so C3 meets both exits rather
89    // than rediscovering the FTS coupling.
90    r#"CREATE TABLE IF NOT EXISTS cold.concepts (
91        rowid_pk         INTEGER,
92        id               TEXT NOT NULL PRIMARY KEY,
93        title            TEXT NOT NULL,
94        content          TEXT NOT NULL DEFAULT '',
95        embedding_model  TEXT,
96        valid_from       TEXT NOT NULL,
97        valid_to         TEXT NOT NULL,
98        recorded_at      TEXT NOT NULL,
99        retired          INTEGER NOT NULL DEFAULT 0
100    )"#,
101    // seq_id is carried over verbatim from the hot log, so it is a plain
102    // INTEGER PRIMARY KEY -- never AUTOINCREMENT, which would renumber history.
103    r#"CREATE TABLE IF NOT EXISTS cold.transaction_log (
104        seq_id      INTEGER PRIMARY KEY,
105        table_name  TEXT NOT NULL,
106        entity_id   TEXT NOT NULL,
107        operation   TEXT NOT NULL,
108        payload     TEXT NOT NULL,
109        recorded_at TEXT NOT NULL
110    )"#,
111    "CREATE INDEX IF NOT EXISTS cold.idx_cold_txlog_entity ON transaction_log (entity_id)",
112    "CREATE INDEX IF NOT EXISTS cold.idx_cold_txlog_time ON transaction_log (recorded_at)",
113    r#"CREATE TABLE IF NOT EXISTS cold.archive_horizon (
114        archived_at TEXT NOT NULL,
115        cutoff      TEXT NOT NULL,
116        horizon     INTEGER
117    )"#,
118];
119
120/// A links assertion is archivable when it is older than the cutoff AND it is
121/// either superseded by a later assertion for the same interval key, or it is
122/// the current belief for an interval that closed before the cutoff.
123///
124/// This keeps every row that `links_current` still projects (Doctrine VI: the
125/// materialization must stay rebuildable from `links`) while moving exactly the
126/// "closed intervals, superseded history" the §2 diagram assigns to the cold file.
127const LINKS_ARCHIVABLE: &str = r#"
128    recorded_at < :cutoff AND (
129        EXISTS (
130            SELECT 1 FROM links newer
131            WHERE newer.source_id   = links.source_id
132              AND newer.target_id   = links.target_id
133              AND newer.edge_type   = links.edge_type
134              AND newer.valid_from  = links.valid_from
135              AND newer.recorded_at > links.recorded_at
136        )
137        OR (valid_to <> '9999-12-31T23:59:59.999999Z' AND valid_to <= :cutoff)
138    )
139"#;
140
141/// A log entry is archivable when it is older than the cutoff and a later entry
142/// exists for the same entity, i.e. it is superseded. The newest entry per
143/// entity always stays hot so that `reconstruct(now)` never needs the cold file.
144const LOG_ARCHIVABLE: &str = r#"
145    recorded_at < :cutoff AND EXISTS (
146        SELECT 1 FROM transaction_log newer
147        WHERE newer.entity_id = transaction_log.entity_id
148          AND newer.seq_id    > transaction_log.seq_id
149    )
150"#;
151
152/// A concept is archivable when it is `retired`, both its clocks are behind the
153/// cutoff, **and no surviving row of hot `links` mentions it in either
154/// direction** (C1, D-128).
155///
156/// # Why reachability, and not a closed interval
157///
158/// A link assertion has a closed interval, so [`LINKS_ARCHIVABLE`] can ask
159/// whether the interval ended. A concept is an *entity*, and has no closed
160/// state: `retired = 1` says belief in it stopped, which is not the same claim
161/// as "nothing points at it any more". The two `links` foreign keys are what
162/// make that difference matter — archiving a concept physically removes its row,
163/// and a surviving hot link naming it would leave the key unsatisfiable.
164/// `ON DELETE CASCADE` is not the way out, because the rows it would cascade
165/// onto are ledger rows.
166///
167/// So concept archival is **strictly downstream of link archival**: a concept
168/// becomes eligible only once every edge mentioning it has itself gone cold.
169/// Inside a session this predicate is therefore evaluated *after* the `links`
170/// delete and never before it, and the same question asked before and after one
171/// session legitimately gives two different answers. That is a property of the
172/// predicate, not a race.
173///
174/// # The other two foreign keys, and why they are not clauses here
175///
176/// `concepts` also has inbound keys from `analytics_annotations` and from every
177/// registered `embeddings_*` table ([`crate::schema::migrations`] lists all
178/// four). Neither appears above, and the distinction is the point: those hold
179/// **derived** rows. Doctrine VII makes an embedding an artifact of a model
180/// applied to content, and an annotation is the output of an algorithm that read
181/// `concepts` in the first place. A derived row is removed and recomputed; a
182/// ledger row is neither. Making archivability wait on a recomputable artifact
183/// would answer "not yet" forever for any concept that had ever been embedded.
184///
185/// # Both clocks, because one of them is not enough
186///
187/// The specification for this predicate named `valid_to` alone.
188/// `recorded_at < :cutoff` is here as well, mirroring [`LINKS_ARCHIVABLE`]:
189/// a concept retired with its `valid_to` behind the cutoff but *recorded* at or
190/// after it is a fact the session is not meant to touch yet, and archiving it
191/// would send the concept cold while the log entries describing it stayed hot.
192/// That is the same two-clock mismatch the `links_current` compensation carried
193/// until Wave 4.5 (see [`archive_session`]), reached from the other side.
194/// Doctrine II: two clocks, never mixed.
195///
196/// The open sentinel needs no clause of its own — `9999-12-31T23:59:59.999999Z`
197/// sorts above every canonical stamp (D-029), so a concept whose validity is
198/// still open fails `valid_to < :cutoff` for any cutoff a caller can pass.
199const CONCEPTS_ARCHIVABLE: &str = r#"
200    retired = 1
201    AND recorded_at < :cutoff
202    AND valid_to    < :cutoff
203    AND NOT EXISTS (
204        SELECT 1 FROM links
205        WHERE links.source_id = concepts.id
206           OR links.target_id = concepts.id
207    )
208"#;
209
210/// The ids of every concept that `CONCEPTS_ARCHIVABLE` admits at `cutoff`, in
211/// `id` order.
212///
213/// **Read-only, and deliberately available before anything can act on it.**
214/// Concept archival is the one operation in this crate a caller cannot undo
215/// without a cold file to hand, so the predicate that decides it is observable
216/// on its own rather than only as a count in a report after the fact.
217///
218/// The answer is a function of the hot state *now*. Archiving links first will
219/// generally enlarge it — that is the downstream relationship
220/// `CONCEPTS_ARCHIVABLE` describes, not an inconsistency — so a caller
221/// planning a session should ask after the link archive, not before it.
222pub async fn archivable_concepts(conn: &libsql::Connection, cutoff: &str) -> Result<Vec<String>> {
223    let mut rows = conn
224        .query(
225            &format!("SELECT id FROM concepts WHERE {CONCEPTS_ARCHIVABLE} ORDER BY id"),
226            libsql::named_params! {":cutoff": cutoff},
227        )
228        .await?;
229
230    let mut ids = Vec::new();
231    while let Some(row) = rows.next().await? {
232        ids.push(row.get::<String>(0)?);
233    }
234    Ok(ids)
235}
236
237/// Move closed edge intervals and superseded log rows older than `cutoff` into
238/// the cold database at `archive_path` (§5.7, D-012, D-022).
239///
240/// The whole session is one `BEGIN IMMEDIATE … COMMIT` transaction (D-012):
241/// copy-then-delete must be atomic, or a crash between the phases duplicates or
242/// loses rows. The archive-session marker that unlocks the delete guards
243/// (D-008 revised) is created as the first statement of that transaction and
244/// dropped as the last, so it never exists as committed state — commit drops
245/// it, rollback discards it, and there is no crash path that leaves the guards
246/// disarmed.
247///
248/// ATTACH is issued outside the transaction and DETACH is issued unconditionally
249/// on the way out, including on error: ATTACH is not transactional and survives
250/// ROLLBACK, so a leaked handle would make every later archive or cold-DB
251/// reconstruct fail with "database cold is already in use".
252/// `archived_at` is **when the session ran**; `cutoff` is the boundary it used.
253///
254/// Both go into `cold.archive_horizon`, and until Wave 4.5 both columns were
255/// written with the cutoff — so the table recorded that every archive had run at
256/// the instant it was archiving *up to*, which is the one time it certainly did
257/// not run. The two are different clocks (Doctrine II) and the row exists to
258/// carry both: the cutoff says what was moved, `archived_at` says when the
259/// decision was taken, and only the second can answer "how stale is this cold
260/// file". The column was there, correctly named, holding the wrong value.
261pub async fn archive(
262    conn: &libsql::Connection,
263    cutoff: &str,
264    archived_at: &str,
265    archive_path: &Path,
266) -> Result<ArchiveReport> {
267    crate::temporal::replay::detach_stale_cold(conn).await;
268
269    // ATTACH creates the cold file if it does not exist.
270    conn.execute(
271        "ATTACH DATABASE ?1 AS cold",
272        libsql::params![archive_path.to_string_lossy().as_ref()],
273    )
274    .await?;
275
276    let result = archive_session(conn, cutoff, archived_at).await;
277
278    // Unconditional: see the DETACH note above.
279    if let Err(e) = conn.execute("DETACH DATABASE cold", ()).await {
280        tracing::warn!("archive: failed to DETACH cold database: {e}");
281    }
282
283    result
284}
285
286/// `conn` is passed alongside `tx` only so [`delete_guarded`] can hand it to
287/// `classify`, which queries on the error path. Both name the same connection.
288async fn archive_session(
289    conn: &libsql::Connection,
290    cutoff: &str,
291    archived_at: &str,
292) -> Result<ArchiveReport> {
293    for ddl in COLD_SCHEMA {
294        conn.execute(ddl, ()).await?;
295    }
296
297    let tx = conn
298        .transaction_with_behavior(TransactionBehavior::Immediate)
299        .await?;
300
301    // --- archive session opens: the delete guards are now satisfied ---
302    tx.execute(&format!("CREATE TABLE {ARCHIVE_SESSION_MARKER} (x)"), ())
303        .await?;
304
305    let links_archived = tx
306        .execute(
307            &format!(
308                "INSERT OR IGNORE INTO cold.links
309                     (source_id, target_id, edge_type, valid_from, recorded_at,
310                      valid_to, weight, properties)
311                 SELECT source_id, target_id, edge_type, valid_from, recorded_at,
312                        valid_to, weight, properties
313                 FROM links WHERE {LINKS_ARCHIVABLE}"
314            ),
315            libsql::named_params! {":cutoff": cutoff},
316        )
317        .await? as usize;
318
319    let links_deleted = delete_guarded(
320        &tx,
321        conn,
322        &format!("DELETE FROM links WHERE {LINKS_ARCHIVABLE}"),
323        cutoff,
324        "links",
325    )
326    .await?;
327
328    // links_current is derivative (Doctrine VI) and must equal the latest-belief
329    // projection of whatever remains in links, or audit_current() reports drift
330    // the moment an archive runs. Re-derive it rather than trying to describe
331    // the deletion's shadow: this used to be a hand-written
332    // `DELETE FROM links_current WHERE valid_to <= :cutoff`, which filters on
333    // *valid* time while LINKS_ARCHIVABLE also requires `recorded_at < :cutoff`.
334    // A row closed at the cutoff but recorded at or after it therefore survived
335    // in links and was deleted from links_current — permanent drift no later
336    // audit could explain, from a compensation that had quietly stopped being
337    // the image of the thing it compensated for. Doctrine II: two clocks, never
338    // mixed. Deriving from the definition cannot drift from the definition.
339    //
340    // **Skipped when the DELETE removed nothing (T1.1, D-080).** `links_current`
341    // is a function of `links`, so if `links` did not change its projection did
342    // not either, and there is no drift for a rebuild to repair. This was
343    // harmless while `archive()` was called once against a whole backlog,
344    // because the one session always had work. It stops being harmless the
345    // moment the caller windows: `rebuild_within` costs O(surviving `links`)
346    // regardless of how much the session archived (D-077), so without this a run
347    // of twenty windows over a quiet stretch of history pays twenty full
348    // reprojections to delete nothing — and windowing makes the archive slower
349    // in total than not windowing. `log_entries_archived` deliberately does not
350    // enter into it: archiving the transaction log cannot change `links`.
351    if links_deleted > 0 {
352        crate::integrity::rebuild::rebuild_within(&tx, crate::integrity::rebuild::Verify::No)
353            .await?;
354    }
355
356    // Concepts, and **only now** — after the `links` delete, never before it
357    // ([D-128](../../docs/architecture/s13-decision-register.md)). A concept is
358    // archivable when nothing in hot `links` names it, so evaluating the
359    // predicate before the edges have gone cold archives strictly less than the
360    // session is entitled to. This ordering is the whole content of "concept
361    // archival is downstream of link archival".
362    let concepts_archived = archive_concepts(&tx, conn, cutoff).await?;
363
364    let log_entries_archived = tx
365        .execute(
366            &format!(
367                "INSERT OR IGNORE INTO cold.transaction_log
368                     (seq_id, table_name, entity_id, operation, payload, recorded_at)
369                 SELECT seq_id, table_name, entity_id, operation, payload, recorded_at
370                 FROM transaction_log WHERE {LOG_ARCHIVABLE}"
371            ),
372            libsql::named_params! {":cutoff": cutoff},
373        )
374        .await? as usize;
375
376    delete_guarded(
377        &tx,
378        conn,
379        &format!("DELETE FROM transaction_log WHERE {LOG_ARCHIVABLE}"),
380        cutoff,
381        "transaction_log",
382    )
383    .await?;
384
385    // Record the new horizon in the cold file so a pre-horizon reconstruct can
386    // tell "archived" from "never existed" (glossary; R14).
387    let horizon: Option<i64> = tx
388        .query("SELECT MIN(seq_id) FROM transaction_log", ())
389        .await?
390        .next()
391        .await?
392        .and_then(|row| row.get(0).ok());
393
394    tx.execute(
395        "INSERT INTO cold.archive_horizon (archived_at, cutoff, horizon) VALUES (?1, ?2, ?3)",
396        libsql::params![archived_at, cutoff, horizon],
397    )
398    .await?;
399
400    // --- archive session closes: the guards re-arm before COMMIT ---
401    tx.execute(&format!("DROP TABLE {ARCHIVE_SESSION_MARKER}"), ())
402        .await?;
403
404    tx.commit().await?;
405
406    Ok(ArchiveReport {
407        links_archived,
408        concepts_archived,
409        log_entries_archived,
410        horizon,
411    })
412}
413
414/// Move every concept [`CONCEPTS_ARCHIVABLE`] admits into `cold.concepts`, and
415/// dispose of its derived rows (C2).
416///
417/// # The partition, which is the decision this function encodes
418///
419/// **Entity data crosses; derivative data does not.** The concept row itself is
420/// moved column for column — a move that drops a column is a rewrite, and
421/// [Doctrine V] does not permit an absence the ledger cannot explain. Its
422/// `analytics_annotations` and `embeddings_*` rows are *deleted* rather than
423/// moved, because [Doctrine VII] makes both recomputable from the content that
424/// did cross. Carrying them would also be unimplementable for the vectors:
425/// `F32_BLOB` and DiskANN are libSQL-specific and the cold file is a plain
426/// database opened through `ATTACH`.
427///
428/// The disposal is not incidental to the move — it is what makes the move
429/// legal. `concepts` has four inbound foreign keys, and the two derived ones
430/// would refuse the `DELETE` outright.
431///
432/// # Why the deletes are not logged, and why that is right
433///
434/// `concepts` carries log triggers on insert and update but **not** on delete —
435/// there was no delete path to log while the guard was unconditional, and there
436/// deliberately still is not. Archival mints no transaction-time facts: the
437/// concept is in the cold file, the log entries describing it are either still
438/// hot or in `cold.transaction_log`, and nothing about what was believed, or
439/// when, has changed. A log entry here would assert that something happened to
440/// the concept at archive time, which is exactly the lie [Doctrine III] forbids.
441async fn archive_concepts(
442    tx: &libsql::Transaction,
443    conn: &libsql::Connection,
444    cutoff: &str,
445) -> Result<usize> {
446    let moved = tx
447        .execute(
448            &format!(
449                "INSERT OR IGNORE INTO cold.concepts
450                     (rowid_pk, id, title, content, embedding_model,
451                      valid_from, valid_to, recorded_at, retired)
452                 SELECT rowid_pk, id, title, content, embedding_model,
453                        valid_from, valid_to, recorded_at, retired
454                 FROM concepts WHERE {CONCEPTS_ARCHIVABLE}"
455            ),
456            libsql::named_params! {":cutoff": cutoff},
457        )
458        .await? as usize;
459
460    if moved == 0 {
461        return Ok(0);
462    }
463
464    // The derived rows, before the concept they hang off. `embeddings_*` is
465    // enumerated from the catalogue rather than from a list, because the set is
466    // whatever `register_model` has created on *this* database and a hard-coded
467    // list would silently miss a model the caller added.
468    let mut derived: Vec<String> = vec!["analytics_annotations".to_string()];
469    let mut rows = tx
470        .query(
471            "SELECT name FROM sqlite_master WHERE type = 'table' \
472             AND name LIKE 'embeddings\\_%' ESCAPE '\\'",
473            (),
474        )
475        .await?;
476    while let Some(row) = rows.next().await? {
477        derived.push(row.get::<String>(0)?);
478    }
479    drop(rows);
480
481    for table in &derived {
482        tx.execute(
483            &format!(
484                "DELETE FROM {table} WHERE concept_id IN \
485                 (SELECT id FROM cold.concepts)"
486            ),
487            (),
488        )
489        .await?;
490    }
491
492    // `trg_concepts_fts_delete` fires on this and keeps the search index
493    // correct — the capability v8 installed inert and this rung made reachable.
494    let deleted = delete_guarded(
495        tx,
496        conn,
497        &format!("DELETE FROM concepts WHERE {CONCEPTS_ARCHIVABLE}"),
498        cutoff,
499        "concepts",
500    )
501    .await? as usize;
502
503    debug_assert_eq!(
504        moved, deleted,
505        "the predicate selected a different set for the copy than for the delete"
506    );
507
508    Ok(deleted)
509}
510
511/// Outcome of one rehydration (0.9.0, C3).
512#[derive(Debug, Clone, PartialEq, Eq)]
513pub struct RehydrateReport {
514    /// Concepts moved back into the hot table.
515    pub concepts_rehydrated: usize,
516    /// Of those, how many could **not** keep their original `rowid_pk` because
517    /// something else had claimed it while they were cold, and were reassigned
518    /// with the FTS index re-pointed to match.
519    ///
520    /// Reported rather than hidden because it is the one way a rehydrated
521    /// concept differs from the row that was archived, and a caller comparing
522    /// rowids across the boundary should be able to see that it happened.
523    pub rowids_reassigned: usize,
524}
525
526/// Move concepts back from the cold file into the hot tables (§2.3, C3).
527///
528/// # Rehydration is a move back, not a write
529///
530/// It mints no transaction-time facts and is invisible to both clocks. The
531/// concept's log entries were never removed, so the ledger already says
532/// everything true about it; writing a fresh `'I'` would assert the concept was
533/// *learned* at rehydration time, and — because the fold takes the highest
534/// `seq_id` per entity — would additionally outrank any later `'U'` that retired
535/// it. See [`crate::schema::ddl::CREATE_CONCEPTS_LOG_INSERT`], which is
536/// marker-gated at v10 for exactly this reason. The whole operation therefore
537/// runs inside a declared archive session, which is what suppresses the trigger.
538///
539/// # `rowid_pk`: reinstate, or reassign and re-point the index
540///
541/// The common case has no collision — the rowid was freed by archival and
542/// nothing has claimed it since — and reinstating is the clean move-back with no
543/// side effects at all. When something *has* taken it, the fallback is a fresh
544/// `rowid_pk` plus an FTS correction: `concepts_fts` is external-content keyed
545/// on `rowid_pk` ([D-119]), so a reassignment without re-pointing leaves the
546/// index describing the wrong row, silently. Both exits are taken here rather
547/// than one being assumed, and [`RehydrateReport::rowids_reassigned`] reports
548/// which was used.
549pub async fn rehydrate(
550    conn: &libsql::Connection,
551    ids: &[&str],
552    archive_path: &Path,
553) -> Result<RehydrateReport> {
554    if ids.is_empty() {
555        return Ok(RehydrateReport {
556            concepts_rehydrated: 0,
557            rowids_reassigned: 0,
558        });
559    }
560
561    crate::temporal::replay::detach_stale_cold(conn).await;
562    conn.execute(
563        "ATTACH DATABASE ?1 AS cold",
564        libsql::params![archive_path.to_string_lossy().as_ref()],
565    )
566    .await?;
567
568    let result = rehydrate_session(conn, ids).await;
569
570    if let Err(e) = conn.execute("DETACH DATABASE cold", ()).await {
571        tracing::warn!("rehydrate: failed to DETACH cold database: {e}");
572    }
573    result
574}
575
576async fn rehydrate_session(conn: &libsql::Connection, ids: &[&str]) -> Result<RehydrateReport> {
577    let tx = conn
578        .transaction_with_behavior(TransactionBehavior::Immediate)
579        .await?;
580
581    // The session opens for the same reason the archive's does, plus one more:
582    // it is what stops `trg_concepts_log_insert` from firing (v10).
583    tx.execute(&format!("CREATE TABLE {ARCHIVE_SESSION_MARKER} (x)"), ())
584        .await?;
585
586    let mut rehydrated = 0usize;
587    let mut reassigned = 0usize;
588
589    for id in ids {
590        let Some(row) = tx
591            .query(
592                "SELECT rowid_pk, id, title, content, embedding_model, \
593                 valid_from, valid_to, recorded_at, retired \
594                 FROM cold.concepts WHERE id = ?1",
595                libsql::params![*id],
596            )
597            .await?
598            .next()
599            .await?
600        else {
601            continue;
602        };
603
604        let old_rowid: i64 = row.get(0)?;
605        let title: String = row.get(2)?;
606        let content: String = row.get(3)?;
607        let model: Option<String> = row.get(4)?;
608        let valid_from: String = row.get(5)?;
609        let valid_to: String = row.get(6)?;
610        let recorded_at: String = row.get(7)?;
611        let retired: i64 = row.get(8)?;
612
613        let taken: i64 = tx
614            .query(
615                "SELECT COUNT(*) FROM concepts WHERE rowid_pk = ?1",
616                libsql::params![old_rowid],
617            )
618            .await?
619            .next()
620            .await?
621            .expect("COUNT(*) always returns a row")
622            .get(0)?;
623
624        if taken == 0 {
625            // The clean move back: same row, same rowid, no side effects.
626            tx.execute(
627                "INSERT INTO concepts (rowid_pk, id, title, content, embedding_model, \
628                 valid_from, valid_to, recorded_at, retired) \
629                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
630                libsql::params![
631                    old_rowid,
632                    *id,
633                    title.clone(),
634                    content.clone(),
635                    model,
636                    valid_from,
637                    valid_to,
638                    recorded_at,
639                    retired
640                ],
641            )
642            .await?;
643        } else {
644            // Something claimed the rowid while this concept was cold. Take a
645            // fresh one, then correct the index: `concepts_fts` is
646            // external-content keyed on `rowid_pk`, and its insert trigger will
647            // have written an entry at the *new* rowid — what has to be undone
648            // is the stale entry still sitting at the old one, which the archive
649            // could not remove because the row it described had already gone.
650            tx.execute(
651                "INSERT INTO concepts (id, title, content, embedding_model, \
652                 valid_from, valid_to, recorded_at, retired) \
653                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
654                libsql::params![
655                    *id,
656                    title.clone(),
657                    content.clone(),
658                    model,
659                    valid_from,
660                    valid_to,
661                    recorded_at,
662                    retired
663                ],
664            )
665            .await?;
666            tx.execute(
667                "INSERT INTO concepts_fts (concepts_fts, rowid, title, content) \
668                 VALUES ('delete', ?1, ?2, ?3)",
669                libsql::params![old_rowid, title, content],
670            )
671            .await?;
672            reassigned += 1;
673        }
674
675        tx.execute(
676            "DELETE FROM cold.concepts WHERE id = ?1",
677            libsql::params![*id],
678        )
679        .await?;
680        rehydrated += 1;
681    }
682
683    tx.execute(&format!("DROP TABLE {ARCHIVE_SESSION_MARKER}"), ())
684        .await?;
685    tx.commit().await?;
686
687    Ok(RehydrateReport {
688        concepts_rehydrated: rehydrated,
689        rowids_reassigned: reassigned,
690    })
691}
692
693/// Run one of the archive's `DELETE`s, naming the table if a guard refuses it.
694///
695/// **This is what closes defect AC, and the shape of the fix is the point.**
696/// There used to be a second classifier here — `classify_archive_violation` —
697/// which was defined, delegated correctly to [`crate::error::abort_kind`], and
698/// called from nowhere, so `DbError::ArchiveViolation` was unreachable by any
699/// code path in the crate. It was recorded as defect H, marked Fixed by a commit
700/// that made the *body* delegate rather than making the function *called*, and
701/// so survived its own repair. It is deleted rather than wired up, because
702/// [`crate::error::classify`] with [`WriteOp::Delete`] already did exactly what
703/// it did: the defect was one classifier too many, not one too few.
704///
705/// A guard firing here means the marker table is absent or was dropped early —
706/// the session's invariant broken from inside. That is worth a typed error
707/// naming the table rather than a raw engine message naming a trigger.
708async fn delete_guarded(
709    tx: &libsql::Transaction,
710    conn: &libsql::Connection,
711    sql: &str,
712    cutoff: &str,
713    table: &str,
714) -> Result<u64> {
715    match tx
716        .execute(sql, libsql::named_params! {":cutoff": cutoff})
717        .await
718    {
719        Ok(n) => Ok(n),
720        Err(e) => Err(crate::error::classify(conn, e, WriteOp::Delete { table }).await),
721    }
722}