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
9pub const SCHEMA_VERSION: &str = "proofborne.v1";
11
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
14#[serde(rename_all = "camelCase")]
15pub struct EventEnvelope {
16 pub schema_version: String,
18 pub session_id: Uuid,
20 pub seq: u64,
22 pub timestamp: DateTime<Utc>,
24 pub kind: String,
26 pub payload: Value,
28 #[serde(skip_serializing_if = "Option::is_none")]
30 pub prev_hash: Option<String>,
31 pub hash: String,
33}
34
35impl EventEnvelope {
36 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 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 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
111pub 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#[derive(Debug, Error, PartialEq, Eq)]
143pub enum EventError {
144 #[error("event sequence mismatch: expected {expected}, got {actual}")]
146 Sequence {
147 expected: u64,
149 actual: u64,
151 },
152 #[error("event {seq} belongs to another session")]
154 SessionMismatch {
155 seq: u64,
157 },
158 #[error("event {seq} has an invalid previous hash")]
160 PreviousHash {
161 seq: u64,
163 },
164 #[error("event {seq} digest verification failed")]
166 Digest {
167 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}