Skip to main content

mnemo_core/query/
recall.rs

1use std::collections::HashSet;
2
3use serde::{Deserialize, Serialize};
4use uuid::Uuid;
5
6use crate::error::Result;
7use crate::hash::compute_content_hash;
8use crate::model::event::{AgentEvent, EventType};
9use crate::model::memory::{MemoryRecord, MemoryType, Scope};
10use crate::query::MnemoEngine;
11use crate::storage::MemoryFilter;
12#[allow(unused_imports)]
13use base64::Engine as _;
14
15#[derive(Debug, Clone, Default, Serialize, Deserialize)]
16pub struct TemporalRange {
17    pub after: Option<String>,
18    pub before: Option<String>,
19}
20
21impl TemporalRange {
22    pub fn new() -> Self {
23        Self::default()
24    }
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct RecallRequest {
29    pub query: String,
30    pub agent_id: Option<String>,
31    pub limit: Option<usize>,
32    pub memory_type: Option<MemoryType>,
33    pub memory_types: Option<Vec<MemoryType>>,
34    pub scope: Option<Scope>,
35    pub min_importance: Option<f32>,
36    pub tags: Option<Vec<String>>,
37    pub org_id: Option<String>,
38    pub strategy: Option<String>,
39    pub temporal_range: Option<TemporalRange>,
40    pub recency_half_life_hours: Option<f64>,
41    pub hybrid_weights: Option<Vec<f32>>,
42    pub rrf_k: Option<f32>,
43    pub as_of: Option<String>,
44    /// When set, each `ScoredMemory` is augmented with a `score_breakdown`
45    /// that reports the per-signal score contributions (vector, bm25, graph,
46    /// recency) and final RRF rank.
47    pub explain: Option<bool>,
48    /// v0.4.0-rc3 (Task B1) — when `Some(true)` AND the engine has a
49    /// [`ProvenanceSigner`](crate::provenance::ProvenanceSigner)
50    /// attached, the response carries a [`ReadProvenance`](crate::provenance::ReadProvenance)
51    /// HMAC receipt over the recalled records. Default `None` keeps
52    /// the recall hot-path overhead at zero for callers that don't
53    /// need verifiable receipts.
54    pub with_provenance: Option<bool>,
55    /// v0.4.4 — typed retrieval mode. When `Some`, takes precedence
56    /// over the legacy `strategy` field (which stays in place for
57    /// backwards compatibility). When `None`, the engine falls back
58    /// to parsing `strategy` exactly as in v0.4.3. See
59    /// [`crate::retrieval::RetrievalMode`].
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub mode: Option<crate::retrieval::RetrievalMode>,
62    /// v0.4.7 — opt-in current-fact resolver. When `Some`, the
63    /// engine runs a post-processor over the standard recall result
64    /// set that groups candidates by `cfg.fact_key` and keeps the
65    /// most-recent write per group. See
66    /// [`crate::query::current_fact_resolver`] for the contract +
67    /// the MINTEval arXiv:2605.18565 anchor. Default `None` keeps
68    /// the read path unchanged.
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub current_fact_resolver:
71        Option<crate::query::current_fact_resolver::CurrentFactResolverConfig>,
72    /// v0.4.8 — opt-in orientation cache. When `Some` AND the
73    /// engine has an
74    /// [`OrientationCacheStore`][crate::query::orientation_cache::OrientationCacheStore]
75    /// attached, the engine maintains a per-namespace, constant-token
76    /// "context map" updated from each recall hit, and returns a
77    /// bounded rendering in
78    /// [`RecallResponse::orientation_cache`]. PEEK-anchored
79    /// (arXiv:2605.19932). Default `None` keeps the read path
80    /// unchanged.
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub orientation_cache: Option<crate::query::orientation_cache::OrientationCacheConfig>,
83    /// v0.4.12 — opt-in cost-aware evidence budget. When `Some`, the
84    /// engine runs the [`crate::query::evidence`] selector over the
85    /// ranked candidate set and returns the smallest prefix that
86    /// clears the configured sufficiency bar (capped by
87    /// `max_evidence`). Purely subtractive — it never reorders the
88    /// retrieval's top-k. Default `None` keeps the read path unchanged
89    /// (front-loaded top-`limit`).
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub evidence_budget: Option<crate::query::evidence::EvidenceBudget>,
92    /// EMBER (arXiv:2606.05894) — opt-in budgeted evidence retention.
93    /// When `Some(budget)`, the engine builds a
94    /// [`RetentionReport`](crate::query::retained::RetentionReport) that
95    /// packs the recalled hits into at most `budget` retained tokens as
96    /// verbatim *evidence capsules* (excerpt + retrieval key), ranked by
97    /// a `recency × hit-rate` recoverability heuristic, and returns it in
98    /// [`RecallResponse::retained_evidence`]. Purely **additive** — the
99    /// `memories` list is unchanged, so the default read path is
100    /// unaffected. See [`crate::query::retained`].
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub retained_token_budget: Option<usize>,
103    /// v0.4.15 — domain-scoped recall predicate (MASDR-RAG,
104    /// arXiv:2606.11350). When set (or when
105    /// [`mode`](Self::mode) is [`RetrievalMode::DomainScoped`][crate::retrieval::RetrievalMode::DomainScoped]),
106    /// the candidate set is restricted to the metadata-defined
107    /// sub-corpus described by this [`DomainScope`][crate::retrieval::DomainScope]
108    /// *before* the dense similarity step, countering vector-search
109    /// dilution at scale. Default `None` keeps the read path unchanged.
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub domain_scope: Option<crate::retrieval::DomainScope>,
112    /// v0.5.17 — opt-in **forged-reasoning defense**. When set, the shared
113    /// recall post-filter excludes entries whose stored reasoning provenance
114    /// fails the trust check (an attacker planted a fabricated chain-of-thought
115    /// so retrieval would treat a lie as "already-reasoned truth"). See
116    /// [`ReasoningTrustPolicy`][crate::retrieval::ReasoningTrustPolicy]. Default
117    /// `None` keeps the read path unchanged; composes with any strategy.
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub reasoning_trust: Option<crate::retrieval::ReasoningTrustPolicy>,
120}
121
122impl RecallRequest {
123    pub fn new(query: String) -> Self {
124        Self {
125            query,
126            agent_id: None,
127            limit: None,
128            memory_type: None,
129            memory_types: None,
130            scope: None,
131            min_importance: None,
132            tags: None,
133            org_id: None,
134            strategy: None,
135            temporal_range: None,
136            recency_half_life_hours: None,
137            hybrid_weights: None,
138            rrf_k: None,
139            as_of: None,
140            explain: None,
141            with_provenance: None,
142            mode: None,
143            current_fact_resolver: None,
144            orientation_cache: None,
145            evidence_budget: None,
146            retained_token_budget: None,
147            domain_scope: None,
148            reasoning_trust: None,
149        }
150    }
151}
152
153/// v0.4.7 — one entry of the supersession chain returned when the
154/// current-fact resolver is enabled with
155/// [`CurrentFactResolverConfig::include_supersession_chain`][crate::query::current_fact_resolver::CurrentFactResolverConfig::include_supersession_chain]
156/// set to `true`. Carries the prior fact version's id + the
157/// timestamps so an auditor can reconstruct the timeline.
158#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
159pub struct SupersededRecord {
160    pub id: Uuid,
161    pub fact_id: String,
162    pub superseded_by: Uuid,
163    /// Timestamp of the winning current record.
164    pub superseded_at: String,
165    /// Timestamp of the older record being marked superseded.
166    pub prior_updated_at: String,
167}
168
169/// Per-signal score contributions for a single recall hit.
170///
171/// Emitted when `RecallRequest.explain = Some(true)`. Each field is the
172/// raw signal score used as input to reciprocal-rank fusion (0 when the
173/// memory didn't appear in that list).
174#[derive(Debug, Clone, Default, Serialize, Deserialize)]
175pub struct ScoreBreakdown {
176    pub vector: f32,
177    pub bm25: f32,
178    pub graph: f32,
179    pub recency: f32,
180    /// 0-based position of the memory in the fused ranking.
181    pub rrf_rank: u32,
182}
183
184#[non_exhaustive]
185#[derive(Debug, Clone, Serialize, Deserialize)]
186pub struct RecallResponse {
187    pub memories: Vec<ScoredMemory>,
188    pub total: usize,
189    /// HMAC receipt over the recalled records — present iff the
190    /// caller set `RecallRequest.with_provenance = Some(true)` AND
191    /// the engine has a `ProvenanceSigner` attached.
192    /// See [`crate::provenance`].
193    #[serde(skip_serializing_if = "Option::is_none", default)]
194    pub provenance: Option<crate::provenance::ReadProvenance>,
195    /// v0.4.7 — older fact-versions dropped by the current-fact
196    /// resolver, in newest-superseded → oldest order. Present iff
197    /// the caller set
198    /// [`CurrentFactResolverConfig::include_supersession_chain`][crate::query::current_fact_resolver::CurrentFactResolverConfig::include_supersession_chain]
199    /// to `true` AND the resolver actually dropped any candidates.
200    #[serde(skip_serializing_if = "Option::is_none", default)]
201    pub superseded: Option<Vec<SupersededRecord>>,
202    /// v0.4.8 — bounded, namespace-scoped orientation map rendered
203    /// after the recall ran. Present iff the caller set
204    /// [`RecallRequest::orientation_cache`] AND the engine has an
205    /// [`OrientationCacheStore`][crate::query::orientation_cache::OrientationCacheStore]
206    /// attached AND the config did not set `include_in_response =
207    /// false`. PEEK-anchored (arXiv:2605.19932).
208    #[serde(skip_serializing_if = "Option::is_none", default)]
209    pub orientation_cache: Option<crate::query::orientation_cache::RenderedContextMap>,
210    /// v0.4.12 — diagnostics from the cost-aware evidence budget.
211    /// Present iff the caller set [`RecallRequest::evidence_budget`].
212    /// Reports the scorer used, how many candidates were examined vs
213    /// returned, the cumulative sufficiency score, and whether
214    /// early-stop / the cap fired. See [`crate::query::evidence`].
215    #[serde(skip_serializing_if = "Option::is_none", default)]
216    pub evidence_selection: Option<crate::query::evidence::EvidenceSelectionReport>,
217    /// EMBER (arXiv:2606.05894) — budgeted evidence-retention view.
218    /// Present iff the caller set
219    /// [`RecallRequest::retained_token_budget`]. Carries verbatim
220    /// evidence capsules (excerpt + retrieval key) packed under the
221    /// requested token cap, ranked by recoverability. Additive: the
222    /// `memories` list above is unchanged. See [`crate::query::retained`].
223    #[serde(skip_serializing_if = "Option::is_none", default)]
224    pub retained_evidence: Option<crate::query::retained::RetentionReport>,
225    /// v0.5.1 — active-reconstruction belief-state node (MRAgent,
226    /// arXiv:2606.06036). Present iff the caller selected the
227    /// `reconstruct` strategy ([`RetrievalMode::Reconstruct`][crate::retrieval::RetrievalMode::Reconstruct]).
228    /// Carries a deterministic summary synthesised from the retrieved
229    /// candidates plus the linked/causal context gathered by walking the
230    /// memory graph. Additive: `memories` is exactly the top-k the default
231    /// hybrid (`auto`) path returns, so the raw read path is unchanged.
232    #[serde(skip_serializing_if = "Option::is_none", default)]
233    pub reconstruction: Option<ReconstructedBelief>,
234}
235
236impl RecallResponse {
237    pub fn new(memories: Vec<ScoredMemory>, total: usize) -> Self {
238        Self {
239            memories,
240            total,
241            provenance: None,
242            superseded: None,
243            orientation_cache: None,
244            evidence_selection: None,
245            retained_evidence: None,
246            reconstruction: None,
247        }
248    }
249}
250
251/// v0.5.1 — a reconstructed belief-state node (MRAgent, arXiv:2606.06036).
252///
253/// Produced by the `reconstruct` recall strategy. Rather than returning
254/// the top-k hits alone, the strategy walks the memory graph from those
255/// hits to gather linked/causal context and synthesises a deterministic
256/// summary the caller receives ALONGSIDE the raw `memories`. The synthesis
257/// is rule-based (no LLM), so the same inputs always yield the same node —
258/// it is an honest substrate for A/B-ing reconstruction vs. retrieval on
259/// your own data, not a claim that retrieval is wrong.
260#[non_exhaustive]
261#[derive(Debug, Clone, Serialize, Deserialize)]
262pub struct ReconstructedBelief {
263    /// The cue (query) the belief was reconstructed for.
264    pub cue: String,
265    /// Deterministic summary: direct evidence (the retrieved hits) followed
266    /// by the linked/causal context gathered from the memory graph.
267    pub summary: String,
268    /// Ids of the retrieved candidates that seeded the reconstruction.
269    pub source_ids: Vec<Uuid>,
270    /// Ids of graph-linked memories pulled in as causal/linked context
271    /// (not present in `source_ids`).
272    pub linked_context_ids: Vec<Uuid>,
273    /// Mean retrieval score of the source hits — a coarse confidence proxy.
274    pub confidence: f32,
275}
276
277#[non_exhaustive]
278#[derive(Debug, Clone, Serialize, Deserialize)]
279pub struct ScoredMemory {
280    pub id: Uuid,
281    pub content: String,
282    pub agent_id: String,
283    pub memory_type: MemoryType,
284    pub scope: Scope,
285    pub importance: f32,
286    pub tags: Vec<String>,
287    pub metadata: serde_json::Value,
288    pub score: f32,
289    pub access_count: u64,
290    pub created_at: String,
291    pub updated_at: String,
292    #[serde(skip_serializing_if = "Option::is_none")]
293    pub score_breakdown: Option<ScoreBreakdown>,
294}
295
296impl From<(MemoryRecord, f32)> for ScoredMemory {
297    fn from((record, score): (MemoryRecord, f32)) -> Self {
298        Self {
299            id: record.id,
300            content: record.content,
301            agent_id: record.agent_id,
302            memory_type: record.memory_type,
303            scope: record.scope,
304            importance: record.importance,
305            tags: record.tags,
306            metadata: record.metadata,
307            score,
308            access_count: record.access_count,
309            created_at: record.created_at,
310            updated_at: record.updated_at,
311            score_breakdown: None,
312        }
313    }
314}
315
316/// Get a memory by ID, checking cache first then falling back to storage.
317async fn get_memory_cached(engine: &MnemoEngine, id: Uuid) -> Result<Option<MemoryRecord>> {
318    if let Some(ref cache) = engine.cache
319        && let Some(record) = cache.get(id)
320    {
321        return Ok(Some(record));
322    }
323    let result = engine.storage.get_memory(id).await?;
324    if let Some(ref record) = result
325        && let Some(ref cache) = engine.cache
326    {
327        cache.put(record.clone());
328    }
329    Ok(result)
330}
331
332pub async fn execute(engine: &MnemoEngine, request: RecallRequest) -> Result<RecallResponse> {
333    let limit = request.limit.unwrap_or(10).min(100);
334    let agent_id = request
335        .agent_id
336        .clone()
337        .unwrap_or_else(|| engine.default_agent_id.clone());
338    super::validate_agent_id(&agent_id)?;
339
340    // Determine strategy. v0.4.4: prefer the typed
341    // `mode: Option<RetrievalMode>` field when set; fall back to the
342    // legacy `strategy: Option<String>` field otherwise. Backwards
343    // compatible — SDKs that only marshal `strategy` continue to work.
344    let strategy = if let Some(ref mode) = request.mode {
345        mode.to_strategy_str()
346    } else if request
347        .domain_scope
348        .as_ref()
349        .map(|s| !s.is_empty())
350        .unwrap_or(false)
351    {
352        // v0.4.15 — a domain_scope predicate selects domain-scoped recall
353        // even when the caller didn't set the typed mode (ergonomic for
354        // SDKs that only marshal a `scope` kwarg).
355        "domain_scoped"
356    } else {
357        request.strategy.as_deref().unwrap_or("auto")
358    };
359
360    // v0.5.13 — fail loud, never silent-empty. Semantic and the semantic legs
361    // of hybrid/auto/graph/domain_scoped all depend on a real query vector. The
362    // no-op embedder returns an all-zero vector, which would make these paths
363    // silently return an empty or meaningless result set. Refuse with a typed
364    // error instead. Purely lexical (BM25) and exact/metadata recall need no
365    // embedder and are unaffected.
366    let needs_semantic = matches!(
367        strategy,
368        "semantic" | "hybrid" | "auto" | "graph" | "domain_scoped"
369    );
370    if needs_semantic && !engine.embedding.is_semantic_capable() {
371        return Err(crate::error::Error::EmbedderNotConfigured {
372            requested: strategy.to_string(),
373            backend: engine.storage.backend_name().to_string(),
374        });
375    }
376
377    // Compute query embedding (needed for semantic/hybrid/auto)
378    let query_embedding = engine.embedding.embed(&request.query).await?;
379
380    // Pre-compute accessible memory IDs for permission-safe ANN pre-filtering
381    let accessible_ids: HashSet<Uuid> = engine
382        .storage
383        .list_accessible_memory_ids(&agent_id, super::MAX_BATCH_QUERY_LIMIT)
384        .await?
385        .into_iter()
386        .collect();
387    let perm_filter = |id: Uuid| accessible_ids.contains(&id);
388
389    let mut scored_memories: Vec<(MemoryRecord, f32)> = Vec::new();
390    let mut breakdowns: std::collections::HashMap<Uuid, ScoreBreakdown> =
391        std::collections::HashMap::new();
392
393    match strategy {
394        "lexical" => {
395            // BM25-only path
396            if let Some(ref ft) = engine.full_text {
397                let bm25_results = ft.search(&request.query, limit * 3)?;
398                for (id, score) in bm25_results {
399                    if let Some(record) = get_memory_cached(engine, id).await?
400                        && passes_filters(&record, &request, &agent_id, engine).await
401                    {
402                        scored_memories.push((record, score));
403                    }
404                }
405            }
406        }
407        "semantic" => {
408            // Vector-only path with permission pre-filtering
409            let search_results = engine
410                .index
411                .filtered_search(&query_embedding, limit * 3, &perm_filter)
412                .await?;
413            for (id, distance) in search_results {
414                if let Some(record) = get_memory_cached(engine, id).await?
415                    && passes_filters(&record, &request, &agent_id, engine).await
416                {
417                    let score = 1.0 - distance;
418                    scored_memories.push((record, score));
419                }
420            }
421        }
422        "domain_scoped" => {
423            // v0.4.15 — domain-scoped recall (MASDR-RAG, arXiv:2606.11350).
424            // Restrict the candidate universe to the metadata-defined
425            // sub-corpus BEFORE the dense similarity step, so off-domain
426            // (but semantically similar) records can never enter the
427            // top-k. Then a single vector pass over the sub-corpus.
428            //
429            // The sub-corpus id-set is resolved from storage by the
430            // `DomainScope` predicate and composed with the permission
431            // filter, so the ANN sees only (accessible ∩ in-domain) ids.
432            let domain_ids: Option<HashSet<Uuid>> = match request.domain_scope.as_ref() {
433                Some(scope) if !scope.is_empty() => {
434                    // Coarse narrowing on org_id at the storage layer, then
435                    // exact predicate matching (namespace / doc_class / tags).
436                    let coarse = MemoryFilter {
437                        agent_id: None,
438                        memory_type: None,
439                        scope: None,
440                        tags: None,
441                        min_importance: None,
442                        org_id: scope.org_id.clone(),
443                        thread_id: None,
444                        include_deleted: false,
445                    };
446                    let records = engine
447                        .storage
448                        .list_memories(&coarse, super::MAX_BATCH_QUERY_LIMIT, 0)
449                        .await?;
450                    Some(
451                        records
452                            .iter()
453                            .filter(|r| scope.matches(r))
454                            .map(|r| r.id)
455                            .collect(),
456                    )
457                }
458                // DomainScoped selected without a predicate degrades to a
459                // plain vector pass (no extra restriction).
460                _ => None,
461            };
462
463            let domain_filter = |id: Uuid| {
464                perm_filter(id) && domain_ids.as_ref().map(|d| d.contains(&id)).unwrap_or(true)
465            };
466            let search_results = engine
467                .index
468                .filtered_search(&query_embedding, limit * 3, &domain_filter)
469                .await?;
470            for (id, distance) in search_results {
471                if let Some(record) = get_memory_cached(engine, id).await?
472                    && passes_filters(&record, &request, &agent_id, engine).await
473                {
474                    let score = 1.0 - distance;
475                    scored_memories.push((record, score));
476                }
477            }
478        }
479        "graph" => {
480            // Seed from vector results with permission pre-filtering, then expand via graph relations
481            let search_results = engine
482                .index
483                .filtered_search(&query_embedding, limit * 3, &perm_filter)
484                .await?;
485            let mut seeds: Vec<(Uuid, f32)> = Vec::new();
486            for (id, distance) in &search_results {
487                if let Some(record) = get_memory_cached(engine, *id).await?
488                    && passes_filters(&record, &request, &agent_id, engine).await
489                {
490                    seeds.push((*id, 1.0 - distance));
491                }
492            }
493
494            // Collect graph-expanded results with configurable multi-hop traversal
495            let max_hops = 2;
496            let mut seen: HashSet<Uuid> = seeds.iter().map(|(id, _)| *id).collect();
497            let mut graph_ranked: Vec<(Uuid, f32)> = Vec::new();
498
499            // Seeds get score 1.0
500            for &(id, _) in &seeds {
501                graph_ranked.push((id, 1.0));
502            }
503
504            // Multi-hop expansion with exponential decay
505            let mut frontier: Vec<Uuid> = seeds.iter().map(|(id, _)| *id).collect();
506            let mut decay = 0.5_f32;
507            for _hop in 0..max_hops {
508                let mut next_frontier: Vec<Uuid> = Vec::new();
509                for &id in &frontier {
510                    let from_rels = engine.storage.get_relations_from(id).await?;
511                    let to_rels = engine.storage.get_relations_to(id).await?;
512                    for rel in from_rels.iter().chain(to_rels.iter()) {
513                        let related_id = if rel.source_id == id {
514                            rel.target_id
515                        } else {
516                            rel.source_id
517                        };
518                        if seen.insert(related_id)
519                            && let Some(record) = get_memory_cached(engine, related_id).await?
520                            && passes_filters(&record, &request, &agent_id, engine).await
521                        {
522                            graph_ranked.push((related_id, decay));
523                            next_frontier.push(related_id);
524                        }
525                    }
526                }
527                frontier = next_frontier;
528                decay *= 0.5;
529            }
530
531            // Use RRF fusion with vector + graph lists
532            let mut v_sorted: Vec<(Uuid, f32)> = seeds.clone();
533            v_sorted.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
534            graph_ranked.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
535
536            let ranked_lists = vec![v_sorted, graph_ranked];
537            let rrf_k = request.rrf_k.unwrap_or(60.0);
538            let fused = if let Some(ref weights) = request.hybrid_weights {
539                crate::query::retrieval::weighted_reciprocal_rank_fusion(
540                    &ranked_lists,
541                    rrf_k,
542                    weights,
543                )
544            } else {
545                crate::query::retrieval::reciprocal_rank_fusion(&ranked_lists, rrf_k)
546            };
547
548            for (id, score) in fused {
549                if let Some(record) = get_memory_cached(engine, id).await?
550                    && passes_filters(&record, &request, &agent_id, engine).await
551                {
552                    scored_memories.push((record, score));
553                }
554            }
555        }
556        "exact" => {
557            // Filter-based exact matching, no embedding needed
558            // When as_of is set, include deleted records so the as_of filter can evaluate them
559            let filter = MemoryFilter {
560                agent_id: Some(agent_id.clone()),
561                memory_type: request.memory_type,
562                scope: request.scope,
563                tags: request.tags.clone(),
564                min_importance: request.min_importance,
565                org_id: request.org_id.clone(),
566                thread_id: None,
567                include_deleted: request.as_of.is_some(),
568            };
569            let memories = engine.storage.list_memories(&filter, limit, 0).await?;
570            for record in memories {
571                if passes_filters(&record, &request, &agent_id, engine).await {
572                    scored_memories.push((record, 1.0));
573                }
574            }
575        }
576        _ => {
577            // "auto" or "hybrid" — use hybrid if full_text available, else semantic
578            let vector_results = engine
579                .index
580                .filtered_search(&query_embedding, limit * 3, &perm_filter)
581                .await?;
582            let mut vector_ranked: Vec<(Uuid, f32)> = Vec::new();
583            for (id, distance) in vector_results {
584                vector_ranked.push((id, 1.0 - distance));
585            }
586
587            if let Some(ref ft) = engine.full_text {
588                // Hybrid: RRF fusion of vector + BM25 + recency
589                let bm25_results = ft.search(&request.query, limit * 3)?;
590
591                // Build recency-scored list from vector candidates
592                let mut recency_ranked: Vec<(Uuid, f32)> = Vec::new();
593                for &(id, _) in &vector_ranked {
594                    if let Some(record) = get_memory_cached(engine, id).await? {
595                        let r_score = crate::query::retrieval::recency_score(
596                            &record.created_at,
597                            request.recency_half_life_hours.unwrap_or(168.0),
598                        );
599                        recency_ranked.push((id, r_score));
600                    }
601                }
602                // Also add BM25 candidates to recency
603                for &(id, _) in &bm25_results {
604                    if !recency_ranked.iter().any(|(rid, _)| *rid == id)
605                        && let Some(record) = get_memory_cached(engine, id).await?
606                    {
607                        let r_score = crate::query::retrieval::recency_score(
608                            &record.created_at,
609                            request.recency_half_life_hours.unwrap_or(168.0),
610                        );
611                        recency_ranked.push((id, r_score));
612                    }
613                }
614
615                // Sort each list by score descending
616                let mut v_sorted = vector_ranked.clone();
617                v_sorted.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
618                let mut b_sorted = bm25_results;
619                b_sorted.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
620                recency_ranked
621                    .sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
622
623                // Graph expansion signal: from top-10 vector results, multi-hop expansion
624                let max_hops = 2;
625                let mut graph_ranked: Vec<(Uuid, f32)> = Vec::new();
626                let top_seeds: Vec<Uuid> =
627                    vector_ranked.iter().take(10).map(|(id, _)| *id).collect();
628                let mut graph_seen: HashSet<Uuid> = top_seeds.iter().copied().collect();
629                for &seed_id in &top_seeds {
630                    graph_ranked.push((seed_id, 1.0));
631                }
632                let mut frontier: Vec<Uuid> = top_seeds;
633                let mut decay = 0.5_f32;
634                for _hop in 0..max_hops {
635                    let mut next_frontier: Vec<Uuid> = Vec::new();
636                    for &fid in &frontier {
637                        match engine.storage.get_relations_from(fid).await {
638                            Ok(from_rels) => {
639                                for rel in &from_rels {
640                                    if graph_seen.insert(rel.target_id) {
641                                        graph_ranked.push((rel.target_id, decay));
642                                        next_frontier.push(rel.target_id);
643                                    }
644                                }
645                            }
646                            Err(e) => {
647                                tracing::warn!(memory_id = %fid, error = %e, "graph expansion: failed to get outgoing relations");
648                            }
649                        }
650                        match engine.storage.get_relations_to(fid).await {
651                            Ok(to_rels) => {
652                                for rel in &to_rels {
653                                    if graph_seen.insert(rel.source_id) {
654                                        graph_ranked.push((rel.source_id, decay));
655                                        next_frontier.push(rel.source_id);
656                                    }
657                                }
658                            }
659                            Err(e) => {
660                                tracing::warn!(memory_id = %fid, error = %e, "graph expansion: failed to get incoming relations");
661                            }
662                        }
663                    }
664                    frontier = next_frontier;
665                    decay *= 0.5;
666                }
667                graph_ranked
668                    .sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
669
670                // Capture per-signal score maps before moving the ranked lists
671                // into the fusion call, so `explain=true` can surface each
672                // signal's contribution in the response.
673                let explain = request.explain.unwrap_or(false);
674                type SignalMap = std::collections::HashMap<Uuid, f32>;
675                let (vector_map, bm25_map, recency_map, graph_map): (
676                    SignalMap,
677                    SignalMap,
678                    SignalMap,
679                    SignalMap,
680                ) = if explain {
681                    (
682                        v_sorted.iter().copied().collect(),
683                        b_sorted.iter().copied().collect(),
684                        recency_ranked.iter().copied().collect(),
685                        graph_ranked.iter().copied().collect(),
686                    )
687                } else {
688                    Default::default()
689                };
690
691                let ranked_lists = vec![v_sorted, b_sorted, recency_ranked, graph_ranked];
692                let rrf_k = request.rrf_k.unwrap_or(60.0);
693                let fused = if let Some(ref weights) = request.hybrid_weights {
694                    crate::query::retrieval::weighted_reciprocal_rank_fusion(
695                        &ranked_lists,
696                        rrf_k,
697                        weights,
698                    )
699                } else {
700                    crate::query::retrieval::reciprocal_rank_fusion(&ranked_lists, rrf_k)
701                };
702
703                for (rank, (id, score)) in fused.into_iter().enumerate() {
704                    if let Some(record) = get_memory_cached(engine, id).await?
705                        && passes_filters(&record, &request, &agent_id, engine).await
706                    {
707                        scored_memories.push((record, score));
708                        if explain {
709                            breakdowns.insert(
710                                id,
711                                ScoreBreakdown {
712                                    vector: vector_map.get(&id).copied().unwrap_or(0.0),
713                                    bm25: bm25_map.get(&id).copied().unwrap_or(0.0),
714                                    graph: graph_map.get(&id).copied().unwrap_or(0.0),
715                                    recency: recency_map.get(&id).copied().unwrap_or(0.0),
716                                    rrf_rank: rank as u32,
717                                },
718                            );
719                        }
720                    }
721                }
722            } else {
723                // Fallback to semantic-only
724                for (id, score) in vector_ranked {
725                    if let Some(record) = get_memory_cached(engine, id).await?
726                        && passes_filters(&record, &request, &agent_id, engine).await
727                    {
728                        scored_memories.push((record, score));
729                    }
730                }
731            }
732        }
733    }
734
735    // Sort by score descending
736    scored_memories.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
737    scored_memories.truncate(limit);
738
739    // v0.4.12 — opt-in cost-aware evidence budget. Runs only when the
740    // caller set `request.evidence_budget`. The selector operates on
741    // the already-ranked list and returns the smallest prefix that
742    // clears the sufficiency bar (capped by `max_evidence`); it never
743    // reorders, so the top-k cosine/RRF ordering is preserved. Applied
744    // BEFORE `touch_memory` so we do not mark-accessed evidence the
745    // budget trimmed away (cost-aware on the write side too). See
746    // [`crate::query::evidence`].
747    let evidence_selection = if let Some(ref budget) = request.evidence_budget {
748        let cosine_default = crate::query::evidence::CosineScorer;
749        let scorer: &dyn crate::query::evidence::EvidenceScorer =
750            match (budget.scorer, engine.evidence_scorer.as_ref()) {
751                (crate::query::evidence::ScorerKind::Delta, Some(s)) => s.as_ref(),
752                _ => &cosine_default,
753            };
754        // Pass the query embedding only when it is non-degenerate
755        // (NoopEmbedding yields all-zero vectors, for which cosine is
756        // undefined and the scorer should fall back to retrieval score).
757        let q_emb: Option<&[f32]> = if query_embedding.iter().any(|v| *v != 0.0) {
758            Some(query_embedding.as_slice())
759        } else {
760            None
761        };
762        let candidates: Vec<crate::query::evidence::EvidenceCandidate<'_>> = scored_memories
763            .iter()
764            .map(|(r, score)| crate::query::evidence::EvidenceCandidate {
765                content: &r.content,
766                embedding: r.embedding.as_deref(),
767                retrieval_score: *score,
768            })
769            .collect();
770        let selection = crate::query::evidence::select_within_budget(
771            &candidates,
772            budget,
773            scorer,
774            &request.query,
775            q_emb,
776        );
777        let keep = selection.keep;
778        drop(candidates);
779        scored_memories.truncate(keep);
780        Some(selection.report)
781    } else {
782        None
783    };
784
785    let _total_pre_resolver = scored_memories.len();
786
787    // Touch accessed memories
788    for (record, _) in &scored_memories {
789        if let Err(e) = engine.storage.touch_memory(record.id).await {
790            tracing::warn!(memory_id = %record.id, error = %e, "failed to update access timestamp");
791        }
792    }
793
794    // Decrypt content if encryption is configured
795    if let Some(ref enc) = engine.encryption {
796        for (record, _) in &mut scored_memories {
797            match base64::engine::general_purpose::STANDARD.decode(&record.content) {
798                Ok(encrypted_bytes) => match enc.decrypt(&encrypted_bytes) {
799                    Ok(decrypted) => match String::from_utf8(decrypted) {
800                        Ok(plaintext) => record.content = plaintext,
801                        Err(e) => {
802                            tracing::error!(memory_id = %record.id, error = %e, "decrypted content is not valid UTF-8");
803                            record.content = "[content unavailable: decryption error]".to_string();
804                        }
805                    },
806                    Err(e) => {
807                        tracing::error!(memory_id = %record.id, error = %e, "failed to decrypt memory content");
808                        record.content = "[content unavailable: decryption error]".to_string();
809                    }
810                },
811                Err(e) => {
812                    tracing::error!(memory_id = %record.id, error = %e, "failed to decode encrypted content");
813                    record.content = "[content unavailable: decryption error]".to_string();
814                }
815            }
816        }
817    }
818
819    // Keep the underlying records around if the caller asked for a
820    // provenance receipt (Task B1) — the HMAC chain needs the
821    // content_hash + prev_hash off each record before they get
822    // collapsed into ScoredMemory.
823    let provenance_records: Option<Vec<MemoryRecord>> =
824        if request.with_provenance == Some(true) && engine.provenance_signer.is_some() {
825            Some(scored_memories.iter().map(|(r, _)| r.clone()).collect())
826        } else {
827            None
828        };
829
830    let memories: Vec<ScoredMemory> = scored_memories
831        .into_iter()
832        .map(|(record, score)| {
833            let id = record.id;
834            let mut scored = ScoredMemory::from((record, score));
835            if let Some(breakdown) = breakdowns.remove(&id) {
836                scored.score_breakdown = Some(breakdown);
837            }
838            scored
839        })
840        .collect();
841
842    // v0.4.7 — opt-in current-fact resolver post-process. Runs only
843    // when the caller set `request.current_fact_resolver`. The
844    // resolver groups by `cfg.fact_key`, keeps the most-recent
845    // write per group, and (optionally) returns the older versions
846    // as a supersession chain. See
847    // [`crate::query::current_fact_resolver`] for the MINTEval
848    // arXiv:2605.18565 anchor + the contract.
849    let (memories, superseded_chain) = if let Some(ref cfg) = request.current_fact_resolver {
850        let out = crate::query::current_fact_resolver::resolve(cfg, memories);
851        let chain = if cfg.include_supersession_chain && !out.superseded.is_empty() {
852            Some(out.superseded)
853        } else {
854            None
855        };
856        (out.kept, chain)
857    } else {
858        (memories, None)
859    };
860    let total = memories.len();
861
862    // v0.5.1 — active reconstruction (MRAgent, arXiv:2606.06036). When the
863    // caller selected the `reconstruct` strategy, walk the memory graph from
864    // the retrieved hits to gather linked/causal context and synthesise a
865    // deterministic belief-state node returned ALONGSIDE the raw hits. The
866    // `memories` list above is untouched, so this is purely additive.
867    let reconstruction = if strategy == "reconstruct" {
868        Some(reconstruct_belief(engine, &request, &agent_id, &memories).await)
869    } else {
870        None
871    };
872
873    // v0.4.8 — opt-in orientation cache. Runs only when the caller
874    // set `request.orientation_cache` AND the engine has an
875    // `OrientationCacheStore` attached. Per-namespace map is
876    // updated from the hits + a bounded rendering is returned. See
877    // [`crate::query::orientation_cache`] for the PEEK
878    // arXiv:2605.19932 anchor + the contract.
879    let orientation_rendered = match (
880        request.orientation_cache.as_ref(),
881        engine.orientation_cache_store.as_ref(),
882    ) {
883        (Some(cfg), Some(store)) => {
884            let ns = crate::query::orientation_cache::resolve_namespace(
885                cfg,
886                &agent_id,
887                request.org_id.as_deref(),
888            );
889            let rendered =
890                crate::query::orientation_cache::update_and_render(store, cfg, &ns, &memories);
891            if cfg.include_in_response {
892                Some(rendered)
893            } else {
894                None
895            }
896        }
897        _ => None,
898    };
899
900    // Emit MemoryRead event with hash chain linking (fire-and-forget)
901    let now = chrono::Utc::now().to_rfc3339();
902    let event_content_hash = compute_content_hash(&request.query, &agent_id, &now);
903    let prev_event_hash = match engine.storage.get_latest_event_hash(&agent_id, None).await {
904        Ok(hash) => hash,
905        Err(e) => {
906            tracing::warn!(error = %e, "failed to get latest event hash, starting new chain segment");
907            None
908        }
909    };
910    let event_prev_hash = Some(crate::hash::compute_chain_hash(
911        &event_content_hash,
912        prev_event_hash.as_deref(),
913    ));
914    let mut event = AgentEvent {
915        id: Uuid::now_v7(),
916        agent_id: agent_id.clone(),
917        thread_id: None,
918        run_id: None,
919        parent_event_id: None,
920        event_type: EventType::MemoryRead,
921        payload: serde_json::json!({
922            "query": request.query,
923            "results": total,
924            "strategy": strategy,
925        }),
926        trace_id: None,
927        span_id: None,
928        model: None,
929        tokens_input: None,
930        tokens_output: None,
931        latency_ms: None,
932        cost_usd: None,
933        timestamp: now.clone(),
934        logical_clock: 0,
935        content_hash: event_content_hash,
936        prev_hash: event_prev_hash,
937        embedding: None,
938    };
939    // Optionally embed the event payload
940    if engine.embed_events
941        && let Ok(emb) = engine.embedding.embed(&event.payload.to_string()).await
942    {
943        event.embedding = Some(emb);
944    }
945    if let Err(e) = engine.storage.insert_event(&event).await {
946        tracing::error!(event_id = %event.id, error = %e, "failed to insert audit event");
947    }
948
949    // v0.4.0-rc3 (B1) — sign a ReadProvenance over the recalled
950    // records when the caller opted in. Failures are non-fatal:
951    // missing signer or HMAC error degrades to "no provenance" so the
952    // recall still returns. The caller can detect by `provenance.is_none()`.
953    let provenance = if let (Some(records), Some(signer)) =
954        (provenance_records, engine.provenance_signer.as_ref())
955    {
956        match signer.sign(&agent_id, &request.query, &records) {
957            Ok(p) => Some(p),
958            Err(e) => {
959                tracing::warn!(error = %e, "failed to sign read provenance; degrading to no-provenance response");
960                None
961            }
962        }
963    } else {
964        None
965    };
966
967    // EMBER (arXiv:2606.05894) — opt-in budgeted evidence retention.
968    // Runs only when the caller set `request.retained_token_budget`.
969    // Builds verbatim evidence capsules (excerpt + retrieval key) packed
970    // under the token cap, ranked by `recency × hit-rate` recoverability.
971    // Computed from the FINAL `memories` (post current-fact resolver,
972    // decrypted) and returned ALONGSIDE them — `memories` is not
973    // modified, so the default read path is unaffected. See
974    // [`crate::query::retained`].
975    let retained_evidence = request.retained_token_budget.map(|budget| {
976        let retain_now = chrono::Utc::now();
977        let candidates: Vec<crate::query::retained::RetentionCandidate<'_>> = memories
978            .iter()
979            .map(|m| {
980                let age_hours = chrono::DateTime::parse_from_rfc3339(&m.updated_at)
981                    .or_else(|_| chrono::DateTime::parse_from_rfc3339(&m.created_at))
982                    .map(|ts| {
983                        (retain_now - ts.with_timezone(&chrono::Utc)).num_seconds() as f64 / 3600.0
984                    })
985                    .unwrap_or(0.0);
986                crate::query::retained::RetentionCandidate {
987                    id: m.id,
988                    content: &m.content,
989                    access_count: m.access_count,
990                    age_hours,
991                    retrieval_score: m.score,
992                }
993            })
994            .collect();
995        crate::query::retained::retain_within_budget(
996            &candidates,
997            budget,
998            crate::query::retained::DEFAULT_EXCERPT_TOKENS,
999        )
1000    });
1001
1002    Ok(RecallResponse {
1003        memories,
1004        total,
1005        provenance,
1006        superseded: superseded_chain,
1007        orientation_cache: orientation_rendered,
1008        evidence_selection,
1009        retained_evidence,
1010        reconstruction,
1011    })
1012}
1013
1014/// v0.5.1 — synthesise a [`ReconstructedBelief`] from the retrieved hits
1015/// (MRAgent, arXiv:2606.06036). Walks one hop of memory-graph relations
1016/// outward from each hit to gather linked/causal context, then renders a
1017/// deterministic, rule-based summary (no LLM). Used only by the
1018/// `reconstruct` strategy; the raw `memories` are left unchanged.
1019async fn reconstruct_belief(
1020    engine: &MnemoEngine,
1021    request: &RecallRequest,
1022    agent_id: &str,
1023    memories: &[ScoredMemory],
1024) -> ReconstructedBelief {
1025    let cue = request.query.clone();
1026    if memories.is_empty() {
1027        return ReconstructedBelief {
1028            cue: cue.clone(),
1029            summary: format!("No memories matched the cue \"{cue}\"."),
1030            source_ids: Vec::new(),
1031            linked_context_ids: Vec::new(),
1032            confidence: 0.0,
1033        };
1034    }
1035
1036    let source_ids: Vec<Uuid> = memories.iter().map(|m| m.id).collect();
1037    let mut seen: HashSet<Uuid> = source_ids.iter().copied().collect();
1038
1039    // Walk one hop of relations outward from each hit to gather
1040    // linked/causal context. Deterministic order: hits in rank order, and
1041    // within a hit, outgoing relations before incoming.
1042    let mut linked: Vec<(Uuid, String)> = Vec::new();
1043    for m in memories {
1044        let from_rels = engine
1045            .storage
1046            .get_relations_from(m.id)
1047            .await
1048            .unwrap_or_default();
1049        let to_rels = engine
1050            .storage
1051            .get_relations_to(m.id)
1052            .await
1053            .unwrap_or_default();
1054        for rel in from_rels.iter().chain(to_rels.iter()) {
1055            let linked_id = if rel.source_id == m.id {
1056                rel.target_id
1057            } else {
1058                rel.source_id
1059            };
1060            if seen.insert(linked_id)
1061                && let Ok(Some(mut rec)) = engine.storage.get_memory(linked_id).await
1062                && passes_filters(&rec, request, agent_id, engine).await
1063            {
1064                decrypt_record_content(engine, &mut rec);
1065                linked.push((linked_id, rec.content));
1066            }
1067        }
1068    }
1069
1070    // Deterministic, rule-based belief summary (no LLM).
1071    let mut summary = format!("Reconstructed belief for cue \"{cue}\":\n\nDirect evidence:\n");
1072    for (i, m) in memories.iter().enumerate() {
1073        summary.push_str(&format!("{}. {}\n", i + 1, excerpt(&m.content, 200)));
1074    }
1075    if linked.is_empty() {
1076        summary.push_str("\n(No linked context found in the memory graph.)\n");
1077    } else {
1078        summary.push_str("\nLinked context (from graph relations):\n");
1079        for (_, content) in &linked {
1080            summary.push_str(&format!("- {}\n", excerpt(content, 160)));
1081        }
1082    }
1083
1084    let confidence = memories.iter().map(|m| m.score).sum::<f32>() / memories.len() as f32;
1085
1086    ReconstructedBelief {
1087        cue,
1088        summary,
1089        source_ids,
1090        linked_context_ids: linked.into_iter().map(|(id, _)| id).collect(),
1091        confidence,
1092    }
1093}
1094
1095/// First non-empty line of `content`, truncated to `max` chars (char-safe).
1096fn excerpt(content: &str, max: usize) -> String {
1097    let line = content.lines().find(|l| !l.trim().is_empty()).unwrap_or("");
1098    let trimmed = line.trim();
1099    if trimmed.chars().count() <= max {
1100        trimmed.to_string()
1101    } else {
1102        let mut out: String = trimmed.chars().take(max).collect();
1103        out.push('…');
1104        out
1105    }
1106}
1107
1108/// Decrypt a record's content in place if engine-level encryption is on.
1109/// Mirrors the read-path decryption in [`execute`]; used by
1110/// [`reconstruct_belief`] for graph-linked records fetched after the main
1111/// decrypt loop.
1112fn decrypt_record_content(engine: &MnemoEngine, record: &mut MemoryRecord) {
1113    if let Some(ref enc) = engine.encryption {
1114        if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(&record.content)
1115            && let Ok(plain) = enc.decrypt(&bytes)
1116            && let Ok(text) = String::from_utf8(plain)
1117        {
1118            record.content = text;
1119        } else {
1120            record.content = "[content unavailable: decryption error]".to_string();
1121        }
1122    }
1123}
1124
1125async fn passes_filters(
1126    record: &MemoryRecord,
1127    request: &RecallRequest,
1128    agent_id: &str,
1129    engine: &MnemoEngine,
1130) -> bool {
1131    // Experience-tier plan records (DocTrace, arXiv:2606.10921) are never
1132    // surfaced by ordinary recall — they are replayed only via
1133    // `recall_plan`. Skip them unless the caller explicitly asks for the
1134    // reserved tag.
1135    if record
1136        .tags
1137        .iter()
1138        .any(|t| t == crate::query::experience::EXPERIENCE_PLAN_TAG)
1139        && !request
1140            .tags
1141            .as_ref()
1142            .map(|ts| {
1143                ts.iter()
1144                    .any(|t| t == crate::query::experience::EXPERIENCE_PLAN_TAG)
1145            })
1146            .unwrap_or(false)
1147    {
1148        return false;
1149    }
1150
1151    // Skip deleted (unless as_of is set — the as_of filter handles deleted records)
1152    if request.as_of.is_none() && record.is_deleted() {
1153        return false;
1154    }
1155
1156    // Skip expired
1157    if let Some(ref expires_at) = record.expires_at
1158        && let Ok(exp) = chrono::DateTime::parse_from_rfc3339(expires_at)
1159        && exp < chrono::Utc::now()
1160    {
1161        return false;
1162    }
1163
1164    // Skip quarantined
1165    if record.quarantined {
1166        return false;
1167    }
1168
1169    // Forged-reasoning defense (v0.5.17) — opt-in reasoning-provenance trust
1170    // filter. Excludes entries whose stored reasoning trace fails the check
1171    // (injected / unverified authorship) when the caller set a Quarantine
1172    // policy. Default read path (no policy) is unchanged.
1173    if let Some(ref policy) = request.reasoning_trust
1174        && policy.excludes_record(record)
1175    {
1176        return false;
1177    }
1178
1179    // Scope filter (explicit request scope filter, separate from visibility below)
1180    if let Some(ref s) = request.scope
1181        && record.scope != *s
1182    {
1183        return false;
1184    }
1185
1186    // Type filter: memory_types (multi) takes precedence over memory_type (single)
1187    if let Some(ref mts) = request.memory_types {
1188        if !mts.contains(&record.memory_type) {
1189            return false;
1190        }
1191    } else if let Some(ref mt) = request.memory_type
1192        && record.memory_type != *mt
1193    {
1194        return false;
1195    }
1196
1197    // Importance filter
1198    if let Some(min_imp) = request.min_importance
1199        && record.importance < min_imp
1200    {
1201        return false;
1202    }
1203
1204    // Tags filter
1205    if let Some(ref req_tags) = request.tags
1206        && !req_tags.iter().any(|t| record.tags.contains(t))
1207    {
1208        return false;
1209    }
1210
1211    // Temporal range filter (parse to DateTime for correct comparison)
1212    if let Some(ref tr) = request.temporal_range {
1213        if let Some(ref after) = tr.after
1214            && let (Ok(after_dt), Ok(record_dt)) = (
1215                chrono::DateTime::parse_from_rfc3339(after),
1216                chrono::DateTime::parse_from_rfc3339(&record.created_at),
1217            )
1218            && record_dt < after_dt
1219        {
1220            return false;
1221        }
1222        if let Some(ref before) = tr.before
1223            && let (Ok(before_dt), Ok(record_dt)) = (
1224                chrono::DateTime::parse_from_rfc3339(before),
1225                chrono::DateTime::parse_from_rfc3339(&record.created_at),
1226            )
1227            && record_dt > before_dt
1228        {
1229            return false;
1230        }
1231    }
1232
1233    // Point-in-time as_of filter: show memory state at time T
1234    if let Some(ref as_of) = request.as_of {
1235        if let (Ok(as_of_dt), Ok(record_dt)) = (
1236            chrono::DateTime::parse_from_rfc3339(as_of),
1237            chrono::DateTime::parse_from_rfc3339(&record.created_at),
1238        ) && record_dt > as_of_dt
1239        {
1240            // Exclude memories created after as_of
1241            return false;
1242        }
1243        // Exclude memories already deleted at as_of
1244        if let Some(ref deleted_at) = record.deleted_at
1245            && let (Ok(del_dt), Ok(as_of_dt)) = (
1246                chrono::DateTime::parse_from_rfc3339(deleted_at),
1247                chrono::DateTime::parse_from_rfc3339(as_of),
1248            )
1249            && del_dt <= as_of_dt
1250        {
1251            return false;
1252        }
1253    }
1254
1255    // Scope-based visibility
1256    match record.scope {
1257        Scope::Public | Scope::Global => true,
1258        Scope::Shared => {
1259            record.agent_id == agent_id
1260                || engine
1261                    .storage
1262                    .check_permission(
1263                        record.id,
1264                        agent_id,
1265                        crate::model::acl::Permission::Read,
1266                    )
1267                    .await
1268                    .unwrap_or_else(|e| {
1269                        tracing::warn!(memory_id = %record.id, error = %e, "permission check failed, denying access");
1270                        false
1271                    })
1272        }
1273        Scope::Private => record.agent_id == agent_id,
1274    }
1275}