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}