use rusqlite::{params, Connection};
use sha2::{Digest, Sha256};
use crate::error::{Error, Result};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RationaleRow {
pub rationale_id: String,
pub rationale_text: String,
pub provider: String,
pub model: String,
}
pub fn rationale_id_for(text: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(text.as_bytes());
format!("{:x}", hasher.finalize())
}
#[allow(clippy::too_many_arguments)]
pub fn insert_rationale(
conn: &Connection,
session_id: Option<&str>,
provider: &str,
model: &str,
prompt_bundle_id: &str,
policy_hash: &str,
rationale_text: &str,
generated_at: i64,
) -> Result<String> {
let rationale_id = rationale_id_for(rationale_text);
let inserted = conn.execute(
"INSERT OR IGNORE INTO reasoning_rationales (
rationale_id, session_id, provider, model,
prompt_bundle_id, policy_hash, rationale_text, generated_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
params![
rationale_id,
session_id,
provider,
model,
prompt_bundle_id,
policy_hash,
rationale_text,
generated_at,
],
)?;
if inserted == 0 {
let (
existing_session_id,
existing_provider,
existing_model,
existing_bundle,
existing_hash,
): (Option<String>, String, String, String, String) = conn.query_row(
"SELECT session_id, provider, model, prompt_bundle_id, policy_hash
FROM reasoning_rationales WHERE rationale_id = ?1",
params![rationale_id],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?)),
)?;
let mut mismatches: Vec<&'static str> = Vec::new();
if existing_session_id.as_deref() != session_id {
mismatches.push("session_id");
}
if existing_provider != provider {
mismatches.push("provider");
}
if existing_model != model {
mismatches.push("model");
}
if existing_bundle != prompt_bundle_id {
mismatches.push("prompt_bundle_id");
}
if existing_hash != policy_hash {
mismatches.push("policy_hash");
}
if !mismatches.is_empty() {
return Err(Error::RationaleProvenanceConflict(format!(
"rationale {rationale_id} already persisted with different {fields}",
fields = mismatches.join(", ")
)));
}
}
Ok(rationale_id)
}
pub fn get_rationale(conn: &Connection, rationale_id: &str) -> Result<Option<RationaleRow>> {
match conn.query_row(
"SELECT rationale_id, rationale_text, provider, model
FROM reasoning_rationales WHERE rationale_id = ?1",
params![rationale_id],
|r| {
Ok(RationaleRow {
rationale_id: r.get(0)?,
rationale_text: r.get(1)?,
provider: r.get(2)?,
model: r.get(3)?,
})
},
) {
Ok(row) => Ok(Some(row)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e.into()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::migrations::run_migrations;
use crate::db::open_in_memory;
fn fresh() -> Connection {
let mut conn = open_in_memory().unwrap();
run_migrations(&mut conn).unwrap();
conn
}
#[test]
fn rationale_id_matches_sha256_of_text() {
assert_eq!(
rationale_id_for("abc"),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
}
#[test]
fn insert_round_trips_two_distinct_rationales() {
let conn = fresh();
let id_a = insert_rationale(
&conn,
None,
"openai",
"gpt-5",
"phase3-default",
"pol-hash-a",
"first rationale",
1000,
)
.unwrap();
let id_b = insert_rationale(
&conn,
None,
"openai",
"gpt-5",
"phase3-default",
"pol-hash-a",
"second rationale — different bytes",
1001,
)
.unwrap();
assert_ne!(id_a, id_b, "different texts must produce different ids");
let row_a = get_rationale(&conn, &id_a).unwrap().expect("row a");
assert_eq!(row_a.rationale_text, "first rationale");
assert_eq!(row_a.provider, "openai");
assert_eq!(row_a.model, "gpt-5");
let row_b = get_rationale(&conn, &id_b).unwrap().expect("row b");
assert_eq!(row_b.rationale_text, "second rationale — different bytes");
}
#[test]
fn inserting_same_text_twice_is_idempotent() {
let conn = fresh();
let id1 = insert_rationale(
&conn,
None,
"openai",
"gpt-5",
"phase3-default",
"pol-hash",
"the same rationale text",
100,
)
.unwrap();
let id2 = insert_rationale(
&conn,
None,
"openai",
"gpt-5",
"phase3-default",
"pol-hash",
"the same rationale text",
200, )
.unwrap();
assert_eq!(id1, id2, "content-addressed id must match");
let count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM reasoning_rationales WHERE rationale_id = ?1",
params![id1],
|r| r.get(0),
)
.unwrap();
assert_eq!(count, 1, "replay must not produce a second row");
}
#[test]
fn get_rationale_returns_none_for_unknown_id() {
let conn = fresh();
assert!(get_rationale(&conn, "deadbeef").unwrap().is_none());
}
#[test]
fn same_text_with_divergent_provenance_is_rejected() {
let conn = fresh();
insert_rationale(
&conn,
None,
"openai",
"gpt-5",
"phase3-default",
"pol-hash-1",
"the exact same rationale text",
100,
)
.unwrap();
let err = insert_rationale(
&conn,
None,
"openai",
"gpt-5",
"phase3-default",
"pol-hash-2-different",
"the exact same rationale text",
200,
)
.expect_err("divergent provenance on an existing content-hash must error");
match err {
crate::error::Error::RationaleProvenanceConflict(msg) => {
assert!(
msg.contains("policy_hash"),
"error should list the conflicting fields: {msg}"
);
}
other => panic!("expected RationaleProvenanceConflict, got {other}"),
}
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM reasoning_rationales", [], |r| {
r.get(0)
})
.unwrap();
assert_eq!(count, 1);
}
}