1use std::collections::HashMap;
4use std::str::FromStr;
5
6use serde::Serialize;
7use uuid::Uuid;
8
9use khive_score::{rrf_score, DeterministicScore};
10use khive_storage::note::Note;
11use khive_storage::types::{
12 DeleteMode, Direction, EdgeSortField, GraphPath, LinkId, NeighborHit, NeighborQuery, Page,
13 PageRequest, SortOrder, SqlRow, SqlStatement, TextDocument, TextFilter, TextQueryMode,
14 TextSearchRequest, TraversalRequest,
15};
16use khive_storage::{Edge, EdgeRelation, Entity, EntityFilter, Event, EventFilter};
17use khive_types::{EdgeEndpointRule, EndpointKind, SubstrateKind};
18
19use crate::error::{RuntimeError, RuntimeResult};
20use crate::runtime::KhiveRuntime;
21
22#[cfg(test)]
30std::thread_local! {
31 static LINK_FAIL_AFTER: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
32}
33
34#[derive(Clone, Debug)]
36pub struct NoteSearchHit {
37 pub note_id: Uuid,
38 pub score: DeterministicScore,
39 pub title: Option<String>,
40 pub snippet: Option<String>,
41}
42
43fn text_preview(text: &str, max_chars: usize) -> Option<String> {
44 let trimmed = text.trim();
45 if trimmed.is_empty() {
46 None
47 } else {
48 Some(trimmed.chars().take(max_chars).collect())
49 }
50}
51
52fn note_title(note: &Note) -> Option<String> {
53 note.name
54 .clone()
55 .filter(|s| !s.trim().is_empty())
56 .or_else(|| text_preview(¬e.content, 80))
57}
58
59fn note_snippet(note: &Note) -> Option<String> {
60 text_preview(¬e.content, 200)
61}
62
63#[derive(Clone, Debug)]
65pub enum Resolved {
66 Entity(Entity),
67 Note(Note),
68 Event(Event),
69}
70
71fn resolved_pair(r: Option<&Resolved>) -> Option<(&'static str, &str)> {
74 match r? {
75 Resolved::Entity(e) => Some(("entity", e.kind.as_str())),
76 Resolved::Note(n) => Some(("note", n.kind.as_str())),
77 Resolved::Event(_) => None,
78 }
79}
80
81fn endpoint_matches(spec: &EndpointKind, substrate: &str, kind: &str) -> bool {
83 match spec {
84 EndpointKind::EntityOfKind(k) => substrate == "entity" && *k == kind,
85 EndpointKind::NoteOfKind(k) => substrate == "note" && *k == kind,
86 }
87}
88
89fn pack_rule_allows(
92 rules: &[EdgeEndpointRule],
93 relation: EdgeRelation,
94 src: Option<&Resolved>,
95 tgt: Option<&Resolved>,
96) -> bool {
97 let Some((src_sub, src_kind)) = resolved_pair(src) else {
98 return false;
99 };
100 let Some((tgt_sub, tgt_kind)) = resolved_pair(tgt) else {
101 return false;
102 };
103 rules.iter().any(|r| {
104 r.relation == relation
105 && endpoint_matches(&r.source, src_sub, src_kind)
106 && endpoint_matches(&r.target, tgt_sub, tgt_kind)
107 })
108}
109
110impl KhiveRuntime {
111 pub async fn create_entity(
115 &self,
116 namespace: Option<&str>,
117 kind: &str,
118 name: &str,
119 description: Option<&str>,
120 properties: Option<serde_json::Value>,
121 tags: Vec<String>,
122 ) -> RuntimeResult<Entity> {
123 let ns = self.ns(namespace);
124 let mut entity = Entity::new(ns, kind, name);
125 if let Some(d) = description {
126 entity = entity.with_description(d);
127 }
128 if let Some(p) = properties {
129 entity = entity.with_properties(p);
130 }
131 if !tags.is_empty() {
132 entity = entity.with_tags(tags);
133 }
134 self.entities(Some(ns))?
135 .upsert_entity(entity.clone())
136 .await?;
137
138 let body = match &entity.description {
139 Some(d) if !d.is_empty() => format!("{} {}", entity.name, d),
140 _ => entity.name.clone(),
141 };
142 self.text(namespace)?
143 .upsert_document(TextDocument {
144 subject_id: entity.id,
145 kind: SubstrateKind::Entity,
146 title: Some(entity.name.clone()),
147 body: body.clone(),
148 tags: entity.tags.clone(),
149 namespace: ns.to_string(),
150 metadata: entity.properties.clone(),
151 updated_at: chrono::Utc::now(),
152 })
153 .await?;
154
155 if self.config().embedding_model.is_some() {
156 let vector = self.embed(&body).await?;
157 self.vectors(namespace)?
158 .insert(entity.id, SubstrateKind::Entity, ns, vector)
159 .await?;
160 }
161
162 Ok(entity)
163 }
164
165 pub async fn get_entity(
170 &self,
171 namespace: Option<&str>,
172 id: Uuid,
173 ) -> RuntimeResult<Option<Entity>> {
174 let entity = match self.entities(namespace)?.get_entity(id).await? {
175 Some(e) => e,
176 None => return Ok(None),
177 };
178 if entity.namespace != self.ns(namespace) {
179 return Ok(None);
180 }
181 Ok(Some(entity))
182 }
183
184 pub async fn list_entities(
186 &self,
187 namespace: Option<&str>,
188 kind: Option<&str>,
189 limit: u32,
190 offset: u32,
191 ) -> RuntimeResult<Vec<Entity>> {
192 let filter = EntityFilter {
193 kinds: match kind {
194 Some(k) => vec![k.to_string()],
195 None => vec![],
196 },
197 ..Default::default()
198 };
199 let page = self
200 .entities(namespace)?
201 .query_entities(
202 self.ns(namespace),
203 filter,
204 PageRequest {
205 offset: offset.into(),
206 limit,
207 },
208 )
209 .await?;
210 Ok(page.items)
211 }
212
213 pub async fn list_events(
215 &self,
216 namespace: Option<&str>,
217 filter: EventFilter,
218 limit: u32,
219 offset: u32,
220 ) -> RuntimeResult<Page<Event>> {
221 let limit = limit.clamp(1, 1000);
222 let page = self
223 .events(namespace)?
224 .query_events(
225 filter,
226 PageRequest {
227 offset: offset.into(),
228 limit,
229 },
230 )
231 .await?;
232 Ok(page)
233 }
234
235 async fn validate_edge_relation_endpoints(
249 &self,
250 namespace: Option<&str>,
251 source_id: Uuid,
252 target_id: Uuid,
253 relation: EdgeRelation,
254 ) -> RuntimeResult<()> {
255 if relation == EdgeRelation::Annotates {
256 match self.resolve(namespace, source_id).await? {
258 Some(Resolved::Note(_)) => {}
259 Some(_) => {
260 return Err(RuntimeError::InvalidInput(format!(
261 "annotates source {source_id} must be a note"
262 )));
263 }
264 None => {
265 if self.get_edge(namespace, source_id).await?.is_some() {
267 return Err(RuntimeError::InvalidInput(format!(
268 "annotates source {source_id} must be a note"
269 )));
270 }
271 return Err(RuntimeError::NotFound(format!(
272 "link source {source_id} not found in namespace"
273 )));
274 }
275 }
276 if !self.substrate_exists_in_ns(namespace, target_id).await? {
278 return Err(RuntimeError::NotFound(format!(
279 "link target {target_id} not found in namespace"
280 )));
281 }
282 } else if relation == EdgeRelation::Supersedes {
283 let src = match self.resolve(namespace, source_id).await? {
286 Some(r) => r,
287 None => {
288 if self.get_edge(namespace, source_id).await?.is_some() {
289 return Err(RuntimeError::InvalidInput(format!(
290 "supersedes source {source_id} must be a note or entity (got edge)"
291 )));
292 }
293 return Err(RuntimeError::NotFound(format!(
294 "link source {source_id} not found in namespace"
295 )));
296 }
297 };
298 let tgt = match self.resolve(namespace, target_id).await? {
299 Some(r) => r,
300 None => {
301 if self.get_edge(namespace, target_id).await?.is_some() {
302 return Err(RuntimeError::InvalidInput(format!(
303 "supersedes target {target_id} must be a note or entity (got edge)"
304 )));
305 }
306 return Err(RuntimeError::NotFound(format!(
307 "link target {target_id} not found in namespace"
308 )));
309 }
310 };
311 match (&src, &tgt) {
312 (Resolved::Entity(_), Resolved::Entity(_)) => {}
313 (Resolved::Note(_), Resolved::Note(_)) => {}
314 (Resolved::Event(_), _) => {
315 return Err(RuntimeError::InvalidInput(format!(
316 "supersedes does not apply to events; source {source_id} is an event"
317 )));
318 }
319 (_, Resolved::Event(_)) => {
320 return Err(RuntimeError::InvalidInput(format!(
321 "supersedes does not apply to events; target {target_id} is an event"
322 )));
323 }
324 (Resolved::Entity(_), Resolved::Note(_)) => {
325 return Err(RuntimeError::InvalidInput(format!(
326 "supersedes endpoints must be the same substrate (note→note or entity→entity); \
327 got source={source_id} (entity) target={target_id} (note)"
328 )));
329 }
330 (Resolved::Note(_), Resolved::Entity(_)) => {
331 return Err(RuntimeError::InvalidInput(format!(
332 "supersedes endpoints must be the same substrate (note→note or entity→entity); \
333 got source={source_id} (note) target={target_id} (entity)"
334 )));
335 }
336 }
337 } else {
338 let src_res = self.resolve(namespace, source_id).await?;
345 let tgt_res = self.resolve(namespace, target_id).await?;
346
347 if pack_rule_allows(
348 &self.pack_edge_rules(),
349 relation,
350 src_res.as_ref(),
351 tgt_res.as_ref(),
352 ) {
353 return Ok(());
354 }
355
356 match src_res {
358 Some(Resolved::Entity(_)) => {}
359 Some(_) => {
360 return Err(RuntimeError::InvalidInput(format!(
361 "link source {source_id} must be an entity for relation {relation:?} \
362 (ADR-002: only `annotates` crosses substrates)"
363 )));
364 }
365 None => {
366 if self.get_edge(namespace, source_id).await?.is_some() {
367 return Err(RuntimeError::InvalidInput(format!(
368 "link source {source_id} must be an entity for relation {relation:?} \
369 (ADR-002: only `annotates` crosses substrates)"
370 )));
371 }
372 return Err(RuntimeError::NotFound(format!(
373 "link source {source_id} not found in namespace"
374 )));
375 }
376 }
377 match tgt_res {
378 Some(Resolved::Entity(_)) => {}
379 Some(_) => {
380 return Err(RuntimeError::InvalidInput(format!(
381 "link target {target_id} must be an entity for relation {relation:?} \
382 (ADR-002: only `annotates` crosses substrates)"
383 )));
384 }
385 None => {
386 if self.get_edge(namespace, target_id).await?.is_some() {
387 return Err(RuntimeError::InvalidInput(format!(
388 "link target {target_id} must be an entity for relation {relation:?} \
389 (ADR-002: only `annotates` crosses substrates)"
390 )));
391 }
392 return Err(RuntimeError::NotFound(format!(
393 "link target {target_id} not found in namespace"
394 )));
395 }
396 }
397 }
398 Ok(())
399 }
400
401 pub async fn link(
409 &self,
410 namespace: Option<&str>,
411 source_id: Uuid,
412 target_id: Uuid,
413 relation: EdgeRelation,
414 weight: f64,
415 ) -> RuntimeResult<Edge> {
416 self.validate_edge_relation_endpoints(namespace, source_id, target_id, relation)
417 .await?;
418 let edge = Edge {
419 id: LinkId::from(Uuid::new_v4()),
420 source_id,
421 target_id,
422 relation,
423 weight,
424 created_at: chrono::Utc::now(),
425 metadata: None,
426 };
427 self.graph(namespace)?.upsert_edge(edge.clone()).await?;
428 Ok(edge)
429 }
430
431 async fn substrate_exists_in_ns(
436 &self,
437 namespace: Option<&str>,
438 id: Uuid,
439 ) -> RuntimeResult<bool> {
440 if self.resolve(namespace, id).await?.is_some() {
441 return Ok(true);
442 }
443 Ok(self.get_edge(namespace, id).await?.is_some())
444 }
445
446 pub async fn neighbors(
451 &self,
452 namespace: Option<&str>,
453 node_id: Uuid,
454 direction: Direction,
455 limit: Option<u32>,
456 relations: Option<Vec<EdgeRelation>>,
457 ) -> RuntimeResult<Vec<NeighborHit>> {
458 self.neighbors_with_query(
459 namespace,
460 node_id,
461 NeighborQuery {
462 direction,
463 relations,
464 limit,
465 min_weight: None,
466 },
467 )
468 .await
469 }
470
471 pub async fn neighbors_with_query(
473 &self,
474 namespace: Option<&str>,
475 node_id: Uuid,
476 query: NeighborQuery,
477 ) -> RuntimeResult<Vec<NeighborHit>> {
478 let mut hits = self.graph(namespace)?.neighbors(node_id, query).await?;
479 self.enrich_neighbor_hits(namespace, &mut hits).await;
480 Ok(hits)
481 }
482
483 pub async fn traverse(
485 &self,
486 namespace: Option<&str>,
487 request: TraversalRequest,
488 ) -> RuntimeResult<Vec<GraphPath>> {
489 let mut paths = self.graph(namespace)?.traverse(request).await?;
490 self.enrich_path_nodes(namespace, &mut paths).await;
491 Ok(paths)
492 }
493
494 async fn enrich_neighbor_hits(&self, namespace: Option<&str>, hits: &mut [NeighborHit]) {
502 if hits.is_empty() {
503 return;
504 }
505 let store = match self.entities(namespace) {
506 Ok(s) => s,
507 Err(_) => return, };
509 for hit in hits.iter_mut() {
510 if let Ok(Some(entity)) = store.get_entity(hit.node_id).await {
511 hit.name = Some(entity.name);
512 hit.kind = Some(entity.kind);
513 }
514 }
515 }
516
517 async fn enrich_path_nodes(&self, namespace: Option<&str>, paths: &mut [GraphPath]) {
520 if paths.is_empty() {
521 return;
522 }
523 let store = match self.entities(namespace) {
524 Ok(s) => s,
525 Err(_) => return,
526 };
527 for path in paths.iter_mut() {
528 for node in path.nodes.iter_mut() {
529 if let Ok(Some(entity)) = store.get_entity(node.node_id).await {
530 node.name = Some(entity.name);
531 node.kind = Some(entity.kind);
532 }
533 }
534 }
535 }
536
537 #[allow(clippy::too_many_arguments)]
548 pub async fn create_note(
549 &self,
550 namespace: Option<&str>,
551 kind: &str,
552 name: Option<&str>,
553 content: &str,
554 salience: f64,
555 properties: Option<serde_json::Value>,
556 annotates: Vec<Uuid>,
557 ) -> RuntimeResult<Note> {
558 self.create_note_inner(
559 namespace, kind, name, content, salience, None, properties, annotates,
560 )
561 .await
562 }
563
564 #[allow(clippy::too_many_arguments)]
566 pub async fn create_note_with_decay(
567 &self,
568 namespace: Option<&str>,
569 kind: &str,
570 name: Option<&str>,
571 content: &str,
572 salience: f64,
573 decay_factor: f64,
574 properties: Option<serde_json::Value>,
575 annotates: Vec<Uuid>,
576 ) -> RuntimeResult<Note> {
577 self.create_note_inner(
578 namespace,
579 kind,
580 name,
581 content,
582 salience,
583 Some(decay_factor),
584 properties,
585 annotates,
586 )
587 .await
588 }
589
590 #[allow(clippy::too_many_arguments)]
591 async fn create_note_inner(
592 &self,
593 namespace: Option<&str>,
594 kind: &str,
595 name: Option<&str>,
596 content: &str,
597 salience: f64,
598 decay_factor: Option<f64>,
599 properties: Option<serde_json::Value>,
600 annotates: Vec<Uuid>,
601 ) -> RuntimeResult<Note> {
602 let ns = self.ns(namespace);
603
604 for &target_id in &annotates {
606 if !self.substrate_exists_in_ns(namespace, target_id).await? {
607 return Err(RuntimeError::NotFound(format!(
608 "create_note annotates target {target_id} not found in namespace"
609 )));
610 }
611 }
612
613 let mut note = Note::new(ns, kind, content).with_salience(salience);
614 if let Some(df) = decay_factor {
615 note = note.with_decay(df);
616 }
617 if let Some(n) = name {
618 note = note.with_name(n);
619 }
620 if let Some(p) = properties {
621 note = note.with_properties(p);
622 }
623 self.notes(Some(ns))?.upsert_note(note.clone()).await?;
624
625 let body = match ¬e.name {
626 Some(n) => format!("{n} {}", note.content),
627 None => note.content.clone(),
628 };
629
630 self.text_for_notes(Some(ns))?
631 .upsert_document(TextDocument {
632 subject_id: note.id,
633 kind: SubstrateKind::Note,
634 title: note.name.clone(),
635 body,
636 tags: vec![],
637 namespace: ns.to_string(),
638 metadata: note.properties.clone(),
639 updated_at: chrono::Utc::now(),
640 })
641 .await?;
642
643 if self.config().embedding_model.is_some() {
644 let vector = self.embed(¬e.content).await?;
645 self.vectors(Some(ns))?
646 .insert(note.id, SubstrateKind::Note, ns, vector)
647 .await?;
648 }
649
650 let mut created_edges: Vec<Uuid> = Vec::with_capacity(annotates.len());
656
657 #[cfg(test)]
660 let annotates_iter: Vec<(usize, Uuid)> = annotates
661 .iter()
662 .enumerate()
663 .map(|(i, &id)| (i, id))
664 .collect();
665 #[cfg(test)]
666 macro_rules! next_target {
667 ($pair:expr) => {
668 $pair.1
669 };
670 }
671 #[cfg(not(test))]
672 let annotates_iter: Vec<Uuid> = annotates.to_vec();
673 #[cfg(not(test))]
674 macro_rules! next_target {
675 ($pair:expr) => {
676 $pair
677 };
678 }
679
680 for pair in annotates_iter {
681 let target_id = next_target!(pair);
682
683 #[cfg(test)]
685 let injected_err: Option<RuntimeError> = {
686 let call_idx = pair.0;
687 LINK_FAIL_AFTER.with(|cell| {
688 let n = cell.get();
689 if n > 0 && call_idx + 1 == n {
690 cell.set(0); Some(RuntimeError::Internal("injected link failure".to_string()))
692 } else {
693 None
694 }
695 })
696 };
697 #[cfg(not(test))]
698 let injected_err: Option<RuntimeError> = None;
699
700 let link_result = if let Some(e) = injected_err {
701 Err(e)
702 } else {
703 self.link(Some(ns), note.id, target_id, EdgeRelation::Annotates, 1.0)
704 .await
705 };
706
707 match link_result {
708 Ok(edge) => created_edges.push(edge.id.into()),
709 Err(e) => {
710 for edge_id in created_edges {
712 let _ = self.delete_edge(Some(ns), edge_id).await;
713 }
714 if let Ok(store) = self.notes(Some(ns)) {
715 let _ = store.delete_note(note.id, DeleteMode::Hard).await;
716 }
717 if let Ok(fts) = self.text_for_notes(Some(ns)) {
718 let _ = fts.delete_document(ns, note.id).await;
719 }
720 if self.config().embedding_model.is_some() {
721 if let Ok(vs) = self.vectors(Some(ns)) {
722 let _ = vs.delete(note.id).await;
723 }
724 }
725 return Err(e);
726 }
727 }
728 }
729
730 Ok(note)
731 }
732
733 pub async fn list_notes(
735 &self,
736 namespace: Option<&str>,
737 kind: Option<&str>,
738 limit: u32,
739 offset: u32,
740 ) -> RuntimeResult<Vec<Note>> {
741 let page = self
742 .notes(namespace)?
743 .query_notes(
744 self.ns(namespace),
745 kind,
746 PageRequest {
747 offset: offset.into(),
748 limit,
749 },
750 )
751 .await?;
752 Ok(page.items)
753 }
754
755 pub async fn search_notes(
765 &self,
766 namespace: Option<&str>,
767 query_text: &str,
768 query_vector: Option<Vec<f32>>,
769 limit: u32,
770 note_kind: Option<&str>,
771 ) -> RuntimeResult<Vec<NoteSearchHit>> {
772 const RRF_K: usize = 60;
773 let candidates = limit.saturating_mul(4).max(limit);
774 let ns = self.ns(namespace).to_string();
775
776 let text_hits = self
778 .text_for_notes(namespace)?
779 .search(TextSearchRequest {
780 query: query_text.to_string(),
781 mode: TextQueryMode::Plain,
782 filter: Some(TextFilter {
783 namespaces: vec![ns.clone()],
784 ..TextFilter::default()
785 }),
786 top_k: candidates,
787 snippet_chars: 200,
788 })
789 .await?;
790
791 let vector_hits = if query_vector.is_some() || self.config().embedding_model.is_some() {
793 self.vector_search(
794 namespace,
795 query_vector,
796 Some(query_text),
797 candidates,
798 Some(SubstrateKind::Note),
799 )
800 .await?
801 } else {
802 vec![]
803 };
804
805 #[derive(Default)]
807 struct Bucket {
808 score: DeterministicScore,
809 title: Option<String>,
810 snippet: Option<String>,
811 }
812
813 let mut buckets: HashMap<Uuid, Bucket> = HashMap::new();
814 for (i, hit) in text_hits.into_iter().enumerate() {
815 let rank = i + 1;
816 let entry = buckets.entry(hit.subject_id).or_default();
817 entry.score = entry.score + rrf_score(rank, RRF_K);
818 if entry.title.is_none() {
819 entry.title = hit.title;
820 }
821 if entry.snippet.is_none() {
822 entry.snippet = hit.snippet;
823 }
824 }
825 for (i, hit) in vector_hits.into_iter().enumerate() {
826 let rank = i + 1;
827 let entry = buckets.entry(hit.subject_id).or_default();
828 entry.score = entry.score + rrf_score(rank, RRF_K);
829 }
830
831 let candidate_ids: Vec<Uuid> = buckets.keys().copied().collect();
832 if candidate_ids.is_empty() {
833 return Ok(vec![]);
834 }
835
836 let note_store = self.notes(namespace)?;
841 let mut alive_notes: HashMap<Uuid, Note> = HashMap::new();
842 for id in &candidate_ids {
843 if let Some(note) = note_store.get_note(*id).await? {
844 if note.deleted_at.is_some() {
845 continue;
846 }
847 if let Some(want_kind) = note_kind {
848 if note.kind != want_kind {
849 continue;
850 }
851 }
852 alive_notes.insert(*id, note);
853 }
854 }
855
856 if !alive_notes.is_empty() {
859 let graph = self.graph(namespace)?;
860 let mut superseded: std::collections::HashSet<Uuid> = std::collections::HashSet::new();
861 for ¬e_id in alive_notes.keys() {
862 let inbound = graph
863 .neighbors(
864 note_id,
865 NeighborQuery {
866 direction: Direction::In,
867 relations: Some(vec![EdgeRelation::Supersedes]),
868 limit: Some(1),
869 min_weight: None,
870 },
871 )
872 .await?;
873 if !inbound.is_empty() {
874 superseded.insert(note_id);
875 }
876 }
877 alive_notes.retain(|id, _| !superseded.contains(id));
878 }
879
880 let mut hits: Vec<NoteSearchHit> = buckets
882 .into_iter()
883 .filter_map(|(id, bucket)| {
884 let note = alive_notes.get(&id)?;
885 let weight = 0.5 + 0.5 * note.salience;
886 let weighted = DeterministicScore::from_f64(bucket.score.to_f64() * weight);
887 Some(NoteSearchHit {
888 note_id: id,
889 score: weighted,
890 title: bucket.title.or_else(|| note_title(note)),
891 snippet: bucket.snippet.or_else(|| note_snippet(note)),
892 })
893 })
894 .collect();
895
896 hits.sort_by(|a, b| b.score.cmp(&a.score).then(a.note_id.cmp(&b.note_id)));
897 hits.truncate(limit as usize);
898 Ok(hits)
899 }
900
901 pub async fn resolve_prefix(
908 &self,
909 namespace: Option<&str>,
910 prefix: &str,
911 ) -> RuntimeResult<Option<Uuid>> {
912 use khive_storage::types::{SqlStatement, SqlValue};
913
914 let ns = self.ns(namespace).to_string();
915 let pattern = format!("{}%", prefix);
916
917 let tables = [
918 ("entities", true),
919 ("notes", true),
920 ("events", false),
921 ("graph_edges", false),
922 ];
923
924 let mut matches: Vec<String> = Vec::new();
925 let mut reader = self.sql().reader().await.map_err(RuntimeError::Storage)?;
926
927 for (table, has_deleted_at) in tables {
928 let deleted_filter = if has_deleted_at {
929 " AND deleted_at IS NULL"
930 } else {
931 ""
932 };
933 let sql = SqlStatement {
934 sql: format!(
935 "SELECT id FROM {table} WHERE id LIKE ?1 AND namespace = ?2{deleted_filter} LIMIT 2"
936 ),
937 params: vec![
938 SqlValue::Text(pattern.clone()),
939 SqlValue::Text(ns.clone()),
940 ],
941 label: Some("resolve_prefix".into()),
942 };
943 match reader.query_all(sql).await {
944 Ok(rows) => {
945 for row in rows {
946 if let Some(col) = row.columns.first() {
947 if let SqlValue::Text(s) = &col.value {
948 matches.push(s.clone());
949 }
950 }
951 }
952 }
953 Err(e) => {
954 let msg = e.to_string();
955 if msg.contains("no such table") {
956 continue;
957 }
958 return Err(RuntimeError::Storage(e));
959 }
960 }
961 if matches.len() > 1 {
962 break;
963 }
964 }
965
966 match matches.len() {
967 0 => Ok(None),
968 1 => {
969 let uuid = Uuid::from_str(&matches[0])
970 .map_err(|e| RuntimeError::Internal(format!("stored UUID is invalid: {e}")))?;
971 Ok(Some(uuid))
972 }
973 _ => Err(RuntimeError::Ambiguous(format!(
974 "prefix '{prefix}' matches multiple UUIDs"
975 ))),
976 }
977 }
978
979 pub async fn resolve(
984 &self,
985 namespace: Option<&str>,
986 id: Uuid,
987 ) -> RuntimeResult<Option<Resolved>> {
988 let ns = self.ns(namespace);
989
990 if let Some(entity) = self.get_entity(namespace, id).await? {
992 return Ok(Some(Resolved::Entity(entity)));
993 }
994
995 if let Some(note) = self.notes(namespace)?.get_note(id).await? {
997 if note.namespace == ns {
998 return Ok(Some(Resolved::Note(note)));
999 }
1000 }
1001
1002 if let Some(event) = self.events(namespace)?.get_event(id).await? {
1004 if event.namespace == ns {
1005 return Ok(Some(Resolved::Event(event)));
1006 }
1007 }
1008
1009 Ok(None)
1010 }
1011
1012 pub async fn delete_note(
1022 &self,
1023 namespace: Option<&str>,
1024 id: Uuid,
1025 hard: bool,
1026 ) -> RuntimeResult<bool> {
1027 let ns = self.ns(namespace);
1028 let note_store = self.notes(namespace)?;
1029 let note = match note_store.get_note(id).await? {
1030 Some(n) => n,
1031 None => return Ok(false),
1032 };
1033 if note.namespace != ns {
1034 return Ok(false);
1035 }
1036 let mode = if hard {
1037 DeleteMode::Hard
1038 } else {
1039 DeleteMode::Soft
1040 };
1041
1042 if hard {
1044 let graph = self.graph(namespace)?;
1045 for direction in [Direction::Out, Direction::In] {
1046 let hits = graph
1047 .neighbors(
1048 id,
1049 NeighborQuery {
1050 direction,
1051 relations: None,
1052 limit: None,
1053 min_weight: None,
1054 },
1055 )
1056 .await?;
1057 for hit in hits {
1058 graph.delete_edge(LinkId::from(hit.edge_id)).await?;
1059 }
1060 }
1061 let ns_str = ns.to_string();
1062 self.text_for_notes(namespace)?
1063 .delete_document(&ns_str, id)
1064 .await?;
1065 if self.config().embedding_model.is_some() {
1066 self.vectors(namespace)?.delete(id).await?;
1067 }
1068 }
1069
1070 let deleted = note_store.delete_note(id, mode).await?;
1071 if !hard && deleted {
1072 let ns_str = ns.to_string();
1073 self.text_for_notes(namespace)?
1074 .delete_document(&ns_str, id)
1075 .await?;
1076 if self.config().embedding_model.is_some() {
1077 self.vectors(namespace)?.delete(id).await?;
1078 }
1079 }
1080 Ok(deleted)
1081 }
1082}
1083
1084#[derive(Clone, Debug, Serialize)]
1086pub struct QueryResult {
1087 pub rows: Vec<SqlRow>,
1088 #[serde(skip_serializing_if = "Vec::is_empty")]
1089 pub warnings: Vec<String>,
1090}
1091
1092impl KhiveRuntime {
1093 pub async fn query(&self, namespace: Option<&str>, query: &str) -> RuntimeResult<Vec<SqlRow>> {
1101 Ok(self.query_with_metadata(namespace, query).await?.rows)
1102 }
1103
1104 pub async fn query_with_metadata(
1106 &self,
1107 namespace: Option<&str>,
1108 query: &str,
1109 ) -> RuntimeResult<QueryResult> {
1110 let ns = self.ns(namespace);
1111 let ast = khive_query::parse_auto(query)?;
1112 let opts = khive_query::CompileOptions {
1113 scopes: vec![ns.to_string()],
1114 ..Default::default()
1115 };
1116 let compiled = khive_query::compile(&ast, &opts)?;
1117 let warnings = compiled.warnings;
1118 let mut reader = self.sql().reader().await?;
1119 let stmt = SqlStatement {
1120 sql: compiled.sql,
1121 params: compiled.params,
1122 label: None,
1123 };
1124 let rows = reader.query_all(stmt).await?;
1125 Ok(QueryResult { rows, warnings })
1126 }
1127
1128 pub async fn delete_entity(
1137 &self,
1138 namespace: Option<&str>,
1139 id: Uuid,
1140 hard: bool,
1141 ) -> RuntimeResult<bool> {
1142 let entity = match self.entities(namespace)?.get_entity(id).await? {
1143 Some(e) => e,
1144 None => return Ok(false),
1145 };
1146 if entity.namespace != self.ns(namespace) {
1147 return Ok(false);
1148 }
1149 let mode = if hard {
1150 DeleteMode::Hard
1151 } else {
1152 DeleteMode::Soft
1153 };
1154
1155 if hard {
1157 let graph = self.graph(namespace)?;
1158 for direction in [Direction::Out, Direction::In] {
1159 let hits = graph
1160 .neighbors(
1161 id,
1162 NeighborQuery {
1163 direction,
1164 relations: None,
1165 limit: None,
1166 min_weight: None,
1167 },
1168 )
1169 .await?;
1170 for hit in hits {
1171 graph.delete_edge(LinkId::from(hit.edge_id)).await?;
1172 }
1173 }
1174 self.remove_from_indexes(namespace, id).await?;
1175 }
1176
1177 let deleted = self.entities(namespace)?.delete_entity(id, mode).await?;
1178 if !hard && deleted {
1179 self.remove_from_indexes(namespace, id).await?;
1180 }
1181 Ok(deleted)
1182 }
1183
1184 pub async fn count_entities(
1186 &self,
1187 namespace: Option<&str>,
1188 kind: Option<&str>,
1189 ) -> RuntimeResult<u64> {
1190 let filter = EntityFilter {
1191 kinds: match kind {
1192 Some(k) => vec![k.to_string()],
1193 None => vec![],
1194 },
1195 ..Default::default()
1196 };
1197 Ok(self
1198 .entities(namespace)?
1199 .count_entities(self.ns(namespace), filter)
1200 .await?)
1201 }
1202
1203 pub async fn get_edge(
1207 &self,
1208 namespace: Option<&str>,
1209 edge_id: Uuid,
1210 ) -> RuntimeResult<Option<Edge>> {
1211 Ok(self
1212 .graph(namespace)?
1213 .get_edge(LinkId::from(edge_id))
1214 .await?)
1215 }
1216
1217 pub async fn list_edges(
1219 &self,
1220 namespace: Option<&str>,
1221 filter: crate::curation::EdgeListFilter,
1222 limit: u32,
1223 ) -> RuntimeResult<Vec<Edge>> {
1224 let limit = limit.clamp(1, 1000);
1225 let page = self
1226 .graph(namespace)?
1227 .query_edges(
1228 filter.into(),
1229 vec![SortOrder {
1230 field: EdgeSortField::CreatedAt,
1231 direction: khive_storage::types::SortDirection::Asc,
1232 }],
1233 PageRequest { offset: 0, limit },
1234 )
1235 .await?;
1236 Ok(page.items)
1237 }
1238
1239 pub async fn update_edge(
1246 &self,
1247 namespace: Option<&str>,
1248 edge_id: Uuid,
1249 relation: Option<EdgeRelation>,
1250 weight: Option<f64>,
1251 ) -> RuntimeResult<Edge> {
1252 let graph = self.graph(namespace)?;
1253 let mut edge = graph
1254 .get_edge(LinkId::from(edge_id))
1255 .await?
1256 .ok_or_else(|| crate::RuntimeError::NotFound(format!("edge {edge_id}")))?;
1257
1258 if let Some(r) = relation {
1259 self.validate_edge_relation_endpoints(namespace, edge.source_id, edge.target_id, r)
1261 .await?;
1262 edge.relation = r;
1263 }
1264 if let Some(w) = weight {
1265 edge.weight = w.clamp(0.0, 1.0);
1266 }
1267
1268 graph.upsert_edge(edge.clone()).await?;
1269 Ok(edge)
1270 }
1271
1272 pub async fn delete_edge(&self, namespace: Option<&str>, edge_id: Uuid) -> RuntimeResult<bool> {
1283 let graph = self.graph(namespace)?;
1284
1285 if graph.get_edge(LinkId::from(edge_id)).await?.is_none() {
1290 return Ok(false);
1291 }
1292
1293 let inbound = graph
1295 .neighbors(
1296 edge_id,
1297 NeighborQuery {
1298 direction: Direction::In,
1299 relations: None,
1300 limit: None,
1301 min_weight: None,
1302 },
1303 )
1304 .await?;
1305 for hit in inbound {
1306 graph.delete_edge(LinkId::from(hit.edge_id)).await?;
1307 }
1308
1309 Ok(graph.delete_edge(LinkId::from(edge_id)).await?)
1310 }
1311
1312 pub async fn count_edges(
1314 &self,
1315 namespace: Option<&str>,
1316 filter: crate::curation::EdgeListFilter,
1317 ) -> RuntimeResult<u64> {
1318 Ok(self.graph(namespace)?.count_edges(filter.into()).await?)
1319 }
1320}
1321
1322#[cfg(test)]
1323mod tests {
1324 use super::*;
1325 use crate::curation::EdgeListFilter;
1326 use crate::runtime::KhiveRuntime;
1327
1328 fn rt() -> KhiveRuntime {
1329 KhiveRuntime::memory().unwrap()
1330 }
1331
1332 #[tokio::test]
1333 async fn update_edge_changes_weight() {
1334 let rt = rt();
1335 let a = rt
1336 .create_entity(None, "concept", "A", None, None, vec![])
1337 .await
1338 .unwrap();
1339 let b = rt
1340 .create_entity(None, "concept", "B", None, None, vec![])
1341 .await
1342 .unwrap();
1343 let edge = rt
1344 .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
1345 .await
1346 .unwrap();
1347 let edge_id: Uuid = edge.id.into();
1348
1349 let updated = rt
1350 .update_edge(None, edge_id, None, Some(0.5))
1351 .await
1352 .unwrap();
1353 assert!((updated.weight - 0.5).abs() < 0.001);
1354 }
1355
1356 #[tokio::test]
1357 async fn update_edge_changes_relation() {
1358 let rt = rt();
1359 let a = rt
1360 .create_entity(None, "concept", "A", None, None, vec![])
1361 .await
1362 .unwrap();
1363 let b = rt
1364 .create_entity(None, "concept", "B", None, None, vec![])
1365 .await
1366 .unwrap();
1367 let edge = rt
1368 .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
1369 .await
1370 .unwrap();
1371 let edge_id: Uuid = edge.id.into();
1372
1373 let updated = rt
1374 .update_edge(None, edge_id, Some(EdgeRelation::VariantOf), None)
1375 .await
1376 .unwrap();
1377 assert_eq!(updated.relation, EdgeRelation::VariantOf);
1378 }
1379
1380 #[tokio::test]
1385 async fn update_edge_annotates_note_to_entity_set_supersedes_returns_invalid_input() {
1386 let rt = rt();
1387 let note = rt
1388 .create_note(None, "observation", None, "a note", 0.5, None, vec![])
1389 .await
1390 .unwrap();
1391 let entity = rt
1392 .create_entity(None, "concept", "E", None, None, vec![])
1393 .await
1394 .unwrap();
1395 let edge = rt
1397 .link(None, note.id, entity.id, EdgeRelation::Annotates, 1.0)
1398 .await
1399 .unwrap();
1400 let edge_id: Uuid = edge.id.into();
1401
1402 let result = rt
1404 .update_edge(None, edge_id, Some(EdgeRelation::Supersedes), None)
1405 .await;
1406 assert!(
1407 matches!(result, Err(RuntimeError::InvalidInput(_))),
1408 "update to Supersedes on note→entity edge must return InvalidInput, got {result:?}"
1409 );
1410
1411 let fetched = rt.get_edge(None, edge_id).await.unwrap().unwrap();
1413 assert_eq!(
1414 fetched.relation,
1415 EdgeRelation::Annotates,
1416 "edge relation must be unchanged after failed update"
1417 );
1418 }
1419
1420 #[tokio::test]
1423 async fn update_edge_entity_to_entity_set_annotates_returns_invalid_input() {
1424 let rt = rt();
1425 let a = rt
1426 .create_entity(None, "concept", "A", None, None, vec![])
1427 .await
1428 .unwrap();
1429 let b = rt
1430 .create_entity(None, "concept", "B", None, None, vec![])
1431 .await
1432 .unwrap();
1433 let edge = rt
1434 .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
1435 .await
1436 .unwrap();
1437 let edge_id: Uuid = edge.id.into();
1438
1439 let result = rt
1440 .update_edge(None, edge_id, Some(EdgeRelation::Annotates), None)
1441 .await;
1442 assert!(
1443 matches!(result, Err(RuntimeError::InvalidInput(_))),
1444 "update to Annotates on entity→entity edge must return InvalidInput, got {result:?}"
1445 );
1446 }
1447
1448 #[tokio::test]
1451 async fn update_edge_entity_to_entity_set_supersedes_succeeds() {
1452 let rt = rt();
1453 let a = rt
1454 .create_entity(None, "concept", "A", None, None, vec![])
1455 .await
1456 .unwrap();
1457 let b = rt
1458 .create_entity(None, "concept", "B", None, None, vec![])
1459 .await
1460 .unwrap();
1461 let edge = rt
1462 .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
1463 .await
1464 .unwrap();
1465 let edge_id: Uuid = edge.id.into();
1466
1467 let updated = rt
1468 .update_edge(None, edge_id, Some(EdgeRelation::Supersedes), None)
1469 .await
1470 .unwrap();
1471 assert_eq!(updated.relation, EdgeRelation::Supersedes);
1472
1473 let fetched = rt.get_edge(None, edge_id).await.unwrap().unwrap();
1475 assert_eq!(fetched.relation, EdgeRelation::Supersedes);
1476 }
1477
1478 #[tokio::test]
1480 async fn update_edge_weight_only_skips_validation() {
1481 let rt = rt();
1482 let a = rt
1483 .create_entity(None, "concept", "A", None, None, vec![])
1484 .await
1485 .unwrap();
1486 let b = rt
1487 .create_entity(None, "concept", "B", None, None, vec![])
1488 .await
1489 .unwrap();
1490 let edge = rt
1491 .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
1492 .await
1493 .unwrap();
1494 let edge_id: Uuid = edge.id.into();
1495
1496 let updated = rt
1497 .update_edge(None, edge_id, None, Some(0.3))
1498 .await
1499 .unwrap();
1500 assert_eq!(updated.relation, EdgeRelation::Extends);
1501 assert!((updated.weight - 0.3).abs() < 0.001);
1502 }
1503
1504 #[tokio::test]
1506 async fn update_edge_same_class_relation_change_succeeds() {
1507 let rt = rt();
1508 let a = rt
1509 .create_entity(None, "concept", "A", None, None, vec![])
1510 .await
1511 .unwrap();
1512 let b = rt
1513 .create_entity(None, "concept", "B", None, None, vec![])
1514 .await
1515 .unwrap();
1516 let edge = rt
1517 .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
1518 .await
1519 .unwrap();
1520 let edge_id: Uuid = edge.id.into();
1521
1522 let updated = rt
1523 .update_edge(None, edge_id, Some(EdgeRelation::VariantOf), None)
1524 .await
1525 .unwrap();
1526 assert_eq!(updated.relation, EdgeRelation::VariantOf);
1527 }
1528
1529 #[tokio::test]
1530 async fn list_edges_filters_by_relation() {
1531 let rt = rt();
1532 let a = rt
1533 .create_entity(None, "concept", "A", None, None, vec![])
1534 .await
1535 .unwrap();
1536 let b = rt
1537 .create_entity(None, "concept", "B", None, None, vec![])
1538 .await
1539 .unwrap();
1540 let c = rt
1541 .create_entity(None, "concept", "C", None, None, vec![])
1542 .await
1543 .unwrap();
1544
1545 rt.link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
1546 .await
1547 .unwrap();
1548 rt.link(None, a.id, c.id, EdgeRelation::DependsOn, 1.0)
1549 .await
1550 .unwrap();
1551
1552 let filter = EdgeListFilter {
1553 relations: vec![EdgeRelation::Extends],
1554 ..Default::default()
1555 };
1556 let edges = rt.list_edges(None, filter, 100).await.unwrap();
1557 assert_eq!(edges.len(), 1);
1558 assert_eq!(edges[0].relation, EdgeRelation::Extends);
1559 }
1560
1561 #[tokio::test]
1562 async fn list_edges_filters_by_source() {
1563 let rt = rt();
1564 let a = rt
1565 .create_entity(None, "concept", "A", None, None, vec![])
1566 .await
1567 .unwrap();
1568 let b = rt
1569 .create_entity(None, "concept", "B", None, None, vec![])
1570 .await
1571 .unwrap();
1572 let c = rt
1573 .create_entity(None, "concept", "C", None, None, vec![])
1574 .await
1575 .unwrap();
1576 let d = rt
1577 .create_entity(None, "concept", "D", None, None, vec![])
1578 .await
1579 .unwrap();
1580
1581 rt.link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
1582 .await
1583 .unwrap();
1584 rt.link(None, c.id, d.id, EdgeRelation::Extends, 1.0)
1585 .await
1586 .unwrap();
1587
1588 let filter = EdgeListFilter {
1589 source_id: Some(a.id),
1590 ..Default::default()
1591 };
1592 let edges = rt.list_edges(None, filter, 100).await.unwrap();
1593 assert_eq!(edges.len(), 1);
1594 let src: Uuid = edges[0].source_id;
1595 assert_eq!(src, a.id);
1596 }
1597
1598 #[tokio::test]
1599 async fn delete_edge_removes_from_storage() {
1600 let rt = rt();
1601 let a = rt
1602 .create_entity(None, "concept", "A", None, None, vec![])
1603 .await
1604 .unwrap();
1605 let b = rt
1606 .create_entity(None, "concept", "B", None, None, vec![])
1607 .await
1608 .unwrap();
1609 let edge = rt
1610 .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
1611 .await
1612 .unwrap();
1613 let edge_id: Uuid = edge.id.into();
1614
1615 let deleted = rt.delete_edge(None, edge_id).await.unwrap();
1616 assert!(deleted);
1617
1618 let fetched = rt.get_edge(None, edge_id).await.unwrap();
1619 assert!(fetched.is_none(), "edge should be gone after delete");
1620 }
1621
1622 #[tokio::test]
1623 async fn count_edges_matches_filter() {
1624 let rt = rt();
1625 let a = rt
1626 .create_entity(None, "concept", "A", None, None, vec![])
1627 .await
1628 .unwrap();
1629 let b = rt
1630 .create_entity(None, "concept", "B", None, None, vec![])
1631 .await
1632 .unwrap();
1633 let c = rt
1634 .create_entity(None, "concept", "C", None, None, vec![])
1635 .await
1636 .unwrap();
1637
1638 rt.link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
1639 .await
1640 .unwrap();
1641 rt.link(None, a.id, c.id, EdgeRelation::DependsOn, 1.0)
1642 .await
1643 .unwrap();
1644
1645 let all = rt
1646 .count_edges(None, EdgeListFilter::default())
1647 .await
1648 .unwrap();
1649 assert_eq!(all, 2);
1650
1651 let just_extends = rt
1652 .count_edges(
1653 None,
1654 EdgeListFilter {
1655 relations: vec![EdgeRelation::Extends],
1656 ..Default::default()
1657 },
1658 )
1659 .await
1660 .unwrap();
1661 assert_eq!(just_extends, 1);
1662 }
1663
1664 #[tokio::test]
1665 async fn get_entity_namespace_isolation() {
1666 let rt = rt();
1667 let entity = rt
1668 .create_entity(Some("ns-a"), "concept", "Alpha", None, None, vec![])
1669 .await
1670 .unwrap();
1671
1672 let found = rt.get_entity(Some("ns-a"), entity.id).await.unwrap();
1674 assert!(found.is_some(), "should be visible in its own namespace");
1675
1676 let not_found = rt.get_entity(Some("ns-b"), entity.id).await.unwrap();
1678 assert!(
1679 not_found.is_none(),
1680 "should not be visible across namespaces"
1681 );
1682 }
1683
1684 #[tokio::test]
1685 async fn delete_entity_namespace_isolation() {
1686 let rt = rt();
1687 let entity = rt
1688 .create_entity(Some("ns-a"), "concept", "Beta", None, None, vec![])
1689 .await
1690 .unwrap();
1691
1692 let deleted = rt
1694 .delete_entity(Some("ns-b"), entity.id, true)
1695 .await
1696 .unwrap();
1697 assert!(!deleted, "cross-namespace delete must return false");
1698
1699 let still_there = rt.get_entity(Some("ns-a"), entity.id).await.unwrap();
1701 assert!(
1702 still_there.is_some(),
1703 "entity must survive cross-ns delete attempt"
1704 );
1705
1706 let deleted_ok = rt
1708 .delete_entity(Some("ns-a"), entity.id, true)
1709 .await
1710 .unwrap();
1711 assert!(deleted_ok, "same-namespace delete must succeed");
1712 }
1713
1714 #[tokio::test]
1717 async fn create_note_indexes_into_fts5() {
1718 let rt = rt();
1719 let note = rt
1720 .create_note(
1721 None,
1722 "observation",
1723 None,
1724 "FlashAttention reduces memory by using tiling",
1725 0.8,
1726 None,
1727 vec![],
1728 )
1729 .await
1730 .unwrap();
1731
1732 let ns = rt.ns(None).to_string();
1734 let hits = rt
1735 .text_for_notes(None)
1736 .unwrap()
1737 .search(khive_storage::types::TextSearchRequest {
1738 query: "FlashAttention".to_string(),
1739 mode: khive_storage::types::TextQueryMode::Plain,
1740 filter: Some(khive_storage::types::TextFilter {
1741 namespaces: vec![ns],
1742 ..Default::default()
1743 }),
1744 top_k: 10,
1745 snippet_chars: 100,
1746 })
1747 .await
1748 .unwrap();
1749
1750 assert!(
1751 hits.iter().any(|h| h.subject_id == note.id),
1752 "note should be indexed in FTS5 after create"
1753 );
1754 }
1755
1756 #[tokio::test]
1757 async fn create_note_with_properties() {
1758 let rt = rt();
1759 let props = serde_json::json!({"source": "arxiv:2205.14135"});
1760 let note = rt
1761 .create_note(
1762 None,
1763 "insight",
1764 None,
1765 "FlashAttention is IO-aware",
1766 0.9,
1767 Some(props.clone()),
1768 vec![],
1769 )
1770 .await
1771 .unwrap();
1772
1773 assert_eq!(note.properties.as_ref().unwrap(), &props);
1774 }
1775
1776 #[tokio::test]
1777 async fn create_note_creates_annotates_edges() {
1778 let rt = rt();
1779 let entity = rt
1780 .create_entity(None, "concept", "FlashAttention", None, None, vec![])
1781 .await
1782 .unwrap();
1783
1784 let note = rt
1785 .create_note(
1786 None,
1787 "observation",
1788 None,
1789 "FlashAttention uses SRAM tiling for memory efficiency",
1790 0.9,
1791 None,
1792 vec![entity.id],
1793 )
1794 .await
1795 .unwrap();
1796
1797 let out_neighbors = rt
1799 .neighbors(
1800 None,
1801 note.id,
1802 Direction::Out,
1803 None,
1804 Some(vec![EdgeRelation::Annotates]),
1805 )
1806 .await
1807 .unwrap();
1808 assert_eq!(out_neighbors.len(), 1);
1809 assert_eq!(out_neighbors[0].node_id, entity.id);
1810 assert_eq!(out_neighbors[0].relation, EdgeRelation::Annotates);
1811
1812 let in_neighbors = rt
1814 .neighbors(
1815 None,
1816 entity.id,
1817 Direction::In,
1818 None,
1819 Some(vec![EdgeRelation::Annotates]),
1820 )
1821 .await
1822 .unwrap();
1823 assert_eq!(in_neighbors.len(), 1);
1824 assert_eq!(in_neighbors[0].node_id, note.id);
1825 }
1826
1827 #[tokio::test]
1828 async fn neighbors_without_relation_filter_returns_all() {
1829 let rt = rt();
1830 let a = rt
1831 .create_entity(None, "concept", "A", None, None, vec![])
1832 .await
1833 .unwrap();
1834 let b = rt
1835 .create_entity(None, "concept", "B", None, None, vec![])
1836 .await
1837 .unwrap();
1838 let c = rt
1839 .create_entity(None, "concept", "C", None, None, vec![])
1840 .await
1841 .unwrap();
1842
1843 rt.link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
1844 .await
1845 .unwrap();
1846 rt.link(None, a.id, c.id, EdgeRelation::DependsOn, 1.0)
1847 .await
1848 .unwrap();
1849
1850 let all = rt
1851 .neighbors(None, a.id, Direction::Out, None, None)
1852 .await
1853 .unwrap();
1854 assert_eq!(all.len(), 2);
1855 }
1856
1857 #[tokio::test]
1858 async fn neighbors_with_relation_filter_returns_subset() {
1859 let rt = rt();
1860 let a = rt
1861 .create_entity(None, "concept", "A", None, None, vec![])
1862 .await
1863 .unwrap();
1864 let b = rt
1865 .create_entity(None, "concept", "B", None, None, vec![])
1866 .await
1867 .unwrap();
1868 let c = rt
1869 .create_entity(None, "concept", "C", None, None, vec![])
1870 .await
1871 .unwrap();
1872
1873 rt.link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
1874 .await
1875 .unwrap();
1876 rt.link(None, a.id, c.id, EdgeRelation::DependsOn, 1.0)
1877 .await
1878 .unwrap();
1879
1880 let filtered = rt
1881 .neighbors(
1882 None,
1883 a.id,
1884 Direction::Out,
1885 None,
1886 Some(vec![EdgeRelation::Extends]),
1887 )
1888 .await
1889 .unwrap();
1890 assert_eq!(filtered.len(), 1);
1891 assert_eq!(filtered[0].node_id, b.id);
1892 assert_eq!(filtered[0].relation, EdgeRelation::Extends);
1893 }
1894
1895 #[tokio::test]
1896 async fn search_notes_returns_relevant_note() {
1897 let rt = rt();
1898 rt.create_note(
1899 None,
1900 "observation",
1901 None,
1902 "GQA reduces KV cache memory for large models",
1903 0.8,
1904 None,
1905 vec![],
1906 )
1907 .await
1908 .unwrap();
1909
1910 let results = rt
1911 .search_notes(None, "GQA KV cache", None, 10, None)
1912 .await
1913 .unwrap();
1914
1915 assert!(!results.is_empty(), "search should return the indexed note");
1916 let hit = &results[0];
1917 assert!(
1918 hit.title.is_some(),
1919 "note hit title should be populated (falls back to content)"
1920 );
1921 assert!(
1922 hit.snippet.is_some(),
1923 "note hit snippet should be populated"
1924 );
1925 }
1926
1927 #[tokio::test]
1928 async fn search_notes_excludes_soft_deleted() {
1929 let rt = rt();
1930 let note = rt
1931 .create_note(
1932 None,
1933 "observation",
1934 None,
1935 "RoPE positional encoding rotary embeddings",
1936 0.7,
1937 None,
1938 vec![],
1939 )
1940 .await
1941 .unwrap();
1942
1943 rt.notes(None)
1945 .unwrap()
1946 .delete_note(note.id, DeleteMode::Soft)
1947 .await
1948 .unwrap();
1949
1950 let results = rt
1951 .search_notes(None, "RoPE rotary positional", None, 10, None)
1952 .await
1953 .unwrap();
1954
1955 assert!(
1956 results.iter().all(|h| h.note_id != note.id),
1957 "soft-deleted note should be excluded from search"
1958 );
1959 }
1960
1961 #[tokio::test]
1962 async fn resolve_returns_entity() {
1963 let rt = rt();
1964 let entity = rt
1965 .create_entity(None, "concept", "LoRA", None, None, vec![])
1966 .await
1967 .unwrap();
1968
1969 let resolved = rt.resolve(None, entity.id).await.unwrap();
1970 match resolved {
1971 Some(Resolved::Entity(e)) => assert_eq!(e.id, entity.id),
1972 other => panic!("expected Resolved::Entity, got {:?}", other),
1973 }
1974 }
1975
1976 #[tokio::test]
1977 async fn resolve_returns_note() {
1978 let rt = rt();
1979 let note = rt
1980 .create_note(
1981 None,
1982 "observation",
1983 None,
1984 "LoRA fine-tunes LLMs with low-rank adapters",
1985 0.85,
1986 None,
1987 vec![],
1988 )
1989 .await
1990 .unwrap();
1991
1992 let resolved = rt.resolve(None, note.id).await.unwrap();
1993 match resolved {
1994 Some(Resolved::Note(n)) => assert_eq!(n.id, note.id),
1995 other => panic!("expected Resolved::Note, got {:?}", other),
1996 }
1997 }
1998
1999 #[tokio::test]
2000 async fn resolve_returns_none_for_unknown_uuid() {
2001 let rt = rt();
2002 let unknown = Uuid::new_v4();
2003 let resolved = rt.resolve(None, unknown).await.unwrap();
2004 assert!(resolved.is_none(), "unknown UUID should resolve to None");
2005 }
2006
2007 #[tokio::test]
2008 async fn resolve_prefix_finds_entity_in_own_namespace() {
2009 let rt = rt();
2010 let entity = rt
2011 .create_entity(None, "concept", "PrefixTest", None, None, vec![])
2012 .await
2013 .unwrap();
2014 let prefix = &entity.id.to_string()[..8];
2015
2016 let resolved = rt.resolve_prefix(None, prefix).await.unwrap();
2017 assert_eq!(resolved, Some(entity.id));
2018 }
2019
2020 #[tokio::test]
2021 async fn resolve_prefix_invisible_across_namespaces() {
2022 let rt = rt();
2023 let entity = rt
2024 .create_entity(Some("ns_a"), "concept", "Invisible", None, None, vec![])
2025 .await
2026 .unwrap();
2027 let prefix = &entity.id.to_string()[..8];
2028
2029 let resolved = rt.resolve_prefix(Some("ns_b"), prefix).await.unwrap();
2031 assert_eq!(resolved, None);
2032 }
2033
2034 #[tokio::test]
2035 async fn resolve_prefix_ambiguous_same_namespace() {
2036 use khive_storage::entity::Entity;
2037
2038 let rt = rt();
2039 let id_a = Uuid::parse_str("aabbccdd-1111-4000-8000-000000000001").unwrap();
2041 let id_b = Uuid::parse_str("aabbccdd-2222-4000-8000-000000000002").unwrap();
2042
2043 let mut entity_a = Entity::new("local", "concept", "AmbigA");
2044 entity_a.id = id_a;
2045 let mut entity_b = Entity::new("local", "concept", "AmbigB");
2046 entity_b.id = id_b;
2047
2048 let store = rt.entities(None).unwrap();
2049 store.upsert_entity(entity_a).await.unwrap();
2050 store.upsert_entity(entity_b).await.unwrap();
2051
2052 let result = rt.resolve_prefix(None, "aabbccdd").await;
2053 assert!(
2054 result.is_err(),
2055 "shared 8-char prefix must return Ambiguous error"
2056 );
2057 }
2058
2059 #[tokio::test]
2066 async fn resolve_finds_event_by_full_uuid() {
2067 use khive_storage::Event;
2068 use khive_types::SubstrateKind;
2069
2070 let rt = rt();
2071 let ns = rt.ns(None);
2072 let event = Event::new(ns, "test_verb", SubstrateKind::Entity, "actor");
2073 let event_id = event.id;
2074 rt.events(None).unwrap().append_event(event).await.unwrap();
2075
2076 let resolved = rt.resolve(None, event_id).await.unwrap();
2077 assert!(
2078 matches!(resolved, Some(Resolved::Event(_))),
2079 "event UUID must resolve to Resolved::Event, got {resolved:?}"
2080 );
2081 }
2082
2083 #[tokio::test]
2084 async fn resolve_prefix_finds_event() {
2085 use khive_storage::Event;
2086 use khive_types::SubstrateKind;
2087
2088 let rt = rt();
2089 let ns = rt.ns(None);
2090 let event = Event::new(ns, "test_verb", SubstrateKind::Entity, "actor");
2091 let event_id = event.id;
2092 rt.events(None).unwrap().append_event(event).await.unwrap();
2093
2094 let prefix = &event_id.to_string()[..8];
2095 let resolved = rt.resolve_prefix(None, prefix).await.unwrap();
2096 assert_eq!(
2097 resolved,
2098 Some(event_id),
2099 "resolve_prefix must return event UUID for 8-char prefix"
2100 );
2101 }
2102
2103 #[tokio::test]
2106 async fn link_phantom_source_returns_not_found() {
2107 let rt = rt();
2108 let b = rt
2109 .create_entity(None, "concept", "B", None, None, vec![])
2110 .await
2111 .unwrap();
2112 let phantom = Uuid::new_v4();
2113
2114 let result = rt
2115 .link(None, phantom, b.id, EdgeRelation::Extends, 1.0)
2116 .await;
2117 match result {
2118 Err(RuntimeError::NotFound(msg)) => {
2119 assert!(
2120 msg.contains("source"),
2121 "error message must name 'source': {msg}"
2122 );
2123 }
2124 other => panic!("expected NotFound for phantom source, got {other:?}"),
2125 }
2126 }
2127
2128 #[tokio::test]
2129 async fn link_phantom_target_returns_not_found() {
2130 let rt = rt();
2131 let a = rt
2132 .create_entity(None, "concept", "A", None, None, vec![])
2133 .await
2134 .unwrap();
2135 let phantom = Uuid::new_v4();
2136
2137 let result = rt
2138 .link(None, a.id, phantom, EdgeRelation::Extends, 1.0)
2139 .await;
2140 match result {
2141 Err(RuntimeError::NotFound(msg)) => {
2142 assert!(
2143 msg.contains("target"),
2144 "error message must name 'target': {msg}"
2145 );
2146 }
2147 other => panic!("expected NotFound for phantom target, got {other:?}"),
2148 }
2149 }
2150
2151 #[tokio::test]
2152 async fn link_real_entities_succeeds() {
2153 let rt = rt();
2154 let a = rt
2155 .create_entity(None, "concept", "A", None, None, vec![])
2156 .await
2157 .unwrap();
2158 let b = rt
2159 .create_entity(None, "concept", "B", None, None, vec![])
2160 .await
2161 .unwrap();
2162
2163 let edge = rt
2164 .link(None, a.id, b.id, EdgeRelation::Extends, 0.8)
2165 .await
2166 .unwrap();
2167 assert_eq!(edge.source_id, a.id);
2168 assert_eq!(edge.target_id, b.id);
2169 assert_eq!(edge.relation, EdgeRelation::Extends);
2170 }
2171
2172 #[tokio::test]
2173 async fn create_note_annotates_phantom_returns_not_found() {
2174 let rt = rt();
2175 let phantom = Uuid::new_v4();
2176
2177 let result = rt
2178 .create_note(
2179 None,
2180 "observation",
2181 None,
2182 "some content",
2183 0.5,
2184 None,
2185 vec![phantom],
2186 )
2187 .await;
2188 assert!(
2189 matches!(result, Err(RuntimeError::NotFound(_))),
2190 "annotates with phantom uuid must return NotFound, got {result:?}"
2191 );
2192 }
2193
2194 #[tokio::test]
2195 async fn create_note_annotates_real_entity_succeeds() {
2196 let rt = rt();
2197 let entity = rt
2198 .create_entity(None, "concept", "RealTarget", None, None, vec![])
2199 .await
2200 .unwrap();
2201
2202 let note = rt
2203 .create_note(
2204 None,
2205 "observation",
2206 None,
2207 "content",
2208 0.5,
2209 None,
2210 vec![entity.id],
2211 )
2212 .await
2213 .unwrap();
2214
2215 let neighbors = rt
2216 .neighbors(
2217 None,
2218 note.id,
2219 Direction::Out,
2220 None,
2221 Some(vec![EdgeRelation::Annotates]),
2222 )
2223 .await
2224 .unwrap();
2225 assert_eq!(neighbors.len(), 1);
2226 assert_eq!(neighbors[0].node_id, entity.id);
2227 }
2228
2229 #[tokio::test]
2231 async fn create_note_multi_annotates_creates_all_edges() {
2232 let rt = rt();
2233 let t1 = rt
2234 .create_entity(None, "concept", "Target1", None, None, vec![])
2235 .await
2236 .unwrap();
2237 let t2 = rt
2238 .create_entity(None, "concept", "Target2", None, None, vec![])
2239 .await
2240 .unwrap();
2241
2242 let note = rt
2243 .create_note(
2244 None,
2245 "observation",
2246 None,
2247 "content",
2248 0.5,
2249 None,
2250 vec![t1.id, t2.id],
2251 )
2252 .await
2253 .unwrap();
2254
2255 let neighbors = rt
2256 .neighbors(
2257 None,
2258 note.id,
2259 Direction::Out,
2260 None,
2261 Some(vec![EdgeRelation::Annotates]),
2262 )
2263 .await
2264 .unwrap();
2265 assert_eq!(
2266 neighbors.len(),
2267 2,
2268 "multi-annotates note must have exactly 2 outbound annotates edges"
2269 );
2270 let target_ids: Vec<Uuid> = neighbors.iter().map(|n| n.node_id).collect();
2271 assert!(target_ids.contains(&t1.id));
2272 assert!(target_ids.contains(&t2.id));
2273 }
2274
2275 #[tokio::test]
2276 async fn link_target_in_different_namespace_returns_not_found() {
2277 let rt = rt();
2278 let a = rt
2279 .create_entity(Some("ns-a"), "concept", "A", None, None, vec![])
2280 .await
2281 .unwrap();
2282 let b = rt
2283 .create_entity(Some("ns-b"), "concept", "B", None, None, vec![])
2284 .await
2285 .unwrap();
2286
2287 let result = rt
2289 .link(Some("ns-a"), a.id, b.id, EdgeRelation::Extends, 1.0)
2290 .await;
2291 assert!(
2292 matches!(result, Err(RuntimeError::NotFound(_))),
2293 "target in different namespace must return NotFound (fail-closed), got {result:?}"
2294 );
2295 }
2296
2297 #[tokio::test]
2298 async fn link_phantom_self_loop_returns_not_found() {
2299 let rt = rt();
2300 let phantom = Uuid::new_v4();
2301
2302 let result = rt
2303 .link(None, phantom, phantom, EdgeRelation::Extends, 1.0)
2304 .await;
2305 match result {
2306 Err(RuntimeError::NotFound(msg)) => {
2307 assert!(
2308 msg.contains("source"),
2309 "self-loop must fail on source first: {msg}"
2310 );
2311 }
2312 other => panic!("expected NotFound for phantom self-loop, got {other:?}"),
2313 }
2314 }
2315
2316 #[tokio::test]
2319 async fn link_note_to_edge_annotates_succeeds() {
2320 let rt = rt();
2321 let a = rt
2322 .create_entity(None, "concept", "A", None, None, vec![])
2323 .await
2324 .unwrap();
2325 let b = rt
2326 .create_entity(None, "concept", "B", None, None, vec![])
2327 .await
2328 .unwrap();
2329 let edge = rt
2331 .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
2332 .await
2333 .unwrap();
2334 let edge_uuid: Uuid = edge.id.into();
2335
2336 let note = rt
2338 .create_note(None, "observation", None, "edge note", 0.5, None, vec![])
2339 .await
2340 .unwrap();
2341
2342 let result = rt
2343 .link(None, note.id, edge_uuid, EdgeRelation::Annotates, 1.0)
2344 .await;
2345 assert!(
2346 result.is_ok(),
2347 "note→edge Annotates must succeed, got {result:?}"
2348 );
2349 }
2350
2351 #[tokio::test]
2352 async fn create_note_annotates_real_edge_succeeds() {
2353 let rt = rt();
2354 let a = rt
2355 .create_entity(None, "concept", "A", None, None, vec![])
2356 .await
2357 .unwrap();
2358 let b = rt
2359 .create_entity(None, "concept", "B", None, None, vec![])
2360 .await
2361 .unwrap();
2362 let edge = rt
2363 .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
2364 .await
2365 .unwrap();
2366 let edge_uuid: Uuid = edge.id.into();
2367
2368 let note = rt
2369 .create_note(
2370 None,
2371 "observation",
2372 None,
2373 "annotating an edge",
2374 0.5,
2375 None,
2376 vec![edge_uuid],
2377 )
2378 .await
2379 .unwrap();
2380
2381 let neighbors = rt
2382 .neighbors(
2383 None,
2384 note.id,
2385 Direction::Out,
2386 None,
2387 Some(vec![EdgeRelation::Annotates]),
2388 )
2389 .await
2390 .unwrap();
2391 assert_eq!(neighbors.len(), 1);
2392 assert_eq!(neighbors[0].node_id, edge_uuid);
2393 }
2394
2395 #[tokio::test]
2396 async fn create_note_annotates_phantom_is_atomic_no_note_persisted() {
2397 let rt = rt();
2398 let phantom = Uuid::new_v4();
2399
2400 let before_count = rt.list_notes(None, None, 1000, 0).await.unwrap().len();
2401
2402 let result = rt
2403 .create_note(
2404 None,
2405 "observation",
2406 None,
2407 "should not persist",
2408 0.5,
2409 None,
2410 vec![phantom],
2411 )
2412 .await;
2413 assert!(
2414 matches!(result, Err(RuntimeError::NotFound(_))),
2415 "phantom annotates target must return NotFound, got {result:?}"
2416 );
2417
2418 let after_count = rt.list_notes(None, None, 1000, 0).await.unwrap().len();
2420 assert_eq!(
2421 before_count, after_count,
2422 "failed create_note must not persist any note row (atomicity)"
2423 );
2424
2425 let search_hits = rt
2427 .search_notes(None, "should not persist", None, 10, None)
2428 .await
2429 .unwrap();
2430 assert!(
2431 search_hits.is_empty(),
2432 "failed create_note must not index into FTS (atomicity)"
2433 );
2434 }
2437
2438 #[tokio::test]
2442 async fn link_entity_to_edge_uuid_non_annotates_returns_invalid_input() {
2443 let rt = rt();
2444 let a = rt
2445 .create_entity(None, "concept", "A", None, None, vec![])
2446 .await
2447 .unwrap();
2448 let b = rt
2449 .create_entity(None, "concept", "B", None, None, vec![])
2450 .await
2451 .unwrap();
2452 let edge = rt
2454 .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
2455 .await
2456 .unwrap();
2457 let edge_uuid: Uuid = edge.id.into();
2458
2459 let result = rt
2460 .link(None, a.id, edge_uuid, EdgeRelation::Extends, 1.0)
2461 .await;
2462 match result {
2463 Err(RuntimeError::InvalidInput(msg)) => {
2464 assert!(
2465 msg.contains("target"),
2466 "error message must name 'target': {msg}"
2467 );
2468 }
2469 other => {
2470 panic!("expected InvalidInput for edge-uuid target with Extends, got {other:?}")
2471 }
2472 }
2473 }
2474
2475 #[tokio::test]
2477 async fn link_note_as_source_non_annotates_returns_invalid_input() {
2478 let rt = rt();
2479 let note = rt
2480 .create_note(None, "observation", None, "a note", 0.5, None, vec![])
2481 .await
2482 .unwrap();
2483 let entity = rt
2484 .create_entity(None, "concept", "E", None, None, vec![])
2485 .await
2486 .unwrap();
2487
2488 let result = rt
2489 .link(None, note.id, entity.id, EdgeRelation::DependsOn, 1.0)
2490 .await;
2491 match result {
2492 Err(RuntimeError::InvalidInput(msg)) => {
2493 assert!(
2494 msg.contains("source"),
2495 "error message must name 'source': {msg}"
2496 );
2497 }
2498 other => panic!("expected InvalidInput for note source with DependsOn, got {other:?}"),
2499 }
2500 }
2501
2502 #[tokio::test]
2504 async fn link_entity_as_annotates_source_returns_invalid_input() {
2505 let rt = rt();
2506 let a = rt
2507 .create_entity(None, "concept", "A", None, None, vec![])
2508 .await
2509 .unwrap();
2510 let b = rt
2511 .create_entity(None, "concept", "B", None, None, vec![])
2512 .await
2513 .unwrap();
2514
2515 let result = rt
2516 .link(None, a.id, b.id, EdgeRelation::Annotates, 1.0)
2517 .await;
2518 match result {
2519 Err(RuntimeError::InvalidInput(msg)) => {
2520 assert!(
2521 msg.contains("source") && msg.contains("note"),
2522 "error must say source must be a note: {msg}"
2523 );
2524 }
2525 other => {
2526 panic!("expected InvalidInput for entity source with Annotates, got {other:?}")
2527 }
2528 }
2529 }
2530
2531 #[tokio::test]
2532 async fn link_edge_as_annotates_source_returns_invalid_input() {
2533 let rt = rt();
2534 let a = rt
2535 .create_entity(None, "concept", "A", None, None, vec![])
2536 .await
2537 .unwrap();
2538 let b = rt
2539 .create_entity(None, "concept", "B", None, None, vec![])
2540 .await
2541 .unwrap();
2542 let edge = rt
2543 .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
2544 .await
2545 .unwrap();
2546 let edge_uuid: Uuid = edge.id.into();
2547
2548 let result = rt
2550 .link(None, edge_uuid, a.id, EdgeRelation::Annotates, 1.0)
2551 .await;
2552 match result {
2553 Err(RuntimeError::InvalidInput(msg)) => {
2554 assert!(
2555 msg.contains("source") && msg.contains("note"),
2556 "edge-as-annotates-source must report wrong kind, not NotFound: {msg}"
2557 );
2558 }
2559 other => panic!("expected InvalidInput for edge source with Annotates, got {other:?}"),
2560 }
2561 }
2562
2563 #[tokio::test]
2565 async fn link_note_to_event_annotates_succeeds() {
2566 use khive_storage::Event;
2567 use khive_types::SubstrateKind;
2568
2569 let rt = rt();
2570 let note = rt
2571 .create_note(
2572 None,
2573 "observation",
2574 None,
2575 "observing an event",
2576 0.6,
2577 None,
2578 vec![],
2579 )
2580 .await
2581 .unwrap();
2582
2583 let ns = rt.ns(None);
2585 let event = Event::new(ns, "test_verb", SubstrateKind::Entity, "test_actor");
2586 let event_id = event.id;
2587 rt.events(None).unwrap().append_event(event).await.unwrap();
2588
2589 let result = rt
2590 .link(None, note.id, event_id, EdgeRelation::Annotates, 1.0)
2591 .await;
2592 assert!(
2593 result.is_ok(),
2594 "note→event Annotates must succeed, got {result:?}"
2595 );
2596 }
2597
2598 #[tokio::test]
2600 async fn create_note_annotates_event_succeeds() {
2601 use khive_storage::Event;
2602 use khive_types::SubstrateKind;
2603
2604 let rt = rt();
2605 let ns = rt.ns(None);
2606 let event = Event::new(ns, "test_verb", SubstrateKind::Entity, "test_actor");
2607 let event_id = event.id;
2608 rt.events(None).unwrap().append_event(event).await.unwrap();
2609
2610 let result = rt
2611 .create_note(
2612 None,
2613 "observation",
2614 None,
2615 "note annotating an event",
2616 0.5,
2617 None,
2618 vec![event_id],
2619 )
2620 .await;
2621 assert!(
2622 result.is_ok(),
2623 "create_note with event annotates target must succeed, got {result:?}"
2624 );
2625 let note = result.unwrap();
2627 let neighbors = rt
2628 .neighbors(
2629 None,
2630 note.id,
2631 Direction::Out,
2632 None,
2633 Some(vec![EdgeRelation::Annotates]),
2634 )
2635 .await
2636 .unwrap();
2637 assert_eq!(neighbors.len(), 1);
2638 assert_eq!(neighbors[0].node_id, event_id);
2639 }
2640
2641 #[tokio::test]
2645 async fn link_supersedes_note_to_note_succeeds() {
2646 let rt = rt();
2647 let old_note = rt
2648 .create_note(
2649 None,
2650 "observation",
2651 None,
2652 "old observation",
2653 0.7,
2654 None,
2655 vec![],
2656 )
2657 .await
2658 .unwrap();
2659 let new_note = rt
2660 .create_note(
2661 None,
2662 "observation",
2663 None,
2664 "revised observation superseding the old one",
2665 0.9,
2666 None,
2667 vec![],
2668 )
2669 .await
2670 .unwrap();
2671
2672 let result = rt
2673 .link(
2674 None,
2675 new_note.id,
2676 old_note.id,
2677 EdgeRelation::Supersedes,
2678 1.0,
2679 )
2680 .await;
2681 assert!(
2682 result.is_ok(),
2683 "note→note Supersedes must succeed (ADR-019 note supersession), got {result:?}"
2684 );
2685 }
2686
2687 #[tokio::test]
2688 async fn link_supersedes_entity_to_entity_succeeds() {
2689 let rt = rt();
2690 let old_entity = rt
2691 .create_entity(None, "concept", "OldConcept", None, None, vec![])
2692 .await
2693 .unwrap();
2694 let new_entity = rt
2695 .create_entity(None, "concept", "NewConcept", None, None, vec![])
2696 .await
2697 .unwrap();
2698
2699 let result = rt
2700 .link(
2701 None,
2702 new_entity.id,
2703 old_entity.id,
2704 EdgeRelation::Supersedes,
2705 1.0,
2706 )
2707 .await;
2708 assert!(
2709 result.is_ok(),
2710 "entity→entity Supersedes must succeed, got {result:?}"
2711 );
2712 }
2713
2714 #[tokio::test]
2715 async fn link_supersedes_note_to_entity_returns_invalid_input() {
2716 let rt = rt();
2717 let note = rt
2718 .create_note(None, "observation", None, "a note", 0.5, None, vec![])
2719 .await
2720 .unwrap();
2721 let entity = rt
2722 .create_entity(None, "concept", "SomeEntity", None, None, vec![])
2723 .await
2724 .unwrap();
2725
2726 let result = rt
2727 .link(None, note.id, entity.id, EdgeRelation::Supersedes, 1.0)
2728 .await;
2729 match result {
2730 Err(RuntimeError::InvalidInput(msg)) => {
2731 assert!(
2732 msg.contains("same substrate") || msg.contains("same-substrate"),
2733 "error must name the same-substrate rule: {msg}"
2734 );
2735 }
2736 other => panic!(
2737 "expected InvalidInput for note→entity Supersedes (cross-substrate), got {other:?}"
2738 ),
2739 }
2740 }
2741
2742 #[tokio::test]
2743 async fn link_supersedes_entity_to_note_returns_invalid_input() {
2744 let rt = rt();
2745 let entity = rt
2746 .create_entity(None, "concept", "SomeEntity", None, None, vec![])
2747 .await
2748 .unwrap();
2749 let note = rt
2750 .create_note(None, "observation", None, "a note", 0.5, None, vec![])
2751 .await
2752 .unwrap();
2753
2754 let result = rt
2755 .link(None, entity.id, note.id, EdgeRelation::Supersedes, 1.0)
2756 .await;
2757 match result {
2758 Err(RuntimeError::InvalidInput(msg)) => {
2759 assert!(
2760 msg.contains("same substrate") || msg.contains("same-substrate"),
2761 "error must name the same-substrate rule: {msg}"
2762 );
2763 }
2764 other => panic!(
2765 "expected InvalidInput for entity→note Supersedes (cross-substrate), got {other:?}"
2766 ),
2767 }
2768 }
2769
2770 #[tokio::test]
2771 async fn link_supersedes_event_source_returns_invalid_input() {
2772 use khive_storage::Event;
2773 use khive_types::SubstrateKind;
2774
2775 let rt = rt();
2776 let ns = rt.ns(None);
2777 let event = Event::new(ns, "test_verb", SubstrateKind::Entity, "test_actor");
2778 let event_id = event.id;
2779 rt.events(None).unwrap().append_event(event).await.unwrap();
2780
2781 let entity = rt
2782 .create_entity(None, "concept", "SomeEntity", None, None, vec![])
2783 .await
2784 .unwrap();
2785
2786 let result = rt
2787 .link(None, event_id, entity.id, EdgeRelation::Supersedes, 1.0)
2788 .await;
2789 match result {
2790 Err(RuntimeError::InvalidInput(msg)) => {
2791 assert!(msg.contains("event"), "error must mention 'event': {msg}");
2792 }
2793 other => {
2794 panic!("expected InvalidInput for event source with Supersedes, got {other:?}")
2795 }
2796 }
2797 }
2798
2799 #[tokio::test]
2800 async fn link_supersedes_event_target_returns_invalid_input() {
2801 use khive_storage::Event;
2802 use khive_types::SubstrateKind;
2803
2804 let rt = rt();
2805 let ns = rt.ns(None);
2806 let event = Event::new(ns, "test_verb", SubstrateKind::Entity, "test_actor");
2807 let event_id = event.id;
2808 rt.events(None).unwrap().append_event(event).await.unwrap();
2809
2810 let entity = rt
2811 .create_entity(None, "concept", "SomeEntity", None, None, vec![])
2812 .await
2813 .unwrap();
2814
2815 let result = rt
2816 .link(None, entity.id, event_id, EdgeRelation::Supersedes, 1.0)
2817 .await;
2818 match result {
2819 Err(RuntimeError::InvalidInput(msg)) => {
2820 assert!(msg.contains("event"), "error must mention 'event': {msg}");
2821 }
2822 other => {
2823 panic!("expected InvalidInput for event target with Supersedes, got {other:?}")
2824 }
2825 }
2826 }
2827
2828 #[tokio::test]
2829 async fn link_supersedes_edge_source_returns_invalid_input() {
2830 let rt = rt();
2831 let a = rt
2832 .create_entity(None, "concept", "A", None, None, vec![])
2833 .await
2834 .unwrap();
2835 let b = rt
2836 .create_entity(None, "concept", "B", None, None, vec![])
2837 .await
2838 .unwrap();
2839 let edge = rt
2840 .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
2841 .await
2842 .unwrap();
2843 let edge_uuid: Uuid = edge.id.into();
2844
2845 let result = rt
2846 .link(None, edge_uuid, a.id, EdgeRelation::Supersedes, 1.0)
2847 .await;
2848 match result {
2849 Err(RuntimeError::InvalidInput(msg)) => {
2850 assert!(msg.contains("source"), "error must name 'source': {msg}");
2851 }
2852 other => {
2853 panic!("expected InvalidInput for edge-uuid source with Supersedes, got {other:?}")
2854 }
2855 }
2856 }
2857
2858 #[tokio::test]
2859 async fn link_supersedes_edge_target_returns_invalid_input() {
2860 let rt = rt();
2861 let a = rt
2862 .create_entity(None, "concept", "A", None, None, vec![])
2863 .await
2864 .unwrap();
2865 let b = rt
2866 .create_entity(None, "concept", "B", None, None, vec![])
2867 .await
2868 .unwrap();
2869 let edge = rt
2870 .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
2871 .await
2872 .unwrap();
2873 let edge_uuid: Uuid = edge.id.into();
2874
2875 let result = rt
2876 .link(None, a.id, edge_uuid, EdgeRelation::Supersedes, 1.0)
2877 .await;
2878 match result {
2879 Err(RuntimeError::InvalidInput(msg)) => {
2880 assert!(msg.contains("target"), "error must name 'target': {msg}");
2881 }
2882 other => {
2883 panic!("expected InvalidInput for edge-uuid target with Supersedes, got {other:?}")
2884 }
2885 }
2886 }
2887
2888 #[tokio::test]
2889 async fn link_supersedes_phantom_source_returns_not_found() {
2890 let rt = rt();
2891 let note = rt
2892 .create_note(
2893 None,
2894 "observation",
2895 None,
2896 "existing note",
2897 0.5,
2898 None,
2899 vec![],
2900 )
2901 .await
2902 .unwrap();
2903 let phantom = Uuid::new_v4();
2904
2905 let result = rt
2906 .link(None, phantom, note.id, EdgeRelation::Supersedes, 1.0)
2907 .await;
2908 match result {
2909 Err(RuntimeError::NotFound(msg)) => {
2910 assert!(msg.contains("source"), "error must name 'source': {msg}");
2911 }
2912 other => panic!("expected NotFound for phantom source with Supersedes, got {other:?}"),
2913 }
2914 }
2915
2916 #[tokio::test]
2917 async fn link_supersedes_phantom_target_returns_not_found() {
2918 let rt = rt();
2919 let note = rt
2920 .create_note(
2921 None,
2922 "observation",
2923 None,
2924 "existing note",
2925 0.5,
2926 None,
2927 vec![],
2928 )
2929 .await
2930 .unwrap();
2931 let phantom = Uuid::new_v4();
2932
2933 let result = rt
2934 .link(None, note.id, phantom, EdgeRelation::Supersedes, 1.0)
2935 .await;
2936 match result {
2937 Err(RuntimeError::NotFound(msg)) => {
2938 assert!(msg.contains("target"), "error must name 'target': {msg}");
2939 }
2940 other => panic!("expected NotFound for phantom target with Supersedes, got {other:?}"),
2941 }
2942 }
2943
2944 #[tokio::test]
2945 async fn link_supersedes_cross_namespace_source_returns_not_found() {
2946 let rt = rt();
2947 let note_a = rt
2948 .create_note(
2949 Some("ns-a"),
2950 "observation",
2951 None,
2952 "note in ns-a",
2953 0.5,
2954 None,
2955 vec![],
2956 )
2957 .await
2958 .unwrap();
2959 let note_b = rt
2960 .create_note(
2961 Some("ns-b"),
2962 "observation",
2963 None,
2964 "note in ns-b",
2965 0.5,
2966 None,
2967 vec![],
2968 )
2969 .await
2970 .unwrap();
2971
2972 let result = rt
2974 .link(
2975 Some("ns-a"),
2976 note_b.id,
2977 note_a.id,
2978 EdgeRelation::Supersedes,
2979 1.0,
2980 )
2981 .await;
2982 assert!(
2983 matches!(result, Err(RuntimeError::NotFound(_))),
2984 "cross-namespace source with Supersedes must return NotFound (fail-closed), got {result:?}"
2985 );
2986 }
2987
2988 #[tokio::test]
2990 async fn link_extends_note_source_still_returns_invalid_input() {
2991 let rt = rt();
2992 let note = rt
2993 .create_note(
2994 None,
2995 "observation",
2996 None,
2997 "a note that cannot be an extends source",
2998 0.5,
2999 None,
3000 vec![],
3001 )
3002 .await
3003 .unwrap();
3004 let entity = rt
3005 .create_entity(None, "concept", "E", None, None, vec![])
3006 .await
3007 .unwrap();
3008
3009 let result = rt
3010 .link(None, note.id, entity.id, EdgeRelation::Extends, 1.0)
3011 .await;
3012 assert!(
3013 matches!(result, Err(RuntimeError::InvalidInput(_))),
3014 "note source with Extends must still return InvalidInput after this fix, got {result:?}"
3015 );
3016 }
3017
3018 #[tokio::test]
3020 async fn link_annotates_note_to_edge_still_succeeds_after_fix() {
3021 let rt = rt();
3022 let a = rt
3023 .create_entity(None, "concept", "A", None, None, vec![])
3024 .await
3025 .unwrap();
3026 let b = rt
3027 .create_entity(None, "concept", "B", None, None, vec![])
3028 .await
3029 .unwrap();
3030 let edge = rt
3031 .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
3032 .await
3033 .unwrap();
3034 let edge_uuid: Uuid = edge.id.into();
3035
3036 let note = rt
3037 .create_note(
3038 None,
3039 "observation",
3040 None,
3041 "annotating an edge",
3042 0.5,
3043 None,
3044 vec![],
3045 )
3046 .await
3047 .unwrap();
3048
3049 let result = rt
3050 .link(None, note.id, edge_uuid, EdgeRelation::Annotates, 1.0)
3051 .await;
3052 assert!(
3053 result.is_ok(),
3054 "note→edge Annotates must still succeed after supersedes fix, got {result:?}"
3055 );
3056 }
3057
3058 #[tokio::test]
3072 async fn create_note_multi_annotates_compensation_cleanup_restores_pristine_state() {
3073 let rt = rt();
3074 let t1 = rt
3075 .create_entity(None, "concept", "T1", None, None, vec![])
3076 .await
3077 .unwrap();
3078
3079 let note = rt
3082 .create_note(
3083 None,
3084 "observation",
3085 None,
3086 "partial note",
3087 0.5,
3088 None,
3089 vec![t1.id],
3090 )
3091 .await
3092 .unwrap();
3093
3094 let before_notes = rt.list_notes(None, None, 1000, 0).await.unwrap();
3096 assert_eq!(before_notes.len(), 1, "note must be present before cleanup");
3097 let before_edges = rt
3098 .neighbors(
3099 None,
3100 note.id,
3101 Direction::Out,
3102 None,
3103 Some(vec![EdgeRelation::Annotates]),
3104 )
3105 .await
3106 .unwrap();
3107 assert_eq!(
3108 before_edges.len(),
3109 1,
3110 "one annotates edge must exist before cleanup"
3111 );
3112 let edge_id: Uuid = before_edges[0].edge_id;
3113
3114 rt.delete_edge(None, edge_id).await.unwrap();
3116 rt.delete_note(None, note.id, true )
3117 .await
3118 .unwrap();
3119
3120 let after_notes = rt.list_notes(None, None, 1000, 0).await.unwrap();
3122 assert!(
3123 after_notes.is_empty(),
3124 "compensation must remove the note row; got {after_notes:?}"
3125 );
3126 let search_hits = rt
3127 .search_notes(None, "partial note", None, 10, None)
3128 .await
3129 .unwrap();
3130 assert!(
3131 search_hits.is_empty(),
3132 "compensation must clean the FTS index; got {search_hits:?}"
3133 );
3134 let after_edges = rt
3135 .neighbors(None, note.id, Direction::Out, None, None)
3136 .await
3137 .unwrap();
3138 assert!(
3139 after_edges.is_empty(),
3140 "compensation must remove all partial edges; got {after_edges:?}"
3141 );
3142 }
3143
3144 #[tokio::test]
3152 async fn annotated_entity_hard_delete_cascades_annotate_edge() {
3153 let rt = rt();
3154 let entity = rt
3155 .create_entity(None, "concept", "E", None, None, vec![])
3156 .await
3157 .unwrap();
3158 let note = rt
3159 .create_note(
3160 None,
3161 "observation",
3162 None,
3163 "note about entity",
3164 0.5,
3165 None,
3166 vec![entity.id],
3167 )
3168 .await
3169 .unwrap();
3170
3171 let before = rt
3173 .neighbors(
3174 None,
3175 note.id,
3176 Direction::Out,
3177 None,
3178 Some(vec![EdgeRelation::Annotates]),
3179 )
3180 .await
3181 .unwrap();
3182 assert_eq!(
3183 before.len(),
3184 1,
3185 "annotates edge must exist before entity delete"
3186 );
3187
3188 let deleted = rt.delete_entity(None, entity.id, true).await.unwrap();
3190 assert!(deleted, "entity hard delete must return true");
3191
3192 let after = rt
3194 .neighbors(
3195 None,
3196 note.id,
3197 Direction::Out,
3198 None,
3199 Some(vec![EdgeRelation::Annotates]),
3200 )
3201 .await
3202 .unwrap();
3203 assert!(
3204 after.is_empty(),
3205 "annotates edge must be cascaded on entity hard delete; got {after:?}"
3206 );
3207 }
3208
3209 #[tokio::test]
3210 async fn annotated_note_hard_delete_cascades_annotate_edge() {
3211 let rt = rt();
3212 let note_target = rt
3214 .create_note(None, "observation", None, "target note", 0.5, None, vec![])
3215 .await
3216 .unwrap();
3217 let note_source = rt
3219 .create_note(
3220 None,
3221 "insight",
3222 None,
3223 "annotation",
3224 0.5,
3225 None,
3226 vec![note_target.id],
3227 )
3228 .await
3229 .unwrap();
3230
3231 let before = rt
3232 .neighbors(
3233 None,
3234 note_source.id,
3235 Direction::Out,
3236 None,
3237 Some(vec![EdgeRelation::Annotates]),
3238 )
3239 .await
3240 .unwrap();
3241 assert_eq!(
3242 before.len(),
3243 1,
3244 "annotates edge must exist before note delete"
3245 );
3246
3247 let deleted = rt.delete_note(None, note_target.id, true).await.unwrap();
3249 assert!(deleted, "note hard delete must return true");
3250
3251 let after = rt
3253 .neighbors(
3254 None,
3255 note_source.id,
3256 Direction::Out,
3257 None,
3258 Some(vec![EdgeRelation::Annotates]),
3259 )
3260 .await
3261 .unwrap();
3262 assert!(
3263 after.is_empty(),
3264 "annotates edge must be cascaded on note-target hard delete; got {after:?}"
3265 );
3266 }
3267
3268 #[tokio::test]
3269 async fn annotated_edge_delete_cascades_annotate_edge() {
3270 let rt = rt();
3271 let a = rt
3272 .create_entity(None, "concept", "A", None, None, vec![])
3273 .await
3274 .unwrap();
3275 let b = rt
3276 .create_entity(None, "concept", "B", None, None, vec![])
3277 .await
3278 .unwrap();
3279 let base_edge = rt
3281 .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
3282 .await
3283 .unwrap();
3284 let base_edge_uuid: Uuid = base_edge.id.into();
3285
3286 let note = rt
3288 .create_note(
3289 None,
3290 "observation",
3291 None,
3292 "note about edge",
3293 0.5,
3294 None,
3295 vec![base_edge_uuid],
3296 )
3297 .await
3298 .unwrap();
3299
3300 let before = rt
3301 .neighbors(
3302 None,
3303 note.id,
3304 Direction::Out,
3305 None,
3306 Some(vec![EdgeRelation::Annotates]),
3307 )
3308 .await
3309 .unwrap();
3310 assert_eq!(
3311 before.len(),
3312 1,
3313 "annotates edge must exist before base edge delete"
3314 );
3315
3316 let deleted = rt.delete_edge(None, base_edge_uuid).await.unwrap();
3318 assert!(deleted, "edge delete must return true");
3319
3320 let after = rt
3322 .neighbors(
3323 None,
3324 note.id,
3325 Direction::Out,
3326 None,
3327 Some(vec![EdgeRelation::Annotates]),
3328 )
3329 .await
3330 .unwrap();
3331 assert!(
3332 after.is_empty(),
3333 "annotates edge must be cascaded on base edge delete; got {after:?}"
3334 );
3335 }
3336
3337 #[tokio::test]
3338 async fn mixed_multi_annotates_partial_target_hard_delete_leaves_remaining_edges() {
3339 let rt = rt();
3340 let t1 = rt
3341 .create_entity(None, "concept", "T1", None, None, vec![])
3342 .await
3343 .unwrap();
3344 let t2 = rt
3345 .create_entity(None, "concept", "T2", None, None, vec![])
3346 .await
3347 .unwrap();
3348
3349 let note = rt
3351 .create_note(
3352 None,
3353 "observation",
3354 None,
3355 "multi-target note",
3356 0.5,
3357 None,
3358 vec![t1.id, t2.id],
3359 )
3360 .await
3361 .unwrap();
3362
3363 let before = rt
3364 .neighbors(
3365 None,
3366 note.id,
3367 Direction::Out,
3368 None,
3369 Some(vec![EdgeRelation::Annotates]),
3370 )
3371 .await
3372 .unwrap();
3373 assert_eq!(
3374 before.len(),
3375 2,
3376 "must have 2 annotates edges before any delete"
3377 );
3378
3379 rt.delete_entity(None, t1.id, true).await.unwrap();
3381
3382 let after = rt
3384 .neighbors(
3385 None,
3386 note.id,
3387 Direction::Out,
3388 None,
3389 Some(vec![EdgeRelation::Annotates]),
3390 )
3391 .await
3392 .unwrap();
3393 assert_eq!(
3394 after.len(),
3395 1,
3396 "only the edge to t1 must be cascaded; t2 edge must remain"
3397 );
3398 assert_eq!(
3399 after[0].node_id, t2.id,
3400 "remaining annotates edge must point to t2"
3401 );
3402 }
3403
3404 #[tokio::test]
3405 async fn annotated_note_soft_delete_preserves_annotate_edge() {
3406 let rt = rt();
3407 let note_target = rt
3408 .create_note(None, "observation", None, "target", 0.5, None, vec![])
3409 .await
3410 .unwrap();
3411 let note_source = rt
3412 .create_note(
3413 None,
3414 "insight",
3415 None,
3416 "annotation",
3417 0.5,
3418 None,
3419 vec![note_target.id],
3420 )
3421 .await
3422 .unwrap();
3423
3424 let before = rt
3425 .neighbors(
3426 None,
3427 note_source.id,
3428 Direction::Out,
3429 None,
3430 Some(vec![EdgeRelation::Annotates]),
3431 )
3432 .await
3433 .unwrap();
3434 assert_eq!(before.len(), 1);
3435
3436 let deleted = rt.delete_note(None, note_target.id, false).await.unwrap();
3438 assert!(deleted, "soft delete must return true");
3439
3440 let after = rt
3441 .neighbors(
3442 None,
3443 note_source.id,
3444 Direction::Out,
3445 None,
3446 Some(vec![EdgeRelation::Annotates]),
3447 )
3448 .await
3449 .unwrap();
3450 assert_eq!(
3451 after.len(),
3452 1,
3453 "soft delete must NOT cascade edges; got {after:?}"
3454 );
3455 }
3456
3457 #[tokio::test]
3464 async fn delete_edge_non_edge_uuid_has_no_side_effects() {
3465 let rt = rt();
3466
3467 let entity = rt
3469 .create_entity(None, "concept", "Target", None, None, vec![])
3470 .await
3471 .unwrap();
3472 let note = rt
3473 .create_note(
3474 None,
3475 "observation",
3476 None,
3477 "annotates the entity",
3478 0.5,
3479 None,
3480 vec![entity.id],
3481 )
3482 .await
3483 .unwrap();
3484
3485 let before = rt
3487 .neighbors(
3488 None,
3489 note.id,
3490 Direction::Out,
3491 None,
3492 Some(vec![EdgeRelation::Annotates]),
3493 )
3494 .await
3495 .unwrap();
3496 assert_eq!(before.len(), 1, "annotates edge must exist before test");
3497 let annotates_edge_id: Uuid = before[0].edge_id;
3498
3499 let result = rt.delete_edge(None, entity.id).await;
3501 assert!(
3502 result.is_ok(),
3503 "delete_edge must not error on a non-edge UUID"
3504 );
3505 assert!(
3506 !result.unwrap(),
3507 "delete_edge must return false for a non-edge UUID"
3508 );
3509
3510 let after = rt
3512 .neighbors(
3513 None,
3514 note.id,
3515 Direction::Out,
3516 None,
3517 Some(vec![EdgeRelation::Annotates]),
3518 )
3519 .await
3520 .unwrap();
3521 assert_eq!(
3522 after.len(),
3523 1,
3524 "delete_edge with a non-edge UUID must not touch inbound annotates edges"
3525 );
3526 assert_eq!(
3527 after[0].edge_id, annotates_edge_id,
3528 "the original annotates edge must be unchanged"
3529 );
3530 }
3531
3532 #[tokio::test]
3543 async fn create_note_multi_annotates_second_link_failure_rolls_back_partial_write() {
3544 let rt = rt();
3545 let t1 = rt
3546 .create_entity(None, "concept", "T1", None, None, vec![])
3547 .await
3548 .unwrap();
3549 let t2 = rt
3550 .create_entity(None, "concept", "T2", None, None, vec![])
3551 .await
3552 .unwrap();
3553
3554 LINK_FAIL_AFTER.with(|cell| cell.set(2));
3556
3557 let result = rt
3558 .create_note(
3559 None,
3560 "observation",
3561 None,
3562 "rollback target",
3563 0.5,
3564 None,
3565 vec![t1.id, t2.id],
3566 )
3567 .await;
3568
3569 assert!(
3571 result.is_err(),
3572 "create_note must propagate the injected link failure"
3573 );
3574 let err_msg = result.unwrap_err().to_string();
3575 assert!(
3576 err_msg.contains("injected link failure"),
3577 "error must carry injection message; got: {err_msg}"
3578 );
3579
3580 let notes = rt.list_notes(None, None, 1000, 0).await.unwrap();
3582 assert!(
3583 notes.is_empty(),
3584 "compensation must remove the note row; got {notes:?}"
3585 );
3586
3587 let hits = rt
3589 .search_notes(None, "rollback target", None, 10, None)
3590 .await
3591 .unwrap();
3592 assert!(
3593 hits.is_empty(),
3594 "compensation must clean FTS index; got {hits:?}"
3595 );
3596
3597 let edges_from_t1 = rt
3599 .neighbors(
3600 None,
3601 t1.id,
3602 Direction::In,
3603 None,
3604 Some(vec![EdgeRelation::Annotates]),
3605 )
3606 .await
3607 .unwrap();
3608 let edges_from_t2 = rt
3609 .neighbors(
3610 None,
3611 t2.id,
3612 Direction::In,
3613 None,
3614 Some(vec![EdgeRelation::Annotates]),
3615 )
3616 .await
3617 .unwrap();
3618 assert!(
3619 edges_from_t1.is_empty(),
3620 "compensation must delete the first annotates edge; got {edges_from_t1:?}"
3621 );
3622 assert!(
3623 edges_from_t2.is_empty(),
3624 "no second annotates edge must exist; got {edges_from_t2:?}"
3625 );
3626 }
3627
3628 #[tokio::test]
3631 async fn soft_delete_entity_removes_indexes() {
3632 let rt = rt();
3633 let entity = rt
3634 .create_entity(
3635 None,
3636 "concept",
3637 "QuantumEntanglement",
3638 Some("unique FTS term xzqjwv for soft delete test"),
3639 None,
3640 vec![],
3641 )
3642 .await
3643 .unwrap();
3644
3645 let ns = rt.ns(None).to_string();
3646
3647 let before = rt
3648 .text(None)
3649 .unwrap()
3650 .search(TextSearchRequest {
3651 query: "xzqjwv".to_string(),
3652 mode: TextQueryMode::Plain,
3653 filter: Some(TextFilter {
3654 namespaces: vec![ns.clone()],
3655 ..Default::default()
3656 }),
3657 top_k: 10,
3658 snippet_chars: 100,
3659 })
3660 .await
3661 .unwrap();
3662 assert!(
3663 before.iter().any(|h| h.subject_id == entity.id),
3664 "entity must be in FTS before soft-delete"
3665 );
3666
3667 let deleted = rt.delete_entity(None, entity.id, false).await.unwrap();
3668 assert!(deleted, "soft delete must return true");
3669
3670 let after = rt
3671 .text(None)
3672 .unwrap()
3673 .search(TextSearchRequest {
3674 query: "xzqjwv".to_string(),
3675 mode: TextQueryMode::Plain,
3676 filter: Some(TextFilter {
3677 namespaces: vec![ns],
3678 ..Default::default()
3679 }),
3680 top_k: 10,
3681 snippet_chars: 100,
3682 })
3683 .await
3684 .unwrap();
3685 assert!(
3686 after.iter().all(|h| h.subject_id != entity.id),
3687 "soft-deleted entity must be removed from FTS index"
3688 );
3689 }
3690
3691 #[tokio::test]
3692 async fn soft_delete_note_removes_indexes() {
3693 let rt = rt();
3694 let note = rt
3695 .create_note(
3696 None,
3697 "observation",
3698 None,
3699 "SpectralDecomposition unique term yvwkqz for soft delete test",
3700 0.7,
3701 None,
3702 vec![],
3703 )
3704 .await
3705 .unwrap();
3706
3707 let before = rt
3708 .search_notes(None, "yvwkqz", None, 10, None)
3709 .await
3710 .unwrap();
3711 assert!(
3712 before.iter().any(|h| h.note_id == note.id),
3713 "note must be in FTS before soft-delete"
3714 );
3715
3716 let deleted = rt.delete_note(None, note.id, false).await.unwrap();
3717 assert!(deleted, "soft delete must return true");
3718
3719 let after = rt
3720 .search_notes(None, "yvwkqz", None, 10, None)
3721 .await
3722 .unwrap();
3723 assert!(
3724 after.iter().all(|h| h.note_id != note.id),
3725 "soft-deleted note must be removed from FTS index"
3726 );
3727 }
3728}