Skip to main content

firstpass_proxy/
store.rs

1//! Tamper-evident trace storage: a background SQLite writer fed by a bounded channel.
2//!
3//! The hot request path never blocks on disk: it `try_send`s a [`Trace`] down a bounded channel
4//! and returns immediately (dropping the trace if the writer has fallen far enough behind to fill
5//! the buffer — bounded memory over a guaranteed write). A single background task owns the SQLite
6//! connection, assigns each trace's `prev_hash` from the current chain head, computes its `hash`,
7//! and appends it (SPEC §9: the hash chain is only meaningful if every writer agrees on chain
8//! order, which a single writer task guarantees for free).
9//!
10//! In [`crate::config::ReceiptsMode::Durable`] mode the channel-full path spills traces to
11//! `<db_path>.spill.jsonl` (one JSON line per trace, synced to disk) instead of dropping them.
12//! The writer drains the spill file at startup and whenever the channel empties, inserting spilled
13//! traces BEFORE new channel arrivals so the hash chain stays append-only and valid.
14
15use std::io::{BufRead, BufReader, Seek, SeekFrom, Write};
16use std::path::Path;
17use std::sync::Arc;
18use std::time::Duration;
19
20use firstpass_core::{DeferredVerdict, GENESIS_HASH, Score, Trace, Verdict};
21use rusqlite::{Connection, OptionalExtension as _};
22use tokio::sync::mpsc;
23use tokio::task::JoinHandle;
24
25use crate::config::ReceiptsMode;
26
27/// Open a connection with WAL + a busy timeout, so the background writer and short-lived
28/// feedback/read connections can share the file without "database is locked" errors.
29fn connect(db_path: impl AsRef<Path>) -> Result<Connection, StoreError> {
30    let conn = Connection::open(db_path.as_ref())?;
31    conn.busy_timeout(Duration::from_secs(5))?;
32    Ok(conn)
33}
34
35/// Errors from the trace store.
36#[derive(Debug, thiserror::Error)]
37pub enum StoreError {
38    /// The SQLite database could not be opened, migrated, or queried.
39    #[error("sqlite error: {0}")]
40    Sqlite(#[from] rusqlite::Error),
41    /// A trace could not be hashed or (de)serialized.
42    #[error("trace error: {0}")]
43    Trace(#[from] firstpass_core::Error),
44    /// A stored row was not valid trace JSON.
45    #[error("json error: {0}")]
46    Json(#[from] serde_json::Error),
47    /// An I/O error from the spill file.
48    #[error("spill I/O error: {0}")]
49    Io(#[from] std::io::Error),
50}
51
52/// A shared, mutex-guarded handle to the spill file used in durable mode.
53///
54/// Multiple async tasks may hit a full channel simultaneously; the `Mutex` serialises their
55/// appends without data loss.
56///
57/// ponytail: global Mutex over the file — upgrade to a dedicated spill-writer task if append
58/// throughput under sustained backpressure becomes the bottleneck.
59pub type SpillHandle = Arc<std::sync::Mutex<std::fs::File>>;
60
61/// Derive the spill-file path from the main DB path: `<db_path>.spill.jsonl`.
62pub(crate) fn spill_path(db_path: &Path) -> std::path::PathBuf {
63    let mut s = db_path.as_os_str().to_owned();
64    s.push(".spill.jsonl");
65    std::path::PathBuf::from(s)
66}
67
68/// Serialize `trace` as one JSON line and append it to the spill file, flushing to disk.
69///
70/// Called from the hot async path only when the writer channel is full (durable mode). This
71/// blocks the calling tokio task on the disk write — the deliberate tradeoff of durable mode;
72/// it only fires under sustained backpressure.
73///
74/// # Errors
75/// Returns [`StoreError::Json`] on serialization failure, [`StoreError::Io`] on disk failure.
76pub fn append_to_spill(handle: &SpillHandle, trace: &Trace) -> Result<(), StoreError> {
77    let line = serde_json::to_string(trace)?;
78    // Recover from a poisoned mutex rather than failing: a previous writer's panic doesn't
79    // corrupt the file, so we can safely continue appending.
80    let mut guard = handle.lock().unwrap_or_else(|e| e.into_inner());
81    writeln!(guard, "{line}")?;
82    // sync_data flushes the write to stable storage — the whole point of durable mode.
83    guard.sync_data()?;
84    Ok(())
85}
86
87/// Sending half of the trace channel; cheap to clone, safe to share across request handlers.
88/// Sending is fire-and-forget via `try_send` — the hot path never awaits the writer, and a bounded
89/// buffer means a stalled writer sheds load (drops traces) instead of growing memory without limit.
90pub type TraceSender = mpsc::Sender<Trace>;
91
92/// Trace buffer depth. Deep enough to absorb normal write bursts; bounded so a wedged writer (disk
93/// stall) can't OOM the process — excess traces are dropped with a warning, not queued forever.
94pub const TRACE_CHANNEL_CAP: usize = 8192;
95
96/// Open (creating if needed) the SQLite trace database in best-effort mode, migrate its schema,
97/// and spawn the background writer task.
98///
99/// Returns a [`TraceSender`] for the hot path and the writer's [`JoinHandle`]. The writer
100/// exits cleanly once every clone of the sender is dropped.
101///
102/// This is the backward-compatible convenience wrapper. Use [`open_with_receipts`] when you need
103/// durable (never-drop) mode.
104///
105/// # Errors
106/// Returns [`StoreError::Sqlite`] if the database cannot be opened or migrated.
107pub fn open(db_path: impl AsRef<Path>) -> Result<(TraceSender, JoinHandle<()>), StoreError> {
108    let (tx, _spill, handle) = open_with_receipts(db_path, ReceiptsMode::BestEffort)?;
109    Ok((tx, handle))
110}
111
112/// Open the SQLite trace database with the given receipts mode, migrate its schema, and spawn
113/// the background writer task.
114///
115/// Returns a [`TraceSender`], an optional [`SpillHandle`] (present only in durable mode, for use
116/// by [`append_to_spill`] on channel-full), and the writer's [`JoinHandle`].
117///
118/// In [`ReceiptsMode::Durable`] mode the writer drains any existing spill file at startup and
119/// whenever the channel empties, inserting spilled traces BEFORE new channel arrivals so the hash
120/// chain stays append-only and valid across crashes and restarts.
121///
122/// # Errors
123/// Returns [`StoreError::Sqlite`] if the database cannot be opened or migrated, or
124/// [`StoreError::Io`] if the spill file cannot be created in durable mode.
125pub fn open_with_receipts(
126    db_path: impl AsRef<Path>,
127    receipts_mode: ReceiptsMode,
128) -> Result<(TraceSender, Option<SpillHandle>, JoinHandle<()>), StoreError> {
129    let db_path = db_path.as_ref();
130    let conn = connect(db_path)?;
131    migrate(&conn)?;
132
133    let spill = if receipts_mode == ReceiptsMode::Durable {
134        let path = spill_path(db_path);
135        let file = std::fs::OpenOptions::new()
136            .create(true)
137            .append(true)
138            .read(true)
139            .open(&path)?;
140        Some(Arc::new(std::sync::Mutex::new(file)))
141    } else {
142        None
143    };
144
145    let (tx, rx) = mpsc::channel::<Trace>(TRACE_CHANNEL_CAP);
146    let spill_writer = spill.clone();
147    let handle = tokio::task::spawn_blocking(move || writer_loop(conn, rx, spill_writer));
148    Ok((tx, spill, handle))
149}
150
151fn migrate(conn: &Connection) -> Result<(), StoreError> {
152    // WAL: lets the background writer and short-lived feedback/read connections share the file
153    // concurrently. `journal_mode` is persisted in the file header once set by any connection.
154    conn.pragma_update(None, "journal_mode", "WAL")?;
155    conn.execute_batch(
156        "CREATE TABLE IF NOT EXISTS traces (
157            seq INTEGER PRIMARY KEY AUTOINCREMENT,
158            trace_id TEXT NOT NULL,
159            ts TEXT NOT NULL,
160            prev_hash TEXT NOT NULL,
161            hash TEXT NOT NULL,
162            tenant TEXT NOT NULL,
163            session TEXT NOT NULL,
164            body TEXT NOT NULL
165        );
166        CREATE INDEX IF NOT EXISTS traces_session_idx ON traces(session);
167        -- Tenant-scoped reads (ADR 0004 §D3) filter on `tenant`; index it with `seq` so a
168        -- per-tenant scan stays ordered and cheap. Existing rows keep their `tenant` value.
169        CREATE INDEX IF NOT EXISTS traces_tenant_seq_idx ON traces(tenant, seq);
170        -- Deferred verdicts live in their OWN table, keyed by trace_id. They are NEVER folded
171        -- into the sealed, hashed `traces.body`, so a late outcome can't alter a past record and
172        -- the tamper-evident chain stays valid. They are merged onto a trace only on read.
173        CREATE TABLE IF NOT EXISTS deferred_verdicts (
174            id INTEGER PRIMARY KEY AUTOINCREMENT,
175            trace_id TEXT NOT NULL,
176            gate_id TEXT NOT NULL,
177            verdict TEXT NOT NULL,
178            score REAL,
179            reported_at TEXT NOT NULL,
180            reporter TEXT NOT NULL
181        );
182        CREATE INDEX IF NOT EXISTS deferred_trace_idx ON deferred_verdicts(trace_id);",
183    )?;
184    Ok(())
185}
186
187/// Assign `prev_hash`, compute `hash`, and insert one trace. Updates `head` on success.
188/// Logs and skips on hash or insert failure — one bad record must not wedge the pipeline.
189fn write_trace(conn: &Connection, trace: &mut Trace, head: &mut String) {
190    trace.prev_hash = head.clone();
191    let hash = match trace.hash() {
192        Ok(h) => h,
193        Err(err) => {
194            tracing::error!(%err, trace_id = %trace.trace_id, "trace writer: hash failed, dropping");
195            return;
196        }
197    };
198    if let Err(err) = insert(conn, trace, &hash) {
199        tracing::error!(%err, trace_id = %trace.trace_id, "trace writer: insert failed, dropping");
200        return;
201    }
202    *head = hash;
203}
204
205/// Drain the spill file into the store, inserting traces in file order BEFORE any new channel
206/// arrivals. Truncates the file after a successful drain. Errors on individual lines are logged
207/// and skipped so one bad spill line can't stall the recovery.
208///
209/// ponytail: reads the entire file into memory; the spill file is bounded by the channel size
210/// and trace sizes, so this is fine in practice. Stream-parse if traces grow very large.
211fn drain_spill(conn: &Connection, spill: &SpillHandle, head: &mut String) {
212    // Hold the lock for the entire drain: prevents concurrent spill writers from appending
213    // new lines mid-drain, which would corrupt ordering.
214    let mut guard = spill.lock().unwrap_or_else(|e| e.into_inner());
215
216    // Seek to start before reading.
217    if let Err(e) = guard.seek(SeekFrom::Start(0)) {
218        tracing::error!(%e, "drain_spill: seek failed");
219        return;
220    }
221
222    // Collect all non-empty lines while holding the lock (reader borrows from guard).
223    let lines: Vec<String> = {
224        let reader = BufReader::new(&*guard);
225        reader
226            .lines()
227            .filter_map(|l| match l {
228                Ok(s) if !s.trim().is_empty() => Some(s),
229                Ok(_) => None,
230                Err(e) => {
231                    tracing::error!(%e, "drain_spill: read error");
232                    None
233                }
234            })
235            .collect()
236    };
237
238    if lines.is_empty() {
239        return;
240    }
241
242    let n = lines.len();
243    for line in &lines {
244        let mut trace: Trace = match serde_json::from_str(line) {
245            Ok(t) => t,
246            Err(e) => {
247                tracing::error!(%e, "drain_spill: bad JSON line, skipping");
248                continue;
249            }
250        };
251        write_trace(conn, &mut trace, head);
252    }
253    tracing::info!(drained = n, "spill file drained into trace store");
254
255    // Truncate: all lines have been processed (failures are skipped and logged above).
256    if let Err(e) = guard.seek(SeekFrom::Start(0)) {
257        tracing::error!(%e, "drain_spill: post-drain seek failed");
258        return;
259    }
260    if let Err(e) = guard.set_len(0) {
261        tracing::error!(%e, "drain_spill: truncate failed — spill file may replay on next boot");
262    }
263}
264
265/// The writer's main loop: runs on a blocking-pool thread for the lifetime of the store.
266/// Never panics on a bad trace — logs and drops it, so one malformed record can't wedge the
267/// whole audit pipeline.
268///
269/// In durable mode (`spill` is `Some`): drains the spill file at startup and whenever the
270/// channel empties, so spilled traces always land BEFORE later channel traces in the chain.
271fn writer_loop(conn: Connection, mut rx: mpsc::Receiver<Trace>, spill: Option<SpillHandle>) {
272    let mut head = match current_head(&conn) {
273        Ok(h) => h,
274        Err(err) => {
275            tracing::error!(%err, "trace writer: failed to load chain head, stopping");
276            return;
277        }
278    };
279
280    // Startup drain: recover any traces spilled during a previous run's backpressure.
281    if let Some(ref s) = spill {
282        drain_spill(&conn, s, &mut head);
283    }
284
285    loop {
286        let Some(mut trace) = rx.blocking_recv() else {
287            break;
288        };
289        write_trace(&conn, &mut trace, &mut head);
290
291        // Fast-drain: pull any immediately available channel items, then drain spill when
292        // the channel is momentarily empty so spilled traces land before the next wave.
293        loop {
294            match rx.try_recv() {
295                Ok(mut t) => write_trace(&conn, &mut t, &mut head),
296                Err(mpsc::error::TryRecvError::Empty) => {
297                    if let Some(ref s) = spill {
298                        drain_spill(&conn, s, &mut head);
299                    }
300                    break;
301                }
302                Err(mpsc::error::TryRecvError::Disconnected) => return,
303            }
304        }
305    }
306
307    // Channel closed: one final spill drain to capture any last-moment spills.
308    if let Some(ref s) = spill {
309        drain_spill(&conn, s, &mut head);
310    }
311}
312
313fn current_head(conn: &Connection) -> Result<String, StoreError> {
314    let mut stmt = conn.prepare("SELECT hash FROM traces ORDER BY seq DESC LIMIT 1")?;
315    let mut rows = stmt.query([])?;
316    match rows.next()? {
317        Some(row) => Ok(row.get(0)?),
318        None => Ok(GENESIS_HASH.to_owned()),
319    }
320}
321
322fn insert(conn: &Connection, trace: &Trace, hash: &str) -> Result<(), StoreError> {
323    let body = serde_json::to_string(trace)?;
324    conn.execute(
325        "INSERT INTO traces (trace_id, ts, prev_hash, hash, tenant, session, body)
326         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
327        rusqlite::params![
328            trace.trace_id.to_string(),
329            trace.ts.to_string(),
330            trace.prev_hash,
331            hash,
332            trace.tenant_id,
333            trace.session_id,
334            body,
335        ],
336    )?;
337    Ok(())
338}
339
340/// Load every trace from the database in insertion (chain) order — used by tests and
341/// operators to verify the hash chain with [`firstpass_core::verify_chain`].
342///
343/// **Operator-wide** read: every trace across ALL tenants, in `seq` order.
344///
345/// This deliberately crosses tenant boundaries and must stay reserved for operator-scoped work
346/// where a global view is intrinsic — namely verifying the single hash-chain, which spans every
347/// tenant's traces in one sequence (ADR 0004 §D3). For tenant-facing reads use
348/// [`load_tenant_traces`].
349///
350/// # Errors
351/// Returns [`StoreError::Sqlite`] on a database error, or [`StoreError::Json`] if a stored
352/// row is not valid trace JSON.
353pub fn load_all_traces(db_path: impl AsRef<Path>) -> Result<Vec<Trace>, StoreError> {
354    let conn = connect(db_path.as_ref())?;
355    let mut stmt = conn.prepare("SELECT body FROM traces ORDER BY seq ASC")?;
356    let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
357    let mut traces = Vec::new();
358    for row in rows {
359        traces.push(serde_json::from_str(&row?)?);
360    }
361    Ok(traces)
362}
363
364/// **Tenant-scoped** read: only traces owned by `tenant`, in `seq` order (ADR 0004 §D3). Tenant A
365/// can never see tenant B's traces through this path.
366///
367/// # Errors
368/// Returns [`StoreError::Sqlite`] on a database error, or [`StoreError::Json`] if a stored
369/// row is not valid trace JSON.
370pub fn load_tenant_traces(
371    db_path: impl AsRef<Path>,
372    tenant: &str,
373) -> Result<Vec<Trace>, StoreError> {
374    let conn = connect(db_path.as_ref())?;
375    let mut stmt = conn.prepare("SELECT body FROM traces WHERE tenant = ?1 ORDER BY seq ASC")?;
376    let rows = stmt.query_map([tenant], |row| row.get::<_, String>(0))?;
377    let mut traces = Vec::new();
378    for row in rows {
379        traces.push(serde_json::from_str(&row?)?);
380    }
381    Ok(traces)
382}
383
384/// Whether a trace with `trace_id` exists **and is owned by `tenant`** — used to reject feedback
385/// for unknown traces and, crucially, to deny cross-tenant feedback (ADR 0004 §D3/§D4). A trace
386/// owned by another tenant is indistinguishable from a non-existent one here, so the caller can
387/// return a `404` with no existence oracle.
388///
389/// # Errors
390/// Returns [`StoreError::Sqlite`] on a database error.
391pub fn trace_exists(
392    db_path: impl AsRef<Path>,
393    tenant: &str,
394    trace_id: &str,
395) -> Result<bool, StoreError> {
396    let conn = connect(db_path.as_ref())?;
397    let n: i64 = conn.query_row(
398        "SELECT COUNT(1) FROM traces WHERE tenant = ?1 AND trace_id = ?2",
399        [tenant, trace_id],
400        |row| row.get(0),
401    )?;
402    Ok(n > 0)
403}
404
405/// The route index recorded on `trace_id`, scoped to `tenant`.
406///
407/// `Ok(None)` means the trace is not visible to this tenant (or does not exist) — the same
408/// ownership check [`trace_exists`] makes, so this cannot be used to probe another tenant's
409/// traces. `Ok(Some(None))` means the trace exists but predates route recording.
410///
411/// # Errors
412/// On a store read failure.
413pub fn trace_route(
414    db_path: impl AsRef<Path>,
415    tenant: &str,
416    trace_id: &str,
417) -> Result<Option<Option<u32>>, StoreError> {
418    let conn = connect(db_path.as_ref())?;
419    let body: Option<String> = conn
420        .query_row(
421            "SELECT body FROM traces WHERE tenant = ?1 AND trace_id = ?2",
422            [tenant, trace_id],
423            |row| row.get(0),
424        )
425        .optional()?;
426    Ok(body.map(|b: String| {
427        serde_json::from_str::<firstpass_core::Trace>(&b)
428            .ok()
429            .and_then(|t| t.route_ix)
430    }))
431}
432
433/// Append a deferred verdict for `trace_id` (a downstream outcome or async gate result). This
434/// writes ONLY to the `deferred_verdicts` table; the sealed trace and its hash are untouched, so
435/// the audit chain remains verifiable (SPEC §8.3.4 — the outcome-feedback loop).
436///
437/// # Errors
438/// Returns [`StoreError::Sqlite`] on a database error.
439pub fn append_deferred(
440    db_path: impl AsRef<Path>,
441    trace_id: &str,
442    v: &DeferredVerdict,
443) -> Result<(), StoreError> {
444    let conn = connect(db_path.as_ref())?;
445    conn.execute(
446        "INSERT INTO deferred_verdicts (trace_id, gate_id, verdict, score, reported_at, reporter)
447         VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
448        rusqlite::params![
449            trace_id,
450            v.gate_id,
451            v.verdict.as_str(),
452            v.score.map(Score::value),
453            v.reported_at.to_string(),
454            v.reporter,
455        ],
456    )?;
457    Ok(())
458}
459
460/// Load the deferred verdicts recorded for `trace_id`, oldest first. Malformed stored rows are
461/// skipped (logged), never fatal — a corrupt late outcome must not break reading the trace.
462///
463/// # Errors
464/// Returns [`StoreError::Sqlite`] on a database error.
465pub fn load_deferred(
466    db_path: impl AsRef<Path>,
467    trace_id: &str,
468) -> Result<Vec<DeferredVerdict>, StoreError> {
469    let conn = connect(db_path.as_ref())?;
470    let mut stmt = conn.prepare(
471        "SELECT gate_id, verdict, score, reported_at, reporter
472         FROM deferred_verdicts WHERE trace_id = ?1 ORDER BY id ASC",
473    )?;
474    let rows = stmt.query_map([trace_id], |row| {
475        Ok((
476            row.get::<_, String>(0)?,
477            row.get::<_, String>(1)?,
478            row.get::<_, Option<f64>>(2)?,
479            row.get::<_, String>(3)?,
480            row.get::<_, String>(4)?,
481        ))
482    })?;
483
484    let mut out = Vec::new();
485    for row in rows {
486        let (gate_id, verdict_s, score, reported_s, reporter) = row?;
487        let verdict = match verdict_s.as_str() {
488            "pass" => Verdict::Pass,
489            "fail" => Verdict::Fail,
490            "abstain" => Verdict::Abstain,
491            other => {
492                tracing::warn!(verdict = %other, %trace_id, "skipping deferred row with bad verdict");
493                continue;
494            }
495        };
496        let Ok(reported_at) = reported_s.parse::<jiff::Timestamp>() else {
497            tracing::warn!(%trace_id, "skipping deferred row with bad timestamp");
498            continue;
499        };
500        out.push(DeferredVerdict {
501            gate_id,
502            verdict,
503            score: score.and_then(|s| Score::new(s).ok()),
504            reported_at,
505            reporter,
506        });
507    }
508    Ok(out)
509}
510
511/// Load a single trace by id **scoped to `tenant`**, with its deferred verdicts merged into
512/// `deferred` — the **view** for display/inspection (ADR 0004 §D3). A trace owned by another
513/// tenant returns `None`, exactly like a missing one, so an inspecting agent can never read across
514/// tenants. This is deliberately separate from [`load_all_traces`]: merging deferred verdicts
515/// changes the record, so a merged trace must NOT be fed to `verify_chain` (chain verification
516/// always runs on the sealed bodies from [`load_all_traces`]).
517///
518/// # Errors
519/// Returns [`StoreError::Sqlite`] / [`StoreError::Json`] on database or decode errors.
520pub fn load_trace_view(
521    db_path: impl AsRef<Path>,
522    tenant: &str,
523    trace_id: &str,
524) -> Result<Option<Trace>, StoreError> {
525    let conn = connect(db_path.as_ref())?;
526    let body: Option<String> = conn
527        .query_row(
528            "SELECT body FROM traces WHERE tenant = ?1 AND trace_id = ?2",
529            [tenant, trace_id],
530            |row| row.get(0),
531        )
532        .ok();
533    let Some(body) = body else { return Ok(None) };
534    let mut trace: Trace = serde_json::from_str(&body)?;
535    trace.deferred = load_deferred(db_path, trace_id)?;
536    Ok(Some(trace))
537}
538
539#[cfg(test)]
540mod tests {
541    use firstpass_core::{
542        Attempt, Features, FinalOutcome, GENESIS_HASH, PolicyRef, RequestInfo, ServedFrom,
543        TaskKind, Verdict, verify_chain,
544    };
545
546    use super::*;
547
548    fn sample_trace(tenant: &str, session: &str) -> Trace {
549        let attempt = Attempt {
550            rung: 0,
551            model: "claude-haiku-4-5".to_owned(),
552            provider: "anthropic".to_owned(),
553            in_tokens: 10,
554            out_tokens: 5,
555            cost_usd: 0.001,
556            latency_ms: 12,
557            gates: vec![],
558            verdict: Verdict::Pass,
559        };
560        let mut trace = Trace {
561            trace_id: uuid::Uuid::now_v7(),
562            prev_hash: GENESIS_HASH.to_owned(),
563            tenant_id: tenant.to_owned(),
564            session_id: session.to_owned(),
565            ts: jiff::Timestamp::now(),
566            mode: firstpass_core::Mode::Observe,
567            policy: PolicyRef {
568                id: "observe-passthrough@v0".to_owned(),
569                explore: false,
570                propensity: None,
571                mode_profile: None,
572            },
573            request: RequestInfo {
574                api: "anthropic.messages".to_owned(),
575                prompt_hash: "deadbeef".to_owned(),
576                features: Features::new(TaskKind::Other),
577            },
578            attempts: vec![attempt],
579            deferred: Vec::new(),
580            final_: FinalOutcome {
581                served_rung: Some(0),
582                served_from: ServedFrom::Attempt,
583                total_cost_usd: 0.001,
584                gate_cost_usd: 0.0,
585                total_latency_ms: 12,
586                escalations: 0,
587                counterfactual_baseline_usd: 0.001,
588                savings_usd: 0.0,
589            },
590            probe: None,
591            rollout: None,
592            shadow: None,
593            route_ix: None,
594            predicted_pass: None,
595            elastic: None,
596        };
597        trace.recompute_savings();
598        trace
599    }
600
601    #[tokio::test]
602    async fn writer_assigns_prev_hash_and_forms_a_valid_chain() {
603        let db_path =
604            std::env::temp_dir().join(format!("firstpass-store-test-{}.db", uuid::Uuid::now_v7()));
605        let (tx, handle) = open(&db_path).unwrap();
606
607        tx.try_send(sample_trace("tenant-a", "session-1")).unwrap();
608        tx.try_send(sample_trace("tenant-a", "session-1")).unwrap();
609        drop(tx);
610        handle.await.unwrap();
611
612        let traces = load_all_traces(&db_path).unwrap();
613        assert_eq!(traces.len(), 2);
614        assert_eq!(traces[0].prev_hash, GENESIS_HASH);
615        assert_eq!(traces[1].prev_hash, traces[0].hash().unwrap());
616        verify_chain(&traces, GENESIS_HASH).unwrap();
617
618        let _ = std::fs::remove_file(&db_path);
619    }
620
621    /// D7 cross-tenant isolation, at the store layer: with rows for tenants A and B, every
622    /// tenant-scoped read for A returns only A's data, and vice-versa. The operator-wide
623    /// [`load_all_traces`] still sees both (for chain verification).
624    #[tokio::test]
625    async fn tenant_scoped_reads_never_cross_the_boundary() {
626        let db_path =
627            std::env::temp_dir().join(format!("firstpass-isolation-{}.db", uuid::Uuid::now_v7()));
628        let (tx, handle) = open(&db_path).unwrap();
629
630        // Two traces for A, one for B.
631        let a0 = sample_trace("tenant-a", "sa-0");
632        let a1 = sample_trace("tenant-a", "sa-1");
633        let b0 = sample_trace("tenant-b", "sb-0");
634        let (a0_id, a1_id, b0_id) = (
635            a0.trace_id.to_string(),
636            a1.trace_id.to_string(),
637            b0.trace_id.to_string(),
638        );
639        tx.try_send(a0).unwrap();
640        tx.try_send(b0).unwrap();
641        tx.try_send(a1).unwrap();
642        drop(tx);
643        handle.await.unwrap();
644
645        // Scoped list: A sees exactly its two, B sees exactly its one.
646        let a_traces = load_tenant_traces(&db_path, "tenant-a").unwrap();
647        assert_eq!(a_traces.len(), 2, "A must see only A's traces");
648        assert!(a_traces.iter().all(|t| t.tenant_id == "tenant-a"));
649        let b_traces = load_tenant_traces(&db_path, "tenant-b").unwrap();
650        assert_eq!(b_traces.len(), 1, "B must see only B's trace");
651        assert!(b_traces.iter().all(|t| t.tenant_id == "tenant-b"));
652
653        // A can prove its own trace exists but cannot see B's, and vice-versa.
654        assert!(trace_exists(&db_path, "tenant-a", &a0_id).unwrap());
655        assert!(!trace_exists(&db_path, "tenant-a", &b0_id).unwrap());
656        assert!(trace_exists(&db_path, "tenant-b", &b0_id).unwrap());
657        assert!(!trace_exists(&db_path, "tenant-b", &a1_id).unwrap());
658
659        // The view is likewise scoped: cross-tenant reads are indistinguishable from a miss.
660        assert!(
661            load_trace_view(&db_path, "tenant-a", &a0_id)
662                .unwrap()
663                .is_some()
664        );
665        assert!(
666            load_trace_view(&db_path, "tenant-a", &b0_id)
667                .unwrap()
668                .is_none()
669        );
670        assert!(
671            load_trace_view(&db_path, "tenant-b", &a0_id)
672                .unwrap()
673                .is_none()
674        );
675
676        // A tenant that owns nothing sees nothing.
677        assert!(load_tenant_traces(&db_path, "ghost").unwrap().is_empty());
678
679        // Operator-wide read still spans both, and the global chain stays valid.
680        let all = load_all_traces(&db_path).unwrap();
681        assert_eq!(all.len(), 3);
682        verify_chain(&all, GENESIS_HASH).unwrap();
683
684        let _ = std::fs::remove_file(&db_path);
685    }
686
687    #[tokio::test]
688    async fn deferred_verdicts_attach_on_read_without_breaking_the_chain() {
689        let db_path = std::env::temp_dir().join(format!(
690            "firstpass-deferred-test-{}.db",
691            uuid::Uuid::now_v7()
692        ));
693        let (tx, handle) = open(&db_path).unwrap();
694        let t0 = sample_trace("acme", "run-1");
695        let t1 = sample_trace("acme", "run-1");
696        let (id0, id1) = (t0.trace_id.to_string(), t1.trace_id.to_string());
697        tx.try_send(t0).unwrap();
698        tx.try_send(t1).unwrap();
699        drop(tx);
700        handle.await.unwrap();
701
702        // A downstream outcome arrives for the first trace (e.g. "tests passed an hour later").
703        let dv = DeferredVerdict {
704            gate_id: "tests".to_owned(),
705            verdict: Verdict::Pass,
706            score: Some(Score::new(1.0).unwrap()),
707            reported_at: jiff::Timestamp::now(),
708            reporter: "ci".to_owned(),
709        };
710        append_deferred(&db_path, &id0, &dv).unwrap();
711        assert!(trace_exists(&db_path, "acme", &id0).unwrap());
712        assert!(!trace_exists(&db_path, "acme", "no-such-trace").unwrap());
713        // Cross-tenant: the same real trace id is invisible to a different tenant.
714        assert!(!trace_exists(&db_path, "other-tenant", &id0).unwrap());
715
716        // The view surfaces the deferred verdict...
717        let view = load_trace_view(&db_path, "acme", &id0).unwrap().unwrap();
718        assert_eq!(view.deferred.len(), 1);
719        assert_eq!(view.deferred[0].gate_id, "tests");
720        assert_eq!(view.deferred[0].verdict, Verdict::Pass);
721        // ...the second trace has none.
722        assert!(
723            load_trace_view(&db_path, "acme", &id1)
724                .unwrap()
725                .unwrap()
726                .deferred
727                .is_empty()
728        );
729        // Cross-tenant: another tenant cannot read this trace's view at all.
730        assert!(
731            load_trace_view(&db_path, "other-tenant", &id0)
732                .unwrap()
733                .is_none()
734        );
735
736        // THE INVARIANT: the sealed bodies are untouched, so the chain still verifies. A late
737        // outcome can never alter a past decision's hash.
738        let traces = load_all_traces(&db_path).unwrap();
739        assert!(
740            traces.iter().all(|t| t.deferred.is_empty()),
741            "sealed records stay deferred-free"
742        );
743        verify_chain(&traces, GENESIS_HASH).unwrap();
744
745        let _ = std::fs::remove_file(&db_path);
746    }
747
748    // ── Durable-receipts tests ────────────────────────────────────────────────
749
750    /// Test (2): `append_to_spill` writes valid JSON lines to the spill file.
751    #[test]
752    fn durable_append_to_spill_writes_valid_json_line() {
753        let db_path =
754            std::env::temp_dir().join(format!("firstpass-spill-write-{}.db", uuid::Uuid::now_v7()));
755        let spill_p = spill_path(&db_path);
756        let file = std::fs::OpenOptions::new()
757            .create(true)
758            .append(true)
759            .read(true)
760            .open(&spill_p)
761            .unwrap();
762        let handle = Arc::new(std::sync::Mutex::new(file));
763
764        let t = sample_trace("t", "s");
765        append_to_spill(&handle, &t).unwrap();
766
767        let content = std::fs::read_to_string(&spill_p).unwrap();
768        let lines: Vec<&str> = content.lines().collect();
769        assert_eq!(lines.len(), 1, "one line per spilled trace");
770        let parsed: Trace = serde_json::from_str(lines[0]).unwrap();
771        assert_eq!(parsed.trace_id, t.trace_id);
772
773        let _ = std::fs::remove_file(&spill_p);
774    }
775
776    /// Test (3): on boot with a pre-populated spill file, the writer drains it and
777    /// `verify_chain` passes over the full store.
778    #[tokio::test]
779    async fn drain_on_boot_restores_spilled_traces_and_chain_is_valid() {
780        let db_path =
781            std::env::temp_dir().join(format!("firstpass-drain-boot-{}.db", uuid::Uuid::now_v7()));
782        let spill_p = spill_path(&db_path);
783
784        // Pre-populate spill file with 2 traces (simulating backpressure from a previous run).
785        let t1 = sample_trace("t", "s1");
786        let t2 = sample_trace("t", "s2");
787        {
788            let file = std::fs::OpenOptions::new()
789                .create(true)
790                .append(true)
791                .read(true)
792                .open(&spill_p)
793                .unwrap();
794            let h = Arc::new(std::sync::Mutex::new(file));
795            append_to_spill(&h, &t1).unwrap();
796            append_to_spill(&h, &t2).unwrap();
797        }
798
799        // Open in durable mode — startup drain fires before any channel traffic.
800        let (tx, _spill, handle) = open_with_receipts(&db_path, ReceiptsMode::Durable).unwrap();
801        drop(tx); // close the channel immediately
802        handle.await.unwrap();
803
804        let traces = load_all_traces(&db_path).unwrap();
805        assert_eq!(traces.len(), 2, "both spilled traces recovered");
806        verify_chain(&traces, GENESIS_HASH).unwrap();
807
808        // Spill file must be empty after a successful drain.
809        let remaining = std::fs::read_to_string(&spill_p).unwrap();
810        assert!(
811            remaining.trim().is_empty(),
812            "spill file must be empty after drain"
813        );
814
815        let _ = std::fs::remove_file(&db_path);
816        let _ = std::fs::remove_file(&spill_p);
817    }
818
819    /// Test (4): spilled traces land before later channel traces in the store, preserving the
820    /// hash chain's append-only ordering.
821    #[tokio::test]
822    async fn spilled_traces_land_before_later_channel_traces() {
823        let db_path =
824            std::env::temp_dir().join(format!("firstpass-spill-order-{}.db", uuid::Uuid::now_v7()));
825        let spill_p = spill_path(&db_path);
826
827        // Pre-populate the spill file (as if these traces were spilled under backpressure).
828        let spilled = sample_trace("t", "spilled");
829        {
830            let file = std::fs::OpenOptions::new()
831                .create(true)
832                .append(true)
833                .read(true)
834                .open(&spill_p)
835                .unwrap();
836            let h = Arc::new(std::sync::Mutex::new(file));
837            append_to_spill(&h, &spilled).unwrap();
838        }
839
840        // Open in durable mode: startup drain inserts the spilled trace first.
841        let (tx, _spill, handle) = open_with_receipts(&db_path, ReceiptsMode::Durable).unwrap();
842        // Send a new channel trace after the store is open (arrives after startup drain).
843        let channel_trace = sample_trace("t", "channel");
844        tx.try_send(channel_trace.clone()).unwrap();
845        drop(tx);
846        handle.await.unwrap();
847
848        let traces = load_all_traces(&db_path).unwrap();
849        assert_eq!(traces.len(), 2, "spilled + channel trace");
850        // Spilled trace must come first (lower seq).
851        assert_eq!(
852            traces[0].trace_id, spilled.trace_id,
853            "spilled trace must precede channel trace"
854        );
855        assert_eq!(
856            traces[1].trace_id, channel_trace.trace_id,
857            "channel trace must follow spilled trace"
858        );
859        verify_chain(&traces, GENESIS_HASH).unwrap();
860
861        let _ = std::fs::remove_file(&db_path);
862        let _ = std::fs::remove_file(&spill_p);
863    }
864}