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        let meta = record.meta.clone();
246        // Release the store lock before publishing: an observer is free to
247        // read this session back, and would deadlock against our own guard.
248        drop(guard);
249        self.hooks.notify_session_changed(&meta);
250        Ok(meta)
251    }
252
253    async fn list(&self, filter: ListFilter) -> StoreResult<Vec<SessionMeta>> {
254        let guard = lock(&self.inner);
255        let limit = filter.limit.unwrap_or(MAX_READ_BATCH).min(MAX_READ_BATCH);
256        let mut out: Vec<SessionMeta> = guard
257            .sessions
258            .values()
259            .map(|record| record.meta.clone())
260            .filter(|meta| match_filter(meta, &filter))
261            .collect();
262        out.sort_by(|left, right| {
263            let timestamp_order = match filter.sort_by {
264                ListSortKey::CreatedAt => left.created_at_ms.cmp(&right.created_at_ms),
265                ListSortKey::UpdatedAt => left.updated_at_ms.cmp(&right.updated_at_ms),
266            };
267            let timestamp_order = match filter.order {
268                ListOrder::Ascending => timestamp_order,
269                ListOrder::Descending => timestamp_order.reverse(),
270            };
271            timestamp_order.then_with(|| left.id.cmp(&right.id))
272        });
273        if let Some(cursor) = filter.cursor.as_ref() {
274            let position = out.iter().position(|meta| meta.id == *cursor);
275            if let Some(start) = position {
276                out = out.into_iter().skip(start + 1).collect();
277            }
278        }
279        out.truncate(limit);
280        Ok(out)
281    }
282
283    async fn append(&self, session_id: &str, event: AppendEvent) -> StoreResult<StoredEvent> {
284        let mut guard = lock(&self.inner);
285        let record = guard
286            .sessions
287            .get_mut(session_id)
288            .ok_or_else(|| StoreError::NotFound(session_id.to_string()))?;
289        append_locked(record, &self.hooks, event)
290    }
291
292    async fn read(&self, session_id: &str, range: ReadRange) -> StoreResult<EventPage> {
293        let guard = lock(&self.inner);
294        let record = guard
295            .sessions
296            .get(session_id)
297            .ok_or_else(|| StoreError::NotFound(session_id.to_string()))?;
298        let from = range.from_event_id.unwrap_or(1);
299        let to = range.to_event_id.unwrap_or(EventId::MAX);
300        let limit = range.limit.unwrap_or(MAX_READ_BATCH).min(MAX_READ_BATCH);
301        let mut events: Vec<StoredEvent> = record
302            .events
303            .iter()
304            .filter(|event| event.event_id >= from && event.event_id <= to)
305            .take(limit)
306            .cloned()
307            .collect();
308        let next_cursor = if events.len() == limit {
309            events.last().map(|tail| tail.event_id + 1)
310        } else {
311            None
312        };
313        // Defense in depth for data imported or written under an older policy.
314        redact_stored_events(&self.hooks, &mut events)?;
315        Ok(EventPage {
316            events,
317            next_cursor,
318        })
319    }
320
321    async fn fork(
322        &self,
323        session_id: &str,
324        at_event_id: EventId,
325        child_id: Option<SessionId>,
326    ) -> StoreResult<ForkResult> {
327        let mut guard = lock(&self.inner);
328        let parent = guard
329            .sessions
330            .get(session_id)
331            .ok_or_else(|| StoreError::NotFound(session_id.to_string()))?;
332        if !parent
333            .events
334            .iter()
335            .any(|event| event.event_id == at_event_id)
336        {
337            return Err(StoreError::InvalidInput(format!(
338                "event {at_event_id} not found in session '{session_id}'"
339            )));
340        }
341        let new_id = child_id.unwrap_or_else(|| Uuid::now_v7().to_string());
342        if guard.sessions.contains_key(&new_id) {
343            return Err(StoreError::AlreadyExists(new_id));
344        }
345        let parent = guard.sessions.get(session_id).unwrap();
346        let (ms, text) = now_ms_and_rfc3339();
347        let mut child_meta = parent.meta.clone();
348        child_meta.id = new_id.clone();
349        child_meta.parent_session_id = Some(parent.meta.id.clone());
350        child_meta.created_at_ms = ms;
351        child_meta.created_at = text.clone();
352        child_meta.updated_at_ms = ms;
353        child_meta.updated_at = text;
354        child_meta.status = SessionStatus::Open;
355        child_meta.closed_at = None;
356        child_meta.closed_at_ms = None;
357        child_meta.soft_deleted_at_ms = None;
358        let mut parent_events: Vec<StoredEvent> = parent
359            .events
360            .iter()
361            .filter(|event| event.event_id <= at_event_id)
362            .cloned()
363            .collect();
364        prepare_stored_events_for_persistence(&self.hooks, &mut parent_events)?;
365        let copied_events = re_anchor_events(&parent_events, &new_id);
366        let copied_event_count = copied_events.len();
367        child_meta.event_count = copied_event_count;
368        child_meta.last_event_id = copied_events.last().map(|tail| tail.event_id);
369        child_meta.chain_root_hash = Some(chain_root_hash(&copied_events));
370        let next_event_id = copied_events
371            .last()
372            .map(|tail| tail.event_id + 1)
373            .unwrap_or(1);
374        let child_record = SessionRecord {
375            meta: child_meta,
376            events: copied_events,
377            next_event_id,
378        };
379        guard.sessions.insert(new_id.clone(), child_record);
380        Ok(ForkResult {
381            child_session_id: new_id,
382            forked_from_event_id: at_event_id,
383            copied_event_count,
384        })
385    }
386
387    async fn truncate(
388        &self,
389        session_id: &str,
390        at_event_id: EventId,
391    ) -> StoreResult<TruncateResult> {
392        let mut guard = lock(&self.inner);
393        let record = guard
394            .sessions
395            .get_mut(session_id)
396            .ok_or_else(|| StoreError::NotFound(session_id.to_string()))?;
397        if !record
398            .events
399            .iter()
400            .any(|event| event.event_id == at_event_id)
401        {
402            return Err(StoreError::InvalidInput(format!(
403                "event {at_event_id} not found in session '{session_id}'"
404            )));
405        }
406        let removed = record
407            .events
408            .iter()
409            .filter(|event| event.event_id > at_event_id)
410            .count();
411        record.events.retain(|event| event.event_id <= at_event_id);
412        record.next_event_id = at_event_id + 1;
413        record.meta.event_count = record.events.len();
414        record.meta.last_event_id = record.events.last().map(|tail| tail.event_id);
415        record.meta.chain_root_hash = Some(chain_root_hash(&record.events));
416        let (ms, text) = now_ms_and_rfc3339();
417        record.meta.updated_at_ms = ms;
418        record.meta.updated_at = text;
419        Ok(TruncateResult {
420            kept_event_count: record.events.len(),
421            removed_event_count: removed,
422            new_tip_event_id: record.events.last().map(|tail| tail.event_id),
423        })
424    }
425
426    async fn snapshot(&self, session_id: &str) -> StoreResult<Snapshot> {
427        let mut guard = lock(&self.inner);
428        let record = guard
429            .sessions
430            .get(session_id)
431            .ok_or_else(|| StoreError::NotFound(session_id.to_string()))?;
432        let (ms, text) = now_ms_and_rfc3339();
433        let mut events = record.events.clone();
434        redact_stored_events(&self.hooks, &mut events)?;
435        let snapshot = Snapshot {
436            id: SnapshotId(format!("snap-{}", Uuid::now_v7())),
437            session: record.meta.clone(),
438            events,
439            captured_at_ms: ms,
440            captured_at: text,
441        };
442        guard
443            .snapshots
444            .insert(snapshot.id.0.clone(), snapshot.clone());
445        Ok(snapshot)
446    }
447
448    async fn replay(&self, snapshot_id: &SnapshotId) -> StoreResult<Snapshot> {
449        let guard = lock(&self.inner);
450        let mut snapshot = guard
451            .snapshots
452            .get(&snapshot_id.0)
453            .cloned()
454            .ok_or_else(|| StoreError::NotFound(snapshot_id.0.clone()))?;
455        redact_stored_events(&self.hooks, &mut snapshot.events)?;
456        Ok(snapshot)
457    }
458
459    async fn close(&self, session_id: &str) -> StoreResult<StoredEvent> {
460        // Hold the single store lock across read -> append receipt ->
461        // finalise so no concurrent append can interleave and move the
462        // tip off the receipt we just minted.
463        let mut guard = lock(&self.inner);
464        let record = guard
465            .sessions
466            .get_mut(session_id)
467            .ok_or_else(|| StoreError::NotFound(session_id.to_string()))?;
468        validate_open(&record.meta)?;
469        let record_root = record
470            .meta
471            .chain_root_hash
472            .clone()
473            .unwrap_or_else(|| chain_root_hash(&record.events));
474        let last_event_id = record.meta.last_event_id.unwrap_or(0);
475        let payload =
476            super::signing::canonical_receipt_payload(session_id, last_event_id, &record_root);
477        let mut append = AppendEvent::new(SessionEventKind::Receipt, payload);
478        append.actor = Some("session_store".into());
479        let mut stored = append_locked(record, &self.hooks, append)?;
480        // Intentionally replace the receipt's append-time per-event
481        // signature with a receipt-root signature: the receipt attests the
482        // chain root, so `verify()` checks it via `verify_receipt_root`
483        // against the pre-receipt root, not the event's own bytes.
484        if let Some(signer) = self
485            .hooks
486            .receipt_signer
487            .as_ref()
488            .or(self.hooks.event_signer.as_ref())
489        {
490            let signature = signer.sign_receipt(&record_root);
491            // Locate the receipt by its event id rather than `last_mut()`,
492            // so we can never sign a different event.
493            if let Some(receipt) = record
494                .events
495                .iter_mut()
496                .find(|event| event.event_id == stored.event_id)
497            {
498                receipt.signed_by = Some(signature.clone());
499            }
500            stored.signed_by = Some(signature);
501        }
502        let (ms, text) = now_ms_and_rfc3339();
503        record.meta.status = SessionStatus::Closed;
504        record.meta.closed_at_ms = Some(ms);
505        record.meta.closed_at = Some(text);
506        Ok(stored)
507    }
508
509    async fn soft_delete(&self, session_id: &str) -> StoreResult<SessionMeta> {
510        let mut guard = lock(&self.inner);
511        let record = guard
512            .sessions
513            .get_mut(session_id)
514            .ok_or_else(|| StoreError::NotFound(session_id.to_string()))?;
515        match record.meta.status {
516            SessionStatus::HardDeleted => return Err(StoreError::NotFound(session_id.to_string())),
517            SessionStatus::SoftDeleted => return Ok(record.meta.clone()),
518            _ => {}
519        }
520        let (ms, text) = now_ms_and_rfc3339();
521        record.meta.status = SessionStatus::SoftDeleted;
522        record.meta.soft_deleted_at_ms = Some(ms);
523        record.meta.updated_at_ms = ms;
524        record.meta.updated_at = text;
525        Ok(record.meta.clone())
526    }
527
528    async fn hard_delete(&self, session_id: &str) -> StoreResult<()> {
529        let mut guard = lock(&self.inner);
530        guard
531            .sessions
532            .remove(session_id)
533            .ok_or_else(|| StoreError::NotFound(session_id.to_string()))?;
534        Ok(())
535    }
536
537    async fn verify(&self, session_id: &str) -> StoreResult<VerifyReport> {
538        let guard = lock(&self.inner);
539        let record = guard
540            .sessions
541            .get(session_id)
542            .ok_or_else(|| StoreError::NotFound(session_id.to_string()))?;
543        let event_verifier = self
544            .hooks
545            .event_signer
546            .as_ref()
547            .map(|signer| signer.verifying_key());
548        let receipt_verifier = self
549            .hooks
550            .receipt_signer
551            .as_ref()
552            .or(self.hooks.event_signer.as_ref())
553            .map(|signer| signer.verifying_key());
554        Ok(verify_session_chain(
555            &record.meta,
556            &record.events,
557            event_verifier.as_ref(),
558            receipt_verifier.as_ref(),
559        ))
560    }
561
562    async fn search(&self, query: SearchQuery) -> StoreResult<SearchResponse> {
563        query.validate().map_err(StoreError::InvalidInput)?;
564        let embedder = self.hooks.embedder.clone();
565        let mut candidates = {
566            let guard = lock(&self.inner);
567            let mut candidates = Vec::new();
568            for record in guard.sessions.values() {
569                if !matches_search_filter(&record.meta, &query.filter) {
570                    continue;
571                }
572                for event in &record.events {
573                    candidates.push((record.meta.clone(), event.clone()));
574                }
575            }
576            candidates
577        };
578        let mut events = candidates
579            .iter()
580            .map(|(_, event)| event.clone())
581            .collect::<Vec<_>>();
582        redact_stored_events(&self.hooks, &mut events)?;
583        for ((_, candidate), redacted) in candidates.iter_mut().zip(events) {
584            *candidate = redacted;
585        }
586
587        let documents = candidates
588            .iter()
589            .map(|(meta, event)| {
590                redacted_search_document(self.hooks.redaction.as_ref(), meta, event)
591            })
592            .collect::<Vec<_>>();
593        let fts_scores = documents
594            .iter()
595            .map(|document| lexical_score(&query.query, document))
596            .collect::<Vec<_>>();
597        let semantic_available = embedder.is_semantic();
598        let semantic_scores = if semantic_available {
599            let query_vector = embedder.embed(&query.query);
600            embedder
601                .embed_batch(&documents)
602                .iter()
603                .map(|vector| super::search::cosine(&query_vector, vector).max(0.0))
604                .collect::<Vec<_>>()
605        } else {
606            vec![0.0; documents.len()]
607        };
608
609        let fts_ranks = ranks(&fts_scores);
610        let semantic_ranks = ranks(&semantic_scores);
611        let effective_mode = if semantic_available {
612            query.mode
613        } else {
614            SearchMode::Fts
615        };
616        let mut hits = candidates
617            .into_iter()
618            .enumerate()
619            .filter_map(|(index, (_, event))| {
620                let fts_rank = fts_ranks.get(&index).copied();
621                let semantic_rank = semantic_ranks.get(&index).copied();
622                let fts_score = (fts_scores[index] > 0.0).then_some(fts_scores[index]);
623                let semantic_score =
624                    (semantic_scores[index] > 0.0).then_some(semantic_scores[index]);
625                let included = match effective_mode {
626                    SearchMode::Fts => fts_rank.is_some(),
627                    SearchMode::Semantic => semantic_rank.is_some(),
628                    SearchMode::Hybrid => fts_rank.is_some() || semantic_rank.is_some(),
629                };
630                included.then(|| SearchHit {
631                    session_id: event.session_id.clone(),
632                    event_id: event.event_id,
633                    kind: event.kind.clone(),
634                    score: combined_score(
635                        effective_mode,
636                        fts_rank,
637                        semantic_rank,
638                        fts_score,
639                        semantic_score,
640                    ),
641                    fts_score,
642                    semantic_score,
643                    snippet: snippet(&documents[index], &query.query, 240),
644                    event,
645                })
646            })
647            .collect::<Vec<_>>();
648        hits.sort_by(|left, right| {
649            right
650                .score
651                .total_cmp(&left.score)
652                .then_with(|| left.session_id.cmp(&right.session_id))
653                .then_with(|| left.event_id.cmp(&right.event_id))
654        });
655        hits.truncate(query.limit());
656        let semantic_floor = !semantic_available;
657        Ok(SearchResponse {
658            requested_mode: query.mode,
659            effective_mode,
660            embedding_backend: embedder.name().to_string(),
661            semantic_floor,
662            fallback_reason: semantic_floor
663                .then(|| "semantic model unavailable; FTS-only fallback active".into()),
664            hits,
665        })
666    }
667}
668
669fn validate_parent(record: &SessionRecord, event: &AppendEvent) -> StoreResult<()> {
670    let Some(parent_event_id) = event.parent_event_id else {
671        return Ok(());
672    };
673    if record
674        .events
675        .iter()
676        .any(|stored| stored.event_id == parent_event_id)
677    {
678        Ok(())
679    } else {
680        Err(StoreError::InvalidInput(format!(
681            "parent_event_id {parent_event_id} not present in session"
682        )))
683    }
684}
685
686fn match_filter(meta: &SessionMeta, filter: &ListFilter) -> bool {
687    if let Some(tenant) = filter.tenant_id.as_ref() {
688        if meta.tenant_id.as_deref() != Some(tenant.as_str()) {
689            return false;
690        }
691    }
692    if let Some(persona) = filter.persona.as_ref() {
693        if meta.persona.as_deref() != Some(persona.as_str()) {
694            return false;
695        }
696    }
697    if let Some(status) = filter.status {
698        if meta.status != status {
699            return false;
700        }
701    }
702    if let Some(tag) = filter.tag.as_ref() {
703        if !meta.tags.iter().any(|value| value == tag) {
704            return false;
705        }
706    }
707    if let Some(parent_session_id) = filter.parent_session_id.as_ref() {
708        if meta.parent_session_id.as_ref() != Some(parent_session_id) {
709            return false;
710        }
711    }
712    if let Some(session_type) = filter.session_type {
713        if meta.session_type != Some(session_type) {
714            return false;
715        }
716    }
717    if let Some(project_scope) = filter.project_scope.as_ref() {
718        if meta.project_scope.as_ref() != Some(project_scope) {
719            return false;
720        }
721    }
722    if let Some(after) = filter.created_after_ms {
723        if meta.created_at_ms < after {
724            return false;
725        }
726    }
727    if let Some(before) = filter.created_before_ms {
728        if meta.created_at_ms > before {
729            return false;
730        }
731    }
732    true
733}
734
735fn matches_search_filter(meta: &SessionMeta, filter: &super::search::SearchFilter) -> bool {
736    if matches!(
737        meta.status,
738        SessionStatus::SoftDeleted | SessionStatus::HardDeleted
739    ) {
740        return false;
741    }
742    if let Some(tenant_id) = filter.tenant_id.as_ref() {
743        if meta.tenant_id.as_ref() != Some(tenant_id) {
744            return false;
745        }
746    }
747    if let Some(project_scope) = filter.project_scope.as_ref() {
748        if meta.project_scope.as_ref() != Some(project_scope) {
749            return false;
750        }
751    }
752    if let Some(session_id) = filter.session_id.as_ref() {
753        if &meta.id != session_id {
754            return false;
755        }
756    }
757    true
758}