Skip to main content

harn_session_store/
event.rs

1//! Session event taxonomy and canonical serialization.
2//!
3//! Every event a session can persist — `Message`, `ToolCall`,
4//! `ToolResult`, `Plan`, `Compaction`, `SystemReminder`, `Hypothesis`,
5//! `Receipt`, `Reminder`, `PermissionDecision`, plus `Custom { type,
6//! payload }` for surface-specific shapes — shares one envelope shape so
7//! a TUI session can be read by a cloud verifier and an IDE replay
8//! without bespoke per-surface marshalling.
9//!
10//! The on-the-wire shape is intentionally JSON-first: the storage
11//! backend keeps events as canonical UTF-8 bytes, which lets the
12//! Ed25519 receipt chain (see [`crate::signing`]) hash the
13//! exact bytes that were appended, independent of any structural
14//! re-ordering a future Rust struct refactor might introduce.
15
16use std::collections::BTreeMap;
17
18use serde::{Deserialize, Serialize};
19use serde_json::Value;
20use time::format_description::well_known::Rfc3339;
21use time::OffsetDateTime;
22
23/// Identifier for a single event within a session. Monotonic per
24/// session; assigned by the store on `append`.
25pub type EventId = u64;
26
27/// The named event variants the primitive understands out of the box.
28/// `Custom` carries an arbitrary string discriminator so surfaces can
29/// extend the taxonomy without forking the schema; the structural
30/// validator (see issue #2365) is the authoritative shape gate.
31#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(tag = "kind", rename_all = "snake_case")]
33pub enum SessionEventKind {
34    Message,
35    ToolCall,
36    ToolResult,
37    Plan,
38    Compaction,
39    SystemReminder,
40    Hypothesis,
41    Receipt,
42    Reminder,
43    PermissionDecision,
44    Custom {
45        #[serde(rename = "type")]
46        custom_type: String,
47    },
48}
49
50impl SessionEventKind {
51    /// Discriminator string used in canonical hashing and SQL filters.
52    pub fn discriminator(&self) -> &str {
53        match self {
54            Self::Message => "message",
55            Self::ToolCall => "tool_call",
56            Self::ToolResult => "tool_result",
57            Self::Plan => "plan",
58            Self::Compaction => "compaction",
59            Self::SystemReminder => "system_reminder",
60            Self::Hypothesis => "hypothesis",
61            Self::Receipt => "receipt",
62            Self::Reminder => "reminder",
63            Self::PermissionDecision => "permission_decision",
64            Self::Custom { custom_type } => custom_type.as_str(),
65        }
66    }
67}
68
69/// Caller-facing event used on append.
70#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
71pub struct AppendEvent {
72    pub kind: SessionEventKind,
73    /// Free-form payload. Validated for null-bytes only — schema
74    /// enforcement lives in the structural validator (#2366).
75    #[serde(default)]
76    pub payload: Value,
77    /// Optional caller-asserted parent event id. Stores reject values
78    /// that point past the current tail.
79    #[serde(default)]
80    pub parent_event_id: Option<EventId>,
81    /// Optional caller actor (e.g. `"user"`, `"assistant"`, persona id).
82    #[serde(default)]
83    pub actor: Option<String>,
84    /// Tags surface filterable list queries.
85    #[serde(default)]
86    pub tags: Vec<String>,
87    /// Caller-supplied metadata copied verbatim onto the stored event.
88    #[serde(default)]
89    pub headers: BTreeMap<String, String>,
90}
91
92impl AppendEvent {
93    pub fn new(kind: SessionEventKind, payload: Value) -> Self {
94        Self {
95            kind,
96            payload,
97            parent_event_id: None,
98            actor: None,
99            tags: Vec::new(),
100            headers: BTreeMap::new(),
101        }
102    }
103
104    pub fn with_actor(mut self, actor: impl Into<String>) -> Self {
105        self.actor = Some(actor.into());
106        self
107    }
108
109    pub fn with_parent(mut self, parent_event_id: EventId) -> Self {
110        self.parent_event_id = Some(parent_event_id);
111        self
112    }
113
114    pub fn with_tags<I, S>(mut self, tags: I) -> Self
115    where
116        I: IntoIterator<Item = S>,
117        S: Into<String>,
118    {
119        self.tags = tags.into_iter().map(Into::into).collect();
120        self
121    }
122}
123
124/// Event as persisted by the store, including assigned identifiers,
125/// timestamps, and optional Ed25519 signature material.
126#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
127pub struct StoredEvent {
128    pub event_id: EventId,
129    pub session_id: String,
130    pub tenant_id: Option<String>,
131    pub parent_event_id: Option<EventId>,
132    pub actor: Option<String>,
133    pub kind: SessionEventKind,
134    pub payload: Value,
135    pub tags: Vec<String>,
136    pub headers: BTreeMap<String, String>,
137    pub ts_ms: i64,
138    pub ts: String,
139    /// Hash of the canonical event bytes (sha256), prefixed with the
140    /// algorithm. Forms the chain links: each event's hash is folded
141    /// into the next event's `prev_hash`.
142    pub record_hash: String,
143    /// Hash of the previous event's `record_hash`, or `None` for the
144    /// genesis event.
145    pub prev_hash: Option<String>,
146    /// Detached signature over `record_hash`. `None` until the session
147    /// is closed and the [`crate::signing::SessionSigner`]
148    /// finalises the chain receipt.
149    pub signed_by: Option<EventSignature>,
150}
151
152#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
153pub struct EventSignature {
154    /// Algorithm tag, e.g. `"ed25519"`.
155    pub algorithm: String,
156    /// Public key identifier (sha256 of the verifying key bytes).
157    pub key_id: String,
158    /// Base64-encoded signature bytes.
159    pub signature: String,
160}
161
162/// Canonical bytes used for hashing & signing. Keys are sorted so the
163/// signature is stable across hashmap iteration order changes.
164pub fn canonical_event_bytes(event: &StoredEvent) -> Vec<u8> {
165    let canonical = serde_json::json!({
166        "schema": "harn.session.event.v1",
167        "session_id": event.session_id,
168        "event_id": event.event_id,
169        "tenant_id": event.tenant_id,
170        "parent_event_id": event.parent_event_id,
171        "actor": event.actor,
172        "kind": event.kind.discriminator(),
173        "payload": event.payload,
174        "tags": event.tags,
175        "headers": event.headers,
176        "ts_ms": event.ts_ms,
177        "prev_hash": event.prev_hash,
178    });
179    canonical_json_bytes(&canonical)
180}
181
182/// Stable JSON encoding that sorts object keys recursively.
183pub fn canonical_json_bytes(value: &Value) -> Vec<u8> {
184    let mut buf = Vec::new();
185    write_canonical(&mut buf, value);
186    buf
187}
188
189fn write_canonical(buf: &mut Vec<u8>, value: &Value) {
190    match value {
191        Value::Null => buf.extend_from_slice(b"null"),
192        Value::Bool(b) => buf.extend_from_slice(if *b { b"true" } else { b"false" }),
193        Value::Number(n) => buf.extend_from_slice(n.to_string().as_bytes()),
194        Value::String(s) => {
195            let encoded = serde_json::to_string(s).expect("string round-trip");
196            buf.extend_from_slice(encoded.as_bytes());
197        }
198        Value::Array(items) => {
199            buf.push(b'[');
200            for (i, item) in items.iter().enumerate() {
201                if i > 0 {
202                    buf.push(b',');
203                }
204                write_canonical(buf, item);
205            }
206            buf.push(b']');
207        }
208        Value::Object(map) => {
209            let mut keys: Vec<&String> = map.keys().collect();
210            keys.sort();
211            buf.push(b'{');
212            for (i, key) in keys.iter().enumerate() {
213                if i > 0 {
214                    buf.push(b',');
215                }
216                let encoded_key = serde_json::to_string(key).expect("key round-trip");
217                buf.extend_from_slice(encoded_key.as_bytes());
218                buf.push(b':');
219                write_canonical(buf, &map[*key]);
220            }
221            buf.push(b'}');
222        }
223    }
224}
225
226pub(crate) fn now_ms_and_rfc3339() -> (i64, String) {
227    let now = OffsetDateTime::now_utc();
228    let ms = (now.unix_timestamp_nanos() / 1_000_000) as i64;
229    let text = now.format(&Rfc3339).unwrap_or_else(|_| now.to_string());
230    (ms, text)
231}
232
233/// Render a millisecond Unix timestamp as RFC 3339. Used by retention
234/// sweeps that already have a `now_ms` from the caller and need to
235/// stamp tombstone events with the same instant.
236pub(crate) fn ms_to_rfc3339(ms: i64) -> String {
237    let nanos = (ms as i128).saturating_mul(1_000_000);
238    OffsetDateTime::from_unix_timestamp_nanos(nanos)
239        .ok()
240        .and_then(|dt| dt.format(&Rfc3339).ok())
241        .unwrap_or_default()
242}