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