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    /// Stamp producer identity into canonical signed headers.
124    pub fn with_identity(
125        mut self,
126        identity: &crate::identity::EventIdentity,
127    ) -> Result<Self, crate::identity::EventIdentityError> {
128        identity.apply_to_headers(&mut self.headers)?;
129        Ok(self)
130    }
131
132    /// Read the producer identity already present in canonical headers.
133    pub fn identity(
134        &self,
135    ) -> Result<crate::identity::EventIdentity, crate::identity::EventIdentityError> {
136        crate::identity::EventIdentity::from_headers(&self.headers)
137    }
138}
139
140/// Event as persisted by the store, including assigned identifiers,
141/// timestamps, and optional Ed25519 signature material.
142#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
143pub struct StoredEvent {
144    pub event_id: EventId,
145    pub session_id: String,
146    pub tenant_id: Option<String>,
147    pub parent_event_id: Option<EventId>,
148    pub actor: Option<String>,
149    pub kind: SessionEventKind,
150    pub payload: Value,
151    pub tags: Vec<String>,
152    pub headers: BTreeMap<String, String>,
153    pub ts_ms: i64,
154    pub ts: String,
155    /// Hash of the canonical event bytes (sha256), prefixed with the
156    /// algorithm. Forms the chain links: each event's hash is folded
157    /// into the next event's `prev_hash`. A confidentiality projection
158    /// uses `redacted:<canonical-source-hash>` and cannot be authenticated
159    /// as the original row.
160    pub record_hash: String,
161    /// Hash of the previous event's `record_hash`, or `None` for the
162    /// genesis event.
163    pub prev_hash: Option<String>,
164    /// Detached signature over the canonical event or receipt root. Cleared
165    /// when a retrieval hook returns a redacted projection.
166    pub signed_by: Option<EventSignature>,
167}
168
169impl StoredEvent {
170    /// Read producer identity from the signed canonical headers.
171    pub fn identity(
172        &self,
173    ) -> Result<crate::identity::EventIdentity, crate::identity::EventIdentityError> {
174        crate::identity::EventIdentity::from_headers(&self.headers)
175    }
176
177    /// Whether this event is a confidentiality projection rather than the
178    /// signed canonical row. Projections retain the source hash after the
179    /// `redacted:` prefix but intentionally clear `signed_by`.
180    pub fn is_redacted_projection(&self) -> bool {
181        self.record_hash.starts_with("redacted:")
182    }
183
184    /// Canonical source hash for a redacted projection, or this event's own
185    /// record hash when no projection was required.
186    pub fn source_record_hash(&self) -> &str {
187        self.record_hash
188            .strip_prefix("redacted:")
189            .unwrap_or(&self.record_hash)
190    }
191
192    pub(crate) fn mark_redacted_projection(&mut self) {
193        if !self.is_redacted_projection() {
194            self.record_hash = format!("redacted:{}", self.record_hash);
195        }
196        self.signed_by = None;
197    }
198}
199
200#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
201pub struct EventSignature {
202    /// Algorithm tag, e.g. `"ed25519"`.
203    pub algorithm: String,
204    /// Public key identifier (sha256 of the verifying key bytes).
205    pub key_id: String,
206    /// Base64-encoded signature bytes.
207    pub signature: String,
208}
209
210/// Canonical bytes used for hashing & signing. Keys are sorted so the
211/// signature is stable across hashmap iteration order changes.
212pub fn canonical_event_bytes(event: &StoredEvent) -> Vec<u8> {
213    let canonical = serde_json::json!({
214        "schema": "harn.session.event.v1",
215        "session_id": event.session_id,
216        "event_id": event.event_id,
217        "tenant_id": event.tenant_id,
218        "parent_event_id": event.parent_event_id,
219        "actor": event.actor,
220        "kind": event.kind.discriminator(),
221        "payload": event.payload,
222        "tags": event.tags,
223        "headers": event.headers,
224        "ts_ms": event.ts_ms,
225        "prev_hash": event.prev_hash,
226    });
227    canonical_json_bytes(&canonical)
228}
229
230/// Stable JSON encoding that sorts object keys recursively.
231pub fn canonical_json_bytes(value: &Value) -> Vec<u8> {
232    let mut buf = Vec::new();
233    write_canonical(&mut buf, value);
234    buf
235}
236
237fn write_canonical(buf: &mut Vec<u8>, value: &Value) {
238    match value {
239        Value::Null => buf.extend_from_slice(b"null"),
240        Value::Bool(b) => buf.extend_from_slice(if *b { b"true" } else { b"false" }),
241        Value::Number(n) => buf.extend_from_slice(n.to_string().as_bytes()),
242        Value::String(s) => {
243            let encoded = serde_json::to_string(s).expect("string round-trip");
244            buf.extend_from_slice(encoded.as_bytes());
245        }
246        Value::Array(items) => {
247            buf.push(b'[');
248            for (i, item) in items.iter().enumerate() {
249                if i > 0 {
250                    buf.push(b',');
251                }
252                write_canonical(buf, item);
253            }
254            buf.push(b']');
255        }
256        Value::Object(map) => {
257            let mut keys: Vec<&String> = map.keys().collect();
258            keys.sort();
259            buf.push(b'{');
260            for (i, key) in keys.iter().enumerate() {
261                if i > 0 {
262                    buf.push(b',');
263                }
264                let encoded_key = serde_json::to_string(key).expect("key round-trip");
265                buf.extend_from_slice(encoded_key.as_bytes());
266                buf.push(b':');
267                write_canonical(buf, &map[*key]);
268            }
269            buf.push(b'}');
270        }
271    }
272}
273
274pub(crate) fn now_ms_and_rfc3339() -> (i64, String) {
275    let now = OffsetDateTime::now_utc();
276    let ms = (now.unix_timestamp_nanos() / 1_000_000) as i64;
277    let text = now.format(&Rfc3339).unwrap_or_else(|_| now.to_string());
278    (ms, text)
279}
280
281/// Render a millisecond Unix timestamp as RFC 3339. Used by retention
282/// sweeps that already have a `now_ms` from the caller and need to
283/// stamp tombstone events with the same instant.
284pub(crate) fn ms_to_rfc3339(ms: i64) -> String {
285    let nanos = (ms as i128).saturating_mul(1_000_000);
286    OffsetDateTime::from_unix_timestamp_nanos(nanos)
287        .ok()
288        .and_then(|dt| dt.format(&Rfc3339).ok())
289        .unwrap_or_default()
290}