use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use crate::canonical;
use crate::intent::IntentId;
use crate::operation::{OpId, StageId};
pub type AttestationId = String;
pub type SpecId = String;
pub type ContentHash = String;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum AttestationKind {
Examples {
file_hash: ContentHash,
count: usize,
},
Spec {
spec_id: SpecId,
method: SpecMethod,
#[serde(default, skip_serializing_if = "Option::is_none")]
trials: Option<usize>,
},
DiffBody {
other_body_hash: ContentHash,
input_count: usize,
},
TypeCheck,
EffectAudit,
SandboxRun {
effects: BTreeSet<String>,
},
Override {
actor: String,
reason: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
target_attestation_id: Option<AttestationId>,
},
Defer {
actor: String,
reason: String,
},
Block {
actor: String,
reason: String,
},
Unblock {
actor: String,
reason: String,
},
}
pub fn is_stage_blocked(attestations: &[Attestation]) -> bool {
let mut latest: Option<&Attestation> = None;
for a in attestations {
if !matches!(a.kind, AttestationKind::Block { .. } | AttestationKind::Unblock { .. }) {
continue;
}
match latest {
None => latest = Some(a),
Some(prev) if a.timestamp > prev.timestamp => latest = Some(a),
Some(prev) if a.timestamp == prev.timestamp
&& matches!(a.kind, AttestationKind::Unblock { .. }) =>
{
latest = Some(a);
}
_ => {}
}
}
matches!(latest.map(|a| &a.kind), Some(AttestationKind::Block { .. }))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SpecMethod {
Exhaustive,
Random,
Symbolic,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "result", rename_all = "snake_case")]
pub enum AttestationResult {
Passed,
Failed { detail: String },
Inconclusive { detail: String },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProducerDescriptor {
pub tool: String,
pub version: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Cost {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tokens_in: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tokens_out: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub usd_cents: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wall_time_ms: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Signature {
pub public_key: String,
pub signature: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Attestation {
pub attestation_id: AttestationId,
pub stage_id: StageId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub op_id: Option<OpId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub intent_id: Option<IntentId>,
pub kind: AttestationKind,
pub result: AttestationResult,
pub produced_by: ProducerDescriptor,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cost: Option<Cost>,
pub timestamp: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signature: Option<Signature>,
}
impl Attestation {
#[allow(clippy::too_many_arguments)]
pub fn new(
stage_id: impl Into<StageId>,
op_id: Option<OpId>,
intent_id: Option<IntentId>,
kind: AttestationKind,
result: AttestationResult,
produced_by: ProducerDescriptor,
cost: Option<Cost>,
) -> Self {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
Self::with_timestamp(stage_id, op_id, intent_id, kind, result, produced_by, cost, now)
}
#[allow(clippy::too_many_arguments)]
pub fn with_timestamp(
stage_id: impl Into<StageId>,
op_id: Option<OpId>,
intent_id: Option<IntentId>,
kind: AttestationKind,
result: AttestationResult,
produced_by: ProducerDescriptor,
cost: Option<Cost>,
timestamp: u64,
) -> Self {
let stage_id = stage_id.into();
let attestation_id = compute_attestation_id(
&stage_id,
op_id.as_deref(),
intent_id.as_deref(),
&kind,
&result,
&produced_by,
);
Self {
attestation_id,
stage_id,
op_id,
intent_id,
kind,
result,
produced_by,
cost,
timestamp,
signature: None,
}
}
pub fn with_signature(mut self, signature: Signature) -> Self {
self.signature = Some(signature);
self
}
}
fn compute_attestation_id(
stage_id: &str,
op_id: Option<&str>,
intent_id: Option<&str>,
kind: &AttestationKind,
result: &AttestationResult,
produced_by: &ProducerDescriptor,
) -> AttestationId {
let view = CanonicalAttestationView {
stage_id,
op_id,
intent_id,
kind,
result,
produced_by,
};
canonical::hash(&view)
}
#[derive(Serialize)]
struct CanonicalAttestationView<'a> {
stage_id: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
op_id: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
intent_id: Option<&'a str>,
kind: &'a AttestationKind,
result: &'a AttestationResult,
produced_by: &'a ProducerDescriptor,
}
pub struct AttestationLog {
dir: PathBuf,
by_stage: PathBuf,
}
impl AttestationLog {
pub fn open(root: &Path) -> io::Result<Self> {
let dir = root.join("attestations");
let by_stage = dir.join("by-stage");
fs::create_dir_all(&by_stage)?;
Ok(Self { dir, by_stage })
}
fn primary_path(&self, id: &AttestationId) -> PathBuf {
self.dir.join(format!("{id}.json"))
}
pub fn put(&self, attestation: &Attestation) -> io::Result<()> {
let primary = self.primary_path(&attestation.attestation_id);
if !primary.exists() {
let bytes = serde_json::to_vec(attestation)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
let tmp = primary.with_extension("json.tmp");
let mut f = fs::File::create(&tmp)?;
f.write_all(&bytes)?;
f.sync_all()?;
fs::rename(&tmp, &primary)?;
}
let stage_dir = self.by_stage.join(&attestation.stage_id);
fs::create_dir_all(&stage_dir)?;
let idx = stage_dir.join(&attestation.attestation_id);
if !idx.exists() {
fs::File::create(&idx)?;
}
Ok(())
}
pub fn get(&self, id: &AttestationId) -> io::Result<Option<Attestation>> {
let path = self.primary_path(id);
if !path.exists() {
return Ok(None);
}
let bytes = fs::read(&path)?;
let attestation: Attestation = serde_json::from_slice(&bytes)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
Ok(Some(attestation))
}
pub fn list_all(&self) -> io::Result<Vec<Attestation>> {
let mut out = Vec::new();
if !self.dir.exists() {
return Ok(out);
}
for entry in fs::read_dir(&self.dir)? {
let entry = entry?;
let p = entry.path();
if p.is_dir() {
continue;
}
if p.extension().is_none_or(|e| e != "json") {
continue;
}
let bytes = fs::read(&p)?;
match serde_json::from_slice::<Attestation>(&bytes) {
Ok(att) => out.push(att),
Err(e) => eprintln!(
"warning: skipping unreadable attestation {}: {e}",
p.display()
),
}
}
Ok(out)
}
pub fn list_for_stage(&self, stage_id: &StageId) -> io::Result<Vec<Attestation>> {
let stage_dir = self.by_stage.join(stage_id);
if !stage_dir.exists() {
return Ok(Vec::new());
}
let mut out = Vec::new();
for entry in fs::read_dir(&stage_dir)? {
let entry = entry?;
let id = match entry.file_name().into_string() {
Ok(s) => s,
Err(_) => continue,
};
if let Some(att) = self.get(&id)? {
out.push(att);
}
}
Ok(out)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ci_runner() -> ProducerDescriptor {
ProducerDescriptor {
tool: "lex check".into(),
version: "0.1.0".into(),
model: None,
}
}
fn typecheck_passed() -> Attestation {
Attestation::with_timestamp(
"stage-abc",
Some("op-123".into()),
None,
AttestationKind::TypeCheck,
AttestationResult::Passed,
ci_runner(),
None,
1000,
)
}
#[test]
fn same_logical_verification_hashes_equal() {
let a = typecheck_passed();
let b = Attestation::with_timestamp(
"stage-abc",
Some("op-123".into()),
None,
AttestationKind::TypeCheck,
AttestationResult::Passed,
ci_runner(),
Some(Cost {
tokens_in: Some(0),
tokens_out: Some(0),
usd_cents: Some(0),
wall_time_ms: Some(42),
}),
99999,
);
assert_eq!(a.attestation_id, b.attestation_id);
}
#[test]
fn different_stages_hash_differently() {
let a = typecheck_passed();
let b = Attestation::with_timestamp(
"stage-XYZ",
Some("op-123".into()),
None,
AttestationKind::TypeCheck,
AttestationResult::Passed,
ci_runner(),
None,
1000,
);
assert_ne!(a.attestation_id, b.attestation_id);
}
#[test]
fn different_op_ids_hash_differently() {
let a = typecheck_passed();
let b = Attestation::with_timestamp(
"stage-abc",
Some("op-XYZ".into()),
None,
AttestationKind::TypeCheck,
AttestationResult::Passed,
ci_runner(),
None,
1000,
);
assert_ne!(a.attestation_id, b.attestation_id);
}
#[test]
fn different_intents_hash_differently() {
let a = Attestation::with_timestamp(
"stage-abc", None,
Some("intent-A".into()),
AttestationKind::TypeCheck, AttestationResult::Passed,
ci_runner(), None, 1000,
);
let b = Attestation::with_timestamp(
"stage-abc", None,
Some("intent-B".into()),
AttestationKind::TypeCheck, AttestationResult::Passed,
ci_runner(), None, 1000,
);
assert_ne!(a.attestation_id, b.attestation_id);
}
#[test]
fn different_kinds_hash_differently() {
let a = typecheck_passed();
let b = Attestation::with_timestamp(
"stage-abc",
Some("op-123".into()),
None,
AttestationKind::EffectAudit,
AttestationResult::Passed,
ci_runner(),
None,
1000,
);
assert_ne!(a.attestation_id, b.attestation_id);
}
#[test]
fn passed_vs_failed_hash_differently() {
let a = typecheck_passed();
let b = Attestation::with_timestamp(
"stage-abc",
Some("op-123".into()),
None,
AttestationKind::TypeCheck,
AttestationResult::Failed { detail: "arity mismatch".into() },
ci_runner(),
None,
1000,
);
assert_ne!(a.attestation_id, b.attestation_id);
}
#[test]
fn different_producers_hash_differently() {
let a = typecheck_passed();
let mut other = ci_runner();
other.tool = "third-party-runner".into();
let b = Attestation::with_timestamp(
"stage-abc",
Some("op-123".into()),
None,
AttestationKind::TypeCheck,
AttestationResult::Passed,
other,
None,
1000,
);
assert_ne!(
a.attestation_id, b.attestation_id,
"an attestation from a different producer is a different fact",
);
}
#[test]
fn signature_is_excluded_from_hash() {
let a = typecheck_passed();
let b = typecheck_passed().with_signature(Signature {
public_key: "ed25519:fffe".into(),
signature: "0xabcd".into(),
});
assert_eq!(a.attestation_id, b.attestation_id);
}
#[test]
fn attestation_id_is_64_char_lowercase_hex() {
let a = typecheck_passed();
assert_eq!(a.attestation_id.len(), 64);
assert!(a
.attestation_id
.chars()
.all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c)));
}
#[test]
fn round_trip_through_serde_json() {
let a = Attestation::with_timestamp(
"stage-abc",
Some("op-123".into()),
Some("intent-A".into()),
AttestationKind::Spec {
spec_id: "clamp.spec".into(),
method: SpecMethod::Random,
trials: Some(1000),
},
AttestationResult::Passed,
ProducerDescriptor {
tool: "lex agent-tool".into(),
version: "0.1.0".into(),
model: Some("claude-opus-4-7".into()),
},
Some(Cost {
tokens_in: Some(1234),
tokens_out: Some(567),
usd_cents: Some(2),
wall_time_ms: Some(3400),
}),
99,
)
.with_signature(Signature {
public_key: "ed25519:abc".into(),
signature: "0x1234".into(),
});
let json = serde_json::to_string(&a).unwrap();
let back: Attestation = serde_json::from_str(&json).unwrap();
assert_eq!(a, back);
}
#[test]
fn canonical_form_is_stable_for_a_known_input() {
let a = Attestation::with_timestamp(
"stage-abc",
Some("op-123".into()),
None,
AttestationKind::TypeCheck,
AttestationResult::Passed,
ProducerDescriptor {
tool: "lex check".into(),
version: "0.1.0".into(),
model: None,
},
None,
0,
);
assert_eq!(
a.attestation_id,
"a4ef921f7bb0db70779c5b698cda1744d49165a4a56aa8414bdbafc85bcbc16b",
"canonical-form regression: the AttestationId for a known input changed",
);
}
#[test]
fn log_round_trips_through_disk() {
let tmp = tempfile::tempdir().unwrap();
let log = AttestationLog::open(tmp.path()).unwrap();
let a = typecheck_passed();
log.put(&a).unwrap();
let read_back = log.get(&a.attestation_id).unwrap().unwrap();
assert_eq!(a, read_back);
}
#[test]
fn log_get_unknown_returns_none() {
let tmp = tempfile::tempdir().unwrap();
let log = AttestationLog::open(tmp.path()).unwrap();
assert!(log
.get(&"nonexistent".to_string())
.unwrap()
.is_none());
}
#[test]
fn log_put_is_idempotent() {
let tmp = tempfile::tempdir().unwrap();
let log = AttestationLog::open(tmp.path()).unwrap();
let a = typecheck_passed();
log.put(&a).unwrap();
log.put(&a).unwrap();
let read_back = log.get(&a.attestation_id).unwrap().unwrap();
assert_eq!(a, read_back);
}
#[test]
fn list_for_stage_returns_only_that_stage() {
let tmp = tempfile::tempdir().unwrap();
let log = AttestationLog::open(tmp.path()).unwrap();
let on_abc_1 = typecheck_passed();
let on_abc_2 = Attestation::with_timestamp(
"stage-abc",
Some("op-123".into()),
None,
AttestationKind::EffectAudit,
AttestationResult::Passed,
ci_runner(),
None,
2000,
);
let on_xyz = Attestation::with_timestamp(
"stage-xyz",
Some("op-456".into()),
None,
AttestationKind::TypeCheck,
AttestationResult::Passed,
ci_runner(),
None,
1000,
);
log.put(&on_abc_1).unwrap();
log.put(&on_abc_2).unwrap();
log.put(&on_xyz).unwrap();
let mut on_abc = log.list_for_stage(&"stage-abc".to_string()).unwrap();
on_abc.sort_by_key(|a| a.timestamp);
assert_eq!(on_abc.len(), 2);
assert_eq!(on_abc[0], on_abc_1);
assert_eq!(on_abc[1], on_abc_2);
let on_xyz_listed = log.list_for_stage(&"stage-xyz".to_string()).unwrap();
assert_eq!(on_xyz_listed.len(), 1);
assert_eq!(on_xyz_listed[0], on_xyz);
}
#[test]
fn list_for_unknown_stage_is_empty() {
let tmp = tempfile::tempdir().unwrap();
let log = AttestationLog::open(tmp.path()).unwrap();
let v = log.list_for_stage(&"never-attested".to_string()).unwrap();
assert!(v.is_empty());
}
#[test]
fn list_all_returns_every_persisted_attestation() {
let tmp = tempfile::tempdir().unwrap();
let log = AttestationLog::open(tmp.path()).unwrap();
let on_abc = typecheck_passed();
let on_xyz = Attestation::with_timestamp(
"stage-xyz",
Some("op-456".into()),
None,
AttestationKind::TypeCheck,
AttestationResult::Passed,
ci_runner(),
None,
2000,
);
log.put(&on_abc).unwrap();
log.put(&on_xyz).unwrap();
let mut all = log.list_all().unwrap();
all.sort_by_key(|a| a.attestation_id.clone());
assert_eq!(all.len(), 2);
let ids: BTreeSet<_> = all.iter().map(|a| a.attestation_id.clone()).collect();
assert!(ids.contains(&on_abc.attestation_id));
assert!(ids.contains(&on_xyz.attestation_id));
}
#[test]
fn list_all_on_empty_log_is_empty() {
let tmp = tempfile::tempdir().unwrap();
let log = AttestationLog::open(tmp.path()).unwrap();
let v = log.list_all().unwrap();
assert!(v.is_empty());
}
#[test]
fn passed_and_failed_for_same_stage_both_persist() {
let tmp = tempfile::tempdir().unwrap();
let log = AttestationLog::open(tmp.path()).unwrap();
let passed = typecheck_passed();
let failed = Attestation::with_timestamp(
"stage-abc",
Some("op-123".into()),
None,
AttestationKind::TypeCheck,
AttestationResult::Failed { detail: "arity mismatch".into() },
ci_runner(),
None,
500,
);
log.put(&failed).unwrap();
log.put(&passed).unwrap();
let listing = log.list_for_stage(&"stage-abc".to_string()).unwrap();
assert_eq!(listing.len(), 2, "both passing and failing evidence must persist");
}
fn human_decision(kind: AttestationKind, ts: u64) -> Attestation {
Attestation::with_timestamp(
"stage-abc",
None, None,
kind,
AttestationResult::Passed,
ProducerDescriptor {
tool: "lex stage".into(),
version: "0.1.0".into(),
model: None,
},
None,
ts,
)
}
#[test]
fn is_stage_blocked_empty_log_is_false() {
assert!(!is_stage_blocked(&[]));
}
#[test]
fn is_stage_blocked_only_unrelated_attestations() {
let attestations = vec![
typecheck_passed(),
human_decision(
AttestationKind::Override {
actor: "alice".into(),
reason: "ship".into(),
target_attestation_id: None,
},
500,
),
];
assert!(!is_stage_blocked(&attestations));
}
#[test]
fn is_stage_blocked_block_alone_blocks() {
let attestations = vec![human_decision(
AttestationKind::Block { actor: "alice".into(), reason: "x".into() },
500,
)];
assert!(is_stage_blocked(&attestations));
}
#[test]
fn is_stage_blocked_later_unblock_clears_block() {
let attestations = vec![
human_decision(
AttestationKind::Block { actor: "alice".into(), reason: "x".into() },
500,
),
human_decision(
AttestationKind::Unblock { actor: "alice".into(), reason: "ok".into() },
600,
),
];
assert!(!is_stage_blocked(&attestations));
}
#[test]
fn is_stage_blocked_later_block_re_blocks() {
let attestations = vec![
human_decision(
AttestationKind::Block { actor: "a".into(), reason: "1".into() },
500,
),
human_decision(
AttestationKind::Unblock { actor: "a".into(), reason: "2".into() },
600,
),
human_decision(
AttestationKind::Block { actor: "a".into(), reason: "3".into() },
700,
),
];
assert!(is_stage_blocked(&attestations));
}
#[test]
fn is_stage_blocked_unblock_wins_at_same_timestamp() {
let attestations = vec![
human_decision(
AttestationKind::Block { actor: "a".into(), reason: "1".into() },
500,
),
human_decision(
AttestationKind::Unblock { actor: "a".into(), reason: "2".into() },
500,
),
];
assert!(!is_stage_blocked(&attestations));
}
}