Skip to main content

aex_audit/
event.rs

1//! Event types, canonical serialization, and hash computation.
2//!
3//! Canonical bytes (what gets hashed) use deterministic JSON with sorted
4//! keys. This is the same discipline as [JCS](https://www.rfc-editor.org/rfc/rfc8785.html)
5//! but limited to the small subset of JSON we emit — we don't accept
6//! arbitrary user JSON in the payload, callers build it via structured
7//! Rust types so ordering is under our control.
8
9use serde::{Deserialize, Serialize};
10use sha2::{Digest, Sha256};
11use time::OffsetDateTime;
12
13use crate::{AuditError, AuditResult, GENESIS_HEAD};
14
15/// High-level classification of the action recorded.
16///
17/// These strings are part of the canonical bytes hashed into the audit
18/// chain. **Never rename a variant** — old events would stop verifying.
19/// To extend, add a new variant.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum EventKind {
23    AgentRegistered,
24    AgentRevoked,
25    TransferInitiated,
26    TransferPolicyDecision,
27    TransferScannerVerdict,
28    TransferAccepted,
29    TransferDelivered,
30    TransferRejected,
31    TransferExpired,
32    /// Deferred decision (ADR-0049) — the policy engine returned
33    /// `Pending` and the control plane signed an
34    /// `aex-decision-request:v2` for the sender.
35    DeferredDecisionRequested,
36    /// Deferred decision (ADR-0049) — a signed
37    /// `aex-decision-response:v2` was emitted with the final
38    /// accept/reject outcome. The payload SHOULD include the
39    /// decision_id, the outcome, and the optional reason; together
40    /// with the actor (the recipient agent) this gives a
41    /// non-repudiable receipt of the decision.
42    SignedDecisionReceipt,
43}
44
45impl EventKind {
46    pub fn as_str(&self) -> &'static str {
47        match self {
48            EventKind::AgentRegistered => "agent_registered",
49            EventKind::AgentRevoked => "agent_revoked",
50            EventKind::TransferInitiated => "transfer_initiated",
51            EventKind::TransferPolicyDecision => "transfer_policy_decision",
52            EventKind::TransferScannerVerdict => "transfer_scanner_verdict",
53            EventKind::TransferAccepted => "transfer_accepted",
54            EventKind::TransferDelivered => "transfer_delivered",
55            EventKind::TransferRejected => "transfer_rejected",
56            EventKind::TransferExpired => "transfer_expired",
57            EventKind::DeferredDecisionRequested => "deferred_decision_requested",
58            EventKind::SignedDecisionReceipt => "signed_decision_receipt",
59        }
60    }
61}
62
63/// The input half of an audit entry — everything the caller supplies.
64///
65/// Once persisted, the audit log attaches `position`, `prev_hash`, and
66/// `this_hash`, producing a [`StoredEvent`].
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct Event {
69    pub kind: EventKind,
70    /// Agent that triggered this event (sender, org admin, scanner).
71    /// Empty string is permitted for system-level events.
72    pub actor: String,
73    /// Logical subject of the event: transfer_id, agent_id, etc. Empty
74    /// means the actor itself is the subject.
75    pub subject: String,
76    /// Structured payload. Must be a JSON object; arrays/primitives at
77    /// the top level are rejected to avoid ambiguous canonical output.
78    pub payload: serde_json::Value,
79}
80
81impl Event {
82    pub fn new(
83        kind: EventKind,
84        actor: impl Into<String>,
85        subject: impl Into<String>,
86        payload: serde_json::Value,
87    ) -> Self {
88        Self {
89            kind,
90            actor: actor.into(),
91            subject: subject.into(),
92            payload,
93        }
94    }
95
96    /// Produce the canonical byte string that will be hashed into the
97    /// chain. Format:
98    ///
99    /// ```text
100    /// {"kind":"...","actor":"...","subject":"...","payload":<canonical>,"ts":"RFC3339","prev":"..."}
101    /// ```
102    ///
103    /// Key order is fixed; payload is canonicalized recursively (sorted
104    /// object keys, no whitespace). This bytestring uniquely determines
105    /// `this_hash` given a stable input.
106    pub fn canonical_bytes(&self, ts: OffsetDateTime, prev_hash: &str) -> AuditResult<Vec<u8>> {
107        if !matches!(self.payload, serde_json::Value::Object(_)) {
108            return Err(AuditError::InvalidEvent(
109                "payload must be a JSON object".into(),
110            ));
111        }
112        let payload_canonical = canonical_json(&self.payload);
113        let ts_str = ts
114            .format(&time::format_description::well_known::Rfc3339)
115            .map_err(|e| AuditError::InvalidEvent(format!("ts format: {}", e)))?;
116
117        let mut out = Vec::with_capacity(128);
118        out.extend_from_slice(b"{\"kind\":\"");
119        out.extend_from_slice(self.kind.as_str().as_bytes());
120        out.extend_from_slice(b"\",\"actor\":");
121        out.extend_from_slice(json_string(&self.actor).as_bytes());
122        out.extend_from_slice(b",\"subject\":");
123        out.extend_from_slice(json_string(&self.subject).as_bytes());
124        out.extend_from_slice(b",\"payload\":");
125        out.extend_from_slice(payload_canonical.as_bytes());
126        out.extend_from_slice(b",\"ts\":");
127        out.extend_from_slice(json_string(&ts_str).as_bytes());
128        out.extend_from_slice(b",\"prev\":");
129        out.extend_from_slice(json_string(prev_hash).as_bytes());
130        out.extend_from_slice(b"}");
131        Ok(out)
132    }
133
134    /// Compute the hash an event would carry given timestamp + previous
135    /// chain head.
136    pub fn compute_hash(&self, ts: OffsetDateTime, prev_hash: &str) -> AuditResult<String> {
137        let bytes = self.canonical_bytes(ts, prev_hash)?;
138        let digest = Sha256::digest(&bytes);
139        Ok(hex::encode(digest))
140    }
141}
142
143/// What's actually stored in the log — the input event plus the chain
144/// metadata the log attaches.
145#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct StoredEvent {
147    pub position: u64,
148    pub event_id: String,
149    #[serde(with = "time::serde::rfc3339")]
150    pub timestamp: OffsetDateTime,
151    pub prev_hash: String,
152    pub this_hash: String,
153    #[serde(flatten)]
154    pub event: Event,
155}
156
157impl StoredEvent {
158    /// Re-derive `this_hash` from stored inputs and compare to the stored
159    /// value. Used by [`AuditLog::verify_chain`].
160    pub fn verify_hash(&self) -> AuditResult<()> {
161        let recomputed = self.event.compute_hash(self.timestamp, &self.prev_hash)?;
162        if recomputed != self.this_hash {
163            return Err(AuditError::HashMismatch {
164                position: self.position,
165                stored: self.this_hash.clone(),
166                recomputed,
167            });
168        }
169        Ok(())
170    }
171}
172
173/// Receipt returned to callers on successful append. Useful to carry as
174/// proof that a particular action was logged — e.g., bundled with a
175/// delivery confirmation.
176#[derive(Debug, Clone, Serialize, Deserialize)]
177pub struct EventReceipt {
178    pub event_id: String,
179    pub position: u64,
180    #[serde(with = "time::serde::rfc3339")]
181    pub timestamp: OffsetDateTime,
182    pub chain_head: String,
183}
184
185impl From<&StoredEvent> for EventReceipt {
186    fn from(e: &StoredEvent) -> Self {
187        EventReceipt {
188            event_id: e.event_id.clone(),
189            position: e.position,
190            timestamp: e.timestamp,
191            chain_head: e.this_hash.clone(),
192        }
193    }
194}
195
196// ---------- canonical JSON helpers ----------
197
198/// Serialize any JSON value with sorted keys and no whitespace.
199fn canonical_json(v: &serde_json::Value) -> String {
200    let mut out = String::new();
201    write_canonical(v, &mut out);
202    out
203}
204
205fn write_canonical(v: &serde_json::Value, out: &mut String) {
206    match v {
207        serde_json::Value::Null => out.push_str("null"),
208        serde_json::Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
209        serde_json::Value::Number(n) => out.push_str(&n.to_string()),
210        serde_json::Value::String(s) => out.push_str(&json_string(s)),
211        serde_json::Value::Array(xs) => {
212            out.push('[');
213            for (i, x) in xs.iter().enumerate() {
214                if i > 0 {
215                    out.push(',');
216                }
217                write_canonical(x, out);
218            }
219            out.push(']');
220        }
221        serde_json::Value::Object(map) => {
222            let mut keys: Vec<&String> = map.keys().collect();
223            keys.sort();
224            out.push('{');
225            for (i, k) in keys.iter().enumerate() {
226                if i > 0 {
227                    out.push(',');
228                }
229                out.push_str(&json_string(k));
230                out.push(':');
231                write_canonical(&map[*k], out);
232            }
233            out.push('}');
234        }
235    }
236}
237
238/// Produce a JSON-encoded string literal (surrounding quotes included).
239fn json_string(s: &str) -> String {
240    serde_json::Value::String(s.to_string()).to_string()
241}
242
243// ---------- utilities ----------
244
245pub fn genesis_head() -> String {
246    GENESIS_HEAD.to_string()
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252
253    fn sample_event() -> Event {
254        Event::new(
255            EventKind::AgentRegistered,
256            "spize:acme/alice:a4f8b2",
257            "spize:acme/alice:a4f8b2",
258            serde_json::json!({"fingerprint": "a4f8b2"}),
259        )
260    }
261
262    #[test]
263    fn canonical_bytes_stable_across_calls() {
264        let e = sample_event();
265        let ts = time::OffsetDateTime::UNIX_EPOCH;
266        let a = e.canonical_bytes(ts, GENESIS_HEAD).unwrap();
267        let b = e.canonical_bytes(ts, GENESIS_HEAD).unwrap();
268        assert_eq!(a, b);
269    }
270
271    #[test]
272    fn canonical_bytes_include_all_fields() {
273        let e = sample_event();
274        let ts = time::OffsetDateTime::UNIX_EPOCH;
275        let bytes = e.canonical_bytes(ts, GENESIS_HEAD).unwrap();
276        let s = std::str::from_utf8(&bytes).unwrap();
277        assert!(s.contains("\"kind\":\"agent_registered\""));
278        assert!(s.contains("\"actor\":\"spize:acme/alice:a4f8b2\""));
279        assert!(s.contains("\"fingerprint\":\"a4f8b2\""));
280        assert!(s.contains("\"prev\":\"0000"));
281    }
282
283    #[test]
284    fn payload_keys_sorted_in_canonical() {
285        let e = Event::new(
286            EventKind::TransferInitiated,
287            "",
288            "tx_1",
289            serde_json::json!({"z": 1, "a": 2, "m": 3}),
290        );
291        let ts = time::OffsetDateTime::UNIX_EPOCH;
292        let bytes = e.canonical_bytes(ts, GENESIS_HEAD).unwrap();
293        let s = std::str::from_utf8(&bytes).unwrap();
294        let a_pos = s.find("\"a\"").unwrap();
295        let m_pos = s.find("\"m\"").unwrap();
296        let z_pos = s.find("\"z\"").unwrap();
297        assert!(a_pos < m_pos && m_pos < z_pos);
298    }
299
300    #[test]
301    fn different_prev_hash_different_hash() {
302        let e = sample_event();
303        let ts = time::OffsetDateTime::UNIX_EPOCH;
304        let h1 = e.compute_hash(ts, GENESIS_HEAD).unwrap();
305        let h2 = e.compute_hash(ts, &"a".repeat(64)).unwrap();
306        assert_ne!(h1, h2);
307    }
308
309    #[test]
310    fn different_kind_different_hash() {
311        let ts = time::OffsetDateTime::UNIX_EPOCH;
312        let a = Event::new(EventKind::AgentRegistered, "", "", serde_json::json!({}))
313            .compute_hash(ts, GENESIS_HEAD)
314            .unwrap();
315        let b = Event::new(EventKind::AgentRevoked, "", "", serde_json::json!({}))
316            .compute_hash(ts, GENESIS_HEAD)
317            .unwrap();
318        assert_ne!(a, b);
319    }
320
321    #[test]
322    fn non_object_payload_rejected() {
323        let e = Event {
324            kind: EventKind::AgentRegistered,
325            actor: "".into(),
326            subject: "".into(),
327            payload: serde_json::json!([1, 2, 3]),
328        };
329        let err = e
330            .canonical_bytes(time::OffsetDateTime::UNIX_EPOCH, GENESIS_HEAD)
331            .unwrap_err();
332        assert!(matches!(err, AuditError::InvalidEvent(_)));
333    }
334}