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, TextDocument, TextFilter,
22 TextQueryMode, 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::error::{RuntimeError, RuntimeResult};
31use crate::runtime::{KhiveRuntime, NamespaceToken};
32
33#[cfg(test)]
41std::thread_local! {
42 static LINK_FAIL_AFTER: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
43}
44
45#[derive(Clone, Debug)]
47pub struct NoteSearchHit {
48 pub note_id: Uuid,
49 pub score: DeterministicScore,
50 pub title: Option<String>,
51 pub snippet: Option<String>,
52}
53
54fn text_preview(text: &str, max_chars: usize) -> Option<String> {
55 let trimmed = text.trim();
56 if trimmed.is_empty() {
57 None
58 } else {
59 Some(trimmed.chars().take(max_chars).collect())
60 }
61}
62
63fn normalize_symmetric_direction(
69 direction: Direction,
70 relations: Option<&[EdgeRelation]>,
71) -> Direction {
72 let Some(rels) = relations else {
73 return direction;
74 };
75 if rels.is_empty() {
76 return direction;
77 }
78 let all_symmetric = rels
79 .iter()
80 .all(|r| matches!(r, EdgeRelation::CompetesWith | EdgeRelation::ComposedWith));
81 if all_symmetric {
82 Direction::Both
83 } else {
84 direction
85 }
86}
87
88fn note_title(note: &Note) -> Option<String> {
89 note.name
90 .clone()
91 .filter(|s| !s.trim().is_empty())
92 .or_else(|| Some(format!("[{}]", note.kind.as_str())))
93}
94
95fn note_snippet(note: &Note) -> Option<String> {
96 text_preview(¬e.content, 200)
97}
98
99#[derive(Clone, Debug)]
101pub enum Resolved {
102 Entity(Entity),
103 Note(Note),
104 Event(Event),
105}
106
107fn resolved_pair(r: Option<&Resolved>) -> Option<(&'static str, &str)> {
110 match r? {
111 Resolved::Entity(e) => Some(("entity", e.kind.as_str())),
112 Resolved::Note(n) => Some(("note", n.kind.as_str())),
113 Resolved::Event(_) => None,
114 }
115}
116
117fn endpoint_matches(spec: &EndpointKind, substrate: &str, kind: &str) -> bool {
119 match spec {
120 EndpointKind::EntityOfKind(k) => substrate == "entity" && *k == kind,
121 EndpointKind::NoteOfKind(k) => substrate == "note" && *k == kind,
122 }
123}
124
125fn pack_rule_allows(
128 rules: &[EdgeEndpointRule],
129 relation: EdgeRelation,
130 src: Option<&Resolved>,
131 tgt: Option<&Resolved>,
132) -> bool {
133 let Some((src_sub, src_kind)) = resolved_pair(src) else {
134 return false;
135 };
136 let Some((tgt_sub, tgt_kind)) = resolved_pair(tgt) else {
137 return false;
138 };
139 rules.iter().any(|r| {
140 r.relation == relation
141 && endpoint_matches(&r.source, src_sub, src_kind)
142 && endpoint_matches(&r.target, tgt_sub, tgt_kind)
143 })
144}
145
146fn base_entity_rule_allows(src_kind: &str, relation: EdgeRelation, tgt_kind: &str) -> bool {
154 const RULES: &[(&str, EdgeRelation, &str)] = &[
155 ("concept", EdgeRelation::Contains, "concept"),
157 ("project", EdgeRelation::Contains, "project"),
158 ("project", EdgeRelation::Contains, "artifact"),
159 ("org", EdgeRelation::Contains, "project"),
160 ("org", EdgeRelation::Contains, "service"),
161 ("concept", EdgeRelation::PartOf, "concept"),
162 ("project", EdgeRelation::PartOf, "project"),
163 ("project", EdgeRelation::PartOf, "org"),
164 ("*", EdgeRelation::InstanceOf, "concept"),
165 ("service", EdgeRelation::InstanceOf, "project"),
166 ("concept", EdgeRelation::Extends, "concept"),
168 ("concept", EdgeRelation::VariantOf, "concept"),
169 ("artifact", EdgeRelation::VariantOf, "artifact"),
170 ("concept", EdgeRelation::IntroducedBy, "document"),
171 ("concept", EdgeRelation::IntroducedBy, "person"),
172 ("artifact", EdgeRelation::IntroducedBy, "document"),
173 ("artifact", EdgeRelation::DerivedFrom, "dataset"),
175 ("artifact", EdgeRelation::DerivedFrom, "document"),
176 ("artifact", EdgeRelation::DerivedFrom, "project"),
177 ("artifact", EdgeRelation::DerivedFrom, "artifact"),
178 ("document", EdgeRelation::Precedes, "document"),
180 ("dataset", EdgeRelation::Precedes, "dataset"),
181 ("artifact", EdgeRelation::Precedes, "artifact"),
182 ("service", EdgeRelation::Precedes, "service"),
183 ("project", EdgeRelation::Precedes, "project"),
184 ("project", EdgeRelation::DependsOn, "project"),
186 ("service", EdgeRelation::DependsOn, "project"),
187 ("service", EdgeRelation::DependsOn, "service"),
188 ("service", EdgeRelation::DependsOn, "artifact"),
189 ("service", EdgeRelation::DependsOn, "dataset"),
190 ("artifact", EdgeRelation::DependsOn, "project"),
191 ("artifact", EdgeRelation::DependsOn, "service"),
192 ("concept", EdgeRelation::Enables, "concept"),
193 ("service", EdgeRelation::Enables, "concept"),
194 ("dataset", EdgeRelation::Enables, "concept"),
195 ("project", EdgeRelation::Implements, "concept"),
197 ("service", EdgeRelation::Implements, "concept"),
198 ("concept", EdgeRelation::CompetesWith, "concept"),
200 ("project", EdgeRelation::CompetesWith, "project"),
201 ("service", EdgeRelation::CompetesWith, "service"),
202 ("concept", EdgeRelation::ComposedWith, "concept"),
203 ("project", EdgeRelation::ComposedWith, "project"),
204 ("concept", EdgeRelation::Supersedes, "concept"),
206 ("document", EdgeRelation::Supersedes, "document"),
207 ("artifact", EdgeRelation::Supersedes, "artifact"),
208 ("service", EdgeRelation::Supersedes, "service"),
209 ("dataset", EdgeRelation::Supersedes, "dataset"),
210 ];
211 RULES.iter().any(|(src, rel, tgt)| {
212 *rel == relation && (*src == "*" || *src == src_kind) && *tgt == tgt_kind
213 })
214}
215
216pub(crate) fn canonical_edge_endpoints(
222 relation: EdgeRelation,
223 source_id: Uuid,
224 target_id: Uuid,
225) -> (Uuid, Uuid) {
226 if relation.is_symmetric() && target_id < source_id {
227 (target_id, source_id)
228 } else {
229 (source_id, target_id)
230 }
231}
232
233fn infer_dependency_kind(src_kind: &str, tgt_kind: &str) -> Option<&'static str> {
235 match (src_kind, tgt_kind) {
236 ("project", "project") => Some("build"),
237 ("service", "service") => Some("runtime"),
238 ("service", "dataset") => Some("data"),
239 ("service", "artifact") => Some("artifact"),
240 ("artifact", "project") | ("artifact", "service") => Some("tooling"),
241 _ => None,
242 }
243}
244
245fn merge_dependency_kind(
252 src_kind: &str,
253 tgt_kind: &str,
254 metadata: Option<serde_json::Value>,
255) -> Option<serde_json::Value> {
256 if let Some(ref m) = metadata {
257 if m.get("dependency_kind").is_some() {
258 return metadata;
259 }
260 }
261 let inferred = infer_dependency_kind(src_kind, tgt_kind)?;
262 let mut obj = metadata.unwrap_or_else(|| serde_json::json!({}));
263 if let Some(o) = obj.as_object_mut() {
264 o.insert("dependency_kind".to_string(), serde_json::json!(inferred));
265 }
266 Some(obj)
267}
268
269const VALID_DEPENDENCY_KINDS: &[&str] = &["build", "runtime", "data", "artifact", "tooling"];
271
272pub(crate) fn validate_edge_weight(weight: f64) -> RuntimeResult<()> {
278 if !weight.is_finite() || !(0.0..=1.0).contains(&weight) {
279 return Err(RuntimeError::InvalidInput(format!(
280 "edge weight must be finite and in [0.0, 1.0], got {weight}"
281 )));
282 }
283 Ok(())
284}
285
286fn validate_edge_metadata(
292 relation: EdgeRelation,
293 metadata: Option<&serde_json::Value>,
294) -> RuntimeResult<()> {
295 let Some(meta) = metadata else {
296 return Ok(());
297 };
298 if let Some(dk) = meta.get("dependency_kind") {
299 if relation != EdgeRelation::DependsOn {
300 return Err(RuntimeError::InvalidInput(format!(
301 "dependency_kind is only valid on depends_on edges (got {})",
302 relation.as_str()
303 )));
304 }
305 let dk_str = dk
306 .as_str()
307 .ok_or_else(|| RuntimeError::InvalidInput("dependency_kind must be a string".into()))?;
308 if !VALID_DEPENDENCY_KINDS.contains(&dk_str) {
309 return Err(RuntimeError::InvalidInput(format!(
310 "unknown dependency_kind {dk_str:?}; valid: {}",
311 VALID_DEPENDENCY_KINDS.join(" | ")
312 )));
313 }
314 }
315 Ok(())
316}
317
318impl KhiveRuntime {
319 #[allow(clippy::too_many_arguments)]
323 pub async fn create_entity(
324 &self,
325 token: &NamespaceToken,
326 kind: &str,
327 entity_type: Option<&str>,
328 name: &str,
329 description: Option<&str>,
330 properties: Option<serde_json::Value>,
331 tags: Vec<String>,
332 ) -> RuntimeResult<Entity> {
333 self.validate_entity_kind(kind)?;
334 let ns = token.namespace().as_str();
335 let mut entity = Entity::new(ns, kind, name).with_entity_type(entity_type);
336 if let Some(d) = description {
337 entity = entity.with_description(d);
338 }
339 if let Some(p) = properties {
340 entity = entity.with_properties(p);
341 }
342 if !tags.is_empty() {
343 entity = entity.with_tags(tags);
344 }
345 self.entities(token)?.upsert_entity(entity.clone()).await?;
346
347 let body = match &entity.description {
348 Some(d) if !d.is_empty() => format!("{} {}", entity.name, d),
349 _ => entity.name.clone(),
350 };
351 self.text(token)?
352 .upsert_document(TextDocument {
353 subject_id: entity.id,
354 kind: SubstrateKind::Entity,
355 title: Some(entity.name.clone()),
356 body: body.clone(),
357 tags: entity.tags.clone(),
358 namespace: ns.to_string(),
359 metadata: entity.properties.clone(),
360 updated_at: chrono::Utc::now(),
361 })
362 .await?;
363
364 if self.config().embedding_model.is_some() {
365 let vector = self.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_entities_by_ids(
398 &self,
399 token: &NamespaceToken,
400 ids: &[Uuid],
401 ) -> RuntimeResult<Vec<Entity>> {
402 if ids.is_empty() {
403 return Ok(vec![]);
404 }
405 let filter = EntityFilter {
406 ids: ids.to_vec(),
407 ..Default::default()
408 };
409 let page = self
410 .entities(token)?
411 .query_entities(
412 token.namespace().as_str(),
413 filter,
414 PageRequest {
415 offset: 0,
416 limit: ids.len() as u32,
417 },
418 )
419 .await?;
420 Ok(page.items)
421 }
422
423 pub(crate) fn ensure_namespace(record_ns: &str, caller_ns: &str) -> RuntimeResult<()> {
428 if record_ns == caller_ns {
429 return Ok(());
430 }
431 Err(RuntimeError::NotFound("not found in this namespace".into()))
432 }
433
434 pub async fn list_entities(
436 &self,
437 token: &NamespaceToken,
438 kind: Option<&str>,
439 entity_type: Option<&str>,
440 limit: u32,
441 offset: u32,
442 ) -> RuntimeResult<Vec<Entity>> {
443 let filter = EntityFilter {
444 kinds: match kind {
445 Some(k) => vec![k.to_string()],
446 None => vec![],
447 },
448 entity_types: match entity_type {
449 Some(t) => vec![t.to_string()],
450 None => vec![],
451 },
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: offset.into(),
461 limit,
462 },
463 )
464 .await?;
465 Ok(page.items)
466 }
467
468 pub async fn list_entities_tagged(
475 &self,
476 token: &NamespaceToken,
477 kind: Option<&str>,
478 domain_tag: Option<&str>,
479 limit: u32,
480 offset: u32,
481 ) -> RuntimeResult<Vec<Entity>> {
482 let filter = EntityFilter {
483 kinds: match kind {
484 Some(k) => vec![k.to_string()],
485 None => vec![],
486 },
487 tags_any: match domain_tag {
488 Some(t) if !t.is_empty() => vec![t.to_string()],
489 _ => vec![],
490 },
491 ..Default::default()
492 };
493 let page = self
494 .entities(token)?
495 .query_entities(
496 token.namespace().as_str(),
497 filter,
498 PageRequest {
499 offset: offset.into(),
500 limit,
501 },
502 )
503 .await?;
504 Ok(page.items)
505 }
506
507 pub async fn count_entities_tagged(
511 &self,
512 token: &NamespaceToken,
513 kind: Option<&str>,
514 domain_tag: Option<&str>,
515 ) -> RuntimeResult<u64> {
516 let filter = EntityFilter {
517 kinds: match kind {
518 Some(k) => vec![k.to_string()],
519 None => vec![],
520 },
521 tags_any: match domain_tag {
522 Some(t) if !t.is_empty() => vec![t.to_string()],
523 _ => vec![],
524 },
525 ..Default::default()
526 };
527 Ok(self
528 .entities(token)?
529 .count_entities(token.namespace().as_str(), filter)
530 .await?)
531 }
532
533 pub async fn list_events(
535 &self,
536 token: &NamespaceToken,
537 filter: EventFilter,
538 page: PageRequest,
539 ) -> RuntimeResult<Page<Event>> {
540 self.events(token)?
541 .query_events(filter, page)
542 .await
543 .map_err(Into::into)
544 }
545
546 async fn validate_edge_relation_endpoints(
560 &self,
561 token: &NamespaceToken,
562 source_id: Uuid,
563 target_id: Uuid,
564 relation: EdgeRelation,
565 ) -> RuntimeResult<()> {
566 if relation == EdgeRelation::Annotates {
567 match self.resolve(token, source_id).await? {
569 Some(Resolved::Note(_)) => {}
570 Some(_) => {
571 return Err(RuntimeError::InvalidInput(format!(
572 "annotates source {source_id} must be a note"
573 )));
574 }
575 None => {
576 if self.get_edge(token, source_id).await?.is_some() {
578 return Err(RuntimeError::InvalidInput(format!(
579 "annotates source {source_id} must be a note"
580 )));
581 }
582 return Err(RuntimeError::NotFound(format!(
583 "link source {source_id} not found in namespace"
584 )));
585 }
586 }
587 if !self.substrate_exists_in_ns(token, target_id).await? {
589 return Err(RuntimeError::NotFound(format!(
590 "link target {target_id} not found in namespace"
591 )));
592 }
593 } else if relation == EdgeRelation::Supersedes {
594 let src = match self.resolve(token, source_id).await? {
597 Some(r) => r,
598 None => {
599 if self.get_edge(token, source_id).await?.is_some() {
600 return Err(RuntimeError::InvalidInput(format!(
601 "supersedes source {source_id} must be a note or entity (got edge)"
602 )));
603 }
604 return Err(RuntimeError::NotFound(format!(
605 "link source {source_id} not found in namespace"
606 )));
607 }
608 };
609 let tgt = match self.resolve(token, target_id).await? {
610 Some(r) => r,
611 None => {
612 if self.get_edge(token, target_id).await?.is_some() {
613 return Err(RuntimeError::InvalidInput(format!(
614 "supersedes target {target_id} must be a note or entity (got edge)"
615 )));
616 }
617 return Err(RuntimeError::NotFound(format!(
618 "link target {target_id} not found in namespace"
619 )));
620 }
621 };
622 match (&src, &tgt) {
623 (Resolved::Entity(src_e), Resolved::Entity(tgt_e)) => {
624 if !base_entity_rule_allows(&src_e.kind, EdgeRelation::Supersedes, &tgt_e.kind)
625 {
626 return Err(RuntimeError::InvalidInput(format!(
627 "({}) -[supersedes]-> ({}) is not in the base endpoint \
628 allowlist; supersedes requires same-kind entity endpoints",
629 src_e.kind, tgt_e.kind
630 )));
631 }
632 }
633 (Resolved::Note(_), Resolved::Note(_)) => {}
634 (Resolved::Event(_), _) => {
635 return Err(RuntimeError::InvalidInput(format!(
636 "supersedes does not apply to events; source {source_id} is an event"
637 )));
638 }
639 (_, Resolved::Event(_)) => {
640 return Err(RuntimeError::InvalidInput(format!(
641 "supersedes does not apply to events; target {target_id} is an event"
642 )));
643 }
644 (Resolved::Entity(_), Resolved::Note(_)) => {
645 return Err(RuntimeError::InvalidInput(format!(
646 "supersedes endpoints must be the same substrate (note→note or entity→entity); \
647 got source={source_id} (entity) target={target_id} (note)"
648 )));
649 }
650 (Resolved::Note(_), Resolved::Entity(_)) => {
651 return Err(RuntimeError::InvalidInput(format!(
652 "supersedes endpoints must be the same substrate (note→note or entity→entity); \
653 got source={source_id} (note) target={target_id} (entity)"
654 )));
655 }
656 }
657 } else {
658 let src_res = self.resolve(token, source_id).await?;
665 let tgt_res = self.resolve(token, target_id).await?;
666
667 if pack_rule_allows(
668 &self.pack_edge_rules(),
669 relation,
670 src_res.as_ref(),
671 tgt_res.as_ref(),
672 ) {
673 return Ok(());
674 }
675
676 let src_kind = match src_res {
678 Some(Resolved::Entity(e)) => e.kind,
679 Some(_) => {
680 return Err(RuntimeError::InvalidInput(format!(
681 "link source {source_id} must be an entity for relation {relation:?} \
682 (only `annotates` crosses substrates)"
683 )));
684 }
685 None => {
686 if self.get_edge(token, source_id).await?.is_some() {
687 return Err(RuntimeError::InvalidInput(format!(
688 "link source {source_id} must be an entity for relation {relation:?} \
689 (only `annotates` crosses substrates)"
690 )));
691 }
692 return Err(RuntimeError::NotFound(format!(
693 "link source {source_id} not found in namespace"
694 )));
695 }
696 };
697 let tgt_kind = match tgt_res {
698 Some(Resolved::Entity(e)) => e.kind,
699 Some(_) => {
700 return Err(RuntimeError::InvalidInput(format!(
701 "link target {target_id} must be an entity for relation {relation:?} \
702 (only `annotates` crosses substrates)"
703 )));
704 }
705 None => {
706 if self.get_edge(token, target_id).await?.is_some() {
707 return Err(RuntimeError::InvalidInput(format!(
708 "link target {target_id} must be an entity for relation {relation:?} \
709 (only `annotates` crosses substrates)"
710 )));
711 }
712 return Err(RuntimeError::NotFound(format!(
713 "link target {target_id} not found in namespace"
714 )));
715 }
716 };
717 if !base_entity_rule_allows(&src_kind, relation, &tgt_kind) {
718 return Err(RuntimeError::InvalidInput(format!(
719 "({src_kind}) -[{}]-> ({tgt_kind}) is not in the base endpoint \
720 allowlist; use pack EDGE_RULES to extend the allowlist",
721 relation.as_str()
722 )));
723 }
724 }
725 Ok(())
726 }
727
728 pub async fn link(
747 &self,
748 token: &NamespaceToken,
749 source_id: Uuid,
750 target_id: Uuid,
751 relation: EdgeRelation,
752 weight: f64,
753 metadata: Option<serde_json::Value>,
754 ) -> RuntimeResult<Edge> {
755 validate_edge_weight(weight)?;
756 self.validate_edge_relation_endpoints(token, source_id, target_id, relation)
757 .await?;
758 let (source_id, target_id) = canonical_edge_endpoints(relation, source_id, target_id);
759 let metadata = if relation == EdgeRelation::DependsOn {
760 match (
761 self.resolve(token, source_id).await?,
762 self.resolve(token, target_id).await?,
763 ) {
764 (Some(Resolved::Entity(src_e)), Some(Resolved::Entity(tgt_e))) => {
765 merge_dependency_kind(&src_e.kind, &tgt_e.kind, metadata)
766 }
767 _ => metadata,
768 }
769 } else {
770 metadata
771 };
772 validate_edge_metadata(relation, metadata.as_ref())?;
773 let now = chrono::Utc::now();
774 let ns = token.namespace().as_str();
775 let edge = Edge {
776 id: LinkId::from(Uuid::new_v4()),
777 namespace: ns.to_string(),
778 source_id,
779 target_id,
780 relation,
781 weight,
782 created_at: now,
783 updated_at: now,
784 deleted_at: None,
785 metadata,
786 target_backend: None,
787 };
788 self.graph(token)?.upsert_edge(edge).await?;
789
790 let persisted = self
796 .list_edges(
797 token,
798 crate::curation::EdgeListFilter {
799 source_id: Some(source_id),
800 target_id: Some(target_id),
801 relations: vec![relation],
802 ..Default::default()
803 },
804 1,
805 )
806 .await?
807 .into_iter()
808 .next()
809 .ok_or_else(|| {
810 crate::RuntimeError::Internal(format!(
811 "upsert_edge succeeded but natural-key lookup for ({source_id}, {target_id}, {relation}) returned nothing"
812 ))
813 })?;
814 Ok(persisted)
815 }
816
817 pub(crate) async fn substrate_exists_in_ns(
822 &self,
823 token: &NamespaceToken,
824 id: Uuid,
825 ) -> RuntimeResult<bool> {
826 if self.resolve(token, id).await?.is_some() {
827 return Ok(true);
828 }
829 match self.get_edge(token, id).await {
830 Ok(Some(_)) => Ok(true),
831 Ok(None) | Err(RuntimeError::NotFound(_)) => Ok(false),
832 Err(err) => Err(err),
833 }
834 }
835
836 pub async fn neighbors(
845 &self,
846 token: &NamespaceToken,
847 node_id: Uuid,
848 direction: Direction,
849 limit: Option<u32>,
850 relations: Option<Vec<EdgeRelation>>,
851 ) -> RuntimeResult<Vec<NeighborHit>> {
852 self.neighbors_with_query(
853 token,
854 node_id,
855 NeighborQuery {
856 direction,
857 relations,
858 limit,
859 min_weight: None,
860 },
861 )
862 .await
863 }
864
865 pub async fn neighbors_with_query(
875 &self,
876 token: &NamespaceToken,
877 node_id: Uuid,
878 mut query: NeighborQuery,
879 ) -> RuntimeResult<Vec<NeighborHit>> {
880 if !self.substrate_exists_in_ns(token, node_id).await? {
881 return Ok(Vec::new());
882 }
883
884 query.direction =
885 normalize_symmetric_direction(query.direction, query.relations.as_deref());
886 let mut hits = self.graph(token)?.neighbors(node_id, query).await?;
887 self.enrich_neighbor_hits(token, &mut hits).await;
888 let candidate_ids: Vec<Uuid> = hits.iter().map(|h| h.node_id).collect();
890 let deleted = self.deleted_entity_ids(candidate_ids).await;
891 if !deleted.is_empty() {
892 hits.retain(|h| !deleted.contains(&h.node_id));
893 }
894 Ok(hits)
895 }
896
897 pub async fn traverse(
902 &self,
903 token: &NamespaceToken,
904 request: TraversalRequest,
905 ) -> RuntimeResult<Vec<GraphPath>> {
906 let mut request = request;
907 let mut visible_roots = Vec::with_capacity(request.roots.len());
908 for root in request.roots.drain(..) {
909 if self.substrate_exists_in_ns(token, root).await? {
910 visible_roots.push(root);
911 }
912 }
913 request.roots = visible_roots;
914 if request.roots.is_empty() {
915 return Ok(Vec::new());
916 }
917
918 let mut paths = self.graph(token)?.traverse(request).await?;
919 self.enrich_path_nodes(token, &mut paths).await;
920 let all_node_ids: Vec<Uuid> = paths
922 .iter()
923 .flat_map(|p| p.nodes.iter().map(|n| n.node_id))
924 .collect();
925 let deleted = self.deleted_entity_ids(all_node_ids).await;
926 if !deleted.is_empty() {
927 for path in paths.iter_mut() {
928 path.nodes.retain(|n| !deleted.contains(&n.node_id));
929 }
930 paths.retain(|p| !p.nodes.is_empty());
931 }
932 Ok(paths)
933 }
934
935 async fn deleted_entity_ids(&self, ids: Vec<Uuid>) -> std::collections::HashSet<Uuid> {
941 if ids.is_empty() {
942 return std::collections::HashSet::new();
943 }
944 let id_strs: Vec<String> = ids.iter().map(|u| u.to_string()).collect();
945 let placeholders = id_strs
946 .iter()
947 .enumerate()
948 .map(|(i, _)| format!("?{}", i + 1))
949 .collect::<Vec<_>>()
950 .join(",");
951 let sql_str = format!(
952 "SELECT id FROM entities WHERE id IN ({placeholders}) AND deleted_at IS NOT NULL"
953 );
954 let params: Vec<SqlValue> = id_strs.into_iter().map(SqlValue::Text).collect();
955 let stmt = SqlStatement {
956 sql: sql_str,
957 params,
958 label: Some("deleted_entity_ids".into()),
959 };
960 let mut out = std::collections::HashSet::new();
961 let sql = self.sql();
962 if let Ok(mut reader) = sql.reader().await {
963 if let Ok(rows) = reader.query_all(stmt).await {
964 for row in rows {
965 if let Some(col) = row.columns.first() {
966 if let SqlValue::Text(s) = &col.value {
967 if let Ok(u) = s.parse::<Uuid>() {
968 out.insert(u);
969 }
970 }
971 }
972 }
973 }
974 }
976 out
977 }
978
979 async fn enrich_neighbor_hits(&self, token: &NamespaceToken, hits: &mut [NeighborHit]) {
982 if hits.is_empty() {
983 return;
984 }
985
986 let entity_store = self.entities(token).ok();
987 let note_store = self.notes(token).ok();
988
989 for hit in hits.iter_mut() {
990 if let Some(store) = &entity_store {
991 if let Ok(Some(entity)) = store.get_entity(hit.node_id).await {
992 hit.name = Some(entity.name);
993 hit.kind = Some(entity.kind);
994 continue;
995 }
996 }
997
998 if let Some(store) = ¬e_store {
999 if let Ok(Some(note)) = store.get_note(hit.node_id).await {
1000 let kind = note.kind;
1001 let name = note
1002 .name
1003 .filter(|s| !s.trim().is_empty())
1004 .unwrap_or_else(|| format!("[{kind}]"));
1005 hit.name = Some(name);
1006 hit.kind = Some(kind);
1007 }
1008 }
1009 }
1010 }
1011
1012 async fn enrich_path_nodes(&self, token: &NamespaceToken, paths: &mut [GraphPath]) {
1015 if paths.is_empty() {
1016 return;
1017 }
1018 let store = match self.entities(token) {
1019 Ok(s) => s,
1020 Err(_) => return,
1021 };
1022 for path in paths.iter_mut() {
1023 for node in path.nodes.iter_mut() {
1024 if let Ok(Some(entity)) = store.get_entity(node.node_id).await {
1025 node.name = Some(entity.name);
1026 node.kind = Some(entity.kind);
1027 }
1028 }
1029 }
1030 }
1031
1032 #[allow(clippy::too_many_arguments)]
1043 pub async fn create_note(
1044 &self,
1045 token: &NamespaceToken,
1046 kind: &str,
1047 name: Option<&str>,
1048 content: &str,
1049 salience: Option<f64>,
1050 properties: Option<serde_json::Value>,
1051 annotates: Vec<Uuid>,
1052 ) -> RuntimeResult<Note> {
1053 self.create_note_inner(
1054 token, kind, name, content, salience, None, properties, annotates, None,
1055 )
1056 .await
1057 }
1058
1059 #[allow(clippy::too_many_arguments)]
1061 pub async fn create_note_with_decay(
1062 &self,
1063 token: &NamespaceToken,
1064 kind: &str,
1065 name: Option<&str>,
1066 content: &str,
1067 salience: Option<f64>,
1068 decay_factor: f64,
1069 properties: Option<serde_json::Value>,
1070 annotates: Vec<Uuid>,
1071 ) -> RuntimeResult<Note> {
1072 self.create_note_with_decay_for_embedding_model(
1073 token,
1074 kind,
1075 name,
1076 content,
1077 salience,
1078 decay_factor,
1079 properties,
1080 annotates,
1081 None,
1082 )
1083 .await
1084 }
1085
1086 #[allow(clippy::too_many_arguments)]
1088 pub async fn create_note_with_decay_for_embedding_model(
1089 &self,
1090 token: &NamespaceToken,
1091 kind: &str,
1092 name: Option<&str>,
1093 content: &str,
1094 salience: Option<f64>,
1095 decay_factor: f64,
1096 properties: Option<serde_json::Value>,
1097 annotates: Vec<Uuid>,
1098 embedding_model: Option<&str>,
1099 ) -> RuntimeResult<Note> {
1100 self.create_note_inner(
1101 token,
1102 kind,
1103 name,
1104 content,
1105 salience,
1106 Some(decay_factor),
1107 properties,
1108 annotates,
1109 embedding_model,
1110 )
1111 .await
1112 }
1113
1114 #[allow(clippy::too_many_arguments)]
1115 async fn create_note_inner(
1116 &self,
1117 token: &NamespaceToken,
1118 kind: &str,
1119 name: Option<&str>,
1120 content: &str,
1121 salience: Option<f64>,
1122 decay_factor: Option<f64>,
1123 properties: Option<serde_json::Value>,
1124 annotates: Vec<Uuid>,
1125 embedding_model: Option<&str>,
1126 ) -> RuntimeResult<Note> {
1127 self.validate_note_kind(kind)?;
1128 let ns = token.namespace().as_str();
1129
1130 for &target_id in &annotates {
1132 if !self.substrate_exists_in_ns(token, target_id).await? {
1133 return Err(RuntimeError::NotFound(format!(
1134 "create_note annotates target {target_id} not found in namespace"
1135 )));
1136 }
1137 }
1138
1139 if let Some(s) = salience {
1142 if !s.is_finite() || !(0.0..=1.0).contains(&s) {
1143 return Err(RuntimeError::InvalidInput(format!(
1144 "salience must be a finite value in [0.0, 1.0]; got {s}"
1145 )));
1146 }
1147 }
1148 if let Some(d) = decay_factor {
1149 if !d.is_finite() || d < 0.0 {
1150 return Err(RuntimeError::InvalidInput(format!(
1151 "decay_factor must be a finite value >= 0.0; got {d}"
1152 )));
1153 }
1154 }
1155
1156 if let Some(model_name) = embedding_model {
1161 self.resolve_embedding_model(Some(model_name))?;
1162 }
1163
1164 let mut note = Note::new(ns, kind, content);
1165 if let Some(s) = salience {
1166 note = note.with_salience(s);
1167 }
1168 if let Some(df) = decay_factor {
1169 note = note.with_decay(df);
1170 }
1171 if let Some(n) = name {
1172 note = note.with_name(n);
1173 }
1174 if let Some(p) = properties {
1175 note = note.with_properties(p);
1176 }
1177 self.notes(token)?.upsert_note(note.clone()).await?;
1178
1179 let body = match ¬e.name {
1180 Some(n) => format!("{n} {}", note.content),
1181 None => note.content.clone(),
1182 };
1183
1184 self.text_for_notes(token)?
1185 .upsert_document(TextDocument {
1186 subject_id: note.id,
1187 kind: SubstrateKind::Note,
1188 title: note.name.clone(),
1189 body,
1190 tags: vec![],
1191 namespace: ns.to_string(),
1192 metadata: note.properties.clone(),
1193 updated_at: chrono::Utc::now(),
1194 })
1195 .await?;
1196
1197 let embed_model_names: Vec<String> = if let Some(m) = embedding_model {
1202 vec![m.to_string()]
1203 } else {
1204 let names = self.registered_embedding_model_names();
1210 if names.is_empty() {
1211 vec![]
1213 } else {
1214 names
1215 }
1216 };
1217
1218 if embed_model_names.len() == 1 {
1219 let model_name = &embed_model_names[0];
1221 let vector = self.embed_with_model(model_name, ¬e.content).await?;
1222 self.vectors_for_model(token, model_name)?
1223 .insert(
1224 note.id,
1225 SubstrateKind::Note,
1226 ns,
1227 "note.content",
1228 vec![vector],
1229 )
1230 .await?;
1231 } else if !embed_model_names.is_empty() {
1232 let rt_clone = self.clone();
1235 let content_owned = note.content.clone();
1236 let mut handles = Vec::with_capacity(embed_model_names.len());
1237 for model_name in &embed_model_names {
1238 let rt = rt_clone.clone();
1239 let text = content_owned.clone();
1240 let name = model_name.clone();
1241 handles.push(tokio::spawn(async move {
1242 rt.embed_with_model(&name, &text).await
1243 }));
1244 }
1245 let mut vectors: Vec<Vec<f32>> = Vec::with_capacity(embed_model_names.len());
1246 for handle in handles {
1247 let vec = handle
1248 .await
1249 .map_err(|e| RuntimeError::Internal(format!("embed task panicked: {e}")))??;
1250 vectors.push(vec);
1251 }
1252 for (model_name, vector) in embed_model_names.iter().zip(vectors.into_iter()) {
1254 self.vectors_for_model(token, model_name)?
1255 .insert(
1256 note.id,
1257 SubstrateKind::Note,
1258 ns,
1259 "note.content",
1260 vec![vector],
1261 )
1262 .await?;
1263 }
1264 }
1265
1266 let mut created_edges: Vec<Uuid> = Vec::with_capacity(annotates.len());
1272
1273 #[cfg(test)]
1276 let annotates_iter: Vec<(usize, Uuid)> = annotates
1277 .iter()
1278 .enumerate()
1279 .map(|(i, &id)| (i, id))
1280 .collect();
1281 #[cfg(test)]
1282 macro_rules! next_target {
1283 ($pair:expr) => {
1284 $pair.1
1285 };
1286 }
1287 #[cfg(not(test))]
1288 let annotates_iter: Vec<Uuid> = annotates.to_vec();
1289 #[cfg(not(test))]
1290 macro_rules! next_target {
1291 ($pair:expr) => {
1292 $pair
1293 };
1294 }
1295
1296 for pair in annotates_iter {
1297 let target_id = next_target!(pair);
1298
1299 #[cfg(test)]
1301 let injected_err: Option<RuntimeError> = {
1302 let call_idx = pair.0;
1303 LINK_FAIL_AFTER.with(|cell| {
1304 let n = cell.get();
1305 if n > 0 && call_idx + 1 == n {
1306 cell.set(0); Some(RuntimeError::Internal("injected link failure".to_string()))
1308 } else {
1309 None
1310 }
1311 })
1312 };
1313 #[cfg(not(test))]
1314 let injected_err: Option<RuntimeError> = None;
1315
1316 let link_result = if let Some(e) = injected_err {
1317 Err(e)
1318 } else {
1319 self.link(
1320 token,
1321 note.id,
1322 target_id,
1323 EdgeRelation::Annotates,
1324 1.0,
1325 None,
1326 )
1327 .await
1328 };
1329
1330 match link_result {
1331 Ok(edge) => created_edges.push(edge.id.into()),
1332 Err(e) => {
1333 for edge_id in created_edges {
1335 let _ = self.delete_edge(token, edge_id, true).await;
1336 }
1337 if let Ok(store) = self.notes(token) {
1338 let _ = store.delete_note(note.id, DeleteMode::Hard).await;
1339 }
1340 if let Ok(fts) = self.text_for_notes(token) {
1341 let _ = fts.delete_document(ns, note.id).await;
1342 }
1343 for model_name in &embed_model_names {
1344 if let Ok(vs) = self.vectors_for_model(token, model_name) {
1345 let _ = vs.delete(note.id).await;
1346 }
1347 }
1348 return Err(e);
1349 }
1350 }
1351 }
1352
1353 Ok(note)
1354 }
1355
1356 pub async fn list_notes(
1358 &self,
1359 token: &NamespaceToken,
1360 kind: Option<&str>,
1361 limit: u32,
1362 offset: u32,
1363 ) -> RuntimeResult<Vec<Note>> {
1364 let page = self
1365 .notes(token)?
1366 .query_notes(
1367 token.namespace().as_str(),
1368 kind,
1369 PageRequest {
1370 offset: offset.into(),
1371 limit,
1372 },
1373 )
1374 .await?;
1375 Ok(page.items)
1376 }
1377
1378 pub async fn search_notes(
1388 &self,
1389 token: &NamespaceToken,
1390 query_text: &str,
1391 query_vector: Option<Vec<f32>>,
1392 limit: u32,
1393 note_kind: Option<&str>,
1394 include_superseded: bool,
1395 ) -> RuntimeResult<Vec<NoteSearchHit>> {
1396 const RRF_K: usize = 60;
1397 let candidates = limit.saturating_mul(4).max(limit);
1398 let ns = token.namespace().as_str().to_owned();
1399
1400 let text_hits = self
1402 .text_for_notes(token)?
1403 .search(TextSearchRequest {
1404 query: query_text.to_string(),
1405 mode: TextQueryMode::Plain,
1406 filter: Some(TextFilter {
1407 namespaces: vec![ns.clone()],
1408 ..TextFilter::default()
1409 }),
1410 top_k: candidates,
1411 snippet_chars: 200,
1412 })
1413 .await?;
1414
1415 let vector_hits = if query_vector.is_some() || self.config().embedding_model.is_some() {
1417 self.vector_search(
1418 token,
1419 query_vector,
1420 Some(query_text),
1421 candidates,
1422 Some(SubstrateKind::Note),
1423 )
1424 .await?
1425 } else {
1426 vec![]
1427 };
1428
1429 let fuse_k = text_hits.len() + vector_hits.len();
1435 let fused = crate::fusion::rrf_fuse_k(text_hits, vector_hits, RRF_K, fuse_k)?;
1436
1437 let candidate_ids: Vec<Uuid> = fused.iter().map(|hit| hit.entity_id).collect();
1438 if candidate_ids.is_empty() {
1439 return Ok(vec![]);
1440 }
1441
1442 let note_store = self.notes(token)?;
1447 let mut alive_notes: HashMap<Uuid, Note> = HashMap::new();
1448 for id in &candidate_ids {
1449 if let Some(note) = note_store.get_note(*id).await? {
1450 if note.deleted_at.is_some() {
1451 continue;
1452 }
1453 if let Some(want_kind) = note_kind {
1454 if note.kind != want_kind {
1455 continue;
1456 }
1457 }
1458 alive_notes.insert(*id, note);
1459 }
1460 }
1461
1462 if !include_superseded && !alive_notes.is_empty() {
1465 let graph = self.graph(token)?;
1466 let mut superseded: std::collections::HashSet<Uuid> = std::collections::HashSet::new();
1467 for ¬e_id in alive_notes.keys() {
1468 let inbound = graph
1469 .neighbors(
1470 note_id,
1471 NeighborQuery {
1472 direction: Direction::In,
1473 relations: Some(vec![EdgeRelation::Supersedes]),
1474 limit: Some(1),
1475 min_weight: None,
1476 },
1477 )
1478 .await?;
1479 if !inbound.is_empty() {
1480 superseded.insert(note_id);
1481 }
1482 }
1483 alive_notes.retain(|id, _| !superseded.contains(id));
1484 }
1485
1486 let mut hits: Vec<NoteSearchHit> = fused
1488 .into_iter()
1489 .filter_map(|hit| {
1490 let note = alive_notes.get(&hit.entity_id)?;
1491 let salience = note.salience.unwrap_or(0.5);
1492 let weight = 0.5 + 0.5 * salience;
1493 let weighted = DeterministicScore::from_f64(hit.score.to_f64() * weight);
1494 Some(NoteSearchHit {
1495 note_id: hit.entity_id,
1496 score: weighted,
1497 title: hit.title.or_else(|| note_title(note)),
1498 snippet: hit.snippet.or_else(|| note_snippet(note)),
1499 })
1500 })
1501 .collect();
1502
1503 hits.sort_by(|a, b| b.score.cmp(&a.score).then(a.note_id.cmp(&b.note_id)));
1504 hits.truncate(limit as usize);
1505 Ok(hits)
1506 }
1507
1508 pub async fn resolve_prefix(
1515 &self,
1516 token: &NamespaceToken,
1517 prefix: &str,
1518 ) -> RuntimeResult<Option<Uuid>> {
1519 use khive_storage::types::{SqlStatement, SqlValue};
1520
1521 let ns = token.namespace().as_str().to_owned();
1522 let pattern = format!("{}%", prefix);
1523
1524 let tables = [
1525 ("entities", true),
1526 ("notes", true),
1527 ("events", false),
1528 ("graph_edges", false),
1529 ];
1530
1531 let mut matches: Vec<String> = Vec::new();
1532 let mut reader = self.sql().reader().await.map_err(RuntimeError::Storage)?;
1533
1534 for (table, has_deleted_at) in tables {
1535 let deleted_filter = if has_deleted_at {
1536 " AND deleted_at IS NULL"
1537 } else {
1538 ""
1539 };
1540 let sql = SqlStatement {
1541 sql: format!(
1542 "SELECT id FROM {table} WHERE id LIKE ?1 AND namespace = ?2{deleted_filter} LIMIT 2"
1543 ),
1544 params: vec![
1545 SqlValue::Text(pattern.clone()),
1546 SqlValue::Text(ns.clone()),
1547 ],
1548 label: Some("resolve_prefix".into()),
1549 };
1550 match reader.query_all(sql).await {
1551 Ok(rows) => {
1552 for row in rows {
1553 if let Some(col) = row.columns.first() {
1554 if let SqlValue::Text(s) = &col.value {
1555 matches.push(s.clone());
1556 }
1557 }
1558 }
1559 }
1560 Err(e) => {
1561 let msg = e.to_string();
1562 if msg.contains("no such table") {
1563 continue;
1564 }
1565 return Err(RuntimeError::Storage(e));
1566 }
1567 }
1568 if matches.len() > 1 {
1569 break;
1570 }
1571 }
1572
1573 match matches.len() {
1574 0 => Ok(None),
1575 1 => {
1576 let uuid = Uuid::from_str(&matches[0])
1577 .map_err(|e| RuntimeError::Internal(format!("stored UUID is invalid: {e}")))?;
1578 Ok(Some(uuid))
1579 }
1580 _ => {
1581 let uuids: Vec<uuid::Uuid> = matches
1582 .iter()
1583 .filter_map(|s| Uuid::from_str(s).ok())
1584 .collect();
1585 Err(RuntimeError::AmbiguousPrefix {
1586 prefix: prefix.to_string(),
1587 matches: uuids,
1588 })
1589 }
1590 }
1591 }
1592
1593 pub async fn resolve(
1598 &self,
1599 token: &NamespaceToken,
1600 id: Uuid,
1601 ) -> RuntimeResult<Option<Resolved>> {
1602 let ns = token.namespace().as_str();
1603
1604 match self.get_entity(token, id).await {
1606 Ok(entity) => return Ok(Some(Resolved::Entity(entity))),
1607 Err(RuntimeError::NotFound(_) | RuntimeError::NamespaceMismatch { .. }) => {}
1608 Err(e) => return Err(e),
1609 }
1610
1611 if let Some(note) = self.notes(token)?.get_note(id).await? {
1613 if Self::ensure_namespace(¬e.namespace, ns).is_ok() {
1614 return Ok(Some(Resolved::Note(note)));
1615 }
1616 }
1617
1618 if let Some(event) = self.events(token)?.get_event(id).await? {
1620 if Self::ensure_namespace(&event.namespace, ns).is_ok() {
1621 return Ok(Some(Resolved::Event(event)));
1622 }
1623 }
1624
1625 Ok(None)
1626 }
1627
1628 pub async fn delete_note(
1638 &self,
1639 token: &NamespaceToken,
1640 id: Uuid,
1641 hard: bool,
1642 ) -> RuntimeResult<bool> {
1643 let ns = token.namespace().as_str();
1644 let note_store = self.notes(token)?;
1645 let note = match note_store.get_note(id).await? {
1646 Some(n) => n,
1647 None => return Ok(false),
1648 };
1649 if Self::ensure_namespace(¬e.namespace, ns).is_err() {
1650 return Ok(false);
1651 }
1652 let mode = if hard {
1653 DeleteMode::Hard
1654 } else {
1655 DeleteMode::Soft
1656 };
1657
1658 if hard {
1660 let graph = self.graph(token)?;
1661 for direction in [Direction::Out, Direction::In] {
1662 let hits = graph
1663 .neighbors(
1664 id,
1665 NeighborQuery {
1666 direction,
1667 relations: None,
1668 limit: None,
1669 min_weight: None,
1670 },
1671 )
1672 .await?;
1673 for hit in hits {
1674 graph
1675 .delete_edge(LinkId::from(hit.edge_id), DeleteMode::Hard)
1676 .await?;
1677 }
1678 }
1679 let ns_str = ns.to_string();
1680 self.text_for_notes(token)?
1681 .delete_document(&ns_str, id)
1682 .await?;
1683 for model_name in self.registered_embedding_model_names() {
1687 self.vectors_for_model(token, &model_name)?
1688 .delete(id)
1689 .await?;
1690 }
1691 }
1692
1693 let deleted = note_store.delete_note(id, mode).await?;
1694 if !hard && deleted {
1695 let ns_str = ns.to_string();
1696 self.text_for_notes(token)?
1697 .delete_document(&ns_str, id)
1698 .await?;
1699 for model_name in self.registered_embedding_model_names() {
1700 self.vectors_for_model(token, &model_name)?
1701 .delete(id)
1702 .await?;
1703 }
1704 }
1705 if deleted {
1706 let event_store = self.events(token)?;
1707 let ns_str = ns.to_string();
1708 let event = khive_storage::event::Event::new(
1709 ns_str.clone(),
1710 "delete",
1711 EventKind::NoteDeleted,
1712 SubstrateKind::Note,
1713 "",
1714 )
1715 .with_target(id)
1716 .with_payload(serde_json::json!({"id": id, "namespace": ns_str, "hard": hard}));
1717 event_store.append_event(event).await.map_err(|e| {
1718 RuntimeError::Internal(format!("delete_note: event store write failed: {e}"))
1719 })?;
1720 }
1721 Ok(deleted)
1722 }
1723}
1724
1725#[derive(Clone, Debug, Serialize)]
1727pub struct QueryResult {
1728 pub rows: Vec<SqlRow>,
1729 #[serde(skip_serializing_if = "Vec::is_empty")]
1730 pub warnings: Vec<String>,
1731}
1732
1733impl KhiveRuntime {
1734 pub async fn query(&self, token: &NamespaceToken, query: &str) -> RuntimeResult<Vec<SqlRow>> {
1742 Ok(self
1743 .query_with_metadata(token, query, khive_query::CompileOptions::default())
1744 .await?
1745 .rows)
1746 }
1747
1748 pub async fn query_with_metadata(
1750 &self,
1751 token: &NamespaceToken,
1752 query: &str,
1753 mut opts: khive_query::CompileOptions,
1754 ) -> RuntimeResult<QueryResult> {
1755 use khive_query::QueryValue;
1756 use khive_storage::types::SqlValue;
1757
1758 let ns = token.namespace().as_str();
1759 let ast = khive_query::parse_auto(query)?;
1760 opts.scopes = vec![ns.to_string()];
1761 let compiled = khive_query::compile(&ast, &opts)?;
1762 let warnings = compiled.warnings;
1763
1764 let params: Vec<SqlValue> = compiled
1767 .params
1768 .into_iter()
1769 .map(|qv| match qv {
1770 QueryValue::Null => SqlValue::Null,
1771 QueryValue::Integer(n) => SqlValue::Integer(n),
1772 QueryValue::Float(f) => SqlValue::Float(f),
1773 QueryValue::Text(s) => SqlValue::Text(s),
1774 QueryValue::Blob(b) => SqlValue::Blob(b),
1775 })
1776 .collect();
1777
1778 let mut reader = self.sql().reader().await?;
1779 let stmt = SqlStatement {
1780 sql: compiled.sql,
1781 params,
1782 label: None,
1783 };
1784 let rows = reader.query_all(stmt).await?;
1785 Ok(QueryResult { rows, warnings })
1786 }
1787
1788 pub async fn delete_entity(
1797 &self,
1798 token: &NamespaceToken,
1799 id: Uuid,
1800 hard: bool,
1801 ) -> RuntimeResult<bool> {
1802 let entity = match self.entities(token)?.get_entity(id).await? {
1803 Some(e) => e,
1804 None => return Ok(false),
1805 };
1806 Self::ensure_namespace(&entity.namespace, token.namespace().as_str())?;
1807 let mode = if hard {
1808 DeleteMode::Hard
1809 } else {
1810 DeleteMode::Soft
1811 };
1812
1813 if hard {
1815 let graph = self.graph(token)?;
1816 for direction in [Direction::Out, Direction::In] {
1817 let hits = graph
1818 .neighbors(
1819 id,
1820 NeighborQuery {
1821 direction,
1822 relations: None,
1823 limit: None,
1824 min_weight: None,
1825 },
1826 )
1827 .await?;
1828 for hit in hits {
1829 graph
1830 .delete_edge(LinkId::from(hit.edge_id), DeleteMode::Hard)
1831 .await?;
1832 }
1833 }
1834 self.remove_from_indexes(token, id).await?;
1835 }
1836
1837 let deleted = self.entities(token)?.delete_entity(id, mode).await?;
1838 if !hard && deleted {
1839 self.remove_from_indexes(token, id).await?;
1840 }
1841 if deleted {
1842 let event_store = self.events(token)?;
1843 let ns = entity.namespace.clone();
1844 let event = khive_storage::event::Event::new(
1845 ns.clone(),
1846 "delete",
1847 EventKind::EntityDeleted,
1848 SubstrateKind::Entity,
1849 "",
1850 )
1851 .with_target(id)
1852 .with_payload(serde_json::json!({"id": id, "namespace": ns, "hard": hard}));
1853 event_store.append_event(event).await.map_err(|e| {
1854 RuntimeError::Internal(format!("delete_entity: event store write failed: {e}"))
1855 })?;
1856 }
1857 Ok(deleted)
1858 }
1859
1860 pub async fn count_entities(
1862 &self,
1863 token: &NamespaceToken,
1864 kind: Option<&str>,
1865 ) -> RuntimeResult<u64> {
1866 let filter = EntityFilter {
1867 kinds: match kind {
1868 Some(k) => vec![k.to_string()],
1869 None => vec![],
1870 },
1871 ..Default::default()
1872 };
1873 Ok(self
1874 .entities(token)?
1875 .count_entities(token.namespace().as_str(), filter)
1876 .await?)
1877 }
1878
1879 pub async fn get_edge(
1886 &self,
1887 token: &NamespaceToken,
1888 edge_id: Uuid,
1889 ) -> RuntimeResult<Option<Edge>> {
1890 let mut reader = self.sql().reader().await?;
1891 let record_ns = reader
1892 .query_scalar(SqlStatement {
1893 sql: "SELECT namespace FROM graph_edges \
1894 WHERE id = ?1 AND deleted_at IS NULL LIMIT 1"
1895 .into(),
1896 params: vec![SqlValue::Text(edge_id.to_string())],
1897 label: Some("get_edge_namespace".into()),
1898 })
1899 .await?;
1900
1901 let Some(SqlValue::Text(record_ns)) = record_ns else {
1902 return Ok(None);
1903 };
1904 if Self::ensure_namespace(&record_ns, token.namespace().as_str()).is_err() {
1906 return Ok(None);
1907 }
1908
1909 Ok(self.graph(token)?.get_edge(LinkId::from(edge_id)).await?)
1910 }
1911
1912 pub async fn list_edges(
1914 &self,
1915 token: &NamespaceToken,
1916 filter: crate::curation::EdgeListFilter,
1917 limit: u32,
1918 ) -> RuntimeResult<Vec<Edge>> {
1919 let limit = limit.clamp(1, 1000);
1920 let page = self
1921 .graph(token)?
1922 .query_edges(
1923 filter.into(),
1924 vec![SortOrder {
1925 field: EdgeSortField::CreatedAt,
1926 direction: khive_storage::types::SortDirection::Asc,
1927 }],
1928 PageRequest { offset: 0, limit },
1929 )
1930 .await?;
1931 Ok(page.items)
1932 }
1933
1934 pub async fn update_edge(
1947 &self,
1948 token: &NamespaceToken,
1949 edge_id: Uuid,
1950 patch: crate::curation::EdgePatch,
1951 ) -> RuntimeResult<Edge> {
1952 let graph = self.graph(token)?;
1953 let mut edge = graph
1954 .get_edge(LinkId::from(edge_id))
1955 .await?
1956 .ok_or_else(|| crate::RuntimeError::NotFound(format!("edge {edge_id}")))?;
1957
1958 let mut changed_fields: Vec<&'static str> = Vec::new();
1959 if let Some(r) = patch.relation {
1960 self.validate_edge_relation_endpoints(token, edge.source_id, edge.target_id, r)
1962 .await?;
1963 edge.relation = r;
1964 changed_fields.push("relation");
1965 }
1966 if let Some(w) = patch.weight {
1967 if !w.is_finite() || !(0.0..=1.0).contains(&w) {
1970 return Err(RuntimeError::InvalidInput(format!(
1971 "edge weight must be a finite value in [0.0, 1.0]; got {w}"
1972 )));
1973 }
1974 edge.weight = w;
1975 changed_fields.push("weight");
1976 }
1977 if let Some(props) = patch.properties {
1978 edge.metadata = Some(props);
1979 }
1980
1981 let (canon_src, canon_tgt) =
1991 canonical_edge_endpoints(edge.relation, edge.source_id, edge.target_id);
1992
1993 if edge.relation.is_symmetric() {
1994 let ns = token.namespace().as_str().to_string();
1996 let edge_id_str = edge_id.to_string();
1997 let relation_str = edge.relation.to_string();
1998 let canon_src_str = canon_src.to_string();
1999 let canon_tgt_str = canon_tgt.to_string();
2000 let weight = edge.weight;
2001 let metadata = edge
2002 .metadata
2003 .as_ref()
2004 .map(|v| serde_json::to_string(v).unwrap_or_default());
2005 let target_backend = edge.target_backend.clone();
2006
2007 let pool = self.backend().pool_arc();
2008
2009 let surviving_id: Option<String> = tokio::task::spawn_blocking(move || {
2013 let guard = pool.writer()?;
2014 guard.transaction(|conn| {
2015 let now_ts = chrono::Utc::now().timestamp();
2016
2017 let conflict_id: Option<String> = conn
2021 .query_row(
2022 "SELECT id FROM graph_edges \
2023 WHERE namespace = ?1 AND source_id = ?2 AND target_id = ?3 \
2024 AND relation = ?4 AND id != ?5",
2025 rusqlite::params![
2026 &ns,
2027 &canon_src_str,
2028 &canon_tgt_str,
2029 &relation_str,
2030 &edge_id_str,
2031 ],
2032 |row| row.get(0),
2033 )
2034 .optional()
2035 .map_err(SqliteError::Rusqlite)?;
2036
2037 if let Some(existing_id) = conflict_id {
2038 conn.execute(
2043 "DELETE FROM graph_edges WHERE namespace = ?1 AND id = ?2",
2044 rusqlite::params![&ns, &edge_id_str],
2045 )
2046 .map_err(SqliteError::Rusqlite)?;
2047 conn.execute(
2048 "UPDATE graph_edges SET \
2049 weight = ?1, updated_at = ?2, deleted_at = NULL, \
2050 target_backend = ?3, metadata = ?4 \
2051 WHERE namespace = ?5 AND id = ?6",
2052 rusqlite::params![
2053 weight,
2054 now_ts,
2055 target_backend,
2056 metadata,
2057 &ns,
2058 &existing_id,
2059 ],
2060 )
2061 .map_err(SqliteError::Rusqlite)?;
2062 Ok(Some(existing_id))
2063 } else {
2064 conn.execute(
2067 "UPDATE graph_edges SET \
2068 source_id = ?1, target_id = ?2, relation = ?3, \
2069 weight = ?4, updated_at = ?5, metadata = ?6 \
2070 WHERE namespace = ?7 AND id = ?8",
2071 rusqlite::params![
2072 &canon_src_str,
2073 &canon_tgt_str,
2074 &relation_str,
2075 weight,
2076 now_ts,
2077 metadata,
2078 &ns,
2079 &edge_id_str,
2080 ],
2081 )
2082 .map_err(SqliteError::Rusqlite)?;
2083 Ok(None)
2084 }
2085 })
2086 })
2087 .await
2088 .map_err(|e| RuntimeError::Internal(format!("update_edge: spawn_blocking join: {e}")))?
2089 .map_err(RuntimeError::Sqlite)?;
2090
2091 if let Some(sid) = surviving_id {
2092 let surviving_uuid = Uuid::parse_str(&sid).map_err(|e| {
2095 RuntimeError::Internal(format!("update_edge: surviving id parse failed: {e}"))
2096 })?;
2097 edge = self
2098 .get_edge(token, surviving_uuid)
2099 .await?
2100 .ok_or_else(|| {
2101 RuntimeError::Internal(format!(
2102 "update_edge: surviving canonical row {surviving_uuid} vanished after update"
2103 ))
2104 })?;
2105 } else {
2106 edge.source_id = canon_src;
2108 edge.target_id = canon_tgt;
2109 }
2110 } else {
2111 graph.upsert_edge(edge.clone()).await?;
2112 }
2113
2114 let event_store = self.events(token)?;
2115 let ns = token.namespace().as_str().to_string();
2116 let event = khive_storage::event::Event::new(
2117 ns.clone(),
2118 "update",
2119 EventKind::EdgeUpdated,
2120 SubstrateKind::Entity,
2121 "",
2122 )
2123 .with_target(edge_id)
2124 .with_payload(
2125 serde_json::json!({"id": edge_id, "namespace": ns, "changed_fields": changed_fields}),
2126 );
2127 event_store.append_event(event).await.map_err(|e| {
2128 RuntimeError::Internal(format!("update_edge: event store write failed: {e}"))
2129 })?;
2130
2131 Ok(edge)
2132 }
2133
2134 pub async fn delete_edge(
2145 &self,
2146 token: &NamespaceToken,
2147 edge_id: Uuid,
2148 hard: bool,
2149 ) -> RuntimeResult<bool> {
2150 let graph = self.graph(token)?;
2151 let mode = if hard {
2152 DeleteMode::Hard
2153 } else {
2154 DeleteMode::Soft
2155 };
2156
2157 if graph.get_edge(LinkId::from(edge_id)).await?.is_none() {
2162 return Ok(false);
2163 }
2164
2165 let inbound = graph
2167 .neighbors(
2168 edge_id,
2169 NeighborQuery {
2170 direction: Direction::In,
2171 relations: None,
2172 limit: None,
2173 min_weight: None,
2174 },
2175 )
2176 .await?;
2177 for hit in inbound {
2178 graph
2179 .delete_edge(LinkId::from(hit.edge_id), DeleteMode::Hard)
2180 .await?;
2181 }
2182
2183 let deleted = graph.delete_edge(LinkId::from(edge_id), mode).await?;
2184 if deleted {
2185 let event_store = self.events(token)?;
2186 let ns = token.namespace().as_str().to_string();
2187 let event = khive_storage::event::Event::new(
2188 ns.clone(),
2189 "delete",
2190 EventKind::EdgeDeleted,
2191 SubstrateKind::Entity,
2192 "",
2193 )
2194 .with_target(edge_id)
2195 .with_payload(serde_json::json!({"id": edge_id, "namespace": ns, "hard": hard}));
2196 event_store.append_event(event).await.map_err(|e| {
2197 RuntimeError::Internal(format!("delete_edge: event store write failed: {e}"))
2198 })?;
2199 }
2200 Ok(deleted)
2201 }
2202
2203 pub async fn count_edges(
2205 &self,
2206 token: &NamespaceToken,
2207 filter: crate::curation::EdgeListFilter,
2208 ) -> RuntimeResult<u64> {
2209 Ok(self.graph(token)?.count_edges(filter.into()).await?)
2210 }
2211
2212 pub async fn build_edge(&self, token: &NamespaceToken, spec: &LinkSpec) -> RuntimeResult<Edge> {
2223 let ns_str = match &spec.namespace {
2224 Some(s) => {
2225 let spec_ns = crate::Namespace::parse(s)
2226 .map_err(|e| RuntimeError::InvalidInput(format!("invalid namespace: {e}")))?;
2227 if &spec_ns != token.namespace() {
2228 return Err(RuntimeError::InvalidInput(
2229 "LinkSpec namespace does not match token namespace".into(),
2230 ));
2231 }
2232 s.as_str()
2233 }
2234 None => token.namespace().as_str(),
2235 };
2236 self.validate_edge_relation_endpoints(token, spec.source_id, spec.target_id, spec.relation)
2237 .await?;
2238 let (source_id, target_id) =
2239 canonical_edge_endpoints(spec.relation, spec.source_id, spec.target_id);
2240 let metadata = if spec.relation == EdgeRelation::DependsOn {
2241 match (
2242 self.resolve(token, source_id).await?,
2243 self.resolve(token, target_id).await?,
2244 ) {
2245 (Some(Resolved::Entity(src_e)), Some(Resolved::Entity(tgt_e))) => {
2246 merge_dependency_kind(&src_e.kind, &tgt_e.kind, spec.metadata.clone())
2247 }
2248 _ => spec.metadata.clone(),
2249 }
2250 } else {
2251 spec.metadata.clone()
2252 };
2253 validate_edge_metadata(spec.relation, metadata.as_ref())?;
2254 let now = chrono::Utc::now();
2255 Ok(Edge {
2256 id: LinkId::from(Uuid::new_v4()),
2257 namespace: ns_str.to_string(),
2258 source_id,
2259 target_id,
2260 relation: spec.relation,
2261 weight: spec.weight,
2262 created_at: now,
2263 updated_at: now,
2264 deleted_at: None,
2265 metadata,
2266 target_backend: None,
2267 })
2268 }
2269
2270 pub async fn link_many(
2287 &self,
2288 token: &NamespaceToken,
2289 specs: Vec<LinkSpec>,
2290 ) -> RuntimeResult<Vec<Edge>> {
2291 if specs.is_empty() {
2292 return Ok(vec![]);
2293 }
2294 let mut edges = Vec::with_capacity(specs.len());
2295 for spec in &specs {
2296 edges.push(self.build_edge(token, spec).await?);
2297 }
2298 self.graph(token)?.upsert_edges(edges.clone()).await?;
2299
2300 let mut persisted = Vec::with_capacity(edges.len());
2303 for edge in &edges {
2304 let row = self
2305 .list_edges(
2306 token,
2307 crate::curation::EdgeListFilter {
2308 source_id: Some(edge.source_id),
2309 target_id: Some(edge.target_id),
2310 relations: vec![edge.relation],
2311 ..Default::default()
2312 },
2313 1,
2314 )
2315 .await?
2316 .into_iter()
2317 .next()
2318 .ok_or_else(|| {
2319 crate::RuntimeError::Internal(format!(
2320 "upsert_edges succeeded but natural-key lookup for ({}, {}, {}) returned nothing",
2321 edge.source_id, edge.target_id, edge.relation.as_str()
2322 ))
2323 })?;
2324 persisted.push(row);
2325 }
2326 Ok(persisted)
2327 }
2328}
2329
2330#[derive(Clone, Debug)]
2333pub struct LinkSpec {
2334 pub namespace: Option<String>,
2335 pub source_id: Uuid,
2336 pub target_id: Uuid,
2337 pub relation: EdgeRelation,
2338 pub weight: f64,
2339 pub metadata: Option<serde_json::Value>,
2340}
2341
2342#[cfg(test)]
2348mod tests {
2349 use super::*;
2350 use crate::curation::EdgeListFilter;
2351 use crate::embedder_registry::EmbedderProvider;
2352 use crate::error::RuntimeError;
2353 use crate::runtime::{KhiveRuntime, NamespaceToken};
2354 use crate::Namespace;
2355 use async_trait::async_trait;
2356 use lattice_embed::{EmbedError, EmbeddingModel, EmbeddingService};
2357 use std::sync::atomic::{AtomicUsize, Ordering};
2358 use std::sync::Arc;
2359
2360 fn rt() -> KhiveRuntime {
2361 KhiveRuntime::memory().unwrap()
2362 }
2363
2364 struct ConstVecService {
2372 dims: usize,
2373 }
2374
2375 #[async_trait]
2376 impl EmbeddingService for ConstVecService {
2377 async fn embed(
2378 &self,
2379 texts: &[String],
2380 _model: EmbeddingModel,
2381 ) -> std::result::Result<Vec<Vec<f32>>, EmbedError> {
2382 Ok(texts.iter().map(|_| vec![1.0_f32; self.dims]).collect())
2383 }
2384
2385 fn supports_model(&self, _model: EmbeddingModel) -> bool {
2386 true
2387 }
2388
2389 fn name(&self) -> &'static str {
2390 "const-vec"
2391 }
2392 }
2393
2394 struct ConstVecProvider {
2395 provider_name: String,
2396 dims: usize,
2397 pub build_count: Arc<AtomicUsize>,
2398 }
2399
2400 impl ConstVecProvider {
2401 fn new(name: &str, dims: usize) -> (Self, Arc<AtomicUsize>) {
2402 let counter = Arc::new(AtomicUsize::new(0));
2403 let provider = Self {
2404 provider_name: name.to_owned(),
2405 dims,
2406 build_count: Arc::clone(&counter),
2407 };
2408 (provider, counter)
2409 }
2410 }
2411
2412 #[async_trait]
2413 impl EmbedderProvider for ConstVecProvider {
2414 fn name(&self) -> &str {
2415 &self.provider_name
2416 }
2417
2418 fn dimensions(&self) -> usize {
2419 self.dims
2420 }
2421
2422 async fn build(&self) -> crate::error::RuntimeResult<Arc<dyn EmbeddingService>> {
2423 self.build_count.fetch_add(1, Ordering::SeqCst);
2424 Ok(Arc::new(ConstVecService { dims: self.dims }))
2425 }
2426 }
2427
2428 #[tokio::test]
2437 async fn custom_embedder_only_runtime_fanout_stores_vector() {
2438 const MODEL_NAME: &str = "test-custom-encoder";
2439 const DIMS: usize = 8;
2440
2441 let rt = KhiveRuntime::memory().unwrap();
2443
2444 let (provider, _counter) = ConstVecProvider::new(MODEL_NAME, DIMS);
2446 rt.register_embedder(provider);
2447
2448 assert!(rt.config().embedding_model.is_none());
2450 assert_eq!(rt.registered_embedding_model_names(), vec![MODEL_NAME]);
2451
2452 let tok = NamespaceToken::local();
2453
2454 let note = rt
2456 .create_note(
2457 &tok,
2458 "memory",
2459 None,
2460 "custom embedder integration test content",
2461 Some(0.7),
2462 None,
2463 vec![],
2464 )
2465 .await
2466 .expect("create_note with custom-only embedder must succeed");
2467
2468 use khive_storage::types::VectorSearchRequest;
2470 let query_vec = vec![1.0_f32; DIMS];
2471 let hits = rt
2472 .vectors_for_model(&tok, MODEL_NAME)
2473 .expect("vector store for custom model must be accessible")
2474 .search(VectorSearchRequest {
2475 query_vectors: vec![query_vec],
2476 top_k: 5,
2477 namespace: Some(tok.namespace().as_str().to_string()),
2478 kind: Some(khive_types::SubstrateKind::Note),
2479 embedding_model: Some(MODEL_NAME.to_string()),
2480 filter: None,
2481 backend_hints: None,
2482 })
2483 .await
2484 .expect("vector search succeeds");
2485
2486 assert!(
2487 hits.iter().any(|h| h.subject_id == note.id),
2488 "custom embedder must have written a vector for note {}: hits={hits:?}",
2489 note.id
2490 );
2491 }
2492
2493 #[tokio::test]
2501 async fn embed_with_model_accepts_custom_provider_name() {
2502 const MODEL_NAME: &str = "my-custom-enc";
2503 const DIMS: usize = 4;
2504
2505 let rt = KhiveRuntime::memory().unwrap();
2506 let (provider, _counter) = ConstVecProvider::new(MODEL_NAME, DIMS);
2507 rt.register_embedder(provider);
2508
2509 let result = rt
2510 .embed_with_model(MODEL_NAME, "hello world")
2511 .await
2512 .expect("embed_with_model must accept custom provider names");
2513
2514 assert_eq!(
2515 result.len(),
2516 DIMS,
2517 "embedding dimension must match provider"
2518 );
2519 assert!(
2520 result.iter().all(|&v| (v - 1.0_f32).abs() < 1e-6),
2521 "ConstVecService must produce all-ones vector; got: {result:?}"
2522 );
2523 }
2524
2525 #[tokio::test]
2528 async fn embed_with_model_rejects_unregistered_name() {
2529 let rt = KhiveRuntime::memory().unwrap();
2530 let result = rt.embed_with_model("nonexistent-model", "hello").await;
2531 assert!(
2532 matches!(result.unwrap_err(), RuntimeError::UnknownModel(ref n) if n == "nonexistent-model"),
2533 "unregistered model name must return UnknownModel"
2534 );
2535 }
2536
2537 #[tokio::test]
2538 async fn update_edge_changes_weight() {
2539 let rt = rt();
2540 let tok = NamespaceToken::local();
2541 let a = rt
2542 .create_entity(&tok, "concept", None, "A", None, None, vec![])
2543 .await
2544 .unwrap();
2545 let b = rt
2546 .create_entity(&tok, "concept", None, "B", None, None, vec![])
2547 .await
2548 .unwrap();
2549 let edge = rt
2550 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
2551 .await
2552 .unwrap();
2553 let edge_id: Uuid = edge.id.into();
2554
2555 let updated = rt
2556 .update_edge(
2557 &tok,
2558 edge_id,
2559 crate::curation::EdgePatch {
2560 weight: Some(0.5),
2561 ..Default::default()
2562 },
2563 )
2564 .await
2565 .unwrap();
2566 assert!((updated.weight - 0.5).abs() < 0.001);
2567 }
2568
2569 #[tokio::test]
2570 async fn update_edge_changes_relation() {
2571 let rt = rt();
2572 let tok = NamespaceToken::local();
2573 let a = rt
2574 .create_entity(&tok, "concept", None, "A", None, None, vec![])
2575 .await
2576 .unwrap();
2577 let b = rt
2578 .create_entity(&tok, "concept", None, "B", None, None, vec![])
2579 .await
2580 .unwrap();
2581 let edge = rt
2582 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
2583 .await
2584 .unwrap();
2585 let edge_id: Uuid = edge.id.into();
2586
2587 let updated = rt
2588 .update_edge(
2589 &tok,
2590 edge_id,
2591 crate::curation::EdgePatch {
2592 relation: Some(EdgeRelation::VariantOf),
2593 ..Default::default()
2594 },
2595 )
2596 .await
2597 .unwrap();
2598 assert_eq!(updated.relation, EdgeRelation::VariantOf);
2599 }
2600
2601 #[tokio::test]
2606 async fn update_edge_annotates_note_to_entity_set_supersedes_returns_invalid_input() {
2607 let rt = rt();
2608 let tok = NamespaceToken::local();
2609 let note = rt
2610 .create_note(&tok, "observation", None, "a note", Some(0.5), None, vec![])
2611 .await
2612 .unwrap();
2613 let entity = rt
2614 .create_entity(&tok, "concept", None, "E", None, None, vec![])
2615 .await
2616 .unwrap();
2617 let edge = rt
2619 .link(&tok, note.id, entity.id, EdgeRelation::Annotates, 1.0, None)
2620 .await
2621 .unwrap();
2622 let edge_id: Uuid = edge.id.into();
2623
2624 let result = rt
2626 .update_edge(
2627 &tok,
2628 edge_id,
2629 crate::curation::EdgePatch {
2630 relation: Some(EdgeRelation::Supersedes),
2631 ..Default::default()
2632 },
2633 )
2634 .await;
2635 assert!(
2636 matches!(result, Err(RuntimeError::InvalidInput(_))),
2637 "update to Supersedes on note→entity edge must return InvalidInput, got {result:?}"
2638 );
2639
2640 let fetched = rt.get_edge(&tok, edge_id).await.unwrap().unwrap();
2642 assert_eq!(
2643 fetched.relation,
2644 EdgeRelation::Annotates,
2645 "edge relation must be unchanged after failed update"
2646 );
2647 }
2648
2649 #[tokio::test]
2652 async fn update_edge_entity_to_entity_set_annotates_returns_invalid_input() {
2653 let rt = rt();
2654 let tok = NamespaceToken::local();
2655 let a = rt
2656 .create_entity(&tok, "concept", None, "A", None, None, vec![])
2657 .await
2658 .unwrap();
2659 let b = rt
2660 .create_entity(&tok, "concept", None, "B", None, None, vec![])
2661 .await
2662 .unwrap();
2663 let edge = rt
2664 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
2665 .await
2666 .unwrap();
2667 let edge_id: Uuid = edge.id.into();
2668
2669 let result = rt
2670 .update_edge(
2671 &tok,
2672 edge_id,
2673 crate::curation::EdgePatch {
2674 relation: Some(EdgeRelation::Annotates),
2675 ..Default::default()
2676 },
2677 )
2678 .await;
2679 assert!(
2680 matches!(result, Err(RuntimeError::InvalidInput(_))),
2681 "update to Annotates on entity→entity edge must return InvalidInput, got {result:?}"
2682 );
2683 }
2684
2685 #[tokio::test]
2688 async fn update_edge_entity_to_entity_set_supersedes_succeeds() {
2689 let rt = rt();
2690 let tok = NamespaceToken::local();
2691 let a = rt
2692 .create_entity(&tok, "concept", None, "A", None, None, vec![])
2693 .await
2694 .unwrap();
2695 let b = rt
2696 .create_entity(&tok, "concept", None, "B", None, None, vec![])
2697 .await
2698 .unwrap();
2699 let edge = rt
2700 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
2701 .await
2702 .unwrap();
2703 let edge_id: Uuid = edge.id.into();
2704
2705 let updated = rt
2706 .update_edge(
2707 &tok,
2708 edge_id,
2709 crate::curation::EdgePatch {
2710 relation: Some(EdgeRelation::Supersedes),
2711 ..Default::default()
2712 },
2713 )
2714 .await
2715 .unwrap();
2716 assert_eq!(updated.relation, EdgeRelation::Supersedes);
2717
2718 let fetched = rt.get_edge(&tok, edge_id).await.unwrap().unwrap();
2720 assert_eq!(fetched.relation, EdgeRelation::Supersedes);
2721 }
2722
2723 #[tokio::test]
2725 async fn update_edge_weight_only_skips_validation() {
2726 let rt = rt();
2727 let tok = NamespaceToken::local();
2728 let a = rt
2729 .create_entity(&tok, "concept", None, "A", None, None, vec![])
2730 .await
2731 .unwrap();
2732 let b = rt
2733 .create_entity(&tok, "concept", None, "B", None, None, vec![])
2734 .await
2735 .unwrap();
2736 let edge = rt
2737 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
2738 .await
2739 .unwrap();
2740 let edge_id: Uuid = edge.id.into();
2741
2742 let updated = rt
2743 .update_edge(
2744 &tok,
2745 edge_id,
2746 crate::curation::EdgePatch {
2747 weight: Some(0.3),
2748 ..Default::default()
2749 },
2750 )
2751 .await
2752 .unwrap();
2753 assert_eq!(updated.relation, EdgeRelation::Extends);
2754 assert!((updated.weight - 0.3).abs() < 0.001);
2755 }
2756
2757 #[tokio::test]
2759 async fn update_edge_same_class_relation_change_succeeds() {
2760 let rt = rt();
2761 let tok = NamespaceToken::local();
2762 let a = rt
2763 .create_entity(&tok, "concept", None, "A", None, None, vec![])
2764 .await
2765 .unwrap();
2766 let b = rt
2767 .create_entity(&tok, "concept", None, "B", None, None, vec![])
2768 .await
2769 .unwrap();
2770 let edge = rt
2771 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
2772 .await
2773 .unwrap();
2774 let edge_id: Uuid = edge.id.into();
2775
2776 let updated = rt
2777 .update_edge(
2778 &tok,
2779 edge_id,
2780 crate::curation::EdgePatch {
2781 relation: Some(EdgeRelation::VariantOf),
2782 ..Default::default()
2783 },
2784 )
2785 .await
2786 .unwrap();
2787 assert_eq!(updated.relation, EdgeRelation::VariantOf);
2788 }
2789
2790 #[tokio::test]
2791 async fn list_edges_filters_by_relation() {
2792 let rt = rt();
2793 let tok = NamespaceToken::local();
2794 let a = rt
2795 .create_entity(&tok, "concept", None, "A", None, None, vec![])
2796 .await
2797 .unwrap();
2798 let b = rt
2799 .create_entity(&tok, "concept", None, "B", None, None, vec![])
2800 .await
2801 .unwrap();
2802 let c = rt
2803 .create_entity(&tok, "concept", None, "C", None, None, vec![])
2804 .await
2805 .unwrap();
2806
2807 rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
2808 .await
2809 .unwrap();
2810 rt.link(&tok, a.id, c.id, EdgeRelation::Enables, 1.0, None)
2811 .await
2812 .unwrap();
2813
2814 let filter = EdgeListFilter {
2815 relations: vec![EdgeRelation::Extends],
2816 ..Default::default()
2817 };
2818 let edges = rt.list_edges(&tok, filter, 100).await.unwrap();
2819 assert_eq!(edges.len(), 1);
2820 assert_eq!(edges[0].relation, EdgeRelation::Extends);
2821 }
2822
2823 #[tokio::test]
2824 async fn list_edges_filters_by_source() {
2825 let rt = rt();
2826 let tok = NamespaceToken::local();
2827 let a = rt
2828 .create_entity(&tok, "concept", None, "A", None, None, vec![])
2829 .await
2830 .unwrap();
2831 let b = rt
2832 .create_entity(&tok, "concept", None, "B", None, None, vec![])
2833 .await
2834 .unwrap();
2835 let c = rt
2836 .create_entity(&tok, "concept", None, "C", None, None, vec![])
2837 .await
2838 .unwrap();
2839 let d = rt
2840 .create_entity(&tok, "concept", None, "D", None, None, vec![])
2841 .await
2842 .unwrap();
2843
2844 rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
2845 .await
2846 .unwrap();
2847 rt.link(&tok, c.id, d.id, EdgeRelation::Extends, 1.0, None)
2848 .await
2849 .unwrap();
2850
2851 let filter = EdgeListFilter {
2852 source_id: Some(a.id),
2853 ..Default::default()
2854 };
2855 let edges = rt.list_edges(&tok, filter, 100).await.unwrap();
2856 assert_eq!(edges.len(), 1);
2857 let src: Uuid = edges[0].source_id;
2858 assert_eq!(src, a.id);
2859 }
2860
2861 #[tokio::test]
2862 async fn delete_edge_removes_from_storage() {
2863 let rt = rt();
2864 let tok = NamespaceToken::local();
2865 let a = rt
2866 .create_entity(&tok, "concept", None, "A", None, None, vec![])
2867 .await
2868 .unwrap();
2869 let b = rt
2870 .create_entity(&tok, "concept", None, "B", None, None, vec![])
2871 .await
2872 .unwrap();
2873 let edge = rt
2874 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
2875 .await
2876 .unwrap();
2877 let edge_id: Uuid = edge.id.into();
2878
2879 let deleted = rt.delete_edge(&tok, edge_id, true).await.unwrap();
2880 assert!(deleted);
2881
2882 let fetched = rt.get_edge(&tok, edge_id).await.unwrap();
2883 assert!(fetched.is_none(), "edge should be gone after delete");
2884 }
2885
2886 #[tokio::test]
2887 async fn count_edges_matches_filter() {
2888 let rt = rt();
2889 let tok = NamespaceToken::local();
2890 let a = rt
2891 .create_entity(&tok, "concept", None, "A", None, None, vec![])
2892 .await
2893 .unwrap();
2894 let b = rt
2895 .create_entity(&tok, "concept", None, "B", None, None, vec![])
2896 .await
2897 .unwrap();
2898 let c = rt
2899 .create_entity(&tok, "concept", None, "C", None, None, vec![])
2900 .await
2901 .unwrap();
2902
2903 rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
2904 .await
2905 .unwrap();
2906 rt.link(&tok, a.id, c.id, EdgeRelation::Enables, 1.0, None)
2907 .await
2908 .unwrap();
2909
2910 let all = rt
2911 .count_edges(&tok, EdgeListFilter::default())
2912 .await
2913 .unwrap();
2914 assert_eq!(all, 2);
2915
2916 let just_extends = rt
2917 .count_edges(
2918 &tok,
2919 EdgeListFilter {
2920 relations: vec![EdgeRelation::Extends],
2921 ..Default::default()
2922 },
2923 )
2924 .await
2925 .unwrap();
2926 assert_eq!(just_extends, 1);
2927 }
2928
2929 #[tokio::test]
2930 async fn get_entity_namespace_isolation() {
2931 let rt = rt();
2932 let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
2933 let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
2934 let entity = rt
2935 .create_entity(&ns_a, "concept", None, "Alpha", None, None, vec![])
2936 .await
2937 .unwrap();
2938
2939 let found = rt.get_entity(&ns_a, entity.id).await;
2941 assert!(found.is_ok(), "should be visible in its own namespace");
2942
2943 let not_found = rt.get_entity(&ns_b, entity.id).await;
2945 assert!(
2946 not_found.is_err(),
2947 "should not be visible across namespaces"
2948 );
2949 assert!(
2950 matches!(not_found.unwrap_err(), crate::RuntimeError::NotFound(_)),
2951 "cross-namespace get must return NotFound, not NamespaceMismatch"
2952 );
2953 }
2954
2955 #[tokio::test]
2956 async fn namespace_mismatch_error_message_is_opaque() {
2957 let rt = rt();
2960 let ns_a = NamespaceToken::for_namespace(Namespace::parse("secret-ns").unwrap());
2961 let ns_b = NamespaceToken::for_namespace(Namespace::parse("other-ns").unwrap());
2962 let entity = rt
2963 .create_entity(&ns_a, "concept", None, "Hidden", None, None, vec![])
2964 .await
2965 .unwrap();
2966
2967 let err = rt.get_entity(&ns_b, entity.id).await.unwrap_err();
2968 let msg = err.to_string();
2969 assert!(
2970 !msg.contains("secret-ns"),
2971 "error message must not leak the actual namespace; got: {msg}"
2972 );
2973 assert!(
2974 !msg.contains("other-ns"),
2975 "error message must not leak the requested namespace; got: {msg}"
2976 );
2977 }
2978
2979 #[tokio::test]
2980 async fn delete_entity_namespace_isolation() {
2981 let rt = rt();
2982 let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
2983 let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
2984 let entity = rt
2985 .create_entity(&ns_a, "concept", None, "Beta", None, None, vec![])
2986 .await
2987 .unwrap();
2988
2989 let cross_ns_result = rt.delete_entity(&ns_b, entity.id, true).await;
2991 assert!(
2992 cross_ns_result.is_err(),
2993 "cross-namespace delete must error"
2994 );
2995 assert!(
2996 matches!(
2997 cross_ns_result.unwrap_err(),
2998 crate::RuntimeError::NotFound(_)
2999 ),
3000 "cross-namespace delete must return NotFound, not NamespaceMismatch"
3001 );
3002
3003 let still_there = rt.get_entity(&ns_a, entity.id).await;
3005 assert!(
3006 still_there.is_ok(),
3007 "entity must survive cross-ns delete attempt"
3008 );
3009
3010 let deleted_ok = rt.delete_entity(&ns_a, entity.id, true).await.unwrap();
3012 assert!(deleted_ok, "same-namespace delete must succeed");
3013 }
3014
3015 #[tokio::test]
3018 async fn create_note_indexes_into_fts5() {
3019 let rt = rt();
3020 let tok = NamespaceToken::local();
3021 let note = rt
3022 .create_note(
3023 &tok,
3024 "observation",
3025 None,
3026 "FlashAttention reduces memory by using tiling",
3027 Some(0.8),
3028 None,
3029 vec![],
3030 )
3031 .await
3032 .unwrap();
3033
3034 let ns = tok.namespace().as_str().to_string();
3036 let hits = rt
3037 .text_for_notes(&tok)
3038 .unwrap()
3039 .search(khive_storage::types::TextSearchRequest {
3040 query: "FlashAttention".to_string(),
3041 mode: khive_storage::types::TextQueryMode::Plain,
3042 filter: Some(khive_storage::types::TextFilter {
3043 namespaces: vec![ns],
3044 ..Default::default()
3045 }),
3046 top_k: 10,
3047 snippet_chars: 100,
3048 })
3049 .await
3050 .unwrap();
3051
3052 assert!(
3053 hits.iter().any(|h| h.subject_id == note.id),
3054 "note should be indexed in FTS5 after create"
3055 );
3056 }
3057
3058 #[tokio::test]
3059 async fn create_note_with_properties() {
3060 let rt = rt();
3061 let tok = NamespaceToken::local();
3062 let props = serde_json::json!({"source": "arxiv:2205.14135"});
3063 let note = rt
3064 .create_note(
3065 &tok,
3066 "insight",
3067 None,
3068 "FlashAttention is IO-aware",
3069 Some(0.9),
3070 Some(props.clone()),
3071 vec![],
3072 )
3073 .await
3074 .unwrap();
3075
3076 assert_eq!(note.properties.as_ref().unwrap(), &props);
3077 }
3078
3079 #[tokio::test]
3080 async fn create_note_creates_annotates_edges() {
3081 let rt = rt();
3082 let tok = NamespaceToken::local();
3083 let entity = rt
3084 .create_entity(&tok, "concept", None, "FlashAttention", None, None, vec![])
3085 .await
3086 .unwrap();
3087
3088 let note = rt
3089 .create_note(
3090 &tok,
3091 "observation",
3092 None,
3093 "FlashAttention uses SRAM tiling for memory efficiency",
3094 Some(0.9),
3095 None,
3096 vec![entity.id],
3097 )
3098 .await
3099 .unwrap();
3100
3101 let out_neighbors = rt
3103 .neighbors(
3104 &tok,
3105 note.id,
3106 Direction::Out,
3107 None,
3108 Some(vec![EdgeRelation::Annotates]),
3109 )
3110 .await
3111 .unwrap();
3112 assert_eq!(out_neighbors.len(), 1);
3113 assert_eq!(out_neighbors[0].node_id, entity.id);
3114 assert_eq!(out_neighbors[0].relation, EdgeRelation::Annotates);
3115
3116 let in_neighbors = rt
3118 .neighbors(
3119 &tok,
3120 entity.id,
3121 Direction::In,
3122 None,
3123 Some(vec![EdgeRelation::Annotates]),
3124 )
3125 .await
3126 .unwrap();
3127 assert_eq!(in_neighbors.len(), 1);
3128 assert_eq!(in_neighbors[0].node_id, note.id);
3129 }
3130
3131 #[tokio::test]
3132 async fn neighbors_without_relation_filter_returns_all() {
3133 let rt = rt();
3134 let tok = NamespaceToken::local();
3135 let a = rt
3136 .create_entity(&tok, "concept", None, "A", None, None, vec![])
3137 .await
3138 .unwrap();
3139 let b = rt
3140 .create_entity(&tok, "concept", None, "B", None, None, vec![])
3141 .await
3142 .unwrap();
3143 let c = rt
3144 .create_entity(&tok, "concept", None, "C", None, None, vec![])
3145 .await
3146 .unwrap();
3147
3148 rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
3149 .await
3150 .unwrap();
3151 rt.link(&tok, a.id, c.id, EdgeRelation::Enables, 1.0, None)
3152 .await
3153 .unwrap();
3154
3155 let all = rt
3156 .neighbors(&tok, a.id, Direction::Out, None, None)
3157 .await
3158 .unwrap();
3159 assert_eq!(all.len(), 2);
3160 }
3161
3162 #[tokio::test]
3163 async fn neighbors_with_relation_filter_returns_subset() {
3164 let rt = rt();
3165 let tok = NamespaceToken::local();
3166 let a = rt
3167 .create_entity(&tok, "concept", None, "A", None, None, vec![])
3168 .await
3169 .unwrap();
3170 let b = rt
3171 .create_entity(&tok, "concept", None, "B", None, None, vec![])
3172 .await
3173 .unwrap();
3174 let c = rt
3175 .create_entity(&tok, "concept", None, "C", None, None, vec![])
3176 .await
3177 .unwrap();
3178
3179 rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
3180 .await
3181 .unwrap();
3182 rt.link(&tok, a.id, c.id, EdgeRelation::Enables, 1.0, None)
3183 .await
3184 .unwrap();
3185
3186 let filtered = rt
3187 .neighbors(
3188 &tok,
3189 a.id,
3190 Direction::Out,
3191 None,
3192 Some(vec![EdgeRelation::Extends]),
3193 )
3194 .await
3195 .unwrap();
3196 assert_eq!(filtered.len(), 1);
3197 assert_eq!(filtered[0].node_id, b.id);
3198 assert_eq!(filtered[0].relation, EdgeRelation::Extends);
3199 }
3200
3201 #[tokio::test]
3202 async fn search_notes_returns_relevant_note() {
3203 let rt = rt();
3204 let tok = NamespaceToken::local();
3205 rt.create_note(
3206 &tok,
3207 "observation",
3208 None,
3209 "GQA reduces KV cache memory for large models",
3210 Some(0.8),
3211 None,
3212 vec![],
3213 )
3214 .await
3215 .unwrap();
3216
3217 let results = rt
3218 .search_notes(&tok, "GQA KV cache", None, 10, None, false)
3219 .await
3220 .unwrap();
3221
3222 assert!(!results.is_empty(), "search should return the indexed note");
3223 let hit = &results[0];
3224 assert!(
3225 hit.title.is_some(),
3226 "note hit title should be populated (falls back to content)"
3227 );
3228 assert!(
3229 hit.snippet.is_some(),
3230 "note hit snippet should be populated"
3231 );
3232 }
3233
3234 #[tokio::test]
3235 async fn search_notes_excludes_soft_deleted() {
3236 let rt = rt();
3237 let tok = NamespaceToken::local();
3238 let note = rt
3239 .create_note(
3240 &tok,
3241 "observation",
3242 None,
3243 "RoPE positional encoding rotary embeddings",
3244 Some(0.7),
3245 None,
3246 vec![],
3247 )
3248 .await
3249 .unwrap();
3250
3251 rt.notes(&tok)
3253 .unwrap()
3254 .delete_note(note.id, DeleteMode::Soft)
3255 .await
3256 .unwrap();
3257
3258 let results = rt
3259 .search_notes(&tok, "RoPE rotary positional", None, 10, None, false)
3260 .await
3261 .unwrap();
3262
3263 assert!(
3264 results.iter().all(|h| h.note_id != note.id),
3265 "soft-deleted note should be excluded from search"
3266 );
3267 }
3268
3269 #[tokio::test]
3270 async fn resolve_returns_entity() {
3271 let rt = rt();
3272 let tok = NamespaceToken::local();
3273 let entity = rt
3274 .create_entity(&tok, "concept", None, "LoRA", None, None, vec![])
3275 .await
3276 .unwrap();
3277
3278 let resolved = rt.resolve(&tok, entity.id).await.unwrap();
3279 match resolved {
3280 Some(Resolved::Entity(e)) => assert_eq!(e.id, entity.id),
3281 other => panic!("expected Resolved::Entity, got {:?}", other),
3282 }
3283 }
3284
3285 #[tokio::test]
3286 async fn resolve_returns_note() {
3287 let rt = rt();
3288 let tok = NamespaceToken::local();
3289 let note = rt
3290 .create_note(
3291 &tok,
3292 "observation",
3293 None,
3294 "LoRA fine-tunes LLMs with low-rank adapters",
3295 Some(0.85),
3296 None,
3297 vec![],
3298 )
3299 .await
3300 .unwrap();
3301
3302 let resolved = rt.resolve(&tok, note.id).await.unwrap();
3303 match resolved {
3304 Some(Resolved::Note(n)) => assert_eq!(n.id, note.id),
3305 other => panic!("expected Resolved::Note, got {:?}", other),
3306 }
3307 }
3308
3309 #[tokio::test]
3310 async fn resolve_returns_none_for_unknown_uuid() {
3311 let rt = rt();
3312 let tok = NamespaceToken::local();
3313 let unknown = Uuid::new_v4();
3314 let resolved = rt.resolve(&tok, unknown).await.unwrap();
3315 assert!(resolved.is_none(), "unknown UUID should resolve to None");
3316 }
3317
3318 #[tokio::test]
3319 async fn resolve_prefix_finds_entity_in_own_namespace() {
3320 let rt = rt();
3321 let tok = NamespaceToken::local();
3322 let entity = rt
3323 .create_entity(&tok, "concept", None, "PrefixTest", None, None, vec![])
3324 .await
3325 .unwrap();
3326 let prefix = &entity.id.to_string()[..8];
3327
3328 let resolved = rt.resolve_prefix(&tok, prefix).await.unwrap();
3329 assert_eq!(resolved, Some(entity.id));
3330 }
3331
3332 #[tokio::test]
3333 async fn resolve_prefix_invisible_across_namespaces() {
3334 let rt = rt();
3335 let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
3336 let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
3337 let entity = rt
3338 .create_entity(&ns_a, "concept", None, "Invisible", None, None, vec![])
3339 .await
3340 .unwrap();
3341 let prefix = &entity.id.to_string()[..8];
3342
3343 let resolved = rt.resolve_prefix(&ns_b, prefix).await.unwrap();
3345 assert_eq!(resolved, None);
3346 }
3347
3348 #[tokio::test]
3349 async fn resolve_prefix_ambiguous_same_namespace() {
3350 use khive_storage::entity::Entity;
3351
3352 let rt = rt();
3353 let tok = NamespaceToken::local();
3354 let id_a = Uuid::parse_str("aabbccdd-1111-4000-8000-000000000001").unwrap();
3356 let id_b = Uuid::parse_str("aabbccdd-2222-4000-8000-000000000002").unwrap();
3357
3358 let mut entity_a = Entity::new("local", "concept", "AmbigA");
3359 entity_a.id = id_a;
3360 let mut entity_b = Entity::new("local", "concept", "AmbigB");
3361 entity_b.id = id_b;
3362
3363 let store = rt.entities(&tok).unwrap();
3364 store.upsert_entity(entity_a).await.unwrap();
3365 store.upsert_entity(entity_b).await.unwrap();
3366
3367 let err = rt.resolve_prefix(&tok, "aabbccdd").await.unwrap_err();
3368 assert!(
3369 matches!(
3370 err,
3371 RuntimeError::AmbiguousPrefix { ref prefix, ref matches }
3372 if prefix == "aabbccdd" && matches.len() == 2
3373 ),
3374 "shared 8-char prefix must return AmbiguousPrefix; got {err:?}"
3375 );
3376 }
3377
3378 #[tokio::test]
3385 async fn resolve_finds_event_by_full_uuid() {
3386 use khive_storage::Event;
3387 use khive_types::{EventKind, SubstrateKind};
3388
3389 let rt = rt();
3390 let tok = NamespaceToken::local();
3391 let ns = tok.namespace().as_str();
3392 let event = Event::new(
3393 ns,
3394 "test_verb",
3395 EventKind::Audit,
3396 SubstrateKind::Entity,
3397 "actor",
3398 );
3399 let event_id = event.id;
3400 rt.events(&tok).unwrap().append_event(event).await.unwrap();
3401
3402 let resolved = rt.resolve(&tok, event_id).await.unwrap();
3403 assert!(
3404 matches!(resolved, Some(Resolved::Event(_))),
3405 "event UUID must resolve to Resolved::Event, got {resolved:?}"
3406 );
3407 }
3408
3409 #[tokio::test]
3410 async fn resolve_prefix_finds_event() {
3411 use khive_storage::Event;
3412 use khive_types::{EventKind, SubstrateKind};
3413
3414 let rt = rt();
3415 let tok = NamespaceToken::local();
3416 let ns = tok.namespace().as_str();
3417 let event = Event::new(
3418 ns,
3419 "test_verb",
3420 EventKind::Audit,
3421 SubstrateKind::Entity,
3422 "actor",
3423 );
3424 let event_id = event.id;
3425 rt.events(&tok).unwrap().append_event(event).await.unwrap();
3426
3427 let prefix = &event_id.to_string()[..8];
3428 let resolved = rt.resolve_prefix(&tok, prefix).await.unwrap();
3429 assert_eq!(
3430 resolved,
3431 Some(event_id),
3432 "resolve_prefix must return event UUID for 8-char prefix"
3433 );
3434 }
3435
3436 #[tokio::test]
3439 async fn link_phantom_source_returns_not_found() {
3440 let rt = rt();
3441 let tok = NamespaceToken::local();
3442 let b = rt
3443 .create_entity(&tok, "concept", None, "B", None, None, vec![])
3444 .await
3445 .unwrap();
3446 let phantom = Uuid::new_v4();
3447
3448 let result = rt
3449 .link(&tok, phantom, b.id, EdgeRelation::Extends, 1.0, None)
3450 .await;
3451 match result {
3452 Err(RuntimeError::NotFound(msg)) => {
3453 assert!(
3454 msg.contains("source"),
3455 "error message must name 'source': {msg}"
3456 );
3457 }
3458 other => panic!("expected NotFound for phantom source, got {other:?}"),
3459 }
3460 }
3461
3462 #[tokio::test]
3463 async fn link_phantom_target_returns_not_found() {
3464 let rt = rt();
3465 let tok = NamespaceToken::local();
3466 let a = rt
3467 .create_entity(&tok, "concept", None, "A", None, None, vec![])
3468 .await
3469 .unwrap();
3470 let phantom = Uuid::new_v4();
3471
3472 let result = rt
3473 .link(&tok, a.id, phantom, EdgeRelation::Extends, 1.0, None)
3474 .await;
3475 match result {
3476 Err(RuntimeError::NotFound(msg)) => {
3477 assert!(
3478 msg.contains("target"),
3479 "error message must name 'target': {msg}"
3480 );
3481 }
3482 other => panic!("expected NotFound for phantom target, got {other:?}"),
3483 }
3484 }
3485
3486 #[tokio::test]
3487 async fn link_real_entities_succeeds() {
3488 let rt = rt();
3489 let tok = NamespaceToken::local();
3490 let a = rt
3491 .create_entity(&tok, "concept", None, "A", None, None, vec![])
3492 .await
3493 .unwrap();
3494 let b = rt
3495 .create_entity(&tok, "concept", None, "B", None, None, vec![])
3496 .await
3497 .unwrap();
3498
3499 let edge = rt
3500 .link(&tok, a.id, b.id, EdgeRelation::Extends, 0.8, None)
3501 .await
3502 .unwrap();
3503 assert_eq!(edge.source_id, a.id);
3504 assert_eq!(edge.target_id, b.id);
3505 assert_eq!(edge.relation, EdgeRelation::Extends);
3506 }
3507
3508 #[tokio::test]
3509 async fn create_note_annotates_phantom_returns_not_found() {
3510 let rt = rt();
3511 let tok = NamespaceToken::local();
3512 let phantom = Uuid::new_v4();
3513
3514 let result = rt
3515 .create_note(
3516 &tok,
3517 "observation",
3518 None,
3519 "some content",
3520 Some(0.5),
3521 None,
3522 vec![phantom],
3523 )
3524 .await;
3525 assert!(
3526 matches!(result, Err(RuntimeError::NotFound(_))),
3527 "annotates with phantom uuid must return NotFound, got {result:?}"
3528 );
3529 }
3530
3531 #[tokio::test]
3532 async fn create_note_annotates_real_entity_succeeds() {
3533 let rt = rt();
3534 let tok = NamespaceToken::local();
3535 let entity = rt
3536 .create_entity(&tok, "concept", None, "RealTarget", None, None, vec![])
3537 .await
3538 .unwrap();
3539
3540 let note = rt
3541 .create_note(
3542 &tok,
3543 "observation",
3544 None,
3545 "content",
3546 Some(0.5),
3547 None,
3548 vec![entity.id],
3549 )
3550 .await
3551 .unwrap();
3552
3553 let neighbors = rt
3554 .neighbors(
3555 &tok,
3556 note.id,
3557 Direction::Out,
3558 None,
3559 Some(vec![EdgeRelation::Annotates]),
3560 )
3561 .await
3562 .unwrap();
3563 assert_eq!(neighbors.len(), 1);
3564 assert_eq!(neighbors[0].node_id, entity.id);
3565 }
3566
3567 #[tokio::test]
3569 async fn create_note_multi_annotates_creates_all_edges() {
3570 let rt = rt();
3571 let tok = NamespaceToken::local();
3572 let t1 = rt
3573 .create_entity(&tok, "concept", None, "Target1", None, None, vec![])
3574 .await
3575 .unwrap();
3576 let t2 = rt
3577 .create_entity(&tok, "concept", None, "Target2", None, None, vec![])
3578 .await
3579 .unwrap();
3580
3581 let note = rt
3582 .create_note(
3583 &tok,
3584 "observation",
3585 None,
3586 "content",
3587 Some(0.5),
3588 None,
3589 vec![t1.id, t2.id],
3590 )
3591 .await
3592 .unwrap();
3593
3594 let neighbors = rt
3595 .neighbors(
3596 &tok,
3597 note.id,
3598 Direction::Out,
3599 None,
3600 Some(vec![EdgeRelation::Annotates]),
3601 )
3602 .await
3603 .unwrap();
3604 assert_eq!(
3605 neighbors.len(),
3606 2,
3607 "multi-annotates note must have exactly 2 outbound annotates edges"
3608 );
3609 let target_ids: Vec<Uuid> = neighbors.iter().map(|n| n.node_id).collect();
3610 assert!(target_ids.contains(&t1.id));
3611 assert!(target_ids.contains(&t2.id));
3612 }
3613
3614 #[tokio::test]
3615 async fn link_target_in_different_namespace_returns_not_found() {
3616 let rt = rt();
3617 let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
3618 let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
3619 let a = rt
3620 .create_entity(&ns_a, "concept", None, "A", None, None, vec![])
3621 .await
3622 .unwrap();
3623 let b = rt
3624 .create_entity(&ns_b, "concept", None, "B", None, None, vec![])
3625 .await
3626 .unwrap();
3627
3628 let result = rt
3630 .link(&ns_a, a.id, b.id, EdgeRelation::Extends, 1.0, None)
3631 .await;
3632 assert!(
3633 matches!(result, Err(RuntimeError::NotFound(_))),
3634 "target in different namespace must return NotFound (fail-closed), got {result:?}"
3635 );
3636 }
3637
3638 #[tokio::test]
3639 async fn link_phantom_self_loop_returns_not_found() {
3640 let rt = rt();
3641 let tok = NamespaceToken::local();
3642 let phantom = Uuid::new_v4();
3643
3644 let result = rt
3645 .link(&tok, phantom, phantom, EdgeRelation::Extends, 1.0, None)
3646 .await;
3647 match result {
3648 Err(RuntimeError::NotFound(msg)) => {
3649 assert!(
3650 msg.contains("source"),
3651 "self-loop must fail on source first: {msg}"
3652 );
3653 }
3654 other => panic!("expected NotFound for phantom self-loop, got {other:?}"),
3655 }
3656 }
3657
3658 #[tokio::test]
3661 async fn link_note_to_edge_annotates_succeeds() {
3662 let rt = rt();
3663 let tok = NamespaceToken::local();
3664 let a = rt
3665 .create_entity(&tok, "concept", None, "A", None, None, vec![])
3666 .await
3667 .unwrap();
3668 let b = rt
3669 .create_entity(&tok, "concept", None, "B", None, None, vec![])
3670 .await
3671 .unwrap();
3672 let edge = rt
3674 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
3675 .await
3676 .unwrap();
3677 let edge_uuid: Uuid = edge.id.into();
3678
3679 let note = rt
3681 .create_note(
3682 &tok,
3683 "observation",
3684 None,
3685 "edge note",
3686 Some(0.5),
3687 None,
3688 vec![],
3689 )
3690 .await
3691 .unwrap();
3692
3693 let result = rt
3694 .link(&tok, note.id, edge_uuid, EdgeRelation::Annotates, 1.0, None)
3695 .await;
3696 assert!(
3697 result.is_ok(),
3698 "note→edge Annotates must succeed, got {result:?}"
3699 );
3700 }
3701
3702 #[tokio::test]
3703 async fn create_note_annotates_real_edge_succeeds() {
3704 let rt = rt();
3705 let tok = NamespaceToken::local();
3706 let a = rt
3707 .create_entity(&tok, "concept", None, "A", None, None, vec![])
3708 .await
3709 .unwrap();
3710 let b = rt
3711 .create_entity(&tok, "concept", None, "B", None, None, vec![])
3712 .await
3713 .unwrap();
3714 let edge = rt
3715 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
3716 .await
3717 .unwrap();
3718 let edge_uuid: Uuid = edge.id.into();
3719
3720 let note = rt
3721 .create_note(
3722 &tok,
3723 "observation",
3724 None,
3725 "annotating an edge",
3726 Some(0.5),
3727 None,
3728 vec![edge_uuid],
3729 )
3730 .await
3731 .unwrap();
3732
3733 let neighbors = rt
3734 .neighbors(
3735 &tok,
3736 note.id,
3737 Direction::Out,
3738 None,
3739 Some(vec![EdgeRelation::Annotates]),
3740 )
3741 .await
3742 .unwrap();
3743 assert_eq!(neighbors.len(), 1);
3744 assert_eq!(neighbors[0].node_id, edge_uuid);
3745 }
3746
3747 #[tokio::test]
3748 async fn create_note_annotates_phantom_is_atomic_no_note_persisted() {
3749 let rt = rt();
3750 let tok = NamespaceToken::local();
3751 let phantom = Uuid::new_v4();
3752
3753 let before_count = rt.list_notes(&tok, None, 1000, 0).await.unwrap().len();
3754
3755 let result = rt
3756 .create_note(
3757 &tok,
3758 "observation",
3759 None,
3760 "should not persist",
3761 Some(0.5),
3762 None,
3763 vec![phantom],
3764 )
3765 .await;
3766 assert!(
3767 matches!(result, Err(RuntimeError::NotFound(_))),
3768 "phantom annotates target must return NotFound, got {result:?}"
3769 );
3770
3771 let after_count = rt.list_notes(&tok, None, 1000, 0).await.unwrap().len();
3773 assert_eq!(
3774 before_count, after_count,
3775 "failed create_note must not persist any note row (atomicity)"
3776 );
3777
3778 let search_hits = rt
3780 .search_notes(&tok, "should not persist", None, 10, None, false)
3781 .await
3782 .unwrap();
3783 assert!(
3784 search_hits.is_empty(),
3785 "failed create_note must not index into FTS (atomicity)"
3786 );
3787 }
3790
3791 #[tokio::test]
3795 async fn link_entity_to_edge_uuid_non_annotates_returns_invalid_input() {
3796 let rt = rt();
3797 let tok = NamespaceToken::local();
3798 let a = rt
3799 .create_entity(&tok, "concept", None, "A", None, None, vec![])
3800 .await
3801 .unwrap();
3802 let b = rt
3803 .create_entity(&tok, "concept", None, "B", None, None, vec![])
3804 .await
3805 .unwrap();
3806 let edge = rt
3808 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
3809 .await
3810 .unwrap();
3811 let edge_uuid: Uuid = edge.id.into();
3812
3813 let result = rt
3814 .link(&tok, a.id, edge_uuid, EdgeRelation::Extends, 1.0, None)
3815 .await;
3816 match result {
3817 Err(RuntimeError::InvalidInput(msg)) => {
3818 assert!(
3819 msg.contains("target"),
3820 "error message must name 'target': {msg}"
3821 );
3822 }
3823 other => {
3824 panic!("expected InvalidInput for edge-uuid target with Extends, got {other:?}")
3825 }
3826 }
3827 }
3828
3829 #[tokio::test]
3831 async fn link_note_as_source_non_annotates_returns_invalid_input() {
3832 let rt = rt();
3833 let tok = NamespaceToken::local();
3834 let note = rt
3835 .create_note(&tok, "observation", None, "a note", Some(0.5), None, vec![])
3836 .await
3837 .unwrap();
3838 let entity = rt
3839 .create_entity(&tok, "concept", None, "E", None, None, vec![])
3840 .await
3841 .unwrap();
3842
3843 let result = rt
3844 .link(&tok, note.id, entity.id, EdgeRelation::DependsOn, 1.0, None)
3845 .await;
3846 match result {
3847 Err(RuntimeError::InvalidInput(msg)) => {
3848 assert!(
3849 msg.contains("source"),
3850 "error message must name 'source': {msg}"
3851 );
3852 }
3853 other => panic!("expected InvalidInput for note source with DependsOn, got {other:?}"),
3854 }
3855 }
3856
3857 #[tokio::test]
3859 async fn link_entity_as_annotates_source_returns_invalid_input() {
3860 let rt = rt();
3861 let tok = NamespaceToken::local();
3862 let a = rt
3863 .create_entity(&tok, "concept", None, "A", None, None, vec![])
3864 .await
3865 .unwrap();
3866 let b = rt
3867 .create_entity(&tok, "concept", None, "B", None, None, vec![])
3868 .await
3869 .unwrap();
3870
3871 let result = rt
3872 .link(&tok, a.id, b.id, EdgeRelation::Annotates, 1.0, None)
3873 .await;
3874 match result {
3875 Err(RuntimeError::InvalidInput(msg)) => {
3876 assert!(
3877 msg.contains("source") && msg.contains("note"),
3878 "error must say source must be a note: {msg}"
3879 );
3880 }
3881 other => {
3882 panic!("expected InvalidInput for entity source with Annotates, got {other:?}")
3883 }
3884 }
3885 }
3886
3887 #[tokio::test]
3888 async fn link_edge_as_annotates_source_returns_invalid_input() {
3889 let rt = rt();
3890 let tok = NamespaceToken::local();
3891 let a = rt
3892 .create_entity(&tok, "concept", None, "A", None, None, vec![])
3893 .await
3894 .unwrap();
3895 let b = rt
3896 .create_entity(&tok, "concept", None, "B", None, None, vec![])
3897 .await
3898 .unwrap();
3899 let edge = rt
3900 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
3901 .await
3902 .unwrap();
3903 let edge_uuid: Uuid = edge.id.into();
3904
3905 let result = rt
3907 .link(&tok, edge_uuid, a.id, EdgeRelation::Annotates, 1.0, None)
3908 .await;
3909 match result {
3910 Err(RuntimeError::InvalidInput(msg)) => {
3911 assert!(
3912 msg.contains("source") && msg.contains("note"),
3913 "edge-as-annotates-source must report wrong kind, not NotFound: {msg}"
3914 );
3915 }
3916 other => panic!("expected InvalidInput for edge source with Annotates, got {other:?}"),
3917 }
3918 }
3919
3920 #[tokio::test]
3922 async fn link_note_to_event_annotates_succeeds() {
3923 use khive_storage::Event;
3924 use khive_types::{EventKind, SubstrateKind};
3925
3926 let rt = rt();
3927 let tok = NamespaceToken::local();
3928 let note = rt
3929 .create_note(
3930 &tok,
3931 "observation",
3932 None,
3933 "observing an event",
3934 Some(0.6),
3935 None,
3936 vec![],
3937 )
3938 .await
3939 .unwrap();
3940
3941 let ns = tok.namespace().as_str();
3943 let event = Event::new(
3944 ns,
3945 "test_verb",
3946 EventKind::Audit,
3947 SubstrateKind::Entity,
3948 "test_actor",
3949 );
3950 let event_id = event.id;
3951 rt.events(&tok).unwrap().append_event(event).await.unwrap();
3952
3953 let result = rt
3954 .link(&tok, note.id, event_id, EdgeRelation::Annotates, 1.0, None)
3955 .await;
3956 assert!(
3957 result.is_ok(),
3958 "note→event Annotates must succeed, got {result:?}"
3959 );
3960 }
3961
3962 #[tokio::test]
3964 async fn create_note_annotates_event_succeeds() {
3965 use khive_storage::Event;
3966 use khive_types::{EventKind, SubstrateKind};
3967
3968 let rt = rt();
3969 let tok = NamespaceToken::local();
3970 let ns = tok.namespace().as_str();
3971 let event = Event::new(
3972 ns,
3973 "test_verb",
3974 EventKind::Audit,
3975 SubstrateKind::Entity,
3976 "test_actor",
3977 );
3978 let event_id = event.id;
3979 rt.events(&tok).unwrap().append_event(event).await.unwrap();
3980
3981 let result = rt
3982 .create_note(
3983 &tok,
3984 "observation",
3985 None,
3986 "note annotating an event",
3987 Some(0.5),
3988 None,
3989 vec![event_id],
3990 )
3991 .await;
3992 assert!(
3993 result.is_ok(),
3994 "create_note with event annotates target must succeed, got {result:?}"
3995 );
3996 let note = result.unwrap();
3998 let neighbors = rt
3999 .neighbors(
4000 &tok,
4001 note.id,
4002 Direction::Out,
4003 None,
4004 Some(vec![EdgeRelation::Annotates]),
4005 )
4006 .await
4007 .unwrap();
4008 assert_eq!(neighbors.len(), 1);
4009 assert_eq!(neighbors[0].node_id, event_id);
4010 }
4011
4012 #[tokio::test]
4016 async fn link_supersedes_note_to_note_succeeds() {
4017 let rt = rt();
4018 let tok = NamespaceToken::local();
4019 let old_note = rt
4020 .create_note(
4021 &tok,
4022 "observation",
4023 None,
4024 "old observation",
4025 Some(0.7),
4026 None,
4027 vec![],
4028 )
4029 .await
4030 .unwrap();
4031 let new_note = rt
4032 .create_note(
4033 &tok,
4034 "observation",
4035 None,
4036 "revised observation superseding the old one",
4037 Some(0.9),
4038 None,
4039 vec![],
4040 )
4041 .await
4042 .unwrap();
4043
4044 let result = rt
4045 .link(
4046 &tok,
4047 new_note.id,
4048 old_note.id,
4049 EdgeRelation::Supersedes,
4050 1.0,
4051 None,
4052 )
4053 .await;
4054 assert!(
4055 result.is_ok(),
4056 "note→note Supersedes must succeed (note supersession), got {result:?}"
4057 );
4058 }
4059
4060 #[tokio::test]
4061 async fn link_supersedes_entity_to_entity_succeeds() {
4062 let rt = rt();
4063 let tok = NamespaceToken::local();
4064 let old_entity = rt
4065 .create_entity(&tok, "concept", None, "OldConcept", None, None, vec![])
4066 .await
4067 .unwrap();
4068 let new_entity = rt
4069 .create_entity(&tok, "concept", None, "NewConcept", None, None, vec![])
4070 .await
4071 .unwrap();
4072
4073 let result = rt
4074 .link(
4075 &tok,
4076 new_entity.id,
4077 old_entity.id,
4078 EdgeRelation::Supersedes,
4079 1.0,
4080 None,
4081 )
4082 .await;
4083 assert!(
4084 result.is_ok(),
4085 "entity→entity Supersedes must succeed, got {result:?}"
4086 );
4087 }
4088
4089 #[tokio::test]
4090 async fn link_supersedes_note_to_entity_returns_invalid_input() {
4091 let rt = rt();
4092 let tok = NamespaceToken::local();
4093 let note = rt
4094 .create_note(&tok, "observation", None, "a note", Some(0.5), None, vec![])
4095 .await
4096 .unwrap();
4097 let entity = rt
4098 .create_entity(&tok, "concept", None, "SomeEntity", None, None, vec![])
4099 .await
4100 .unwrap();
4101
4102 let result = rt
4103 .link(
4104 &tok,
4105 note.id,
4106 entity.id,
4107 EdgeRelation::Supersedes,
4108 1.0,
4109 None,
4110 )
4111 .await;
4112 match result {
4113 Err(RuntimeError::InvalidInput(msg)) => {
4114 assert!(
4115 msg.contains("same substrate") || msg.contains("same-substrate"),
4116 "error must name the same-substrate rule: {msg}"
4117 );
4118 }
4119 other => panic!(
4120 "expected InvalidInput for note→entity Supersedes (cross-substrate), got {other:?}"
4121 ),
4122 }
4123 }
4124
4125 #[tokio::test]
4126 async fn link_supersedes_entity_to_note_returns_invalid_input() {
4127 let rt = rt();
4128 let tok = NamespaceToken::local();
4129 let entity = rt
4130 .create_entity(&tok, "concept", None, "SomeEntity", None, None, vec![])
4131 .await
4132 .unwrap();
4133 let note = rt
4134 .create_note(&tok, "observation", None, "a note", Some(0.5), None, vec![])
4135 .await
4136 .unwrap();
4137
4138 let result = rt
4139 .link(
4140 &tok,
4141 entity.id,
4142 note.id,
4143 EdgeRelation::Supersedes,
4144 1.0,
4145 None,
4146 )
4147 .await;
4148 match result {
4149 Err(RuntimeError::InvalidInput(msg)) => {
4150 assert!(
4151 msg.contains("same substrate") || msg.contains("same-substrate"),
4152 "error must name the same-substrate rule: {msg}"
4153 );
4154 }
4155 other => panic!(
4156 "expected InvalidInput for entity→note Supersedes (cross-substrate), got {other:?}"
4157 ),
4158 }
4159 }
4160
4161 #[tokio::test]
4162 async fn link_supersedes_event_source_returns_invalid_input() {
4163 use khive_storage::Event;
4164 use khive_types::{EventKind, SubstrateKind};
4165
4166 let rt = rt();
4167 let tok = NamespaceToken::local();
4168 let ns = tok.namespace().as_str();
4169 let event = Event::new(
4170 ns,
4171 "test_verb",
4172 EventKind::Audit,
4173 SubstrateKind::Entity,
4174 "test_actor",
4175 );
4176 let event_id = event.id;
4177 rt.events(&tok).unwrap().append_event(event).await.unwrap();
4178
4179 let entity = rt
4180 .create_entity(&tok, "concept", None, "SomeEntity", None, None, vec![])
4181 .await
4182 .unwrap();
4183
4184 let result = rt
4185 .link(
4186 &tok,
4187 event_id,
4188 entity.id,
4189 EdgeRelation::Supersedes,
4190 1.0,
4191 None,
4192 )
4193 .await;
4194 match result {
4195 Err(RuntimeError::InvalidInput(msg)) => {
4196 assert!(msg.contains("event"), "error must mention 'event': {msg}");
4197 }
4198 other => {
4199 panic!("expected InvalidInput for event source with Supersedes, got {other:?}")
4200 }
4201 }
4202 }
4203
4204 #[tokio::test]
4205 async fn link_supersedes_event_target_returns_invalid_input() {
4206 use khive_storage::Event;
4207 use khive_types::{EventKind, SubstrateKind};
4208
4209 let rt = rt();
4210 let tok = NamespaceToken::local();
4211 let ns = tok.namespace().as_str();
4212 let event = Event::new(
4213 ns,
4214 "test_verb",
4215 EventKind::Audit,
4216 SubstrateKind::Entity,
4217 "test_actor",
4218 );
4219 let event_id = event.id;
4220 rt.events(&tok).unwrap().append_event(event).await.unwrap();
4221
4222 let entity = rt
4223 .create_entity(&tok, "concept", None, "SomeEntity", None, None, vec![])
4224 .await
4225 .unwrap();
4226
4227 let result = rt
4228 .link(
4229 &tok,
4230 entity.id,
4231 event_id,
4232 EdgeRelation::Supersedes,
4233 1.0,
4234 None,
4235 )
4236 .await;
4237 match result {
4238 Err(RuntimeError::InvalidInput(msg)) => {
4239 assert!(msg.contains("event"), "error must mention 'event': {msg}");
4240 }
4241 other => {
4242 panic!("expected InvalidInput for event target with Supersedes, got {other:?}")
4243 }
4244 }
4245 }
4246
4247 #[tokio::test]
4248 async fn link_supersedes_edge_source_returns_invalid_input() {
4249 let rt = rt();
4250 let tok = NamespaceToken::local();
4251 let a = rt
4252 .create_entity(&tok, "concept", None, "A", None, None, vec![])
4253 .await
4254 .unwrap();
4255 let b = rt
4256 .create_entity(&tok, "concept", None, "B", None, None, vec![])
4257 .await
4258 .unwrap();
4259 let edge = rt
4260 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
4261 .await
4262 .unwrap();
4263 let edge_uuid: Uuid = edge.id.into();
4264
4265 let result = rt
4266 .link(&tok, edge_uuid, a.id, EdgeRelation::Supersedes, 1.0, None)
4267 .await;
4268 match result {
4269 Err(RuntimeError::InvalidInput(msg)) => {
4270 assert!(msg.contains("source"), "error must name 'source': {msg}");
4271 }
4272 other => {
4273 panic!("expected InvalidInput for edge-uuid source with Supersedes, got {other:?}")
4274 }
4275 }
4276 }
4277
4278 #[tokio::test]
4279 async fn link_supersedes_edge_target_returns_invalid_input() {
4280 let rt = rt();
4281 let tok = NamespaceToken::local();
4282 let a = rt
4283 .create_entity(&tok, "concept", None, "A", None, None, vec![])
4284 .await
4285 .unwrap();
4286 let b = rt
4287 .create_entity(&tok, "concept", None, "B", None, None, vec![])
4288 .await
4289 .unwrap();
4290 let edge = rt
4291 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
4292 .await
4293 .unwrap();
4294 let edge_uuid: Uuid = edge.id.into();
4295
4296 let result = rt
4297 .link(&tok, a.id, edge_uuid, EdgeRelation::Supersedes, 1.0, None)
4298 .await;
4299 match result {
4300 Err(RuntimeError::InvalidInput(msg)) => {
4301 assert!(msg.contains("target"), "error must name 'target': {msg}");
4302 }
4303 other => {
4304 panic!("expected InvalidInput for edge-uuid target with Supersedes, got {other:?}")
4305 }
4306 }
4307 }
4308
4309 #[tokio::test]
4310 async fn link_supersedes_phantom_source_returns_not_found() {
4311 let rt = rt();
4312 let tok = NamespaceToken::local();
4313 let note = rt
4314 .create_note(
4315 &tok,
4316 "observation",
4317 None,
4318 "existing note",
4319 Some(0.5),
4320 None,
4321 vec![],
4322 )
4323 .await
4324 .unwrap();
4325 let phantom = Uuid::new_v4();
4326
4327 let result = rt
4328 .link(&tok, phantom, note.id, EdgeRelation::Supersedes, 1.0, None)
4329 .await;
4330 match result {
4331 Err(RuntimeError::NotFound(msg)) => {
4332 assert!(msg.contains("source"), "error must name 'source': {msg}");
4333 }
4334 other => panic!("expected NotFound for phantom source with Supersedes, got {other:?}"),
4335 }
4336 }
4337
4338 #[tokio::test]
4339 async fn link_supersedes_phantom_target_returns_not_found() {
4340 let rt = rt();
4341 let tok = NamespaceToken::local();
4342 let note = rt
4343 .create_note(
4344 &tok,
4345 "observation",
4346 None,
4347 "existing note",
4348 Some(0.5),
4349 None,
4350 vec![],
4351 )
4352 .await
4353 .unwrap();
4354 let phantom = Uuid::new_v4();
4355
4356 let result = rt
4357 .link(&tok, note.id, phantom, EdgeRelation::Supersedes, 1.0, None)
4358 .await;
4359 match result {
4360 Err(RuntimeError::NotFound(msg)) => {
4361 assert!(msg.contains("target"), "error must name 'target': {msg}");
4362 }
4363 other => panic!("expected NotFound for phantom target with Supersedes, got {other:?}"),
4364 }
4365 }
4366
4367 #[tokio::test]
4368 async fn link_supersedes_cross_namespace_source_returns_not_found() {
4369 let rt = rt();
4370 let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
4371 let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
4372 let note_a = rt
4373 .create_note(
4374 &ns_a,
4375 "observation",
4376 None,
4377 "note in ns-a",
4378 Some(0.5),
4379 None,
4380 vec![],
4381 )
4382 .await
4383 .unwrap();
4384 let note_b = rt
4385 .create_note(
4386 &ns_b,
4387 "observation",
4388 None,
4389 "note in ns-b",
4390 Some(0.5),
4391 None,
4392 vec![],
4393 )
4394 .await
4395 .unwrap();
4396
4397 let result = rt
4399 .link(
4400 &ns_a,
4401 note_b.id,
4402 note_a.id,
4403 EdgeRelation::Supersedes,
4404 1.0,
4405 None,
4406 )
4407 .await;
4408 assert!(
4409 matches!(result, Err(RuntimeError::NotFound(_))),
4410 "cross-namespace source with Supersedes must return NotFound (fail-closed), got {result:?}"
4411 );
4412 }
4413
4414 #[tokio::test]
4416 async fn link_extends_note_source_still_returns_invalid_input() {
4417 let rt = rt();
4418 let tok = NamespaceToken::local();
4419 let note = rt
4420 .create_note(
4421 &tok,
4422 "observation",
4423 None,
4424 "a note that cannot be an extends source",
4425 Some(0.5),
4426 None,
4427 vec![],
4428 )
4429 .await
4430 .unwrap();
4431 let entity = rt
4432 .create_entity(&tok, "concept", None, "E", None, None, vec![])
4433 .await
4434 .unwrap();
4435
4436 let result = rt
4437 .link(&tok, note.id, entity.id, EdgeRelation::Extends, 1.0, None)
4438 .await;
4439 assert!(
4440 matches!(result, Err(RuntimeError::InvalidInput(_))),
4441 "note source with Extends must still return InvalidInput after this fix, got {result:?}"
4442 );
4443 }
4444
4445 #[tokio::test]
4447 async fn link_annotates_note_to_edge_still_succeeds_after_fix() {
4448 let rt = rt();
4449 let tok = NamespaceToken::local();
4450 let a = rt
4451 .create_entity(&tok, "concept", None, "A", None, None, vec![])
4452 .await
4453 .unwrap();
4454 let b = rt
4455 .create_entity(&tok, "concept", None, "B", None, None, vec![])
4456 .await
4457 .unwrap();
4458 let edge = rt
4459 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
4460 .await
4461 .unwrap();
4462 let edge_uuid: Uuid = edge.id.into();
4463
4464 let note = rt
4465 .create_note(
4466 &tok,
4467 "observation",
4468 None,
4469 "annotating an edge",
4470 Some(0.5),
4471 None,
4472 vec![],
4473 )
4474 .await
4475 .unwrap();
4476
4477 let result = rt
4478 .link(&tok, note.id, edge_uuid, EdgeRelation::Annotates, 1.0, None)
4479 .await;
4480 assert!(
4481 result.is_ok(),
4482 "note→edge Annotates must still succeed after supersedes fix, got {result:?}"
4483 );
4484 }
4485
4486 #[tokio::test]
4500 async fn create_note_multi_annotates_compensation_cleanup_restores_pristine_state() {
4501 let rt = rt();
4502 let tok = NamespaceToken::local();
4503 let t1 = rt
4504 .create_entity(&tok, "concept", None, "T1", None, None, vec![])
4505 .await
4506 .unwrap();
4507
4508 let note = rt
4511 .create_note(
4512 &tok,
4513 "observation",
4514 None,
4515 "partial note",
4516 Some(0.5),
4517 None,
4518 vec![t1.id],
4519 )
4520 .await
4521 .unwrap();
4522
4523 let before_notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
4525 assert_eq!(before_notes.len(), 1, "note must be present before cleanup");
4526 let before_edges = rt
4527 .neighbors(
4528 &tok,
4529 note.id,
4530 Direction::Out,
4531 None,
4532 Some(vec![EdgeRelation::Annotates]),
4533 )
4534 .await
4535 .unwrap();
4536 assert_eq!(
4537 before_edges.len(),
4538 1,
4539 "one annotates edge must exist before cleanup"
4540 );
4541 let edge_id: Uuid = before_edges[0].edge_id;
4542
4543 rt.delete_edge(&tok, edge_id, true).await.unwrap();
4545 rt.delete_note(&tok, note.id, true )
4546 .await
4547 .unwrap();
4548
4549 let after_notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
4551 assert!(
4552 after_notes.is_empty(),
4553 "compensation must remove the note row; got {after_notes:?}"
4554 );
4555 let search_hits = rt
4556 .search_notes(&tok, "partial note", None, 10, None, false)
4557 .await
4558 .unwrap();
4559 assert!(
4560 search_hits.is_empty(),
4561 "compensation must clean the FTS index; got {search_hits:?}"
4562 );
4563 let after_edges = rt
4564 .neighbors(&tok, note.id, Direction::Out, None, None)
4565 .await
4566 .unwrap();
4567 assert!(
4568 after_edges.is_empty(),
4569 "compensation must remove all partial edges; got {after_edges:?}"
4570 );
4571 }
4572
4573 #[tokio::test]
4581 async fn annotated_entity_hard_delete_cascades_annotate_edge() {
4582 let rt = rt();
4583 let tok = NamespaceToken::local();
4584 let entity = rt
4585 .create_entity(&tok, "concept", None, "E", None, None, vec![])
4586 .await
4587 .unwrap();
4588 let note = rt
4589 .create_note(
4590 &tok,
4591 "observation",
4592 None,
4593 "note about entity",
4594 Some(0.5),
4595 None,
4596 vec![entity.id],
4597 )
4598 .await
4599 .unwrap();
4600
4601 let before = rt
4603 .neighbors(
4604 &tok,
4605 note.id,
4606 Direction::Out,
4607 None,
4608 Some(vec![EdgeRelation::Annotates]),
4609 )
4610 .await
4611 .unwrap();
4612 assert_eq!(
4613 before.len(),
4614 1,
4615 "annotates edge must exist before entity delete"
4616 );
4617
4618 let deleted = rt.delete_entity(&tok, entity.id, true).await.unwrap();
4620 assert!(deleted, "entity hard delete must return true");
4621
4622 let after = rt
4624 .neighbors(
4625 &tok,
4626 note.id,
4627 Direction::Out,
4628 None,
4629 Some(vec![EdgeRelation::Annotates]),
4630 )
4631 .await
4632 .unwrap();
4633 assert!(
4634 after.is_empty(),
4635 "annotates edge must be cascaded on entity hard delete; got {after:?}"
4636 );
4637 }
4638
4639 #[tokio::test]
4640 async fn annotated_note_hard_delete_cascades_annotate_edge() {
4641 let rt = rt();
4642 let tok = NamespaceToken::local();
4643 let note_target = rt
4645 .create_note(
4646 &tok,
4647 "observation",
4648 None,
4649 "target note",
4650 Some(0.5),
4651 None,
4652 vec![],
4653 )
4654 .await
4655 .unwrap();
4656 let note_source = rt
4658 .create_note(
4659 &tok,
4660 "insight",
4661 None,
4662 "annotation",
4663 Some(0.5),
4664 None,
4665 vec![note_target.id],
4666 )
4667 .await
4668 .unwrap();
4669
4670 let before = rt
4671 .neighbors(
4672 &tok,
4673 note_source.id,
4674 Direction::Out,
4675 None,
4676 Some(vec![EdgeRelation::Annotates]),
4677 )
4678 .await
4679 .unwrap();
4680 assert_eq!(
4681 before.len(),
4682 1,
4683 "annotates edge must exist before note delete"
4684 );
4685
4686 let deleted = rt.delete_note(&tok, note_target.id, true).await.unwrap();
4688 assert!(deleted, "note hard delete must return true");
4689
4690 let after = rt
4692 .neighbors(
4693 &tok,
4694 note_source.id,
4695 Direction::Out,
4696 None,
4697 Some(vec![EdgeRelation::Annotates]),
4698 )
4699 .await
4700 .unwrap();
4701 assert!(
4702 after.is_empty(),
4703 "annotates edge must be cascaded on note-target hard delete; got {after:?}"
4704 );
4705 }
4706
4707 #[tokio::test]
4708 async fn annotated_edge_delete_cascades_annotate_edge() {
4709 let rt = rt();
4710 let tok = NamespaceToken::local();
4711 let a = rt
4712 .create_entity(&tok, "concept", None, "A", None, None, vec![])
4713 .await
4714 .unwrap();
4715 let b = rt
4716 .create_entity(&tok, "concept", None, "B", None, None, vec![])
4717 .await
4718 .unwrap();
4719 let base_edge = rt
4721 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
4722 .await
4723 .unwrap();
4724 let base_edge_uuid: Uuid = base_edge.id.into();
4725
4726 let note = rt
4728 .create_note(
4729 &tok,
4730 "observation",
4731 None,
4732 "note about edge",
4733 Some(0.5),
4734 None,
4735 vec![base_edge_uuid],
4736 )
4737 .await
4738 .unwrap();
4739
4740 let before = rt
4741 .neighbors(
4742 &tok,
4743 note.id,
4744 Direction::Out,
4745 None,
4746 Some(vec![EdgeRelation::Annotates]),
4747 )
4748 .await
4749 .unwrap();
4750 assert_eq!(
4751 before.len(),
4752 1,
4753 "annotates edge must exist before base edge delete"
4754 );
4755
4756 let deleted = rt.delete_edge(&tok, base_edge_uuid, true).await.unwrap();
4758 assert!(deleted, "edge delete must return true");
4759
4760 let after = rt
4762 .neighbors(
4763 &tok,
4764 note.id,
4765 Direction::Out,
4766 None,
4767 Some(vec![EdgeRelation::Annotates]),
4768 )
4769 .await
4770 .unwrap();
4771 assert!(
4772 after.is_empty(),
4773 "annotates edge must be cascaded on base edge delete; got {after:?}"
4774 );
4775 }
4776
4777 #[tokio::test]
4778 async fn mixed_multi_annotates_partial_target_hard_delete_leaves_remaining_edges() {
4779 let rt = rt();
4780 let tok = NamespaceToken::local();
4781 let t1 = rt
4782 .create_entity(&tok, "concept", None, "T1", None, None, vec![])
4783 .await
4784 .unwrap();
4785 let t2 = rt
4786 .create_entity(&tok, "concept", None, "T2", None, None, vec![])
4787 .await
4788 .unwrap();
4789
4790 let note = rt
4792 .create_note(
4793 &tok,
4794 "observation",
4795 None,
4796 "multi-target note",
4797 Some(0.5),
4798 None,
4799 vec![t1.id, t2.id],
4800 )
4801 .await
4802 .unwrap();
4803
4804 let before = rt
4805 .neighbors(
4806 &tok,
4807 note.id,
4808 Direction::Out,
4809 None,
4810 Some(vec![EdgeRelation::Annotates]),
4811 )
4812 .await
4813 .unwrap();
4814 assert_eq!(
4815 before.len(),
4816 2,
4817 "must have 2 annotates edges before any delete"
4818 );
4819
4820 rt.delete_entity(&tok, t1.id, true).await.unwrap();
4822
4823 let after = rt
4825 .neighbors(
4826 &tok,
4827 note.id,
4828 Direction::Out,
4829 None,
4830 Some(vec![EdgeRelation::Annotates]),
4831 )
4832 .await
4833 .unwrap();
4834 assert_eq!(
4835 after.len(),
4836 1,
4837 "only the edge to t1 must be cascaded; t2 edge must remain"
4838 );
4839 assert_eq!(
4840 after[0].node_id, t2.id,
4841 "remaining annotates edge must point to t2"
4842 );
4843 }
4844
4845 #[tokio::test]
4846 async fn annotated_note_soft_delete_preserves_annotate_edge() {
4847 let rt = rt();
4848 let tok = NamespaceToken::local();
4849 let note_target = rt
4850 .create_note(&tok, "observation", None, "target", Some(0.5), None, vec![])
4851 .await
4852 .unwrap();
4853 let note_source = rt
4854 .create_note(
4855 &tok,
4856 "insight",
4857 None,
4858 "annotation",
4859 Some(0.5),
4860 None,
4861 vec![note_target.id],
4862 )
4863 .await
4864 .unwrap();
4865
4866 let before = rt
4867 .neighbors(
4868 &tok,
4869 note_source.id,
4870 Direction::Out,
4871 None,
4872 Some(vec![EdgeRelation::Annotates]),
4873 )
4874 .await
4875 .unwrap();
4876 assert_eq!(before.len(), 1);
4877
4878 let deleted = rt.delete_note(&tok, note_target.id, false).await.unwrap();
4880 assert!(deleted, "soft delete must return true");
4881
4882 let after = rt
4883 .neighbors(
4884 &tok,
4885 note_source.id,
4886 Direction::Out,
4887 None,
4888 Some(vec![EdgeRelation::Annotates]),
4889 )
4890 .await
4891 .unwrap();
4892 assert_eq!(
4893 after.len(),
4894 1,
4895 "soft delete must NOT cascade edges; got {after:?}"
4896 );
4897 }
4898
4899 #[tokio::test]
4906 async fn delete_edge_non_edge_uuid_has_no_side_effects() {
4907 let rt = rt();
4908 let tok = NamespaceToken::local();
4909
4910 let entity = rt
4912 .create_entity(&tok, "concept", None, "Target", None, None, vec![])
4913 .await
4914 .unwrap();
4915 let note = rt
4916 .create_note(
4917 &tok,
4918 "observation",
4919 None,
4920 "annotates the entity",
4921 Some(0.5),
4922 None,
4923 vec![entity.id],
4924 )
4925 .await
4926 .unwrap();
4927
4928 let before = rt
4930 .neighbors(
4931 &tok,
4932 note.id,
4933 Direction::Out,
4934 None,
4935 Some(vec![EdgeRelation::Annotates]),
4936 )
4937 .await
4938 .unwrap();
4939 assert_eq!(before.len(), 1, "annotates edge must exist before test");
4940 let annotates_edge_id: Uuid = before[0].edge_id;
4941
4942 let result = rt.delete_edge(&tok, entity.id, true).await;
4944 assert!(
4945 result.is_ok(),
4946 "delete_edge must not error on a non-edge UUID"
4947 );
4948 assert!(
4949 !result.unwrap(),
4950 "delete_edge must return false for a non-edge UUID"
4951 );
4952
4953 let after = rt
4955 .neighbors(
4956 &tok,
4957 note.id,
4958 Direction::Out,
4959 None,
4960 Some(vec![EdgeRelation::Annotates]),
4961 )
4962 .await
4963 .unwrap();
4964 assert_eq!(
4965 after.len(),
4966 1,
4967 "delete_edge with a non-edge UUID must not touch inbound annotates edges"
4968 );
4969 assert_eq!(
4970 after[0].edge_id, annotates_edge_id,
4971 "the original annotates edge must be unchanged"
4972 );
4973 }
4974
4975 #[tokio::test]
4986 async fn create_note_multi_annotates_second_link_failure_rolls_back_partial_write() {
4987 let rt = rt();
4988 let tok = NamespaceToken::local();
4989 let t1 = rt
4990 .create_entity(&tok, "concept", None, "T1", None, None, vec![])
4991 .await
4992 .unwrap();
4993 let t2 = rt
4994 .create_entity(&tok, "concept", None, "T2", None, None, vec![])
4995 .await
4996 .unwrap();
4997
4998 LINK_FAIL_AFTER.with(|cell| cell.set(2));
5000
5001 let result = rt
5002 .create_note(
5003 &tok,
5004 "observation",
5005 None,
5006 "rollback target",
5007 Some(0.5),
5008 None,
5009 vec![t1.id, t2.id],
5010 )
5011 .await;
5012
5013 assert!(
5015 result.is_err(),
5016 "create_note must propagate the injected link failure"
5017 );
5018 let err_msg = result.unwrap_err().to_string();
5019 assert!(
5020 err_msg.contains("injected link failure"),
5021 "error must carry injection message; got: {err_msg}"
5022 );
5023
5024 let notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
5026 assert!(
5027 notes.is_empty(),
5028 "compensation must remove the note row; got {notes:?}"
5029 );
5030
5031 let hits = rt
5033 .search_notes(&tok, "rollback target", None, 10, None, false)
5034 .await
5035 .unwrap();
5036 assert!(
5037 hits.is_empty(),
5038 "compensation must clean FTS index; got {hits:?}"
5039 );
5040
5041 let edges_from_t1 = rt
5043 .neighbors(
5044 &tok,
5045 t1.id,
5046 Direction::In,
5047 None,
5048 Some(vec![EdgeRelation::Annotates]),
5049 )
5050 .await
5051 .unwrap();
5052 let edges_from_t2 = rt
5053 .neighbors(
5054 &tok,
5055 t2.id,
5056 Direction::In,
5057 None,
5058 Some(vec![EdgeRelation::Annotates]),
5059 )
5060 .await
5061 .unwrap();
5062 assert!(
5063 edges_from_t1.is_empty(),
5064 "compensation must delete the first annotates edge; got {edges_from_t1:?}"
5065 );
5066 assert!(
5067 edges_from_t2.is_empty(),
5068 "no second annotates edge must exist; got {edges_from_t2:?}"
5069 );
5070 }
5071
5072 #[tokio::test]
5075 async fn soft_delete_entity_removes_indexes() {
5076 let rt = rt();
5077 let tok = NamespaceToken::local();
5078 let entity = rt
5079 .create_entity(
5080 &tok,
5081 "concept",
5082 None,
5083 "QuantumEntanglement",
5084 Some("unique FTS term xzqjwv for soft delete test"),
5085 None,
5086 vec![],
5087 )
5088 .await
5089 .unwrap();
5090
5091 let ns = tok.namespace().as_str().to_string();
5092
5093 let before = rt
5094 .text(&tok)
5095 .unwrap()
5096 .search(TextSearchRequest {
5097 query: "xzqjwv".to_string(),
5098 mode: TextQueryMode::Plain,
5099 filter: Some(TextFilter {
5100 namespaces: vec![ns.clone()],
5101 ..Default::default()
5102 }),
5103 top_k: 10,
5104 snippet_chars: 100,
5105 })
5106 .await
5107 .unwrap();
5108 assert!(
5109 before.iter().any(|h| h.subject_id == entity.id),
5110 "entity must be in FTS before soft-delete"
5111 );
5112
5113 let deleted = rt.delete_entity(&tok, entity.id, false).await.unwrap();
5114 assert!(deleted, "soft delete must return true");
5115
5116 let after = rt
5117 .text(&tok)
5118 .unwrap()
5119 .search(TextSearchRequest {
5120 query: "xzqjwv".to_string(),
5121 mode: TextQueryMode::Plain,
5122 filter: Some(TextFilter {
5123 namespaces: vec![ns],
5124 ..Default::default()
5125 }),
5126 top_k: 10,
5127 snippet_chars: 100,
5128 })
5129 .await
5130 .unwrap();
5131 assert!(
5132 after.iter().all(|h| h.subject_id != entity.id),
5133 "soft-deleted entity must be removed from FTS index"
5134 );
5135 }
5136
5137 #[tokio::test]
5138 async fn soft_delete_note_removes_indexes() {
5139 let rt = rt();
5140 let tok = NamespaceToken::local();
5141 let note = rt
5142 .create_note(
5143 &tok,
5144 "observation",
5145 None,
5146 "SpectralDecomposition unique term yvwkqz for soft delete test",
5147 Some(0.7),
5148 None,
5149 vec![],
5150 )
5151 .await
5152 .unwrap();
5153
5154 let before = rt
5155 .search_notes(&tok, "yvwkqz", None, 10, None, false)
5156 .await
5157 .unwrap();
5158 assert!(
5159 before.iter().any(|h| h.note_id == note.id),
5160 "note must be in FTS before soft-delete"
5161 );
5162
5163 let deleted = rt.delete_note(&tok, note.id, false).await.unwrap();
5164 assert!(deleted, "soft delete must return true");
5165
5166 let after = rt
5167 .search_notes(&tok, "yvwkqz", None, 10, None, false)
5168 .await
5169 .unwrap();
5170 assert!(
5171 after.iter().all(|h| h.note_id != note.id),
5172 "soft-deleted note must be removed from FTS index"
5173 );
5174 }
5175
5176 #[tokio::test]
5179 async fn link_extends_document_to_document_returns_invalid_input() {
5180 let rt = rt();
5181 let tok = NamespaceToken::local();
5182 let d1 = rt
5183 .create_entity(&tok, "document", None, "DocA", None, None, vec![])
5184 .await
5185 .unwrap();
5186 let d2 = rt
5187 .create_entity(&tok, "document", None, "DocB", None, None, vec![])
5188 .await
5189 .unwrap();
5190 let result = rt
5191 .link(&tok, d1.id, d2.id, EdgeRelation::Extends, 1.0, None)
5192 .await;
5193 assert!(
5194 result.is_err(),
5195 "F010: document->document Extends must be rejected by the base allowlist; \
5196 current generic entity fallthrough incorrectly accepts it"
5197 );
5198 }
5199
5200 #[tokio::test]
5202 async fn link_extends_concept_to_concept_succeeds() {
5203 let rt = rt();
5204 let tok = NamespaceToken::local();
5205 let a = rt
5206 .create_entity(&tok, "concept", None, "CA", None, None, vec![])
5207 .await
5208 .unwrap();
5209 let b = rt
5210 .create_entity(&tok, "concept", None, "CB", None, None, vec![])
5211 .await
5212 .unwrap();
5213 let result = rt
5214 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5215 .await;
5216 assert!(
5217 result.is_ok(),
5218 "F010: concept->concept Extends must be allowed (base allowlist)"
5219 );
5220 }
5221
5222 #[tokio::test]
5225 async fn link_symmetric_relation_canonicalizes_endpoint_order() {
5226 use khive_storage::EdgeFilter;
5227 let rt = rt();
5228 let tok = NamespaceToken::local();
5229 let a = rt
5230 .create_entity(&tok, "concept", None, "ConceptP", None, None, vec![])
5231 .await
5232 .unwrap();
5233 let b = rt
5234 .create_entity(&tok, "concept", None, "ConceptQ", None, None, vec![])
5235 .await
5236 .unwrap();
5237 rt.link(&tok, a.id, b.id, EdgeRelation::CompetesWith, 1.0, None)
5239 .await
5240 .unwrap();
5241 rt.link(&tok, b.id, a.id, EdgeRelation::CompetesWith, 1.0, None)
5242 .await
5243 .unwrap();
5244 let count = rt
5245 .graph(&tok)
5246 .unwrap()
5247 .count_edges(EdgeFilter::default())
5248 .await
5249 .unwrap();
5250 assert_eq!(
5251 count,
5252 1,
5253 "F012: CompetesWith is symmetric; A->B and B->A must deduplicate to one canonical row; \
5254 found {count} rows (canonicalization not yet implemented)"
5255 );
5256 }
5257
5258 #[tokio::test]
5260 async fn f010_supersedes_document_to_document_allowed() {
5261 let rt = rt();
5262 let tok = NamespaceToken::local();
5263 let a = rt
5264 .create_entity(&tok, "document", None, "DocA", None, None, vec![])
5265 .await
5266 .unwrap();
5267 let b = rt
5268 .create_entity(&tok, "document", None, "DocB", None, None, vec![])
5269 .await
5270 .unwrap();
5271 let result = rt
5272 .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
5273 .await;
5274 assert!(
5275 result.is_ok(),
5276 "document->document Supersedes must be allowed (allowlist), got {result:?}"
5277 );
5278 }
5279
5280 #[tokio::test]
5281 async fn f010_supersedes_artifact_to_artifact_allowed() {
5282 let rt = rt();
5283 let tok = NamespaceToken::local();
5284 let a = rt
5285 .create_entity(&tok, "artifact", None, "ArtA", None, None, vec![])
5286 .await
5287 .unwrap();
5288 let b = rt
5289 .create_entity(&tok, "artifact", None, "ArtB", None, None, vec![])
5290 .await
5291 .unwrap();
5292 let result = rt
5293 .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
5294 .await;
5295 assert!(
5296 result.is_ok(),
5297 "artifact->artifact Supersedes must be allowed (allowlist), got {result:?}"
5298 );
5299 }
5300
5301 #[tokio::test]
5302 async fn f010_supersedes_service_to_service_allowed() {
5303 let rt = rt();
5304 let tok = NamespaceToken::local();
5305 let a = rt
5306 .create_entity(&tok, "service", None, "SvcA", None, None, vec![])
5307 .await
5308 .unwrap();
5309 let b = rt
5310 .create_entity(&tok, "service", None, "SvcB", None, None, vec![])
5311 .await
5312 .unwrap();
5313 let result = rt
5314 .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
5315 .await;
5316 assert!(
5317 result.is_ok(),
5318 "service->service Supersedes must be allowed (allowlist), got {result:?}"
5319 );
5320 }
5321
5322 #[tokio::test]
5323 async fn f010_supersedes_dataset_to_dataset_allowed() {
5324 let rt = rt();
5325 let tok = NamespaceToken::local();
5326 let a = rt
5327 .create_entity(&tok, "dataset", None, "DataA", None, None, vec![])
5328 .await
5329 .unwrap();
5330 let b = rt
5331 .create_entity(&tok, "dataset", None, "DataB", None, None, vec![])
5332 .await
5333 .unwrap();
5334 let result = rt
5335 .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
5336 .await;
5337 assert!(
5338 result.is_ok(),
5339 "dataset->dataset Supersedes must be allowed (allowlist), got {result:?}"
5340 );
5341 }
5342
5343 #[tokio::test]
5345 async fn f010_supersedes_project_to_project_rejected() {
5346 let rt = rt();
5347 let tok = NamespaceToken::local();
5348 let a = rt
5349 .create_entity(&tok, "project", None, "ProjA", None, None, vec![])
5350 .await
5351 .unwrap();
5352 let b = rt
5353 .create_entity(&tok, "project", None, "ProjB", None, None, vec![])
5354 .await
5355 .unwrap();
5356 let result = rt
5357 .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
5358 .await;
5359 assert!(
5360 matches!(result, Err(RuntimeError::InvalidInput(_))),
5361 "project->project Supersedes must be rejected (not in allowlist), got {result:?}"
5362 );
5363 }
5364
5365 #[tokio::test]
5366 async fn f010_supersedes_person_to_person_rejected() {
5367 let rt = rt();
5368 let tok = NamespaceToken::local();
5369 let a = rt
5370 .create_entity(&tok, "person", None, "Alice", None, None, vec![])
5371 .await
5372 .unwrap();
5373 let b = rt
5374 .create_entity(&tok, "person", None, "Bob", None, None, vec![])
5375 .await
5376 .unwrap();
5377 let result = rt
5378 .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
5379 .await;
5380 assert!(
5381 matches!(result, Err(RuntimeError::InvalidInput(_))),
5382 "person->person Supersedes must be rejected (not in allowlist), got {result:?}"
5383 );
5384 }
5385
5386 #[tokio::test]
5387 async fn f010_supersedes_org_to_org_rejected() {
5388 let rt = rt();
5389 let tok = NamespaceToken::local();
5390 let a = rt
5391 .create_entity(&tok, "org", None, "OrgA", None, None, vec![])
5392 .await
5393 .unwrap();
5394 let b = rt
5395 .create_entity(&tok, "org", None, "OrgB", None, None, vec![])
5396 .await
5397 .unwrap();
5398 let result = rt
5399 .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
5400 .await;
5401 assert!(
5402 matches!(result, Err(RuntimeError::InvalidInput(_))),
5403 "org->org Supersedes must be rejected (not in allowlist), got {result:?}"
5404 );
5405 }
5406
5407 #[tokio::test]
5409 async fn f010_supersedes_same_kind_entity_allowed() {
5410 let rt = rt();
5411 let tok = NamespaceToken::local();
5412 let a = rt
5413 .create_entity(&tok, "concept", None, "OldV", None, None, vec![])
5414 .await
5415 .unwrap();
5416 let b = rt
5417 .create_entity(&tok, "concept", None, "NewV", None, None, vec![])
5418 .await
5419 .unwrap();
5420 let result = rt
5421 .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
5422 .await;
5423 assert!(
5424 result.is_ok(),
5425 "concept->concept Supersedes must be allowed by the base allowlist, got {result:?}"
5426 );
5427 }
5428
5429 #[tokio::test]
5433 async fn f161_link_always_writes_null_target_backend() {
5434 let rt = rt();
5435 let tok = NamespaceToken::local();
5436 let a = rt
5437 .create_entity(&tok, "concept", None, "A", None, None, vec![])
5438 .await
5439 .unwrap();
5440 let b = rt
5441 .create_entity(&tok, "concept", None, "B", None, None, vec![])
5442 .await
5443 .unwrap();
5444 let edge = rt
5445 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5446 .await
5447 .unwrap();
5448 assert!(
5449 edge.target_backend.is_none(),
5450 "F161: target_backend must be None for locally-routed edges; got {:?}",
5451 edge.target_backend
5452 );
5453 }
5454
5455 #[tokio::test]
5457 async fn f161_link_many_always_writes_null_target_backend() {
5458 let rt = rt();
5459 let tok = NamespaceToken::local();
5460 let a = rt
5461 .create_entity(&tok, "concept", None, "A", None, None, vec![])
5462 .await
5463 .unwrap();
5464 let b = rt
5465 .create_entity(&tok, "concept", None, "B", None, None, vec![])
5466 .await
5467 .unwrap();
5468 let c = rt
5469 .create_entity(&tok, "concept", None, "C", None, None, vec![])
5470 .await
5471 .unwrap();
5472 let specs = vec![
5473 LinkSpec {
5474 namespace: None,
5475 source_id: a.id,
5476 target_id: b.id,
5477 relation: EdgeRelation::Extends,
5478 weight: 1.0,
5479 metadata: None,
5480 },
5481 LinkSpec {
5482 namespace: None,
5483 source_id: a.id,
5484 target_id: c.id,
5485 relation: EdgeRelation::Enables,
5486 weight: 1.0,
5487 metadata: None,
5488 },
5489 ];
5490 let edges = rt.link_many(&tok, specs).await.unwrap();
5491 for edge in &edges {
5492 assert!(
5493 edge.target_backend.is_none(),
5494 "F161: target_backend must be None for locally-routed edges in link_many; got {:?}",
5495 edge.target_backend
5496 );
5497 }
5498 }
5499
5500 #[tokio::test]
5503 async fn f012_symmetric_neighbors_visible_from_both_endpoints() {
5504 let rt = rt();
5505 let tok = NamespaceToken::local();
5506 let a = rt
5507 .create_entity(&tok, "concept", None, "A", None, None, vec![])
5508 .await
5509 .unwrap();
5510 let b = rt
5511 .create_entity(&tok, "concept", None, "B", None, None, vec![])
5512 .await
5513 .unwrap();
5514 rt.link(&tok, a.id, b.id, EdgeRelation::CompetesWith, 1.0, None)
5516 .await
5517 .unwrap();
5518 let from_a = rt
5520 .neighbors(
5521 &tok,
5522 a.id,
5523 Direction::Out,
5524 None,
5525 Some(vec![EdgeRelation::CompetesWith]),
5526 )
5527 .await
5528 .unwrap();
5529 let from_b = rt
5530 .neighbors(
5531 &tok,
5532 b.id,
5533 Direction::Out,
5534 None,
5535 Some(vec![EdgeRelation::CompetesWith]),
5536 )
5537 .await
5538 .unwrap();
5539 assert_eq!(
5540 from_a.len(),
5541 1,
5542 "node A must see competes_with neighbor from Direction::Out (F012); got {from_a:?}"
5543 );
5544 assert_eq!(
5545 from_b.len(),
5546 1,
5547 "node B must see competes_with neighbor from Direction::Out (F012); got {from_b:?}"
5548 );
5549 }
5550
5551 #[tokio::test]
5553 async fn f010_supersedes_cross_kind_entity_rejected() {
5554 let rt = rt();
5555 let tok = NamespaceToken::local();
5556 let concept = rt
5557 .create_entity(&tok, "concept", None, "MyConcept", None, None, vec![])
5558 .await
5559 .unwrap();
5560 let doc = rt
5561 .create_entity(&tok, "document", None, "MyDoc", None, None, vec![])
5562 .await
5563 .unwrap();
5564 let result = rt
5565 .link(
5566 &tok,
5567 concept.id,
5568 doc.id,
5569 EdgeRelation::Supersedes,
5570 1.0,
5571 None,
5572 )
5573 .await;
5574 assert!(
5575 matches!(result, Err(RuntimeError::InvalidInput(_))),
5576 "concept->document Supersedes must be rejected by the base allowlist, got {result:?}"
5577 );
5578 }
5579
5580 #[tokio::test]
5581 async fn delete_note_cross_namespace_returns_mismatch_error() {
5582 let rt = rt();
5583 let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
5584 let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
5585 let note = rt
5586 .create_note(
5587 &ns_a,
5588 "observation",
5589 None,
5590 "note in ns-a",
5591 Some(0.8),
5592 None,
5593 vec![],
5594 )
5595 .await
5596 .unwrap();
5597
5598 let result = rt.delete_note(&ns_b, note.id, true).await;
5601 assert!(
5602 !result.unwrap(),
5603 "cross-namespace delete_note must return Ok(false), not NamespaceMismatch"
5604 );
5605
5606 let note_store = rt.notes(&ns_a).unwrap();
5608 let still_there = note_store.get_note(note.id).await.unwrap();
5609 assert!(
5610 still_there.is_some(),
5611 "note must survive cross-ns delete attempt"
5612 );
5613 }
5614
5615 #[tokio::test]
5623 async fn link_many_overlapping_triple_returns_persisted_ids() {
5624 let rt = rt();
5625 let tok = NamespaceToken::local();
5626 let a = rt
5627 .create_entity(&tok, "concept", None, "A", None, None, vec![])
5628 .await
5629 .unwrap();
5630 let b = rt
5631 .create_entity(&tok, "concept", None, "B", None, None, vec![])
5632 .await
5633 .unwrap();
5634
5635 let spec = || LinkSpec {
5636 namespace: None,
5637 source_id: a.id,
5638 target_id: b.id,
5639 relation: EdgeRelation::Extends,
5640 weight: 1.0,
5641 metadata: None,
5642 };
5643
5644 let first = rt.link_many(&tok, vec![spec()]).await.unwrap();
5646 assert_eq!(first.len(), 1);
5647 let persisted_id: Uuid = first[0].id.into();
5648
5649 let second = rt.link_many(&tok, vec![spec()]).await.unwrap();
5652 assert_eq!(second.len(), 1);
5653 let second_id: Uuid = second[0].id.into();
5654
5655 assert_eq!(
5656 persisted_id, second_id,
5657 "link_many with an existing triple must return the persisted row ID ({persisted_id}), \
5658 not a new phantom ID ({second_id})"
5659 );
5660
5661 let count = rt
5663 .count_edges(&tok, crate::curation::EdgeListFilter::default())
5664 .await
5665 .unwrap();
5666 assert_eq!(count, 1, "upsert must not duplicate the edge row");
5667 }
5668
5669 #[tokio::test]
5672 async fn get_edge_cross_namespace_returns_none() {
5673 let rt = rt();
5674 let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
5675 let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
5676
5677 let src = rt
5678 .create_entity(&ns_a, "concept", None, "Src", None, None, vec![])
5679 .await
5680 .unwrap();
5681 let tgt = rt
5682 .create_entity(&ns_a, "concept", None, "Tgt", None, None, vec![])
5683 .await
5684 .unwrap();
5685 let edge = rt
5686 .link(&ns_a, src.id, tgt.id, EdgeRelation::Extends, 1.0, None)
5687 .await
5688 .unwrap();
5689
5690 let ok = rt.get_edge(&ns_a, Uuid::from(edge.id)).await;
5692 assert!(
5693 ok.is_ok() && ok.unwrap().is_some(),
5694 "edge must be visible in its own namespace"
5695 );
5696
5697 let result = rt.get_edge(&ns_b, Uuid::from(edge.id)).await;
5699 assert!(
5700 matches!(result, Ok(None)),
5701 "cross-namespace get_edge must return Ok(None), got {result:?}"
5702 );
5703
5704 let absent = rt.get_edge(&ns_b, Uuid::new_v4()).await;
5706 match (&result, &absent) {
5707 (Ok(None), Ok(None)) => {}
5708 other => panic!(
5709 "foreign and absent edge IDs must have identical observable shape, got {other:?}"
5710 ),
5711 }
5712 }
5713
5714 #[tokio::test]
5717 async fn traverse_foreign_namespace_root_yields_no_expansion() {
5718 use khive_storage::types::TraversalOptions;
5719
5720 let rt = rt();
5721 let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
5722 let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
5723
5724 let a = rt
5725 .create_entity(&ns_a, "concept", None, "A", None, None, vec![])
5726 .await
5727 .unwrap();
5728 let b = rt
5729 .create_entity(&ns_a, "concept", None, "B", None, None, vec![])
5730 .await
5731 .unwrap();
5732 rt.link(&ns_a, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5733 .await
5734 .unwrap();
5735
5736 let paths = rt
5738 .traverse(
5739 &ns_b,
5740 TraversalRequest {
5741 roots: vec![a.id],
5742 options: TraversalOptions {
5743 max_depth: 1,
5744 direction: Direction::Out,
5745 ..Default::default()
5746 },
5747 include_roots: true,
5748 },
5749 )
5750 .await
5751 .unwrap();
5752 assert!(
5753 paths.is_empty(),
5754 "foreign traversal root must be filtered before expansion, got {paths:?}"
5755 );
5756 }
5757}