Skip to main content

ijima_core/
store.rs

1// Copyright (C) 2026 Industrial Algebra
2// SPDX-License-Identifier: Apache-2.0
3
4//! The storage contract — the abstraction boundary between Ijima's domain
5//! logic and the SurrealDB / SQLite / mock backends.
6//!
7//! Every backend implements [`Store`] (memory palace + session context) and
8//! (in future) [`KnowledgeGraph`](crate::knowledge::KnowledgeGraph).
9
10use async_trait::async_trait;
11
12use crate::{
13    AcceptedExtraction, DiaryEntry, Embedding, Memory, MemoryId, NamespaceId, QueuedExtraction,
14    RepoDirectory, Result, Session, SessionId, SessionTurn, TokenRevocation,
15    harness::Harness,
16    namespace::NamespaceMembership,
17    palace::{PalaceGraph, ProjectTaxon, Room, TunnelTraversal},
18};
19
20/// A scored semantic-search hit: the matched [`Memory`] plus its cosine
21/// similarity to the query (0.0–1.0, higher = more relevant).
22///
23/// Required both for cross-namespace result merging (the pi integration's
24/// `scope=visible` search) and for downstream "% match" display.
25#[derive(Debug, Clone, PartialEq)]
26#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
27pub struct SearchHit {
28    /// The matched memory.
29    pub memory: Memory,
30    /// Cosine similarity to the query (0.0–1.0).
31    pub similarity: f32,
32}
33/// Global memory-palace statistics (across all namespaces).
34#[derive(Debug, Clone, Default, PartialEq, Eq)]
35#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
36pub struct StoreStats {
37    /// Total memories across every namespace.
38    pub total_memories: usize,
39    /// Per-namespace breakdown.
40    pub namespaces: Vec<NamespaceCount>,
41}
42
43/// One namespace's memory count.
44#[derive(Debug, Clone, PartialEq, Eq)]
45#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
46pub struct NamespaceCount {
47    /// The namespace id.
48    pub namespace: String,
49    /// Memories in that namespace.
50    pub memories: usize,
51}
52
53/// The storage contract.
54///
55/// Implementations must be `Send + Sync` (shared across an axum handler
56/// pool). Async via [`async_trait`], matching the DB backends' async I/O.
57#[async_trait]
58pub trait Store: Send + Sync {
59    // ===== Memory palace =====
60
61    /// Stores a curated memory under `ns`, performing content-hash +
62    /// semantic dedup. Returns the stored id.
63    async fn store_memory(&self, ns: &NamespaceId, memory: Memory) -> Result<MemoryId>;
64
65    /// Recalls a single memory by id within `ns`. Returns `None` if the
66    /// id is absent or belongs to a different namespace (isolation).
67    async fn recall_memory(&self, ns: &NamespaceId, id: &MemoryId) -> Result<Option<Memory>>;
68
69    /// Deletes a memory by id within `ns`.
70    async fn delete_memory(&self, ns: &NamespaceId, id: &MemoryId) -> Result<()>;
71
72    /// Lists up to `limit` memories in `ns`, ranked by importance DESC
73    /// then recency DESC. Powers wake-up composition (L1a personal
74    /// essentials, L1b doctrine baseline).
75    async fn list_memories(&self, ns: &NamespaceId, limit: usize) -> Result<Vec<Memory>>;
76
77    /// Lists memories in `ns`, optionally filtered to `project`/`topic`.
78    /// Powers `GET /memories` (the `memory_recall` browse path — distinct
79    /// from [`Self::list_memories`], which is the importance-ranked
80    /// wake-up feed). Default: fetch a cap, filter in Rust, truncate.
81    /// Backends MAY override with a native filtered query.
82    async fn list_memories_filtered(
83        &self,
84        ns: &NamespaceId,
85        project: Option<&str>,
86        topic: Option<&str>,
87        limit: usize,
88    ) -> Result<Vec<Memory>> {
89        let cap = if project.is_some() || topic.is_some() {
90            500
91        } else {
92            limit
93        };
94        let mut mems = self.list_memories(ns, cap).await?;
95        if let Some(p) = project {
96            mems.retain(|m| m.project == p);
97        }
98        if let Some(t) = topic {
99            mems.retain(|m| m.topic == t);
100        }
101        mems.truncate(limit);
102        Ok(mems)
103    }
104
105    /// Global store statistics across all namespaces (operator/admin
106    /// view). Powers `GET /status`.
107    async fn store_stats(&self) -> Result<StoreStats>;
108
109    /// Checks whether a memory with identical content already exists in
110    /// `ns` (content-hash dedup). Returns the existing [`MemoryId`] if so.
111    async fn check_duplicate(&self, ns: &NamespaceId, content: &str) -> Result<Option<MemoryId>>;
112
113    /// Semantic search over memories in `ns` by nearest embedding.
114    ///
115    /// Implementations back this with a vector index (SurrealDB MTREE,
116    /// pgvector). v0 backends MAY return [`crate::IjimaError::Store`] until
117    /// the vector index is wired.
118    async fn search_memories(
119        &self,
120        ns: &NamespaceId,
121        embedding: &Embedding,
122        limit: usize,
123    ) -> Result<Vec<SearchHit>>;
124
125    // ===== Palace organization (Phase 3.1 + 3.2) =====
126
127    /// Lists rooms (topic cells) in `ns`, optionally filtered to a single
128    /// project. Each room carries its memory count. Ordered by count desc.
129    async fn list_rooms(
130        &self,
131        ns: &NamespaceId,
132        project: Option<&str>,
133        limit: usize,
134    ) -> Result<Vec<Room>>;
135
136    /// Full project → topic → count taxonomy of `ns`. Powers
137    /// `getTaxonomy` navigation.
138    async fn taxonomy(&self, ns: &NamespaceId) -> Result<Vec<ProjectTaxon>>;
139
140    /// The palace graph: projects as nodes, shared-topic tunnels as edges.
141    /// Powers `getPalaceGraph` — *"what connects these projects?"*
142    async fn palace_graph(&self, ns: &NamespaceId) -> Result<PalaceGraph>;
143
144    /// Traverses a tunnel: returns the actual memories from both projects on
145    /// the shared `topic`, so the caller can see what connects them.
146    async fn traverse_tunnel(
147        &self,
148        ns: &NamespaceId,
149        topic: &str,
150        project_a: &str,
151        project_b: &str,
152        limit: usize,
153    ) -> Result<TunnelTraversal>;
154
155    // ===== Session-context repository =====
156
157    /// Appends a raw turn to the session transcript under `ns`.
158    async fn ingest_turn(&self, ns: &NamespaceId, turn: SessionTurn) -> Result<()>;
159
160    /// Returns the last `limit` turns of `session` under `ns`, in order.
161    async fn session_turns(
162        &self,
163        ns: &NamespaceId,
164        session: &SessionId,
165        limit: usize,
166    ) -> Result<Vec<SessionTurn>>;
167
168    /// Creates or updates a session's metadata under `ns` (upsert by
169    /// id). Call when a session starts; turns reference the session id.
170    /// `ended_at` is set via [`Self::end_session`].
171    async fn create_session(&self, ns: &NamespaceId, session: Session) -> Result<SessionId>;
172
173    /// Lists up to `limit` sessions in `ns`, newest first, optionally
174    /// filtered by `harness`.
175    async fn list_sessions(
176        &self,
177        ns: &NamespaceId,
178        harness: Option<&Harness>,
179        limit: usize,
180    ) -> Result<Vec<Session>>;
181
182    /// Marks a session as ended (sets `ended_at`). Scoped by `ns` so a
183    /// principal can only end sessions in their own namespace.
184    async fn end_session(
185        &self,
186        ns: &NamespaceId,
187        session: &SessionId,
188        ended_at: String,
189    ) -> Result<()>;
190
191    // ===== Diaries (Phase 3.3) =====
192
193    /// Appends a diary entry under `ns`.
194    async fn write_diary(&self, ns: &NamespaceId, entry: DiaryEntry) -> Result<()>;
195
196    /// Returns the last `limit` entries of `agent`'s diary under `ns`, in
197    /// chronological order.
198    async fn read_diary(
199        &self,
200        ns: &NamespaceId,
201        agent: &str,
202        limit: usize,
203    ) -> Result<Vec<DiaryEntry>>;
204
205    // ===== Repo directory (global — Context Mapper) =====
206
207    /// Registers or upserts a repository in the global registry (keyed by
208    /// `name`). Powers `POST /repos` — the canonical Anima roster.
209    async fn register_repo(&self, repo: RepoDirectory) -> Result<()>;
210
211    /// Lists every registered repository (the ecosystem roster).
212    async fn list_repos(&self) -> Result<Vec<RepoDirectory>>;
213    /// Reverse-resolves a working directory to its registered repo: the
214    /// most specific repo whose `path` is a prefix of `cwd` (after
215    /// normalizing). Powers `GET /repos/resolve` (CWD → project).
216    async fn resolve_repo(&self, cwd: &str) -> Result<Option<RepoDirectory>> {
217        let target = crate::repo::normalize_path(cwd);
218        let repos = self.list_repos().await?;
219        Ok(repos
220            .into_iter()
221            .filter(|r| target == r.path || target.starts_with(&format!("{}/", r.path)))
222            .max_by_key(|r| r.path.len()))
223    }
224
225    // ===== Token revocation (WS1b — the grant kill-switch) =====
226
227    /// Records a token revocation (idempotent upsert keyed by hash).
228    /// Powers `POST /tokens/revoke` (admin).
229    async fn revoke_token(&self, revocation: TokenRevocation) -> Result<()>;
230
231    /// Lists every recorded revocation, oldest first. Powers
232    /// `GET /tokens/revocations` (admin) and daemon-boot hydration of the
233    /// in-memory rejection set.
234    async fn list_revocations(&self) -> Result<Vec<TokenRevocation>>;
235
236    // ===== Shared-namespace membership (WS3 org walls) =====
237
238    /// Grants (upserts) a principal's membership in a shared namespace.
239    /// Admin operation; idempotent — re-granting refreshes `granted_at`/
240    /// `granted_by` but never duplicates.
241    async fn grant_namespace_membership(&self, membership: NamespaceMembership) -> Result<()>;
242
243    /// Revokes a membership. Idempotent: revoking an absent membership is
244    /// not an error.
245    async fn revoke_namespace_membership(&self, ns: &NamespaceId, principal: &str) -> Result<()>;
246
247    /// Lists the members of a namespace, oldest grant first. Powers
248    /// `GET /namespaces/members` (admin).
249    async fn list_namespace_members(&self, ns: &NamespaceId) -> Result<Vec<NamespaceMembership>>;
250
251    /// Hot-path membership check behind `resolve_ns` (shared-namespace
252    /// reads/writes).
253    async fn is_namespace_member(&self, ns: &NamespaceId, principal: &str) -> Result<bool>;
254
255    // ===== Mining review queue (ADR M2, M3) =====
256
257    /// Stages a PendingReview extraction in the per-namespace queue.
258    async fn enqueue_extraction(
259        &self,
260        ns: &NamespaceId,
261        memory: Memory,
262        confidence: f32,
263    ) -> Result<String>;
264
265    /// Lists pending extractions in `ns`, newest first.
266    async fn list_pending(&self, ns: &NamespaceId, limit: usize) -> Result<Vec<QueuedExtraction>>;
267
268    /// Accepts a queued extraction: promotes it to the palace and removes
269    /// it from the queue.
270    async fn accept_extraction(
271        &self,
272        ns: &NamespaceId,
273        queue_id: &str,
274    ) -> Result<AcceptedExtraction>;
275
276    /// Rejects a queued extraction: drops it from the queue without promoting.
277    async fn reject_extraction(&self, ns: &NamespaceId, queue_id: &str) -> Result<()>;
278}