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::search::{
18    combined_score, lexical_score, ranks, redacted_search_document, snippet, SearchHit, SearchMode,
19    SearchQuery, SearchResponse,
20};
21use super::signing::{
22    chain_root_fold, chain_root_hash, chain_root_init, compute_record_hash, re_anchor_events,
23    verify_session_chain,
24};
25use super::store::{
26    CreateSession, EventPage, ForkResult, ImportResult, ImportSession, ListFilter, ListOrder,
27    ListSortKey, ReadRange, SessionId, SessionImporter, SessionMeta, SessionStatus, SessionStore,
28    Snapshot, SnapshotId, StoreError, StoreHooks, StoreResult, TruncateResult, VerifyReport,
29    MAX_READ_BATCH,
30};
31
32struct SessionRecord {
33    meta: SessionMeta,
34    events: Vec<StoredEvent>,
35    next_event_id: EventId,
36}
37
38impl SessionRecord {
39    fn fresh(meta: SessionMeta) -> Self {
40        Self {
41            meta,
42            events: Vec::new(),
43            next_event_id: 1,
44        }
45    }
46}
47
48#[derive(Default)]
49struct Inner {
50    sessions: BTreeMap<SessionId, SessionRecord>,
51    snapshots: BTreeMap<String, Snapshot>,
52    imports: BTreeMap<String, ImportResult>,
53}
54
55#[derive(Clone)]
56pub struct MemorySessionStore {
57    inner: Arc<Mutex<Inner>>,
58    hooks: Arc<StoreHooks>,
59}
60
61impl MemorySessionStore {
62    pub fn new() -> Self {
63        Self::with_hooks(StoreHooks::default())
64    }
65
66    pub fn with_hooks(hooks: StoreHooks) -> Self {
67        Self {
68            inner: Arc::new(Mutex::new(Inner::default())),
69            hooks: Arc::new(hooks),
70        }
71    }
72}
73
74impl Default for MemorySessionStore {
75    fn default() -> Self {
76        Self::new()
77    }
78}
79
80fn lock(inner: &Arc<Mutex<Inner>>) -> std::sync::MutexGuard<'_, Inner> {
81    inner.lock().unwrap_or_else(|e| e.into_inner())
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    prepare_append_event(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 SessionImporter for MemorySessionStore {
137    async fn import(&self, request: ImportSession) -> StoreResult<ImportResult> {
138        request.validate()?;
139        let mut guard = lock(&self.inner);
140        if let Some(existing) = guard.imports.get(&request.source_id) {
141            if existing.source_digest != request.source_digest {
142                return Err(StoreError::Conflict(format!(
143                    "import source '{}' changed digest",
144                    request.source_id
145                )));
146            }
147            let mut result = existing.clone();
148            result.imported = false;
149            return Ok(result);
150        }
151
152        let meta = meta_for_create(request.session);
153        if guard.sessions.contains_key(&meta.id) {
154            return Err(StoreError::AlreadyExists(meta.id));
155        }
156        let mut record = SessionRecord::fresh(meta.clone());
157        for event in request.events {
158            append_locked(&mut record, &self.hooks, event)?;
159        }
160        let result = ImportResult {
161            source_id: request.source_id.clone(),
162            source_digest: request.source_digest,
163            session_id: meta.id.clone(),
164            event_count: record.events.len(),
165            imported: true,
166        };
167        guard.sessions.insert(meta.id, record);
168        guard.imports.insert(request.source_id, result.clone());
169        Ok(result)
170    }
171}
172
173#[async_trait]
174impl SessionStore for MemorySessionStore {
175    fn hooks(&self) -> &StoreHooks {
176        &self.hooks
177    }
178
179    async fn create(&self, request: CreateSession) -> StoreResult<SessionMeta> {
180        let meta = meta_for_create(request);
181        let mut guard = lock(&self.inner);
182        if guard.sessions.contains_key(&meta.id) {
183            return Err(StoreError::AlreadyExists(meta.id));
184        }
185        guard
186            .sessions
187            .insert(meta.id.clone(), SessionRecord::fresh(meta.clone()));
188        Ok(meta)
189    }
190
191    async fn describe(&self, session_id: &str) -> StoreResult<SessionMeta> {
192        let guard = lock(&self.inner);
193        guard
194            .sessions
195            .get(session_id)
196            .map(|record| record.meta.clone())
197            .ok_or_else(|| StoreError::NotFound(session_id.to_string()))
198    }
199
200    async fn update(
201        &self,
202        session_id: &str,
203        request: crate::UpdateSession,
204    ) -> StoreResult<SessionMeta> {
205        let mut guard = lock(&self.inner);
206        let record = guard
207            .sessions
208            .get_mut(session_id)
209            .ok_or_else(|| StoreError::NotFound(session_id.to_string()))?;
210        let (title, title_pinned) = crate::memory_helpers::resolve_title_update(
211            record.meta.title.take(),
212            record.meta.title_pinned,
213            request.title,
214            request.title_pinned,
215        );
216        record.meta.title = title;
217        record.meta.title_pinned = title_pinned;
218        if let Some(value) = request.cwd {
219            record.meta.cwd = Some(value);
220        }
221        if let Some(value) = request.model {
222            record.meta.model = Some(value);
223        }
224        if let Some(value) = request.parent_session_id {
225            record.meta.parent_session_id = Some(value);
226        }
227        if let Some(value) = request.session_type {
228            record.meta.session_type = Some(value);
229        }
230        if let Some(value) = request.project_scope {
231            record.meta.project_scope = Some(value);
232        }
233        if let Some(value) = request.usage_input {
234            record.meta.usage_input = value;
235        }
236        if let Some(value) = request.usage_output {
237            record.meta.usage_output = value;
238        }
239        if let Some(value) = request.usage_cost_usd_micros {
240            record.meta.usage_cost_usd_micros = value;
241        }
242        let (updated_at_ms, updated_at) = crate::event::now_ms_and_rfc3339();
243        record.meta.updated_at_ms = updated_at_ms;
244        record.meta.updated_at = updated_at;
245        Ok(record.meta.clone())
246    }
247
248    async fn list(&self, filter: ListFilter) -> StoreResult<Vec<SessionMeta>> {
249        let guard = lock(&self.inner);
250        let limit = filter.limit.unwrap_or(MAX_READ_BATCH).min(MAX_READ_BATCH);
251        let mut out: Vec<SessionMeta> = guard
252            .sessions
253            .values()
254            .map(|record| record.meta.clone())
255            .filter(|meta| match_filter(meta, &filter))
256            .collect();
257        out.sort_by(|left, right| {
258            let timestamp_order = match filter.sort_by {
259                ListSortKey::CreatedAt => left.created_at_ms.cmp(&right.created_at_ms),
260                ListSortKey::UpdatedAt => left.updated_at_ms.cmp(&right.updated_at_ms),
261            };
262            let timestamp_order = match filter.order {
263                ListOrder::Ascending => timestamp_order,
264                ListOrder::Descending => timestamp_order.reverse(),
265            };
266            timestamp_order.then_with(|| left.id.cmp(&right.id))
267        });
268        if let Some(cursor) = filter.cursor.as_ref() {
269            let position = out.iter().position(|meta| meta.id == *cursor);
270            if let Some(start) = position {
271                out = out.into_iter().skip(start + 1).collect();
272            }
273        }
274        out.truncate(limit);
275        Ok(out)
276    }
277
278    async fn append(&self, session_id: &str, event: AppendEvent) -> StoreResult<StoredEvent> {
279        let mut guard = lock(&self.inner);
280        let record = guard
281            .sessions
282            .get_mut(session_id)
283            .ok_or_else(|| StoreError::NotFound(session_id.to_string()))?;
284        append_locked(record, &self.hooks, event)
285    }
286
287    async fn read(&self, session_id: &str, range: ReadRange) -> StoreResult<EventPage> {
288        let guard = lock(&self.inner);
289        let record = guard
290            .sessions
291            .get(session_id)
292            .ok_or_else(|| StoreError::NotFound(session_id.to_string()))?;
293        let from = range.from_event_id.unwrap_or(1);
294        let to = range.to_event_id.unwrap_or(EventId::MAX);
295        let limit = range.limit.unwrap_or(MAX_READ_BATCH).min(MAX_READ_BATCH);
296        let mut events: Vec<StoredEvent> = record
297            .events
298            .iter()
299            .filter(|event| event.event_id >= from && event.event_id <= to)
300            .take(limit)
301            .cloned()
302            .collect();
303        let next_cursor = if events.len() == limit {
304            events.last().map(|tail| tail.event_id + 1)
305        } else {
306            None
307        };
308        // Defense in depth for data imported or written under an older policy.
309        redact_stored_events(&self.hooks, &mut events)?;
310        Ok(EventPage {
311            events,
312            next_cursor,
313        })
314    }
315
316    async fn fork(
317        &self,
318        session_id: &str,
319        at_event_id: EventId,
320        child_id: Option<SessionId>,
321    ) -> StoreResult<ForkResult> {
322        let mut guard = lock(&self.inner);
323        let parent = guard
324            .sessions
325            .get(session_id)
326            .ok_or_else(|| StoreError::NotFound(session_id.to_string()))?;
327        if !parent
328            .events
329            .iter()
330            .any(|event| event.event_id == at_event_id)
331        {
332            return Err(StoreError::InvalidInput(format!(
333                "event {at_event_id} not found in session '{session_id}'"
334            )));
335        }
336        let new_id = child_id.unwrap_or_else(|| Uuid::now_v7().to_string());
337        if guard.sessions.contains_key(&new_id) {
338            return Err(StoreError::AlreadyExists(new_id));
339        }
340        let parent = guard.sessions.get(session_id).unwrap();
341        let (ms, text) = now_ms_and_rfc3339();
342        let mut child_meta = parent.meta.clone();
343        child_meta.id = new_id.clone();
344        child_meta.parent_session_id = Some(parent.meta.id.clone());
345        child_meta.created_at_ms = ms;
346        child_meta.created_at = text.clone();
347        child_meta.updated_at_ms = ms;
348        child_meta.updated_at = text;
349        child_meta.status = SessionStatus::Open;
350        child_meta.closed_at = None;
351        child_meta.closed_at_ms = None;
352        child_meta.soft_deleted_at_ms = None;
353        let mut parent_events: Vec<StoredEvent> = parent
354            .events
355            .iter()
356            .filter(|event| event.event_id <= at_event_id)
357            .cloned()
358            .collect();
359        prepare_stored_events_for_persistence(&self.hooks, &mut parent_events)?;
360        let copied_events = re_anchor_events(&parent_events, &new_id);
361        let copied_event_count = copied_events.len();
362        child_meta.event_count = copied_event_count;
363        child_meta.last_event_id = copied_events.last().map(|tail| tail.event_id);
364        child_meta.chain_root_hash = Some(chain_root_hash(&copied_events));
365        let next_event_id = copied_events
366            .last()
367            .map(|tail| tail.event_id + 1)
368            .unwrap_or(1);
369        let child_record = SessionRecord {
370            meta: child_meta,
371            events: copied_events,
372            next_event_id,
373        };
374        guard.sessions.insert(new_id.clone(), child_record);
375        Ok(ForkResult {
376            child_session_id: new_id,
377            forked_from_event_id: at_event_id,
378            copied_event_count,
379        })
380    }
381
382    async fn truncate(
383        &self,
384        session_id: &str,
385        at_event_id: EventId,
386    ) -> StoreResult<TruncateResult> {
387        let mut guard = lock(&self.inner);
388        let record = guard
389            .sessions
390            .get_mut(session_id)
391            .ok_or_else(|| StoreError::NotFound(session_id.to_string()))?;
392        if !record
393            .events
394            .iter()
395            .any(|event| event.event_id == at_event_id)
396        {
397            return Err(StoreError::InvalidInput(format!(
398                "event {at_event_id} not found in session '{session_id}'"
399            )));
400        }
401        let removed = record
402            .events
403            .iter()
404            .filter(|event| event.event_id > at_event_id)
405            .count();
406        record.events.retain(|event| event.event_id <= at_event_id);
407        record.next_event_id = at_event_id + 1;
408        record.meta.event_count = record.events.len();
409        record.meta.last_event_id = record.events.last().map(|tail| tail.event_id);
410        record.meta.chain_root_hash = Some(chain_root_hash(&record.events));
411        let (ms, text) = now_ms_and_rfc3339();
412        record.meta.updated_at_ms = ms;
413        record.meta.updated_at = text;
414        Ok(TruncateResult {
415            kept_event_count: record.events.len(),
416            removed_event_count: removed,
417            new_tip_event_id: record.events.last().map(|tail| tail.event_id),
418        })
419    }
420
421    async fn snapshot(&self, session_id: &str) -> StoreResult<Snapshot> {
422        let mut guard = lock(&self.inner);
423        let record = guard
424            .sessions
425            .get(session_id)
426            .ok_or_else(|| StoreError::NotFound(session_id.to_string()))?;
427        let (ms, text) = now_ms_and_rfc3339();
428        let mut events = record.events.clone();
429        redact_stored_events(&self.hooks, &mut events)?;
430        let snapshot = Snapshot {
431            id: SnapshotId(format!("snap-{}", Uuid::now_v7())),
432            session: record.meta.clone(),
433            events,
434            captured_at_ms: ms,
435            captured_at: text,
436        };
437        guard
438            .snapshots
439            .insert(snapshot.id.0.clone(), snapshot.clone());
440        Ok(snapshot)
441    }
442
443    async fn replay(&self, snapshot_id: &SnapshotId) -> StoreResult<Snapshot> {
444        let guard = lock(&self.inner);
445        let mut snapshot = guard
446            .snapshots
447            .get(&snapshot_id.0)
448            .cloned()
449            .ok_or_else(|| StoreError::NotFound(snapshot_id.0.clone()))?;
450        redact_stored_events(&self.hooks, &mut snapshot.events)?;
451        Ok(snapshot)
452    }
453
454    async fn close(&self, session_id: &str) -> StoreResult<StoredEvent> {
455        // Hold the single store lock across read -> append receipt ->
456        // finalise so no concurrent append can interleave and move the
457        // tip off the receipt we just minted.
458        let mut guard = lock(&self.inner);
459        let record = guard
460            .sessions
461            .get_mut(session_id)
462            .ok_or_else(|| StoreError::NotFound(session_id.to_string()))?;
463        validate_open(&record.meta)?;
464        let record_root = record
465            .meta
466            .chain_root_hash
467            .clone()
468            .unwrap_or_else(|| chain_root_hash(&record.events));
469        let last_event_id = record.meta.last_event_id.unwrap_or(0);
470        let payload =
471            super::signing::canonical_receipt_payload(session_id, last_event_id, &record_root);
472        let mut append = AppendEvent::new(SessionEventKind::Receipt, payload);
473        append.actor = Some("session_store".into());
474        let mut stored = append_locked(record, &self.hooks, append)?;
475        // Intentionally replace the receipt's append-time per-event
476        // signature with a receipt-root signature: the receipt attests the
477        // chain root, so `verify()` checks it via `verify_receipt_root`
478        // against the pre-receipt root, not the event's own bytes.
479        if let Some(signer) = self
480            .hooks
481            .receipt_signer
482            .as_ref()
483            .or(self.hooks.event_signer.as_ref())
484        {
485            let signature = signer.sign_receipt(&record_root);
486            // Locate the receipt by its event id rather than `last_mut()`,
487            // so we can never sign a different event.
488            if let Some(receipt) = record
489                .events
490                .iter_mut()
491                .find(|event| event.event_id == stored.event_id)
492            {
493                receipt.signed_by = Some(signature.clone());
494            }
495            stored.signed_by = Some(signature);
496        }
497        let (ms, text) = now_ms_and_rfc3339();
498        record.meta.status = SessionStatus::Closed;
499        record.meta.closed_at_ms = Some(ms);
500        record.meta.closed_at = Some(text);
501        Ok(stored)
502    }
503
504    async fn soft_delete(&self, session_id: &str) -> StoreResult<SessionMeta> {
505        let mut guard = lock(&self.inner);
506        let record = guard
507            .sessions
508            .get_mut(session_id)
509            .ok_or_else(|| StoreError::NotFound(session_id.to_string()))?;
510        match record.meta.status {
511            SessionStatus::HardDeleted => return Err(StoreError::NotFound(session_id.to_string())),
512            SessionStatus::SoftDeleted => return Ok(record.meta.clone()),
513            _ => {}
514        }
515        let (ms, text) = now_ms_and_rfc3339();
516        record.meta.status = SessionStatus::SoftDeleted;
517        record.meta.soft_deleted_at_ms = Some(ms);
518        record.meta.updated_at_ms = ms;
519        record.meta.updated_at = text;
520        Ok(record.meta.clone())
521    }
522
523    async fn hard_delete(&self, session_id: &str) -> StoreResult<()> {
524        let mut guard = lock(&self.inner);
525        guard
526            .sessions
527            .remove(session_id)
528            .ok_or_else(|| StoreError::NotFound(session_id.to_string()))?;
529        Ok(())
530    }
531
532    async fn verify(&self, session_id: &str) -> StoreResult<VerifyReport> {
533        let guard = lock(&self.inner);
534        let record = guard
535            .sessions
536            .get(session_id)
537            .ok_or_else(|| StoreError::NotFound(session_id.to_string()))?;
538        let event_verifier = self
539            .hooks
540            .event_signer
541            .as_ref()
542            .map(|signer| signer.verifying_key());
543        let receipt_verifier = self
544            .hooks
545            .receipt_signer
546            .as_ref()
547            .or(self.hooks.event_signer.as_ref())
548            .map(|signer| signer.verifying_key());
549        Ok(verify_session_chain(
550            &record.meta,
551            &record.events,
552            event_verifier.as_ref(),
553            receipt_verifier.as_ref(),
554        ))
555    }
556
557    async fn search(&self, query: SearchQuery) -> StoreResult<SearchResponse> {
558        query.validate().map_err(StoreError::InvalidInput)?;
559        let embedder = self.hooks.embedder.clone();
560        let mut candidates = {
561            let guard = lock(&self.inner);
562            let mut candidates = Vec::new();
563            for record in guard.sessions.values() {
564                if !matches_search_filter(&record.meta, &query.filter) {
565                    continue;
566                }
567                for event in &record.events {
568                    candidates.push((record.meta.clone(), event.clone()));
569                }
570            }
571            candidates
572        };
573        let mut events = candidates
574            .iter()
575            .map(|(_, event)| event.clone())
576            .collect::<Vec<_>>();
577        redact_stored_events(&self.hooks, &mut events)?;
578        for ((_, candidate), redacted) in candidates.iter_mut().zip(events) {
579            *candidate = redacted;
580        }
581
582        let documents = candidates
583            .iter()
584            .map(|(meta, event)| {
585                redacted_search_document(self.hooks.redaction.as_ref(), meta, event)
586            })
587            .collect::<Vec<_>>();
588        let fts_scores = documents
589            .iter()
590            .map(|document| lexical_score(&query.query, document))
591            .collect::<Vec<_>>();
592        let semantic_available = embedder.is_semantic();
593        let semantic_scores = if semantic_available {
594            let query_vector = embedder.embed(&query.query);
595            embedder
596                .embed_batch(&documents)
597                .iter()
598                .map(|vector| super::search::cosine(&query_vector, vector).max(0.0))
599                .collect::<Vec<_>>()
600        } else {
601            vec![0.0; documents.len()]
602        };
603
604        let fts_ranks = ranks(&fts_scores);
605        let semantic_ranks = ranks(&semantic_scores);
606        let effective_mode = if semantic_available {
607            query.mode
608        } else {
609            SearchMode::Fts
610        };
611        let mut hits = candidates
612            .into_iter()
613            .enumerate()
614            .filter_map(|(index, (_, event))| {
615                let fts_rank = fts_ranks.get(&index).copied();
616                let semantic_rank = semantic_ranks.get(&index).copied();
617                let fts_score = (fts_scores[index] > 0.0).then_some(fts_scores[index]);
618                let semantic_score =
619                    (semantic_scores[index] > 0.0).then_some(semantic_scores[index]);
620                let included = match effective_mode {
621                    SearchMode::Fts => fts_rank.is_some(),
622                    SearchMode::Semantic => semantic_rank.is_some(),
623                    SearchMode::Hybrid => fts_rank.is_some() || semantic_rank.is_some(),
624                };
625                included.then(|| SearchHit {
626                    session_id: event.session_id.clone(),
627                    event_id: event.event_id,
628                    kind: event.kind.clone(),
629                    score: combined_score(
630                        effective_mode,
631                        fts_rank,
632                        semantic_rank,
633                        fts_score,
634                        semantic_score,
635                    ),
636                    fts_score,
637                    semantic_score,
638                    snippet: snippet(&documents[index], &query.query, 240),
639                    event,
640                })
641            })
642            .collect::<Vec<_>>();
643        hits.sort_by(|left, right| {
644            right
645                .score
646                .total_cmp(&left.score)
647                .then_with(|| left.session_id.cmp(&right.session_id))
648                .then_with(|| left.event_id.cmp(&right.event_id))
649        });
650        hits.truncate(query.limit());
651        let semantic_floor = !semantic_available;
652        Ok(SearchResponse {
653            requested_mode: query.mode,
654            effective_mode,
655            embedding_backend: embedder.name().to_string(),
656            semantic_floor,
657            fallback_reason: semantic_floor
658                .then(|| "semantic model unavailable; FTS-only fallback active".into()),
659            hits,
660        })
661    }
662}
663
664fn validate_parent(record: &SessionRecord, event: &AppendEvent) -> StoreResult<()> {
665    let Some(parent_event_id) = event.parent_event_id else {
666        return Ok(());
667    };
668    if record
669        .events
670        .iter()
671        .any(|stored| stored.event_id == parent_event_id)
672    {
673        Ok(())
674    } else {
675        Err(StoreError::InvalidInput(format!(
676            "parent_event_id {parent_event_id} not present in session"
677        )))
678    }
679}
680
681fn match_filter(meta: &SessionMeta, filter: &ListFilter) -> bool {
682    if let Some(tenant) = filter.tenant_id.as_ref() {
683        if meta.tenant_id.as_deref() != Some(tenant.as_str()) {
684            return false;
685        }
686    }
687    if let Some(persona) = filter.persona.as_ref() {
688        if meta.persona.as_deref() != Some(persona.as_str()) {
689            return false;
690        }
691    }
692    if let Some(status) = filter.status {
693        if meta.status != status {
694            return false;
695        }
696    }
697    if let Some(tag) = filter.tag.as_ref() {
698        if !meta.tags.iter().any(|value| value == tag) {
699            return false;
700        }
701    }
702    if let Some(parent_session_id) = filter.parent_session_id.as_ref() {
703        if meta.parent_session_id.as_ref() != Some(parent_session_id) {
704            return false;
705        }
706    }
707    if let Some(session_type) = filter.session_type {
708        if meta.session_type != Some(session_type) {
709            return false;
710        }
711    }
712    if let Some(project_scope) = filter.project_scope.as_ref() {
713        if meta.project_scope.as_ref() != Some(project_scope) {
714            return false;
715        }
716    }
717    if let Some(after) = filter.created_after_ms {
718        if meta.created_at_ms < after {
719            return false;
720        }
721    }
722    if let Some(before) = filter.created_before_ms {
723        if meta.created_at_ms > before {
724            return false;
725        }
726    }
727    true
728}
729
730fn matches_search_filter(meta: &SessionMeta, filter: &super::search::SearchFilter) -> bool {
731    if matches!(
732        meta.status,
733        SessionStatus::SoftDeleted | SessionStatus::HardDeleted
734    ) {
735        return false;
736    }
737    if let Some(tenant_id) = filter.tenant_id.as_ref() {
738        if meta.tenant_id.as_ref() != Some(tenant_id) {
739            return false;
740        }
741    }
742    if let Some(project_scope) = filter.project_scope.as_ref() {
743        if meta.project_scope.as_ref() != Some(project_scope) {
744            return false;
745        }
746    }
747    if let Some(session_id) = filter.session_id.as_ref() {
748        if &meta.id != session_id {
749            return false;
750        }
751    }
752    true
753}