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