1use std::collections::HashMap;
12use std::str::FromStr;
13
14use serde::Serialize;
15use uuid::Uuid;
16
17use khive_score::DeterministicScore;
18use khive_storage::note::Note;
19use khive_storage::types::{
20 DeleteMode, Direction, EdgeSortField, GraphPath, LinkId, NeighborHit, NeighborQuery, Page,
21 PageRequest, SortOrder, SqlRow, SqlStatement, SqlValue, TextFilter, TextQueryMode,
22 TextSearchRequest, TraversalRequest,
23};
24use khive_storage::{Edge, EdgeRelation, Entity, EntityFilter, Event, EventFilter};
25use khive_types::{EdgeEndpointRule, EndpointKind, EventKind, SubstrateKind};
26
27use khive_db::SqliteError;
28use rusqlite::OptionalExtension;
29
30use crate::curation::{entity_fts_document, note_fts_document};
31use crate::error::{RuntimeError, RuntimeResult};
32use crate::runtime::{KhiveRuntime, NamespaceToken};
33
34#[cfg(test)]
42std::thread_local! {
43 static LINK_FAIL_AFTER: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
44}
45
46#[derive(Clone, Debug)]
48pub struct NoteSearchHit {
49 pub note_id: Uuid,
50 pub score: DeterministicScore,
51 pub title: Option<String>,
52 pub snippet: Option<String>,
53}
54
55fn text_preview(text: &str, max_chars: usize) -> Option<String> {
56 let trimmed = text.trim();
57 if trimmed.is_empty() {
58 None
59 } else {
60 Some(trimmed.chars().take(max_chars).collect())
61 }
62}
63
64fn normalize_symmetric_direction(
70 direction: Direction,
71 relations: Option<&[EdgeRelation]>,
72) -> Direction {
73 let Some(rels) = relations else {
74 return direction;
75 };
76 if rels.is_empty() {
77 return direction;
78 }
79 let all_symmetric = rels
80 .iter()
81 .all(|r| matches!(r, EdgeRelation::CompetesWith | EdgeRelation::ComposedWith));
82 if all_symmetric {
83 Direction::Both
84 } else {
85 direction
86 }
87}
88
89fn note_title(note: &Note) -> Option<String> {
90 note.name
91 .clone()
92 .filter(|s| !s.trim().is_empty())
93 .or_else(|| Some(format!("[{}]", note.kind.as_str())))
94}
95
96fn note_snippet(note: &Note) -> Option<String> {
97 text_preview(¬e.content, 200)
98}
99
100#[derive(Clone, Debug)]
102pub enum Resolved {
103 Entity(Entity),
104 Note(Note),
105 Event(Event),
106}
107
108fn resolved_pair(r: Option<&Resolved>) -> Option<(&'static str, &str)> {
111 match r? {
112 Resolved::Entity(e) => Some(("entity", e.kind.as_str())),
113 Resolved::Note(n) => Some(("note", n.kind.as_str())),
114 Resolved::Event(_) => None,
115 }
116}
117
118fn endpoint_matches(spec: &EndpointKind, substrate: &str, kind: &str) -> bool {
120 match spec {
121 EndpointKind::EntityOfKind(k) => substrate == "entity" && *k == kind,
122 EndpointKind::NoteOfKind(k) => substrate == "note" && *k == kind,
123 }
124}
125
126fn pack_rule_allows(
129 rules: &[EdgeEndpointRule],
130 relation: EdgeRelation,
131 src: Option<&Resolved>,
132 tgt: Option<&Resolved>,
133) -> bool {
134 let Some((src_sub, src_kind)) = resolved_pair(src) else {
135 return false;
136 };
137 let Some((tgt_sub, tgt_kind)) = resolved_pair(tgt) else {
138 return false;
139 };
140 rules.iter().any(|r| {
141 r.relation == relation
142 && endpoint_matches(&r.source, src_sub, src_kind)
143 && endpoint_matches(&r.target, tgt_sub, tgt_kind)
144 })
145}
146
147fn base_entity_rule_allows(src_kind: &str, relation: EdgeRelation, tgt_kind: &str) -> bool {
155 const RULES: &[(&str, EdgeRelation, &str)] = &[
156 ("concept", EdgeRelation::Contains, "concept"),
158 ("project", EdgeRelation::Contains, "project"),
159 ("project", EdgeRelation::Contains, "artifact"),
160 ("org", EdgeRelation::Contains, "project"),
161 ("org", EdgeRelation::Contains, "service"),
162 ("concept", EdgeRelation::PartOf, "concept"),
163 ("project", EdgeRelation::PartOf, "project"),
164 ("project", EdgeRelation::PartOf, "org"),
165 ("*", EdgeRelation::InstanceOf, "concept"),
166 ("service", EdgeRelation::InstanceOf, "project"),
167 ("concept", EdgeRelation::Extends, "concept"),
169 ("concept", EdgeRelation::VariantOf, "concept"),
170 ("artifact", EdgeRelation::VariantOf, "artifact"),
171 ("concept", EdgeRelation::IntroducedBy, "document"),
172 ("concept", EdgeRelation::IntroducedBy, "person"),
173 ("artifact", EdgeRelation::IntroducedBy, "document"),
174 ("artifact", EdgeRelation::DerivedFrom, "dataset"),
176 ("artifact", EdgeRelation::DerivedFrom, "document"),
177 ("artifact", EdgeRelation::DerivedFrom, "project"),
178 ("artifact", EdgeRelation::DerivedFrom, "artifact"),
179 ("document", EdgeRelation::Precedes, "document"),
181 ("dataset", EdgeRelation::Precedes, "dataset"),
182 ("artifact", EdgeRelation::Precedes, "artifact"),
183 ("service", EdgeRelation::Precedes, "service"),
184 ("project", EdgeRelation::Precedes, "project"),
185 ("project", EdgeRelation::DependsOn, "project"),
187 ("service", EdgeRelation::DependsOn, "project"),
188 ("service", EdgeRelation::DependsOn, "service"),
189 ("service", EdgeRelation::DependsOn, "artifact"),
190 ("service", EdgeRelation::DependsOn, "dataset"),
191 ("artifact", EdgeRelation::DependsOn, "project"),
192 ("artifact", EdgeRelation::DependsOn, "service"),
193 ("concept", EdgeRelation::Enables, "concept"),
194 ("service", EdgeRelation::Enables, "concept"),
195 ("dataset", EdgeRelation::Enables, "concept"),
196 ("project", EdgeRelation::Implements, "concept"),
198 ("service", EdgeRelation::Implements, "concept"),
199 ("concept", EdgeRelation::CompetesWith, "concept"),
201 ("project", EdgeRelation::CompetesWith, "project"),
202 ("service", EdgeRelation::CompetesWith, "service"),
203 ("concept", EdgeRelation::ComposedWith, "concept"),
204 ("project", EdgeRelation::ComposedWith, "project"),
205 ("concept", EdgeRelation::Supersedes, "concept"),
207 ("document", EdgeRelation::Supersedes, "document"),
208 ("artifact", EdgeRelation::Supersedes, "artifact"),
209 ("service", EdgeRelation::Supersedes, "service"),
210 ("dataset", EdgeRelation::Supersedes, "dataset"),
211 ];
212 RULES.iter().any(|(src, rel, tgt)| {
213 *rel == relation && (*src == "*" || *src == src_kind) && *tgt == tgt_kind
214 })
215}
216
217pub(crate) fn canonical_edge_endpoints(
223 relation: EdgeRelation,
224 source_id: Uuid,
225 target_id: Uuid,
226) -> (Uuid, Uuid) {
227 if relation.is_symmetric() && target_id < source_id {
228 (target_id, source_id)
229 } else {
230 (source_id, target_id)
231 }
232}
233
234fn infer_dependency_kind(src_kind: &str, tgt_kind: &str) -> Option<&'static str> {
236 match (src_kind, tgt_kind) {
237 ("project", "project") => Some("build"),
238 ("service", "service") => Some("runtime"),
239 ("service", "dataset") => Some("data"),
240 ("service", "artifact") => Some("artifact"),
241 ("artifact", "project") | ("artifact", "service") => Some("tooling"),
242 _ => None,
243 }
244}
245
246fn merge_dependency_kind(
253 src_kind: &str,
254 tgt_kind: &str,
255 metadata: Option<serde_json::Value>,
256) -> Option<serde_json::Value> {
257 if let Some(ref m) = metadata {
258 if m.get("dependency_kind").is_some() {
259 return metadata;
260 }
261 }
262 let inferred = infer_dependency_kind(src_kind, tgt_kind)?;
263 let mut obj = metadata.unwrap_or_else(|| serde_json::json!({}));
264 if let Some(o) = obj.as_object_mut() {
265 o.insert("dependency_kind".to_string(), serde_json::json!(inferred));
266 }
267 Some(obj)
268}
269
270const VALID_DEPENDENCY_KINDS: &[&str] = &["build", "runtime", "data", "artifact", "tooling"];
272
273pub(crate) fn validate_edge_weight(weight: f64) -> RuntimeResult<()> {
279 if !weight.is_finite() || !(0.0..=1.0).contains(&weight) {
280 return Err(RuntimeError::InvalidInput(format!(
281 "edge weight must be finite and in [0.0, 1.0], got {weight}"
282 )));
283 }
284 Ok(())
285}
286
287fn validate_edge_metadata(
293 relation: EdgeRelation,
294 metadata: Option<&serde_json::Value>,
295) -> RuntimeResult<()> {
296 let Some(meta) = metadata else {
297 return Ok(());
298 };
299 if let Some(dk) = meta.get("dependency_kind") {
300 if relation != EdgeRelation::DependsOn {
301 return Err(RuntimeError::InvalidInput(format!(
302 "dependency_kind is only valid on depends_on edges (got {})",
303 relation.as_str()
304 )));
305 }
306 let dk_str = dk
307 .as_str()
308 .ok_or_else(|| RuntimeError::InvalidInput("dependency_kind must be a string".into()))?;
309 if !VALID_DEPENDENCY_KINDS.contains(&dk_str) {
310 return Err(RuntimeError::InvalidInput(format!(
311 "unknown dependency_kind {dk_str:?}; valid: {}",
312 VALID_DEPENDENCY_KINDS.join(" | ")
313 )));
314 }
315 }
316 Ok(())
317}
318
319impl KhiveRuntime {
320 #[allow(clippy::too_many_arguments)]
327 pub async fn create_entity(
328 &self,
329 token: &NamespaceToken,
330 kind: &str,
331 entity_type: Option<&str>,
332 name: &str,
333 description: Option<&str>,
334 properties: Option<serde_json::Value>,
335 tags: Vec<String>,
336 ) -> RuntimeResult<Entity> {
337 self.validate_entity_kind(kind)?;
338 crate::secret_gate::check(name)?;
340 if let Some(d) = description {
341 crate::secret_gate::check(d)?;
342 }
343 if let Some(ref p) = properties {
344 crate::secret_gate::check_json(p)?;
345 }
346 crate::secret_gate::check_tags(&tags)?;
347 let ns = token.namespace().as_str();
348 let mut entity = Entity::new(ns, kind, name).with_entity_type(entity_type);
349 if let Some(d) = description {
350 entity = entity.with_description(d);
351 }
352 if let Some(p) = properties {
353 entity = entity.with_properties(p);
354 }
355 if !tags.is_empty() {
356 entity = entity.with_tags(tags);
357 }
358 self.entities(token)?.upsert_entity(entity.clone()).await?;
359
360 let doc = entity_fts_document(&entity);
361 let embed_body = doc.body.clone();
362 self.text(token)?.upsert_document(doc).await?;
363
364 if self.config().embedding_model.is_some() {
365 let vector = self.embed_document(&embed_body).await?;
366 self.vectors(token)?
367 .insert(
368 entity.id,
369 SubstrateKind::Entity,
370 ns,
371 "entity.body",
372 vec![vector],
373 )
374 .await?;
375 }
376
377 Ok(entity)
378 }
379
380 pub async fn get_entity(&self, token: &NamespaceToken, id: Uuid) -> RuntimeResult<Entity> {
385 let entity = self
386 .entities(token)?
387 .get_entity(id)
388 .await?
389 .ok_or_else(|| RuntimeError::NotFound("not found in this namespace".into()))?;
390 Self::ensure_namespace(&entity.namespace, token.namespace().as_str())?;
391 Ok(entity)
392 }
393
394 pub async fn get_entity_including_deleted(
401 &self,
402 token: &NamespaceToken,
403 id: Uuid,
404 ) -> RuntimeResult<Option<Entity>> {
405 let entity = match self
406 .entities(token)?
407 .get_entity_including_deleted(id)
408 .await?
409 {
410 Some(e) => e,
411 None => return Ok(None),
412 };
413 if entity.namespace != token.namespace().as_str() {
414 return Ok(None);
415 }
416 Ok(Some(entity))
417 }
418
419 pub async fn get_note_including_deleted(
425 &self,
426 token: &NamespaceToken,
427 id: Uuid,
428 ) -> RuntimeResult<Option<khive_storage::note::Note>> {
429 let note = match self.notes(token)?.get_note_including_deleted(id).await? {
430 Some(n) => n,
431 None => return Ok(None),
432 };
433 if note.namespace != token.namespace().as_str() {
434 return Ok(None);
435 }
436 Ok(Some(note))
437 }
438
439 pub async fn get_entities_by_ids(
443 &self,
444 token: &NamespaceToken,
445 ids: &[Uuid],
446 ) -> RuntimeResult<Vec<Entity>> {
447 if ids.is_empty() {
448 return Ok(vec![]);
449 }
450 let filter = EntityFilter {
451 ids: ids.to_vec(),
452 ..Default::default()
453 };
454 let page = self
455 .entities(token)?
456 .query_entities(
457 token.namespace().as_str(),
458 filter,
459 PageRequest {
460 offset: 0,
461 limit: ids.len() as u32,
462 },
463 )
464 .await?;
465 Ok(page.items)
466 }
467
468 pub(crate) fn ensure_namespace(record_ns: &str, caller_ns: &str) -> RuntimeResult<()> {
473 if record_ns == caller_ns {
474 return Ok(());
475 }
476 Err(RuntimeError::NotFound("not found in this namespace".into()))
477 }
478
479 pub async fn list_entities(
481 &self,
482 token: &NamespaceToken,
483 kind: Option<&str>,
484 entity_type: Option<&str>,
485 limit: u32,
486 offset: u32,
487 ) -> RuntimeResult<Vec<Entity>> {
488 let filter = EntityFilter {
489 kinds: match kind {
490 Some(k) => vec![k.to_string()],
491 None => vec![],
492 },
493 entity_types: match entity_type {
494 Some(t) => vec![t.to_string()],
495 None => vec![],
496 },
497 ..Default::default()
498 };
499 let page = self
500 .entities(token)?
501 .query_entities(
502 token.namespace().as_str(),
503 filter,
504 PageRequest {
505 offset: offset.into(),
506 limit,
507 },
508 )
509 .await?;
510 Ok(page.items)
511 }
512
513 pub async fn list_entities_tagged(
520 &self,
521 token: &NamespaceToken,
522 kind: Option<&str>,
523 domain_tag: Option<&str>,
524 limit: u32,
525 offset: u32,
526 ) -> RuntimeResult<Vec<Entity>> {
527 let filter = EntityFilter {
528 kinds: match kind {
529 Some(k) => vec![k.to_string()],
530 None => vec![],
531 },
532 tags_any: match domain_tag {
533 Some(t) if !t.is_empty() => vec![t.to_string()],
534 _ => vec![],
535 },
536 ..Default::default()
537 };
538 let page = self
539 .entities(token)?
540 .query_entities(
541 token.namespace().as_str(),
542 filter,
543 PageRequest {
544 offset: offset.into(),
545 limit,
546 },
547 )
548 .await?;
549 Ok(page.items)
550 }
551
552 pub async fn count_entities_tagged(
556 &self,
557 token: &NamespaceToken,
558 kind: Option<&str>,
559 domain_tag: Option<&str>,
560 ) -> RuntimeResult<u64> {
561 let filter = EntityFilter {
562 kinds: match kind {
563 Some(k) => vec![k.to_string()],
564 None => vec![],
565 },
566 tags_any: match domain_tag {
567 Some(t) if !t.is_empty() => vec![t.to_string()],
568 _ => vec![],
569 },
570 ..Default::default()
571 };
572 Ok(self
573 .entities(token)?
574 .count_entities(token.namespace().as_str(), filter)
575 .await?)
576 }
577
578 pub async fn list_events(
580 &self,
581 token: &NamespaceToken,
582 filter: EventFilter,
583 page: PageRequest,
584 ) -> RuntimeResult<Page<Event>> {
585 self.events(token)?
586 .query_events(filter, page)
587 .await
588 .map_err(Into::into)
589 }
590
591 async fn validate_edge_relation_endpoints(
605 &self,
606 token: &NamespaceToken,
607 source_id: Uuid,
608 target_id: Uuid,
609 relation: EdgeRelation,
610 ) -> RuntimeResult<()> {
611 if source_id == target_id {
612 return Err(RuntimeError::InvalidInput(
613 "self-loop edges are not allowed: source_id and target_id must be different".into(),
614 ));
615 }
616 if relation == EdgeRelation::Annotates {
617 match self.resolve(token, source_id).await? {
619 Some(Resolved::Note(_)) => {}
620 Some(_) => {
621 return Err(RuntimeError::InvalidInput(format!(
622 "annotates source {source_id} must be a note"
623 )));
624 }
625 None => {
626 if self.get_edge(token, source_id).await?.is_some() {
628 return Err(RuntimeError::InvalidInput(format!(
629 "annotates source {source_id} must be a note"
630 )));
631 }
632 return Err(RuntimeError::NotFound(format!(
633 "link source {source_id} not found in namespace"
634 )));
635 }
636 }
637 if !self.substrate_exists_in_ns(token, target_id).await? {
639 return Err(RuntimeError::NotFound(format!(
640 "link target {target_id} not found in namespace"
641 )));
642 }
643 } else if relation == EdgeRelation::Supersedes {
644 let src = match self.resolve(token, source_id).await? {
647 Some(r) => r,
648 None => {
649 if self.get_edge(token, source_id).await?.is_some() {
650 return Err(RuntimeError::InvalidInput(format!(
651 "supersedes source {source_id} must be a note or entity (got edge)"
652 )));
653 }
654 return Err(RuntimeError::NotFound(format!(
655 "link source {source_id} not found in namespace"
656 )));
657 }
658 };
659 let tgt = match self.resolve(token, target_id).await? {
660 Some(r) => r,
661 None => {
662 if self.get_edge(token, target_id).await?.is_some() {
663 return Err(RuntimeError::InvalidInput(format!(
664 "supersedes target {target_id} must be a note or entity (got edge)"
665 )));
666 }
667 return Err(RuntimeError::NotFound(format!(
668 "link target {target_id} not found in namespace"
669 )));
670 }
671 };
672 match (&src, &tgt) {
673 (Resolved::Entity(src_e), Resolved::Entity(tgt_e)) => {
674 if !base_entity_rule_allows(&src_e.kind, EdgeRelation::Supersedes, &tgt_e.kind)
675 {
676 return Err(RuntimeError::InvalidInput(format!(
677 "({}) -[supersedes]-> ({}) is not in the base endpoint \
678 allowlist; supersedes requires same-kind entity endpoints",
679 src_e.kind, tgt_e.kind
680 )));
681 }
682 }
683 (Resolved::Note(_), Resolved::Note(_)) => {}
684 (Resolved::Event(_), _) => {
685 return Err(RuntimeError::InvalidInput(format!(
686 "supersedes does not apply to events; source {source_id} is an event"
687 )));
688 }
689 (_, Resolved::Event(_)) => {
690 return Err(RuntimeError::InvalidInput(format!(
691 "supersedes does not apply to events; target {target_id} is an event"
692 )));
693 }
694 (Resolved::Entity(_), Resolved::Note(_)) => {
695 return Err(RuntimeError::InvalidInput(format!(
696 "supersedes endpoints must be the same substrate (note→note or entity→entity); \
697 got source={source_id} (entity) target={target_id} (note)"
698 )));
699 }
700 (Resolved::Note(_), Resolved::Entity(_)) => {
701 return Err(RuntimeError::InvalidInput(format!(
702 "supersedes endpoints must be the same substrate (note→note or entity→entity); \
703 got source={source_id} (note) target={target_id} (entity)"
704 )));
705 }
706 }
707 } else {
708 let src_res = self.resolve(token, source_id).await?;
715 let tgt_res = self.resolve(token, target_id).await?;
716
717 if pack_rule_allows(
718 &self.pack_edge_rules(),
719 relation,
720 src_res.as_ref(),
721 tgt_res.as_ref(),
722 ) {
723 return Ok(());
724 }
725
726 let src_kind = match src_res {
728 Some(Resolved::Entity(e)) => e.kind,
729 Some(_) => {
730 return Err(RuntimeError::InvalidInput(format!(
731 "link source {source_id} must be an entity for relation {relation:?} \
732 (only `annotates` crosses substrates)"
733 )));
734 }
735 None => {
736 if self.get_edge(token, source_id).await?.is_some() {
737 return Err(RuntimeError::InvalidInput(format!(
738 "link source {source_id} must be an entity for relation {relation:?} \
739 (only `annotates` crosses substrates)"
740 )));
741 }
742 return Err(RuntimeError::NotFound(format!(
743 "link source {source_id} not found in namespace"
744 )));
745 }
746 };
747 let tgt_kind = match tgt_res {
748 Some(Resolved::Entity(e)) => e.kind,
749 Some(_) => {
750 return Err(RuntimeError::InvalidInput(format!(
751 "link target {target_id} must be an entity for relation {relation:?} \
752 (only `annotates` crosses substrates)"
753 )));
754 }
755 None => {
756 if self.get_edge(token, target_id).await?.is_some() {
757 return Err(RuntimeError::InvalidInput(format!(
758 "link target {target_id} must be an entity for relation {relation:?} \
759 (only `annotates` crosses substrates)"
760 )));
761 }
762 return Err(RuntimeError::NotFound(format!(
763 "link target {target_id} not found in namespace"
764 )));
765 }
766 };
767 if !base_entity_rule_allows(&src_kind, relation, &tgt_kind) {
768 return Err(RuntimeError::InvalidInput(format!(
769 "({src_kind}) -[{}]-> ({tgt_kind}) is not in the base endpoint \
770 allowlist; use pack EDGE_RULES to extend the allowlist",
771 relation.as_str()
772 )));
773 }
774 }
775 Ok(())
776 }
777
778 pub async fn link(
797 &self,
798 token: &NamespaceToken,
799 source_id: Uuid,
800 target_id: Uuid,
801 relation: EdgeRelation,
802 weight: f64,
803 metadata: Option<serde_json::Value>,
804 ) -> RuntimeResult<Edge> {
805 validate_edge_weight(weight)?;
806 self.validate_edge_relation_endpoints(token, source_id, target_id, relation)
807 .await?;
808 let (source_id, target_id) = canonical_edge_endpoints(relation, source_id, target_id);
809 let metadata = if relation == EdgeRelation::DependsOn {
810 match (
811 self.resolve(token, source_id).await?,
812 self.resolve(token, target_id).await?,
813 ) {
814 (Some(Resolved::Entity(src_e)), Some(Resolved::Entity(tgt_e))) => {
815 merge_dependency_kind(&src_e.kind, &tgt_e.kind, metadata)
816 }
817 _ => metadata,
818 }
819 } else {
820 metadata
821 };
822 validate_edge_metadata(relation, metadata.as_ref())?;
823 let now = chrono::Utc::now();
824 let ns = token.namespace().as_str();
825 let edge = Edge {
826 id: LinkId::from(Uuid::new_v4()),
827 namespace: ns.to_string(),
828 source_id,
829 target_id,
830 relation,
831 weight,
832 created_at: now,
833 updated_at: now,
834 deleted_at: None,
835 metadata,
836 target_backend: None,
837 };
838 self.graph(token)?.upsert_edge(edge).await?;
839
840 let persisted = self
846 .list_edges(
847 token,
848 crate::curation::EdgeListFilter {
849 source_id: Some(source_id),
850 target_id: Some(target_id),
851 relations: vec![relation],
852 ..Default::default()
853 },
854 1,
855 )
856 .await?
857 .into_iter()
858 .next()
859 .ok_or_else(|| {
860 crate::RuntimeError::Internal(format!(
861 "upsert_edge succeeded but natural-key lookup for ({source_id}, {target_id}, {relation}) returned nothing"
862 ))
863 })?;
864 Ok(persisted)
865 }
866
867 pub(crate) async fn substrate_exists_in_ns(
872 &self,
873 token: &NamespaceToken,
874 id: Uuid,
875 ) -> RuntimeResult<bool> {
876 if self.resolve(token, id).await?.is_some() {
877 return Ok(true);
878 }
879 match self.get_edge(token, id).await {
880 Ok(Some(_)) => Ok(true),
881 Ok(None) | Err(RuntimeError::NotFound(_)) => Ok(false),
882 Err(err) => Err(err),
883 }
884 }
885
886 pub async fn neighbors(
895 &self,
896 token: &NamespaceToken,
897 node_id: Uuid,
898 direction: Direction,
899 limit: Option<u32>,
900 relations: Option<Vec<EdgeRelation>>,
901 ) -> RuntimeResult<Vec<NeighborHit>> {
902 self.neighbors_with_query(
903 token,
904 node_id,
905 NeighborQuery {
906 direction,
907 relations,
908 limit,
909 min_weight: None,
910 },
911 )
912 .await
913 }
914
915 pub async fn neighbors_with_query(
925 &self,
926 token: &NamespaceToken,
927 node_id: Uuid,
928 mut query: NeighborQuery,
929 ) -> RuntimeResult<Vec<NeighborHit>> {
930 if !self.substrate_exists_in_ns(token, node_id).await? {
931 return Ok(Vec::new());
932 }
933
934 query.direction =
935 normalize_symmetric_direction(query.direction, query.relations.as_deref());
936 let mut hits = self.graph(token)?.neighbors(node_id, query).await?;
937 self.enrich_neighbor_hits(token, &mut hits).await;
938 let candidate_ids: Vec<Uuid> = hits.iter().map(|h| h.node_id).collect();
940 let deleted = self.deleted_entity_ids(candidate_ids).await;
941 if !deleted.is_empty() {
942 hits.retain(|h| !deleted.contains(&h.node_id));
943 }
944 Ok(hits)
945 }
946
947 pub async fn traverse(
952 &self,
953 token: &NamespaceToken,
954 request: TraversalRequest,
955 ) -> RuntimeResult<Vec<GraphPath>> {
956 let mut request = request;
957 let mut visible_roots = Vec::with_capacity(request.roots.len());
958 for root in request.roots.drain(..) {
959 if self.substrate_exists_in_ns(token, root).await? {
960 visible_roots.push(root);
961 }
962 }
963 request.roots = visible_roots;
964 if request.roots.is_empty() {
965 return Ok(Vec::new());
966 }
967
968 let mut paths = self.graph(token)?.traverse(request).await?;
969 self.enrich_path_nodes(token, &mut paths).await;
970 let all_node_ids: Vec<Uuid> = paths
972 .iter()
973 .flat_map(|p| p.nodes.iter().map(|n| n.node_id))
974 .collect();
975 let deleted = self.deleted_entity_ids(all_node_ids).await;
976 if !deleted.is_empty() {
977 for path in paths.iter_mut() {
978 path.nodes.retain(|n| !deleted.contains(&n.node_id));
979 }
980 paths.retain(|p| !p.nodes.is_empty());
981 }
982 Ok(paths)
983 }
984
985 async fn deleted_entity_ids(&self, ids: Vec<Uuid>) -> std::collections::HashSet<Uuid> {
991 if ids.is_empty() {
992 return std::collections::HashSet::new();
993 }
994 let id_strs: Vec<String> = ids.iter().map(|u| u.to_string()).collect();
995 let placeholders = id_strs
996 .iter()
997 .enumerate()
998 .map(|(i, _)| format!("?{}", i + 1))
999 .collect::<Vec<_>>()
1000 .join(",");
1001 let sql_str = format!(
1002 "SELECT id FROM entities WHERE id IN ({placeholders}) AND deleted_at IS NOT NULL"
1003 );
1004 let params: Vec<SqlValue> = id_strs.into_iter().map(SqlValue::Text).collect();
1005 let stmt = SqlStatement {
1006 sql: sql_str,
1007 params,
1008 label: Some("deleted_entity_ids".into()),
1009 };
1010 let mut out = std::collections::HashSet::new();
1011 let sql = self.sql();
1012 if let Ok(mut reader) = sql.reader().await {
1013 if let Ok(rows) = reader.query_all(stmt).await {
1014 for row in rows {
1015 if let Some(col) = row.columns.first() {
1016 if let SqlValue::Text(s) = &col.value {
1017 if let Ok(u) = s.parse::<Uuid>() {
1018 out.insert(u);
1019 }
1020 }
1021 }
1022 }
1023 }
1024 }
1026 out
1027 }
1028
1029 async fn enrich_neighbor_hits(&self, token: &NamespaceToken, hits: &mut [NeighborHit]) {
1032 if hits.is_empty() {
1033 return;
1034 }
1035
1036 let entity_store = self.entities(token).ok();
1037 let note_store = self.notes(token).ok();
1038
1039 for hit in hits.iter_mut() {
1040 if let Some(store) = &entity_store {
1041 if let Ok(Some(entity)) = store.get_entity(hit.node_id).await {
1042 hit.name = Some(entity.name);
1043 hit.kind = Some(entity.kind);
1044 continue;
1045 }
1046 }
1047
1048 if let Some(store) = ¬e_store {
1049 if let Ok(Some(note)) = store.get_note(hit.node_id).await {
1050 let kind = note.kind;
1051 let name = note
1052 .name
1053 .filter(|s| !s.trim().is_empty())
1054 .unwrap_or_else(|| format!("[{kind}]"));
1055 hit.name = Some(name);
1056 hit.kind = Some(kind);
1057 }
1058 }
1059 }
1060 }
1061
1062 async fn enrich_path_nodes(&self, token: &NamespaceToken, paths: &mut [GraphPath]) {
1065 if paths.is_empty() {
1066 return;
1067 }
1068 let store = match self.entities(token) {
1069 Ok(s) => s,
1070 Err(_) => return,
1071 };
1072 for path in paths.iter_mut() {
1073 for node in path.nodes.iter_mut() {
1074 if let Ok(Some(entity)) = store.get_entity(node.node_id).await {
1075 node.name = Some(entity.name);
1076 node.kind = Some(entity.kind);
1077 }
1078 }
1079 }
1080 }
1081
1082 #[allow(clippy::too_many_arguments)]
1096 pub async fn create_note(
1097 &self,
1098 token: &NamespaceToken,
1099 kind: &str,
1100 name: Option<&str>,
1101 content: &str,
1102 salience: Option<f64>,
1103 properties: Option<serde_json::Value>,
1104 annotates: Vec<Uuid>,
1105 ) -> RuntimeResult<Note> {
1106 self.create_note_inner(
1107 token, kind, name, content, salience, None, properties, annotates, None,
1108 )
1109 .await
1110 }
1111
1112 #[allow(clippy::too_many_arguments)]
1116 pub async fn create_note_with_decay(
1117 &self,
1118 token: &NamespaceToken,
1119 kind: &str,
1120 name: Option<&str>,
1121 content: &str,
1122 salience: Option<f64>,
1123 decay_factor: f64,
1124 properties: Option<serde_json::Value>,
1125 annotates: Vec<Uuid>,
1126 ) -> RuntimeResult<Note> {
1127 self.create_note_with_decay_for_embedding_model(
1128 token,
1129 kind,
1130 name,
1131 content,
1132 salience,
1133 decay_factor,
1134 properties,
1135 annotates,
1136 None,
1137 )
1138 .await
1139 }
1140
1141 #[allow(clippy::too_many_arguments)]
1146 pub async fn create_note_with_decay_for_embedding_model(
1147 &self,
1148 token: &NamespaceToken,
1149 kind: &str,
1150 name: Option<&str>,
1151 content: &str,
1152 salience: Option<f64>,
1153 decay_factor: f64,
1154 properties: Option<serde_json::Value>,
1155 annotates: Vec<Uuid>,
1156 embedding_model: Option<&str>,
1157 ) -> RuntimeResult<Note> {
1158 self.create_note_inner(
1159 token,
1160 kind,
1161 name,
1162 content,
1163 salience,
1164 Some(decay_factor),
1165 properties,
1166 annotates,
1167 embedding_model,
1168 )
1169 .await
1170 }
1171
1172 #[allow(clippy::too_many_arguments)]
1176 async fn create_note_inner(
1177 &self,
1178 token: &NamespaceToken,
1179 kind: &str,
1180 name: Option<&str>,
1181 content: &str,
1182 salience: Option<f64>,
1183 decay_factor: Option<f64>,
1184 properties: Option<serde_json::Value>,
1185 annotates: Vec<Uuid>,
1186 embedding_model: Option<&str>,
1187 ) -> RuntimeResult<Note> {
1188 self.validate_note_kind(kind)?;
1189 crate::secret_gate::check(content)?;
1191 if let Some(n) = name {
1192 crate::secret_gate::check(n)?;
1193 }
1194 if let Some(ref p) = properties {
1195 crate::secret_gate::check_json(p)?;
1196 }
1197 let ns = token.namespace().as_str();
1198
1199 for &target_id in &annotates {
1201 if !self.substrate_exists_in_ns(token, target_id).await? {
1202 return Err(RuntimeError::NotFound(format!(
1203 "create_note annotates target {target_id} not found in namespace"
1204 )));
1205 }
1206 }
1207
1208 if let Some(s) = salience {
1211 if !s.is_finite() || !(0.0..=1.0).contains(&s) {
1212 return Err(RuntimeError::InvalidInput(format!(
1213 "salience must be a finite value in [0.0, 1.0]; got {s}"
1214 )));
1215 }
1216 }
1217 if let Some(d) = decay_factor {
1218 if !d.is_finite() || d < 0.0 {
1219 return Err(RuntimeError::InvalidInput(format!(
1220 "decay_factor must be a finite value >= 0.0; got {d}"
1221 )));
1222 }
1223 }
1224
1225 if let Some(model_name) = embedding_model {
1230 self.resolve_embedding_model(Some(model_name))?;
1231 }
1232
1233 let mut note = Note::new(ns, kind, content);
1234 if let Some(s) = salience {
1235 note = note.with_salience(s);
1236 }
1237 if let Some(df) = decay_factor {
1238 note = note.with_decay(df);
1239 }
1240 if let Some(n) = name {
1241 note = note.with_name(n);
1242 }
1243 if let Some(p) = properties {
1244 note = note.with_properties(p);
1245 }
1246 self.notes(token)?.upsert_note(note.clone()).await?;
1247
1248 self.text_for_notes(token)?
1249 .upsert_document(note_fts_document(¬e))
1250 .await?;
1251
1252 let embed_model_names: Vec<String> = if let Some(m) = embedding_model {
1257 vec![m.to_string()]
1258 } else {
1259 let names = self.registered_embedding_model_names();
1265 if names.is_empty() {
1266 vec![]
1268 } else {
1269 names
1270 }
1271 };
1272
1273 if embed_model_names.len() == 1 {
1274 let model_name = &embed_model_names[0];
1276 let vector = self
1277 .embed_document_with_model(model_name, ¬e.content)
1278 .await?;
1279 self.vectors_for_model(token, model_name)?
1280 .insert(
1281 note.id,
1282 SubstrateKind::Note,
1283 ns,
1284 "note.content",
1285 vec![vector],
1286 )
1287 .await?;
1288 } else if !embed_model_names.is_empty() {
1289 let rt_clone = self.clone();
1292 let content_owned = note.content.clone();
1293 let mut handles = Vec::with_capacity(embed_model_names.len());
1294 for model_name in &embed_model_names {
1295 let rt = rt_clone.clone();
1296 let text = content_owned.clone();
1297 let name = model_name.clone();
1298 handles.push(tokio::spawn(async move {
1299 rt.embed_document_with_model(&name, &text).await
1300 }));
1301 }
1302 let mut vectors: Vec<Vec<f32>> = Vec::with_capacity(embed_model_names.len());
1303 for handle in handles {
1304 let vec = handle
1305 .await
1306 .map_err(|e| RuntimeError::Internal(format!("embed task panicked: {e}")))??;
1307 vectors.push(vec);
1308 }
1309 for (model_name, vector) in embed_model_names.iter().zip(vectors.into_iter()) {
1311 self.vectors_for_model(token, model_name)?
1312 .insert(
1313 note.id,
1314 SubstrateKind::Note,
1315 ns,
1316 "note.content",
1317 vec![vector],
1318 )
1319 .await?;
1320 }
1321 }
1322
1323 let mut created_edges: Vec<Uuid> = Vec::with_capacity(annotates.len());
1329
1330 #[cfg(test)]
1333 let annotates_iter: Vec<(usize, Uuid)> = annotates
1334 .iter()
1335 .enumerate()
1336 .map(|(i, &id)| (i, id))
1337 .collect();
1338 #[cfg(test)]
1339 macro_rules! next_target {
1340 ($pair:expr) => {
1341 $pair.1
1342 };
1343 }
1344 #[cfg(not(test))]
1345 let annotates_iter: Vec<Uuid> = annotates.to_vec();
1346 #[cfg(not(test))]
1347 macro_rules! next_target {
1348 ($pair:expr) => {
1349 $pair
1350 };
1351 }
1352
1353 for pair in annotates_iter {
1354 let target_id = next_target!(pair);
1355
1356 #[cfg(test)]
1358 let injected_err: Option<RuntimeError> = {
1359 let call_idx = pair.0;
1360 LINK_FAIL_AFTER.with(|cell| {
1361 let n = cell.get();
1362 if n > 0 && call_idx + 1 == n {
1363 cell.set(0); Some(RuntimeError::Internal("injected link failure".to_string()))
1365 } else {
1366 None
1367 }
1368 })
1369 };
1370 #[cfg(not(test))]
1371 let injected_err: Option<RuntimeError> = None;
1372
1373 let link_result = if let Some(e) = injected_err {
1374 Err(e)
1375 } else {
1376 self.link(
1377 token,
1378 note.id,
1379 target_id,
1380 EdgeRelation::Annotates,
1381 1.0,
1382 None,
1383 )
1384 .await
1385 };
1386
1387 match link_result {
1388 Ok(edge) => created_edges.push(edge.id.into()),
1389 Err(e) => {
1390 for edge_id in created_edges {
1392 let _ = self.delete_edge(token, edge_id, true).await;
1393 }
1394 if let Ok(store) = self.notes(token) {
1395 let _ = store.delete_note(note.id, DeleteMode::Hard).await;
1396 }
1397 if let Ok(fts) = self.text_for_notes(token) {
1398 let _ = fts.delete_document(ns, note.id).await;
1399 }
1400 for model_name in &embed_model_names {
1401 if let Ok(vs) = self.vectors_for_model(token, model_name) {
1402 let _ = vs.delete(note.id).await;
1403 }
1404 }
1405 return Err(e);
1406 }
1407 }
1408 }
1409
1410 Ok(note)
1411 }
1412
1413 pub async fn list_notes(
1415 &self,
1416 token: &NamespaceToken,
1417 kind: Option<&str>,
1418 limit: u32,
1419 offset: u32,
1420 ) -> RuntimeResult<Vec<Note>> {
1421 let page = self
1422 .notes(token)?
1423 .query_notes(
1424 token.namespace().as_str(),
1425 kind,
1426 PageRequest {
1427 offset: offset.into(),
1428 limit,
1429 },
1430 )
1431 .await?;
1432 Ok(page.items)
1433 }
1434
1435 pub async fn search_notes(
1445 &self,
1446 token: &NamespaceToken,
1447 query_text: &str,
1448 query_vector: Option<Vec<f32>>,
1449 limit: u32,
1450 note_kind: Option<&str>,
1451 include_superseded: bool,
1452 ) -> RuntimeResult<Vec<NoteSearchHit>> {
1453 const RRF_K: usize = 60;
1454 let candidates = limit.saturating_mul(4).max(limit);
1455 let ns = token.namespace().as_str().to_owned();
1456
1457 let text_hits = self
1459 .text_for_notes(token)?
1460 .search(TextSearchRequest {
1461 query: query_text.to_string(),
1462 mode: TextQueryMode::Plain,
1463 filter: Some(TextFilter {
1464 namespaces: vec![ns.clone()],
1465 ..TextFilter::default()
1466 }),
1467 top_k: candidates,
1468 snippet_chars: 200,
1469 })
1470 .await?;
1471
1472 let vector_hits = if query_vector.is_some() || self.config().embedding_model.is_some() {
1474 self.vector_search(
1475 token,
1476 query_vector,
1477 Some(query_text),
1478 candidates,
1479 Some(SubstrateKind::Note),
1480 )
1481 .await?
1482 } else {
1483 vec![]
1484 };
1485
1486 let fuse_k = text_hits.len() + vector_hits.len();
1492 let fused = crate::fusion::rrf_fuse_k(text_hits, vector_hits, RRF_K, fuse_k)?;
1493
1494 let candidate_ids: Vec<Uuid> = fused.iter().map(|hit| hit.entity_id).collect();
1495 if candidate_ids.is_empty() {
1496 return Ok(vec![]);
1497 }
1498
1499 let note_store = self.notes(token)?;
1504 let mut alive_notes: HashMap<Uuid, Note> = HashMap::new();
1505 for id in &candidate_ids {
1506 if let Some(note) = note_store.get_note(*id).await? {
1507 if note.deleted_at.is_some() {
1508 continue;
1509 }
1510 if let Some(want_kind) = note_kind {
1511 if note.kind != want_kind {
1512 continue;
1513 }
1514 }
1515 alive_notes.insert(*id, note);
1516 }
1517 }
1518
1519 if !include_superseded && !alive_notes.is_empty() {
1522 let graph = self.graph(token)?;
1523 let mut superseded: std::collections::HashSet<Uuid> = std::collections::HashSet::new();
1524 for ¬e_id in alive_notes.keys() {
1525 let inbound = graph
1526 .neighbors(
1527 note_id,
1528 NeighborQuery {
1529 direction: Direction::In,
1530 relations: Some(vec![EdgeRelation::Supersedes]),
1531 limit: Some(1),
1532 min_weight: None,
1533 },
1534 )
1535 .await?;
1536 if !inbound.is_empty() {
1537 superseded.insert(note_id);
1538 }
1539 }
1540 alive_notes.retain(|id, _| !superseded.contains(id));
1541 }
1542
1543 let mut hits: Vec<NoteSearchHit> = fused
1545 .into_iter()
1546 .filter_map(|hit| {
1547 let note = alive_notes.get(&hit.entity_id)?;
1548 let salience = note.salience.unwrap_or(0.5);
1549 let weight = 0.5 + 0.5 * salience;
1550 let weighted = DeterministicScore::from_f64(hit.score.to_f64() * weight);
1551 Some(NoteSearchHit {
1552 note_id: hit.entity_id,
1553 score: weighted,
1554 title: hit.title.or_else(|| note_title(note)),
1555 snippet: hit.snippet.or_else(|| note_snippet(note)),
1556 })
1557 })
1558 .collect();
1559
1560 hits.sort_by(|a, b| b.score.cmp(&a.score).then(a.note_id.cmp(&b.note_id)));
1561 hits.truncate(limit as usize);
1562 Ok(hits)
1563 }
1564
1565 pub async fn resolve_prefix(
1572 &self,
1573 token: &NamespaceToken,
1574 prefix: &str,
1575 ) -> RuntimeResult<Option<Uuid>> {
1576 self.resolve_prefix_inner(token, prefix, false).await
1577 }
1578
1579 pub async fn resolve_prefix_including_deleted(
1580 &self,
1581 token: &NamespaceToken,
1582 prefix: &str,
1583 ) -> RuntimeResult<Option<Uuid>> {
1584 self.resolve_prefix_inner(token, prefix, true).await
1585 }
1586
1587 async fn resolve_prefix_inner(
1588 &self,
1589 token: &NamespaceToken,
1590 prefix: &str,
1591 include_deleted: bool,
1592 ) -> RuntimeResult<Option<Uuid>> {
1593 use khive_storage::types::{SqlStatement, SqlValue};
1594
1595 let ns = token.namespace().as_str().to_owned();
1596 let pattern = format!("{}%", prefix);
1597
1598 let tables = [
1599 ("entities", true),
1600 ("notes", true),
1601 ("events", false),
1602 ("graph_edges", false),
1603 ];
1604
1605 let mut matches: Vec<String> = Vec::new();
1606 let mut reader = self.sql().reader().await.map_err(RuntimeError::Storage)?;
1607
1608 for (table, has_deleted_at) in tables {
1609 let deleted_filter = if has_deleted_at && !include_deleted {
1610 " AND deleted_at IS NULL"
1611 } else {
1612 ""
1613 };
1614 let sql = SqlStatement {
1615 sql: format!(
1616 "SELECT id FROM {table} WHERE id LIKE ?1 AND namespace = ?2{deleted_filter} LIMIT 2"
1617 ),
1618 params: vec![
1619 SqlValue::Text(pattern.clone()),
1620 SqlValue::Text(ns.clone()),
1621 ],
1622 label: Some("resolve_prefix".into()),
1623 };
1624 match reader.query_all(sql).await {
1625 Ok(rows) => {
1626 for row in rows {
1627 if let Some(col) = row.columns.first() {
1628 if let SqlValue::Text(s) = &col.value {
1629 matches.push(s.clone());
1630 }
1631 }
1632 }
1633 }
1634 Err(e) => {
1635 let msg = e.to_string();
1636 if msg.contains("no such table") {
1637 continue;
1638 }
1639 return Err(RuntimeError::Storage(e));
1640 }
1641 }
1642 if matches.len() > 1 {
1643 break;
1644 }
1645 }
1646
1647 match matches.len() {
1648 0 => Ok(None),
1649 1 => {
1650 let uuid = Uuid::from_str(&matches[0])
1651 .map_err(|e| RuntimeError::Internal(format!("stored UUID is invalid: {e}")))?;
1652 Ok(Some(uuid))
1653 }
1654 _ => {
1655 let uuids: Vec<uuid::Uuid> = matches
1656 .iter()
1657 .filter_map(|s| Uuid::from_str(s).ok())
1658 .collect();
1659 Err(RuntimeError::AmbiguousPrefix {
1660 prefix: prefix.to_string(),
1661 matches: uuids,
1662 })
1663 }
1664 }
1665 }
1666
1667 pub async fn resolve(
1672 &self,
1673 token: &NamespaceToken,
1674 id: Uuid,
1675 ) -> RuntimeResult<Option<Resolved>> {
1676 let ns = token.namespace().as_str();
1677
1678 match self.get_entity(token, id).await {
1680 Ok(entity) => return Ok(Some(Resolved::Entity(entity))),
1681 Err(RuntimeError::NotFound(_) | RuntimeError::NamespaceMismatch { .. }) => {}
1682 Err(e) => return Err(e),
1683 }
1684
1685 if let Some(note) = self.notes(token)?.get_note(id).await? {
1687 if Self::ensure_namespace(¬e.namespace, ns).is_ok() {
1688 return Ok(Some(Resolved::Note(note)));
1689 }
1690 }
1691
1692 if let Some(event) = self.events(token)?.get_event(id).await? {
1694 if Self::ensure_namespace(&event.namespace, ns).is_ok() {
1695 return Ok(Some(Resolved::Event(event)));
1696 }
1697 }
1698
1699 Ok(None)
1700 }
1701
1702 pub async fn resolve_including_deleted(
1707 &self,
1708 token: &NamespaceToken,
1709 id: Uuid,
1710 ) -> RuntimeResult<Option<Resolved>> {
1711 let ns = token.namespace().as_str();
1712
1713 if let Some(entity) = self
1714 .entities(token)?
1715 .get_entity_including_deleted(id)
1716 .await?
1717 {
1718 if Self::ensure_namespace(&entity.namespace, ns).is_ok() {
1719 return Ok(Some(Resolved::Entity(entity)));
1720 }
1721 }
1722
1723 if let Some(note) = self.notes(token)?.get_note_including_deleted(id).await? {
1724 if Self::ensure_namespace(¬e.namespace, ns).is_ok() {
1725 return Ok(Some(Resolved::Note(note)));
1726 }
1727 }
1728
1729 if let Some(event) = self.events(token)?.get_event(id).await? {
1730 if Self::ensure_namespace(&event.namespace, ns).is_ok() {
1731 return Ok(Some(Resolved::Event(event)));
1732 }
1733 }
1734
1735 Ok(None)
1736 }
1737
1738 pub async fn delete_note(
1748 &self,
1749 token: &NamespaceToken,
1750 id: Uuid,
1751 hard: bool,
1752 ) -> RuntimeResult<bool> {
1753 let ns = token.namespace().as_str();
1754 let note_store = self.notes(token)?;
1755 let note = if hard {
1756 match note_store.get_note_including_deleted(id).await? {
1757 Some(n) => n,
1758 None => return Ok(false),
1759 }
1760 } else {
1761 match note_store.get_note(id).await? {
1762 Some(n) => n,
1763 None => return Ok(false),
1764 }
1765 };
1766 if Self::ensure_namespace(¬e.namespace, ns).is_err() {
1767 return Ok(false);
1768 }
1769 let mode = if hard {
1770 DeleteMode::Hard
1771 } else {
1772 DeleteMode::Soft
1773 };
1774
1775 if hard {
1779 let graph = self.graph(token)?;
1780 graph.purge_incident_edges(id).await?;
1781 let ns_str = ns.to_string();
1782 self.text_for_notes(token)?
1783 .delete_document(&ns_str, id)
1784 .await?;
1785 for model_name in self.registered_embedding_model_names() {
1789 self.vectors_for_model(token, &model_name)?
1790 .delete(id)
1791 .await?;
1792 }
1793 }
1794
1795 let deleted = note_store.delete_note(id, mode).await?;
1796 if !hard && deleted {
1797 let ns_str = ns.to_string();
1798 self.text_for_notes(token)?
1799 .delete_document(&ns_str, id)
1800 .await?;
1801 for model_name in self.registered_embedding_model_names() {
1802 self.vectors_for_model(token, &model_name)?
1803 .delete(id)
1804 .await?;
1805 }
1806 }
1807 if deleted {
1808 let event_store = self.events(token)?;
1809 let ns_str = ns.to_string();
1810 let event = khive_storage::event::Event::new(
1811 ns_str.clone(),
1812 "delete",
1813 EventKind::NoteDeleted,
1814 SubstrateKind::Note,
1815 "",
1816 )
1817 .with_target(id)
1818 .with_payload(serde_json::json!({"id": id, "namespace": ns_str, "hard": hard}));
1819 event_store.append_event(event).await.map_err(|e| {
1820 RuntimeError::Internal(format!("delete_note: event store write failed: {e}"))
1821 })?;
1822 }
1823 Ok(deleted)
1824 }
1825}
1826
1827#[derive(Clone, Debug, Serialize)]
1829pub struct QueryResult {
1830 pub rows: Vec<SqlRow>,
1831 #[serde(skip_serializing_if = "Vec::is_empty")]
1832 pub warnings: Vec<String>,
1833}
1834
1835impl KhiveRuntime {
1836 pub async fn query(&self, token: &NamespaceToken, query: &str) -> RuntimeResult<Vec<SqlRow>> {
1844 Ok(self
1845 .query_with_metadata(token, query, khive_query::CompileOptions::default())
1846 .await?
1847 .rows)
1848 }
1849
1850 pub async fn query_with_metadata(
1852 &self,
1853 token: &NamespaceToken,
1854 query: &str,
1855 mut opts: khive_query::CompileOptions,
1856 ) -> RuntimeResult<QueryResult> {
1857 use khive_query::QueryValue;
1858 use khive_storage::types::SqlValue;
1859
1860 let ns = token.namespace().as_str();
1861 let ast = khive_query::parse_auto(query)?;
1862 opts.scopes = vec![ns.to_string()];
1863 let compiled = khive_query::compile(&ast, &opts)?;
1864 let warnings = compiled.warnings;
1865
1866 let params: Vec<SqlValue> = compiled
1869 .params
1870 .into_iter()
1871 .map(|qv| match qv {
1872 QueryValue::Null => SqlValue::Null,
1873 QueryValue::Integer(n) => SqlValue::Integer(n),
1874 QueryValue::Float(f) => SqlValue::Float(f),
1875 QueryValue::Text(s) => SqlValue::Text(s),
1876 QueryValue::Blob(b) => SqlValue::Blob(b),
1877 })
1878 .collect();
1879
1880 let mut reader = self.sql().reader().await?;
1881 let stmt = SqlStatement {
1882 sql: compiled.sql,
1883 params,
1884 label: None,
1885 };
1886 let rows = reader.query_all(stmt).await?;
1887 Ok(QueryResult { rows, warnings })
1888 }
1889
1890 pub async fn delete_entity(
1899 &self,
1900 token: &NamespaceToken,
1901 id: Uuid,
1902 hard: bool,
1903 ) -> RuntimeResult<bool> {
1904 let entity = if hard {
1905 match self
1906 .entities(token)?
1907 .get_entity_including_deleted(id)
1908 .await?
1909 {
1910 Some(e) => e,
1911 None => return Ok(false),
1912 }
1913 } else {
1914 match self.entities(token)?.get_entity(id).await? {
1915 Some(e) => e,
1916 None => return Ok(false),
1917 }
1918 };
1919 Self::ensure_namespace(&entity.namespace, token.namespace().as_str())?;
1920 let mode = if hard {
1921 DeleteMode::Hard
1922 } else {
1923 DeleteMode::Soft
1924 };
1925
1926 if hard {
1930 let graph = self.graph(token)?;
1931 graph.purge_incident_edges(id).await?;
1932 self.remove_from_indexes(token, id).await?;
1933 }
1934
1935 let deleted = self.entities(token)?.delete_entity(id, mode).await?;
1936 if !hard && deleted {
1937 self.remove_from_indexes(token, id).await?;
1938 }
1939 if deleted {
1940 let event_store = self.events(token)?;
1941 let ns = entity.namespace.clone();
1942 let event = khive_storage::event::Event::new(
1943 ns.clone(),
1944 "delete",
1945 EventKind::EntityDeleted,
1946 SubstrateKind::Entity,
1947 "",
1948 )
1949 .with_target(id)
1950 .with_payload(serde_json::json!({"id": id, "namespace": ns, "hard": hard}));
1951 event_store.append_event(event).await.map_err(|e| {
1952 RuntimeError::Internal(format!("delete_entity: event store write failed: {e}"))
1953 })?;
1954 }
1955 Ok(deleted)
1956 }
1957
1958 pub async fn count_entities(
1960 &self,
1961 token: &NamespaceToken,
1962 kind: Option<&str>,
1963 ) -> RuntimeResult<u64> {
1964 let filter = EntityFilter {
1965 kinds: match kind {
1966 Some(k) => vec![k.to_string()],
1967 None => vec![],
1968 },
1969 ..Default::default()
1970 };
1971 Ok(self
1972 .entities(token)?
1973 .count_entities(token.namespace().as_str(), filter)
1974 .await?)
1975 }
1976
1977 pub async fn get_edge(
1984 &self,
1985 token: &NamespaceToken,
1986 edge_id: Uuid,
1987 ) -> RuntimeResult<Option<Edge>> {
1988 let mut reader = self.sql().reader().await?;
1989 let record_ns = reader
1990 .query_scalar(SqlStatement {
1991 sql: "SELECT namespace FROM graph_edges \
1992 WHERE id = ?1 AND deleted_at IS NULL LIMIT 1"
1993 .into(),
1994 params: vec![SqlValue::Text(edge_id.to_string())],
1995 label: Some("get_edge_namespace".into()),
1996 })
1997 .await?;
1998
1999 let Some(SqlValue::Text(record_ns)) = record_ns else {
2000 return Ok(None);
2001 };
2002 if Self::ensure_namespace(&record_ns, token.namespace().as_str()).is_err() {
2004 return Ok(None);
2005 }
2006
2007 Ok(self.graph(token)?.get_edge(LinkId::from(edge_id)).await?)
2008 }
2009
2010 pub async fn get_edge_including_deleted(
2014 &self,
2015 token: &NamespaceToken,
2016 edge_id: Uuid,
2017 ) -> RuntimeResult<Option<Edge>> {
2018 let mut reader = self.sql().reader().await?;
2019 let record_ns = reader
2020 .query_scalar(SqlStatement {
2021 sql: "SELECT namespace FROM graph_edges WHERE id = ?1 LIMIT 1".into(),
2022 params: vec![SqlValue::Text(edge_id.to_string())],
2023 label: Some("get_edge_including_deleted_namespace".into()),
2024 })
2025 .await?;
2026
2027 let Some(SqlValue::Text(record_ns)) = record_ns else {
2028 return Ok(None);
2029 };
2030 if Self::ensure_namespace(&record_ns, token.namespace().as_str()).is_err() {
2031 return Ok(None);
2032 }
2033
2034 Ok(self
2035 .graph(token)?
2036 .get_edge_including_deleted(LinkId::from(edge_id))
2037 .await?)
2038 }
2039
2040 pub async fn list_edges(
2042 &self,
2043 token: &NamespaceToken,
2044 filter: crate::curation::EdgeListFilter,
2045 limit: u32,
2046 ) -> RuntimeResult<Vec<Edge>> {
2047 let limit = limit.clamp(1, 1000);
2048 let page = self
2049 .graph(token)?
2050 .query_edges(
2051 filter.into(),
2052 vec![SortOrder {
2053 field: EdgeSortField::CreatedAt,
2054 direction: khive_storage::types::SortDirection::Asc,
2055 }],
2056 PageRequest { offset: 0, limit },
2057 )
2058 .await?;
2059 Ok(page.items)
2060 }
2061
2062 pub async fn update_edge(
2075 &self,
2076 token: &NamespaceToken,
2077 edge_id: Uuid,
2078 patch: crate::curation::EdgePatch,
2079 ) -> RuntimeResult<Edge> {
2080 let graph = self.graph(token)?;
2081 let mut edge = graph
2082 .get_edge(LinkId::from(edge_id))
2083 .await?
2084 .ok_or_else(|| crate::RuntimeError::NotFound(format!("edge {edge_id}")))?;
2085
2086 let mut changed_fields: Vec<&'static str> = Vec::new();
2087 if let Some(r) = patch.relation {
2088 self.validate_edge_relation_endpoints(token, edge.source_id, edge.target_id, r)
2090 .await?;
2091 edge.relation = r;
2092 changed_fields.push("relation");
2093 }
2094 if let Some(w) = patch.weight {
2095 if !w.is_finite() || !(0.0..=1.0).contains(&w) {
2098 return Err(RuntimeError::InvalidInput(format!(
2099 "edge weight must be a finite value in [0.0, 1.0]; got {w}"
2100 )));
2101 }
2102 edge.weight = w;
2103 changed_fields.push("weight");
2104 }
2105 if let Some(props) = patch.properties {
2106 edge.metadata = Some(props);
2107 }
2108
2109 let (canon_src, canon_tgt) =
2119 canonical_edge_endpoints(edge.relation, edge.source_id, edge.target_id);
2120
2121 if edge.relation.is_symmetric() {
2122 let ns = token.namespace().as_str().to_string();
2124 let edge_id_str = edge_id.to_string();
2125 let relation_str = edge.relation.to_string();
2126 let canon_src_str = canon_src.to_string();
2127 let canon_tgt_str = canon_tgt.to_string();
2128 let weight = edge.weight;
2129 let metadata = edge
2130 .metadata
2131 .as_ref()
2132 .map(|v| serde_json::to_string(v).unwrap_or_default());
2133 let target_backend = edge.target_backend.clone();
2134
2135 let pool = self.backend().pool_arc();
2136
2137 let surviving_id: Option<String> = tokio::task::spawn_blocking(move || {
2141 let guard = pool.writer()?;
2142 guard.transaction(|conn| {
2143 let now_ts = chrono::Utc::now().timestamp();
2144
2145 let conflict_id: Option<String> = conn
2149 .query_row(
2150 "SELECT id FROM graph_edges \
2151 WHERE namespace = ?1 AND source_id = ?2 AND target_id = ?3 \
2152 AND relation = ?4 AND id != ?5",
2153 rusqlite::params![
2154 &ns,
2155 &canon_src_str,
2156 &canon_tgt_str,
2157 &relation_str,
2158 &edge_id_str,
2159 ],
2160 |row| row.get(0),
2161 )
2162 .optional()
2163 .map_err(SqliteError::Rusqlite)?;
2164
2165 if let Some(existing_id) = conflict_id {
2166 conn.execute(
2171 "DELETE FROM graph_edges WHERE namespace = ?1 AND id = ?2",
2172 rusqlite::params![&ns, &edge_id_str],
2173 )
2174 .map_err(SqliteError::Rusqlite)?;
2175 conn.execute(
2176 "UPDATE graph_edges SET \
2177 weight = ?1, updated_at = ?2, deleted_at = NULL, \
2178 target_backend = ?3, metadata = ?4 \
2179 WHERE namespace = ?5 AND id = ?6",
2180 rusqlite::params![
2181 weight,
2182 now_ts,
2183 target_backend,
2184 metadata,
2185 &ns,
2186 &existing_id,
2187 ],
2188 )
2189 .map_err(SqliteError::Rusqlite)?;
2190 Ok(Some(existing_id))
2191 } else {
2192 conn.execute(
2195 "UPDATE graph_edges SET \
2196 source_id = ?1, target_id = ?2, relation = ?3, \
2197 weight = ?4, updated_at = ?5, metadata = ?6 \
2198 WHERE namespace = ?7 AND id = ?8",
2199 rusqlite::params![
2200 &canon_src_str,
2201 &canon_tgt_str,
2202 &relation_str,
2203 weight,
2204 now_ts,
2205 metadata,
2206 &ns,
2207 &edge_id_str,
2208 ],
2209 )
2210 .map_err(SqliteError::Rusqlite)?;
2211 Ok(None)
2212 }
2213 })
2214 })
2215 .await
2216 .map_err(|e| RuntimeError::Internal(format!("update_edge: spawn_blocking join: {e}")))?
2217 .map_err(RuntimeError::Sqlite)?;
2218
2219 if let Some(sid) = surviving_id {
2220 let surviving_uuid = Uuid::parse_str(&sid).map_err(|e| {
2223 RuntimeError::Internal(format!("update_edge: surviving id parse failed: {e}"))
2224 })?;
2225 edge = self
2226 .get_edge(token, surviving_uuid)
2227 .await?
2228 .ok_or_else(|| {
2229 RuntimeError::Internal(format!(
2230 "update_edge: surviving canonical row {surviving_uuid} vanished after update"
2231 ))
2232 })?;
2233 } else {
2234 edge.source_id = canon_src;
2236 edge.target_id = canon_tgt;
2237 }
2238 } else {
2239 graph.upsert_edge(edge.clone()).await?;
2240 }
2241
2242 let event_store = self.events(token)?;
2243 let ns = token.namespace().as_str().to_string();
2244 let event = khive_storage::event::Event::new(
2245 ns.clone(),
2246 "update",
2247 EventKind::EdgeUpdated,
2248 SubstrateKind::Entity,
2249 "",
2250 )
2251 .with_target(edge_id)
2252 .with_payload(
2253 serde_json::json!({"id": edge_id, "namespace": ns, "changed_fields": changed_fields}),
2254 );
2255 event_store.append_event(event).await.map_err(|e| {
2256 RuntimeError::Internal(format!("update_edge: event store write failed: {e}"))
2257 })?;
2258
2259 Ok(edge)
2260 }
2261
2262 pub async fn delete_edge(
2273 &self,
2274 token: &NamespaceToken,
2275 edge_id: Uuid,
2276 hard: bool,
2277 ) -> RuntimeResult<bool> {
2278 let graph = self.graph(token)?;
2279 let mode = if hard {
2280 DeleteMode::Hard
2281 } else {
2282 DeleteMode::Soft
2283 };
2284
2285 let edge_exists = if hard {
2291 self.get_edge_including_deleted(token, edge_id)
2292 .await?
2293 .is_some()
2294 } else {
2295 graph.get_edge(LinkId::from(edge_id)).await?.is_some()
2296 };
2297 if !edge_exists {
2298 return Ok(false);
2299 }
2300
2301 if hard {
2306 graph.purge_incident_edges(edge_id).await?;
2307 }
2308
2309 let deleted = graph.delete_edge(LinkId::from(edge_id), mode).await?;
2310 if deleted {
2311 let event_store = self.events(token)?;
2312 let ns = token.namespace().as_str().to_string();
2313 let event = khive_storage::event::Event::new(
2314 ns.clone(),
2315 "delete",
2316 EventKind::EdgeDeleted,
2317 SubstrateKind::Entity,
2318 "",
2319 )
2320 .with_target(edge_id)
2321 .with_payload(serde_json::json!({"id": edge_id, "namespace": ns, "hard": hard}));
2322 event_store.append_event(event).await.map_err(|e| {
2323 RuntimeError::Internal(format!("delete_edge: event store write failed: {e}"))
2324 })?;
2325 }
2326 Ok(deleted)
2327 }
2328
2329 pub async fn count_edges(
2331 &self,
2332 token: &NamespaceToken,
2333 filter: crate::curation::EdgeListFilter,
2334 ) -> RuntimeResult<u64> {
2335 Ok(self.graph(token)?.count_edges(filter.into()).await?)
2336 }
2337
2338 pub async fn build_edge(&self, token: &NamespaceToken, spec: &LinkSpec) -> RuntimeResult<Edge> {
2349 let ns_str = match &spec.namespace {
2350 Some(s) => {
2351 let spec_ns = crate::Namespace::parse(s)
2352 .map_err(|e| RuntimeError::InvalidInput(format!("invalid namespace: {e}")))?;
2353 if &spec_ns != token.namespace() {
2354 return Err(RuntimeError::InvalidInput(
2355 "LinkSpec namespace does not match token namespace".into(),
2356 ));
2357 }
2358 s.as_str()
2359 }
2360 None => token.namespace().as_str(),
2361 };
2362 self.validate_edge_relation_endpoints(token, spec.source_id, spec.target_id, spec.relation)
2363 .await?;
2364 let (source_id, target_id) =
2365 canonical_edge_endpoints(spec.relation, spec.source_id, spec.target_id);
2366 let metadata = if spec.relation == EdgeRelation::DependsOn {
2367 match (
2368 self.resolve(token, source_id).await?,
2369 self.resolve(token, target_id).await?,
2370 ) {
2371 (Some(Resolved::Entity(src_e)), Some(Resolved::Entity(tgt_e))) => {
2372 merge_dependency_kind(&src_e.kind, &tgt_e.kind, spec.metadata.clone())
2373 }
2374 _ => spec.metadata.clone(),
2375 }
2376 } else {
2377 spec.metadata.clone()
2378 };
2379 validate_edge_metadata(spec.relation, metadata.as_ref())?;
2380 let now = chrono::Utc::now();
2381 Ok(Edge {
2382 id: LinkId::from(Uuid::new_v4()),
2383 namespace: ns_str.to_string(),
2384 source_id,
2385 target_id,
2386 relation: spec.relation,
2387 weight: spec.weight,
2388 created_at: now,
2389 updated_at: now,
2390 deleted_at: None,
2391 metadata,
2392 target_backend: None,
2393 })
2394 }
2395
2396 pub async fn link_many(
2413 &self,
2414 token: &NamespaceToken,
2415 specs: Vec<LinkSpec>,
2416 ) -> RuntimeResult<Vec<Edge>> {
2417 if specs.is_empty() {
2418 return Ok(vec![]);
2419 }
2420 let mut edges = Vec::with_capacity(specs.len());
2421 for spec in &specs {
2422 edges.push(self.build_edge(token, spec).await?);
2423 }
2424 self.graph(token)?.upsert_edges(edges.clone()).await?;
2425
2426 let mut persisted = Vec::with_capacity(edges.len());
2429 for edge in &edges {
2430 let row = self
2431 .list_edges(
2432 token,
2433 crate::curation::EdgeListFilter {
2434 source_id: Some(edge.source_id),
2435 target_id: Some(edge.target_id),
2436 relations: vec![edge.relation],
2437 ..Default::default()
2438 },
2439 1,
2440 )
2441 .await?
2442 .into_iter()
2443 .next()
2444 .ok_or_else(|| {
2445 crate::RuntimeError::Internal(format!(
2446 "upsert_edges succeeded but natural-key lookup for ({}, {}, {}) returned nothing",
2447 edge.source_id, edge.target_id, edge.relation.as_str()
2448 ))
2449 })?;
2450 persisted.push(row);
2451 }
2452 Ok(persisted)
2453 }
2454}
2455
2456#[derive(Clone, Debug)]
2459pub struct LinkSpec {
2460 pub namespace: Option<String>,
2461 pub source_id: Uuid,
2462 pub target_id: Uuid,
2463 pub relation: EdgeRelation,
2464 pub weight: f64,
2465 pub metadata: Option<serde_json::Value>,
2466}
2467
2468#[cfg(test)]
2474mod tests {
2475 use super::*;
2476 use crate::curation::EdgeListFilter;
2477 use crate::embedder_registry::EmbedderProvider;
2478 use crate::error::RuntimeError;
2479 use crate::runtime::{KhiveRuntime, NamespaceToken};
2480 use crate::Namespace;
2481 use async_trait::async_trait;
2482 use lattice_embed::{EmbedError, EmbeddingModel, EmbeddingService};
2483 use std::sync::atomic::{AtomicUsize, Ordering};
2484 use std::sync::Arc;
2485
2486 fn rt() -> KhiveRuntime {
2487 KhiveRuntime::memory().unwrap()
2488 }
2489
2490 struct ConstVecService {
2498 dims: usize,
2499 }
2500
2501 #[async_trait]
2502 impl EmbeddingService for ConstVecService {
2503 async fn embed(
2504 &self,
2505 texts: &[String],
2506 _model: EmbeddingModel,
2507 ) -> std::result::Result<Vec<Vec<f32>>, EmbedError> {
2508 Ok(texts.iter().map(|_| vec![1.0_f32; self.dims]).collect())
2509 }
2510
2511 fn supports_model(&self, _model: EmbeddingModel) -> bool {
2512 true
2513 }
2514
2515 fn name(&self) -> &'static str {
2516 "const-vec"
2517 }
2518 }
2519
2520 struct ConstVecProvider {
2521 provider_name: String,
2522 dims: usize,
2523 pub build_count: Arc<AtomicUsize>,
2524 }
2525
2526 impl ConstVecProvider {
2527 fn new(name: &str, dims: usize) -> (Self, Arc<AtomicUsize>) {
2528 let counter = Arc::new(AtomicUsize::new(0));
2529 let provider = Self {
2530 provider_name: name.to_owned(),
2531 dims,
2532 build_count: Arc::clone(&counter),
2533 };
2534 (provider, counter)
2535 }
2536 }
2537
2538 #[async_trait]
2539 impl EmbedderProvider for ConstVecProvider {
2540 fn name(&self) -> &str {
2541 &self.provider_name
2542 }
2543
2544 fn dimensions(&self) -> usize {
2545 self.dims
2546 }
2547
2548 async fn build(&self) -> crate::error::RuntimeResult<Arc<dyn EmbeddingService>> {
2549 self.build_count.fetch_add(1, Ordering::SeqCst);
2550 Ok(Arc::new(ConstVecService { dims: self.dims }))
2551 }
2552 }
2553
2554 #[tokio::test]
2563 async fn custom_embedder_only_runtime_fanout_stores_vector() {
2564 const MODEL_NAME: &str = "test-custom-encoder";
2565 const DIMS: usize = 8;
2566
2567 let rt = KhiveRuntime::memory().unwrap();
2569
2570 let (provider, _counter) = ConstVecProvider::new(MODEL_NAME, DIMS);
2572 rt.register_embedder(provider);
2573
2574 assert!(rt.config().embedding_model.is_none());
2576 assert_eq!(rt.registered_embedding_model_names(), vec![MODEL_NAME]);
2577
2578 let tok = NamespaceToken::local();
2579
2580 let note = rt
2582 .create_note(
2583 &tok,
2584 "memory",
2585 None,
2586 "custom embedder integration test content",
2587 Some(0.7),
2588 None,
2589 vec![],
2590 )
2591 .await
2592 .expect("create_note with custom-only embedder must succeed");
2593
2594 use khive_storage::types::VectorSearchRequest;
2596 let query_vec = vec![1.0_f32; DIMS];
2597 let hits = rt
2598 .vectors_for_model(&tok, MODEL_NAME)
2599 .expect("vector store for custom model must be accessible")
2600 .search(VectorSearchRequest {
2601 query_vectors: vec![query_vec],
2602 top_k: 5,
2603 namespace: Some(tok.namespace().as_str().to_string()),
2604 kind: Some(khive_types::SubstrateKind::Note),
2605 embedding_model: Some(MODEL_NAME.to_string()),
2606 filter: None,
2607 backend_hints: None,
2608 })
2609 .await
2610 .expect("vector search succeeds");
2611
2612 assert!(
2613 hits.iter().any(|h| h.subject_id == note.id),
2614 "custom embedder must have written a vector for note {}: hits={hits:?}",
2615 note.id
2616 );
2617 }
2618
2619 #[tokio::test]
2627 async fn embed_with_model_accepts_custom_provider_name() {
2628 const MODEL_NAME: &str = "my-custom-enc";
2629 const DIMS: usize = 4;
2630
2631 let rt = KhiveRuntime::memory().unwrap();
2632 let (provider, _counter) = ConstVecProvider::new(MODEL_NAME, DIMS);
2633 rt.register_embedder(provider);
2634
2635 let result = rt
2636 .embed_with_model(MODEL_NAME, "hello world")
2637 .await
2638 .expect("embed_with_model must accept custom provider names");
2639
2640 assert_eq!(
2641 result.len(),
2642 DIMS,
2643 "embedding dimension must match provider"
2644 );
2645 assert!(
2646 result.iter().all(|&v| (v - 1.0_f32).abs() < 1e-6),
2647 "ConstVecService must produce all-ones vector; got: {result:?}"
2648 );
2649 }
2650
2651 #[tokio::test]
2654 async fn embed_with_model_rejects_unregistered_name() {
2655 let rt = KhiveRuntime::memory().unwrap();
2656 let result = rt.embed_with_model("nonexistent-model", "hello").await;
2657 assert!(
2658 matches!(result.unwrap_err(), RuntimeError::UnknownModel(ref n) if n == "nonexistent-model"),
2659 "unregistered model name must return UnknownModel"
2660 );
2661 }
2662
2663 #[tokio::test]
2664 async fn update_edge_changes_weight() {
2665 let rt = rt();
2666 let tok = NamespaceToken::local();
2667 let a = rt
2668 .create_entity(&tok, "concept", None, "A", None, None, vec![])
2669 .await
2670 .unwrap();
2671 let b = rt
2672 .create_entity(&tok, "concept", None, "B", None, None, vec![])
2673 .await
2674 .unwrap();
2675 let edge = rt
2676 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
2677 .await
2678 .unwrap();
2679 let edge_id: Uuid = edge.id.into();
2680
2681 let updated = rt
2682 .update_edge(
2683 &tok,
2684 edge_id,
2685 crate::curation::EdgePatch {
2686 weight: Some(0.5),
2687 ..Default::default()
2688 },
2689 )
2690 .await
2691 .unwrap();
2692 assert!((updated.weight - 0.5).abs() < 0.001);
2693 }
2694
2695 #[tokio::test]
2696 async fn update_edge_changes_relation() {
2697 let rt = rt();
2698 let tok = NamespaceToken::local();
2699 let a = rt
2700 .create_entity(&tok, "concept", None, "A", None, None, vec![])
2701 .await
2702 .unwrap();
2703 let b = rt
2704 .create_entity(&tok, "concept", None, "B", None, None, vec![])
2705 .await
2706 .unwrap();
2707 let edge = rt
2708 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
2709 .await
2710 .unwrap();
2711 let edge_id: Uuid = edge.id.into();
2712
2713 let updated = rt
2714 .update_edge(
2715 &tok,
2716 edge_id,
2717 crate::curation::EdgePatch {
2718 relation: Some(EdgeRelation::VariantOf),
2719 ..Default::default()
2720 },
2721 )
2722 .await
2723 .unwrap();
2724 assert_eq!(updated.relation, EdgeRelation::VariantOf);
2725 }
2726
2727 #[tokio::test]
2732 async fn update_edge_annotates_note_to_entity_set_supersedes_returns_invalid_input() {
2733 let rt = rt();
2734 let tok = NamespaceToken::local();
2735 let note = rt
2736 .create_note(&tok, "observation", None, "a note", Some(0.5), None, vec![])
2737 .await
2738 .unwrap();
2739 let entity = rt
2740 .create_entity(&tok, "concept", None, "E", None, None, vec![])
2741 .await
2742 .unwrap();
2743 let edge = rt
2745 .link(&tok, note.id, entity.id, EdgeRelation::Annotates, 1.0, None)
2746 .await
2747 .unwrap();
2748 let edge_id: Uuid = edge.id.into();
2749
2750 let result = rt
2752 .update_edge(
2753 &tok,
2754 edge_id,
2755 crate::curation::EdgePatch {
2756 relation: Some(EdgeRelation::Supersedes),
2757 ..Default::default()
2758 },
2759 )
2760 .await;
2761 assert!(
2762 matches!(result, Err(RuntimeError::InvalidInput(_))),
2763 "update to Supersedes on note→entity edge must return InvalidInput, got {result:?}"
2764 );
2765
2766 let fetched = rt.get_edge(&tok, edge_id).await.unwrap().unwrap();
2768 assert_eq!(
2769 fetched.relation,
2770 EdgeRelation::Annotates,
2771 "edge relation must be unchanged after failed update"
2772 );
2773 }
2774
2775 #[tokio::test]
2778 async fn update_edge_entity_to_entity_set_annotates_returns_invalid_input() {
2779 let rt = rt();
2780 let tok = NamespaceToken::local();
2781 let a = rt
2782 .create_entity(&tok, "concept", None, "A", None, None, vec![])
2783 .await
2784 .unwrap();
2785 let b = rt
2786 .create_entity(&tok, "concept", None, "B", None, None, vec![])
2787 .await
2788 .unwrap();
2789 let edge = rt
2790 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
2791 .await
2792 .unwrap();
2793 let edge_id: Uuid = edge.id.into();
2794
2795 let result = rt
2796 .update_edge(
2797 &tok,
2798 edge_id,
2799 crate::curation::EdgePatch {
2800 relation: Some(EdgeRelation::Annotates),
2801 ..Default::default()
2802 },
2803 )
2804 .await;
2805 assert!(
2806 matches!(result, Err(RuntimeError::InvalidInput(_))),
2807 "update to Annotates on entity→entity edge must return InvalidInput, got {result:?}"
2808 );
2809 }
2810
2811 #[tokio::test]
2814 async fn update_edge_entity_to_entity_set_supersedes_succeeds() {
2815 let rt = rt();
2816 let tok = NamespaceToken::local();
2817 let a = rt
2818 .create_entity(&tok, "concept", None, "A", None, None, vec![])
2819 .await
2820 .unwrap();
2821 let b = rt
2822 .create_entity(&tok, "concept", None, "B", None, None, vec![])
2823 .await
2824 .unwrap();
2825 let edge = rt
2826 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
2827 .await
2828 .unwrap();
2829 let edge_id: Uuid = edge.id.into();
2830
2831 let updated = rt
2832 .update_edge(
2833 &tok,
2834 edge_id,
2835 crate::curation::EdgePatch {
2836 relation: Some(EdgeRelation::Supersedes),
2837 ..Default::default()
2838 },
2839 )
2840 .await
2841 .unwrap();
2842 assert_eq!(updated.relation, EdgeRelation::Supersedes);
2843
2844 let fetched = rt.get_edge(&tok, edge_id).await.unwrap().unwrap();
2846 assert_eq!(fetched.relation, EdgeRelation::Supersedes);
2847 }
2848
2849 #[tokio::test]
2851 async fn update_edge_weight_only_skips_validation() {
2852 let rt = rt();
2853 let tok = NamespaceToken::local();
2854 let a = rt
2855 .create_entity(&tok, "concept", None, "A", None, None, vec![])
2856 .await
2857 .unwrap();
2858 let b = rt
2859 .create_entity(&tok, "concept", None, "B", None, None, vec![])
2860 .await
2861 .unwrap();
2862 let edge = rt
2863 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
2864 .await
2865 .unwrap();
2866 let edge_id: Uuid = edge.id.into();
2867
2868 let updated = rt
2869 .update_edge(
2870 &tok,
2871 edge_id,
2872 crate::curation::EdgePatch {
2873 weight: Some(0.3),
2874 ..Default::default()
2875 },
2876 )
2877 .await
2878 .unwrap();
2879 assert_eq!(updated.relation, EdgeRelation::Extends);
2880 assert!((updated.weight - 0.3).abs() < 0.001);
2881 }
2882
2883 #[tokio::test]
2885 async fn update_edge_same_class_relation_change_succeeds() {
2886 let rt = rt();
2887 let tok = NamespaceToken::local();
2888 let a = rt
2889 .create_entity(&tok, "concept", None, "A", None, None, vec![])
2890 .await
2891 .unwrap();
2892 let b = rt
2893 .create_entity(&tok, "concept", None, "B", None, None, vec![])
2894 .await
2895 .unwrap();
2896 let edge = rt
2897 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
2898 .await
2899 .unwrap();
2900 let edge_id: Uuid = edge.id.into();
2901
2902 let updated = rt
2903 .update_edge(
2904 &tok,
2905 edge_id,
2906 crate::curation::EdgePatch {
2907 relation: Some(EdgeRelation::VariantOf),
2908 ..Default::default()
2909 },
2910 )
2911 .await
2912 .unwrap();
2913 assert_eq!(updated.relation, EdgeRelation::VariantOf);
2914 }
2915
2916 #[tokio::test]
2917 async fn list_edges_filters_by_relation() {
2918 let rt = rt();
2919 let tok = NamespaceToken::local();
2920 let a = rt
2921 .create_entity(&tok, "concept", None, "A", None, None, vec![])
2922 .await
2923 .unwrap();
2924 let b = rt
2925 .create_entity(&tok, "concept", None, "B", None, None, vec![])
2926 .await
2927 .unwrap();
2928 let c = rt
2929 .create_entity(&tok, "concept", None, "C", None, None, vec![])
2930 .await
2931 .unwrap();
2932
2933 rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
2934 .await
2935 .unwrap();
2936 rt.link(&tok, a.id, c.id, EdgeRelation::Enables, 1.0, None)
2937 .await
2938 .unwrap();
2939
2940 let filter = EdgeListFilter {
2941 relations: vec![EdgeRelation::Extends],
2942 ..Default::default()
2943 };
2944 let edges = rt.list_edges(&tok, filter, 100).await.unwrap();
2945 assert_eq!(edges.len(), 1);
2946 assert_eq!(edges[0].relation, EdgeRelation::Extends);
2947 }
2948
2949 #[tokio::test]
2950 async fn list_edges_filters_by_source() {
2951 let rt = rt();
2952 let tok = NamespaceToken::local();
2953 let a = rt
2954 .create_entity(&tok, "concept", None, "A", None, None, vec![])
2955 .await
2956 .unwrap();
2957 let b = rt
2958 .create_entity(&tok, "concept", None, "B", None, None, vec![])
2959 .await
2960 .unwrap();
2961 let c = rt
2962 .create_entity(&tok, "concept", None, "C", None, None, vec![])
2963 .await
2964 .unwrap();
2965 let d = rt
2966 .create_entity(&tok, "concept", None, "D", None, None, vec![])
2967 .await
2968 .unwrap();
2969
2970 rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
2971 .await
2972 .unwrap();
2973 rt.link(&tok, c.id, d.id, EdgeRelation::Extends, 1.0, None)
2974 .await
2975 .unwrap();
2976
2977 let filter = EdgeListFilter {
2978 source_id: Some(a.id),
2979 ..Default::default()
2980 };
2981 let edges = rt.list_edges(&tok, filter, 100).await.unwrap();
2982 assert_eq!(edges.len(), 1);
2983 let src: Uuid = edges[0].source_id;
2984 assert_eq!(src, a.id);
2985 }
2986
2987 #[tokio::test]
2988 async fn delete_edge_removes_from_storage() {
2989 let rt = rt();
2990 let tok = NamespaceToken::local();
2991 let a = rt
2992 .create_entity(&tok, "concept", None, "A", None, None, vec![])
2993 .await
2994 .unwrap();
2995 let b = rt
2996 .create_entity(&tok, "concept", None, "B", None, None, vec![])
2997 .await
2998 .unwrap();
2999 let edge = rt
3000 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
3001 .await
3002 .unwrap();
3003 let edge_id: Uuid = edge.id.into();
3004
3005 let deleted = rt.delete_edge(&tok, edge_id, true).await.unwrap();
3006 assert!(deleted);
3007
3008 let fetched = rt.get_edge(&tok, edge_id).await.unwrap();
3009 assert!(fetched.is_none(), "edge should be gone after delete");
3010 }
3011
3012 #[tokio::test]
3013 async fn count_edges_matches_filter() {
3014 let rt = rt();
3015 let tok = NamespaceToken::local();
3016 let a = rt
3017 .create_entity(&tok, "concept", None, "A", None, None, vec![])
3018 .await
3019 .unwrap();
3020 let b = rt
3021 .create_entity(&tok, "concept", None, "B", None, None, vec![])
3022 .await
3023 .unwrap();
3024 let c = rt
3025 .create_entity(&tok, "concept", None, "C", None, None, vec![])
3026 .await
3027 .unwrap();
3028
3029 rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
3030 .await
3031 .unwrap();
3032 rt.link(&tok, a.id, c.id, EdgeRelation::Enables, 1.0, None)
3033 .await
3034 .unwrap();
3035
3036 let all = rt
3037 .count_edges(&tok, EdgeListFilter::default())
3038 .await
3039 .unwrap();
3040 assert_eq!(all, 2);
3041
3042 let just_extends = rt
3043 .count_edges(
3044 &tok,
3045 EdgeListFilter {
3046 relations: vec![EdgeRelation::Extends],
3047 ..Default::default()
3048 },
3049 )
3050 .await
3051 .unwrap();
3052 assert_eq!(just_extends, 1);
3053 }
3054
3055 #[tokio::test]
3056 async fn get_entity_namespace_isolation() {
3057 let rt = rt();
3058 let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
3059 let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
3060 let entity = rt
3061 .create_entity(&ns_a, "concept", None, "Alpha", None, None, vec![])
3062 .await
3063 .unwrap();
3064
3065 let found = rt.get_entity(&ns_a, entity.id).await;
3067 assert!(found.is_ok(), "should be visible in its own namespace");
3068
3069 let not_found = rt.get_entity(&ns_b, entity.id).await;
3071 assert!(
3072 not_found.is_err(),
3073 "should not be visible across namespaces"
3074 );
3075 assert!(
3076 matches!(not_found.unwrap_err(), crate::RuntimeError::NotFound(_)),
3077 "cross-namespace get must return NotFound, not NamespaceMismatch"
3078 );
3079 }
3080
3081 #[tokio::test]
3082 async fn namespace_mismatch_error_message_is_opaque() {
3083 let rt = rt();
3086 let ns_a = NamespaceToken::for_namespace(Namespace::parse("secret-ns").unwrap());
3087 let ns_b = NamespaceToken::for_namespace(Namespace::parse("other-ns").unwrap());
3088 let entity = rt
3089 .create_entity(&ns_a, "concept", None, "Hidden", None, None, vec![])
3090 .await
3091 .unwrap();
3092
3093 let err = rt.get_entity(&ns_b, entity.id).await.unwrap_err();
3094 let msg = err.to_string();
3095 assert!(
3096 !msg.contains("secret-ns"),
3097 "error message must not leak the actual namespace; got: {msg}"
3098 );
3099 assert!(
3100 !msg.contains("other-ns"),
3101 "error message must not leak the requested namespace; got: {msg}"
3102 );
3103 }
3104
3105 #[tokio::test]
3106 async fn delete_entity_namespace_isolation() {
3107 let rt = rt();
3108 let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
3109 let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
3110 let entity = rt
3111 .create_entity(&ns_a, "concept", None, "Beta", None, None, vec![])
3112 .await
3113 .unwrap();
3114
3115 let cross_ns_result = rt.delete_entity(&ns_b, entity.id, true).await;
3117 assert!(
3118 cross_ns_result.is_err(),
3119 "cross-namespace delete must error"
3120 );
3121 assert!(
3122 matches!(
3123 cross_ns_result.unwrap_err(),
3124 crate::RuntimeError::NotFound(_)
3125 ),
3126 "cross-namespace delete must return NotFound, not NamespaceMismatch"
3127 );
3128
3129 let still_there = rt.get_entity(&ns_a, entity.id).await;
3131 assert!(
3132 still_there.is_ok(),
3133 "entity must survive cross-ns delete attempt"
3134 );
3135
3136 let deleted_ok = rt.delete_entity(&ns_a, entity.id, true).await.unwrap();
3138 assert!(deleted_ok, "same-namespace delete must succeed");
3139 }
3140
3141 #[tokio::test]
3144 async fn create_note_indexes_into_fts5() {
3145 let rt = rt();
3146 let tok = NamespaceToken::local();
3147 let note = rt
3148 .create_note(
3149 &tok,
3150 "observation",
3151 None,
3152 "FlashAttention reduces memory by using tiling",
3153 Some(0.8),
3154 None,
3155 vec![],
3156 )
3157 .await
3158 .unwrap();
3159
3160 let ns = tok.namespace().as_str().to_string();
3162 let hits = rt
3163 .text_for_notes(&tok)
3164 .unwrap()
3165 .search(khive_storage::types::TextSearchRequest {
3166 query: "FlashAttention".to_string(),
3167 mode: khive_storage::types::TextQueryMode::Plain,
3168 filter: Some(khive_storage::types::TextFilter {
3169 namespaces: vec![ns],
3170 ..Default::default()
3171 }),
3172 top_k: 10,
3173 snippet_chars: 100,
3174 })
3175 .await
3176 .unwrap();
3177
3178 assert!(
3179 hits.iter().any(|h| h.subject_id == note.id),
3180 "note should be indexed in FTS5 after create"
3181 );
3182 }
3183
3184 #[tokio::test]
3185 async fn create_note_with_properties() {
3186 let rt = rt();
3187 let tok = NamespaceToken::local();
3188 let props = serde_json::json!({"source": "arxiv:2205.14135"});
3189 let note = rt
3190 .create_note(
3191 &tok,
3192 "insight",
3193 None,
3194 "FlashAttention is IO-aware",
3195 Some(0.9),
3196 Some(props.clone()),
3197 vec![],
3198 )
3199 .await
3200 .unwrap();
3201
3202 assert_eq!(note.properties.as_ref().unwrap(), &props);
3203 }
3204
3205 #[tokio::test]
3206 async fn create_note_creates_annotates_edges() {
3207 let rt = rt();
3208 let tok = NamespaceToken::local();
3209 let entity = rt
3210 .create_entity(&tok, "concept", None, "FlashAttention", None, None, vec![])
3211 .await
3212 .unwrap();
3213
3214 let note = rt
3215 .create_note(
3216 &tok,
3217 "observation",
3218 None,
3219 "FlashAttention uses SRAM tiling for memory efficiency",
3220 Some(0.9),
3221 None,
3222 vec![entity.id],
3223 )
3224 .await
3225 .unwrap();
3226
3227 let out_neighbors = rt
3229 .neighbors(
3230 &tok,
3231 note.id,
3232 Direction::Out,
3233 None,
3234 Some(vec![EdgeRelation::Annotates]),
3235 )
3236 .await
3237 .unwrap();
3238 assert_eq!(out_neighbors.len(), 1);
3239 assert_eq!(out_neighbors[0].node_id, entity.id);
3240 assert_eq!(out_neighbors[0].relation, EdgeRelation::Annotates);
3241
3242 let in_neighbors = rt
3244 .neighbors(
3245 &tok,
3246 entity.id,
3247 Direction::In,
3248 None,
3249 Some(vec![EdgeRelation::Annotates]),
3250 )
3251 .await
3252 .unwrap();
3253 assert_eq!(in_neighbors.len(), 1);
3254 assert_eq!(in_neighbors[0].node_id, note.id);
3255 }
3256
3257 #[tokio::test]
3258 async fn neighbors_without_relation_filter_returns_all() {
3259 let rt = rt();
3260 let tok = NamespaceToken::local();
3261 let a = rt
3262 .create_entity(&tok, "concept", None, "A", None, None, vec![])
3263 .await
3264 .unwrap();
3265 let b = rt
3266 .create_entity(&tok, "concept", None, "B", None, None, vec![])
3267 .await
3268 .unwrap();
3269 let c = rt
3270 .create_entity(&tok, "concept", None, "C", None, None, vec![])
3271 .await
3272 .unwrap();
3273
3274 rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
3275 .await
3276 .unwrap();
3277 rt.link(&tok, a.id, c.id, EdgeRelation::Enables, 1.0, None)
3278 .await
3279 .unwrap();
3280
3281 let all = rt
3282 .neighbors(&tok, a.id, Direction::Out, None, None)
3283 .await
3284 .unwrap();
3285 assert_eq!(all.len(), 2);
3286 }
3287
3288 #[tokio::test]
3289 async fn neighbors_with_relation_filter_returns_subset() {
3290 let rt = rt();
3291 let tok = NamespaceToken::local();
3292 let a = rt
3293 .create_entity(&tok, "concept", None, "A", None, None, vec![])
3294 .await
3295 .unwrap();
3296 let b = rt
3297 .create_entity(&tok, "concept", None, "B", None, None, vec![])
3298 .await
3299 .unwrap();
3300 let c = rt
3301 .create_entity(&tok, "concept", None, "C", None, None, vec![])
3302 .await
3303 .unwrap();
3304
3305 rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
3306 .await
3307 .unwrap();
3308 rt.link(&tok, a.id, c.id, EdgeRelation::Enables, 1.0, None)
3309 .await
3310 .unwrap();
3311
3312 let filtered = rt
3313 .neighbors(
3314 &tok,
3315 a.id,
3316 Direction::Out,
3317 None,
3318 Some(vec![EdgeRelation::Extends]),
3319 )
3320 .await
3321 .unwrap();
3322 assert_eq!(filtered.len(), 1);
3323 assert_eq!(filtered[0].node_id, b.id);
3324 assert_eq!(filtered[0].relation, EdgeRelation::Extends);
3325 }
3326
3327 #[tokio::test]
3328 async fn search_notes_returns_relevant_note() {
3329 let rt = rt();
3330 let tok = NamespaceToken::local();
3331 rt.create_note(
3332 &tok,
3333 "observation",
3334 None,
3335 "GQA reduces KV cache memory for large models",
3336 Some(0.8),
3337 None,
3338 vec![],
3339 )
3340 .await
3341 .unwrap();
3342
3343 let results = rt
3344 .search_notes(&tok, "GQA KV cache", None, 10, None, false)
3345 .await
3346 .unwrap();
3347
3348 assert!(!results.is_empty(), "search should return the indexed note");
3349 let hit = &results[0];
3350 assert!(
3351 hit.title.is_some(),
3352 "note hit title should be populated (falls back to content)"
3353 );
3354 assert!(
3355 hit.snippet.is_some(),
3356 "note hit snippet should be populated"
3357 );
3358 }
3359
3360 #[tokio::test]
3361 async fn search_notes_excludes_soft_deleted() {
3362 let rt = rt();
3363 let tok = NamespaceToken::local();
3364 let note = rt
3365 .create_note(
3366 &tok,
3367 "observation",
3368 None,
3369 "RoPE positional encoding rotary embeddings",
3370 Some(0.7),
3371 None,
3372 vec![],
3373 )
3374 .await
3375 .unwrap();
3376
3377 rt.notes(&tok)
3379 .unwrap()
3380 .delete_note(note.id, DeleteMode::Soft)
3381 .await
3382 .unwrap();
3383
3384 let results = rt
3385 .search_notes(&tok, "RoPE rotary positional", None, 10, None, false)
3386 .await
3387 .unwrap();
3388
3389 assert!(
3390 results.iter().all(|h| h.note_id != note.id),
3391 "soft-deleted note should be excluded from search"
3392 );
3393 }
3394
3395 #[tokio::test]
3396 async fn resolve_returns_entity() {
3397 let rt = rt();
3398 let tok = NamespaceToken::local();
3399 let entity = rt
3400 .create_entity(&tok, "concept", None, "LoRA", None, None, vec![])
3401 .await
3402 .unwrap();
3403
3404 let resolved = rt.resolve(&tok, entity.id).await.unwrap();
3405 match resolved {
3406 Some(Resolved::Entity(e)) => assert_eq!(e.id, entity.id),
3407 other => panic!("expected Resolved::Entity, got {:?}", other),
3408 }
3409 }
3410
3411 #[tokio::test]
3412 async fn resolve_returns_note() {
3413 let rt = rt();
3414 let tok = NamespaceToken::local();
3415 let note = rt
3416 .create_note(
3417 &tok,
3418 "observation",
3419 None,
3420 "LoRA fine-tunes LLMs with low-rank adapters",
3421 Some(0.85),
3422 None,
3423 vec![],
3424 )
3425 .await
3426 .unwrap();
3427
3428 let resolved = rt.resolve(&tok, note.id).await.unwrap();
3429 match resolved {
3430 Some(Resolved::Note(n)) => assert_eq!(n.id, note.id),
3431 other => panic!("expected Resolved::Note, got {:?}", other),
3432 }
3433 }
3434
3435 #[tokio::test]
3436 async fn resolve_returns_none_for_unknown_uuid() {
3437 let rt = rt();
3438 let tok = NamespaceToken::local();
3439 let unknown = Uuid::new_v4();
3440 let resolved = rt.resolve(&tok, unknown).await.unwrap();
3441 assert!(resolved.is_none(), "unknown UUID should resolve to None");
3442 }
3443
3444 #[tokio::test]
3445 async fn resolve_prefix_finds_entity_in_own_namespace() {
3446 let rt = rt();
3447 let tok = NamespaceToken::local();
3448 let entity = rt
3449 .create_entity(&tok, "concept", None, "PrefixTest", None, None, vec![])
3450 .await
3451 .unwrap();
3452 let prefix = &entity.id.to_string()[..8];
3453
3454 let resolved = rt.resolve_prefix(&tok, prefix).await.unwrap();
3455 assert_eq!(resolved, Some(entity.id));
3456 }
3457
3458 #[tokio::test]
3459 async fn resolve_prefix_invisible_across_namespaces() {
3460 let rt = rt();
3461 let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
3462 let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
3463 let entity = rt
3464 .create_entity(&ns_a, "concept", None, "Invisible", None, None, vec![])
3465 .await
3466 .unwrap();
3467 let prefix = &entity.id.to_string()[..8];
3468
3469 let resolved = rt.resolve_prefix(&ns_b, prefix).await.unwrap();
3471 assert_eq!(resolved, None);
3472 }
3473
3474 #[tokio::test]
3475 async fn resolve_prefix_ambiguous_same_namespace() {
3476 use khive_storage::entity::Entity;
3477
3478 let rt = rt();
3479 let tok = NamespaceToken::local();
3480 let id_a = Uuid::parse_str("aabbccdd-1111-4000-8000-000000000001").unwrap();
3482 let id_b = Uuid::parse_str("aabbccdd-2222-4000-8000-000000000002").unwrap();
3483
3484 let mut entity_a = Entity::new("local", "concept", "AmbigA");
3485 entity_a.id = id_a;
3486 let mut entity_b = Entity::new("local", "concept", "AmbigB");
3487 entity_b.id = id_b;
3488
3489 let store = rt.entities(&tok).unwrap();
3490 store.upsert_entity(entity_a).await.unwrap();
3491 store.upsert_entity(entity_b).await.unwrap();
3492
3493 let err = rt.resolve_prefix(&tok, "aabbccdd").await.unwrap_err();
3494 assert!(
3495 matches!(
3496 err,
3497 RuntimeError::AmbiguousPrefix { ref prefix, ref matches }
3498 if prefix == "aabbccdd" && matches.len() == 2
3499 ),
3500 "shared 8-char prefix must return AmbiguousPrefix; got {err:?}"
3501 );
3502 }
3503
3504 #[tokio::test]
3511 async fn resolve_finds_event_by_full_uuid() {
3512 use khive_storage::Event;
3513 use khive_types::{EventKind, SubstrateKind};
3514
3515 let rt = rt();
3516 let tok = NamespaceToken::local();
3517 let ns = tok.namespace().as_str();
3518 let event = Event::new(
3519 ns,
3520 "test_verb",
3521 EventKind::Audit,
3522 SubstrateKind::Entity,
3523 "actor",
3524 );
3525 let event_id = event.id;
3526 rt.events(&tok).unwrap().append_event(event).await.unwrap();
3527
3528 let resolved = rt.resolve(&tok, event_id).await.unwrap();
3529 assert!(
3530 matches!(resolved, Some(Resolved::Event(_))),
3531 "event UUID must resolve to Resolved::Event, got {resolved:?}"
3532 );
3533 }
3534
3535 #[tokio::test]
3536 async fn resolve_prefix_finds_event() {
3537 use khive_storage::Event;
3538 use khive_types::{EventKind, SubstrateKind};
3539
3540 let rt = rt();
3541 let tok = NamespaceToken::local();
3542 let ns = tok.namespace().as_str();
3543 let event = Event::new(
3544 ns,
3545 "test_verb",
3546 EventKind::Audit,
3547 SubstrateKind::Entity,
3548 "actor",
3549 );
3550 let event_id = event.id;
3551 rt.events(&tok).unwrap().append_event(event).await.unwrap();
3552
3553 let prefix = &event_id.to_string()[..8];
3554 let resolved = rt.resolve_prefix(&tok, prefix).await.unwrap();
3555 assert_eq!(
3556 resolved,
3557 Some(event_id),
3558 "resolve_prefix must return event UUID for 8-char prefix"
3559 );
3560 }
3561
3562 #[tokio::test]
3565 async fn link_phantom_source_returns_not_found() {
3566 let rt = rt();
3567 let tok = NamespaceToken::local();
3568 let b = rt
3569 .create_entity(&tok, "concept", None, "B", None, None, vec![])
3570 .await
3571 .unwrap();
3572 let phantom = Uuid::new_v4();
3573
3574 let result = rt
3575 .link(&tok, phantom, b.id, EdgeRelation::Extends, 1.0, None)
3576 .await;
3577 match result {
3578 Err(RuntimeError::NotFound(msg)) => {
3579 assert!(
3580 msg.contains("source"),
3581 "error message must name 'source': {msg}"
3582 );
3583 }
3584 other => panic!("expected NotFound for phantom source, got {other:?}"),
3585 }
3586 }
3587
3588 #[tokio::test]
3589 async fn link_phantom_target_returns_not_found() {
3590 let rt = rt();
3591 let tok = NamespaceToken::local();
3592 let a = rt
3593 .create_entity(&tok, "concept", None, "A", None, None, vec![])
3594 .await
3595 .unwrap();
3596 let phantom = Uuid::new_v4();
3597
3598 let result = rt
3599 .link(&tok, a.id, phantom, EdgeRelation::Extends, 1.0, None)
3600 .await;
3601 match result {
3602 Err(RuntimeError::NotFound(msg)) => {
3603 assert!(
3604 msg.contains("target"),
3605 "error message must name 'target': {msg}"
3606 );
3607 }
3608 other => panic!("expected NotFound for phantom target, got {other:?}"),
3609 }
3610 }
3611
3612 #[tokio::test]
3613 async fn link_real_entities_succeeds() {
3614 let rt = rt();
3615 let tok = NamespaceToken::local();
3616 let a = rt
3617 .create_entity(&tok, "concept", None, "A", None, None, vec![])
3618 .await
3619 .unwrap();
3620 let b = rt
3621 .create_entity(&tok, "concept", None, "B", None, None, vec![])
3622 .await
3623 .unwrap();
3624
3625 let edge = rt
3626 .link(&tok, a.id, b.id, EdgeRelation::Extends, 0.8, None)
3627 .await
3628 .unwrap();
3629 assert_eq!(edge.source_id, a.id);
3630 assert_eq!(edge.target_id, b.id);
3631 assert_eq!(edge.relation, EdgeRelation::Extends);
3632 }
3633
3634 #[tokio::test]
3635 async fn create_note_annotates_phantom_returns_not_found() {
3636 let rt = rt();
3637 let tok = NamespaceToken::local();
3638 let phantom = Uuid::new_v4();
3639
3640 let result = rt
3641 .create_note(
3642 &tok,
3643 "observation",
3644 None,
3645 "some content",
3646 Some(0.5),
3647 None,
3648 vec![phantom],
3649 )
3650 .await;
3651 assert!(
3652 matches!(result, Err(RuntimeError::NotFound(_))),
3653 "annotates with phantom uuid must return NotFound, got {result:?}"
3654 );
3655 }
3656
3657 #[tokio::test]
3658 async fn create_note_annotates_real_entity_succeeds() {
3659 let rt = rt();
3660 let tok = NamespaceToken::local();
3661 let entity = rt
3662 .create_entity(&tok, "concept", None, "RealTarget", None, None, vec![])
3663 .await
3664 .unwrap();
3665
3666 let note = rt
3667 .create_note(
3668 &tok,
3669 "observation",
3670 None,
3671 "content",
3672 Some(0.5),
3673 None,
3674 vec![entity.id],
3675 )
3676 .await
3677 .unwrap();
3678
3679 let neighbors = rt
3680 .neighbors(
3681 &tok,
3682 note.id,
3683 Direction::Out,
3684 None,
3685 Some(vec![EdgeRelation::Annotates]),
3686 )
3687 .await
3688 .unwrap();
3689 assert_eq!(neighbors.len(), 1);
3690 assert_eq!(neighbors[0].node_id, entity.id);
3691 }
3692
3693 #[tokio::test]
3695 async fn create_note_multi_annotates_creates_all_edges() {
3696 let rt = rt();
3697 let tok = NamespaceToken::local();
3698 let t1 = rt
3699 .create_entity(&tok, "concept", None, "Target1", None, None, vec![])
3700 .await
3701 .unwrap();
3702 let t2 = rt
3703 .create_entity(&tok, "concept", None, "Target2", None, None, vec![])
3704 .await
3705 .unwrap();
3706
3707 let note = rt
3708 .create_note(
3709 &tok,
3710 "observation",
3711 None,
3712 "content",
3713 Some(0.5),
3714 None,
3715 vec![t1.id, t2.id],
3716 )
3717 .await
3718 .unwrap();
3719
3720 let neighbors = rt
3721 .neighbors(
3722 &tok,
3723 note.id,
3724 Direction::Out,
3725 None,
3726 Some(vec![EdgeRelation::Annotates]),
3727 )
3728 .await
3729 .unwrap();
3730 assert_eq!(
3731 neighbors.len(),
3732 2,
3733 "multi-annotates note must have exactly 2 outbound annotates edges"
3734 );
3735 let target_ids: Vec<Uuid> = neighbors.iter().map(|n| n.node_id).collect();
3736 assert!(target_ids.contains(&t1.id));
3737 assert!(target_ids.contains(&t2.id));
3738 }
3739
3740 #[tokio::test]
3741 async fn link_target_in_different_namespace_returns_not_found() {
3742 let rt = rt();
3743 let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
3744 let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
3745 let a = rt
3746 .create_entity(&ns_a, "concept", None, "A", None, None, vec![])
3747 .await
3748 .unwrap();
3749 let b = rt
3750 .create_entity(&ns_b, "concept", None, "B", None, None, vec![])
3751 .await
3752 .unwrap();
3753
3754 let result = rt
3756 .link(&ns_a, a.id, b.id, EdgeRelation::Extends, 1.0, None)
3757 .await;
3758 assert!(
3759 matches!(result, Err(RuntimeError::NotFound(_))),
3760 "target in different namespace must return NotFound (fail-closed), got {result:?}"
3761 );
3762 }
3763
3764 #[tokio::test]
3765 async fn link_phantom_self_loop_returns_invalid_input() {
3766 let rt = rt();
3767 let tok = NamespaceToken::local();
3768 let phantom = Uuid::new_v4();
3769
3770 let result = rt
3771 .link(&tok, phantom, phantom, EdgeRelation::Extends, 1.0, None)
3772 .await;
3773 match result {
3774 Err(RuntimeError::InvalidInput(msg)) => {
3775 assert!(
3776 msg.contains("self-loop"),
3777 "self-loop must be rejected with self-loop message: {msg}"
3778 );
3779 }
3780 other => panic!("expected InvalidInput for self-loop, got {other:?}"),
3781 }
3782 }
3783
3784 #[tokio::test]
3787 async fn link_note_to_edge_annotates_succeeds() {
3788 let rt = rt();
3789 let tok = NamespaceToken::local();
3790 let a = rt
3791 .create_entity(&tok, "concept", None, "A", None, None, vec![])
3792 .await
3793 .unwrap();
3794 let b = rt
3795 .create_entity(&tok, "concept", None, "B", None, None, vec![])
3796 .await
3797 .unwrap();
3798 let edge = rt
3800 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
3801 .await
3802 .unwrap();
3803 let edge_uuid: Uuid = edge.id.into();
3804
3805 let note = rt
3807 .create_note(
3808 &tok,
3809 "observation",
3810 None,
3811 "edge note",
3812 Some(0.5),
3813 None,
3814 vec![],
3815 )
3816 .await
3817 .unwrap();
3818
3819 let result = rt
3820 .link(&tok, note.id, edge_uuid, EdgeRelation::Annotates, 1.0, None)
3821 .await;
3822 assert!(
3823 result.is_ok(),
3824 "note→edge Annotates must succeed, got {result:?}"
3825 );
3826 }
3827
3828 #[tokio::test]
3829 async fn create_note_annotates_real_edge_succeeds() {
3830 let rt = rt();
3831 let tok = NamespaceToken::local();
3832 let a = rt
3833 .create_entity(&tok, "concept", None, "A", None, None, vec![])
3834 .await
3835 .unwrap();
3836 let b = rt
3837 .create_entity(&tok, "concept", None, "B", None, None, vec![])
3838 .await
3839 .unwrap();
3840 let edge = rt
3841 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
3842 .await
3843 .unwrap();
3844 let edge_uuid: Uuid = edge.id.into();
3845
3846 let note = rt
3847 .create_note(
3848 &tok,
3849 "observation",
3850 None,
3851 "annotating an edge",
3852 Some(0.5),
3853 None,
3854 vec![edge_uuid],
3855 )
3856 .await
3857 .unwrap();
3858
3859 let neighbors = rt
3860 .neighbors(
3861 &tok,
3862 note.id,
3863 Direction::Out,
3864 None,
3865 Some(vec![EdgeRelation::Annotates]),
3866 )
3867 .await
3868 .unwrap();
3869 assert_eq!(neighbors.len(), 1);
3870 assert_eq!(neighbors[0].node_id, edge_uuid);
3871 }
3872
3873 #[tokio::test]
3874 async fn create_note_annotates_phantom_is_atomic_no_note_persisted() {
3875 let rt = rt();
3876 let tok = NamespaceToken::local();
3877 let phantom = Uuid::new_v4();
3878
3879 let before_count = rt.list_notes(&tok, None, 1000, 0).await.unwrap().len();
3880
3881 let result = rt
3882 .create_note(
3883 &tok,
3884 "observation",
3885 None,
3886 "should not persist",
3887 Some(0.5),
3888 None,
3889 vec![phantom],
3890 )
3891 .await;
3892 assert!(
3893 matches!(result, Err(RuntimeError::NotFound(_))),
3894 "phantom annotates target must return NotFound, got {result:?}"
3895 );
3896
3897 let after_count = rt.list_notes(&tok, None, 1000, 0).await.unwrap().len();
3899 assert_eq!(
3900 before_count, after_count,
3901 "failed create_note must not persist any note row (atomicity)"
3902 );
3903
3904 let search_hits = rt
3906 .search_notes(&tok, "should not persist", None, 10, None, false)
3907 .await
3908 .unwrap();
3909 assert!(
3910 search_hits.is_empty(),
3911 "failed create_note must not index into FTS (atomicity)"
3912 );
3913 }
3916
3917 #[tokio::test]
3921 async fn link_entity_to_edge_uuid_non_annotates_returns_invalid_input() {
3922 let rt = rt();
3923 let tok = NamespaceToken::local();
3924 let a = rt
3925 .create_entity(&tok, "concept", None, "A", None, None, vec![])
3926 .await
3927 .unwrap();
3928 let b = rt
3929 .create_entity(&tok, "concept", None, "B", None, None, vec![])
3930 .await
3931 .unwrap();
3932 let edge = rt
3934 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
3935 .await
3936 .unwrap();
3937 let edge_uuid: Uuid = edge.id.into();
3938
3939 let result = rt
3940 .link(&tok, a.id, edge_uuid, EdgeRelation::Extends, 1.0, None)
3941 .await;
3942 match result {
3943 Err(RuntimeError::InvalidInput(msg)) => {
3944 assert!(
3945 msg.contains("target"),
3946 "error message must name 'target': {msg}"
3947 );
3948 }
3949 other => {
3950 panic!("expected InvalidInput for edge-uuid target with Extends, got {other:?}")
3951 }
3952 }
3953 }
3954
3955 #[tokio::test]
3957 async fn link_note_as_source_non_annotates_returns_invalid_input() {
3958 let rt = rt();
3959 let tok = NamespaceToken::local();
3960 let note = rt
3961 .create_note(&tok, "observation", None, "a note", Some(0.5), None, vec![])
3962 .await
3963 .unwrap();
3964 let entity = rt
3965 .create_entity(&tok, "concept", None, "E", None, None, vec![])
3966 .await
3967 .unwrap();
3968
3969 let result = rt
3970 .link(&tok, note.id, entity.id, EdgeRelation::DependsOn, 1.0, None)
3971 .await;
3972 match result {
3973 Err(RuntimeError::InvalidInput(msg)) => {
3974 assert!(
3975 msg.contains("source"),
3976 "error message must name 'source': {msg}"
3977 );
3978 }
3979 other => panic!("expected InvalidInput for note source with DependsOn, got {other:?}"),
3980 }
3981 }
3982
3983 #[tokio::test]
3985 async fn link_entity_as_annotates_source_returns_invalid_input() {
3986 let rt = rt();
3987 let tok = NamespaceToken::local();
3988 let a = rt
3989 .create_entity(&tok, "concept", None, "A", None, None, vec![])
3990 .await
3991 .unwrap();
3992 let b = rt
3993 .create_entity(&tok, "concept", None, "B", None, None, vec![])
3994 .await
3995 .unwrap();
3996
3997 let result = rt
3998 .link(&tok, a.id, b.id, EdgeRelation::Annotates, 1.0, None)
3999 .await;
4000 match result {
4001 Err(RuntimeError::InvalidInput(msg)) => {
4002 assert!(
4003 msg.contains("source") && msg.contains("note"),
4004 "error must say source must be a note: {msg}"
4005 );
4006 }
4007 other => {
4008 panic!("expected InvalidInput for entity source with Annotates, got {other:?}")
4009 }
4010 }
4011 }
4012
4013 #[tokio::test]
4014 async fn link_edge_as_annotates_source_returns_invalid_input() {
4015 let rt = rt();
4016 let tok = NamespaceToken::local();
4017 let a = rt
4018 .create_entity(&tok, "concept", None, "A", None, None, vec![])
4019 .await
4020 .unwrap();
4021 let b = rt
4022 .create_entity(&tok, "concept", None, "B", None, None, vec![])
4023 .await
4024 .unwrap();
4025 let edge = rt
4026 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
4027 .await
4028 .unwrap();
4029 let edge_uuid: Uuid = edge.id.into();
4030
4031 let result = rt
4033 .link(&tok, edge_uuid, a.id, EdgeRelation::Annotates, 1.0, None)
4034 .await;
4035 match result {
4036 Err(RuntimeError::InvalidInput(msg)) => {
4037 assert!(
4038 msg.contains("source") && msg.contains("note"),
4039 "edge-as-annotates-source must report wrong kind, not NotFound: {msg}"
4040 );
4041 }
4042 other => panic!("expected InvalidInput for edge source with Annotates, got {other:?}"),
4043 }
4044 }
4045
4046 #[tokio::test]
4048 async fn link_note_to_event_annotates_succeeds() {
4049 use khive_storage::Event;
4050 use khive_types::{EventKind, SubstrateKind};
4051
4052 let rt = rt();
4053 let tok = NamespaceToken::local();
4054 let note = rt
4055 .create_note(
4056 &tok,
4057 "observation",
4058 None,
4059 "observing an event",
4060 Some(0.6),
4061 None,
4062 vec![],
4063 )
4064 .await
4065 .unwrap();
4066
4067 let ns = tok.namespace().as_str();
4069 let event = Event::new(
4070 ns,
4071 "test_verb",
4072 EventKind::Audit,
4073 SubstrateKind::Entity,
4074 "test_actor",
4075 );
4076 let event_id = event.id;
4077 rt.events(&tok).unwrap().append_event(event).await.unwrap();
4078
4079 let result = rt
4080 .link(&tok, note.id, event_id, EdgeRelation::Annotates, 1.0, None)
4081 .await;
4082 assert!(
4083 result.is_ok(),
4084 "note→event Annotates must succeed, got {result:?}"
4085 );
4086 }
4087
4088 #[tokio::test]
4090 async fn create_note_annotates_event_succeeds() {
4091 use khive_storage::Event;
4092 use khive_types::{EventKind, SubstrateKind};
4093
4094 let rt = rt();
4095 let tok = NamespaceToken::local();
4096 let ns = tok.namespace().as_str();
4097 let event = Event::new(
4098 ns,
4099 "test_verb",
4100 EventKind::Audit,
4101 SubstrateKind::Entity,
4102 "test_actor",
4103 );
4104 let event_id = event.id;
4105 rt.events(&tok).unwrap().append_event(event).await.unwrap();
4106
4107 let result = rt
4108 .create_note(
4109 &tok,
4110 "observation",
4111 None,
4112 "note annotating an event",
4113 Some(0.5),
4114 None,
4115 vec![event_id],
4116 )
4117 .await;
4118 assert!(
4119 result.is_ok(),
4120 "create_note with event annotates target must succeed, got {result:?}"
4121 );
4122 let note = result.unwrap();
4124 let neighbors = rt
4125 .neighbors(
4126 &tok,
4127 note.id,
4128 Direction::Out,
4129 None,
4130 Some(vec![EdgeRelation::Annotates]),
4131 )
4132 .await
4133 .unwrap();
4134 assert_eq!(neighbors.len(), 1);
4135 assert_eq!(neighbors[0].node_id, event_id);
4136 }
4137
4138 #[tokio::test]
4142 async fn link_supersedes_note_to_note_succeeds() {
4143 let rt = rt();
4144 let tok = NamespaceToken::local();
4145 let old_note = rt
4146 .create_note(
4147 &tok,
4148 "observation",
4149 None,
4150 "old observation",
4151 Some(0.7),
4152 None,
4153 vec![],
4154 )
4155 .await
4156 .unwrap();
4157 let new_note = rt
4158 .create_note(
4159 &tok,
4160 "observation",
4161 None,
4162 "revised observation superseding the old one",
4163 Some(0.9),
4164 None,
4165 vec![],
4166 )
4167 .await
4168 .unwrap();
4169
4170 let result = rt
4171 .link(
4172 &tok,
4173 new_note.id,
4174 old_note.id,
4175 EdgeRelation::Supersedes,
4176 1.0,
4177 None,
4178 )
4179 .await;
4180 assert!(
4181 result.is_ok(),
4182 "note→note Supersedes must succeed (note supersession), got {result:?}"
4183 );
4184 }
4185
4186 #[tokio::test]
4187 async fn link_supersedes_entity_to_entity_succeeds() {
4188 let rt = rt();
4189 let tok = NamespaceToken::local();
4190 let old_entity = rt
4191 .create_entity(&tok, "concept", None, "OldConcept", None, None, vec![])
4192 .await
4193 .unwrap();
4194 let new_entity = rt
4195 .create_entity(&tok, "concept", None, "NewConcept", None, None, vec![])
4196 .await
4197 .unwrap();
4198
4199 let result = rt
4200 .link(
4201 &tok,
4202 new_entity.id,
4203 old_entity.id,
4204 EdgeRelation::Supersedes,
4205 1.0,
4206 None,
4207 )
4208 .await;
4209 assert!(
4210 result.is_ok(),
4211 "entity→entity Supersedes must succeed, got {result:?}"
4212 );
4213 }
4214
4215 #[tokio::test]
4216 async fn link_supersedes_note_to_entity_returns_invalid_input() {
4217 let rt = rt();
4218 let tok = NamespaceToken::local();
4219 let note = rt
4220 .create_note(&tok, "observation", None, "a note", Some(0.5), None, vec![])
4221 .await
4222 .unwrap();
4223 let entity = rt
4224 .create_entity(&tok, "concept", None, "SomeEntity", None, None, vec![])
4225 .await
4226 .unwrap();
4227
4228 let result = rt
4229 .link(
4230 &tok,
4231 note.id,
4232 entity.id,
4233 EdgeRelation::Supersedes,
4234 1.0,
4235 None,
4236 )
4237 .await;
4238 match result {
4239 Err(RuntimeError::InvalidInput(msg)) => {
4240 assert!(
4241 msg.contains("same substrate") || msg.contains("same-substrate"),
4242 "error must name the same-substrate rule: {msg}"
4243 );
4244 }
4245 other => panic!(
4246 "expected InvalidInput for note→entity Supersedes (cross-substrate), got {other:?}"
4247 ),
4248 }
4249 }
4250
4251 #[tokio::test]
4252 async fn link_supersedes_entity_to_note_returns_invalid_input() {
4253 let rt = rt();
4254 let tok = NamespaceToken::local();
4255 let entity = rt
4256 .create_entity(&tok, "concept", None, "SomeEntity", None, None, vec![])
4257 .await
4258 .unwrap();
4259 let note = rt
4260 .create_note(&tok, "observation", None, "a note", Some(0.5), None, vec![])
4261 .await
4262 .unwrap();
4263
4264 let result = rt
4265 .link(
4266 &tok,
4267 entity.id,
4268 note.id,
4269 EdgeRelation::Supersedes,
4270 1.0,
4271 None,
4272 )
4273 .await;
4274 match result {
4275 Err(RuntimeError::InvalidInput(msg)) => {
4276 assert!(
4277 msg.contains("same substrate") || msg.contains("same-substrate"),
4278 "error must name the same-substrate rule: {msg}"
4279 );
4280 }
4281 other => panic!(
4282 "expected InvalidInput for entity→note Supersedes (cross-substrate), got {other:?}"
4283 ),
4284 }
4285 }
4286
4287 #[tokio::test]
4288 async fn link_supersedes_event_source_returns_invalid_input() {
4289 use khive_storage::Event;
4290 use khive_types::{EventKind, SubstrateKind};
4291
4292 let rt = rt();
4293 let tok = NamespaceToken::local();
4294 let ns = tok.namespace().as_str();
4295 let event = Event::new(
4296 ns,
4297 "test_verb",
4298 EventKind::Audit,
4299 SubstrateKind::Entity,
4300 "test_actor",
4301 );
4302 let event_id = event.id;
4303 rt.events(&tok).unwrap().append_event(event).await.unwrap();
4304
4305 let entity = rt
4306 .create_entity(&tok, "concept", None, "SomeEntity", None, None, vec![])
4307 .await
4308 .unwrap();
4309
4310 let result = rt
4311 .link(
4312 &tok,
4313 event_id,
4314 entity.id,
4315 EdgeRelation::Supersedes,
4316 1.0,
4317 None,
4318 )
4319 .await;
4320 match result {
4321 Err(RuntimeError::InvalidInput(msg)) => {
4322 assert!(msg.contains("event"), "error must mention 'event': {msg}");
4323 }
4324 other => {
4325 panic!("expected InvalidInput for event source with Supersedes, got {other:?}")
4326 }
4327 }
4328 }
4329
4330 #[tokio::test]
4331 async fn link_supersedes_event_target_returns_invalid_input() {
4332 use khive_storage::Event;
4333 use khive_types::{EventKind, SubstrateKind};
4334
4335 let rt = rt();
4336 let tok = NamespaceToken::local();
4337 let ns = tok.namespace().as_str();
4338 let event = Event::new(
4339 ns,
4340 "test_verb",
4341 EventKind::Audit,
4342 SubstrateKind::Entity,
4343 "test_actor",
4344 );
4345 let event_id = event.id;
4346 rt.events(&tok).unwrap().append_event(event).await.unwrap();
4347
4348 let entity = rt
4349 .create_entity(&tok, "concept", None, "SomeEntity", None, None, vec![])
4350 .await
4351 .unwrap();
4352
4353 let result = rt
4354 .link(
4355 &tok,
4356 entity.id,
4357 event_id,
4358 EdgeRelation::Supersedes,
4359 1.0,
4360 None,
4361 )
4362 .await;
4363 match result {
4364 Err(RuntimeError::InvalidInput(msg)) => {
4365 assert!(msg.contains("event"), "error must mention 'event': {msg}");
4366 }
4367 other => {
4368 panic!("expected InvalidInput for event target with Supersedes, got {other:?}")
4369 }
4370 }
4371 }
4372
4373 #[tokio::test]
4374 async fn link_supersedes_edge_source_returns_invalid_input() {
4375 let rt = rt();
4376 let tok = NamespaceToken::local();
4377 let a = rt
4378 .create_entity(&tok, "concept", None, "A", None, None, vec![])
4379 .await
4380 .unwrap();
4381 let b = rt
4382 .create_entity(&tok, "concept", None, "B", None, None, vec![])
4383 .await
4384 .unwrap();
4385 let edge = rt
4386 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
4387 .await
4388 .unwrap();
4389 let edge_uuid: Uuid = edge.id.into();
4390
4391 let result = rt
4392 .link(&tok, edge_uuid, a.id, EdgeRelation::Supersedes, 1.0, None)
4393 .await;
4394 match result {
4395 Err(RuntimeError::InvalidInput(msg)) => {
4396 assert!(msg.contains("source"), "error must name 'source': {msg}");
4397 }
4398 other => {
4399 panic!("expected InvalidInput for edge-uuid source with Supersedes, got {other:?}")
4400 }
4401 }
4402 }
4403
4404 #[tokio::test]
4405 async fn link_supersedes_edge_target_returns_invalid_input() {
4406 let rt = rt();
4407 let tok = NamespaceToken::local();
4408 let a = rt
4409 .create_entity(&tok, "concept", None, "A", None, None, vec![])
4410 .await
4411 .unwrap();
4412 let b = rt
4413 .create_entity(&tok, "concept", None, "B", None, None, vec![])
4414 .await
4415 .unwrap();
4416 let edge = rt
4417 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
4418 .await
4419 .unwrap();
4420 let edge_uuid: Uuid = edge.id.into();
4421
4422 let result = rt
4423 .link(&tok, a.id, edge_uuid, EdgeRelation::Supersedes, 1.0, None)
4424 .await;
4425 match result {
4426 Err(RuntimeError::InvalidInput(msg)) => {
4427 assert!(msg.contains("target"), "error must name 'target': {msg}");
4428 }
4429 other => {
4430 panic!("expected InvalidInput for edge-uuid target with Supersedes, got {other:?}")
4431 }
4432 }
4433 }
4434
4435 #[tokio::test]
4436 async fn link_supersedes_phantom_source_returns_not_found() {
4437 let rt = rt();
4438 let tok = NamespaceToken::local();
4439 let note = rt
4440 .create_note(
4441 &tok,
4442 "observation",
4443 None,
4444 "existing note",
4445 Some(0.5),
4446 None,
4447 vec![],
4448 )
4449 .await
4450 .unwrap();
4451 let phantom = Uuid::new_v4();
4452
4453 let result = rt
4454 .link(&tok, phantom, note.id, EdgeRelation::Supersedes, 1.0, None)
4455 .await;
4456 match result {
4457 Err(RuntimeError::NotFound(msg)) => {
4458 assert!(msg.contains("source"), "error must name 'source': {msg}");
4459 }
4460 other => panic!("expected NotFound for phantom source with Supersedes, got {other:?}"),
4461 }
4462 }
4463
4464 #[tokio::test]
4465 async fn link_supersedes_phantom_target_returns_not_found() {
4466 let rt = rt();
4467 let tok = NamespaceToken::local();
4468 let note = rt
4469 .create_note(
4470 &tok,
4471 "observation",
4472 None,
4473 "existing note",
4474 Some(0.5),
4475 None,
4476 vec![],
4477 )
4478 .await
4479 .unwrap();
4480 let phantom = Uuid::new_v4();
4481
4482 let result = rt
4483 .link(&tok, note.id, phantom, EdgeRelation::Supersedes, 1.0, None)
4484 .await;
4485 match result {
4486 Err(RuntimeError::NotFound(msg)) => {
4487 assert!(msg.contains("target"), "error must name 'target': {msg}");
4488 }
4489 other => panic!("expected NotFound for phantom target with Supersedes, got {other:?}"),
4490 }
4491 }
4492
4493 #[tokio::test]
4494 async fn link_supersedes_cross_namespace_source_returns_not_found() {
4495 let rt = rt();
4496 let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
4497 let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
4498 let note_a = rt
4499 .create_note(
4500 &ns_a,
4501 "observation",
4502 None,
4503 "note in ns-a",
4504 Some(0.5),
4505 None,
4506 vec![],
4507 )
4508 .await
4509 .unwrap();
4510 let note_b = rt
4511 .create_note(
4512 &ns_b,
4513 "observation",
4514 None,
4515 "note in ns-b",
4516 Some(0.5),
4517 None,
4518 vec![],
4519 )
4520 .await
4521 .unwrap();
4522
4523 let result = rt
4525 .link(
4526 &ns_a,
4527 note_b.id,
4528 note_a.id,
4529 EdgeRelation::Supersedes,
4530 1.0,
4531 None,
4532 )
4533 .await;
4534 assert!(
4535 matches!(result, Err(RuntimeError::NotFound(_))),
4536 "cross-namespace source with Supersedes must return NotFound (fail-closed), got {result:?}"
4537 );
4538 }
4539
4540 #[tokio::test]
4542 async fn link_extends_note_source_still_returns_invalid_input() {
4543 let rt = rt();
4544 let tok = NamespaceToken::local();
4545 let note = rt
4546 .create_note(
4547 &tok,
4548 "observation",
4549 None,
4550 "a note that cannot be an extends source",
4551 Some(0.5),
4552 None,
4553 vec![],
4554 )
4555 .await
4556 .unwrap();
4557 let entity = rt
4558 .create_entity(&tok, "concept", None, "E", None, None, vec![])
4559 .await
4560 .unwrap();
4561
4562 let result = rt
4563 .link(&tok, note.id, entity.id, EdgeRelation::Extends, 1.0, None)
4564 .await;
4565 assert!(
4566 matches!(result, Err(RuntimeError::InvalidInput(_))),
4567 "note source with Extends must still return InvalidInput after this fix, got {result:?}"
4568 );
4569 }
4570
4571 #[tokio::test]
4573 async fn link_annotates_note_to_edge_still_succeeds_after_fix() {
4574 let rt = rt();
4575 let tok = NamespaceToken::local();
4576 let a = rt
4577 .create_entity(&tok, "concept", None, "A", None, None, vec![])
4578 .await
4579 .unwrap();
4580 let b = rt
4581 .create_entity(&tok, "concept", None, "B", None, None, vec![])
4582 .await
4583 .unwrap();
4584 let edge = rt
4585 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
4586 .await
4587 .unwrap();
4588 let edge_uuid: Uuid = edge.id.into();
4589
4590 let note = rt
4591 .create_note(
4592 &tok,
4593 "observation",
4594 None,
4595 "annotating an edge",
4596 Some(0.5),
4597 None,
4598 vec![],
4599 )
4600 .await
4601 .unwrap();
4602
4603 let result = rt
4604 .link(&tok, note.id, edge_uuid, EdgeRelation::Annotates, 1.0, None)
4605 .await;
4606 assert!(
4607 result.is_ok(),
4608 "note→edge Annotates must still succeed after supersedes fix, got {result:?}"
4609 );
4610 }
4611
4612 #[tokio::test]
4626 async fn create_note_multi_annotates_compensation_cleanup_restores_pristine_state() {
4627 let rt = rt();
4628 let tok = NamespaceToken::local();
4629 let t1 = rt
4630 .create_entity(&tok, "concept", None, "T1", None, None, vec![])
4631 .await
4632 .unwrap();
4633
4634 let note = rt
4637 .create_note(
4638 &tok,
4639 "observation",
4640 None,
4641 "partial note",
4642 Some(0.5),
4643 None,
4644 vec![t1.id],
4645 )
4646 .await
4647 .unwrap();
4648
4649 let before_notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
4651 assert_eq!(before_notes.len(), 1, "note must be present before cleanup");
4652 let before_edges = rt
4653 .neighbors(
4654 &tok,
4655 note.id,
4656 Direction::Out,
4657 None,
4658 Some(vec![EdgeRelation::Annotates]),
4659 )
4660 .await
4661 .unwrap();
4662 assert_eq!(
4663 before_edges.len(),
4664 1,
4665 "one annotates edge must exist before cleanup"
4666 );
4667 let edge_id: Uuid = before_edges[0].edge_id;
4668
4669 rt.delete_edge(&tok, edge_id, true).await.unwrap();
4671 rt.delete_note(&tok, note.id, true )
4672 .await
4673 .unwrap();
4674
4675 let after_notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
4677 assert!(
4678 after_notes.is_empty(),
4679 "compensation must remove the note row; got {after_notes:?}"
4680 );
4681 let search_hits = rt
4682 .search_notes(&tok, "partial note", None, 10, None, false)
4683 .await
4684 .unwrap();
4685 assert!(
4686 search_hits.is_empty(),
4687 "compensation must clean the FTS index; got {search_hits:?}"
4688 );
4689 let after_edges = rt
4690 .neighbors(&tok, note.id, Direction::Out, None, None)
4691 .await
4692 .unwrap();
4693 assert!(
4694 after_edges.is_empty(),
4695 "compensation must remove all partial edges; got {after_edges:?}"
4696 );
4697 }
4698
4699 #[tokio::test]
4707 async fn annotated_entity_hard_delete_cascades_annotate_edge() {
4708 let rt = rt();
4709 let tok = NamespaceToken::local();
4710 let entity = rt
4711 .create_entity(&tok, "concept", None, "E", None, None, vec![])
4712 .await
4713 .unwrap();
4714 let note = rt
4715 .create_note(
4716 &tok,
4717 "observation",
4718 None,
4719 "note about entity",
4720 Some(0.5),
4721 None,
4722 vec![entity.id],
4723 )
4724 .await
4725 .unwrap();
4726
4727 let before = rt
4729 .neighbors(
4730 &tok,
4731 note.id,
4732 Direction::Out,
4733 None,
4734 Some(vec![EdgeRelation::Annotates]),
4735 )
4736 .await
4737 .unwrap();
4738 assert_eq!(
4739 before.len(),
4740 1,
4741 "annotates edge must exist before entity delete"
4742 );
4743
4744 let deleted = rt.delete_entity(&tok, entity.id, true).await.unwrap();
4746 assert!(deleted, "entity hard delete must return true");
4747
4748 let after = rt
4750 .neighbors(
4751 &tok,
4752 note.id,
4753 Direction::Out,
4754 None,
4755 Some(vec![EdgeRelation::Annotates]),
4756 )
4757 .await
4758 .unwrap();
4759 assert!(
4760 after.is_empty(),
4761 "annotates edge must be cascaded on entity hard delete; got {after:?}"
4762 );
4763 }
4764
4765 #[tokio::test]
4766 async fn annotated_note_hard_delete_cascades_annotate_edge() {
4767 let rt = rt();
4768 let tok = NamespaceToken::local();
4769 let note_target = rt
4771 .create_note(
4772 &tok,
4773 "observation",
4774 None,
4775 "target note",
4776 Some(0.5),
4777 None,
4778 vec![],
4779 )
4780 .await
4781 .unwrap();
4782 let note_source = rt
4784 .create_note(
4785 &tok,
4786 "insight",
4787 None,
4788 "annotation",
4789 Some(0.5),
4790 None,
4791 vec![note_target.id],
4792 )
4793 .await
4794 .unwrap();
4795
4796 let before = rt
4797 .neighbors(
4798 &tok,
4799 note_source.id,
4800 Direction::Out,
4801 None,
4802 Some(vec![EdgeRelation::Annotates]),
4803 )
4804 .await
4805 .unwrap();
4806 assert_eq!(
4807 before.len(),
4808 1,
4809 "annotates edge must exist before note delete"
4810 );
4811
4812 let deleted = rt.delete_note(&tok, note_target.id, true).await.unwrap();
4814 assert!(deleted, "note hard delete must return true");
4815
4816 let after = rt
4818 .neighbors(
4819 &tok,
4820 note_source.id,
4821 Direction::Out,
4822 None,
4823 Some(vec![EdgeRelation::Annotates]),
4824 )
4825 .await
4826 .unwrap();
4827 assert!(
4828 after.is_empty(),
4829 "annotates edge must be cascaded on note-target hard delete; got {after:?}"
4830 );
4831 }
4832
4833 #[tokio::test]
4834 async fn annotated_edge_delete_cascades_annotate_edge() {
4835 let rt = rt();
4836 let tok = NamespaceToken::local();
4837 let a = rt
4838 .create_entity(&tok, "concept", None, "A", None, None, vec![])
4839 .await
4840 .unwrap();
4841 let b = rt
4842 .create_entity(&tok, "concept", None, "B", None, None, vec![])
4843 .await
4844 .unwrap();
4845 let base_edge = rt
4847 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
4848 .await
4849 .unwrap();
4850 let base_edge_uuid: Uuid = base_edge.id.into();
4851
4852 let note = rt
4854 .create_note(
4855 &tok,
4856 "observation",
4857 None,
4858 "note about edge",
4859 Some(0.5),
4860 None,
4861 vec![base_edge_uuid],
4862 )
4863 .await
4864 .unwrap();
4865
4866 let before = rt
4867 .neighbors(
4868 &tok,
4869 note.id,
4870 Direction::Out,
4871 None,
4872 Some(vec![EdgeRelation::Annotates]),
4873 )
4874 .await
4875 .unwrap();
4876 assert_eq!(
4877 before.len(),
4878 1,
4879 "annotates edge must exist before base edge delete"
4880 );
4881
4882 let deleted = rt.delete_edge(&tok, base_edge_uuid, true).await.unwrap();
4884 assert!(deleted, "edge delete must return true");
4885
4886 let after = rt
4888 .neighbors(
4889 &tok,
4890 note.id,
4891 Direction::Out,
4892 None,
4893 Some(vec![EdgeRelation::Annotates]),
4894 )
4895 .await
4896 .unwrap();
4897 assert!(
4898 after.is_empty(),
4899 "annotates edge must be cascaded on base edge delete; got {after:?}"
4900 );
4901 }
4902
4903 #[tokio::test]
4904 async fn mixed_multi_annotates_partial_target_hard_delete_leaves_remaining_edges() {
4905 let rt = rt();
4906 let tok = NamespaceToken::local();
4907 let t1 = rt
4908 .create_entity(&tok, "concept", None, "T1", None, None, vec![])
4909 .await
4910 .unwrap();
4911 let t2 = rt
4912 .create_entity(&tok, "concept", None, "T2", None, None, vec![])
4913 .await
4914 .unwrap();
4915
4916 let note = rt
4918 .create_note(
4919 &tok,
4920 "observation",
4921 None,
4922 "multi-target note",
4923 Some(0.5),
4924 None,
4925 vec![t1.id, t2.id],
4926 )
4927 .await
4928 .unwrap();
4929
4930 let before = rt
4931 .neighbors(
4932 &tok,
4933 note.id,
4934 Direction::Out,
4935 None,
4936 Some(vec![EdgeRelation::Annotates]),
4937 )
4938 .await
4939 .unwrap();
4940 assert_eq!(
4941 before.len(),
4942 2,
4943 "must have 2 annotates edges before any delete"
4944 );
4945
4946 rt.delete_entity(&tok, t1.id, true).await.unwrap();
4948
4949 let after = rt
4951 .neighbors(
4952 &tok,
4953 note.id,
4954 Direction::Out,
4955 None,
4956 Some(vec![EdgeRelation::Annotates]),
4957 )
4958 .await
4959 .unwrap();
4960 assert_eq!(
4961 after.len(),
4962 1,
4963 "only the edge to t1 must be cascaded; t2 edge must remain"
4964 );
4965 assert_eq!(
4966 after[0].node_id, t2.id,
4967 "remaining annotates edge must point to t2"
4968 );
4969 }
4970
4971 #[tokio::test]
4972 async fn annotated_note_soft_delete_preserves_annotate_edge() {
4973 let rt = rt();
4974 let tok = NamespaceToken::local();
4975 let note_target = rt
4976 .create_note(&tok, "observation", None, "target", Some(0.5), None, vec![])
4977 .await
4978 .unwrap();
4979 let note_source = rt
4980 .create_note(
4981 &tok,
4982 "insight",
4983 None,
4984 "annotation",
4985 Some(0.5),
4986 None,
4987 vec![note_target.id],
4988 )
4989 .await
4990 .unwrap();
4991
4992 let before = rt
4993 .neighbors(
4994 &tok,
4995 note_source.id,
4996 Direction::Out,
4997 None,
4998 Some(vec![EdgeRelation::Annotates]),
4999 )
5000 .await
5001 .unwrap();
5002 assert_eq!(before.len(), 1);
5003
5004 let deleted = rt.delete_note(&tok, note_target.id, false).await.unwrap();
5006 assert!(deleted, "soft delete must return true");
5007
5008 let after = rt
5009 .neighbors(
5010 &tok,
5011 note_source.id,
5012 Direction::Out,
5013 None,
5014 Some(vec![EdgeRelation::Annotates]),
5015 )
5016 .await
5017 .unwrap();
5018 assert_eq!(
5019 after.len(),
5020 1,
5021 "soft delete must NOT cascade edges; got {after:?}"
5022 );
5023 }
5024
5025 #[tokio::test]
5032 async fn delete_edge_non_edge_uuid_has_no_side_effects() {
5033 let rt = rt();
5034 let tok = NamespaceToken::local();
5035
5036 let entity = rt
5038 .create_entity(&tok, "concept", None, "Target", None, None, vec![])
5039 .await
5040 .unwrap();
5041 let note = rt
5042 .create_note(
5043 &tok,
5044 "observation",
5045 None,
5046 "annotates the entity",
5047 Some(0.5),
5048 None,
5049 vec![entity.id],
5050 )
5051 .await
5052 .unwrap();
5053
5054 let before = rt
5056 .neighbors(
5057 &tok,
5058 note.id,
5059 Direction::Out,
5060 None,
5061 Some(vec![EdgeRelation::Annotates]),
5062 )
5063 .await
5064 .unwrap();
5065 assert_eq!(before.len(), 1, "annotates edge must exist before test");
5066 let annotates_edge_id: Uuid = before[0].edge_id;
5067
5068 let result = rt.delete_edge(&tok, entity.id, true).await;
5070 assert!(
5071 result.is_ok(),
5072 "delete_edge must not error on a non-edge UUID"
5073 );
5074 assert!(
5075 !result.unwrap(),
5076 "delete_edge must return false for a non-edge UUID"
5077 );
5078
5079 let after = rt
5081 .neighbors(
5082 &tok,
5083 note.id,
5084 Direction::Out,
5085 None,
5086 Some(vec![EdgeRelation::Annotates]),
5087 )
5088 .await
5089 .unwrap();
5090 assert_eq!(
5091 after.len(),
5092 1,
5093 "delete_edge with a non-edge UUID must not touch inbound annotates edges"
5094 );
5095 assert_eq!(
5096 after[0].edge_id, annotates_edge_id,
5097 "the original annotates edge must be unchanged"
5098 );
5099 }
5100
5101 #[tokio::test]
5112 async fn create_note_multi_annotates_second_link_failure_rolls_back_partial_write() {
5113 let rt = rt();
5114 let tok = NamespaceToken::local();
5115 let t1 = rt
5116 .create_entity(&tok, "concept", None, "T1", None, None, vec![])
5117 .await
5118 .unwrap();
5119 let t2 = rt
5120 .create_entity(&tok, "concept", None, "T2", None, None, vec![])
5121 .await
5122 .unwrap();
5123
5124 LINK_FAIL_AFTER.with(|cell| cell.set(2));
5126
5127 let result = rt
5128 .create_note(
5129 &tok,
5130 "observation",
5131 None,
5132 "rollback target",
5133 Some(0.5),
5134 None,
5135 vec![t1.id, t2.id],
5136 )
5137 .await;
5138
5139 assert!(
5141 result.is_err(),
5142 "create_note must propagate the injected link failure"
5143 );
5144 let err_msg = result.unwrap_err().to_string();
5145 assert!(
5146 err_msg.contains("injected link failure"),
5147 "error must carry injection message; got: {err_msg}"
5148 );
5149
5150 let notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
5152 assert!(
5153 notes.is_empty(),
5154 "compensation must remove the note row; got {notes:?}"
5155 );
5156
5157 let hits = rt
5159 .search_notes(&tok, "rollback target", None, 10, None, false)
5160 .await
5161 .unwrap();
5162 assert!(
5163 hits.is_empty(),
5164 "compensation must clean FTS index; got {hits:?}"
5165 );
5166
5167 let edges_from_t1 = rt
5169 .neighbors(
5170 &tok,
5171 t1.id,
5172 Direction::In,
5173 None,
5174 Some(vec![EdgeRelation::Annotates]),
5175 )
5176 .await
5177 .unwrap();
5178 let edges_from_t2 = rt
5179 .neighbors(
5180 &tok,
5181 t2.id,
5182 Direction::In,
5183 None,
5184 Some(vec![EdgeRelation::Annotates]),
5185 )
5186 .await
5187 .unwrap();
5188 assert!(
5189 edges_from_t1.is_empty(),
5190 "compensation must delete the first annotates edge; got {edges_from_t1:?}"
5191 );
5192 assert!(
5193 edges_from_t2.is_empty(),
5194 "no second annotates edge must exist; got {edges_from_t2:?}"
5195 );
5196 }
5197
5198 #[tokio::test]
5201 async fn soft_delete_entity_removes_indexes() {
5202 let rt = rt();
5203 let tok = NamespaceToken::local();
5204 let entity = rt
5205 .create_entity(
5206 &tok,
5207 "concept",
5208 None,
5209 "QuantumEntanglement",
5210 Some("unique FTS term xzqjwv for soft delete test"),
5211 None,
5212 vec![],
5213 )
5214 .await
5215 .unwrap();
5216
5217 let ns = tok.namespace().as_str().to_string();
5218
5219 let before = rt
5220 .text(&tok)
5221 .unwrap()
5222 .search(TextSearchRequest {
5223 query: "xzqjwv".to_string(),
5224 mode: TextQueryMode::Plain,
5225 filter: Some(TextFilter {
5226 namespaces: vec![ns.clone()],
5227 ..Default::default()
5228 }),
5229 top_k: 10,
5230 snippet_chars: 100,
5231 })
5232 .await
5233 .unwrap();
5234 assert!(
5235 before.iter().any(|h| h.subject_id == entity.id),
5236 "entity must be in FTS before soft-delete"
5237 );
5238
5239 let deleted = rt.delete_entity(&tok, entity.id, false).await.unwrap();
5240 assert!(deleted, "soft delete must return true");
5241
5242 let after = rt
5243 .text(&tok)
5244 .unwrap()
5245 .search(TextSearchRequest {
5246 query: "xzqjwv".to_string(),
5247 mode: TextQueryMode::Plain,
5248 filter: Some(TextFilter {
5249 namespaces: vec![ns],
5250 ..Default::default()
5251 }),
5252 top_k: 10,
5253 snippet_chars: 100,
5254 })
5255 .await
5256 .unwrap();
5257 assert!(
5258 after.iter().all(|h| h.subject_id != entity.id),
5259 "soft-deleted entity must be removed from FTS index"
5260 );
5261 }
5262
5263 #[tokio::test]
5264 async fn soft_delete_note_removes_indexes() {
5265 let rt = rt();
5266 let tok = NamespaceToken::local();
5267 let note = rt
5268 .create_note(
5269 &tok,
5270 "observation",
5271 None,
5272 "SpectralDecomposition unique term yvwkqz for soft delete test",
5273 Some(0.7),
5274 None,
5275 vec![],
5276 )
5277 .await
5278 .unwrap();
5279
5280 let before = rt
5281 .search_notes(&tok, "yvwkqz", None, 10, None, false)
5282 .await
5283 .unwrap();
5284 assert!(
5285 before.iter().any(|h| h.note_id == note.id),
5286 "note must be in FTS before soft-delete"
5287 );
5288
5289 let deleted = rt.delete_note(&tok, note.id, false).await.unwrap();
5290 assert!(deleted, "soft delete must return true");
5291
5292 let after = rt
5293 .search_notes(&tok, "yvwkqz", None, 10, None, false)
5294 .await
5295 .unwrap();
5296 assert!(
5297 after.iter().all(|h| h.note_id != note.id),
5298 "soft-deleted note must be removed from FTS index"
5299 );
5300 }
5301
5302 #[tokio::test]
5305 async fn link_extends_document_to_document_returns_invalid_input() {
5306 let rt = rt();
5307 let tok = NamespaceToken::local();
5308 let d1 = rt
5309 .create_entity(&tok, "document", None, "DocA", None, None, vec![])
5310 .await
5311 .unwrap();
5312 let d2 = rt
5313 .create_entity(&tok, "document", None, "DocB", None, None, vec![])
5314 .await
5315 .unwrap();
5316 let result = rt
5317 .link(&tok, d1.id, d2.id, EdgeRelation::Extends, 1.0, None)
5318 .await;
5319 assert!(
5320 result.is_err(),
5321 "F010: document->document Extends must be rejected by the base allowlist; \
5322 current generic entity fallthrough incorrectly accepts it"
5323 );
5324 }
5325
5326 #[tokio::test]
5328 async fn link_extends_concept_to_concept_succeeds() {
5329 let rt = rt();
5330 let tok = NamespaceToken::local();
5331 let a = rt
5332 .create_entity(&tok, "concept", None, "CA", None, None, vec![])
5333 .await
5334 .unwrap();
5335 let b = rt
5336 .create_entity(&tok, "concept", None, "CB", None, None, vec![])
5337 .await
5338 .unwrap();
5339 let result = rt
5340 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5341 .await;
5342 assert!(
5343 result.is_ok(),
5344 "F010: concept->concept Extends must be allowed (base allowlist)"
5345 );
5346 }
5347
5348 #[tokio::test]
5351 async fn link_symmetric_relation_canonicalizes_endpoint_order() {
5352 use khive_storage::EdgeFilter;
5353 let rt = rt();
5354 let tok = NamespaceToken::local();
5355 let a = rt
5356 .create_entity(&tok, "concept", None, "ConceptP", None, None, vec![])
5357 .await
5358 .unwrap();
5359 let b = rt
5360 .create_entity(&tok, "concept", None, "ConceptQ", None, None, vec![])
5361 .await
5362 .unwrap();
5363 rt.link(&tok, a.id, b.id, EdgeRelation::CompetesWith, 1.0, None)
5365 .await
5366 .unwrap();
5367 rt.link(&tok, b.id, a.id, EdgeRelation::CompetesWith, 1.0, None)
5368 .await
5369 .unwrap();
5370 let count = rt
5371 .graph(&tok)
5372 .unwrap()
5373 .count_edges(EdgeFilter::default())
5374 .await
5375 .unwrap();
5376 assert_eq!(
5377 count,
5378 1,
5379 "F012: CompetesWith is symmetric; A->B and B->A must deduplicate to one canonical row; \
5380 found {count} rows (canonicalization not yet implemented)"
5381 );
5382 }
5383
5384 #[tokio::test]
5386 async fn f010_supersedes_document_to_document_allowed() {
5387 let rt = rt();
5388 let tok = NamespaceToken::local();
5389 let a = rt
5390 .create_entity(&tok, "document", None, "DocA", None, None, vec![])
5391 .await
5392 .unwrap();
5393 let b = rt
5394 .create_entity(&tok, "document", None, "DocB", None, None, vec![])
5395 .await
5396 .unwrap();
5397 let result = rt
5398 .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
5399 .await;
5400 assert!(
5401 result.is_ok(),
5402 "document->document Supersedes must be allowed (allowlist), got {result:?}"
5403 );
5404 }
5405
5406 #[tokio::test]
5407 async fn f010_supersedes_artifact_to_artifact_allowed() {
5408 let rt = rt();
5409 let tok = NamespaceToken::local();
5410 let a = rt
5411 .create_entity(&tok, "artifact", None, "ArtA", None, None, vec![])
5412 .await
5413 .unwrap();
5414 let b = rt
5415 .create_entity(&tok, "artifact", None, "ArtB", None, None, vec![])
5416 .await
5417 .unwrap();
5418 let result = rt
5419 .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
5420 .await;
5421 assert!(
5422 result.is_ok(),
5423 "artifact->artifact Supersedes must be allowed (allowlist), got {result:?}"
5424 );
5425 }
5426
5427 #[tokio::test]
5428 async fn f010_supersedes_service_to_service_allowed() {
5429 let rt = rt();
5430 let tok = NamespaceToken::local();
5431 let a = rt
5432 .create_entity(&tok, "service", None, "SvcA", None, None, vec![])
5433 .await
5434 .unwrap();
5435 let b = rt
5436 .create_entity(&tok, "service", None, "SvcB", None, None, vec![])
5437 .await
5438 .unwrap();
5439 let result = rt
5440 .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
5441 .await;
5442 assert!(
5443 result.is_ok(),
5444 "service->service Supersedes must be allowed (allowlist), got {result:?}"
5445 );
5446 }
5447
5448 #[tokio::test]
5449 async fn f010_supersedes_dataset_to_dataset_allowed() {
5450 let rt = rt();
5451 let tok = NamespaceToken::local();
5452 let a = rt
5453 .create_entity(&tok, "dataset", None, "DataA", None, None, vec![])
5454 .await
5455 .unwrap();
5456 let b = rt
5457 .create_entity(&tok, "dataset", None, "DataB", None, None, vec![])
5458 .await
5459 .unwrap();
5460 let result = rt
5461 .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
5462 .await;
5463 assert!(
5464 result.is_ok(),
5465 "dataset->dataset Supersedes must be allowed (allowlist), got {result:?}"
5466 );
5467 }
5468
5469 #[tokio::test]
5471 async fn f010_supersedes_project_to_project_rejected() {
5472 let rt = rt();
5473 let tok = NamespaceToken::local();
5474 let a = rt
5475 .create_entity(&tok, "project", None, "ProjA", None, None, vec![])
5476 .await
5477 .unwrap();
5478 let b = rt
5479 .create_entity(&tok, "project", None, "ProjB", None, None, vec![])
5480 .await
5481 .unwrap();
5482 let result = rt
5483 .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
5484 .await;
5485 assert!(
5486 matches!(result, Err(RuntimeError::InvalidInput(_))),
5487 "project->project Supersedes must be rejected (not in allowlist), got {result:?}"
5488 );
5489 }
5490
5491 #[tokio::test]
5492 async fn f010_supersedes_person_to_person_rejected() {
5493 let rt = rt();
5494 let tok = NamespaceToken::local();
5495 let a = rt
5496 .create_entity(&tok, "person", None, "Alice", None, None, vec![])
5497 .await
5498 .unwrap();
5499 let b = rt
5500 .create_entity(&tok, "person", None, "Bob", None, None, vec![])
5501 .await
5502 .unwrap();
5503 let result = rt
5504 .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
5505 .await;
5506 assert!(
5507 matches!(result, Err(RuntimeError::InvalidInput(_))),
5508 "person->person Supersedes must be rejected (not in allowlist), got {result:?}"
5509 );
5510 }
5511
5512 #[tokio::test]
5513 async fn f010_supersedes_org_to_org_rejected() {
5514 let rt = rt();
5515 let tok = NamespaceToken::local();
5516 let a = rt
5517 .create_entity(&tok, "org", None, "OrgA", None, None, vec![])
5518 .await
5519 .unwrap();
5520 let b = rt
5521 .create_entity(&tok, "org", None, "OrgB", None, None, vec![])
5522 .await
5523 .unwrap();
5524 let result = rt
5525 .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
5526 .await;
5527 assert!(
5528 matches!(result, Err(RuntimeError::InvalidInput(_))),
5529 "org->org Supersedes must be rejected (not in allowlist), got {result:?}"
5530 );
5531 }
5532
5533 #[tokio::test]
5535 async fn f010_supersedes_same_kind_entity_allowed() {
5536 let rt = rt();
5537 let tok = NamespaceToken::local();
5538 let a = rt
5539 .create_entity(&tok, "concept", None, "OldV", None, None, vec![])
5540 .await
5541 .unwrap();
5542 let b = rt
5543 .create_entity(&tok, "concept", None, "NewV", None, None, vec![])
5544 .await
5545 .unwrap();
5546 let result = rt
5547 .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
5548 .await;
5549 assert!(
5550 result.is_ok(),
5551 "concept->concept Supersedes must be allowed by the base allowlist, got {result:?}"
5552 );
5553 }
5554
5555 #[tokio::test]
5559 async fn f161_link_always_writes_null_target_backend() {
5560 let rt = rt();
5561 let tok = NamespaceToken::local();
5562 let a = rt
5563 .create_entity(&tok, "concept", None, "A", None, None, vec![])
5564 .await
5565 .unwrap();
5566 let b = rt
5567 .create_entity(&tok, "concept", None, "B", None, None, vec![])
5568 .await
5569 .unwrap();
5570 let edge = rt
5571 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5572 .await
5573 .unwrap();
5574 assert!(
5575 edge.target_backend.is_none(),
5576 "F161: target_backend must be None for locally-routed edges; got {:?}",
5577 edge.target_backend
5578 );
5579 }
5580
5581 #[tokio::test]
5583 async fn f161_link_many_always_writes_null_target_backend() {
5584 let rt = rt();
5585 let tok = NamespaceToken::local();
5586 let a = rt
5587 .create_entity(&tok, "concept", None, "A", None, None, vec![])
5588 .await
5589 .unwrap();
5590 let b = rt
5591 .create_entity(&tok, "concept", None, "B", None, None, vec![])
5592 .await
5593 .unwrap();
5594 let c = rt
5595 .create_entity(&tok, "concept", None, "C", None, None, vec![])
5596 .await
5597 .unwrap();
5598 let specs = vec![
5599 LinkSpec {
5600 namespace: None,
5601 source_id: a.id,
5602 target_id: b.id,
5603 relation: EdgeRelation::Extends,
5604 weight: 1.0,
5605 metadata: None,
5606 },
5607 LinkSpec {
5608 namespace: None,
5609 source_id: a.id,
5610 target_id: c.id,
5611 relation: EdgeRelation::Enables,
5612 weight: 1.0,
5613 metadata: None,
5614 },
5615 ];
5616 let edges = rt.link_many(&tok, specs).await.unwrap();
5617 for edge in &edges {
5618 assert!(
5619 edge.target_backend.is_none(),
5620 "F161: target_backend must be None for locally-routed edges in link_many; got {:?}",
5621 edge.target_backend
5622 );
5623 }
5624 }
5625
5626 #[tokio::test]
5629 async fn f012_symmetric_neighbors_visible_from_both_endpoints() {
5630 let rt = rt();
5631 let tok = NamespaceToken::local();
5632 let a = rt
5633 .create_entity(&tok, "concept", None, "A", None, None, vec![])
5634 .await
5635 .unwrap();
5636 let b = rt
5637 .create_entity(&tok, "concept", None, "B", None, None, vec![])
5638 .await
5639 .unwrap();
5640 rt.link(&tok, a.id, b.id, EdgeRelation::CompetesWith, 1.0, None)
5642 .await
5643 .unwrap();
5644 let from_a = rt
5646 .neighbors(
5647 &tok,
5648 a.id,
5649 Direction::Out,
5650 None,
5651 Some(vec![EdgeRelation::CompetesWith]),
5652 )
5653 .await
5654 .unwrap();
5655 let from_b = rt
5656 .neighbors(
5657 &tok,
5658 b.id,
5659 Direction::Out,
5660 None,
5661 Some(vec![EdgeRelation::CompetesWith]),
5662 )
5663 .await
5664 .unwrap();
5665 assert_eq!(
5666 from_a.len(),
5667 1,
5668 "node A must see competes_with neighbor from Direction::Out (F012); got {from_a:?}"
5669 );
5670 assert_eq!(
5671 from_b.len(),
5672 1,
5673 "node B must see competes_with neighbor from Direction::Out (F012); got {from_b:?}"
5674 );
5675 }
5676
5677 #[tokio::test]
5679 async fn f010_supersedes_cross_kind_entity_rejected() {
5680 let rt = rt();
5681 let tok = NamespaceToken::local();
5682 let concept = rt
5683 .create_entity(&tok, "concept", None, "MyConcept", None, None, vec![])
5684 .await
5685 .unwrap();
5686 let doc = rt
5687 .create_entity(&tok, "document", None, "MyDoc", None, None, vec![])
5688 .await
5689 .unwrap();
5690 let result = rt
5691 .link(
5692 &tok,
5693 concept.id,
5694 doc.id,
5695 EdgeRelation::Supersedes,
5696 1.0,
5697 None,
5698 )
5699 .await;
5700 assert!(
5701 matches!(result, Err(RuntimeError::InvalidInput(_))),
5702 "concept->document Supersedes must be rejected by the base allowlist, got {result:?}"
5703 );
5704 }
5705
5706 #[tokio::test]
5707 async fn delete_note_cross_namespace_returns_mismatch_error() {
5708 let rt = rt();
5709 let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
5710 let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
5711 let note = rt
5712 .create_note(
5713 &ns_a,
5714 "observation",
5715 None,
5716 "note in ns-a",
5717 Some(0.8),
5718 None,
5719 vec![],
5720 )
5721 .await
5722 .unwrap();
5723
5724 let result = rt.delete_note(&ns_b, note.id, true).await;
5727 assert!(
5728 !result.unwrap(),
5729 "cross-namespace delete_note must return Ok(false), not NamespaceMismatch"
5730 );
5731
5732 let note_store = rt.notes(&ns_a).unwrap();
5734 let still_there = note_store.get_note(note.id).await.unwrap();
5735 assert!(
5736 still_there.is_some(),
5737 "note must survive cross-ns delete attempt"
5738 );
5739 }
5740
5741 #[tokio::test]
5749 async fn link_many_overlapping_triple_returns_persisted_ids() {
5750 let rt = rt();
5751 let tok = NamespaceToken::local();
5752 let a = rt
5753 .create_entity(&tok, "concept", None, "A", None, None, vec![])
5754 .await
5755 .unwrap();
5756 let b = rt
5757 .create_entity(&tok, "concept", None, "B", None, None, vec![])
5758 .await
5759 .unwrap();
5760
5761 let spec = || LinkSpec {
5762 namespace: None,
5763 source_id: a.id,
5764 target_id: b.id,
5765 relation: EdgeRelation::Extends,
5766 weight: 1.0,
5767 metadata: None,
5768 };
5769
5770 let first = rt.link_many(&tok, vec![spec()]).await.unwrap();
5772 assert_eq!(first.len(), 1);
5773 let persisted_id: Uuid = first[0].id.into();
5774
5775 let second = rt.link_many(&tok, vec![spec()]).await.unwrap();
5778 assert_eq!(second.len(), 1);
5779 let second_id: Uuid = second[0].id.into();
5780
5781 assert_eq!(
5782 persisted_id, second_id,
5783 "link_many with an existing triple must return the persisted row ID ({persisted_id}), \
5784 not a new phantom ID ({second_id})"
5785 );
5786
5787 let count = rt
5789 .count_edges(&tok, crate::curation::EdgeListFilter::default())
5790 .await
5791 .unwrap();
5792 assert_eq!(count, 1, "upsert must not duplicate the edge row");
5793 }
5794
5795 #[tokio::test]
5798 async fn get_edge_cross_namespace_returns_none() {
5799 let rt = rt();
5800 let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
5801 let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
5802
5803 let src = rt
5804 .create_entity(&ns_a, "concept", None, "Src", None, None, vec![])
5805 .await
5806 .unwrap();
5807 let tgt = rt
5808 .create_entity(&ns_a, "concept", None, "Tgt", None, None, vec![])
5809 .await
5810 .unwrap();
5811 let edge = rt
5812 .link(&ns_a, src.id, tgt.id, EdgeRelation::Extends, 1.0, None)
5813 .await
5814 .unwrap();
5815
5816 let ok = rt.get_edge(&ns_a, Uuid::from(edge.id)).await;
5818 assert!(
5819 ok.is_ok() && ok.unwrap().is_some(),
5820 "edge must be visible in its own namespace"
5821 );
5822
5823 let result = rt.get_edge(&ns_b, Uuid::from(edge.id)).await;
5825 assert!(
5826 matches!(result, Ok(None)),
5827 "cross-namespace get_edge must return Ok(None), got {result:?}"
5828 );
5829
5830 let absent = rt.get_edge(&ns_b, Uuid::new_v4()).await;
5832 match (&result, &absent) {
5833 (Ok(None), Ok(None)) => {}
5834 other => panic!(
5835 "foreign and absent edge IDs must have identical observable shape, got {other:?}"
5836 ),
5837 }
5838 }
5839
5840 #[tokio::test]
5843 async fn traverse_foreign_namespace_root_yields_no_expansion() {
5844 use khive_storage::types::TraversalOptions;
5845
5846 let rt = rt();
5847 let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
5848 let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
5849
5850 let a = rt
5851 .create_entity(&ns_a, "concept", None, "A", None, None, vec![])
5852 .await
5853 .unwrap();
5854 let b = rt
5855 .create_entity(&ns_a, "concept", None, "B", None, None, vec![])
5856 .await
5857 .unwrap();
5858 rt.link(&ns_a, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5859 .await
5860 .unwrap();
5861
5862 let paths = rt
5864 .traverse(
5865 &ns_b,
5866 TraversalRequest {
5867 roots: vec![a.id],
5868 options: TraversalOptions {
5869 max_depth: 1,
5870 direction: Direction::Out,
5871 ..Default::default()
5872 },
5873 include_roots: true,
5874 },
5875 )
5876 .await
5877 .unwrap();
5878 assert!(
5879 paths.is_empty(),
5880 "foreign traversal root must be filtered before expansion, got {paths:?}"
5881 );
5882 }
5883
5884 async fn count_all_incident_edges(rt: &KhiveRuntime, node_id: Uuid, ns: &str) -> u64 {
5893 let mut reader = rt.sql().reader().await.expect("sql reader must open");
5894 let row = reader
5895 .query_scalar(SqlStatement {
5896 sql: "SELECT COUNT(*) FROM graph_edges \
5897 WHERE namespace = ?1 AND (source_id = ?2 OR target_id = ?2)"
5898 .into(),
5899 params: vec![
5900 SqlValue::Text(ns.to_string()),
5901 SqlValue::Text(node_id.to_string()),
5902 ],
5903 label: Some("count_all_incident_edges".into()),
5904 })
5905 .await
5906 .expect("count query must succeed");
5907 match row {
5908 Some(SqlValue::Integer(n)) => n as u64,
5909 _ => panic!("count must return an integer"),
5910 }
5911 }
5912
5913 #[tokio::test]
5914 async fn hard_delete_entity_purges_already_soft_deleted_incident_edge() {
5915 let rt = rt();
5916 let tok = NamespaceToken::local();
5917 let ns = tok.namespace().to_string();
5918
5919 let a = rt
5920 .create_entity(&tok, "concept", None, "SrcA", None, None, vec![])
5921 .await
5922 .unwrap();
5923 let b = rt
5924 .create_entity(&tok, "concept", None, "TgtB", None, None, vec![])
5925 .await
5926 .unwrap();
5927
5928 rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5929 .await
5930 .unwrap();
5931
5932 let edge_hit = rt
5934 .neighbors(&tok, a.id, Direction::Out, None, None)
5935 .await
5936 .unwrap();
5937 assert_eq!(edge_hit.len(), 1, "edge must exist before soft-delete");
5938 let edge_uuid = edge_hit[0].edge_id;
5939 rt.delete_edge(&tok, edge_uuid, false).await.unwrap();
5940
5941 let visible = rt
5943 .neighbors(&tok, a.id, Direction::Out, None, None)
5944 .await
5945 .unwrap();
5946 assert!(visible.is_empty(), "soft-deleted edge must be invisible");
5947 let raw_before = count_all_incident_edges(&rt, a.id, &ns).await;
5948 assert_eq!(
5949 raw_before, 1,
5950 "soft-deleted edge must still be a physical row"
5951 );
5952
5953 rt.delete_entity(&tok, a.id, true).await.unwrap();
5955
5956 let raw_after = count_all_incident_edges(&rt, a.id, &ns).await;
5957 assert_eq!(
5958 raw_after, 0,
5959 "purge_incident_edges must physically remove soft-deleted edge rows (ADR-002)"
5960 );
5961 }
5962
5963 #[tokio::test]
5964 async fn hard_delete_note_purges_already_soft_deleted_incident_edge() {
5965 let rt = rt();
5966 let tok = NamespaceToken::local();
5967 let ns = tok.namespace().to_string();
5968
5969 let target = rt
5970 .create_note(
5971 &tok,
5972 "observation",
5973 None,
5974 "purge-cascade target note",
5975 Some(0.5),
5976 None,
5977 vec![],
5978 )
5979 .await
5980 .unwrap();
5981 let annotating = rt
5982 .create_note(
5983 &tok,
5984 "insight",
5985 None,
5986 "annotator note",
5987 Some(0.5),
5988 None,
5989 vec![target.id],
5990 )
5991 .await
5992 .unwrap();
5993
5994 let edge_hit = rt
5996 .neighbors(
5997 &tok,
5998 annotating.id,
5999 Direction::Out,
6000 None,
6001 Some(vec![EdgeRelation::Annotates]),
6002 )
6003 .await
6004 .unwrap();
6005 assert_eq!(edge_hit.len(), 1, "annotates edge must exist");
6006 let edge_uuid = edge_hit[0].edge_id;
6007 rt.delete_edge(&tok, edge_uuid, false).await.unwrap();
6008
6009 let raw_before = count_all_incident_edges(&rt, target.id, &ns).await;
6010 assert_eq!(
6011 raw_before, 1,
6012 "soft-deleted edge must still be a physical row before note purge"
6013 );
6014
6015 rt.delete_note(&tok, target.id, true).await.unwrap();
6017
6018 let raw_after = count_all_incident_edges(&rt, target.id, &ns).await;
6019 assert_eq!(
6020 raw_after, 0,
6021 "purge_incident_edges must physically remove soft-deleted edge rows on note purge (ADR-002)"
6022 );
6023 }
6024
6025 async fn count_edge_rows_by_id(rt: &KhiveRuntime, edge_id: Uuid, ns: &str) -> u64 {
6035 let mut reader = rt.sql().reader().await.expect("sql reader must open");
6036 let row = reader
6037 .query_scalar(SqlStatement {
6038 sql: "SELECT COUNT(*) FROM graph_edges WHERE namespace = ?1 AND id = ?2".into(),
6039 params: vec![
6040 SqlValue::Text(ns.to_string()),
6041 SqlValue::Text(edge_id.to_string()),
6042 ],
6043 label: Some("count_edge_rows_by_id".into()),
6044 })
6045 .await
6046 .expect("count query must succeed");
6047 match row {
6048 Some(SqlValue::Integer(n)) => n as u64,
6049 _ => panic!("count must return an integer"),
6050 }
6051 }
6052
6053 #[tokio::test]
6054 async fn hard_delete_edge_purges_already_soft_deleted_primary_edge() {
6055 let rt = rt();
6056 let tok = NamespaceToken::local();
6057 let ns = tok.namespace().to_string();
6058
6059 let a = rt
6060 .create_entity(&tok, "concept", None, "EA", None, None, vec![])
6061 .await
6062 .unwrap();
6063 let b = rt
6064 .create_entity(&tok, "concept", None, "EB", None, None, vec![])
6065 .await
6066 .unwrap();
6067
6068 let edge = rt
6069 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
6070 .await
6071 .unwrap();
6072 let edge_uuid: Uuid = edge.id.into();
6073
6074 let soft = rt.delete_edge(&tok, edge_uuid, false).await.unwrap();
6076 assert!(soft, "soft delete must succeed");
6077
6078 assert!(
6080 rt.get_edge(&tok, edge_uuid).await.unwrap().is_none(),
6081 "soft-deleted edge must be invisible to get_edge"
6082 );
6083 assert_eq!(
6084 count_edge_rows_by_id(&rt, edge_uuid, &ns).await,
6085 1,
6086 "soft-deleted edge must still be a physical row"
6087 );
6088
6089 let purged = rt.delete_edge(&tok, edge_uuid, true).await.unwrap();
6091 assert!(
6092 purged,
6093 "hard delete of a soft-deleted edge must return true"
6094 );
6095
6096 assert_eq!(
6097 count_edge_rows_by_id(&rt, edge_uuid, &ns).await,
6098 0,
6099 "hard-delete must physically remove the soft-deleted edge row (ADR-002)"
6100 );
6101 }
6102
6103 #[tokio::test]
6104 async fn hard_delete_base_edge_purges_already_soft_deleted_annotates_edge() {
6105 let rt = rt();
6106 let tok = NamespaceToken::local();
6107 let ns = tok.namespace().to_string();
6108
6109 let a = rt
6110 .create_entity(&tok, "concept", None, "CA", None, None, vec![])
6111 .await
6112 .unwrap();
6113 let b = rt
6114 .create_entity(&tok, "concept", None, "CB", None, None, vec![])
6115 .await
6116 .unwrap();
6117
6118 let base_edge = rt
6120 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
6121 .await
6122 .unwrap();
6123 let base_edge_uuid: Uuid = base_edge.id.into();
6124
6125 let note = rt
6127 .create_note(
6128 &tok,
6129 "observation",
6130 None,
6131 "note about base edge",
6132 Some(0.5),
6133 None,
6134 vec![base_edge_uuid],
6135 )
6136 .await
6137 .unwrap();
6138
6139 let ann_hits = rt
6141 .neighbors(
6142 &tok,
6143 note.id,
6144 Direction::Out,
6145 None,
6146 Some(vec![EdgeRelation::Annotates]),
6147 )
6148 .await
6149 .unwrap();
6150 assert_eq!(ann_hits.len(), 1, "annotates edge must exist");
6151 let ann_edge_uuid = ann_hits[0].edge_id;
6152
6153 rt.delete_edge(&tok, ann_edge_uuid, false).await.unwrap();
6155 assert_eq!(
6156 count_edge_rows_by_id(&rt, ann_edge_uuid, &ns).await,
6157 1,
6158 "soft-deleted annotates edge must still be a physical row"
6159 );
6160
6161 let purged = rt.delete_edge(&tok, base_edge_uuid, true).await.unwrap();
6163 assert!(purged, "hard delete of base edge must return true");
6164
6165 assert_eq!(
6166 count_edge_rows_by_id(&rt, ann_edge_uuid, &ns).await,
6167 0,
6168 "hard-delete of base edge must purge already-soft-deleted annotates edge row (ADR-002)"
6169 );
6170 assert_eq!(
6171 count_edge_rows_by_id(&rt, base_edge_uuid, &ns).await,
6172 0,
6173 "hard-delete must physically remove the base edge row"
6174 );
6175 }
6176}