Skip to main content

harn_session_store/
memory.rs

1//! In-memory backend. Single-process, no persistence — but matches
2//! the public [`SessionStore`] contract exactly so the rest of the
3//! primitive can be exercised end-to-end in tests without touching
4//! disk.
5
6use std::collections::BTreeMap;
7use std::sync::{Arc, Mutex};
8
9use async_trait::async_trait;
10use uuid::Uuid;
11
12use super::event::{now_ms_and_rfc3339, AppendEvent, EventId, SessionEventKind, StoredEvent};
13use super::memory_helpers::{meta_for_create, validate_open};
14use super::signing::{
15    chain_root_fold, chain_root_hash, chain_root_init, compute_record_hash, re_anchor_events,
16    verify_event_chain,
17};
18use super::store::{
19    CreateSession, EventPage, ForkResult, ListFilter, ReadRange, SessionId, SessionMeta,
20    SessionStatus, SessionStore, Snapshot, SnapshotId, StoreError, StoreHooks, StoreResult,
21    TruncateResult, VerifyFailure, VerifyReport, MAX_READ_BATCH,
22};
23
24struct SessionRecord {
25    meta: SessionMeta,
26    events: Vec<StoredEvent>,
27    next_event_id: EventId,
28}
29
30impl SessionRecord {
31    fn fresh(meta: SessionMeta) -> Self {
32        Self {
33            meta,
34            events: Vec::new(),
35            next_event_id: 1,
36        }
37    }
38}
39
40#[derive(Default)]
41struct Inner {
42    sessions: BTreeMap<SessionId, SessionRecord>,
43    snapshots: BTreeMap<String, Snapshot>,
44}
45
46#[derive(Clone)]
47pub struct MemorySessionStore {
48    inner: Arc<Mutex<Inner>>,
49    hooks: Arc<StoreHooks>,
50}
51
52impl MemorySessionStore {
53    pub fn new() -> Self {
54        Self::with_hooks(StoreHooks::default())
55    }
56
57    pub fn with_hooks(hooks: StoreHooks) -> Self {
58        Self {
59            inner: Arc::new(Mutex::new(Inner::default())),
60            hooks: Arc::new(hooks),
61        }
62    }
63}
64
65impl Default for MemorySessionStore {
66    fn default() -> Self {
67        Self::new()
68    }
69}
70
71fn lock(inner: &Arc<Mutex<Inner>>) -> std::sync::MutexGuard<'_, Inner> {
72    inner.lock().unwrap_or_else(|e| e.into_inner())
73}
74
75fn apply_redaction(hooks: &StoreHooks, event: &mut AppendEvent) {
76    let Some(policy) = hooks.redaction.as_ref() else {
77        return;
78    };
79    policy.redact_json_in_place(&mut event.payload);
80    let redacted_headers = policy.redact_headers(&event.headers);
81    event.headers = redacted_headers;
82}
83
84/// Core append logic against an already-locked session record. Both
85/// `append` and `close` call this while holding the single store lock, so
86/// `close` can read the chain state, append the receipt, and finalise it
87/// without releasing the guard in between (which previously let a
88/// concurrent append interleave and displace the receipt).
89fn append_locked(
90    record: &mut SessionRecord,
91    hooks: &StoreHooks,
92    mut event: AppendEvent,
93) -> StoreResult<StoredEvent> {
94    apply_redaction(hooks, &mut event);
95    validate_open(&record.meta)?;
96    validate_parent(record, &event)?;
97    let (ts_ms, ts) = now_ms_and_rfc3339();
98    let event_id = record.next_event_id;
99    record.next_event_id = record.next_event_id.saturating_add(1);
100    let prev_hash = record.events.last().map(|tail| tail.record_hash.clone());
101    let mut stored = StoredEvent {
102        event_id,
103        session_id: record.meta.id.clone(),
104        tenant_id: record.meta.tenant_id.clone(),
105        parent_event_id: event.parent_event_id,
106        actor: event.actor,
107        kind: event.kind,
108        payload: event.payload,
109        tags: event.tags,
110        headers: event.headers,
111        ts_ms,
112        ts,
113        record_hash: String::new(),
114        prev_hash,
115        signed_by: None,
116    };
117    stored.record_hash = compute_record_hash(&stored);
118    if let Some(signer) = hooks.event_signer.as_ref() {
119        stored.signed_by = Some(signer.sign_event(&stored));
120    }
121    let prev_root = record
122        .meta
123        .chain_root_hash
124        .clone()
125        .unwrap_or_else(chain_root_init);
126    record.events.push(stored.clone());
127    record.meta.event_count = record.events.len();
128    record.meta.last_event_id = Some(event_id);
129    record.meta.updated_at_ms = ts_ms;
130    record.meta.updated_at = stored.ts.clone();
131    record.meta.chain_root_hash = Some(chain_root_fold(&prev_root, &stored.record_hash));
132    Ok(stored)
133}
134
135#[async_trait]
136impl SessionStore for MemorySessionStore {
137    fn hooks(&self) -> &StoreHooks {
138        &self.hooks
139    }
140
141    async fn create(&self, request: CreateSession) -> StoreResult<SessionMeta> {
142        let meta = meta_for_create(request);
143        let mut guard = lock(&self.inner);
144        if guard.sessions.contains_key(&meta.id) {
145            return Err(StoreError::AlreadyExists(meta.id));
146        }
147        guard
148            .sessions
149            .insert(meta.id.clone(), SessionRecord::fresh(meta.clone()));
150        Ok(meta)
151    }
152
153    async fn describe(&self, session_id: &str) -> StoreResult<SessionMeta> {
154        let guard = lock(&self.inner);
155        guard
156            .sessions
157            .get(session_id)
158            .map(|record| record.meta.clone())
159            .ok_or_else(|| StoreError::NotFound(session_id.to_string()))
160    }
161
162    async fn list(&self, filter: ListFilter) -> StoreResult<Vec<SessionMeta>> {
163        let guard = lock(&self.inner);
164        let limit = filter.limit.unwrap_or(MAX_READ_BATCH).min(MAX_READ_BATCH);
165        let mut out: Vec<SessionMeta> = guard
166            .sessions
167            .values()
168            .map(|record| record.meta.clone())
169            .filter(|meta| match_filter(meta, &filter))
170            .collect();
171        out.sort_by_key(|meta| meta.created_at_ms);
172        if let Some(cursor) = filter.cursor.as_ref() {
173            let position = out.iter().position(|meta| meta.id == *cursor);
174            if let Some(start) = position {
175                out = out.into_iter().skip(start + 1).collect();
176            }
177        }
178        out.truncate(limit);
179        Ok(out)
180    }
181
182    async fn append(&self, session_id: &str, event: AppendEvent) -> StoreResult<StoredEvent> {
183        let mut guard = lock(&self.inner);
184        let record = guard
185            .sessions
186            .get_mut(session_id)
187            .ok_or_else(|| StoreError::NotFound(session_id.to_string()))?;
188        append_locked(record, &self.hooks, event)
189    }
190
191    async fn read(&self, session_id: &str, range: ReadRange) -> StoreResult<EventPage> {
192        let guard = lock(&self.inner);
193        let record = guard
194            .sessions
195            .get(session_id)
196            .ok_or_else(|| StoreError::NotFound(session_id.to_string()))?;
197        let from = range.from_event_id.unwrap_or(1);
198        let to = range.to_event_id.unwrap_or(EventId::MAX);
199        let limit = range.limit.unwrap_or(MAX_READ_BATCH).min(MAX_READ_BATCH);
200        let mut events: Vec<StoredEvent> = record
201            .events
202            .iter()
203            .filter(|event| event.event_id >= from && event.event_id <= to)
204            .take(limit)
205            .cloned()
206            .collect();
207        let next_cursor = if events.len() == limit {
208            events.last().map(|tail| tail.event_id + 1)
209        } else {
210            None
211        };
212        // Defense in depth: re-redact on read if policy demands it.
213        if let Some(policy) = self.hooks.redaction.as_ref() {
214            for event in events.iter_mut() {
215                policy.redact_json_in_place(&mut event.payload);
216            }
217        }
218        Ok(EventPage {
219            events,
220            next_cursor,
221        })
222    }
223
224    async fn fork(
225        &self,
226        session_id: &str,
227        at_event_id: EventId,
228        child_id: Option<SessionId>,
229    ) -> StoreResult<ForkResult> {
230        let mut guard = lock(&self.inner);
231        let parent = guard
232            .sessions
233            .get(session_id)
234            .ok_or_else(|| StoreError::NotFound(session_id.to_string()))?;
235        if !parent
236            .events
237            .iter()
238            .any(|event| event.event_id == at_event_id)
239        {
240            return Err(StoreError::InvalidInput(format!(
241                "event {at_event_id} not found in session '{session_id}'"
242            )));
243        }
244        let new_id = child_id.unwrap_or_else(|| Uuid::now_v7().to_string());
245        if guard.sessions.contains_key(&new_id) {
246            return Err(StoreError::AlreadyExists(new_id));
247        }
248        let parent = guard.sessions.get(session_id).unwrap();
249        let (ms, text) = now_ms_and_rfc3339();
250        let mut child_meta = parent.meta.clone();
251        child_meta.id = new_id.clone();
252        child_meta.parent_session_id = Some(parent.meta.id.clone());
253        child_meta.created_at_ms = ms;
254        child_meta.created_at = text.clone();
255        child_meta.updated_at_ms = ms;
256        child_meta.updated_at = text;
257        child_meta.status = SessionStatus::Open;
258        child_meta.closed_at = None;
259        child_meta.closed_at_ms = None;
260        child_meta.soft_deleted_at_ms = None;
261        let parent_events: Vec<StoredEvent> = parent
262            .events
263            .iter()
264            .filter(|event| event.event_id <= at_event_id)
265            .cloned()
266            .collect();
267        let copied_events = re_anchor_events(&parent_events, &new_id);
268        let copied_event_count = copied_events.len();
269        child_meta.event_count = copied_event_count;
270        child_meta.last_event_id = copied_events.last().map(|tail| tail.event_id);
271        child_meta.chain_root_hash = Some(chain_root_hash(&copied_events));
272        let next_event_id = copied_events
273            .last()
274            .map(|tail| tail.event_id + 1)
275            .unwrap_or(1);
276        let child_record = SessionRecord {
277            meta: child_meta,
278            events: copied_events,
279            next_event_id,
280        };
281        guard.sessions.insert(new_id.clone(), child_record);
282        Ok(ForkResult {
283            child_session_id: new_id,
284            forked_from_event_id: at_event_id,
285            copied_event_count,
286        })
287    }
288
289    async fn truncate(
290        &self,
291        session_id: &str,
292        at_event_id: EventId,
293    ) -> StoreResult<TruncateResult> {
294        let mut guard = lock(&self.inner);
295        let record = guard
296            .sessions
297            .get_mut(session_id)
298            .ok_or_else(|| StoreError::NotFound(session_id.to_string()))?;
299        if !record
300            .events
301            .iter()
302            .any(|event| event.event_id == at_event_id)
303        {
304            return Err(StoreError::InvalidInput(format!(
305                "event {at_event_id} not found in session '{session_id}'"
306            )));
307        }
308        let removed = record
309            .events
310            .iter()
311            .filter(|event| event.event_id > at_event_id)
312            .count();
313        record.events.retain(|event| event.event_id <= at_event_id);
314        record.next_event_id = at_event_id + 1;
315        record.meta.event_count = record.events.len();
316        record.meta.last_event_id = record.events.last().map(|tail| tail.event_id);
317        record.meta.chain_root_hash = Some(chain_root_hash(&record.events));
318        let (ms, text) = now_ms_and_rfc3339();
319        record.meta.updated_at_ms = ms;
320        record.meta.updated_at = text;
321        Ok(TruncateResult {
322            kept_event_count: record.events.len(),
323            removed_event_count: removed,
324            new_tip_event_id: record.events.last().map(|tail| tail.event_id),
325        })
326    }
327
328    async fn snapshot(&self, session_id: &str) -> StoreResult<Snapshot> {
329        let mut guard = lock(&self.inner);
330        let record = guard
331            .sessions
332            .get(session_id)
333            .ok_or_else(|| StoreError::NotFound(session_id.to_string()))?;
334        let (ms, text) = now_ms_and_rfc3339();
335        let snapshot = Snapshot {
336            id: SnapshotId(format!("snap-{}", Uuid::now_v7())),
337            session: record.meta.clone(),
338            events: record.events.clone(),
339            captured_at_ms: ms,
340            captured_at: text,
341        };
342        guard
343            .snapshots
344            .insert(snapshot.id.0.clone(), snapshot.clone());
345        Ok(snapshot)
346    }
347
348    async fn replay(&self, snapshot_id: &SnapshotId) -> StoreResult<Snapshot> {
349        let guard = lock(&self.inner);
350        guard
351            .snapshots
352            .get(&snapshot_id.0)
353            .cloned()
354            .ok_or_else(|| StoreError::NotFound(snapshot_id.0.clone()))
355    }
356
357    async fn close(&self, session_id: &str) -> StoreResult<StoredEvent> {
358        // Hold the single store lock across read -> append receipt ->
359        // finalise so no concurrent append can interleave and move the
360        // tip off the receipt we just minted.
361        let mut guard = lock(&self.inner);
362        let record = guard
363            .sessions
364            .get_mut(session_id)
365            .ok_or_else(|| StoreError::NotFound(session_id.to_string()))?;
366        validate_open(&record.meta)?;
367        let record_root = record
368            .meta
369            .chain_root_hash
370            .clone()
371            .unwrap_or_else(|| chain_root_hash(&record.events));
372        let last_event_id = record.meta.last_event_id.unwrap_or(0);
373        let payload =
374            super::signing::canonical_receipt_payload(session_id, last_event_id, &record_root);
375        let mut append = AppendEvent::new(SessionEventKind::Receipt, payload);
376        append.actor = Some("session_store".into());
377        let mut stored = append_locked(record, &self.hooks, append)?;
378        // Intentionally replace the receipt's append-time per-event
379        // signature with a receipt-root signature: the receipt attests the
380        // chain root, so `verify()` checks it via `verify_receipt_root`
381        // against the pre-receipt root, not the event's own bytes.
382        if let Some(signer) = self
383            .hooks
384            .receipt_signer
385            .as_ref()
386            .or(self.hooks.event_signer.as_ref())
387        {
388            let signature = signer.sign_receipt(&record_root);
389            // Locate the receipt by its event id rather than `last_mut()`,
390            // so we can never sign a different event.
391            if let Some(receipt) = record
392                .events
393                .iter_mut()
394                .find(|event| event.event_id == stored.event_id)
395            {
396                receipt.signed_by = Some(signature.clone());
397            }
398            stored.signed_by = Some(signature);
399        }
400        let (ms, text) = now_ms_and_rfc3339();
401        record.meta.status = SessionStatus::Closed;
402        record.meta.closed_at_ms = Some(ms);
403        record.meta.closed_at = Some(text);
404        Ok(stored)
405    }
406
407    async fn soft_delete(&self, session_id: &str) -> StoreResult<SessionMeta> {
408        let mut guard = lock(&self.inner);
409        let record = guard
410            .sessions
411            .get_mut(session_id)
412            .ok_or_else(|| StoreError::NotFound(session_id.to_string()))?;
413        match record.meta.status {
414            SessionStatus::HardDeleted => return Err(StoreError::NotFound(session_id.to_string())),
415            SessionStatus::SoftDeleted => return Ok(record.meta.clone()),
416            _ => {}
417        }
418        let (ms, text) = now_ms_and_rfc3339();
419        record.meta.status = SessionStatus::SoftDeleted;
420        record.meta.soft_deleted_at_ms = Some(ms);
421        record.meta.updated_at_ms = ms;
422        record.meta.updated_at = text;
423        Ok(record.meta.clone())
424    }
425
426    async fn hard_delete(&self, session_id: &str) -> StoreResult<()> {
427        let mut guard = lock(&self.inner);
428        guard
429            .sessions
430            .remove(session_id)
431            .ok_or_else(|| StoreError::NotFound(session_id.to_string()))?;
432        Ok(())
433    }
434
435    async fn verify(&self, session_id: &str) -> StoreResult<VerifyReport> {
436        let guard = lock(&self.inner);
437        let record = guard
438            .sessions
439            .get(session_id)
440            .ok_or_else(|| StoreError::NotFound(session_id.to_string()))?;
441        let chain_root = chain_root_hash(&record.events);
442        let event_verifier = self
443            .hooks
444            .event_signer
445            .as_ref()
446            .map(|signer| signer.verifying_key());
447        let receipt_verifier = self
448            .hooks
449            .receipt_signer
450            .as_ref()
451            .or(self.hooks.event_signer.as_ref())
452            .map(|signer| signer.verifying_key());
453        let (signed, failures) = verify_event_chain(
454            &record.events,
455            event_verifier.as_ref(),
456            receipt_verifier.as_ref(),
457        );
458        Ok(VerifyReport {
459            session_id: session_id.to_string(),
460            chain_root_hash: chain_root,
461            event_count: record.events.len(),
462            signed_event_count: signed,
463            failures: failures
464                .into_iter()
465                .map(|(event_id, reason)| VerifyFailure { event_id, reason })
466                .collect(),
467        })
468    }
469}
470
471fn validate_parent(record: &SessionRecord, event: &AppendEvent) -> StoreResult<()> {
472    let Some(parent_event_id) = event.parent_event_id else {
473        return Ok(());
474    };
475    if record
476        .events
477        .iter()
478        .any(|stored| stored.event_id == parent_event_id)
479    {
480        Ok(())
481    } else {
482        Err(StoreError::InvalidInput(format!(
483            "parent_event_id {parent_event_id} not present in session"
484        )))
485    }
486}
487
488fn match_filter(meta: &SessionMeta, filter: &ListFilter) -> bool {
489    if let Some(tenant) = filter.tenant_id.as_ref() {
490        if meta.tenant_id.as_deref() != Some(tenant.as_str()) {
491            return false;
492        }
493    }
494    if let Some(persona) = filter.persona.as_ref() {
495        if meta.persona.as_deref() != Some(persona.as_str()) {
496            return false;
497        }
498    }
499    if let Some(status) = filter.status {
500        if meta.status != status {
501            return false;
502        }
503    }
504    if let Some(tag) = filter.tag.as_ref() {
505        if !meta.tags.iter().any(|value| value == tag) {
506            return false;
507        }
508    }
509    if let Some(after) = filter.created_after_ms {
510        if meta.created_at_ms < after {
511            return false;
512        }
513    }
514    if let Some(before) = filter.created_before_ms {
515        if meta.created_at_ms > before {
516            return false;
517        }
518    }
519    true
520}