Skip to main content

ai_memory/transcripts/
mod.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! v0.7.0 / I-track substrate — compressed transcript storage.
5//!
6//! `memory_transcripts` is the storage layer for raw conversation
7//! transcripts that back the attested-cortex epic. A memory captured by
8//! the agent can be linked (via the I2 join table) back to the verbatim
9//! source it was extracted from, so re-grounding and replay (I4) become
10//! possible without keeping the entire transcript inline on every
11//! `memories` row.
12//!
13//! This module ships only the **storage primitives** for I1: write a
14//! transcript, read it back, and purge expired rows. Higher-level
15//! lifecycle (archive/prune in I3) and the replay tool (I4) build on
16//! top of `store` / `fetch` / `purge_expired`.
17//!
18//! ## Encoding
19//!
20//! Content is compressed with `zstd` at level 3, the same level the
21//! operational-log archiver in `cli::logs` uses. Level 3 is the zstd
22//! default and gives a strong ratio/CPU tradeoff for chat-shaped text
23//! (dialogue with repeated speaker tokens, system prompts, and tool
24//! calls compresses to roughly 5-10x). The encoding parameter is
25//! recorded per-row in `zstd_level` so a future migration that changes
26//! the default can still decode legacy rows.
27//!
28//! ## Sizes
29//!
30//! `compressed_size` and `original_size` are recorded at write time so
31//! `memory_stats` overlays can report compression ratios without
32//! decompressing every blob. Both values are derived from the byte
33//! length of the encoded / source content respectively.
34
35pub mod replay;
36pub mod storage;
37
38pub use storage::*;
39
40#[cfg(test)]
41mod tests {
42    use super::*;
43    use crate::db;
44    use rusqlite::{Connection, params};
45
46    fn test_db() -> Connection {
47        db::open(std::path::Path::new(":memory:")).unwrap()
48    }
49
50    /// ~5KB of plausible chat-shaped content. Heavy on repeated speaker
51    /// tokens, system-prompt boilerplate, and tool-call envelopes — the
52    /// shape that motivates the BLOB+zstd substrate in the first place.
53    fn chat_corpus() -> String {
54        let mut s = String::new();
55        let header = "[system] You are an assistant operating on the alphaonedev/ai-memory-mcp codebase. Always cite tool ids in your replies. Always cite tool ids in your replies.\n";
56        let user = "[user] What did we decide about the v0.7 attested-cortex epic last sprint? Please include the full transcript of the relevant meeting.\n";
57        let assistant = "[assistant] Per the v0.7 epic doc, the attested-cortex track adds a memory_transcripts table backed by zstd-3 compressed BLOBs. The decision was logged in the meeting transcript on 2026-04-12 at 14:33 UTC.\n";
58        let tool_call = "[tool_call name=\"memory_recall\" args={\"query\":\"v0.7 attested cortex\",\"limit\":10,\"namespace\":\"team/eng/memory\"}]\n";
59        let tool_result = "[tool_result name=\"memory_recall\" ok=true count=10 latency_ms=42]\n";
60        for _ in 0..16 {
61            s.push_str(header);
62            s.push_str(user);
63            s.push_str(assistant);
64            s.push_str(tool_call);
65            s.push_str(tool_result);
66        }
67        debug_assert!(s.len() >= 5_000, "corpus too small: {}", s.len());
68        s
69    }
70
71    #[test]
72    fn migration_is_idempotent() {
73        // Open the DB twice in succession; the CREATE TABLE IF NOT
74        // EXISTS path for memory_transcripts must not error.
75        let p = tempfile::NamedTempFile::new().unwrap();
76        let path = p.path().to_path_buf();
77        let _ = db::open(&path).unwrap();
78        let conn = db::open(&path).unwrap();
79        // Table is reachable.
80        let cnt: i64 = conn
81            .query_row("SELECT count(*) FROM memory_transcripts", [], |r| r.get(0))
82            .unwrap();
83        assert_eq!(cnt, 0);
84    }
85
86    #[test]
87    fn round_trip_returns_original_content() {
88        let conn = test_db();
89        let body = chat_corpus();
90        let handle = store(&conn, "team/eng", &body, None).unwrap();
91        let got = fetch(&conn, &handle.id).unwrap();
92        assert_eq!(got.as_deref(), Some(body.as_str()));
93    }
94
95    #[test]
96    fn fetch_missing_id_returns_none() {
97        let conn = test_db();
98        let got = fetch(&conn, "not-a-real-uuid").unwrap();
99        assert!(got.is_none());
100    }
101
102    #[test]
103    fn compression_ratio_at_least_5x_on_chat_corpus() {
104        let conn = test_db();
105        let body = chat_corpus();
106        let handle = store(&conn, "team/eng", &body, None).unwrap();
107        let ratio = handle.original_size as f64 / handle.compressed_size as f64;
108        assert!(
109            ratio >= 5.0,
110            "expected >=5x zstd-3 ratio on chat-shaped text, got {ratio:.2}x \
111             (orig={} compressed={})",
112            handle.original_size,
113            handle.compressed_size,
114        );
115        // Sanity: the recorded sizes match the raw-byte facts.
116        assert_eq!(handle.original_size, body.len() as i64);
117    }
118
119    #[test]
120    fn namespace_created_index_exists() {
121        let conn = test_db();
122        let mut stmt = conn
123            .prepare("PRAGMA index_list('memory_transcripts')")
124            .unwrap();
125        let names: Vec<String> = stmt
126            .query_map([], |r| r.get::<_, String>(1))
127            .unwrap()
128            .map(std::result::Result::unwrap)
129            .collect();
130        assert!(
131            names
132                .iter()
133                .any(|n| n == "idx_memory_transcripts_namespace_created"),
134            "expected idx_memory_transcripts_namespace_created in {names:?}"
135        );
136    }
137
138    /// v0.7.0 I2 — insert a stub `memories` row so the join-table FK
139    /// can be satisfied without dragging in the full `store::create`
140    /// pipeline (capabilities, governance, embeddings...). The minimal
141    /// column set mirrors the SCHEMA defaults at the top of `db.rs`.
142    fn insert_test_memory(conn: &Connection, id: &str) {
143        let now = chrono::Utc::now().to_rfc3339();
144        // v0.7.0 fix campaign R1-M2 — the substrate's CHECK trigger now
145        // refuses tier values outside {short, mid, long}. The legacy
146        // fixture wrote `'short_term'` (a label that pre-dates the
147        // closed-set enum) and was silently accepted; the trigger now
148        // catches it. Use the canonical literal that
149        // `models::Tier::Short.as_str()` returns.
150        conn.execute(
151            "INSERT INTO memories (
152                id, tier, namespace, title, content, created_at, updated_at
153             ) VALUES (?1, 'short', 'team/eng', ?2, 'body', ?3, ?3)",
154            params![id, format!("title-{id}"), now],
155        )
156        .unwrap();
157    }
158
159    /// v0.7.0 I2 — round-trip: `link_transcript` writes an edge,
160    /// `transcripts_for_memory` reads it back with span fidelity.
161    #[test]
162    fn i2_link_then_transcripts_for_memory_round_trip() {
163        let conn = test_db();
164        insert_test_memory(&conn, "mem-1");
165        let t = store(&conn, "team/eng", "abcdefghij", None).unwrap();
166
167        link_transcript(&conn, "mem-1", &t.id, Some(2), Some(7)).unwrap();
168
169        let got = transcripts_for_memory(&conn, "mem-1").unwrap();
170        assert_eq!(
171            got,
172            vec![TranscriptLink {
173                memory_id: "mem-1".into(),
174                transcript_id: t.id.clone(),
175                span_start: Some(2),
176                span_end: Some(7),
177            }],
178        );
179    }
180
181    /// v0.7.0 I2 — fan-in: a single transcript referenced by multiple
182    /// memories. `memories_for_transcript` returns every link, ordered
183    /// by `memory_id` for deterministic downstream consumption.
184    #[test]
185    fn i2_memories_for_transcript_returns_all_linked_memories() {
186        let conn = test_db();
187        insert_test_memory(&conn, "mem-a");
188        insert_test_memory(&conn, "mem-b");
189        insert_test_memory(&conn, "mem-c");
190        let t = store(&conn, "team/eng", "shared transcript body", None).unwrap();
191
192        link_transcript(&conn, "mem-a", &t.id, None, None).unwrap();
193        link_transcript(&conn, "mem-b", &t.id, Some(0), Some(10)).unwrap();
194        link_transcript(&conn, "mem-c", &t.id, Some(11), Some(22)).unwrap();
195
196        let got = memories_for_transcript(&conn, &t.id).unwrap();
197        let ids: Vec<&str> = got.iter().map(|l| l.memory_id.as_str()).collect();
198        assert_eq!(ids, vec!["mem-a", "mem-b", "mem-c"]);
199    }
200
201    /// v0.7.0 I2 — `NULL` spans (whole-transcript provenance) survive
202    /// the round-trip cleanly. Guards against a future refactor that
203    /// might silently coerce them to 0.
204    #[test]
205    fn i2_null_spans_round_trip_as_none() {
206        let conn = test_db();
207        insert_test_memory(&conn, "mem-null");
208        let t = store(&conn, "team/eng", "body", None).unwrap();
209
210        link_transcript(&conn, "mem-null", &t.id, None, None).unwrap();
211
212        let got = transcripts_for_memory(&conn, "mem-null").unwrap();
213        assert_eq!(got.len(), 1);
214        assert_eq!(got[0].span_start, None);
215        assert_eq!(got[0].span_end, None);
216    }
217
218    /// v0.7.0 I2 — deleting a memory must cascade to its provenance
219    /// edges. Without this, the join table would accumulate dangling
220    /// rows that point at vanished memories and confuse I4's replay.
221    #[test]
222    fn i2_delete_memory_cascades_to_links() {
223        let conn = test_db();
224        insert_test_memory(&conn, "mem-doomed");
225        insert_test_memory(&conn, "mem-survives");
226        let t = store(&conn, "team/eng", "body", None).unwrap();
227
228        link_transcript(&conn, "mem-doomed", &t.id, None, None).unwrap();
229        link_transcript(&conn, "mem-survives", &t.id, None, None).unwrap();
230        assert_eq!(memories_for_transcript(&conn, &t.id).unwrap().len(), 2);
231
232        conn.execute("DELETE FROM memories WHERE id = ?1", params!["mem-doomed"])
233            .unwrap();
234
235        let remaining = memories_for_transcript(&conn, &t.id).unwrap();
236        assert_eq!(remaining.len(), 1);
237        assert_eq!(remaining[0].memory_id, "mem-survives");
238    }
239
240    /// v0.7.0 I2 — deleting a transcript must cascade to its links.
241    /// I3's archive->prune lifecycle relies on this so callers of
242    /// `transcripts_for_memory` never see an id that can no longer be
243    /// fetched.
244    #[test]
245    fn i2_delete_transcript_cascades_to_links() {
246        let conn = test_db();
247        insert_test_memory(&conn, "mem-x");
248        let t = store(&conn, "team/eng", "ephemeral", None).unwrap();
249
250        link_transcript(&conn, "mem-x", &t.id, None, None).unwrap();
251        assert_eq!(transcripts_for_memory(&conn, "mem-x").unwrap().len(), 1);
252
253        conn.execute(
254            "DELETE FROM memory_transcripts WHERE id = ?1",
255            params![t.id],
256        )
257        .unwrap();
258
259        assert!(transcripts_for_memory(&conn, "mem-x").unwrap().is_empty());
260    }
261
262    /// v0.7.0 I2 — the migration is idempotent: opening the same DB
263    /// path twice in succession must not error on the join table's
264    /// CREATE TABLE / CREATE INDEX statements.
265    #[test]
266    fn i2_migration_is_idempotent() {
267        let p = tempfile::NamedTempFile::new().unwrap();
268        let path = p.path().to_path_buf();
269        let _ = db::open(&path).unwrap();
270        let conn = db::open(&path).unwrap();
271        let cnt: i64 = conn
272            .query_row("SELECT count(*) FROM memory_transcript_links", [], |r| {
273                r.get(0)
274            })
275            .unwrap();
276        assert_eq!(cnt, 0);
277    }
278
279    /// v0.7.0 I2 — both supporting indexes are present. Guards against
280    /// a future migration that drops the SQL file's `CREATE INDEX` and
281    /// silently regresses I4's replay path to a table scan.
282    #[test]
283    fn i2_join_table_indexes_exist() {
284        let conn = test_db();
285        let mut stmt = conn
286            .prepare("PRAGMA index_list('memory_transcript_links')")
287            .unwrap();
288        let names: Vec<String> = stmt
289            .query_map([], |r| r.get::<_, String>(1))
290            .unwrap()
291            .map(std::result::Result::unwrap)
292            .collect();
293        for expected in ["idx_mtl_transcript", "idx_mtl_memory"] {
294            assert!(
295                names.iter().any(|n| n == expected),
296                "expected {expected} in {names:?}"
297            );
298        }
299    }
300
301    #[test]
302    fn purge_expired_only_removes_past_due_rows() {
303        let conn = test_db();
304        // One expired row.
305        let expired = store(
306            &conn,
307            "team/eng",
308            "expired body",
309            Some(chrono::Duration::seconds(-crate::SECS_PER_HOUR)),
310        )
311        .unwrap();
312        // One live row with future TTL.
313        let live = store(
314            &conn,
315            "team/eng",
316            "live body",
317            Some(chrono::Duration::seconds(crate::SECS_PER_HOUR)),
318        )
319        .unwrap();
320        // One row with no TTL — must NOT be purged.
321        let immortal = store(&conn, "team/eng", "immortal body", None).unwrap();
322
323        let n = purge_expired(&conn).unwrap();
324        assert_eq!(n, 1, "exactly the past-due row should be deleted");
325
326        assert!(fetch(&conn, &expired.id).unwrap().is_none());
327        assert_eq!(
328            fetch(&conn, &live.id).unwrap().as_deref(),
329            Some("live body"),
330        );
331        assert_eq!(
332            fetch(&conn, &immortal.id).unwrap().as_deref(),
333            Some("immortal body"),
334        );
335    }
336
337    // ---------------------------------------------------------------
338    // I3 — per-namespace TTL with archive→prune lifecycle.
339    // ---------------------------------------------------------------
340
341    use crate::config::{TranscriptNamespaceConfig, TranscriptsConfig};
342    use std::collections::HashMap;
343
344    /// Backdate a transcript's `created_at` to `secs` ago. The
345    /// `store()` API takes a TTL relative to `now()`, so the only
346    /// way to fake an aged row in a test is to UPDATE the column
347    /// directly. Returns the rewritten timestamp for assertions.
348    fn backdate_created(conn: &Connection, id: &str, secs: i64) -> String {
349        let stamp = (chrono::Utc::now() - chrono::Duration::seconds(secs)).to_rfc3339();
350        conn.execute(
351            "UPDATE memory_transcripts SET created_at = ?1 WHERE id = ?2",
352            params![stamp, id],
353        )
354        .unwrap();
355        stamp
356    }
357
358    /// Backdate `archived_at` directly so the prune phase sees a row
359    /// that was archived `secs` ago.
360    fn backdate_archived(conn: &Connection, id: &str, secs: i64) -> String {
361        let stamp = (chrono::Utc::now() - chrono::Duration::seconds(secs)).to_rfc3339();
362        conn.execute(
363            "UPDATE memory_transcripts SET archived_at = ?1 WHERE id = ?2",
364            params![stamp, id],
365        )
366        .unwrap();
367        stamp
368    }
369
370    /// Build a [`TranscriptsConfig`] with a 1-hour global TTL and a
371    /// 1-hour grace window — small enough that test-side backdating
372    /// can cleanly straddle both thresholds without touching the
373    /// system clock.
374    fn fast_cfg() -> TranscriptsConfig {
375        TranscriptsConfig {
376            default_ttl_secs: Some(crate::SECS_PER_HOUR),
377            archive_grace_secs: Some(crate::SECS_PER_HOUR),
378            namespaces: None,
379            max_decompressed_bytes: None,
380        }
381    }
382
383    /// Read the current `archived_at` for `id`. `None` means the row
384    /// is still live (NULL column). Asserts the row exists.
385    fn archived_at(conn: &Connection, id: &str) -> Option<String> {
386        conn.query_row(
387            "SELECT archived_at FROM memory_transcripts WHERE id = ?1",
388            params![id],
389            |r| r.get::<_, Option<String>>(0),
390        )
391        .unwrap()
392    }
393
394    /// Read the `memory_transcripts` row count for `id` — 0 means the
395    /// prune phase fired.
396    fn row_exists(conn: &Connection, id: &str) -> bool {
397        let n: i64 = conn
398            .query_row(
399                "SELECT COUNT(*) FROM memory_transcripts WHERE id = ?1",
400                params![id],
401                |r| r.get(0),
402            )
403            .unwrap();
404        n > 0
405    }
406
407    /// I3 — a transcript with no linked memories AND age beyond the
408    /// resolved TTL must be archived in phase 1.
409    #[test]
410    fn i3_unlinked_aged_transcript_is_archived() {
411        let conn = test_db();
412        let cfg = fast_cfg();
413
414        let t = store(&conn, "team/eng", "old body", None).unwrap();
415        backdate_created(&conn, &t.id, 7200); // 2 h old, TTL = 1 h
416
417        let report = sweep_transcript_lifecycle(&conn, &cfg).unwrap();
418        assert_eq!(report.archived, 1, "phase 1 must archive the aged row");
419        assert_eq!(
420            report.pruned, 0,
421            "phase 2 must not fire on a freshly archived row"
422        );
423        assert!(
424            archived_at(&conn, &t.id).is_some(),
425            "archived_at must be set after phase 1",
426        );
427    }
428
429    /// I3 — phase 2 deletes an archived row whose grace window has
430    /// passed. Cascades to the I2 join table for free.
431    #[test]
432    fn i3_archived_past_grace_is_pruned_with_cascade() {
433        let conn = test_db();
434        let cfg = fast_cfg();
435
436        insert_test_memory(&conn, "mem-cascade");
437        let t = store(&conn, "team/eng", "to be pruned", None).unwrap();
438        link_transcript(&conn, "mem-cascade", &t.id, None, None).unwrap();
439
440        // Mark it archived 2 h ago (grace = 1 h).
441        backdate_archived(&conn, &t.id, 7200);
442
443        let report = sweep_transcript_lifecycle(&conn, &cfg).unwrap();
444        assert_eq!(report.pruned, 1, "phase 2 must hard-DELETE the row");
445        assert!(!row_exists(&conn, &t.id), "transcript row gone");
446
447        // I2 cascade fires: the link row goes too.
448        assert!(
449            transcripts_for_memory(&conn, "mem-cascade")
450                .unwrap()
451                .is_empty(),
452            "ON DELETE CASCADE must clear the I2 join row",
453        );
454    }
455
456    /// I3 — a transcript with a still-live linked memory (NULL
457    /// `expires_at` or future `expires_at`) is NOT archived even when
458    /// the transcript itself is older than its TTL. The memory's
459    /// liveness pins the transcript.
460    #[test]
461    fn i3_live_linked_memory_keeps_transcript_alive() {
462        let conn = test_db();
463        let cfg = fast_cfg();
464
465        // Memory with no expiry — counts as "live forever".
466        insert_test_memory(&conn, "mem-immortal");
467
468        let t = store(&conn, "team/eng", "still wanted", None).unwrap();
469        link_transcript(&conn, "mem-immortal", &t.id, None, None).unwrap();
470        backdate_created(&conn, &t.id, 7200); // way past TTL
471
472        let report = sweep_transcript_lifecycle(&conn, &cfg).unwrap();
473        assert_eq!(report.archived, 0);
474        assert!(
475            archived_at(&conn, &t.id).is_none(),
476            "live linked memory must keep archived_at NULL",
477        );
478    }
479
480    /// I3 — a transcript whose every linked memory has an `expires_at`
481    /// in the past IS archived. Mirror image of the test above —
482    /// guards against the SQL accidentally treating an empty-future
483    /// memory as live.
484    #[test]
485    fn i3_all_linked_memories_expired_then_transcript_is_archived() {
486        let conn = test_db();
487        let cfg = fast_cfg();
488
489        // Insert a memory then force its expires_at into the past.
490        insert_test_memory(&conn, "mem-expired");
491        let past = (chrono::Utc::now() - chrono::Duration::seconds(60)).to_rfc3339();
492        conn.execute(
493            "UPDATE memories SET expires_at = ?1 WHERE id = 'mem-expired'",
494            params![past],
495        )
496        .unwrap();
497
498        let t = store(&conn, "team/eng", "no longer needed", None).unwrap();
499        link_transcript(&conn, "mem-expired", &t.id, None, None).unwrap();
500        backdate_created(&conn, &t.id, 7200);
501
502        let report = sweep_transcript_lifecycle(&conn, &cfg).unwrap();
503        assert_eq!(report.archived, 1);
504        assert!(archived_at(&conn, &t.id).is_some());
505    }
506
507    /// I3 — a per-namespace override (longer TTL) beats the global
508    /// default. The global cfg would archive the row; the namespace
509    /// override keeps it live.
510    #[test]
511    fn i3_per_namespace_override_extends_ttl_beyond_global_default() {
512        let conn = test_db();
513
514        // Global TTL = 1h, but the team/audit namespace gets 1 day.
515        let mut ns_table = HashMap::new();
516        ns_table.insert(
517            "team/audit".to_string(),
518            TranscriptNamespaceConfig {
519                default_ttl_secs: Some(crate::SECS_PER_DAY),
520                archive_grace_secs: None,
521                auto_extract: None,
522            },
523        );
524        let cfg = TranscriptsConfig {
525            default_ttl_secs: Some(crate::SECS_PER_HOUR),
526            archive_grace_secs: Some(crate::SECS_PER_HOUR),
527            namespaces: Some(ns_table),
528            max_decompressed_bytes: None,
529        };
530
531        // Two rows, two namespaces, both 2 h old.
532        let eng = store(&conn, "team/eng", "eng body", None).unwrap();
533        backdate_created(&conn, &eng.id, 7200);
534        let audit = store(&conn, "team/audit", "audit body", None).unwrap();
535        backdate_created(&conn, &audit.id, 7200);
536
537        let report = sweep_transcript_lifecycle(&conn, &cfg).unwrap();
538        assert_eq!(report.archived, 1, "only team/eng is past the resolved TTL");
539        assert!(archived_at(&conn, &eng.id).is_some());
540        assert!(
541            archived_at(&conn, &audit.id).is_none(),
542            "team/audit override (1d) keeps the audit row live",
543        );
544    }
545
546    /// I3 — a `prefix/*` per-namespace pattern matches every child
547    /// namespace (longest-prefix wins on multiple matches). Guards
548    /// the resolver's prefix-walk path.
549    #[test]
550    fn i3_prefix_pattern_override_matches_child_namespaces() {
551        let conn = test_db();
552
553        let mut ns_table = HashMap::new();
554        ns_table.insert(
555            "ephemeral/*".to_string(),
556            TranscriptNamespaceConfig {
557                default_ttl_secs: Some(60),
558                archive_grace_secs: Some(60),
559                auto_extract: None,
560            },
561        );
562        let cfg = TranscriptsConfig {
563            default_ttl_secs: Some(crate::SECS_PER_DAY * 30), // 30 days global
564            archive_grace_secs: Some(crate::SECS_PER_WEEK),
565            namespaces: Some(ns_table),
566            max_decompressed_bytes: None,
567        };
568
569        // 5-min-old row in ephemeral/scratch — past the 60s pattern TTL,
570        // well under the 30-day global default.
571        let t = store(&conn, "ephemeral/scratch", "scratch", None).unwrap();
572        backdate_created(&conn, &t.id, 300);
573
574        let report = sweep_transcript_lifecycle(&conn, &cfg).unwrap();
575        assert_eq!(
576            report.archived, 1,
577            "prefix pattern must apply to ephemeral/scratch"
578        );
579    }
580
581    /// I3 — a freshly-archived row (within the grace window) is NOT
582    /// pruned. Together with `i3_archived_past_grace_is_pruned`, the
583    /// pair brackets the prune-phase boundary condition.
584    #[test]
585    fn i3_archived_within_grace_is_not_pruned() {
586        let conn = test_db();
587        let cfg = fast_cfg();
588
589        let t = store(&conn, "team/eng", "still in grace", None).unwrap();
590        // Archived 30 minutes ago; grace = 1 h.
591        backdate_archived(&conn, &t.id, 1800);
592
593        let report = sweep_transcript_lifecycle(&conn, &cfg).unwrap();
594        assert_eq!(report.pruned, 0);
595        assert!(row_exists(&conn, &t.id));
596    }
597
598    /// I3 — end-to-end: one sweep archives, a second sweep (after the
599    /// grace window) prunes. Documents the two-tick lifecycle the
600    /// daemon sweeper relies on.
601    #[test]
602    fn i3_archive_then_prune_in_two_sweeps() {
603        let conn = test_db();
604        let cfg = fast_cfg();
605
606        let t = store(&conn, "team/eng", "lifecycle e2e", None).unwrap();
607        backdate_created(&conn, &t.id, 7200);
608
609        // Tick 1: archives.
610        let r1 = sweep_transcript_lifecycle(&conn, &cfg).unwrap();
611        assert_eq!(r1.archived, 1);
612        assert_eq!(r1.pruned, 0);
613
614        // Backdate the archive stamp past the grace window.
615        backdate_archived(&conn, &t.id, 7200);
616
617        // Tick 2: prunes.
618        let r2 = sweep_transcript_lifecycle(&conn, &cfg).unwrap();
619        assert_eq!(r2.archived, 0);
620        assert_eq!(r2.pruned, 1);
621        assert!(!row_exists(&conn, &t.id));
622    }
623
624    /// I3 — already-archived rows are not re-archived in subsequent
625    /// sweeps. Phase-1 SQL filters on `archived_at IS NULL`; this
626    /// test pins that filter so a future refactor can't silently
627    /// re-stamp archived rows.
628    #[test]
629    fn i3_idempotent_phase1_does_not_restamp_archived_rows() {
630        let conn = test_db();
631        let cfg = fast_cfg();
632
633        let t = store(&conn, "team/eng", "already archived", None).unwrap();
634        backdate_created(&conn, &t.id, 7200);
635        let _ = sweep_transcript_lifecycle(&conn, &cfg).unwrap();
636        let first_stamp = archived_at(&conn, &t.id).unwrap();
637
638        // Sleep far enough to perceive a clock tick, then re-sweep.
639        std::thread::sleep(std::time::Duration::from_millis(20));
640        let r2 = sweep_transcript_lifecycle(&conn, &cfg).unwrap();
641        assert_eq!(r2.archived, 0, "no row should be re-archived");
642
643        let second_stamp = archived_at(&conn, &t.id).unwrap();
644        assert_eq!(
645            first_stamp, second_stamp,
646            "archived_at must be preserved across sweeps",
647        );
648    }
649}