use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use thiserror::Error;
use uuid::Uuid;
use crate::hash_json;
pub const SCHEMA_VERSION: &str = "proofborne.v1";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct EventEnvelope {
pub schema_version: String,
pub session_id: Uuid,
pub seq: u64,
pub timestamp: DateTime<Utc>,
pub kind: String,
pub payload: Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub prev_hash: Option<String>,
pub hash: String,
}
impl EventEnvelope {
pub fn new(
session_id: Uuid,
seq: u64,
kind: impl Into<String>,
payload: Value,
prev_hash: Option<String>,
) -> Self {
Self::new_at(session_id, seq, Utc::now(), kind, payload, prev_hash)
}
pub fn new_at(
session_id: Uuid,
seq: u64,
timestamp: DateTime<Utc>,
kind: impl Into<String>,
payload: Value,
prev_hash: Option<String>,
) -> Self {
let kind = kind.into();
let hash = event_hash(
session_id,
seq,
timestamp,
&kind,
&payload,
prev_hash.as_deref(),
);
Self {
schema_version: SCHEMA_VERSION.to_owned(),
session_id,
seq,
timestamp,
kind,
payload,
prev_hash,
hash,
}
}
pub fn verify_hash(&self) -> bool {
self.schema_version == SCHEMA_VERSION
&& self.hash
== event_hash(
self.session_id,
self.seq,
self.timestamp,
&self.kind,
&self.payload,
self.prev_hash.as_deref(),
)
}
}
fn event_hash(
session_id: Uuid,
seq: u64,
timestamp: DateTime<Utc>,
kind: &str,
payload: &Value,
prev_hash: Option<&str>,
) -> String {
hash_json(&json!({
"schemaVersion": SCHEMA_VERSION,
"sessionId": session_id,
"seq": seq,
"timestamp": timestamp,
"kind": kind,
"payload": payload,
"prevHash": prev_hash,
}))
}
pub fn verify_event_chain(events: &[EventEnvelope]) -> Result<(), EventError> {
let Some(first) = events.first() else {
return Ok(());
};
let session_id = first.session_id;
for (index, event) in events.iter().enumerate() {
let expected_seq = index as u64;
if event.seq != expected_seq {
return Err(EventError::Sequence {
expected: expected_seq,
actual: event.seq,
});
}
if event.session_id != session_id {
return Err(EventError::SessionMismatch { seq: event.seq });
}
let expected_previous = index
.checked_sub(1)
.map(|previous| events[previous].hash.as_str());
if event.prev_hash.as_deref() != expected_previous {
return Err(EventError::PreviousHash { seq: event.seq });
}
if !event.verify_hash() {
return Err(EventError::Digest { seq: event.seq });
}
}
Ok(())
}
#[derive(Debug, Error, PartialEq, Eq)]
pub enum EventError {
#[error("event sequence mismatch: expected {expected}, got {actual}")]
Sequence {
expected: u64,
actual: u64,
},
#[error("event {seq} belongs to another session")]
SessionMismatch {
seq: u64,
},
#[error("event {seq} has an invalid previous hash")]
PreviousHash {
seq: u64,
},
#[error("event {seq} digest verification failed")]
Digest {
seq: u64,
},
}
#[cfg(test)]
mod tests {
use chrono::TimeZone;
use serde_json::json;
use super::*;
#[test]
fn detects_payload_tampering() {
let session = Uuid::nil();
let at = Utc.with_ymd_and_hms(2026, 7, 29, 12, 0, 0).unwrap();
let first = EventEnvelope::new_at(session, 0, at, "session.created", json!({}), None);
let second = EventEnvelope::new_at(
session,
1,
at,
"task.contract",
json!({"goal": "safe"}),
Some(first.hash.clone()),
);
let mut events = vec![first, second];
assert!(verify_event_chain(&events).is_ok());
events[1].payload = json!({"goal": "changed"});
assert_eq!(
verify_event_chain(&events),
Err(EventError::Digest { seq: 1 })
);
}
#[test]
fn schema_version_is_committed_by_verification() {
let session = Uuid::now_v7();
let mut event = EventEnvelope::new(session, 0, "session.created", json!({}), None);
event.schema_version = "attacker.v1".to_owned();
assert_eq!(
verify_event_chain(&[event]),
Err(EventError::Digest { seq: 0 })
);
}
}