1use std::collections::HashMap;
12use std::str::FromStr;
13
14use serde::Serialize;
15use uuid::Uuid;
16
17use khive_score::DeterministicScore;
18use khive_storage::note::Note;
19use khive_storage::types::{
20 BatchWriteSummary, DeleteMode, DirectedNeighborHit, Direction, EdgeSortField, GraphPath,
21 LinkId, NeighborHit, NeighborQuery, Page, PageRequest, SortOrder, SqlRow, SqlStatement,
22 SqlValue, TextFilter, TextQueryMode, TextSearchRequest, TraversalRequest,
23};
24use khive_storage::{Edge, EdgeRelation, Entity, EntityFilter, Event, EventFilter};
25use khive_types::{EdgeEndpointRule, EndpointKind, EventKind, SubstrateKind};
26
27use khive_db::stores::entity::entity_hard_delete_statement;
28use khive_db::stores::graph::{edge_hard_delete_statement, purge_incident_edges_statement};
29use khive_db::stores::note::note_hard_delete_statement;
30use khive_db::SqliteError;
31use rusqlite::OptionalExtension;
32
33use crate::atomic_plan::{AffectedRowGuard, DeletePlan, PlanStatement, PostCommitEffect};
34use crate::atomic_runner::{run_atomic_unit, AtomicOpFailure, AtomicOpPlan, AtomicRunOutcome};
35use crate::curation::{entity_fts_document, note_fts_document};
36use crate::error::{GuardedWriteFailure, RuntimeError, RuntimeResult};
37use crate::runtime::{KhiveRuntime, NamespaceToken};
38
39#[cfg(test)]
45std::thread_local! {
46 static LINK_FAIL_AFTER: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
47}
48
49#[cfg(any(test, feature = "fault-injection"))]
58std::thread_local! {
59 static VECTOR_FAIL_AFTER: std::cell::Cell<Option<usize>> =
60 const { std::cell::Cell::new(None) };
61}
62
63#[cfg(any(test, feature = "fault-injection"))]
71pub fn arm_vector_fail_after(n: usize) {
72 VECTOR_FAIL_AFTER.with(|cell| cell.set(Some(n)));
73}
74
75#[cfg(any(test, feature = "fault-injection"))]
87static FTS_FAIL_NS: std::sync::LazyLock<std::sync::Mutex<std::collections::HashSet<String>>> =
88 std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashSet::new()));
89#[cfg(any(test, feature = "fault-injection"))]
90static VECTOR_FAIL_NS: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
91#[cfg(any(test, feature = "fault-injection"))]
94static FTS_FAIL_MANY_NS: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
95#[cfg(any(test, feature = "fault-injection"))]
99static FTS_FAIL_MANY_PARTIAL_NS: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
100#[cfg(any(test, feature = "fault-injection"))]
107static FTS_SEARCH_FAIL_NS: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
108
109#[cfg(any(test, feature = "fault-injection"))]
120pub fn arm_fts_fail(ns: &str) {
121 FTS_FAIL_NS.lock().unwrap().insert(ns.to_string());
122}
123
124#[cfg(any(test, feature = "fault-injection"))]
131pub fn arm_fts_fail_many(ns: &str) {
132 *FTS_FAIL_MANY_NS.lock().unwrap() = Some(ns.to_string());
133}
134
135#[cfg(any(test, feature = "fault-injection"))]
143pub fn arm_fts_fail_many_partial(ns: &str) {
144 *FTS_FAIL_MANY_PARTIAL_NS.lock().unwrap() = Some(ns.to_string());
145}
146
147#[cfg(any(test, feature = "fault-injection"))]
157pub fn arm_fts_search_fail(ns: &str) {
158 *FTS_SEARCH_FAIL_NS.lock().unwrap() = Some(ns.to_string());
159}
160
161#[cfg(any(test, feature = "fault-injection"))]
168pub fn arm_vector_fail(ns: &str) {
169 *VECTOR_FAIL_NS.lock().unwrap() = Some(ns.to_string());
170}
171
172#[cfg(any(test, feature = "fault-injection"))]
177static ROLLBACK_CLEANUP_FAIL_NS: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
178
179#[cfg(any(test, feature = "fault-injection"))]
186pub fn arm_rollback_cleanup_fail(ns: &str) {
187 *ROLLBACK_CLEANUP_FAIL_NS.lock().unwrap() = Some(ns.to_string());
188}
189
190#[derive(Clone, Debug)]
192pub struct NoteSearchHit {
193 pub note_id: Uuid,
194 pub score: DeterministicScore,
195 pub title: Option<String>,
196 pub snippet: Option<String>,
197}
198
199pub fn hex_prefix_to_uuid_pattern(prefix: &str) -> String {
210 if prefix.contains('-') {
211 return prefix.to_string();
212 }
213 const BOUNDARIES: [usize; 4] = [8, 13, 18, 23]; let mut out = String::with_capacity(36);
215 for c in prefix.chars() {
216 if BOUNDARIES.contains(&out.len()) {
217 out.push('-');
218 }
219 out.push(c);
220 }
221 out
222}
223
224fn text_preview(text: &str, max_chars: usize) -> Option<String> {
225 let trimmed = text.trim();
226 if trimmed.is_empty() {
227 None
228 } else {
229 Some(trimmed.chars().take(max_chars).collect())
230 }
231}
232
233fn normalize_symmetric_direction(
239 direction: Direction,
240 relations: Option<&[EdgeRelation]>,
241) -> Direction {
242 let Some(rels) = relations else {
243 return direction;
244 };
245 if rels.is_empty() {
246 return direction;
247 }
248 let all_symmetric = rels
249 .iter()
250 .all(|r| matches!(r, EdgeRelation::CompetesWith | EdgeRelation::ComposedWith));
251 if all_symmetric {
252 Direction::Both
253 } else {
254 direction
255 }
256}
257
258fn direction_sort_rank(direction: &Direction) -> u8 {
265 match direction {
266 Direction::Out => 0,
267 Direction::In => 1,
268 Direction::Both => 2,
269 }
270}
271
272fn note_title(note: &Note) -> Option<String> {
273 note.name
274 .clone()
275 .filter(|s| !s.trim().is_empty())
276 .or_else(|| Some(format!("[{}]", note.kind.as_str())))
277}
278
279fn note_snippet(note: &Note) -> Option<String> {
280 text_preview(¬e.content, 200)
281}
282
283#[derive(Clone, Debug)]
285pub enum Resolved {
286 Entity(Entity),
287 Note(Note),
288 Event(Event),
289 PackRecord {
296 pack: String,
297 kind: String,
298 data: serde_json::Value,
299 },
300}
301
302#[derive(Clone, Copy, Debug, Eq, PartialEq)]
309pub enum EdgeEndpointKind {
310 Entity,
311 Note,
312 Event,
313 Edge,
314}
315
316fn resolved_pair(r: Option<&Resolved>) -> Option<(&'static str, &str, Option<&str>)> {
322 match r? {
323 Resolved::Entity(e) => Some(("entity", e.kind.as_str(), e.entity_type.as_deref())),
324 Resolved::Note(n) => Some(("note", n.kind.as_str(), None)),
325 Resolved::Event(_) => None,
326 Resolved::PackRecord { .. } => None,
327 }
328}
329
330pub fn endpoint_matches(
338 spec: &EndpointKind,
339 substrate: &str,
340 kind: &str,
341 entity_type: Option<&str>,
342) -> bool {
343 match spec {
344 EndpointKind::EntityOfKind(k) => substrate == "entity" && *k == kind,
345 EndpointKind::NoteOfKind(k) => substrate == "note" && *k == kind,
346 EndpointKind::EntityOfType {
347 kind: k,
348 entity_type: t,
349 } => substrate == "entity" && *k == kind && entity_type == Some(*t),
350 }
351}
352
353pub fn accepted_pack_relations_for_entities(
367 rules: &[EdgeEndpointRule],
368 src_kind: &str,
369 src_entity_type: Option<&str>,
370 tgt_kind: &str,
371 tgt_entity_type: Option<&str>,
372) -> Vec<EdgeRelation> {
373 let mut relations: Vec<EdgeRelation> = rules
374 .iter()
375 .filter(|r| {
376 endpoint_matches(&r.source, "entity", src_kind, src_entity_type)
377 && endpoint_matches(&r.target, "entity", tgt_kind, tgt_entity_type)
378 })
379 .map(|r| r.relation)
380 .collect();
381 relations.sort_by_key(|r| r.as_str());
382 relations.dedup();
383 relations
384}
385
386fn pack_rule_allows(
389 rules: &[EdgeEndpointRule],
390 relation: EdgeRelation,
391 src: Option<&Resolved>,
392 tgt: Option<&Resolved>,
393) -> bool {
394 let Some((src_sub, src_kind, src_type)) = resolved_pair(src) else {
395 return false;
396 };
397 let Some((tgt_sub, tgt_kind, tgt_type)) = resolved_pair(tgt) else {
398 return false;
399 };
400 rules.iter().any(|r| {
401 r.relation == relation
402 && endpoint_matches(&r.source, src_sub, src_kind, src_type)
403 && endpoint_matches(&r.target, tgt_sub, tgt_kind, tgt_type)
404 })
405}
406
407pub const BASE_ENTITY_ENDPOINT_RULES: &[(&str, EdgeRelation, &str)] = &[
417 ("concept", EdgeRelation::Contains, "concept"),
419 ("project", EdgeRelation::Contains, "project"),
420 ("project", EdgeRelation::Contains, "artifact"),
421 ("org", EdgeRelation::Contains, "project"),
422 ("org", EdgeRelation::Contains, "service"),
423 ("concept", EdgeRelation::PartOf, "concept"),
424 ("project", EdgeRelation::PartOf, "project"),
425 ("project", EdgeRelation::PartOf, "org"),
426 ("*", EdgeRelation::InstanceOf, "concept"),
427 ("service", EdgeRelation::InstanceOf, "project"),
428 ("concept", EdgeRelation::Extends, "concept"),
430 ("concept", EdgeRelation::VariantOf, "concept"),
431 ("artifact", EdgeRelation::VariantOf, "artifact"),
432 ("concept", EdgeRelation::IntroducedBy, "document"),
433 ("concept", EdgeRelation::IntroducedBy, "person"),
434 ("artifact", EdgeRelation::IntroducedBy, "document"),
435 ("document", EdgeRelation::IntroducedBy, "person"),
436 ("document", EdgeRelation::IntroducedBy, "org"),
437 ("concept", EdgeRelation::IntroducedBy, "org"),
438 ("artifact", EdgeRelation::DerivedFrom, "dataset"),
440 ("artifact", EdgeRelation::DerivedFrom, "document"),
441 ("artifact", EdgeRelation::DerivedFrom, "project"),
442 ("artifact", EdgeRelation::DerivedFrom, "artifact"),
443 ("document", EdgeRelation::Precedes, "document"),
445 ("dataset", EdgeRelation::Precedes, "dataset"),
446 ("artifact", EdgeRelation::Precedes, "artifact"),
447 ("service", EdgeRelation::Precedes, "service"),
448 ("project", EdgeRelation::Precedes, "project"),
449 ("project", EdgeRelation::DependsOn, "project"),
451 ("service", EdgeRelation::DependsOn, "project"),
452 ("service", EdgeRelation::DependsOn, "service"),
453 ("service", EdgeRelation::DependsOn, "artifact"),
454 ("service", EdgeRelation::DependsOn, "dataset"),
455 ("artifact", EdgeRelation::DependsOn, "project"),
456 ("artifact", EdgeRelation::DependsOn, "service"),
457 ("document", EdgeRelation::DependsOn, "document"),
458 ("concept", EdgeRelation::Enables, "concept"),
459 ("service", EdgeRelation::Enables, "concept"),
460 ("dataset", EdgeRelation::Enables, "concept"),
461 ("project", EdgeRelation::Implements, "concept"),
463 ("service", EdgeRelation::Implements, "concept"),
464 ("concept", EdgeRelation::CompetesWith, "concept"),
466 ("project", EdgeRelation::CompetesWith, "project"),
467 ("service", EdgeRelation::CompetesWith, "service"),
468 ("concept", EdgeRelation::ComposedWith, "concept"),
469 ("project", EdgeRelation::ComposedWith, "project"),
470 ("concept", EdgeRelation::Supersedes, "concept"),
472 ("document", EdgeRelation::Supersedes, "document"),
473 ("artifact", EdgeRelation::Supersedes, "artifact"),
474 ("service", EdgeRelation::Supersedes, "service"),
475 ("dataset", EdgeRelation::Supersedes, "dataset"),
476 ("concept", EdgeRelation::Supports, "concept"),
478 ("document", EdgeRelation::Supports, "concept"),
479 ("dataset", EdgeRelation::Supports, "concept"),
480 ("artifact", EdgeRelation::Supports, "concept"),
481 ("concept", EdgeRelation::Refutes, "concept"),
482 ("document", EdgeRelation::Refutes, "concept"),
483 ("dataset", EdgeRelation::Refutes, "concept"),
484 ("artifact", EdgeRelation::Refutes, "concept"),
485];
486
487pub fn base_entity_endpoint_rules() -> &'static [(&'static str, EdgeRelation, &'static str)] {
493 BASE_ENTITY_ENDPOINT_RULES
494}
495
496pub fn base_entity_rule_allows(src_kind: &str, relation: EdgeRelation, tgt_kind: &str) -> bool {
502 BASE_ENTITY_ENDPOINT_RULES.iter().any(|(src, rel, tgt)| {
503 *rel == relation && (*src == "*" || *src == src_kind) && *tgt == tgt_kind
504 })
505}
506
507pub(crate) fn canonical_edge_endpoints(
513 relation: EdgeRelation,
514 source_id: Uuid,
515 target_id: Uuid,
516) -> (Uuid, Uuid) {
517 if relation.is_symmetric() && target_id < source_id {
518 (target_id, source_id)
519 } else {
520 (source_id, target_id)
521 }
522}
523
524pub(crate) fn infer_dependency_kind(src_kind: &str, tgt_kind: &str) -> Option<&'static str> {
530 match (src_kind, tgt_kind) {
531 ("project", "project") => Some("build"),
532 ("service", "service") => Some("runtime"),
533 ("service", "dataset") => Some("data"),
534 ("service", "artifact") => Some("artifact"),
535 ("artifact", "project") | ("artifact", "service") => Some("tooling"),
536 ("document", "document") => Some("normative"),
537 _ => None,
538 }
539}
540
541pub(crate) fn merge_dependency_kind(
551 src_kind: &str,
552 tgt_kind: &str,
553 metadata: Option<serde_json::Value>,
554) -> Option<serde_json::Value> {
555 if let Some(ref m) = metadata {
556 if m.get("dependency_kind").is_some() {
557 return metadata;
558 }
559 }
560 let inferred = infer_dependency_kind(src_kind, tgt_kind)?;
561 let mut obj = metadata.unwrap_or_else(|| serde_json::json!({}));
562 if let Some(o) = obj.as_object_mut() {
563 o.insert("dependency_kind".to_string(), serde_json::json!(inferred));
564 }
565 Some(obj)
566}
567
568pub fn merge_entry_metadata(
582 metadata: Option<serde_json::Value>,
583 dependency_kind: Option<String>,
584) -> RuntimeResult<Option<serde_json::Value>> {
585 let Some(dk) = dependency_kind else {
586 return Ok(metadata);
587 };
588 let mut obj = metadata.unwrap_or_else(|| serde_json::json!({}));
589 let map = obj
590 .as_object_mut()
591 .ok_or_else(|| RuntimeError::InvalidInput("metadata must be a JSON object".into()))?;
592 map.entry("dependency_kind".to_string())
593 .or_insert_with(|| serde_json::json!(dk));
594 Ok(Some(obj))
595}
596
597const VALID_DEPENDENCY_KINDS: &[&str] = &[
599 "build",
600 "runtime",
601 "data",
602 "artifact",
603 "tooling",
604 "normative",
605];
606
607pub(crate) fn validate_edge_weight(weight: f64) -> RuntimeResult<()> {
613 if !weight.is_finite() || !(0.0..=1.0).contains(&weight) {
614 return Err(RuntimeError::InvalidInput(format!(
615 "edge weight must be finite and in [0.0, 1.0], got {weight}"
616 )));
617 }
618 Ok(())
619}
620
621pub(crate) fn validate_edge_metadata(
627 relation: EdgeRelation,
628 metadata: Option<&serde_json::Value>,
629) -> RuntimeResult<()> {
630 let Some(meta) = metadata else {
631 return Ok(());
632 };
633 if let Some(dk) = meta.get("dependency_kind") {
634 if relation != EdgeRelation::DependsOn {
635 return Err(RuntimeError::InvalidInput(format!(
636 "dependency_kind is only valid on depends_on edges (got {})",
637 relation.as_str()
638 )));
639 }
640 let dk_str = dk
641 .as_str()
642 .ok_or_else(|| RuntimeError::InvalidInput("dependency_kind must be a string".into()))?;
643 if !VALID_DEPENDENCY_KINDS.contains(&dk_str) {
644 return Err(RuntimeError::InvalidInput(format!(
645 "unknown dependency_kind {dk_str:?}; valid: {}",
646 VALID_DEPENDENCY_KINDS.join(" | ")
647 )));
648 }
649 }
650 Ok(())
651}
652
653fn note_props_match(note_props: Option<&serde_json::Value>, filter: &serde_json::Value) -> bool {
658 let required = match filter.as_object() {
659 Some(obj) if !obj.is_empty() => obj,
660 _ => return true,
661 };
662 let actual = match note_props.and_then(serde_json::Value::as_object) {
663 Some(obj) => obj,
664 None => return false,
665 };
666 required
667 .iter()
668 .all(|(k, v)| actual.get(k).is_some_and(|av| av == v))
669}
670
671impl KhiveRuntime {
672 #[allow(clippy::too_many_arguments)]
679 pub async fn create_entity(
680 &self,
681 token: &NamespaceToken,
682 kind: &str,
683 entity_type: Option<&str>,
684 name: &str,
685 description: Option<&str>,
686 properties: Option<serde_json::Value>,
687 tags: Vec<String>,
688 ) -> RuntimeResult<Entity> {
689 self.validate_entity_kind(kind)?;
690 crate::secret_gate::check(name)?;
692 if let Some(d) = description {
693 crate::secret_gate::check(d)?;
694 }
695 if let Some(ref p) = properties {
696 crate::secret_gate::check_json(p)?;
697 }
698 crate::secret_gate::check_tags(&tags)?;
699 let ns = token.namespace().as_str();
700 let mut entity = Entity::new(ns, kind, name).with_entity_type(entity_type);
701 if let Some(d) = description {
702 entity = entity.with_description(d);
703 }
704 if let Some(p) = properties {
705 entity = entity.with_properties(p);
706 }
707 if !tags.is_empty() {
708 entity = entity.with_tags(tags);
709 }
710 self.entities(token)?.upsert_entity(entity.clone()).await?;
711
712 let doc = entity_fts_document(&entity);
713 let embed_body = doc.body.clone();
714
715 {
717 #[cfg(any(test, feature = "fault-injection"))]
718 let fts_inject = FTS_FAIL_NS.lock().unwrap().remove(ns);
719 #[cfg(not(any(test, feature = "fault-injection")))]
720 let fts_inject = false;
721 let fts_result: RuntimeResult<()> = if fts_inject {
722 Err(RuntimeError::Internal("injected FTS failure".to_string()))
723 } else {
724 match self.text(token) {
725 Ok(fts) => fts.upsert_document(doc).await.map_err(RuntimeError::from),
726 Err(e) => Err(e),
727 }
728 };
729 if let Err(e) = fts_result {
730 if let Ok(store) = self.entities(token) {
731 if let Err(ce) = store.delete_entity(entity.id, DeleteMode::Hard).await {
732 tracing::error!(
733 error = %ce,
734 id = %entity.id,
735 "create_entity: failed to roll back entity row after FTS failure"
736 );
737 }
738 }
739 return Err(e);
740 }
741 }
742
743 let embed_model_names = {
746 let names = self.registered_embedding_model_names();
747 if names.is_empty() {
748 vec![]
749 } else {
750 names
751 }
752 };
753
754 if embed_model_names.len() == 1 {
755 let model_name = &embed_model_names[0];
756 let vec_result = self
757 .embed_document_with_model(model_name, &embed_body)
758 .await;
759
760 #[cfg(any(test, feature = "fault-injection"))]
761 let vec_inject = {
762 let mut g = VECTOR_FAIL_NS.lock().unwrap();
763 if g.as_deref() == Some(ns) {
764 *g = None;
765 true
766 } else {
767 false
768 }
769 };
770 #[cfg(not(any(test, feature = "fault-injection")))]
771 let vec_inject = false;
772 let vec_result: RuntimeResult<Vec<f32>> = if vec_inject {
773 Err(RuntimeError::Internal(
774 "injected vector failure".to_string(),
775 ))
776 } else {
777 vec_result
778 };
779
780 let single_result: RuntimeResult<()> = match vec_result {
781 Ok(vector) => match self.vectors_for_model(token, model_name) {
782 Ok(vs) => vs
783 .insert(
784 entity.id,
785 SubstrateKind::Entity,
786 ns,
787 "entity.body",
788 vec![vector],
789 )
790 .await
791 .map_err(RuntimeError::from),
792 Err(e) => Err(e),
793 },
794 Err(e) => Err(e),
795 };
796 if let Err(e) = single_result {
797 if let Ok(store) = self.entities(token) {
798 if let Err(ce) = store.delete_entity(entity.id, DeleteMode::Hard).await {
799 tracing::error!(
800 error = %ce,
801 id = %entity.id,
802 "create_entity: failed to roll back entity row after vector failure"
803 );
804 }
805 }
806 if let Ok(fts) = self.text(token) {
807 if let Err(ce) = fts.delete_document(ns, entity.id).await {
808 tracing::error!(
809 error = %ce,
810 id = %entity.id,
811 "create_entity: failed to roll back FTS document after vector failure"
812 );
813 }
814 }
815 return Err(e);
816 }
817 } else if !embed_model_names.is_empty() {
818 let rt_clone = self.clone();
821 let body_owned = embed_body.clone();
822 let mut handles = Vec::with_capacity(embed_model_names.len());
823 for model_name in &embed_model_names {
824 let rt = rt_clone.clone();
825 let text = body_owned.clone();
826 let name = model_name.clone();
827 handles.push(tokio::spawn(async move {
828 rt.embed_document_with_model(&name, &text).await
829 }));
830 }
831 let mut vectors: Vec<Vec<f32>> = Vec::with_capacity(embed_model_names.len());
832 for handle in handles {
833 let join_result = handle
834 .await
835 .map_err(|e| RuntimeError::Internal(format!("embed task panicked: {e}")));
836 match join_result {
837 Err(e) => {
838 if let Ok(store) = self.entities(token) {
839 if let Err(ce) = store.delete_entity(entity.id, DeleteMode::Hard).await
840 {
841 tracing::error!(
842 error = %ce,
843 id = %entity.id,
844 "create_entity: failed to roll back entity row after embed task panic"
845 );
846 }
847 }
848 if let Ok(fts) = self.text(token) {
849 if let Err(ce) = fts.delete_document(ns, entity.id).await {
850 tracing::error!(
851 error = %ce,
852 id = %entity.id,
853 "create_entity: failed to roll back FTS document after embed task panic"
854 );
855 }
856 }
857 return Err(e);
858 }
859 Ok(Err(e)) => {
860 if let Ok(store) = self.entities(token) {
861 if let Err(ce) = store.delete_entity(entity.id, DeleteMode::Hard).await
862 {
863 tracing::error!(
864 error = %ce,
865 id = %entity.id,
866 "create_entity: failed to roll back entity row after embed failure"
867 );
868 }
869 }
870 if let Ok(fts) = self.text(token) {
871 if let Err(ce) = fts.delete_document(ns, entity.id).await {
872 tracing::error!(
873 error = %ce,
874 id = %entity.id,
875 "create_entity: failed to roll back FTS document after embed failure"
876 );
877 }
878 }
879 return Err(e);
880 }
881 Ok(Ok(vec)) => vectors.push(vec),
882 }
883 }
884 let mut inserted_models: Vec<String> = Vec::with_capacity(embed_model_names.len());
886 for (model_name, vector) in embed_model_names.iter().zip(vectors) {
887 #[cfg(any(test, feature = "fault-injection"))]
889 let count_inject = VECTOR_FAIL_AFTER.with(|cell| match cell.get() {
890 Some(0) => {
891 cell.set(None);
892 true
893 }
894 Some(n) => {
895 cell.set(Some(n - 1));
896 false
897 }
898 None => false,
899 });
900 #[cfg(not(any(test, feature = "fault-injection")))]
901 let count_inject = false;
902
903 let insert_result = if count_inject {
904 Err(RuntimeError::Internal(
905 "injected vector insert failure".to_string(),
906 ))
907 } else {
908 match self.vectors_for_model(token, model_name) {
909 Ok(vs) => vs
910 .insert(
911 entity.id,
912 SubstrateKind::Entity,
913 ns,
914 "entity.body",
915 vec![vector],
916 )
917 .await
918 .map_err(RuntimeError::from),
919 Err(e) => Err(e),
920 }
921 };
922 if let Err(e) = insert_result {
923 if let Ok(store) = self.entities(token) {
925 if let Err(ce) = store.delete_entity(entity.id, DeleteMode::Hard).await {
926 tracing::error!(
927 error = %ce,
928 id = %entity.id,
929 "create_entity: failed to roll back entity row after vector insert failure"
930 );
931 }
932 }
933 if let Ok(fts) = self.text(token) {
934 if let Err(ce) = fts.delete_document(ns, entity.id).await {
935 tracing::error!(
936 error = %ce,
937 id = %entity.id,
938 "create_entity: failed to roll back FTS document after vector insert failure"
939 );
940 }
941 }
942 for m in &inserted_models {
943 if let Ok(vs) = self.vectors_for_model(token, m) {
944 if let Err(ce) = vs.delete(entity.id).await {
945 tracing::error!(
946 error = %ce,
947 model = m,
948 id = %entity.id,
949 "create_entity: failed to roll back vector for model after insert failure"
950 );
951 }
952 }
953 }
954 return Err(e);
955 }
956 inserted_models.push(model_name.clone());
957 }
958 }
959
960 Ok(entity)
961 }
962
963 pub async fn get_entity(&self, token: &NamespaceToken, id: Uuid) -> RuntimeResult<Entity> {
967 self.entities(token)?
968 .get_entity(id)
969 .await?
970 .ok_or_else(|| RuntimeError::NotFound(format!("entity {id}")))
971 }
972
973 pub async fn get_entity_including_deleted(
977 &self,
978 token: &NamespaceToken,
979 id: Uuid,
980 ) -> RuntimeResult<Option<Entity>> {
981 self.entities(token)?
982 .get_entity_including_deleted(id)
983 .await
984 .map_err(Into::into)
985 }
986
987 pub async fn get_note_including_deleted(
991 &self,
992 token: &NamespaceToken,
993 id: Uuid,
994 ) -> RuntimeResult<Option<khive_storage::note::Note>> {
995 self.notes(token)?
996 .get_note_including_deleted(id)
997 .await
998 .map_err(Into::into)
999 }
1000
1001 pub async fn get_entities_by_ids(
1005 &self,
1006 token: &NamespaceToken,
1007 ids: &[Uuid],
1008 ) -> RuntimeResult<Vec<Entity>> {
1009 if ids.is_empty() {
1010 return Ok(vec![]);
1011 }
1012 let filter = EntityFilter {
1013 ids: ids.to_vec(),
1014 ..Default::default()
1015 };
1016 let page = self
1017 .entities(token)?
1018 .query_entities(
1019 token.namespace().as_str(),
1020 filter,
1021 PageRequest {
1022 offset: 0,
1023 limit: ids.len() as u32,
1024 },
1025 )
1026 .await?;
1027 Ok(page.items)
1028 }
1029
1030 async fn get_entities_by_ids_visible(
1039 &self,
1040 token: &NamespaceToken,
1041 ids: &[Uuid],
1042 ) -> RuntimeResult<Vec<Entity>> {
1043 if ids.is_empty() {
1044 return Ok(vec![]);
1045 }
1046 let namespaces: Vec<String> = token
1047 .visible_namespaces()
1048 .iter()
1049 .map(|ns| ns.as_str().to_owned())
1050 .collect();
1051 let filter = EntityFilter {
1052 ids: ids.to_vec(),
1053 namespaces,
1054 ..Default::default()
1055 };
1056 let page = self
1057 .entities(token)?
1058 .query_entities(
1059 token.namespace().as_str(),
1060 filter,
1061 PageRequest {
1062 offset: 0,
1063 limit: ids.len() as u32,
1064 },
1065 )
1066 .await?;
1067 Ok(page.items)
1068 }
1069
1070 pub(crate) fn ensure_namespace(record_ns: &str, caller_primary_ns: &str) -> RuntimeResult<()> {
1079 if record_ns == caller_primary_ns {
1080 return Ok(());
1081 }
1082 Err(RuntimeError::NotFound("not found in this namespace".into()))
1083 }
1084
1085 pub(crate) fn ensure_namespace_visible(
1091 record_ns: &str,
1092 token: &NamespaceToken,
1093 ) -> RuntimeResult<()> {
1094 for ns in token.visible_namespaces() {
1095 if record_ns == ns.as_str() {
1096 return Ok(());
1097 }
1098 }
1099 Err(RuntimeError::NotFound("not found in this namespace".into()))
1100 }
1101
1102 pub async fn list_entities(
1108 &self,
1109 token: &NamespaceToken,
1110 kind: Option<&str>,
1111 entity_type: Option<&str>,
1112 limit: u32,
1113 offset: u32,
1114 ) -> RuntimeResult<Vec<Entity>> {
1115 let ns_strs: Vec<String> = token
1116 .visible_namespaces()
1117 .iter()
1118 .map(|ns| ns.as_str().to_owned())
1119 .collect();
1120 let filter = EntityFilter {
1121 kinds: match kind {
1122 Some(k) => vec![k.to_string()],
1123 None => vec![],
1124 },
1125 entity_types: match entity_type {
1126 Some(t) => vec![t.to_string()],
1127 None => vec![],
1128 },
1129 namespaces: ns_strs,
1130 ..Default::default()
1131 };
1132 let page = self
1133 .entities(token)?
1134 .query_entities(
1135 token.namespace().as_str(),
1136 filter,
1137 PageRequest {
1138 offset: offset.into(),
1139 limit,
1140 },
1141 )
1142 .await?;
1143 Ok(page.items)
1144 }
1145
1146 pub async fn list_entities_tagged(
1153 &self,
1154 token: &NamespaceToken,
1155 kind: Option<&str>,
1156 domain_tag: Option<&str>,
1157 limit: u32,
1158 offset: u32,
1159 ) -> RuntimeResult<Vec<Entity>> {
1160 let ns_strs: Vec<String> = token
1161 .visible_namespaces()
1162 .iter()
1163 .map(|ns| ns.as_str().to_owned())
1164 .collect();
1165 let filter = EntityFilter {
1166 kinds: match kind {
1167 Some(k) => vec![k.to_string()],
1168 None => vec![],
1169 },
1170 tags_any: match domain_tag {
1171 Some(t) if !t.is_empty() => vec![t.to_string()],
1172 _ => vec![],
1173 },
1174 namespaces: ns_strs,
1175 ..Default::default()
1176 };
1177 let page = self
1178 .entities(token)?
1179 .query_entities(
1180 token.namespace().as_str(),
1181 filter,
1182 PageRequest {
1183 offset: offset.into(),
1184 limit,
1185 },
1186 )
1187 .await?;
1188 Ok(page.items)
1189 }
1190
1191 pub async fn count_entities_tagged(
1196 &self,
1197 token: &NamespaceToken,
1198 kind: Option<&str>,
1199 domain_tag: Option<&str>,
1200 ) -> RuntimeResult<u64> {
1201 let ns_strs: Vec<String> = token
1202 .visible_namespaces()
1203 .iter()
1204 .map(|ns| ns.as_str().to_owned())
1205 .collect();
1206 let filter = EntityFilter {
1207 kinds: match kind {
1208 Some(k) => vec![k.to_string()],
1209 None => vec![],
1210 },
1211 tags_any: match domain_tag {
1212 Some(t) if !t.is_empty() => vec![t.to_string()],
1213 _ => vec![],
1214 },
1215 namespaces: ns_strs,
1216 ..Default::default()
1217 };
1218 Ok(self
1219 .entities(token)?
1220 .count_entities(token.namespace().as_str(), filter)
1221 .await?)
1222 }
1223
1224 pub async fn list_events(
1226 &self,
1227 token: &NamespaceToken,
1228 filter: EventFilter,
1229 page: PageRequest,
1230 ) -> RuntimeResult<Page<Event>> {
1231 self.events(token)?
1232 .query_events(filter, page)
1233 .await
1234 .map_err(Into::into)
1235 }
1236
1237 pub(crate) async fn validate_edge_relation_endpoints(
1255 &self,
1256 token: &NamespaceToken,
1257 source_id: Uuid,
1258 target_id: Uuid,
1259 relation: EdgeRelation,
1260 ) -> RuntimeResult<()> {
1261 if source_id == target_id {
1262 return Err(RuntimeError::InvalidInput(
1263 "self-loop edges are not allowed: source_id and target_id must be different".into(),
1264 ));
1265 }
1266 if relation == EdgeRelation::Annotates {
1267 match self.resolve_edge_endpoint(token, source_id).await? {
1271 Some(Resolved::Note(_)) => {}
1272 Some(_) => {
1273 return Err(RuntimeError::InvalidInput(format!(
1274 "annotates source {source_id} must be a note"
1275 )));
1276 }
1277 None => {
1278 if self.get_edge(token, source_id).await?.is_some() {
1280 return Err(RuntimeError::InvalidInput(format!(
1281 "annotates source {source_id} must be a note"
1282 )));
1283 }
1284 return Err(RuntimeError::NotFound(format!(
1285 "link source {source_id} not found"
1286 )));
1287 }
1288 }
1289 if !self.substrate_exists_by_id(token, target_id).await? {
1291 return Err(RuntimeError::NotFound(format!(
1292 "link target {target_id} not found"
1293 )));
1294 }
1295 } else if matches!(
1296 relation,
1297 EdgeRelation::Supersedes | EdgeRelation::Supports | EdgeRelation::Refutes
1298 ) {
1299 let rel_name = relation.as_str();
1303 let src = match self.resolve_edge_endpoint(token, source_id).await? {
1304 Some(r) => r,
1305 None => {
1306 if self.get_edge(token, source_id).await?.is_some() {
1307 return Err(RuntimeError::InvalidInput(format!(
1308 "{rel_name} source {source_id} must be a note or entity (got edge)"
1309 )));
1310 }
1311 return Err(RuntimeError::NotFound(format!(
1312 "link source {source_id} not found"
1313 )));
1314 }
1315 };
1316 let tgt = match self.resolve_edge_endpoint(token, target_id).await? {
1317 Some(r) => r,
1318 None => {
1319 if self.get_edge(token, target_id).await?.is_some() {
1320 return Err(RuntimeError::InvalidInput(format!(
1321 "{rel_name} target {target_id} must be a note or entity (got edge)"
1322 )));
1323 }
1324 return Err(RuntimeError::NotFound(format!(
1325 "link target {target_id} not found"
1326 )));
1327 }
1328 };
1329 match (&src, &tgt) {
1330 (Resolved::Entity(src_e), Resolved::Entity(tgt_e)) => {
1331 if !base_entity_rule_allows(&src_e.kind, relation, &tgt_e.kind) {
1332 let rule_hint = match relation {
1333 EdgeRelation::Supports | EdgeRelation::Refutes => {
1334 "requires concept|document|dataset|artifact -> concept \
1335 (or same-substrate note -> note)"
1336 }
1337 _ => "requires same-kind entity endpoints",
1338 };
1339 return Err(RuntimeError::InvalidInput(format!(
1340 "({}) -[{rel_name}]-> ({}) is not in the base endpoint \
1341 allowlist; {rel_name} {rule_hint}",
1342 src_e.kind, tgt_e.kind
1343 )));
1344 }
1345 }
1346 (Resolved::Note(_), Resolved::Note(_)) => {}
1347 (Resolved::Event(_), _) => {
1348 return Err(RuntimeError::InvalidInput(format!(
1349 "{rel_name} does not apply to events; source {source_id} is an event"
1350 )));
1351 }
1352 (_, Resolved::Event(_)) => {
1353 return Err(RuntimeError::InvalidInput(format!(
1354 "{rel_name} does not apply to events; target {target_id} is an event"
1355 )));
1356 }
1357 (Resolved::Entity(_), Resolved::Note(_)) => {
1358 return Err(RuntimeError::InvalidInput(format!(
1359 "{rel_name} endpoints must be the same substrate (note→note or entity→entity); \
1360 got source={source_id} (entity) target={target_id} (note)"
1361 )));
1362 }
1363 (Resolved::Note(_), Resolved::Entity(_)) => {
1364 return Err(RuntimeError::InvalidInput(format!(
1365 "{rel_name} endpoints must be the same substrate (note→note or entity→entity); \
1366 got source={source_id} (note) target={target_id} (entity)"
1367 )));
1368 }
1369 (Resolved::PackRecord { .. }, _) | (_, Resolved::PackRecord { .. }) => {
1370 return Err(RuntimeError::InvalidInput(format!(
1371 "pack-private record is not a valid edge endpoint for {rel_name}"
1372 )));
1373 }
1374 }
1375 } else {
1376 let src_res = self.resolve_edge_endpoint(token, source_id).await?;
1383 let tgt_res = self.resolve_edge_endpoint(token, target_id).await?;
1384
1385 if pack_rule_allows(
1386 &self.pack_edge_rules(),
1387 relation,
1388 src_res.as_ref(),
1389 tgt_res.as_ref(),
1390 ) {
1391 return Ok(());
1392 }
1393
1394 let src_kind = match src_res {
1396 Some(Resolved::Entity(e)) => e.kind,
1397 Some(_) => {
1398 return Err(RuntimeError::InvalidInput(format!(
1399 "link source {source_id} must be an entity for relation {relation:?} \
1400 (only `annotates` crosses substrates)"
1401 )));
1402 }
1403 None => {
1404 if self.get_edge(token, source_id).await?.is_some() {
1405 return Err(RuntimeError::InvalidInput(format!(
1406 "link source {source_id} must be an entity for relation {relation:?} \
1407 (only `annotates` crosses substrates)"
1408 )));
1409 }
1410 return Err(RuntimeError::NotFound(format!(
1411 "link source {source_id} not found"
1412 )));
1413 }
1414 };
1415 let tgt_kind = match tgt_res {
1416 Some(Resolved::Entity(e)) => e.kind,
1417 Some(_) => {
1418 return Err(RuntimeError::InvalidInput(format!(
1419 "link target {target_id} must be an entity for relation {relation:?} \
1420 (only `annotates` crosses substrates)"
1421 )));
1422 }
1423 None => {
1424 if self.get_edge(token, target_id).await?.is_some() {
1425 return Err(RuntimeError::InvalidInput(format!(
1426 "link target {target_id} must be an entity for relation {relation:?} \
1427 (only `annotates` crosses substrates)"
1428 )));
1429 }
1430 return Err(RuntimeError::NotFound(format!(
1431 "link target {target_id} not found"
1432 )));
1433 }
1434 };
1435 if !base_entity_rule_allows(&src_kind, relation, &tgt_kind) {
1436 return Err(RuntimeError::InvalidInput(format!(
1437 "({src_kind}) -[{}]-> ({tgt_kind}) is not in the base endpoint \
1438 allowlist; use pack EDGE_RULES to extend the allowlist",
1439 relation.as_str()
1440 )));
1441 }
1442 }
1443 Ok(())
1444 }
1445
1446 pub async fn validate_link_endpoints(
1451 &self,
1452 token: &NamespaceToken,
1453 source_id: Uuid,
1454 target_id: Uuid,
1455 relation: EdgeRelation,
1456 ) -> RuntimeResult<()> {
1457 self.validate_edge_relation_endpoints(token, source_id, target_id, relation)
1458 .await
1459 }
1460
1461 pub fn validate_link_endpoints_by_resolved(
1472 &self,
1473 source_id: Uuid,
1474 target_id: Uuid,
1475 relation: EdgeRelation,
1476 src: Option<&Resolved>,
1477 tgt: Option<&Resolved>,
1478 ) -> RuntimeResult<()> {
1479 if source_id == target_id {
1480 return Err(RuntimeError::InvalidInput(
1481 "self-loop edges are not allowed: source_id and target_id must be different".into(),
1482 ));
1483 }
1484
1485 if relation == EdgeRelation::Annotates {
1486 match src {
1487 Some(Resolved::Note(_)) => {}
1488 Some(_) => {
1489 return Err(RuntimeError::InvalidInput(format!(
1490 "annotates source {source_id} must be a note"
1491 )));
1492 }
1493 None => {
1494 return Err(RuntimeError::NotFound(format!(
1495 "link source {source_id} not found"
1496 )));
1497 }
1498 }
1499 if tgt.is_none() {
1500 return Err(RuntimeError::NotFound(format!(
1501 "link target {target_id} not found"
1502 )));
1503 }
1504 return Ok(());
1505 }
1506
1507 if matches!(
1508 relation,
1509 EdgeRelation::Supersedes | EdgeRelation::Supports | EdgeRelation::Refutes
1510 ) {
1511 let rel_name = relation.as_str();
1512 let src = src.ok_or_else(|| {
1513 RuntimeError::NotFound(format!("link source {source_id} not found"))
1514 })?;
1515 let tgt = tgt.ok_or_else(|| {
1516 RuntimeError::NotFound(format!("link target {target_id} not found"))
1517 })?;
1518 match (src, tgt) {
1519 (Resolved::Entity(src_e), Resolved::Entity(tgt_e)) => {
1520 if !base_entity_rule_allows(&src_e.kind, relation, &tgt_e.kind) {
1521 let rule_hint = match relation {
1522 EdgeRelation::Supports | EdgeRelation::Refutes => {
1523 "requires concept|document|dataset|artifact -> concept \
1524 (or same-substrate note -> note)"
1525 }
1526 _ => "requires same-kind entity endpoints",
1527 };
1528 return Err(RuntimeError::InvalidInput(format!(
1529 "({}) -[{rel_name}]-> ({}) is not in the base endpoint \
1530 allowlist; {rel_name} {rule_hint}",
1531 src_e.kind, tgt_e.kind
1532 )));
1533 }
1534 }
1535 (Resolved::Note(_), Resolved::Note(_)) => {}
1536 (Resolved::Entity(_), Resolved::Note(_)) => {
1537 return Err(RuntimeError::InvalidInput(format!(
1538 "{rel_name} endpoints must be the same substrate \
1539 (note→note or entity→entity); got source={source_id} (entity) \
1540 target={target_id} (note)"
1541 )));
1542 }
1543 (Resolved::Note(_), Resolved::Entity(_)) => {
1544 return Err(RuntimeError::InvalidInput(format!(
1545 "{rel_name} endpoints must be the same substrate \
1546 (note→note or entity→entity); got source={source_id} (note) \
1547 target={target_id} (entity)"
1548 )));
1549 }
1550 (Resolved::PackRecord { .. }, _) | (_, Resolved::PackRecord { .. }) => {
1551 return Err(RuntimeError::InvalidInput(format!(
1552 "pack-private record is not a valid edge endpoint for {rel_name}"
1553 )));
1554 }
1555 _ => {
1556 return Err(RuntimeError::InvalidInput(format!(
1557 "{rel_name} endpoints must be notes or entities (not events)"
1558 )));
1559 }
1560 }
1561 return Ok(());
1562 }
1563
1564 if pack_rule_allows(&self.pack_edge_rules(), relation, src, tgt) {
1567 return Ok(());
1568 }
1569
1570 let src_kind = match src {
1571 Some(Resolved::Entity(e)) => &e.kind,
1572 Some(_) => {
1573 return Err(RuntimeError::InvalidInput(format!(
1574 "link source {source_id} must be an entity for relation {relation:?} \
1575 (only `annotates` crosses substrates)"
1576 )));
1577 }
1578 None => {
1579 return Err(RuntimeError::NotFound(format!(
1580 "link source {source_id} not found"
1581 )));
1582 }
1583 };
1584 let tgt_kind = match tgt {
1585 Some(Resolved::Entity(e)) => &e.kind,
1586 Some(_) => {
1587 return Err(RuntimeError::InvalidInput(format!(
1588 "link target {target_id} must be an entity for relation {relation:?} \
1589 (only `annotates` crosses substrates)"
1590 )));
1591 }
1592 None => {
1593 return Err(RuntimeError::NotFound(format!(
1594 "link target {target_id} not found"
1595 )));
1596 }
1597 };
1598
1599 if !base_entity_rule_allows(src_kind, relation, tgt_kind) {
1600 return Err(RuntimeError::InvalidInput(format!(
1601 "({src_kind}) -[{}]-> ({tgt_kind}) is not in the base endpoint \
1602 allowlist; use pack EDGE_RULES to extend the allowlist",
1603 relation.as_str()
1604 )));
1605 }
1606
1607 Ok(())
1608 }
1609
1610 pub fn validate_annotates_endpoint_kinds(
1622 &self,
1623 source_id: Uuid,
1624 target_id: Uuid,
1625 source: Option<EdgeEndpointKind>,
1626 target: Option<EdgeEndpointKind>,
1627 ) -> RuntimeResult<()> {
1628 if source_id == target_id {
1629 return Err(RuntimeError::InvalidInput(
1630 "self-loop edges are not allowed: source_id and target_id must be different".into(),
1631 ));
1632 }
1633 match source {
1634 Some(EdgeEndpointKind::Note) => {}
1635 Some(_) => {
1636 return Err(RuntimeError::InvalidInput(format!(
1637 "annotates source {source_id} must be a note"
1638 )));
1639 }
1640 None => {
1641 return Err(RuntimeError::NotFound(format!(
1642 "link source {source_id} not found"
1643 )));
1644 }
1645 }
1646 if target.is_none() {
1647 return Err(RuntimeError::NotFound(format!(
1648 "link target {target_id} not found"
1649 )));
1650 }
1651 Ok(())
1652 }
1653
1654 pub async fn link(
1674 &self,
1675 token: &NamespaceToken,
1676 source_id: Uuid,
1677 target_id: Uuid,
1678 relation: EdgeRelation,
1679 weight: f64,
1680 metadata: Option<serde_json::Value>,
1681 ) -> RuntimeResult<Edge> {
1682 validate_edge_weight(weight)?;
1683 self.validate_edge_relation_endpoints(token, source_id, target_id, relation)
1684 .await?;
1685 let (source_id, target_id) = canonical_edge_endpoints(relation, source_id, target_id);
1686 let metadata = if relation == EdgeRelation::DependsOn {
1687 match (
1692 self.resolve_edge_endpoint(token, source_id).await?,
1693 self.resolve_edge_endpoint(token, target_id).await?,
1694 ) {
1695 (Some(Resolved::Entity(src_e)), Some(Resolved::Entity(tgt_e))) => {
1696 merge_dependency_kind(&src_e.kind, &tgt_e.kind, metadata)
1697 }
1698 _ => metadata,
1699 }
1700 } else {
1701 metadata
1702 };
1703 validate_edge_metadata(relation, metadata.as_ref())?;
1704 let now = chrono::Utc::now();
1705 let ns = token.namespace().as_str();
1706 let edge = Edge {
1707 id: LinkId::from(Uuid::new_v4()),
1708 namespace: ns.to_string(),
1709 source_id,
1710 target_id,
1711 relation,
1712 weight,
1713 created_at: now,
1714 updated_at: now,
1715 deleted_at: None,
1716 metadata,
1717 target_backend: None,
1718 };
1719 match self.graph(token)?.upsert_edge_guarded(edge).await? {
1729 khive_storage::GuardedWriteOutcome::Written => {}
1730 khive_storage::GuardedWriteOutcome::Refused(missing) => {
1731 return Err(RuntimeError::GuardedWriteFailed(GuardedWriteFailure {
1732 entry_index: None,
1733 missing_source: missing.source.then_some(source_id),
1734 missing_target: missing.target.then_some(target_id),
1735 }));
1736 }
1737 }
1738
1739 let persisted = self
1745 .list_edges(
1746 token,
1747 crate::curation::EdgeListFilter {
1748 source_id: Some(source_id),
1749 target_id: Some(target_id),
1750 relations: vec![relation],
1751 ..Default::default()
1752 },
1753 1,
1754 0,
1755 )
1756 .await?
1757 .into_iter()
1758 .next()
1759 .ok_or_else(|| {
1760 crate::RuntimeError::Internal(format!(
1761 "upsert_edge succeeded but natural-key lookup for ({source_id}, {target_id}, {relation}) returned nothing"
1762 ))
1763 })?;
1764 Ok(persisted)
1765 }
1766
1767 #[allow(clippy::too_many_arguments)]
1774 pub async fn link_with_target_backend(
1775 &self,
1776 token: &NamespaceToken,
1777 source_id: Uuid,
1778 target_id: Uuid,
1779 relation: EdgeRelation,
1780 weight: f64,
1781 metadata: Option<serde_json::Value>,
1782 target_backend: Option<String>,
1783 ) -> RuntimeResult<Edge> {
1784 validate_edge_weight(weight)?;
1785 let (source_id, target_id) = canonical_edge_endpoints(relation, source_id, target_id);
1786 validate_edge_metadata(relation, metadata.as_ref())?;
1787 let now = chrono::Utc::now();
1788 let ns = token.namespace().as_str();
1789 let edge = Edge {
1790 id: LinkId::from(Uuid::new_v4()),
1791 namespace: ns.to_string(),
1792 source_id,
1793 target_id,
1794 relation,
1795 weight,
1796 created_at: now,
1797 updated_at: now,
1798 deleted_at: None,
1799 metadata,
1800 target_backend,
1801 };
1802 self.graph(token)?.upsert_edge(edge).await?;
1803 let persisted = self
1804 .list_edges(
1805 token,
1806 crate::curation::EdgeListFilter {
1807 source_id: Some(source_id),
1808 target_id: Some(target_id),
1809 relations: vec![relation],
1810 ..Default::default()
1811 },
1812 1,
1813 0,
1814 )
1815 .await?
1816 .into_iter()
1817 .next()
1818 .ok_or_else(|| {
1819 crate::RuntimeError::Internal(format!(
1820 "upsert_edge succeeded but natural-key lookup for ({source_id}, {target_id}, {relation}) returned nothing"
1821 ))
1822 })?;
1823 Ok(persisted)
1824 }
1825
1826 pub(crate) async fn substrate_exists_in_ns(
1833 &self,
1834 token: &NamespaceToken,
1835 id: Uuid,
1836 ) -> RuntimeResult<bool> {
1837 if self.resolve(token, id).await?.is_some() {
1838 return Ok(true);
1839 }
1840 match self.get_edge_visible(token, id).await {
1841 Ok(Some(_)) => Ok(true),
1842 Ok(None) | Err(RuntimeError::NotFound(_)) => Ok(false),
1843 Err(err) => Err(err),
1844 }
1845 }
1846
1847 pub(crate) async fn substrate_exists_by_id(
1854 &self,
1855 token: &NamespaceToken,
1856 id: Uuid,
1857 ) -> RuntimeResult<bool> {
1858 if self.resolve_edge_endpoint(token, id).await?.is_some() {
1859 return Ok(true);
1860 }
1861 match self.get_edge(token, id).await {
1862 Ok(Some(_)) => Ok(true),
1863 Ok(None) | Err(RuntimeError::NotFound(_)) => Ok(false),
1864 Err(err) => Err(err),
1865 }
1866 }
1867
1868 pub async fn neighbors(
1877 &self,
1878 token: &NamespaceToken,
1879 node_id: Uuid,
1880 direction: Direction,
1881 limit: Option<u32>,
1882 relations: Option<Vec<EdgeRelation>>,
1883 ) -> RuntimeResult<Vec<NeighborHit>> {
1884 self.neighbors_with_query(
1885 token,
1886 node_id,
1887 NeighborQuery {
1888 direction,
1889 relations,
1890 limit,
1891 min_weight: None,
1892 },
1893 )
1894 .await
1895 }
1896
1897 pub async fn neighbors_with_query(
1907 &self,
1908 token: &NamespaceToken,
1909 node_id: Uuid,
1910 mut query: NeighborQuery,
1911 ) -> RuntimeResult<Vec<NeighborHit>> {
1912 if !self.substrate_exists_in_ns(token, node_id).await? {
1913 return Ok(Vec::new());
1914 }
1915
1916 query.direction =
1917 normalize_symmetric_direction(query.direction, query.relations.as_deref());
1918 let mut hits = Vec::new();
1919 for ns in token.visible_namespaces() {
1920 let temp = NamespaceToken::for_namespace(ns.clone());
1921 let mut ns_hits = self.graph(&temp)?.neighbors(node_id, query.clone()).await?;
1922 hits.append(&mut ns_hits);
1923 }
1924 hits.sort_by_key(|h| (h.node_id, h.edge_id));
1925 hits.dedup_by_key(|h| (h.node_id, h.edge_id));
1926 self.enrich_neighbor_hits(token, &mut hits).await;
1927 let candidate_ids: Vec<Uuid> = hits.iter().map(|h| h.node_id).collect();
1929 let deleted = self.deleted_entity_ids(candidate_ids).await;
1930 if !deleted.is_empty() {
1931 hits.retain(|h| !deleted.contains(&h.node_id));
1932 }
1933 hits.sort_by(|a, b| {
1940 b.weight
1941 .partial_cmp(&a.weight)
1942 .unwrap_or(std::cmp::Ordering::Equal)
1943 .then(a.node_id.cmp(&b.node_id))
1944 });
1945 Ok(hits)
1946 }
1947
1948 pub async fn annotation_neighbors_by_target_id(
1956 &self,
1957 target_id: Uuid,
1958 ) -> RuntimeResult<Vec<NeighborHit>> {
1959 let mut reader = self.sql().reader().await?;
1960 let rows = reader
1961 .query_all(SqlStatement {
1962 sql: "SELECT source_id, id, weight FROM graph_edges \
1963 WHERE target_id = ?1 AND relation = ?2 AND deleted_at IS NULL \
1964 ORDER BY weight DESC, source_id ASC"
1965 .to_string(),
1966 params: vec![
1967 SqlValue::Text(target_id.to_string()),
1968 SqlValue::Text(EdgeRelation::Annotates.to_string()),
1969 ],
1970 label: Some("annotations.by_target_id_unfiltered".into()),
1971 })
1972 .await?;
1973
1974 rows.into_iter()
1975 .map(|row| {
1976 let parse_uuid = |name: &str| match row.get(name) {
1977 Some(SqlValue::Text(value)) => Uuid::from_str(value).map_err(|error| {
1978 RuntimeError::Internal(format!("graph_edges.{name} is not a UUID: {error}"))
1979 }),
1980 Some(value) => Err(RuntimeError::Internal(format!(
1981 "graph_edges.{name} has unexpected SQL value {value:?}"
1982 ))),
1983 None => Err(RuntimeError::Internal(format!(
1984 "graph_edges row missing {name}"
1985 ))),
1986 };
1987 let weight = match row.get("weight") {
1988 Some(SqlValue::Float(value)) => Ok(*value),
1989 Some(value) => Err(RuntimeError::Internal(format!(
1990 "graph_edges.weight has unexpected SQL value {value:?}"
1991 ))),
1992 None => Err(RuntimeError::Internal(
1993 "graph_edges row missing weight".into(),
1994 )),
1995 }?;
1996
1997 Ok(NeighborHit {
1998 node_id: parse_uuid("source_id")?,
1999 edge_id: parse_uuid("id")?,
2000 relation: EdgeRelation::Annotates,
2001 weight,
2002 name: None,
2003 kind: None,
2004 entity_type: None,
2005 })
2006 })
2007 .collect()
2008 }
2009
2010 pub async fn neighbors_with_query_directed(
2019 &self,
2020 token: &NamespaceToken,
2021 node_id: Uuid,
2022 query: NeighborQuery,
2023 ) -> RuntimeResult<Vec<(NeighborHit, Direction)>> {
2024 if !self.substrate_exists_in_ns(token, node_id).await? {
2025 return Ok(Vec::new());
2026 }
2027
2028 let mut hits: Vec<DirectedNeighborHit> = Vec::new();
2029 for ns in token.visible_namespaces() {
2030 let temp = NamespaceToken::for_namespace(ns.clone());
2031 let mut ns_hits = self
2032 .graph(&temp)?
2033 .neighbors_both_directions(node_id, query.clone())
2034 .await?;
2035 hits.append(&mut ns_hits);
2036 }
2037 hits.sort_by_key(|h| {
2041 (
2042 h.hit.node_id,
2043 h.hit.edge_id,
2044 direction_sort_rank(&h.direction),
2045 )
2046 });
2047 hits.dedup_by_key(|h| {
2048 (
2049 h.hit.node_id,
2050 h.hit.edge_id,
2051 direction_sort_rank(&h.direction),
2052 )
2053 });
2054
2055 let mut plain_hits: Vec<NeighborHit> = hits.iter().map(|h| h.hit.clone()).collect();
2056 self.enrich_neighbor_hits(token, &mut plain_hits).await;
2057 for (dh, enriched) in hits.iter_mut().zip(plain_hits) {
2058 dh.hit = enriched;
2059 }
2060
2061 let candidate_ids: Vec<Uuid> = hits.iter().map(|h| h.hit.node_id).collect();
2063 let deleted = self.deleted_entity_ids(candidate_ids).await;
2064 if !deleted.is_empty() {
2065 hits.retain(|h| !deleted.contains(&h.hit.node_id));
2066 }
2067 hits.sort_by(|a, b| {
2071 b.hit
2072 .weight
2073 .partial_cmp(&a.hit.weight)
2074 .unwrap_or(std::cmp::Ordering::Equal)
2075 .then(a.hit.node_id.cmp(&b.hit.node_id))
2076 });
2077 Ok(hits.into_iter().map(|h| (h.hit, h.direction)).collect())
2078 }
2079
2080 pub async fn traverse(
2085 &self,
2086 token: &NamespaceToken,
2087 request: TraversalRequest,
2088 ) -> RuntimeResult<Vec<GraphPath>> {
2089 let mut request = request;
2090 let mut visible_roots = Vec::with_capacity(request.roots.len());
2091 for root in request.roots.drain(..) {
2092 if self.substrate_exists_in_ns(token, root).await? {
2093 visible_roots.push(root);
2094 }
2095 }
2096 request.roots = visible_roots;
2097 if request.roots.is_empty() {
2098 return Ok(Vec::new());
2099 }
2100
2101 let mut paths = Vec::new();
2102 for ns in token.visible_namespaces() {
2103 let temp = NamespaceToken::for_namespace(ns.clone());
2104 let mut ns_paths = self.graph(&temp)?.traverse(request.clone()).await?;
2105 paths.append(&mut ns_paths);
2106 }
2107 self.enrich_path_nodes(token, &mut paths, request.include_properties)
2108 .await;
2109 let all_node_ids: Vec<Uuid> = paths
2111 .iter()
2112 .flat_map(|p| p.nodes.iter().map(|n| n.node_id))
2113 .collect();
2114 let deleted = self.deleted_entity_ids(all_node_ids).await;
2115 if !deleted.is_empty() {
2116 for path in paths.iter_mut() {
2117 path.nodes.retain(|n| !deleted.contains(&n.node_id));
2118 }
2119 paths.retain(|p| !p.nodes.is_empty());
2120 }
2121 Ok(paths)
2122 }
2123
2124 async fn deleted_entity_ids(&self, ids: Vec<Uuid>) -> std::collections::HashSet<Uuid> {
2137 if ids.is_empty() {
2138 return std::collections::HashSet::new();
2139 }
2140 let id_strs: Vec<String> = ids.iter().map(|u| u.to_string()).collect();
2141 let n = id_strs.len();
2142 let entities_placeholders = (0..n)
2149 .map(|i| format!("?{}", i + 1))
2150 .collect::<Vec<_>>()
2151 .join(",");
2152 let notes_placeholders = (0..n)
2153 .map(|i| format!("?{}", n + i + 1))
2154 .collect::<Vec<_>>()
2155 .join(",");
2156 let sql_str = format!(
2157 "SELECT id FROM entities WHERE id IN ({entities_placeholders}) AND deleted_at IS NOT NULL \
2158 UNION \
2159 SELECT id FROM notes WHERE id IN ({notes_placeholders}) AND deleted_at IS NOT NULL"
2160 );
2161 let params: Vec<SqlValue> = id_strs
2163 .iter()
2164 .chain(id_strs.iter())
2165 .cloned()
2166 .map(SqlValue::Text)
2167 .collect();
2168 let stmt = SqlStatement {
2169 sql: sql_str,
2170 params,
2171 label: Some("deleted_entity_ids".into()),
2172 };
2173 let mut out = std::collections::HashSet::new();
2174 let sql = self.sql();
2175 if let Ok(mut reader) = sql.reader().await {
2176 if let Ok(rows) = reader.query_all(stmt).await {
2177 for row in rows {
2178 if let Some(col) = row.columns.first() {
2179 if let SqlValue::Text(s) = &col.value {
2180 if let Ok(u) = s.parse::<Uuid>() {
2181 out.insert(u);
2182 }
2183 }
2184 }
2185 }
2186 }
2187 }
2189 out
2190 }
2191
2192 async fn enrich_neighbor_hits(&self, token: &NamespaceToken, hits: &mut [NeighborHit]) {
2201 if hits.is_empty() {
2202 return;
2203 }
2204
2205 let unique_ids: Vec<Uuid> = {
2207 let mut seen = std::collections::HashSet::new();
2208 hits.iter()
2209 .filter_map(|h| {
2210 if seen.insert(h.node_id) {
2211 Some(h.node_id)
2212 } else {
2213 None
2214 }
2215 })
2216 .collect()
2217 };
2218
2219 let entity_map: HashMap<Uuid, Entity> = self
2220 .get_entities_by_ids_visible(token, &unique_ids)
2221 .await
2222 .unwrap_or_default()
2223 .into_iter()
2224 .map(|e| (e.id, e))
2225 .collect();
2226
2227 let residual_ids: Vec<Uuid> = unique_ids
2229 .iter()
2230 .filter(|id| !entity_map.contains_key(id))
2231 .copied()
2232 .collect();
2233
2234 let note_map: HashMap<Uuid, Note> = if !residual_ids.is_empty() {
2235 if let Ok(store) = self.notes(token) {
2236 store
2237 .get_notes_batch(&residual_ids)
2238 .await
2239 .unwrap_or_default()
2240 .into_iter()
2241 .map(|n| (n.id, n))
2242 .collect()
2243 } else {
2244 HashMap::new()
2245 }
2246 } else {
2247 HashMap::new()
2248 };
2249
2250 for hit in hits.iter_mut() {
2251 if let Some(entity) = entity_map.get(&hit.node_id) {
2252 hit.name = Some(entity.name.clone());
2253 hit.kind = Some(entity.kind.clone());
2254 hit.entity_type = entity.entity_type.clone();
2255 } else if let Some(note) = note_map.get(&hit.node_id) {
2256 let kind = note.kind.clone();
2257 let name = note
2258 .name
2259 .as_deref()
2260 .filter(|s| !s.trim().is_empty())
2261 .map(|s| s.to_owned())
2262 .unwrap_or_else(|| format!("[{kind}]"));
2263 hit.name = Some(name);
2264 hit.kind = Some(kind);
2265 }
2266 }
2267 }
2268
2269 async fn enrich_path_nodes(
2280 &self,
2281 token: &NamespaceToken,
2282 paths: &mut [GraphPath],
2283 include_properties: bool,
2284 ) {
2285 if paths.is_empty() {
2286 return;
2287 }
2288
2289 let unique_ids: Vec<Uuid> = {
2291 let mut seen = std::collections::HashSet::new();
2292 paths
2293 .iter()
2294 .flat_map(|p| p.nodes.iter())
2295 .filter_map(|n| {
2296 if seen.insert(n.node_id) {
2297 Some(n.node_id)
2298 } else {
2299 None
2300 }
2301 })
2302 .collect()
2303 };
2304
2305 let entity_map: HashMap<Uuid, Entity> = self
2306 .get_entities_by_ids_visible(token, &unique_ids)
2307 .await
2308 .unwrap_or_default()
2309 .into_iter()
2310 .map(|e| (e.id, e))
2311 .collect();
2312
2313 for path in paths.iter_mut() {
2314 for node in path.nodes.iter_mut() {
2315 if let Some(entity) = entity_map.get(&node.node_id) {
2316 node.name = Some(entity.name.clone());
2317 node.kind = Some(entity.kind.clone());
2318 if include_properties {
2319 node.properties = entity.properties.clone();
2320 }
2321 }
2322 }
2323 }
2324 }
2325
2326 #[allow(clippy::too_many_arguments)]
2340 pub async fn create_note(
2341 &self,
2342 token: &NamespaceToken,
2343 kind: &str,
2344 name: Option<&str>,
2345 content: &str,
2346 salience: Option<f64>,
2347 properties: Option<serde_json::Value>,
2348 annotates: Vec<Uuid>,
2349 ) -> RuntimeResult<Note> {
2350 self.create_note_inner(
2351 token, kind, name, content, None, salience, None, properties, annotates, None,
2352 )
2353 .await
2354 }
2355
2356 #[allow(clippy::too_many_arguments)]
2367 pub async fn create_note_with_embedding_content(
2368 &self,
2369 token: &NamespaceToken,
2370 kind: &str,
2371 name: Option<&str>,
2372 content: &str,
2373 embedding_content: Option<&str>,
2374 salience: Option<f64>,
2375 properties: Option<serde_json::Value>,
2376 annotates: Vec<Uuid>,
2377 ) -> RuntimeResult<Note> {
2378 self.create_note_inner(
2379 token,
2380 kind,
2381 name,
2382 content,
2383 embedding_content,
2384 salience,
2385 None,
2386 properties,
2387 annotates,
2388 None,
2389 )
2390 .await
2391 }
2392
2393 #[allow(clippy::too_many_arguments)]
2397 pub async fn create_note_with_decay(
2398 &self,
2399 token: &NamespaceToken,
2400 kind: &str,
2401 name: Option<&str>,
2402 content: &str,
2403 salience: Option<f64>,
2404 decay_factor: f64,
2405 properties: Option<serde_json::Value>,
2406 annotates: Vec<Uuid>,
2407 ) -> RuntimeResult<Note> {
2408 self.create_note_with_decay_for_embedding_model(
2409 token,
2410 kind,
2411 name,
2412 content,
2413 salience,
2414 decay_factor,
2415 properties,
2416 annotates,
2417 None,
2418 )
2419 .await
2420 }
2421
2422 #[allow(clippy::too_many_arguments)]
2427 pub async fn create_note_with_decay_for_embedding_model(
2428 &self,
2429 token: &NamespaceToken,
2430 kind: &str,
2431 name: Option<&str>,
2432 content: &str,
2433 salience: Option<f64>,
2434 decay_factor: f64,
2435 properties: Option<serde_json::Value>,
2436 annotates: Vec<Uuid>,
2437 embedding_model: Option<&str>,
2438 ) -> RuntimeResult<Note> {
2439 self.create_note_inner(
2440 token,
2441 kind,
2442 name,
2443 content,
2444 None,
2445 salience,
2446 Some(decay_factor),
2447 properties,
2448 annotates,
2449 embedding_model,
2450 )
2451 .await
2452 }
2453
2454 pub async fn try_create_note(
2467 &self,
2468 token: &NamespaceToken,
2469 kind: &str,
2470 name: Option<&str>,
2471 content: &str,
2472 properties: Option<serde_json::Value>,
2473 ) -> RuntimeResult<Option<Note>> {
2474 self.validate_note_kind(kind)?;
2475 crate::secret_gate::check(content)?;
2476 if let Some(n) = name {
2477 crate::secret_gate::check(n)?;
2478 }
2479 if let Some(ref p) = properties {
2480 crate::secret_gate::check_json(p)?;
2481 }
2482
2483 let ns = token.namespace().as_str();
2484 let mut note = Note::new(ns, kind, content);
2485 if let Some(n) = name {
2486 note = note.with_name(n);
2487 }
2488 if let Some(p) = properties {
2489 note = note.with_properties(p);
2490 }
2491
2492 let inserted = self.notes(token)?.try_insert_note(note.clone()).await?;
2493 if !inserted {
2494 return Ok(None);
2495 }
2496
2497 if let Ok(fts) = self.text_for_notes(token) {
2499 if let Err(e) = fts.upsert_document(note_fts_document(¬e)).await {
2500 tracing::warn!(
2501 note_id = %note.id,
2502 error = %e,
2503 "try_create_note: FTS indexing failed (non-fatal)"
2504 );
2505 }
2506 }
2507
2508 let embed_model_names = self.registered_embedding_model_names();
2510 for model_name in &embed_model_names {
2511 match self
2512 .embed_document_with_model(model_name, ¬e.content)
2513 .await
2514 {
2515 Ok(vector) => {
2516 if let Ok(vs) = self.vectors_for_model(token, model_name) {
2517 if let Err(e) = vs
2518 .insert(
2519 note.id,
2520 SubstrateKind::Note,
2521 ns,
2522 "note.content",
2523 vec![vector],
2524 )
2525 .await
2526 {
2527 tracing::warn!(
2528 note_id = %note.id,
2529 model = %model_name,
2530 error = %e,
2531 "try_create_note: vector insert failed (non-fatal)"
2532 );
2533 }
2534 }
2535 }
2536 Err(e) => {
2537 tracing::warn!(
2538 note_id = %note.id,
2539 model = %model_name,
2540 error = %e,
2541 "try_create_note: embedding failed (non-fatal)"
2542 );
2543 }
2544 }
2545 }
2546
2547 Ok(Some(note))
2548 }
2549
2550 #[allow(clippy::too_many_arguments)]
2554 async fn create_note_inner(
2555 &self,
2556 token: &NamespaceToken,
2557 kind: &str,
2558 name: Option<&str>,
2559 content: &str,
2560 embedding_content: Option<&str>,
2561 salience: Option<f64>,
2562 decay_factor: Option<f64>,
2563 properties: Option<serde_json::Value>,
2564 annotates: Vec<Uuid>,
2565 embedding_model: Option<&str>,
2566 ) -> RuntimeResult<Note> {
2567 self.validate_note_kind(kind)?;
2568 crate::secret_gate::check(content)?;
2570 if let Some(n) = name {
2571 crate::secret_gate::check(n)?;
2572 }
2573 if let Some(ref p) = properties {
2574 crate::secret_gate::check_json(p)?;
2575 }
2576 if let Some(ec) = embedding_content {
2582 if ec.is_empty() {
2583 return Err(RuntimeError::InvalidInput(
2584 "embedding_content must not be empty".into(),
2585 ));
2586 }
2587 if ec.len() >= content.len() || !content.starts_with(ec) {
2588 return Err(RuntimeError::InvalidInput(
2589 "embedding_content must be a proper prefix of content".into(),
2590 ));
2591 }
2592 crate::secret_gate::check(ec)?;
2593 }
2594 let ns = token.namespace().as_str();
2595
2596 for &target_id in &annotates {
2599 if !self.substrate_exists_by_id(token, target_id).await? {
2600 return Err(RuntimeError::NotFound(format!(
2601 "create_note annotates target {target_id} not found"
2602 )));
2603 }
2604 }
2605
2606 if let Some(s) = salience {
2609 if !s.is_finite() || !(0.0..=1.0).contains(&s) {
2610 return Err(RuntimeError::InvalidInput(format!(
2611 "salience must be a finite value in [0.0, 1.0]; got {s}"
2612 )));
2613 }
2614 }
2615 if let Some(d) = decay_factor {
2616 if !d.is_finite() || d < 0.0 {
2617 return Err(RuntimeError::InvalidInput(format!(
2618 "decay_factor must be a finite value >= 0.0; got {d}"
2619 )));
2620 }
2621 }
2622
2623 if let Some(model_name) = embedding_model {
2627 self.resolve_embedding_model(Some(model_name))?;
2628 }
2629
2630 let mut note = Note::new(ns, kind, content);
2631 if let Some(s) = salience {
2632 note = note.with_salience(s);
2633 }
2634 if let Some(df) = decay_factor {
2635 note = note.with_decay(df);
2636 }
2637 if let Some(n) = name {
2638 note = note.with_name(n);
2639 }
2640 if let Some(p) = properties {
2641 note = note.with_properties(p);
2642 }
2643 self.notes(token)?.upsert_note(note.clone()).await?;
2644
2645 let embed_model_names: Vec<String> = if let Some(m) = embedding_model {
2651 vec![m.to_string()]
2652 } else {
2653 let names = self.registered_embedding_model_names();
2659 if names.is_empty() {
2660 vec![]
2662 } else {
2663 names
2664 }
2665 };
2666
2667 {
2669 #[cfg(any(test, feature = "fault-injection"))]
2675 let fts_inject = FTS_FAIL_NS.lock().unwrap().remove(ns);
2676 #[cfg(not(any(test, feature = "fault-injection")))]
2677 let fts_inject = false;
2678 let fts_result: RuntimeResult<()> = if fts_inject {
2679 Err(RuntimeError::Internal("injected FTS failure".to_string()))
2680 } else {
2681 match self.text_for_notes(token) {
2682 Ok(fts) => fts
2683 .upsert_document(note_fts_document(¬e))
2684 .await
2685 .map_err(RuntimeError::from),
2686 Err(e) => Err(e),
2687 }
2688 };
2689
2690 if let Err(e) = fts_result {
2691 if let Ok(store) = self.notes(token) {
2693 let _ = store.delete_note(note.id, DeleteMode::Hard).await;
2694 }
2695 return Err(e);
2696 }
2697 }
2698
2699 let embed_text: &str = embedding_content.unwrap_or(content);
2709
2710 if embed_model_names.len() == 1 {
2711 let model_name = &embed_model_names[0];
2713 let vec_result = self.embed_document_with_model(model_name, embed_text).await;
2714
2715 #[cfg(any(test, feature = "fault-injection"))]
2725 let vec_inject = {
2726 let ns_inject = {
2727 let mut g = VECTOR_FAIL_NS.lock().unwrap();
2728 if g.as_deref() == Some(ns) {
2729 *g = None;
2730 true
2731 } else {
2732 false
2733 }
2734 };
2735 let count_inject = VECTOR_FAIL_AFTER.with(|cell| match cell.get() {
2736 Some(0) => {
2737 cell.set(None);
2738 true
2739 }
2740 Some(n) => {
2741 cell.set(Some(n - 1));
2742 false
2743 }
2744 None => false,
2745 });
2746 ns_inject || count_inject
2747 };
2748 #[cfg(not(any(test, feature = "fault-injection")))]
2749 let vec_inject = false;
2750 let vec_result: RuntimeResult<Vec<f32>> = if vec_inject {
2751 Err(RuntimeError::Internal(
2752 "injected vector failure".to_string(),
2753 ))
2754 } else {
2755 vec_result
2756 };
2757
2758 let single_model_result: RuntimeResult<()> = match vec_result {
2759 Ok(vector) => match self.vectors_for_model(token, model_name) {
2760 Ok(vs) => vs
2761 .insert(
2762 note.id,
2763 SubstrateKind::Note,
2764 ns,
2765 "note.content",
2766 vec![vector],
2767 )
2768 .await
2769 .map_err(RuntimeError::from),
2770 Err(e) => Err(e),
2771 },
2772 Err(e) => Err(e),
2773 };
2774 if let Err(e) = single_model_result {
2775 if let Ok(store) = self.notes(token) {
2777 let _ = store.delete_note(note.id, DeleteMode::Hard).await;
2778 }
2779 if let Ok(fts) = self.text_for_notes(token) {
2780 let _ = fts.delete_document(ns, note.id).await;
2781 }
2782 return Err(e);
2783 }
2784 } else if !embed_model_names.is_empty() {
2785 let rt_clone = self.clone();
2788 let content_owned = embed_text.to_string();
2789 let mut handles = Vec::with_capacity(embed_model_names.len());
2790 for model_name in &embed_model_names {
2791 let rt = rt_clone.clone();
2792 let text = content_owned.clone();
2793 let name = model_name.clone();
2794 handles.push(tokio::spawn(async move {
2795 rt.embed_document_with_model(&name, &text).await
2796 }));
2797 }
2798 let mut vectors: Vec<Vec<f32>> = Vec::with_capacity(embed_model_names.len());
2799 for handle in handles {
2800 let join_result = handle
2801 .await
2802 .map_err(|e| RuntimeError::Internal(format!("embed task panicked: {e}")));
2803 match join_result {
2804 Err(e) => {
2805 if let Ok(store) = self.notes(token) {
2807 let _ = store.delete_note(note.id, DeleteMode::Hard).await;
2808 }
2809 if let Ok(fts) = self.text_for_notes(token) {
2810 let _ = fts.delete_document(ns, note.id).await;
2811 }
2812 return Err(e);
2813 }
2814 Ok(Err(e)) => {
2815 if let Ok(store) = self.notes(token) {
2817 let _ = store.delete_note(note.id, DeleteMode::Hard).await;
2818 }
2819 if let Ok(fts) = self.text_for_notes(token) {
2820 let _ = fts.delete_document(ns, note.id).await;
2821 }
2822 return Err(e);
2823 }
2824 Ok(Ok(vec)) => vectors.push(vec),
2825 }
2826 }
2827 let mut inserted_models: Vec<String> = Vec::with_capacity(embed_model_names.len());
2829 for (model_name, vector) in embed_model_names.iter().zip(vectors) {
2830 let insert_result = match self.vectors_for_model(token, model_name) {
2831 Ok(vs) => vs
2832 .insert(
2833 note.id,
2834 SubstrateKind::Note,
2835 ns,
2836 "note.content",
2837 vec![vector],
2838 )
2839 .await
2840 .map_err(RuntimeError::from),
2841 Err(e) => Err(e),
2842 };
2843 if let Err(e) = insert_result {
2844 if let Ok(store) = self.notes(token) {
2846 let _ = store.delete_note(note.id, DeleteMode::Hard).await;
2847 }
2848 if let Ok(fts) = self.text_for_notes(token) {
2849 let _ = fts.delete_document(ns, note.id).await;
2850 }
2851 for m in &inserted_models {
2852 if let Ok(vs) = self.vectors_for_model(token, m) {
2853 let _ = vs.delete(note.id).await;
2854 }
2855 }
2856 return Err(e);
2857 }
2858 inserted_models.push(model_name.clone());
2859 }
2860 }
2861
2862 let mut created_edges: Vec<Uuid> = Vec::with_capacity(annotates.len());
2868
2869 #[cfg(test)]
2872 let annotates_iter: Vec<(usize, Uuid)> = annotates
2873 .iter()
2874 .enumerate()
2875 .map(|(i, &id)| (i, id))
2876 .collect();
2877 #[cfg(test)]
2878 macro_rules! next_target {
2879 ($pair:expr) => {
2880 $pair.1
2881 };
2882 }
2883 #[cfg(not(test))]
2884 let annotates_iter: Vec<Uuid> = annotates.to_vec();
2885 #[cfg(not(test))]
2886 macro_rules! next_target {
2887 ($pair:expr) => {
2888 $pair
2889 };
2890 }
2891
2892 for pair in annotates_iter {
2893 let target_id = next_target!(pair);
2894
2895 #[cfg(test)]
2897 let injected_err: Option<RuntimeError> = {
2898 let call_idx = pair.0;
2899 LINK_FAIL_AFTER.with(|cell| {
2900 let n = cell.get();
2901 if n > 0 && call_idx + 1 == n {
2902 cell.set(0); Some(RuntimeError::Internal("injected link failure".to_string()))
2904 } else {
2905 None
2906 }
2907 })
2908 };
2909 #[cfg(not(test))]
2910 let injected_err: Option<RuntimeError> = None;
2911
2912 let link_result = if let Some(e) = injected_err {
2913 Err(e)
2914 } else {
2915 self.link(
2916 token,
2917 note.id,
2918 target_id,
2919 EdgeRelation::Annotates,
2920 1.0,
2921 None,
2922 )
2923 .await
2924 };
2925
2926 match link_result {
2927 Ok(edge) => created_edges.push(edge.id.into()),
2928 Err(e) => {
2929 for edge_id in created_edges {
2931 let _ = self.delete_edge(token, edge_id, true).await;
2932 }
2933 if let Ok(store) = self.notes(token) {
2934 let _ = store.delete_note(note.id, DeleteMode::Hard).await;
2935 }
2936 if let Ok(fts) = self.text_for_notes(token) {
2937 let _ = fts.delete_document(ns, note.id).await;
2938 }
2939 for model_name in &embed_model_names {
2940 if let Ok(vs) = self.vectors_for_model(token, model_name) {
2941 let _ = vs.delete(note.id).await;
2942 }
2943 }
2944 return Err(e);
2945 }
2946 }
2947 }
2948
2949 Ok(note)
2950 }
2951
2952 pub async fn list_notes(
2958 &self,
2959 token: &NamespaceToken,
2960 kind: Option<&str>,
2961 limit: u32,
2962 offset: u32,
2963 ) -> RuntimeResult<Vec<Note>> {
2964 let visible = token.visible_namespaces();
2965 if visible.len() == 1 {
2966 let page = self
2968 .notes(token)?
2969 .query_notes(
2970 token.namespace().as_str(),
2971 kind,
2972 PageRequest {
2973 offset: offset.into(),
2974 limit,
2975 },
2976 )
2977 .await?;
2978 return Ok(page.items);
2979 }
2980 use khive_storage::note::NoteFilter;
2982 let ns_strs: Vec<String> = visible.iter().map(|ns| ns.as_str().to_owned()).collect();
2983 let filter = NoteFilter {
2984 kind: kind.map(|k| k.to_string()),
2985 namespaces: ns_strs,
2986 ..Default::default()
2987 };
2988 let page = self
2989 .notes(token)?
2990 .query_notes_filtered(
2991 token.namespace().as_str(),
2992 &filter,
2993 PageRequest {
2994 offset: offset.into(),
2995 limit,
2996 },
2997 )
2998 .await?;
2999 Ok(page.items)
3000 }
3001
3002 pub async fn count_notes(
3009 &self,
3010 token: &NamespaceToken,
3011 kind: Option<&str>,
3012 ) -> RuntimeResult<u64> {
3013 let mut total = 0u64;
3014 for ns in token.visible_namespaces() {
3015 let temp = NamespaceToken::for_namespace(ns.clone());
3016 total += self.notes(&temp)?.count_notes(ns.as_str(), kind).await?;
3017 }
3018 Ok(total)
3019 }
3020
3021 #[allow(clippy::too_many_arguments)]
3041 pub async fn search_notes(
3042 &self,
3043 token: &NamespaceToken,
3044 query_text: &str,
3045 query_vector: Option<Vec<f32>>,
3046 limit: u32,
3047 note_kind: Option<&str>,
3048 include_superseded: bool,
3049 tags_any: &[String],
3050 properties_filter: Option<&serde_json::Value>,
3051 ) -> RuntimeResult<Vec<NoteSearchHit>> {
3052 const RRF_K: usize = 60;
3053 let candidates = limit.saturating_mul(4).max(limit);
3054 let visible_ns: Vec<String> = token
3055 .visible_namespaces()
3056 .iter()
3057 .map(|ns| ns.as_str().to_owned())
3058 .collect();
3059
3060 #[cfg(any(test, feature = "fault-injection"))]
3073 let fts_search_inject = {
3074 let mut g = FTS_SEARCH_FAIL_NS.lock().unwrap();
3075 match g.as_deref() {
3076 Some(armed) if visible_ns.iter().any(|ns| ns == armed) => {
3077 *g = None;
3078 true
3079 }
3080 _ => false,
3081 }
3082 };
3083 #[cfg(not(any(test, feature = "fault-injection")))]
3084 let fts_search_inject = false;
3085
3086 let text_search_result = if fts_search_inject {
3087 Err(khive_storage::StorageError::Timeout {
3088 operation: "fts_search".into(),
3089 })
3090 } else {
3091 self.text_for_notes(token)?
3092 .search(TextSearchRequest {
3093 query: query_text.to_string(),
3094 mode: TextQueryMode::Plain,
3095 filter: Some(TextFilter {
3096 namespaces: visible_ns.clone(),
3097 ..TextFilter::default()
3098 }),
3099 top_k: candidates,
3100 snippet_chars: 200,
3101 })
3102 .await
3103 };
3104
3105 let text_hits = crate::error::fts_text_leg_or_err(
3106 text_search_result.map_err(RuntimeError::from),
3107 "search_notes",
3108 query_text,
3109 )?;
3110
3111 let vector_hits = if query_vector.is_some() || self.config().embedding_model.is_some() {
3113 self.vector_search(
3114 token,
3115 query_vector,
3116 Some(query_text),
3117 candidates,
3118 Some(SubstrateKind::Note),
3119 )
3120 .await?
3121 } else {
3122 vec![]
3123 };
3124
3125 let fuse_k = text_hits.len() + vector_hits.len();
3131 let fused = crate::fusion::rrf_fuse_k(text_hits, vector_hits, RRF_K, fuse_k)?;
3132
3133 let candidate_ids: Vec<Uuid> = fused.iter().map(|hit| hit.entity_id).collect();
3134 if candidate_ids.is_empty() {
3135 return Ok(vec![]);
3136 }
3137
3138 let note_store = self.notes(token)?;
3143 let mut alive_notes: HashMap<Uuid, Note> = HashMap::new();
3144 for id in &candidate_ids {
3145 if let Some(note) = note_store.get_note(*id).await? {
3146 if note.deleted_at.is_some() {
3147 continue;
3148 }
3149 if let Some(want_kind) = note_kind {
3150 if note.kind != want_kind {
3151 continue;
3152 }
3153 }
3154 if !tags_any.is_empty() {
3159 let note_tags: Vec<String> = note
3160 .properties
3161 .as_ref()
3162 .and_then(|p| p.get("tags"))
3163 .and_then(serde_json::Value::as_array)
3164 .map(|arr| {
3165 arr.iter()
3166 .filter_map(serde_json::Value::as_str)
3167 .map(str::to_owned)
3168 .collect()
3169 })
3170 .unwrap_or_default();
3171 if !note_tags
3172 .iter()
3173 .any(|t| tags_any.iter().any(|w| t.eq_ignore_ascii_case(w)))
3174 {
3175 continue;
3176 }
3177 }
3178 if let Some(pf) = properties_filter {
3180 if !note_props_match(note.properties.as_ref(), pf) {
3181 continue;
3182 }
3183 }
3184 alive_notes.insert(*id, note);
3185 }
3186 }
3187
3188 if !include_superseded && !alive_notes.is_empty() {
3191 let graph = self.graph(token)?;
3192 let mut superseded: std::collections::HashSet<Uuid> = std::collections::HashSet::new();
3193 for ¬e_id in alive_notes.keys() {
3194 let inbound = graph
3195 .neighbors(
3196 note_id,
3197 NeighborQuery {
3198 direction: Direction::In,
3199 relations: Some(vec![EdgeRelation::Supersedes]),
3200 limit: Some(1),
3201 min_weight: None,
3202 },
3203 )
3204 .await?;
3205 if !inbound.is_empty() {
3206 superseded.insert(note_id);
3207 }
3208 }
3209 alive_notes.retain(|id, _| !superseded.contains(id));
3210 }
3211
3212 let mut hits: Vec<NoteSearchHit> = fused
3214 .into_iter()
3215 .filter_map(|hit| {
3216 let note = alive_notes.get(&hit.entity_id)?;
3217 let salience = note.salience.unwrap_or(0.5);
3218 let weight = 0.5 + 0.5 * salience;
3219 let weighted = DeterministicScore::from_f64(hit.score.to_f64() * weight);
3220 Some(NoteSearchHit {
3221 note_id: hit.entity_id,
3222 score: weighted,
3223 title: hit.title.or_else(|| note_title(note)),
3224 snippet: hit.snippet.or_else(|| note_snippet(note)),
3225 })
3226 })
3227 .collect();
3228
3229 hits.sort_by(|a, b| b.score.cmp(&a.score).then(a.note_id.cmp(&b.note_id)));
3230 hits.truncate(limit as usize);
3231 Ok(hits)
3232 }
3233
3234 pub async fn resolve_prefix(
3241 &self,
3242 token: &NamespaceToken,
3243 prefix: &str,
3244 ) -> RuntimeResult<Option<Uuid>> {
3245 let namespaces = [token.namespace().as_str().to_owned()];
3246 self.resolve_prefix_inner(Some(&namespaces), prefix, false)
3247 .await
3248 }
3249
3250 pub async fn resolve_prefix_including_deleted(
3251 &self,
3252 token: &NamespaceToken,
3253 prefix: &str,
3254 ) -> RuntimeResult<Option<Uuid>> {
3255 let namespaces = [token.namespace().as_str().to_owned()];
3256 self.resolve_prefix_inner(Some(&namespaces), prefix, true)
3257 .await
3258 }
3259
3260 pub async fn resolve_prefix_unfiltered(&self, prefix: &str) -> RuntimeResult<Option<Uuid>> {
3268 self.resolve_prefix_inner(None, prefix, false).await
3269 }
3270
3271 pub async fn resolve_prefix_unfiltered_including_deleted(
3274 &self,
3275 prefix: &str,
3276 ) -> RuntimeResult<Option<Uuid>> {
3277 self.resolve_prefix_inner(None, prefix, true).await
3278 }
3279
3280 async fn resolve_prefix_inner(
3292 &self,
3293 namespaces: Option<&[String]>,
3294 prefix: &str,
3295 include_deleted: bool,
3296 ) -> RuntimeResult<Option<Uuid>> {
3297 use khive_storage::types::{SqlStatement, SqlValue};
3298
3299 if !prefix.chars().all(|c| c.is_ascii_hexdigit() || c == '-') {
3308 return Ok(None);
3309 }
3310
3311 let pattern = format!("{}%", hex_prefix_to_uuid_pattern(prefix));
3312
3313 let tables = [
3314 ("entities", true),
3315 ("notes", true),
3316 ("events", false),
3317 ("graph_edges", false),
3318 ];
3319
3320 let ns_clause = namespaces.map(|ns| {
3321 let placeholders: Vec<String> = (0..ns.len()).map(|i| format!("?{}", i + 2)).collect();
3322 format!(" AND namespace IN ({})", placeholders.join(", "))
3323 });
3324
3325 let mut matches: Vec<String> = Vec::new();
3334 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
3335 let mut reader = self.sql().reader().await.map_err(RuntimeError::Storage)?;
3336
3337 for (table, has_deleted_at) in tables {
3338 let deleted_filter = if has_deleted_at && !include_deleted {
3339 " AND deleted_at IS NULL"
3340 } else {
3341 ""
3342 };
3343 let mut params = vec![SqlValue::Text(pattern.clone())];
3344 if let Some(ns) = namespaces {
3345 params.extend(ns.iter().map(|n| SqlValue::Text(n.clone())));
3346 }
3347 let sql = SqlStatement {
3348 sql: format!(
3349 "SELECT id FROM {table} WHERE id LIKE ?1{ns_clause}{deleted_filter} LIMIT 2",
3350 ns_clause = ns_clause.as_deref().unwrap_or("")
3351 ),
3352 params,
3353 label: Some("resolve_prefix".into()),
3354 };
3355 match reader.query_all(sql).await {
3356 Ok(rows) => {
3357 for row in rows {
3358 if let Some(col) = row.columns.first() {
3359 if let SqlValue::Text(s) = &col.value {
3360 if seen.insert(s.clone()) {
3361 matches.push(s.clone());
3362 }
3363 }
3364 }
3365 }
3366 }
3367 Err(e) => {
3368 let msg = e.to_string();
3369 if msg.contains("no such table") {
3370 continue;
3371 }
3372 return Err(RuntimeError::Storage(e));
3373 }
3374 }
3375 if matches.len() > 1 {
3376 break;
3377 }
3378 }
3379
3380 match matches.len() {
3381 0 => Ok(None),
3382 1 => {
3383 let uuid = Uuid::from_str(&matches[0])
3384 .map_err(|e| RuntimeError::Internal(format!("stored UUID is invalid: {e}")))?;
3385 Ok(Some(uuid))
3386 }
3387 _ => {
3388 let uuids: Vec<uuid::Uuid> = matches
3389 .iter()
3390 .filter_map(|s| Uuid::from_str(s).ok())
3391 .collect();
3392 Err(RuntimeError::AmbiguousPrefix {
3393 prefix: prefix.to_string(),
3394 matches: uuids,
3395 })
3396 }
3397 }
3398 }
3399
3400 pub async fn resolve_by_id(
3411 &self,
3412 token: &NamespaceToken,
3413 id: Uuid,
3414 ) -> RuntimeResult<Option<Resolved>> {
3415 if let Some(entity) = self.entities(token)?.get_entity(id).await? {
3417 return Ok(Some(Resolved::Entity(entity)));
3418 }
3419
3420 if let Some(note) = self.notes(token)?.get_note(id).await? {
3422 return Ok(Some(Resolved::Note(note)));
3423 }
3424
3425 Ok(None)
3428 }
3429
3430 pub async fn resolve_by_id_including_deleted(
3436 &self,
3437 token: &NamespaceToken,
3438 id: Uuid,
3439 ) -> RuntimeResult<Option<Resolved>> {
3440 if let Some(entity) = self
3442 .entities(token)?
3443 .get_entity_including_deleted(id)
3444 .await?
3445 {
3446 return Ok(Some(Resolved::Entity(entity)));
3447 }
3448
3449 if let Some(note) = self.notes(token)?.get_note_including_deleted(id).await? {
3451 return Ok(Some(Resolved::Note(note)));
3452 }
3453
3454 Ok(None)
3457 }
3458
3459 pub async fn resolve(
3464 &self,
3465 token: &NamespaceToken,
3466 id: Uuid,
3467 ) -> RuntimeResult<Option<Resolved>> {
3468 match self.get_entity(token, id).await {
3470 Ok(entity) => return Ok(Some(Resolved::Entity(entity))),
3471 Err(RuntimeError::NotFound(_) | RuntimeError::NamespaceMismatch { .. }) => {}
3472 Err(e) => return Err(e),
3473 }
3474
3475 if let Some(note) = self.notes(token)?.get_note(id).await? {
3477 if Self::ensure_namespace_visible(¬e.namespace, token).is_ok() {
3478 return Ok(Some(Resolved::Note(note)));
3479 }
3480 }
3481
3482 if let Some(event) = self.events(token)?.get_event(id).await? {
3484 if Self::ensure_namespace_visible(&event.namespace, token).is_ok() {
3485 return Ok(Some(Resolved::Event(event)));
3486 }
3487 }
3488
3489 Ok(None)
3490 }
3491
3492 pub async fn resolve_edge_endpoint(
3503 &self,
3504 token: &NamespaceToken,
3505 id: Uuid,
3506 ) -> RuntimeResult<Option<Resolved>> {
3507 if let Some(resolved) = self.resolve_by_id(token, id).await? {
3508 return Ok(Some(resolved));
3509 }
3510 if let Some(event) = self.events(token)?.get_event(id).await? {
3511 return Ok(Some(Resolved::Event(event)));
3512 }
3513 Ok(None)
3514 }
3515
3516 pub async fn resolve_primary(
3521 &self,
3522 token: &NamespaceToken,
3523 id: Uuid,
3524 ) -> RuntimeResult<Option<Resolved>> {
3525 let ns = token.namespace().as_str();
3526
3527 if let Some(entity) = self.entities(token)?.get_entity(id).await? {
3529 if Self::ensure_namespace(&entity.namespace, ns).is_ok() {
3530 return Ok(Some(Resolved::Entity(entity)));
3531 }
3532 }
3533
3534 if let Some(note) = self.notes(token)?.get_note(id).await? {
3536 if Self::ensure_namespace(¬e.namespace, ns).is_ok() {
3537 return Ok(Some(Resolved::Note(note)));
3538 }
3539 }
3540
3541 if let Some(event) = self.events(token)?.get_event(id).await? {
3543 if Self::ensure_namespace(&event.namespace, ns).is_ok() {
3544 return Ok(Some(Resolved::Event(event)));
3545 }
3546 }
3547
3548 Ok(None)
3549 }
3550
3551 pub async fn resolve_including_deleted(
3556 &self,
3557 token: &NamespaceToken,
3558 id: Uuid,
3559 ) -> RuntimeResult<Option<Resolved>> {
3560 let ns = token.namespace().as_str();
3561
3562 if let Some(entity) = self
3563 .entities(token)?
3564 .get_entity_including_deleted(id)
3565 .await?
3566 {
3567 if Self::ensure_namespace(&entity.namespace, ns).is_ok() {
3568 return Ok(Some(Resolved::Entity(entity)));
3569 }
3570 }
3571
3572 if let Some(note) = self.notes(token)?.get_note_including_deleted(id).await? {
3573 if Self::ensure_namespace(¬e.namespace, ns).is_ok() {
3574 return Ok(Some(Resolved::Note(note)));
3575 }
3576 }
3577
3578 if let Some(event) = self.events(token)?.get_event(id).await? {
3579 if Self::ensure_namespace(&event.namespace, ns).is_ok() {
3580 return Ok(Some(Resolved::Event(event)));
3581 }
3582 }
3583
3584 Ok(None)
3585 }
3586
3587 async fn atomic_hard_delete_with_edge_purge(
3612 &self,
3613 row_statement: SqlStatement,
3614 node_id: Uuid,
3615 ) -> RuntimeResult<bool> {
3616 let plan = AtomicOpPlan::Delete(DeletePlan {
3617 target_id: node_id,
3618 statements: vec![
3619 PlanStatement {
3620 statement: row_statement,
3621 guard: Some(AffectedRowGuard::exactly(1)),
3622 },
3623 PlanStatement {
3624 statement: purge_incident_edges_statement(node_id),
3625 guard: None,
3626 },
3627 ],
3628 post_commit: PostCommitEffect::None,
3629 });
3630 match run_atomic_unit(self.sql().as_ref(), vec![plan]).await {
3631 Ok(AtomicRunOutcome::Committed { .. }) => Ok(true),
3632 Ok(AtomicRunOutcome::RolledBack {
3633 failure: AtomicOpFailure::GuardFailed { .. },
3634 ..
3635 }) => Ok(false),
3636 Ok(AtomicRunOutcome::RolledBack {
3637 failure: AtomicOpFailure::SqlError { message, .. },
3638 ..
3639 }) => Err(RuntimeError::Internal(format!(
3640 "hard delete + edge purge for {node_id} failed: {message}"
3641 ))),
3642 Err(e) => Err(RuntimeError::Internal(format!(
3643 "hard delete + edge purge for {node_id}: atomic unit seam failure: {}",
3644 e.0
3645 ))),
3646 }
3647 }
3648
3649 pub async fn delete_note(
3660 &self,
3661 token: &NamespaceToken,
3662 id: Uuid,
3663 hard: bool,
3664 ) -> RuntimeResult<bool> {
3665 let note_store = self.notes(token)?;
3666 let note = if hard {
3667 match note_store.get_note_including_deleted(id).await? {
3668 Some(n) => n,
3669 None => return Ok(false),
3670 }
3671 } else {
3672 match note_store.get_note(id).await? {
3673 Some(n) => n,
3674 None => return Ok(false),
3675 }
3676 };
3677 let mode = if hard {
3678 DeleteMode::Hard
3679 } else {
3680 DeleteMode::Soft
3681 };
3682
3683 let record_tok = NamespaceToken::for_namespace(
3685 khive_types::Namespace::parse(¬e.namespace)
3686 .map_err(|e| RuntimeError::Internal(format!("note namespace invalid: {e}")))?,
3687 );
3688 let record_ns = note.namespace.clone();
3689
3690 let deleted = if hard {
3695 let deleted = self
3696 .atomic_hard_delete_with_edge_purge(note_hard_delete_statement(id), id)
3697 .await?;
3698 self.text_for_notes(&record_tok)?
3699 .delete_document(&record_ns, id)
3700 .await?;
3701 for model_name in self.registered_embedding_model_names() {
3704 self.vectors_for_model(&record_tok, &model_name)?
3705 .delete(id)
3706 .await?;
3707 }
3708 deleted
3709 } else {
3710 let deleted = note_store.delete_note(id, mode).await?;
3711 if deleted {
3712 self.text_for_notes(&record_tok)?
3713 .delete_document(&record_ns, id)
3714 .await?;
3715 for model_name in self.registered_embedding_model_names() {
3716 self.vectors_for_model(&record_tok, &model_name)?
3717 .delete(id)
3718 .await?;
3719 }
3720 }
3721 deleted
3722 };
3723 if deleted {
3724 let event_store = self.events(token)?;
3725 let event = khive_storage::event::Event::new(
3726 record_ns.clone(),
3727 "delete",
3728 EventKind::NoteDeleted,
3729 SubstrateKind::Note,
3730 "",
3731 )
3732 .with_target(id)
3733 .with_payload(serde_json::json!({"id": id, "namespace": record_ns, "hard": hard}));
3734 event_store.append_event(event).await.map_err(|e| {
3735 RuntimeError::Internal(format!("delete_note: event store write failed: {e}"))
3736 })?;
3737 self.fire_note_mutation_hook(¬e.kind, id).await;
3744 }
3745 Ok(deleted)
3746 }
3747
3748 pub async fn delete_note_row_first_for_compensation(
3767 &self,
3768 token: &NamespaceToken,
3769 id: Uuid,
3770 ) -> RuntimeResult<()> {
3771 let note_store = self.notes(token)?;
3772 let Some(note) = note_store.get_note_including_deleted(id).await? else {
3773 return Ok(());
3774 };
3775 let record_tok = NamespaceToken::for_namespace(
3776 khive_types::Namespace::parse(¬e.namespace)
3777 .map_err(|e| RuntimeError::Internal(format!("note namespace invalid: {e}")))?,
3778 );
3779 let record_ns = note.namespace.clone();
3780
3781 note_store.delete_note(id, DeleteMode::Hard).await?;
3783
3784 #[cfg(any(test, feature = "fault-injection"))]
3785 {
3786 let armed = ROLLBACK_CLEANUP_FAIL_NS.lock().unwrap().take();
3787 if armed.as_deref() == Some(record_ns.as_str()) {
3788 return Err(RuntimeError::Internal(
3789 "row removed but compensation cleanup failed: injected=true".to_string(),
3790 ));
3791 }
3792 }
3793
3794 let mut cleanup_errors = Vec::new();
3795 if let Err(e) = self.graph(&record_tok)?.purge_incident_edges(id).await {
3796 cleanup_errors.push(format!("graph={e}"));
3797 }
3798 if let Err(e) = self
3799 .text_for_notes(&record_tok)?
3800 .delete_document(&record_ns, id)
3801 .await
3802 {
3803 cleanup_errors.push(format!("fts={e}"));
3804 }
3805 for model_name in self.registered_embedding_model_names() {
3806 if let Err(e) = self
3807 .vectors_for_model(&record_tok, &model_name)?
3808 .delete(id)
3809 .await
3810 {
3811 cleanup_errors.push(format!("vector[{model_name}]={e}"));
3812 }
3813 }
3814 if cleanup_errors.is_empty() {
3815 Ok(())
3816 } else {
3817 Err(RuntimeError::Internal(format!(
3818 "row removed but compensation cleanup failed: {}",
3819 cleanup_errors.join("; ")
3820 )))
3821 }
3822 }
3823}
3824
3825#[derive(Clone, Debug, Serialize)]
3827pub struct QueryResult {
3828 pub rows: Vec<SqlRow>,
3829 #[serde(skip_serializing_if = "Vec::is_empty")]
3830 pub warnings: Vec<String>,
3831}
3832
3833impl KhiveRuntime {
3834 pub async fn query(&self, token: &NamespaceToken, query: &str) -> RuntimeResult<Vec<SqlRow>> {
3842 Ok(self
3843 .query_with_metadata(token, query, khive_query::CompileOptions::default())
3844 .await?
3845 .rows)
3846 }
3847
3848 pub async fn query_with_metadata(
3850 &self,
3851 token: &NamespaceToken,
3852 query: &str,
3853 mut opts: khive_query::CompileOptions,
3854 ) -> RuntimeResult<QueryResult> {
3855 use khive_query::QueryValue;
3856 use khive_storage::types::SqlValue;
3857
3858 let ast = khive_query::parse_auto(query)?;
3859 opts.scopes = token
3860 .visible_namespaces()
3861 .iter()
3862 .map(|ns| ns.as_str().to_string())
3863 .collect();
3864 let compiled = khive_query::compile(&ast, &opts)?;
3865 let mut warnings = compiled.warnings;
3866 let truncation_check = compiled.truncation_check;
3867
3868 let params: Vec<SqlValue> = compiled
3871 .params
3872 .into_iter()
3873 .map(|qv| match qv {
3874 QueryValue::Null => SqlValue::Null,
3875 QueryValue::Integer(n) => SqlValue::Integer(n),
3876 QueryValue::Float(f) => SqlValue::Float(f),
3877 QueryValue::Text(s) => SqlValue::Text(s),
3878 QueryValue::Blob(b) => SqlValue::Blob(b),
3879 })
3880 .collect();
3881
3882 let mut reader = self.sql().reader().await?;
3883 let stmt = SqlStatement {
3884 sql: compiled.sql,
3885 params,
3886 label: None,
3887 };
3888 let mut rows = reader.query_all(stmt).await?;
3889
3890 if let Some(check) = truncation_check {
3896 if rows.len() > check.max_limit {
3897 rows.truncate(check.max_limit);
3898 warnings.push(match check.requested_limit {
3899 Some(requested) => format!(
3900 "result set capped at {} rows; requested limit {requested} exceeds the \
3901 cap — use LIMIT/OFFSET to page through the remaining results",
3902 check.max_limit
3903 ),
3904 None => format!(
3905 "result set capped at {} rows; more than {} rows matched with no LIMIT \
3906 clause — use LIMIT/OFFSET to page through the remaining results",
3907 check.max_limit, check.max_limit
3908 ),
3909 });
3910 }
3911 }
3912
3913 Ok(QueryResult { rows, warnings })
3914 }
3915
3916 pub async fn delete_entity(
3924 &self,
3925 token: &NamespaceToken,
3926 id: Uuid,
3927 hard: bool,
3928 ) -> RuntimeResult<bool> {
3929 let entity = if hard {
3930 match self
3931 .entities(token)?
3932 .get_entity_including_deleted(id)
3933 .await?
3934 {
3935 Some(e) => e,
3936 None => return Ok(false),
3937 }
3938 } else {
3939 match self.entities(token)?.get_entity(id).await? {
3940 Some(e) => e,
3941 None => return Ok(false),
3942 }
3943 };
3944 let mode = if hard {
3945 DeleteMode::Hard
3946 } else {
3947 DeleteMode::Soft
3948 };
3949
3950 let record_tok = NamespaceToken::for_namespace(
3952 khive_types::Namespace::parse(&entity.namespace)
3953 .map_err(|e| RuntimeError::Internal(format!("entity namespace invalid: {e}")))?,
3954 );
3955
3956 let deleted = if hard {
3961 let deleted = self
3962 .atomic_hard_delete_with_edge_purge(entity_hard_delete_statement(id), id)
3963 .await?;
3964 self.remove_from_indexes(&record_tok, id).await?;
3965 deleted
3966 } else {
3967 let deleted = self.entities(token)?.delete_entity(id, mode).await?;
3968 if deleted {
3969 self.remove_from_indexes(&record_tok, id).await?;
3970 }
3971 deleted
3972 };
3973 if deleted {
3974 let event_store = self.events(token)?;
3975 let ns = entity.namespace.clone();
3976 let event = khive_storage::event::Event::new(
3977 ns.clone(),
3978 "delete",
3979 EventKind::EntityDeleted,
3980 SubstrateKind::Entity,
3981 "",
3982 )
3983 .with_target(id)
3984 .with_payload(serde_json::json!({"id": id, "namespace": ns, "hard": hard}));
3985 event_store.append_event(event).await.map_err(|e| {
3986 RuntimeError::Internal(format!("delete_entity: event store write failed: {e}"))
3987 })?;
3988 }
3989 Ok(deleted)
3990 }
3991
3992 pub async fn count_entities(
3994 &self,
3995 token: &NamespaceToken,
3996 kind: Option<&str>,
3997 ) -> RuntimeResult<u64> {
3998 let ns_strs: Vec<String> = token
3999 .visible_namespaces()
4000 .iter()
4001 .map(|ns| ns.as_str().to_owned())
4002 .collect();
4003 let filter = EntityFilter {
4004 kinds: match kind {
4005 Some(k) => vec![k.to_string()],
4006 None => vec![],
4007 },
4008 namespaces: ns_strs,
4009 ..Default::default()
4010 };
4011 Ok(self
4012 .entities(token)?
4013 .count_entities(token.namespace().as_str(), filter)
4014 .await?)
4015 }
4016
4017 pub async fn get_edge(
4024 &self,
4025 _token: &NamespaceToken,
4026 edge_id: Uuid,
4027 ) -> RuntimeResult<Option<Edge>> {
4028 let mut reader = self.sql().reader().await?;
4029 let record_ns = reader
4030 .query_scalar(SqlStatement {
4031 sql: "SELECT namespace FROM graph_edges \
4032 WHERE id = ?1 AND deleted_at IS NULL LIMIT 1"
4033 .into(),
4034 params: vec![SqlValue::Text(edge_id.to_string())],
4035 label: Some("get_edge_namespace".into()),
4036 })
4037 .await?;
4038
4039 let Some(SqlValue::Text(record_ns)) = record_ns else {
4040 return Ok(None);
4041 };
4042 let record_tok = NamespaceToken::for_namespace(
4045 khive_types::Namespace::parse(&record_ns)
4046 .map_err(|e| RuntimeError::Internal(format!("edge namespace invalid: {e}")))?,
4047 );
4048 Ok(self
4049 .graph(&record_tok)?
4050 .get_edge(LinkId::from(edge_id))
4051 .await?)
4052 }
4053
4054 pub async fn get_edge_visible(
4059 &self,
4060 token: &NamespaceToken,
4061 edge_id: Uuid,
4062 ) -> RuntimeResult<Option<Edge>> {
4063 self.get_edge(token, edge_id).await
4064 }
4065
4066 pub async fn get_edge_including_deleted(
4072 &self,
4073 _token: &NamespaceToken,
4074 edge_id: Uuid,
4075 ) -> RuntimeResult<Option<Edge>> {
4076 let mut reader = self.sql().reader().await?;
4077 let record_ns = reader
4078 .query_scalar(SqlStatement {
4079 sql: "SELECT namespace FROM graph_edges WHERE id = ?1 LIMIT 1".into(),
4080 params: vec![SqlValue::Text(edge_id.to_string())],
4081 label: Some("get_edge_including_deleted_namespace".into()),
4082 })
4083 .await?;
4084
4085 let Some(SqlValue::Text(record_ns)) = record_ns else {
4086 return Ok(None);
4087 };
4088 let record_tok = NamespaceToken::for_namespace(
4090 khive_types::Namespace::parse(&record_ns)
4091 .map_err(|e| RuntimeError::Internal(format!("edge namespace invalid: {e}")))?,
4092 );
4093 Ok(self
4094 .graph(&record_tok)?
4095 .get_edge_including_deleted(LinkId::from(edge_id))
4096 .await?)
4097 }
4098
4099 pub const EDGE_LIST_MAX_LIMIT: u32 = 1000;
4104
4105 pub async fn list_edges(
4113 &self,
4114 token: &NamespaceToken,
4115 filter: crate::curation::EdgeListFilter,
4116 limit: u32,
4117 offset: u32,
4118 ) -> RuntimeResult<Vec<Edge>> {
4119 let limit = limit.clamp(1, Self::EDGE_LIST_MAX_LIMIT);
4120 let visible = token.visible_namespaces();
4121
4122 if let [ns] = visible {
4125 let temp = NamespaceToken::for_namespace(ns.clone());
4126 let page = self
4127 .graph(&temp)?
4128 .query_edges(
4129 filter.into(),
4130 vec![SortOrder {
4131 field: EdgeSortField::CreatedAt,
4132 direction: khive_storage::types::SortDirection::Asc,
4133 }],
4134 PageRequest {
4135 offset: offset.into(),
4136 limit,
4137 },
4138 )
4139 .await?;
4140 return Ok(page.items);
4141 }
4142
4143 let fetch_limit = offset.saturating_add(limit);
4147 let mut results = Vec::new();
4148 for ns in visible {
4149 let temp = NamespaceToken::for_namespace(ns.clone());
4150 let page = self
4151 .graph(&temp)?
4152 .query_edges(
4153 filter.clone().into(),
4154 vec![SortOrder {
4155 field: EdgeSortField::CreatedAt,
4156 direction: khive_storage::types::SortDirection::Asc,
4157 }],
4158 PageRequest {
4159 offset: 0,
4160 limit: fetch_limit,
4161 },
4162 )
4163 .await?;
4164 results.extend(page.items);
4165 }
4166 results.sort_by_key(|e| Uuid::from(e.id));
4167 results.dedup_by_key(|e| Uuid::from(e.id));
4168 let start = (offset as usize).min(results.len());
4169 let end = (start + limit as usize).min(results.len());
4170 Ok(results[start..end].to_vec())
4171 }
4172
4173 pub async fn list_edges_after(
4184 &self,
4185 token: &NamespaceToken,
4186 filter: crate::curation::EdgeListFilter,
4187 after: Option<Uuid>,
4188 limit: u32,
4189 ) -> RuntimeResult<(Vec<Edge>, Option<Uuid>)> {
4190 let limit = limit.clamp(1, Self::EDGE_LIST_MAX_LIMIT);
4191 let visible = token.visible_namespaces();
4192 let limit_usize = limit as usize;
4193
4194 if let [ns] = visible {
4195 let temp = NamespaceToken::for_namespace(ns.clone());
4196 let page = self
4197 .graph(&temp)?
4198 .query_edges_after(filter.into(), after, limit)
4199 .await?;
4200 return Ok((page.items, page.next_after));
4201 }
4202
4203 let probe_limit = limit + 1;
4207 let mut results = Vec::new();
4208 for ns in visible {
4209 let temp = NamespaceToken::for_namespace(ns.clone());
4210 let page = self
4211 .graph(&temp)?
4212 .query_edges_after(filter.clone().into(), after, probe_limit)
4213 .await?;
4214 results.extend(page.items);
4215 }
4216 results.sort_by_key(|e| Uuid::from(e.id));
4217 results.dedup_by_key(|e| Uuid::from(e.id));
4218 let has_more = results.len() > limit_usize;
4219 if has_more {
4220 results.truncate(limit_usize);
4221 }
4222 let next_after = if has_more {
4223 results.last().map(|e| Uuid::from(e.id))
4224 } else {
4225 None
4226 };
4227 Ok((results, next_after))
4228 }
4229
4230 pub async fn count_edges_by_relation(
4234 &self,
4235 token: &NamespaceToken,
4236 ) -> RuntimeResult<std::collections::HashMap<String, u64>> {
4237 let mut totals: std::collections::HashMap<String, u64> = std::collections::HashMap::new();
4238 for ns in token.visible_namespaces() {
4239 let temp = NamespaceToken::for_namespace(ns.clone());
4240 for (relation, count) in self.graph(&temp)?.count_edges_by_relation().await? {
4241 *totals.entry(relation.to_string()).or_insert(0) += count;
4242 }
4243 }
4244 Ok(totals)
4245 }
4246
4247 #[allow(clippy::too_many_arguments)]
4268 fn update_edge_symmetric_dml(
4269 conn: &rusqlite::Connection,
4270 ns: &str,
4271 edge_id_str: &str,
4272 canon_src_str: &str,
4273 canon_tgt_str: &str,
4274 relation_str: &str,
4275 weight: f64,
4276 metadata: Option<String>,
4277 target_backend: Option<String>,
4278 ) -> Result<Option<String>, SqliteError> {
4279 let now_ts = chrono::Utc::now().timestamp_micros();
4287
4288 let conflict_id: Option<String> = conn
4291 .query_row(
4292 khive_db::stores::graph::EDGE_SYMMETRIC_CONFLICT_PROBE_SQL,
4293 rusqlite::params![
4294 &ns,
4295 &canon_src_str,
4296 &canon_tgt_str,
4297 &relation_str,
4298 &edge_id_str
4299 ],
4300 |row| row.get(0),
4301 )
4302 .optional()
4303 .map_err(SqliteError::Rusqlite)?;
4304
4305 if let Some(existing_id) = conflict_id {
4306 conn.execute(
4310 khive_db::stores::graph::EDGE_SYMMETRIC_DELETE_NONCANONICAL_SQL,
4311 rusqlite::params![&ns, &edge_id_str],
4312 )
4313 .map_err(SqliteError::Rusqlite)?;
4314 let affected = conn
4315 .execute(
4316 khive_db::stores::graph::EDGE_SYMMETRIC_REFRESH_CANONICAL_SQL,
4317 rusqlite::params![weight, now_ts, target_backend, metadata, &ns, &existing_id],
4318 )
4319 .map_err(SqliteError::Rusqlite)?;
4320 if affected == 0 {
4321 return Err(SqliteError::InvalidData(format!(
4322 "update_edge: surviving canonical row {existing_id} vanished during update"
4323 )));
4324 }
4325 Ok(Some(existing_id))
4326 } else {
4327 let affected = conn
4330 .execute(
4331 khive_db::stores::graph::EDGE_SYMMETRIC_UPDATE_INPLACE_SQL,
4332 rusqlite::params![
4333 &canon_src_str,
4334 &canon_tgt_str,
4335 &relation_str,
4336 weight,
4337 now_ts,
4338 metadata,
4339 &ns,
4340 &edge_id_str,
4341 ],
4342 )
4343 .map_err(SqliteError::Rusqlite)?;
4344 if affected == 0 {
4345 return Err(SqliteError::InvalidData(format!(
4348 "update_edge: zero rows affected updating edge {edge_id_str} \
4349 in namespace {ns} — row vanished between fetch and update"
4350 )));
4351 }
4352 Ok(None)
4353 }
4354 }
4355
4356 pub async fn update_edge(
4369 &self,
4370 token: &NamespaceToken,
4371 edge_id: Uuid,
4372 patch: crate::curation::EdgePatch,
4373 ) -> RuntimeResult<Edge> {
4374 let graph_for_fetch = self.graph(token)?;
4377 let mut edge = graph_for_fetch
4378 .get_edge(LinkId::from(edge_id))
4379 .await?
4380 .ok_or_else(|| crate::RuntimeError::NotFound(format!("edge {edge_id}")))?;
4381
4382 let record_ns: String = edge.namespace.clone();
4387 let record_tok = NamespaceToken::for_namespace(
4388 khive_types::Namespace::parse(&record_ns)
4389 .map_err(|e| RuntimeError::Internal(format!("edge namespace invalid: {e}")))?,
4390 );
4391 let graph = self.graph(&record_tok)?;
4392
4393 let mut changed_fields: Vec<&'static str> = Vec::new();
4394 if let Some(r) = patch.relation {
4395 self.validate_edge_relation_endpoints(&record_tok, edge.source_id, edge.target_id, r)
4398 .await?;
4399 edge.relation = r;
4400 changed_fields.push("relation");
4401 }
4402 if let Some(w) = patch.weight {
4403 if !w.is_finite() || !(0.0..=1.0).contains(&w) {
4406 return Err(RuntimeError::InvalidInput(format!(
4407 "edge weight must be a finite value in [0.0, 1.0]; got {w}"
4408 )));
4409 }
4410 edge.weight = w;
4411 changed_fields.push("weight");
4412 }
4413 if let Some(props) = patch.properties {
4414 edge.metadata = Some(props);
4415 }
4416
4417 let (canon_src, canon_tgt) =
4427 canonical_edge_endpoints(edge.relation, edge.source_id, edge.target_id);
4428
4429 if edge.relation.is_symmetric() {
4430 let ns = record_ns.clone();
4434 let edge_id_str = edge_id.to_string();
4435 let relation_str = edge.relation.to_string();
4436 let canon_src_str = canon_src.to_string();
4437 let canon_tgt_str = canon_tgt.to_string();
4438 let weight = edge.weight;
4439 let metadata = edge
4440 .metadata
4441 .as_ref()
4442 .map(|v| serde_json::to_string(v).unwrap_or_default());
4443 let target_backend = edge.target_backend.clone();
4444
4445 let pool = self.backend().pool_arc();
4446 let writer_task = pool.writer_task_handle().ok().flatten();
4450
4451 let surviving_id: Option<String> = if let Some(writer_task) = writer_task {
4455 writer_task
4456 .send(move |conn| {
4457 Self::update_edge_symmetric_dml(
4458 conn,
4459 &ns,
4460 &edge_id_str,
4461 &canon_src_str,
4462 &canon_tgt_str,
4463 &relation_str,
4464 weight,
4465 metadata,
4466 target_backend,
4467 )
4468 .map_err(|e| {
4469 khive_storage::StorageError::driver(
4470 khive_storage::StorageCapability::Graph,
4471 "update_edge",
4472 e,
4473 )
4474 })
4475 })
4476 .await
4477 .map_err(RuntimeError::Storage)?
4478 } else {
4479 tokio::task::spawn_blocking(move || {
4480 let guard = pool.writer()?;
4481 guard.transaction(|conn| {
4482 Self::update_edge_symmetric_dml(
4483 conn,
4484 &ns,
4485 &edge_id_str,
4486 &canon_src_str,
4487 &canon_tgt_str,
4488 &relation_str,
4489 weight,
4490 metadata,
4491 target_backend,
4492 )
4493 })
4494 })
4495 .await
4496 .map_err(|e| {
4497 RuntimeError::Internal(format!("update_edge: spawn_blocking join: {e}"))
4498 })?
4499 .map_err(RuntimeError::Sqlite)?
4500 };
4501
4502 if let Some(sid) = surviving_id {
4503 let surviving_uuid = Uuid::parse_str(&sid).map_err(|e| {
4507 RuntimeError::Internal(format!("update_edge: surviving id parse failed: {e}"))
4508 })?;
4509 edge = self
4510 .get_edge(&record_tok, surviving_uuid)
4511 .await?
4512 .ok_or_else(|| {
4513 RuntimeError::Internal(format!(
4514 "update_edge: surviving canonical row {surviving_uuid} vanished after update"
4515 ))
4516 })?;
4517 } else {
4518 edge.source_id = canon_src;
4520 edge.target_id = canon_tgt;
4521 }
4522 } else {
4523 graph.upsert_edge(edge.clone()).await?;
4527 }
4528
4529 let event_store = self.events(&record_tok)?;
4531 let event = khive_storage::event::Event::new(
4532 record_ns.clone(),
4533 "update",
4534 EventKind::EdgeUpdated,
4535 SubstrateKind::Entity,
4536 "",
4537 )
4538 .with_target(edge_id)
4539 .with_payload(
4540 serde_json::json!({"id": edge_id, "namespace": record_ns, "changed_fields": changed_fields}),
4541 );
4542 event_store.append_event(event).await.map_err(|e| {
4543 RuntimeError::Internal(format!("update_edge: event store write failed: {e}"))
4544 })?;
4545
4546 Ok(edge)
4547 }
4548
4549 pub async fn delete_edge(
4560 &self,
4561 token: &NamespaceToken,
4562 edge_id: Uuid,
4563 hard: bool,
4564 ) -> RuntimeResult<bool> {
4565 let mode = if hard {
4566 DeleteMode::Hard
4567 } else {
4568 DeleteMode::Soft
4569 };
4570
4571 let edge = if hard {
4577 self.get_edge_including_deleted(token, edge_id).await?
4578 } else {
4579 self.get_edge(token, edge_id).await?
4580 };
4581 let Some(edge) = edge else {
4582 return Ok(false);
4583 };
4584
4585 let record_ns: String = edge.namespace.clone();
4587 let record_tok = NamespaceToken::for_namespace(
4588 khive_types::Namespace::parse(&record_ns)
4589 .map_err(|e| RuntimeError::Internal(format!("edge namespace invalid: {e}")))?,
4590 );
4591 let graph = self.graph(&record_tok)?;
4592
4593 let deleted = if hard {
4600 self.atomic_hard_delete_with_edge_purge(edge_hard_delete_statement(edge_id), edge_id)
4601 .await?
4602 } else {
4603 graph.delete_edge(LinkId::from(edge_id), mode).await?
4604 };
4605 if deleted {
4606 let event_store = self.events(&record_tok)?;
4608 let event = khive_storage::event::Event::new(
4609 record_ns.clone(),
4610 "delete",
4611 EventKind::EdgeDeleted,
4612 SubstrateKind::Entity,
4613 "",
4614 )
4615 .with_target(edge_id)
4616 .with_payload(serde_json::json!({"id": edge_id, "namespace": record_ns, "hard": hard}));
4617 event_store.append_event(event).await.map_err(|e| {
4618 RuntimeError::Internal(format!("delete_edge: event store write failed: {e}"))
4619 })?;
4620 }
4621 Ok(deleted)
4622 }
4623
4624 pub async fn count_edges(
4629 &self,
4630 token: &NamespaceToken,
4631 filter: crate::curation::EdgeListFilter,
4632 ) -> RuntimeResult<u64> {
4633 let mut total = 0u64;
4634 for ns in token.visible_namespaces() {
4635 let temp = NamespaceToken::for_namespace(ns.clone());
4636 total += self
4637 .graph(&temp)?
4638 .count_edges(filter.clone().into())
4639 .await?;
4640 }
4641 Ok(total)
4642 }
4643
4644 pub async fn build_edge(&self, token: &NamespaceToken, spec: &LinkSpec) -> RuntimeResult<Edge> {
4655 let ns_str = match &spec.namespace {
4656 Some(s) => {
4657 let spec_ns = crate::Namespace::parse(s)
4658 .map_err(|e| RuntimeError::InvalidInput(format!("invalid namespace: {e}")))?;
4659 if &spec_ns != token.namespace() {
4660 return Err(RuntimeError::InvalidInput(
4661 "LinkSpec namespace does not match token namespace".into(),
4662 ));
4663 }
4664 s.as_str()
4665 }
4666 None => token.namespace().as_str(),
4667 };
4668 self.validate_edge_relation_endpoints(token, spec.source_id, spec.target_id, spec.relation)
4669 .await?;
4670 let (source_id, target_id) =
4671 canonical_edge_endpoints(spec.relation, spec.source_id, spec.target_id);
4672 let metadata = if spec.relation == EdgeRelation::DependsOn {
4673 match (
4678 self.resolve_edge_endpoint(token, source_id).await?,
4679 self.resolve_edge_endpoint(token, target_id).await?,
4680 ) {
4681 (Some(Resolved::Entity(src_e)), Some(Resolved::Entity(tgt_e))) => {
4682 merge_dependency_kind(&src_e.kind, &tgt_e.kind, spec.metadata.clone())
4683 }
4684 _ => spec.metadata.clone(),
4685 }
4686 } else {
4687 spec.metadata.clone()
4688 };
4689 validate_edge_metadata(spec.relation, metadata.as_ref())?;
4690 let now = chrono::Utc::now();
4691 Ok(Edge {
4692 id: LinkId::from(Uuid::new_v4()),
4693 namespace: ns_str.to_string(),
4694 source_id,
4695 target_id,
4696 relation: spec.relation,
4697 weight: spec.weight,
4698 created_at: now,
4699 updated_at: now,
4700 deleted_at: None,
4701 metadata,
4702 target_backend: None,
4703 })
4704 }
4705
4706 pub async fn link_many(
4723 &self,
4724 token: &NamespaceToken,
4725 specs: Vec<LinkSpec>,
4726 ) -> RuntimeResult<Vec<Edge>> {
4727 if specs.is_empty() {
4728 return Ok(vec![]);
4729 }
4730 let mut edges = Vec::with_capacity(specs.len());
4731 for spec in &specs {
4732 edges.push(self.build_edge(token, spec).await?);
4733 }
4734 let outcome = self
4743 .graph(token)?
4744 .upsert_edges_guarded(edges.clone())
4745 .await?;
4746 if let Some(refusal) = outcome.refused {
4747 return Err(RuntimeError::GuardedWriteFailed(GuardedWriteFailure {
4748 entry_index: Some(refusal.entry_index),
4749 missing_source: refusal
4750 .missing
4751 .source
4752 .then_some(edges[refusal.entry_index].source_id),
4753 missing_target: refusal
4754 .missing
4755 .target
4756 .then_some(edges[refusal.entry_index].target_id),
4757 }));
4758 }
4759 if outcome.summary.affected != edges.len() as u64 {
4760 return Err(RuntimeError::NotFound(format!(
4761 "link_many: one or more edge endpoints no longer exist at write time: {}",
4762 outcome.summary.first_error
4763 )));
4764 }
4765
4766 let mut persisted = Vec::with_capacity(edges.len());
4769 for edge in &edges {
4770 let row = self
4771 .list_edges(
4772 token,
4773 crate::curation::EdgeListFilter {
4774 source_id: Some(edge.source_id),
4775 target_id: Some(edge.target_id),
4776 relations: vec![edge.relation],
4777 ..Default::default()
4778 },
4779 1,
4780 0,
4781 )
4782 .await?
4783 .into_iter()
4784 .next()
4785 .ok_or_else(|| {
4786 crate::RuntimeError::Internal(format!(
4787 "upsert_edges succeeded but natural-key lookup for ({}, {}, {}) returned nothing",
4788 edge.source_id, edge.target_id, edge.relation.as_str()
4789 ))
4790 })?;
4791 persisted.push(row);
4792 }
4793 Ok(persisted)
4794 }
4795
4796 pub async fn create_many(
4812 &self,
4813 token: &NamespaceToken,
4814 specs: Vec<EntityCreateSpec>,
4815 ) -> RuntimeResult<Vec<Entity>> {
4816 if specs.is_empty() {
4817 return Ok(vec![]);
4818 }
4819 let ns = token.namespace().as_str();
4820
4821 let mut entities = Vec::with_capacity(specs.len());
4825 for spec in &specs {
4826 self.validate_entity_kind(&spec.kind)?;
4827 let validated_type =
4832 self.validate_entity_type_for_kind(&spec.kind, spec.entity_type.as_deref())?;
4833 if spec.name.trim().is_empty() {
4834 return Err(RuntimeError::InvalidInput("name must not be empty".into()));
4835 }
4836 crate::secret_gate::check(&spec.name)?;
4837 if let Some(d) = &spec.description {
4838 crate::secret_gate::check(d)?;
4839 }
4840 if let Some(ref p) = spec.properties {
4841 crate::secret_gate::check_json(p)?;
4842 }
4843 crate::secret_gate::check_tags(&spec.tags)?;
4844
4845 let mut entity =
4846 Entity::new(ns, &spec.kind, &spec.name).with_entity_type(validated_type.as_deref());
4847 if let Some(d) = &spec.description {
4848 entity = entity.with_description(d);
4849 }
4850 if let Some(p) = spec.properties.clone() {
4851 entity = entity.with_properties(p);
4852 }
4853 if !spec.tags.is_empty() {
4854 entity = entity.with_tags(spec.tags.clone());
4855 }
4856 entities.push(entity);
4857 }
4858
4859 let entity_summary = self
4870 .entities(token)?
4871 .upsert_entities(entities.clone())
4872 .await?;
4873
4874 if entity_summary.failed > 0 {
4875 if let Ok(store) = self.entities(token) {
4877 for entity in &entities {
4878 if let Err(ce) = store.delete_entity(entity.id, DeleteMode::Hard).await {
4879 tracing::error!(
4880 error = %ce,
4881 id = %entity.id,
4882 "create_many: failed to roll back entity row after partial entity write"
4883 );
4884 }
4885 }
4886 }
4887 return Err(RuntimeError::Internal(format!(
4888 "create_many: {}/{} entity rows failed to write (first error: {}); \
4889 all rows rolled back",
4890 entity_summary.failed, entity_summary.attempted, entity_summary.first_error
4891 )));
4892 }
4893
4894 let docs: Vec<_> = entities.iter().map(entity_fts_document).collect();
4907
4908 #[cfg(any(test, feature = "fault-injection"))]
4909 let fts_many_inject = {
4910 let mut g = FTS_FAIL_MANY_NS.lock().unwrap();
4911 if g.as_deref() == Some(ns) {
4912 *g = None;
4913 true
4914 } else {
4915 false
4916 }
4917 };
4918 #[cfg(not(any(test, feature = "fault-injection")))]
4919 let fts_many_inject = false;
4920
4921 #[cfg(any(test, feature = "fault-injection"))]
4924 let fts_many_inject_partial = {
4925 let mut g = FTS_FAIL_MANY_PARTIAL_NS.lock().unwrap();
4926 if g.as_deref() == Some(ns) {
4927 *g = None;
4928 true
4929 } else {
4930 false
4931 }
4932 };
4933 #[cfg(not(any(test, feature = "fault-injection")))]
4934 let fts_many_inject_partial = false;
4935
4936 let fts_summary_result: RuntimeResult<BatchWriteSummary> = if fts_many_inject {
4937 Err(RuntimeError::Internal(
4938 "injected FTS failure for create_many".to_string(),
4939 ))
4940 } else if fts_many_inject_partial {
4941 Ok(BatchWriteSummary {
4942 attempted: docs.len() as u64,
4943 affected: docs.len().saturating_sub(1) as u64,
4944 failed: 1,
4945 first_error: "injected partial FTS failure for create_many".to_string(),
4946 })
4947 } else {
4948 match self.text(token) {
4949 Ok(fts) => fts.upsert_documents(docs).await.map_err(RuntimeError::from),
4950 Err(e) => Err(e),
4951 }
4952 };
4953
4954 let fts_err: Option<RuntimeError> = match fts_summary_result {
4955 Err(e) => Some(e),
4956 Ok(summary) if summary.failed > 0 => Some(RuntimeError::Internal(format!(
4957 "create_many: {}/{} FTS rows failed to index (first error: {}); \
4958 all rows rolled back",
4959 summary.failed, summary.attempted, summary.first_error
4960 ))),
4961 Ok(_) => None,
4962 };
4963
4964 if let Some(e) = fts_err {
4965 if let Ok(fts) = self.text(token) {
4967 for entity in &entities {
4968 if let Err(ce) = fts.delete_document(ns, entity.id).await {
4969 tracing::error!(
4970 error = %ce,
4971 id = %entity.id,
4972 "create_many: failed to remove FTS doc during rollback"
4973 );
4974 }
4975 }
4976 }
4977 if let Ok(store) = self.entities(token) {
4978 for entity in &entities {
4979 if let Err(ce) = store.delete_entity(entity.id, DeleteMode::Hard).await {
4980 tracing::error!(
4981 error = %ce,
4982 id = %entity.id,
4983 "create_many: failed to roll back entity row after FTS failure"
4984 );
4985 }
4986 }
4987 }
4988 return Err(e);
4989 }
4990
4991 Ok(entities)
4993 }
4994}
4995
4996#[derive(Clone, Debug)]
4999pub struct LinkSpec {
5000 pub namespace: Option<String>,
5001 pub source_id: Uuid,
5002 pub target_id: Uuid,
5003 pub relation: EdgeRelation,
5004 pub weight: f64,
5005 pub metadata: Option<serde_json::Value>,
5006}
5007
5008#[derive(Clone, Debug)]
5016pub struct EntityCreateSpec {
5017 pub kind: String,
5018 pub entity_type: Option<String>,
5019 pub name: String,
5020 pub description: Option<String>,
5021 pub properties: Option<serde_json::Value>,
5022 pub tags: Vec<String>,
5023}
5024
5025#[cfg(test)]
5031mod tests {
5032 use super::*;
5033 use crate::curation::EdgeListFilter;
5034 use crate::embedder_registry::EmbedderProvider;
5035 use crate::error::RuntimeError;
5036 use crate::runtime::{KhiveRuntime, NamespaceToken};
5037 use crate::{ActorRef, Namespace};
5038 use async_trait::async_trait;
5039 use khive_storage::types::PathNode;
5040 use lattice_embed::{EmbedError, EmbeddingModel, EmbeddingService};
5041 use std::sync::atomic::{AtomicUsize, Ordering};
5042 use std::sync::Arc;
5043
5044 fn rt() -> KhiveRuntime {
5045 KhiveRuntime::memory().unwrap()
5046 }
5047
5048 struct ConstVecService {
5056 dims: usize,
5057 }
5058
5059 #[async_trait]
5060 impl EmbeddingService for ConstVecService {
5061 async fn embed(
5062 &self,
5063 texts: &[String],
5064 _model: EmbeddingModel,
5065 ) -> std::result::Result<Vec<Vec<f32>>, EmbedError> {
5066 Ok(texts.iter().map(|_| vec![1.0_f32; self.dims]).collect())
5067 }
5068
5069 fn supports_model(&self, _model: EmbeddingModel) -> bool {
5070 true
5071 }
5072
5073 fn name(&self) -> &'static str {
5074 "const-vec"
5075 }
5076 }
5077
5078 struct ConstVecProvider {
5079 provider_name: String,
5080 dims: usize,
5081 pub build_count: Arc<AtomicUsize>,
5082 }
5083
5084 impl ConstVecProvider {
5085 fn new(name: &str, dims: usize) -> (Self, Arc<AtomicUsize>) {
5086 let counter = Arc::new(AtomicUsize::new(0));
5087 let provider = Self {
5088 provider_name: name.to_owned(),
5089 dims,
5090 build_count: Arc::clone(&counter),
5091 };
5092 (provider, counter)
5093 }
5094 }
5095
5096 #[async_trait]
5097 impl EmbedderProvider for ConstVecProvider {
5098 fn name(&self) -> &str {
5099 &self.provider_name
5100 }
5101
5102 fn dimensions(&self) -> usize {
5103 self.dims
5104 }
5105
5106 async fn build(&self) -> crate::error::RuntimeResult<Arc<dyn EmbeddingService>> {
5107 self.build_count.fetch_add(1, Ordering::SeqCst);
5108 Ok(Arc::new(ConstVecService { dims: self.dims }))
5109 }
5110 }
5111
5112 #[tokio::test]
5117 async fn custom_embedder_only_runtime_fanout_stores_vector() {
5118 const MODEL_NAME: &str = "test-custom-encoder";
5119 const DIMS: usize = 8;
5120
5121 let rt = KhiveRuntime::memory().unwrap();
5123
5124 let (provider, _counter) = ConstVecProvider::new(MODEL_NAME, DIMS);
5126 rt.register_embedder(provider);
5127
5128 assert!(rt.config().embedding_model.is_none());
5130 assert_eq!(rt.registered_embedding_model_names(), vec![MODEL_NAME]);
5131
5132 let tok = NamespaceToken::local();
5133
5134 let note = rt
5136 .create_note(
5137 &tok,
5138 "memory",
5139 None,
5140 "custom embedder integration test content",
5141 Some(0.7),
5142 None,
5143 vec![],
5144 )
5145 .await
5146 .expect("create_note with custom-only embedder must succeed");
5147
5148 use khive_storage::types::VectorSearchRequest;
5150 let query_vec = vec![1.0_f32; DIMS];
5151 let hits = rt
5152 .vectors_for_model(&tok, MODEL_NAME)
5153 .expect("vector store for custom model must be accessible")
5154 .search(VectorSearchRequest {
5155 query_vectors: vec![query_vec],
5156 top_k: 5,
5157 namespace: Some(tok.namespace().as_str().to_string()),
5158 kind: Some(khive_types::SubstrateKind::Note),
5159 embedding_model: Some(MODEL_NAME.to_string()),
5160 filter: None,
5161 backend_hints: None,
5162 })
5163 .await
5164 .expect("vector search succeeds");
5165
5166 assert!(
5167 hits.iter().any(|h| h.subject_id == note.id),
5168 "custom embedder must have written a vector for note {}: hits={hits:?}",
5169 note.id
5170 );
5171 }
5172
5173 #[tokio::test]
5178 async fn embed_with_model_accepts_custom_provider_name() {
5179 const MODEL_NAME: &str = "my-custom-enc";
5180 const DIMS: usize = 4;
5181
5182 let rt = KhiveRuntime::memory().unwrap();
5183 let (provider, _counter) = ConstVecProvider::new(MODEL_NAME, DIMS);
5184 rt.register_embedder(provider);
5185
5186 let result = rt
5187 .embed_with_model(MODEL_NAME, "hello world")
5188 .await
5189 .expect("embed_with_model must accept custom provider names");
5190
5191 assert_eq!(
5192 result.len(),
5193 DIMS,
5194 "embedding dimension must match provider"
5195 );
5196 assert!(
5197 result.iter().all(|&v| (v - 1.0_f32).abs() < 1e-6),
5198 "ConstVecService must produce all-ones vector; got: {result:?}"
5199 );
5200 }
5201
5202 #[tokio::test]
5205 async fn embed_with_model_rejects_unregistered_name() {
5206 let rt = KhiveRuntime::memory().unwrap();
5207 let result = rt.embed_with_model("nonexistent-model", "hello").await;
5208 assert!(
5209 matches!(result.unwrap_err(), RuntimeError::UnknownModel(ref n) if n == "nonexistent-model"),
5210 "unregistered model name must return UnknownModel"
5211 );
5212 }
5213
5214 #[tokio::test]
5221 async fn no_embeddings_config_registers_zero_embedders() {
5222 let config = crate::config::RuntimeConfig {
5223 db_path: None,
5224 packs: vec!["kg".to_string()],
5225 ..crate::config::RuntimeConfig::no_embeddings()
5226 };
5227 let rt = KhiveRuntime::new(config).expect("runtime construction must succeed");
5228
5229 assert!(rt.config().embedding_model.is_none());
5230 assert!(
5231 rt.registered_embedding_model_names().is_empty(),
5232 "no_embeddings() runtime must register zero embedders"
5233 );
5234 }
5235
5236 #[tokio::test]
5237 async fn no_embeddings_runtime_create_note_succeeds_without_model_fanout() {
5238 let config = crate::config::RuntimeConfig {
5239 db_path: None,
5240 packs: vec!["kg".to_string()],
5241 ..crate::config::RuntimeConfig::no_embeddings()
5242 };
5243 let rt = KhiveRuntime::new(config).expect("runtime construction must succeed");
5244 let tok = NamespaceToken::local();
5245
5246 let note = rt
5250 .create_note(
5251 &tok,
5252 "memory",
5253 None,
5254 "issue-396 regression: model-less remember must succeed",
5255 Some(0.7),
5256 None,
5257 vec![],
5258 )
5259 .await
5260 .expect("create_note must succeed with zero registered embedders");
5261
5262 assert_eq!(
5263 note.content,
5264 "issue-396 regression: model-less remember must succeed"
5265 );
5266 }
5267
5268 #[tokio::test]
5269 async fn update_edge_changes_weight() {
5270 let rt = rt();
5271 let tok = NamespaceToken::local();
5272 let a = rt
5273 .create_entity(&tok, "concept", None, "A", None, None, vec![])
5274 .await
5275 .unwrap();
5276 let b = rt
5277 .create_entity(&tok, "concept", None, "B", None, None, vec![])
5278 .await
5279 .unwrap();
5280 let edge = rt
5281 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5282 .await
5283 .unwrap();
5284 let edge_id: Uuid = edge.id.into();
5285
5286 let updated = rt
5287 .update_edge(
5288 &tok,
5289 edge_id,
5290 crate::curation::EdgePatch {
5291 weight: Some(0.5),
5292 ..Default::default()
5293 },
5294 )
5295 .await
5296 .unwrap();
5297 assert!((updated.weight - 0.5).abs() < 0.001);
5298 }
5299
5300 #[tokio::test]
5310 async fn update_edge_symmetric_relation_stores_microsecond_updated_at() {
5311 let rt = rt();
5312 let tok = NamespaceToken::local();
5313 let a = rt
5314 .create_entity(&tok, "concept", None, "A", None, None, vec![])
5315 .await
5316 .unwrap();
5317 let b = rt
5318 .create_entity(&tok, "concept", None, "B", None, None, vec![])
5319 .await
5320 .unwrap();
5321 let edge = rt
5322 .link(&tok, a.id, b.id, EdgeRelation::CompetesWith, 1.0, None)
5323 .await
5324 .unwrap();
5325 let edge_id: Uuid = edge.id.into();
5326
5327 let before = chrono::Utc::now();
5328 let updated = rt
5329 .update_edge(
5330 &tok,
5331 edge_id,
5332 crate::curation::EdgePatch {
5333 weight: Some(0.5),
5334 ..Default::default()
5335 },
5336 )
5337 .await
5338 .unwrap();
5339
5340 let drift = (updated.updated_at - before).num_seconds().abs();
5341 assert!(
5342 drift < 60,
5343 "updated_at must round-trip as a recent timestamp (micros, not \
5344 seconds); got {:?}, expected within 60s of {:?}",
5345 updated.updated_at,
5346 before
5347 );
5348 }
5349
5350 #[tokio::test]
5351 async fn update_edge_changes_relation() {
5352 let rt = rt();
5353 let tok = NamespaceToken::local();
5354 let a = rt
5355 .create_entity(&tok, "concept", None, "A", None, None, vec![])
5356 .await
5357 .unwrap();
5358 let b = rt
5359 .create_entity(&tok, "concept", None, "B", None, None, vec![])
5360 .await
5361 .unwrap();
5362 let edge = rt
5363 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5364 .await
5365 .unwrap();
5366 let edge_id: Uuid = edge.id.into();
5367
5368 let updated = rt
5369 .update_edge(
5370 &tok,
5371 edge_id,
5372 crate::curation::EdgePatch {
5373 relation: Some(EdgeRelation::VariantOf),
5374 ..Default::default()
5375 },
5376 )
5377 .await
5378 .unwrap();
5379 assert_eq!(updated.relation, EdgeRelation::VariantOf);
5380 }
5381
5382 #[tokio::test]
5387 async fn update_edge_annotates_note_to_entity_set_supersedes_returns_invalid_input() {
5388 let rt = rt();
5389 let tok = NamespaceToken::local();
5390 let note = rt
5391 .create_note(&tok, "observation", None, "a note", Some(0.5), None, vec![])
5392 .await
5393 .unwrap();
5394 let entity = rt
5395 .create_entity(&tok, "concept", None, "E", None, None, vec![])
5396 .await
5397 .unwrap();
5398 let edge = rt
5400 .link(&tok, note.id, entity.id, EdgeRelation::Annotates, 1.0, None)
5401 .await
5402 .unwrap();
5403 let edge_id: Uuid = edge.id.into();
5404
5405 let result = rt
5407 .update_edge(
5408 &tok,
5409 edge_id,
5410 crate::curation::EdgePatch {
5411 relation: Some(EdgeRelation::Supersedes),
5412 ..Default::default()
5413 },
5414 )
5415 .await;
5416 assert!(
5417 matches!(result, Err(RuntimeError::InvalidInput(_))),
5418 "update to Supersedes on note→entity edge must return InvalidInput, got {result:?}"
5419 );
5420
5421 let fetched = rt.get_edge(&tok, edge_id).await.unwrap().unwrap();
5423 assert_eq!(
5424 fetched.relation,
5425 EdgeRelation::Annotates,
5426 "edge relation must be unchanged after failed update"
5427 );
5428 }
5429
5430 #[tokio::test]
5433 async fn update_edge_entity_to_entity_set_annotates_returns_invalid_input() {
5434 let rt = rt();
5435 let tok = NamespaceToken::local();
5436 let a = rt
5437 .create_entity(&tok, "concept", None, "A", None, None, vec![])
5438 .await
5439 .unwrap();
5440 let b = rt
5441 .create_entity(&tok, "concept", None, "B", None, None, vec![])
5442 .await
5443 .unwrap();
5444 let edge = rt
5445 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5446 .await
5447 .unwrap();
5448 let edge_id: Uuid = edge.id.into();
5449
5450 let result = rt
5451 .update_edge(
5452 &tok,
5453 edge_id,
5454 crate::curation::EdgePatch {
5455 relation: Some(EdgeRelation::Annotates),
5456 ..Default::default()
5457 },
5458 )
5459 .await;
5460 assert!(
5461 matches!(result, Err(RuntimeError::InvalidInput(_))),
5462 "update to Annotates on entity→entity edge must return InvalidInput, got {result:?}"
5463 );
5464 }
5465
5466 #[tokio::test]
5469 async fn update_edge_entity_to_entity_set_supersedes_succeeds() {
5470 let rt = rt();
5471 let tok = NamespaceToken::local();
5472 let a = rt
5473 .create_entity(&tok, "concept", None, "A", None, None, vec![])
5474 .await
5475 .unwrap();
5476 let b = rt
5477 .create_entity(&tok, "concept", None, "B", None, None, vec![])
5478 .await
5479 .unwrap();
5480 let edge = rt
5481 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5482 .await
5483 .unwrap();
5484 let edge_id: Uuid = edge.id.into();
5485
5486 let updated = rt
5487 .update_edge(
5488 &tok,
5489 edge_id,
5490 crate::curation::EdgePatch {
5491 relation: Some(EdgeRelation::Supersedes),
5492 ..Default::default()
5493 },
5494 )
5495 .await
5496 .unwrap();
5497 assert_eq!(updated.relation, EdgeRelation::Supersedes);
5498
5499 let fetched = rt.get_edge(&tok, edge_id).await.unwrap().unwrap();
5501 assert_eq!(fetched.relation, EdgeRelation::Supersedes);
5502 }
5503
5504 #[tokio::test]
5506 async fn update_edge_weight_only_skips_validation() {
5507 let rt = rt();
5508 let tok = NamespaceToken::local();
5509 let a = rt
5510 .create_entity(&tok, "concept", None, "A", None, None, vec![])
5511 .await
5512 .unwrap();
5513 let b = rt
5514 .create_entity(&tok, "concept", None, "B", None, None, vec![])
5515 .await
5516 .unwrap();
5517 let edge = rt
5518 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5519 .await
5520 .unwrap();
5521 let edge_id: Uuid = edge.id.into();
5522
5523 let updated = rt
5524 .update_edge(
5525 &tok,
5526 edge_id,
5527 crate::curation::EdgePatch {
5528 weight: Some(0.3),
5529 ..Default::default()
5530 },
5531 )
5532 .await
5533 .unwrap();
5534 assert_eq!(updated.relation, EdgeRelation::Extends);
5535 assert!((updated.weight - 0.3).abs() < 0.001);
5536 }
5537
5538 #[tokio::test]
5540 async fn update_edge_same_class_relation_change_succeeds() {
5541 let rt = rt();
5542 let tok = NamespaceToken::local();
5543 let a = rt
5544 .create_entity(&tok, "concept", None, "A", None, None, vec![])
5545 .await
5546 .unwrap();
5547 let b = rt
5548 .create_entity(&tok, "concept", None, "B", None, None, vec![])
5549 .await
5550 .unwrap();
5551 let edge = rt
5552 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5553 .await
5554 .unwrap();
5555 let edge_id: Uuid = edge.id.into();
5556
5557 let updated = rt
5558 .update_edge(
5559 &tok,
5560 edge_id,
5561 crate::curation::EdgePatch {
5562 relation: Some(EdgeRelation::VariantOf),
5563 ..Default::default()
5564 },
5565 )
5566 .await
5567 .unwrap();
5568 assert_eq!(updated.relation, EdgeRelation::VariantOf);
5569 }
5570
5571 #[tokio::test]
5572 async fn list_edges_filters_by_relation() {
5573 let rt = rt();
5574 let tok = NamespaceToken::local();
5575 let a = rt
5576 .create_entity(&tok, "concept", None, "A", None, None, vec![])
5577 .await
5578 .unwrap();
5579 let b = rt
5580 .create_entity(&tok, "concept", None, "B", None, None, vec![])
5581 .await
5582 .unwrap();
5583 let c = rt
5584 .create_entity(&tok, "concept", None, "C", None, None, vec![])
5585 .await
5586 .unwrap();
5587
5588 rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5589 .await
5590 .unwrap();
5591 rt.link(&tok, a.id, c.id, EdgeRelation::Enables, 1.0, None)
5592 .await
5593 .unwrap();
5594
5595 let filter = EdgeListFilter {
5596 relations: vec![EdgeRelation::Extends],
5597 ..Default::default()
5598 };
5599 let edges = rt.list_edges(&tok, filter, 100, 0).await.unwrap();
5600 assert_eq!(edges.len(), 1);
5601 assert_eq!(edges[0].relation, EdgeRelation::Extends);
5602 }
5603
5604 #[tokio::test]
5605 async fn list_edges_filters_by_source() {
5606 let rt = rt();
5607 let tok = NamespaceToken::local();
5608 let a = rt
5609 .create_entity(&tok, "concept", None, "A", None, None, vec![])
5610 .await
5611 .unwrap();
5612 let b = rt
5613 .create_entity(&tok, "concept", None, "B", None, None, vec![])
5614 .await
5615 .unwrap();
5616 let c = rt
5617 .create_entity(&tok, "concept", None, "C", None, None, vec![])
5618 .await
5619 .unwrap();
5620 let d = rt
5621 .create_entity(&tok, "concept", None, "D", None, None, vec![])
5622 .await
5623 .unwrap();
5624
5625 rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5626 .await
5627 .unwrap();
5628 rt.link(&tok, c.id, d.id, EdgeRelation::Extends, 1.0, None)
5629 .await
5630 .unwrap();
5631
5632 let filter = EdgeListFilter {
5633 source_id: Some(a.id),
5634 ..Default::default()
5635 };
5636 let edges = rt.list_edges(&tok, filter, 100, 0).await.unwrap();
5637 assert_eq!(edges.len(), 1);
5638 let src: Uuid = edges[0].source_id;
5639 assert_eq!(src, a.id);
5640 }
5641
5642 #[tokio::test]
5647 async fn list_edges_offset_pages_through_full_set() {
5648 let rt = rt();
5649 let tok = NamespaceToken::local();
5650 let a = rt
5651 .create_entity(&tok, "concept", None, "A", None, None, vec![])
5652 .await
5653 .unwrap();
5654 for i in 0..5 {
5655 let t = rt
5656 .create_entity(&tok, "concept", None, &format!("T{i}"), None, None, vec![])
5657 .await
5658 .unwrap();
5659 rt.link(&tok, a.id, t.id, EdgeRelation::Extends, 1.0, None)
5660 .await
5661 .unwrap();
5662 }
5663
5664 let filter = EdgeListFilter {
5665 source_id: Some(a.id),
5666 relations: vec![EdgeRelation::Extends],
5667 ..Default::default()
5668 };
5669
5670 let page0 = rt.list_edges(&tok, filter.clone(), 2, 0).await.unwrap();
5671 let page1 = rt.list_edges(&tok, filter.clone(), 2, 2).await.unwrap();
5672 let page2 = rt.list_edges(&tok, filter.clone(), 2, 4).await.unwrap();
5673 assert_eq!(page0.len(), 2);
5674 assert_eq!(page1.len(), 2);
5675 assert_eq!(page2.len(), 1);
5676
5677 let ids = |p: &[Edge]| p.iter().map(|e| Uuid::from(e.id)).collect::<Vec<_>>();
5678 assert_ne!(ids(&page0), ids(&page1), "page 2 must differ from page 1");
5679
5680 let mut all_ids: Vec<Uuid> = ids(&page0)
5681 .into_iter()
5682 .chain(ids(&page1))
5683 .chain(ids(&page2))
5684 .collect();
5685 all_ids.sort();
5686 all_ids.dedup();
5687 assert_eq!(all_ids.len(), 5, "pages must tile the full edge set");
5688
5689 let empty = rt.list_edges(&tok, filter.clone(), 2, 100).await.unwrap();
5690 assert!(
5691 empty.is_empty(),
5692 "offset past the end must return empty, not page 1"
5693 );
5694 }
5695
5696 #[tokio::test]
5700 async fn list_edges_after_keyset_tiles_full_set() {
5701 let rt = rt();
5702 let tok = NamespaceToken::local();
5703 let a = rt
5704 .create_entity(&tok, "concept", None, "A", None, None, vec![])
5705 .await
5706 .unwrap();
5707 for i in 0..5 {
5708 let t = rt
5709 .create_entity(&tok, "concept", None, &format!("K{i}"), None, None, vec![])
5710 .await
5711 .unwrap();
5712 rt.link(&tok, a.id, t.id, EdgeRelation::Extends, 1.0, None)
5713 .await
5714 .unwrap();
5715 }
5716
5717 let filter = EdgeListFilter {
5718 source_id: Some(a.id),
5719 relations: vec![EdgeRelation::Extends],
5720 ..Default::default()
5721 };
5722
5723 let mut seen = Vec::new();
5724 let mut cursor: Option<Uuid> = None;
5725 for _ in 0..20 {
5726 let (page, next) = rt
5727 .list_edges_after(&tok, filter.clone(), cursor, 2)
5728 .await
5729 .unwrap();
5730 if page.is_empty() {
5731 break;
5732 }
5733 seen.extend(page.iter().map(|e| Uuid::from(e.id)));
5734 if next.is_none() {
5735 break;
5736 }
5737 cursor = next;
5738 }
5739 seen.sort();
5740 seen.dedup();
5741 assert_eq!(seen.len(), 5, "keyset walk must tile the full edge set");
5742
5743 let (first_a, next_a) = rt
5749 .list_edges_after(&tok, filter.clone(), None, 2)
5750 .await
5751 .unwrap();
5752 let (first_b, next_b) = rt
5753 .list_edges_after(&tok, filter.clone(), None, 2)
5754 .await
5755 .unwrap();
5756 assert_eq!(
5757 first_a.iter().map(|e| e.id.0).collect::<Vec<_>>(),
5758 first_b.iter().map(|e| e.id.0).collect::<Vec<_>>(),
5759 );
5760 assert_eq!(next_a, next_b);
5761 }
5762
5763 #[tokio::test]
5764 async fn list_edges_after_single_namespace_exact_final_page_has_no_next_after() {
5765 let rt = rt();
5766 let tok = NamespaceToken::local();
5767 let a = rt
5768 .create_entity(&tok, "concept", None, "SingleCursorA", None, None, vec![])
5769 .await
5770 .unwrap();
5771 for i in 0..4 {
5772 let t = rt
5773 .create_entity(
5774 &tok,
5775 "concept",
5776 None,
5777 &format!("SingleCursorT{i}"),
5778 None,
5779 None,
5780 vec![],
5781 )
5782 .await
5783 .unwrap();
5784 rt.link(&tok, a.id, t.id, EdgeRelation::Extends, 1.0, None)
5785 .await
5786 .unwrap();
5787 }
5788
5789 let filter = EdgeListFilter {
5790 source_id: Some(a.id),
5791 relations: vec![EdgeRelation::Extends],
5792 ..Default::default()
5793 };
5794
5795 let (page1, next1) = rt
5796 .list_edges_after(&tok, filter.clone(), None, 2)
5797 .await
5798 .unwrap();
5799 assert_eq!(page1.len(), 2);
5800 let cursor = next1.expect("first page must report a cursor when two rows remain");
5801
5802 let (page2, next2) = rt
5803 .list_edges_after(&tok, filter, Some(cursor), 2)
5804 .await
5805 .unwrap();
5806 assert_eq!(page2.len(), 2);
5807 assert_eq!(
5808 next2, None,
5809 "an exact-size final single-namespace page must not report a cursor"
5810 );
5811 }
5812
5813 #[tokio::test]
5814 async fn list_edges_after_multi_namespace_exact_final_page_has_no_next_after() {
5815 let rt = rt();
5816 let ns_a = Namespace::parse("cursor-ns-a").unwrap();
5817 let ns_b = Namespace::parse("cursor-ns-b").unwrap();
5818 let tok_a = NamespaceToken::for_namespace(ns_a.clone());
5819 let tok_b = NamespaceToken::for_namespace(ns_b.clone());
5820 let visible = NamespaceToken::mint_with_visibility(ns_a, vec![ns_b], ActorRef::anonymous());
5821
5822 for (tok, prefix) in [(&tok_a, "A"), (&tok_b, "B")] {
5823 let source = rt
5824 .create_entity(
5825 tok,
5826 "concept",
5827 None,
5828 &format!("MultiCursor{prefix}Source"),
5829 None,
5830 None,
5831 vec![],
5832 )
5833 .await
5834 .unwrap();
5835 for i in 0..2 {
5836 let target = rt
5837 .create_entity(
5838 tok,
5839 "concept",
5840 None,
5841 &format!("MultiCursor{prefix}Target{i}"),
5842 None,
5843 None,
5844 vec![],
5845 )
5846 .await
5847 .unwrap();
5848 rt.link(tok, source.id, target.id, EdgeRelation::Extends, 1.0, None)
5849 .await
5850 .unwrap();
5851 }
5852 }
5853
5854 let filter = EdgeListFilter {
5855 relations: vec![EdgeRelation::Extends],
5856 ..Default::default()
5857 };
5858 let (page1, next1) = rt
5859 .list_edges_after(&visible, filter.clone(), None, 2)
5860 .await
5861 .unwrap();
5862 assert_eq!(page1.len(), 2);
5863 let cursor = next1.expect("first merged page must report a cursor when rows remain");
5864
5865 let (page2, next2) = rt
5866 .list_edges_after(&visible, filter, Some(cursor), 2)
5867 .await
5868 .unwrap();
5869 assert_eq!(page2.len(), 2);
5870 assert_eq!(
5871 next2, None,
5872 "an exact-size final multi-namespace page must not report a cursor"
5873 );
5874 }
5875
5876 #[tokio::test]
5879 async fn count_edges_by_relation_matches_fixtures() {
5880 let rt = rt();
5881 let tok = NamespaceToken::local();
5882 let a = rt
5883 .create_entity(&tok, "concept", None, "A", None, None, vec![])
5884 .await
5885 .unwrap();
5886 let b = rt
5887 .create_entity(&tok, "concept", None, "B", None, None, vec![])
5888 .await
5889 .unwrap();
5890 let c = rt
5891 .create_entity(&tok, "concept", None, "C", None, None, vec![])
5892 .await
5893 .unwrap();
5894
5895 rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5896 .await
5897 .unwrap();
5898 rt.link(&tok, a.id, c.id, EdgeRelation::Extends, 1.0, None)
5899 .await
5900 .unwrap();
5901 rt.link(&tok, b.id, c.id, EdgeRelation::Enables, 1.0, None)
5902 .await
5903 .unwrap();
5904
5905 let counts = rt.count_edges_by_relation(&tok).await.unwrap();
5906 assert_eq!(counts.get("extends").copied(), Some(2));
5907 assert_eq!(counts.get("enables").copied(), Some(1));
5908 }
5909
5910 #[tokio::test]
5911 async fn delete_edge_removes_from_storage() {
5912 let rt = rt();
5913 let tok = NamespaceToken::local();
5914 let a = rt
5915 .create_entity(&tok, "concept", None, "A", None, None, vec![])
5916 .await
5917 .unwrap();
5918 let b = rt
5919 .create_entity(&tok, "concept", None, "B", None, None, vec![])
5920 .await
5921 .unwrap();
5922 let edge = rt
5923 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5924 .await
5925 .unwrap();
5926 let edge_id: Uuid = edge.id.into();
5927
5928 let deleted = rt.delete_edge(&tok, edge_id, true).await.unwrap();
5929 assert!(deleted);
5930
5931 let fetched = rt.get_edge(&tok, edge_id).await.unwrap();
5932 assert!(fetched.is_none(), "edge should be gone after delete");
5933 }
5934
5935 #[tokio::test]
5936 async fn count_edges_matches_filter() {
5937 let rt = rt();
5938 let tok = NamespaceToken::local();
5939 let a = rt
5940 .create_entity(&tok, "concept", None, "A", None, None, vec![])
5941 .await
5942 .unwrap();
5943 let b = rt
5944 .create_entity(&tok, "concept", None, "B", None, None, vec![])
5945 .await
5946 .unwrap();
5947 let c = rt
5948 .create_entity(&tok, "concept", None, "C", None, None, vec![])
5949 .await
5950 .unwrap();
5951
5952 rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5953 .await
5954 .unwrap();
5955 rt.link(&tok, a.id, c.id, EdgeRelation::Enables, 1.0, None)
5956 .await
5957 .unwrap();
5958
5959 let all = rt
5960 .count_edges(&tok, EdgeListFilter::default())
5961 .await
5962 .unwrap();
5963 assert_eq!(all, 2);
5964
5965 let just_extends = rt
5966 .count_edges(
5967 &tok,
5968 EdgeListFilter {
5969 relations: vec![EdgeRelation::Extends],
5970 ..Default::default()
5971 },
5972 )
5973 .await
5974 .unwrap();
5975 assert_eq!(just_extends, 1);
5976 }
5977
5978 #[tokio::test]
5984 async fn edge_in_visible_namespace_reachable_as_graph_root() {
5985 let rt = rt();
5986 let ns_a = Namespace::parse("vis-edge-a").unwrap();
5987 let ns_b = Namespace::parse("vis-edge-b").unwrap();
5988
5989 let tok_b = NamespaceToken::for_namespace(ns_b.clone());
5991 let src = rt
5992 .create_entity(&tok_b, "concept", None, "SrcB", None, None, vec![])
5993 .await
5994 .unwrap();
5995 let tgt = rt
5996 .create_entity(&tok_b, "concept", None, "TgtB", None, None, vec![])
5997 .await
5998 .unwrap();
5999 let edge = rt
6000 .link(&tok_b, src.id, tgt.id, EdgeRelation::Extends, 1.0, None)
6001 .await
6002 .unwrap();
6003
6004 let tok_a_vis = rt
6007 .authorize_with_visibility(ns_a.clone(), vec![ns_b.clone()])
6008 .unwrap();
6009
6010 let got = rt.get_edge_visible(&tok_a_vis, edge.id.0).await.unwrap();
6012 assert!(
6013 got.is_some(),
6014 "edge in visible namespace must be retrievable via get_edge_visible"
6015 );
6016
6017 let neighbors = rt
6020 .neighbors(&tok_a_vis, src.id, Direction::Out, Some(16), None)
6021 .await
6022 .unwrap();
6023 assert!(
6024 neighbors.iter().any(|h| h.node_id == tgt.id),
6025 "neighbors of visible-ns node must include its visible-ns neighbor; got: {neighbors:?}"
6026 );
6027 }
6028
6029 #[tokio::test]
6033 async fn get_entity_cross_namespace_no_longer_denied() {
6034 let rt = rt();
6035 let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
6036 let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
6037 let entity = rt
6038 .create_entity(&ns_a, "concept", None, "Alpha", None, None, vec![])
6039 .await
6040 .unwrap();
6041
6042 let found = rt.get_entity(&ns_a, entity.id).await;
6044 assert!(found.is_ok(), "same-namespace get must succeed");
6045
6046 let cross = rt.get_entity(&ns_b, entity.id).await;
6048 assert!(
6049 cross.is_ok(),
6050 "cross-namespace get must succeed in shared-brain OSS (ADR-007 rule 2)"
6051 );
6052 assert_eq!(cross.unwrap().id, entity.id);
6053 }
6054
6055 #[tokio::test]
6056 async fn delete_entity_cross_namespace_no_longer_denied() {
6057 let rt = rt();
6058 let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
6059 let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
6060 let entity = rt
6061 .create_entity(&ns_a, "concept", None, "Beta", None, None, vec![])
6062 .await
6063 .unwrap();
6064
6065 let cross_ns_result = rt.delete_entity(&ns_b, entity.id, true).await;
6067 assert!(
6068 cross_ns_result.is_ok(),
6069 "cross-namespace delete must succeed in shared-brain OSS; got {:?}",
6070 cross_ns_result
6071 );
6072 assert!(cross_ns_result.unwrap(), "delete must return true");
6073
6074 let gone = rt.get_entity(&ns_a, entity.id).await;
6076 assert!(gone.is_err(), "entity must be gone after delete");
6077 }
6078
6079 #[tokio::test]
6082 async fn create_note_indexes_into_fts5() {
6083 let rt = rt();
6084 let tok = NamespaceToken::local();
6085 let note = rt
6086 .create_note(
6087 &tok,
6088 "observation",
6089 None,
6090 "FlashAttention reduces memory by using tiling",
6091 Some(0.8),
6092 None,
6093 vec![],
6094 )
6095 .await
6096 .unwrap();
6097
6098 let ns = tok.namespace().as_str().to_string();
6100 let hits = rt
6101 .text_for_notes(&tok)
6102 .unwrap()
6103 .search(khive_storage::types::TextSearchRequest {
6104 query: "FlashAttention".to_string(),
6105 mode: khive_storage::types::TextQueryMode::Plain,
6106 filter: Some(khive_storage::types::TextFilter {
6107 namespaces: vec![ns],
6108 ..Default::default()
6109 }),
6110 top_k: 10,
6111 snippet_chars: 100,
6112 })
6113 .await
6114 .unwrap();
6115
6116 assert!(
6117 hits.iter().any(|h| h.subject_id == note.id),
6118 "note should be indexed in FTS5 after create"
6119 );
6120 }
6121
6122 #[tokio::test]
6129 async fn search_notes_with_residual_fts5_char_now_sanitized() {
6130 let rt = rt();
6131 let tok = NamespaceToken::local();
6132 rt.create_note(
6133 &tok,
6134 "observation",
6135 None,
6136 "use foo@bar to chain calls",
6137 Some(0.5),
6138 None,
6139 vec![],
6140 )
6141 .await
6142 .unwrap();
6143
6144 let result = rt
6145 .search_notes(&tok, "foo@bar", None, 10, None, false, &[], None)
6146 .await;
6147
6148 let hits = result.unwrap_or_else(|e| {
6149 panic!("#916 search_notes must not fail on an '@'-bearing query, got: {e:?}")
6150 });
6151 assert!(
6152 !hits.is_empty(),
6153 "#916 '@'-bearing query must still find the seeded 'foo@bar' content via the \
6154 quoted-phrase alternative"
6155 );
6156 }
6157
6158 #[tokio::test]
6167 async fn search_notes_propagates_non_parser_fts_error() {
6168 let rt = rt();
6169 let tok = NamespaceToken::local();
6170 rt.create_note(
6171 &tok,
6172 "observation",
6173 None,
6174 "FlashAttention reduces memory by using tiling",
6175 Some(0.8),
6176 None,
6177 vec![],
6178 )
6179 .await
6180 .unwrap();
6181
6182 let ns = tok.namespace().as_str().to_string();
6183 arm_fts_search_fail(&ns);
6184
6185 let result = rt
6186 .search_notes(&tok, "FlashAttention", None, 10, None, false, &[], None)
6187 .await;
6188
6189 assert!(
6190 result.is_err(),
6191 "search_notes must propagate a non-parser FTS StorageError (Timeout) \
6192 as Err, not silently degrade it to an empty result, got: {:?}",
6193 result.ok()
6194 );
6195 assert!(
6196 matches!(
6197 result.unwrap_err(),
6198 RuntimeError::Storage(khive_storage::StorageError::Timeout { .. })
6199 ),
6200 "propagated error must be the injected StorageError::Timeout, unwrapped \
6201 through RuntimeError::Storage"
6202 );
6203 }
6204
6205 #[tokio::test]
6206 async fn create_note_with_properties() {
6207 let rt = rt();
6208 let tok = NamespaceToken::local();
6209 let props = serde_json::json!({"source": "arxiv:2205.14135"});
6210 let note = rt
6211 .create_note(
6212 &tok,
6213 "insight",
6214 None,
6215 "FlashAttention is IO-aware",
6216 Some(0.9),
6217 Some(props.clone()),
6218 vec![],
6219 )
6220 .await
6221 .unwrap();
6222
6223 assert_eq!(note.properties.as_ref().unwrap(), &props);
6224 }
6225
6226 #[tokio::test]
6227 async fn create_note_creates_annotates_edges() {
6228 let rt = rt();
6229 let tok = NamespaceToken::local();
6230 let entity = rt
6231 .create_entity(&tok, "concept", None, "FlashAttention", None, None, vec![])
6232 .await
6233 .unwrap();
6234
6235 let note = rt
6236 .create_note(
6237 &tok,
6238 "observation",
6239 None,
6240 "FlashAttention uses SRAM tiling for memory efficiency",
6241 Some(0.9),
6242 None,
6243 vec![entity.id],
6244 )
6245 .await
6246 .unwrap();
6247
6248 let out_neighbors = rt
6250 .neighbors(
6251 &tok,
6252 note.id,
6253 Direction::Out,
6254 None,
6255 Some(vec![EdgeRelation::Annotates]),
6256 )
6257 .await
6258 .unwrap();
6259 assert_eq!(out_neighbors.len(), 1);
6260 assert_eq!(out_neighbors[0].node_id, entity.id);
6261 assert_eq!(out_neighbors[0].relation, EdgeRelation::Annotates);
6262
6263 let in_neighbors = rt
6265 .neighbors(
6266 &tok,
6267 entity.id,
6268 Direction::In,
6269 None,
6270 Some(vec![EdgeRelation::Annotates]),
6271 )
6272 .await
6273 .unwrap();
6274 assert_eq!(in_neighbors.len(), 1);
6275 assert_eq!(in_neighbors[0].node_id, note.id);
6276 }
6277
6278 #[tokio::test]
6279 async fn neighbors_without_relation_filter_returns_all() {
6280 let rt = rt();
6281 let tok = NamespaceToken::local();
6282 let a = rt
6283 .create_entity(&tok, "concept", None, "A", None, None, vec![])
6284 .await
6285 .unwrap();
6286 let b = rt
6287 .create_entity(&tok, "concept", None, "B", None, None, vec![])
6288 .await
6289 .unwrap();
6290 let c = rt
6291 .create_entity(&tok, "concept", None, "C", None, None, vec![])
6292 .await
6293 .unwrap();
6294
6295 rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
6296 .await
6297 .unwrap();
6298 rt.link(&tok, a.id, c.id, EdgeRelation::Enables, 1.0, None)
6299 .await
6300 .unwrap();
6301
6302 let all = rt
6303 .neighbors(&tok, a.id, Direction::Out, None, None)
6304 .await
6305 .unwrap();
6306 assert_eq!(all.len(), 2);
6307 }
6308
6309 #[tokio::test]
6310 async fn neighbors_with_relation_filter_returns_subset() {
6311 let rt = rt();
6312 let tok = NamespaceToken::local();
6313 let a = rt
6314 .create_entity(&tok, "concept", None, "A", None, None, vec![])
6315 .await
6316 .unwrap();
6317 let b = rt
6318 .create_entity(&tok, "concept", None, "B", None, None, vec![])
6319 .await
6320 .unwrap();
6321 let c = rt
6322 .create_entity(&tok, "concept", None, "C", None, None, vec![])
6323 .await
6324 .unwrap();
6325
6326 rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
6327 .await
6328 .unwrap();
6329 rt.link(&tok, a.id, c.id, EdgeRelation::Enables, 1.0, None)
6330 .await
6331 .unwrap();
6332
6333 let filtered = rt
6334 .neighbors(
6335 &tok,
6336 a.id,
6337 Direction::Out,
6338 None,
6339 Some(vec![EdgeRelation::Extends]),
6340 )
6341 .await
6342 .unwrap();
6343 assert_eq!(filtered.len(), 1);
6344 assert_eq!(filtered[0].node_id, b.id);
6345 assert_eq!(filtered[0].relation, EdgeRelation::Extends);
6346 }
6347
6348 #[tokio::test]
6356 async fn neighbors_with_query_directed_preserves_self_loop_direction_parity() {
6357 let rt = rt();
6358 let tok = NamespaceToken::local();
6359 let centre = rt
6360 .create_entity(&tok, "concept", None, "Centre", None, None, vec![])
6361 .await
6362 .unwrap();
6363
6364 let now = chrono::Utc::now();
6365 rt.graph(&tok)
6366 .unwrap()
6367 .upsert_edge(Edge {
6368 id: LinkId::from(Uuid::new_v4()),
6369 namespace: "local".to_string(),
6370 source_id: centre.id,
6371 target_id: centre.id,
6372 relation: EdgeRelation::Extends,
6373 weight: 0.7,
6374 created_at: now,
6375 updated_at: now,
6376 deleted_at: None,
6377 metadata: None,
6378 target_backend: None,
6379 })
6380 .await
6381 .unwrap();
6382
6383 let directed = rt
6384 .neighbors_with_query_directed(
6385 &tok,
6386 centre.id,
6387 NeighborQuery {
6388 direction: Direction::Both,
6389 relations: None,
6390 limit: None,
6391 min_weight: None,
6392 },
6393 )
6394 .await
6395 .unwrap();
6396
6397 assert_eq!(
6398 directed.len(),
6399 2,
6400 "a self-loop edge must produce both an Out hit and an In hit, not one collapsed hit"
6401 );
6402 let directions: Vec<Direction> = directed.iter().map(|(_, d)| d.clone()).collect();
6403 assert!(
6404 directions.contains(&Direction::Out),
6405 "self-loop must retain its Out-tagged hit"
6406 );
6407 assert!(
6408 directions.contains(&Direction::In),
6409 "self-loop must retain its In-tagged hit"
6410 );
6411 }
6412
6413 #[tokio::test]
6414 async fn search_notes_returns_relevant_note() {
6415 let rt = rt();
6416 let tok = NamespaceToken::local();
6417 rt.create_note(
6418 &tok,
6419 "observation",
6420 None,
6421 "GQA reduces KV cache memory for large models",
6422 Some(0.8),
6423 None,
6424 vec![],
6425 )
6426 .await
6427 .unwrap();
6428
6429 let results = rt
6430 .search_notes(&tok, "GQA KV cache", None, 10, None, false, &[], None)
6431 .await
6432 .unwrap();
6433
6434 assert!(!results.is_empty(), "search should return the indexed note");
6435 let hit = &results[0];
6436 assert!(
6437 hit.title.is_some(),
6438 "note hit title should be populated (falls back to content)"
6439 );
6440 assert!(
6441 hit.snippet.is_some(),
6442 "note hit snippet should be populated"
6443 );
6444 }
6445
6446 #[tokio::test]
6447 async fn search_notes_excludes_soft_deleted() {
6448 let rt = rt();
6449 let tok = NamespaceToken::local();
6450 let note = rt
6451 .create_note(
6452 &tok,
6453 "observation",
6454 None,
6455 "RoPE positional encoding rotary embeddings",
6456 Some(0.7),
6457 None,
6458 vec![],
6459 )
6460 .await
6461 .unwrap();
6462
6463 rt.notes(&tok)
6465 .unwrap()
6466 .delete_note(note.id, DeleteMode::Soft)
6467 .await
6468 .unwrap();
6469
6470 let results = rt
6471 .search_notes(
6472 &tok,
6473 "RoPE rotary positional",
6474 None,
6475 10,
6476 None,
6477 false,
6478 &[],
6479 None,
6480 )
6481 .await
6482 .unwrap();
6483
6484 assert!(
6485 results.iter().all(|h| h.note_id != note.id),
6486 "soft-deleted note should be excluded from search"
6487 );
6488 }
6489
6490 #[tokio::test]
6504 async fn search_notes_tag_filter_pushed_before_truncation() {
6505 let rt = rt();
6506 let tok = NamespaceToken::local();
6507
6508 rt.create_note(
6510 &tok,
6511 "observation",
6512 None,
6513 "kappa lambda mu note decoy kappa lambda mu note decoy kappa lambda mu",
6514 Some(0.5),
6515 Some(serde_json::json!({"tags": ["other-note-tag"]})),
6516 vec![],
6517 )
6518 .await
6519 .unwrap();
6520
6521 let target = rt
6523 .create_note(
6524 &tok,
6525 "observation",
6526 None,
6527 "kappa lambda mu note target",
6528 Some(0.5),
6529 Some(serde_json::json!({"tags": ["note-target-tag"]})),
6530 vec![],
6531 )
6532 .await
6533 .unwrap();
6534
6535 let hits = rt
6538 .search_notes(
6539 &tok,
6540 "kappa lambda mu note",
6541 None,
6542 1,
6543 None,
6544 false,
6545 &["note-target-tag".to_string()],
6546 None,
6547 )
6548 .await
6549 .unwrap();
6550
6551 assert_eq!(
6552 hits.len(),
6553 1,
6554 "exactly one hit expected (tag-matching note)"
6555 );
6556 assert_eq!(
6557 hits[0].note_id, target.id,
6558 "tag-filtered note must be returned even when ranked below limit in raw fusion"
6559 );
6560 }
6561
6562 #[tokio::test]
6569 async fn search_notes_props_filter_pushed_before_truncation() {
6570 let rt = rt();
6571 let tok = NamespaceToken::local();
6572
6573 rt.create_note(
6574 &tok,
6575 "observation",
6576 None,
6577 "nu xi omicron note decoy nu xi omicron note decoy nu xi omicron",
6578 Some(0.5),
6579 Some(serde_json::json!({"source": "other"})),
6580 vec![],
6581 )
6582 .await
6583 .unwrap();
6584
6585 let target = rt
6586 .create_note(
6587 &tok,
6588 "observation",
6589 None,
6590 "nu xi omicron note target",
6591 Some(0.5),
6592 Some(serde_json::json!({"source": "target"})),
6593 vec![],
6594 )
6595 .await
6596 .unwrap();
6597
6598 let filter = serde_json::json!({"source": "target"});
6599 let hits = rt
6600 .search_notes(
6601 &tok,
6602 "nu xi omicron note",
6603 None,
6604 1,
6605 None,
6606 false,
6607 &[],
6608 Some(&filter),
6609 )
6610 .await
6611 .unwrap();
6612
6613 assert_eq!(
6614 hits.len(),
6615 1,
6616 "exactly one hit expected (properties-matching note)"
6617 );
6618 assert_eq!(
6619 hits[0].note_id, target.id,
6620 "properties-filtered note must be returned even when ranked below limit"
6621 );
6622 }
6623
6624 #[tokio::test]
6625 async fn resolve_returns_entity() {
6626 let rt = rt();
6627 let tok = NamespaceToken::local();
6628 let entity = rt
6629 .create_entity(&tok, "concept", None, "LoRA", None, None, vec![])
6630 .await
6631 .unwrap();
6632
6633 let resolved = rt.resolve(&tok, entity.id).await.unwrap();
6634 match resolved {
6635 Some(Resolved::Entity(e)) => assert_eq!(e.id, entity.id),
6636 other => panic!("expected Resolved::Entity, got {:?}", other),
6637 }
6638 }
6639
6640 #[tokio::test]
6641 async fn resolve_returns_note() {
6642 let rt = rt();
6643 let tok = NamespaceToken::local();
6644 let note = rt
6645 .create_note(
6646 &tok,
6647 "observation",
6648 None,
6649 "LoRA fine-tunes LLMs with low-rank adapters",
6650 Some(0.85),
6651 None,
6652 vec![],
6653 )
6654 .await
6655 .unwrap();
6656
6657 let resolved = rt.resolve(&tok, note.id).await.unwrap();
6658 match resolved {
6659 Some(Resolved::Note(n)) => assert_eq!(n.id, note.id),
6660 other => panic!("expected Resolved::Note, got {:?}", other),
6661 }
6662 }
6663
6664 #[tokio::test]
6665 async fn resolve_returns_none_for_unknown_uuid() {
6666 let rt = rt();
6667 let tok = NamespaceToken::local();
6668 let unknown = Uuid::new_v4();
6669 let resolved = rt.resolve(&tok, unknown).await.unwrap();
6670 assert!(resolved.is_none(), "unknown UUID should resolve to None");
6671 }
6672
6673 #[tokio::test]
6674 async fn resolve_prefix_finds_entity_in_own_namespace() {
6675 let rt = rt();
6676 let tok = NamespaceToken::local();
6677 let entity = rt
6678 .create_entity(&tok, "concept", None, "PrefixTest", None, None, vec![])
6679 .await
6680 .unwrap();
6681 let prefix = &entity.id.to_string()[..8];
6682
6683 let resolved = rt.resolve_prefix(&tok, prefix).await.unwrap();
6684 assert_eq!(resolved, Some(entity.id));
6685 }
6686
6687 #[test]
6688 fn hex_prefix_to_uuid_pattern_inserts_hyphens_at_canonical_boundaries() {
6689 let full = "aabbccdd112240008000000000000ab1";
6690 let cases: &[(usize, &str)] = &[
6691 (1, "a"),
6692 (7, "aabbccd"),
6693 (8, "aabbccdd"),
6694 (9, "aabbccdd-1"),
6695 (12, "aabbccdd-1122"),
6696 (13, "aabbccdd-1122-4"),
6697 (16, "aabbccdd-1122-4000"),
6698 (18, "aabbccdd-1122-4000-80"),
6699 (20, "aabbccdd-1122-4000-8000"),
6700 (23, "aabbccdd-1122-4000-8000-000"),
6701 (24, "aabbccdd-1122-4000-8000-0000"),
6702 (28, "aabbccdd-1122-4000-8000-00000000"),
6703 (31, "aabbccdd-1122-4000-8000-000000000ab"),
6704 ];
6705 for (len, expected) in cases {
6706 let input = &full[..*len];
6707 assert_eq!(
6708 hex_prefix_to_uuid_pattern(input),
6709 *expected,
6710 "len={len} input={input:?}"
6711 );
6712 }
6713 }
6714
6715 #[test]
6716 fn hex_prefix_to_uuid_pattern_full_32_char_matches_canonical_uuid() {
6717 let compact = "aabbccdd112240008000000000000ab1";
6718 let compact32 = &compact[..32];
6719 assert_eq!(
6720 hex_prefix_to_uuid_pattern(compact32),
6721 "aabbccdd-1122-4000-8000-000000000ab1"
6722 );
6723 }
6724
6725 #[test]
6731 fn hex_prefix_to_uuid_pattern_overlong_input_is_not_truncated() {
6732 let compact32 = "aabbccdd112240008000000000000ab1";
6733 let overlong = format!("{compact32}extrahex");
6734 let pattern = hex_prefix_to_uuid_pattern(&overlong);
6735 assert_eq!(
6736 pattern, "aabbccdd-1122-4000-8000-000000000ab1extrahex",
6737 "overlong input must keep its extra chars, not truncate to the valid UUID"
6738 );
6739 assert_ne!(
6740 pattern, "aabbccdd-1122-4000-8000-000000000ab1",
6741 "overlong pattern must not collapse to the canonical 36-char UUID form"
6742 );
6743 }
6744
6745 #[test]
6746 fn hex_prefix_to_uuid_pattern_passes_through_hyphenated_input() {
6747 let hyphenated = "aabbccdd-1122-4000-8000-000000000ab1";
6748 assert_eq!(hex_prefix_to_uuid_pattern(hyphenated), hyphenated);
6749
6750 let partial = "aabbccdd-11";
6751 assert_eq!(hex_prefix_to_uuid_pattern(partial), partial);
6752 }
6753
6754 #[tokio::test]
6755 async fn resolve_prefix_compact_9_to_31_char_matches() {
6756 let rt = rt();
6757 let tok = NamespaceToken::local();
6758 let entity = rt
6759 .create_entity(
6760 &tok,
6761 "concept",
6762 None,
6763 "CompactPrefixTest",
6764 None,
6765 None,
6766 vec![],
6767 )
6768 .await
6769 .unwrap();
6770 let compact = entity.id.simple().to_string();
6771
6772 for len in [9, 12, 16, 20, 24, 28, 31] {
6773 let prefix = &compact[..len];
6774 let resolved = rt.resolve_prefix(&tok, prefix).await.unwrap();
6775 assert_eq!(
6776 resolved,
6777 Some(entity.id),
6778 "compact prefix of len {len} should resolve"
6779 );
6780 }
6781 }
6782
6783 #[tokio::test]
6784 async fn resolve_prefix_compact_full_32_char_matches() {
6785 let rt = rt();
6786 let tok = NamespaceToken::local();
6787 let entity = rt
6788 .create_entity(&tok, "concept", None, "Full32Test", None, None, vec![])
6789 .await
6790 .unwrap();
6791 let compact = entity.id.simple().to_string();
6792 assert_eq!(compact.len(), 32);
6793
6794 let resolved = rt.resolve_prefix(&tok, &compact).await.unwrap();
6795 assert_eq!(resolved, Some(entity.id));
6796 }
6797
6798 #[tokio::test]
6801 async fn resolve_prefix_rejects_overlong_all_hex_input() {
6802 let rt = rt();
6803 let tok = NamespaceToken::local();
6804 let entity = rt
6805 .create_entity(&tok, "concept", None, "OverlongTest", None, None, vec![])
6806 .await
6807 .unwrap();
6808 let compact = entity.id.simple().to_string();
6809 assert_eq!(compact.len(), 32);
6810
6811 let overlong = format!("{compact}ab");
6812 let resolved = rt.resolve_prefix(&tok, &overlong).await.unwrap();
6813 assert_eq!(
6814 resolved, None,
6815 "a 32-char id plus extra hex chars must not resolve to the valid entity"
6816 );
6817 }
6818
6819 #[tokio::test]
6825 async fn resolve_prefix_rejects_like_wildcard_input() {
6826 let rt = rt();
6827 let tok = NamespaceToken::local();
6828 let entity = rt
6829 .create_entity(&tok, "concept", None, "WildcardTest", None, None, vec![])
6830 .await
6831 .unwrap();
6832 let compact = entity.id.simple().to_string();
6833 let wildcard_prefix = format!("{}%", &compact[..8]);
6837
6838 let resolved = rt.resolve_prefix(&tok, &wildcard_prefix).await.unwrap();
6839 assert_eq!(
6840 resolved, None,
6841 "prefix containing a LIKE wildcard must be rejected, not resolved"
6842 );
6843 }
6844
6845 #[tokio::test]
6846 async fn resolve_prefix_boundary_at_hyphen_positions() {
6847 let rt = rt();
6848 let tok = NamespaceToken::local();
6849 let entity = rt
6850 .create_entity(&tok, "concept", None, "BoundaryTest", None, None, vec![])
6851 .await
6852 .unwrap();
6853 let compact = entity.id.simple().to_string();
6854
6855 for len in [8, 12, 16, 20, 24] {
6856 let prefix = &compact[..len];
6857 let resolved = rt.resolve_prefix(&tok, prefix).await.unwrap();
6858 assert_eq!(
6859 resolved,
6860 Some(entity.id),
6861 "boundary prefix of len {len} should resolve"
6862 );
6863 }
6864 }
6865
6866 #[tokio::test]
6867 async fn resolve_prefix_ambiguous_still_detected_after_normalization() {
6868 use khive_storage::entity::Entity;
6869
6870 let rt = rt();
6871 let tok = NamespaceToken::local();
6872 let id_a = Uuid::parse_str("aabbccdd-1111-4000-8000-000000000001").unwrap();
6873 let id_b = Uuid::parse_str("aabbccdd-1111-4000-8000-000000000002").unwrap();
6874
6875 let mut entity_a = Entity::new("local", "concept", "AmbigCompactA");
6876 entity_a.id = id_a;
6877 let mut entity_b = Entity::new("local", "concept", "AmbigCompactB");
6878 entity_b.id = id_b;
6879
6880 let store = rt.entities(&tok).unwrap();
6881 store.upsert_entity(entity_a).await.unwrap();
6882 store.upsert_entity(entity_b).await.unwrap();
6883
6884 let shared_compact = &id_a.simple().to_string()[..20];
6886 let err = rt.resolve_prefix(&tok, shared_compact).await.unwrap_err();
6887 assert!(
6888 matches!(
6889 err,
6890 RuntimeError::AmbiguousPrefix { ref matches, .. } if matches.len() == 2
6891 ),
6892 "shared compact prefix must still return AmbiguousPrefix; got {err:?}"
6893 );
6894 }
6895
6896 #[tokio::test]
6897 async fn resolve_prefix_invisible_across_namespaces() {
6898 let rt = rt();
6899 let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
6900 let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
6901 let entity = rt
6902 .create_entity(&ns_a, "concept", None, "Invisible", None, None, vec![])
6903 .await
6904 .unwrap();
6905 let prefix = &entity.id.to_string()[..8];
6906
6907 let resolved = rt.resolve_prefix(&ns_b, prefix).await.unwrap();
6909 assert_eq!(resolved, None);
6910 }
6911
6912 #[tokio::test]
6913 async fn resolve_prefix_ambiguous_same_namespace() {
6914 use khive_storage::entity::Entity;
6915
6916 let rt = rt();
6917 let tok = NamespaceToken::local();
6918 let id_a = Uuid::parse_str("aabbccdd-1111-4000-8000-000000000001").unwrap();
6920 let id_b = Uuid::parse_str("aabbccdd-2222-4000-8000-000000000002").unwrap();
6921
6922 let mut entity_a = Entity::new("local", "concept", "AmbigA");
6923 entity_a.id = id_a;
6924 let mut entity_b = Entity::new("local", "concept", "AmbigB");
6925 entity_b.id = id_b;
6926
6927 let store = rt.entities(&tok).unwrap();
6928 store.upsert_entity(entity_a).await.unwrap();
6929 store.upsert_entity(entity_b).await.unwrap();
6930
6931 let err = rt.resolve_prefix(&tok, "aabbccdd").await.unwrap_err();
6932 assert!(
6933 matches!(
6934 err,
6935 RuntimeError::AmbiguousPrefix { ref prefix, ref matches }
6936 if prefix == "aabbccdd" && matches.len() == 2
6937 ),
6938 "shared 8-char prefix must return AmbiguousPrefix; got {err:?}"
6939 );
6940 }
6941
6942 #[tokio::test]
6947 async fn resolve_prefix_cross_table_duplicate_uuid_resolves_cleanly() {
6948 use khive_storage::entity::Entity;
6949
6950 let rt = rt();
6951 let tok = NamespaceToken::local();
6952 let shared_id = Uuid::parse_str("ccddeeff-1111-4000-8000-000000000001").unwrap();
6953
6954 let mut entity = Entity::new("local", "concept", "Nvk749Entity");
6955 entity.id = shared_id;
6956 rt.entities(&tok)
6957 .unwrap()
6958 .upsert_entity(entity)
6959 .await
6960 .unwrap();
6961
6962 let mut note = Note::new("local", "observation", "nvk749 note with the same id");
6963 note.id = shared_id;
6964 rt.notes(&tok).unwrap().upsert_note(note).await.unwrap();
6965
6966 let resolved = rt
6967 .resolve_prefix(&tok, "ccddeeff")
6968 .await
6969 .expect("#749: a UUID present in two tables must not be reported as ambiguous");
6970 assert_eq!(
6971 resolved,
6972 Some(shared_id),
6973 "#749: cross-table duplicate must resolve to the single shared UUID"
6974 );
6975 }
6976
6977 #[tokio::test]
6985 async fn resolve_prefix_early_exit_uses_deduped_match_count() {
6986 use khive_storage::entity::Entity;
6987
6988 let rt = rt();
6989 let tok = NamespaceToken::local();
6990 let shared_id = Uuid::parse_str("ddeeff11-2222-4000-8000-000000000002").unwrap();
6991
6992 let mut entity = Entity::new("local", "concept", "Nvk749bEntity");
6997 entity.id = shared_id;
6998 rt.entities(&tok)
6999 .unwrap()
7000 .upsert_entity(entity)
7001 .await
7002 .unwrap();
7003
7004 let mut note = Note::new("local", "observation", "nvk749b note with the same id");
7005 note.id = shared_id;
7006 rt.notes(&tok).unwrap().upsert_note(note).await.unwrap();
7007
7008 let resolved = rt
7009 .resolve_prefix(&tok, "ddeeff11")
7010 .await
7011 .expect("#749: deduped early-exit must not falsely report ambiguity");
7012 assert_eq!(resolved, Some(shared_id));
7013 }
7014
7015 #[tokio::test]
7022 async fn resolve_finds_event_by_full_uuid() {
7023 use khive_storage::Event;
7024 use khive_types::{EventKind, SubstrateKind};
7025
7026 let rt = rt();
7027 let tok = NamespaceToken::local();
7028 let ns = tok.namespace().as_str();
7029 let event = Event::new(
7030 ns,
7031 "test_verb",
7032 EventKind::Audit,
7033 SubstrateKind::Entity,
7034 "actor",
7035 );
7036 let event_id = event.id;
7037 rt.events(&tok).unwrap().append_event(event).await.unwrap();
7038
7039 let resolved = rt.resolve(&tok, event_id).await.unwrap();
7040 assert!(
7041 matches!(resolved, Some(Resolved::Event(_))),
7042 "event UUID must resolve to Resolved::Event, got {resolved:?}"
7043 );
7044 }
7045
7046 #[tokio::test]
7047 async fn resolve_prefix_finds_event() {
7048 use khive_storage::Event;
7049 use khive_types::{EventKind, SubstrateKind};
7050
7051 let rt = rt();
7052 let tok = NamespaceToken::local();
7053 let ns = tok.namespace().as_str();
7054 let event = Event::new(
7055 ns,
7056 "test_verb",
7057 EventKind::Audit,
7058 SubstrateKind::Entity,
7059 "actor",
7060 );
7061 let event_id = event.id;
7062 rt.events(&tok).unwrap().append_event(event).await.unwrap();
7063
7064 let prefix = &event_id.to_string()[..8];
7065 let resolved = rt.resolve_prefix(&tok, prefix).await.unwrap();
7066 assert_eq!(
7067 resolved,
7068 Some(event_id),
7069 "resolve_prefix must return event UUID for 8-char prefix"
7070 );
7071 }
7072
7073 #[tokio::test]
7076 async fn link_phantom_source_returns_not_found() {
7077 let rt = rt();
7078 let tok = NamespaceToken::local();
7079 let b = rt
7080 .create_entity(&tok, "concept", None, "B", None, None, vec![])
7081 .await
7082 .unwrap();
7083 let phantom = Uuid::new_v4();
7084
7085 let result = rt
7086 .link(&tok, phantom, b.id, EdgeRelation::Extends, 1.0, None)
7087 .await;
7088 match result {
7089 Err(RuntimeError::NotFound(msg)) => {
7090 assert!(
7091 msg.contains("source"),
7092 "error message must name 'source': {msg}"
7093 );
7094 }
7095 other => panic!("expected NotFound for phantom source, got {other:?}"),
7096 }
7097 }
7098
7099 #[tokio::test]
7100 async fn link_phantom_target_returns_not_found() {
7101 let rt = rt();
7102 let tok = NamespaceToken::local();
7103 let a = rt
7104 .create_entity(&tok, "concept", None, "A", None, None, vec![])
7105 .await
7106 .unwrap();
7107 let phantom = Uuid::new_v4();
7108
7109 let result = rt
7110 .link(&tok, a.id, phantom, EdgeRelation::Extends, 1.0, None)
7111 .await;
7112 match result {
7113 Err(RuntimeError::NotFound(msg)) => {
7114 assert!(
7115 msg.contains("target"),
7116 "error message must name 'target': {msg}"
7117 );
7118 }
7119 other => panic!("expected NotFound for phantom target, got {other:?}"),
7120 }
7121 }
7122
7123 #[tokio::test]
7124 async fn link_real_entities_succeeds() {
7125 let rt = rt();
7126 let tok = NamespaceToken::local();
7127 let a = rt
7128 .create_entity(&tok, "concept", None, "A", None, None, vec![])
7129 .await
7130 .unwrap();
7131 let b = rt
7132 .create_entity(&tok, "concept", None, "B", None, None, vec![])
7133 .await
7134 .unwrap();
7135
7136 let edge = rt
7137 .link(&tok, a.id, b.id, EdgeRelation::Extends, 0.8, None)
7138 .await
7139 .unwrap();
7140 assert_eq!(edge.source_id, a.id);
7141 assert_eq!(edge.target_id, b.id);
7142 assert_eq!(edge.relation, EdgeRelation::Extends);
7143 }
7144
7145 #[tokio::test]
7152 async fn link_write_time_guard_blocks_dangling_edge_after_target_vanishes() {
7153 let rt = rt();
7154 let tok = NamespaceToken::local();
7155 let a = rt
7156 .create_entity(&tok, "concept", None, "A", None, None, vec![])
7157 .await
7158 .unwrap();
7159 let x = rt
7160 .create_entity(&tok, "concept", None, "X", None, None, vec![])
7161 .await
7162 .unwrap();
7163
7164 rt.validate_edge_relation_endpoints(&tok, a.id, x.id, EdgeRelation::Extends)
7165 .await
7166 .expect("prepare-time validation must pass while X is live");
7167
7168 assert!(rt.delete_entity(&tok, x.id, true).await.unwrap());
7169
7170 let now = chrono::Utc::now();
7171 let edge = Edge {
7172 id: LinkId::from(Uuid::new_v4()),
7173 namespace: tok.namespace().as_str().to_string(),
7174 source_id: a.id,
7175 target_id: x.id,
7176 relation: EdgeRelation::Extends,
7177 weight: 1.0,
7178 created_at: now,
7179 updated_at: now,
7180 deleted_at: None,
7181 metadata: None,
7182 target_backend: None,
7183 };
7184 let outcome = rt
7185 .graph(&tok)
7186 .unwrap()
7187 .upsert_edge_guarded(edge)
7188 .await
7189 .unwrap();
7190 match outcome {
7191 khive_storage::GuardedWriteOutcome::Refused(missing) => {
7192 assert!(missing.target, "target must be reported missing");
7193 }
7194 other => panic!(
7195 "guarded write must refuse an edge whose target vanished before commit, got {other:?}"
7196 ),
7197 }
7198
7199 let edges = rt
7200 .list_edges(
7201 &tok,
7202 crate::curation::EdgeListFilter {
7203 source_id: Some(a.id),
7204 target_id: Some(x.id),
7205 relations: vec![EdgeRelation::Extends],
7206 ..Default::default()
7207 },
7208 10,
7209 0,
7210 )
7211 .await
7212 .unwrap();
7213 assert!(
7214 edges.is_empty(),
7215 "no dangling edge may be persisted after the guarded write refused it"
7216 );
7217 }
7218
7219 #[tokio::test]
7220 async fn link_many_writes_nothing_when_one_target_vanishes_before_write() {
7221 let rt = rt();
7222 let tok = NamespaceToken::local();
7223 let a = rt
7224 .create_entity(&tok, "concept", None, "A", None, None, vec![])
7225 .await
7226 .unwrap();
7227 let b = rt
7228 .create_entity(&tok, "concept", None, "B", None, None, vec![])
7229 .await
7230 .unwrap();
7231 let x = rt
7232 .create_entity(&tok, "concept", None, "X", None, None, vec![])
7233 .await
7234 .unwrap();
7235
7236 let specs = vec![
7237 LinkSpec {
7238 namespace: None,
7239 source_id: a.id,
7240 target_id: x.id,
7241 relation: EdgeRelation::Extends,
7242 weight: 1.0,
7243 metadata: None,
7244 },
7245 LinkSpec {
7246 namespace: None,
7247 source_id: a.id,
7248 target_id: b.id,
7249 relation: EdgeRelation::Extends,
7250 weight: 1.0,
7251 metadata: None,
7252 },
7253 ];
7254
7255 let mut edges = Vec::with_capacity(specs.len());
7257 for spec in &specs {
7258 edges.push(rt.build_edge(&tok, spec).await.unwrap());
7259 }
7260
7261 assert!(rt.delete_entity(&tok, x.id, true).await.unwrap());
7265
7266 let outcome = rt
7267 .graph(&tok)
7268 .unwrap()
7269 .upsert_edges_guarded(edges)
7270 .await
7271 .unwrap();
7272 assert_eq!(
7273 outcome.summary.affected, 0,
7274 "no edge from the batch may be persisted when any endpoint vanished"
7275 );
7276 assert!(
7277 outcome.refused.is_some(),
7278 "refused batch entry must be reported"
7279 );
7280
7281 let edges = rt
7282 .list_edges(
7283 &tok,
7284 crate::curation::EdgeListFilter {
7285 source_id: Some(a.id),
7286 relations: vec![EdgeRelation::Extends],
7287 ..Default::default()
7288 },
7289 10,
7290 0,
7291 )
7292 .await
7293 .unwrap();
7294 assert!(
7295 edges.is_empty(),
7296 "link_many's guarded batch must be all-or-nothing: the live A-B edge \
7297 must not have been persisted alongside the doomed A-X edge"
7298 );
7299 }
7300
7301 fn raw_edge(source_id: Uuid, target_id: Uuid, ns: &str) -> Edge {
7312 let now = chrono::Utc::now();
7313 Edge {
7314 id: LinkId::from(Uuid::new_v4()),
7315 namespace: ns.to_string(),
7316 source_id,
7317 target_id,
7318 relation: EdgeRelation::Extends,
7319 weight: 1.0,
7320 created_at: now,
7321 updated_at: now,
7322 deleted_at: None,
7323 metadata: None,
7324 target_backend: None,
7325 }
7326 }
7327
7328 async fn assert_no_edges_touch(rt: &KhiveRuntime, tok: &NamespaceToken, node_id: Uuid) {
7329 let as_source = rt
7330 .list_edges(
7331 tok,
7332 crate::curation::EdgeListFilter {
7333 source_id: Some(node_id),
7334 ..Default::default()
7335 },
7336 10,
7337 0,
7338 )
7339 .await
7340 .unwrap();
7341 let as_target = rt
7342 .list_edges(
7343 tok,
7344 crate::curation::EdgeListFilter {
7345 target_id: Some(node_id),
7346 ..Default::default()
7347 },
7348 10,
7349 0,
7350 )
7351 .await
7352 .unwrap();
7353 assert!(
7354 as_source.is_empty() && as_target.is_empty(),
7355 "no edge may reference hard-deleted node {node_id}: source-side={as_source:?} \
7356 target-side={as_target:?}"
7357 );
7358 }
7359
7360 #[tokio::test]
7361 async fn hard_delete_entity_purges_edge_written_before_delete() {
7362 let rt = rt();
7363 let tok = NamespaceToken::local();
7364 let a = rt
7365 .create_entity(&tok, "concept", None, "A", None, None, vec![])
7366 .await
7367 .unwrap();
7368 let x = rt
7369 .create_entity(&tok, "concept", None, "X", None, None, vec![])
7370 .await
7371 .unwrap();
7372
7373 let edge = raw_edge(a.id, x.id, tok.namespace().as_str());
7374 assert_eq!(
7375 rt.graph(&tok)
7376 .unwrap()
7377 .upsert_edge_guarded(edge)
7378 .await
7379 .unwrap(),
7380 khive_storage::GuardedWriteOutcome::Written
7381 );
7382
7383 assert!(rt.delete_entity(&tok, x.id, true).await.unwrap());
7384 assert_no_edges_touch(&rt, &tok, x.id).await;
7385 }
7386
7387 #[tokio::test]
7388 async fn hard_delete_entity_concurrent_with_guarded_write_never_leaves_dangling_edge() {
7389 let rt = std::sync::Arc::new(rt());
7390 let tok = NamespaceToken::local();
7391 let a = rt
7392 .create_entity(&tok, "concept", None, "A", None, None, vec![])
7393 .await
7394 .unwrap();
7395 let x = rt
7396 .create_entity(&tok, "concept", None, "X", None, None, vec![])
7397 .await
7398 .unwrap();
7399
7400 let delete_rt = std::sync::Arc::clone(&rt);
7401 let delete_tok = tok.clone();
7402 let delete_task =
7403 tokio::spawn(async move { delete_rt.delete_entity(&delete_tok, x.id, true).await });
7404
7405 let write_rt = std::sync::Arc::clone(&rt);
7406 let write_tok = tok.clone();
7407 let ns = tok.namespace().as_str().to_string();
7408 let write_task = tokio::spawn(async move {
7409 let edge = raw_edge(a.id, x.id, &ns);
7410 write_rt
7411 .graph(&write_tok)
7412 .unwrap()
7413 .upsert_edge_guarded(edge)
7414 .await
7415 });
7416
7417 let (deleted, _written) = tokio::join!(delete_task, write_task);
7418 deleted.unwrap().unwrap();
7419 assert_no_edges_touch(&rt, &tok, x.id).await;
7420 }
7421
7422 #[tokio::test]
7423 async fn hard_delete_note_purges_edge_written_before_delete() {
7424 let rt = rt();
7425 let tok = NamespaceToken::local();
7426 let a = rt
7427 .create_entity(&tok, "concept", None, "A", None, None, vec![])
7428 .await
7429 .unwrap();
7430 let n = rt
7431 .create_note(
7432 &tok,
7433 "observation",
7434 None,
7435 "note content",
7436 None,
7437 None,
7438 vec![],
7439 )
7440 .await
7441 .unwrap();
7442
7443 let edge = raw_edge(a.id, n.id, tok.namespace().as_str());
7444 assert_eq!(
7445 rt.graph(&tok)
7446 .unwrap()
7447 .upsert_edge_guarded(edge)
7448 .await
7449 .unwrap(),
7450 khive_storage::GuardedWriteOutcome::Written
7451 );
7452
7453 assert!(rt.delete_note(&tok, n.id, true).await.unwrap());
7454 assert_no_edges_touch(&rt, &tok, n.id).await;
7455 }
7456
7457 #[tokio::test]
7458 async fn hard_delete_note_concurrent_with_guarded_write_never_leaves_dangling_edge() {
7459 let rt = std::sync::Arc::new(rt());
7460 let tok = NamespaceToken::local();
7461 let a = rt
7462 .create_entity(&tok, "concept", None, "A", None, None, vec![])
7463 .await
7464 .unwrap();
7465 let n = rt
7466 .create_note(
7467 &tok,
7468 "observation",
7469 None,
7470 "note content",
7471 None,
7472 None,
7473 vec![],
7474 )
7475 .await
7476 .unwrap();
7477
7478 let delete_rt = std::sync::Arc::clone(&rt);
7479 let delete_tok = tok.clone();
7480 let note_id = n.id;
7481 let delete_task =
7482 tokio::spawn(async move { delete_rt.delete_note(&delete_tok, note_id, true).await });
7483
7484 let write_rt = std::sync::Arc::clone(&rt);
7485 let write_tok = tok.clone();
7486 let ns = tok.namespace().as_str().to_string();
7487 let write_task = tokio::spawn(async move {
7488 let edge = raw_edge(a.id, note_id, &ns);
7489 write_rt
7490 .graph(&write_tok)
7491 .unwrap()
7492 .upsert_edge_guarded(edge)
7493 .await
7494 });
7495
7496 let (deleted, _written) = tokio::join!(delete_task, write_task);
7497 deleted.unwrap().unwrap();
7498 assert_no_edges_touch(&rt, &tok, note_id).await;
7499 }
7500
7501 #[tokio::test]
7502 async fn hard_delete_edge_endpoint_purges_annotating_edge_written_before_delete() {
7503 let rt = rt();
7504 let tok = NamespaceToken::local();
7505 let a = rt
7506 .create_entity(&tok, "concept", None, "A", None, None, vec![])
7507 .await
7508 .unwrap();
7509 let b = rt
7510 .create_entity(&tok, "concept", None, "B", None, None, vec![])
7511 .await
7512 .unwrap();
7513 let n = rt
7514 .create_note(
7515 &tok,
7516 "observation",
7517 None,
7518 "note content",
7519 None,
7520 None,
7521 vec![],
7522 )
7523 .await
7524 .unwrap();
7525 let base_edge = rt
7526 .link(&tok, a.id, b.id, EdgeRelation::Extends, 0.8, None)
7527 .await
7528 .unwrap();
7529 let base_edge_id = Uuid::from(base_edge.id);
7530
7531 let annotating = raw_edge(n.id, base_edge_id, tok.namespace().as_str());
7534 assert_eq!(
7535 rt.graph(&tok)
7536 .unwrap()
7537 .upsert_edge_guarded(annotating)
7538 .await
7539 .unwrap(),
7540 khive_storage::GuardedWriteOutcome::Written
7541 );
7542
7543 assert!(rt.delete_edge(&tok, base_edge_id, true).await.unwrap());
7544 assert_no_edges_touch(&rt, &tok, base_edge_id).await;
7545 }
7546
7547 #[tokio::test]
7548 async fn hard_delete_edge_endpoint_concurrent_with_guarded_write_never_leaves_dangling_edge() {
7549 let rt = std::sync::Arc::new(rt());
7550 let tok = NamespaceToken::local();
7551 let a = rt
7552 .create_entity(&tok, "concept", None, "A", None, None, vec![])
7553 .await
7554 .unwrap();
7555 let b = rt
7556 .create_entity(&tok, "concept", None, "B", None, None, vec![])
7557 .await
7558 .unwrap();
7559 let n = rt
7560 .create_note(
7561 &tok,
7562 "observation",
7563 None,
7564 "note content",
7565 None,
7566 None,
7567 vec![],
7568 )
7569 .await
7570 .unwrap();
7571 let base_edge = rt
7572 .link(&tok, a.id, b.id, EdgeRelation::Extends, 0.8, None)
7573 .await
7574 .unwrap();
7575 let base_edge_id = Uuid::from(base_edge.id);
7576
7577 let delete_rt = std::sync::Arc::clone(&rt);
7578 let delete_tok = tok.clone();
7579 let delete_task =
7580 tokio::spawn(
7581 async move { delete_rt.delete_edge(&delete_tok, base_edge_id, true).await },
7582 );
7583
7584 let write_rt = std::sync::Arc::clone(&rt);
7585 let write_tok = tok.clone();
7586 let ns = tok.namespace().as_str().to_string();
7587 let write_task = tokio::spawn(async move {
7588 let edge = raw_edge(n.id, base_edge_id, &ns);
7589 write_rt
7590 .graph(&write_tok)
7591 .unwrap()
7592 .upsert_edge_guarded(edge)
7593 .await
7594 });
7595
7596 let (deleted, _written) = tokio::join!(delete_task, write_task);
7597 deleted.unwrap().unwrap();
7598 assert_no_edges_touch(&rt, &tok, base_edge_id).await;
7599 }
7600
7601 fn file_backed_runtime(
7619 dir: &tempfile::TempDir,
7620 name: &str,
7621 write_queue_enabled: bool,
7622 ) -> KhiveRuntime {
7623 let path = dir.path().join(name);
7624 if write_queue_enabled {
7625 std::env::set_var("KHIVE_WRITE_QUEUE", "1");
7626 } else {
7627 std::env::remove_var("KHIVE_WRITE_QUEUE");
7628 }
7629 let rt = KhiveRuntime::new(crate::config::RuntimeConfig {
7630 db_path: Some(path),
7631 packs: vec!["kg".to_string()],
7632 brain_profile: None,
7633 actor_id: None,
7634 ..crate::config::RuntimeConfig::no_embeddings()
7635 })
7636 .unwrap();
7637 std::env::remove_var("KHIVE_WRITE_QUEUE");
7638 rt
7639 }
7640
7641 async fn assert_guarded_write_committed_before_delete_is_swept(rt: &KhiveRuntime) {
7642 let tok = NamespaceToken::local();
7643 let a = rt
7644 .create_entity(&tok, "concept", None, "A", None, None, vec![])
7645 .await
7646 .unwrap();
7647 let x = rt
7648 .create_entity(&tok, "concept", None, "X", None, None, vec![])
7649 .await
7650 .unwrap();
7651
7652 let edge = raw_edge(a.id, x.id, tok.namespace().as_str());
7655 assert_eq!(
7656 rt.graph(&tok)
7657 .unwrap()
7658 .upsert_edge_guarded(edge)
7659 .await
7660 .unwrap(),
7661 khive_storage::GuardedWriteOutcome::Written,
7662 "write must succeed while both endpoints are still live"
7663 );
7664
7665 assert!(rt.delete_entity(&tok, x.id, true).await.unwrap());
7666 assert_no_edges_touch(rt, &tok, x.id).await;
7667 }
7668
7669 async fn assert_guarded_write_attempted_after_delete_is_refused(rt: &KhiveRuntime) {
7670 let tok = NamespaceToken::local();
7671 let a = rt
7672 .create_entity(&tok, "concept", None, "A", None, None, vec![])
7673 .await
7674 .unwrap();
7675 let x = rt
7676 .create_entity(&tok, "concept", None, "X", None, None, vec![])
7677 .await
7678 .unwrap();
7679
7680 assert!(rt.delete_entity(&tok, x.id, true).await.unwrap());
7683
7684 let edge = raw_edge(a.id, x.id, tok.namespace().as_str());
7685 let outcome = rt
7686 .graph(&tok)
7687 .unwrap()
7688 .upsert_edge_guarded(edge)
7689 .await
7690 .unwrap();
7691 match outcome {
7692 khive_storage::GuardedWriteOutcome::Refused(missing) => {
7693 assert!(
7694 missing.target,
7695 "target must be reported missing once the delete has committed"
7696 );
7697 assert!(!missing.source, "source was never deleted");
7698 }
7699 other => panic!(
7700 "guarded write attempted after the delete committed must be refused, got {other:?}"
7701 ),
7702 }
7703 assert_no_edges_touch(rt, &tok, x.id).await;
7704 }
7705
7706 #[tokio::test]
7707 #[serial_test::serial(khive_write_queue_env)]
7708 async fn guarded_write_before_delete_swept_file_backed_write_queue_off() {
7709 let dir = tempfile::tempdir().unwrap();
7710 let rt = file_backed_runtime(&dir, "guard_before_off.db", false);
7711 assert_guarded_write_committed_before_delete_is_swept(&rt).await;
7712 }
7713
7714 #[tokio::test]
7715 #[serial_test::serial(khive_write_queue_env)]
7716 async fn guarded_write_after_delete_refused_file_backed_write_queue_off() {
7717 let dir = tempfile::tempdir().unwrap();
7718 let rt = file_backed_runtime(&dir, "guard_after_off.db", false);
7719 assert_guarded_write_attempted_after_delete_is_refused(&rt).await;
7720 }
7721
7722 #[tokio::test]
7723 #[serial_test::serial(khive_write_queue_env)]
7724 async fn guarded_write_before_delete_swept_file_backed_write_queue_on() {
7725 let dir = tempfile::tempdir().unwrap();
7726 let rt = file_backed_runtime(&dir, "guard_before_on.db", true);
7727 assert_guarded_write_committed_before_delete_is_swept(&rt).await;
7728 }
7729
7730 #[tokio::test]
7731 #[serial_test::serial(khive_write_queue_env)]
7732 async fn guarded_write_after_delete_refused_file_backed_write_queue_on() {
7733 let dir = tempfile::tempdir().unwrap();
7734 let rt = file_backed_runtime(&dir, "guard_after_on.db", true);
7735 assert_guarded_write_attempted_after_delete_is_refused(&rt).await;
7736 }
7737
7738 #[tokio::test]
7739 async fn create_note_annotates_phantom_returns_not_found() {
7740 let rt = rt();
7741 let tok = NamespaceToken::local();
7742 let phantom = Uuid::new_v4();
7743
7744 let result = rt
7745 .create_note(
7746 &tok,
7747 "observation",
7748 None,
7749 "some content",
7750 Some(0.5),
7751 None,
7752 vec![phantom],
7753 )
7754 .await;
7755 assert!(
7756 matches!(result, Err(RuntimeError::NotFound(_))),
7757 "annotates with phantom uuid must return NotFound, got {result:?}"
7758 );
7759 }
7760
7761 #[tokio::test]
7762 async fn create_note_annotates_real_entity_succeeds() {
7763 let rt = rt();
7764 let tok = NamespaceToken::local();
7765 let entity = rt
7766 .create_entity(&tok, "concept", None, "RealTarget", None, None, vec![])
7767 .await
7768 .unwrap();
7769
7770 let note = rt
7771 .create_note(
7772 &tok,
7773 "observation",
7774 None,
7775 "content",
7776 Some(0.5),
7777 None,
7778 vec![entity.id],
7779 )
7780 .await
7781 .unwrap();
7782
7783 let neighbors = rt
7784 .neighbors(
7785 &tok,
7786 note.id,
7787 Direction::Out,
7788 None,
7789 Some(vec![EdgeRelation::Annotates]),
7790 )
7791 .await
7792 .unwrap();
7793 assert_eq!(neighbors.len(), 1);
7794 assert_eq!(neighbors[0].node_id, entity.id);
7795 }
7796
7797 #[tokio::test]
7799 async fn create_note_multi_annotates_creates_all_edges() {
7800 let rt = rt();
7801 let tok = NamespaceToken::local();
7802 let t1 = rt
7803 .create_entity(&tok, "concept", None, "Target1", None, None, vec![])
7804 .await
7805 .unwrap();
7806 let t2 = rt
7807 .create_entity(&tok, "concept", None, "Target2", None, None, vec![])
7808 .await
7809 .unwrap();
7810
7811 let note = rt
7812 .create_note(
7813 &tok,
7814 "observation",
7815 None,
7816 "content",
7817 Some(0.5),
7818 None,
7819 vec![t1.id, t2.id],
7820 )
7821 .await
7822 .unwrap();
7823
7824 let neighbors = rt
7825 .neighbors(
7826 &tok,
7827 note.id,
7828 Direction::Out,
7829 None,
7830 Some(vec![EdgeRelation::Annotates]),
7831 )
7832 .await
7833 .unwrap();
7834 assert_eq!(
7835 neighbors.len(),
7836 2,
7837 "multi-annotates note must have exactly 2 outbound annotates edges"
7838 );
7839 let target_ids: Vec<Uuid> = neighbors.iter().map(|n| n.node_id).collect();
7840 assert!(target_ids.contains(&t1.id));
7841 assert!(target_ids.contains(&t2.id));
7842 }
7843
7844 #[tokio::test]
7848 async fn link_target_in_different_namespace_succeeds() {
7849 let rt = rt();
7850 let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
7851 let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
7852 let a = rt
7853 .create_entity(&ns_a, "concept", None, "A", None, None, vec![])
7854 .await
7855 .unwrap();
7856 let b = rt
7857 .create_entity(&ns_b, "concept", None, "B", None, None, vec![])
7858 .await
7859 .unwrap();
7860
7861 let result = rt
7863 .link(&ns_a, a.id, b.id, EdgeRelation::Extends, 1.0, None)
7864 .await;
7865 assert!(
7866 result.is_ok(),
7867 "target in a different namespace than the caller must resolve (#631), got {result:?}"
7868 );
7869 }
7870
7871 #[tokio::test]
7872 async fn link_phantom_self_loop_returns_invalid_input() {
7873 let rt = rt();
7874 let tok = NamespaceToken::local();
7875 let phantom = Uuid::new_v4();
7876
7877 let result = rt
7878 .link(&tok, phantom, phantom, EdgeRelation::Extends, 1.0, None)
7879 .await;
7880 match result {
7881 Err(RuntimeError::InvalidInput(msg)) => {
7882 assert!(
7883 msg.contains("self-loop"),
7884 "self-loop must be rejected with self-loop message: {msg}"
7885 );
7886 }
7887 other => panic!("expected InvalidInput for self-loop, got {other:?}"),
7888 }
7889 }
7890
7891 #[tokio::test]
7894 async fn link_note_to_edge_annotates_succeeds() {
7895 let rt = rt();
7896 let tok = NamespaceToken::local();
7897 let a = rt
7898 .create_entity(&tok, "concept", None, "A", None, None, vec![])
7899 .await
7900 .unwrap();
7901 let b = rt
7902 .create_entity(&tok, "concept", None, "B", None, None, vec![])
7903 .await
7904 .unwrap();
7905 let edge = rt
7907 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
7908 .await
7909 .unwrap();
7910 let edge_uuid: Uuid = edge.id.into();
7911
7912 let note = rt
7914 .create_note(
7915 &tok,
7916 "observation",
7917 None,
7918 "edge note",
7919 Some(0.5),
7920 None,
7921 vec![],
7922 )
7923 .await
7924 .unwrap();
7925
7926 let result = rt
7927 .link(&tok, note.id, edge_uuid, EdgeRelation::Annotates, 1.0, None)
7928 .await;
7929 assert!(
7930 result.is_ok(),
7931 "note→edge Annotates must succeed, got {result:?}"
7932 );
7933 }
7934 #[tokio::test]
7941 async fn neighbors_edge_id_finds_annotating_note() {
7942 let rt = rt();
7943 let tok = NamespaceToken::local();
7944 let a = rt
7945 .create_entity(&tok, "concept", None, "A", None, None, vec![])
7946 .await
7947 .unwrap();
7948 let b = rt
7949 .create_entity(&tok, "concept", None, "B", None, None, vec![])
7950 .await
7951 .unwrap();
7952 let edge = rt
7953 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
7954 .await
7955 .unwrap();
7956 let edge_uuid: Uuid = edge.id.into();
7957
7958 let note = rt
7959 .create_note(
7960 &tok,
7961 "observation",
7962 None,
7963 "edge note",
7964 Some(0.5),
7965 None,
7966 vec![edge_uuid],
7967 )
7968 .await
7969 .unwrap();
7970
7971 let neighbors = rt
7972 .neighbors(
7973 &tok,
7974 edge_uuid,
7975 Direction::In,
7976 None,
7977 Some(vec![EdgeRelation::Annotates]),
7978 )
7979 .await
7980 .unwrap();
7981 assert_eq!(neighbors.len(), 1, "expected annotating note to show up");
7982 assert_eq!(neighbors[0].node_id, note.id);
7983 }
7984
7985 #[tokio::test]
7986 async fn create_note_annotates_real_edge_succeeds() {
7987 let rt = rt();
7988 let tok = NamespaceToken::local();
7989 let a = rt
7990 .create_entity(&tok, "concept", None, "A", None, None, vec![])
7991 .await
7992 .unwrap();
7993 let b = rt
7994 .create_entity(&tok, "concept", None, "B", None, None, vec![])
7995 .await
7996 .unwrap();
7997 let edge = rt
7998 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
7999 .await
8000 .unwrap();
8001 let edge_uuid: Uuid = edge.id.into();
8002
8003 let note = rt
8004 .create_note(
8005 &tok,
8006 "observation",
8007 None,
8008 "annotating an edge",
8009 Some(0.5),
8010 None,
8011 vec![edge_uuid],
8012 )
8013 .await
8014 .unwrap();
8015
8016 let neighbors = rt
8017 .neighbors(
8018 &tok,
8019 note.id,
8020 Direction::Out,
8021 None,
8022 Some(vec![EdgeRelation::Annotates]),
8023 )
8024 .await
8025 .unwrap();
8026 assert_eq!(neighbors.len(), 1);
8027 assert_eq!(neighbors[0].node_id, edge_uuid);
8028 }
8029
8030 #[tokio::test]
8031 async fn create_note_annotates_phantom_is_atomic_no_note_persisted() {
8032 let rt = rt();
8033 let tok = NamespaceToken::local();
8034 let phantom = Uuid::new_v4();
8035
8036 let before_count = rt.list_notes(&tok, None, 1000, 0).await.unwrap().len();
8037
8038 let result = rt
8039 .create_note(
8040 &tok,
8041 "observation",
8042 None,
8043 "should not persist",
8044 Some(0.5),
8045 None,
8046 vec![phantom],
8047 )
8048 .await;
8049 assert!(
8050 matches!(result, Err(RuntimeError::NotFound(_))),
8051 "phantom annotates target must return NotFound, got {result:?}"
8052 );
8053
8054 let after_count = rt.list_notes(&tok, None, 1000, 0).await.unwrap().len();
8056 assert_eq!(
8057 before_count, after_count,
8058 "failed create_note must not persist any note row (atomicity)"
8059 );
8060
8061 let search_hits = rt
8063 .search_notes(&tok, "should not persist", None, 10, None, false, &[], None)
8064 .await
8065 .unwrap();
8066 assert!(
8067 search_hits.is_empty(),
8068 "failed create_note must not index into FTS (atomicity)"
8069 );
8070 }
8073
8074 #[tokio::test]
8078 async fn link_entity_to_edge_uuid_non_annotates_returns_invalid_input() {
8079 let rt = rt();
8080 let tok = NamespaceToken::local();
8081 let a = rt
8082 .create_entity(&tok, "concept", None, "A", None, None, vec![])
8083 .await
8084 .unwrap();
8085 let b = rt
8086 .create_entity(&tok, "concept", None, "B", None, None, vec![])
8087 .await
8088 .unwrap();
8089 let edge = rt
8091 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
8092 .await
8093 .unwrap();
8094 let edge_uuid: Uuid = edge.id.into();
8095
8096 let result = rt
8097 .link(&tok, a.id, edge_uuid, EdgeRelation::Extends, 1.0, None)
8098 .await;
8099 match result {
8100 Err(RuntimeError::InvalidInput(msg)) => {
8101 assert!(
8102 msg.contains("target"),
8103 "error message must name 'target': {msg}"
8104 );
8105 }
8106 other => {
8107 panic!("expected InvalidInput for edge-uuid target with Extends, got {other:?}")
8108 }
8109 }
8110 }
8111
8112 #[tokio::test]
8114 async fn link_note_as_source_non_annotates_returns_invalid_input() {
8115 let rt = rt();
8116 let tok = NamespaceToken::local();
8117 let note = rt
8118 .create_note(&tok, "observation", None, "a note", Some(0.5), None, vec![])
8119 .await
8120 .unwrap();
8121 let entity = rt
8122 .create_entity(&tok, "concept", None, "E", None, None, vec![])
8123 .await
8124 .unwrap();
8125
8126 let result = rt
8127 .link(&tok, note.id, entity.id, EdgeRelation::DependsOn, 1.0, None)
8128 .await;
8129 match result {
8130 Err(RuntimeError::InvalidInput(msg)) => {
8131 assert!(
8132 msg.contains("source"),
8133 "error message must name 'source': {msg}"
8134 );
8135 }
8136 other => panic!("expected InvalidInput for note source with DependsOn, got {other:?}"),
8137 }
8138 }
8139
8140 #[tokio::test]
8142 async fn link_entity_as_annotates_source_returns_invalid_input() {
8143 let rt = rt();
8144 let tok = NamespaceToken::local();
8145 let a = rt
8146 .create_entity(&tok, "concept", None, "A", None, None, vec![])
8147 .await
8148 .unwrap();
8149 let b = rt
8150 .create_entity(&tok, "concept", None, "B", None, None, vec![])
8151 .await
8152 .unwrap();
8153
8154 let result = rt
8155 .link(&tok, a.id, b.id, EdgeRelation::Annotates, 1.0, None)
8156 .await;
8157 match result {
8158 Err(RuntimeError::InvalidInput(msg)) => {
8159 assert!(
8160 msg.contains("source") && msg.contains("note"),
8161 "error must say source must be a note: {msg}"
8162 );
8163 }
8164 other => {
8165 panic!("expected InvalidInput for entity source with Annotates, got {other:?}")
8166 }
8167 }
8168 }
8169
8170 #[tokio::test]
8171 async fn link_edge_as_annotates_source_returns_invalid_input() {
8172 let rt = rt();
8173 let tok = NamespaceToken::local();
8174 let a = rt
8175 .create_entity(&tok, "concept", None, "A", None, None, vec![])
8176 .await
8177 .unwrap();
8178 let b = rt
8179 .create_entity(&tok, "concept", None, "B", None, None, vec![])
8180 .await
8181 .unwrap();
8182 let edge = rt
8183 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
8184 .await
8185 .unwrap();
8186 let edge_uuid: Uuid = edge.id.into();
8187
8188 let result = rt
8190 .link(&tok, edge_uuid, a.id, EdgeRelation::Annotates, 1.0, None)
8191 .await;
8192 match result {
8193 Err(RuntimeError::InvalidInput(msg)) => {
8194 assert!(
8195 msg.contains("source") && msg.contains("note"),
8196 "edge-as-annotates-source must report wrong kind, not NotFound: {msg}"
8197 );
8198 }
8199 other => panic!("expected InvalidInput for edge source with Annotates, got {other:?}"),
8200 }
8201 }
8202
8203 #[tokio::test]
8205 async fn link_note_to_event_annotates_succeeds() {
8206 use khive_storage::Event;
8207 use khive_types::{EventKind, SubstrateKind};
8208
8209 let rt = rt();
8210 let tok = NamespaceToken::local();
8211 let note = rt
8212 .create_note(
8213 &tok,
8214 "observation",
8215 None,
8216 "observing an event",
8217 Some(0.6),
8218 None,
8219 vec![],
8220 )
8221 .await
8222 .unwrap();
8223
8224 let ns = tok.namespace().as_str();
8226 let event = Event::new(
8227 ns,
8228 "test_verb",
8229 EventKind::Audit,
8230 SubstrateKind::Entity,
8231 "test_actor",
8232 );
8233 let event_id = event.id;
8234 rt.events(&tok).unwrap().append_event(event).await.unwrap();
8235
8236 let result = rt
8237 .link(&tok, note.id, event_id, EdgeRelation::Annotates, 1.0, None)
8238 .await;
8239 assert!(
8240 result.is_ok(),
8241 "note→event Annotates must succeed, got {result:?}"
8242 );
8243 }
8244
8245 #[tokio::test]
8247 async fn create_note_annotates_event_succeeds() {
8248 use khive_storage::Event;
8249 use khive_types::{EventKind, SubstrateKind};
8250
8251 let rt = rt();
8252 let tok = NamespaceToken::local();
8253 let ns = tok.namespace().as_str();
8254 let event = Event::new(
8255 ns,
8256 "test_verb",
8257 EventKind::Audit,
8258 SubstrateKind::Entity,
8259 "test_actor",
8260 );
8261 let event_id = event.id;
8262 rt.events(&tok).unwrap().append_event(event).await.unwrap();
8263
8264 let result = rt
8265 .create_note(
8266 &tok,
8267 "observation",
8268 None,
8269 "note annotating an event",
8270 Some(0.5),
8271 None,
8272 vec![event_id],
8273 )
8274 .await;
8275 assert!(
8276 result.is_ok(),
8277 "create_note with event annotates target must succeed, got {result:?}"
8278 );
8279 let note = result.unwrap();
8281 let neighbors = rt
8282 .neighbors(
8283 &tok,
8284 note.id,
8285 Direction::Out,
8286 None,
8287 Some(vec![EdgeRelation::Annotates]),
8288 )
8289 .await
8290 .unwrap();
8291 assert_eq!(neighbors.len(), 1);
8292 assert_eq!(neighbors[0].node_id, event_id);
8293 }
8294
8295 #[tokio::test]
8299 async fn link_supersedes_note_to_note_succeeds() {
8300 let rt = rt();
8301 let tok = NamespaceToken::local();
8302 let old_note = rt
8303 .create_note(
8304 &tok,
8305 "observation",
8306 None,
8307 "old observation",
8308 Some(0.7),
8309 None,
8310 vec![],
8311 )
8312 .await
8313 .unwrap();
8314 let new_note = rt
8315 .create_note(
8316 &tok,
8317 "observation",
8318 None,
8319 "revised observation superseding the old one",
8320 Some(0.9),
8321 None,
8322 vec![],
8323 )
8324 .await
8325 .unwrap();
8326
8327 let result = rt
8328 .link(
8329 &tok,
8330 new_note.id,
8331 old_note.id,
8332 EdgeRelation::Supersedes,
8333 1.0,
8334 None,
8335 )
8336 .await;
8337 assert!(
8338 result.is_ok(),
8339 "note→note Supersedes must succeed (note supersession), got {result:?}"
8340 );
8341 }
8342
8343 #[tokio::test]
8344 async fn link_supersedes_entity_to_entity_succeeds() {
8345 let rt = rt();
8346 let tok = NamespaceToken::local();
8347 let old_entity = rt
8348 .create_entity(&tok, "concept", None, "OldConcept", None, None, vec![])
8349 .await
8350 .unwrap();
8351 let new_entity = rt
8352 .create_entity(&tok, "concept", None, "NewConcept", None, None, vec![])
8353 .await
8354 .unwrap();
8355
8356 let result = rt
8357 .link(
8358 &tok,
8359 new_entity.id,
8360 old_entity.id,
8361 EdgeRelation::Supersedes,
8362 1.0,
8363 None,
8364 )
8365 .await;
8366 assert!(
8367 result.is_ok(),
8368 "entity→entity Supersedes must succeed, got {result:?}"
8369 );
8370 }
8371
8372 #[tokio::test]
8373 async fn link_supersedes_note_to_entity_returns_invalid_input() {
8374 let rt = rt();
8375 let tok = NamespaceToken::local();
8376 let note = rt
8377 .create_note(&tok, "observation", None, "a note", Some(0.5), None, vec![])
8378 .await
8379 .unwrap();
8380 let entity = rt
8381 .create_entity(&tok, "concept", None, "SomeEntity", None, None, vec![])
8382 .await
8383 .unwrap();
8384
8385 let result = rt
8386 .link(
8387 &tok,
8388 note.id,
8389 entity.id,
8390 EdgeRelation::Supersedes,
8391 1.0,
8392 None,
8393 )
8394 .await;
8395 match result {
8396 Err(RuntimeError::InvalidInput(msg)) => {
8397 assert!(
8398 msg.contains("same substrate") || msg.contains("same-substrate"),
8399 "error must name the same-substrate rule: {msg}"
8400 );
8401 }
8402 other => panic!(
8403 "expected InvalidInput for note→entity Supersedes (cross-substrate), got {other:?}"
8404 ),
8405 }
8406 }
8407
8408 #[tokio::test]
8409 async fn link_supersedes_entity_to_note_returns_invalid_input() {
8410 let rt = rt();
8411 let tok = NamespaceToken::local();
8412 let entity = rt
8413 .create_entity(&tok, "concept", None, "SomeEntity", None, None, vec![])
8414 .await
8415 .unwrap();
8416 let note = rt
8417 .create_note(&tok, "observation", None, "a note", Some(0.5), None, vec![])
8418 .await
8419 .unwrap();
8420
8421 let result = rt
8422 .link(
8423 &tok,
8424 entity.id,
8425 note.id,
8426 EdgeRelation::Supersedes,
8427 1.0,
8428 None,
8429 )
8430 .await;
8431 match result {
8432 Err(RuntimeError::InvalidInput(msg)) => {
8433 assert!(
8434 msg.contains("same substrate") || msg.contains("same-substrate"),
8435 "error must name the same-substrate rule: {msg}"
8436 );
8437 }
8438 other => panic!(
8439 "expected InvalidInput for entity→note Supersedes (cross-substrate), got {other:?}"
8440 ),
8441 }
8442 }
8443
8444 #[tokio::test]
8445 async fn link_supersedes_event_source_returns_invalid_input() {
8446 use khive_storage::Event;
8447 use khive_types::{EventKind, SubstrateKind};
8448
8449 let rt = rt();
8450 let tok = NamespaceToken::local();
8451 let ns = tok.namespace().as_str();
8452 let event = Event::new(
8453 ns,
8454 "test_verb",
8455 EventKind::Audit,
8456 SubstrateKind::Entity,
8457 "test_actor",
8458 );
8459 let event_id = event.id;
8460 rt.events(&tok).unwrap().append_event(event).await.unwrap();
8461
8462 let entity = rt
8463 .create_entity(&tok, "concept", None, "SomeEntity", None, None, vec![])
8464 .await
8465 .unwrap();
8466
8467 let result = rt
8468 .link(
8469 &tok,
8470 event_id,
8471 entity.id,
8472 EdgeRelation::Supersedes,
8473 1.0,
8474 None,
8475 )
8476 .await;
8477 match result {
8478 Err(RuntimeError::InvalidInput(msg)) => {
8479 assert!(msg.contains("event"), "error must mention 'event': {msg}");
8480 }
8481 other => {
8482 panic!("expected InvalidInput for event source with Supersedes, got {other:?}")
8483 }
8484 }
8485 }
8486
8487 #[tokio::test]
8488 async fn link_supersedes_event_target_returns_invalid_input() {
8489 use khive_storage::Event;
8490 use khive_types::{EventKind, SubstrateKind};
8491
8492 let rt = rt();
8493 let tok = NamespaceToken::local();
8494 let ns = tok.namespace().as_str();
8495 let event = Event::new(
8496 ns,
8497 "test_verb",
8498 EventKind::Audit,
8499 SubstrateKind::Entity,
8500 "test_actor",
8501 );
8502 let event_id = event.id;
8503 rt.events(&tok).unwrap().append_event(event).await.unwrap();
8504
8505 let entity = rt
8506 .create_entity(&tok, "concept", None, "SomeEntity", None, None, vec![])
8507 .await
8508 .unwrap();
8509
8510 let result = rt
8511 .link(
8512 &tok,
8513 entity.id,
8514 event_id,
8515 EdgeRelation::Supersedes,
8516 1.0,
8517 None,
8518 )
8519 .await;
8520 match result {
8521 Err(RuntimeError::InvalidInput(msg)) => {
8522 assert!(msg.contains("event"), "error must mention 'event': {msg}");
8523 }
8524 other => {
8525 panic!("expected InvalidInput for event target with Supersedes, got {other:?}")
8526 }
8527 }
8528 }
8529
8530 #[tokio::test]
8531 async fn link_supersedes_edge_source_returns_invalid_input() {
8532 let rt = rt();
8533 let tok = NamespaceToken::local();
8534 let a = rt
8535 .create_entity(&tok, "concept", None, "A", None, None, vec![])
8536 .await
8537 .unwrap();
8538 let b = rt
8539 .create_entity(&tok, "concept", None, "B", None, None, vec![])
8540 .await
8541 .unwrap();
8542 let edge = rt
8543 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
8544 .await
8545 .unwrap();
8546 let edge_uuid: Uuid = edge.id.into();
8547
8548 let result = rt
8549 .link(&tok, edge_uuid, a.id, EdgeRelation::Supersedes, 1.0, None)
8550 .await;
8551 match result {
8552 Err(RuntimeError::InvalidInput(msg)) => {
8553 assert!(msg.contains("source"), "error must name 'source': {msg}");
8554 }
8555 other => {
8556 panic!("expected InvalidInput for edge-uuid source with Supersedes, got {other:?}")
8557 }
8558 }
8559 }
8560
8561 #[tokio::test]
8562 async fn link_supersedes_edge_target_returns_invalid_input() {
8563 let rt = rt();
8564 let tok = NamespaceToken::local();
8565 let a = rt
8566 .create_entity(&tok, "concept", None, "A", None, None, vec![])
8567 .await
8568 .unwrap();
8569 let b = rt
8570 .create_entity(&tok, "concept", None, "B", None, None, vec![])
8571 .await
8572 .unwrap();
8573 let edge = rt
8574 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
8575 .await
8576 .unwrap();
8577 let edge_uuid: Uuid = edge.id.into();
8578
8579 let result = rt
8580 .link(&tok, a.id, edge_uuid, EdgeRelation::Supersedes, 1.0, None)
8581 .await;
8582 match result {
8583 Err(RuntimeError::InvalidInput(msg)) => {
8584 assert!(msg.contains("target"), "error must name 'target': {msg}");
8585 }
8586 other => {
8587 panic!("expected InvalidInput for edge-uuid target with Supersedes, got {other:?}")
8588 }
8589 }
8590 }
8591
8592 #[tokio::test]
8593 async fn link_supersedes_phantom_source_returns_not_found() {
8594 let rt = rt();
8595 let tok = NamespaceToken::local();
8596 let note = rt
8597 .create_note(
8598 &tok,
8599 "observation",
8600 None,
8601 "existing note",
8602 Some(0.5),
8603 None,
8604 vec![],
8605 )
8606 .await
8607 .unwrap();
8608 let phantom = Uuid::new_v4();
8609
8610 let result = rt
8611 .link(&tok, phantom, note.id, EdgeRelation::Supersedes, 1.0, None)
8612 .await;
8613 match result {
8614 Err(RuntimeError::NotFound(msg)) => {
8615 assert!(msg.contains("source"), "error must name 'source': {msg}");
8616 }
8617 other => panic!("expected NotFound for phantom source with Supersedes, got {other:?}"),
8618 }
8619 }
8620
8621 #[tokio::test]
8622 async fn link_supersedes_phantom_target_returns_not_found() {
8623 let rt = rt();
8624 let tok = NamespaceToken::local();
8625 let note = rt
8626 .create_note(
8627 &tok,
8628 "observation",
8629 None,
8630 "existing note",
8631 Some(0.5),
8632 None,
8633 vec![],
8634 )
8635 .await
8636 .unwrap();
8637 let phantom = Uuid::new_v4();
8638
8639 let result = rt
8640 .link(&tok, note.id, phantom, EdgeRelation::Supersedes, 1.0, None)
8641 .await;
8642 match result {
8643 Err(RuntimeError::NotFound(msg)) => {
8644 assert!(msg.contains("target"), "error must name 'target': {msg}");
8645 }
8646 other => panic!("expected NotFound for phantom target with Supersedes, got {other:?}"),
8647 }
8648 }
8649
8650 #[tokio::test]
8653 async fn link_supersedes_cross_namespace_source_succeeds() {
8654 let rt = rt();
8655 let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
8656 let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
8657 let note_a = rt
8658 .create_note(
8659 &ns_a,
8660 "observation",
8661 None,
8662 "note in ns-a",
8663 Some(0.5),
8664 None,
8665 vec![],
8666 )
8667 .await
8668 .unwrap();
8669 let note_b = rt
8670 .create_note(
8671 &ns_b,
8672 "observation",
8673 None,
8674 "note in ns-b",
8675 Some(0.5),
8676 None,
8677 vec![],
8678 )
8679 .await
8680 .unwrap();
8681
8682 let result = rt
8685 .link(
8686 &ns_a,
8687 note_b.id,
8688 note_a.id,
8689 EdgeRelation::Supersedes,
8690 1.0,
8691 None,
8692 )
8693 .await;
8694 assert!(
8695 result.is_ok(),
8696 "cross-namespace supersedes source must resolve (#631), got {result:?}"
8697 );
8698 }
8699
8700 #[tokio::test]
8702 async fn link_extends_note_source_still_returns_invalid_input() {
8703 let rt = rt();
8704 let tok = NamespaceToken::local();
8705 let note = rt
8706 .create_note(
8707 &tok,
8708 "observation",
8709 None,
8710 "a note that cannot be an extends source",
8711 Some(0.5),
8712 None,
8713 vec![],
8714 )
8715 .await
8716 .unwrap();
8717 let entity = rt
8718 .create_entity(&tok, "concept", None, "E", None, None, vec![])
8719 .await
8720 .unwrap();
8721
8722 let result = rt
8723 .link(&tok, note.id, entity.id, EdgeRelation::Extends, 1.0, None)
8724 .await;
8725 assert!(
8726 matches!(result, Err(RuntimeError::InvalidInput(_))),
8727 "note source with Extends must still return InvalidInput after this fix, got {result:?}"
8728 );
8729 }
8730
8731 #[tokio::test]
8733 async fn link_annotates_note_to_edge_still_succeeds_after_fix() {
8734 let rt = rt();
8735 let tok = NamespaceToken::local();
8736 let a = rt
8737 .create_entity(&tok, "concept", None, "A", None, None, vec![])
8738 .await
8739 .unwrap();
8740 let b = rt
8741 .create_entity(&tok, "concept", None, "B", None, None, vec![])
8742 .await
8743 .unwrap();
8744 let edge = rt
8745 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
8746 .await
8747 .unwrap();
8748 let edge_uuid: Uuid = edge.id.into();
8749
8750 let note = rt
8751 .create_note(
8752 &tok,
8753 "observation",
8754 None,
8755 "annotating an edge",
8756 Some(0.5),
8757 None,
8758 vec![],
8759 )
8760 .await
8761 .unwrap();
8762
8763 let result = rt
8764 .link(&tok, note.id, edge_uuid, EdgeRelation::Annotates, 1.0, None)
8765 .await;
8766 assert!(
8767 result.is_ok(),
8768 "note→edge Annotates must still succeed after supersedes fix, got {result:?}"
8769 );
8770 }
8771
8772 #[tokio::test]
8786 async fn create_note_multi_annotates_compensation_cleanup_restores_pristine_state() {
8787 let rt = rt();
8788 let tok = NamespaceToken::local();
8789 let t1 = rt
8790 .create_entity(&tok, "concept", None, "T1", None, None, vec![])
8791 .await
8792 .unwrap();
8793
8794 let note = rt
8797 .create_note(
8798 &tok,
8799 "observation",
8800 None,
8801 "partial note",
8802 Some(0.5),
8803 None,
8804 vec![t1.id],
8805 )
8806 .await
8807 .unwrap();
8808
8809 let before_notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
8811 assert_eq!(before_notes.len(), 1, "note must be present before cleanup");
8812 let before_edges = rt
8813 .neighbors(
8814 &tok,
8815 note.id,
8816 Direction::Out,
8817 None,
8818 Some(vec![EdgeRelation::Annotates]),
8819 )
8820 .await
8821 .unwrap();
8822 assert_eq!(
8823 before_edges.len(),
8824 1,
8825 "one annotates edge must exist before cleanup"
8826 );
8827 let edge_id: Uuid = before_edges[0].edge_id;
8828
8829 rt.delete_edge(&tok, edge_id, true).await.unwrap();
8831 rt.delete_note(&tok, note.id, true )
8832 .await
8833 .unwrap();
8834
8835 let after_notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
8837 assert!(
8838 after_notes.is_empty(),
8839 "compensation must remove the note row; got {after_notes:?}"
8840 );
8841 let search_hits = rt
8842 .search_notes(&tok, "partial note", None, 10, None, false, &[], None)
8843 .await
8844 .unwrap();
8845 assert!(
8846 search_hits.is_empty(),
8847 "compensation must clean the FTS index; got {search_hits:?}"
8848 );
8849 let after_edges = rt
8850 .neighbors(&tok, note.id, Direction::Out, None, None)
8851 .await
8852 .unwrap();
8853 assert!(
8854 after_edges.is_empty(),
8855 "compensation must remove all partial edges; got {after_edges:?}"
8856 );
8857 }
8858
8859 #[tokio::test]
8867 async fn annotated_entity_hard_delete_cascades_annotate_edge() {
8868 let rt = rt();
8869 let tok = NamespaceToken::local();
8870 let entity = rt
8871 .create_entity(&tok, "concept", None, "E", None, None, vec![])
8872 .await
8873 .unwrap();
8874 let note = rt
8875 .create_note(
8876 &tok,
8877 "observation",
8878 None,
8879 "note about entity",
8880 Some(0.5),
8881 None,
8882 vec![entity.id],
8883 )
8884 .await
8885 .unwrap();
8886
8887 let before = rt
8889 .neighbors(
8890 &tok,
8891 note.id,
8892 Direction::Out,
8893 None,
8894 Some(vec![EdgeRelation::Annotates]),
8895 )
8896 .await
8897 .unwrap();
8898 assert_eq!(
8899 before.len(),
8900 1,
8901 "annotates edge must exist before entity delete"
8902 );
8903
8904 let deleted = rt.delete_entity(&tok, entity.id, true).await.unwrap();
8906 assert!(deleted, "entity hard delete must return true");
8907
8908 let after = rt
8910 .neighbors(
8911 &tok,
8912 note.id,
8913 Direction::Out,
8914 None,
8915 Some(vec![EdgeRelation::Annotates]),
8916 )
8917 .await
8918 .unwrap();
8919 assert!(
8920 after.is_empty(),
8921 "annotates edge must be cascaded on entity hard delete; got {after:?}"
8922 );
8923 }
8924
8925 #[tokio::test]
8926 async fn annotated_note_hard_delete_cascades_annotate_edge() {
8927 let rt = rt();
8928 let tok = NamespaceToken::local();
8929 let note_target = rt
8931 .create_note(
8932 &tok,
8933 "observation",
8934 None,
8935 "target note",
8936 Some(0.5),
8937 None,
8938 vec![],
8939 )
8940 .await
8941 .unwrap();
8942 let note_source = rt
8944 .create_note(
8945 &tok,
8946 "insight",
8947 None,
8948 "annotation",
8949 Some(0.5),
8950 None,
8951 vec![note_target.id],
8952 )
8953 .await
8954 .unwrap();
8955
8956 let before = rt
8957 .neighbors(
8958 &tok,
8959 note_source.id,
8960 Direction::Out,
8961 None,
8962 Some(vec![EdgeRelation::Annotates]),
8963 )
8964 .await
8965 .unwrap();
8966 assert_eq!(
8967 before.len(),
8968 1,
8969 "annotates edge must exist before note delete"
8970 );
8971
8972 let deleted = rt.delete_note(&tok, note_target.id, true).await.unwrap();
8974 assert!(deleted, "note hard delete must return true");
8975
8976 let after = rt
8978 .neighbors(
8979 &tok,
8980 note_source.id,
8981 Direction::Out,
8982 None,
8983 Some(vec![EdgeRelation::Annotates]),
8984 )
8985 .await
8986 .unwrap();
8987 assert!(
8988 after.is_empty(),
8989 "annotates edge must be cascaded on note-target hard delete; got {after:?}"
8990 );
8991 }
8992
8993 #[tokio::test]
8994 async fn annotated_edge_delete_cascades_annotate_edge() {
8995 let rt = rt();
8996 let tok = NamespaceToken::local();
8997 let a = rt
8998 .create_entity(&tok, "concept", None, "A", None, None, vec![])
8999 .await
9000 .unwrap();
9001 let b = rt
9002 .create_entity(&tok, "concept", None, "B", None, None, vec![])
9003 .await
9004 .unwrap();
9005 let base_edge = rt
9007 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
9008 .await
9009 .unwrap();
9010 let base_edge_uuid: Uuid = base_edge.id.into();
9011
9012 let note = rt
9014 .create_note(
9015 &tok,
9016 "observation",
9017 None,
9018 "note about edge",
9019 Some(0.5),
9020 None,
9021 vec![base_edge_uuid],
9022 )
9023 .await
9024 .unwrap();
9025
9026 let before = rt
9027 .neighbors(
9028 &tok,
9029 note.id,
9030 Direction::Out,
9031 None,
9032 Some(vec![EdgeRelation::Annotates]),
9033 )
9034 .await
9035 .unwrap();
9036 assert_eq!(
9037 before.len(),
9038 1,
9039 "annotates edge must exist before base edge delete"
9040 );
9041
9042 let deleted = rt.delete_edge(&tok, base_edge_uuid, true).await.unwrap();
9044 assert!(deleted, "edge delete must return true");
9045
9046 let after = rt
9048 .neighbors(
9049 &tok,
9050 note.id,
9051 Direction::Out,
9052 None,
9053 Some(vec![EdgeRelation::Annotates]),
9054 )
9055 .await
9056 .unwrap();
9057 assert!(
9058 after.is_empty(),
9059 "annotates edge must be cascaded on base edge delete; got {after:?}"
9060 );
9061 }
9062
9063 #[tokio::test]
9064 async fn mixed_multi_annotates_partial_target_hard_delete_leaves_remaining_edges() {
9065 let rt = rt();
9066 let tok = NamespaceToken::local();
9067 let t1 = rt
9068 .create_entity(&tok, "concept", None, "T1", None, None, vec![])
9069 .await
9070 .unwrap();
9071 let t2 = rt
9072 .create_entity(&tok, "concept", None, "T2", None, None, vec![])
9073 .await
9074 .unwrap();
9075
9076 let note = rt
9078 .create_note(
9079 &tok,
9080 "observation",
9081 None,
9082 "multi-target note",
9083 Some(0.5),
9084 None,
9085 vec![t1.id, t2.id],
9086 )
9087 .await
9088 .unwrap();
9089
9090 let before = rt
9091 .neighbors(
9092 &tok,
9093 note.id,
9094 Direction::Out,
9095 None,
9096 Some(vec![EdgeRelation::Annotates]),
9097 )
9098 .await
9099 .unwrap();
9100 assert_eq!(
9101 before.len(),
9102 2,
9103 "must have 2 annotates edges before any delete"
9104 );
9105
9106 rt.delete_entity(&tok, t1.id, true).await.unwrap();
9108
9109 let after = rt
9111 .neighbors(
9112 &tok,
9113 note.id,
9114 Direction::Out,
9115 None,
9116 Some(vec![EdgeRelation::Annotates]),
9117 )
9118 .await
9119 .unwrap();
9120 assert_eq!(
9121 after.len(),
9122 1,
9123 "only the edge to t1 must be cascaded; t2 edge must remain"
9124 );
9125 assert_eq!(
9126 after[0].node_id, t2.id,
9127 "remaining annotates edge must point to t2"
9128 );
9129 }
9130
9131 #[tokio::test]
9132 async fn annotated_note_soft_delete_preserves_annotate_edge() {
9133 let rt = rt();
9134 let tok = NamespaceToken::local();
9135 let note_target = rt
9136 .create_note(&tok, "observation", None, "target", Some(0.5), None, vec![])
9137 .await
9138 .unwrap();
9139 let note_source = rt
9140 .create_note(
9141 &tok,
9142 "insight",
9143 None,
9144 "annotation",
9145 Some(0.5),
9146 None,
9147 vec![note_target.id],
9148 )
9149 .await
9150 .unwrap();
9151
9152 let before = rt
9153 .neighbors(
9154 &tok,
9155 note_source.id,
9156 Direction::Out,
9157 None,
9158 Some(vec![EdgeRelation::Annotates]),
9159 )
9160 .await
9161 .unwrap();
9162 assert_eq!(before.len(), 1);
9163 let edge_id = before[0].edge_id;
9164
9165 let deleted = rt.delete_note(&tok, note_target.id, false).await.unwrap();
9167 assert!(deleted, "soft delete must return true");
9168
9169 let edge_after = rt.get_edge(&tok, edge_id).await.unwrap();
9177 assert!(
9178 edge_after.is_some(),
9179 "soft delete must NOT cascade edges; get_edge returned None"
9180 );
9181
9182 let after = rt
9183 .neighbors(
9184 &tok,
9185 note_source.id,
9186 Direction::Out,
9187 None,
9188 Some(vec![EdgeRelation::Annotates]),
9189 )
9190 .await
9191 .unwrap();
9192 assert_eq!(
9193 after.len(),
9194 0,
9195 "#748: neighbors() must screen out the soft-deleted note target; got {after:?}"
9196 );
9197 }
9198
9199 #[tokio::test]
9206 async fn delete_edge_non_edge_uuid_has_no_side_effects() {
9207 let rt = rt();
9208 let tok = NamespaceToken::local();
9209
9210 let entity = rt
9212 .create_entity(&tok, "concept", None, "Target", None, None, vec![])
9213 .await
9214 .unwrap();
9215 let note = rt
9216 .create_note(
9217 &tok,
9218 "observation",
9219 None,
9220 "annotates the entity",
9221 Some(0.5),
9222 None,
9223 vec![entity.id],
9224 )
9225 .await
9226 .unwrap();
9227
9228 let before = rt
9230 .neighbors(
9231 &tok,
9232 note.id,
9233 Direction::Out,
9234 None,
9235 Some(vec![EdgeRelation::Annotates]),
9236 )
9237 .await
9238 .unwrap();
9239 assert_eq!(before.len(), 1, "annotates edge must exist before test");
9240 let annotates_edge_id: Uuid = before[0].edge_id;
9241
9242 let result = rt.delete_edge(&tok, entity.id, true).await;
9244 assert!(
9245 result.is_ok(),
9246 "delete_edge must not error on a non-edge UUID"
9247 );
9248 assert!(
9249 !result.unwrap(),
9250 "delete_edge must return false for a non-edge UUID"
9251 );
9252
9253 let after = rt
9255 .neighbors(
9256 &tok,
9257 note.id,
9258 Direction::Out,
9259 None,
9260 Some(vec![EdgeRelation::Annotates]),
9261 )
9262 .await
9263 .unwrap();
9264 assert_eq!(
9265 after.len(),
9266 1,
9267 "delete_edge with a non-edge UUID must not touch inbound annotates edges"
9268 );
9269 assert_eq!(
9270 after[0].edge_id, annotates_edge_id,
9271 "the original annotates edge must be unchanged"
9272 );
9273 }
9274
9275 #[tokio::test]
9286 async fn create_note_multi_annotates_second_link_failure_rolls_back_partial_write() {
9287 let rt = rt();
9288 let tok = NamespaceToken::local();
9289 let t1 = rt
9290 .create_entity(&tok, "concept", None, "T1", None, None, vec![])
9291 .await
9292 .unwrap();
9293 let t2 = rt
9294 .create_entity(&tok, "concept", None, "T2", None, None, vec![])
9295 .await
9296 .unwrap();
9297
9298 LINK_FAIL_AFTER.with(|cell| cell.set(2));
9300
9301 let result = rt
9302 .create_note(
9303 &tok,
9304 "observation",
9305 None,
9306 "rollback target",
9307 Some(0.5),
9308 None,
9309 vec![t1.id, t2.id],
9310 )
9311 .await;
9312
9313 assert!(
9315 result.is_err(),
9316 "create_note must propagate the injected link failure"
9317 );
9318 let err_msg = result.unwrap_err().to_string();
9319 assert!(
9320 err_msg.contains("injected link failure"),
9321 "error must carry injection message; got: {err_msg}"
9322 );
9323
9324 let notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
9326 assert!(
9327 notes.is_empty(),
9328 "compensation must remove the note row; got {notes:?}"
9329 );
9330
9331 let hits = rt
9333 .search_notes(&tok, "rollback target", None, 10, None, false, &[], None)
9334 .await
9335 .unwrap();
9336 assert!(
9337 hits.is_empty(),
9338 "compensation must clean FTS index; got {hits:?}"
9339 );
9340
9341 let edges_from_t1 = rt
9343 .neighbors(
9344 &tok,
9345 t1.id,
9346 Direction::In,
9347 None,
9348 Some(vec![EdgeRelation::Annotates]),
9349 )
9350 .await
9351 .unwrap();
9352 let edges_from_t2 = rt
9353 .neighbors(
9354 &tok,
9355 t2.id,
9356 Direction::In,
9357 None,
9358 Some(vec![EdgeRelation::Annotates]),
9359 )
9360 .await
9361 .unwrap();
9362 assert!(
9363 edges_from_t1.is_empty(),
9364 "compensation must delete the first annotates edge; got {edges_from_t1:?}"
9365 );
9366 assert!(
9367 edges_from_t2.is_empty(),
9368 "no second annotates edge must exist; got {edges_from_t2:?}"
9369 );
9370 }
9371
9372 #[tokio::test]
9376 async fn create_note_fts_failure_rolls_back_note_row() {
9377 let rt = rt();
9378 let ns = Namespace::parse("fault-fts-rollback").unwrap();
9383 let tok = NamespaceToken::for_namespace(ns.clone());
9384
9385 arm_fts_fail(ns.as_str());
9386
9387 let result = rt
9388 .create_note(
9389 &tok,
9390 "observation",
9391 None,
9392 "fts-fail rollback target",
9393 None,
9394 None,
9395 vec![],
9396 )
9397 .await;
9398
9399 assert!(
9400 result.is_err(),
9401 "create_note must propagate the injected FTS failure"
9402 );
9403 let err_msg = result.unwrap_err().to_string();
9404 assert!(
9405 err_msg.contains("injected FTS failure"),
9406 "error must carry injection message; got: {err_msg}"
9407 );
9408
9409 let notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
9411 assert!(
9412 notes.is_empty(),
9413 "compensation must remove the note row after FTS failure; got {notes:?}"
9414 );
9415 }
9416
9417 #[tokio::test]
9426 async fn create_note_fts_failure_fires_across_os_threads() {
9427 let rt = std::sync::Arc::new(rt());
9428 let ns = Namespace::parse("fault-fts-rollback-cross-thread").unwrap();
9429 let tok = NamespaceToken::for_namespace(ns.clone());
9430
9431 arm_fts_fail(ns.as_str());
9432
9433 let thread_rt = std::sync::Arc::clone(&rt);
9434 let thread_tok = tok.clone();
9435 let result = std::thread::spawn(move || {
9436 let worker = tokio::runtime::Builder::new_current_thread()
9437 .enable_all()
9438 .build()
9439 .expect("worker runtime must build");
9440 worker.block_on(thread_rt.create_note(
9441 &thread_tok,
9442 "observation",
9443 None,
9444 "fts-fail rollback target (cross-thread)",
9445 None,
9446 None,
9447 vec![],
9448 ))
9449 })
9450 .join()
9451 .expect("worker thread must not panic");
9452
9453 assert!(
9454 result.is_err(),
9455 "create_note on a different OS thread must still observe the injected FTS failure"
9456 );
9457 let err_msg = result.unwrap_err().to_string();
9458 assert!(
9459 err_msg.contains("injected FTS failure"),
9460 "error must carry injection message; got: {err_msg}"
9461 );
9462
9463 let notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
9465 assert!(
9466 notes.is_empty(),
9467 "compensation must remove the note row after FTS failure; got {notes:?}"
9468 );
9469 }
9470
9471 #[tokio::test]
9479 async fn create_note_vector_failure_rolls_back_note_row_and_fts() {
9480 const MODEL: &str = "test-vec-inject";
9481 const DIMS: usize = 4;
9482
9483 let rt = KhiveRuntime::memory().unwrap();
9484 let (provider, _counter) = ConstVecProvider::new(MODEL, DIMS);
9485 rt.register_embedder(provider);
9486
9487 let ns = Namespace::parse("fault-vec-rollback").unwrap();
9488 let tok = NamespaceToken::for_namespace(ns.clone());
9489
9490 arm_vector_fail(ns.as_str());
9491
9492 let result = rt
9493 .create_note(
9494 &tok,
9495 "observation",
9496 None,
9497 "vec-fail rollback target",
9498 None,
9499 None,
9500 vec![],
9501 )
9502 .await;
9503
9504 assert!(
9505 result.is_err(),
9506 "create_note must propagate the injected vector failure"
9507 );
9508 let err_msg = result.unwrap_err().to_string();
9509 assert!(
9510 err_msg.contains("injected vector failure"),
9511 "error must carry injection message; got: {err_msg}"
9512 );
9513
9514 let notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
9516 assert!(
9517 notes.is_empty(),
9518 "compensation must remove note row after vector failure; got {notes:?}"
9519 );
9520 }
9521
9522 #[tokio::test]
9528 async fn create_note_with_embedding_content_fts_failure_rolls_back_note_row() {
9529 let rt = rt();
9530 let ns = Namespace::parse("fault-fts-rollback-embedding-content").unwrap();
9531 let tok = NamespaceToken::for_namespace(ns.clone());
9532
9533 arm_fts_fail(ns.as_str());
9534
9535 let full = "fts-fail rollback target with an embedding-content override";
9536 let head = &full[.."fts-fail rollback target".len()];
9537 let result = rt
9538 .create_note_with_embedding_content(
9539 &tok,
9540 "observation",
9541 None,
9542 full,
9543 Some(head),
9544 None,
9545 None,
9546 vec![],
9547 )
9548 .await;
9549
9550 assert!(
9551 result.is_err(),
9552 "create_note_with_embedding_content must propagate the injected FTS failure"
9553 );
9554 let err_msg = result.unwrap_err().to_string();
9555 assert!(
9556 err_msg.contains("injected FTS failure"),
9557 "error must carry injection message; got: {err_msg}"
9558 );
9559
9560 let notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
9564 assert!(
9565 notes.is_empty(),
9566 "compensation must remove the note row after FTS failure; got {notes:?}"
9567 );
9568 }
9569
9570 #[tokio::test]
9571 async fn create_note_with_embedding_content_vector_failure_rolls_back_note_row_and_fts() {
9572 const MODEL: &str = "test-vec-inject-embedding-content";
9573 const DIMS: usize = 4;
9574
9575 let rt = KhiveRuntime::memory().unwrap();
9576 let (provider, _counter) = ConstVecProvider::new(MODEL, DIMS);
9577 rt.register_embedder(provider);
9578
9579 let ns = Namespace::parse("fault-vec-rollback-embedding-content").unwrap();
9580 let tok = NamespaceToken::for_namespace(ns.clone());
9581
9582 arm_vector_fail(ns.as_str());
9583
9584 let full = "vec-fail rollback target with an embedding-content override";
9585 let head = &full[.."vec-fail rollback target".len()];
9586 let result = rt
9587 .create_note_with_embedding_content(
9588 &tok,
9589 "observation",
9590 None,
9591 full,
9592 Some(head),
9593 None,
9594 None,
9595 vec![],
9596 )
9597 .await;
9598
9599 assert!(
9600 result.is_err(),
9601 "create_note_with_embedding_content must propagate the injected vector failure"
9602 );
9603 let err_msg = result.unwrap_err().to_string();
9604 assert!(
9605 err_msg.contains("injected vector failure"),
9606 "error must carry injection message; got: {err_msg}"
9607 );
9608
9609 let notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
9614 assert!(
9615 notes.is_empty(),
9616 "compensation must remove note row after vector failure; got {notes:?}"
9617 );
9618 }
9619
9620 #[tokio::test]
9623 async fn soft_delete_entity_removes_indexes() {
9624 let rt = rt();
9625 let tok = NamespaceToken::local();
9626 let entity = rt
9627 .create_entity(
9628 &tok,
9629 "concept",
9630 None,
9631 "QuantumEntanglement",
9632 Some("unique FTS term xzqjwv for soft delete test"),
9633 None,
9634 vec![],
9635 )
9636 .await
9637 .unwrap();
9638
9639 let ns = tok.namespace().as_str().to_string();
9640
9641 let before = rt
9642 .text(&tok)
9643 .unwrap()
9644 .search(TextSearchRequest {
9645 query: "xzqjwv".to_string(),
9646 mode: TextQueryMode::Plain,
9647 filter: Some(TextFilter {
9648 namespaces: vec![ns.clone()],
9649 ..Default::default()
9650 }),
9651 top_k: 10,
9652 snippet_chars: 100,
9653 })
9654 .await
9655 .unwrap();
9656 assert!(
9657 before.iter().any(|h| h.subject_id == entity.id),
9658 "entity must be in FTS before soft-delete"
9659 );
9660
9661 let deleted = rt.delete_entity(&tok, entity.id, false).await.unwrap();
9662 assert!(deleted, "soft delete must return true");
9663
9664 let after = rt
9665 .text(&tok)
9666 .unwrap()
9667 .search(TextSearchRequest {
9668 query: "xzqjwv".to_string(),
9669 mode: TextQueryMode::Plain,
9670 filter: Some(TextFilter {
9671 namespaces: vec![ns],
9672 ..Default::default()
9673 }),
9674 top_k: 10,
9675 snippet_chars: 100,
9676 })
9677 .await
9678 .unwrap();
9679 assert!(
9680 after.iter().all(|h| h.subject_id != entity.id),
9681 "soft-deleted entity must be removed from FTS index"
9682 );
9683 }
9684
9685 #[tokio::test]
9686 async fn soft_delete_note_removes_indexes() {
9687 let rt = rt();
9688 let tok = NamespaceToken::local();
9689 let note = rt
9690 .create_note(
9691 &tok,
9692 "observation",
9693 None,
9694 "SpectralDecomposition unique term yvwkqz for soft delete test",
9695 Some(0.7),
9696 None,
9697 vec![],
9698 )
9699 .await
9700 .unwrap();
9701
9702 let before = rt
9703 .search_notes(&tok, "yvwkqz", None, 10, None, false, &[], None)
9704 .await
9705 .unwrap();
9706 assert!(
9707 before.iter().any(|h| h.note_id == note.id),
9708 "note must be in FTS before soft-delete"
9709 );
9710
9711 let deleted = rt.delete_note(&tok, note.id, false).await.unwrap();
9712 assert!(deleted, "soft delete must return true");
9713
9714 let after = rt
9715 .search_notes(&tok, "yvwkqz", None, 10, None, false, &[], None)
9716 .await
9717 .unwrap();
9718 assert!(
9719 after.iter().all(|h| h.note_id != note.id),
9720 "soft-deleted note must be removed from FTS index"
9721 );
9722 }
9723
9724 #[tokio::test]
9727 async fn link_extends_document_to_document_returns_invalid_input() {
9728 let rt = rt();
9729 let tok = NamespaceToken::local();
9730 let d1 = rt
9731 .create_entity(&tok, "document", None, "DocA", None, None, vec![])
9732 .await
9733 .unwrap();
9734 let d2 = rt
9735 .create_entity(&tok, "document", None, "DocB", None, None, vec![])
9736 .await
9737 .unwrap();
9738 let result = rt
9739 .link(&tok, d1.id, d2.id, EdgeRelation::Extends, 1.0, None)
9740 .await;
9741 assert!(
9742 result.is_err(),
9743 "F010: document->document Extends must be rejected by the base allowlist; \
9744 current generic entity fallthrough incorrectly accepts it"
9745 );
9746 }
9747
9748 #[tokio::test]
9750 async fn link_extends_concept_to_concept_succeeds() {
9751 let rt = rt();
9752 let tok = NamespaceToken::local();
9753 let a = rt
9754 .create_entity(&tok, "concept", None, "CA", None, None, vec![])
9755 .await
9756 .unwrap();
9757 let b = rt
9758 .create_entity(&tok, "concept", None, "CB", None, None, vec![])
9759 .await
9760 .unwrap();
9761 let result = rt
9762 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
9763 .await;
9764 assert!(
9765 result.is_ok(),
9766 "F010: concept->concept Extends must be allowed (base allowlist)"
9767 );
9768 }
9769
9770 #[tokio::test]
9772 async fn link_symmetric_relation_canonicalizes_endpoint_order() {
9773 use khive_storage::EdgeFilter;
9774 let rt = rt();
9775 let tok = NamespaceToken::local();
9776 let a = rt
9777 .create_entity(&tok, "concept", None, "ConceptP", None, None, vec![])
9778 .await
9779 .unwrap();
9780 let b = rt
9781 .create_entity(&tok, "concept", None, "ConceptQ", None, None, vec![])
9782 .await
9783 .unwrap();
9784 rt.link(&tok, a.id, b.id, EdgeRelation::CompetesWith, 1.0, None)
9786 .await
9787 .unwrap();
9788 rt.link(&tok, b.id, a.id, EdgeRelation::CompetesWith, 1.0, None)
9789 .await
9790 .unwrap();
9791 let count = rt
9792 .graph(&tok)
9793 .unwrap()
9794 .count_edges(EdgeFilter::default())
9795 .await
9796 .unwrap();
9797 assert_eq!(
9798 count,
9799 1,
9800 "F012: CompetesWith is symmetric; A->B and B->A must deduplicate to one canonical row; \
9801 found {count} rows (canonicalization not yet implemented)"
9802 );
9803 }
9804
9805 #[tokio::test]
9807 async fn f010_supersedes_document_to_document_allowed() {
9808 let rt = rt();
9809 let tok = NamespaceToken::local();
9810 let a = rt
9811 .create_entity(&tok, "document", None, "DocA", None, None, vec![])
9812 .await
9813 .unwrap();
9814 let b = rt
9815 .create_entity(&tok, "document", None, "DocB", None, None, vec![])
9816 .await
9817 .unwrap();
9818 let result = rt
9819 .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
9820 .await;
9821 assert!(
9822 result.is_ok(),
9823 "document->document Supersedes must be allowed (allowlist), got {result:?}"
9824 );
9825 }
9826
9827 #[tokio::test]
9828 async fn f010_supersedes_artifact_to_artifact_allowed() {
9829 let rt = rt();
9830 let tok = NamespaceToken::local();
9831 let a = rt
9832 .create_entity(&tok, "artifact", None, "ArtA", None, None, vec![])
9833 .await
9834 .unwrap();
9835 let b = rt
9836 .create_entity(&tok, "artifact", None, "ArtB", None, None, vec![])
9837 .await
9838 .unwrap();
9839 let result = rt
9840 .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
9841 .await;
9842 assert!(
9843 result.is_ok(),
9844 "artifact->artifact Supersedes must be allowed (allowlist), got {result:?}"
9845 );
9846 }
9847
9848 #[tokio::test]
9849 async fn f010_supersedes_service_to_service_allowed() {
9850 let rt = rt();
9851 let tok = NamespaceToken::local();
9852 let a = rt
9853 .create_entity(&tok, "service", None, "SvcA", None, None, vec![])
9854 .await
9855 .unwrap();
9856 let b = rt
9857 .create_entity(&tok, "service", None, "SvcB", None, None, vec![])
9858 .await
9859 .unwrap();
9860 let result = rt
9861 .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
9862 .await;
9863 assert!(
9864 result.is_ok(),
9865 "service->service Supersedes must be allowed (allowlist), got {result:?}"
9866 );
9867 }
9868
9869 #[tokio::test]
9870 async fn f010_supersedes_dataset_to_dataset_allowed() {
9871 let rt = rt();
9872 let tok = NamespaceToken::local();
9873 let a = rt
9874 .create_entity(&tok, "dataset", None, "DataA", None, None, vec![])
9875 .await
9876 .unwrap();
9877 let b = rt
9878 .create_entity(&tok, "dataset", None, "DataB", None, None, vec![])
9879 .await
9880 .unwrap();
9881 let result = rt
9882 .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
9883 .await;
9884 assert!(
9885 result.is_ok(),
9886 "dataset->dataset Supersedes must be allowed (allowlist), got {result:?}"
9887 );
9888 }
9889
9890 #[tokio::test]
9892 async fn f010_supersedes_project_to_project_rejected() {
9893 let rt = rt();
9894 let tok = NamespaceToken::local();
9895 let a = rt
9896 .create_entity(&tok, "project", None, "ProjA", None, None, vec![])
9897 .await
9898 .unwrap();
9899 let b = rt
9900 .create_entity(&tok, "project", None, "ProjB", None, None, vec![])
9901 .await
9902 .unwrap();
9903 let result = rt
9904 .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
9905 .await;
9906 assert!(
9907 matches!(result, Err(RuntimeError::InvalidInput(_))),
9908 "project->project Supersedes must be rejected (not in allowlist), got {result:?}"
9909 );
9910 }
9911
9912 #[tokio::test]
9913 async fn f010_supersedes_person_to_person_rejected() {
9914 let rt = rt();
9915 let tok = NamespaceToken::local();
9916 let a = rt
9917 .create_entity(&tok, "person", None, "Alice", None, None, vec![])
9918 .await
9919 .unwrap();
9920 let b = rt
9921 .create_entity(&tok, "person", None, "Bob", None, None, vec![])
9922 .await
9923 .unwrap();
9924 let result = rt
9925 .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
9926 .await;
9927 assert!(
9928 matches!(result, Err(RuntimeError::InvalidInput(_))),
9929 "person->person Supersedes must be rejected (not in allowlist), got {result:?}"
9930 );
9931 }
9932
9933 #[tokio::test]
9934 async fn f010_supersedes_org_to_org_rejected() {
9935 let rt = rt();
9936 let tok = NamespaceToken::local();
9937 let a = rt
9938 .create_entity(&tok, "org", None, "OrgA", None, None, vec![])
9939 .await
9940 .unwrap();
9941 let b = rt
9942 .create_entity(&tok, "org", None, "OrgB", None, None, vec![])
9943 .await
9944 .unwrap();
9945 let result = rt
9946 .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
9947 .await;
9948 assert!(
9949 matches!(result, Err(RuntimeError::InvalidInput(_))),
9950 "org->org Supersedes must be rejected (not in allowlist), got {result:?}"
9951 );
9952 }
9953
9954 #[tokio::test]
9956 async fn f010_supersedes_same_kind_entity_allowed() {
9957 let rt = rt();
9958 let tok = NamespaceToken::local();
9959 let a = rt
9960 .create_entity(&tok, "concept", None, "OldV", None, None, vec![])
9961 .await
9962 .unwrap();
9963 let b = rt
9964 .create_entity(&tok, "concept", None, "NewV", None, None, vec![])
9965 .await
9966 .unwrap();
9967 let result = rt
9968 .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
9969 .await;
9970 assert!(
9971 result.is_ok(),
9972 "concept->concept Supersedes must be allowed by the base allowlist, got {result:?}"
9973 );
9974 }
9975
9976 #[tokio::test]
9980 async fn f161_link_always_writes_null_target_backend() {
9981 let rt = rt();
9982 let tok = NamespaceToken::local();
9983 let a = rt
9984 .create_entity(&tok, "concept", None, "A", None, None, vec![])
9985 .await
9986 .unwrap();
9987 let b = rt
9988 .create_entity(&tok, "concept", None, "B", None, None, vec![])
9989 .await
9990 .unwrap();
9991 let edge = rt
9992 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
9993 .await
9994 .unwrap();
9995 assert!(
9996 edge.target_backend.is_none(),
9997 "F161: target_backend must be None for locally-routed edges; got {:?}",
9998 edge.target_backend
9999 );
10000 }
10001
10002 #[tokio::test]
10004 async fn f161_link_many_always_writes_null_target_backend() {
10005 let rt = rt();
10006 let tok = NamespaceToken::local();
10007 let a = rt
10008 .create_entity(&tok, "concept", None, "A", None, None, vec![])
10009 .await
10010 .unwrap();
10011 let b = rt
10012 .create_entity(&tok, "concept", None, "B", None, None, vec![])
10013 .await
10014 .unwrap();
10015 let c = rt
10016 .create_entity(&tok, "concept", None, "C", None, None, vec![])
10017 .await
10018 .unwrap();
10019 let specs = vec![
10020 LinkSpec {
10021 namespace: None,
10022 source_id: a.id,
10023 target_id: b.id,
10024 relation: EdgeRelation::Extends,
10025 weight: 1.0,
10026 metadata: None,
10027 },
10028 LinkSpec {
10029 namespace: None,
10030 source_id: a.id,
10031 target_id: c.id,
10032 relation: EdgeRelation::Enables,
10033 weight: 1.0,
10034 metadata: None,
10035 },
10036 ];
10037 let edges = rt.link_many(&tok, specs).await.unwrap();
10038 for edge in &edges {
10039 assert!(
10040 edge.target_backend.is_none(),
10041 "F161: target_backend must be None for locally-routed edges in link_many; got {:?}",
10042 edge.target_backend
10043 );
10044 }
10045 }
10046
10047 #[tokio::test]
10050 async fn f012_symmetric_neighbors_visible_from_both_endpoints() {
10051 let rt = rt();
10052 let tok = NamespaceToken::local();
10053 let a = rt
10054 .create_entity(&tok, "concept", None, "A", None, None, vec![])
10055 .await
10056 .unwrap();
10057 let b = rt
10058 .create_entity(&tok, "concept", None, "B", None, None, vec![])
10059 .await
10060 .unwrap();
10061 rt.link(&tok, a.id, b.id, EdgeRelation::CompetesWith, 1.0, None)
10063 .await
10064 .unwrap();
10065 let from_a = rt
10067 .neighbors(
10068 &tok,
10069 a.id,
10070 Direction::Out,
10071 None,
10072 Some(vec![EdgeRelation::CompetesWith]),
10073 )
10074 .await
10075 .unwrap();
10076 let from_b = rt
10077 .neighbors(
10078 &tok,
10079 b.id,
10080 Direction::Out,
10081 None,
10082 Some(vec![EdgeRelation::CompetesWith]),
10083 )
10084 .await
10085 .unwrap();
10086 assert_eq!(
10087 from_a.len(),
10088 1,
10089 "node A must see competes_with neighbor from Direction::Out (F012); got {from_a:?}"
10090 );
10091 assert_eq!(
10092 from_b.len(),
10093 1,
10094 "node B must see competes_with neighbor from Direction::Out (F012); got {from_b:?}"
10095 );
10096 }
10097
10098 #[tokio::test]
10100 async fn f010_supersedes_cross_kind_entity_rejected() {
10101 let rt = rt();
10102 let tok = NamespaceToken::local();
10103 let concept = rt
10104 .create_entity(&tok, "concept", None, "MyConcept", None, None, vec![])
10105 .await
10106 .unwrap();
10107 let doc = rt
10108 .create_entity(&tok, "document", None, "MyDoc", None, None, vec![])
10109 .await
10110 .unwrap();
10111 let result = rt
10112 .link(
10113 &tok,
10114 concept.id,
10115 doc.id,
10116 EdgeRelation::Supersedes,
10117 1.0,
10118 None,
10119 )
10120 .await;
10121 assert!(
10122 matches!(result, Err(RuntimeError::InvalidInput(_))),
10123 "concept->document Supersedes must be rejected by the base allowlist, got {result:?}"
10124 );
10125 }
10126
10127 #[tokio::test]
10130 async fn delete_note_cross_namespace_succeeds() {
10131 let rt = rt();
10132 let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
10133 let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
10134 let note = rt
10135 .create_note(
10136 &ns_a,
10137 "observation",
10138 None,
10139 "note in ns-a",
10140 Some(0.8),
10141 None,
10142 vec![],
10143 )
10144 .await
10145 .unwrap();
10146
10147 let result = rt.delete_note(&ns_b, note.id, false).await;
10149 assert!(
10150 result.unwrap(),
10151 "cross-namespace delete_note (soft) must return Ok(true)"
10152 );
10153
10154 let note_store = rt.notes(&ns_a).unwrap();
10156 let gone = note_store.get_note(note.id).await.unwrap();
10157 assert!(
10158 gone.is_none(),
10159 "note must be soft-deleted in its home namespace after cross-ns delete"
10160 );
10161
10162 let note2 = rt
10164 .create_note(
10165 &ns_a,
10166 "observation",
10167 None,
10168 "note2 in ns-a",
10169 Some(0.5),
10170 None,
10171 vec![],
10172 )
10173 .await
10174 .unwrap();
10175 let hard_result = rt.delete_note(&ns_b, note2.id, true).await;
10176 assert!(
10177 hard_result.unwrap(),
10178 "cross-namespace hard delete_note must return Ok(true)"
10179 );
10180 let gone2 = rt
10181 .get_note_including_deleted(&ns_a, note2.id)
10182 .await
10183 .unwrap();
10184 assert!(
10185 gone2.is_none(),
10186 "hard-deleted note must not appear even in including_deleted query"
10187 );
10188 }
10189
10190 #[tokio::test]
10198 async fn link_many_overlapping_triple_returns_persisted_ids() {
10199 let rt = rt();
10200 let tok = NamespaceToken::local();
10201 let a = rt
10202 .create_entity(&tok, "concept", None, "A", None, None, vec![])
10203 .await
10204 .unwrap();
10205 let b = rt
10206 .create_entity(&tok, "concept", None, "B", None, None, vec![])
10207 .await
10208 .unwrap();
10209
10210 let spec = || LinkSpec {
10211 namespace: None,
10212 source_id: a.id,
10213 target_id: b.id,
10214 relation: EdgeRelation::Extends,
10215 weight: 1.0,
10216 metadata: None,
10217 };
10218
10219 let first = rt.link_many(&tok, vec![spec()]).await.unwrap();
10221 assert_eq!(first.len(), 1);
10222 let persisted_id: Uuid = first[0].id.into();
10223
10224 let second = rt.link_many(&tok, vec![spec()]).await.unwrap();
10227 assert_eq!(second.len(), 1);
10228 let second_id: Uuid = second[0].id.into();
10229
10230 assert_eq!(
10231 persisted_id, second_id,
10232 "link_many with an existing triple must return the persisted row ID ({persisted_id}), \
10233 not a new phantom ID ({second_id})"
10234 );
10235
10236 let count = rt
10238 .count_edges(&tok, crate::curation::EdgeListFilter::default())
10239 .await
10240 .unwrap();
10241 assert_eq!(count, 1, "upsert must not duplicate the edge row");
10242 }
10243
10244 #[tokio::test]
10247 async fn create_many_persists_all_entities() {
10248 let rt = rt();
10249 let tok = NamespaceToken::local();
10250
10251 let specs: Vec<EntityCreateSpec> = (0..5)
10252 .map(|i| EntityCreateSpec {
10253 kind: "concept".into(),
10254 entity_type: None,
10255 name: format!("BulkConcept-{i}"),
10256 description: Some(format!("desc {i}")),
10257 properties: None,
10258 tags: vec!["bulk-test".into()],
10259 })
10260 .collect();
10261
10262 let entities = rt.create_many(&tok, specs).await.unwrap();
10263 assert_eq!(entities.len(), 5, "all 5 entities must be returned");
10264
10265 for entity in &entities {
10267 let fetched = rt.get_entity(&tok, entity.id).await.unwrap();
10268 assert_eq!(fetched.id, entity.id);
10269 }
10270 }
10271
10272 #[tokio::test]
10273 async fn create_many_empty_name_rejects_atomically() {
10274 let rt = rt();
10275 let tok = NamespaceToken::local();
10276
10277 let specs = vec![
10278 EntityCreateSpec {
10279 kind: "concept".into(),
10280 entity_type: None,
10281 name: "ValidEntity".into(),
10282 description: None,
10283 properties: None,
10284 tags: vec![],
10285 },
10286 EntityCreateSpec {
10287 kind: "concept".into(),
10288 entity_type: None,
10289 name: "".into(), description: None,
10291 properties: None,
10292 tags: vec![],
10293 },
10294 ];
10295
10296 let result = rt.create_many(&tok, specs).await;
10297 assert!(
10298 matches!(result, Err(RuntimeError::InvalidInput(_))),
10299 "empty name must produce InvalidInput error"
10300 );
10301
10302 let rows = rt.list_entities(&tok, None, None, 100, 0).await.unwrap();
10304 assert_eq!(
10305 rows.len(),
10306 0,
10307 "atomic rejection must leave storage unchanged"
10308 );
10309 }
10310
10311 #[tokio::test]
10313 async fn create_many_rejects_unknown_entity_type_when_validator_installed() {
10314 let rt = rt();
10315 let tok = NamespaceToken::local();
10316
10317 rt.install_entity_type_validator(Arc::new(|kind, entity_type| {
10319 let Some(raw) = entity_type else {
10320 return Ok(None);
10321 };
10322 if kind == "concept" && raw == "algorithm" {
10323 return Ok(Some("algorithm".to_string()));
10324 }
10325 Err(RuntimeError::InvalidInput(format!(
10326 "unknown entity_type {raw:?} for {kind:?}; valid: algorithm"
10327 )))
10328 }));
10329
10330 let bad_spec = vec![EntityCreateSpec {
10331 kind: "concept".into(),
10332 entity_type: Some("not_a_registered_type".into()),
10333 name: "ShouldNotLand".into(),
10334 description: None,
10335 properties: None,
10336 tags: vec![],
10337 }];
10338
10339 let result = rt.create_many(&tok, bad_spec).await;
10340 assert!(
10341 matches!(result, Err(RuntimeError::InvalidInput(_))),
10342 "unknown entity_type must be rejected by the runtime-layer validator; got {result:?}"
10343 );
10344
10345 let rows = rt.list_entities(&tok, None, None, 100, 0).await.unwrap();
10347 assert_eq!(
10348 rows.len(),
10349 0,
10350 "validator rejection must leave storage empty"
10351 );
10352 }
10353
10354 #[tokio::test]
10356 async fn create_many_accepts_valid_entity_type_via_validator() {
10357 let rt = rt();
10358 let tok = NamespaceToken::local();
10359
10360 rt.install_entity_type_validator(Arc::new(|kind, entity_type| {
10361 let Some(raw) = entity_type else {
10362 return Ok(None);
10363 };
10364 if kind == "concept" && raw == "algorithm" {
10365 return Ok(Some("algorithm".to_string()));
10366 }
10367 Err(RuntimeError::InvalidInput(format!(
10368 "unknown entity_type {raw:?} for {kind:?}"
10369 )))
10370 }));
10371
10372 let specs = vec![EntityCreateSpec {
10373 kind: "concept".into(),
10374 entity_type: Some("algorithm".into()),
10375 name: "BubbleSort".into(),
10376 description: None,
10377 properties: None,
10378 tags: vec![],
10379 }];
10380
10381 let entities = rt.create_many(&tok, specs).await.unwrap();
10382 assert_eq!(entities.len(), 1, "valid entity_type must be accepted");
10383 assert_eq!(
10384 entities[0].entity_type.as_deref(),
10385 Some("algorithm"),
10386 "entity_type must be stored as returned by the validator"
10387 );
10388 }
10389
10390 #[tokio::test]
10395 async fn create_many_fts_failure_rolls_back_both_substrates() {
10396 let ns = format!("fts-fail-many-{}", uuid::Uuid::new_v4().as_simple());
10399 let rt = rt();
10400 let tok = NamespaceToken::for_namespace(Namespace::parse(&ns).unwrap());
10401
10402 let specs = vec![
10403 EntityCreateSpec {
10404 kind: "concept".into(),
10405 entity_type: None,
10406 name: "FtsRollbackA".into(),
10407 description: None,
10408 properties: None,
10409 tags: vec![],
10410 },
10411 EntityCreateSpec {
10412 kind: "concept".into(),
10413 entity_type: None,
10414 name: "FtsRollbackB".into(),
10415 description: None,
10416 properties: None,
10417 tags: vec![],
10418 },
10419 ];
10420
10421 arm_fts_fail_many(&ns);
10422 let result = rt.create_many(&tok, specs).await;
10423
10424 assert!(
10425 result.is_err(),
10426 "create_many must return Err when FTS write fails"
10427 );
10428
10429 let entity_rows = rt.list_entities(&tok, None, None, 100, 0).await.unwrap();
10431 assert_eq!(
10432 entity_rows.len(),
10433 0,
10434 "entity rows must be rolled back on FTS failure; found {entity_rows:?}"
10435 );
10436
10437 let fts = rt.text(&tok).unwrap();
10439 let fts_count = fts
10440 .count(TextFilter {
10441 ids: vec![],
10442 kinds: vec![],
10443 namespaces: vec![ns.clone()],
10444 })
10445 .await
10446 .unwrap();
10447 assert_eq!(
10448 fts_count, 0,
10449 "fts_entities must be empty after FTS-failure rollback; found {fts_count}"
10450 );
10451 }
10452
10453 #[tokio::test]
10462 async fn create_many_fts_partial_failure_rolls_back_both_substrates() {
10463 let ns = format!("fts-fail-partial-{}", uuid::Uuid::new_v4().as_simple());
10464 let rt = rt();
10465 let tok = NamespaceToken::for_namespace(Namespace::parse(&ns).unwrap());
10466
10467 let specs = vec![
10468 EntityCreateSpec {
10469 kind: "concept".into(),
10470 entity_type: None,
10471 name: "PartialRollbackA".into(),
10472 description: None,
10473 properties: None,
10474 tags: vec![],
10475 },
10476 EntityCreateSpec {
10477 kind: "concept".into(),
10478 entity_type: None,
10479 name: "PartialRollbackB".into(),
10480 description: None,
10481 properties: None,
10482 tags: vec![],
10483 },
10484 ];
10485
10486 arm_fts_fail_many_partial(&ns);
10487 let result = rt.create_many(&tok, specs).await;
10488
10489 assert!(
10490 result.is_err(),
10491 "create_many must return Err when FTS summary.failed > 0"
10492 );
10493
10494 let entity_rows = rt.list_entities(&tok, None, None, 100, 0).await.unwrap();
10496 assert_eq!(
10497 entity_rows.len(),
10498 0,
10499 "entity rows must be rolled back when FTS summary.failed > 0; found {entity_rows:?}"
10500 );
10501
10502 let fts = rt.text(&tok).unwrap();
10504 let fts_count = fts
10505 .count(TextFilter {
10506 ids: vec![],
10507 kinds: vec![],
10508 namespaces: vec![ns.clone()],
10509 })
10510 .await
10511 .unwrap();
10512 assert_eq!(
10513 fts_count, 0,
10514 "fts_entities must be empty after partial-FTS-failure rollback; found {fts_count}"
10515 );
10516 }
10517
10518 #[tokio::test]
10521 async fn get_edge_cross_namespace_succeeds() {
10522 let rt = rt();
10523 let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
10524 let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
10525
10526 let src = rt
10527 .create_entity(&ns_a, "concept", None, "Src", None, None, vec![])
10528 .await
10529 .unwrap();
10530 let tgt = rt
10531 .create_entity(&ns_a, "concept", None, "Tgt", None, None, vec![])
10532 .await
10533 .unwrap();
10534 let edge = rt
10535 .link(&ns_a, src.id, tgt.id, EdgeRelation::Extends, 1.0, None)
10536 .await
10537 .unwrap();
10538
10539 let own_ns = rt.get_edge(&ns_a, Uuid::from(edge.id)).await;
10541 assert!(
10542 own_ns.is_ok() && own_ns.unwrap().is_some(),
10543 "edge must be visible in its own namespace"
10544 );
10545
10546 let cross_ns = rt.get_edge(&ns_b, Uuid::from(edge.id)).await;
10548 assert!(
10549 matches!(cross_ns, Ok(Some(_))),
10550 "cross-namespace get_edge must return Ok(Some(_)) after PR-A1, got {cross_ns:?}"
10551 );
10552
10553 let absent = rt.get_edge(&ns_b, Uuid::new_v4()).await;
10555 assert!(
10556 matches!(absent, Ok(None)),
10557 "absent edge must return Ok(None), got {absent:?}"
10558 );
10559 }
10560
10561 #[tokio::test]
10568 async fn traverse_cross_namespace_root_is_accepted() {
10569 use khive_storage::types::TraversalOptions;
10570
10571 let rt = rt();
10572 let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
10573 let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
10574
10575 let a = rt
10576 .create_entity(&ns_a, "concept", None, "A", None, None, vec![])
10577 .await
10578 .unwrap();
10579 rt.create_entity(&ns_a, "concept", None, "B", None, None, vec![])
10580 .await
10581 .unwrap();
10582 rt.link(&ns_a, a.id, a.id, EdgeRelation::Extends, 1.0, None)
10583 .await
10584 .ok(); let result = rt
10589 .traverse(
10590 &ns_b,
10591 TraversalRequest {
10592 roots: vec![a.id],
10593 options: TraversalOptions {
10594 max_depth: 1,
10595 direction: Direction::Out,
10596 ..Default::default()
10597 },
10598 include_roots: true,
10599 include_properties: false,
10600 },
10601 )
10602 .await;
10603 assert!(result.is_ok(), "traverse must not error; got {:?}", result);
10604 }
10605
10606 async fn count_all_incident_edges(rt: &KhiveRuntime, node_id: Uuid, ns: &str) -> u64 {
10615 let mut reader = rt.sql().reader().await.expect("sql reader must open");
10616 let row = reader
10617 .query_scalar(SqlStatement {
10618 sql: "SELECT COUNT(*) FROM graph_edges \
10619 WHERE namespace = ?1 AND (source_id = ?2 OR target_id = ?2)"
10620 .into(),
10621 params: vec![
10622 SqlValue::Text(ns.to_string()),
10623 SqlValue::Text(node_id.to_string()),
10624 ],
10625 label: Some("count_all_incident_edges".into()),
10626 })
10627 .await
10628 .expect("count query must succeed");
10629 match row {
10630 Some(SqlValue::Integer(n)) => n as u64,
10631 _ => panic!("count must return an integer"),
10632 }
10633 }
10634
10635 #[tokio::test]
10636 async fn hard_delete_entity_purges_already_soft_deleted_incident_edge() {
10637 let rt = rt();
10638 let tok = NamespaceToken::local();
10639 let ns = tok.namespace().to_string();
10640
10641 let a = rt
10642 .create_entity(&tok, "concept", None, "SrcA", None, None, vec![])
10643 .await
10644 .unwrap();
10645 let b = rt
10646 .create_entity(&tok, "concept", None, "TgtB", None, None, vec![])
10647 .await
10648 .unwrap();
10649
10650 rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
10651 .await
10652 .unwrap();
10653
10654 let edge_hit = rt
10656 .neighbors(&tok, a.id, Direction::Out, None, None)
10657 .await
10658 .unwrap();
10659 assert_eq!(edge_hit.len(), 1, "edge must exist before soft-delete");
10660 let edge_uuid = edge_hit[0].edge_id;
10661 rt.delete_edge(&tok, edge_uuid, false).await.unwrap();
10662
10663 let visible = rt
10665 .neighbors(&tok, a.id, Direction::Out, None, None)
10666 .await
10667 .unwrap();
10668 assert!(visible.is_empty(), "soft-deleted edge must be invisible");
10669 let raw_before = count_all_incident_edges(&rt, a.id, &ns).await;
10670 assert_eq!(
10671 raw_before, 1,
10672 "soft-deleted edge must still be a physical row"
10673 );
10674
10675 rt.delete_entity(&tok, a.id, true).await.unwrap();
10677
10678 let raw_after = count_all_incident_edges(&rt, a.id, &ns).await;
10679 assert_eq!(
10680 raw_after, 0,
10681 "purge_incident_edges must physically remove soft-deleted edge rows (ADR-002)"
10682 );
10683 }
10684
10685 #[tokio::test]
10686 async fn hard_delete_note_purges_already_soft_deleted_incident_edge() {
10687 let rt = rt();
10688 let tok = NamespaceToken::local();
10689 let ns = tok.namespace().to_string();
10690
10691 let target = rt
10692 .create_note(
10693 &tok,
10694 "observation",
10695 None,
10696 "purge-cascade target note",
10697 Some(0.5),
10698 None,
10699 vec![],
10700 )
10701 .await
10702 .unwrap();
10703 let annotating = rt
10704 .create_note(
10705 &tok,
10706 "insight",
10707 None,
10708 "annotator note",
10709 Some(0.5),
10710 None,
10711 vec![target.id],
10712 )
10713 .await
10714 .unwrap();
10715
10716 let edge_hit = rt
10718 .neighbors(
10719 &tok,
10720 annotating.id,
10721 Direction::Out,
10722 None,
10723 Some(vec![EdgeRelation::Annotates]),
10724 )
10725 .await
10726 .unwrap();
10727 assert_eq!(edge_hit.len(), 1, "annotates edge must exist");
10728 let edge_uuid = edge_hit[0].edge_id;
10729 rt.delete_edge(&tok, edge_uuid, false).await.unwrap();
10730
10731 let raw_before = count_all_incident_edges(&rt, target.id, &ns).await;
10732 assert_eq!(
10733 raw_before, 1,
10734 "soft-deleted edge must still be a physical row before note purge"
10735 );
10736
10737 rt.delete_note(&tok, target.id, true).await.unwrap();
10739
10740 let raw_after = count_all_incident_edges(&rt, target.id, &ns).await;
10741 assert_eq!(
10742 raw_after, 0,
10743 "purge_incident_edges must physically remove soft-deleted edge rows on note purge (ADR-002)"
10744 );
10745 }
10746
10747 async fn count_all_incident_edges_global(rt: &KhiveRuntime, node_id: Uuid) -> u64 {
10755 let mut reader = rt.sql().reader().await.expect("sql reader must open");
10756 let row = reader
10757 .query_scalar(SqlStatement {
10758 sql: "SELECT COUNT(*) FROM graph_edges WHERE source_id = ?1 OR target_id = ?1"
10759 .into(),
10760 params: vec![SqlValue::Text(node_id.to_string())],
10761 label: Some("count_all_incident_edges_global".into()),
10762 })
10763 .await
10764 .expect("count query must succeed");
10765 match row {
10766 Some(SqlValue::Integer(n)) => n as u64,
10767 _ => panic!("count must return an integer"),
10768 }
10769 }
10770
10771 #[tokio::test]
10772 async fn cross_namespace_hard_delete_entity_purges_all_incident_edges() {
10773 let rt = rt();
10777 let ns_owner = NamespaceToken::for_namespace(Namespace::parse("ns-owner").unwrap());
10778 let ns_caller = NamespaceToken::for_namespace(Namespace::parse("ns-caller").unwrap());
10779
10780 let entity = rt
10781 .create_entity(
10782 &ns_owner,
10783 "concept",
10784 None,
10785 "ForeignEntity",
10786 None,
10787 None,
10788 vec![],
10789 )
10790 .await
10791 .unwrap();
10792 let peer = rt
10793 .create_entity(&ns_owner, "concept", None, "Peer", None, None, vec![])
10794 .await
10795 .unwrap();
10796 rt.link(
10798 &ns_owner,
10799 entity.id,
10800 peer.id,
10801 EdgeRelation::Extends,
10802 1.0,
10803 None,
10804 )
10805 .await
10806 .unwrap();
10807 rt.link(
10808 &ns_owner,
10809 peer.id,
10810 entity.id,
10811 EdgeRelation::Extends,
10812 1.0,
10813 None,
10814 )
10815 .await
10816 .unwrap();
10817
10818 let before = count_all_incident_edges_global(&rt, entity.id).await;
10819 assert_eq!(before, 2, "two incident edges must exist before delete");
10820
10821 let deleted = rt.delete_entity(&ns_caller, entity.id, true).await.unwrap();
10823 assert!(deleted, "cross-ns hard delete must return true");
10824
10825 let after = count_all_incident_edges_global(&rt, entity.id).await;
10827 assert_eq!(
10828 after, 0,
10829 "purge_incident_edges must remove all incident edges across namespaces (ADR-002, ADR-007)"
10830 );
10831 }
10832
10833 async fn count_edge_rows_by_id(rt: &KhiveRuntime, edge_id: Uuid, ns: &str) -> u64 {
10843 let mut reader = rt.sql().reader().await.expect("sql reader must open");
10844 let row = reader
10845 .query_scalar(SqlStatement {
10846 sql: "SELECT COUNT(*) FROM graph_edges WHERE namespace = ?1 AND id = ?2".into(),
10847 params: vec![
10848 SqlValue::Text(ns.to_string()),
10849 SqlValue::Text(edge_id.to_string()),
10850 ],
10851 label: Some("count_edge_rows_by_id".into()),
10852 })
10853 .await
10854 .expect("count query must succeed");
10855 match row {
10856 Some(SqlValue::Integer(n)) => n as u64,
10857 _ => panic!("count must return an integer"),
10858 }
10859 }
10860
10861 #[tokio::test]
10862 async fn hard_delete_edge_purges_already_soft_deleted_primary_edge() {
10863 let rt = rt();
10864 let tok = NamespaceToken::local();
10865 let ns = tok.namespace().to_string();
10866
10867 let a = rt
10868 .create_entity(&tok, "concept", None, "EA", None, None, vec![])
10869 .await
10870 .unwrap();
10871 let b = rt
10872 .create_entity(&tok, "concept", None, "EB", None, None, vec![])
10873 .await
10874 .unwrap();
10875
10876 let edge = rt
10877 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
10878 .await
10879 .unwrap();
10880 let edge_uuid: Uuid = edge.id.into();
10881
10882 let soft = rt.delete_edge(&tok, edge_uuid, false).await.unwrap();
10884 assert!(soft, "soft delete must succeed");
10885
10886 assert!(
10888 rt.get_edge(&tok, edge_uuid).await.unwrap().is_none(),
10889 "soft-deleted edge must be invisible to get_edge"
10890 );
10891 assert_eq!(
10892 count_edge_rows_by_id(&rt, edge_uuid, &ns).await,
10893 1,
10894 "soft-deleted edge must still be a physical row"
10895 );
10896
10897 let purged = rt.delete_edge(&tok, edge_uuid, true).await.unwrap();
10899 assert!(
10900 purged,
10901 "hard delete of a soft-deleted edge must return true"
10902 );
10903
10904 assert_eq!(
10905 count_edge_rows_by_id(&rt, edge_uuid, &ns).await,
10906 0,
10907 "hard-delete must physically remove the soft-deleted edge row (ADR-002)"
10908 );
10909 }
10910
10911 #[tokio::test]
10912 async fn hard_delete_base_edge_purges_already_soft_deleted_annotates_edge() {
10913 let rt = rt();
10914 let tok = NamespaceToken::local();
10915 let ns = tok.namespace().to_string();
10916
10917 let a = rt
10918 .create_entity(&tok, "concept", None, "CA", None, None, vec![])
10919 .await
10920 .unwrap();
10921 let b = rt
10922 .create_entity(&tok, "concept", None, "CB", None, None, vec![])
10923 .await
10924 .unwrap();
10925
10926 let base_edge = rt
10928 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
10929 .await
10930 .unwrap();
10931 let base_edge_uuid: Uuid = base_edge.id.into();
10932
10933 let note = rt
10935 .create_note(
10936 &tok,
10937 "observation",
10938 None,
10939 "note about base edge",
10940 Some(0.5),
10941 None,
10942 vec![base_edge_uuid],
10943 )
10944 .await
10945 .unwrap();
10946
10947 let ann_hits = rt
10949 .neighbors(
10950 &tok,
10951 note.id,
10952 Direction::Out,
10953 None,
10954 Some(vec![EdgeRelation::Annotates]),
10955 )
10956 .await
10957 .unwrap();
10958 assert_eq!(ann_hits.len(), 1, "annotates edge must exist");
10959 let ann_edge_uuid = ann_hits[0].edge_id;
10960
10961 rt.delete_edge(&tok, ann_edge_uuid, false).await.unwrap();
10963 assert_eq!(
10964 count_edge_rows_by_id(&rt, ann_edge_uuid, &ns).await,
10965 1,
10966 "soft-deleted annotates edge must still be a physical row"
10967 );
10968
10969 let purged = rt.delete_edge(&tok, base_edge_uuid, true).await.unwrap();
10971 assert!(purged, "hard delete of base edge must return true");
10972
10973 assert_eq!(
10974 count_edge_rows_by_id(&rt, ann_edge_uuid, &ns).await,
10975 0,
10976 "hard-delete of base edge must purge already-soft-deleted annotates edge row (ADR-002)"
10977 );
10978 assert_eq!(
10979 count_edge_rows_by_id(&rt, base_edge_uuid, &ns).await,
10980 0,
10981 "hard-delete must physically remove the base edge row"
10982 );
10983 }
10984
10985 #[tokio::test]
10992 async fn create_entity_fts_failure_rolls_back_entity_row() {
10993 let rt = KhiveRuntime::memory().unwrap();
10994 let ns = Namespace::parse("fault-entity-fts").unwrap();
10995 let tok = NamespaceToken::for_namespace(ns.clone());
10996
10997 arm_fts_fail(ns.as_str());
10998
10999 let result = rt
11000 .create_entity(
11001 &tok,
11002 "concept",
11003 None,
11004 "fts-fail rollback target",
11005 None,
11006 None,
11007 vec![],
11008 )
11009 .await;
11010
11011 assert!(
11012 result.is_err(),
11013 "create_entity must propagate the injected FTS failure"
11014 );
11015 let err_msg = result.unwrap_err().to_string();
11016 assert!(
11017 err_msg.contains("injected FTS failure"),
11018 "error must carry injection message; got: {err_msg}"
11019 );
11020
11021 let entities = rt.list_entities(&tok, None, None, 1000, 0).await.unwrap();
11022 assert!(
11023 entities.is_empty(),
11024 "compensation must remove the entity row after FTS failure; got {entities:?}"
11025 );
11026 }
11027
11028 #[tokio::test]
11032 async fn create_entity_vector_failure_rolls_back_entity_row_and_fts() {
11033 const MODEL: &str = "test-entity-vec-inject";
11034 const DIMS: usize = 4;
11035
11036 let rt = KhiveRuntime::memory().unwrap();
11037 let (provider, _counter) = ConstVecProvider::new(MODEL, DIMS);
11038 rt.register_embedder(provider);
11039
11040 let ns = Namespace::parse("fault-entity-vec").unwrap();
11041 let tok = NamespaceToken::for_namespace(ns.clone());
11042
11043 arm_vector_fail(ns.as_str());
11044
11045 let result = rt
11046 .create_entity(
11047 &tok,
11048 "concept",
11049 None,
11050 "vec-fail rollback target",
11051 Some("description so embed body is non-empty"),
11052 None,
11053 vec![],
11054 )
11055 .await;
11056
11057 assert!(
11058 result.is_err(),
11059 "create_entity must propagate the injected vector failure"
11060 );
11061 let err_msg = result.unwrap_err().to_string();
11062 assert!(
11063 err_msg.contains("injected vector failure"),
11064 "error must carry injection message; got: {err_msg}"
11065 );
11066
11067 let entities = rt.list_entities(&tok, None, None, 1000, 0).await.unwrap();
11068 assert!(
11069 entities.is_empty(),
11070 "compensation must remove entity row after vector failure; got {entities:?}"
11071 );
11072
11073 use khive_storage::types::{TextFilter, TextQueryMode, TextSearchRequest};
11075 let fts_hits = rt
11076 .text(&tok)
11077 .unwrap()
11078 .search(TextSearchRequest {
11079 query: "vec-fail rollback target".to_string(),
11080 mode: TextQueryMode::Plain,
11081 filter: Some(TextFilter {
11082 namespaces: vec![ns.as_str().to_string()],
11083 ..Default::default()
11084 }),
11085 top_k: 10,
11086 snippet_chars: 100,
11087 })
11088 .await
11089 .unwrap();
11090 assert!(
11091 fts_hits.is_empty(),
11092 "compensation must remove FTS document after vector failure; got {fts_hits:?}"
11093 );
11094 }
11095
11096 #[tokio::test]
11103 async fn create_entity_multi_model_second_vector_failure_rolls_back_all() {
11104 const DIMS: usize = 4;
11105
11106 let rt = KhiveRuntime::memory().unwrap();
11107 let (provider_a, _ca) = ConstVecProvider::new("model-a", DIMS);
11108 let (provider_b, _cb) = ConstVecProvider::new("model-b", DIMS);
11109 rt.register_embedder(provider_a);
11110 rt.register_embedder(provider_b);
11111
11112 let ns = Namespace::parse("fault-entity-multi").unwrap();
11113 let tok = NamespaceToken::for_namespace(ns.clone());
11114
11115 arm_vector_fail_after(1);
11117
11118 let result = rt
11119 .create_entity(
11120 &tok,
11121 "concept",
11122 None,
11123 "multi-model rollback target",
11124 Some("description for embedding"),
11125 None,
11126 vec![],
11127 )
11128 .await;
11129
11130 assert!(
11131 result.is_err(),
11132 "create_entity must propagate the injected multi-model vector failure"
11133 );
11134
11135 let entities = rt.list_entities(&tok, None, None, 1000, 0).await.unwrap();
11136 assert!(
11137 entities.is_empty(),
11138 "compensation must remove entity row; got {entities:?}"
11139 );
11140
11141 use khive_storage::types::VectorSearchRequest;
11146 let query_vec = vec![1.0_f32; DIMS];
11147 let hits_a = rt
11148 .vectors_for_model(&tok, "model-a")
11149 .unwrap()
11150 .search(VectorSearchRequest {
11151 query_vectors: vec![query_vec.clone()],
11152 top_k: 100,
11153 namespace: Some(ns.as_str().to_string()),
11154 kind: Some(khive_types::SubstrateKind::Entity),
11155 embedding_model: Some("model-a".to_string()),
11156 filter: None,
11157 backend_hints: None,
11158 })
11159 .await
11160 .unwrap();
11161 assert!(
11162 hits_a.is_empty(),
11163 "model-a vector store must be empty after rollback; got {hits_a:?}"
11164 );
11165 let hits_b = rt
11166 .vectors_for_model(&tok, "model-b")
11167 .unwrap()
11168 .search(VectorSearchRequest {
11169 query_vectors: vec![query_vec],
11170 top_k: 100,
11171 namespace: Some(ns.as_str().to_string()),
11172 kind: Some(khive_types::SubstrateKind::Entity),
11173 embedding_model: Some("model-b".to_string()),
11174 filter: None,
11175 backend_hints: None,
11176 })
11177 .await
11178 .unwrap();
11179 assert!(
11180 hits_b.is_empty(),
11181 "model-b vector store must be empty after rollback; got {hits_b:?}"
11182 );
11183 }
11184
11185 #[tokio::test]
11189 async fn update_entity_fans_out_to_all_registered_models() {
11190 const DIMS: usize = 4;
11191
11192 let rt = KhiveRuntime::memory().unwrap();
11193 let (provider_a, _ca) = ConstVecProvider::new("embed-a", DIMS);
11194 let (provider_b, _cb) = ConstVecProvider::new("embed-b", DIMS);
11195 rt.register_embedder(provider_a);
11196 rt.register_embedder(provider_b);
11197
11198 let ns = Namespace::parse("update-entity-fanout").unwrap();
11199 let tok = NamespaceToken::for_namespace(ns.clone());
11200
11201 let entity = rt
11202 .create_entity(
11203 &tok,
11204 "concept",
11205 None,
11206 "FanOutEntity",
11207 Some("initial description"),
11208 None,
11209 vec![],
11210 )
11211 .await
11212 .expect("create_entity must succeed");
11213
11214 use crate::curation::EntityPatch;
11215 let patch = EntityPatch {
11216 description: Some(Some("updated description after fan-out fix".to_string())),
11217 ..Default::default()
11218 };
11219 rt.update_entity(&tok, entity.id, patch)
11220 .await
11221 .expect("update_entity must succeed");
11222
11223 use khive_storage::types::VectorSearchRequest;
11224 let query_vec = vec![1.0_f32; DIMS];
11225
11226 let hits_a = rt
11227 .vectors_for_model(&tok, "embed-a")
11228 .unwrap()
11229 .search(VectorSearchRequest {
11230 query_vectors: vec![query_vec.clone()],
11231 top_k: 10,
11232 namespace: Some(ns.as_str().to_string()),
11233 kind: Some(khive_types::SubstrateKind::Entity),
11234 embedding_model: Some("embed-a".to_string()),
11235 filter: None,
11236 backend_hints: None,
11237 })
11238 .await
11239 .unwrap();
11240 assert!(
11241 hits_a.iter().any(|h| h.subject_id == entity.id),
11242 "embed-a must hold a vector for the entity after update; got {hits_a:?}"
11243 );
11244
11245 let hits_b = rt
11246 .vectors_for_model(&tok, "embed-b")
11247 .unwrap()
11248 .search(VectorSearchRequest {
11249 query_vectors: vec![query_vec],
11250 top_k: 10,
11251 namespace: Some(ns.as_str().to_string()),
11252 kind: Some(khive_types::SubstrateKind::Entity),
11253 embedding_model: Some("embed-b".to_string()),
11254 filter: None,
11255 backend_hints: None,
11256 })
11257 .await
11258 .unwrap();
11259 assert!(
11260 hits_b.iter().any(|h| h.subject_id == entity.id),
11261 "embed-b must hold a vector for the entity after update; got {hits_b:?}"
11262 );
11263 }
11264
11265 #[tokio::test]
11269 async fn update_note_fans_out_to_all_registered_models() {
11270 const DIMS: usize = 4;
11271
11272 let rt = KhiveRuntime::memory().unwrap();
11273 let (provider_a, _ca) = ConstVecProvider::new("embed-a", DIMS);
11274 let (provider_b, _cb) = ConstVecProvider::new("embed-b", DIMS);
11275 rt.register_embedder(provider_a);
11276 rt.register_embedder(provider_b);
11277
11278 let ns = Namespace::parse("update-note-fanout").unwrap();
11279 let tok = NamespaceToken::for_namespace(ns.clone());
11280
11281 let note = rt
11282 .create_note(
11283 &tok,
11284 "observation",
11285 None,
11286 "initial note content for fan-out test",
11287 None,
11288 None,
11289 vec![],
11290 )
11291 .await
11292 .expect("create_note must succeed");
11293
11294 use crate::curation::NotePatch;
11295 let patch = NotePatch {
11296 content: Some("updated content after fan-out fix".to_string()),
11297 ..Default::default()
11298 };
11299 rt.update_note(&tok, note.id, patch)
11300 .await
11301 .expect("update_note must succeed");
11302
11303 use khive_storage::types::VectorSearchRequest;
11304 let query_vec = vec![1.0_f32; DIMS];
11305
11306 let hits_a = rt
11307 .vectors_for_model(&tok, "embed-a")
11308 .unwrap()
11309 .search(VectorSearchRequest {
11310 query_vectors: vec![query_vec.clone()],
11311 top_k: 10,
11312 namespace: Some(ns.as_str().to_string()),
11313 kind: Some(khive_types::SubstrateKind::Note),
11314 embedding_model: Some("embed-a".to_string()),
11315 filter: None,
11316 backend_hints: None,
11317 })
11318 .await
11319 .unwrap();
11320 assert!(
11321 hits_a.iter().any(|h| h.subject_id == note.id),
11322 "embed-a must hold a vector for the note after update; got {hits_a:?}"
11323 );
11324
11325 let hits_b = rt
11326 .vectors_for_model(&tok, "embed-b")
11327 .unwrap()
11328 .search(VectorSearchRequest {
11329 query_vectors: vec![query_vec],
11330 top_k: 10,
11331 namespace: Some(ns.as_str().to_string()),
11332 kind: Some(khive_types::SubstrateKind::Note),
11333 embedding_model: Some("embed-b".to_string()),
11334 filter: None,
11335 backend_hints: None,
11336 })
11337 .await
11338 .unwrap();
11339 assert!(
11340 hits_b.iter().any(|h| h.subject_id == note.id),
11341 "embed-b must hold a vector for the note after update; got {hits_b:?}"
11342 );
11343 }
11344
11345 #[tokio::test]
11353 async fn get_entity_cross_namespace_succeeds() {
11354 let rt = rt();
11355 let leo_tok = NamespaceToken::for_namespace(Namespace::parse("lambda:leo").unwrap());
11357 let entity = rt
11358 .create_entity(&leo_tok, "concept", None, "Peer-Entity", None, None, vec![])
11359 .await
11360 .unwrap();
11361 assert_eq!(entity.namespace, "lambda:leo");
11362
11363 let local_tok = NamespaceToken::local();
11365 let fetched = rt.get_entity(&local_tok, entity.id).await;
11366 assert!(
11367 fetched.is_ok(),
11368 "get_entity from local token must find lambda:leo entity; got {:?}",
11369 fetched
11370 );
11371 assert_eq!(fetched.unwrap().id, entity.id);
11372 }
11373
11374 #[tokio::test]
11375 async fn update_entity_cross_namespace_succeeds() {
11376 let rt = rt();
11377 let leo_tok = NamespaceToken::for_namespace(Namespace::parse("lambda:leo").unwrap());
11378 let entity = rt
11379 .create_entity(
11380 &leo_tok,
11381 "concept",
11382 None,
11383 "Peer-Entity-Update",
11384 None,
11385 None,
11386 vec![],
11387 )
11388 .await
11389 .unwrap();
11390
11391 let local_tok = NamespaceToken::local();
11393 let patch = crate::curation::EntityPatch {
11394 name: Some("Peer-Entity-Updated".to_string()),
11395 ..Default::default()
11396 };
11397 let result = rt.update_entity(&local_tok, entity.id, patch).await;
11398 assert!(
11399 result.is_ok(),
11400 "update_entity from local token must succeed on lambda:leo entity; got {:?}",
11401 result
11402 );
11403 assert_eq!(result.unwrap().name, "Peer-Entity-Updated");
11404 }
11405
11406 #[tokio::test]
11407 async fn delete_entity_cross_namespace_succeeds() {
11408 let rt = rt();
11409 let leo_tok = NamespaceToken::for_namespace(Namespace::parse("lambda:leo").unwrap());
11410 let entity = rt
11411 .create_entity(
11412 &leo_tok,
11413 "concept",
11414 None,
11415 "Peer-Entity-Delete",
11416 None,
11417 None,
11418 vec![],
11419 )
11420 .await
11421 .unwrap();
11422
11423 let local_tok = NamespaceToken::local();
11425 let deleted = rt.delete_entity(&local_tok, entity.id, false).await;
11426 assert!(
11427 deleted.is_ok(),
11428 "delete_entity from local token must succeed on lambda:leo entity; got {:?}",
11429 deleted
11430 );
11431 assert!(
11432 deleted.unwrap(),
11433 "delete must return true when entity existed"
11434 );
11435 }
11436
11437 #[tokio::test]
11438 async fn namespace_preserved_on_entity_after_cross_namespace_get() {
11439 let rt = rt();
11440 let leo_tok = NamespaceToken::for_namespace(Namespace::parse("lambda:leo").unwrap());
11441 let entity = rt
11442 .create_entity(
11443 &leo_tok,
11444 "concept",
11445 None,
11446 "NS-Preserved",
11447 None,
11448 None,
11449 vec![],
11450 )
11451 .await
11452 .unwrap();
11453
11454 let local_tok = NamespaceToken::local();
11456 let fetched = rt.get_entity(&local_tok, entity.id).await.unwrap();
11457 assert_eq!(
11458 fetched.namespace, "lambda:leo",
11459 "namespace column must be preserved; not overwritten with caller's namespace"
11460 );
11461 }
11462
11463 use crate::pack::PackByIdResolver;
11466 use tokio::sync::Mutex as TokioMutex;
11467
11468 #[derive(Debug, Default)]
11469 struct MockResolverState {
11470 owned: Vec<Uuid>,
11471 deleted: Vec<Uuid>,
11472 delete_calls: Vec<(Uuid, bool)>,
11473 }
11474
11475 struct MockPackResolver(TokioMutex<MockResolverState>);
11476
11477 impl MockPackResolver {
11478 fn new() -> Self {
11479 Self(TokioMutex::new(MockResolverState::default()))
11480 }
11481 }
11482
11483 #[async_trait::async_trait]
11484 impl crate::pack::PackByIdResolver for MockPackResolver {
11485 async fn resolve_by_id(&self, id: Uuid) -> Result<Option<Resolved>, RuntimeError> {
11486 let state = self.0.lock().await;
11487 if state.owned.contains(&id) && !state.deleted.contains(&id) {
11488 Ok(Some(Resolved::PackRecord {
11489 pack: "mock".into(),
11490 kind: "widget".into(),
11491 data: serde_json::json!({ "id": id.to_string(), "name": "test-widget" }),
11492 }))
11493 } else {
11494 Ok(None)
11495 }
11496 }
11497
11498 async fn resolve_by_id_including_deleted(
11499 &self,
11500 id: Uuid,
11501 ) -> Result<Option<Resolved>, RuntimeError> {
11502 let state = self.0.lock().await;
11503 if state.owned.contains(&id) {
11504 Ok(Some(Resolved::PackRecord {
11505 pack: "mock".into(),
11506 kind: "widget".into(),
11507 data: serde_json::json!({ "id": id.to_string(), "name": "test-widget" }),
11508 }))
11509 } else {
11510 Ok(None)
11511 }
11512 }
11513
11514 async fn delete_by_id(
11515 &self,
11516 id: Uuid,
11517 hard: bool,
11518 ) -> Result<serde_json::Value, RuntimeError> {
11519 let mut state = self.0.lock().await;
11520 if !state.owned.contains(&id) {
11521 return Err(RuntimeError::NotFound(format!(
11522 "mock widget not found: {id}"
11523 )));
11524 }
11525 state.delete_calls.push((id, hard));
11526 if hard {
11527 state.owned.retain(|&x| x != id);
11528 state.deleted.retain(|&x| x != id);
11529 } else {
11530 state.deleted.push(id);
11531 }
11532 Ok(
11533 serde_json::json!({ "deleted": true, "id": id.to_string(), "kind": "widget", "hard": hard }),
11534 )
11535 }
11536 }
11537
11538 fn registry_with_mock_resolver(
11539 rt: KhiveRuntime,
11540 resolver: Box<dyn crate::pack::PackByIdResolver>,
11541 ) -> crate::VerbRegistry {
11542 use crate::pack::{PackRuntime, VerbRegistryBuilder};
11543 use khive_types::{HandlerDef, VerbCategory, Visibility};
11544
11545 static MINIMAL_HANDLERS: &[HandlerDef] = &[HandlerDef {
11546 name: "minimal.noop",
11547 description: "noop",
11548 visibility: Visibility::Verb,
11549 category: VerbCategory::Commissive,
11550 params: &[],
11551 }];
11552
11553 struct MinimalPack;
11554 impl khive_types::Pack for MinimalPack {
11555 const NAME: &'static str = "minimal";
11556 const NOTE_KINDS: &'static [&'static str] = &[];
11557 const ENTITY_KINDS: &'static [&'static str] = &[];
11558 const HANDLERS: &'static [HandlerDef] = MINIMAL_HANDLERS;
11559 }
11560 #[async_trait::async_trait]
11561 impl PackRuntime for MinimalPack {
11562 fn name(&self) -> &str {
11563 "minimal"
11564 }
11565 fn note_kinds(&self) -> &'static [&'static str] {
11566 &[]
11567 }
11568 fn entity_kinds(&self) -> &'static [&'static str] {
11569 &[]
11570 }
11571 fn handlers(&self) -> &'static [HandlerDef] {
11572 MINIMAL_HANDLERS
11573 }
11574 async fn dispatch(
11575 &self,
11576 _verb: &str,
11577 _params: serde_json::Value,
11578 _registry: &crate::VerbRegistry,
11579 _token: &NamespaceToken,
11580 ) -> Result<serde_json::Value, RuntimeError> {
11581 Err(RuntimeError::InvalidInput("stub".into()))
11582 }
11583 }
11584
11585 let _ = rt;
11586 let mut builder = VerbRegistryBuilder::new();
11587 builder.register(MinimalPack);
11588 builder.register_resolver("mock", resolver);
11589 builder.build().expect("registry build failed")
11590 }
11591
11592 #[tokio::test]
11593 async fn pack_record_resolved_pair_returns_none() {
11594 let pr = Resolved::PackRecord {
11595 pack: "knowledge".into(),
11596 kind: "atom".into(),
11597 data: serde_json::json!({}),
11598 };
11599 assert!(
11600 resolved_pair(Some(&pr)).is_none(),
11601 "PackRecord must not be a valid edge endpoint"
11602 );
11603 }
11604
11605 #[test]
11606 fn resolved_pair_surfaces_entity_type() {
11607 let e = Resolved::Entity(
11608 Entity::new("mathlib", "concept", "Nat.add_comm").with_entity_type(Some("theorem")),
11609 );
11610 assert_eq!(
11611 resolved_pair(Some(&e)),
11612 Some(("entity", "concept", Some("theorem"))),
11613 "entity_type subtype must be surfaced alongside base kind"
11614 );
11615 }
11616
11617 #[test]
11618 fn endpoint_of_type_matches_subtype_not_base_kind() {
11619 let kind = "concept";
11621 let et = Some("theorem");
11622
11623 assert!(endpoint_matches(
11625 &EndpointKind::EntityOfType {
11626 kind: "concept",
11627 entity_type: "theorem",
11628 },
11629 "entity",
11630 kind,
11631 et
11632 ));
11633 assert!(!endpoint_matches(
11634 &EndpointKind::EntityOfType {
11635 kind: "concept",
11636 entity_type: "definition",
11637 },
11638 "entity",
11639 kind,
11640 et
11641 ));
11642
11643 assert!(!endpoint_matches(
11646 &EndpointKind::EntityOfKind("theorem"),
11647 "entity",
11648 kind,
11649 et
11650 ));
11651 assert!(endpoint_matches(
11653 &EndpointKind::EntityOfKind("concept"),
11654 "entity",
11655 kind,
11656 et
11657 ));
11658
11659 assert!(!endpoint_matches(
11661 &EndpointKind::EntityOfType {
11662 kind: "concept",
11663 entity_type: "theorem",
11664 },
11665 "note",
11666 "task",
11667 None
11668 ));
11669 assert!(!endpoint_matches(
11670 &EndpointKind::EntityOfType {
11671 kind: "concept",
11672 entity_type: "theorem",
11673 },
11674 "entity",
11675 kind,
11676 None
11677 ));
11678 }
11679
11680 #[test]
11681 fn endpoint_of_type_requires_base_kind_match() {
11682 let wrong_base_kind = "project"; let et = Some("theorem");
11687
11688 assert!(
11692 !endpoint_matches(
11693 &EndpointKind::EntityOfType {
11694 kind: "concept",
11695 entity_type: "theorem",
11696 },
11697 "entity",
11698 wrong_base_kind,
11699 et
11700 ),
11701 "EntityOfType must reject an entity whose base kind != rule.kind \
11702 even when entity_type matches — the pre-fix bug admitted this"
11703 );
11704
11705 assert!(endpoint_matches(
11707 &EndpointKind::EntityOfType {
11708 kind: "concept",
11709 entity_type: "theorem",
11710 },
11711 "entity",
11712 "concept",
11713 et
11714 ));
11715 }
11716
11717 #[tokio::test]
11718 async fn registry_resolvers_accessor_returns_registered() {
11719 let resolver = Box::new(MockPackResolver::new());
11720 let registry = registry_with_mock_resolver(rt(), resolver);
11721 assert_eq!(registry.resolvers().len(), 1);
11722 assert_eq!(registry.resolvers()[0].0, "mock");
11723 }
11724
11725 #[tokio::test]
11726 async fn mock_resolver_resolve_by_id_returns_pack_record() {
11727 let id = Uuid::new_v4();
11728 let resolver: Box<dyn PackByIdResolver> = Box::new(MockPackResolver::new());
11729 let inner = MockPackResolver::new();
11731 inner.0.lock().await.owned.push(id);
11732 let result: Result<Option<Resolved>, RuntimeError> = inner.resolve_by_id(id).await;
11733 match result.unwrap() {
11734 Some(Resolved::PackRecord { pack, kind, data }) => {
11735 assert_eq!(pack, "mock");
11736 assert_eq!(kind, "widget");
11737 assert_eq!(data["id"].as_str().unwrap(), id.to_string());
11738 }
11739 other => panic!("expected PackRecord, got {:?}", other),
11740 }
11741 let _ = resolver;
11742 }
11743
11744 #[tokio::test]
11745 async fn mock_resolver_resolve_unknown_uuid_returns_none() {
11746 let inner = MockPackResolver::new();
11747 let id = Uuid::new_v4();
11748 let result: Result<Option<Resolved>, RuntimeError> = inner.resolve_by_id(id).await;
11749 assert!(result.unwrap().is_none());
11750 }
11751
11752 #[tokio::test]
11753 async fn mock_resolver_delete_soft_records_call() {
11754 let id = Uuid::new_v4();
11755 let inner = MockPackResolver::new();
11756 inner.0.lock().await.owned.push(id);
11757
11758 let result: Result<serde_json::Value, RuntimeError> = inner.delete_by_id(id, false).await;
11759 let result = result.unwrap();
11760 assert_eq!(result["deleted"], serde_json::json!(true));
11761 assert_eq!(result["hard"], serde_json::json!(false));
11762
11763 let live: Result<Option<Resolved>, RuntimeError> = inner.resolve_by_id(id).await;
11765 assert!(live.unwrap().is_none());
11766 let incl: Result<Option<Resolved>, RuntimeError> =
11767 inner.resolve_by_id_including_deleted(id).await;
11768 assert!(incl.unwrap().is_some());
11769 }
11770
11771 #[tokio::test]
11772 async fn mock_resolver_delete_hard_removes_record() {
11773 let id = Uuid::new_v4();
11774 let inner = MockPackResolver::new();
11775 inner.0.lock().await.owned.push(id);
11776
11777 let result: Result<serde_json::Value, RuntimeError> = inner.delete_by_id(id, true).await;
11778 assert_eq!(result.unwrap()["hard"], serde_json::json!(true));
11779
11780 let incl: Result<Option<Resolved>, RuntimeError> =
11782 inner.resolve_by_id_including_deleted(id).await;
11783 assert!(incl.unwrap().is_none());
11784 }
11785
11786 #[tokio::test]
11787 async fn pack_record_not_valid_context_entity() {
11788 let pr = Resolved::PackRecord {
11791 pack: "knowledge".into(),
11792 kind: "atom".into(),
11793 data: serde_json::json!({}),
11794 };
11795 assert!(matches!(pr, Resolved::PackRecord { .. }));
11798 }
11799
11800 fn neighbor_hit(node_id: Uuid) -> NeighborHit {
11803 NeighborHit {
11804 node_id,
11805 edge_id: Uuid::new_v4(),
11806 relation: EdgeRelation::Extends,
11807 weight: 1.0,
11808 name: None,
11809 kind: None,
11810 entity_type: None,
11811 }
11812 }
11813
11814 fn path_node(node_id: Uuid, depth: usize) -> PathNode {
11815 PathNode {
11816 node_id,
11817 via_edge: None,
11818 depth,
11819 name: None,
11820 kind: None,
11821 properties: None,
11822 }
11823 }
11824
11825 #[tokio::test]
11828 async fn enrich_neighbor_hits_batch_entity_note_and_bogus() {
11829 let rt = rt();
11830 let tok = NamespaceToken::local();
11831
11832 let entity = rt
11834 .create_entity(&tok, "concept", None, "MyEntity", None, None, vec![])
11835 .await
11836 .unwrap();
11837
11838 let note = rt
11840 .create_note(&tok, "observation", None, "body", Some(0.5), None, vec![])
11841 .await
11842 .unwrap();
11843
11844 let bogus_id = Uuid::new_v4();
11845
11846 let mut hits = vec![
11847 neighbor_hit(entity.id),
11848 neighbor_hit(note.id),
11849 neighbor_hit(bogus_id),
11850 ];
11851
11852 rt.enrich_neighbor_hits(&tok, &mut hits).await;
11853
11854 assert_eq!(hits[0].name.as_deref(), Some("MyEntity"));
11855 assert_eq!(hits[0].kind.as_deref(), Some("concept"));
11856
11857 assert_eq!(hits[1].name.as_deref(), Some("[observation]"));
11858 assert_eq!(hits[1].kind.as_deref(), Some("observation"));
11859
11860 assert!(hits[2].name.is_none());
11861 assert!(hits[2].kind.is_none());
11862 }
11863
11864 #[tokio::test]
11866 async fn enrich_neighbor_hits_note_with_name_uses_name() {
11867 let rt = rt();
11868 let tok = NamespaceToken::local();
11869
11870 let note = rt
11871 .create_note(
11872 &tok,
11873 "insight",
11874 Some("NoteTitle"),
11875 "body",
11876 Some(0.5),
11877 None,
11878 vec![],
11879 )
11880 .await
11881 .unwrap();
11882
11883 let mut hits = vec![neighbor_hit(note.id)];
11884 rt.enrich_neighbor_hits(&tok, &mut hits).await;
11885
11886 assert_eq!(hits[0].name.as_deref(), Some("NoteTitle"));
11887 assert_eq!(hits[0].kind.as_deref(), Some("insight"));
11888 }
11889
11890 #[tokio::test]
11893 async fn enrich_path_nodes_batch_dedup_and_unresolved() {
11894 let rt = rt();
11895 let tok = NamespaceToken::local();
11896
11897 let ea = rt
11898 .create_entity(&tok, "concept", None, "Alpha", None, None, vec![])
11899 .await
11900 .unwrap();
11901 let eb = rt
11902 .create_entity(&tok, "document", None, "Beta", None, None, vec![])
11903 .await
11904 .unwrap();
11905 let bogus_id = Uuid::new_v4();
11906
11907 let mut paths = vec![
11909 GraphPath {
11910 root_id: ea.id,
11911 nodes: vec![
11912 path_node(ea.id, 0),
11913 path_node(eb.id, 1),
11914 path_node(bogus_id, 2),
11915 ],
11916 total_weight: 1.0,
11917 },
11918 GraphPath {
11919 root_id: eb.id,
11920 nodes: vec![path_node(eb.id, 0), path_node(ea.id, 1)],
11921 total_weight: 1.0,
11922 },
11923 ];
11924
11925 rt.enrich_path_nodes(&tok, &mut paths, false).await;
11926
11927 assert_eq!(paths[0].nodes[0].name.as_deref(), Some("Alpha"));
11928 assert_eq!(paths[0].nodes[0].kind.as_deref(), Some("concept"));
11929 assert_eq!(paths[0].nodes[1].name.as_deref(), Some("Beta"));
11930 assert_eq!(paths[0].nodes[1].kind.as_deref(), Some("document"));
11931 assert!(paths[0].nodes[2].name.is_none());
11932 assert!(paths[0].nodes[2].kind.is_none());
11933
11934 assert_eq!(paths[1].nodes[0].name.as_deref(), Some("Beta"));
11936 assert_eq!(paths[1].nodes[0].kind.as_deref(), Some("document"));
11937 assert_eq!(paths[1].nodes[1].name.as_deref(), Some("Alpha"));
11938 assert_eq!(paths[1].nodes[1].kind.as_deref(), Some("concept"));
11939 }
11940
11941 #[tokio::test]
11949 async fn enrich_resolves_entities_in_extra_visible_namespace() {
11950 let rt = KhiveRuntime::memory().unwrap();
11951
11952 let ns_a = Namespace::parse("enrich-ns-a").unwrap();
11953 let ns_b = Namespace::parse("enrich-ns-b").unwrap();
11954
11955 let tok_b = rt.authorize(ns_b.clone()).unwrap();
11956
11957 let entity_b = rt
11959 .create_entity(&tok_b, "concept", None, "EntityInB", None, None, vec![])
11960 .await
11961 .unwrap();
11962 assert_eq!(entity_b.namespace, "enrich-ns-b");
11963
11964 let vis_tok = rt
11966 .authorize_with_visibility(ns_a.clone(), vec![ns_b.clone()])
11967 .unwrap();
11968
11969 let mut hits = vec![neighbor_hit(entity_b.id)];
11971 rt.enrich_neighbor_hits(&vis_tok, &mut hits).await;
11972
11973 assert_eq!(
11974 hits[0].name.as_deref(),
11975 Some("EntityInB"),
11976 "entity in extra-visible ns must be enriched by enrich_neighbor_hits"
11977 );
11978 assert_eq!(hits[0].kind.as_deref(), Some("concept"));
11979
11980 let mut paths = vec![GraphPath {
11982 root_id: entity_b.id,
11983 nodes: vec![path_node(entity_b.id, 0)],
11984 total_weight: 1.0,
11985 }];
11986 rt.enrich_path_nodes(&vis_tok, &mut paths, false).await;
11987
11988 assert_eq!(
11989 paths[0].nodes[0].name.as_deref(),
11990 Some("EntityInB"),
11991 "entity in extra-visible ns must be enriched by enrich_path_nodes"
11992 );
11993 assert_eq!(paths[0].nodes[0].kind.as_deref(), Some("concept"));
11994 }
11995
11996 #[tokio::test]
12000 async fn enrich_neighbor_hits_populates_entity_type() {
12001 let rt = rt();
12002 let tok = NamespaceToken::local();
12003
12004 let props = serde_json::json!({"domain": "attention"});
12005 let entity = rt
12006 .create_entity(
12007 &tok,
12008 "concept",
12009 Some("algorithm"),
12010 "FlashAttn",
12011 None,
12012 Some(props),
12013 vec![],
12014 )
12015 .await
12016 .unwrap();
12017
12018 let entity_no_type = rt
12019 .create_entity(&tok, "concept", None, "PlainConcept", None, None, vec![])
12020 .await
12021 .unwrap();
12022
12023 let mut hits = vec![neighbor_hit(entity.id), neighbor_hit(entity_no_type.id)];
12024 rt.enrich_neighbor_hits(&tok, &mut hits).await;
12025
12026 assert_eq!(hits[0].entity_type.as_deref(), Some("algorithm"));
12027 assert!(
12028 hits[1].entity_type.is_none(),
12029 "entity without entity_type must leave the field as None"
12030 );
12031 }
12032
12033 #[tokio::test]
12037 async fn enrich_path_nodes_populates_properties() {
12038 let rt = rt();
12039 let tok = NamespaceToken::local();
12040
12041 let props = serde_json::json!({"year": 2024, "venue": "NeurIPS"});
12042 let entity_with_props = rt
12043 .create_entity(
12044 &tok,
12045 "document",
12046 None,
12047 "AttentionPaper",
12048 None,
12049 Some(props.clone()),
12050 vec![],
12051 )
12052 .await
12053 .unwrap();
12054
12055 let entity_no_props = rt
12056 .create_entity(&tok, "concept", None, "BareConceptNode", None, None, vec![])
12057 .await
12058 .unwrap();
12059
12060 let mut paths = vec![GraphPath {
12061 root_id: entity_with_props.id,
12062 nodes: vec![
12063 path_node(entity_with_props.id, 0),
12064 path_node(entity_no_props.id, 1),
12065 ],
12066 total_weight: 1.0,
12067 }];
12068
12069 rt.enrich_path_nodes(&tok, &mut paths, true).await;
12070
12071 assert_eq!(
12072 paths[0].nodes[0].properties.as_ref(),
12073 Some(&props),
12074 "properties must be filled when entity has a non-null properties blob"
12075 );
12076 assert!(
12077 paths[0].nodes[1].properties.is_none(),
12078 "entity without properties must leave the field as None"
12079 );
12080 }
12081
12082 #[tokio::test]
12092 async fn traverse_chunks_root_binds_over_host_param_limit() {
12093 use khive_storage::types::TraversalOptions;
12094
12095 let rt = rt();
12096 let tok = NamespaceToken::local();
12097 let graph = rt.graph(&tok).unwrap();
12098
12099 const N: usize = 1_000;
12100 let now = chrono::Utc::now();
12101
12102 let mut roots: Vec<uuid::Uuid> = Vec::with_capacity(N);
12103 let mut expected_children: std::collections::HashMap<uuid::Uuid, uuid::Uuid> =
12104 std::collections::HashMap::with_capacity(N);
12105
12106 for _ in 0..N {
12107 let root = uuid::Uuid::new_v4();
12108 let child = uuid::Uuid::new_v4();
12109 graph
12110 .upsert_edge(Edge {
12111 id: LinkId::from(uuid::Uuid::new_v4()),
12112 namespace: "local".to_string(),
12113 source_id: root,
12114 target_id: child,
12115 relation: EdgeRelation::Extends,
12116 weight: 1.0,
12117 created_at: now,
12118 updated_at: now,
12119 deleted_at: None,
12120 metadata: None,
12121 target_backend: None,
12122 })
12123 .await
12124 .unwrap();
12125 roots.push(root);
12126 expected_children.insert(root, child);
12127 }
12128
12129 let paths = graph
12131 .traverse(TraversalRequest {
12132 roots: roots.clone(),
12133 options: TraversalOptions {
12134 max_depth: 1,
12135 direction: Direction::Out,
12136 relations: None,
12137 min_weight: None,
12138 limit: None,
12139 },
12140 include_roots: false,
12141 include_properties: false,
12142 })
12143 .await
12144 .unwrap();
12145
12146 assert_eq!(
12147 paths.len(),
12148 N,
12149 "traverse over {N} roots must return one GraphPath per root"
12150 );
12151
12152 for path in &paths {
12153 let expected_child = expected_children[&path.root_id];
12154 assert_eq!(
12155 path.nodes.len(),
12156 1,
12157 "root {:?} must reach exactly 1 node",
12158 path.root_id
12159 );
12160 assert_eq!(
12161 path.nodes[0].node_id, expected_child,
12162 "root {:?} must reach its direct child",
12163 path.root_id
12164 );
12165 }
12166 }
12167
12168 #[test]
12183 fn pack_entity_of_type_rules_do_not_shadow_base_entity_of_kind_rule() {
12184 let pack_rules: Vec<EdgeEndpointRule> = vec![
12187 EdgeEndpointRule {
12188 relation: EdgeRelation::VariantOf,
12189 source: EndpointKind::EntityOfType {
12190 kind: "concept",
12191 entity_type: "goal",
12192 },
12193 target: EndpointKind::EntityOfType {
12194 kind: "concept",
12195 entity_type: "theorem",
12196 },
12197 },
12198 EdgeEndpointRule {
12199 relation: EdgeRelation::VariantOf,
12200 source: EndpointKind::EntityOfType {
12201 kind: "concept",
12202 entity_type: "goal",
12203 },
12204 target: EndpointKind::EntityOfType {
12205 kind: "concept",
12206 entity_type: "definition",
12207 },
12208 },
12209 ];
12210
12211 let goal_a =
12212 Resolved::Entity(Entity::new("local", "concept", "G-a").with_entity_type(Some("goal")));
12213 let goal_b =
12214 Resolved::Entity(Entity::new("local", "concept", "G-b").with_entity_type(Some("goal")));
12215
12216 assert!(
12218 !pack_rule_allows(
12219 &pack_rules,
12220 EdgeRelation::VariantOf,
12221 Some(&goal_a),
12222 Some(&goal_b)
12223 ),
12224 "pack rules must not cover goal->goal variant_of (no such rule declared)"
12225 );
12226
12227 assert!(
12230 base_entity_rule_allows("concept", EdgeRelation::VariantOf, "concept"),
12231 "base contract must allow concept->concept variant_of regardless of pack EntityOfType rules"
12232 );
12233 }
12234
12235 #[tokio::test]
12240 async fn link_variant_of_goal_to_goal_allowed_when_pack_has_entity_of_type_rules() {
12241 let rt = rt();
12242 let tok = NamespaceToken::local();
12243
12244 rt.install_edge_rules(vec![
12247 EdgeEndpointRule {
12248 relation: EdgeRelation::VariantOf,
12249 source: EndpointKind::EntityOfType {
12250 kind: "concept",
12251 entity_type: "goal",
12252 },
12253 target: EndpointKind::EntityOfType {
12254 kind: "concept",
12255 entity_type: "theorem",
12256 },
12257 },
12258 EdgeEndpointRule {
12259 relation: EdgeRelation::VariantOf,
12260 source: EndpointKind::EntityOfType {
12261 kind: "concept",
12262 entity_type: "goal",
12263 },
12264 target: EndpointKind::EntityOfType {
12265 kind: "concept",
12266 entity_type: "definition",
12267 },
12268 },
12269 ]);
12270
12271 let a = rt
12272 .create_entity(
12273 &tok,
12274 "concept",
12275 Some("goal"),
12276 "Goal Alpha",
12277 None,
12278 None,
12279 vec![],
12280 )
12281 .await
12282 .unwrap();
12283 let b = rt
12284 .create_entity(
12285 &tok,
12286 "concept",
12287 Some("goal"),
12288 "Goal Beta",
12289 None,
12290 None,
12291 vec![],
12292 )
12293 .await
12294 .unwrap();
12295
12296 let result = rt
12299 .link(&tok, a.id, b.id, EdgeRelation::VariantOf, 1.0, None)
12300 .await;
12301 assert!(
12302 result.is_ok(),
12303 "goal->goal variant_of must be allowed via the base concept->concept rule \
12304 even when EntityOfType rules for variant_of are installed; got {result:?}"
12305 );
12306
12307 let p = rt
12312 .create_entity(&tok, "project", None, "Proj", None, None, vec![])
12313 .await
12314 .unwrap();
12315 let bad = rt
12316 .link(&tok, a.id, p.id, EdgeRelation::VariantOf, 1.0, None)
12317 .await;
12318 assert!(
12319 bad.is_err(),
12320 "additive pack rules must not make validation fail-open; \
12321 goal(concept)->project variant_of must be rejected (pack miss + base miss); \
12322 got {bad:?}"
12323 );
12324 }
12325
12326 #[tokio::test]
12331 async fn link_depends_on_theorem_to_definition_allowed_only_via_pack_rule() {
12332 let rt = rt();
12333 let tok = NamespaceToken::local();
12334
12335 assert!(
12338 !base_entity_rule_allows("concept", EdgeRelation::DependsOn, "concept"),
12339 "base contract must not allow concept->concept DependsOn; \
12340 test would be vacuous if this precondition fails"
12341 );
12342
12343 rt.install_edge_rules(vec![EdgeEndpointRule {
12347 relation: EdgeRelation::DependsOn,
12348 source: EndpointKind::EntityOfType {
12349 kind: "concept",
12350 entity_type: "theorem",
12351 },
12352 target: EndpointKind::EntityOfType {
12353 kind: "concept",
12354 entity_type: "definition",
12355 },
12356 }]);
12357
12358 let thm = rt
12359 .create_entity(&tok, "concept", Some("theorem"), "T1", None, None, vec![])
12360 .await
12361 .unwrap();
12362 let def = rt
12363 .create_entity(
12364 &tok,
12365 "concept",
12366 Some("definition"),
12367 "D1",
12368 None,
12369 None,
12370 vec![],
12371 )
12372 .await
12373 .unwrap();
12374
12375 let result = rt
12377 .link(&tok, thm.id, def.id, EdgeRelation::DependsOn, 1.0, None)
12378 .await;
12379 assert!(
12380 result.is_ok(),
12381 "theorem->definition DependsOn must be allowed by the installed pack rule; \
12382 the base contract has no concept->concept DependsOn row; got {result:?}"
12383 );
12384 }
12385
12386 #[tokio::test]
12393 async fn link_document_introduced_by_person_allowed() {
12394 let rt = rt();
12395 let tok = NamespaceToken::local();
12396
12397 let doc = rt
12398 .create_entity(&tok, "document", None, "Paper", None, None, vec![])
12399 .await
12400 .unwrap();
12401 let author = rt
12402 .create_entity(&tok, "person", None, "Author", None, None, vec![])
12403 .await
12404 .unwrap();
12405
12406 let result = rt
12407 .link(
12408 &tok,
12409 doc.id,
12410 author.id,
12411 EdgeRelation::IntroducedBy,
12412 1.0,
12413 None,
12414 )
12415 .await;
12416 assert!(
12417 result.is_ok(),
12418 "document->person introduced_by must be allowed by the ADR-002 \
12419 endpoint amendment; got {result:?}"
12420 );
12421 }
12422
12423 #[tokio::test]
12424 async fn link_document_introduced_by_org_allowed() {
12425 let rt = rt();
12426 let tok = NamespaceToken::local();
12427
12428 let doc = rt
12429 .create_entity(&tok, "document", None, "Whitepaper", None, None, vec![])
12430 .await
12431 .unwrap();
12432 let org = rt
12433 .create_entity(&tok, "org", None, "Publisher", None, None, vec![])
12434 .await
12435 .unwrap();
12436
12437 let result = rt
12438 .link(&tok, doc.id, org.id, EdgeRelation::IntroducedBy, 1.0, None)
12439 .await;
12440 assert!(
12441 result.is_ok(),
12442 "document->org introduced_by must be allowed by the ADR-002 \
12443 endpoint amendment; got {result:?}"
12444 );
12445 }
12446
12447 #[tokio::test]
12448 async fn link_concept_introduced_by_org_allowed() {
12449 let rt = rt();
12450 let tok = NamespaceToken::local();
12451
12452 let concept = rt
12453 .create_entity(&tok, "concept", None, "Architecture", None, None, vec![])
12454 .await
12455 .unwrap();
12456 let org = rt
12457 .create_entity(&tok, "org", None, "Originator", None, None, vec![])
12458 .await
12459 .unwrap();
12460
12461 let result = rt
12462 .link(
12463 &tok,
12464 concept.id,
12465 org.id,
12466 EdgeRelation::IntroducedBy,
12467 1.0,
12468 None,
12469 )
12470 .await;
12471 assert!(
12472 result.is_ok(),
12473 "concept->org introduced_by must be allowed by the ADR-002 \
12474 endpoint amendment; got {result:?}"
12475 );
12476 }
12477
12478 #[tokio::test]
12479 async fn link_document_depends_on_document_allowed() {
12480 let rt = rt();
12481 let tok = NamespaceToken::local();
12482
12483 let doc_a = rt
12484 .create_entity(&tok, "document", None, "Spec A", None, None, vec![])
12485 .await
12486 .unwrap();
12487 let doc_b = rt
12488 .create_entity(&tok, "document", None, "Spec B", None, None, vec![])
12489 .await
12490 .unwrap();
12491
12492 let result = rt
12493 .link(&tok, doc_a.id, doc_b.id, EdgeRelation::DependsOn, 1.0, None)
12494 .await;
12495 assert!(
12496 result.is_ok(),
12497 "document->document depends_on must be allowed by the ADR-002 \
12498 endpoint amendment; got {result:?}"
12499 );
12500 let edge = result.unwrap();
12501 let dk = edge
12502 .metadata
12503 .as_ref()
12504 .and_then(|m| m.get("dependency_kind"))
12505 .and_then(|v| v.as_str());
12506 assert_eq!(
12507 dk,
12508 Some("normative"),
12509 "document->document depends_on must infer dependency_kind=normative"
12510 );
12511 }
12512
12513 #[tokio::test]
12514 async fn link_org_introduced_by_document_rejected_direction_matters() {
12515 let rt = rt();
12516 let tok = NamespaceToken::local();
12517
12518 let org = rt
12519 .create_entity(&tok, "org", None, "Publisher", None, None, vec![])
12520 .await
12521 .unwrap();
12522 let doc = rt
12523 .create_entity(&tok, "document", None, "Paper", None, None, vec![])
12524 .await
12525 .unwrap();
12526
12527 let result = rt
12530 .link(&tok, org.id, doc.id, EdgeRelation::IntroducedBy, 1.0, None)
12531 .await;
12532 assert!(
12533 result.is_err(),
12534 "org->document introduced_by must remain rejected; only \
12535 document->org is permitted, not the reverse; got {result:?}"
12536 );
12537 }
12538
12539 struct CapturingVecService {
12546 dims: usize,
12547 captured: Arc<std::sync::Mutex<Vec<String>>>,
12548 }
12549
12550 #[async_trait]
12551 impl EmbeddingService for CapturingVecService {
12552 async fn embed(
12553 &self,
12554 texts: &[String],
12555 _model: EmbeddingModel,
12556 ) -> std::result::Result<Vec<Vec<f32>>, EmbedError> {
12557 self.captured.lock().unwrap().extend(texts.iter().cloned());
12558 Ok(texts.iter().map(|_| vec![1.0_f32; self.dims]).collect())
12559 }
12560
12561 fn supports_model(&self, _model: EmbeddingModel) -> bool {
12562 true
12563 }
12564
12565 fn name(&self) -> &'static str {
12566 "capturing-vec"
12567 }
12568 }
12569
12570 struct CapturingVecProvider {
12571 provider_name: String,
12572 dims: usize,
12573 captured: Arc<std::sync::Mutex<Vec<String>>>,
12574 }
12575
12576 #[async_trait]
12577 impl EmbedderProvider for CapturingVecProvider {
12578 fn name(&self) -> &str {
12579 &self.provider_name
12580 }
12581
12582 fn dimensions(&self) -> usize {
12583 self.dims
12584 }
12585
12586 async fn build(&self) -> crate::error::RuntimeResult<Arc<dyn EmbeddingService>> {
12587 Ok(Arc::new(CapturingVecService {
12588 dims: self.dims,
12589 captured: Arc::clone(&self.captured),
12590 }))
12591 }
12592 }
12593
12594 #[tokio::test]
12595 async fn create_note_with_embedding_content_none_matches_create_note() {
12596 let rt = rt();
12597 let tok = NamespaceToken::local();
12598 let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
12599 rt.register_embedder(CapturingVecProvider {
12600 provider_name: "capturing-vec".into(),
12601 dims: 4,
12602 captured: Arc::clone(&captured),
12603 });
12604
12605 let note = rt
12606 .create_note_with_embedding_content(
12607 &tok,
12608 "observation",
12609 None,
12610 "full content, no override",
12611 None,
12612 None,
12613 None,
12614 vec![],
12615 )
12616 .await
12617 .expect("create with None override must behave like create_note");
12618 assert_eq!(note.content, "full content, no override");
12619 let seen = captured.lock().unwrap().clone();
12620 assert_eq!(
12621 seen,
12622 vec!["full content, no override".to_string()],
12623 "with no override the embedder must see the full content"
12624 );
12625 }
12626
12627 #[tokio::test]
12628 async fn create_note_with_embedding_content_embeds_capped_override_and_stores_full_content() {
12629 let rt = rt();
12630 let tok = NamespaceToken::local();
12631 let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
12632 rt.register_embedder(CapturingVecProvider {
12633 provider_name: "capturing-vec".into(),
12634 dims: 4,
12635 captured: Arc::clone(&captured),
12636 });
12637
12638 let full = "head-term and then a very long tail-term that exceeds any cap";
12639 let head = &full[.."head-term and then a very long".len()];
12640
12641 let note = rt
12642 .create_note_with_embedding_content(
12643 &tok,
12644 "observation",
12645 None,
12646 full,
12647 Some(head),
12648 None,
12649 None,
12650 vec![],
12651 )
12652 .await
12653 .expect("proper-prefix override must be accepted");
12654 assert_eq!(note.content, full, "stored content must be the full text");
12655
12656 let seen = captured.lock().unwrap().clone();
12657 assert_eq!(
12658 seen,
12659 vec![head.to_string()],
12660 "embedder must see only the capped override"
12661 );
12662 }
12663
12664 #[tokio::test]
12665 async fn create_note_with_embedding_content_fans_out_identical_override_to_multiple_models() {
12666 let rt = rt();
12667 let tok = NamespaceToken::local();
12668 let captured_a = Arc::new(std::sync::Mutex::new(Vec::new()));
12669 let captured_b = Arc::new(std::sync::Mutex::new(Vec::new()));
12670 rt.register_embedder(CapturingVecProvider {
12671 provider_name: "capturing-vec-a".into(),
12672 dims: 4,
12673 captured: Arc::clone(&captured_a),
12674 });
12675 rt.register_embedder(CapturingVecProvider {
12676 provider_name: "capturing-vec-b".into(),
12677 dims: 4,
12678 captured: Arc::clone(&captured_b),
12679 });
12680
12681 let full = "head-only-embedded plus a long discarded tail";
12682 let head = &full[.."head-only-embedded".len()];
12683
12684 rt.create_note_with_embedding_content(
12685 &tok,
12686 "observation",
12687 None,
12688 full,
12689 Some(head),
12690 None,
12691 None,
12692 vec![],
12693 )
12694 .await
12695 .expect("create ok");
12696
12697 assert_eq!(
12698 captured_a.lock().unwrap().clone(),
12699 vec![head.to_string()],
12700 "model A must receive the identical capped override"
12701 );
12702 assert_eq!(
12703 captured_b.lock().unwrap().clone(),
12704 vec![head.to_string()],
12705 "model B must receive the identical capped override"
12706 );
12707
12708 let vs_a = rt
12711 .vectors_for_model(&tok, "capturing-vec-a")
12712 .expect("vector store for model A");
12713 assert_eq!(
12714 vs_a.count().await.expect("vector count A"),
12715 1,
12716 "model A must have exactly one vector row for the note"
12717 );
12718 let vs_b = rt
12719 .vectors_for_model(&tok, "capturing-vec-b")
12720 .expect("vector store for model B");
12721 assert_eq!(
12722 vs_b.count().await.expect("vector count B"),
12723 1,
12724 "model B must have exactly one vector row for the note"
12725 );
12726 }
12727
12728 #[tokio::test]
12729 async fn create_note_with_embedding_content_rejects_empty_override() {
12730 let rt = rt();
12731 let tok = NamespaceToken::local();
12732
12733 let err = rt
12734 .create_note_with_embedding_content(
12735 &tok,
12736 "observation",
12737 None,
12738 "some content",
12739 Some(""),
12740 None,
12741 None,
12742 vec![],
12743 )
12744 .await
12745 .expect_err("empty override must be rejected");
12746 assert!(matches!(err, RuntimeError::InvalidInput(_)));
12747 }
12748
12749 #[tokio::test]
12750 async fn create_note_with_embedding_content_rejects_non_prefix_override() {
12751 let rt = rt();
12752 let tok = NamespaceToken::local();
12753
12754 let err = rt
12755 .create_note_with_embedding_content(
12756 &tok,
12757 "observation",
12758 None,
12759 "the actual content",
12760 Some("an unrelated string"),
12761 None,
12762 None,
12763 vec![],
12764 )
12765 .await
12766 .expect_err("non-prefix override must be rejected");
12767 assert!(matches!(err, RuntimeError::InvalidInput(ref m) if m.contains("prefix")));
12768 }
12769
12770 #[tokio::test]
12771 async fn create_note_with_embedding_content_rejects_equal_length_override() {
12772 let rt = rt();
12773 let tok = NamespaceToken::local();
12774
12775 let err = rt
12777 .create_note_with_embedding_content(
12778 &tok,
12779 "observation",
12780 None,
12781 "identical text",
12782 Some("identical text"),
12783 None,
12784 None,
12785 vec![],
12786 )
12787 .await
12788 .expect_err("an equal-length override must be rejected as not a proper prefix");
12789 assert!(matches!(err, RuntimeError::InvalidInput(ref m) if m.contains("prefix")));
12790 }
12791
12792 #[tokio::test]
12793 async fn create_note_with_embedding_content_rejects_secret_bearing_override() {
12794 let rt = rt();
12795 let tok = NamespaceToken::local();
12796
12797 let token_span = "ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
12798 let content = format!("{token_span} plus extra trailing content beyond the override");
12799 let embedding_content = format!("{token_span} plus extra");
12800
12801 let err = rt
12802 .create_note_with_embedding_content(
12803 &tok,
12804 "observation",
12805 None,
12806 &content,
12807 Some(&embedding_content),
12808 None,
12809 None,
12810 vec![],
12811 )
12812 .await
12813 .expect_err("a credential-shaped override must fail the secret gate");
12814 assert!(
12815 matches!(err, RuntimeError::SecretDetected(_)),
12816 "expected SecretDetected, got {err:?}"
12817 );
12818
12819 let count = rt
12821 .notes(&tok)
12822 .unwrap()
12823 .count_notes(tok.namespace().as_str(), None)
12824 .await
12825 .unwrap();
12826 assert_eq!(count, 0, "a rejected create must leave no note behind");
12827 }
12828}