1use std::collections::HashMap;
4use std::str::FromStr;
5
6use uuid::Uuid;
7
8use khive_score::{rrf_score, DeterministicScore};
9use khive_storage::note::Note;
10use khive_storage::types::{
11 DeleteMode, Direction, EdgeSortField, GraphPath, LinkId, NeighborHit, NeighborQuery, Page,
12 PageRequest, SortOrder, SqlStatement, TextDocument, TextFilter, TextQueryMode,
13 TextSearchRequest, TraversalRequest, VectorSearchRequest,
14};
15use khive_storage::{Edge, EdgeRelation, Entity, EntityFilter, Event, EventFilter};
16use khive_types::{EdgeEndpointRule, EndpointKind, SubstrateKind};
17
18use crate::error::{RuntimeError, RuntimeResult};
19use crate::runtime::KhiveRuntime;
20
21#[derive(Clone, Debug)]
23pub struct NoteSearchHit {
24 pub note_id: Uuid,
25 pub score: DeterministicScore,
26}
27
28#[derive(Clone, Debug)]
30pub enum Resolved {
31 Entity(Entity),
32 Note(Note),
33 Event(Event),
34}
35
36fn resolved_pair(r: Option<&Resolved>) -> Option<(&'static str, &str)> {
39 match r? {
40 Resolved::Entity(e) => Some(("entity", e.kind.as_str())),
41 Resolved::Note(n) => Some(("note", n.kind.as_str())),
42 Resolved::Event(_) => None,
43 }
44}
45
46fn endpoint_matches(spec: &EndpointKind, substrate: &str, kind: &str) -> bool {
48 match spec {
49 EndpointKind::EntityOfKind(k) => substrate == "entity" && *k == kind,
50 EndpointKind::NoteOfKind(k) => substrate == "note" && *k == kind,
51 }
52}
53
54fn pack_rule_allows(
57 rules: &[EdgeEndpointRule],
58 relation: EdgeRelation,
59 src: Option<&Resolved>,
60 tgt: Option<&Resolved>,
61) -> bool {
62 let Some((src_sub, src_kind)) = resolved_pair(src) else {
63 return false;
64 };
65 let Some((tgt_sub, tgt_kind)) = resolved_pair(tgt) else {
66 return false;
67 };
68 rules.iter().any(|r| {
69 r.relation == relation
70 && endpoint_matches(&r.source, src_sub, src_kind)
71 && endpoint_matches(&r.target, tgt_sub, tgt_kind)
72 })
73}
74
75impl KhiveRuntime {
76 pub async fn create_entity(
80 &self,
81 namespace: Option<&str>,
82 kind: &str,
83 name: &str,
84 description: Option<&str>,
85 properties: Option<serde_json::Value>,
86 tags: Vec<String>,
87 ) -> RuntimeResult<Entity> {
88 let ns = self.ns(namespace);
89 let mut entity = Entity::new(ns, kind, name);
90 if let Some(d) = description {
91 entity = entity.with_description(d);
92 }
93 if let Some(p) = properties {
94 entity = entity.with_properties(p);
95 }
96 if !tags.is_empty() {
97 entity = entity.with_tags(tags);
98 }
99 self.entities(Some(ns))?
100 .upsert_entity(entity.clone())
101 .await?;
102
103 let body = match &entity.description {
104 Some(d) if !d.is_empty() => format!("{} {}", entity.name, d),
105 _ => entity.name.clone(),
106 };
107 self.text(namespace)?
108 .upsert_document(TextDocument {
109 subject_id: entity.id,
110 kind: SubstrateKind::Entity,
111 title: Some(entity.name.clone()),
112 body: body.clone(),
113 tags: entity.tags.clone(),
114 namespace: ns.to_string(),
115 metadata: entity.properties.clone(),
116 updated_at: chrono::Utc::now(),
117 })
118 .await?;
119
120 if self.config().embedding_model.is_some() {
121 let vector = self.embed(&body).await?;
122 self.vectors(namespace)?
123 .insert(entity.id, SubstrateKind::Entity, ns, vector)
124 .await?;
125 }
126
127 Ok(entity)
128 }
129
130 pub async fn get_entity(
135 &self,
136 namespace: Option<&str>,
137 id: Uuid,
138 ) -> RuntimeResult<Option<Entity>> {
139 let entity = match self.entities(namespace)?.get_entity(id).await? {
140 Some(e) => e,
141 None => return Ok(None),
142 };
143 if entity.namespace != self.ns(namespace) {
144 return Ok(None);
145 }
146 Ok(Some(entity))
147 }
148
149 pub async fn list_entities(
151 &self,
152 namespace: Option<&str>,
153 kind: Option<&str>,
154 limit: u32,
155 ) -> RuntimeResult<Vec<Entity>> {
156 let filter = EntityFilter {
157 kinds: match kind {
158 Some(k) => vec![k.to_string()],
159 None => vec![],
160 },
161 ..Default::default()
162 };
163 let page = self
164 .entities(namespace)?
165 .query_entities(self.ns(namespace), filter, PageRequest { offset: 0, limit })
166 .await?;
167 Ok(page.items)
168 }
169
170 pub async fn list_events(
172 &self,
173 namespace: Option<&str>,
174 filter: EventFilter,
175 limit: u32,
176 offset: u32,
177 ) -> RuntimeResult<Page<Event>> {
178 let limit = limit.clamp(1, 1000);
179 let page = self
180 .events(namespace)?
181 .query_events(
182 filter,
183 PageRequest {
184 offset: offset.into(),
185 limit,
186 },
187 )
188 .await?;
189 Ok(page)
190 }
191
192 async fn validate_edge_relation_endpoints(
206 &self,
207 namespace: Option<&str>,
208 source_id: Uuid,
209 target_id: Uuid,
210 relation: EdgeRelation,
211 ) -> RuntimeResult<()> {
212 if relation == EdgeRelation::Annotates {
213 match self.resolve(namespace, source_id).await? {
215 Some(Resolved::Note(_)) => {}
216 Some(_) => {
217 return Err(RuntimeError::InvalidInput(format!(
218 "annotates source {source_id} must be a note"
219 )));
220 }
221 None => {
222 if self.get_edge(namespace, source_id).await?.is_some() {
224 return Err(RuntimeError::InvalidInput(format!(
225 "annotates source {source_id} must be a note"
226 )));
227 }
228 return Err(RuntimeError::NotFound(format!(
229 "link source {source_id} not found in namespace"
230 )));
231 }
232 }
233 if !self.substrate_exists_in_ns(namespace, target_id).await? {
235 return Err(RuntimeError::NotFound(format!(
236 "link target {target_id} not found in namespace"
237 )));
238 }
239 } else if relation == EdgeRelation::Supersedes {
240 let src = match self.resolve(namespace, source_id).await? {
243 Some(r) => r,
244 None => {
245 if self.get_edge(namespace, source_id).await?.is_some() {
246 return Err(RuntimeError::InvalidInput(format!(
247 "supersedes source {source_id} must be a note or entity (got edge)"
248 )));
249 }
250 return Err(RuntimeError::NotFound(format!(
251 "link source {source_id} not found in namespace"
252 )));
253 }
254 };
255 let tgt = match self.resolve(namespace, target_id).await? {
256 Some(r) => r,
257 None => {
258 if self.get_edge(namespace, target_id).await?.is_some() {
259 return Err(RuntimeError::InvalidInput(format!(
260 "supersedes target {target_id} must be a note or entity (got edge)"
261 )));
262 }
263 return Err(RuntimeError::NotFound(format!(
264 "link target {target_id} not found in namespace"
265 )));
266 }
267 };
268 match (&src, &tgt) {
269 (Resolved::Entity(_), Resolved::Entity(_)) => {}
270 (Resolved::Note(_), Resolved::Note(_)) => {}
271 (Resolved::Event(_), _) => {
272 return Err(RuntimeError::InvalidInput(format!(
273 "supersedes does not apply to events; source {source_id} is an event"
274 )));
275 }
276 (_, Resolved::Event(_)) => {
277 return Err(RuntimeError::InvalidInput(format!(
278 "supersedes does not apply to events; target {target_id} is an event"
279 )));
280 }
281 (Resolved::Entity(_), Resolved::Note(_)) => {
282 return Err(RuntimeError::InvalidInput(format!(
283 "supersedes endpoints must be the same substrate (note→note or entity→entity); \
284 got source={source_id} (entity) target={target_id} (note)"
285 )));
286 }
287 (Resolved::Note(_), Resolved::Entity(_)) => {
288 return Err(RuntimeError::InvalidInput(format!(
289 "supersedes endpoints must be the same substrate (note→note or entity→entity); \
290 got source={source_id} (note) target={target_id} (entity)"
291 )));
292 }
293 }
294 } else {
295 let src_res = self.resolve(namespace, source_id).await?;
302 let tgt_res = self.resolve(namespace, target_id).await?;
303
304 if pack_rule_allows(
305 &self.pack_edge_rules(),
306 relation,
307 src_res.as_ref(),
308 tgt_res.as_ref(),
309 ) {
310 return Ok(());
311 }
312
313 match src_res {
315 Some(Resolved::Entity(_)) => {}
316 Some(_) => {
317 return Err(RuntimeError::InvalidInput(format!(
318 "link source {source_id} must be an entity for relation {relation:?} \
319 (ADR-002: only `annotates` crosses substrates)"
320 )));
321 }
322 None => {
323 if self.get_edge(namespace, source_id).await?.is_some() {
324 return Err(RuntimeError::InvalidInput(format!(
325 "link source {source_id} must be an entity for relation {relation:?} \
326 (ADR-002: only `annotates` crosses substrates)"
327 )));
328 }
329 return Err(RuntimeError::NotFound(format!(
330 "link source {source_id} not found in namespace"
331 )));
332 }
333 }
334 match tgt_res {
335 Some(Resolved::Entity(_)) => {}
336 Some(_) => {
337 return Err(RuntimeError::InvalidInput(format!(
338 "link target {target_id} must be an entity for relation {relation:?} \
339 (ADR-002: only `annotates` crosses substrates)"
340 )));
341 }
342 None => {
343 if self.get_edge(namespace, target_id).await?.is_some() {
344 return Err(RuntimeError::InvalidInput(format!(
345 "link target {target_id} must be an entity for relation {relation:?} \
346 (ADR-002: only `annotates` crosses substrates)"
347 )));
348 }
349 return Err(RuntimeError::NotFound(format!(
350 "link target {target_id} not found in namespace"
351 )));
352 }
353 }
354 }
355 Ok(())
356 }
357
358 pub async fn link(
366 &self,
367 namespace: Option<&str>,
368 source_id: Uuid,
369 target_id: Uuid,
370 relation: EdgeRelation,
371 weight: f64,
372 ) -> RuntimeResult<Edge> {
373 self.validate_edge_relation_endpoints(namespace, source_id, target_id, relation)
374 .await?;
375 let edge = Edge {
376 id: LinkId::from(Uuid::new_v4()),
377 source_id,
378 target_id,
379 relation,
380 weight,
381 created_at: chrono::Utc::now(),
382 metadata: None,
383 };
384 self.graph(namespace)?.upsert_edge(edge.clone()).await?;
385 Ok(edge)
386 }
387
388 async fn substrate_exists_in_ns(
393 &self,
394 namespace: Option<&str>,
395 id: Uuid,
396 ) -> RuntimeResult<bool> {
397 if self.resolve(namespace, id).await?.is_some() {
398 return Ok(true);
399 }
400 Ok(self.get_edge(namespace, id).await?.is_some())
401 }
402
403 pub async fn neighbors(
408 &self,
409 namespace: Option<&str>,
410 node_id: Uuid,
411 direction: Direction,
412 limit: Option<u32>,
413 relations: Option<Vec<EdgeRelation>>,
414 ) -> RuntimeResult<Vec<NeighborHit>> {
415 let query = NeighborQuery {
416 direction,
417 relations,
418 limit,
419 min_weight: None,
420 };
421 Ok(self.graph(namespace)?.neighbors(node_id, query).await?)
422 }
423
424 pub async fn traverse(
426 &self,
427 namespace: Option<&str>,
428 request: TraversalRequest,
429 ) -> RuntimeResult<Vec<GraphPath>> {
430 Ok(self.graph(namespace)?.traverse(request).await?)
431 }
432
433 #[allow(clippy::too_many_arguments)]
444 pub async fn create_note(
445 &self,
446 namespace: Option<&str>,
447 kind: &str,
448 name: Option<&str>,
449 content: &str,
450 salience: f64,
451 properties: Option<serde_json::Value>,
452 annotates: Vec<Uuid>,
453 ) -> RuntimeResult<Note> {
454 self.create_note_inner(
455 namespace, kind, name, content, salience, None, properties, annotates,
456 )
457 .await
458 }
459
460 #[allow(clippy::too_many_arguments)]
462 pub async fn create_note_with_decay(
463 &self,
464 namespace: Option<&str>,
465 kind: &str,
466 name: Option<&str>,
467 content: &str,
468 salience: f64,
469 decay_factor: f64,
470 properties: Option<serde_json::Value>,
471 annotates: Vec<Uuid>,
472 ) -> RuntimeResult<Note> {
473 self.create_note_inner(
474 namespace,
475 kind,
476 name,
477 content,
478 salience,
479 Some(decay_factor),
480 properties,
481 annotates,
482 )
483 .await
484 }
485
486 #[allow(clippy::too_many_arguments)]
487 async fn create_note_inner(
488 &self,
489 namespace: Option<&str>,
490 kind: &str,
491 name: Option<&str>,
492 content: &str,
493 salience: f64,
494 decay_factor: Option<f64>,
495 properties: Option<serde_json::Value>,
496 annotates: Vec<Uuid>,
497 ) -> RuntimeResult<Note> {
498 let ns = self.ns(namespace);
499
500 for &target_id in &annotates {
502 if !self.substrate_exists_in_ns(namespace, target_id).await? {
503 return Err(RuntimeError::NotFound(format!(
504 "create_note annotates target {target_id} not found in namespace"
505 )));
506 }
507 }
508
509 let mut note = Note::new(ns, kind, content).with_salience(salience);
510 if let Some(df) = decay_factor {
511 note = note.with_decay(df);
512 }
513 if let Some(n) = name {
514 note = note.with_name(n);
515 }
516 if let Some(p) = properties {
517 note = note.with_properties(p);
518 }
519 self.notes(Some(ns))?.upsert_note(note.clone()).await?;
520
521 let body = match ¬e.name {
522 Some(n) => format!("{n} {}", note.content),
523 None => note.content.clone(),
524 };
525
526 self.text_for_notes(Some(ns))?
527 .upsert_document(TextDocument {
528 subject_id: note.id,
529 kind: SubstrateKind::Note,
530 title: note.name.clone(),
531 body,
532 tags: vec![],
533 namespace: ns.to_string(),
534 metadata: note.properties.clone(),
535 updated_at: chrono::Utc::now(),
536 })
537 .await?;
538
539 if self.config().embedding_model.is_some() {
540 let vector = self.embed(¬e.content).await?;
541 self.vectors(Some(ns))?
542 .insert(note.id, SubstrateKind::Note, ns, vector)
543 .await?;
544 }
545
546 for target_id in annotates {
547 self.link(Some(ns), note.id, target_id, EdgeRelation::Annotates, 1.0)
548 .await?;
549 }
550
551 Ok(note)
552 }
553
554 pub async fn list_notes(
556 &self,
557 namespace: Option<&str>,
558 kind: Option<&str>,
559 limit: u32,
560 ) -> RuntimeResult<Vec<Note>> {
561 let page = self
562 .notes(namespace)?
563 .query_notes(self.ns(namespace), kind, PageRequest { offset: 0, limit })
564 .await?;
565 Ok(page.items)
566 }
567
568 pub async fn search_notes(
578 &self,
579 namespace: Option<&str>,
580 query_text: &str,
581 query_vector: Option<Vec<f32>>,
582 limit: u32,
583 note_kind: Option<&str>,
584 ) -> RuntimeResult<Vec<NoteSearchHit>> {
585 const RRF_K: usize = 60;
586 let candidates = limit.saturating_mul(4).max(limit);
587 let ns = self.ns(namespace).to_string();
588
589 let text_hits = self
591 .text_for_notes(namespace)?
592 .search(TextSearchRequest {
593 query: query_text.to_string(),
594 mode: TextQueryMode::Plain,
595 filter: Some(TextFilter {
596 namespaces: vec![ns.clone()],
597 ..TextFilter::default()
598 }),
599 top_k: candidates,
600 snippet_chars: 200,
601 })
602 .await?;
603
604 let vector_hits = if let Some(vec) = query_vector {
606 self.vectors(namespace)?
607 .search(VectorSearchRequest {
608 query_embedding: vec,
609 top_k: candidates,
610 namespace: Some(ns.clone()),
611 kind: Some(SubstrateKind::Note),
612 })
613 .await?
614 } else {
615 vec![]
616 };
617
618 let mut buckets: HashMap<Uuid, DeterministicScore> = HashMap::new();
620 for (i, hit) in text_hits.into_iter().enumerate() {
621 let rank = i + 1;
622 let entry = buckets.entry(hit.subject_id).or_default();
623 *entry = *entry + rrf_score(rank, RRF_K);
624 }
625 for (i, hit) in vector_hits.into_iter().enumerate() {
626 let rank = i + 1;
627 let entry = buckets.entry(hit.subject_id).or_default();
628 *entry = *entry + rrf_score(rank, RRF_K);
629 }
630
631 let candidate_ids: Vec<Uuid> = buckets.keys().copied().collect();
632 if candidate_ids.is_empty() {
633 return Ok(vec![]);
634 }
635
636 let note_store = self.notes(namespace)?;
641 let mut alive_notes: HashMap<Uuid, Note> = HashMap::new();
642 for id in &candidate_ids {
643 if let Some(note) = note_store.get_note(*id).await? {
644 if note.deleted_at.is_some() {
645 continue;
646 }
647 if let Some(want_kind) = note_kind {
648 if note.kind != want_kind {
649 continue;
650 }
651 }
652 alive_notes.insert(*id, note);
653 }
654 }
655
656 if !alive_notes.is_empty() {
659 let graph = self.graph(namespace)?;
660 let mut superseded: std::collections::HashSet<Uuid> = std::collections::HashSet::new();
661 for ¬e_id in alive_notes.keys() {
662 let inbound = graph
663 .neighbors(
664 note_id,
665 NeighborQuery {
666 direction: Direction::In,
667 relations: Some(vec![EdgeRelation::Supersedes]),
668 limit: Some(1),
669 min_weight: None,
670 },
671 )
672 .await?;
673 if !inbound.is_empty() {
674 superseded.insert(note_id);
675 }
676 }
677 alive_notes.retain(|id, _| !superseded.contains(id));
678 }
679
680 let mut hits: Vec<NoteSearchHit> = buckets
682 .into_iter()
683 .filter_map(|(id, rrf)| {
684 let note = alive_notes.get(&id)?;
685 let weight = 0.5 + 0.5 * note.salience;
686 let weighted = DeterministicScore::from_f64(rrf.to_f64() * weight);
687 Some(NoteSearchHit {
688 note_id: id,
689 score: weighted,
690 })
691 })
692 .collect();
693
694 hits.sort_by(|a, b| b.score.cmp(&a.score).then(a.note_id.cmp(&b.note_id)));
695 hits.truncate(limit as usize);
696 Ok(hits)
697 }
698
699 pub async fn resolve_prefix(
706 &self,
707 namespace: Option<&str>,
708 prefix: &str,
709 ) -> RuntimeResult<Option<Uuid>> {
710 use khive_storage::types::{SqlStatement, SqlValue};
711
712 let ns = self.ns(namespace).to_string();
713 let pattern = format!("{}%", prefix);
714
715 let tables = [
716 ("entities", true),
717 ("notes", true),
718 ("events", false),
719 ("graph_edges", false),
720 ];
721
722 let mut matches: Vec<String> = Vec::new();
723 let mut reader = self.sql().reader().await.map_err(RuntimeError::Storage)?;
724
725 for (table, has_deleted_at) in tables {
726 let deleted_filter = if has_deleted_at {
727 " AND deleted_at IS NULL"
728 } else {
729 ""
730 };
731 let sql = SqlStatement {
732 sql: format!(
733 "SELECT id FROM {table} WHERE id LIKE ?1 AND namespace = ?2{deleted_filter} LIMIT 2"
734 ),
735 params: vec![
736 SqlValue::Text(pattern.clone()),
737 SqlValue::Text(ns.clone()),
738 ],
739 label: Some("resolve_prefix".into()),
740 };
741 match reader.query_all(sql).await {
742 Ok(rows) => {
743 for row in rows {
744 if let Some(col) = row.columns.first() {
745 if let SqlValue::Text(s) = &col.value {
746 matches.push(s.clone());
747 }
748 }
749 }
750 }
751 Err(e) => {
752 let msg = e.to_string();
753 if msg.contains("no such table") {
754 continue;
755 }
756 return Err(RuntimeError::Storage(e));
757 }
758 }
759 if matches.len() > 1 {
760 break;
761 }
762 }
763
764 match matches.len() {
765 0 => Ok(None),
766 1 => {
767 let uuid = Uuid::from_str(&matches[0])
768 .map_err(|e| RuntimeError::Internal(format!("stored UUID is invalid: {e}")))?;
769 Ok(Some(uuid))
770 }
771 _ => Err(RuntimeError::Ambiguous(format!(
772 "prefix '{prefix}' matches multiple UUIDs"
773 ))),
774 }
775 }
776
777 pub async fn resolve(
782 &self,
783 namespace: Option<&str>,
784 id: Uuid,
785 ) -> RuntimeResult<Option<Resolved>> {
786 let ns = self.ns(namespace);
787
788 if let Some(entity) = self.get_entity(namespace, id).await? {
790 return Ok(Some(Resolved::Entity(entity)));
791 }
792
793 if let Some(note) = self.notes(namespace)?.get_note(id).await? {
795 if note.namespace == ns {
796 return Ok(Some(Resolved::Note(note)));
797 }
798 }
799
800 if let Some(event) = self.events(namespace)?.get_event(id).await? {
802 if event.namespace == ns {
803 return Ok(Some(Resolved::Event(event)));
804 }
805 }
806
807 Ok(None)
808 }
809
810 pub async fn delete_note(
815 &self,
816 namespace: Option<&str>,
817 id: Uuid,
818 hard: bool,
819 ) -> RuntimeResult<bool> {
820 let ns = self.ns(namespace);
821 let note_store = self.notes(namespace)?;
822 let note = match note_store.get_note(id).await? {
823 Some(n) => n,
824 None => return Ok(false),
825 };
826 if note.namespace != ns {
827 return Ok(false);
828 }
829 let mode = if hard {
830 DeleteMode::Hard
831 } else {
832 DeleteMode::Soft
833 };
834 Ok(note_store.delete_note(id, mode).await?)
835 }
836
837 pub async fn query(
845 &self,
846 namespace: Option<&str>,
847 query: &str,
848 ) -> RuntimeResult<Vec<khive_storage::types::SqlRow>> {
849 let ns = self.ns(namespace);
850 let ast = khive_query::parse_auto(query)?;
851 let opts = khive_query::CompileOptions {
852 scopes: vec![ns.to_string()],
853 ..Default::default()
854 };
855 let compiled = khive_query::compile(&ast, &opts)?;
856 let mut reader = self.sql().reader().await?;
857 let stmt = SqlStatement {
858 sql: compiled.sql,
859 params: compiled.params,
860 label: None,
861 };
862 Ok(reader.query_all(stmt).await?)
863 }
864
865 pub async fn delete_entity(
874 &self,
875 namespace: Option<&str>,
876 id: Uuid,
877 hard: bool,
878 ) -> RuntimeResult<bool> {
879 let entity = match self.entities(namespace)?.get_entity(id).await? {
880 Some(e) => e,
881 None => return Ok(false),
882 };
883 if entity.namespace != self.ns(namespace) {
884 return Ok(false);
885 }
886 let mode = if hard {
887 DeleteMode::Hard
888 } else {
889 DeleteMode::Soft
890 };
891
892 if hard {
894 let graph = self.graph(namespace)?;
895 for direction in [Direction::Out, Direction::In] {
896 let hits = graph
897 .neighbors(
898 id,
899 NeighborQuery {
900 direction,
901 relations: None,
902 limit: None,
903 min_weight: None,
904 },
905 )
906 .await?;
907 for hit in hits {
908 graph.delete_edge(LinkId::from(hit.edge_id)).await?;
909 }
910 }
911 self.remove_from_indexes(namespace, id).await?;
912 }
913
914 Ok(self.entities(namespace)?.delete_entity(id, mode).await?)
915 }
916
917 pub async fn count_entities(
919 &self,
920 namespace: Option<&str>,
921 kind: Option<&str>,
922 ) -> RuntimeResult<u64> {
923 let filter = EntityFilter {
924 kinds: match kind {
925 Some(k) => vec![k.to_string()],
926 None => vec![],
927 },
928 ..Default::default()
929 };
930 Ok(self
931 .entities(namespace)?
932 .count_entities(self.ns(namespace), filter)
933 .await?)
934 }
935
936 pub async fn get_edge(
940 &self,
941 namespace: Option<&str>,
942 edge_id: Uuid,
943 ) -> RuntimeResult<Option<Edge>> {
944 Ok(self
945 .graph(namespace)?
946 .get_edge(LinkId::from(edge_id))
947 .await?)
948 }
949
950 pub async fn list_edges(
952 &self,
953 namespace: Option<&str>,
954 filter: crate::curation::EdgeListFilter,
955 limit: u32,
956 ) -> RuntimeResult<Vec<Edge>> {
957 let limit = limit.clamp(1, 1000);
958 let page = self
959 .graph(namespace)?
960 .query_edges(
961 filter.into(),
962 vec![SortOrder {
963 field: EdgeSortField::CreatedAt,
964 direction: khive_storage::types::SortDirection::Asc,
965 }],
966 PageRequest { offset: 0, limit },
967 )
968 .await?;
969 Ok(page.items)
970 }
971
972 pub async fn update_edge(
979 &self,
980 namespace: Option<&str>,
981 edge_id: Uuid,
982 relation: Option<EdgeRelation>,
983 weight: Option<f64>,
984 ) -> RuntimeResult<Edge> {
985 let graph = self.graph(namespace)?;
986 let mut edge = graph
987 .get_edge(LinkId::from(edge_id))
988 .await?
989 .ok_or_else(|| crate::RuntimeError::NotFound(format!("edge {edge_id}")))?;
990
991 if let Some(r) = relation {
992 self.validate_edge_relation_endpoints(namespace, edge.source_id, edge.target_id, r)
994 .await?;
995 edge.relation = r;
996 }
997 if let Some(w) = weight {
998 edge.weight = w.clamp(0.0, 1.0);
999 }
1000
1001 graph.upsert_edge(edge.clone()).await?;
1002 Ok(edge)
1003 }
1004
1005 pub async fn delete_edge(&self, namespace: Option<&str>, edge_id: Uuid) -> RuntimeResult<bool> {
1007 Ok(self
1008 .graph(namespace)?
1009 .delete_edge(LinkId::from(edge_id))
1010 .await?)
1011 }
1012
1013 pub async fn count_edges(
1015 &self,
1016 namespace: Option<&str>,
1017 filter: crate::curation::EdgeListFilter,
1018 ) -> RuntimeResult<u64> {
1019 Ok(self.graph(namespace)?.count_edges(filter.into()).await?)
1020 }
1021}
1022
1023#[cfg(test)]
1024mod tests {
1025 use super::*;
1026 use crate::curation::EdgeListFilter;
1027 use crate::runtime::KhiveRuntime;
1028
1029 fn rt() -> KhiveRuntime {
1030 KhiveRuntime::memory().unwrap()
1031 }
1032
1033 #[tokio::test]
1034 async fn update_edge_changes_weight() {
1035 let rt = rt();
1036 let a = rt
1037 .create_entity(None, "concept", "A", None, None, vec![])
1038 .await
1039 .unwrap();
1040 let b = rt
1041 .create_entity(None, "concept", "B", None, None, vec![])
1042 .await
1043 .unwrap();
1044 let edge = rt
1045 .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
1046 .await
1047 .unwrap();
1048 let edge_id: Uuid = edge.id.into();
1049
1050 let updated = rt
1051 .update_edge(None, edge_id, None, Some(0.5))
1052 .await
1053 .unwrap();
1054 assert!((updated.weight - 0.5).abs() < 0.001);
1055 }
1056
1057 #[tokio::test]
1058 async fn update_edge_changes_relation() {
1059 let rt = rt();
1060 let a = rt
1061 .create_entity(None, "concept", "A", None, None, vec![])
1062 .await
1063 .unwrap();
1064 let b = rt
1065 .create_entity(None, "concept", "B", None, None, vec![])
1066 .await
1067 .unwrap();
1068 let edge = rt
1069 .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
1070 .await
1071 .unwrap();
1072 let edge_id: Uuid = edge.id.into();
1073
1074 let updated = rt
1075 .update_edge(None, edge_id, Some(EdgeRelation::VariantOf), None)
1076 .await
1077 .unwrap();
1078 assert_eq!(updated.relation, EdgeRelation::VariantOf);
1079 }
1080
1081 #[tokio::test]
1086 async fn update_edge_annotates_note_to_entity_set_supersedes_returns_invalid_input() {
1087 let rt = rt();
1088 let note = rt
1089 .create_note(None, "observation", None, "a note", 0.5, None, vec![])
1090 .await
1091 .unwrap();
1092 let entity = rt
1093 .create_entity(None, "concept", "E", None, None, vec![])
1094 .await
1095 .unwrap();
1096 let edge = rt
1098 .link(None, note.id, entity.id, EdgeRelation::Annotates, 1.0)
1099 .await
1100 .unwrap();
1101 let edge_id: Uuid = edge.id.into();
1102
1103 let result = rt
1105 .update_edge(None, edge_id, Some(EdgeRelation::Supersedes), None)
1106 .await;
1107 assert!(
1108 matches!(result, Err(RuntimeError::InvalidInput(_))),
1109 "update to Supersedes on note→entity edge must return InvalidInput, got {result:?}"
1110 );
1111
1112 let fetched = rt.get_edge(None, edge_id).await.unwrap().unwrap();
1114 assert_eq!(
1115 fetched.relation,
1116 EdgeRelation::Annotates,
1117 "edge relation must be unchanged after failed update"
1118 );
1119 }
1120
1121 #[tokio::test]
1124 async fn update_edge_entity_to_entity_set_annotates_returns_invalid_input() {
1125 let rt = rt();
1126 let a = rt
1127 .create_entity(None, "concept", "A", None, None, vec![])
1128 .await
1129 .unwrap();
1130 let b = rt
1131 .create_entity(None, "concept", "B", None, None, vec![])
1132 .await
1133 .unwrap();
1134 let edge = rt
1135 .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
1136 .await
1137 .unwrap();
1138 let edge_id: Uuid = edge.id.into();
1139
1140 let result = rt
1141 .update_edge(None, edge_id, Some(EdgeRelation::Annotates), None)
1142 .await;
1143 assert!(
1144 matches!(result, Err(RuntimeError::InvalidInput(_))),
1145 "update to Annotates on entity→entity edge must return InvalidInput, got {result:?}"
1146 );
1147 }
1148
1149 #[tokio::test]
1152 async fn update_edge_entity_to_entity_set_supersedes_succeeds() {
1153 let rt = rt();
1154 let a = rt
1155 .create_entity(None, "concept", "A", None, None, vec![])
1156 .await
1157 .unwrap();
1158 let b = rt
1159 .create_entity(None, "concept", "B", None, None, vec![])
1160 .await
1161 .unwrap();
1162 let edge = rt
1163 .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
1164 .await
1165 .unwrap();
1166 let edge_id: Uuid = edge.id.into();
1167
1168 let updated = rt
1169 .update_edge(None, edge_id, Some(EdgeRelation::Supersedes), None)
1170 .await
1171 .unwrap();
1172 assert_eq!(updated.relation, EdgeRelation::Supersedes);
1173
1174 let fetched = rt.get_edge(None, edge_id).await.unwrap().unwrap();
1176 assert_eq!(fetched.relation, EdgeRelation::Supersedes);
1177 }
1178
1179 #[tokio::test]
1181 async fn update_edge_weight_only_skips_validation() {
1182 let rt = rt();
1183 let a = rt
1184 .create_entity(None, "concept", "A", None, None, vec![])
1185 .await
1186 .unwrap();
1187 let b = rt
1188 .create_entity(None, "concept", "B", None, None, vec![])
1189 .await
1190 .unwrap();
1191 let edge = rt
1192 .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
1193 .await
1194 .unwrap();
1195 let edge_id: Uuid = edge.id.into();
1196
1197 let updated = rt
1198 .update_edge(None, edge_id, None, Some(0.3))
1199 .await
1200 .unwrap();
1201 assert_eq!(updated.relation, EdgeRelation::Extends);
1202 assert!((updated.weight - 0.3).abs() < 0.001);
1203 }
1204
1205 #[tokio::test]
1207 async fn update_edge_same_class_relation_change_succeeds() {
1208 let rt = rt();
1209 let a = rt
1210 .create_entity(None, "concept", "A", None, None, vec![])
1211 .await
1212 .unwrap();
1213 let b = rt
1214 .create_entity(None, "concept", "B", None, None, vec![])
1215 .await
1216 .unwrap();
1217 let edge = rt
1218 .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
1219 .await
1220 .unwrap();
1221 let edge_id: Uuid = edge.id.into();
1222
1223 let updated = rt
1224 .update_edge(None, edge_id, Some(EdgeRelation::VariantOf), None)
1225 .await
1226 .unwrap();
1227 assert_eq!(updated.relation, EdgeRelation::VariantOf);
1228 }
1229
1230 #[tokio::test]
1231 async fn list_edges_filters_by_relation() {
1232 let rt = rt();
1233 let a = rt
1234 .create_entity(None, "concept", "A", None, None, vec![])
1235 .await
1236 .unwrap();
1237 let b = rt
1238 .create_entity(None, "concept", "B", None, None, vec![])
1239 .await
1240 .unwrap();
1241 let c = rt
1242 .create_entity(None, "concept", "C", None, None, vec![])
1243 .await
1244 .unwrap();
1245
1246 rt.link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
1247 .await
1248 .unwrap();
1249 rt.link(None, a.id, c.id, EdgeRelation::DependsOn, 1.0)
1250 .await
1251 .unwrap();
1252
1253 let filter = EdgeListFilter {
1254 relations: vec![EdgeRelation::Extends],
1255 ..Default::default()
1256 };
1257 let edges = rt.list_edges(None, filter, 100).await.unwrap();
1258 assert_eq!(edges.len(), 1);
1259 assert_eq!(edges[0].relation, EdgeRelation::Extends);
1260 }
1261
1262 #[tokio::test]
1263 async fn list_edges_filters_by_source() {
1264 let rt = rt();
1265 let a = rt
1266 .create_entity(None, "concept", "A", None, None, vec![])
1267 .await
1268 .unwrap();
1269 let b = rt
1270 .create_entity(None, "concept", "B", None, None, vec![])
1271 .await
1272 .unwrap();
1273 let c = rt
1274 .create_entity(None, "concept", "C", None, None, vec![])
1275 .await
1276 .unwrap();
1277 let d = rt
1278 .create_entity(None, "concept", "D", None, None, vec![])
1279 .await
1280 .unwrap();
1281
1282 rt.link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
1283 .await
1284 .unwrap();
1285 rt.link(None, c.id, d.id, EdgeRelation::Extends, 1.0)
1286 .await
1287 .unwrap();
1288
1289 let filter = EdgeListFilter {
1290 source_id: Some(a.id),
1291 ..Default::default()
1292 };
1293 let edges = rt.list_edges(None, filter, 100).await.unwrap();
1294 assert_eq!(edges.len(), 1);
1295 let src: Uuid = edges[0].source_id;
1296 assert_eq!(src, a.id);
1297 }
1298
1299 #[tokio::test]
1300 async fn delete_edge_removes_from_storage() {
1301 let rt = rt();
1302 let a = rt
1303 .create_entity(None, "concept", "A", None, None, vec![])
1304 .await
1305 .unwrap();
1306 let b = rt
1307 .create_entity(None, "concept", "B", None, None, vec![])
1308 .await
1309 .unwrap();
1310 let edge = rt
1311 .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
1312 .await
1313 .unwrap();
1314 let edge_id: Uuid = edge.id.into();
1315
1316 let deleted = rt.delete_edge(None, edge_id).await.unwrap();
1317 assert!(deleted);
1318
1319 let fetched = rt.get_edge(None, edge_id).await.unwrap();
1320 assert!(fetched.is_none(), "edge should be gone after delete");
1321 }
1322
1323 #[tokio::test]
1324 async fn count_edges_matches_filter() {
1325 let rt = rt();
1326 let a = rt
1327 .create_entity(None, "concept", "A", None, None, vec![])
1328 .await
1329 .unwrap();
1330 let b = rt
1331 .create_entity(None, "concept", "B", None, None, vec![])
1332 .await
1333 .unwrap();
1334 let c = rt
1335 .create_entity(None, "concept", "C", None, None, vec![])
1336 .await
1337 .unwrap();
1338
1339 rt.link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
1340 .await
1341 .unwrap();
1342 rt.link(None, a.id, c.id, EdgeRelation::DependsOn, 1.0)
1343 .await
1344 .unwrap();
1345
1346 let all = rt
1347 .count_edges(None, EdgeListFilter::default())
1348 .await
1349 .unwrap();
1350 assert_eq!(all, 2);
1351
1352 let just_extends = rt
1353 .count_edges(
1354 None,
1355 EdgeListFilter {
1356 relations: vec![EdgeRelation::Extends],
1357 ..Default::default()
1358 },
1359 )
1360 .await
1361 .unwrap();
1362 assert_eq!(just_extends, 1);
1363 }
1364
1365 #[tokio::test]
1366 async fn get_entity_namespace_isolation() {
1367 let rt = rt();
1368 let entity = rt
1369 .create_entity(Some("ns-a"), "concept", "Alpha", None, None, vec![])
1370 .await
1371 .unwrap();
1372
1373 let found = rt.get_entity(Some("ns-a"), entity.id).await.unwrap();
1375 assert!(found.is_some(), "should be visible in its own namespace");
1376
1377 let not_found = rt.get_entity(Some("ns-b"), entity.id).await.unwrap();
1379 assert!(
1380 not_found.is_none(),
1381 "should not be visible across namespaces"
1382 );
1383 }
1384
1385 #[tokio::test]
1386 async fn delete_entity_namespace_isolation() {
1387 let rt = rt();
1388 let entity = rt
1389 .create_entity(Some("ns-a"), "concept", "Beta", None, None, vec![])
1390 .await
1391 .unwrap();
1392
1393 let deleted = rt
1395 .delete_entity(Some("ns-b"), entity.id, true)
1396 .await
1397 .unwrap();
1398 assert!(!deleted, "cross-namespace delete must return false");
1399
1400 let still_there = rt.get_entity(Some("ns-a"), entity.id).await.unwrap();
1402 assert!(
1403 still_there.is_some(),
1404 "entity must survive cross-ns delete attempt"
1405 );
1406
1407 let deleted_ok = rt
1409 .delete_entity(Some("ns-a"), entity.id, true)
1410 .await
1411 .unwrap();
1412 assert!(deleted_ok, "same-namespace delete must succeed");
1413 }
1414
1415 #[tokio::test]
1418 async fn create_note_indexes_into_fts5() {
1419 let rt = rt();
1420 let note = rt
1421 .create_note(
1422 None,
1423 "observation",
1424 None,
1425 "FlashAttention reduces memory by using tiling",
1426 0.8,
1427 None,
1428 vec![],
1429 )
1430 .await
1431 .unwrap();
1432
1433 let ns = rt.ns(None).to_string();
1435 let hits = rt
1436 .text_for_notes(None)
1437 .unwrap()
1438 .search(khive_storage::types::TextSearchRequest {
1439 query: "FlashAttention".to_string(),
1440 mode: khive_storage::types::TextQueryMode::Plain,
1441 filter: Some(khive_storage::types::TextFilter {
1442 namespaces: vec![ns],
1443 ..Default::default()
1444 }),
1445 top_k: 10,
1446 snippet_chars: 100,
1447 })
1448 .await
1449 .unwrap();
1450
1451 assert!(
1452 hits.iter().any(|h| h.subject_id == note.id),
1453 "note should be indexed in FTS5 after create"
1454 );
1455 }
1456
1457 #[tokio::test]
1458 async fn create_note_with_properties() {
1459 let rt = rt();
1460 let props = serde_json::json!({"source": "arxiv:2205.14135"});
1461 let note = rt
1462 .create_note(
1463 None,
1464 "insight",
1465 None,
1466 "FlashAttention is IO-aware",
1467 0.9,
1468 Some(props.clone()),
1469 vec![],
1470 )
1471 .await
1472 .unwrap();
1473
1474 assert_eq!(note.properties.as_ref().unwrap(), &props);
1475 }
1476
1477 #[tokio::test]
1478 async fn create_note_creates_annotates_edges() {
1479 let rt = rt();
1480 let entity = rt
1481 .create_entity(None, "concept", "FlashAttention", None, None, vec![])
1482 .await
1483 .unwrap();
1484
1485 let note = rt
1486 .create_note(
1487 None,
1488 "observation",
1489 None,
1490 "FlashAttention uses SRAM tiling for memory efficiency",
1491 0.9,
1492 None,
1493 vec![entity.id],
1494 )
1495 .await
1496 .unwrap();
1497
1498 let out_neighbors = rt
1500 .neighbors(
1501 None,
1502 note.id,
1503 Direction::Out,
1504 None,
1505 Some(vec![EdgeRelation::Annotates]),
1506 )
1507 .await
1508 .unwrap();
1509 assert_eq!(out_neighbors.len(), 1);
1510 assert_eq!(out_neighbors[0].node_id, entity.id);
1511 assert_eq!(out_neighbors[0].relation, EdgeRelation::Annotates);
1512
1513 let in_neighbors = rt
1515 .neighbors(
1516 None,
1517 entity.id,
1518 Direction::In,
1519 None,
1520 Some(vec![EdgeRelation::Annotates]),
1521 )
1522 .await
1523 .unwrap();
1524 assert_eq!(in_neighbors.len(), 1);
1525 assert_eq!(in_neighbors[0].node_id, note.id);
1526 }
1527
1528 #[tokio::test]
1529 async fn neighbors_without_relation_filter_returns_all() {
1530 let rt = rt();
1531 let a = rt
1532 .create_entity(None, "concept", "A", None, None, vec![])
1533 .await
1534 .unwrap();
1535 let b = rt
1536 .create_entity(None, "concept", "B", None, None, vec![])
1537 .await
1538 .unwrap();
1539 let c = rt
1540 .create_entity(None, "concept", "C", None, None, vec![])
1541 .await
1542 .unwrap();
1543
1544 rt.link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
1545 .await
1546 .unwrap();
1547 rt.link(None, a.id, c.id, EdgeRelation::DependsOn, 1.0)
1548 .await
1549 .unwrap();
1550
1551 let all = rt
1552 .neighbors(None, a.id, Direction::Out, None, None)
1553 .await
1554 .unwrap();
1555 assert_eq!(all.len(), 2);
1556 }
1557
1558 #[tokio::test]
1559 async fn neighbors_with_relation_filter_returns_subset() {
1560 let rt = rt();
1561 let a = rt
1562 .create_entity(None, "concept", "A", None, None, vec![])
1563 .await
1564 .unwrap();
1565 let b = rt
1566 .create_entity(None, "concept", "B", None, None, vec![])
1567 .await
1568 .unwrap();
1569 let c = rt
1570 .create_entity(None, "concept", "C", None, None, vec![])
1571 .await
1572 .unwrap();
1573
1574 rt.link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
1575 .await
1576 .unwrap();
1577 rt.link(None, a.id, c.id, EdgeRelation::DependsOn, 1.0)
1578 .await
1579 .unwrap();
1580
1581 let filtered = rt
1582 .neighbors(
1583 None,
1584 a.id,
1585 Direction::Out,
1586 None,
1587 Some(vec![EdgeRelation::Extends]),
1588 )
1589 .await
1590 .unwrap();
1591 assert_eq!(filtered.len(), 1);
1592 assert_eq!(filtered[0].node_id, b.id);
1593 assert_eq!(filtered[0].relation, EdgeRelation::Extends);
1594 }
1595
1596 #[tokio::test]
1597 async fn search_notes_returns_relevant_note() {
1598 let rt = rt();
1599 rt.create_note(
1600 None,
1601 "observation",
1602 None,
1603 "GQA reduces KV cache memory for large models",
1604 0.8,
1605 None,
1606 vec![],
1607 )
1608 .await
1609 .unwrap();
1610
1611 let results = rt
1612 .search_notes(None, "GQA KV cache", None, 10, None)
1613 .await
1614 .unwrap();
1615
1616 assert!(!results.is_empty(), "search should return the indexed note");
1617 }
1618
1619 #[tokio::test]
1620 async fn search_notes_excludes_soft_deleted() {
1621 let rt = rt();
1622 let note = rt
1623 .create_note(
1624 None,
1625 "observation",
1626 None,
1627 "RoPE positional encoding rotary embeddings",
1628 0.7,
1629 None,
1630 vec![],
1631 )
1632 .await
1633 .unwrap();
1634
1635 rt.notes(None)
1637 .unwrap()
1638 .delete_note(note.id, DeleteMode::Soft)
1639 .await
1640 .unwrap();
1641
1642 let results = rt
1643 .search_notes(None, "RoPE rotary positional", None, 10, None)
1644 .await
1645 .unwrap();
1646
1647 assert!(
1648 results.iter().all(|h| h.note_id != note.id),
1649 "soft-deleted note should be excluded from search"
1650 );
1651 }
1652
1653 #[tokio::test]
1654 async fn resolve_returns_entity() {
1655 let rt = rt();
1656 let entity = rt
1657 .create_entity(None, "concept", "LoRA", None, None, vec![])
1658 .await
1659 .unwrap();
1660
1661 let resolved = rt.resolve(None, entity.id).await.unwrap();
1662 match resolved {
1663 Some(Resolved::Entity(e)) => assert_eq!(e.id, entity.id),
1664 other => panic!("expected Resolved::Entity, got {:?}", other),
1665 }
1666 }
1667
1668 #[tokio::test]
1669 async fn resolve_returns_note() {
1670 let rt = rt();
1671 let note = rt
1672 .create_note(
1673 None,
1674 "observation",
1675 None,
1676 "LoRA fine-tunes LLMs with low-rank adapters",
1677 0.85,
1678 None,
1679 vec![],
1680 )
1681 .await
1682 .unwrap();
1683
1684 let resolved = rt.resolve(None, note.id).await.unwrap();
1685 match resolved {
1686 Some(Resolved::Note(n)) => assert_eq!(n.id, note.id),
1687 other => panic!("expected Resolved::Note, got {:?}", other),
1688 }
1689 }
1690
1691 #[tokio::test]
1692 async fn resolve_returns_none_for_unknown_uuid() {
1693 let rt = rt();
1694 let unknown = Uuid::new_v4();
1695 let resolved = rt.resolve(None, unknown).await.unwrap();
1696 assert!(resolved.is_none(), "unknown UUID should resolve to None");
1697 }
1698
1699 #[tokio::test]
1700 async fn resolve_prefix_finds_entity_in_own_namespace() {
1701 let rt = rt();
1702 let entity = rt
1703 .create_entity(None, "concept", "PrefixTest", None, None, vec![])
1704 .await
1705 .unwrap();
1706 let prefix = &entity.id.to_string()[..8];
1707
1708 let resolved = rt.resolve_prefix(None, prefix).await.unwrap();
1709 assert_eq!(resolved, Some(entity.id));
1710 }
1711
1712 #[tokio::test]
1713 async fn resolve_prefix_invisible_across_namespaces() {
1714 let rt = rt();
1715 let entity = rt
1716 .create_entity(Some("ns_a"), "concept", "Invisible", None, None, vec![])
1717 .await
1718 .unwrap();
1719 let prefix = &entity.id.to_string()[..8];
1720
1721 let resolved = rt.resolve_prefix(Some("ns_b"), prefix).await.unwrap();
1723 assert_eq!(resolved, None);
1724 }
1725
1726 #[tokio::test]
1727 async fn resolve_prefix_ambiguous_same_namespace() {
1728 use khive_storage::entity::Entity;
1729
1730 let rt = rt();
1731 let id_a = Uuid::parse_str("aabbccdd-1111-4000-8000-000000000001").unwrap();
1733 let id_b = Uuid::parse_str("aabbccdd-2222-4000-8000-000000000002").unwrap();
1734
1735 let mut entity_a = Entity::new("local", "concept", "AmbigA");
1736 entity_a.id = id_a;
1737 let mut entity_b = Entity::new("local", "concept", "AmbigB");
1738 entity_b.id = id_b;
1739
1740 let store = rt.entities(None).unwrap();
1741 store.upsert_entity(entity_a).await.unwrap();
1742 store.upsert_entity(entity_b).await.unwrap();
1743
1744 let result = rt.resolve_prefix(None, "aabbccdd").await;
1745 assert!(
1746 result.is_err(),
1747 "shared 8-char prefix must return Ambiguous error"
1748 );
1749 }
1750
1751 #[tokio::test]
1754 async fn link_phantom_source_returns_not_found() {
1755 let rt = rt();
1756 let b = rt
1757 .create_entity(None, "concept", "B", None, None, vec![])
1758 .await
1759 .unwrap();
1760 let phantom = Uuid::new_v4();
1761
1762 let result = rt
1763 .link(None, phantom, b.id, EdgeRelation::Extends, 1.0)
1764 .await;
1765 match result {
1766 Err(RuntimeError::NotFound(msg)) => {
1767 assert!(
1768 msg.contains("source"),
1769 "error message must name 'source': {msg}"
1770 );
1771 }
1772 other => panic!("expected NotFound for phantom source, got {other:?}"),
1773 }
1774 }
1775
1776 #[tokio::test]
1777 async fn link_phantom_target_returns_not_found() {
1778 let rt = rt();
1779 let a = rt
1780 .create_entity(None, "concept", "A", None, None, vec![])
1781 .await
1782 .unwrap();
1783 let phantom = Uuid::new_v4();
1784
1785 let result = rt
1786 .link(None, a.id, phantom, EdgeRelation::Extends, 1.0)
1787 .await;
1788 match result {
1789 Err(RuntimeError::NotFound(msg)) => {
1790 assert!(
1791 msg.contains("target"),
1792 "error message must name 'target': {msg}"
1793 );
1794 }
1795 other => panic!("expected NotFound for phantom target, got {other:?}"),
1796 }
1797 }
1798
1799 #[tokio::test]
1800 async fn link_real_entities_succeeds() {
1801 let rt = rt();
1802 let a = rt
1803 .create_entity(None, "concept", "A", None, None, vec![])
1804 .await
1805 .unwrap();
1806 let b = rt
1807 .create_entity(None, "concept", "B", None, None, vec![])
1808 .await
1809 .unwrap();
1810
1811 let edge = rt
1812 .link(None, a.id, b.id, EdgeRelation::Extends, 0.8)
1813 .await
1814 .unwrap();
1815 assert_eq!(edge.source_id, a.id);
1816 assert_eq!(edge.target_id, b.id);
1817 assert_eq!(edge.relation, EdgeRelation::Extends);
1818 }
1819
1820 #[tokio::test]
1821 async fn create_note_annotates_phantom_returns_not_found() {
1822 let rt = rt();
1823 let phantom = Uuid::new_v4();
1824
1825 let result = rt
1826 .create_note(
1827 None,
1828 "observation",
1829 None,
1830 "some content",
1831 0.5,
1832 None,
1833 vec![phantom],
1834 )
1835 .await;
1836 assert!(
1837 matches!(result, Err(RuntimeError::NotFound(_))),
1838 "annotates with phantom uuid must return NotFound, got {result:?}"
1839 );
1840 }
1841
1842 #[tokio::test]
1843 async fn create_note_annotates_real_entity_succeeds() {
1844 let rt = rt();
1845 let entity = rt
1846 .create_entity(None, "concept", "RealTarget", None, None, vec![])
1847 .await
1848 .unwrap();
1849
1850 let note = rt
1851 .create_note(
1852 None,
1853 "observation",
1854 None,
1855 "content",
1856 0.5,
1857 None,
1858 vec![entity.id],
1859 )
1860 .await
1861 .unwrap();
1862
1863 let neighbors = rt
1864 .neighbors(
1865 None,
1866 note.id,
1867 Direction::Out,
1868 None,
1869 Some(vec![EdgeRelation::Annotates]),
1870 )
1871 .await
1872 .unwrap();
1873 assert_eq!(neighbors.len(), 1);
1874 assert_eq!(neighbors[0].node_id, entity.id);
1875 }
1876
1877 #[tokio::test]
1878 async fn link_target_in_different_namespace_returns_not_found() {
1879 let rt = rt();
1880 let a = rt
1881 .create_entity(Some("ns-a"), "concept", "A", None, None, vec![])
1882 .await
1883 .unwrap();
1884 let b = rt
1885 .create_entity(Some("ns-b"), "concept", "B", None, None, vec![])
1886 .await
1887 .unwrap();
1888
1889 let result = rt
1891 .link(Some("ns-a"), a.id, b.id, EdgeRelation::Extends, 1.0)
1892 .await;
1893 assert!(
1894 matches!(result, Err(RuntimeError::NotFound(_))),
1895 "target in different namespace must return NotFound (fail-closed), got {result:?}"
1896 );
1897 }
1898
1899 #[tokio::test]
1900 async fn link_phantom_self_loop_returns_not_found() {
1901 let rt = rt();
1902 let phantom = Uuid::new_v4();
1903
1904 let result = rt
1905 .link(None, phantom, phantom, EdgeRelation::Extends, 1.0)
1906 .await;
1907 match result {
1908 Err(RuntimeError::NotFound(msg)) => {
1909 assert!(
1910 msg.contains("source"),
1911 "self-loop must fail on source first: {msg}"
1912 );
1913 }
1914 other => panic!("expected NotFound for phantom self-loop, got {other:?}"),
1915 }
1916 }
1917
1918 #[tokio::test]
1921 async fn link_note_to_edge_annotates_succeeds() {
1922 let rt = rt();
1923 let a = rt
1924 .create_entity(None, "concept", "A", None, None, vec![])
1925 .await
1926 .unwrap();
1927 let b = rt
1928 .create_entity(None, "concept", "B", None, None, vec![])
1929 .await
1930 .unwrap();
1931 let edge = rt
1933 .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
1934 .await
1935 .unwrap();
1936 let edge_uuid: Uuid = edge.id.into();
1937
1938 let note = rt
1940 .create_note(None, "observation", None, "edge note", 0.5, None, vec![])
1941 .await
1942 .unwrap();
1943
1944 let result = rt
1945 .link(None, note.id, edge_uuid, EdgeRelation::Annotates, 1.0)
1946 .await;
1947 assert!(
1948 result.is_ok(),
1949 "note→edge Annotates must succeed, got {result:?}"
1950 );
1951 }
1952
1953 #[tokio::test]
1954 async fn create_note_annotates_real_edge_succeeds() {
1955 let rt = rt();
1956 let a = rt
1957 .create_entity(None, "concept", "A", None, None, vec![])
1958 .await
1959 .unwrap();
1960 let b = rt
1961 .create_entity(None, "concept", "B", None, None, vec![])
1962 .await
1963 .unwrap();
1964 let edge = rt
1965 .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
1966 .await
1967 .unwrap();
1968 let edge_uuid: Uuid = edge.id.into();
1969
1970 let note = rt
1971 .create_note(
1972 None,
1973 "observation",
1974 None,
1975 "annotating an edge",
1976 0.5,
1977 None,
1978 vec![edge_uuid],
1979 )
1980 .await
1981 .unwrap();
1982
1983 let neighbors = rt
1984 .neighbors(
1985 None,
1986 note.id,
1987 Direction::Out,
1988 None,
1989 Some(vec![EdgeRelation::Annotates]),
1990 )
1991 .await
1992 .unwrap();
1993 assert_eq!(neighbors.len(), 1);
1994 assert_eq!(neighbors[0].node_id, edge_uuid);
1995 }
1996
1997 #[tokio::test]
1998 async fn create_note_annotates_phantom_is_atomic_no_note_persisted() {
1999 let rt = rt();
2000 let phantom = Uuid::new_v4();
2001
2002 let before_count = rt.list_notes(None, None, 1000).await.unwrap().len();
2003
2004 let result = rt
2005 .create_note(
2006 None,
2007 "observation",
2008 None,
2009 "should not persist",
2010 0.5,
2011 None,
2012 vec![phantom],
2013 )
2014 .await;
2015 assert!(
2016 matches!(result, Err(RuntimeError::NotFound(_))),
2017 "phantom annotates target must return NotFound, got {result:?}"
2018 );
2019
2020 let after_count = rt.list_notes(None, None, 1000).await.unwrap().len();
2022 assert_eq!(
2023 before_count, after_count,
2024 "failed create_note must not persist any note row (atomicity)"
2025 );
2026
2027 let search_hits = rt
2029 .search_notes(None, "should not persist", None, 10, None)
2030 .await
2031 .unwrap();
2032 assert!(
2033 search_hits.is_empty(),
2034 "failed create_note must not index into FTS (atomicity)"
2035 );
2036 }
2039
2040 #[tokio::test]
2044 async fn link_entity_to_edge_uuid_non_annotates_returns_invalid_input() {
2045 let rt = rt();
2046 let a = rt
2047 .create_entity(None, "concept", "A", None, None, vec![])
2048 .await
2049 .unwrap();
2050 let b = rt
2051 .create_entity(None, "concept", "B", None, None, vec![])
2052 .await
2053 .unwrap();
2054 let edge = rt
2056 .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
2057 .await
2058 .unwrap();
2059 let edge_uuid: Uuid = edge.id.into();
2060
2061 let result = rt
2062 .link(None, a.id, edge_uuid, EdgeRelation::Extends, 1.0)
2063 .await;
2064 match result {
2065 Err(RuntimeError::InvalidInput(msg)) => {
2066 assert!(
2067 msg.contains("target"),
2068 "error message must name 'target': {msg}"
2069 );
2070 }
2071 other => {
2072 panic!("expected InvalidInput for edge-uuid target with Extends, got {other:?}")
2073 }
2074 }
2075 }
2076
2077 #[tokio::test]
2079 async fn link_note_as_source_non_annotates_returns_invalid_input() {
2080 let rt = rt();
2081 let note = rt
2082 .create_note(None, "observation", None, "a note", 0.5, None, vec![])
2083 .await
2084 .unwrap();
2085 let entity = rt
2086 .create_entity(None, "concept", "E", None, None, vec![])
2087 .await
2088 .unwrap();
2089
2090 let result = rt
2091 .link(None, note.id, entity.id, EdgeRelation::DependsOn, 1.0)
2092 .await;
2093 match result {
2094 Err(RuntimeError::InvalidInput(msg)) => {
2095 assert!(
2096 msg.contains("source"),
2097 "error message must name 'source': {msg}"
2098 );
2099 }
2100 other => panic!("expected InvalidInput for note source with DependsOn, got {other:?}"),
2101 }
2102 }
2103
2104 #[tokio::test]
2106 async fn link_entity_as_annotates_source_returns_invalid_input() {
2107 let rt = rt();
2108 let a = rt
2109 .create_entity(None, "concept", "A", None, None, vec![])
2110 .await
2111 .unwrap();
2112 let b = rt
2113 .create_entity(None, "concept", "B", None, None, vec![])
2114 .await
2115 .unwrap();
2116
2117 let result = rt
2118 .link(None, a.id, b.id, EdgeRelation::Annotates, 1.0)
2119 .await;
2120 match result {
2121 Err(RuntimeError::InvalidInput(msg)) => {
2122 assert!(
2123 msg.contains("source") && msg.contains("note"),
2124 "error must say source must be a note: {msg}"
2125 );
2126 }
2127 other => {
2128 panic!("expected InvalidInput for entity source with Annotates, got {other:?}")
2129 }
2130 }
2131 }
2132
2133 #[tokio::test]
2134 async fn link_edge_as_annotates_source_returns_invalid_input() {
2135 let rt = rt();
2136 let a = rt
2137 .create_entity(None, "concept", "A", None, None, vec![])
2138 .await
2139 .unwrap();
2140 let b = rt
2141 .create_entity(None, "concept", "B", None, None, vec![])
2142 .await
2143 .unwrap();
2144 let edge = rt
2145 .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
2146 .await
2147 .unwrap();
2148 let edge_uuid: Uuid = edge.id.into();
2149
2150 let result = rt
2152 .link(None, edge_uuid, a.id, EdgeRelation::Annotates, 1.0)
2153 .await;
2154 match result {
2155 Err(RuntimeError::InvalidInput(msg)) => {
2156 assert!(
2157 msg.contains("source") && msg.contains("note"),
2158 "edge-as-annotates-source must report wrong kind, not NotFound: {msg}"
2159 );
2160 }
2161 other => panic!("expected InvalidInput for edge source with Annotates, got {other:?}"),
2162 }
2163 }
2164
2165 #[tokio::test]
2167 async fn link_note_to_event_annotates_succeeds() {
2168 use khive_storage::Event;
2169 use khive_types::SubstrateKind;
2170
2171 let rt = rt();
2172 let note = rt
2173 .create_note(
2174 None,
2175 "observation",
2176 None,
2177 "observing an event",
2178 0.6,
2179 None,
2180 vec![],
2181 )
2182 .await
2183 .unwrap();
2184
2185 let ns = rt.ns(None);
2187 let event = Event::new(ns, "test_verb", SubstrateKind::Entity, "test_actor");
2188 let event_id = event.id;
2189 rt.events(None).unwrap().append_event(event).await.unwrap();
2190
2191 let result = rt
2192 .link(None, note.id, event_id, EdgeRelation::Annotates, 1.0)
2193 .await;
2194 assert!(
2195 result.is_ok(),
2196 "note→event Annotates must succeed, got {result:?}"
2197 );
2198 }
2199
2200 #[tokio::test]
2202 async fn create_note_annotates_event_succeeds() {
2203 use khive_storage::Event;
2204 use khive_types::SubstrateKind;
2205
2206 let rt = rt();
2207 let ns = rt.ns(None);
2208 let event = Event::new(ns, "test_verb", SubstrateKind::Entity, "test_actor");
2209 let event_id = event.id;
2210 rt.events(None).unwrap().append_event(event).await.unwrap();
2211
2212 let result = rt
2213 .create_note(
2214 None,
2215 "observation",
2216 None,
2217 "note annotating an event",
2218 0.5,
2219 None,
2220 vec![event_id],
2221 )
2222 .await;
2223 assert!(
2224 result.is_ok(),
2225 "create_note with event annotates target must succeed, got {result:?}"
2226 );
2227 let note = result.unwrap();
2229 let neighbors = rt
2230 .neighbors(
2231 None,
2232 note.id,
2233 Direction::Out,
2234 None,
2235 Some(vec![EdgeRelation::Annotates]),
2236 )
2237 .await
2238 .unwrap();
2239 assert_eq!(neighbors.len(), 1);
2240 assert_eq!(neighbors[0].node_id, event_id);
2241 }
2242
2243 #[tokio::test]
2247 async fn link_supersedes_note_to_note_succeeds() {
2248 let rt = rt();
2249 let old_note = rt
2250 .create_note(
2251 None,
2252 "observation",
2253 None,
2254 "old observation",
2255 0.7,
2256 None,
2257 vec![],
2258 )
2259 .await
2260 .unwrap();
2261 let new_note = rt
2262 .create_note(
2263 None,
2264 "observation",
2265 None,
2266 "revised observation superseding the old one",
2267 0.9,
2268 None,
2269 vec![],
2270 )
2271 .await
2272 .unwrap();
2273
2274 let result = rt
2275 .link(
2276 None,
2277 new_note.id,
2278 old_note.id,
2279 EdgeRelation::Supersedes,
2280 1.0,
2281 )
2282 .await;
2283 assert!(
2284 result.is_ok(),
2285 "note→note Supersedes must succeed (ADR-019 note supersession), got {result:?}"
2286 );
2287 }
2288
2289 #[tokio::test]
2290 async fn link_supersedes_entity_to_entity_succeeds() {
2291 let rt = rt();
2292 let old_entity = rt
2293 .create_entity(None, "concept", "OldConcept", None, None, vec![])
2294 .await
2295 .unwrap();
2296 let new_entity = rt
2297 .create_entity(None, "concept", "NewConcept", None, None, vec![])
2298 .await
2299 .unwrap();
2300
2301 let result = rt
2302 .link(
2303 None,
2304 new_entity.id,
2305 old_entity.id,
2306 EdgeRelation::Supersedes,
2307 1.0,
2308 )
2309 .await;
2310 assert!(
2311 result.is_ok(),
2312 "entity→entity Supersedes must succeed, got {result:?}"
2313 );
2314 }
2315
2316 #[tokio::test]
2317 async fn link_supersedes_note_to_entity_returns_invalid_input() {
2318 let rt = rt();
2319 let note = rt
2320 .create_note(None, "observation", None, "a note", 0.5, None, vec![])
2321 .await
2322 .unwrap();
2323 let entity = rt
2324 .create_entity(None, "concept", "SomeEntity", None, None, vec![])
2325 .await
2326 .unwrap();
2327
2328 let result = rt
2329 .link(None, note.id, entity.id, EdgeRelation::Supersedes, 1.0)
2330 .await;
2331 match result {
2332 Err(RuntimeError::InvalidInput(msg)) => {
2333 assert!(
2334 msg.contains("same substrate") || msg.contains("same-substrate"),
2335 "error must name the same-substrate rule: {msg}"
2336 );
2337 }
2338 other => panic!(
2339 "expected InvalidInput for note→entity Supersedes (cross-substrate), got {other:?}"
2340 ),
2341 }
2342 }
2343
2344 #[tokio::test]
2345 async fn link_supersedes_entity_to_note_returns_invalid_input() {
2346 let rt = rt();
2347 let entity = rt
2348 .create_entity(None, "concept", "SomeEntity", None, None, vec![])
2349 .await
2350 .unwrap();
2351 let note = rt
2352 .create_note(None, "observation", None, "a note", 0.5, None, vec![])
2353 .await
2354 .unwrap();
2355
2356 let result = rt
2357 .link(None, entity.id, note.id, EdgeRelation::Supersedes, 1.0)
2358 .await;
2359 match result {
2360 Err(RuntimeError::InvalidInput(msg)) => {
2361 assert!(
2362 msg.contains("same substrate") || msg.contains("same-substrate"),
2363 "error must name the same-substrate rule: {msg}"
2364 );
2365 }
2366 other => panic!(
2367 "expected InvalidInput for entity→note Supersedes (cross-substrate), got {other:?}"
2368 ),
2369 }
2370 }
2371
2372 #[tokio::test]
2373 async fn link_supersedes_event_source_returns_invalid_input() {
2374 use khive_storage::Event;
2375 use khive_types::SubstrateKind;
2376
2377 let rt = rt();
2378 let ns = rt.ns(None);
2379 let event = Event::new(ns, "test_verb", SubstrateKind::Entity, "test_actor");
2380 let event_id = event.id;
2381 rt.events(None).unwrap().append_event(event).await.unwrap();
2382
2383 let entity = rt
2384 .create_entity(None, "concept", "SomeEntity", None, None, vec![])
2385 .await
2386 .unwrap();
2387
2388 let result = rt
2389 .link(None, event_id, entity.id, EdgeRelation::Supersedes, 1.0)
2390 .await;
2391 match result {
2392 Err(RuntimeError::InvalidInput(msg)) => {
2393 assert!(msg.contains("event"), "error must mention 'event': {msg}");
2394 }
2395 other => {
2396 panic!("expected InvalidInput for event source with Supersedes, got {other:?}")
2397 }
2398 }
2399 }
2400
2401 #[tokio::test]
2402 async fn link_supersedes_event_target_returns_invalid_input() {
2403 use khive_storage::Event;
2404 use khive_types::SubstrateKind;
2405
2406 let rt = rt();
2407 let ns = rt.ns(None);
2408 let event = Event::new(ns, "test_verb", SubstrateKind::Entity, "test_actor");
2409 let event_id = event.id;
2410 rt.events(None).unwrap().append_event(event).await.unwrap();
2411
2412 let entity = rt
2413 .create_entity(None, "concept", "SomeEntity", None, None, vec![])
2414 .await
2415 .unwrap();
2416
2417 let result = rt
2418 .link(None, entity.id, event_id, EdgeRelation::Supersedes, 1.0)
2419 .await;
2420 match result {
2421 Err(RuntimeError::InvalidInput(msg)) => {
2422 assert!(msg.contains("event"), "error must mention 'event': {msg}");
2423 }
2424 other => {
2425 panic!("expected InvalidInput for event target with Supersedes, got {other:?}")
2426 }
2427 }
2428 }
2429
2430 #[tokio::test]
2431 async fn link_supersedes_edge_source_returns_invalid_input() {
2432 let rt = rt();
2433 let a = rt
2434 .create_entity(None, "concept", "A", None, None, vec![])
2435 .await
2436 .unwrap();
2437 let b = rt
2438 .create_entity(None, "concept", "B", None, None, vec![])
2439 .await
2440 .unwrap();
2441 let edge = rt
2442 .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
2443 .await
2444 .unwrap();
2445 let edge_uuid: Uuid = edge.id.into();
2446
2447 let result = rt
2448 .link(None, edge_uuid, a.id, EdgeRelation::Supersedes, 1.0)
2449 .await;
2450 match result {
2451 Err(RuntimeError::InvalidInput(msg)) => {
2452 assert!(msg.contains("source"), "error must name 'source': {msg}");
2453 }
2454 other => {
2455 panic!("expected InvalidInput for edge-uuid source with Supersedes, got {other:?}")
2456 }
2457 }
2458 }
2459
2460 #[tokio::test]
2461 async fn link_supersedes_edge_target_returns_invalid_input() {
2462 let rt = rt();
2463 let a = rt
2464 .create_entity(None, "concept", "A", None, None, vec![])
2465 .await
2466 .unwrap();
2467 let b = rt
2468 .create_entity(None, "concept", "B", None, None, vec![])
2469 .await
2470 .unwrap();
2471 let edge = rt
2472 .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
2473 .await
2474 .unwrap();
2475 let edge_uuid: Uuid = edge.id.into();
2476
2477 let result = rt
2478 .link(None, a.id, edge_uuid, EdgeRelation::Supersedes, 1.0)
2479 .await;
2480 match result {
2481 Err(RuntimeError::InvalidInput(msg)) => {
2482 assert!(msg.contains("target"), "error must name 'target': {msg}");
2483 }
2484 other => {
2485 panic!("expected InvalidInput for edge-uuid target with Supersedes, got {other:?}")
2486 }
2487 }
2488 }
2489
2490 #[tokio::test]
2491 async fn link_supersedes_phantom_source_returns_not_found() {
2492 let rt = rt();
2493 let note = rt
2494 .create_note(
2495 None,
2496 "observation",
2497 None,
2498 "existing note",
2499 0.5,
2500 None,
2501 vec![],
2502 )
2503 .await
2504 .unwrap();
2505 let phantom = Uuid::new_v4();
2506
2507 let result = rt
2508 .link(None, phantom, note.id, EdgeRelation::Supersedes, 1.0)
2509 .await;
2510 match result {
2511 Err(RuntimeError::NotFound(msg)) => {
2512 assert!(msg.contains("source"), "error must name 'source': {msg}");
2513 }
2514 other => panic!("expected NotFound for phantom source with Supersedes, got {other:?}"),
2515 }
2516 }
2517
2518 #[tokio::test]
2519 async fn link_supersedes_phantom_target_returns_not_found() {
2520 let rt = rt();
2521 let note = rt
2522 .create_note(
2523 None,
2524 "observation",
2525 None,
2526 "existing note",
2527 0.5,
2528 None,
2529 vec![],
2530 )
2531 .await
2532 .unwrap();
2533 let phantom = Uuid::new_v4();
2534
2535 let result = rt
2536 .link(None, note.id, phantom, EdgeRelation::Supersedes, 1.0)
2537 .await;
2538 match result {
2539 Err(RuntimeError::NotFound(msg)) => {
2540 assert!(msg.contains("target"), "error must name 'target': {msg}");
2541 }
2542 other => panic!("expected NotFound for phantom target with Supersedes, got {other:?}"),
2543 }
2544 }
2545
2546 #[tokio::test]
2547 async fn link_supersedes_cross_namespace_source_returns_not_found() {
2548 let rt = rt();
2549 let note_a = rt
2550 .create_note(
2551 Some("ns-a"),
2552 "observation",
2553 None,
2554 "note in ns-a",
2555 0.5,
2556 None,
2557 vec![],
2558 )
2559 .await
2560 .unwrap();
2561 let note_b = rt
2562 .create_note(
2563 Some("ns-b"),
2564 "observation",
2565 None,
2566 "note in ns-b",
2567 0.5,
2568 None,
2569 vec![],
2570 )
2571 .await
2572 .unwrap();
2573
2574 let result = rt
2576 .link(
2577 Some("ns-a"),
2578 note_b.id,
2579 note_a.id,
2580 EdgeRelation::Supersedes,
2581 1.0,
2582 )
2583 .await;
2584 assert!(
2585 matches!(result, Err(RuntimeError::NotFound(_))),
2586 "cross-namespace source with Supersedes must return NotFound (fail-closed), got {result:?}"
2587 );
2588 }
2589
2590 #[tokio::test]
2592 async fn link_extends_note_source_still_returns_invalid_input() {
2593 let rt = rt();
2594 let note = rt
2595 .create_note(
2596 None,
2597 "observation",
2598 None,
2599 "a note that cannot be an extends source",
2600 0.5,
2601 None,
2602 vec![],
2603 )
2604 .await
2605 .unwrap();
2606 let entity = rt
2607 .create_entity(None, "concept", "E", None, None, vec![])
2608 .await
2609 .unwrap();
2610
2611 let result = rt
2612 .link(None, note.id, entity.id, EdgeRelation::Extends, 1.0)
2613 .await;
2614 assert!(
2615 matches!(result, Err(RuntimeError::InvalidInput(_))),
2616 "note source with Extends must still return InvalidInput after this fix, got {result:?}"
2617 );
2618 }
2619
2620 #[tokio::test]
2622 async fn link_annotates_note_to_edge_still_succeeds_after_fix() {
2623 let rt = rt();
2624 let a = rt
2625 .create_entity(None, "concept", "A", None, None, vec![])
2626 .await
2627 .unwrap();
2628 let b = rt
2629 .create_entity(None, "concept", "B", None, None, vec![])
2630 .await
2631 .unwrap();
2632 let edge = rt
2633 .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
2634 .await
2635 .unwrap();
2636 let edge_uuid: Uuid = edge.id.into();
2637
2638 let note = rt
2639 .create_note(
2640 None,
2641 "observation",
2642 None,
2643 "annotating an edge",
2644 0.5,
2645 None,
2646 vec![],
2647 )
2648 .await
2649 .unwrap();
2650
2651 let result = rt
2652 .link(None, note.id, edge_uuid, EdgeRelation::Annotates, 1.0)
2653 .await;
2654 assert!(
2655 result.is_ok(),
2656 "note→edge Annotates must still succeed after supersedes fix, got {result:?}"
2657 );
2658 }
2659}