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