Skip to main content

khive_runtime/
operations.rs

1//! High-level operations composing storage capabilities into user-facing verbs.
2
3use std::collections::HashMap;
4use std::str::FromStr;
5
6use uuid::Uuid;
7
8use khive_score::{rrf_score, DeterministicScore};
9use khive_storage::note::{Note, NoteKind};
10use khive_storage::types::{
11    DeleteMode, Direction, EdgeSortField, GraphPath, LinkId, NeighborHit, NeighborQuery,
12    PageRequest, SortOrder, SqlStatement, TextDocument, TextFilter, TextQueryMode,
13    TextSearchRequest, TraversalRequest, VectorSearchRequest,
14};
15use khive_storage::{Edge, EdgeRelation, Entity, EntityFilter, Event};
16use khive_types::{EntityKind, SubstrateKind};
17
18use crate::error::{RuntimeError, RuntimeResult};
19use crate::runtime::KhiveRuntime;
20
21/// A note search result with UUID and salience-weighted RRF score.
22#[derive(Clone, Debug)]
23pub struct NoteSearchHit {
24    pub note_id: Uuid,
25    pub score: DeterministicScore,
26}
27
28/// Result of resolving a UUID to its substrate kind.
29#[derive(Clone, Debug)]
30pub enum Resolved {
31    Entity(Entity),
32    Note(Note),
33    Event(Event),
34}
35
36impl KhiveRuntime {
37    // ---- Entity operations ----
38
39    /// Create and persist a new entity.
40    pub async fn create_entity(
41        &self,
42        namespace: Option<&str>,
43        kind: &str,
44        name: &str,
45        description: Option<&str>,
46        properties: Option<serde_json::Value>,
47        tags: Vec<String>,
48    ) -> RuntimeResult<Entity> {
49        let ns = self.ns(namespace);
50        let entity_kind = EntityKind::from_str(kind).map_err(RuntimeError::InvalidInput)?;
51        let mut entity = Entity::new(ns, entity_kind, name);
52        if let Some(d) = description {
53            entity = entity.with_description(d);
54        }
55        if let Some(p) = properties {
56            entity = entity.with_properties(p);
57        }
58        if !tags.is_empty() {
59            entity = entity.with_tags(tags);
60        }
61        self.entities(Some(ns))?
62            .upsert_entity(entity.clone())
63            .await?;
64
65        let body = match &entity.description {
66            Some(d) if !d.is_empty() => format!("{} {}", entity.name, d),
67            _ => entity.name.clone(),
68        };
69        self.text(namespace)?
70            .upsert_document(TextDocument {
71                subject_id: entity.id,
72                kind: SubstrateKind::Entity,
73                title: Some(entity.name.clone()),
74                body: body.clone(),
75                tags: entity.tags.clone(),
76                namespace: ns.to_string(),
77                metadata: entity.properties.clone(),
78                updated_at: chrono::Utc::now(),
79            })
80            .await?;
81
82        if self.config().embedding_model.is_some() {
83            let vector = self.embed(&body).await?;
84            self.vectors(namespace)?
85                .insert(entity.id, SubstrateKind::Entity, ns, vector)
86                .await?;
87        }
88
89        Ok(entity)
90    }
91
92    /// Retrieve an entity by ID.
93    ///
94    /// Returns `None` if the entity does not exist or belongs to a different namespace.
95    /// This enforces ADR-007 namespace isolation at the runtime layer.
96    pub async fn get_entity(
97        &self,
98        namespace: Option<&str>,
99        id: Uuid,
100    ) -> RuntimeResult<Option<Entity>> {
101        let entity = match self.entities(namespace)?.get_entity(id).await? {
102            Some(e) => e,
103            None => return Ok(None),
104        };
105        if entity.namespace != self.ns(namespace) {
106            return Ok(None);
107        }
108        Ok(Some(entity))
109    }
110
111    /// List entities in a namespace, optionally filtered by kind.
112    pub async fn list_entities(
113        &self,
114        namespace: Option<&str>,
115        kind: Option<&str>,
116        limit: u32,
117    ) -> RuntimeResult<Vec<Entity>> {
118        let filter = EntityFilter {
119            kinds: match kind {
120                Some(k) => vec![EntityKind::from_str(k).map_err(RuntimeError::InvalidInput)?],
121                None => vec![],
122            },
123            ..Default::default()
124        };
125        let page = self
126            .entities(namespace)?
127            .query_entities(self.ns(namespace), filter, PageRequest { offset: 0, limit })
128            .await?;
129        Ok(page.items)
130    }
131
132    // ---- Edge operations ----
133
134    /// Create a directed edge between two entities.
135    pub async fn link(
136        &self,
137        namespace: Option<&str>,
138        source_id: Uuid,
139        target_id: Uuid,
140        relation: EdgeRelation,
141        weight: f64,
142    ) -> RuntimeResult<Edge> {
143        let edge = Edge {
144            id: LinkId::from(Uuid::new_v4()),
145            source_id,
146            target_id,
147            relation,
148            weight,
149            created_at: chrono::Utc::now(),
150            metadata: None,
151        };
152        self.graph(namespace)?.upsert_edge(edge.clone()).await?;
153        Ok(edge)
154    }
155
156    /// Get immediate neighbors of a node, optionally filtered by relation type.
157    ///
158    /// Pass `relations: Some(vec![EdgeRelation::Annotates])` to retrieve only
159    /// annotation edges, enabling cross-substrate navigation as described in ADR-024.
160    pub async fn neighbors(
161        &self,
162        namespace: Option<&str>,
163        node_id: Uuid,
164        direction: Direction,
165        limit: Option<u32>,
166        relations: Option<Vec<EdgeRelation>>,
167    ) -> RuntimeResult<Vec<NeighborHit>> {
168        let query = NeighborQuery {
169            direction,
170            relations,
171            limit,
172            min_weight: None,
173        };
174        Ok(self.graph(namespace)?.neighbors(node_id, query).await?)
175    }
176
177    /// Traverse the graph from a set of root nodes.
178    pub async fn traverse(
179        &self,
180        namespace: Option<&str>,
181        request: TraversalRequest,
182    ) -> RuntimeResult<Vec<GraphPath>> {
183        Ok(self.graph(namespace)?.traverse(request).await?)
184    }
185
186    // ---- Note operations ----
187
188    /// Create and persist a note, optionally with properties and annotation targets.
189    ///
190    /// After creating the note:
191    /// - Always indexes into FTS5 at the `notes_<namespace>` key.
192    /// - If an embedding model is configured, indexes into the vector store with
193    ///   `SubstrateKind::Note`.
194    /// - For each UUID in `annotates`, creates an `EdgeRelation::Annotates` edge from
195    ///   the note to that target.
196    #[allow(clippy::too_many_arguments)]
197    pub async fn create_note(
198        &self,
199        namespace: Option<&str>,
200        kind: NoteKind,
201        name: Option<&str>,
202        content: &str,
203        salience: f64,
204        properties: Option<serde_json::Value>,
205        annotates: Vec<Uuid>,
206    ) -> RuntimeResult<Note> {
207        let ns = self.ns(namespace);
208        let mut note = Note::new(ns, kind, content).with_salience(salience);
209        if let Some(n) = name {
210            note = note.with_name(n);
211        }
212        if let Some(p) = properties {
213            note = note.with_properties(p);
214        }
215        self.notes(Some(ns))?.upsert_note(note.clone()).await?;
216
217        let body = match &note.name {
218            Some(n) => format!("{n} {}", note.content),
219            None => note.content.clone(),
220        };
221
222        // Index into FTS5.
223        self.text_for_notes(Some(ns))?
224            .upsert_document(TextDocument {
225                subject_id: note.id,
226                kind: SubstrateKind::Note,
227                title: note.name.clone(),
228                body,
229                tags: vec![],
230                namespace: ns.to_string(),
231                metadata: note.properties.clone(),
232                updated_at: chrono::Utc::now(),
233            })
234            .await?;
235
236        // Index into vector store if model is configured.
237        if self.config().embedding_model.is_some() {
238            let vector = self.embed(&note.content).await?;
239            self.vectors(Some(ns))?
240                .insert(note.id, SubstrateKind::Note, ns, vector)
241                .await?;
242        }
243
244        // Create annotates edges.
245        for target_id in annotates {
246            self.link(Some(ns), note.id, target_id, EdgeRelation::Annotates, 1.0)
247                .await?;
248        }
249
250        Ok(note)
251    }
252
253    /// List notes, optionally filtered by kind.
254    pub async fn list_notes(
255        &self,
256        namespace: Option<&str>,
257        kind: Option<&str>,
258        limit: u32,
259    ) -> RuntimeResult<Vec<Note>> {
260        let note_kind = match kind {
261            Some(k) => Some(NoteKind::from_str(k).map_err(RuntimeError::InvalidInput)?),
262            None => None,
263        };
264        let page = self
265            .notes(namespace)?
266            .query_notes(
267                self.ns(namespace),
268                note_kind,
269                PageRequest { offset: 0, limit },
270            )
271            .await?;
272        Ok(page.items)
273    }
274
275    /// Search notes using a hybrid FTS5 + vector pipeline with salience weighting.
276    ///
277    /// Pipeline (per ADR-024):
278    /// 1. FTS5 query against `notes_<namespace>`.
279    /// 2. If embedding model is configured: vector search filtered to `kind="note"`.
280    /// 3. RRF fusion (k=60).
281    /// 4. Salience-weighted rerank: `score *= (0.5 + 0.5 * note.salience)`.
282    /// 5. Filter soft-deleted notes (`deleted_at IS NOT NULL`).
283    /// 6. Truncate to `limit`.
284    pub async fn search_notes(
285        &self,
286        namespace: Option<&str>,
287        query_text: &str,
288        query_vector: Option<Vec<f32>>,
289        limit: u32,
290    ) -> RuntimeResult<Vec<NoteSearchHit>> {
291        const RRF_K: usize = 60;
292        let candidates = limit.saturating_mul(4).max(limit);
293        let ns = self.ns(namespace).to_string();
294
295        // FTS5 over the notes index.
296        let text_hits = self
297            .text_for_notes(namespace)?
298            .search(TextSearchRequest {
299                query: query_text.to_string(),
300                mode: TextQueryMode::Plain,
301                filter: Some(TextFilter {
302                    namespaces: vec![ns.clone()],
303                    ..TextFilter::default()
304                }),
305                top_k: candidates,
306                snippet_chars: 200,
307            })
308            .await?;
309
310        // Vector search filtered to notes.
311        let vector_hits = if let Some(vec) = query_vector {
312            self.vectors(namespace)?
313                .search(VectorSearchRequest {
314                    query_embedding: vec,
315                    top_k: candidates,
316                    namespace: Some(ns.clone()),
317                    kind: Some(SubstrateKind::Note),
318                })
319                .await?
320        } else {
321            vec![]
322        };
323
324        // RRF fusion.
325        let mut buckets: HashMap<Uuid, DeterministicScore> = HashMap::new();
326        for (i, hit) in text_hits.into_iter().enumerate() {
327            let rank = i + 1;
328            let entry = buckets.entry(hit.subject_id).or_default();
329            *entry = *entry + rrf_score(rank, RRF_K);
330        }
331        for (i, hit) in vector_hits.into_iter().enumerate() {
332            let rank = i + 1;
333            let entry = buckets.entry(hit.subject_id).or_default();
334            *entry = *entry + rrf_score(rank, RRF_K);
335        }
336
337        let candidate_ids: Vec<Uuid> = buckets.keys().copied().collect();
338        if candidate_ids.is_empty() {
339            return Ok(vec![]);
340        }
341
342        // Fetch each candidate note individually to get salience and apply soft-delete filter.
343        let note_store = self.notes(namespace)?;
344        let mut alive_notes: HashMap<Uuid, Note> = HashMap::new();
345        for id in &candidate_ids {
346            if let Some(note) = note_store.get_note(*id).await? {
347                if note.deleted_at.is_none() {
348                    alive_notes.insert(*id, note);
349                }
350            }
351        }
352
353        // Drop superseded notes: any note targeted by a `supersedes` edge is
354        // obsolete and excluded from default search (ADR-019, ADR-024).
355        if !alive_notes.is_empty() {
356            let graph = self.graph(namespace)?;
357            let mut superseded: std::collections::HashSet<Uuid> = std::collections::HashSet::new();
358            for &note_id in alive_notes.keys() {
359                let inbound = graph
360                    .neighbors(
361                        note_id,
362                        NeighborQuery {
363                            direction: Direction::In,
364                            relations: Some(vec![EdgeRelation::Supersedes]),
365                            limit: Some(1),
366                            min_weight: None,
367                        },
368                    )
369                    .await?;
370                if !inbound.is_empty() {
371                    superseded.insert(note_id);
372                }
373            }
374            alive_notes.retain(|id, _| !superseded.contains(id));
375        }
376
377        // Apply salience weighting and collect final hits.
378        let mut hits: Vec<NoteSearchHit> = buckets
379            .into_iter()
380            .filter_map(|(id, rrf)| {
381                let note = alive_notes.get(&id)?;
382                let weight = 0.5 + 0.5 * note.salience;
383                let weighted = DeterministicScore::from_f64(rrf.to_f64() * weight);
384                Some(NoteSearchHit {
385                    note_id: id,
386                    score: weighted,
387                })
388            })
389            .collect();
390
391        hits.sort_by(|a, b| b.score.cmp(&a.score).then(a.note_id.cmp(&b.note_id)));
392        hits.truncate(limit as usize);
393        Ok(hits)
394    }
395
396    /// Resolve a short UUID prefix (8+ hex chars) to a full UUID.
397    ///
398    /// Searches entities, notes, and edges tables for a UUID starting with the
399    /// given prefix, scoped to the caller's namespace. Returns `Ok(Some(uuid))`
400    /// if exactly one match is found, `Ok(None)` if no matches, or an error if
401    /// ambiguous (multiple matches).
402    pub async fn resolve_prefix(
403        &self,
404        namespace: Option<&str>,
405        prefix: &str,
406    ) -> RuntimeResult<Option<Uuid>> {
407        use khive_storage::types::{SqlStatement, SqlValue};
408
409        let ns = self.ns(namespace).to_string();
410        let pattern = format!("{}%", prefix);
411
412        let tables = [("entities", true), ("notes", true), ("graph_edges", false)];
413
414        let mut matches: Vec<String> = Vec::new();
415        let mut reader = self.sql().reader().await.map_err(RuntimeError::Storage)?;
416
417        for (table, has_deleted_at) in tables {
418            let deleted_filter = if has_deleted_at {
419                " AND deleted_at IS NULL"
420            } else {
421                ""
422            };
423            let sql = SqlStatement {
424                sql: format!(
425                    "SELECT id FROM {table} WHERE id LIKE ?1 AND namespace = ?2{deleted_filter} LIMIT 2"
426                ),
427                params: vec![
428                    SqlValue::Text(pattern.clone()),
429                    SqlValue::Text(ns.clone()),
430                ],
431                label: Some("resolve_prefix".into()),
432            };
433            match reader.query_all(sql).await {
434                Ok(rows) => {
435                    for row in rows {
436                        if let Some(col) = row.columns.first() {
437                            if let SqlValue::Text(s) = &col.value {
438                                matches.push(s.clone());
439                            }
440                        }
441                    }
442                }
443                Err(e) => {
444                    let msg = e.to_string();
445                    if msg.contains("no such table") {
446                        continue;
447                    }
448                    return Err(RuntimeError::Storage(e));
449                }
450            }
451            if matches.len() > 1 {
452                break;
453            }
454        }
455
456        match matches.len() {
457            0 => Ok(None),
458            1 => {
459                let uuid = Uuid::from_str(&matches[0])
460                    .map_err(|e| RuntimeError::Internal(format!("stored UUID is invalid: {e}")))?;
461                Ok(Some(uuid))
462            }
463            _ => Err(RuntimeError::Ambiguous(format!(
464                "prefix '{prefix}' matches multiple UUIDs"
465            ))),
466        }
467    }
468
469    /// Resolve a UUID to its substrate kind by trying entity, then note, then event stores.
470    ///
471    /// Returns `None` if the UUID is not found in any substrate.
472    /// Cost: at most 3 store lookups per call (cheap for v0.1).
473    pub async fn resolve(
474        &self,
475        namespace: Option<&str>,
476        id: Uuid,
477    ) -> RuntimeResult<Option<Resolved>> {
478        let ns = self.ns(namespace);
479
480        // Entity: use the namespace-checked getter (returns None on mismatch).
481        if let Some(entity) = self.get_entity(namespace, id).await? {
482            return Ok(Some(Resolved::Entity(entity)));
483        }
484
485        // Note: storage get_note is ID-only — verify namespace after fetch.
486        if let Some(note) = self.notes(namespace)?.get_note(id).await? {
487            if note.namespace == ns {
488                return Ok(Some(Resolved::Note(note)));
489            }
490        }
491
492        // Event: storage get_event is ID-only — verify namespace after fetch.
493        if let Some(event) = self.events(namespace)?.get_event(id).await? {
494            if event.namespace == ns {
495                return Ok(Some(Resolved::Event(event)));
496            }
497        }
498
499        Ok(None)
500    }
501
502    /// Delete a note by ID, enforcing namespace isolation.
503    ///
504    /// Returns `false` without deleting if the note does not exist or belongs to
505    /// a different namespace (ADR-007 namespace isolation).
506    pub async fn delete_note(
507        &self,
508        namespace: Option<&str>,
509        id: Uuid,
510        hard: bool,
511    ) -> RuntimeResult<bool> {
512        let ns = self.ns(namespace);
513        let note_store = self.notes(namespace)?;
514        let note = match note_store.get_note(id).await? {
515            Some(n) => n,
516            None => return Ok(false),
517        };
518        if note.namespace != ns {
519            return Ok(false);
520        }
521        let mode = if hard {
522            DeleteMode::Hard
523        } else {
524            DeleteMode::Soft
525        };
526        Ok(note_store.delete_note(id, mode).await?)
527    }
528
529    // ---- Query operations ----
530
531    /// Execute a GQL or SPARQL query string, returning raw SQL rows.
532    ///
533    /// The query is compiled to SQL with the namespace scope applied.
534    /// GQL syntax: `MATCH (a:concept)-[e:extends]->(b) RETURN a, b LIMIT 10`
535    /// SPARQL syntax: `SELECT ?a WHERE { ?a :kind "concept" . }`
536    pub async fn query(
537        &self,
538        namespace: Option<&str>,
539        query: &str,
540    ) -> RuntimeResult<Vec<khive_storage::types::SqlRow>> {
541        let ns = self.ns(namespace);
542        let ast = khive_query::parse_auto(query)?;
543        let opts = khive_query::CompileOptions {
544            scopes: vec![ns.to_string()],
545            ..Default::default()
546        };
547        let compiled = khive_query::compile(&ast, &opts)?;
548        let mut reader = self.sql().reader().await?;
549        let stmt = SqlStatement {
550            sql: compiled.sql,
551            params: compiled.params,
552            label: None,
553        };
554        Ok(reader.query_all(stmt).await?)
555    }
556
557    /// Delete an entity by ID (soft delete by default).
558    ///
559    /// On hard delete, cascades to remove all incident edges (both inbound and
560    /// outbound) to prevent dangling references. Soft delete leaves edges in
561    /// place — queries already filter by `deleted_at IS NULL`.
562    ///
563    /// Returns `false` without deleting if the entity exists but belongs to a
564    /// different namespace (ADR-007 namespace isolation).
565    pub async fn delete_entity(
566        &self,
567        namespace: Option<&str>,
568        id: Uuid,
569        hard: bool,
570    ) -> RuntimeResult<bool> {
571        let entity = match self.entities(namespace)?.get_entity(id).await? {
572            Some(e) => e,
573            None => return Ok(false),
574        };
575        if entity.namespace != self.ns(namespace) {
576            return Ok(false);
577        }
578        let mode = if hard {
579            DeleteMode::Hard
580        } else {
581            DeleteMode::Soft
582        };
583
584        // On hard delete, cascade-remove incident edges to prevent dangling refs.
585        if hard {
586            let graph = self.graph(namespace)?;
587            for direction in [Direction::Out, Direction::In] {
588                let hits = graph
589                    .neighbors(
590                        id,
591                        NeighborQuery {
592                            direction,
593                            relations: None,
594                            limit: None,
595                            min_weight: None,
596                        },
597                    )
598                    .await?;
599                for hit in hits {
600                    graph.delete_edge(LinkId::from(hit.edge_id)).await?;
601                }
602            }
603            self.remove_from_indexes(namespace, id).await?;
604        }
605
606        Ok(self.entities(namespace)?.delete_entity(id, mode).await?)
607    }
608
609    /// Count entities in a namespace, optionally filtered.
610    pub async fn count_entities(
611        &self,
612        namespace: Option<&str>,
613        kind: Option<&str>,
614    ) -> RuntimeResult<u64> {
615        let filter = EntityFilter {
616            kinds: match kind {
617                Some(k) => vec![EntityKind::from_str(k).map_err(RuntimeError::InvalidInput)?],
618                None => vec![],
619            },
620            ..Default::default()
621        };
622        Ok(self
623            .entities(namespace)?
624            .count_entities(self.ns(namespace), filter)
625            .await?)
626    }
627
628    // ---- Edge CRUD operations ----
629
630    /// Fetch a single edge by id. Returns `None` if the edge does not exist.
631    pub async fn get_edge(
632        &self,
633        namespace: Option<&str>,
634        edge_id: Uuid,
635    ) -> RuntimeResult<Option<Edge>> {
636        Ok(self
637            .graph(namespace)?
638            .get_edge(LinkId::from(edge_id))
639            .await?)
640    }
641
642    /// List edges matching `filter`. `limit` is capped at 1000; defaults to 100.
643    pub async fn list_edges(
644        &self,
645        namespace: Option<&str>,
646        filter: crate::curation::EdgeListFilter,
647        limit: u32,
648    ) -> RuntimeResult<Vec<Edge>> {
649        let limit = limit.clamp(1, 1000);
650        let page = self
651            .graph(namespace)?
652            .query_edges(
653                filter.into(),
654                vec![SortOrder {
655                    field: EdgeSortField::CreatedAt,
656                    direction: khive_storage::types::SortDirection::Asc,
657                }],
658                PageRequest { offset: 0, limit },
659            )
660            .await?;
661        Ok(page.items)
662    }
663
664    /// Patch-style edge update. Only `Some(_)` fields are applied.
665    pub async fn update_edge(
666        &self,
667        namespace: Option<&str>,
668        edge_id: Uuid,
669        relation: Option<EdgeRelation>,
670        weight: Option<f64>,
671    ) -> RuntimeResult<Edge> {
672        let graph = self.graph(namespace)?;
673        let mut edge = graph
674            .get_edge(LinkId::from(edge_id))
675            .await?
676            .ok_or_else(|| crate::RuntimeError::NotFound(format!("edge {edge_id}")))?;
677
678        if let Some(r) = relation {
679            edge.relation = r;
680        }
681        if let Some(w) = weight {
682            edge.weight = w.clamp(0.0, 1.0);
683        }
684
685        graph.upsert_edge(edge.clone()).await?;
686        Ok(edge)
687    }
688
689    /// Hard-delete an edge by id. Returns `true` if an edge was removed.
690    pub async fn delete_edge(&self, namespace: Option<&str>, edge_id: Uuid) -> RuntimeResult<bool> {
691        Ok(self
692            .graph(namespace)?
693            .delete_edge(LinkId::from(edge_id))
694            .await?)
695    }
696
697    /// Count edges matching `filter`.
698    pub async fn count_edges(
699        &self,
700        namespace: Option<&str>,
701        filter: crate::curation::EdgeListFilter,
702    ) -> RuntimeResult<u64> {
703        Ok(self.graph(namespace)?.count_edges(filter.into()).await?)
704    }
705}
706
707#[cfg(test)]
708mod tests {
709    use super::*;
710    use crate::curation::EdgeListFilter;
711    use crate::runtime::KhiveRuntime;
712
713    fn rt() -> KhiveRuntime {
714        KhiveRuntime::memory().unwrap()
715    }
716
717    #[tokio::test]
718    async fn update_edge_changes_weight() {
719        let rt = rt();
720        let a = rt
721            .create_entity(None, "concept", "A", None, None, vec![])
722            .await
723            .unwrap();
724        let b = rt
725            .create_entity(None, "concept", "B", None, None, vec![])
726            .await
727            .unwrap();
728        let edge = rt
729            .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
730            .await
731            .unwrap();
732        let edge_id: Uuid = edge.id.into();
733
734        let updated = rt
735            .update_edge(None, edge_id, None, Some(0.5))
736            .await
737            .unwrap();
738        assert!((updated.weight - 0.5).abs() < 0.001);
739    }
740
741    #[tokio::test]
742    async fn update_edge_changes_relation() {
743        let rt = rt();
744        let a = rt
745            .create_entity(None, "concept", "A", None, None, vec![])
746            .await
747            .unwrap();
748        let b = rt
749            .create_entity(None, "concept", "B", None, None, vec![])
750            .await
751            .unwrap();
752        let edge = rt
753            .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
754            .await
755            .unwrap();
756        let edge_id: Uuid = edge.id.into();
757
758        let updated = rt
759            .update_edge(None, edge_id, Some(EdgeRelation::VariantOf), None)
760            .await
761            .unwrap();
762        assert_eq!(updated.relation, EdgeRelation::VariantOf);
763    }
764
765    #[tokio::test]
766    async fn list_edges_filters_by_relation() {
767        let rt = rt();
768        let a = rt
769            .create_entity(None, "concept", "A", None, None, vec![])
770            .await
771            .unwrap();
772        let b = rt
773            .create_entity(None, "concept", "B", None, None, vec![])
774            .await
775            .unwrap();
776        let c = rt
777            .create_entity(None, "concept", "C", None, None, vec![])
778            .await
779            .unwrap();
780
781        rt.link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
782            .await
783            .unwrap();
784        rt.link(None, a.id, c.id, EdgeRelation::DependsOn, 1.0)
785            .await
786            .unwrap();
787
788        let filter = EdgeListFilter {
789            relations: vec![EdgeRelation::Extends],
790            ..Default::default()
791        };
792        let edges = rt.list_edges(None, filter, 100).await.unwrap();
793        assert_eq!(edges.len(), 1);
794        assert_eq!(edges[0].relation, EdgeRelation::Extends);
795    }
796
797    #[tokio::test]
798    async fn list_edges_filters_by_source() {
799        let rt = rt();
800        let a = rt
801            .create_entity(None, "concept", "A", None, None, vec![])
802            .await
803            .unwrap();
804        let b = rt
805            .create_entity(None, "concept", "B", None, None, vec![])
806            .await
807            .unwrap();
808        let c = rt
809            .create_entity(None, "concept", "C", None, None, vec![])
810            .await
811            .unwrap();
812        let d = rt
813            .create_entity(None, "concept", "D", None, None, vec![])
814            .await
815            .unwrap();
816
817        rt.link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
818            .await
819            .unwrap();
820        rt.link(None, c.id, d.id, EdgeRelation::Extends, 1.0)
821            .await
822            .unwrap();
823
824        let filter = EdgeListFilter {
825            source_id: Some(a.id),
826            ..Default::default()
827        };
828        let edges = rt.list_edges(None, filter, 100).await.unwrap();
829        assert_eq!(edges.len(), 1);
830        let src: Uuid = edges[0].source_id;
831        assert_eq!(src, a.id);
832    }
833
834    #[tokio::test]
835    async fn delete_edge_removes_from_storage() {
836        let rt = rt();
837        let a = rt
838            .create_entity(None, "concept", "A", None, None, vec![])
839            .await
840            .unwrap();
841        let b = rt
842            .create_entity(None, "concept", "B", None, None, vec![])
843            .await
844            .unwrap();
845        let edge = rt
846            .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
847            .await
848            .unwrap();
849        let edge_id: Uuid = edge.id.into();
850
851        let deleted = rt.delete_edge(None, edge_id).await.unwrap();
852        assert!(deleted);
853
854        let fetched = rt.get_edge(None, edge_id).await.unwrap();
855        assert!(fetched.is_none(), "edge should be gone after delete");
856    }
857
858    #[tokio::test]
859    async fn count_edges_matches_filter() {
860        let rt = rt();
861        let a = rt
862            .create_entity(None, "concept", "A", None, None, vec![])
863            .await
864            .unwrap();
865        let b = rt
866            .create_entity(None, "concept", "B", None, None, vec![])
867            .await
868            .unwrap();
869        let c = rt
870            .create_entity(None, "concept", "C", None, None, vec![])
871            .await
872            .unwrap();
873
874        rt.link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
875            .await
876            .unwrap();
877        rt.link(None, a.id, c.id, EdgeRelation::DependsOn, 1.0)
878            .await
879            .unwrap();
880
881        let all = rt
882            .count_edges(None, EdgeListFilter::default())
883            .await
884            .unwrap();
885        assert_eq!(all, 2);
886
887        let just_extends = rt
888            .count_edges(
889                None,
890                EdgeListFilter {
891                    relations: vec![EdgeRelation::Extends],
892                    ..Default::default()
893                },
894            )
895            .await
896            .unwrap();
897        assert_eq!(just_extends, 1);
898    }
899
900    #[tokio::test]
901    async fn get_entity_namespace_isolation() {
902        let rt = rt();
903        let entity = rt
904            .create_entity(Some("ns-a"), "concept", "Alpha", None, None, vec![])
905            .await
906            .unwrap();
907
908        // Same namespace: visible.
909        let found = rt.get_entity(Some("ns-a"), entity.id).await.unwrap();
910        assert!(found.is_some(), "should be visible in its own namespace");
911
912        // Different namespace: invisible.
913        let not_found = rt.get_entity(Some("ns-b"), entity.id).await.unwrap();
914        assert!(
915            not_found.is_none(),
916            "should not be visible across namespaces"
917        );
918    }
919
920    #[tokio::test]
921    async fn delete_entity_namespace_isolation() {
922        let rt = rt();
923        let entity = rt
924            .create_entity(Some("ns-a"), "concept", "Beta", None, None, vec![])
925            .await
926            .unwrap();
927
928        // Delete from wrong namespace: no-op, returns false.
929        let deleted = rt
930            .delete_entity(Some("ns-b"), entity.id, true)
931            .await
932            .unwrap();
933        assert!(!deleted, "cross-namespace delete must return false");
934
935        // Entity still present in its own namespace.
936        let still_there = rt.get_entity(Some("ns-a"), entity.id).await.unwrap();
937        assert!(
938            still_there.is_some(),
939            "entity must survive cross-ns delete attempt"
940        );
941
942        // Delete from correct namespace: succeeds.
943        let deleted_ok = rt
944            .delete_entity(Some("ns-a"), entity.id, true)
945            .await
946            .unwrap();
947        assert!(deleted_ok, "same-namespace delete must succeed");
948    }
949
950    // ---- Note ADR-024 tests ----
951
952    #[tokio::test]
953    async fn create_note_indexes_into_fts5() {
954        let rt = rt();
955        let note = rt
956            .create_note(
957                None,
958                khive_storage::NoteKind::Observation,
959                None,
960                "FlashAttention reduces memory by using tiling",
961                0.8,
962                None,
963                vec![],
964            )
965            .await
966            .unwrap();
967
968        // FTS5 should have indexed the note content.
969        let ns = rt.ns(None).to_string();
970        let hits = rt
971            .text_for_notes(None)
972            .unwrap()
973            .search(khive_storage::types::TextSearchRequest {
974                query: "FlashAttention".to_string(),
975                mode: khive_storage::types::TextQueryMode::Plain,
976                filter: Some(khive_storage::types::TextFilter {
977                    namespaces: vec![ns],
978                    ..Default::default()
979                }),
980                top_k: 10,
981                snippet_chars: 100,
982            })
983            .await
984            .unwrap();
985
986        assert!(
987            hits.iter().any(|h| h.subject_id == note.id),
988            "note should be indexed in FTS5 after create"
989        );
990    }
991
992    #[tokio::test]
993    async fn create_note_with_properties() {
994        let rt = rt();
995        let props = serde_json::json!({"source": "arxiv:2205.14135"});
996        let note = rt
997            .create_note(
998                None,
999                khive_storage::NoteKind::Insight,
1000                None,
1001                "FlashAttention is IO-aware",
1002                0.9,
1003                Some(props.clone()),
1004                vec![],
1005            )
1006            .await
1007            .unwrap();
1008
1009        assert_eq!(note.properties.as_ref().unwrap(), &props);
1010    }
1011
1012    #[tokio::test]
1013    async fn create_note_creates_annotates_edges() {
1014        let rt = rt();
1015        let entity = rt
1016            .create_entity(None, "concept", "FlashAttention", None, None, vec![])
1017            .await
1018            .unwrap();
1019
1020        let note = rt
1021            .create_note(
1022                None,
1023                khive_storage::NoteKind::Observation,
1024                None,
1025                "FlashAttention uses SRAM tiling for memory efficiency",
1026                0.9,
1027                None,
1028                vec![entity.id],
1029            )
1030            .await
1031            .unwrap();
1032
1033        // The note should have an outbound `annotates` edge to the entity.
1034        let out_neighbors = rt
1035            .neighbors(
1036                None,
1037                note.id,
1038                Direction::Out,
1039                None,
1040                Some(vec![EdgeRelation::Annotates]),
1041            )
1042            .await
1043            .unwrap();
1044        assert_eq!(out_neighbors.len(), 1);
1045        assert_eq!(out_neighbors[0].node_id, entity.id);
1046        assert_eq!(out_neighbors[0].relation, EdgeRelation::Annotates);
1047
1048        // The entity should have an inbound `annotates` edge from the note.
1049        let in_neighbors = rt
1050            .neighbors(
1051                None,
1052                entity.id,
1053                Direction::In,
1054                None,
1055                Some(vec![EdgeRelation::Annotates]),
1056            )
1057            .await
1058            .unwrap();
1059        assert_eq!(in_neighbors.len(), 1);
1060        assert_eq!(in_neighbors[0].node_id, note.id);
1061    }
1062
1063    #[tokio::test]
1064    async fn neighbors_without_relation_filter_returns_all() {
1065        let rt = rt();
1066        let a = rt
1067            .create_entity(None, "concept", "A", None, None, vec![])
1068            .await
1069            .unwrap();
1070        let b = rt
1071            .create_entity(None, "concept", "B", None, None, vec![])
1072            .await
1073            .unwrap();
1074        let c = rt
1075            .create_entity(None, "concept", "C", None, None, vec![])
1076            .await
1077            .unwrap();
1078
1079        rt.link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
1080            .await
1081            .unwrap();
1082        rt.link(None, a.id, c.id, EdgeRelation::DependsOn, 1.0)
1083            .await
1084            .unwrap();
1085
1086        let all = rt
1087            .neighbors(None, a.id, Direction::Out, None, None)
1088            .await
1089            .unwrap();
1090        assert_eq!(all.len(), 2);
1091    }
1092
1093    #[tokio::test]
1094    async fn neighbors_with_relation_filter_returns_subset() {
1095        let rt = rt();
1096        let a = rt
1097            .create_entity(None, "concept", "A", None, None, vec![])
1098            .await
1099            .unwrap();
1100        let b = rt
1101            .create_entity(None, "concept", "B", None, None, vec![])
1102            .await
1103            .unwrap();
1104        let c = rt
1105            .create_entity(None, "concept", "C", None, None, vec![])
1106            .await
1107            .unwrap();
1108
1109        rt.link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
1110            .await
1111            .unwrap();
1112        rt.link(None, a.id, c.id, EdgeRelation::DependsOn, 1.0)
1113            .await
1114            .unwrap();
1115
1116        let filtered = rt
1117            .neighbors(
1118                None,
1119                a.id,
1120                Direction::Out,
1121                None,
1122                Some(vec![EdgeRelation::Extends]),
1123            )
1124            .await
1125            .unwrap();
1126        assert_eq!(filtered.len(), 1);
1127        assert_eq!(filtered[0].node_id, b.id);
1128        assert_eq!(filtered[0].relation, EdgeRelation::Extends);
1129    }
1130
1131    #[tokio::test]
1132    async fn search_notes_returns_relevant_note() {
1133        let rt = rt();
1134        rt.create_note(
1135            None,
1136            khive_storage::NoteKind::Observation,
1137            None,
1138            "GQA reduces KV cache memory for large models",
1139            0.8,
1140            None,
1141            vec![],
1142        )
1143        .await
1144        .unwrap();
1145
1146        let results = rt
1147            .search_notes(None, "GQA KV cache", None, 10)
1148            .await
1149            .unwrap();
1150
1151        assert!(!results.is_empty(), "search should return the indexed note");
1152    }
1153
1154    #[tokio::test]
1155    async fn search_notes_excludes_soft_deleted() {
1156        let rt = rt();
1157        let note = rt
1158            .create_note(
1159                None,
1160                khive_storage::NoteKind::Observation,
1161                None,
1162                "RoPE positional encoding rotary embeddings",
1163                0.7,
1164                None,
1165                vec![],
1166            )
1167            .await
1168            .unwrap();
1169
1170        // Soft-delete the note.
1171        rt.notes(None)
1172            .unwrap()
1173            .delete_note(note.id, DeleteMode::Soft)
1174            .await
1175            .unwrap();
1176
1177        let results = rt
1178            .search_notes(None, "RoPE rotary positional", None, 10)
1179            .await
1180            .unwrap();
1181
1182        assert!(
1183            results.iter().all(|h| h.note_id != note.id),
1184            "soft-deleted note should be excluded from search"
1185        );
1186    }
1187
1188    #[tokio::test]
1189    async fn resolve_returns_entity() {
1190        let rt = rt();
1191        let entity = rt
1192            .create_entity(None, "concept", "LoRA", None, None, vec![])
1193            .await
1194            .unwrap();
1195
1196        let resolved = rt.resolve(None, entity.id).await.unwrap();
1197        match resolved {
1198            Some(Resolved::Entity(e)) => assert_eq!(e.id, entity.id),
1199            other => panic!("expected Resolved::Entity, got {:?}", other),
1200        }
1201    }
1202
1203    #[tokio::test]
1204    async fn resolve_returns_note() {
1205        let rt = rt();
1206        let note = rt
1207            .create_note(
1208                None,
1209                khive_storage::NoteKind::Observation,
1210                None,
1211                "LoRA fine-tunes LLMs with low-rank adapters",
1212                0.85,
1213                None,
1214                vec![],
1215            )
1216            .await
1217            .unwrap();
1218
1219        let resolved = rt.resolve(None, note.id).await.unwrap();
1220        match resolved {
1221            Some(Resolved::Note(n)) => assert_eq!(n.id, note.id),
1222            other => panic!("expected Resolved::Note, got {:?}", other),
1223        }
1224    }
1225
1226    #[tokio::test]
1227    async fn resolve_returns_none_for_unknown_uuid() {
1228        let rt = rt();
1229        let unknown = Uuid::new_v4();
1230        let resolved = rt.resolve(None, unknown).await.unwrap();
1231        assert!(resolved.is_none(), "unknown UUID should resolve to None");
1232    }
1233
1234    #[tokio::test]
1235    async fn resolve_prefix_finds_entity_in_own_namespace() {
1236        let rt = rt();
1237        let entity = rt
1238            .create_entity(None, "concept", "PrefixTest", None, None, vec![])
1239            .await
1240            .unwrap();
1241        let prefix = &entity.id.to_string()[..8];
1242
1243        let resolved = rt.resolve_prefix(None, prefix).await.unwrap();
1244        assert_eq!(resolved, Some(entity.id));
1245    }
1246
1247    #[tokio::test]
1248    async fn resolve_prefix_invisible_across_namespaces() {
1249        let rt = rt();
1250        let entity = rt
1251            .create_entity(Some("ns_a"), "concept", "Invisible", None, None, vec![])
1252            .await
1253            .unwrap();
1254        let prefix = &entity.id.to_string()[..8];
1255
1256        // From ns_b, the entity in ns_a should not be visible.
1257        let resolved = rt.resolve_prefix(Some("ns_b"), prefix).await.unwrap();
1258        assert_eq!(resolved, None);
1259    }
1260
1261    #[tokio::test]
1262    async fn resolve_prefix_ambiguous_same_namespace() {
1263        use khive_storage::entity::Entity;
1264        use khive_types::EntityKind;
1265
1266        let rt = rt();
1267        // Two entities with UUIDs sharing the same 8-char prefix "aabbccdd".
1268        let id_a = Uuid::parse_str("aabbccdd-1111-4000-8000-000000000001").unwrap();
1269        let id_b = Uuid::parse_str("aabbccdd-2222-4000-8000-000000000002").unwrap();
1270
1271        let mut entity_a = Entity::new("local", EntityKind::Concept, "AmbigA");
1272        entity_a.id = id_a;
1273        let mut entity_b = Entity::new("local", EntityKind::Concept, "AmbigB");
1274        entity_b.id = id_b;
1275
1276        let store = rt.entities(None).unwrap();
1277        store.upsert_entity(entity_a).await.unwrap();
1278        store.upsert_entity(entity_b).await.unwrap();
1279
1280        let result = rt.resolve_prefix(None, "aabbccdd").await;
1281        assert!(
1282            result.is_err(),
1283            "shared 8-char prefix must return Ambiguous error"
1284        );
1285    }
1286}