1use std::collections::BTreeSet;
4
5use async_trait::async_trait;
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8use sha2::{Digest, Sha256};
9use starweaver_core::{AgentId, RunId, SessionId};
10use starweaver_stream::{ReplayCursor, ReplayScope};
11use thiserror::Error;
12
13use crate::{RunStatus, SessionStatus};
14
15#[async_trait]
17pub trait SessionSearchProvider: Send + Sync {
18 fn capabilities(&self) -> SessionSearchCapabilities;
20
21 async fn search(
23 &self,
24 scope: &SessionSearchScope,
25 query: SessionSearchQuery,
26 ) -> Result<SessionSearchPage, SessionSearchError>;
27}
28
29#[derive(Clone, Debug, Eq, PartialEq)]
31pub struct SessionSearchScope {
32 namespace: String,
33 policy_fingerprint: String,
34}
35
36impl SessionSearchScope {
37 #[must_use]
39 pub fn new(namespace: impl Into<String>, policy_fingerprint: impl Into<String>) -> Self {
40 Self {
41 namespace: namespace.into(),
42 policy_fingerprint: policy_fingerprint.into(),
43 }
44 }
45
46 #[must_use]
48 pub fn local(namespace: impl Into<String>) -> Self {
49 Self::new(namespace, "local-user-visible-v1")
50 }
51
52 #[must_use]
54 pub fn fingerprint(&self) -> String {
55 digest_parts(&[&self.namespace, &self.policy_fingerprint])
56 }
57}
58
59#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
61#[serde(rename_all = "snake_case")]
62pub enum SessionSearchQueryMode {
63 #[default]
65 Literal,
66 Phrase,
68 Prefix,
70 Semantic,
72}
73
74#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
76#[serde(rename_all = "snake_case")]
77pub enum SessionSearchSource {
78 SessionMetadata,
80 RunInput,
82 RunOutputPreview,
84 DisplayMessage,
86}
87
88impl SessionSearchSource {
89 #[must_use]
91 pub fn baseline() -> BTreeSet<Self> {
92 BTreeSet::from([
93 Self::SessionMetadata,
94 Self::RunInput,
95 Self::RunOutputPreview,
96 Self::DisplayMessage,
97 ])
98 }
99}
100
101#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
103#[serde(rename_all = "snake_case")]
104pub enum SessionSearchGranularity {
105 #[default]
107 Session,
108 Run,
110 Occurrence,
112}
113
114#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
116#[serde(rename_all = "snake_case")]
117pub enum SessionSearchSort {
118 #[default]
120 Auto,
121 Relevance,
123 UpdatedDesc,
125}
126
127#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
129#[serde(rename_all = "snake_case")]
130pub enum SessionSearchVisibility {
131 Public,
133 Diagnostic,
135 Internal,
137}
138
139#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
141#[serde(rename_all = "camelCase")]
142pub struct SessionSearchTimeRange {
143 #[serde(default, skip_serializing_if = "Option::is_none")]
145 pub from: Option<DateTime<Utc>>,
146 #[serde(default, skip_serializing_if = "Option::is_none")]
148 pub until: Option<DateTime<Utc>>,
149}
150
151#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
153#[serde(rename_all = "camelCase")]
154pub struct SessionSearchFilter {
155 #[serde(default, skip_serializing_if = "Vec::is_empty")]
157 pub session_statuses: Vec<SessionStatus>,
158 #[serde(default, skip_serializing_if = "Vec::is_empty")]
160 pub run_statuses: Vec<RunStatus>,
161 #[serde(default, skip_serializing_if = "Option::is_none")]
163 pub profile: Option<String>,
164 #[serde(default, skip_serializing_if = "Option::is_none")]
166 pub workspace: Option<String>,
167 #[serde(default, skip_serializing_if = "Option::is_none")]
169 pub created: Option<SessionSearchTimeRange>,
170 #[serde(default, skip_serializing_if = "Option::is_none")]
172 pub updated: Option<SessionSearchTimeRange>,
173 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
175 pub session_ids: BTreeSet<SessionId>,
176 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
178 pub display_visibilities: BTreeSet<SessionSearchVisibility>,
179}
180
181#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
183#[serde(rename_all = "camelCase")]
184pub struct SessionSearchQuery {
185 #[serde(default, skip_serializing_if = "Option::is_none", alias = "query")]
187 pub text: Option<String>,
188 #[serde(default)]
190 pub mode: SessionSearchQueryMode,
191 #[serde(default, alias = "filters")]
193 pub filter: SessionSearchFilter,
194 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
196 pub sources: BTreeSet<SessionSearchSource>,
197 #[serde(default)]
199 pub granularity: SessionSearchGranularity,
200 #[serde(default)]
202 pub sort: SessionSearchSort,
203 pub limit: u32,
205 #[serde(default, skip_serializing_if = "Option::is_none", alias = "after")]
207 pub cursor: Option<String>,
208}
209
210impl Default for SessionSearchQuery {
211 fn default() -> Self {
212 Self {
213 text: None,
214 mode: SessionSearchQueryMode::Literal,
215 filter: SessionSearchFilter::default(),
216 sources: SessionSearchSource::baseline(),
217 granularity: SessionSearchGranularity::Session,
218 sort: SessionSearchSort::Auto,
219 limit: 20,
220 cursor: None,
221 }
222 }
223}
224
225impl SessionSearchQuery {
226 pub fn fingerprint(&self) -> Result<String, SessionSearchError> {
231 let mut normalized = self.clone();
232 normalized.cursor = None;
233 if let Some(text) = normalized.text.as_mut() {
234 *text = text.trim().to_lowercase();
235 }
236 let bytes = serde_json::to_vec(&normalized)
237 .map_err(|error| SessionSearchError::InvalidQuery(error.to_string()))?;
238 Ok(hex_digest(&bytes))
239 }
240}
241
242#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
244#[serde(rename_all = "snake_case")]
245pub enum SessionSearchFilterKind {
246 SessionStatus,
248 RunStatus,
250 Profile,
252 Workspace,
254 CreatedTime,
256 UpdatedTime,
258 SessionIds,
260 DisplayVisibility,
262}
263
264#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
266#[serde(rename_all = "snake_case")]
267pub enum SessionSearchConsistency {
268 ReadThrough,
270 TransactionalIndex,
272 EventualIndex,
274}
275
276#[allow(clippy::struct_excessive_bools)]
278#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
279#[serde(rename_all = "camelCase")]
280pub struct SessionSearchCapabilities {
281 pub provider: String,
283 pub query_modes: BTreeSet<SessionSearchQueryMode>,
285 pub sources: BTreeSet<SessionSearchSource>,
287 pub filters: BTreeSet<SessionSearchFilterKind>,
289 pub granularities: BTreeSet<SessionSearchGranularity>,
291 pub sorts: BTreeSet<SessionSearchSort>,
293 pub occurrence_locations: bool,
295 pub snippets: bool,
297 pub scores: bool,
299 pub freshness_watermarks: bool,
301 pub max_page_size: u32,
303 pub consistency: SessionSearchConsistency,
305}
306
307#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
309#[serde(rename_all = "snake_case")]
310pub enum SessionSearchCoverageState {
311 Complete,
313 EventuallyConsistent,
315 Partial,
317 Degraded,
319}
320
321#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
323#[serde(rename_all = "snake_case")]
324pub enum SessionSearchWarningKind {
325 MissingSource,
327 MalformedSource,
329 LimitReached,
331 UnavailableSource,
333 UnverifiedSource,
335 Fallback,
337}
338
339#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
341#[serde(rename_all = "camelCase")]
342pub struct SessionSearchWarning {
343 pub kind: SessionSearchWarningKind,
345 pub message: String,
347}
348
349#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
351#[serde(rename_all = "camelCase")]
352pub struct SessionSearchCoverage {
353 pub state: SessionSearchCoverageState,
355 pub searched_sources: BTreeSet<SessionSearchSource>,
357 pub unavailable_sources: BTreeSet<SessionSearchSource>,
359 #[serde(default, skip_serializing_if = "Option::is_none")]
361 pub indexed_through: Option<DateTime<Utc>>,
362 #[serde(default, skip_serializing_if = "Option::is_none")]
364 pub generation: Option<String>,
365 #[serde(default, skip_serializing_if = "Vec::is_empty")]
367 pub warnings: Vec<SessionSearchWarning>,
368}
369
370#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
372#[serde(rename_all = "camelCase")]
373pub struct SessionSearchSummary {
374 pub session_id: SessionId,
376 #[serde(default, skip_serializing_if = "Option::is_none")]
378 pub title: Option<String>,
379 pub status: SessionStatus,
381 #[serde(default, skip_serializing_if = "Option::is_none")]
383 pub profile: Option<String>,
384 #[serde(default, skip_serializing_if = "Option::is_none")]
386 pub workspace: Option<String>,
387 pub created_at: DateTime<Utc>,
389 pub updated_at: DateTime<Utc>,
391 #[serde(default, skip_serializing_if = "Option::is_none")]
393 pub run_status: Option<RunStatus>,
394 #[serde(default, skip_serializing_if = "Option::is_none")]
396 pub run_preview: Option<String>,
397}
398
399#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
401#[serde(rename_all = "camelCase")]
402pub struct SessionSearchHighlight {
403 pub start: usize,
405 pub end: usize,
407}
408
409#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
411#[serde(rename_all = "camelCase")]
412pub struct SessionSearchSnippet {
413 pub text: String,
415 #[serde(default, skip_serializing_if = "Vec::is_empty")]
417 pub highlights: Vec<SessionSearchHighlight>,
418}
419
420#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
422#[serde(rename_all = "camelCase")]
423pub struct SessionSearchLocation {
424 pub session_id: SessionId,
426 #[serde(default, skip_serializing_if = "Option::is_none")]
428 pub run_id: Option<RunId>,
429 pub source: SessionSearchSource,
431 #[serde(default, skip_serializing_if = "Option::is_none")]
433 pub archive_scope: Option<ReplayScope>,
434 #[serde(default, skip_serializing_if = "Option::is_none")]
436 pub source_agent_id: Option<AgentId>,
437 #[serde(default, skip_serializing_if = "Option::is_none")]
439 pub source_run_id: Option<RunId>,
440 #[serde(default, skip_serializing_if = "Option::is_none")]
442 pub display_sequence: Option<usize>,
443 #[serde(default, skip_serializing_if = "Option::is_none")]
445 pub cursor: Option<ReplayCursor>,
446 pub document_id: String,
448}
449
450#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
452#[serde(rename_all = "camelCase")]
453pub struct SessionSearchHit {
454 pub session: SessionSearchSummary,
456 #[serde(default, skip_serializing_if = "Option::is_none")]
458 pub run_id: Option<RunId>,
459 pub source: SessionSearchSource,
461 pub location: SessionSearchLocation,
463 #[serde(default, skip_serializing_if = "Option::is_none")]
465 pub snippet: Option<SessionSearchSnippet>,
466 #[serde(default, skip_serializing_if = "Option::is_none")]
468 pub score: Option<f64>,
469 #[serde(default, skip_serializing_if = "Option::is_none")]
471 pub matched_at: Option<DateTime<Utc>>,
472}
473
474#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
476#[serde(rename_all = "camelCase")]
477pub struct SessionSearchPage {
478 pub hits: Vec<SessionSearchHit>,
480 #[serde(default, skip_serializing_if = "Option::is_none")]
482 pub next_cursor: Option<String>,
483 pub coverage: SessionSearchCoverage,
485}
486
487#[derive(Clone, Debug, Error, Eq, PartialEq)]
489pub enum SessionSearchError {
490 #[error("invalid search query: {0}")]
492 InvalidQuery(String),
493 #[error("invalid search cursor: {0}")]
495 InvalidCursor(String),
496 #[error("unsupported search capability: {0}")]
498 Unsupported(String),
499 #[error("session search unavailable: {0}")]
501 Unavailable(String),
502 #[error("session search permission denied")]
504 PermissionDenied,
505 #[error("session search failed: {0}")]
507 Failed(String),
508}
509
510impl SessionSearchError {
511 #[must_use]
513 pub const fn category(&self) -> &'static str {
514 match self {
515 Self::InvalidQuery(_) => "invalid_query",
516 Self::InvalidCursor(_) => "invalid_cursor",
517 Self::Unsupported(_) => "unsupported",
518 Self::Unavailable(_) => "unavailable",
519 Self::PermissionDenied => "permission_denied",
520 Self::Failed(_) => "failed",
521 }
522 }
523}
524
525#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
527#[serde(rename_all = "camelCase")]
528pub struct SessionSearchCursorBinding {
529 pub version: u32,
531 pub provider: String,
533 pub query_fingerprint: String,
535 pub scope_fingerprint: String,
537 pub generation: String,
539 pub offset: usize,
541 pub last_identity: Option<String>,
543}
544
545#[derive(Clone, Debug)]
547pub struct SessionSearchCursorCodec {
548 key: Vec<u8>,
549}
550
551impl SessionSearchCursorCodec {
552 #[must_use]
554 pub fn new(key: impl AsRef<[u8]>) -> Self {
555 Self {
556 key: key.as_ref().to_vec(),
557 }
558 }
559
560 pub fn encode(
565 &self,
566 binding: &SessionSearchCursorBinding,
567 ) -> Result<String, SessionSearchError> {
568 let payload = serde_json::to_vec(binding)
569 .map_err(|error| SessionSearchError::Failed(error.to_string()))?;
570 let signature = self.signature(&payload);
571 Ok(format!("ssc1.{}.{}", hex_encode(&payload), signature))
572 }
573
574 pub fn decode(&self, cursor: &str) -> Result<SessionSearchCursorBinding, SessionSearchError> {
579 let mut parts = cursor.split('.');
580 if parts.next() != Some("ssc1") {
581 return Err(SessionSearchError::InvalidCursor(
582 "unknown cursor format".to_string(),
583 ));
584 }
585 let payload = parts
586 .next()
587 .and_then(hex_decode)
588 .ok_or_else(|| SessionSearchError::InvalidCursor("malformed cursor".to_string()))?;
589 let signature = parts
590 .next()
591 .filter(|_| parts.next().is_none())
592 .ok_or_else(|| SessionSearchError::InvalidCursor("malformed cursor".to_string()))?;
593 if self.signature(&payload) != signature {
594 return Err(SessionSearchError::InvalidCursor(
595 "cursor authentication failed".to_string(),
596 ));
597 }
598 serde_json::from_slice(&payload)
599 .map_err(|_| SessionSearchError::InvalidCursor("malformed cursor payload".to_string()))
600 }
601
602 fn signature(&self, payload: &[u8]) -> String {
603 let mut digest = Sha256::new();
604 digest.update(b"starweaver.session-search.cursor.v1\0");
605 digest.update(&self.key);
606 digest.update([0]);
607 digest.update(payload);
608 hex_encode(&digest.finalize())
609 }
610}
611
612#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
614#[serde(rename_all = "camelCase")]
615pub struct SessionSearchDocument {
616 pub document_id: String,
618 pub session_id: SessionId,
620 #[serde(default, skip_serializing_if = "Option::is_none")]
622 pub run_id: Option<RunId>,
623 pub source: SessionSearchSource,
625 pub text: String,
627 pub content_digest: String,
629 pub updated_at: DateTime<Utc>,
631}
632
633#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
635#[serde(rename_all = "snake_case")]
636pub enum SessionSearchMutationOperation {
637 UpsertSession,
639 UpsertRun,
641 UpsertDisplay,
643 DeleteRun,
645 TombstoneSession,
647 ResetGeneration,
649}
650
651#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
653#[serde(rename_all = "camelCase")]
654pub struct SessionSearchMutation {
655 pub event_id: String,
657 pub source_revision: u64,
659 pub projection_version: String,
661 pub operation: SessionSearchMutationOperation,
663 pub scope_fingerprint: String,
665 #[serde(default, skip_serializing_if = "Option::is_none")]
667 pub document: Option<SessionSearchDocument>,
668 #[serde(default, skip_serializing_if = "Option::is_none")]
670 pub session_id: Option<SessionId>,
671 #[serde(default, skip_serializing_if = "Option::is_none")]
673 pub run_id: Option<RunId>,
674}
675
676#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
678#[serde(rename_all = "camelCase")]
679pub struct SessionSearchCheckpoint {
680 pub generation: String,
682 pub event_id: String,
684 #[serde(default, skip_serializing_if = "Option::is_none")]
686 pub indexed_through: Option<DateTime<Utc>>,
687}
688
689#[async_trait]
691pub trait SessionSearchIndexWriter: Send + Sync {
692 async fn apply(
694 &self,
695 mutations: &[SessionSearchMutation],
696 ) -> Result<SessionSearchCheckpoint, SessionSearchIndexError>;
697}
698
699#[derive(Clone, Debug, Error, Eq, PartialEq)]
701pub enum SessionSearchIndexError {
702 #[error("invalid session search mutation: {0}")]
704 InvalidMutation(String),
705 #[error("session search index unavailable: {0}")]
707 Unavailable(String),
708 #[error("session search index failed: {0}")]
710 Failed(String),
711}
712
713fn digest_parts(parts: &[&str]) -> String {
714 let mut digest = Sha256::new();
715 for part in parts {
716 digest.update(part.as_bytes());
717 digest.update([0]);
718 }
719 hex_encode(&digest.finalize())
720}
721
722fn hex_digest(bytes: &[u8]) -> String {
723 let mut digest = Sha256::new();
724 digest.update(bytes);
725 hex_encode(&digest.finalize())
726}
727
728fn hex_encode(bytes: &[u8]) -> String {
729 const HEX: &[u8; 16] = b"0123456789abcdef";
730 let mut output = String::with_capacity(bytes.len() * 2);
731 for byte in bytes {
732 output.push(HEX[(byte >> 4) as usize] as char);
733 output.push(HEX[(byte & 0x0f) as usize] as char);
734 }
735 output
736}
737
738fn hex_decode(value: &str) -> Option<Vec<u8>> {
739 if !value.len().is_multiple_of(2) {
740 return None;
741 }
742 value
743 .as_bytes()
744 .chunks_exact(2)
745 .map(|pair| {
746 let high = (pair[0] as char).to_digit(16)?;
747 let low = (pair[1] as char).to_digit(16)?;
748 u8::try_from((high << 4) | low).ok()
749 })
750 .collect()
751}
752
753#[cfg(test)]
754mod tests {
755 #![allow(clippy::expect_used)]
756
757 use std::{collections::BTreeMap, sync::Mutex};
758
759 use super::*;
760
761 #[test]
762 fn cursor_codec_authenticates_all_normative_bindings() {
763 let codec = SessionSearchCursorCodec::new("test-secret");
764 let binding = SessionSearchCursorBinding {
765 version: 1,
766 provider: "fake".to_string(),
767 query_fingerprint: "query-a".to_string(),
768 scope_fingerprint: "scope-a".to_string(),
769 generation: "generation-a".to_string(),
770 offset: 20,
771 last_identity: Some("document-a".to_string()),
772 };
773 let encoded = codec.encode(&binding).expect("encode cursor");
774 assert!(!encoded.contains("document-a"));
775 assert_eq!(codec.decode(&encoded).expect("decode cursor"), binding);
776
777 let mut tampered = encoded.into_bytes();
778 let last = tampered.len() - 1;
779 tampered[last] = if tampered[last] == b'a' { b'b' } else { b'a' };
780 assert!(matches!(
781 codec.decode(std::str::from_utf8(&tampered).expect("utf8")),
782 Err(SessionSearchError::InvalidCursor(_))
783 ));
784 }
785
786 #[test]
787 fn query_fingerprint_normalizes_text_and_excludes_cursor() {
788 let mut first = SessionSearchQuery {
789 text: Some(" OAuth Refresh ".to_string()),
790 cursor: Some("cursor-a".to_string()),
791 ..SessionSearchQuery::default()
792 };
793 let second = SessionSearchQuery {
794 text: Some("oauth refresh".to_string()),
795 cursor: Some("cursor-b".to_string()),
796 ..SessionSearchQuery::default()
797 };
798 assert_eq!(
799 first.fingerprint().expect("fingerprint"),
800 second.fingerprint().expect("fingerprint")
801 );
802 first.filter.profile = Some("coding".to_string());
803 assert_ne!(
804 first.fingerprint().expect("fingerprint"),
805 second.fingerprint().expect("fingerprint")
806 );
807 }
808
809 struct ConformanceProvider {
810 response: Result<SessionSearchPage, SessionSearchError>,
811 }
812
813 #[async_trait]
814 impl SessionSearchProvider for ConformanceProvider {
815 fn capabilities(&self) -> SessionSearchCapabilities {
816 SessionSearchCapabilities {
817 provider: "fake".to_string(),
818 query_modes: BTreeSet::from([SessionSearchQueryMode::Literal]),
819 sources: BTreeSet::from([SessionSearchSource::SessionMetadata]),
820 filters: BTreeSet::new(),
821 granularities: BTreeSet::from([SessionSearchGranularity::Session]),
822 sorts: BTreeSet::from([SessionSearchSort::Auto]),
823 occurrence_locations: false,
824 snippets: false,
825 scores: false,
826 freshness_watermarks: false,
827 max_page_size: 20,
828 consistency: SessionSearchConsistency::ReadThrough,
829 }
830 }
831
832 async fn search(
833 &self,
834 _scope: &SessionSearchScope,
835 _query: SessionSearchQuery,
836 ) -> Result<SessionSearchPage, SessionSearchError> {
837 self.response.clone()
838 }
839 }
840
841 fn empty_page(state: SessionSearchCoverageState) -> SessionSearchPage {
842 SessionSearchPage {
843 hits: Vec::new(),
844 next_cursor: None,
845 coverage: SessionSearchCoverage {
846 state,
847 searched_sources: BTreeSet::from([SessionSearchSource::SessionMetadata]),
848 unavailable_sources: BTreeSet::new(),
849 indexed_through: None,
850 generation: Some("fake-generation".to_string()),
851 warnings: Vec::new(),
852 },
853 }
854 }
855
856 #[tokio::test]
857 async fn fake_provider_distinguishes_empty_coverage_and_service_errors() {
858 let scope = SessionSearchScope::local("fake");
859 for state in [
860 SessionSearchCoverageState::Complete,
861 SessionSearchCoverageState::Partial,
862 SessionSearchCoverageState::Degraded,
863 SessionSearchCoverageState::EventuallyConsistent,
864 ] {
865 let provider = ConformanceProvider {
866 response: Ok(empty_page(state)),
867 };
868 let page = provider
869 .search(&scope, SessionSearchQuery::default())
870 .await
871 .expect("fake page");
872 assert!(page.hits.is_empty());
873 assert_eq!(page.coverage.state, state);
874 }
875 for error in [
876 SessionSearchError::Unsupported("mode".to_string()),
877 SessionSearchError::Unavailable("index".to_string()),
878 ] {
879 let provider = ConformanceProvider {
880 response: Err(error.clone()),
881 };
882 assert_eq!(
883 provider.search(&scope, SessionSearchQuery::default()).await,
884 Err(error)
885 );
886 }
887 }
888
889 #[derive(Default)]
890 struct ConformanceWriter {
891 revisions: Mutex<BTreeMap<String, (u64, Option<SessionSearchDocument>)>>,
892 }
893
894 #[async_trait]
895 impl SessionSearchIndexWriter for ConformanceWriter {
896 async fn apply(
897 &self,
898 mutations: &[SessionSearchMutation],
899 ) -> Result<SessionSearchCheckpoint, SessionSearchIndexError> {
900 let mut revisions = self.revisions.lock().expect("lock revisions");
901 for mutation in mutations {
902 let key = mutation
903 .session_id
904 .as_ref()
905 .map_or_else(
906 || {
907 mutation
908 .document
909 .as_ref()
910 .map_or("generation", |document| document.session_id.as_str())
911 },
912 SessionId::as_str,
913 )
914 .to_string();
915 let current = revisions.get(&key).map_or(0, |(revision, _)| *revision);
916 if mutation.source_revision < current {
917 continue;
918 }
919 let document = match mutation.operation {
920 SessionSearchMutationOperation::TombstoneSession
921 | SessionSearchMutationOperation::DeleteRun => None,
922 _ => mutation.document.clone(),
923 };
924 revisions.insert(key, (mutation.source_revision, document));
925 }
926 Ok(SessionSearchCheckpoint {
927 generation: "fake-generation".to_string(),
928 event_id: mutations
929 .last()
930 .map_or("none", |mutation| mutation.event_id.as_str())
931 .to_string(),
932 indexed_through: Some(Utc::now()),
933 })
934 }
935 }
936
937 #[tokio::test]
938 async fn writer_conformance_prevents_delayed_upsert_resurrection() {
939 let writer = ConformanceWriter::default();
940 let session_id = SessionId::from_string("session_writer");
941 let document = SessionSearchDocument {
942 document_id: "doc".to_string(),
943 session_id: session_id.clone(),
944 run_id: None,
945 source: SessionSearchSource::SessionMetadata,
946 text: "safe projection".to_string(),
947 content_digest: "digest".to_string(),
948 updated_at: Utc::now(),
949 };
950 let upsert = SessionSearchMutation {
951 event_id: "upsert-1".to_string(),
952 source_revision: 1,
953 projection_version: "v1".to_string(),
954 operation: SessionSearchMutationOperation::UpsertSession,
955 scope_fingerprint: "scope".to_string(),
956 document: Some(document.clone()),
957 session_id: Some(session_id.clone()),
958 run_id: None,
959 };
960 let tombstone = SessionSearchMutation {
961 event_id: "delete-2".to_string(),
962 source_revision: 2,
963 operation: SessionSearchMutationOperation::TombstoneSession,
964 document: None,
965 ..upsert.clone()
966 };
967 writer.apply(&[tombstone]).await.expect("tombstone");
968 writer.apply(&[upsert]).await.expect("delayed upsert");
969 let revisions = writer.revisions.lock().expect("lock revisions");
970 assert_eq!(revisions[session_id.as_str()], (2, None));
971 }
972
973 #[test]
974 fn scope_fingerprint_hides_namespace_and_policy() {
975 let scope = SessionSearchScope::new("tenant-secret", "policy-secret");
976 let fingerprint = scope.fingerprint();
977 assert!(!fingerprint.contains("tenant-secret"));
978 assert!(!fingerprint.contains("policy-secret"));
979 assert_ne!(
980 fingerprint,
981 SessionSearchScope::new("other", "policy-secret").fingerprint()
982 );
983 }
984}