Skip to main content

proofborne_core/
event.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use serde_json::{Value, json};
4use thiserror::Error;
5use uuid::Uuid;
6
7use crate::hash_json;
8
9/// Current stable external schema identifier.
10pub const SCHEMA_VERSION: &str = "proofborne.v1";
11
12/// One append-only, hash-chained session event.
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
14#[serde(rename_all = "camelCase")]
15pub struct EventEnvelope {
16    /// External schema version.
17    pub schema_version: String,
18    /// Session identifier.
19    pub session_id: Uuid,
20    /// Zero-based monotonic event sequence.
21    pub seq: u64,
22    /// Event timestamp.
23    pub timestamp: DateTime<Utc>,
24    /// Extensible dotted event name.
25    pub kind: String,
26    /// Event-specific public payload.
27    pub payload: Value,
28    /// Hash of the previous event, absent for the genesis event.
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub prev_hash: Option<String>,
31    /// BLAKE3 digest of canonical hash material.
32    pub hash: String,
33}
34
35impl EventEnvelope {
36    /// Creates an event using the current UTC time.
37    pub fn new(
38        session_id: Uuid,
39        seq: u64,
40        kind: impl Into<String>,
41        payload: Value,
42        prev_hash: Option<String>,
43    ) -> Self {
44        Self::new_at(session_id, seq, Utc::now(), kind, payload, prev_hash)
45    }
46
47    /// Creates an event at a supplied time, primarily for deterministic fixtures.
48    pub fn new_at(
49        session_id: Uuid,
50        seq: u64,
51        timestamp: DateTime<Utc>,
52        kind: impl Into<String>,
53        payload: Value,
54        prev_hash: Option<String>,
55    ) -> Self {
56        let kind = kind.into();
57        let hash = event_hash(
58            session_id,
59            seq,
60            timestamp,
61            &kind,
62            &payload,
63            prev_hash.as_deref(),
64        );
65        Self {
66            schema_version: SCHEMA_VERSION.to_owned(),
67            session_id,
68            seq,
69            timestamp,
70            kind,
71            payload,
72            prev_hash,
73            hash,
74        }
75    }
76
77    /// Verifies this event's digest without looking at neighbors.
78    pub fn verify_hash(&self) -> bool {
79        self.schema_version == SCHEMA_VERSION
80            && self.hash
81                == event_hash(
82                    self.session_id,
83                    self.seq,
84                    self.timestamp,
85                    &self.kind,
86                    &self.payload,
87                    self.prev_hash.as_deref(),
88                )
89    }
90}
91
92fn event_hash(
93    session_id: Uuid,
94    seq: u64,
95    timestamp: DateTime<Utc>,
96    kind: &str,
97    payload: &Value,
98    prev_hash: Option<&str>,
99) -> String {
100    hash_json(&json!({
101        "schemaVersion": SCHEMA_VERSION,
102        "sessionId": session_id,
103        "seq": seq,
104        "timestamp": timestamp,
105        "kind": kind,
106        "payload": payload,
107        "prevHash": prev_hash,
108    }))
109}
110
111/// Verifies sequence numbers, session identity, previous hashes, and event hashes.
112pub fn verify_event_chain(events: &[EventEnvelope]) -> Result<(), EventError> {
113    let Some(first) = events.first() else {
114        return Ok(());
115    };
116    let session_id = first.session_id;
117    for (index, event) in events.iter().enumerate() {
118        let expected_seq = index as u64;
119        if event.seq != expected_seq {
120            return Err(EventError::Sequence {
121                expected: expected_seq,
122                actual: event.seq,
123            });
124        }
125        if event.session_id != session_id {
126            return Err(EventError::SessionMismatch { seq: event.seq });
127        }
128        let expected_previous = index
129            .checked_sub(1)
130            .map(|previous| events[previous].hash.as_str());
131        if event.prev_hash.as_deref() != expected_previous {
132            return Err(EventError::PreviousHash { seq: event.seq });
133        }
134        if !event.verify_hash() {
135            return Err(EventError::Digest { seq: event.seq });
136        }
137    }
138    Ok(())
139}
140
141/// Hash-chain validation failures.
142#[derive(Debug, Error, PartialEq, Eq)]
143pub enum EventError {
144    /// Sequence numbers are not contiguous.
145    #[error("event sequence mismatch: expected {expected}, got {actual}")]
146    Sequence {
147        /// Expected sequence number.
148        expected: u64,
149        /// Observed sequence number.
150        actual: u64,
151    },
152    /// An event belongs to another session.
153    #[error("event {seq} belongs to another session")]
154    SessionMismatch {
155        /// Invalid sequence.
156        seq: u64,
157    },
158    /// An event points at the wrong predecessor.
159    #[error("event {seq} has an invalid previous hash")]
160    PreviousHash {
161        /// Invalid sequence.
162        seq: u64,
163    },
164    /// An event digest was modified.
165    #[error("event {seq} digest verification failed")]
166    Digest {
167        /// Invalid sequence.
168        seq: u64,
169    },
170}
171
172#[cfg(test)]
173mod tests {
174    use chrono::TimeZone;
175    use serde_json::json;
176
177    use super::*;
178
179    #[test]
180    fn detects_payload_tampering() {
181        let session = Uuid::nil();
182        let at = Utc.with_ymd_and_hms(2026, 7, 29, 12, 0, 0).unwrap();
183        let first = EventEnvelope::new_at(session, 0, at, "session.created", json!({}), None);
184        let second = EventEnvelope::new_at(
185            session,
186            1,
187            at,
188            "task.contract",
189            json!({"goal": "safe"}),
190            Some(first.hash.clone()),
191        );
192        let mut events = vec![first, second];
193        assert!(verify_event_chain(&events).is_ok());
194        events[1].payload = json!({"goal": "changed"});
195        assert_eq!(
196            verify_event_chain(&events),
197            Err(EventError::Digest { seq: 1 })
198        );
199    }
200
201    #[test]
202    fn schema_version_is_committed_by_verification() {
203        let session = Uuid::now_v7();
204        let mut event = EventEnvelope::new(session, 0, "session.created", json!({}), None);
205        event.schema_version = "attacker.v1".to_owned();
206        assert_eq!(
207            verify_event_chain(&[event]),
208            Err(EventError::Digest { seq: 0 })
209        );
210    }
211}