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, EventKind, SubstrateKind};
18
19use crate::error::{RuntimeError, RuntimeResult};
20use crate::runtime::{KhiveRuntime, NamespaceToken};
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 normalize_symmetric_direction(
58 direction: Direction,
59 relations: Option<&[EdgeRelation]>,
60) -> Direction {
61 let Some(rels) = relations else {
62 return direction;
63 };
64 if rels.is_empty() {
65 return direction;
66 }
67 let all_symmetric = rels
68 .iter()
69 .all(|r| matches!(r, EdgeRelation::CompetesWith | EdgeRelation::ComposedWith));
70 if all_symmetric {
71 Direction::Both
72 } else {
73 direction
74 }
75}
76
77fn note_title(note: &Note) -> Option<String> {
78 note.name
79 .clone()
80 .filter(|s| !s.trim().is_empty())
81 .or_else(|| text_preview(¬e.content, 80))
82}
83
84fn note_snippet(note: &Note) -> Option<String> {
85 text_preview(¬e.content, 200)
86}
87
88#[derive(Clone, Debug)]
90pub enum Resolved {
91 Entity(Entity),
92 Note(Note),
93 Event(Event),
94}
95
96fn resolved_pair(r: Option<&Resolved>) -> Option<(&'static str, &str)> {
99 match r? {
100 Resolved::Entity(e) => Some(("entity", e.kind.as_str())),
101 Resolved::Note(n) => Some(("note", n.kind.as_str())),
102 Resolved::Event(_) => None,
103 }
104}
105
106fn endpoint_matches(spec: &EndpointKind, substrate: &str, kind: &str) -> bool {
108 match spec {
109 EndpointKind::EntityOfKind(k) => substrate == "entity" && *k == kind,
110 EndpointKind::NoteOfKind(k) => substrate == "note" && *k == kind,
111 }
112}
113
114fn pack_rule_allows(
117 rules: &[EdgeEndpointRule],
118 relation: EdgeRelation,
119 src: Option<&Resolved>,
120 tgt: Option<&Resolved>,
121) -> bool {
122 let Some((src_sub, src_kind)) = resolved_pair(src) else {
123 return false;
124 };
125 let Some((tgt_sub, tgt_kind)) = resolved_pair(tgt) else {
126 return false;
127 };
128 rules.iter().any(|r| {
129 r.relation == relation
130 && endpoint_matches(&r.source, src_sub, src_kind)
131 && endpoint_matches(&r.target, tgt_sub, tgt_kind)
132 })
133}
134
135fn base_entity_rule_allows(src_kind: &str, relation: EdgeRelation, tgt_kind: &str) -> bool {
143 const RULES: &[(&str, EdgeRelation, &str)] = &[
144 ("concept", EdgeRelation::Contains, "concept"),
146 ("project", EdgeRelation::Contains, "project"),
147 ("project", EdgeRelation::Contains, "artifact"),
148 ("org", EdgeRelation::Contains, "project"),
149 ("org", EdgeRelation::Contains, "service"),
150 ("concept", EdgeRelation::PartOf, "concept"),
151 ("project", EdgeRelation::PartOf, "project"),
152 ("project", EdgeRelation::PartOf, "org"),
153 ("*", EdgeRelation::InstanceOf, "concept"),
154 ("service", EdgeRelation::InstanceOf, "project"),
155 ("concept", EdgeRelation::Extends, "concept"),
157 ("concept", EdgeRelation::VariantOf, "concept"),
158 ("artifact", EdgeRelation::VariantOf, "artifact"),
159 ("concept", EdgeRelation::IntroducedBy, "document"),
160 ("concept", EdgeRelation::IntroducedBy, "person"),
161 ("artifact", EdgeRelation::IntroducedBy, "document"),
162 ("artifact", EdgeRelation::DerivedFrom, "dataset"),
164 ("artifact", EdgeRelation::DerivedFrom, "document"),
165 ("artifact", EdgeRelation::DerivedFrom, "project"),
166 ("artifact", EdgeRelation::DerivedFrom, "artifact"),
167 ("document", EdgeRelation::Precedes, "document"),
169 ("dataset", EdgeRelation::Precedes, "dataset"),
170 ("artifact", EdgeRelation::Precedes, "artifact"),
171 ("service", EdgeRelation::Precedes, "service"),
172 ("project", EdgeRelation::Precedes, "project"),
173 ("project", EdgeRelation::DependsOn, "project"),
175 ("service", EdgeRelation::DependsOn, "project"),
176 ("service", EdgeRelation::DependsOn, "service"),
177 ("service", EdgeRelation::DependsOn, "artifact"),
178 ("service", EdgeRelation::DependsOn, "dataset"),
179 ("artifact", EdgeRelation::DependsOn, "project"),
180 ("artifact", EdgeRelation::DependsOn, "service"),
181 ("concept", EdgeRelation::Enables, "concept"),
182 ("service", EdgeRelation::Enables, "concept"),
183 ("dataset", EdgeRelation::Enables, "concept"),
184 ("project", EdgeRelation::Implements, "concept"),
186 ("service", EdgeRelation::Implements, "concept"),
187 ("concept", EdgeRelation::CompetesWith, "concept"),
189 ("project", EdgeRelation::CompetesWith, "project"),
190 ("service", EdgeRelation::CompetesWith, "service"),
191 ("concept", EdgeRelation::ComposedWith, "concept"),
192 ("project", EdgeRelation::ComposedWith, "project"),
193 ("concept", EdgeRelation::Supersedes, "concept"),
195 ("document", EdgeRelation::Supersedes, "document"),
196 ("artifact", EdgeRelation::Supersedes, "artifact"),
197 ("service", EdgeRelation::Supersedes, "service"),
198 ("dataset", EdgeRelation::Supersedes, "dataset"),
199 ];
200 RULES.iter().any(|(src, rel, tgt)| {
201 *rel == relation && (*src == "*" || *src == src_kind) && *tgt == tgt_kind
202 })
203}
204
205fn canonical_edge_endpoints(
211 relation: EdgeRelation,
212 source_id: Uuid,
213 target_id: Uuid,
214) -> (Uuid, Uuid) {
215 if relation.is_symmetric() && target_id < source_id {
216 (target_id, source_id)
217 } else {
218 (source_id, target_id)
219 }
220}
221
222fn infer_dependency_kind(src_kind: &str, tgt_kind: &str) -> Option<&'static str> {
224 match (src_kind, tgt_kind) {
225 ("project", "project") => Some("build"),
226 ("service", "service") => Some("runtime"),
227 ("service", "dataset") => Some("data"),
228 ("service", "artifact") => Some("artifact"),
229 ("artifact", "project") | ("artifact", "service") => Some("tooling"),
230 _ => None,
231 }
232}
233
234fn merge_dependency_kind(
241 src_kind: &str,
242 tgt_kind: &str,
243 metadata: Option<serde_json::Value>,
244) -> Option<serde_json::Value> {
245 if let Some(ref m) = metadata {
246 if m.get("dependency_kind").is_some() {
247 return metadata;
248 }
249 }
250 let inferred = infer_dependency_kind(src_kind, tgt_kind)?;
251 let mut obj = metadata.unwrap_or_else(|| serde_json::json!({}));
252 if let Some(o) = obj.as_object_mut() {
253 o.insert("dependency_kind".to_string(), serde_json::json!(inferred));
254 }
255 Some(obj)
256}
257
258const VALID_DEPENDENCY_KINDS: &[&str] = &["build", "runtime", "data", "artifact", "tooling"];
260
261fn validate_edge_metadata(
267 relation: EdgeRelation,
268 metadata: Option<&serde_json::Value>,
269) -> RuntimeResult<()> {
270 let Some(meta) = metadata else {
271 return Ok(());
272 };
273 if let Some(dk) = meta.get("dependency_kind") {
274 if relation != EdgeRelation::DependsOn {
275 return Err(RuntimeError::InvalidInput(format!(
276 "dependency_kind is only valid on depends_on edges (got {})",
277 relation.as_str()
278 )));
279 }
280 let dk_str = dk
281 .as_str()
282 .ok_or_else(|| RuntimeError::InvalidInput("dependency_kind must be a string".into()))?;
283 if !VALID_DEPENDENCY_KINDS.contains(&dk_str) {
284 return Err(RuntimeError::InvalidInput(format!(
285 "unknown dependency_kind {dk_str:?}; valid: {}",
286 VALID_DEPENDENCY_KINDS.join(" | ")
287 )));
288 }
289 }
290 Ok(())
291}
292
293impl KhiveRuntime {
294 #[allow(clippy::too_many_arguments)]
298 pub async fn create_entity(
299 &self,
300 token: &NamespaceToken,
301 kind: &str,
302 entity_type: Option<&str>,
303 name: &str,
304 description: Option<&str>,
305 properties: Option<serde_json::Value>,
306 tags: Vec<String>,
307 ) -> RuntimeResult<Entity> {
308 let ns = token.namespace().as_str();
309 let mut entity = Entity::new(ns, kind, name).with_entity_type(entity_type);
310 if let Some(d) = description {
311 entity = entity.with_description(d);
312 }
313 if let Some(p) = properties {
314 entity = entity.with_properties(p);
315 }
316 if !tags.is_empty() {
317 entity = entity.with_tags(tags);
318 }
319 self.entities(token)?.upsert_entity(entity.clone()).await?;
320
321 let body = match &entity.description {
322 Some(d) if !d.is_empty() => format!("{} {}", entity.name, d),
323 _ => entity.name.clone(),
324 };
325 self.text(token)?
326 .upsert_document(TextDocument {
327 subject_id: entity.id,
328 kind: SubstrateKind::Entity,
329 title: Some(entity.name.clone()),
330 body: body.clone(),
331 tags: entity.tags.clone(),
332 namespace: ns.to_string(),
333 metadata: entity.properties.clone(),
334 updated_at: chrono::Utc::now(),
335 })
336 .await?;
337
338 if self.config().embedding_model.is_some() {
339 let vector = self.embed(&body).await?;
340 self.vectors(token)?
341 .insert(
342 entity.id,
343 SubstrateKind::Entity,
344 ns,
345 "entity.body",
346 vec![vector],
347 )
348 .await?;
349 }
350
351 Ok(entity)
352 }
353
354 pub async fn get_entity(&self, token: &NamespaceToken, id: Uuid) -> RuntimeResult<Entity> {
359 let entity = self
360 .entities(token)?
361 .get_entity(id)
362 .await?
363 .ok_or_else(|| RuntimeError::NotFound("not found in this namespace".into()))?;
364 self.ensure_namespace(&entity.namespace, token, id)?;
365 Ok(entity)
366 }
367
368 pub(crate) fn ensure_namespace(
373 &self,
374 actual: &str,
375 token: &NamespaceToken,
376 id: Uuid,
377 ) -> RuntimeResult<()> {
378 if actual == token.namespace().as_str() {
379 return Ok(());
380 }
381 Err(RuntimeError::NamespaceMismatch { id })
382 }
383
384 pub async fn list_entities(
386 &self,
387 token: &NamespaceToken,
388 kind: Option<&str>,
389 entity_type: Option<&str>,
390 limit: u32,
391 offset: u32,
392 ) -> RuntimeResult<Vec<Entity>> {
393 let filter = EntityFilter {
394 kinds: match kind {
395 Some(k) => vec![k.to_string()],
396 None => vec![],
397 },
398 entity_types: match entity_type {
399 Some(t) => vec![t.to_string()],
400 None => vec![],
401 },
402 ..Default::default()
403 };
404 let page = self
405 .entities(token)?
406 .query_entities(
407 token.namespace().as_str(),
408 filter,
409 PageRequest {
410 offset: offset.into(),
411 limit,
412 },
413 )
414 .await?;
415 Ok(page.items)
416 }
417
418 pub async fn list_events(
420 &self,
421 token: &NamespaceToken,
422 filter: EventFilter,
423 page: PageRequest,
424 ) -> RuntimeResult<Page<Event>> {
425 self.events(token)?
426 .query_events(filter, page)
427 .await
428 .map_err(Into::into)
429 }
430
431 async fn validate_edge_relation_endpoints(
445 &self,
446 token: &NamespaceToken,
447 source_id: Uuid,
448 target_id: Uuid,
449 relation: EdgeRelation,
450 ) -> RuntimeResult<()> {
451 if relation == EdgeRelation::Annotates {
452 match self.resolve(token, source_id).await? {
454 Some(Resolved::Note(_)) => {}
455 Some(_) => {
456 return Err(RuntimeError::InvalidInput(format!(
457 "annotates source {source_id} must be a note"
458 )));
459 }
460 None => {
461 if self.get_edge(token, source_id).await?.is_some() {
463 return Err(RuntimeError::InvalidInput(format!(
464 "annotates source {source_id} must be a note"
465 )));
466 }
467 return Err(RuntimeError::NotFound(format!(
468 "link source {source_id} not found in namespace"
469 )));
470 }
471 }
472 if !self.substrate_exists_in_ns(token, target_id).await? {
474 return Err(RuntimeError::NotFound(format!(
475 "link target {target_id} not found in namespace"
476 )));
477 }
478 } else if relation == EdgeRelation::Supersedes {
479 let src = match self.resolve(token, source_id).await? {
482 Some(r) => r,
483 None => {
484 if self.get_edge(token, source_id).await?.is_some() {
485 return Err(RuntimeError::InvalidInput(format!(
486 "supersedes source {source_id} must be a note or entity (got edge)"
487 )));
488 }
489 return Err(RuntimeError::NotFound(format!(
490 "link source {source_id} not found in namespace"
491 )));
492 }
493 };
494 let tgt = match self.resolve(token, target_id).await? {
495 Some(r) => r,
496 None => {
497 if self.get_edge(token, target_id).await?.is_some() {
498 return Err(RuntimeError::InvalidInput(format!(
499 "supersedes target {target_id} must be a note or entity (got edge)"
500 )));
501 }
502 return Err(RuntimeError::NotFound(format!(
503 "link target {target_id} not found in namespace"
504 )));
505 }
506 };
507 match (&src, &tgt) {
508 (Resolved::Entity(src_e), Resolved::Entity(tgt_e)) => {
509 if !base_entity_rule_allows(&src_e.kind, EdgeRelation::Supersedes, &tgt_e.kind)
510 {
511 return Err(RuntimeError::InvalidInput(format!(
512 "({}) -[supersedes]-> ({}) is not in the ADR-002 base endpoint \
513 allowlist; supersedes requires same-kind entity endpoints",
514 src_e.kind, tgt_e.kind
515 )));
516 }
517 }
518 (Resolved::Note(_), Resolved::Note(_)) => {}
519 (Resolved::Event(_), _) => {
520 return Err(RuntimeError::InvalidInput(format!(
521 "supersedes does not apply to events; source {source_id} is an event"
522 )));
523 }
524 (_, Resolved::Event(_)) => {
525 return Err(RuntimeError::InvalidInput(format!(
526 "supersedes does not apply to events; target {target_id} is an event"
527 )));
528 }
529 (Resolved::Entity(_), Resolved::Note(_)) => {
530 return Err(RuntimeError::InvalidInput(format!(
531 "supersedes endpoints must be the same substrate (note→note or entity→entity); \
532 got source={source_id} (entity) target={target_id} (note)"
533 )));
534 }
535 (Resolved::Note(_), Resolved::Entity(_)) => {
536 return Err(RuntimeError::InvalidInput(format!(
537 "supersedes endpoints must be the same substrate (note→note or entity→entity); \
538 got source={source_id} (note) target={target_id} (entity)"
539 )));
540 }
541 }
542 } else {
543 let src_res = self.resolve(token, source_id).await?;
550 let tgt_res = self.resolve(token, target_id).await?;
551
552 if pack_rule_allows(
553 &self.pack_edge_rules(),
554 relation,
555 src_res.as_ref(),
556 tgt_res.as_ref(),
557 ) {
558 return Ok(());
559 }
560
561 let src_kind = match src_res {
563 Some(Resolved::Entity(e)) => e.kind,
564 Some(_) => {
565 return Err(RuntimeError::InvalidInput(format!(
566 "link source {source_id} must be an entity for relation {relation:?} \
567 (ADR-002: only `annotates` crosses substrates)"
568 )));
569 }
570 None => {
571 if self.get_edge(token, source_id).await?.is_some() {
572 return Err(RuntimeError::InvalidInput(format!(
573 "link source {source_id} must be an entity for relation {relation:?} \
574 (ADR-002: only `annotates` crosses substrates)"
575 )));
576 }
577 return Err(RuntimeError::NotFound(format!(
578 "link source {source_id} not found in namespace"
579 )));
580 }
581 };
582 let tgt_kind = match tgt_res {
583 Some(Resolved::Entity(e)) => e.kind,
584 Some(_) => {
585 return Err(RuntimeError::InvalidInput(format!(
586 "link target {target_id} must be an entity for relation {relation:?} \
587 (ADR-002: only `annotates` crosses substrates)"
588 )));
589 }
590 None => {
591 if self.get_edge(token, target_id).await?.is_some() {
592 return Err(RuntimeError::InvalidInput(format!(
593 "link target {target_id} must be an entity for relation {relation:?} \
594 (ADR-002: only `annotates` crosses substrates)"
595 )));
596 }
597 return Err(RuntimeError::NotFound(format!(
598 "link target {target_id} not found in namespace"
599 )));
600 }
601 };
602 if !base_entity_rule_allows(&src_kind, relation, &tgt_kind) {
603 return Err(RuntimeError::InvalidInput(format!(
604 "({src_kind}) -[{}]-> ({tgt_kind}) is not in the ADR-002 base endpoint \
605 allowlist; use pack EDGE_RULES to extend the allowlist",
606 relation.as_str()
607 )));
608 }
609 }
610 Ok(())
611 }
612
613 pub async fn link(
633 &self,
634 token: &NamespaceToken,
635 source_id: Uuid,
636 target_id: Uuid,
637 relation: EdgeRelation,
638 weight: f64,
639 metadata: Option<serde_json::Value>,
640 ) -> RuntimeResult<Edge> {
641 self.validate_edge_relation_endpoints(token, source_id, target_id, relation)
642 .await?;
643 let (source_id, target_id) = canonical_edge_endpoints(relation, source_id, target_id);
644 let metadata = if relation == EdgeRelation::DependsOn {
645 match (
646 self.resolve(token, source_id).await?,
647 self.resolve(token, target_id).await?,
648 ) {
649 (Some(Resolved::Entity(src_e)), Some(Resolved::Entity(tgt_e))) => {
650 merge_dependency_kind(&src_e.kind, &tgt_e.kind, metadata)
651 }
652 _ => metadata,
653 }
654 } else {
655 metadata
656 };
657 validate_edge_metadata(relation, metadata.as_ref())?;
658 let now = chrono::Utc::now();
659 let ns = token.namespace().as_str();
660 let edge = Edge {
661 id: LinkId::from(Uuid::new_v4()),
662 namespace: ns.to_string(),
663 source_id,
664 target_id,
665 relation,
666 weight,
667 created_at: now,
668 updated_at: now,
669 deleted_at: None,
670 metadata,
671 target_backend: None,
672 };
673 self.graph(token)?.upsert_edge(edge.clone()).await?;
674 Ok(edge)
675 }
676
677 async fn substrate_exists_in_ns(
682 &self,
683 token: &NamespaceToken,
684 id: Uuid,
685 ) -> RuntimeResult<bool> {
686 if self.resolve(token, id).await?.is_some() {
687 return Ok(true);
688 }
689 Ok(self.get_edge(token, id).await?.is_some())
690 }
691
692 pub async fn neighbors(
701 &self,
702 token: &NamespaceToken,
703 node_id: Uuid,
704 direction: Direction,
705 limit: Option<u32>,
706 relations: Option<Vec<EdgeRelation>>,
707 ) -> RuntimeResult<Vec<NeighborHit>> {
708 self.neighbors_with_query(
709 token,
710 node_id,
711 NeighborQuery {
712 direction,
713 relations,
714 limit,
715 min_weight: None,
716 },
717 )
718 .await
719 }
720
721 pub async fn neighbors_with_query(
727 &self,
728 token: &NamespaceToken,
729 node_id: Uuid,
730 mut query: NeighborQuery,
731 ) -> RuntimeResult<Vec<NeighborHit>> {
732 query.direction =
733 normalize_symmetric_direction(query.direction, query.relations.as_deref());
734 let mut hits = self.graph(token)?.neighbors(node_id, query).await?;
735 self.enrich_neighbor_hits(token, &mut hits).await;
736 Ok(hits)
737 }
738
739 pub async fn traverse(
741 &self,
742 token: &NamespaceToken,
743 request: TraversalRequest,
744 ) -> RuntimeResult<Vec<GraphPath>> {
745 let mut paths = self.graph(token)?.traverse(request).await?;
746 self.enrich_path_nodes(token, &mut paths).await;
747 Ok(paths)
748 }
749
750 async fn enrich_neighbor_hits(&self, token: &NamespaceToken, hits: &mut [NeighborHit]) {
758 if hits.is_empty() {
759 return;
760 }
761 let store = match self.entities(token) {
762 Ok(s) => s,
763 Err(_) => return, };
765 for hit in hits.iter_mut() {
766 if let Ok(Some(entity)) = store.get_entity(hit.node_id).await {
767 hit.name = Some(entity.name);
768 hit.kind = Some(entity.kind);
769 }
770 }
771 }
772
773 async fn enrich_path_nodes(&self, token: &NamespaceToken, paths: &mut [GraphPath]) {
776 if paths.is_empty() {
777 return;
778 }
779 let store = match self.entities(token) {
780 Ok(s) => s,
781 Err(_) => return,
782 };
783 for path in paths.iter_mut() {
784 for node in path.nodes.iter_mut() {
785 if let Ok(Some(entity)) = store.get_entity(node.node_id).await {
786 node.name = Some(entity.name);
787 node.kind = Some(entity.kind);
788 }
789 }
790 }
791 }
792
793 #[allow(clippy::too_many_arguments)]
804 pub async fn create_note(
805 &self,
806 token: &NamespaceToken,
807 kind: &str,
808 name: Option<&str>,
809 content: &str,
810 salience: Option<f64>,
811 properties: Option<serde_json::Value>,
812 annotates: Vec<Uuid>,
813 ) -> RuntimeResult<Note> {
814 self.create_note_inner(
815 token, kind, name, content, salience, None, properties, annotates,
816 )
817 .await
818 }
819
820 #[allow(clippy::too_many_arguments)]
822 pub async fn create_note_with_decay(
823 &self,
824 token: &NamespaceToken,
825 kind: &str,
826 name: Option<&str>,
827 content: &str,
828 salience: Option<f64>,
829 decay_factor: f64,
830 properties: Option<serde_json::Value>,
831 annotates: Vec<Uuid>,
832 ) -> RuntimeResult<Note> {
833 self.create_note_inner(
834 token,
835 kind,
836 name,
837 content,
838 salience,
839 Some(decay_factor),
840 properties,
841 annotates,
842 )
843 .await
844 }
845
846 #[allow(clippy::too_many_arguments)]
847 async fn create_note_inner(
848 &self,
849 token: &NamespaceToken,
850 kind: &str,
851 name: Option<&str>,
852 content: &str,
853 salience: Option<f64>,
854 decay_factor: Option<f64>,
855 properties: Option<serde_json::Value>,
856 annotates: Vec<Uuid>,
857 ) -> RuntimeResult<Note> {
858 let ns = token.namespace().as_str();
859
860 for &target_id in &annotates {
862 if !self.substrate_exists_in_ns(token, target_id).await? {
863 return Err(RuntimeError::NotFound(format!(
864 "create_note annotates target {target_id} not found in namespace"
865 )));
866 }
867 }
868
869 let mut note = Note::new(ns, kind, content);
870 if let Some(s) = salience {
871 note = note.with_salience(s);
872 }
873 if let Some(df) = decay_factor {
874 note = note.with_decay(df);
875 }
876 if let Some(n) = name {
877 note = note.with_name(n);
878 }
879 if let Some(p) = properties {
880 note = note.with_properties(p);
881 }
882 self.notes(token)?.upsert_note(note.clone()).await?;
883
884 let body = match ¬e.name {
885 Some(n) => format!("{n} {}", note.content),
886 None => note.content.clone(),
887 };
888
889 self.text_for_notes(token)?
890 .upsert_document(TextDocument {
891 subject_id: note.id,
892 kind: SubstrateKind::Note,
893 title: note.name.clone(),
894 body,
895 tags: vec![],
896 namespace: ns.to_string(),
897 metadata: note.properties.clone(),
898 updated_at: chrono::Utc::now(),
899 })
900 .await?;
901
902 if self.config().embedding_model.is_some() {
903 let vector = self.embed(¬e.content).await?;
904 self.vectors(token)?
905 .insert(
906 note.id,
907 SubstrateKind::Note,
908 ns,
909 "note.content",
910 vec![vector],
911 )
912 .await?;
913 }
914
915 let mut created_edges: Vec<Uuid> = Vec::with_capacity(annotates.len());
921
922 #[cfg(test)]
925 let annotates_iter: Vec<(usize, Uuid)> = annotates
926 .iter()
927 .enumerate()
928 .map(|(i, &id)| (i, id))
929 .collect();
930 #[cfg(test)]
931 macro_rules! next_target {
932 ($pair:expr) => {
933 $pair.1
934 };
935 }
936 #[cfg(not(test))]
937 let annotates_iter: Vec<Uuid> = annotates.to_vec();
938 #[cfg(not(test))]
939 macro_rules! next_target {
940 ($pair:expr) => {
941 $pair
942 };
943 }
944
945 for pair in annotates_iter {
946 let target_id = next_target!(pair);
947
948 #[cfg(test)]
950 let injected_err: Option<RuntimeError> = {
951 let call_idx = pair.0;
952 LINK_FAIL_AFTER.with(|cell| {
953 let n = cell.get();
954 if n > 0 && call_idx + 1 == n {
955 cell.set(0); Some(RuntimeError::Internal("injected link failure".to_string()))
957 } else {
958 None
959 }
960 })
961 };
962 #[cfg(not(test))]
963 let injected_err: Option<RuntimeError> = None;
964
965 let link_result = if let Some(e) = injected_err {
966 Err(e)
967 } else {
968 self.link(
969 token,
970 note.id,
971 target_id,
972 EdgeRelation::Annotates,
973 1.0,
974 None,
975 )
976 .await
977 };
978
979 match link_result {
980 Ok(edge) => created_edges.push(edge.id.into()),
981 Err(e) => {
982 for edge_id in created_edges {
984 let _ = self.delete_edge(token, edge_id, true).await;
985 }
986 if let Ok(store) = self.notes(token) {
987 let _ = store.delete_note(note.id, DeleteMode::Hard).await;
988 }
989 if let Ok(fts) = self.text_for_notes(token) {
990 let _ = fts.delete_document(ns, note.id).await;
991 }
992 if self.config().embedding_model.is_some() {
993 if let Ok(vs) = self.vectors(token) {
994 let _ = vs.delete(note.id).await;
995 }
996 }
997 return Err(e);
998 }
999 }
1000 }
1001
1002 Ok(note)
1003 }
1004
1005 pub async fn list_notes(
1007 &self,
1008 token: &NamespaceToken,
1009 kind: Option<&str>,
1010 limit: u32,
1011 offset: u32,
1012 ) -> RuntimeResult<Vec<Note>> {
1013 let page = self
1014 .notes(token)?
1015 .query_notes(
1016 token.namespace().as_str(),
1017 kind,
1018 PageRequest {
1019 offset: offset.into(),
1020 limit,
1021 },
1022 )
1023 .await?;
1024 Ok(page.items)
1025 }
1026
1027 pub async fn search_notes(
1037 &self,
1038 token: &NamespaceToken,
1039 query_text: &str,
1040 query_vector: Option<Vec<f32>>,
1041 limit: u32,
1042 note_kind: Option<&str>,
1043 include_superseded: bool,
1044 ) -> RuntimeResult<Vec<NoteSearchHit>> {
1045 const RRF_K: usize = 60;
1046 let candidates = limit.saturating_mul(4).max(limit);
1047 let ns = token.namespace().as_str().to_owned();
1048
1049 let text_hits = self
1051 .text_for_notes(token)?
1052 .search(TextSearchRequest {
1053 query: query_text.to_string(),
1054 mode: TextQueryMode::Plain,
1055 filter: Some(TextFilter {
1056 namespaces: vec![ns.clone()],
1057 ..TextFilter::default()
1058 }),
1059 top_k: candidates,
1060 snippet_chars: 200,
1061 })
1062 .await?;
1063
1064 let vector_hits = if query_vector.is_some() || self.config().embedding_model.is_some() {
1066 self.vector_search(
1067 token,
1068 query_vector,
1069 Some(query_text),
1070 candidates,
1071 Some(SubstrateKind::Note),
1072 )
1073 .await?
1074 } else {
1075 vec![]
1076 };
1077
1078 #[derive(Default)]
1080 struct Bucket {
1081 score: DeterministicScore,
1082 title: Option<String>,
1083 snippet: Option<String>,
1084 }
1085
1086 let mut buckets: HashMap<Uuid, Bucket> = HashMap::new();
1087 for (i, hit) in text_hits.into_iter().enumerate() {
1088 let rank = i + 1;
1089 let entry = buckets.entry(hit.subject_id).or_default();
1090 entry.score = entry.score + rrf_score(rank, RRF_K);
1091 if entry.title.is_none() {
1092 entry.title = hit.title;
1093 }
1094 if entry.snippet.is_none() {
1095 entry.snippet = hit.snippet;
1096 }
1097 }
1098 for (i, hit) in vector_hits.into_iter().enumerate() {
1099 let rank = i + 1;
1100 let entry = buckets.entry(hit.subject_id).or_default();
1101 entry.score = entry.score + rrf_score(rank, RRF_K);
1102 }
1103
1104 let candidate_ids: Vec<Uuid> = buckets.keys().copied().collect();
1105 if candidate_ids.is_empty() {
1106 return Ok(vec![]);
1107 }
1108
1109 let note_store = self.notes(token)?;
1114 let mut alive_notes: HashMap<Uuid, Note> = HashMap::new();
1115 for id in &candidate_ids {
1116 if let Some(note) = note_store.get_note(*id).await? {
1117 if note.deleted_at.is_some() {
1118 continue;
1119 }
1120 if let Some(want_kind) = note_kind {
1121 if note.kind != want_kind {
1122 continue;
1123 }
1124 }
1125 alive_notes.insert(*id, note);
1126 }
1127 }
1128
1129 if !include_superseded && !alive_notes.is_empty() {
1133 let graph = self.graph(token)?;
1134 let mut superseded: std::collections::HashSet<Uuid> = std::collections::HashSet::new();
1135 for ¬e_id in alive_notes.keys() {
1136 let inbound = graph
1137 .neighbors(
1138 note_id,
1139 NeighborQuery {
1140 direction: Direction::In,
1141 relations: Some(vec![EdgeRelation::Supersedes]),
1142 limit: Some(1),
1143 min_weight: None,
1144 },
1145 )
1146 .await?;
1147 if !inbound.is_empty() {
1148 superseded.insert(note_id);
1149 }
1150 }
1151 alive_notes.retain(|id, _| !superseded.contains(id));
1152 }
1153
1154 let mut hits: Vec<NoteSearchHit> = buckets
1156 .into_iter()
1157 .filter_map(|(id, bucket)| {
1158 let note = alive_notes.get(&id)?;
1159 let salience = note.salience.unwrap_or(0.5);
1160 let weight = 0.5 + 0.5 * salience;
1161 let weighted = DeterministicScore::from_f64(bucket.score.to_f64() * weight);
1162 Some(NoteSearchHit {
1163 note_id: id,
1164 score: weighted,
1165 title: bucket.title.or_else(|| note_title(note)),
1166 snippet: bucket.snippet.or_else(|| note_snippet(note)),
1167 })
1168 })
1169 .collect();
1170
1171 hits.sort_by(|a, b| b.score.cmp(&a.score).then(a.note_id.cmp(&b.note_id)));
1172 hits.truncate(limit as usize);
1173 Ok(hits)
1174 }
1175
1176 pub async fn resolve_prefix(
1183 &self,
1184 token: &NamespaceToken,
1185 prefix: &str,
1186 ) -> RuntimeResult<Option<Uuid>> {
1187 use khive_storage::types::{SqlStatement, SqlValue};
1188
1189 let ns = token.namespace().as_str().to_owned();
1190 let pattern = format!("{}%", prefix);
1191
1192 let tables = [
1193 ("entities", true),
1194 ("notes", true),
1195 ("events", false),
1196 ("graph_edges", false),
1197 ];
1198
1199 let mut matches: Vec<String> = Vec::new();
1200 let mut reader = self.sql().reader().await.map_err(RuntimeError::Storage)?;
1201
1202 for (table, has_deleted_at) in tables {
1203 let deleted_filter = if has_deleted_at {
1204 " AND deleted_at IS NULL"
1205 } else {
1206 ""
1207 };
1208 let sql = SqlStatement {
1209 sql: format!(
1210 "SELECT id FROM {table} WHERE id LIKE ?1 AND namespace = ?2{deleted_filter} LIMIT 2"
1211 ),
1212 params: vec![
1213 SqlValue::Text(pattern.clone()),
1214 SqlValue::Text(ns.clone()),
1215 ],
1216 label: Some("resolve_prefix".into()),
1217 };
1218 match reader.query_all(sql).await {
1219 Ok(rows) => {
1220 for row in rows {
1221 if let Some(col) = row.columns.first() {
1222 if let SqlValue::Text(s) = &col.value {
1223 matches.push(s.clone());
1224 }
1225 }
1226 }
1227 }
1228 Err(e) => {
1229 let msg = e.to_string();
1230 if msg.contains("no such table") {
1231 continue;
1232 }
1233 return Err(RuntimeError::Storage(e));
1234 }
1235 }
1236 if matches.len() > 1 {
1237 break;
1238 }
1239 }
1240
1241 match matches.len() {
1242 0 => Ok(None),
1243 1 => {
1244 let uuid = Uuid::from_str(&matches[0])
1245 .map_err(|e| RuntimeError::Internal(format!("stored UUID is invalid: {e}")))?;
1246 Ok(Some(uuid))
1247 }
1248 _ => {
1249 let uuids: Vec<uuid::Uuid> = matches
1250 .iter()
1251 .filter_map(|s| Uuid::from_str(s).ok())
1252 .collect();
1253 Err(RuntimeError::AmbiguousPrefix {
1254 prefix: prefix.to_string(),
1255 matches: uuids,
1256 })
1257 }
1258 }
1259 }
1260
1261 pub async fn resolve(
1266 &self,
1267 token: &NamespaceToken,
1268 id: Uuid,
1269 ) -> RuntimeResult<Option<Resolved>> {
1270 let ns = token.namespace().as_str();
1271
1272 match self.get_entity(token, id).await {
1274 Ok(entity) => return Ok(Some(Resolved::Entity(entity))),
1275 Err(RuntimeError::NotFound(_) | RuntimeError::NamespaceMismatch { .. }) => {}
1276 Err(e) => return Err(e),
1277 }
1278
1279 if let Some(note) = self.notes(token)?.get_note(id).await? {
1281 if note.namespace == ns {
1282 return Ok(Some(Resolved::Note(note)));
1283 }
1284 }
1285
1286 if let Some(event) = self.events(token)?.get_event(id).await? {
1288 if event.namespace == ns {
1289 return Ok(Some(Resolved::Event(event)));
1290 }
1291 }
1292
1293 Ok(None)
1294 }
1295
1296 pub async fn delete_note(
1306 &self,
1307 token: &NamespaceToken,
1308 id: Uuid,
1309 hard: bool,
1310 ) -> RuntimeResult<bool> {
1311 let ns = token.namespace().as_str();
1312 let note_store = self.notes(token)?;
1313 let note = match note_store.get_note(id).await? {
1314 Some(n) => n,
1315 None => return Ok(false),
1316 };
1317 if note.namespace != ns {
1318 return Err(RuntimeError::NamespaceMismatch { id });
1319 }
1320 let mode = if hard {
1321 DeleteMode::Hard
1322 } else {
1323 DeleteMode::Soft
1324 };
1325
1326 if hard {
1328 let graph = self.graph(token)?;
1329 for direction in [Direction::Out, Direction::In] {
1330 let hits = graph
1331 .neighbors(
1332 id,
1333 NeighborQuery {
1334 direction,
1335 relations: None,
1336 limit: None,
1337 min_weight: None,
1338 },
1339 )
1340 .await?;
1341 for hit in hits {
1342 graph
1343 .delete_edge(LinkId::from(hit.edge_id), DeleteMode::Hard)
1344 .await?;
1345 }
1346 }
1347 let ns_str = ns.to_string();
1348 self.text_for_notes(token)?
1349 .delete_document(&ns_str, id)
1350 .await?;
1351 if self.config().embedding_model.is_some() {
1352 self.vectors(token)?.delete(id).await?;
1353 }
1354 }
1355
1356 let deleted = note_store.delete_note(id, mode).await?;
1357 if !hard && deleted {
1358 let ns_str = ns.to_string();
1359 self.text_for_notes(token)?
1360 .delete_document(&ns_str, id)
1361 .await?;
1362 if self.config().embedding_model.is_some() {
1363 self.vectors(token)?.delete(id).await?;
1364 }
1365 }
1366 if deleted {
1367 let event_store = self.events(token)?;
1368 let ns_str = ns.to_string();
1369 let event = khive_storage::event::Event::new(
1370 ns_str.clone(),
1371 "delete",
1372 EventKind::NoteDeleted,
1373 SubstrateKind::Note,
1374 "",
1375 )
1376 .with_target(id)
1377 .with_payload(serde_json::json!({"id": id, "namespace": ns_str, "hard": hard}));
1378 event_store.append_event(event).await.map_err(|e| {
1379 RuntimeError::Internal(format!("delete_note: event store write failed: {e}"))
1380 })?;
1381 }
1382 Ok(deleted)
1383 }
1384}
1385
1386#[derive(Clone, Debug, Serialize)]
1388pub struct QueryResult {
1389 pub rows: Vec<SqlRow>,
1390 #[serde(skip_serializing_if = "Vec::is_empty")]
1391 pub warnings: Vec<String>,
1392}
1393
1394impl KhiveRuntime {
1395 pub async fn query(&self, token: &NamespaceToken, query: &str) -> RuntimeResult<Vec<SqlRow>> {
1403 Ok(self.query_with_metadata(token, query).await?.rows)
1404 }
1405
1406 pub async fn query_with_metadata(
1408 &self,
1409 token: &NamespaceToken,
1410 query: &str,
1411 ) -> RuntimeResult<QueryResult> {
1412 use khive_query::QueryValue;
1413 use khive_storage::types::SqlValue;
1414
1415 let ns = token.namespace().as_str();
1416 let ast = khive_query::parse_auto(query)?;
1417 let opts = khive_query::CompileOptions {
1418 scopes: vec![ns.to_string()],
1419 ..Default::default()
1420 };
1421 let compiled = khive_query::compile(&ast, &opts)?;
1422 let warnings = compiled.warnings;
1423
1424 let params: Vec<SqlValue> = compiled
1427 .params
1428 .into_iter()
1429 .map(|qv| match qv {
1430 QueryValue::Null => SqlValue::Null,
1431 QueryValue::Integer(n) => SqlValue::Integer(n),
1432 QueryValue::Float(f) => SqlValue::Float(f),
1433 QueryValue::Text(s) => SqlValue::Text(s),
1434 QueryValue::Blob(b) => SqlValue::Blob(b),
1435 })
1436 .collect();
1437
1438 let mut reader = self.sql().reader().await?;
1439 let stmt = SqlStatement {
1440 sql: compiled.sql,
1441 params,
1442 label: None,
1443 };
1444 let rows = reader.query_all(stmt).await?;
1445 Ok(QueryResult { rows, warnings })
1446 }
1447
1448 pub async fn delete_entity(
1457 &self,
1458 token: &NamespaceToken,
1459 id: Uuid,
1460 hard: bool,
1461 ) -> RuntimeResult<bool> {
1462 let entity = match self.entities(token)?.get_entity(id).await? {
1463 Some(e) => e,
1464 None => return Ok(false),
1465 };
1466 self.ensure_namespace(&entity.namespace, token, id)?;
1467 let mode = if hard {
1468 DeleteMode::Hard
1469 } else {
1470 DeleteMode::Soft
1471 };
1472
1473 if hard {
1475 let graph = self.graph(token)?;
1476 for direction in [Direction::Out, Direction::In] {
1477 let hits = graph
1478 .neighbors(
1479 id,
1480 NeighborQuery {
1481 direction,
1482 relations: None,
1483 limit: None,
1484 min_weight: None,
1485 },
1486 )
1487 .await?;
1488 for hit in hits {
1489 graph
1490 .delete_edge(LinkId::from(hit.edge_id), DeleteMode::Hard)
1491 .await?;
1492 }
1493 }
1494 self.remove_from_indexes(token, id).await?;
1495 }
1496
1497 let deleted = self.entities(token)?.delete_entity(id, mode).await?;
1498 if !hard && deleted {
1499 self.remove_from_indexes(token, id).await?;
1500 }
1501 if deleted {
1502 let event_store = self.events(token)?;
1503 let ns = entity.namespace.clone();
1504 let event = khive_storage::event::Event::new(
1505 ns.clone(),
1506 "delete",
1507 EventKind::EntityDeleted,
1508 SubstrateKind::Entity,
1509 "",
1510 )
1511 .with_target(id)
1512 .with_payload(serde_json::json!({"id": id, "namespace": ns, "hard": hard}));
1513 event_store.append_event(event).await.map_err(|e| {
1514 RuntimeError::Internal(format!("delete_entity: event store write failed: {e}"))
1515 })?;
1516 }
1517 Ok(deleted)
1518 }
1519
1520 pub async fn count_entities(
1522 &self,
1523 token: &NamespaceToken,
1524 kind: Option<&str>,
1525 ) -> RuntimeResult<u64> {
1526 let filter = EntityFilter {
1527 kinds: match kind {
1528 Some(k) => vec![k.to_string()],
1529 None => vec![],
1530 },
1531 ..Default::default()
1532 };
1533 Ok(self
1534 .entities(token)?
1535 .count_entities(token.namespace().as_str(), filter)
1536 .await?)
1537 }
1538
1539 pub async fn get_edge(
1543 &self,
1544 token: &NamespaceToken,
1545 edge_id: Uuid,
1546 ) -> RuntimeResult<Option<Edge>> {
1547 Ok(self.graph(token)?.get_edge(LinkId::from(edge_id)).await?)
1548 }
1549
1550 pub async fn list_edges(
1552 &self,
1553 token: &NamespaceToken,
1554 filter: crate::curation::EdgeListFilter,
1555 limit: u32,
1556 ) -> RuntimeResult<Vec<Edge>> {
1557 let limit = limit.clamp(1, 1000);
1558 let page = self
1559 .graph(token)?
1560 .query_edges(
1561 filter.into(),
1562 vec![SortOrder {
1563 field: EdgeSortField::CreatedAt,
1564 direction: khive_storage::types::SortDirection::Asc,
1565 }],
1566 PageRequest { offset: 0, limit },
1567 )
1568 .await?;
1569 Ok(page.items)
1570 }
1571
1572 pub async fn update_edge(
1579 &self,
1580 token: &NamespaceToken,
1581 edge_id: Uuid,
1582 patch: crate::curation::EdgePatch,
1583 ) -> RuntimeResult<Edge> {
1584 let graph = self.graph(token)?;
1585 let mut edge = graph
1586 .get_edge(LinkId::from(edge_id))
1587 .await?
1588 .ok_or_else(|| crate::RuntimeError::NotFound(format!("edge {edge_id}")))?;
1589
1590 let mut changed_fields: Vec<&'static str> = Vec::new();
1591 if let Some(r) = patch.relation {
1592 self.validate_edge_relation_endpoints(token, edge.source_id, edge.target_id, r)
1594 .await?;
1595 edge.relation = r;
1596 changed_fields.push("relation");
1597 }
1598 if let Some(w) = patch.weight {
1599 edge.weight = w.clamp(0.0, 1.0);
1600 changed_fields.push("weight");
1601 }
1602 if let Some(props) = patch.properties {
1603 edge.metadata = Some(props);
1604 }
1605
1606 graph.upsert_edge(edge.clone()).await?;
1607
1608 let event_store = self.events(token)?;
1609 let ns = token.namespace().as_str().to_string();
1610 let event = khive_storage::event::Event::new(
1611 ns.clone(),
1612 "update",
1613 EventKind::EdgeUpdated,
1614 SubstrateKind::Entity,
1615 "",
1616 )
1617 .with_target(edge_id)
1618 .with_payload(
1619 serde_json::json!({"id": edge_id, "namespace": ns, "changed_fields": changed_fields}),
1620 );
1621 event_store.append_event(event).await.map_err(|e| {
1622 RuntimeError::Internal(format!("update_edge: event store write failed: {e}"))
1623 })?;
1624
1625 Ok(edge)
1626 }
1627
1628 pub async fn delete_edge(
1639 &self,
1640 token: &NamespaceToken,
1641 edge_id: Uuid,
1642 hard: bool,
1643 ) -> RuntimeResult<bool> {
1644 let graph = self.graph(token)?;
1645 let mode = if hard {
1646 DeleteMode::Hard
1647 } else {
1648 DeleteMode::Soft
1649 };
1650
1651 if graph.get_edge(LinkId::from(edge_id)).await?.is_none() {
1656 return Ok(false);
1657 }
1658
1659 let inbound = graph
1661 .neighbors(
1662 edge_id,
1663 NeighborQuery {
1664 direction: Direction::In,
1665 relations: None,
1666 limit: None,
1667 min_weight: None,
1668 },
1669 )
1670 .await?;
1671 for hit in inbound {
1672 graph
1673 .delete_edge(LinkId::from(hit.edge_id), DeleteMode::Hard)
1674 .await?;
1675 }
1676
1677 let deleted = graph.delete_edge(LinkId::from(edge_id), mode).await?;
1678 if deleted {
1679 let event_store = self.events(token)?;
1680 let ns = token.namespace().as_str().to_string();
1681 let event = khive_storage::event::Event::new(
1682 ns.clone(),
1683 "delete",
1684 EventKind::EdgeDeleted,
1685 SubstrateKind::Entity,
1686 "",
1687 )
1688 .with_target(edge_id)
1689 .with_payload(serde_json::json!({"id": edge_id, "namespace": ns, "hard": hard}));
1690 event_store.append_event(event).await.map_err(|e| {
1691 RuntimeError::Internal(format!("delete_edge: event store write failed: {e}"))
1692 })?;
1693 }
1694 Ok(deleted)
1695 }
1696
1697 pub async fn count_edges(
1699 &self,
1700 token: &NamespaceToken,
1701 filter: crate::curation::EdgeListFilter,
1702 ) -> RuntimeResult<u64> {
1703 Ok(self.graph(token)?.count_edges(filter.into()).await?)
1704 }
1705
1706 pub async fn build_edge(&self, token: &NamespaceToken, spec: &LinkSpec) -> RuntimeResult<Edge> {
1717 let ns_str = match &spec.namespace {
1718 Some(s) => {
1719 let spec_ns = crate::Namespace::parse(s)
1720 .map_err(|e| RuntimeError::InvalidInput(format!("invalid namespace: {e}")))?;
1721 if &spec_ns != token.namespace() {
1722 return Err(RuntimeError::InvalidInput(
1723 "LinkSpec namespace does not match token namespace".into(),
1724 ));
1725 }
1726 s.as_str()
1727 }
1728 None => token.namespace().as_str(),
1729 };
1730 self.validate_edge_relation_endpoints(token, spec.source_id, spec.target_id, spec.relation)
1731 .await?;
1732 let (source_id, target_id) =
1733 canonical_edge_endpoints(spec.relation, spec.source_id, spec.target_id);
1734 let metadata = if spec.relation == EdgeRelation::DependsOn {
1735 match (
1736 self.resolve(token, source_id).await?,
1737 self.resolve(token, target_id).await?,
1738 ) {
1739 (Some(Resolved::Entity(src_e)), Some(Resolved::Entity(tgt_e))) => {
1740 merge_dependency_kind(&src_e.kind, &tgt_e.kind, spec.metadata.clone())
1741 }
1742 _ => spec.metadata.clone(),
1743 }
1744 } else {
1745 spec.metadata.clone()
1746 };
1747 validate_edge_metadata(spec.relation, metadata.as_ref())?;
1748 let now = chrono::Utc::now();
1749 Ok(Edge {
1750 id: LinkId::from(Uuid::new_v4()),
1751 namespace: ns_str.to_string(),
1752 source_id,
1753 target_id,
1754 relation: spec.relation,
1755 weight: spec.weight,
1756 created_at: now,
1757 updated_at: now,
1758 deleted_at: None,
1759 metadata,
1760 target_backend: None,
1761 })
1762 }
1763
1764 pub async fn link_many(
1774 &self,
1775 token: &NamespaceToken,
1776 specs: Vec<LinkSpec>,
1777 ) -> RuntimeResult<Vec<Edge>> {
1778 if specs.is_empty() {
1779 return Ok(vec![]);
1780 }
1781 let mut edges = Vec::with_capacity(specs.len());
1782 for spec in &specs {
1783 edges.push(self.build_edge(token, spec).await?);
1784 }
1785 self.graph(token)?.upsert_edges(edges.clone()).await?;
1786 Ok(edges)
1787 }
1788}
1789
1790#[derive(Clone, Debug)]
1793pub struct LinkSpec {
1794 pub namespace: Option<String>,
1795 pub source_id: Uuid,
1796 pub target_id: Uuid,
1797 pub relation: EdgeRelation,
1798 pub weight: f64,
1799 pub metadata: Option<serde_json::Value>,
1800}
1801
1802#[cfg(test)]
1803mod tests {
1804 use super::*;
1805 use crate::curation::EdgeListFilter;
1806 use crate::runtime::{KhiveRuntime, NamespaceToken};
1807 use crate::Namespace;
1808
1809 fn rt() -> KhiveRuntime {
1810 KhiveRuntime::memory().unwrap()
1811 }
1812
1813 #[tokio::test]
1814 async fn update_edge_changes_weight() {
1815 let rt = rt();
1816 let tok = NamespaceToken::local();
1817 let a = rt
1818 .create_entity(&tok, "concept", None, "A", None, None, vec![])
1819 .await
1820 .unwrap();
1821 let b = rt
1822 .create_entity(&tok, "concept", None, "B", None, None, vec![])
1823 .await
1824 .unwrap();
1825 let edge = rt
1826 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
1827 .await
1828 .unwrap();
1829 let edge_id: Uuid = edge.id.into();
1830
1831 let updated = rt
1832 .update_edge(
1833 &tok,
1834 edge_id,
1835 crate::curation::EdgePatch {
1836 weight: Some(0.5),
1837 ..Default::default()
1838 },
1839 )
1840 .await
1841 .unwrap();
1842 assert!((updated.weight - 0.5).abs() < 0.001);
1843 }
1844
1845 #[tokio::test]
1846 async fn update_edge_changes_relation() {
1847 let rt = rt();
1848 let tok = NamespaceToken::local();
1849 let a = rt
1850 .create_entity(&tok, "concept", None, "A", None, None, vec![])
1851 .await
1852 .unwrap();
1853 let b = rt
1854 .create_entity(&tok, "concept", None, "B", None, None, vec![])
1855 .await
1856 .unwrap();
1857 let edge = rt
1858 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
1859 .await
1860 .unwrap();
1861 let edge_id: Uuid = edge.id.into();
1862
1863 let updated = rt
1864 .update_edge(
1865 &tok,
1866 edge_id,
1867 crate::curation::EdgePatch {
1868 relation: Some(EdgeRelation::VariantOf),
1869 ..Default::default()
1870 },
1871 )
1872 .await
1873 .unwrap();
1874 assert_eq!(updated.relation, EdgeRelation::VariantOf);
1875 }
1876
1877 #[tokio::test]
1882 async fn update_edge_annotates_note_to_entity_set_supersedes_returns_invalid_input() {
1883 let rt = rt();
1884 let tok = NamespaceToken::local();
1885 let note = rt
1886 .create_note(&tok, "observation", None, "a note", Some(0.5), None, vec![])
1887 .await
1888 .unwrap();
1889 let entity = rt
1890 .create_entity(&tok, "concept", None, "E", None, None, vec![])
1891 .await
1892 .unwrap();
1893 let edge = rt
1895 .link(&tok, note.id, entity.id, EdgeRelation::Annotates, 1.0, None)
1896 .await
1897 .unwrap();
1898 let edge_id: Uuid = edge.id.into();
1899
1900 let result = rt
1902 .update_edge(
1903 &tok,
1904 edge_id,
1905 crate::curation::EdgePatch {
1906 relation: Some(EdgeRelation::Supersedes),
1907 ..Default::default()
1908 },
1909 )
1910 .await;
1911 assert!(
1912 matches!(result, Err(RuntimeError::InvalidInput(_))),
1913 "update to Supersedes on note→entity edge must return InvalidInput, got {result:?}"
1914 );
1915
1916 let fetched = rt.get_edge(&tok, edge_id).await.unwrap().unwrap();
1918 assert_eq!(
1919 fetched.relation,
1920 EdgeRelation::Annotates,
1921 "edge relation must be unchanged after failed update"
1922 );
1923 }
1924
1925 #[tokio::test]
1928 async fn update_edge_entity_to_entity_set_annotates_returns_invalid_input() {
1929 let rt = rt();
1930 let tok = NamespaceToken::local();
1931 let a = rt
1932 .create_entity(&tok, "concept", None, "A", None, None, vec![])
1933 .await
1934 .unwrap();
1935 let b = rt
1936 .create_entity(&tok, "concept", None, "B", None, None, vec![])
1937 .await
1938 .unwrap();
1939 let edge = rt
1940 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
1941 .await
1942 .unwrap();
1943 let edge_id: Uuid = edge.id.into();
1944
1945 let result = rt
1946 .update_edge(
1947 &tok,
1948 edge_id,
1949 crate::curation::EdgePatch {
1950 relation: Some(EdgeRelation::Annotates),
1951 ..Default::default()
1952 },
1953 )
1954 .await;
1955 assert!(
1956 matches!(result, Err(RuntimeError::InvalidInput(_))),
1957 "update to Annotates on entity→entity edge must return InvalidInput, got {result:?}"
1958 );
1959 }
1960
1961 #[tokio::test]
1964 async fn update_edge_entity_to_entity_set_supersedes_succeeds() {
1965 let rt = rt();
1966 let tok = NamespaceToken::local();
1967 let a = rt
1968 .create_entity(&tok, "concept", None, "A", None, None, vec![])
1969 .await
1970 .unwrap();
1971 let b = rt
1972 .create_entity(&tok, "concept", None, "B", None, None, vec![])
1973 .await
1974 .unwrap();
1975 let edge = rt
1976 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
1977 .await
1978 .unwrap();
1979 let edge_id: Uuid = edge.id.into();
1980
1981 let updated = rt
1982 .update_edge(
1983 &tok,
1984 edge_id,
1985 crate::curation::EdgePatch {
1986 relation: Some(EdgeRelation::Supersedes),
1987 ..Default::default()
1988 },
1989 )
1990 .await
1991 .unwrap();
1992 assert_eq!(updated.relation, EdgeRelation::Supersedes);
1993
1994 let fetched = rt.get_edge(&tok, edge_id).await.unwrap().unwrap();
1996 assert_eq!(fetched.relation, EdgeRelation::Supersedes);
1997 }
1998
1999 #[tokio::test]
2001 async fn update_edge_weight_only_skips_validation() {
2002 let rt = rt();
2003 let tok = NamespaceToken::local();
2004 let a = rt
2005 .create_entity(&tok, "concept", None, "A", None, None, vec![])
2006 .await
2007 .unwrap();
2008 let b = rt
2009 .create_entity(&tok, "concept", None, "B", None, None, vec![])
2010 .await
2011 .unwrap();
2012 let edge = rt
2013 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
2014 .await
2015 .unwrap();
2016 let edge_id: Uuid = edge.id.into();
2017
2018 let updated = rt
2019 .update_edge(
2020 &tok,
2021 edge_id,
2022 crate::curation::EdgePatch {
2023 weight: Some(0.3),
2024 ..Default::default()
2025 },
2026 )
2027 .await
2028 .unwrap();
2029 assert_eq!(updated.relation, EdgeRelation::Extends);
2030 assert!((updated.weight - 0.3).abs() < 0.001);
2031 }
2032
2033 #[tokio::test]
2035 async fn update_edge_same_class_relation_change_succeeds() {
2036 let rt = rt();
2037 let tok = NamespaceToken::local();
2038 let a = rt
2039 .create_entity(&tok, "concept", None, "A", None, None, vec![])
2040 .await
2041 .unwrap();
2042 let b = rt
2043 .create_entity(&tok, "concept", None, "B", None, None, vec![])
2044 .await
2045 .unwrap();
2046 let edge = rt
2047 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
2048 .await
2049 .unwrap();
2050 let edge_id: Uuid = edge.id.into();
2051
2052 let updated = rt
2053 .update_edge(
2054 &tok,
2055 edge_id,
2056 crate::curation::EdgePatch {
2057 relation: Some(EdgeRelation::VariantOf),
2058 ..Default::default()
2059 },
2060 )
2061 .await
2062 .unwrap();
2063 assert_eq!(updated.relation, EdgeRelation::VariantOf);
2064 }
2065
2066 #[tokio::test]
2067 async fn list_edges_filters_by_relation() {
2068 let rt = rt();
2069 let tok = NamespaceToken::local();
2070 let a = rt
2071 .create_entity(&tok, "concept", None, "A", None, None, vec![])
2072 .await
2073 .unwrap();
2074 let b = rt
2075 .create_entity(&tok, "concept", None, "B", None, None, vec![])
2076 .await
2077 .unwrap();
2078 let c = rt
2079 .create_entity(&tok, "concept", None, "C", None, None, vec![])
2080 .await
2081 .unwrap();
2082
2083 rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
2084 .await
2085 .unwrap();
2086 rt.link(&tok, a.id, c.id, EdgeRelation::Enables, 1.0, None)
2087 .await
2088 .unwrap();
2089
2090 let filter = EdgeListFilter {
2091 relations: vec![EdgeRelation::Extends],
2092 ..Default::default()
2093 };
2094 let edges = rt.list_edges(&tok, filter, 100).await.unwrap();
2095 assert_eq!(edges.len(), 1);
2096 assert_eq!(edges[0].relation, EdgeRelation::Extends);
2097 }
2098
2099 #[tokio::test]
2100 async fn list_edges_filters_by_source() {
2101 let rt = rt();
2102 let tok = NamespaceToken::local();
2103 let a = rt
2104 .create_entity(&tok, "concept", None, "A", None, None, vec![])
2105 .await
2106 .unwrap();
2107 let b = rt
2108 .create_entity(&tok, "concept", None, "B", None, None, vec![])
2109 .await
2110 .unwrap();
2111 let c = rt
2112 .create_entity(&tok, "concept", None, "C", None, None, vec![])
2113 .await
2114 .unwrap();
2115 let d = rt
2116 .create_entity(&tok, "concept", None, "D", None, None, vec![])
2117 .await
2118 .unwrap();
2119
2120 rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
2121 .await
2122 .unwrap();
2123 rt.link(&tok, c.id, d.id, EdgeRelation::Extends, 1.0, None)
2124 .await
2125 .unwrap();
2126
2127 let filter = EdgeListFilter {
2128 source_id: Some(a.id),
2129 ..Default::default()
2130 };
2131 let edges = rt.list_edges(&tok, filter, 100).await.unwrap();
2132 assert_eq!(edges.len(), 1);
2133 let src: Uuid = edges[0].source_id;
2134 assert_eq!(src, a.id);
2135 }
2136
2137 #[tokio::test]
2138 async fn delete_edge_removes_from_storage() {
2139 let rt = rt();
2140 let tok = NamespaceToken::local();
2141 let a = rt
2142 .create_entity(&tok, "concept", None, "A", None, None, vec![])
2143 .await
2144 .unwrap();
2145 let b = rt
2146 .create_entity(&tok, "concept", None, "B", None, None, vec![])
2147 .await
2148 .unwrap();
2149 let edge = rt
2150 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
2151 .await
2152 .unwrap();
2153 let edge_id: Uuid = edge.id.into();
2154
2155 let deleted = rt.delete_edge(&tok, edge_id, true).await.unwrap();
2156 assert!(deleted);
2157
2158 let fetched = rt.get_edge(&tok, edge_id).await.unwrap();
2159 assert!(fetched.is_none(), "edge should be gone after delete");
2160 }
2161
2162 #[tokio::test]
2163 async fn count_edges_matches_filter() {
2164 let rt = rt();
2165 let tok = NamespaceToken::local();
2166 let a = rt
2167 .create_entity(&tok, "concept", None, "A", None, None, vec![])
2168 .await
2169 .unwrap();
2170 let b = rt
2171 .create_entity(&tok, "concept", None, "B", None, None, vec![])
2172 .await
2173 .unwrap();
2174 let c = rt
2175 .create_entity(&tok, "concept", None, "C", None, None, vec![])
2176 .await
2177 .unwrap();
2178
2179 rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
2180 .await
2181 .unwrap();
2182 rt.link(&tok, a.id, c.id, EdgeRelation::Enables, 1.0, None)
2183 .await
2184 .unwrap();
2185
2186 let all = rt
2187 .count_edges(&tok, EdgeListFilter::default())
2188 .await
2189 .unwrap();
2190 assert_eq!(all, 2);
2191
2192 let just_extends = rt
2193 .count_edges(
2194 &tok,
2195 EdgeListFilter {
2196 relations: vec![EdgeRelation::Extends],
2197 ..Default::default()
2198 },
2199 )
2200 .await
2201 .unwrap();
2202 assert_eq!(just_extends, 1);
2203 }
2204
2205 #[tokio::test]
2206 async fn get_entity_namespace_isolation() {
2207 let rt = rt();
2208 let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
2209 let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
2210 let entity = rt
2211 .create_entity(&ns_a, "concept", None, "Alpha", None, None, vec![])
2212 .await
2213 .unwrap();
2214
2215 let found = rt.get_entity(&ns_a, entity.id).await;
2217 assert!(found.is_ok(), "should be visible in its own namespace");
2218
2219 let not_found = rt.get_entity(&ns_b, entity.id).await;
2221 assert!(
2222 not_found.is_err(),
2223 "should not be visible across namespaces"
2224 );
2225 assert!(
2227 matches!(not_found.unwrap_err(), crate::RuntimeError::NamespaceMismatch { id } if id == entity.id),
2228 "cross-namespace get must return NamespaceMismatch with the entity id"
2229 );
2230 }
2231
2232 #[tokio::test]
2233 async fn namespace_mismatch_error_message_is_opaque() {
2234 let rt = rt();
2237 let ns_a = NamespaceToken::for_namespace(Namespace::parse("secret-ns").unwrap());
2238 let ns_b = NamespaceToken::for_namespace(Namespace::parse("other-ns").unwrap());
2239 let entity = rt
2240 .create_entity(&ns_a, "concept", None, "Hidden", None, None, vec![])
2241 .await
2242 .unwrap();
2243
2244 let err = rt.get_entity(&ns_b, entity.id).await.unwrap_err();
2245 let msg = err.to_string();
2246 assert!(
2247 !msg.contains("secret-ns"),
2248 "error message must not leak the actual namespace; got: {msg}"
2249 );
2250 assert!(
2251 !msg.contains("other-ns"),
2252 "error message must not leak the requested namespace; got: {msg}"
2253 );
2254 }
2255
2256 #[tokio::test]
2257 async fn delete_entity_namespace_isolation() {
2258 let rt = rt();
2259 let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
2260 let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
2261 let entity = rt
2262 .create_entity(&ns_a, "concept", None, "Beta", None, None, vec![])
2263 .await
2264 .unwrap();
2265
2266 let cross_ns_result = rt.delete_entity(&ns_b, entity.id, true).await;
2268 assert!(
2269 cross_ns_result.is_err(),
2270 "cross-namespace delete must error"
2271 );
2272 assert!(
2273 matches!(cross_ns_result.unwrap_err(), crate::RuntimeError::NamespaceMismatch { id } if id == entity.id),
2274 "cross-namespace delete must return NamespaceMismatch, not a generic error"
2275 );
2276
2277 let still_there = rt.get_entity(&ns_a, entity.id).await;
2279 assert!(
2280 still_there.is_ok(),
2281 "entity must survive cross-ns delete attempt"
2282 );
2283
2284 let deleted_ok = rt.delete_entity(&ns_a, entity.id, true).await.unwrap();
2286 assert!(deleted_ok, "same-namespace delete must succeed");
2287 }
2288
2289 #[tokio::test]
2292 async fn create_note_indexes_into_fts5() {
2293 let rt = rt();
2294 let tok = NamespaceToken::local();
2295 let note = rt
2296 .create_note(
2297 &tok,
2298 "observation",
2299 None,
2300 "FlashAttention reduces memory by using tiling",
2301 Some(0.8),
2302 None,
2303 vec![],
2304 )
2305 .await
2306 .unwrap();
2307
2308 let ns = tok.namespace().as_str().to_string();
2310 let hits = rt
2311 .text_for_notes(&tok)
2312 .unwrap()
2313 .search(khive_storage::types::TextSearchRequest {
2314 query: "FlashAttention".to_string(),
2315 mode: khive_storage::types::TextQueryMode::Plain,
2316 filter: Some(khive_storage::types::TextFilter {
2317 namespaces: vec![ns],
2318 ..Default::default()
2319 }),
2320 top_k: 10,
2321 snippet_chars: 100,
2322 })
2323 .await
2324 .unwrap();
2325
2326 assert!(
2327 hits.iter().any(|h| h.subject_id == note.id),
2328 "note should be indexed in FTS5 after create"
2329 );
2330 }
2331
2332 #[tokio::test]
2333 async fn create_note_with_properties() {
2334 let rt = rt();
2335 let tok = NamespaceToken::local();
2336 let props = serde_json::json!({"source": "arxiv:2205.14135"});
2337 let note = rt
2338 .create_note(
2339 &tok,
2340 "insight",
2341 None,
2342 "FlashAttention is IO-aware",
2343 Some(0.9),
2344 Some(props.clone()),
2345 vec![],
2346 )
2347 .await
2348 .unwrap();
2349
2350 assert_eq!(note.properties.as_ref().unwrap(), &props);
2351 }
2352
2353 #[tokio::test]
2354 async fn create_note_creates_annotates_edges() {
2355 let rt = rt();
2356 let tok = NamespaceToken::local();
2357 let entity = rt
2358 .create_entity(&tok, "concept", None, "FlashAttention", None, None, vec![])
2359 .await
2360 .unwrap();
2361
2362 let note = rt
2363 .create_note(
2364 &tok,
2365 "observation",
2366 None,
2367 "FlashAttention uses SRAM tiling for memory efficiency",
2368 Some(0.9),
2369 None,
2370 vec![entity.id],
2371 )
2372 .await
2373 .unwrap();
2374
2375 let out_neighbors = rt
2377 .neighbors(
2378 &tok,
2379 note.id,
2380 Direction::Out,
2381 None,
2382 Some(vec![EdgeRelation::Annotates]),
2383 )
2384 .await
2385 .unwrap();
2386 assert_eq!(out_neighbors.len(), 1);
2387 assert_eq!(out_neighbors[0].node_id, entity.id);
2388 assert_eq!(out_neighbors[0].relation, EdgeRelation::Annotates);
2389
2390 let in_neighbors = rt
2392 .neighbors(
2393 &tok,
2394 entity.id,
2395 Direction::In,
2396 None,
2397 Some(vec![EdgeRelation::Annotates]),
2398 )
2399 .await
2400 .unwrap();
2401 assert_eq!(in_neighbors.len(), 1);
2402 assert_eq!(in_neighbors[0].node_id, note.id);
2403 }
2404
2405 #[tokio::test]
2406 async fn neighbors_without_relation_filter_returns_all() {
2407 let rt = rt();
2408 let tok = NamespaceToken::local();
2409 let a = rt
2410 .create_entity(&tok, "concept", None, "A", None, None, vec![])
2411 .await
2412 .unwrap();
2413 let b = rt
2414 .create_entity(&tok, "concept", None, "B", None, None, vec![])
2415 .await
2416 .unwrap();
2417 let c = rt
2418 .create_entity(&tok, "concept", None, "C", None, None, vec![])
2419 .await
2420 .unwrap();
2421
2422 rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
2423 .await
2424 .unwrap();
2425 rt.link(&tok, a.id, c.id, EdgeRelation::Enables, 1.0, None)
2426 .await
2427 .unwrap();
2428
2429 let all = rt
2430 .neighbors(&tok, a.id, Direction::Out, None, None)
2431 .await
2432 .unwrap();
2433 assert_eq!(all.len(), 2);
2434 }
2435
2436 #[tokio::test]
2437 async fn neighbors_with_relation_filter_returns_subset() {
2438 let rt = rt();
2439 let tok = NamespaceToken::local();
2440 let a = rt
2441 .create_entity(&tok, "concept", None, "A", None, None, vec![])
2442 .await
2443 .unwrap();
2444 let b = rt
2445 .create_entity(&tok, "concept", None, "B", None, None, vec![])
2446 .await
2447 .unwrap();
2448 let c = rt
2449 .create_entity(&tok, "concept", None, "C", None, None, vec![])
2450 .await
2451 .unwrap();
2452
2453 rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
2454 .await
2455 .unwrap();
2456 rt.link(&tok, a.id, c.id, EdgeRelation::Enables, 1.0, None)
2457 .await
2458 .unwrap();
2459
2460 let filtered = rt
2461 .neighbors(
2462 &tok,
2463 a.id,
2464 Direction::Out,
2465 None,
2466 Some(vec![EdgeRelation::Extends]),
2467 )
2468 .await
2469 .unwrap();
2470 assert_eq!(filtered.len(), 1);
2471 assert_eq!(filtered[0].node_id, b.id);
2472 assert_eq!(filtered[0].relation, EdgeRelation::Extends);
2473 }
2474
2475 #[tokio::test]
2476 async fn search_notes_returns_relevant_note() {
2477 let rt = rt();
2478 let tok = NamespaceToken::local();
2479 rt.create_note(
2480 &tok,
2481 "observation",
2482 None,
2483 "GQA reduces KV cache memory for large models",
2484 Some(0.8),
2485 None,
2486 vec![],
2487 )
2488 .await
2489 .unwrap();
2490
2491 let results = rt
2492 .search_notes(&tok, "GQA KV cache", None, 10, None, false)
2493 .await
2494 .unwrap();
2495
2496 assert!(!results.is_empty(), "search should return the indexed note");
2497 let hit = &results[0];
2498 assert!(
2499 hit.title.is_some(),
2500 "note hit title should be populated (falls back to content)"
2501 );
2502 assert!(
2503 hit.snippet.is_some(),
2504 "note hit snippet should be populated"
2505 );
2506 }
2507
2508 #[tokio::test]
2509 async fn search_notes_excludes_soft_deleted() {
2510 let rt = rt();
2511 let tok = NamespaceToken::local();
2512 let note = rt
2513 .create_note(
2514 &tok,
2515 "observation",
2516 None,
2517 "RoPE positional encoding rotary embeddings",
2518 Some(0.7),
2519 None,
2520 vec![],
2521 )
2522 .await
2523 .unwrap();
2524
2525 rt.notes(&tok)
2527 .unwrap()
2528 .delete_note(note.id, DeleteMode::Soft)
2529 .await
2530 .unwrap();
2531
2532 let results = rt
2533 .search_notes(&tok, "RoPE rotary positional", None, 10, None, false)
2534 .await
2535 .unwrap();
2536
2537 assert!(
2538 results.iter().all(|h| h.note_id != note.id),
2539 "soft-deleted note should be excluded from search"
2540 );
2541 }
2542
2543 #[tokio::test]
2544 async fn resolve_returns_entity() {
2545 let rt = rt();
2546 let tok = NamespaceToken::local();
2547 let entity = rt
2548 .create_entity(&tok, "concept", None, "LoRA", None, None, vec![])
2549 .await
2550 .unwrap();
2551
2552 let resolved = rt.resolve(&tok, entity.id).await.unwrap();
2553 match resolved {
2554 Some(Resolved::Entity(e)) => assert_eq!(e.id, entity.id),
2555 other => panic!("expected Resolved::Entity, got {:?}", other),
2556 }
2557 }
2558
2559 #[tokio::test]
2560 async fn resolve_returns_note() {
2561 let rt = rt();
2562 let tok = NamespaceToken::local();
2563 let note = rt
2564 .create_note(
2565 &tok,
2566 "observation",
2567 None,
2568 "LoRA fine-tunes LLMs with low-rank adapters",
2569 Some(0.85),
2570 None,
2571 vec![],
2572 )
2573 .await
2574 .unwrap();
2575
2576 let resolved = rt.resolve(&tok, note.id).await.unwrap();
2577 match resolved {
2578 Some(Resolved::Note(n)) => assert_eq!(n.id, note.id),
2579 other => panic!("expected Resolved::Note, got {:?}", other),
2580 }
2581 }
2582
2583 #[tokio::test]
2584 async fn resolve_returns_none_for_unknown_uuid() {
2585 let rt = rt();
2586 let tok = NamespaceToken::local();
2587 let unknown = Uuid::new_v4();
2588 let resolved = rt.resolve(&tok, unknown).await.unwrap();
2589 assert!(resolved.is_none(), "unknown UUID should resolve to None");
2590 }
2591
2592 #[tokio::test]
2593 async fn resolve_prefix_finds_entity_in_own_namespace() {
2594 let rt = rt();
2595 let tok = NamespaceToken::local();
2596 let entity = rt
2597 .create_entity(&tok, "concept", None, "PrefixTest", None, None, vec![])
2598 .await
2599 .unwrap();
2600 let prefix = &entity.id.to_string()[..8];
2601
2602 let resolved = rt.resolve_prefix(&tok, prefix).await.unwrap();
2603 assert_eq!(resolved, Some(entity.id));
2604 }
2605
2606 #[tokio::test]
2607 async fn resolve_prefix_invisible_across_namespaces() {
2608 let rt = rt();
2609 let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
2610 let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
2611 let entity = rt
2612 .create_entity(&ns_a, "concept", None, "Invisible", None, None, vec![])
2613 .await
2614 .unwrap();
2615 let prefix = &entity.id.to_string()[..8];
2616
2617 let resolved = rt.resolve_prefix(&ns_b, prefix).await.unwrap();
2619 assert_eq!(resolved, None);
2620 }
2621
2622 #[tokio::test]
2623 async fn resolve_prefix_ambiguous_same_namespace() {
2624 use khive_storage::entity::Entity;
2625
2626 let rt = rt();
2627 let tok = NamespaceToken::local();
2628 let id_a = Uuid::parse_str("aabbccdd-1111-4000-8000-000000000001").unwrap();
2630 let id_b = Uuid::parse_str("aabbccdd-2222-4000-8000-000000000002").unwrap();
2631
2632 let mut entity_a = Entity::new("local", "concept", "AmbigA");
2633 entity_a.id = id_a;
2634 let mut entity_b = Entity::new("local", "concept", "AmbigB");
2635 entity_b.id = id_b;
2636
2637 let store = rt.entities(&tok).unwrap();
2638 store.upsert_entity(entity_a).await.unwrap();
2639 store.upsert_entity(entity_b).await.unwrap();
2640
2641 let result = rt.resolve_prefix(&tok, "aabbccdd").await;
2642 assert!(
2643 result.is_err(),
2644 "shared 8-char prefix must return Ambiguous error"
2645 );
2646 }
2647
2648 #[tokio::test]
2655 async fn resolve_finds_event_by_full_uuid() {
2656 use khive_storage::Event;
2657 use khive_types::{EventKind, SubstrateKind};
2658
2659 let rt = rt();
2660 let tok = NamespaceToken::local();
2661 let ns = tok.namespace().as_str();
2662 let event = Event::new(
2663 ns,
2664 "test_verb",
2665 EventKind::Audit,
2666 SubstrateKind::Entity,
2667 "actor",
2668 );
2669 let event_id = event.id;
2670 rt.events(&tok).unwrap().append_event(event).await.unwrap();
2671
2672 let resolved = rt.resolve(&tok, event_id).await.unwrap();
2673 assert!(
2674 matches!(resolved, Some(Resolved::Event(_))),
2675 "event UUID must resolve to Resolved::Event, got {resolved:?}"
2676 );
2677 }
2678
2679 #[tokio::test]
2680 async fn resolve_prefix_finds_event() {
2681 use khive_storage::Event;
2682 use khive_types::{EventKind, SubstrateKind};
2683
2684 let rt = rt();
2685 let tok = NamespaceToken::local();
2686 let ns = tok.namespace().as_str();
2687 let event = Event::new(
2688 ns,
2689 "test_verb",
2690 EventKind::Audit,
2691 SubstrateKind::Entity,
2692 "actor",
2693 );
2694 let event_id = event.id;
2695 rt.events(&tok).unwrap().append_event(event).await.unwrap();
2696
2697 let prefix = &event_id.to_string()[..8];
2698 let resolved = rt.resolve_prefix(&tok, prefix).await.unwrap();
2699 assert_eq!(
2700 resolved,
2701 Some(event_id),
2702 "resolve_prefix must return event UUID for 8-char prefix"
2703 );
2704 }
2705
2706 #[tokio::test]
2709 async fn link_phantom_source_returns_not_found() {
2710 let rt = rt();
2711 let tok = NamespaceToken::local();
2712 let b = rt
2713 .create_entity(&tok, "concept", None, "B", None, None, vec![])
2714 .await
2715 .unwrap();
2716 let phantom = Uuid::new_v4();
2717
2718 let result = rt
2719 .link(&tok, phantom, b.id, EdgeRelation::Extends, 1.0, None)
2720 .await;
2721 match result {
2722 Err(RuntimeError::NotFound(msg)) => {
2723 assert!(
2724 msg.contains("source"),
2725 "error message must name 'source': {msg}"
2726 );
2727 }
2728 other => panic!("expected NotFound for phantom source, got {other:?}"),
2729 }
2730 }
2731
2732 #[tokio::test]
2733 async fn link_phantom_target_returns_not_found() {
2734 let rt = rt();
2735 let tok = NamespaceToken::local();
2736 let a = rt
2737 .create_entity(&tok, "concept", None, "A", None, None, vec![])
2738 .await
2739 .unwrap();
2740 let phantom = Uuid::new_v4();
2741
2742 let result = rt
2743 .link(&tok, a.id, phantom, EdgeRelation::Extends, 1.0, None)
2744 .await;
2745 match result {
2746 Err(RuntimeError::NotFound(msg)) => {
2747 assert!(
2748 msg.contains("target"),
2749 "error message must name 'target': {msg}"
2750 );
2751 }
2752 other => panic!("expected NotFound for phantom target, got {other:?}"),
2753 }
2754 }
2755
2756 #[tokio::test]
2757 async fn link_real_entities_succeeds() {
2758 let rt = rt();
2759 let tok = NamespaceToken::local();
2760 let a = rt
2761 .create_entity(&tok, "concept", None, "A", None, None, vec![])
2762 .await
2763 .unwrap();
2764 let b = rt
2765 .create_entity(&tok, "concept", None, "B", None, None, vec![])
2766 .await
2767 .unwrap();
2768
2769 let edge = rt
2770 .link(&tok, a.id, b.id, EdgeRelation::Extends, 0.8, None)
2771 .await
2772 .unwrap();
2773 assert_eq!(edge.source_id, a.id);
2774 assert_eq!(edge.target_id, b.id);
2775 assert_eq!(edge.relation, EdgeRelation::Extends);
2776 }
2777
2778 #[tokio::test]
2779 async fn create_note_annotates_phantom_returns_not_found() {
2780 let rt = rt();
2781 let tok = NamespaceToken::local();
2782 let phantom = Uuid::new_v4();
2783
2784 let result = rt
2785 .create_note(
2786 &tok,
2787 "observation",
2788 None,
2789 "some content",
2790 Some(0.5),
2791 None,
2792 vec![phantom],
2793 )
2794 .await;
2795 assert!(
2796 matches!(result, Err(RuntimeError::NotFound(_))),
2797 "annotates with phantom uuid must return NotFound, got {result:?}"
2798 );
2799 }
2800
2801 #[tokio::test]
2802 async fn create_note_annotates_real_entity_succeeds() {
2803 let rt = rt();
2804 let tok = NamespaceToken::local();
2805 let entity = rt
2806 .create_entity(&tok, "concept", None, "RealTarget", None, None, vec![])
2807 .await
2808 .unwrap();
2809
2810 let note = rt
2811 .create_note(
2812 &tok,
2813 "observation",
2814 None,
2815 "content",
2816 Some(0.5),
2817 None,
2818 vec![entity.id],
2819 )
2820 .await
2821 .unwrap();
2822
2823 let neighbors = rt
2824 .neighbors(
2825 &tok,
2826 note.id,
2827 Direction::Out,
2828 None,
2829 Some(vec![EdgeRelation::Annotates]),
2830 )
2831 .await
2832 .unwrap();
2833 assert_eq!(neighbors.len(), 1);
2834 assert_eq!(neighbors[0].node_id, entity.id);
2835 }
2836
2837 #[tokio::test]
2839 async fn create_note_multi_annotates_creates_all_edges() {
2840 let rt = rt();
2841 let tok = NamespaceToken::local();
2842 let t1 = rt
2843 .create_entity(&tok, "concept", None, "Target1", None, None, vec![])
2844 .await
2845 .unwrap();
2846 let t2 = rt
2847 .create_entity(&tok, "concept", None, "Target2", None, None, vec![])
2848 .await
2849 .unwrap();
2850
2851 let note = rt
2852 .create_note(
2853 &tok,
2854 "observation",
2855 None,
2856 "content",
2857 Some(0.5),
2858 None,
2859 vec![t1.id, t2.id],
2860 )
2861 .await
2862 .unwrap();
2863
2864 let neighbors = rt
2865 .neighbors(
2866 &tok,
2867 note.id,
2868 Direction::Out,
2869 None,
2870 Some(vec![EdgeRelation::Annotates]),
2871 )
2872 .await
2873 .unwrap();
2874 assert_eq!(
2875 neighbors.len(),
2876 2,
2877 "multi-annotates note must have exactly 2 outbound annotates edges"
2878 );
2879 let target_ids: Vec<Uuid> = neighbors.iter().map(|n| n.node_id).collect();
2880 assert!(target_ids.contains(&t1.id));
2881 assert!(target_ids.contains(&t2.id));
2882 }
2883
2884 #[tokio::test]
2885 async fn link_target_in_different_namespace_returns_not_found() {
2886 let rt = rt();
2887 let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
2888 let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
2889 let a = rt
2890 .create_entity(&ns_a, "concept", None, "A", None, None, vec![])
2891 .await
2892 .unwrap();
2893 let b = rt
2894 .create_entity(&ns_b, "concept", None, "B", None, None, vec![])
2895 .await
2896 .unwrap();
2897
2898 let result = rt
2900 .link(&ns_a, a.id, b.id, EdgeRelation::Extends, 1.0, None)
2901 .await;
2902 assert!(
2903 matches!(result, Err(RuntimeError::NotFound(_))),
2904 "target in different namespace must return NotFound (fail-closed), got {result:?}"
2905 );
2906 }
2907
2908 #[tokio::test]
2909 async fn link_phantom_self_loop_returns_not_found() {
2910 let rt = rt();
2911 let tok = NamespaceToken::local();
2912 let phantom = Uuid::new_v4();
2913
2914 let result = rt
2915 .link(&tok, phantom, phantom, EdgeRelation::Extends, 1.0, None)
2916 .await;
2917 match result {
2918 Err(RuntimeError::NotFound(msg)) => {
2919 assert!(
2920 msg.contains("source"),
2921 "self-loop must fail on source first: {msg}"
2922 );
2923 }
2924 other => panic!("expected NotFound for phantom self-loop, got {other:?}"),
2925 }
2926 }
2927
2928 #[tokio::test]
2931 async fn link_note_to_edge_annotates_succeeds() {
2932 let rt = rt();
2933 let tok = NamespaceToken::local();
2934 let a = rt
2935 .create_entity(&tok, "concept", None, "A", None, None, vec![])
2936 .await
2937 .unwrap();
2938 let b = rt
2939 .create_entity(&tok, "concept", None, "B", None, None, vec![])
2940 .await
2941 .unwrap();
2942 let edge = rt
2944 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
2945 .await
2946 .unwrap();
2947 let edge_uuid: Uuid = edge.id.into();
2948
2949 let note = rt
2951 .create_note(
2952 &tok,
2953 "observation",
2954 None,
2955 "edge note",
2956 Some(0.5),
2957 None,
2958 vec![],
2959 )
2960 .await
2961 .unwrap();
2962
2963 let result = rt
2964 .link(&tok, note.id, edge_uuid, EdgeRelation::Annotates, 1.0, None)
2965 .await;
2966 assert!(
2967 result.is_ok(),
2968 "note→edge Annotates must succeed, got {result:?}"
2969 );
2970 }
2971
2972 #[tokio::test]
2973 async fn create_note_annotates_real_edge_succeeds() {
2974 let rt = rt();
2975 let tok = NamespaceToken::local();
2976 let a = rt
2977 .create_entity(&tok, "concept", None, "A", None, None, vec![])
2978 .await
2979 .unwrap();
2980 let b = rt
2981 .create_entity(&tok, "concept", None, "B", None, None, vec![])
2982 .await
2983 .unwrap();
2984 let edge = rt
2985 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
2986 .await
2987 .unwrap();
2988 let edge_uuid: Uuid = edge.id.into();
2989
2990 let note = rt
2991 .create_note(
2992 &tok,
2993 "observation",
2994 None,
2995 "annotating an edge",
2996 Some(0.5),
2997 None,
2998 vec![edge_uuid],
2999 )
3000 .await
3001 .unwrap();
3002
3003 let neighbors = rt
3004 .neighbors(
3005 &tok,
3006 note.id,
3007 Direction::Out,
3008 None,
3009 Some(vec![EdgeRelation::Annotates]),
3010 )
3011 .await
3012 .unwrap();
3013 assert_eq!(neighbors.len(), 1);
3014 assert_eq!(neighbors[0].node_id, edge_uuid);
3015 }
3016
3017 #[tokio::test]
3018 async fn create_note_annotates_phantom_is_atomic_no_note_persisted() {
3019 let rt = rt();
3020 let tok = NamespaceToken::local();
3021 let phantom = Uuid::new_v4();
3022
3023 let before_count = rt.list_notes(&tok, None, 1000, 0).await.unwrap().len();
3024
3025 let result = rt
3026 .create_note(
3027 &tok,
3028 "observation",
3029 None,
3030 "should not persist",
3031 Some(0.5),
3032 None,
3033 vec![phantom],
3034 )
3035 .await;
3036 assert!(
3037 matches!(result, Err(RuntimeError::NotFound(_))),
3038 "phantom annotates target must return NotFound, got {result:?}"
3039 );
3040
3041 let after_count = rt.list_notes(&tok, None, 1000, 0).await.unwrap().len();
3043 assert_eq!(
3044 before_count, after_count,
3045 "failed create_note must not persist any note row (atomicity)"
3046 );
3047
3048 let search_hits = rt
3050 .search_notes(&tok, "should not persist", None, 10, None, false)
3051 .await
3052 .unwrap();
3053 assert!(
3054 search_hits.is_empty(),
3055 "failed create_note must not index into FTS (atomicity)"
3056 );
3057 }
3060
3061 #[tokio::test]
3065 async fn link_entity_to_edge_uuid_non_annotates_returns_invalid_input() {
3066 let rt = rt();
3067 let tok = NamespaceToken::local();
3068 let a = rt
3069 .create_entity(&tok, "concept", None, "A", None, None, vec![])
3070 .await
3071 .unwrap();
3072 let b = rt
3073 .create_entity(&tok, "concept", None, "B", None, None, vec![])
3074 .await
3075 .unwrap();
3076 let edge = rt
3078 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
3079 .await
3080 .unwrap();
3081 let edge_uuid: Uuid = edge.id.into();
3082
3083 let result = rt
3084 .link(&tok, a.id, edge_uuid, EdgeRelation::Extends, 1.0, None)
3085 .await;
3086 match result {
3087 Err(RuntimeError::InvalidInput(msg)) => {
3088 assert!(
3089 msg.contains("target"),
3090 "error message must name 'target': {msg}"
3091 );
3092 }
3093 other => {
3094 panic!("expected InvalidInput for edge-uuid target with Extends, got {other:?}")
3095 }
3096 }
3097 }
3098
3099 #[tokio::test]
3101 async fn link_note_as_source_non_annotates_returns_invalid_input() {
3102 let rt = rt();
3103 let tok = NamespaceToken::local();
3104 let note = rt
3105 .create_note(&tok, "observation", None, "a note", Some(0.5), None, vec![])
3106 .await
3107 .unwrap();
3108 let entity = rt
3109 .create_entity(&tok, "concept", None, "E", None, None, vec![])
3110 .await
3111 .unwrap();
3112
3113 let result = rt
3114 .link(&tok, note.id, entity.id, EdgeRelation::DependsOn, 1.0, None)
3115 .await;
3116 match result {
3117 Err(RuntimeError::InvalidInput(msg)) => {
3118 assert!(
3119 msg.contains("source"),
3120 "error message must name 'source': {msg}"
3121 );
3122 }
3123 other => panic!("expected InvalidInput for note source with DependsOn, got {other:?}"),
3124 }
3125 }
3126
3127 #[tokio::test]
3129 async fn link_entity_as_annotates_source_returns_invalid_input() {
3130 let rt = rt();
3131 let tok = NamespaceToken::local();
3132 let a = rt
3133 .create_entity(&tok, "concept", None, "A", None, None, vec![])
3134 .await
3135 .unwrap();
3136 let b = rt
3137 .create_entity(&tok, "concept", None, "B", None, None, vec![])
3138 .await
3139 .unwrap();
3140
3141 let result = rt
3142 .link(&tok, a.id, b.id, EdgeRelation::Annotates, 1.0, None)
3143 .await;
3144 match result {
3145 Err(RuntimeError::InvalidInput(msg)) => {
3146 assert!(
3147 msg.contains("source") && msg.contains("note"),
3148 "error must say source must be a note: {msg}"
3149 );
3150 }
3151 other => {
3152 panic!("expected InvalidInput for entity source with Annotates, got {other:?}")
3153 }
3154 }
3155 }
3156
3157 #[tokio::test]
3158 async fn link_edge_as_annotates_source_returns_invalid_input() {
3159 let rt = rt();
3160 let tok = NamespaceToken::local();
3161 let a = rt
3162 .create_entity(&tok, "concept", None, "A", None, None, vec![])
3163 .await
3164 .unwrap();
3165 let b = rt
3166 .create_entity(&tok, "concept", None, "B", None, None, vec![])
3167 .await
3168 .unwrap();
3169 let edge = rt
3170 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
3171 .await
3172 .unwrap();
3173 let edge_uuid: Uuid = edge.id.into();
3174
3175 let result = rt
3177 .link(&tok, edge_uuid, a.id, EdgeRelation::Annotates, 1.0, None)
3178 .await;
3179 match result {
3180 Err(RuntimeError::InvalidInput(msg)) => {
3181 assert!(
3182 msg.contains("source") && msg.contains("note"),
3183 "edge-as-annotates-source must report wrong kind, not NotFound: {msg}"
3184 );
3185 }
3186 other => panic!("expected InvalidInput for edge source with Annotates, got {other:?}"),
3187 }
3188 }
3189
3190 #[tokio::test]
3192 async fn link_note_to_event_annotates_succeeds() {
3193 use khive_storage::Event;
3194 use khive_types::{EventKind, SubstrateKind};
3195
3196 let rt = rt();
3197 let tok = NamespaceToken::local();
3198 let note = rt
3199 .create_note(
3200 &tok,
3201 "observation",
3202 None,
3203 "observing an event",
3204 Some(0.6),
3205 None,
3206 vec![],
3207 )
3208 .await
3209 .unwrap();
3210
3211 let ns = tok.namespace().as_str();
3213 let event = Event::new(
3214 ns,
3215 "test_verb",
3216 EventKind::Audit,
3217 SubstrateKind::Entity,
3218 "test_actor",
3219 );
3220 let event_id = event.id;
3221 rt.events(&tok).unwrap().append_event(event).await.unwrap();
3222
3223 let result = rt
3224 .link(&tok, note.id, event_id, EdgeRelation::Annotates, 1.0, None)
3225 .await;
3226 assert!(
3227 result.is_ok(),
3228 "note→event Annotates must succeed, got {result:?}"
3229 );
3230 }
3231
3232 #[tokio::test]
3234 async fn create_note_annotates_event_succeeds() {
3235 use khive_storage::Event;
3236 use khive_types::{EventKind, SubstrateKind};
3237
3238 let rt = rt();
3239 let tok = NamespaceToken::local();
3240 let ns = tok.namespace().as_str();
3241 let event = Event::new(
3242 ns,
3243 "test_verb",
3244 EventKind::Audit,
3245 SubstrateKind::Entity,
3246 "test_actor",
3247 );
3248 let event_id = event.id;
3249 rt.events(&tok).unwrap().append_event(event).await.unwrap();
3250
3251 let result = rt
3252 .create_note(
3253 &tok,
3254 "observation",
3255 None,
3256 "note annotating an event",
3257 Some(0.5),
3258 None,
3259 vec![event_id],
3260 )
3261 .await;
3262 assert!(
3263 result.is_ok(),
3264 "create_note with event annotates target must succeed, got {result:?}"
3265 );
3266 let note = result.unwrap();
3268 let neighbors = rt
3269 .neighbors(
3270 &tok,
3271 note.id,
3272 Direction::Out,
3273 None,
3274 Some(vec![EdgeRelation::Annotates]),
3275 )
3276 .await
3277 .unwrap();
3278 assert_eq!(neighbors.len(), 1);
3279 assert_eq!(neighbors[0].node_id, event_id);
3280 }
3281
3282 #[tokio::test]
3286 async fn link_supersedes_note_to_note_succeeds() {
3287 let rt = rt();
3288 let tok = NamespaceToken::local();
3289 let old_note = rt
3290 .create_note(
3291 &tok,
3292 "observation",
3293 None,
3294 "old observation",
3295 Some(0.7),
3296 None,
3297 vec![],
3298 )
3299 .await
3300 .unwrap();
3301 let new_note = rt
3302 .create_note(
3303 &tok,
3304 "observation",
3305 None,
3306 "revised observation superseding the old one",
3307 Some(0.9),
3308 None,
3309 vec![],
3310 )
3311 .await
3312 .unwrap();
3313
3314 let result = rt
3315 .link(
3316 &tok,
3317 new_note.id,
3318 old_note.id,
3319 EdgeRelation::Supersedes,
3320 1.0,
3321 None,
3322 )
3323 .await;
3324 assert!(
3325 result.is_ok(),
3326 "note→note Supersedes must succeed (ADR-019 note supersession), got {result:?}"
3327 );
3328 }
3329
3330 #[tokio::test]
3331 async fn link_supersedes_entity_to_entity_succeeds() {
3332 let rt = rt();
3333 let tok = NamespaceToken::local();
3334 let old_entity = rt
3335 .create_entity(&tok, "concept", None, "OldConcept", None, None, vec![])
3336 .await
3337 .unwrap();
3338 let new_entity = rt
3339 .create_entity(&tok, "concept", None, "NewConcept", None, None, vec![])
3340 .await
3341 .unwrap();
3342
3343 let result = rt
3344 .link(
3345 &tok,
3346 new_entity.id,
3347 old_entity.id,
3348 EdgeRelation::Supersedes,
3349 1.0,
3350 None,
3351 )
3352 .await;
3353 assert!(
3354 result.is_ok(),
3355 "entity→entity Supersedes must succeed, got {result:?}"
3356 );
3357 }
3358
3359 #[tokio::test]
3360 async fn link_supersedes_note_to_entity_returns_invalid_input() {
3361 let rt = rt();
3362 let tok = NamespaceToken::local();
3363 let note = rt
3364 .create_note(&tok, "observation", None, "a note", Some(0.5), None, vec![])
3365 .await
3366 .unwrap();
3367 let entity = rt
3368 .create_entity(&tok, "concept", None, "SomeEntity", None, None, vec![])
3369 .await
3370 .unwrap();
3371
3372 let result = rt
3373 .link(
3374 &tok,
3375 note.id,
3376 entity.id,
3377 EdgeRelation::Supersedes,
3378 1.0,
3379 None,
3380 )
3381 .await;
3382 match result {
3383 Err(RuntimeError::InvalidInput(msg)) => {
3384 assert!(
3385 msg.contains("same substrate") || msg.contains("same-substrate"),
3386 "error must name the same-substrate rule: {msg}"
3387 );
3388 }
3389 other => panic!(
3390 "expected InvalidInput for note→entity Supersedes (cross-substrate), got {other:?}"
3391 ),
3392 }
3393 }
3394
3395 #[tokio::test]
3396 async fn link_supersedes_entity_to_note_returns_invalid_input() {
3397 let rt = rt();
3398 let tok = NamespaceToken::local();
3399 let entity = rt
3400 .create_entity(&tok, "concept", None, "SomeEntity", None, None, vec![])
3401 .await
3402 .unwrap();
3403 let note = rt
3404 .create_note(&tok, "observation", None, "a note", Some(0.5), None, vec![])
3405 .await
3406 .unwrap();
3407
3408 let result = rt
3409 .link(
3410 &tok,
3411 entity.id,
3412 note.id,
3413 EdgeRelation::Supersedes,
3414 1.0,
3415 None,
3416 )
3417 .await;
3418 match result {
3419 Err(RuntimeError::InvalidInput(msg)) => {
3420 assert!(
3421 msg.contains("same substrate") || msg.contains("same-substrate"),
3422 "error must name the same-substrate rule: {msg}"
3423 );
3424 }
3425 other => panic!(
3426 "expected InvalidInput for entity→note Supersedes (cross-substrate), got {other:?}"
3427 ),
3428 }
3429 }
3430
3431 #[tokio::test]
3432 async fn link_supersedes_event_source_returns_invalid_input() {
3433 use khive_storage::Event;
3434 use khive_types::{EventKind, SubstrateKind};
3435
3436 let rt = rt();
3437 let tok = NamespaceToken::local();
3438 let ns = tok.namespace().as_str();
3439 let event = Event::new(
3440 ns,
3441 "test_verb",
3442 EventKind::Audit,
3443 SubstrateKind::Entity,
3444 "test_actor",
3445 );
3446 let event_id = event.id;
3447 rt.events(&tok).unwrap().append_event(event).await.unwrap();
3448
3449 let entity = rt
3450 .create_entity(&tok, "concept", None, "SomeEntity", None, None, vec![])
3451 .await
3452 .unwrap();
3453
3454 let result = rt
3455 .link(
3456 &tok,
3457 event_id,
3458 entity.id,
3459 EdgeRelation::Supersedes,
3460 1.0,
3461 None,
3462 )
3463 .await;
3464 match result {
3465 Err(RuntimeError::InvalidInput(msg)) => {
3466 assert!(msg.contains("event"), "error must mention 'event': {msg}");
3467 }
3468 other => {
3469 panic!("expected InvalidInput for event source with Supersedes, got {other:?}")
3470 }
3471 }
3472 }
3473
3474 #[tokio::test]
3475 async fn link_supersedes_event_target_returns_invalid_input() {
3476 use khive_storage::Event;
3477 use khive_types::{EventKind, SubstrateKind};
3478
3479 let rt = rt();
3480 let tok = NamespaceToken::local();
3481 let ns = tok.namespace().as_str();
3482 let event = Event::new(
3483 ns,
3484 "test_verb",
3485 EventKind::Audit,
3486 SubstrateKind::Entity,
3487 "test_actor",
3488 );
3489 let event_id = event.id;
3490 rt.events(&tok).unwrap().append_event(event).await.unwrap();
3491
3492 let entity = rt
3493 .create_entity(&tok, "concept", None, "SomeEntity", None, None, vec![])
3494 .await
3495 .unwrap();
3496
3497 let result = rt
3498 .link(
3499 &tok,
3500 entity.id,
3501 event_id,
3502 EdgeRelation::Supersedes,
3503 1.0,
3504 None,
3505 )
3506 .await;
3507 match result {
3508 Err(RuntimeError::InvalidInput(msg)) => {
3509 assert!(msg.contains("event"), "error must mention 'event': {msg}");
3510 }
3511 other => {
3512 panic!("expected InvalidInput for event target with Supersedes, got {other:?}")
3513 }
3514 }
3515 }
3516
3517 #[tokio::test]
3518 async fn link_supersedes_edge_source_returns_invalid_input() {
3519 let rt = rt();
3520 let tok = NamespaceToken::local();
3521 let a = rt
3522 .create_entity(&tok, "concept", None, "A", None, None, vec![])
3523 .await
3524 .unwrap();
3525 let b = rt
3526 .create_entity(&tok, "concept", None, "B", None, None, vec![])
3527 .await
3528 .unwrap();
3529 let edge = rt
3530 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
3531 .await
3532 .unwrap();
3533 let edge_uuid: Uuid = edge.id.into();
3534
3535 let result = rt
3536 .link(&tok, edge_uuid, a.id, EdgeRelation::Supersedes, 1.0, None)
3537 .await;
3538 match result {
3539 Err(RuntimeError::InvalidInput(msg)) => {
3540 assert!(msg.contains("source"), "error must name 'source': {msg}");
3541 }
3542 other => {
3543 panic!("expected InvalidInput for edge-uuid source with Supersedes, got {other:?}")
3544 }
3545 }
3546 }
3547
3548 #[tokio::test]
3549 async fn link_supersedes_edge_target_returns_invalid_input() {
3550 let rt = rt();
3551 let tok = NamespaceToken::local();
3552 let a = rt
3553 .create_entity(&tok, "concept", None, "A", None, None, vec![])
3554 .await
3555 .unwrap();
3556 let b = rt
3557 .create_entity(&tok, "concept", None, "B", None, None, vec![])
3558 .await
3559 .unwrap();
3560 let edge = rt
3561 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
3562 .await
3563 .unwrap();
3564 let edge_uuid: Uuid = edge.id.into();
3565
3566 let result = rt
3567 .link(&tok, a.id, edge_uuid, EdgeRelation::Supersedes, 1.0, None)
3568 .await;
3569 match result {
3570 Err(RuntimeError::InvalidInput(msg)) => {
3571 assert!(msg.contains("target"), "error must name 'target': {msg}");
3572 }
3573 other => {
3574 panic!("expected InvalidInput for edge-uuid target with Supersedes, got {other:?}")
3575 }
3576 }
3577 }
3578
3579 #[tokio::test]
3580 async fn link_supersedes_phantom_source_returns_not_found() {
3581 let rt = rt();
3582 let tok = NamespaceToken::local();
3583 let note = rt
3584 .create_note(
3585 &tok,
3586 "observation",
3587 None,
3588 "existing note",
3589 Some(0.5),
3590 None,
3591 vec![],
3592 )
3593 .await
3594 .unwrap();
3595 let phantom = Uuid::new_v4();
3596
3597 let result = rt
3598 .link(&tok, phantom, note.id, EdgeRelation::Supersedes, 1.0, None)
3599 .await;
3600 match result {
3601 Err(RuntimeError::NotFound(msg)) => {
3602 assert!(msg.contains("source"), "error must name 'source': {msg}");
3603 }
3604 other => panic!("expected NotFound for phantom source with Supersedes, got {other:?}"),
3605 }
3606 }
3607
3608 #[tokio::test]
3609 async fn link_supersedes_phantom_target_returns_not_found() {
3610 let rt = rt();
3611 let tok = NamespaceToken::local();
3612 let note = rt
3613 .create_note(
3614 &tok,
3615 "observation",
3616 None,
3617 "existing note",
3618 Some(0.5),
3619 None,
3620 vec![],
3621 )
3622 .await
3623 .unwrap();
3624 let phantom = Uuid::new_v4();
3625
3626 let result = rt
3627 .link(&tok, note.id, phantom, EdgeRelation::Supersedes, 1.0, None)
3628 .await;
3629 match result {
3630 Err(RuntimeError::NotFound(msg)) => {
3631 assert!(msg.contains("target"), "error must name 'target': {msg}");
3632 }
3633 other => panic!("expected NotFound for phantom target with Supersedes, got {other:?}"),
3634 }
3635 }
3636
3637 #[tokio::test]
3638 async fn link_supersedes_cross_namespace_source_returns_not_found() {
3639 let rt = rt();
3640 let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
3641 let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
3642 let note_a = rt
3643 .create_note(
3644 &ns_a,
3645 "observation",
3646 None,
3647 "note in ns-a",
3648 Some(0.5),
3649 None,
3650 vec![],
3651 )
3652 .await
3653 .unwrap();
3654 let note_b = rt
3655 .create_note(
3656 &ns_b,
3657 "observation",
3658 None,
3659 "note in ns-b",
3660 Some(0.5),
3661 None,
3662 vec![],
3663 )
3664 .await
3665 .unwrap();
3666
3667 let result = rt
3669 .link(
3670 &ns_a,
3671 note_b.id,
3672 note_a.id,
3673 EdgeRelation::Supersedes,
3674 1.0,
3675 None,
3676 )
3677 .await;
3678 assert!(
3679 matches!(result, Err(RuntimeError::NotFound(_))),
3680 "cross-namespace source with Supersedes must return NotFound (fail-closed), got {result:?}"
3681 );
3682 }
3683
3684 #[tokio::test]
3686 async fn link_extends_note_source_still_returns_invalid_input() {
3687 let rt = rt();
3688 let tok = NamespaceToken::local();
3689 let note = rt
3690 .create_note(
3691 &tok,
3692 "observation",
3693 None,
3694 "a note that cannot be an extends source",
3695 Some(0.5),
3696 None,
3697 vec![],
3698 )
3699 .await
3700 .unwrap();
3701 let entity = rt
3702 .create_entity(&tok, "concept", None, "E", None, None, vec![])
3703 .await
3704 .unwrap();
3705
3706 let result = rt
3707 .link(&tok, note.id, entity.id, EdgeRelation::Extends, 1.0, None)
3708 .await;
3709 assert!(
3710 matches!(result, Err(RuntimeError::InvalidInput(_))),
3711 "note source with Extends must still return InvalidInput after this fix, got {result:?}"
3712 );
3713 }
3714
3715 #[tokio::test]
3717 async fn link_annotates_note_to_edge_still_succeeds_after_fix() {
3718 let rt = rt();
3719 let tok = NamespaceToken::local();
3720 let a = rt
3721 .create_entity(&tok, "concept", None, "A", None, None, vec![])
3722 .await
3723 .unwrap();
3724 let b = rt
3725 .create_entity(&tok, "concept", None, "B", None, None, vec![])
3726 .await
3727 .unwrap();
3728 let edge = rt
3729 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
3730 .await
3731 .unwrap();
3732 let edge_uuid: Uuid = edge.id.into();
3733
3734 let note = rt
3735 .create_note(
3736 &tok,
3737 "observation",
3738 None,
3739 "annotating an edge",
3740 Some(0.5),
3741 None,
3742 vec![],
3743 )
3744 .await
3745 .unwrap();
3746
3747 let result = rt
3748 .link(&tok, note.id, edge_uuid, EdgeRelation::Annotates, 1.0, None)
3749 .await;
3750 assert!(
3751 result.is_ok(),
3752 "note→edge Annotates must still succeed after supersedes fix, got {result:?}"
3753 );
3754 }
3755
3756 #[tokio::test]
3770 async fn create_note_multi_annotates_compensation_cleanup_restores_pristine_state() {
3771 let rt = rt();
3772 let tok = NamespaceToken::local();
3773 let t1 = rt
3774 .create_entity(&tok, "concept", None, "T1", None, None, vec![])
3775 .await
3776 .unwrap();
3777
3778 let note = rt
3781 .create_note(
3782 &tok,
3783 "observation",
3784 None,
3785 "partial note",
3786 Some(0.5),
3787 None,
3788 vec![t1.id],
3789 )
3790 .await
3791 .unwrap();
3792
3793 let before_notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
3795 assert_eq!(before_notes.len(), 1, "note must be present before cleanup");
3796 let before_edges = rt
3797 .neighbors(
3798 &tok,
3799 note.id,
3800 Direction::Out,
3801 None,
3802 Some(vec![EdgeRelation::Annotates]),
3803 )
3804 .await
3805 .unwrap();
3806 assert_eq!(
3807 before_edges.len(),
3808 1,
3809 "one annotates edge must exist before cleanup"
3810 );
3811 let edge_id: Uuid = before_edges[0].edge_id;
3812
3813 rt.delete_edge(&tok, edge_id, true).await.unwrap();
3815 rt.delete_note(&tok, note.id, true )
3816 .await
3817 .unwrap();
3818
3819 let after_notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
3821 assert!(
3822 after_notes.is_empty(),
3823 "compensation must remove the note row; got {after_notes:?}"
3824 );
3825 let search_hits = rt
3826 .search_notes(&tok, "partial note", None, 10, None, false)
3827 .await
3828 .unwrap();
3829 assert!(
3830 search_hits.is_empty(),
3831 "compensation must clean the FTS index; got {search_hits:?}"
3832 );
3833 let after_edges = rt
3834 .neighbors(&tok, note.id, Direction::Out, None, None)
3835 .await
3836 .unwrap();
3837 assert!(
3838 after_edges.is_empty(),
3839 "compensation must remove all partial edges; got {after_edges:?}"
3840 );
3841 }
3842
3843 #[tokio::test]
3851 async fn annotated_entity_hard_delete_cascades_annotate_edge() {
3852 let rt = rt();
3853 let tok = NamespaceToken::local();
3854 let entity = rt
3855 .create_entity(&tok, "concept", None, "E", None, None, vec![])
3856 .await
3857 .unwrap();
3858 let note = rt
3859 .create_note(
3860 &tok,
3861 "observation",
3862 None,
3863 "note about entity",
3864 Some(0.5),
3865 None,
3866 vec![entity.id],
3867 )
3868 .await
3869 .unwrap();
3870
3871 let before = rt
3873 .neighbors(
3874 &tok,
3875 note.id,
3876 Direction::Out,
3877 None,
3878 Some(vec![EdgeRelation::Annotates]),
3879 )
3880 .await
3881 .unwrap();
3882 assert_eq!(
3883 before.len(),
3884 1,
3885 "annotates edge must exist before entity delete"
3886 );
3887
3888 let deleted = rt.delete_entity(&tok, entity.id, true).await.unwrap();
3890 assert!(deleted, "entity hard delete must return true");
3891
3892 let after = rt
3894 .neighbors(
3895 &tok,
3896 note.id,
3897 Direction::Out,
3898 None,
3899 Some(vec![EdgeRelation::Annotates]),
3900 )
3901 .await
3902 .unwrap();
3903 assert!(
3904 after.is_empty(),
3905 "annotates edge must be cascaded on entity hard delete; got {after:?}"
3906 );
3907 }
3908
3909 #[tokio::test]
3910 async fn annotated_note_hard_delete_cascades_annotate_edge() {
3911 let rt = rt();
3912 let tok = NamespaceToken::local();
3913 let note_target = rt
3915 .create_note(
3916 &tok,
3917 "observation",
3918 None,
3919 "target note",
3920 Some(0.5),
3921 None,
3922 vec![],
3923 )
3924 .await
3925 .unwrap();
3926 let note_source = rt
3928 .create_note(
3929 &tok,
3930 "insight",
3931 None,
3932 "annotation",
3933 Some(0.5),
3934 None,
3935 vec![note_target.id],
3936 )
3937 .await
3938 .unwrap();
3939
3940 let before = rt
3941 .neighbors(
3942 &tok,
3943 note_source.id,
3944 Direction::Out,
3945 None,
3946 Some(vec![EdgeRelation::Annotates]),
3947 )
3948 .await
3949 .unwrap();
3950 assert_eq!(
3951 before.len(),
3952 1,
3953 "annotates edge must exist before note delete"
3954 );
3955
3956 let deleted = rt.delete_note(&tok, note_target.id, true).await.unwrap();
3958 assert!(deleted, "note hard delete must return true");
3959
3960 let after = rt
3962 .neighbors(
3963 &tok,
3964 note_source.id,
3965 Direction::Out,
3966 None,
3967 Some(vec![EdgeRelation::Annotates]),
3968 )
3969 .await
3970 .unwrap();
3971 assert!(
3972 after.is_empty(),
3973 "annotates edge must be cascaded on note-target hard delete; got {after:?}"
3974 );
3975 }
3976
3977 #[tokio::test]
3978 async fn annotated_edge_delete_cascades_annotate_edge() {
3979 let rt = rt();
3980 let tok = NamespaceToken::local();
3981 let a = rt
3982 .create_entity(&tok, "concept", None, "A", None, None, vec![])
3983 .await
3984 .unwrap();
3985 let b = rt
3986 .create_entity(&tok, "concept", None, "B", None, None, vec![])
3987 .await
3988 .unwrap();
3989 let base_edge = rt
3991 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
3992 .await
3993 .unwrap();
3994 let base_edge_uuid: Uuid = base_edge.id.into();
3995
3996 let note = rt
3998 .create_note(
3999 &tok,
4000 "observation",
4001 None,
4002 "note about edge",
4003 Some(0.5),
4004 None,
4005 vec![base_edge_uuid],
4006 )
4007 .await
4008 .unwrap();
4009
4010 let before = rt
4011 .neighbors(
4012 &tok,
4013 note.id,
4014 Direction::Out,
4015 None,
4016 Some(vec![EdgeRelation::Annotates]),
4017 )
4018 .await
4019 .unwrap();
4020 assert_eq!(
4021 before.len(),
4022 1,
4023 "annotates edge must exist before base edge delete"
4024 );
4025
4026 let deleted = rt.delete_edge(&tok, base_edge_uuid, true).await.unwrap();
4028 assert!(deleted, "edge delete must return true");
4029
4030 let after = rt
4032 .neighbors(
4033 &tok,
4034 note.id,
4035 Direction::Out,
4036 None,
4037 Some(vec![EdgeRelation::Annotates]),
4038 )
4039 .await
4040 .unwrap();
4041 assert!(
4042 after.is_empty(),
4043 "annotates edge must be cascaded on base edge delete; got {after:?}"
4044 );
4045 }
4046
4047 #[tokio::test]
4048 async fn mixed_multi_annotates_partial_target_hard_delete_leaves_remaining_edges() {
4049 let rt = rt();
4050 let tok = NamespaceToken::local();
4051 let t1 = rt
4052 .create_entity(&tok, "concept", None, "T1", None, None, vec![])
4053 .await
4054 .unwrap();
4055 let t2 = rt
4056 .create_entity(&tok, "concept", None, "T2", None, None, vec![])
4057 .await
4058 .unwrap();
4059
4060 let note = rt
4062 .create_note(
4063 &tok,
4064 "observation",
4065 None,
4066 "multi-target note",
4067 Some(0.5),
4068 None,
4069 vec![t1.id, t2.id],
4070 )
4071 .await
4072 .unwrap();
4073
4074 let before = rt
4075 .neighbors(
4076 &tok,
4077 note.id,
4078 Direction::Out,
4079 None,
4080 Some(vec![EdgeRelation::Annotates]),
4081 )
4082 .await
4083 .unwrap();
4084 assert_eq!(
4085 before.len(),
4086 2,
4087 "must have 2 annotates edges before any delete"
4088 );
4089
4090 rt.delete_entity(&tok, t1.id, true).await.unwrap();
4092
4093 let after = rt
4095 .neighbors(
4096 &tok,
4097 note.id,
4098 Direction::Out,
4099 None,
4100 Some(vec![EdgeRelation::Annotates]),
4101 )
4102 .await
4103 .unwrap();
4104 assert_eq!(
4105 after.len(),
4106 1,
4107 "only the edge to t1 must be cascaded; t2 edge must remain"
4108 );
4109 assert_eq!(
4110 after[0].node_id, t2.id,
4111 "remaining annotates edge must point to t2"
4112 );
4113 }
4114
4115 #[tokio::test]
4116 async fn annotated_note_soft_delete_preserves_annotate_edge() {
4117 let rt = rt();
4118 let tok = NamespaceToken::local();
4119 let note_target = rt
4120 .create_note(&tok, "observation", None, "target", Some(0.5), None, vec![])
4121 .await
4122 .unwrap();
4123 let note_source = rt
4124 .create_note(
4125 &tok,
4126 "insight",
4127 None,
4128 "annotation",
4129 Some(0.5),
4130 None,
4131 vec![note_target.id],
4132 )
4133 .await
4134 .unwrap();
4135
4136 let before = rt
4137 .neighbors(
4138 &tok,
4139 note_source.id,
4140 Direction::Out,
4141 None,
4142 Some(vec![EdgeRelation::Annotates]),
4143 )
4144 .await
4145 .unwrap();
4146 assert_eq!(before.len(), 1);
4147
4148 let deleted = rt.delete_note(&tok, note_target.id, false).await.unwrap();
4150 assert!(deleted, "soft delete must return true");
4151
4152 let after = rt
4153 .neighbors(
4154 &tok,
4155 note_source.id,
4156 Direction::Out,
4157 None,
4158 Some(vec![EdgeRelation::Annotates]),
4159 )
4160 .await
4161 .unwrap();
4162 assert_eq!(
4163 after.len(),
4164 1,
4165 "soft delete must NOT cascade edges; got {after:?}"
4166 );
4167 }
4168
4169 #[tokio::test]
4176 async fn delete_edge_non_edge_uuid_has_no_side_effects() {
4177 let rt = rt();
4178 let tok = NamespaceToken::local();
4179
4180 let entity = rt
4182 .create_entity(&tok, "concept", None, "Target", None, None, vec![])
4183 .await
4184 .unwrap();
4185 let note = rt
4186 .create_note(
4187 &tok,
4188 "observation",
4189 None,
4190 "annotates the entity",
4191 Some(0.5),
4192 None,
4193 vec![entity.id],
4194 )
4195 .await
4196 .unwrap();
4197
4198 let before = rt
4200 .neighbors(
4201 &tok,
4202 note.id,
4203 Direction::Out,
4204 None,
4205 Some(vec![EdgeRelation::Annotates]),
4206 )
4207 .await
4208 .unwrap();
4209 assert_eq!(before.len(), 1, "annotates edge must exist before test");
4210 let annotates_edge_id: Uuid = before[0].edge_id;
4211
4212 let result = rt.delete_edge(&tok, entity.id, true).await;
4214 assert!(
4215 result.is_ok(),
4216 "delete_edge must not error on a non-edge UUID"
4217 );
4218 assert!(
4219 !result.unwrap(),
4220 "delete_edge must return false for a non-edge UUID"
4221 );
4222
4223 let after = rt
4225 .neighbors(
4226 &tok,
4227 note.id,
4228 Direction::Out,
4229 None,
4230 Some(vec![EdgeRelation::Annotates]),
4231 )
4232 .await
4233 .unwrap();
4234 assert_eq!(
4235 after.len(),
4236 1,
4237 "delete_edge with a non-edge UUID must not touch inbound annotates edges"
4238 );
4239 assert_eq!(
4240 after[0].edge_id, annotates_edge_id,
4241 "the original annotates edge must be unchanged"
4242 );
4243 }
4244
4245 #[tokio::test]
4256 async fn create_note_multi_annotates_second_link_failure_rolls_back_partial_write() {
4257 let rt = rt();
4258 let tok = NamespaceToken::local();
4259 let t1 = rt
4260 .create_entity(&tok, "concept", None, "T1", None, None, vec![])
4261 .await
4262 .unwrap();
4263 let t2 = rt
4264 .create_entity(&tok, "concept", None, "T2", None, None, vec![])
4265 .await
4266 .unwrap();
4267
4268 LINK_FAIL_AFTER.with(|cell| cell.set(2));
4270
4271 let result = rt
4272 .create_note(
4273 &tok,
4274 "observation",
4275 None,
4276 "rollback target",
4277 Some(0.5),
4278 None,
4279 vec![t1.id, t2.id],
4280 )
4281 .await;
4282
4283 assert!(
4285 result.is_err(),
4286 "create_note must propagate the injected link failure"
4287 );
4288 let err_msg = result.unwrap_err().to_string();
4289 assert!(
4290 err_msg.contains("injected link failure"),
4291 "error must carry injection message; got: {err_msg}"
4292 );
4293
4294 let notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
4296 assert!(
4297 notes.is_empty(),
4298 "compensation must remove the note row; got {notes:?}"
4299 );
4300
4301 let hits = rt
4303 .search_notes(&tok, "rollback target", None, 10, None, false)
4304 .await
4305 .unwrap();
4306 assert!(
4307 hits.is_empty(),
4308 "compensation must clean FTS index; got {hits:?}"
4309 );
4310
4311 let edges_from_t1 = rt
4313 .neighbors(
4314 &tok,
4315 t1.id,
4316 Direction::In,
4317 None,
4318 Some(vec![EdgeRelation::Annotates]),
4319 )
4320 .await
4321 .unwrap();
4322 let edges_from_t2 = rt
4323 .neighbors(
4324 &tok,
4325 t2.id,
4326 Direction::In,
4327 None,
4328 Some(vec![EdgeRelation::Annotates]),
4329 )
4330 .await
4331 .unwrap();
4332 assert!(
4333 edges_from_t1.is_empty(),
4334 "compensation must delete the first annotates edge; got {edges_from_t1:?}"
4335 );
4336 assert!(
4337 edges_from_t2.is_empty(),
4338 "no second annotates edge must exist; got {edges_from_t2:?}"
4339 );
4340 }
4341
4342 #[tokio::test]
4345 async fn soft_delete_entity_removes_indexes() {
4346 let rt = rt();
4347 let tok = NamespaceToken::local();
4348 let entity = rt
4349 .create_entity(
4350 &tok,
4351 "concept",
4352 None,
4353 "QuantumEntanglement",
4354 Some("unique FTS term xzqjwv for soft delete test"),
4355 None,
4356 vec![],
4357 )
4358 .await
4359 .unwrap();
4360
4361 let ns = tok.namespace().as_str().to_string();
4362
4363 let before = rt
4364 .text(&tok)
4365 .unwrap()
4366 .search(TextSearchRequest {
4367 query: "xzqjwv".to_string(),
4368 mode: TextQueryMode::Plain,
4369 filter: Some(TextFilter {
4370 namespaces: vec![ns.clone()],
4371 ..Default::default()
4372 }),
4373 top_k: 10,
4374 snippet_chars: 100,
4375 })
4376 .await
4377 .unwrap();
4378 assert!(
4379 before.iter().any(|h| h.subject_id == entity.id),
4380 "entity must be in FTS before soft-delete"
4381 );
4382
4383 let deleted = rt.delete_entity(&tok, entity.id, false).await.unwrap();
4384 assert!(deleted, "soft delete must return true");
4385
4386 let after = rt
4387 .text(&tok)
4388 .unwrap()
4389 .search(TextSearchRequest {
4390 query: "xzqjwv".to_string(),
4391 mode: TextQueryMode::Plain,
4392 filter: Some(TextFilter {
4393 namespaces: vec![ns],
4394 ..Default::default()
4395 }),
4396 top_k: 10,
4397 snippet_chars: 100,
4398 })
4399 .await
4400 .unwrap();
4401 assert!(
4402 after.iter().all(|h| h.subject_id != entity.id),
4403 "soft-deleted entity must be removed from FTS index"
4404 );
4405 }
4406
4407 #[tokio::test]
4408 async fn soft_delete_note_removes_indexes() {
4409 let rt = rt();
4410 let tok = NamespaceToken::local();
4411 let note = rt
4412 .create_note(
4413 &tok,
4414 "observation",
4415 None,
4416 "SpectralDecomposition unique term yvwkqz for soft delete test",
4417 Some(0.7),
4418 None,
4419 vec![],
4420 )
4421 .await
4422 .unwrap();
4423
4424 let before = rt
4425 .search_notes(&tok, "yvwkqz", None, 10, None, false)
4426 .await
4427 .unwrap();
4428 assert!(
4429 before.iter().any(|h| h.note_id == note.id),
4430 "note must be in FTS before soft-delete"
4431 );
4432
4433 let deleted = rt.delete_note(&tok, note.id, false).await.unwrap();
4434 assert!(deleted, "soft delete must return true");
4435
4436 let after = rt
4437 .search_notes(&tok, "yvwkqz", None, 10, None, false)
4438 .await
4439 .unwrap();
4440 assert!(
4441 after.iter().all(|h| h.note_id != note.id),
4442 "soft-deleted note must be removed from FTS index"
4443 );
4444 }
4445
4446 #[tokio::test]
4449 async fn link_extends_document_to_document_returns_invalid_input() {
4450 let rt = rt();
4451 let tok = NamespaceToken::local();
4452 let d1 = rt
4453 .create_entity(&tok, "document", None, "DocA", None, None, vec![])
4454 .await
4455 .unwrap();
4456 let d2 = rt
4457 .create_entity(&tok, "document", None, "DocB", None, None, vec![])
4458 .await
4459 .unwrap();
4460 let result = rt
4461 .link(&tok, d1.id, d2.id, EdgeRelation::Extends, 1.0, None)
4462 .await;
4463 assert!(
4464 result.is_err(),
4465 "F010: document->document Extends must be rejected by ADR-002 allowlist; \
4466 current generic entity fallthrough incorrectly accepts it"
4467 );
4468 }
4469
4470 #[tokio::test]
4472 async fn link_extends_concept_to_concept_succeeds() {
4473 let rt = rt();
4474 let tok = NamespaceToken::local();
4475 let a = rt
4476 .create_entity(&tok, "concept", None, "CA", None, None, vec![])
4477 .await
4478 .unwrap();
4479 let b = rt
4480 .create_entity(&tok, "concept", None, "CB", None, None, vec![])
4481 .await
4482 .unwrap();
4483 let result = rt
4484 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
4485 .await;
4486 assert!(
4487 result.is_ok(),
4488 "F010: concept->concept Extends must be allowed (ADR-002 allowlist)"
4489 );
4490 }
4491
4492 #[tokio::test]
4495 async fn link_symmetric_relation_canonicalizes_endpoint_order() {
4496 use khive_storage::EdgeFilter;
4497 let rt = rt();
4498 let tok = NamespaceToken::local();
4499 let a = rt
4500 .create_entity(&tok, "concept", None, "ConceptP", None, None, vec![])
4501 .await
4502 .unwrap();
4503 let b = rt
4504 .create_entity(&tok, "concept", None, "ConceptQ", None, None, vec![])
4505 .await
4506 .unwrap();
4507 rt.link(&tok, a.id, b.id, EdgeRelation::CompetesWith, 1.0, None)
4509 .await
4510 .unwrap();
4511 rt.link(&tok, b.id, a.id, EdgeRelation::CompetesWith, 1.0, None)
4512 .await
4513 .unwrap();
4514 let count = rt
4515 .graph(&tok)
4516 .unwrap()
4517 .count_edges(EdgeFilter::default())
4518 .await
4519 .unwrap();
4520 assert_eq!(
4521 count,
4522 1,
4523 "F012: CompetesWith is symmetric; A->B and B->A must deduplicate to one canonical row; \
4524 found {count} rows (canonicalization not yet implemented)"
4525 );
4526 }
4527
4528 #[tokio::test]
4530 async fn f010_supersedes_document_to_document_allowed() {
4531 let rt = rt();
4532 let tok = NamespaceToken::local();
4533 let a = rt
4534 .create_entity(&tok, "document", None, "DocA", None, None, vec![])
4535 .await
4536 .unwrap();
4537 let b = rt
4538 .create_entity(&tok, "document", None, "DocB", None, None, vec![])
4539 .await
4540 .unwrap();
4541 let result = rt
4542 .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
4543 .await;
4544 assert!(
4545 result.is_ok(),
4546 "document->document Supersedes must be allowed (ADR-002:191), got {result:?}"
4547 );
4548 }
4549
4550 #[tokio::test]
4551 async fn f010_supersedes_artifact_to_artifact_allowed() {
4552 let rt = rt();
4553 let tok = NamespaceToken::local();
4554 let a = rt
4555 .create_entity(&tok, "artifact", None, "ArtA", None, None, vec![])
4556 .await
4557 .unwrap();
4558 let b = rt
4559 .create_entity(&tok, "artifact", None, "ArtB", None, None, vec![])
4560 .await
4561 .unwrap();
4562 let result = rt
4563 .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
4564 .await;
4565 assert!(
4566 result.is_ok(),
4567 "artifact->artifact Supersedes must be allowed (ADR-002:192), got {result:?}"
4568 );
4569 }
4570
4571 #[tokio::test]
4572 async fn f010_supersedes_service_to_service_allowed() {
4573 let rt = rt();
4574 let tok = NamespaceToken::local();
4575 let a = rt
4576 .create_entity(&tok, "service", None, "SvcA", None, None, vec![])
4577 .await
4578 .unwrap();
4579 let b = rt
4580 .create_entity(&tok, "service", None, "SvcB", None, None, vec![])
4581 .await
4582 .unwrap();
4583 let result = rt
4584 .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
4585 .await;
4586 assert!(
4587 result.is_ok(),
4588 "service->service Supersedes must be allowed (ADR-002:193), got {result:?}"
4589 );
4590 }
4591
4592 #[tokio::test]
4593 async fn f010_supersedes_dataset_to_dataset_allowed() {
4594 let rt = rt();
4595 let tok = NamespaceToken::local();
4596 let a = rt
4597 .create_entity(&tok, "dataset", None, "DataA", None, None, vec![])
4598 .await
4599 .unwrap();
4600 let b = rt
4601 .create_entity(&tok, "dataset", None, "DataB", None, None, vec![])
4602 .await
4603 .unwrap();
4604 let result = rt
4605 .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
4606 .await;
4607 assert!(
4608 result.is_ok(),
4609 "dataset->dataset Supersedes must be allowed (ADR-002:194), got {result:?}"
4610 );
4611 }
4612
4613 #[tokio::test]
4615 async fn f010_supersedes_project_to_project_rejected() {
4616 let rt = rt();
4617 let tok = NamespaceToken::local();
4618 let a = rt
4619 .create_entity(&tok, "project", None, "ProjA", None, None, vec![])
4620 .await
4621 .unwrap();
4622 let b = rt
4623 .create_entity(&tok, "project", None, "ProjB", None, None, vec![])
4624 .await
4625 .unwrap();
4626 let result = rt
4627 .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
4628 .await;
4629 assert!(
4630 matches!(result, Err(RuntimeError::InvalidInput(_))),
4631 "project->project Supersedes must be rejected (not in ADR-002 allowlist), got {result:?}"
4632 );
4633 }
4634
4635 #[tokio::test]
4636 async fn f010_supersedes_person_to_person_rejected() {
4637 let rt = rt();
4638 let tok = NamespaceToken::local();
4639 let a = rt
4640 .create_entity(&tok, "person", None, "Alice", None, None, vec![])
4641 .await
4642 .unwrap();
4643 let b = rt
4644 .create_entity(&tok, "person", None, "Bob", None, None, vec![])
4645 .await
4646 .unwrap();
4647 let result = rt
4648 .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
4649 .await;
4650 assert!(
4651 matches!(result, Err(RuntimeError::InvalidInput(_))),
4652 "person->person Supersedes must be rejected (not in ADR-002 allowlist), got {result:?}"
4653 );
4654 }
4655
4656 #[tokio::test]
4657 async fn f010_supersedes_org_to_org_rejected() {
4658 let rt = rt();
4659 let tok = NamespaceToken::local();
4660 let a = rt
4661 .create_entity(&tok, "org", None, "OrgA", None, None, vec![])
4662 .await
4663 .unwrap();
4664 let b = rt
4665 .create_entity(&tok, "org", None, "OrgB", None, None, vec![])
4666 .await
4667 .unwrap();
4668 let result = rt
4669 .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
4670 .await;
4671 assert!(
4672 matches!(result, Err(RuntimeError::InvalidInput(_))),
4673 "org->org Supersedes must be rejected (not in ADR-002 allowlist), got {result:?}"
4674 );
4675 }
4676
4677 #[tokio::test]
4679 async fn f010_supersedes_same_kind_entity_allowed() {
4680 let rt = rt();
4681 let tok = NamespaceToken::local();
4682 let a = rt
4683 .create_entity(&tok, "concept", None, "OldV", None, None, vec![])
4684 .await
4685 .unwrap();
4686 let b = rt
4687 .create_entity(&tok, "concept", None, "NewV", None, None, vec![])
4688 .await
4689 .unwrap();
4690 let result = rt
4691 .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
4692 .await;
4693 assert!(
4694 result.is_ok(),
4695 "concept->concept Supersedes must be allowed by ADR-002 allowlist, got {result:?}"
4696 );
4697 }
4698
4699 #[tokio::test]
4703 async fn f161_link_always_writes_null_target_backend() {
4704 let rt = rt();
4705 let tok = NamespaceToken::local();
4706 let a = rt
4707 .create_entity(&tok, "concept", None, "A", None, None, vec![])
4708 .await
4709 .unwrap();
4710 let b = rt
4711 .create_entity(&tok, "concept", None, "B", None, None, vec![])
4712 .await
4713 .unwrap();
4714 let edge = rt
4715 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
4716 .await
4717 .unwrap();
4718 assert!(
4719 edge.target_backend.is_none(),
4720 "ADR-009: target_backend must be None for locally-routed edges (F161); got {:?}",
4721 edge.target_backend
4722 );
4723 }
4724
4725 #[tokio::test]
4727 async fn f161_link_many_always_writes_null_target_backend() {
4728 let rt = rt();
4729 let tok = NamespaceToken::local();
4730 let a = rt
4731 .create_entity(&tok, "concept", None, "A", None, None, vec![])
4732 .await
4733 .unwrap();
4734 let b = rt
4735 .create_entity(&tok, "concept", None, "B", None, None, vec![])
4736 .await
4737 .unwrap();
4738 let c = rt
4739 .create_entity(&tok, "concept", None, "C", None, None, vec![])
4740 .await
4741 .unwrap();
4742 let specs = vec![
4743 LinkSpec {
4744 namespace: None,
4745 source_id: a.id,
4746 target_id: b.id,
4747 relation: EdgeRelation::Extends,
4748 weight: 1.0,
4749 metadata: None,
4750 },
4751 LinkSpec {
4752 namespace: None,
4753 source_id: a.id,
4754 target_id: c.id,
4755 relation: EdgeRelation::Enables,
4756 weight: 1.0,
4757 metadata: None,
4758 },
4759 ];
4760 let edges = rt.link_many(&tok, specs).await.unwrap();
4761 for edge in &edges {
4762 assert!(
4763 edge.target_backend.is_none(),
4764 "ADR-009: target_backend must be None for locally-routed edges in link_many (F161); got {:?}",
4765 edge.target_backend
4766 );
4767 }
4768 }
4769
4770 #[tokio::test]
4773 async fn f012_symmetric_neighbors_visible_from_both_endpoints() {
4774 let rt = rt();
4775 let tok = NamespaceToken::local();
4776 let a = rt
4777 .create_entity(&tok, "concept", None, "A", None, None, vec![])
4778 .await
4779 .unwrap();
4780 let b = rt
4781 .create_entity(&tok, "concept", None, "B", None, None, vec![])
4782 .await
4783 .unwrap();
4784 rt.link(&tok, a.id, b.id, EdgeRelation::CompetesWith, 1.0, None)
4786 .await
4787 .unwrap();
4788 let from_a = rt
4790 .neighbors(
4791 &tok,
4792 a.id,
4793 Direction::Out,
4794 None,
4795 Some(vec![EdgeRelation::CompetesWith]),
4796 )
4797 .await
4798 .unwrap();
4799 let from_b = rt
4800 .neighbors(
4801 &tok,
4802 b.id,
4803 Direction::Out,
4804 None,
4805 Some(vec![EdgeRelation::CompetesWith]),
4806 )
4807 .await
4808 .unwrap();
4809 assert_eq!(
4810 from_a.len(),
4811 1,
4812 "node A must see competes_with neighbor from Direction::Out (F012); got {from_a:?}"
4813 );
4814 assert_eq!(
4815 from_b.len(),
4816 1,
4817 "node B must see competes_with neighbor from Direction::Out (F012); got {from_b:?}"
4818 );
4819 }
4820
4821 #[tokio::test]
4823 async fn f010_supersedes_cross_kind_entity_rejected() {
4824 let rt = rt();
4825 let tok = NamespaceToken::local();
4826 let concept = rt
4827 .create_entity(&tok, "concept", None, "MyConcept", None, None, vec![])
4828 .await
4829 .unwrap();
4830 let doc = rt
4831 .create_entity(&tok, "document", None, "MyDoc", None, None, vec![])
4832 .await
4833 .unwrap();
4834 let result = rt
4835 .link(
4836 &tok,
4837 concept.id,
4838 doc.id,
4839 EdgeRelation::Supersedes,
4840 1.0,
4841 None,
4842 )
4843 .await;
4844 assert!(
4845 matches!(result, Err(RuntimeError::InvalidInput(_))),
4846 "concept->document Supersedes must be rejected by ADR-002 allowlist, got {result:?}"
4847 );
4848 }
4849
4850 #[tokio::test]
4851 async fn delete_note_cross_namespace_returns_mismatch_error() {
4852 let rt = rt();
4853 let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
4854 let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
4855 let note = rt
4856 .create_note(
4857 &ns_a,
4858 "observation",
4859 None,
4860 "note in ns-a",
4861 Some(0.8),
4862 None,
4863 vec![],
4864 )
4865 .await
4866 .unwrap();
4867
4868 let result = rt.delete_note(&ns_b, note.id, true).await;
4870 assert!(
4871 matches!(result.unwrap_err(), crate::RuntimeError::NamespaceMismatch { id } if id == note.id),
4872 "cross-namespace delete_note must return NamespaceMismatch with the note id"
4873 );
4874
4875 let note_store = rt.notes(&ns_a).unwrap();
4877 let still_there = note_store.get_note(note.id).await.unwrap();
4878 assert!(
4879 still_there.is_some(),
4880 "note must survive cross-ns delete attempt"
4881 );
4882 }
4883}