proofborne-core 0.1.0-alpha.3

Versioned contracts, events, provider types, and proof graph for Proofborne
Documentation
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use thiserror::Error;
use uuid::Uuid;

use crate::hash_json;

/// Current stable external schema identifier.
pub const SCHEMA_VERSION: &str = "proofborne.v1";

/// One append-only, hash-chained session event.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct EventEnvelope {
    /// External schema version.
    pub schema_version: String,
    /// Session identifier.
    pub session_id: Uuid,
    /// Zero-based monotonic event sequence.
    pub seq: u64,
    /// Event timestamp.
    pub timestamp: DateTime<Utc>,
    /// Extensible dotted event name.
    pub kind: String,
    /// Event-specific public payload.
    pub payload: Value,
    /// Hash of the previous event, absent for the genesis event.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub prev_hash: Option<String>,
    /// BLAKE3 digest of canonical hash material.
    pub hash: String,
}

impl EventEnvelope {
    /// Creates an event using the current UTC time.
    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)
    }

    /// Creates an event at a supplied time, primarily for deterministic fixtures.
    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,
        }
    }

    /// Verifies this event's digest without looking at neighbors.
    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,
    }))
}

/// Verifies sequence numbers, session identity, previous hashes, and event hashes.
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(())
}

/// Hash-chain validation failures.
#[derive(Debug, Error, PartialEq, Eq)]
pub enum EventError {
    /// Sequence numbers are not contiguous.
    #[error("event sequence mismatch: expected {expected}, got {actual}")]
    Sequence {
        /// Expected sequence number.
        expected: u64,
        /// Observed sequence number.
        actual: u64,
    },
    /// An event belongs to another session.
    #[error("event {seq} belongs to another session")]
    SessionMismatch {
        /// Invalid sequence.
        seq: u64,
    },
    /// An event points at the wrong predecessor.
    #[error("event {seq} has an invalid previous hash")]
    PreviousHash {
        /// Invalid sequence.
        seq: u64,
    },
    /// An event digest was modified.
    #[error("event {seq} digest verification failed")]
    Digest {
        /// Invalid sequence.
        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 })
        );
    }
}