use rusqlite::{Connection, OptionalExtension};
use super::{parse_canonical_sources, ConversationStore};
use crate::conversation::ConversationRecord;
pub(super) const TURN_ENCODING_V1_PREFIX: &[u8] = b"newt-turn:v1";
pub(super) const TURN_ENCODING_V2_PREFIX: &[u8] = b"newt-turn:v2";
pub(super) const TURN_CONTENT_ID_PREFIX: &[u8] = b"newt-turn-content:v1";
pub(super) const TURN_ENCODING_VERSION_CURRENT: i64 = 2;
pub(super) const GENESIS_PREFIX: &[u8] = b"newt-turn-chain-genesis:v1";
#[derive(Debug)]
pub(super) struct TurnRow {
pub(super) conversation_id: String,
pub(super) writer_fingerprint: String,
pub(super) seq: i64,
pub(super) prev_hash: String,
pub(super) user: String,
pub(super) assistant: String,
pub(super) events: String,
pub(super) phantom_reaches: String,
pub(super) sources: String,
pub(super) tokens_in: Option<i64>,
pub(super) tokens_out: Option<i64>,
pub(super) ts_claim: i64,
pub(super) encoding_version: i64,
}
impl TurnRow {
pub(super) fn content_hash(&self) -> anyhow::Result<String> {
match self.encoding_version {
1 => Ok(blake3::hash(&self.canonical_encoding_v1())
.to_hex()
.to_string()),
2 => Ok(blake3::hash(&self.canonical_encoding_v2())
.to_hex()
.to_string()),
other => anyhow::bail!(
"turn (conversation `{}`, writer {}, seq {}) carries encoding_version {other}, \
which this newt does not understand (known: 1, 2) — upgrade newt to verify \
or extend this chain",
self.conversation_id,
self.writer_fingerprint,
self.seq
),
}
}
pub(super) fn content_id(&self) -> String {
turn_content_id(
&self.user,
&self.assistant,
&self.events,
&self.phantom_reaches,
&self.sources,
)
}
pub(super) fn canonical_encoding_v1(&self) -> Vec<u8> {
let mut out = Vec::with_capacity(
64 + self.conversation_id.len()
+ self.writer_fingerprint.len()
+ self.prev_hash.len()
+ self.user.len()
+ self.assistant.len()
+ self.events.len(),
);
out.extend_from_slice(TURN_ENCODING_V1_PREFIX);
for field in [
self.conversation_id.as_bytes(),
self.writer_fingerprint.as_bytes(),
self.prev_hash.as_bytes(),
self.user.as_bytes(),
self.assistant.as_bytes(),
self.events.as_bytes(),
] {
out.extend_from_slice(&(field.len() as u64).to_le_bytes());
out.extend_from_slice(field);
}
out.extend_from_slice(&self.seq.to_le_bytes());
for opt in [self.tokens_in, self.tokens_out] {
match opt {
Some(v) => {
out.push(1);
out.extend_from_slice(&v.to_le_bytes());
}
None => out.push(0),
}
}
out.extend_from_slice(&self.ts_claim.to_le_bytes());
out
}
pub(super) fn canonical_encoding_v2(&self) -> Vec<u8> {
let mut out = Vec::with_capacity(
64 + self.conversation_id.len()
+ self.writer_fingerprint.len()
+ self.prev_hash.len()
+ self.user.len()
+ self.assistant.len()
+ self.events.len()
+ self.phantom_reaches.len()
+ self.sources.len(),
);
out.extend_from_slice(TURN_ENCODING_V2_PREFIX);
for field in [
self.conversation_id.as_bytes(),
self.writer_fingerprint.as_bytes(),
self.prev_hash.as_bytes(),
self.user.as_bytes(),
self.assistant.as_bytes(),
self.events.as_bytes(),
self.phantom_reaches.as_bytes(),
self.sources.as_bytes(),
] {
out.extend_from_slice(&(field.len() as u64).to_le_bytes());
out.extend_from_slice(field);
}
out.extend_from_slice(&self.seq.to_le_bytes());
for opt in [self.tokens_in, self.tokens_out] {
match opt {
Some(v) => {
out.push(1);
out.extend_from_slice(&v.to_le_bytes());
}
None => out.push(0),
}
}
out.extend_from_slice(&self.ts_claim.to_le_bytes());
out
}
}
pub(super) fn turn_content_id(
user: &str,
assistant: &str,
events: &str,
phantom_reaches: &str,
sources: &str,
) -> String {
let mut buf = Vec::with_capacity(
32 + user.len() + assistant.len() + events.len() + phantom_reaches.len() + sources.len(),
);
buf.extend_from_slice(TURN_CONTENT_ID_PREFIX);
for field in [
user.as_bytes(),
assistant.as_bytes(),
events.as_bytes(),
phantom_reaches.as_bytes(),
sources.as_bytes(),
] {
buf.extend_from_slice(&(field.len() as u64).to_le_bytes());
buf.extend_from_slice(field);
}
blake3::hash(&buf).to_hex().to_string()
}
pub(super) fn turn_row_from_sql(row: &rusqlite::Row<'_>) -> rusqlite::Result<TurnRow> {
Ok(TurnRow {
conversation_id: row.get(0)?,
writer_fingerprint: row.get(1)?,
seq: row.get(2)?,
prev_hash: row.get(3)?,
user: row.get(4)?,
assistant: row.get(5)?,
events: row.get(6)?,
tokens_in: row.get(7)?,
tokens_out: row.get(8)?,
ts_claim: row.get(9)?,
encoding_version: row.get(10)?,
phantom_reaches: row.get(11)?,
sources: row.get(12)?,
})
}
pub(super) fn insert_turn_row(conn: &Connection, row: &TurnRow) -> anyhow::Result<()> {
conn.execute(
"INSERT INTO turns
(conversation_id, writer_fingerprint, seq, prev_hash, user, assistant,
events, tokens_in, tokens_out, ts_claim, encoding_version,
phantom_reaches, sources)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
rusqlite::params![
row.conversation_id,
row.writer_fingerprint,
row.seq,
row.prev_hash,
row.user,
row.assistant,
row.events,
row.tokens_in,
row.tokens_out,
row.ts_claim,
row.encoding_version,
row.phantom_reaches,
row.sources,
],
)?;
Ok(())
}
pub(super) fn genesis_hash(conversation_id: &str, writer_fingerprint: &str) -> String {
let mut hasher = blake3::Hasher::new();
hasher.update(GENESIS_PREFIX);
for field in [conversation_id.as_bytes(), writer_fingerprint.as_bytes()] {
hasher.update(&(field.len() as u64).to_le_bytes());
hasher.update(field);
}
hasher.finalize().to_hex().to_string()
}
pub(super) fn last_turn(
conn: &Connection,
conversation_id: &str,
writer_fingerprint: &str,
) -> anyhow::Result<Option<TurnRow>> {
Ok(conn
.query_row(
"SELECT conversation_id, writer_fingerprint, seq, prev_hash, user, assistant,
events, tokens_in, tokens_out, ts_claim, encoding_version,
phantom_reaches, sources
FROM turns
WHERE conversation_id = ?1 AND writer_fingerprint = ?2
ORDER BY seq DESC
LIMIT 1",
rusqlite::params![conversation_id, writer_fingerprint],
turn_row_from_sql,
)
.optional()?)
}
pub(super) fn turn_at_seq(
conn: &Connection,
conversation_id: &str,
writer_fingerprint: &str,
seq: i64,
) -> anyhow::Result<Option<TurnRow>> {
Ok(conn
.query_row(
"SELECT conversation_id, writer_fingerprint, seq, prev_hash, user, assistant,
events, tokens_in, tokens_out, ts_claim, encoding_version,
phantom_reaches, sources
FROM turns
WHERE conversation_id = ?1 AND writer_fingerprint = ?2 AND seq = ?3",
rusqlite::params![conversation_id, writer_fingerprint, seq],
turn_row_from_sql,
)
.optional()?)
}
pub(super) fn next_tick(conn: &Connection, writer_fingerprint: &str) -> anyhow::Result<i64> {
let bumped = conn.execute(
"UPDATE writer_clock SET last_tick = last_tick + 1 WHERE writer_fingerprint = ?1",
[writer_fingerprint],
)?;
if bumped == 0 {
conn.execute(
"INSERT OR IGNORE INTO writer_clock (writer_fingerprint, last_tick)
SELECT ?1, COALESCE(MAX(t), 0) FROM (
SELECT MAX(seq) AS t FROM turns
UNION ALL
-- A prompt is allocated before its turn and survives when
-- that turn fails. The clock must never reuse its sequence
-- after a lost/recreated writer_clock row.
SELECT MAX(seq) AS t FROM prompt_receipts
UNION ALL
SELECT MAX(activity_tick) AS t FROM conversations
UNION ALL
-- Other writers' issued ticks: keeps the seed at the true
-- issued-max even when their rows were pruned (review
-- finding N6 on #261 — Lamport receive rule over all
-- observable evidence, not just surviving rows).
SELECT MAX(last_tick) AS t FROM writer_clock
)",
[writer_fingerprint],
)?;
conn.execute(
"UPDATE writer_clock SET last_tick = last_tick + 1 WHERE writer_fingerprint = ?1",
[writer_fingerprint],
)?;
}
Ok(conn.query_row(
"SELECT last_tick FROM writer_clock WHERE writer_fingerprint = ?1",
[writer_fingerprint],
|row| row.get(0),
)?)
}
impl ConversationStore {
pub fn load_verified(&self, id: &str) -> anyhow::Result<ConversationRecord> {
let id = self.resolve_id(id)?;
let conn = self.lock_conn();
let tx = conn.unchecked_transaction()?;
Self::verify_conversation_chain(&tx, &id)
.map_err(|e| anyhow::anyhow!("refusing the restore — nothing has moved: {e:#}"))?;
let record = Self::load_record_on(&tx, &self.workspace_id, &id)?;
tx.commit()?;
Ok(record)
}
pub(crate) fn turn_refs(&self, id: &str) -> anyhow::Result<Vec<(String, String, String)>> {
let id = self.resolve_id(id)?;
let conn = self.lock_conn();
let mut stmt = conn.prepare(
"SELECT conversation_id, writer_fingerprint, seq, prev_hash, user, assistant,
events, tokens_in, tokens_out, ts_claim, encoding_version,
phantom_reaches, sources
FROM turns
WHERE conversation_id = ?1
ORDER BY seq ASC, writer_fingerprint ASC",
)?;
let rows = stmt
.query_map([&id], turn_row_from_sql)?
.collect::<Result<Vec<_>, _>>()?;
Ok(rows
.into_iter()
.map(|r| {
let content_id = r.content_id();
(r.user, r.assistant, content_id)
})
.collect())
}
pub fn verify_chain(&self, id: &str) -> anyhow::Result<()> {
let id = self.resolve_id(id)?;
let conn = self.lock_conn();
let tx = conn.unchecked_transaction()?;
Self::verify_conversation_chain(&tx, &id)?;
tx.commit()?;
Ok(())
}
fn verify_conversation_chain(conn: &Connection, id: &str) -> anyhow::Result<()> {
let (tip, tip_writer): (String, String) = conn
.query_row(
"SELECT tip_hash, writer_fingerprint FROM conversations WHERE id = ?1",
[&id],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.optional()?
.ok_or_else(|| anyhow::anyhow!("conversation `{id}` not found"))?;
let mut stmt = conn.prepare(
"SELECT conversation_id, writer_fingerprint, seq, prev_hash, user, assistant,
events, tokens_in, tokens_out, ts_claim, encoding_version,
phantom_reaches, sources
FROM turns
WHERE conversation_id = ?1
ORDER BY writer_fingerprint ASC, seq ASC",
)?;
let rows = stmt
.query_map([&id], turn_row_from_sql)?
.collect::<Result<Vec<_>, _>>()?;
let mut prev: Option<&TurnRow> = None;
for row in &rows {
let same_writer = prev.is_some_and(|p| p.writer_fingerprint == row.writer_fingerprint);
if same_writer {
let p = prev.expect("same_writer implies prev");
if row.seq <= p.seq {
anyhow::bail!(
"chain violation in `{id}`: writer {} seq {} not strictly \
after {}",
row.writer_fingerprint,
row.seq,
p.seq
);
}
if row.prev_hash != p.content_hash()? {
anyhow::bail!(
"chain violation in `{id}`: writer {} turn seq {} does not \
link to seq {} (row tampered or out of order)",
row.writer_fingerprint,
row.seq,
p.seq
);
}
} else {
let genesis = genesis_hash(id, &row.writer_fingerprint);
if row.prev_hash != genesis {
anyhow::bail!(
"chain violation in `{id}`: first turn of writer {} (seq {}) does \
not link to the genesis hash",
row.writer_fingerprint,
row.seq
);
}
}
prev = Some(row);
}
let content_ids: std::collections::HashSet<String> =
rows.iter().map(|r| r.content_id()).collect();
for row in &rows {
if row.encoding_version < 2 {
continue;
}
let cited = parse_canonical_sources(&row.sources).map_err(|e| {
anyhow::anyhow!(
"chain violation in `{id}`: writer {} turn seq {} carries a \
sources column that is not in canonical form ({e}) — \
well-formed evidence or none",
row.writer_fingerprint,
row.seq
)
})?;
if !cited.is_empty() {
if row.events != "[]" || row.phantom_reaches != "[]" {
anyhow::bail!(
"chain violation in `{id}`: writer {} turn seq {} claims \
derivation (non-empty sources) AND tool activity — a \
derived row is harness-minted and carries neither events \
nor phantom reaches",
row.writer_fingerprint,
row.seq
);
}
for source in &cited {
if !content_ids.contains(source) {
anyhow::bail!(
"chain violation in `{id}`: writer {} turn seq {} cites \
source `{source}`, which matches no turn in this \
conversation — an orphan citation is an unattributable \
assertion. Rows are left exactly as found.",
row.writer_fingerprint,
row.seq
);
}
}
}
}
Self::check_tip_witness(
id,
&tip,
&tip_writer,
rows.iter().rfind(|r| r.writer_fingerprint == tip_writer),
)?;
let mut witness_stmt = conn.prepare(
"SELECT writer_fingerprint, tip_hash, tip_seq FROM writer_tips
WHERE conversation_id = ?1",
)?;
let witnesses = witness_stmt
.query_map([&id], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, i64>(2)?,
))
})?
.collect::<Result<Vec<_>, _>>()?;
for (writer, tip_hash, tip_seq) in &witnesses {
let final_seq = rows
.iter()
.rfind(|r| &r.writer_fingerprint == writer)
.map(|r| r.seq);
match final_seq {
Some(final_seq) => {
let row_at = rows
.iter()
.find(|r| &r.writer_fingerprint == writer && r.seq == *tip_seq);
Self::check_writer_tip_witness(
id, writer, tip_hash, *tip_seq, final_seq, row_at,
)?;
}
None => {
if tip_hash != &genesis_hash(id, writer) || *tip_seq != 0 {
anyhow::bail!(
"chain violation in `{id}`: a witness exists for writer \
{writer}, which has no recorded turns, and it is not \
that writer's genesis witness — either the writer's \
turns were deleted or the witness was planted. Rows \
are left exactly as found."
);
}
}
}
}
Ok(())
}
pub(super) fn check_writer_tip_witness(
id: &str,
writer: &str,
tip_hash: &str,
tip_seq: i64,
final_seq: i64,
row_at_tip_seq: Option<&TurnRow>,
) -> anyhow::Result<()> {
if tip_seq > final_seq {
anyhow::bail!(
"chain violation in `{id}`: writer {writer}'s witness pins seq \
{tip_seq}, past its last recorded turn (seq {final_seq}) — turns \
were deleted out from under the witness. Rows are left exactly \
as found."
);
}
match row_at_tip_seq {
Some(row) => {
if tip_hash != row.content_hash()? {
anyhow::bail!(
"chain violation in `{id}`: writer {writer}'s witness \
disagrees with its turn at seq {tip_seq}. The per-turn \
links do not cover a writer's final turn, so this check \
cannot localize the alteration further. Rows are left \
exactly as found."
);
}
}
None => anyhow::bail!(
"chain violation in `{id}`: writer {writer}'s witness pins seq \
{tip_seq}, but no such turn exists. Rows are left exactly as \
found."
),
}
Ok(())
}
pub(super) fn check_tip_witness(
id: &str,
recorded_tip: &str,
tip_writer: &str,
final_row: Option<&TurnRow>,
) -> anyhow::Result<()> {
if recorded_tip.is_empty() {
return Ok(());
}
if tip_writer.is_empty() {
anyhow::bail!(
"chain violation in `{id}`: the conversation records a tip witness \
but no writer to attribute it to — a state no newt write path or \
migration produces. The witness columns themselves appear \
altered; rows are left exactly as found."
);
}
match final_row {
Some(row) => {
if recorded_tip != row.content_hash()? {
anyhow::bail!(
"chain violation in `{id}`: the tip witness disagrees with \
writer `{tip_writer}` at its final turn (seq {}). The \
per-turn links do not cover the final turn — nothing \
chains onto it — so this check cannot localize the \
alteration further: an altered final turn, an altered \
witness, and deleted trailing turns all produce exactly \
this disagreement. Rows are left exactly as found.",
row.seq
);
}
}
None => {
if recorded_tip != genesis_hash(id, tip_writer) {
anyhow::bail!(
"chain violation in `{id}`: the tip witness names writer \
`{tip_writer}`, which has no recorded turns, yet does not \
equal that writer's genesis hash. An altered witness, \
altered writer attribution, and deletion of that \
writer's turns all produce exactly this state; rows are \
left exactly as found."
);
}
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn clone_row(row: &TurnRow) -> TurnRow {
TurnRow {
conversation_id: row.conversation_id.clone(),
writer_fingerprint: row.writer_fingerprint.clone(),
seq: row.seq,
prev_hash: row.prev_hash.clone(),
user: row.user.clone(),
assistant: row.assistant.clone(),
events: row.events.clone(),
phantom_reaches: row.phantom_reaches.clone(),
sources: row.sources.clone(),
tokens_in: row.tokens_in,
tokens_out: row.tokens_out,
ts_claim: row.ts_claim,
encoding_version: row.encoding_version,
}
}
#[test]
fn canonical_encoding_is_unambiguous_across_field_boundaries() {
let base = TurnRow {
conversation_id: "c".into(),
writer_fingerprint: "w".into(),
seq: 1,
prev_hash: "p".into(),
user: "ab".into(),
assistant: "c".into(),
events: "[]".into(),
phantom_reaches: "[]".into(),
sources: "[]".into(),
tokens_in: None,
tokens_out: None,
ts_claim: 7,
encoding_version: 1,
};
let shifted = TurnRow {
user: "a".into(),
assistant: "bc".into(),
..clone_row(&base)
};
assert_ne!(
base.canonical_encoding_v1(),
shifted.canonical_encoding_v1(),
"length prefixes must prevent (ab,c) == (a,bc)"
);
let skewed = TurnRow {
ts_claim: 8,
..clone_row(&base)
};
assert_ne!(base.content_hash().unwrap(), skewed.content_hash().unwrap());
let tokens = TurnRow {
tokens_in: Some(5),
..clone_row(&base)
};
assert_ne!(base.content_hash().unwrap(), tokens.content_hash().unwrap());
}
#[test]
fn v1_known_answer_vectors() {
let row = TurnRow {
conversation_id: "kat-conv".into(),
writer_fingerprint: "kat-writer".into(),
seq: 3,
prev_hash: "kat-prev".into(),
user: "kat user".into(),
assistant: "kat assistant".into(),
events: "[]".into(),
phantom_reaches: "[]".into(),
sources: "[]".into(),
tokens_in: Some(11),
tokens_out: None,
ts_claim: 1234567890,
encoding_version: 1,
};
assert_eq!(
row.content_hash().unwrap(),
"a2166c898d5f471dab2c2cee07d85d192de3a8756d7a404b54dae49168f98a3f",
"the v1 canonical encoding changed — every stored v1 row would stop verifying"
);
assert_eq!(
genesis_hash("kat-conv", "kat-writer"),
"054ea5340e2930244bf81b23aedd809870e52a2ceb5cc6f4c42777fa5c9e92e8",
"the genesis hash changed — every stored chain root would stop verifying"
);
}
#[test]
fn v2_and_content_id_known_answer_vectors() {
let row = TurnRow {
conversation_id: "kat-conv".into(),
writer_fingerprint: "kat-writer".into(),
seq: 3,
prev_hash: "kat-prev".into(),
user: "kat user".into(),
assistant: "kat assistant".into(),
events: "[]".into(),
phantom_reaches: "[]".into(),
sources: "[]".into(),
tokens_in: Some(11),
tokens_out: None,
ts_claim: 1234567890,
encoding_version: 2,
};
assert_eq!(
row.content_hash().unwrap(),
"5c0f48e872ee477620df1fe8b3f764e2abe446547303de9748a5e60652cbb789",
"the v2 canonical encoding changed — every stored v2 row would stop verifying"
);
assert_eq!(
row.content_id(),
"e8974dcd81f696582b2b693570ec4fb7c0935703c4035eaecb2d934f3643f8fc",
"the content id changed — every recorded citation would orphan"
);
let v1 = TurnRow {
encoding_version: 1,
..clone_row(&row)
};
let v1_hash = v1.content_hash().unwrap();
assert_ne!(v1_hash, row.content_hash().unwrap());
assert_ne!(row.content_id(), v1_hash);
assert_ne!(row.content_id(), row.content_hash().unwrap());
let rechained = TurnRow {
conversation_id: "elsewhere".into(),
writer_fingerprint: "other-writer".into(),
seq: 999,
prev_hash: "other-prev".into(),
ts_claim: 1,
..clone_row(&row)
};
assert_eq!(rechained.content_id(), row.content_id());
assert_ne!(
rechained.content_hash().unwrap(),
row.content_hash().unwrap()
);
let derived = TurnRow {
sources: "[\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"]"
.into(),
..clone_row(&row)
};
assert_ne!(derived.content_id(), row.content_id());
}
#[test]
fn content_hash_rejects_unknown_encoding_versions() {
let v1 = TurnRow {
conversation_id: "c".into(),
writer_fingerprint: "w".into(),
seq: 1,
prev_hash: "p".into(),
user: "u".into(),
assistant: "a".into(),
events: "[]".into(),
phantom_reaches: "[]".into(),
sources: "[]".into(),
tokens_in: None,
tokens_out: None,
ts_claim: 7,
encoding_version: 1,
};
v1.content_hash().expect("v1 must hash");
let future = TurnRow {
encoding_version: 3,
..clone_row(&v1)
};
let err = future.content_hash().unwrap_err().to_string();
assert!(
err.contains("encoding_version 3") && err.contains("known: 1, 2"),
"unknown version must error clearly: {err}"
);
}
#[test]
fn genesis_hash_is_deterministic_and_writer_scoped() {
assert_eq!(genesis_hash("conv", "w1"), genesis_hash("conv", "w1"));
assert_ne!(genesis_hash("conv", "w1"), genesis_hash("conv", "w2"));
assert_ne!(genesis_hash("conv", "w1"), genesis_hash("other", "w1"));
assert_ne!(genesis_hash("ab", "c"), genesis_hash("a", "bc"));
}
}