Skip to main content

chio_store_sqlite/
approval_store.rs

1//! Phase 3.5 SQLite-backed HITL approval store.
2//!
3//! Pending requests survive kernel restart because every `store_pending`
4//! call persists into a WAL-journaled SQLite database. Duplicate ids are
5//! idempotent only when the serialized payload matches exactly; mismatched
6//! retries are rejected so in-flight HITL state cannot be silently
7//! overwritten. Resolved approvals and consumed tokens live in the same
8//! database so the replay registry survives alongside the pending log.
9//!
10//! The store is synchronous; it uses a small r2d2 pool to keep the
11//! hot-path query against a cheap connection pool rather than opening a
12//! new file handle per call.
13
14use std::fs;
15use std::path::Path;
16
17use chio_kernel::{
18    ApprovalDecision, ApprovalFilter, ApprovalOutcome, ApprovalRequest, ApprovalStore,
19    ApprovalStoreError, ResolvedApproval,
20};
21use r2d2::Pool;
22use r2d2_sqlite::SqliteConnectionManager;
23use rusqlite::{params, OptionalExtension};
24
25/// SQLite-backed `ApprovalStore`.
26///
27/// Schema is created on `open`. Migrations are additive and idempotent
28/// via `CREATE TABLE IF NOT EXISTS`.
29pub struct SqliteApprovalStore {
30    pool: Pool<SqliteConnectionManager>,
31}
32
33impl SqliteApprovalStore {
34    /// Open the store at the given path. Creates the parent directory
35    /// if needed.
36    pub fn open(path: impl AsRef<Path>) -> Result<Self, ApprovalStoreError> {
37        let path = path.as_ref();
38        if let Some(parent) = path.parent() {
39            if !parent.as_os_str().is_empty() {
40                fs::create_dir_all(parent)
41                    .map_err(|e| ApprovalStoreError::Backend(format!("create dir: {e}")))?;
42            }
43        }
44        let manager = SqliteConnectionManager::file(path);
45        let pool = Pool::builder()
46            .max_size(8)
47            .build(manager)
48            .map_err(|e| ApprovalStoreError::Backend(format!("pool build: {e}")))?;
49        let store = Self { pool };
50        store.run_migrations()?;
51        Ok(store)
52    }
53
54    /// Open an in-memory store for tests.
55    pub fn open_in_memory() -> Result<Self, ApprovalStoreError> {
56        let manager = SqliteConnectionManager::memory();
57        let pool = Pool::builder()
58            .max_size(1)
59            .build(manager)
60            .map_err(|e| ApprovalStoreError::Backend(format!("pool build: {e}")))?;
61        let store = Self { pool };
62        store.run_migrations()?;
63        Ok(store)
64    }
65
66    fn run_migrations(&self) -> Result<(), ApprovalStoreError> {
67        let conn = self
68            .pool
69            .get()
70            .map_err(|e| ApprovalStoreError::Backend(format!("pool get: {e}")))?;
71        conn.execute_batch(
72            r#"
73            PRAGMA journal_mode = WAL;
74            PRAGMA synchronous = FULL;
75            PRAGMA busy_timeout = 5000;
76            PRAGMA foreign_keys = ON;
77
78            CREATE TABLE IF NOT EXISTS chio_hitl_pending (
79                approval_id TEXT PRIMARY KEY,
80                policy_id TEXT NOT NULL,
81                subject_id TEXT NOT NULL,
82                tool_server TEXT NOT NULL,
83                tool_name TEXT NOT NULL,
84                parameter_hash TEXT NOT NULL,
85                expires_at INTEGER NOT NULL,
86                created_at INTEGER NOT NULL,
87                payload TEXT NOT NULL
88            );
89            CREATE INDEX IF NOT EXISTS idx_chio_hitl_pending_subject
90                ON chio_hitl_pending(subject_id);
91            CREATE INDEX IF NOT EXISTS idx_chio_hitl_pending_expires
92                ON chio_hitl_pending(expires_at);
93
94            CREATE TABLE IF NOT EXISTS chio_hitl_resolved (
95                approval_id TEXT PRIMARY KEY,
96                policy_id TEXT NOT NULL,
97                subject_id TEXT NOT NULL,
98                outcome TEXT NOT NULL,
99                resolved_at INTEGER NOT NULL,
100                approver_hex TEXT NOT NULL,
101                token_id TEXT NOT NULL
102            );
103            CREATE INDEX IF NOT EXISTS idx_chio_hitl_resolved_counts
104                ON chio_hitl_resolved(subject_id, policy_id, outcome);
105
106            CREATE TABLE IF NOT EXISTS chio_hitl_consumed_tokens (
107                token_id TEXT NOT NULL,
108                parameter_hash TEXT NOT NULL,
109                consumed_at INTEGER NOT NULL,
110                PRIMARY KEY (token_id, parameter_hash)
111            );
112            "#,
113        )
114        .map_err(|e| ApprovalStoreError::Backend(format!("migration: {e}")))?;
115        Ok(())
116    }
117}
118
119fn serialize_payload(request: &ApprovalRequest) -> Result<String, ApprovalStoreError> {
120    serde_json::to_string(request).map_err(|e| ApprovalStoreError::Serialization(e.to_string()))
121}
122
123fn deserialize_payload(raw: &str) -> Result<ApprovalRequest, ApprovalStoreError> {
124    serde_json::from_str(raw).map_err(|e| ApprovalStoreError::Serialization(e.to_string()))
125}
126
127impl ApprovalStore for SqliteApprovalStore {
128    fn store_pending(&self, request: &ApprovalRequest) -> Result<(), ApprovalStoreError> {
129        let payload = serialize_payload(request)?;
130        let conn = self
131            .pool
132            .get()
133            .map_err(|e| ApprovalStoreError::Backend(format!("pool get: {e}")))?;
134        let inserted = conn
135            .execute(
136                r#"
137            INSERT INTO chio_hitl_pending (
138                approval_id, policy_id, subject_id, tool_server, tool_name,
139                parameter_hash, expires_at, created_at, payload
140            ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
141            ON CONFLICT(approval_id) DO NOTHING
142            "#,
143                params![
144                    request.approval_id,
145                    request.policy_id,
146                    request.subject_id,
147                    request.tool_server,
148                    request.tool_name,
149                    request.parameter_hash,
150                    request.expires_at as i64,
151                    request.created_at as i64,
152                    payload,
153                ],
154            )
155            .map_err(|e| ApprovalStoreError::Backend(format!("insert pending: {e}")))?;
156        if inserted == 0 {
157            let existing: Option<String> = conn
158                .query_row(
159                    "SELECT payload FROM chio_hitl_pending WHERE approval_id = ?1",
160                    params![request.approval_id],
161                    |row| row.get::<_, String>(0),
162                )
163                .optional()
164                .map_err(|e| {
165                    ApprovalStoreError::Backend(format!("select existing pending: {e}"))
166                })?;
167            match existing {
168                Some(existing) if existing == payload => Ok(()),
169                Some(_) => Err(ApprovalStoreError::Backend(format!(
170                    "approval_id {} already exists with different payload",
171                    request.approval_id
172                ))),
173                None => Err(ApprovalStoreError::Backend(format!(
174                    "approval_id {} conflicted but existing row could not be loaded",
175                    request.approval_id
176                ))),
177            }
178        } else {
179            Ok(())
180        }
181    }
182
183    fn get_pending(&self, id: &str) -> Result<Option<ApprovalRequest>, ApprovalStoreError> {
184        let conn = self
185            .pool
186            .get()
187            .map_err(|e| ApprovalStoreError::Backend(format!("pool get: {e}")))?;
188        let row: Option<String> = conn
189            .query_row(
190                "SELECT payload FROM chio_hitl_pending WHERE approval_id = ?1",
191                params![id],
192                |row| row.get::<_, String>(0),
193            )
194            .optional()
195            .map_err(|e| ApprovalStoreError::Backend(format!("select pending: {e}")))?;
196        match row {
197            Some(raw) => Ok(Some(deserialize_payload(&raw)?)),
198            None => Ok(None),
199        }
200    }
201
202    fn list_pending(
203        &self,
204        filter: &ApprovalFilter,
205    ) -> Result<Vec<ApprovalRequest>, ApprovalStoreError> {
206        let conn = self
207            .pool
208            .get()
209            .map_err(|e| ApprovalStoreError::Backend(format!("pool get: {e}")))?;
210        let mut sql = String::from("SELECT payload FROM chio_hitl_pending WHERE 1=1");
211        if filter.subject_id.is_some() {
212            sql.push_str(" AND subject_id = :subject_id");
213        }
214        if filter.tool_server.is_some() {
215            sql.push_str(" AND tool_server = :tool_server");
216        }
217        if filter.tool_name.is_some() {
218            sql.push_str(" AND tool_name = :tool_name");
219        }
220        if filter.not_expired_at.is_some() {
221            sql.push_str(" AND expires_at > :not_expired_at");
222        }
223        sql.push_str(" ORDER BY created_at ASC");
224        if filter.limit.is_some() {
225            sql.push_str(" LIMIT :limit");
226        }
227
228        let mut stmt = conn
229            .prepare(&sql)
230            .map_err(|e| ApprovalStoreError::Backend(format!("prepare list: {e}")))?;
231
232        let mut params_vec: Vec<(&str, Box<dyn rusqlite::ToSql>)> = Vec::new();
233        if let Some(s) = &filter.subject_id {
234            params_vec.push((":subject_id", Box::new(s.clone())));
235        }
236        if let Some(s) = &filter.tool_server {
237            params_vec.push((":tool_server", Box::new(s.clone())));
238        }
239        if let Some(s) = &filter.tool_name {
240            params_vec.push((":tool_name", Box::new(s.clone())));
241        }
242        if let Some(t) = &filter.not_expired_at {
243            params_vec.push((":not_expired_at", Box::new(*t as i64)));
244        }
245        if let Some(limit) = &filter.limit {
246            params_vec.push((":limit", Box::new(*limit as i64)));
247        }
248
249        let refs: Vec<(&str, &dyn rusqlite::ToSql)> = params_vec
250            .iter()
251            .map(|(name, value)| (*name, value.as_ref()))
252            .collect();
253
254        let rows = stmt
255            .query_map(refs.as_slice(), |row| row.get::<_, String>(0))
256            .map_err(|e| ApprovalStoreError::Backend(format!("query list: {e}")))?;
257
258        let mut out = Vec::new();
259        for row in rows {
260            let raw = row.map_err(|e| ApprovalStoreError::Backend(format!("row: {e}")))?;
261            out.push(deserialize_payload(&raw)?);
262        }
263        Ok(out)
264    }
265
266    fn resolve(&self, id: &str, decision: &ApprovalDecision) -> Result<(), ApprovalStoreError> {
267        let mut conn = self
268            .pool
269            .get()
270            .map_err(|e| ApprovalStoreError::Backend(format!("pool get: {e}")))?;
271        let tx = conn
272            .transaction()
273            .map_err(|e| ApprovalStoreError::Backend(format!("begin tx: {e}")))?;
274
275        // Pull pending record inside the tx to avoid TOCTOU races.
276        let pending: Option<(String, String)> = tx
277            .query_row(
278                "SELECT policy_id, parameter_hash FROM chio_hitl_pending WHERE approval_id = ?1",
279                params![id],
280                |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
281            )
282            .optional()
283            .map_err(|e| ApprovalStoreError::Backend(format!("select: {e}")))?;
284        let (policy_id, parameter_hash) = match pending {
285            Some(p) => p,
286            None => return Err(ApprovalStoreError::NotFound(id.to_string())),
287        };
288
289        // Replay guard: the bound token must not already be consumed.
290        let already: Option<i64> = tx
291            .query_row(
292                "SELECT 1 FROM chio_hitl_consumed_tokens WHERE token_id = ?1 AND parameter_hash = ?2",
293                params![decision.token.id, parameter_hash],
294                |row| row.get(0),
295            )
296            .optional()
297            .map_err(|e| ApprovalStoreError::Backend(format!("replay check: {e}")))?;
298        if already.is_some() {
299            return Err(ApprovalStoreError::Replay(id.to_string()));
300        }
301
302        // Idempotency: if already resolved, treat as AlreadyResolved.
303        let already_resolved: Option<i64> = tx
304            .query_row(
305                "SELECT 1 FROM chio_hitl_resolved WHERE approval_id = ?1",
306                params![id],
307                |row| row.get(0),
308            )
309            .optional()
310            .map_err(|e| ApprovalStoreError::Backend(format!("resolved check: {e}")))?;
311        if already_resolved.is_some() {
312            return Err(ApprovalStoreError::AlreadyResolved(id.to_string()));
313        }
314
315        let outcome = match decision.outcome {
316            ApprovalOutcome::Approved => "approved",
317            ApprovalOutcome::Denied => "denied",
318        };
319
320        tx.execute(
321            r#"INSERT INTO chio_hitl_resolved (
322                approval_id, policy_id, subject_id, outcome, resolved_at,
323                approver_hex, token_id
324            ) SELECT approval_id, policy_id, subject_id, ?2, ?3, ?4, ?5
325            FROM chio_hitl_pending WHERE approval_id = ?1"#,
326            params![
327                id,
328                outcome,
329                decision.received_at as i64,
330                decision.approver.to_hex(),
331                decision.token.id,
332            ],
333        )
334        .map_err(|e| ApprovalStoreError::Backend(format!("insert resolved: {e}")))?;
335
336        tx.execute(
337            "INSERT INTO chio_hitl_consumed_tokens (token_id, parameter_hash, consumed_at) VALUES (?1, ?2, ?3)",
338            params![decision.token.id, parameter_hash, decision.received_at as i64],
339        )
340        .map_err(|e| ApprovalStoreError::Backend(format!("insert consumed: {e}")))?;
341
342        tx.execute(
343            "DELETE FROM chio_hitl_pending WHERE approval_id = ?1",
344            params![id],
345        )
346        .map_err(|e| ApprovalStoreError::Backend(format!("delete pending: {e}")))?;
347
348        tx.commit()
349            .map_err(|e| ApprovalStoreError::Backend(format!("commit: {e}")))?;
350
351        // Silence unused warning for policy_id -- we kept it to sanity
352        // check the join. Future migrations may surface it in analytics.
353        let _ = policy_id;
354        Ok(())
355    }
356
357    fn count_approved(&self, subject_id: &str, policy_id: &str) -> Result<u64, ApprovalStoreError> {
358        let conn = self
359            .pool
360            .get()
361            .map_err(|e| ApprovalStoreError::Backend(format!("pool get: {e}")))?;
362        let count: i64 = conn
363            .query_row(
364                "SELECT COUNT(*) FROM chio_hitl_resolved WHERE subject_id = ?1 AND policy_id = ?2 AND outcome = 'approved'",
365                params![subject_id, policy_id],
366                |row| row.get(0),
367            )
368            .map_err(|e| ApprovalStoreError::Backend(format!("count: {e}")))?;
369        Ok(count.max(0) as u64)
370    }
371
372    fn record_consumed(
373        &self,
374        token_id: &str,
375        parameter_hash: &str,
376        now: u64,
377    ) -> Result<(), ApprovalStoreError> {
378        let conn = self
379            .pool
380            .get()
381            .map_err(|e| ApprovalStoreError::Backend(format!("pool get: {e}")))?;
382        let rows = conn.execute(
383            "INSERT OR IGNORE INTO chio_hitl_consumed_tokens (token_id, parameter_hash, consumed_at) VALUES (?1, ?2, ?3)",
384            params![token_id, parameter_hash, now as i64],
385        )
386        .map_err(|e| ApprovalStoreError::Backend(format!("insert consumed: {e}")))?;
387        if rows == 0 {
388            return Err(ApprovalStoreError::Replay(format!(
389                "token {token_id} already consumed"
390            )));
391        }
392        Ok(())
393    }
394
395    fn is_consumed(
396        &self,
397        token_id: &str,
398        parameter_hash: &str,
399    ) -> Result<bool, ApprovalStoreError> {
400        let conn = self
401            .pool
402            .get()
403            .map_err(|e| ApprovalStoreError::Backend(format!("pool get: {e}")))?;
404        let row: Option<i64> = conn
405            .query_row(
406                "SELECT 1 FROM chio_hitl_consumed_tokens WHERE token_id = ?1 AND parameter_hash = ?2",
407                params![token_id, parameter_hash],
408                |row| row.get(0),
409            )
410            .optional()
411            .map_err(|e| ApprovalStoreError::Backend(format!("is_consumed: {e}")))?;
412        Ok(row.is_some())
413    }
414
415    fn get_resolution(&self, id: &str) -> Result<Option<ResolvedApproval>, ApprovalStoreError> {
416        let conn = self
417            .pool
418            .get()
419            .map_err(|e| ApprovalStoreError::Backend(format!("pool get: {e}")))?;
420        let row: Option<(String, String, i64, String, String)> = conn
421            .query_row(
422                r#"SELECT approval_id, outcome, resolved_at, approver_hex, token_id
423                   FROM chio_hitl_resolved WHERE approval_id = ?1"#,
424                params![id],
425                |row| {
426                    Ok((
427                        row.get::<_, String>(0)?,
428                        row.get::<_, String>(1)?,
429                        row.get::<_, i64>(2)?,
430                        row.get::<_, String>(3)?,
431                        row.get::<_, String>(4)?,
432                    ))
433                },
434            )
435            .optional()
436            .map_err(|e| ApprovalStoreError::Backend(format!("get_resolution: {e}")))?;
437        match row {
438            Some((approval_id, outcome_str, resolved_at, approver_hex, token_id)) => {
439                let outcome = match outcome_str.as_str() {
440                    "approved" => ApprovalOutcome::Approved,
441                    "denied" => ApprovalOutcome::Denied,
442                    other => {
443                        return Err(ApprovalStoreError::Serialization(format!(
444                            "unknown outcome: {other}"
445                        )))
446                    }
447                };
448                Ok(Some(ResolvedApproval {
449                    approval_id,
450                    outcome,
451                    resolved_at: resolved_at.max(0) as u64,
452                    approver_hex,
453                    token_id,
454                }))
455            }
456            None => Ok(None),
457        }
458    }
459}
460
461#[cfg(test)]
462#[allow(clippy::expect_used, clippy::unwrap_used)]
463mod tests {
464    use super::*;
465    use chio_core::crypto::Keypair;
466
467    fn sample_request(id: &str, hash: &str) -> ApprovalRequest {
468        let subject = Keypair::generate();
469        let approver = Keypair::generate();
470        ApprovalRequest {
471            approval_id: id.into(),
472            policy_id: "policy-1".into(),
473            subject_id: "agent-1".into(),
474            capability_id: "cap-1".into(),
475            subject_public_key: Some(subject.public_key()),
476            tool_server: "srv".into(),
477            tool_name: "tool".into(),
478            action: "invoke".into(),
479            parameter_hash: hash.into(),
480            expires_at: 1_000_000,
481            callback_hint: None,
482            created_at: 42,
483            summary: "unit".into(),
484            governed_intent: None,
485            trusted_approvers: vec![approver.public_key()],
486            triggered_by: vec![],
487        }
488    }
489
490    #[test]
491    fn store_and_list_round_trip() {
492        let store = SqliteApprovalStore::open_in_memory().unwrap();
493        let r1 = sample_request("a-1", "h-1");
494        let r2 = sample_request("a-2", "h-2");
495        store.store_pending(&r1).unwrap();
496        store.store_pending(&r2).unwrap();
497
498        let all = store.list_pending(&ApprovalFilter::default()).unwrap();
499        assert_eq!(all.len(), 2);
500
501        let fetched = store.get_pending("a-1").unwrap().unwrap();
502        assert_eq!(fetched.approval_id, "a-1");
503        assert_eq!(fetched.parameter_hash, "h-1");
504    }
505
506    #[test]
507    fn duplicate_pending_insert_is_idempotent_only_when_payload_matches() {
508        let store = SqliteApprovalStore::open_in_memory().unwrap();
509        let original = sample_request("dup-1", "hash-a");
510        let identical = original.clone();
511        let mut mismatched = original.clone();
512        mismatched.parameter_hash = "hash-b".into();
513
514        store.store_pending(&original).unwrap();
515        store.store_pending(&identical).unwrap();
516
517        let err = store.store_pending(&mismatched).unwrap_err();
518        match err {
519            ApprovalStoreError::Backend(message) => {
520                assert!(message.contains("already exists with different payload"));
521            }
522            other => panic!("expected Backend mismatch error, got {other:?}"),
523        }
524
525        let fetched = store.get_pending("dup-1").unwrap().unwrap();
526        assert_eq!(fetched.parameter_hash, "hash-a");
527    }
528}