Skip to main content

ai_memory/
visibility.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! v0.7.0 #951 (Track A QC sweep, 2026-05-20) — single canonical
5//! `is_visible_to_caller` helper, available on both `sal` and
6//! non-sal builds.
7//!
8//! Pre-#951 the same visibility check was inlined / duplicated in
9//! at least 3 sites:
10//! - `src/store/mod.rs::is_visible_to_caller` (sal-gated; canonical)
11//! - `src/handlers/memories_query.rs::is_visible_to_caller`
12//!   (handler-local duplicate; DRIFT — missing the
13//!   `metadata.target_agent_id` inbox carve-out)
14//! - `src/handlers/memories.rs::get_memory` (inline gate per #927;
15//!   couldn't import the canonical version because `crate::store`
16//!   is `#[cfg(feature = "sal")]`-gated)
17//!
18//! Moving the helper here (not gated) lets the sqlite-only build,
19//! the sal-only build, and the sal-postgres build all share the
20//! same predicate so future scope semantics can change once and
21//! land everywhere.
22//!
23//! Semantics (load-bearing — DO NOT drift):
24//!   `is_visible_to_caller(mem, caller)` returns true iff:
25//!     - `mem.metadata.scope != "private"` (rows without the field
26//!       default to private per the CLAUDE.md NHI contract), OR
27//!     - `mem.metadata.agent_id == caller` (owner), OR
28//!     - `mem.metadata.target_agent_id == caller` (inbox carve-
29//!       out: the sender stamps `target_agent_id` on a private-by-
30//!       default `_inbox/<recipient>` row so the recipient can
31//!       read their own inbox even though the row is scope=private
32//!       under the sender's ownership).
33
34use crate::models::Memory;
35
36/// Returns `true` when the caller is entitled to see the memory.
37///
38/// Per #951 this is the **single canonical** implementation — every
39/// handler, MCP tool, and SAL adapter that needs an in-process
40/// visibility check should call this rather than re-implementing
41/// the predicate. Drift between copies is a real defect (the
42/// pre-#951 inline copy in `handlers/memories_query.rs` was missing
43/// the inbox carve-out, which would have surfaced the day a private
44/// inbox row hit a list+filter path).
45#[must_use]
46pub fn is_visible_to_caller(mem: &Memory, caller: &str) -> bool {
47    // v0.7.0 multi-agent literal-sweep (scanner B finding F-B8.x):
48    // route through `META_KEY_*` + `MemoryScope::Private.as_str()`
49    // SSOTs instead of raw string literals. String comparison (vs.
50    // typed-enum parsing) is INTENTIONAL — the pre-refactor semantics
51    // treat ANY non-"private" scope string as visible (including
52    // legacy values like "shared", custom org scopes, typos).
53    // Tightening to typed-enum parse would deny unknown scopes and
54    // break the `shared_scope_anyone_can_see` test contract below.
55    use crate::models::namespace::MemoryScope;
56    let scope = mem
57        .metadata
58        .get(crate::META_KEY_SCOPE)
59        .and_then(serde_json::Value::as_str)
60        .unwrap_or(MemoryScope::Private.as_str());
61    if scope != MemoryScope::Private.as_str() {
62        return true;
63    }
64    let owner = mem
65        .metadata
66        .get(crate::META_KEY_AGENT_ID)
67        .and_then(serde_json::Value::as_str)
68        .unwrap_or("");
69    if owner == caller {
70        return true;
71    }
72    let target = mem
73        .metadata
74        .get(crate::META_KEY_TARGET_AGENT_ID)
75        .and_then(serde_json::Value::as_str)
76        .unwrap_or("");
77    target == caller
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83    use crate::models::{ConfidenceSource, Memory, MemoryKind, Tier};
84    use serde_json::json;
85
86    fn mem_with_metadata(metadata: serde_json::Value) -> Memory {
87        Memory {
88            id: "test-id".to_string(),
89            tier: Tier::Long,
90            namespace: "test-ns".to_string(),
91            title: "test".to_string(),
92            content: "test".to_string(),
93            tags: vec![],
94            priority: 5,
95            confidence: 1.0,
96            source: "test".to_string(),
97            access_count: 0,
98            created_at: "2026-05-20T00:00:00Z".to_string(),
99            updated_at: "2026-05-20T00:00:00Z".to_string(),
100            last_accessed_at: None,
101            expires_at: None,
102            metadata,
103            reflection_depth: 0,
104            memory_kind: MemoryKind::Observation,
105            entity_id: None,
106            persona_version: None,
107            citations: vec![],
108            source_uri: None,
109            source_span: None,
110            confidence_source: ConfidenceSource::CallerProvided,
111            confidence_signals: None,
112            confidence_decayed_at: None,
113            version: 1,
114        }
115    }
116
117    #[test]
118    fn private_default_owner_can_see() {
119        let m = mem_with_metadata(json!({"agent_id": "alice"}));
120        assert!(is_visible_to_caller(&m, "alice"));
121    }
122
123    #[test]
124    fn private_default_non_owner_cannot_see() {
125        let m = mem_with_metadata(json!({"agent_id": "alice"}));
126        assert!(!is_visible_to_caller(&m, "bob"));
127    }
128
129    #[test]
130    fn explicit_private_owner_can_see() {
131        let m = mem_with_metadata(json!({"agent_id": "alice", "scope": "private"}));
132        assert!(is_visible_to_caller(&m, "alice"));
133    }
134
135    #[test]
136    fn explicit_private_non_owner_cannot_see() {
137        let m = mem_with_metadata(json!({"agent_id": "alice", "scope": "private"}));
138        assert!(!is_visible_to_caller(&m, "bob"));
139    }
140
141    #[test]
142    fn shared_scope_anyone_can_see() {
143        let m = mem_with_metadata(json!({"agent_id": "alice", "scope": "shared"}));
144        assert!(is_visible_to_caller(&m, "bob"));
145        assert!(is_visible_to_caller(&m, "carol"));
146    }
147
148    #[test]
149    fn inbox_target_can_see_private_row() {
150        // Inbox carve-out: sender stamps target_agent_id; recipient
151        // reads their own inbox even though scope=private under
152        // sender's ownership.
153        let m = mem_with_metadata(json!({
154            "agent_id": "alice",
155            "scope": "private",
156            "target_agent_id": "bob"
157        }));
158        assert!(is_visible_to_caller(&m, "bob"));
159        // Non-target non-owner still blocked.
160        assert!(!is_visible_to_caller(&m, "carol"));
161    }
162
163    #[test]
164    fn empty_owner_blocks_named_caller() {
165        // Legacy unowned (no agent_id) scope=private rows are NOT
166        // visible to a named caller — the empty `owner` string
167        // doesn't match "alice", so the predicate denies. (Higher-
168        // level handler code interprets empty owner as
169        // "unowned-legacy" and may treat that as claimable, but
170        // the predicate itself is strict-equality.)
171        let m = mem_with_metadata(json!({"scope": "private"}));
172        assert!(!is_visible_to_caller(&m, "alice"));
173    }
174
175    #[test]
176    fn empty_owner_visible_to_empty_caller_edge_case() {
177        // The "" == "" equality is a degenerate edge case — handler
178        // callers always synthesize a non-empty principal
179        // (`anonymous:req-<uuid>` or X-Agent-Id), so this branch
180        // would only fire on a misconfigured caller chain. Document
181        // the behavior so a future refactor doesn't tighten it
182        // without understanding the call-site contract.
183        let m = mem_with_metadata(json!({"scope": "private"}));
184        assert!(is_visible_to_caller(&m, ""));
185    }
186}