Skip to main content

remem/truth/
inventory.rs

1//! Aggregate-only, snapshot-bound inventory over the G2 visibility projection.
2
3use std::collections::BTreeMap;
4
5use anyhow::{Context, Result};
6use rusqlite::Connection;
7use serde::Serialize;
8use sha2::{Digest, Sha256};
9
10use super::visibility::classify_memory;
11
12pub const MEMORY_VISIBILITY_INVENTORY_SCHEMA_VERSION: u32 = 1;
13
14#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
15pub struct MemoryVisibilityInventory {
16    pub schema_version: u32,
17    pub runtime_version: String,
18    pub database_schema_version: i64,
19    pub sqlite_user_version: i64,
20    pub as_of_epoch: i64,
21    pub snapshot_memory_count: u64,
22    pub snapshot_max_memory_id: Option<i64>,
23    pub snapshot_max_updated_at_epoch: Option<i64>,
24    pub classification_counts: BTreeMap<String, u64>,
25    pub reason_counts: BTreeMap<String, u64>,
26    pub inventory_sha256: String,
27}
28
29pub fn build_memory_visibility_inventory(
30    conn: &Connection,
31    as_of_epoch: i64,
32) -> Result<MemoryVisibilityInventory> {
33    let snapshot = conn.unchecked_transaction()?;
34    let report = build_memory_visibility_inventory_in_snapshot(&snapshot, as_of_epoch)?;
35    snapshot.commit()?;
36    Ok(report)
37}
38
39fn build_memory_visibility_inventory_in_snapshot(
40    conn: &Connection,
41    as_of_epoch: i64,
42) -> Result<MemoryVisibilityInventory> {
43    let sqlite_user_version = conn.query_row("PRAGMA user_version", [], |row| row.get(0))?;
44    let database_schema_version = logical_schema_version(conn, sqlite_user_version)?;
45    let (snapshot_memory_count, snapshot_max_memory_id, snapshot_max_updated_at_epoch) = conn
46        .query_row(
47            "SELECT COUNT(*), MAX(id), MAX(updated_at_epoch) FROM memories",
48            [],
49            |row| Ok((row.get::<_, u64>(0)?, row.get(1)?, row.get(2)?)),
50        )
51        .context("bind memory visibility inventory snapshot")?;
52    let mut statement = conn.prepare("SELECT id FROM memories ORDER BY id")?;
53    let ids = statement
54        .query_map([], |row| row.get::<_, i64>(0))?
55        .collect::<std::result::Result<Vec<_>, _>>()?;
56    let mut classification_counts = BTreeMap::new();
57    let mut reason_counts = BTreeMap::new();
58    for id in ids {
59        let visibility = classify_memory(conn, id, as_of_epoch)?;
60        *classification_counts
61            .entry(visibility.classification.as_str().to_string())
62            .or_insert(0) += 1;
63        *reason_counts
64            .entry(visibility.reason.as_str().to_string())
65            .or_insert(0) += 1;
66    }
67    let mut report = MemoryVisibilityInventory {
68        schema_version: MEMORY_VISIBILITY_INVENTORY_SCHEMA_VERSION,
69        runtime_version: env!("CARGO_PKG_VERSION").to_string(),
70        database_schema_version,
71        sqlite_user_version,
72        as_of_epoch,
73        snapshot_memory_count,
74        snapshot_max_memory_id,
75        snapshot_max_updated_at_epoch,
76        classification_counts,
77        reason_counts,
78        inventory_sha256: String::new(),
79    };
80    report.inventory_sha256 = format!("{:x}", Sha256::digest(serde_json::to_vec(&report)?));
81    Ok(report)
82}
83
84fn logical_schema_version(conn: &Connection, sqlite_user_version: i64) -> Result<i64> {
85    let has_ledger: bool = conn.query_row(
86        "SELECT EXISTS(SELECT 1 FROM sqlite_schema
87                       WHERE type='table' AND name='_schema_migrations')",
88        [],
89        |row| row.get(0),
90    )?;
91    if !has_ledger {
92        return Ok(sqlite_user_version);
93    }
94    conn.query_row(
95        "SELECT COALESCE(MAX(version), 0) FROM _schema_migrations",
96        [],
97        |row| row.get(0),
98    )
99    .context("read logical schema version")
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105    use std::time::{SystemTime, UNIX_EPOCH};
106
107    #[test]
108    fn inventory_is_deterministic_content_free_and_snapshot_bound() -> Result<()> {
109        let conn = Connection::open_in_memory()?;
110        crate::memory::tests_helper::setup_memory_schema(&conn);
111        conn.execute(
112            "INSERT INTO memories
113             (id, project, title, content, memory_type, created_at_epoch,
114              updated_at_epoch, status, source_trust_class)
115             VALUES (1, '/repo', 'SECRET_TITLE', 'SECRET_BODY', 'bugfix',
116                     10, 11, 'active', 'local_tool_output')",
117            [],
118        )?;
119        conn.execute(
120            "INSERT INTO memories
121             (id, project, title, content, memory_type, created_at_epoch,
122              updated_at_epoch, status, source_trust_class)
123             VALUES (2, '/repo', 'manual', 'safe', 'bugfix',
124                     12, 13, 'active', 'user_prompt')",
125            [],
126        )?;
127        let first = build_memory_visibility_inventory(&conn, 100)?;
128        let second = build_memory_visibility_inventory(&conn, 100)?;
129        assert_eq!(first, second);
130        assert_eq!(first.snapshot_memory_count, 2);
131        assert_eq!(first.classification_counts["legacy_unverified"], 1);
132        assert_eq!(first.classification_counts["current"], 1);
133        let json = serde_json::to_string(&first)?;
134        assert!(!json.contains("SECRET_TITLE"));
135        assert!(!json.contains("SECRET_BODY"));
136
137        conn.execute("UPDATE memories SET updated_at_epoch = 14 WHERE id = 2", [])?;
138        let changed = build_memory_visibility_inventory(&conn, 100)?;
139        assert_ne!(first.inventory_sha256, changed.inventory_sha256);
140        Ok(())
141    }
142
143    #[test]
144    fn inventory_snapshot_stays_consistent_after_wal_writer_commit() -> Result<()> {
145        let path = std::env::temp_dir().join(format!(
146            "remem-g2-inventory-{}-{}.db",
147            std::process::id(),
148            SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos()
149        ));
150        let result = (|| -> Result<()> {
151            let reader = Connection::open(&path)?;
152            reader.execute_batch("PRAGMA journal_mode = WAL;")?;
153            crate::memory::tests_helper::setup_memory_schema(&reader);
154            reader.execute(
155                "INSERT INTO memories
156                 (id, project, title, content, memory_type, created_at_epoch,
157                  updated_at_epoch, status, source_trust_class)
158                 VALUES (1, '/repo', 'before', 'body', 'bugfix', 10, 10,
159                         'active', 'local_tool_output')",
160                [],
161            )?;
162
163            let writer = Connection::open(&path)?;
164            writer.execute_batch("PRAGMA journal_mode = WAL;")?;
165            let snapshot = reader.unchecked_transaction()?;
166            let _: i64 =
167                snapshot.query_row("SELECT COUNT(*) FROM memories", [], |row| row.get(0))?;
168            writer.execute(
169                "INSERT INTO memories
170                 (id, project, title, content, memory_type, created_at_epoch,
171                  updated_at_epoch, status, source_trust_class)
172                 VALUES (2, '/repo', 'after', 'body', 'bugfix', 20, 20,
173                         'active', 'local_tool_output')",
174                [],
175            )?;
176
177            let report = build_memory_visibility_inventory_in_snapshot(&snapshot, 100)?;
178            assert_eq!(report.snapshot_memory_count, 1);
179            assert_eq!(report.snapshot_max_memory_id, Some(1));
180            assert_eq!(report.classification_counts["legacy_unverified"], 1);
181            snapshot.commit()?;
182            Ok(())
183        })();
184        let _ = std::fs::remove_file(&path);
185        result
186    }
187}