1use std::path::Path;
12use std::time::Duration;
13
14use firstpass_core::{DeferredVerdict, GENESIS_HASH, Score, Trace, Verdict};
15use rusqlite::Connection;
16use tokio::sync::mpsc;
17use tokio::task::JoinHandle;
18
19fn connect(db_path: impl AsRef<Path>) -> Result<Connection, StoreError> {
22 let conn = Connection::open(db_path.as_ref())?;
23 conn.busy_timeout(Duration::from_secs(5))?;
24 Ok(conn)
25}
26
27#[derive(Debug, thiserror::Error)]
29pub enum StoreError {
30 #[error("sqlite error: {0}")]
32 Sqlite(#[from] rusqlite::Error),
33 #[error("trace error: {0}")]
35 Trace(#[from] firstpass_core::Error),
36 #[error("json error: {0}")]
38 Json(#[from] serde_json::Error),
39}
40
41pub type TraceSender = mpsc::Sender<Trace>;
45
46pub const TRACE_CHANNEL_CAP: usize = 8192;
49
50pub fn open(db_path: impl AsRef<Path>) -> Result<(TraceSender, JoinHandle<()>), StoreError> {
59 let conn = connect(db_path.as_ref())?;
60 migrate(&conn)?;
61
62 let (tx, rx) = mpsc::channel::<Trace>(TRACE_CHANNEL_CAP);
63 let handle = tokio::task::spawn_blocking(move || writer_loop(conn, rx));
64 Ok((tx, handle))
65}
66
67fn migrate(conn: &Connection) -> Result<(), StoreError> {
68 conn.pragma_update(None, "journal_mode", "WAL")?;
71 conn.execute_batch(
72 "CREATE TABLE IF NOT EXISTS traces (
73 seq INTEGER PRIMARY KEY AUTOINCREMENT,
74 trace_id TEXT NOT NULL,
75 ts TEXT NOT NULL,
76 prev_hash TEXT NOT NULL,
77 hash TEXT NOT NULL,
78 tenant TEXT NOT NULL,
79 session TEXT NOT NULL,
80 body TEXT NOT NULL
81 );
82 CREATE INDEX IF NOT EXISTS traces_session_idx ON traces(session);
83 -- Tenant-scoped reads (ADR 0004 §D3) filter on `tenant`; index it with `seq` so a
84 -- per-tenant scan stays ordered and cheap. Existing rows keep their `tenant` value.
85 CREATE INDEX IF NOT EXISTS traces_tenant_seq_idx ON traces(tenant, seq);
86 -- Deferred verdicts live in their OWN table, keyed by trace_id. They are NEVER folded
87 -- into the sealed, hashed `traces.body`, so a late outcome can't alter a past record and
88 -- the tamper-evident chain stays valid. They are merged onto a trace only on read.
89 CREATE TABLE IF NOT EXISTS deferred_verdicts (
90 id INTEGER PRIMARY KEY AUTOINCREMENT,
91 trace_id TEXT NOT NULL,
92 gate_id TEXT NOT NULL,
93 verdict TEXT NOT NULL,
94 score REAL,
95 reported_at TEXT NOT NULL,
96 reporter TEXT NOT NULL
97 );
98 CREATE INDEX IF NOT EXISTS deferred_trace_idx ON deferred_verdicts(trace_id);",
99 )?;
100 Ok(())
101}
102
103fn writer_loop(conn: Connection, mut rx: mpsc::Receiver<Trace>) {
107 let mut head = match current_head(&conn) {
108 Ok(head) => head,
109 Err(err) => {
110 tracing::error!(%err, "trace writer: failed to load chain head, stopping");
111 return;
112 }
113 };
114
115 while let Some(mut trace) = rx.blocking_recv() {
116 trace.prev_hash = head.clone();
117 let hash = match trace.hash() {
118 Ok(hash) => hash,
119 Err(err) => {
120 tracing::error!(%err, trace_id = %trace.trace_id, "trace writer: failed to hash trace, dropping");
121 continue;
122 }
123 };
124 if let Err(err) = insert(&conn, &trace, &hash) {
125 tracing::error!(%err, trace_id = %trace.trace_id, "trace writer: failed to persist trace, dropping");
126 continue;
127 }
128 head = hash;
129 }
130}
131
132fn current_head(conn: &Connection) -> Result<String, StoreError> {
133 let mut stmt = conn.prepare("SELECT hash FROM traces ORDER BY seq DESC LIMIT 1")?;
134 let mut rows = stmt.query([])?;
135 match rows.next()? {
136 Some(row) => Ok(row.get(0)?),
137 None => Ok(GENESIS_HASH.to_owned()),
138 }
139}
140
141fn insert(conn: &Connection, trace: &Trace, hash: &str) -> Result<(), StoreError> {
142 let body = serde_json::to_string(trace)?;
143 conn.execute(
144 "INSERT INTO traces (trace_id, ts, prev_hash, hash, tenant, session, body)
145 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
146 rusqlite::params![
147 trace.trace_id.to_string(),
148 trace.ts.to_string(),
149 trace.prev_hash,
150 hash,
151 trace.tenant_id,
152 trace.session_id,
153 body,
154 ],
155 )?;
156 Ok(())
157}
158
159pub fn load_all_traces(db_path: impl AsRef<Path>) -> Result<Vec<Trace>, StoreError> {
173 let conn = connect(db_path.as_ref())?;
174 let mut stmt = conn.prepare("SELECT body FROM traces ORDER BY seq ASC")?;
175 let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
176 let mut traces = Vec::new();
177 for row in rows {
178 traces.push(serde_json::from_str(&row?)?);
179 }
180 Ok(traces)
181}
182
183pub fn load_tenant_traces(
190 db_path: impl AsRef<Path>,
191 tenant: &str,
192) -> Result<Vec<Trace>, StoreError> {
193 let conn = connect(db_path.as_ref())?;
194 let mut stmt = conn.prepare("SELECT body FROM traces WHERE tenant = ?1 ORDER BY seq ASC")?;
195 let rows = stmt.query_map([tenant], |row| row.get::<_, String>(0))?;
196 let mut traces = Vec::new();
197 for row in rows {
198 traces.push(serde_json::from_str(&row?)?);
199 }
200 Ok(traces)
201}
202
203pub fn trace_exists(
211 db_path: impl AsRef<Path>,
212 tenant: &str,
213 trace_id: &str,
214) -> Result<bool, StoreError> {
215 let conn = connect(db_path.as_ref())?;
216 let n: i64 = conn.query_row(
217 "SELECT COUNT(1) FROM traces WHERE tenant = ?1 AND trace_id = ?2",
218 [tenant, trace_id],
219 |row| row.get(0),
220 )?;
221 Ok(n > 0)
222}
223
224pub fn append_deferred(
231 db_path: impl AsRef<Path>,
232 trace_id: &str,
233 v: &DeferredVerdict,
234) -> Result<(), StoreError> {
235 let conn = connect(db_path.as_ref())?;
236 conn.execute(
237 "INSERT INTO deferred_verdicts (trace_id, gate_id, verdict, score, reported_at, reporter)
238 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
239 rusqlite::params![
240 trace_id,
241 v.gate_id,
242 v.verdict.as_str(),
243 v.score.map(Score::value),
244 v.reported_at.to_string(),
245 v.reporter,
246 ],
247 )?;
248 Ok(())
249}
250
251pub fn load_deferred(
257 db_path: impl AsRef<Path>,
258 trace_id: &str,
259) -> Result<Vec<DeferredVerdict>, StoreError> {
260 let conn = connect(db_path.as_ref())?;
261 let mut stmt = conn.prepare(
262 "SELECT gate_id, verdict, score, reported_at, reporter
263 FROM deferred_verdicts WHERE trace_id = ?1 ORDER BY id ASC",
264 )?;
265 let rows = stmt.query_map([trace_id], |row| {
266 Ok((
267 row.get::<_, String>(0)?,
268 row.get::<_, String>(1)?,
269 row.get::<_, Option<f64>>(2)?,
270 row.get::<_, String>(3)?,
271 row.get::<_, String>(4)?,
272 ))
273 })?;
274
275 let mut out = Vec::new();
276 for row in rows {
277 let (gate_id, verdict_s, score, reported_s, reporter) = row?;
278 let verdict = match verdict_s.as_str() {
279 "pass" => Verdict::Pass,
280 "fail" => Verdict::Fail,
281 "abstain" => Verdict::Abstain,
282 other => {
283 tracing::warn!(verdict = %other, %trace_id, "skipping deferred row with bad verdict");
284 continue;
285 }
286 };
287 let Ok(reported_at) = reported_s.parse::<jiff::Timestamp>() else {
288 tracing::warn!(%trace_id, "skipping deferred row with bad timestamp");
289 continue;
290 };
291 out.push(DeferredVerdict {
292 gate_id,
293 verdict,
294 score: score.and_then(|s| Score::new(s).ok()),
295 reported_at,
296 reporter,
297 });
298 }
299 Ok(out)
300}
301
302pub fn load_trace_view(
312 db_path: impl AsRef<Path>,
313 tenant: &str,
314 trace_id: &str,
315) -> Result<Option<Trace>, StoreError> {
316 let conn = connect(db_path.as_ref())?;
317 let body: Option<String> = conn
318 .query_row(
319 "SELECT body FROM traces WHERE tenant = ?1 AND trace_id = ?2",
320 [tenant, trace_id],
321 |row| row.get(0),
322 )
323 .ok();
324 let Some(body) = body else { return Ok(None) };
325 let mut trace: Trace = serde_json::from_str(&body)?;
326 trace.deferred = load_deferred(db_path, trace_id)?;
327 Ok(Some(trace))
328}
329
330#[cfg(test)]
331mod tests {
332 use firstpass_core::{
333 Attempt, Features, FinalOutcome, GENESIS_HASH, PolicyRef, RequestInfo, ServedFrom,
334 TaskKind, Verdict, verify_chain,
335 };
336
337 use super::*;
338
339 fn sample_trace(tenant: &str, session: &str) -> Trace {
340 let attempt = Attempt {
341 rung: 0,
342 model: "claude-haiku-4-5".to_owned(),
343 provider: "anthropic".to_owned(),
344 in_tokens: 10,
345 out_tokens: 5,
346 cost_usd: 0.001,
347 latency_ms: 12,
348 gates: vec![],
349 verdict: Verdict::Pass,
350 };
351 let mut trace = Trace {
352 trace_id: uuid::Uuid::now_v7(),
353 prev_hash: GENESIS_HASH.to_owned(),
354 tenant_id: tenant.to_owned(),
355 session_id: session.to_owned(),
356 ts: jiff::Timestamp::now(),
357 mode: firstpass_core::Mode::Observe,
358 policy: PolicyRef {
359 id: "observe-passthrough@v0".to_owned(),
360 explore: false,
361 },
362 request: RequestInfo {
363 api: "anthropic.messages".to_owned(),
364 prompt_hash: "deadbeef".to_owned(),
365 features: Features::new(TaskKind::Other),
366 },
367 attempts: vec![attempt],
368 deferred: Vec::new(),
369 final_: FinalOutcome {
370 served_rung: Some(0),
371 served_from: ServedFrom::Attempt,
372 total_cost_usd: 0.001,
373 gate_cost_usd: 0.0,
374 total_latency_ms: 12,
375 escalations: 0,
376 counterfactual_baseline_usd: 0.001,
377 savings_usd: 0.0,
378 },
379 };
380 trace.recompute_savings();
381 trace
382 }
383
384 #[tokio::test]
385 async fn writer_assigns_prev_hash_and_forms_a_valid_chain() {
386 let db_path =
387 std::env::temp_dir().join(format!("firstpass-store-test-{}.db", uuid::Uuid::now_v7()));
388 let (tx, handle) = open(&db_path).unwrap();
389
390 tx.try_send(sample_trace("tenant-a", "session-1")).unwrap();
391 tx.try_send(sample_trace("tenant-a", "session-1")).unwrap();
392 drop(tx);
393 handle.await.unwrap();
394
395 let traces = load_all_traces(&db_path).unwrap();
396 assert_eq!(traces.len(), 2);
397 assert_eq!(traces[0].prev_hash, GENESIS_HASH);
398 assert_eq!(traces[1].prev_hash, traces[0].hash().unwrap());
399 verify_chain(&traces, GENESIS_HASH).unwrap();
400
401 let _ = std::fs::remove_file(&db_path);
402 }
403
404 #[tokio::test]
408 async fn tenant_scoped_reads_never_cross_the_boundary() {
409 let db_path =
410 std::env::temp_dir().join(format!("firstpass-isolation-{}.db", uuid::Uuid::now_v7()));
411 let (tx, handle) = open(&db_path).unwrap();
412
413 let a0 = sample_trace("tenant-a", "sa-0");
415 let a1 = sample_trace("tenant-a", "sa-1");
416 let b0 = sample_trace("tenant-b", "sb-0");
417 let (a0_id, a1_id, b0_id) = (
418 a0.trace_id.to_string(),
419 a1.trace_id.to_string(),
420 b0.trace_id.to_string(),
421 );
422 tx.try_send(a0).unwrap();
423 tx.try_send(b0).unwrap();
424 tx.try_send(a1).unwrap();
425 drop(tx);
426 handle.await.unwrap();
427
428 let a_traces = load_tenant_traces(&db_path, "tenant-a").unwrap();
430 assert_eq!(a_traces.len(), 2, "A must see only A's traces");
431 assert!(a_traces.iter().all(|t| t.tenant_id == "tenant-a"));
432 let b_traces = load_tenant_traces(&db_path, "tenant-b").unwrap();
433 assert_eq!(b_traces.len(), 1, "B must see only B's trace");
434 assert!(b_traces.iter().all(|t| t.tenant_id == "tenant-b"));
435
436 assert!(trace_exists(&db_path, "tenant-a", &a0_id).unwrap());
438 assert!(!trace_exists(&db_path, "tenant-a", &b0_id).unwrap());
439 assert!(trace_exists(&db_path, "tenant-b", &b0_id).unwrap());
440 assert!(!trace_exists(&db_path, "tenant-b", &a1_id).unwrap());
441
442 assert!(
444 load_trace_view(&db_path, "tenant-a", &a0_id)
445 .unwrap()
446 .is_some()
447 );
448 assert!(
449 load_trace_view(&db_path, "tenant-a", &b0_id)
450 .unwrap()
451 .is_none()
452 );
453 assert!(
454 load_trace_view(&db_path, "tenant-b", &a0_id)
455 .unwrap()
456 .is_none()
457 );
458
459 assert!(load_tenant_traces(&db_path, "ghost").unwrap().is_empty());
461
462 let all = load_all_traces(&db_path).unwrap();
464 assert_eq!(all.len(), 3);
465 verify_chain(&all, GENESIS_HASH).unwrap();
466
467 let _ = std::fs::remove_file(&db_path);
468 }
469
470 #[tokio::test]
471 async fn deferred_verdicts_attach_on_read_without_breaking_the_chain() {
472 let db_path = std::env::temp_dir().join(format!(
473 "firstpass-deferred-test-{}.db",
474 uuid::Uuid::now_v7()
475 ));
476 let (tx, handle) = open(&db_path).unwrap();
477 let t0 = sample_trace("acme", "run-1");
478 let t1 = sample_trace("acme", "run-1");
479 let (id0, id1) = (t0.trace_id.to_string(), t1.trace_id.to_string());
480 tx.try_send(t0).unwrap();
481 tx.try_send(t1).unwrap();
482 drop(tx);
483 handle.await.unwrap();
484
485 let dv = DeferredVerdict {
487 gate_id: "tests".to_owned(),
488 verdict: Verdict::Pass,
489 score: Some(Score::new(1.0).unwrap()),
490 reported_at: jiff::Timestamp::now(),
491 reporter: "ci".to_owned(),
492 };
493 append_deferred(&db_path, &id0, &dv).unwrap();
494 assert!(trace_exists(&db_path, "acme", &id0).unwrap());
495 assert!(!trace_exists(&db_path, "acme", "no-such-trace").unwrap());
496 assert!(!trace_exists(&db_path, "other-tenant", &id0).unwrap());
498
499 let view = load_trace_view(&db_path, "acme", &id0).unwrap().unwrap();
501 assert_eq!(view.deferred.len(), 1);
502 assert_eq!(view.deferred[0].gate_id, "tests");
503 assert_eq!(view.deferred[0].verdict, Verdict::Pass);
504 assert!(
506 load_trace_view(&db_path, "acme", &id1)
507 .unwrap()
508 .unwrap()
509 .deferred
510 .is_empty()
511 );
512 assert!(
514 load_trace_view(&db_path, "other-tenant", &id0)
515 .unwrap()
516 .is_none()
517 );
518
519 let traces = load_all_traces(&db_path).unwrap();
522 assert!(
523 traces.iter().all(|t| t.deferred.is_empty()),
524 "sealed records stay deferred-free"
525 );
526 verify_chain(&traces, GENESIS_HASH).unwrap();
527
528 let _ = std::fs::remove_file(&db_path);
529 }
530}