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"))]
76static FTS_FAIL_NS: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
77#[cfg(any(test, feature = "fault-injection"))]
78static VECTOR_FAIL_NS: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
79#[cfg(any(test, feature = "fault-injection"))]
82static FTS_FAIL_MANY_NS: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
83#[cfg(any(test, feature = "fault-injection"))]
87static FTS_FAIL_MANY_PARTIAL_NS: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
88#[cfg(any(test, feature = "fault-injection"))]
95static FTS_SEARCH_FAIL_NS: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
96
97#[cfg(any(test, feature = "fault-injection"))]
104pub fn arm_fts_fail(ns: &str) {
105 *FTS_FAIL_NS.lock().unwrap() = Some(ns.to_string());
106}
107
108#[cfg(any(test, feature = "fault-injection"))]
115pub fn arm_fts_fail_many(ns: &str) {
116 *FTS_FAIL_MANY_NS.lock().unwrap() = Some(ns.to_string());
117}
118
119#[cfg(any(test, feature = "fault-injection"))]
127pub fn arm_fts_fail_many_partial(ns: &str) {
128 *FTS_FAIL_MANY_PARTIAL_NS.lock().unwrap() = Some(ns.to_string());
129}
130
131#[cfg(any(test, feature = "fault-injection"))]
141pub fn arm_fts_search_fail(ns: &str) {
142 *FTS_SEARCH_FAIL_NS.lock().unwrap() = Some(ns.to_string());
143}
144
145#[cfg(any(test, feature = "fault-injection"))]
152pub fn arm_vector_fail(ns: &str) {
153 *VECTOR_FAIL_NS.lock().unwrap() = Some(ns.to_string());
154}
155
156#[cfg(any(test, feature = "fault-injection"))]
161static ROLLBACK_CLEANUP_FAIL_NS: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
162
163#[cfg(any(test, feature = "fault-injection"))]
170pub fn arm_rollback_cleanup_fail(ns: &str) {
171 *ROLLBACK_CLEANUP_FAIL_NS.lock().unwrap() = Some(ns.to_string());
172}
173
174#[derive(Clone, Debug)]
176pub struct NoteSearchHit {
177 pub note_id: Uuid,
178 pub score: DeterministicScore,
179 pub title: Option<String>,
180 pub snippet: Option<String>,
181}
182
183pub fn hex_prefix_to_uuid_pattern(prefix: &str) -> String {
194 if prefix.contains('-') {
195 return prefix.to_string();
196 }
197 const BOUNDARIES: [usize; 4] = [8, 13, 18, 23]; let mut out = String::with_capacity(36);
199 for c in prefix.chars() {
200 if BOUNDARIES.contains(&out.len()) {
201 out.push('-');
202 }
203 out.push(c);
204 }
205 out
206}
207
208fn text_preview(text: &str, max_chars: usize) -> Option<String> {
209 let trimmed = text.trim();
210 if trimmed.is_empty() {
211 None
212 } else {
213 Some(trimmed.chars().take(max_chars).collect())
214 }
215}
216
217fn normalize_symmetric_direction(
223 direction: Direction,
224 relations: Option<&[EdgeRelation]>,
225) -> Direction {
226 let Some(rels) = relations else {
227 return direction;
228 };
229 if rels.is_empty() {
230 return direction;
231 }
232 let all_symmetric = rels
233 .iter()
234 .all(|r| matches!(r, EdgeRelation::CompetesWith | EdgeRelation::ComposedWith));
235 if all_symmetric {
236 Direction::Both
237 } else {
238 direction
239 }
240}
241
242fn direction_sort_rank(direction: &Direction) -> u8 {
249 match direction {
250 Direction::Out => 0,
251 Direction::In => 1,
252 Direction::Both => 2,
253 }
254}
255
256fn note_title(note: &Note) -> Option<String> {
257 note.name
258 .clone()
259 .filter(|s| !s.trim().is_empty())
260 .or_else(|| Some(format!("[{}]", note.kind.as_str())))
261}
262
263fn note_snippet(note: &Note) -> Option<String> {
264 text_preview(¬e.content, 200)
265}
266
267#[derive(Clone, Debug)]
269pub enum Resolved {
270 Entity(Entity),
271 Note(Note),
272 Event(Event),
273 PackRecord {
280 pack: String,
281 kind: String,
282 data: serde_json::Value,
283 },
284}
285
286#[derive(Clone, Copy, Debug, Eq, PartialEq)]
293pub enum EdgeEndpointKind {
294 Entity,
295 Note,
296 Event,
297 Edge,
298}
299
300fn resolved_pair(r: Option<&Resolved>) -> Option<(&'static str, &str, Option<&str>)> {
306 match r? {
307 Resolved::Entity(e) => Some(("entity", e.kind.as_str(), e.entity_type.as_deref())),
308 Resolved::Note(n) => Some(("note", n.kind.as_str(), None)),
309 Resolved::Event(_) => None,
310 Resolved::PackRecord { .. } => None,
311 }
312}
313
314pub fn endpoint_matches(
322 spec: &EndpointKind,
323 substrate: &str,
324 kind: &str,
325 entity_type: Option<&str>,
326) -> bool {
327 match spec {
328 EndpointKind::EntityOfKind(k) => substrate == "entity" && *k == kind,
329 EndpointKind::NoteOfKind(k) => substrate == "note" && *k == kind,
330 EndpointKind::EntityOfType {
331 kind: k,
332 entity_type: t,
333 } => substrate == "entity" && *k == kind && entity_type == Some(*t),
334 }
335}
336
337pub fn accepted_pack_relations_for_entities(
351 rules: &[EdgeEndpointRule],
352 src_kind: &str,
353 src_entity_type: Option<&str>,
354 tgt_kind: &str,
355 tgt_entity_type: Option<&str>,
356) -> Vec<EdgeRelation> {
357 let mut relations: Vec<EdgeRelation> = rules
358 .iter()
359 .filter(|r| {
360 endpoint_matches(&r.source, "entity", src_kind, src_entity_type)
361 && endpoint_matches(&r.target, "entity", tgt_kind, tgt_entity_type)
362 })
363 .map(|r| r.relation)
364 .collect();
365 relations.sort_by_key(|r| r.as_str());
366 relations.dedup();
367 relations
368}
369
370fn pack_rule_allows(
373 rules: &[EdgeEndpointRule],
374 relation: EdgeRelation,
375 src: Option<&Resolved>,
376 tgt: Option<&Resolved>,
377) -> bool {
378 let Some((src_sub, src_kind, src_type)) = resolved_pair(src) else {
379 return false;
380 };
381 let Some((tgt_sub, tgt_kind, tgt_type)) = resolved_pair(tgt) else {
382 return false;
383 };
384 rules.iter().any(|r| {
385 r.relation == relation
386 && endpoint_matches(&r.source, src_sub, src_kind, src_type)
387 && endpoint_matches(&r.target, tgt_sub, tgt_kind, tgt_type)
388 })
389}
390
391pub const BASE_ENTITY_ENDPOINT_RULES: &[(&str, EdgeRelation, &str)] = &[
401 ("concept", EdgeRelation::Contains, "concept"),
403 ("project", EdgeRelation::Contains, "project"),
404 ("project", EdgeRelation::Contains, "artifact"),
405 ("org", EdgeRelation::Contains, "project"),
406 ("org", EdgeRelation::Contains, "service"),
407 ("concept", EdgeRelation::PartOf, "concept"),
408 ("project", EdgeRelation::PartOf, "project"),
409 ("project", EdgeRelation::PartOf, "org"),
410 ("*", EdgeRelation::InstanceOf, "concept"),
411 ("service", EdgeRelation::InstanceOf, "project"),
412 ("concept", EdgeRelation::Extends, "concept"),
414 ("concept", EdgeRelation::VariantOf, "concept"),
415 ("artifact", EdgeRelation::VariantOf, "artifact"),
416 ("concept", EdgeRelation::IntroducedBy, "document"),
417 ("concept", EdgeRelation::IntroducedBy, "person"),
418 ("artifact", EdgeRelation::IntroducedBy, "document"),
419 ("document", EdgeRelation::IntroducedBy, "person"),
420 ("document", EdgeRelation::IntroducedBy, "org"),
421 ("concept", EdgeRelation::IntroducedBy, "org"),
422 ("artifact", EdgeRelation::DerivedFrom, "dataset"),
424 ("artifact", EdgeRelation::DerivedFrom, "document"),
425 ("artifact", EdgeRelation::DerivedFrom, "project"),
426 ("artifact", EdgeRelation::DerivedFrom, "artifact"),
427 ("document", EdgeRelation::Precedes, "document"),
429 ("dataset", EdgeRelation::Precedes, "dataset"),
430 ("artifact", EdgeRelation::Precedes, "artifact"),
431 ("service", EdgeRelation::Precedes, "service"),
432 ("project", EdgeRelation::Precedes, "project"),
433 ("project", EdgeRelation::DependsOn, "project"),
435 ("service", EdgeRelation::DependsOn, "project"),
436 ("service", EdgeRelation::DependsOn, "service"),
437 ("service", EdgeRelation::DependsOn, "artifact"),
438 ("service", EdgeRelation::DependsOn, "dataset"),
439 ("artifact", EdgeRelation::DependsOn, "project"),
440 ("artifact", EdgeRelation::DependsOn, "service"),
441 ("document", EdgeRelation::DependsOn, "document"),
442 ("concept", EdgeRelation::Enables, "concept"),
443 ("service", EdgeRelation::Enables, "concept"),
444 ("dataset", EdgeRelation::Enables, "concept"),
445 ("project", EdgeRelation::Implements, "concept"),
447 ("service", EdgeRelation::Implements, "concept"),
448 ("concept", EdgeRelation::CompetesWith, "concept"),
450 ("project", EdgeRelation::CompetesWith, "project"),
451 ("service", EdgeRelation::CompetesWith, "service"),
452 ("concept", EdgeRelation::ComposedWith, "concept"),
453 ("project", EdgeRelation::ComposedWith, "project"),
454 ("concept", EdgeRelation::Supersedes, "concept"),
456 ("document", EdgeRelation::Supersedes, "document"),
457 ("artifact", EdgeRelation::Supersedes, "artifact"),
458 ("service", EdgeRelation::Supersedes, "service"),
459 ("dataset", EdgeRelation::Supersedes, "dataset"),
460 ("concept", EdgeRelation::Supports, "concept"),
462 ("document", EdgeRelation::Supports, "concept"),
463 ("dataset", EdgeRelation::Supports, "concept"),
464 ("artifact", EdgeRelation::Supports, "concept"),
465 ("concept", EdgeRelation::Refutes, "concept"),
466 ("document", EdgeRelation::Refutes, "concept"),
467 ("dataset", EdgeRelation::Refutes, "concept"),
468 ("artifact", EdgeRelation::Refutes, "concept"),
469];
470
471pub fn base_entity_endpoint_rules() -> &'static [(&'static str, EdgeRelation, &'static str)] {
477 BASE_ENTITY_ENDPOINT_RULES
478}
479
480pub fn base_entity_rule_allows(src_kind: &str, relation: EdgeRelation, tgt_kind: &str) -> bool {
486 BASE_ENTITY_ENDPOINT_RULES.iter().any(|(src, rel, tgt)| {
487 *rel == relation && (*src == "*" || *src == src_kind) && *tgt == tgt_kind
488 })
489}
490
491pub(crate) fn canonical_edge_endpoints(
497 relation: EdgeRelation,
498 source_id: Uuid,
499 target_id: Uuid,
500) -> (Uuid, Uuid) {
501 if relation.is_symmetric() && target_id < source_id {
502 (target_id, source_id)
503 } else {
504 (source_id, target_id)
505 }
506}
507
508pub(crate) fn infer_dependency_kind(src_kind: &str, tgt_kind: &str) -> Option<&'static str> {
514 match (src_kind, tgt_kind) {
515 ("project", "project") => Some("build"),
516 ("service", "service") => Some("runtime"),
517 ("service", "dataset") => Some("data"),
518 ("service", "artifact") => Some("artifact"),
519 ("artifact", "project") | ("artifact", "service") => Some("tooling"),
520 ("document", "document") => Some("normative"),
521 _ => None,
522 }
523}
524
525pub(crate) fn merge_dependency_kind(
535 src_kind: &str,
536 tgt_kind: &str,
537 metadata: Option<serde_json::Value>,
538) -> Option<serde_json::Value> {
539 if let Some(ref m) = metadata {
540 if m.get("dependency_kind").is_some() {
541 return metadata;
542 }
543 }
544 let inferred = infer_dependency_kind(src_kind, tgt_kind)?;
545 let mut obj = metadata.unwrap_or_else(|| serde_json::json!({}));
546 if let Some(o) = obj.as_object_mut() {
547 o.insert("dependency_kind".to_string(), serde_json::json!(inferred));
548 }
549 Some(obj)
550}
551
552pub fn merge_entry_metadata(
566 metadata: Option<serde_json::Value>,
567 dependency_kind: Option<String>,
568) -> RuntimeResult<Option<serde_json::Value>> {
569 let Some(dk) = dependency_kind else {
570 return Ok(metadata);
571 };
572 let mut obj = metadata.unwrap_or_else(|| serde_json::json!({}));
573 let map = obj
574 .as_object_mut()
575 .ok_or_else(|| RuntimeError::InvalidInput("metadata must be a JSON object".into()))?;
576 map.entry("dependency_kind".to_string())
577 .or_insert_with(|| serde_json::json!(dk));
578 Ok(Some(obj))
579}
580
581const VALID_DEPENDENCY_KINDS: &[&str] = &[
583 "build",
584 "runtime",
585 "data",
586 "artifact",
587 "tooling",
588 "normative",
589];
590
591pub(crate) fn validate_edge_weight(weight: f64) -> RuntimeResult<()> {
597 if !weight.is_finite() || !(0.0..=1.0).contains(&weight) {
598 return Err(RuntimeError::InvalidInput(format!(
599 "edge weight must be finite and in [0.0, 1.0], got {weight}"
600 )));
601 }
602 Ok(())
603}
604
605pub(crate) fn validate_edge_metadata(
611 relation: EdgeRelation,
612 metadata: Option<&serde_json::Value>,
613) -> RuntimeResult<()> {
614 let Some(meta) = metadata else {
615 return Ok(());
616 };
617 if let Some(dk) = meta.get("dependency_kind") {
618 if relation != EdgeRelation::DependsOn {
619 return Err(RuntimeError::InvalidInput(format!(
620 "dependency_kind is only valid on depends_on edges (got {})",
621 relation.as_str()
622 )));
623 }
624 let dk_str = dk
625 .as_str()
626 .ok_or_else(|| RuntimeError::InvalidInput("dependency_kind must be a string".into()))?;
627 if !VALID_DEPENDENCY_KINDS.contains(&dk_str) {
628 return Err(RuntimeError::InvalidInput(format!(
629 "unknown dependency_kind {dk_str:?}; valid: {}",
630 VALID_DEPENDENCY_KINDS.join(" | ")
631 )));
632 }
633 }
634 Ok(())
635}
636
637fn note_props_match(note_props: Option<&serde_json::Value>, filter: &serde_json::Value) -> bool {
642 let required = match filter.as_object() {
643 Some(obj) if !obj.is_empty() => obj,
644 _ => return true,
645 };
646 let actual = match note_props.and_then(serde_json::Value::as_object) {
647 Some(obj) => obj,
648 None => return false,
649 };
650 required
651 .iter()
652 .all(|(k, v)| actual.get(k).is_some_and(|av| av == v))
653}
654
655impl KhiveRuntime {
656 #[allow(clippy::too_many_arguments)]
663 pub async fn create_entity(
664 &self,
665 token: &NamespaceToken,
666 kind: &str,
667 entity_type: Option<&str>,
668 name: &str,
669 description: Option<&str>,
670 properties: Option<serde_json::Value>,
671 tags: Vec<String>,
672 ) -> RuntimeResult<Entity> {
673 self.validate_entity_kind(kind)?;
674 crate::secret_gate::check(name)?;
676 if let Some(d) = description {
677 crate::secret_gate::check(d)?;
678 }
679 if let Some(ref p) = properties {
680 crate::secret_gate::check_json(p)?;
681 }
682 crate::secret_gate::check_tags(&tags)?;
683 let ns = token.namespace().as_str();
684 let mut entity = Entity::new(ns, kind, name).with_entity_type(entity_type);
685 if let Some(d) = description {
686 entity = entity.with_description(d);
687 }
688 if let Some(p) = properties {
689 entity = entity.with_properties(p);
690 }
691 if !tags.is_empty() {
692 entity = entity.with_tags(tags);
693 }
694 self.entities(token)?.upsert_entity(entity.clone()).await?;
695
696 let doc = entity_fts_document(&entity);
697 let embed_body = doc.body.clone();
698
699 {
701 #[cfg(any(test, feature = "fault-injection"))]
702 let fts_inject = {
703 let mut g = FTS_FAIL_NS.lock().unwrap();
704 if g.as_deref() == Some(ns) {
705 *g = None;
706 true
707 } else {
708 false
709 }
710 };
711 #[cfg(not(any(test, feature = "fault-injection")))]
712 let fts_inject = false;
713 let fts_result: RuntimeResult<()> = if fts_inject {
714 Err(RuntimeError::Internal("injected FTS failure".to_string()))
715 } else {
716 match self.text(token) {
717 Ok(fts) => fts.upsert_document(doc).await.map_err(RuntimeError::from),
718 Err(e) => Err(e),
719 }
720 };
721 if let Err(e) = fts_result {
722 if let Ok(store) = self.entities(token) {
723 if let Err(ce) = store.delete_entity(entity.id, DeleteMode::Hard).await {
724 tracing::error!(
725 error = %ce,
726 id = %entity.id,
727 "create_entity: failed to roll back entity row after FTS failure"
728 );
729 }
730 }
731 return Err(e);
732 }
733 }
734
735 let embed_model_names = {
738 let names = self.registered_embedding_model_names();
739 if names.is_empty() {
740 vec![]
741 } else {
742 names
743 }
744 };
745
746 if embed_model_names.len() == 1 {
747 let model_name = &embed_model_names[0];
748 let vec_result = self
749 .embed_document_with_model(model_name, &embed_body)
750 .await;
751
752 #[cfg(any(test, feature = "fault-injection"))]
753 let vec_inject = {
754 let mut g = VECTOR_FAIL_NS.lock().unwrap();
755 if g.as_deref() == Some(ns) {
756 *g = None;
757 true
758 } else {
759 false
760 }
761 };
762 #[cfg(not(any(test, feature = "fault-injection")))]
763 let vec_inject = false;
764 let vec_result: RuntimeResult<Vec<f32>> = if vec_inject {
765 Err(RuntimeError::Internal(
766 "injected vector failure".to_string(),
767 ))
768 } else {
769 vec_result
770 };
771
772 let single_result: RuntimeResult<()> = match vec_result {
773 Ok(vector) => match self.vectors_for_model(token, model_name) {
774 Ok(vs) => vs
775 .insert(
776 entity.id,
777 SubstrateKind::Entity,
778 ns,
779 "entity.body",
780 vec![vector],
781 )
782 .await
783 .map_err(RuntimeError::from),
784 Err(e) => Err(e),
785 },
786 Err(e) => Err(e),
787 };
788 if let Err(e) = single_result {
789 if let Ok(store) = self.entities(token) {
790 if let Err(ce) = store.delete_entity(entity.id, DeleteMode::Hard).await {
791 tracing::error!(
792 error = %ce,
793 id = %entity.id,
794 "create_entity: failed to roll back entity row after vector failure"
795 );
796 }
797 }
798 if let Ok(fts) = self.text(token) {
799 if let Err(ce) = fts.delete_document(ns, entity.id).await {
800 tracing::error!(
801 error = %ce,
802 id = %entity.id,
803 "create_entity: failed to roll back FTS document after vector failure"
804 );
805 }
806 }
807 return Err(e);
808 }
809 } else if !embed_model_names.is_empty() {
810 let rt_clone = self.clone();
813 let body_owned = embed_body.clone();
814 let mut handles = Vec::with_capacity(embed_model_names.len());
815 for model_name in &embed_model_names {
816 let rt = rt_clone.clone();
817 let text = body_owned.clone();
818 let name = model_name.clone();
819 handles.push(tokio::spawn(async move {
820 rt.embed_document_with_model(&name, &text).await
821 }));
822 }
823 let mut vectors: Vec<Vec<f32>> = Vec::with_capacity(embed_model_names.len());
824 for handle in handles {
825 let join_result = handle
826 .await
827 .map_err(|e| RuntimeError::Internal(format!("embed task panicked: {e}")));
828 match join_result {
829 Err(e) => {
830 if let Ok(store) = self.entities(token) {
831 if let Err(ce) = store.delete_entity(entity.id, DeleteMode::Hard).await
832 {
833 tracing::error!(
834 error = %ce,
835 id = %entity.id,
836 "create_entity: failed to roll back entity row after embed task panic"
837 );
838 }
839 }
840 if let Ok(fts) = self.text(token) {
841 if let Err(ce) = fts.delete_document(ns, entity.id).await {
842 tracing::error!(
843 error = %ce,
844 id = %entity.id,
845 "create_entity: failed to roll back FTS document after embed task panic"
846 );
847 }
848 }
849 return Err(e);
850 }
851 Ok(Err(e)) => {
852 if let Ok(store) = self.entities(token) {
853 if let Err(ce) = store.delete_entity(entity.id, DeleteMode::Hard).await
854 {
855 tracing::error!(
856 error = %ce,
857 id = %entity.id,
858 "create_entity: failed to roll back entity row after embed failure"
859 );
860 }
861 }
862 if let Ok(fts) = self.text(token) {
863 if let Err(ce) = fts.delete_document(ns, entity.id).await {
864 tracing::error!(
865 error = %ce,
866 id = %entity.id,
867 "create_entity: failed to roll back FTS document after embed failure"
868 );
869 }
870 }
871 return Err(e);
872 }
873 Ok(Ok(vec)) => vectors.push(vec),
874 }
875 }
876 let mut inserted_models: Vec<String> = Vec::with_capacity(embed_model_names.len());
878 for (model_name, vector) in embed_model_names.iter().zip(vectors) {
879 #[cfg(any(test, feature = "fault-injection"))]
881 let count_inject = VECTOR_FAIL_AFTER.with(|cell| match cell.get() {
882 Some(0) => {
883 cell.set(None);
884 true
885 }
886 Some(n) => {
887 cell.set(Some(n - 1));
888 false
889 }
890 None => false,
891 });
892 #[cfg(not(any(test, feature = "fault-injection")))]
893 let count_inject = false;
894
895 let insert_result = if count_inject {
896 Err(RuntimeError::Internal(
897 "injected vector insert failure".to_string(),
898 ))
899 } else {
900 match self.vectors_for_model(token, model_name) {
901 Ok(vs) => vs
902 .insert(
903 entity.id,
904 SubstrateKind::Entity,
905 ns,
906 "entity.body",
907 vec![vector],
908 )
909 .await
910 .map_err(RuntimeError::from),
911 Err(e) => Err(e),
912 }
913 };
914 if let Err(e) = insert_result {
915 if let Ok(store) = self.entities(token) {
917 if let Err(ce) = store.delete_entity(entity.id, DeleteMode::Hard).await {
918 tracing::error!(
919 error = %ce,
920 id = %entity.id,
921 "create_entity: failed to roll back entity row after vector insert failure"
922 );
923 }
924 }
925 if let Ok(fts) = self.text(token) {
926 if let Err(ce) = fts.delete_document(ns, entity.id).await {
927 tracing::error!(
928 error = %ce,
929 id = %entity.id,
930 "create_entity: failed to roll back FTS document after vector insert failure"
931 );
932 }
933 }
934 for m in &inserted_models {
935 if let Ok(vs) = self.vectors_for_model(token, m) {
936 if let Err(ce) = vs.delete(entity.id).await {
937 tracing::error!(
938 error = %ce,
939 model = m,
940 id = %entity.id,
941 "create_entity: failed to roll back vector for model after insert failure"
942 );
943 }
944 }
945 }
946 return Err(e);
947 }
948 inserted_models.push(model_name.clone());
949 }
950 }
951
952 Ok(entity)
953 }
954
955 pub async fn get_entity(&self, token: &NamespaceToken, id: Uuid) -> RuntimeResult<Entity> {
959 self.entities(token)?
960 .get_entity(id)
961 .await?
962 .ok_or_else(|| RuntimeError::NotFound(format!("entity {id}")))
963 }
964
965 pub async fn get_entity_including_deleted(
969 &self,
970 token: &NamespaceToken,
971 id: Uuid,
972 ) -> RuntimeResult<Option<Entity>> {
973 self.entities(token)?
974 .get_entity_including_deleted(id)
975 .await
976 .map_err(Into::into)
977 }
978
979 pub async fn get_note_including_deleted(
983 &self,
984 token: &NamespaceToken,
985 id: Uuid,
986 ) -> RuntimeResult<Option<khive_storage::note::Note>> {
987 self.notes(token)?
988 .get_note_including_deleted(id)
989 .await
990 .map_err(Into::into)
991 }
992
993 pub async fn get_entities_by_ids(
997 &self,
998 token: &NamespaceToken,
999 ids: &[Uuid],
1000 ) -> RuntimeResult<Vec<Entity>> {
1001 if ids.is_empty() {
1002 return Ok(vec![]);
1003 }
1004 let filter = EntityFilter {
1005 ids: ids.to_vec(),
1006 ..Default::default()
1007 };
1008 let page = self
1009 .entities(token)?
1010 .query_entities(
1011 token.namespace().as_str(),
1012 filter,
1013 PageRequest {
1014 offset: 0,
1015 limit: ids.len() as u32,
1016 },
1017 )
1018 .await?;
1019 Ok(page.items)
1020 }
1021
1022 async fn get_entities_by_ids_visible(
1031 &self,
1032 token: &NamespaceToken,
1033 ids: &[Uuid],
1034 ) -> RuntimeResult<Vec<Entity>> {
1035 if ids.is_empty() {
1036 return Ok(vec![]);
1037 }
1038 let namespaces: Vec<String> = token
1039 .visible_namespaces()
1040 .iter()
1041 .map(|ns| ns.as_str().to_owned())
1042 .collect();
1043 let filter = EntityFilter {
1044 ids: ids.to_vec(),
1045 namespaces,
1046 ..Default::default()
1047 };
1048 let page = self
1049 .entities(token)?
1050 .query_entities(
1051 token.namespace().as_str(),
1052 filter,
1053 PageRequest {
1054 offset: 0,
1055 limit: ids.len() as u32,
1056 },
1057 )
1058 .await?;
1059 Ok(page.items)
1060 }
1061
1062 pub(crate) fn ensure_namespace(record_ns: &str, caller_primary_ns: &str) -> RuntimeResult<()> {
1071 if record_ns == caller_primary_ns {
1072 return Ok(());
1073 }
1074 Err(RuntimeError::NotFound("not found in this namespace".into()))
1075 }
1076
1077 pub(crate) fn ensure_namespace_visible(
1083 record_ns: &str,
1084 token: &NamespaceToken,
1085 ) -> RuntimeResult<()> {
1086 for ns in token.visible_namespaces() {
1087 if record_ns == ns.as_str() {
1088 return Ok(());
1089 }
1090 }
1091 Err(RuntimeError::NotFound("not found in this namespace".into()))
1092 }
1093
1094 pub async fn list_entities(
1100 &self,
1101 token: &NamespaceToken,
1102 kind: Option<&str>,
1103 entity_type: Option<&str>,
1104 limit: u32,
1105 offset: u32,
1106 ) -> RuntimeResult<Vec<Entity>> {
1107 let ns_strs: Vec<String> = token
1108 .visible_namespaces()
1109 .iter()
1110 .map(|ns| ns.as_str().to_owned())
1111 .collect();
1112 let filter = EntityFilter {
1113 kinds: match kind {
1114 Some(k) => vec![k.to_string()],
1115 None => vec![],
1116 },
1117 entity_types: match entity_type {
1118 Some(t) => vec![t.to_string()],
1119 None => vec![],
1120 },
1121 namespaces: ns_strs,
1122 ..Default::default()
1123 };
1124 let page = self
1125 .entities(token)?
1126 .query_entities(
1127 token.namespace().as_str(),
1128 filter,
1129 PageRequest {
1130 offset: offset.into(),
1131 limit,
1132 },
1133 )
1134 .await?;
1135 Ok(page.items)
1136 }
1137
1138 pub async fn list_entities_tagged(
1145 &self,
1146 token: &NamespaceToken,
1147 kind: Option<&str>,
1148 domain_tag: Option<&str>,
1149 limit: u32,
1150 offset: u32,
1151 ) -> RuntimeResult<Vec<Entity>> {
1152 let ns_strs: Vec<String> = token
1153 .visible_namespaces()
1154 .iter()
1155 .map(|ns| ns.as_str().to_owned())
1156 .collect();
1157 let filter = EntityFilter {
1158 kinds: match kind {
1159 Some(k) => vec![k.to_string()],
1160 None => vec![],
1161 },
1162 tags_any: match domain_tag {
1163 Some(t) if !t.is_empty() => vec![t.to_string()],
1164 _ => vec![],
1165 },
1166 namespaces: ns_strs,
1167 ..Default::default()
1168 };
1169 let page = self
1170 .entities(token)?
1171 .query_entities(
1172 token.namespace().as_str(),
1173 filter,
1174 PageRequest {
1175 offset: offset.into(),
1176 limit,
1177 },
1178 )
1179 .await?;
1180 Ok(page.items)
1181 }
1182
1183 pub async fn count_entities_tagged(
1188 &self,
1189 token: &NamespaceToken,
1190 kind: Option<&str>,
1191 domain_tag: Option<&str>,
1192 ) -> RuntimeResult<u64> {
1193 let ns_strs: Vec<String> = token
1194 .visible_namespaces()
1195 .iter()
1196 .map(|ns| ns.as_str().to_owned())
1197 .collect();
1198 let filter = EntityFilter {
1199 kinds: match kind {
1200 Some(k) => vec![k.to_string()],
1201 None => vec![],
1202 },
1203 tags_any: match domain_tag {
1204 Some(t) if !t.is_empty() => vec![t.to_string()],
1205 _ => vec![],
1206 },
1207 namespaces: ns_strs,
1208 ..Default::default()
1209 };
1210 Ok(self
1211 .entities(token)?
1212 .count_entities(token.namespace().as_str(), filter)
1213 .await?)
1214 }
1215
1216 pub async fn list_events(
1218 &self,
1219 token: &NamespaceToken,
1220 filter: EventFilter,
1221 page: PageRequest,
1222 ) -> RuntimeResult<Page<Event>> {
1223 self.events(token)?
1224 .query_events(filter, page)
1225 .await
1226 .map_err(Into::into)
1227 }
1228
1229 pub(crate) async fn validate_edge_relation_endpoints(
1247 &self,
1248 token: &NamespaceToken,
1249 source_id: Uuid,
1250 target_id: Uuid,
1251 relation: EdgeRelation,
1252 ) -> RuntimeResult<()> {
1253 if source_id == target_id {
1254 return Err(RuntimeError::InvalidInput(
1255 "self-loop edges are not allowed: source_id and target_id must be different".into(),
1256 ));
1257 }
1258 if relation == EdgeRelation::Annotates {
1259 match self.resolve_edge_endpoint(token, source_id).await? {
1263 Some(Resolved::Note(_)) => {}
1264 Some(_) => {
1265 return Err(RuntimeError::InvalidInput(format!(
1266 "annotates source {source_id} must be a note"
1267 )));
1268 }
1269 None => {
1270 if self.get_edge(token, source_id).await?.is_some() {
1272 return Err(RuntimeError::InvalidInput(format!(
1273 "annotates source {source_id} must be a note"
1274 )));
1275 }
1276 return Err(RuntimeError::NotFound(format!(
1277 "link source {source_id} not found"
1278 )));
1279 }
1280 }
1281 if !self.substrate_exists_by_id(token, target_id).await? {
1283 return Err(RuntimeError::NotFound(format!(
1284 "link target {target_id} not found"
1285 )));
1286 }
1287 } else if matches!(
1288 relation,
1289 EdgeRelation::Supersedes | EdgeRelation::Supports | EdgeRelation::Refutes
1290 ) {
1291 let rel_name = relation.as_str();
1295 let src = match self.resolve_edge_endpoint(token, source_id).await? {
1296 Some(r) => r,
1297 None => {
1298 if self.get_edge(token, source_id).await?.is_some() {
1299 return Err(RuntimeError::InvalidInput(format!(
1300 "{rel_name} source {source_id} must be a note or entity (got edge)"
1301 )));
1302 }
1303 return Err(RuntimeError::NotFound(format!(
1304 "link source {source_id} not found"
1305 )));
1306 }
1307 };
1308 let tgt = match self.resolve_edge_endpoint(token, target_id).await? {
1309 Some(r) => r,
1310 None => {
1311 if self.get_edge(token, target_id).await?.is_some() {
1312 return Err(RuntimeError::InvalidInput(format!(
1313 "{rel_name} target {target_id} must be a note or entity (got edge)"
1314 )));
1315 }
1316 return Err(RuntimeError::NotFound(format!(
1317 "link target {target_id} not found"
1318 )));
1319 }
1320 };
1321 match (&src, &tgt) {
1322 (Resolved::Entity(src_e), Resolved::Entity(tgt_e)) => {
1323 if !base_entity_rule_allows(&src_e.kind, relation, &tgt_e.kind) {
1324 let rule_hint = match relation {
1325 EdgeRelation::Supports | EdgeRelation::Refutes => {
1326 "requires concept|document|dataset|artifact -> concept \
1327 (or same-substrate note -> note)"
1328 }
1329 _ => "requires same-kind entity endpoints",
1330 };
1331 return Err(RuntimeError::InvalidInput(format!(
1332 "({}) -[{rel_name}]-> ({}) is not in the base endpoint \
1333 allowlist; {rel_name} {rule_hint}",
1334 src_e.kind, tgt_e.kind
1335 )));
1336 }
1337 }
1338 (Resolved::Note(_), Resolved::Note(_)) => {}
1339 (Resolved::Event(_), _) => {
1340 return Err(RuntimeError::InvalidInput(format!(
1341 "{rel_name} does not apply to events; source {source_id} is an event"
1342 )));
1343 }
1344 (_, Resolved::Event(_)) => {
1345 return Err(RuntimeError::InvalidInput(format!(
1346 "{rel_name} does not apply to events; target {target_id} is an event"
1347 )));
1348 }
1349 (Resolved::Entity(_), Resolved::Note(_)) => {
1350 return Err(RuntimeError::InvalidInput(format!(
1351 "{rel_name} endpoints must be the same substrate (note→note or entity→entity); \
1352 got source={source_id} (entity) target={target_id} (note)"
1353 )));
1354 }
1355 (Resolved::Note(_), Resolved::Entity(_)) => {
1356 return Err(RuntimeError::InvalidInput(format!(
1357 "{rel_name} endpoints must be the same substrate (note→note or entity→entity); \
1358 got source={source_id} (note) target={target_id} (entity)"
1359 )));
1360 }
1361 (Resolved::PackRecord { .. }, _) | (_, Resolved::PackRecord { .. }) => {
1362 return Err(RuntimeError::InvalidInput(format!(
1363 "pack-private record is not a valid edge endpoint for {rel_name}"
1364 )));
1365 }
1366 }
1367 } else {
1368 let src_res = self.resolve_edge_endpoint(token, source_id).await?;
1375 let tgt_res = self.resolve_edge_endpoint(token, target_id).await?;
1376
1377 if pack_rule_allows(
1378 &self.pack_edge_rules(),
1379 relation,
1380 src_res.as_ref(),
1381 tgt_res.as_ref(),
1382 ) {
1383 return Ok(());
1384 }
1385
1386 let src_kind = match src_res {
1388 Some(Resolved::Entity(e)) => e.kind,
1389 Some(_) => {
1390 return Err(RuntimeError::InvalidInput(format!(
1391 "link source {source_id} must be an entity for relation {relation:?} \
1392 (only `annotates` crosses substrates)"
1393 )));
1394 }
1395 None => {
1396 if self.get_edge(token, source_id).await?.is_some() {
1397 return Err(RuntimeError::InvalidInput(format!(
1398 "link source {source_id} must be an entity for relation {relation:?} \
1399 (only `annotates` crosses substrates)"
1400 )));
1401 }
1402 return Err(RuntimeError::NotFound(format!(
1403 "link source {source_id} not found"
1404 )));
1405 }
1406 };
1407 let tgt_kind = match tgt_res {
1408 Some(Resolved::Entity(e)) => e.kind,
1409 Some(_) => {
1410 return Err(RuntimeError::InvalidInput(format!(
1411 "link target {target_id} must be an entity for relation {relation:?} \
1412 (only `annotates` crosses substrates)"
1413 )));
1414 }
1415 None => {
1416 if self.get_edge(token, target_id).await?.is_some() {
1417 return Err(RuntimeError::InvalidInput(format!(
1418 "link target {target_id} must be an entity for relation {relation:?} \
1419 (only `annotates` crosses substrates)"
1420 )));
1421 }
1422 return Err(RuntimeError::NotFound(format!(
1423 "link target {target_id} not found"
1424 )));
1425 }
1426 };
1427 if !base_entity_rule_allows(&src_kind, relation, &tgt_kind) {
1428 return Err(RuntimeError::InvalidInput(format!(
1429 "({src_kind}) -[{}]-> ({tgt_kind}) is not in the base endpoint \
1430 allowlist; use pack EDGE_RULES to extend the allowlist",
1431 relation.as_str()
1432 )));
1433 }
1434 }
1435 Ok(())
1436 }
1437
1438 pub async fn validate_link_endpoints(
1443 &self,
1444 token: &NamespaceToken,
1445 source_id: Uuid,
1446 target_id: Uuid,
1447 relation: EdgeRelation,
1448 ) -> RuntimeResult<()> {
1449 self.validate_edge_relation_endpoints(token, source_id, target_id, relation)
1450 .await
1451 }
1452
1453 pub fn validate_link_endpoints_by_resolved(
1464 &self,
1465 source_id: Uuid,
1466 target_id: Uuid,
1467 relation: EdgeRelation,
1468 src: Option<&Resolved>,
1469 tgt: Option<&Resolved>,
1470 ) -> RuntimeResult<()> {
1471 if source_id == target_id {
1472 return Err(RuntimeError::InvalidInput(
1473 "self-loop edges are not allowed: source_id and target_id must be different".into(),
1474 ));
1475 }
1476
1477 if relation == EdgeRelation::Annotates {
1478 match src {
1479 Some(Resolved::Note(_)) => {}
1480 Some(_) => {
1481 return Err(RuntimeError::InvalidInput(format!(
1482 "annotates source {source_id} must be a note"
1483 )));
1484 }
1485 None => {
1486 return Err(RuntimeError::NotFound(format!(
1487 "link source {source_id} not found"
1488 )));
1489 }
1490 }
1491 if tgt.is_none() {
1492 return Err(RuntimeError::NotFound(format!(
1493 "link target {target_id} not found"
1494 )));
1495 }
1496 return Ok(());
1497 }
1498
1499 if matches!(
1500 relation,
1501 EdgeRelation::Supersedes | EdgeRelation::Supports | EdgeRelation::Refutes
1502 ) {
1503 let rel_name = relation.as_str();
1504 let src = src.ok_or_else(|| {
1505 RuntimeError::NotFound(format!("link source {source_id} not found"))
1506 })?;
1507 let tgt = tgt.ok_or_else(|| {
1508 RuntimeError::NotFound(format!("link target {target_id} not found"))
1509 })?;
1510 match (src, tgt) {
1511 (Resolved::Entity(src_e), Resolved::Entity(tgt_e)) => {
1512 if !base_entity_rule_allows(&src_e.kind, relation, &tgt_e.kind) {
1513 let rule_hint = match relation {
1514 EdgeRelation::Supports | EdgeRelation::Refutes => {
1515 "requires concept|document|dataset|artifact -> concept \
1516 (or same-substrate note -> note)"
1517 }
1518 _ => "requires same-kind entity endpoints",
1519 };
1520 return Err(RuntimeError::InvalidInput(format!(
1521 "({}) -[{rel_name}]-> ({}) is not in the base endpoint \
1522 allowlist; {rel_name} {rule_hint}",
1523 src_e.kind, tgt_e.kind
1524 )));
1525 }
1526 }
1527 (Resolved::Note(_), Resolved::Note(_)) => {}
1528 (Resolved::Entity(_), Resolved::Note(_)) => {
1529 return Err(RuntimeError::InvalidInput(format!(
1530 "{rel_name} endpoints must be the same substrate \
1531 (note→note or entity→entity); got source={source_id} (entity) \
1532 target={target_id} (note)"
1533 )));
1534 }
1535 (Resolved::Note(_), Resolved::Entity(_)) => {
1536 return Err(RuntimeError::InvalidInput(format!(
1537 "{rel_name} endpoints must be the same substrate \
1538 (note→note or entity→entity); got source={source_id} (note) \
1539 target={target_id} (entity)"
1540 )));
1541 }
1542 (Resolved::PackRecord { .. }, _) | (_, Resolved::PackRecord { .. }) => {
1543 return Err(RuntimeError::InvalidInput(format!(
1544 "pack-private record is not a valid edge endpoint for {rel_name}"
1545 )));
1546 }
1547 _ => {
1548 return Err(RuntimeError::InvalidInput(format!(
1549 "{rel_name} endpoints must be notes or entities (not events)"
1550 )));
1551 }
1552 }
1553 return Ok(());
1554 }
1555
1556 if pack_rule_allows(&self.pack_edge_rules(), relation, src, tgt) {
1559 return Ok(());
1560 }
1561
1562 let src_kind = match src {
1563 Some(Resolved::Entity(e)) => &e.kind,
1564 Some(_) => {
1565 return Err(RuntimeError::InvalidInput(format!(
1566 "link source {source_id} must be an entity for relation {relation:?} \
1567 (only `annotates` crosses substrates)"
1568 )));
1569 }
1570 None => {
1571 return Err(RuntimeError::NotFound(format!(
1572 "link source {source_id} not found"
1573 )));
1574 }
1575 };
1576 let tgt_kind = match tgt {
1577 Some(Resolved::Entity(e)) => &e.kind,
1578 Some(_) => {
1579 return Err(RuntimeError::InvalidInput(format!(
1580 "link target {target_id} must be an entity for relation {relation:?} \
1581 (only `annotates` crosses substrates)"
1582 )));
1583 }
1584 None => {
1585 return Err(RuntimeError::NotFound(format!(
1586 "link target {target_id} not found"
1587 )));
1588 }
1589 };
1590
1591 if !base_entity_rule_allows(src_kind, relation, tgt_kind) {
1592 return Err(RuntimeError::InvalidInput(format!(
1593 "({src_kind}) -[{}]-> ({tgt_kind}) is not in the base endpoint \
1594 allowlist; use pack EDGE_RULES to extend the allowlist",
1595 relation.as_str()
1596 )));
1597 }
1598
1599 Ok(())
1600 }
1601
1602 pub fn validate_annotates_endpoint_kinds(
1614 &self,
1615 source_id: Uuid,
1616 target_id: Uuid,
1617 source: Option<EdgeEndpointKind>,
1618 target: Option<EdgeEndpointKind>,
1619 ) -> RuntimeResult<()> {
1620 if source_id == target_id {
1621 return Err(RuntimeError::InvalidInput(
1622 "self-loop edges are not allowed: source_id and target_id must be different".into(),
1623 ));
1624 }
1625 match source {
1626 Some(EdgeEndpointKind::Note) => {}
1627 Some(_) => {
1628 return Err(RuntimeError::InvalidInput(format!(
1629 "annotates source {source_id} must be a note"
1630 )));
1631 }
1632 None => {
1633 return Err(RuntimeError::NotFound(format!(
1634 "link source {source_id} not found"
1635 )));
1636 }
1637 }
1638 if target.is_none() {
1639 return Err(RuntimeError::NotFound(format!(
1640 "link target {target_id} not found"
1641 )));
1642 }
1643 Ok(())
1644 }
1645
1646 pub async fn link(
1666 &self,
1667 token: &NamespaceToken,
1668 source_id: Uuid,
1669 target_id: Uuid,
1670 relation: EdgeRelation,
1671 weight: f64,
1672 metadata: Option<serde_json::Value>,
1673 ) -> RuntimeResult<Edge> {
1674 validate_edge_weight(weight)?;
1675 self.validate_edge_relation_endpoints(token, source_id, target_id, relation)
1676 .await?;
1677 let (source_id, target_id) = canonical_edge_endpoints(relation, source_id, target_id);
1678 let metadata = if relation == EdgeRelation::DependsOn {
1679 match (
1684 self.resolve_edge_endpoint(token, source_id).await?,
1685 self.resolve_edge_endpoint(token, target_id).await?,
1686 ) {
1687 (Some(Resolved::Entity(src_e)), Some(Resolved::Entity(tgt_e))) => {
1688 merge_dependency_kind(&src_e.kind, &tgt_e.kind, metadata)
1689 }
1690 _ => metadata,
1691 }
1692 } else {
1693 metadata
1694 };
1695 validate_edge_metadata(relation, metadata.as_ref())?;
1696 let now = chrono::Utc::now();
1697 let ns = token.namespace().as_str();
1698 let edge = Edge {
1699 id: LinkId::from(Uuid::new_v4()),
1700 namespace: ns.to_string(),
1701 source_id,
1702 target_id,
1703 relation,
1704 weight,
1705 created_at: now,
1706 updated_at: now,
1707 deleted_at: None,
1708 metadata,
1709 target_backend: None,
1710 };
1711 match self.graph(token)?.upsert_edge_guarded(edge).await? {
1721 khive_storage::GuardedWriteOutcome::Written => {}
1722 khive_storage::GuardedWriteOutcome::Refused(missing) => {
1723 return Err(RuntimeError::GuardedWriteFailed(GuardedWriteFailure {
1724 entry_index: None,
1725 missing_source: missing.source.then_some(source_id),
1726 missing_target: missing.target.then_some(target_id),
1727 }));
1728 }
1729 }
1730
1731 let persisted = self
1737 .list_edges(
1738 token,
1739 crate::curation::EdgeListFilter {
1740 source_id: Some(source_id),
1741 target_id: Some(target_id),
1742 relations: vec![relation],
1743 ..Default::default()
1744 },
1745 1,
1746 0,
1747 )
1748 .await?
1749 .into_iter()
1750 .next()
1751 .ok_or_else(|| {
1752 crate::RuntimeError::Internal(format!(
1753 "upsert_edge succeeded but natural-key lookup for ({source_id}, {target_id}, {relation}) returned nothing"
1754 ))
1755 })?;
1756 Ok(persisted)
1757 }
1758
1759 #[allow(clippy::too_many_arguments)]
1766 pub async fn link_with_target_backend(
1767 &self,
1768 token: &NamespaceToken,
1769 source_id: Uuid,
1770 target_id: Uuid,
1771 relation: EdgeRelation,
1772 weight: f64,
1773 metadata: Option<serde_json::Value>,
1774 target_backend: Option<String>,
1775 ) -> RuntimeResult<Edge> {
1776 validate_edge_weight(weight)?;
1777 let (source_id, target_id) = canonical_edge_endpoints(relation, source_id, target_id);
1778 validate_edge_metadata(relation, metadata.as_ref())?;
1779 let now = chrono::Utc::now();
1780 let ns = token.namespace().as_str();
1781 let edge = Edge {
1782 id: LinkId::from(Uuid::new_v4()),
1783 namespace: ns.to_string(),
1784 source_id,
1785 target_id,
1786 relation,
1787 weight,
1788 created_at: now,
1789 updated_at: now,
1790 deleted_at: None,
1791 metadata,
1792 target_backend,
1793 };
1794 self.graph(token)?.upsert_edge(edge).await?;
1795 let persisted = self
1796 .list_edges(
1797 token,
1798 crate::curation::EdgeListFilter {
1799 source_id: Some(source_id),
1800 target_id: Some(target_id),
1801 relations: vec![relation],
1802 ..Default::default()
1803 },
1804 1,
1805 0,
1806 )
1807 .await?
1808 .into_iter()
1809 .next()
1810 .ok_or_else(|| {
1811 crate::RuntimeError::Internal(format!(
1812 "upsert_edge succeeded but natural-key lookup for ({source_id}, {target_id}, {relation}) returned nothing"
1813 ))
1814 })?;
1815 Ok(persisted)
1816 }
1817
1818 pub(crate) async fn substrate_exists_in_ns(
1825 &self,
1826 token: &NamespaceToken,
1827 id: Uuid,
1828 ) -> RuntimeResult<bool> {
1829 if self.resolve(token, id).await?.is_some() {
1830 return Ok(true);
1831 }
1832 match self.get_edge_visible(token, id).await {
1833 Ok(Some(_)) => Ok(true),
1834 Ok(None) | Err(RuntimeError::NotFound(_)) => Ok(false),
1835 Err(err) => Err(err),
1836 }
1837 }
1838
1839 pub(crate) async fn substrate_exists_by_id(
1846 &self,
1847 token: &NamespaceToken,
1848 id: Uuid,
1849 ) -> RuntimeResult<bool> {
1850 if self.resolve_edge_endpoint(token, id).await?.is_some() {
1851 return Ok(true);
1852 }
1853 match self.get_edge(token, id).await {
1854 Ok(Some(_)) => Ok(true),
1855 Ok(None) | Err(RuntimeError::NotFound(_)) => Ok(false),
1856 Err(err) => Err(err),
1857 }
1858 }
1859
1860 pub async fn neighbors(
1869 &self,
1870 token: &NamespaceToken,
1871 node_id: Uuid,
1872 direction: Direction,
1873 limit: Option<u32>,
1874 relations: Option<Vec<EdgeRelation>>,
1875 ) -> RuntimeResult<Vec<NeighborHit>> {
1876 self.neighbors_with_query(
1877 token,
1878 node_id,
1879 NeighborQuery {
1880 direction,
1881 relations,
1882 limit,
1883 min_weight: None,
1884 },
1885 )
1886 .await
1887 }
1888
1889 pub async fn neighbors_with_query(
1899 &self,
1900 token: &NamespaceToken,
1901 node_id: Uuid,
1902 mut query: NeighborQuery,
1903 ) -> RuntimeResult<Vec<NeighborHit>> {
1904 if !self.substrate_exists_in_ns(token, node_id).await? {
1905 return Ok(Vec::new());
1906 }
1907
1908 query.direction =
1909 normalize_symmetric_direction(query.direction, query.relations.as_deref());
1910 let mut hits = Vec::new();
1911 for ns in token.visible_namespaces() {
1912 let temp = NamespaceToken::for_namespace(ns.clone());
1913 let mut ns_hits = self.graph(&temp)?.neighbors(node_id, query.clone()).await?;
1914 hits.append(&mut ns_hits);
1915 }
1916 hits.sort_by_key(|h| (h.node_id, h.edge_id));
1917 hits.dedup_by_key(|h| (h.node_id, h.edge_id));
1918 self.enrich_neighbor_hits(token, &mut hits).await;
1919 let candidate_ids: Vec<Uuid> = hits.iter().map(|h| h.node_id).collect();
1921 let deleted = self.deleted_entity_ids(candidate_ids).await;
1922 if !deleted.is_empty() {
1923 hits.retain(|h| !deleted.contains(&h.node_id));
1924 }
1925 hits.sort_by(|a, b| {
1932 b.weight
1933 .partial_cmp(&a.weight)
1934 .unwrap_or(std::cmp::Ordering::Equal)
1935 .then(a.node_id.cmp(&b.node_id))
1936 });
1937 Ok(hits)
1938 }
1939
1940 pub async fn annotation_neighbors_by_target_id(
1948 &self,
1949 target_id: Uuid,
1950 ) -> RuntimeResult<Vec<NeighborHit>> {
1951 let mut reader = self.sql().reader().await?;
1952 let rows = reader
1953 .query_all(SqlStatement {
1954 sql: "SELECT source_id, id, weight FROM graph_edges \
1955 WHERE target_id = ?1 AND relation = ?2 AND deleted_at IS NULL \
1956 ORDER BY weight DESC, source_id ASC"
1957 .to_string(),
1958 params: vec![
1959 SqlValue::Text(target_id.to_string()),
1960 SqlValue::Text(EdgeRelation::Annotates.to_string()),
1961 ],
1962 label: Some("annotations.by_target_id_unfiltered".into()),
1963 })
1964 .await?;
1965
1966 rows.into_iter()
1967 .map(|row| {
1968 let parse_uuid = |name: &str| match row.get(name) {
1969 Some(SqlValue::Text(value)) => Uuid::from_str(value).map_err(|error| {
1970 RuntimeError::Internal(format!("graph_edges.{name} is not a UUID: {error}"))
1971 }),
1972 Some(value) => Err(RuntimeError::Internal(format!(
1973 "graph_edges.{name} has unexpected SQL value {value:?}"
1974 ))),
1975 None => Err(RuntimeError::Internal(format!(
1976 "graph_edges row missing {name}"
1977 ))),
1978 };
1979 let weight = match row.get("weight") {
1980 Some(SqlValue::Float(value)) => Ok(*value),
1981 Some(value) => Err(RuntimeError::Internal(format!(
1982 "graph_edges.weight has unexpected SQL value {value:?}"
1983 ))),
1984 None => Err(RuntimeError::Internal(
1985 "graph_edges row missing weight".into(),
1986 )),
1987 }?;
1988
1989 Ok(NeighborHit {
1990 node_id: parse_uuid("source_id")?,
1991 edge_id: parse_uuid("id")?,
1992 relation: EdgeRelation::Annotates,
1993 weight,
1994 name: None,
1995 kind: None,
1996 entity_type: None,
1997 })
1998 })
1999 .collect()
2000 }
2001
2002 pub async fn neighbors_with_query_directed(
2011 &self,
2012 token: &NamespaceToken,
2013 node_id: Uuid,
2014 query: NeighborQuery,
2015 ) -> RuntimeResult<Vec<(NeighborHit, Direction)>> {
2016 if !self.substrate_exists_in_ns(token, node_id).await? {
2017 return Ok(Vec::new());
2018 }
2019
2020 let mut hits: Vec<DirectedNeighborHit> = Vec::new();
2021 for ns in token.visible_namespaces() {
2022 let temp = NamespaceToken::for_namespace(ns.clone());
2023 let mut ns_hits = self
2024 .graph(&temp)?
2025 .neighbors_both_directions(node_id, query.clone())
2026 .await?;
2027 hits.append(&mut ns_hits);
2028 }
2029 hits.sort_by_key(|h| {
2033 (
2034 h.hit.node_id,
2035 h.hit.edge_id,
2036 direction_sort_rank(&h.direction),
2037 )
2038 });
2039 hits.dedup_by_key(|h| {
2040 (
2041 h.hit.node_id,
2042 h.hit.edge_id,
2043 direction_sort_rank(&h.direction),
2044 )
2045 });
2046
2047 let mut plain_hits: Vec<NeighborHit> = hits.iter().map(|h| h.hit.clone()).collect();
2048 self.enrich_neighbor_hits(token, &mut plain_hits).await;
2049 for (dh, enriched) in hits.iter_mut().zip(plain_hits) {
2050 dh.hit = enriched;
2051 }
2052
2053 let candidate_ids: Vec<Uuid> = hits.iter().map(|h| h.hit.node_id).collect();
2055 let deleted = self.deleted_entity_ids(candidate_ids).await;
2056 if !deleted.is_empty() {
2057 hits.retain(|h| !deleted.contains(&h.hit.node_id));
2058 }
2059 hits.sort_by(|a, b| {
2063 b.hit
2064 .weight
2065 .partial_cmp(&a.hit.weight)
2066 .unwrap_or(std::cmp::Ordering::Equal)
2067 .then(a.hit.node_id.cmp(&b.hit.node_id))
2068 });
2069 Ok(hits.into_iter().map(|h| (h.hit, h.direction)).collect())
2070 }
2071
2072 pub async fn traverse(
2077 &self,
2078 token: &NamespaceToken,
2079 request: TraversalRequest,
2080 ) -> RuntimeResult<Vec<GraphPath>> {
2081 let mut request = request;
2082 let mut visible_roots = Vec::with_capacity(request.roots.len());
2083 for root in request.roots.drain(..) {
2084 if self.substrate_exists_in_ns(token, root).await? {
2085 visible_roots.push(root);
2086 }
2087 }
2088 request.roots = visible_roots;
2089 if request.roots.is_empty() {
2090 return Ok(Vec::new());
2091 }
2092
2093 let mut paths = Vec::new();
2094 for ns in token.visible_namespaces() {
2095 let temp = NamespaceToken::for_namespace(ns.clone());
2096 let mut ns_paths = self.graph(&temp)?.traverse(request.clone()).await?;
2097 paths.append(&mut ns_paths);
2098 }
2099 self.enrich_path_nodes(token, &mut paths, request.include_properties)
2100 .await;
2101 let all_node_ids: Vec<Uuid> = paths
2103 .iter()
2104 .flat_map(|p| p.nodes.iter().map(|n| n.node_id))
2105 .collect();
2106 let deleted = self.deleted_entity_ids(all_node_ids).await;
2107 if !deleted.is_empty() {
2108 for path in paths.iter_mut() {
2109 path.nodes.retain(|n| !deleted.contains(&n.node_id));
2110 }
2111 paths.retain(|p| !p.nodes.is_empty());
2112 }
2113 Ok(paths)
2114 }
2115
2116 async fn deleted_entity_ids(&self, ids: Vec<Uuid>) -> std::collections::HashSet<Uuid> {
2129 if ids.is_empty() {
2130 return std::collections::HashSet::new();
2131 }
2132 let id_strs: Vec<String> = ids.iter().map(|u| u.to_string()).collect();
2133 let n = id_strs.len();
2134 let entities_placeholders = (0..n)
2141 .map(|i| format!("?{}", i + 1))
2142 .collect::<Vec<_>>()
2143 .join(",");
2144 let notes_placeholders = (0..n)
2145 .map(|i| format!("?{}", n + i + 1))
2146 .collect::<Vec<_>>()
2147 .join(",");
2148 let sql_str = format!(
2149 "SELECT id FROM entities WHERE id IN ({entities_placeholders}) AND deleted_at IS NOT NULL \
2150 UNION \
2151 SELECT id FROM notes WHERE id IN ({notes_placeholders}) AND deleted_at IS NOT NULL"
2152 );
2153 let params: Vec<SqlValue> = id_strs
2155 .iter()
2156 .chain(id_strs.iter())
2157 .cloned()
2158 .map(SqlValue::Text)
2159 .collect();
2160 let stmt = SqlStatement {
2161 sql: sql_str,
2162 params,
2163 label: Some("deleted_entity_ids".into()),
2164 };
2165 let mut out = std::collections::HashSet::new();
2166 let sql = self.sql();
2167 if let Ok(mut reader) = sql.reader().await {
2168 if let Ok(rows) = reader.query_all(stmt).await {
2169 for row in rows {
2170 if let Some(col) = row.columns.first() {
2171 if let SqlValue::Text(s) = &col.value {
2172 if let Ok(u) = s.parse::<Uuid>() {
2173 out.insert(u);
2174 }
2175 }
2176 }
2177 }
2178 }
2179 }
2181 out
2182 }
2183
2184 async fn enrich_neighbor_hits(&self, token: &NamespaceToken, hits: &mut [NeighborHit]) {
2193 if hits.is_empty() {
2194 return;
2195 }
2196
2197 let unique_ids: Vec<Uuid> = {
2199 let mut seen = std::collections::HashSet::new();
2200 hits.iter()
2201 .filter_map(|h| {
2202 if seen.insert(h.node_id) {
2203 Some(h.node_id)
2204 } else {
2205 None
2206 }
2207 })
2208 .collect()
2209 };
2210
2211 let entity_map: HashMap<Uuid, Entity> = self
2212 .get_entities_by_ids_visible(token, &unique_ids)
2213 .await
2214 .unwrap_or_default()
2215 .into_iter()
2216 .map(|e| (e.id, e))
2217 .collect();
2218
2219 let residual_ids: Vec<Uuid> = unique_ids
2221 .iter()
2222 .filter(|id| !entity_map.contains_key(id))
2223 .copied()
2224 .collect();
2225
2226 let note_map: HashMap<Uuid, Note> = if !residual_ids.is_empty() {
2227 if let Ok(store) = self.notes(token) {
2228 store
2229 .get_notes_batch(&residual_ids)
2230 .await
2231 .unwrap_or_default()
2232 .into_iter()
2233 .map(|n| (n.id, n))
2234 .collect()
2235 } else {
2236 HashMap::new()
2237 }
2238 } else {
2239 HashMap::new()
2240 };
2241
2242 for hit in hits.iter_mut() {
2243 if let Some(entity) = entity_map.get(&hit.node_id) {
2244 hit.name = Some(entity.name.clone());
2245 hit.kind = Some(entity.kind.clone());
2246 hit.entity_type = entity.entity_type.clone();
2247 } else if let Some(note) = note_map.get(&hit.node_id) {
2248 let kind = note.kind.clone();
2249 let name = note
2250 .name
2251 .as_deref()
2252 .filter(|s| !s.trim().is_empty())
2253 .map(|s| s.to_owned())
2254 .unwrap_or_else(|| format!("[{kind}]"));
2255 hit.name = Some(name);
2256 hit.kind = Some(kind);
2257 }
2258 }
2259 }
2260
2261 async fn enrich_path_nodes(
2272 &self,
2273 token: &NamespaceToken,
2274 paths: &mut [GraphPath],
2275 include_properties: bool,
2276 ) {
2277 if paths.is_empty() {
2278 return;
2279 }
2280
2281 let unique_ids: Vec<Uuid> = {
2283 let mut seen = std::collections::HashSet::new();
2284 paths
2285 .iter()
2286 .flat_map(|p| p.nodes.iter())
2287 .filter_map(|n| {
2288 if seen.insert(n.node_id) {
2289 Some(n.node_id)
2290 } else {
2291 None
2292 }
2293 })
2294 .collect()
2295 };
2296
2297 let entity_map: HashMap<Uuid, Entity> = self
2298 .get_entities_by_ids_visible(token, &unique_ids)
2299 .await
2300 .unwrap_or_default()
2301 .into_iter()
2302 .map(|e| (e.id, e))
2303 .collect();
2304
2305 for path in paths.iter_mut() {
2306 for node in path.nodes.iter_mut() {
2307 if let Some(entity) = entity_map.get(&node.node_id) {
2308 node.name = Some(entity.name.clone());
2309 node.kind = Some(entity.kind.clone());
2310 if include_properties {
2311 node.properties = entity.properties.clone();
2312 }
2313 }
2314 }
2315 }
2316 }
2317
2318 #[allow(clippy::too_many_arguments)]
2332 pub async fn create_note(
2333 &self,
2334 token: &NamespaceToken,
2335 kind: &str,
2336 name: Option<&str>,
2337 content: &str,
2338 salience: Option<f64>,
2339 properties: Option<serde_json::Value>,
2340 annotates: Vec<Uuid>,
2341 ) -> RuntimeResult<Note> {
2342 self.create_note_inner(
2343 token, kind, name, content, None, salience, None, properties, annotates, None,
2344 )
2345 .await
2346 }
2347
2348 #[allow(clippy::too_many_arguments)]
2359 pub async fn create_note_with_embedding_content(
2360 &self,
2361 token: &NamespaceToken,
2362 kind: &str,
2363 name: Option<&str>,
2364 content: &str,
2365 embedding_content: Option<&str>,
2366 salience: Option<f64>,
2367 properties: Option<serde_json::Value>,
2368 annotates: Vec<Uuid>,
2369 ) -> RuntimeResult<Note> {
2370 self.create_note_inner(
2371 token,
2372 kind,
2373 name,
2374 content,
2375 embedding_content,
2376 salience,
2377 None,
2378 properties,
2379 annotates,
2380 None,
2381 )
2382 .await
2383 }
2384
2385 #[allow(clippy::too_many_arguments)]
2389 pub async fn create_note_with_decay(
2390 &self,
2391 token: &NamespaceToken,
2392 kind: &str,
2393 name: Option<&str>,
2394 content: &str,
2395 salience: Option<f64>,
2396 decay_factor: f64,
2397 properties: Option<serde_json::Value>,
2398 annotates: Vec<Uuid>,
2399 ) -> RuntimeResult<Note> {
2400 self.create_note_with_decay_for_embedding_model(
2401 token,
2402 kind,
2403 name,
2404 content,
2405 salience,
2406 decay_factor,
2407 properties,
2408 annotates,
2409 None,
2410 )
2411 .await
2412 }
2413
2414 #[allow(clippy::too_many_arguments)]
2419 pub async fn create_note_with_decay_for_embedding_model(
2420 &self,
2421 token: &NamespaceToken,
2422 kind: &str,
2423 name: Option<&str>,
2424 content: &str,
2425 salience: Option<f64>,
2426 decay_factor: f64,
2427 properties: Option<serde_json::Value>,
2428 annotates: Vec<Uuid>,
2429 embedding_model: Option<&str>,
2430 ) -> RuntimeResult<Note> {
2431 self.create_note_inner(
2432 token,
2433 kind,
2434 name,
2435 content,
2436 None,
2437 salience,
2438 Some(decay_factor),
2439 properties,
2440 annotates,
2441 embedding_model,
2442 )
2443 .await
2444 }
2445
2446 pub async fn try_create_note(
2459 &self,
2460 token: &NamespaceToken,
2461 kind: &str,
2462 name: Option<&str>,
2463 content: &str,
2464 properties: Option<serde_json::Value>,
2465 ) -> RuntimeResult<Option<Note>> {
2466 self.validate_note_kind(kind)?;
2467 crate::secret_gate::check(content)?;
2468 if let Some(n) = name {
2469 crate::secret_gate::check(n)?;
2470 }
2471 if let Some(ref p) = properties {
2472 crate::secret_gate::check_json(p)?;
2473 }
2474
2475 let ns = token.namespace().as_str();
2476 let mut note = Note::new(ns, kind, content);
2477 if let Some(n) = name {
2478 note = note.with_name(n);
2479 }
2480 if let Some(p) = properties {
2481 note = note.with_properties(p);
2482 }
2483
2484 let inserted = self.notes(token)?.try_insert_note(note.clone()).await?;
2485 if !inserted {
2486 return Ok(None);
2487 }
2488
2489 if let Ok(fts) = self.text_for_notes(token) {
2491 if let Err(e) = fts.upsert_document(note_fts_document(¬e)).await {
2492 tracing::warn!(
2493 note_id = %note.id,
2494 error = %e,
2495 "try_create_note: FTS indexing failed (non-fatal)"
2496 );
2497 }
2498 }
2499
2500 let embed_model_names = self.registered_embedding_model_names();
2502 for model_name in &embed_model_names {
2503 match self
2504 .embed_document_with_model(model_name, ¬e.content)
2505 .await
2506 {
2507 Ok(vector) => {
2508 if let Ok(vs) = self.vectors_for_model(token, model_name) {
2509 if let Err(e) = vs
2510 .insert(
2511 note.id,
2512 SubstrateKind::Note,
2513 ns,
2514 "note.content",
2515 vec![vector],
2516 )
2517 .await
2518 {
2519 tracing::warn!(
2520 note_id = %note.id,
2521 model = %model_name,
2522 error = %e,
2523 "try_create_note: vector insert failed (non-fatal)"
2524 );
2525 }
2526 }
2527 }
2528 Err(e) => {
2529 tracing::warn!(
2530 note_id = %note.id,
2531 model = %model_name,
2532 error = %e,
2533 "try_create_note: embedding failed (non-fatal)"
2534 );
2535 }
2536 }
2537 }
2538
2539 Ok(Some(note))
2540 }
2541
2542 #[allow(clippy::too_many_arguments)]
2546 async fn create_note_inner(
2547 &self,
2548 token: &NamespaceToken,
2549 kind: &str,
2550 name: Option<&str>,
2551 content: &str,
2552 embedding_content: Option<&str>,
2553 salience: Option<f64>,
2554 decay_factor: Option<f64>,
2555 properties: Option<serde_json::Value>,
2556 annotates: Vec<Uuid>,
2557 embedding_model: Option<&str>,
2558 ) -> RuntimeResult<Note> {
2559 self.validate_note_kind(kind)?;
2560 crate::secret_gate::check(content)?;
2562 if let Some(n) = name {
2563 crate::secret_gate::check(n)?;
2564 }
2565 if let Some(ref p) = properties {
2566 crate::secret_gate::check_json(p)?;
2567 }
2568 if let Some(ec) = embedding_content {
2574 if ec.is_empty() {
2575 return Err(RuntimeError::InvalidInput(
2576 "embedding_content must not be empty".into(),
2577 ));
2578 }
2579 if ec.len() >= content.len() || !content.starts_with(ec) {
2580 return Err(RuntimeError::InvalidInput(
2581 "embedding_content must be a proper prefix of content".into(),
2582 ));
2583 }
2584 crate::secret_gate::check(ec)?;
2585 }
2586 let ns = token.namespace().as_str();
2587
2588 for &target_id in &annotates {
2591 if !self.substrate_exists_by_id(token, target_id).await? {
2592 return Err(RuntimeError::NotFound(format!(
2593 "create_note annotates target {target_id} not found"
2594 )));
2595 }
2596 }
2597
2598 if let Some(s) = salience {
2601 if !s.is_finite() || !(0.0..=1.0).contains(&s) {
2602 return Err(RuntimeError::InvalidInput(format!(
2603 "salience must be a finite value in [0.0, 1.0]; got {s}"
2604 )));
2605 }
2606 }
2607 if let Some(d) = decay_factor {
2608 if !d.is_finite() || d < 0.0 {
2609 return Err(RuntimeError::InvalidInput(format!(
2610 "decay_factor must be a finite value >= 0.0; got {d}"
2611 )));
2612 }
2613 }
2614
2615 if let Some(model_name) = embedding_model {
2619 self.resolve_embedding_model(Some(model_name))?;
2620 }
2621
2622 let mut note = Note::new(ns, kind, content);
2623 if let Some(s) = salience {
2624 note = note.with_salience(s);
2625 }
2626 if let Some(df) = decay_factor {
2627 note = note.with_decay(df);
2628 }
2629 if let Some(n) = name {
2630 note = note.with_name(n);
2631 }
2632 if let Some(p) = properties {
2633 note = note.with_properties(p);
2634 }
2635 self.notes(token)?.upsert_note(note.clone()).await?;
2636
2637 let embed_model_names: Vec<String> = if let Some(m) = embedding_model {
2643 vec![m.to_string()]
2644 } else {
2645 let names = self.registered_embedding_model_names();
2651 if names.is_empty() {
2652 vec![]
2654 } else {
2655 names
2656 }
2657 };
2658
2659 {
2661 #[cfg(any(test, feature = "fault-injection"))]
2667 let fts_inject = {
2668 let mut g = FTS_FAIL_NS.lock().unwrap();
2669 if g.as_deref() == Some(ns) {
2670 *g = None;
2671 true
2672 } else {
2673 false
2674 }
2675 };
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 #[allow(clippy::too_many_arguments)]
3022 pub async fn search_notes(
3023 &self,
3024 token: &NamespaceToken,
3025 query_text: &str,
3026 query_vector: Option<Vec<f32>>,
3027 limit: u32,
3028 note_kind: Option<&str>,
3029 include_superseded: bool,
3030 tags_any: &[String],
3031 properties_filter: Option<&serde_json::Value>,
3032 ) -> RuntimeResult<Vec<NoteSearchHit>> {
3033 const RRF_K: usize = 60;
3034 let candidates = limit.saturating_mul(4).max(limit);
3035 let visible_ns: Vec<String> = token
3036 .visible_namespaces()
3037 .iter()
3038 .map(|ns| ns.as_str().to_owned())
3039 .collect();
3040
3041 #[cfg(any(test, feature = "fault-injection"))]
3054 let fts_search_inject = {
3055 let mut g = FTS_SEARCH_FAIL_NS.lock().unwrap();
3056 match g.as_deref() {
3057 Some(armed) if visible_ns.iter().any(|ns| ns == armed) => {
3058 *g = None;
3059 true
3060 }
3061 _ => false,
3062 }
3063 };
3064 #[cfg(not(any(test, feature = "fault-injection")))]
3065 let fts_search_inject = false;
3066
3067 let text_search_result = if fts_search_inject {
3068 Err(khive_storage::StorageError::Timeout {
3069 operation: "fts_search".into(),
3070 })
3071 } else {
3072 self.text_for_notes(token)?
3073 .search(TextSearchRequest {
3074 query: query_text.to_string(),
3075 mode: TextQueryMode::Plain,
3076 filter: Some(TextFilter {
3077 namespaces: visible_ns.clone(),
3078 ..TextFilter::default()
3079 }),
3080 top_k: candidates,
3081 snippet_chars: 200,
3082 })
3083 .await
3084 };
3085
3086 let text_hits = crate::error::fts_text_leg_or_err(
3087 text_search_result.map_err(RuntimeError::from),
3088 "search_notes",
3089 query_text,
3090 )?;
3091
3092 let vector_hits = if query_vector.is_some() || self.config().embedding_model.is_some() {
3094 self.vector_search(
3095 token,
3096 query_vector,
3097 Some(query_text),
3098 candidates,
3099 Some(SubstrateKind::Note),
3100 )
3101 .await?
3102 } else {
3103 vec![]
3104 };
3105
3106 let fuse_k = text_hits.len() + vector_hits.len();
3112 let fused = crate::fusion::rrf_fuse_k(text_hits, vector_hits, RRF_K, fuse_k)?;
3113
3114 let candidate_ids: Vec<Uuid> = fused.iter().map(|hit| hit.entity_id).collect();
3115 if candidate_ids.is_empty() {
3116 return Ok(vec![]);
3117 }
3118
3119 let note_store = self.notes(token)?;
3124 let mut alive_notes: HashMap<Uuid, Note> = HashMap::new();
3125 for id in &candidate_ids {
3126 if let Some(note) = note_store.get_note(*id).await? {
3127 if note.deleted_at.is_some() {
3128 continue;
3129 }
3130 if let Some(want_kind) = note_kind {
3131 if note.kind != want_kind {
3132 continue;
3133 }
3134 }
3135 if !tags_any.is_empty() {
3140 let note_tags: Vec<String> = note
3141 .properties
3142 .as_ref()
3143 .and_then(|p| p.get("tags"))
3144 .and_then(serde_json::Value::as_array)
3145 .map(|arr| {
3146 arr.iter()
3147 .filter_map(serde_json::Value::as_str)
3148 .map(str::to_owned)
3149 .collect()
3150 })
3151 .unwrap_or_default();
3152 if !note_tags
3153 .iter()
3154 .any(|t| tags_any.iter().any(|w| t.eq_ignore_ascii_case(w)))
3155 {
3156 continue;
3157 }
3158 }
3159 if let Some(pf) = properties_filter {
3161 if !note_props_match(note.properties.as_ref(), pf) {
3162 continue;
3163 }
3164 }
3165 alive_notes.insert(*id, note);
3166 }
3167 }
3168
3169 if !include_superseded && !alive_notes.is_empty() {
3172 let graph = self.graph(token)?;
3173 let mut superseded: std::collections::HashSet<Uuid> = std::collections::HashSet::new();
3174 for ¬e_id in alive_notes.keys() {
3175 let inbound = graph
3176 .neighbors(
3177 note_id,
3178 NeighborQuery {
3179 direction: Direction::In,
3180 relations: Some(vec![EdgeRelation::Supersedes]),
3181 limit: Some(1),
3182 min_weight: None,
3183 },
3184 )
3185 .await?;
3186 if !inbound.is_empty() {
3187 superseded.insert(note_id);
3188 }
3189 }
3190 alive_notes.retain(|id, _| !superseded.contains(id));
3191 }
3192
3193 let mut hits: Vec<NoteSearchHit> = fused
3195 .into_iter()
3196 .filter_map(|hit| {
3197 let note = alive_notes.get(&hit.entity_id)?;
3198 let salience = note.salience.unwrap_or(0.5);
3199 let weight = 0.5 + 0.5 * salience;
3200 let weighted = DeterministicScore::from_f64(hit.score.to_f64() * weight);
3201 Some(NoteSearchHit {
3202 note_id: hit.entity_id,
3203 score: weighted,
3204 title: hit.title.or_else(|| note_title(note)),
3205 snippet: hit.snippet.or_else(|| note_snippet(note)),
3206 })
3207 })
3208 .collect();
3209
3210 hits.sort_by(|a, b| b.score.cmp(&a.score).then(a.note_id.cmp(&b.note_id)));
3211 hits.truncate(limit as usize);
3212 Ok(hits)
3213 }
3214
3215 pub async fn resolve_prefix(
3222 &self,
3223 token: &NamespaceToken,
3224 prefix: &str,
3225 ) -> RuntimeResult<Option<Uuid>> {
3226 let namespaces = [token.namespace().as_str().to_owned()];
3227 self.resolve_prefix_inner(Some(&namespaces), prefix, false)
3228 .await
3229 }
3230
3231 pub async fn resolve_prefix_including_deleted(
3232 &self,
3233 token: &NamespaceToken,
3234 prefix: &str,
3235 ) -> RuntimeResult<Option<Uuid>> {
3236 let namespaces = [token.namespace().as_str().to_owned()];
3237 self.resolve_prefix_inner(Some(&namespaces), prefix, true)
3238 .await
3239 }
3240
3241 pub async fn resolve_prefix_unfiltered(&self, prefix: &str) -> RuntimeResult<Option<Uuid>> {
3249 self.resolve_prefix_inner(None, prefix, false).await
3250 }
3251
3252 pub async fn resolve_prefix_unfiltered_including_deleted(
3255 &self,
3256 prefix: &str,
3257 ) -> RuntimeResult<Option<Uuid>> {
3258 self.resolve_prefix_inner(None, prefix, true).await
3259 }
3260
3261 async fn resolve_prefix_inner(
3273 &self,
3274 namespaces: Option<&[String]>,
3275 prefix: &str,
3276 include_deleted: bool,
3277 ) -> RuntimeResult<Option<Uuid>> {
3278 use khive_storage::types::{SqlStatement, SqlValue};
3279
3280 if !prefix.chars().all(|c| c.is_ascii_hexdigit() || c == '-') {
3289 return Ok(None);
3290 }
3291
3292 let pattern = format!("{}%", hex_prefix_to_uuid_pattern(prefix));
3293
3294 let tables = [
3295 ("entities", true),
3296 ("notes", true),
3297 ("events", false),
3298 ("graph_edges", false),
3299 ];
3300
3301 let ns_clause = namespaces.map(|ns| {
3302 let placeholders: Vec<String> = (0..ns.len()).map(|i| format!("?{}", i + 2)).collect();
3303 format!(" AND namespace IN ({})", placeholders.join(", "))
3304 });
3305
3306 let mut matches: Vec<String> = Vec::new();
3315 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
3316 let mut reader = self.sql().reader().await.map_err(RuntimeError::Storage)?;
3317
3318 for (table, has_deleted_at) in tables {
3319 let deleted_filter = if has_deleted_at && !include_deleted {
3320 " AND deleted_at IS NULL"
3321 } else {
3322 ""
3323 };
3324 let mut params = vec![SqlValue::Text(pattern.clone())];
3325 if let Some(ns) = namespaces {
3326 params.extend(ns.iter().map(|n| SqlValue::Text(n.clone())));
3327 }
3328 let sql = SqlStatement {
3329 sql: format!(
3330 "SELECT id FROM {table} WHERE id LIKE ?1{ns_clause}{deleted_filter} LIMIT 2",
3331 ns_clause = ns_clause.as_deref().unwrap_or("")
3332 ),
3333 params,
3334 label: Some("resolve_prefix".into()),
3335 };
3336 match reader.query_all(sql).await {
3337 Ok(rows) => {
3338 for row in rows {
3339 if let Some(col) = row.columns.first() {
3340 if let SqlValue::Text(s) = &col.value {
3341 if seen.insert(s.clone()) {
3342 matches.push(s.clone());
3343 }
3344 }
3345 }
3346 }
3347 }
3348 Err(e) => {
3349 let msg = e.to_string();
3350 if msg.contains("no such table") {
3351 continue;
3352 }
3353 return Err(RuntimeError::Storage(e));
3354 }
3355 }
3356 if matches.len() > 1 {
3357 break;
3358 }
3359 }
3360
3361 match matches.len() {
3362 0 => Ok(None),
3363 1 => {
3364 let uuid = Uuid::from_str(&matches[0])
3365 .map_err(|e| RuntimeError::Internal(format!("stored UUID is invalid: {e}")))?;
3366 Ok(Some(uuid))
3367 }
3368 _ => {
3369 let uuids: Vec<uuid::Uuid> = matches
3370 .iter()
3371 .filter_map(|s| Uuid::from_str(s).ok())
3372 .collect();
3373 Err(RuntimeError::AmbiguousPrefix {
3374 prefix: prefix.to_string(),
3375 matches: uuids,
3376 })
3377 }
3378 }
3379 }
3380
3381 pub async fn resolve_by_id(
3392 &self,
3393 token: &NamespaceToken,
3394 id: Uuid,
3395 ) -> RuntimeResult<Option<Resolved>> {
3396 if let Some(entity) = self.entities(token)?.get_entity(id).await? {
3398 return Ok(Some(Resolved::Entity(entity)));
3399 }
3400
3401 if let Some(note) = self.notes(token)?.get_note(id).await? {
3403 return Ok(Some(Resolved::Note(note)));
3404 }
3405
3406 Ok(None)
3409 }
3410
3411 pub async fn resolve_by_id_including_deleted(
3417 &self,
3418 token: &NamespaceToken,
3419 id: Uuid,
3420 ) -> RuntimeResult<Option<Resolved>> {
3421 if let Some(entity) = self
3423 .entities(token)?
3424 .get_entity_including_deleted(id)
3425 .await?
3426 {
3427 return Ok(Some(Resolved::Entity(entity)));
3428 }
3429
3430 if let Some(note) = self.notes(token)?.get_note_including_deleted(id).await? {
3432 return Ok(Some(Resolved::Note(note)));
3433 }
3434
3435 Ok(None)
3438 }
3439
3440 pub async fn resolve(
3445 &self,
3446 token: &NamespaceToken,
3447 id: Uuid,
3448 ) -> RuntimeResult<Option<Resolved>> {
3449 match self.get_entity(token, id).await {
3451 Ok(entity) => return Ok(Some(Resolved::Entity(entity))),
3452 Err(RuntimeError::NotFound(_) | RuntimeError::NamespaceMismatch { .. }) => {}
3453 Err(e) => return Err(e),
3454 }
3455
3456 if let Some(note) = self.notes(token)?.get_note(id).await? {
3458 if Self::ensure_namespace_visible(¬e.namespace, token).is_ok() {
3459 return Ok(Some(Resolved::Note(note)));
3460 }
3461 }
3462
3463 if let Some(event) = self.events(token)?.get_event(id).await? {
3465 if Self::ensure_namespace_visible(&event.namespace, token).is_ok() {
3466 return Ok(Some(Resolved::Event(event)));
3467 }
3468 }
3469
3470 Ok(None)
3471 }
3472
3473 pub async fn resolve_edge_endpoint(
3484 &self,
3485 token: &NamespaceToken,
3486 id: Uuid,
3487 ) -> RuntimeResult<Option<Resolved>> {
3488 if let Some(resolved) = self.resolve_by_id(token, id).await? {
3489 return Ok(Some(resolved));
3490 }
3491 if let Some(event) = self.events(token)?.get_event(id).await? {
3492 return Ok(Some(Resolved::Event(event)));
3493 }
3494 Ok(None)
3495 }
3496
3497 pub async fn resolve_primary(
3502 &self,
3503 token: &NamespaceToken,
3504 id: Uuid,
3505 ) -> RuntimeResult<Option<Resolved>> {
3506 let ns = token.namespace().as_str();
3507
3508 if let Some(entity) = self.entities(token)?.get_entity(id).await? {
3510 if Self::ensure_namespace(&entity.namespace, ns).is_ok() {
3511 return Ok(Some(Resolved::Entity(entity)));
3512 }
3513 }
3514
3515 if let Some(note) = self.notes(token)?.get_note(id).await? {
3517 if Self::ensure_namespace(¬e.namespace, ns).is_ok() {
3518 return Ok(Some(Resolved::Note(note)));
3519 }
3520 }
3521
3522 if let Some(event) = self.events(token)?.get_event(id).await? {
3524 if Self::ensure_namespace(&event.namespace, ns).is_ok() {
3525 return Ok(Some(Resolved::Event(event)));
3526 }
3527 }
3528
3529 Ok(None)
3530 }
3531
3532 pub async fn resolve_including_deleted(
3537 &self,
3538 token: &NamespaceToken,
3539 id: Uuid,
3540 ) -> RuntimeResult<Option<Resolved>> {
3541 let ns = token.namespace().as_str();
3542
3543 if let Some(entity) = self
3544 .entities(token)?
3545 .get_entity_including_deleted(id)
3546 .await?
3547 {
3548 if Self::ensure_namespace(&entity.namespace, ns).is_ok() {
3549 return Ok(Some(Resolved::Entity(entity)));
3550 }
3551 }
3552
3553 if let Some(note) = self.notes(token)?.get_note_including_deleted(id).await? {
3554 if Self::ensure_namespace(¬e.namespace, ns).is_ok() {
3555 return Ok(Some(Resolved::Note(note)));
3556 }
3557 }
3558
3559 if let Some(event) = self.events(token)?.get_event(id).await? {
3560 if Self::ensure_namespace(&event.namespace, ns).is_ok() {
3561 return Ok(Some(Resolved::Event(event)));
3562 }
3563 }
3564
3565 Ok(None)
3566 }
3567
3568 async fn atomic_hard_delete_with_edge_purge(
3593 &self,
3594 row_statement: SqlStatement,
3595 node_id: Uuid,
3596 ) -> RuntimeResult<bool> {
3597 let plan = AtomicOpPlan::Delete(DeletePlan {
3598 target_id: node_id,
3599 statements: vec![
3600 PlanStatement {
3601 statement: row_statement,
3602 guard: Some(AffectedRowGuard::exactly(1)),
3603 },
3604 PlanStatement {
3605 statement: purge_incident_edges_statement(node_id),
3606 guard: None,
3607 },
3608 ],
3609 post_commit: PostCommitEffect::None,
3610 });
3611 match run_atomic_unit(self.sql().as_ref(), vec![plan]).await {
3612 Ok(AtomicRunOutcome::Committed { .. }) => Ok(true),
3613 Ok(AtomicRunOutcome::RolledBack {
3614 failure: AtomicOpFailure::GuardFailed { .. },
3615 ..
3616 }) => Ok(false),
3617 Ok(AtomicRunOutcome::RolledBack {
3618 failure: AtomicOpFailure::SqlError { message, .. },
3619 ..
3620 }) => Err(RuntimeError::Internal(format!(
3621 "hard delete + edge purge for {node_id} failed: {message}"
3622 ))),
3623 Err(e) => Err(RuntimeError::Internal(format!(
3624 "hard delete + edge purge for {node_id}: atomic unit seam failure: {}",
3625 e.0
3626 ))),
3627 }
3628 }
3629
3630 pub async fn delete_note(
3641 &self,
3642 token: &NamespaceToken,
3643 id: Uuid,
3644 hard: bool,
3645 ) -> RuntimeResult<bool> {
3646 let note_store = self.notes(token)?;
3647 let note = if hard {
3648 match note_store.get_note_including_deleted(id).await? {
3649 Some(n) => n,
3650 None => return Ok(false),
3651 }
3652 } else {
3653 match note_store.get_note(id).await? {
3654 Some(n) => n,
3655 None => return Ok(false),
3656 }
3657 };
3658 let mode = if hard {
3659 DeleteMode::Hard
3660 } else {
3661 DeleteMode::Soft
3662 };
3663
3664 let record_tok = NamespaceToken::for_namespace(
3666 khive_types::Namespace::parse(¬e.namespace)
3667 .map_err(|e| RuntimeError::Internal(format!("note namespace invalid: {e}")))?,
3668 );
3669 let record_ns = note.namespace.clone();
3670
3671 let deleted = if hard {
3676 let deleted = self
3677 .atomic_hard_delete_with_edge_purge(note_hard_delete_statement(id), id)
3678 .await?;
3679 self.text_for_notes(&record_tok)?
3680 .delete_document(&record_ns, id)
3681 .await?;
3682 for model_name in self.registered_embedding_model_names() {
3685 self.vectors_for_model(&record_tok, &model_name)?
3686 .delete(id)
3687 .await?;
3688 }
3689 deleted
3690 } else {
3691 let deleted = note_store.delete_note(id, mode).await?;
3692 if deleted {
3693 self.text_for_notes(&record_tok)?
3694 .delete_document(&record_ns, id)
3695 .await?;
3696 for model_name in self.registered_embedding_model_names() {
3697 self.vectors_for_model(&record_tok, &model_name)?
3698 .delete(id)
3699 .await?;
3700 }
3701 }
3702 deleted
3703 };
3704 if deleted {
3705 let event_store = self.events(token)?;
3706 let event = khive_storage::event::Event::new(
3707 record_ns.clone(),
3708 "delete",
3709 EventKind::NoteDeleted,
3710 SubstrateKind::Note,
3711 "",
3712 )
3713 .with_target(id)
3714 .with_payload(serde_json::json!({"id": id, "namespace": record_ns, "hard": hard}));
3715 event_store.append_event(event).await.map_err(|e| {
3716 RuntimeError::Internal(format!("delete_note: event store write failed: {e}"))
3717 })?;
3718 self.fire_note_mutation_hook(¬e.kind, id).await;
3725 }
3726 Ok(deleted)
3727 }
3728
3729 pub async fn delete_note_row_first_for_compensation(
3748 &self,
3749 token: &NamespaceToken,
3750 id: Uuid,
3751 ) -> RuntimeResult<()> {
3752 let note_store = self.notes(token)?;
3753 let Some(note) = note_store.get_note_including_deleted(id).await? else {
3754 return Ok(());
3755 };
3756 let record_tok = NamespaceToken::for_namespace(
3757 khive_types::Namespace::parse(¬e.namespace)
3758 .map_err(|e| RuntimeError::Internal(format!("note namespace invalid: {e}")))?,
3759 );
3760 let record_ns = note.namespace.clone();
3761
3762 note_store.delete_note(id, DeleteMode::Hard).await?;
3764
3765 #[cfg(any(test, feature = "fault-injection"))]
3766 {
3767 let armed = ROLLBACK_CLEANUP_FAIL_NS.lock().unwrap().take();
3768 if armed.as_deref() == Some(record_ns.as_str()) {
3769 return Err(RuntimeError::Internal(
3770 "row removed but compensation cleanup failed: injected=true".to_string(),
3771 ));
3772 }
3773 }
3774
3775 let mut cleanup_errors = Vec::new();
3776 if let Err(e) = self.graph(&record_tok)?.purge_incident_edges(id).await {
3777 cleanup_errors.push(format!("graph={e}"));
3778 }
3779 if let Err(e) = self
3780 .text_for_notes(&record_tok)?
3781 .delete_document(&record_ns, id)
3782 .await
3783 {
3784 cleanup_errors.push(format!("fts={e}"));
3785 }
3786 for model_name in self.registered_embedding_model_names() {
3787 if let Err(e) = self
3788 .vectors_for_model(&record_tok, &model_name)?
3789 .delete(id)
3790 .await
3791 {
3792 cleanup_errors.push(format!("vector[{model_name}]={e}"));
3793 }
3794 }
3795 if cleanup_errors.is_empty() {
3796 Ok(())
3797 } else {
3798 Err(RuntimeError::Internal(format!(
3799 "row removed but compensation cleanup failed: {}",
3800 cleanup_errors.join("; ")
3801 )))
3802 }
3803 }
3804}
3805
3806#[derive(Clone, Debug, Serialize)]
3808pub struct QueryResult {
3809 pub rows: Vec<SqlRow>,
3810 #[serde(skip_serializing_if = "Vec::is_empty")]
3811 pub warnings: Vec<String>,
3812}
3813
3814impl KhiveRuntime {
3815 pub async fn query(&self, token: &NamespaceToken, query: &str) -> RuntimeResult<Vec<SqlRow>> {
3823 Ok(self
3824 .query_with_metadata(token, query, khive_query::CompileOptions::default())
3825 .await?
3826 .rows)
3827 }
3828
3829 pub async fn query_with_metadata(
3831 &self,
3832 token: &NamespaceToken,
3833 query: &str,
3834 mut opts: khive_query::CompileOptions,
3835 ) -> RuntimeResult<QueryResult> {
3836 use khive_query::QueryValue;
3837 use khive_storage::types::SqlValue;
3838
3839 let ast = khive_query::parse_auto(query)?;
3840 opts.scopes = token
3841 .visible_namespaces()
3842 .iter()
3843 .map(|ns| ns.as_str().to_string())
3844 .collect();
3845 let compiled = khive_query::compile(&ast, &opts)?;
3846 let mut warnings = compiled.warnings;
3847 let truncation_check = compiled.truncation_check;
3848
3849 let params: Vec<SqlValue> = compiled
3852 .params
3853 .into_iter()
3854 .map(|qv| match qv {
3855 QueryValue::Null => SqlValue::Null,
3856 QueryValue::Integer(n) => SqlValue::Integer(n),
3857 QueryValue::Float(f) => SqlValue::Float(f),
3858 QueryValue::Text(s) => SqlValue::Text(s),
3859 QueryValue::Blob(b) => SqlValue::Blob(b),
3860 })
3861 .collect();
3862
3863 let mut reader = self.sql().reader().await?;
3864 let stmt = SqlStatement {
3865 sql: compiled.sql,
3866 params,
3867 label: None,
3868 };
3869 let mut rows = reader.query_all(stmt).await?;
3870
3871 if let Some(check) = truncation_check {
3877 if rows.len() > check.max_limit {
3878 rows.truncate(check.max_limit);
3879 warnings.push(match check.requested_limit {
3880 Some(requested) => format!(
3881 "result set capped at {} rows; requested limit {requested} exceeds the \
3882 cap — use LIMIT/OFFSET to page through the remaining results",
3883 check.max_limit
3884 ),
3885 None => format!(
3886 "result set capped at {} rows; more than {} rows matched with no LIMIT \
3887 clause — use LIMIT/OFFSET to page through the remaining results",
3888 check.max_limit, check.max_limit
3889 ),
3890 });
3891 }
3892 }
3893
3894 Ok(QueryResult { rows, warnings })
3895 }
3896
3897 pub async fn delete_entity(
3905 &self,
3906 token: &NamespaceToken,
3907 id: Uuid,
3908 hard: bool,
3909 ) -> RuntimeResult<bool> {
3910 let entity = if hard {
3911 match self
3912 .entities(token)?
3913 .get_entity_including_deleted(id)
3914 .await?
3915 {
3916 Some(e) => e,
3917 None => return Ok(false),
3918 }
3919 } else {
3920 match self.entities(token)?.get_entity(id).await? {
3921 Some(e) => e,
3922 None => return Ok(false),
3923 }
3924 };
3925 let mode = if hard {
3926 DeleteMode::Hard
3927 } else {
3928 DeleteMode::Soft
3929 };
3930
3931 let record_tok = NamespaceToken::for_namespace(
3933 khive_types::Namespace::parse(&entity.namespace)
3934 .map_err(|e| RuntimeError::Internal(format!("entity namespace invalid: {e}")))?,
3935 );
3936
3937 let deleted = if hard {
3942 let deleted = self
3943 .atomic_hard_delete_with_edge_purge(entity_hard_delete_statement(id), id)
3944 .await?;
3945 self.remove_from_indexes(&record_tok, id).await?;
3946 deleted
3947 } else {
3948 let deleted = self.entities(token)?.delete_entity(id, mode).await?;
3949 if deleted {
3950 self.remove_from_indexes(&record_tok, id).await?;
3951 }
3952 deleted
3953 };
3954 if deleted {
3955 let event_store = self.events(token)?;
3956 let ns = entity.namespace.clone();
3957 let event = khive_storage::event::Event::new(
3958 ns.clone(),
3959 "delete",
3960 EventKind::EntityDeleted,
3961 SubstrateKind::Entity,
3962 "",
3963 )
3964 .with_target(id)
3965 .with_payload(serde_json::json!({"id": id, "namespace": ns, "hard": hard}));
3966 event_store.append_event(event).await.map_err(|e| {
3967 RuntimeError::Internal(format!("delete_entity: event store write failed: {e}"))
3968 })?;
3969 }
3970 Ok(deleted)
3971 }
3972
3973 pub async fn count_entities(
3975 &self,
3976 token: &NamespaceToken,
3977 kind: Option<&str>,
3978 ) -> RuntimeResult<u64> {
3979 let filter = EntityFilter {
3980 kinds: match kind {
3981 Some(k) => vec![k.to_string()],
3982 None => vec![],
3983 },
3984 ..Default::default()
3985 };
3986 Ok(self
3987 .entities(token)?
3988 .count_entities(token.namespace().as_str(), filter)
3989 .await?)
3990 }
3991
3992 pub async fn get_edge(
3999 &self,
4000 _token: &NamespaceToken,
4001 edge_id: Uuid,
4002 ) -> RuntimeResult<Option<Edge>> {
4003 let mut reader = self.sql().reader().await?;
4004 let record_ns = reader
4005 .query_scalar(SqlStatement {
4006 sql: "SELECT namespace FROM graph_edges \
4007 WHERE id = ?1 AND deleted_at IS NULL LIMIT 1"
4008 .into(),
4009 params: vec![SqlValue::Text(edge_id.to_string())],
4010 label: Some("get_edge_namespace".into()),
4011 })
4012 .await?;
4013
4014 let Some(SqlValue::Text(record_ns)) = record_ns else {
4015 return Ok(None);
4016 };
4017 let record_tok = NamespaceToken::for_namespace(
4020 khive_types::Namespace::parse(&record_ns)
4021 .map_err(|e| RuntimeError::Internal(format!("edge namespace invalid: {e}")))?,
4022 );
4023 Ok(self
4024 .graph(&record_tok)?
4025 .get_edge(LinkId::from(edge_id))
4026 .await?)
4027 }
4028
4029 pub async fn get_edge_visible(
4034 &self,
4035 token: &NamespaceToken,
4036 edge_id: Uuid,
4037 ) -> RuntimeResult<Option<Edge>> {
4038 self.get_edge(token, edge_id).await
4039 }
4040
4041 pub async fn get_edge_including_deleted(
4047 &self,
4048 _token: &NamespaceToken,
4049 edge_id: Uuid,
4050 ) -> RuntimeResult<Option<Edge>> {
4051 let mut reader = self.sql().reader().await?;
4052 let record_ns = reader
4053 .query_scalar(SqlStatement {
4054 sql: "SELECT namespace FROM graph_edges WHERE id = ?1 LIMIT 1".into(),
4055 params: vec![SqlValue::Text(edge_id.to_string())],
4056 label: Some("get_edge_including_deleted_namespace".into()),
4057 })
4058 .await?;
4059
4060 let Some(SqlValue::Text(record_ns)) = record_ns else {
4061 return Ok(None);
4062 };
4063 let record_tok = NamespaceToken::for_namespace(
4065 khive_types::Namespace::parse(&record_ns)
4066 .map_err(|e| RuntimeError::Internal(format!("edge namespace invalid: {e}")))?,
4067 );
4068 Ok(self
4069 .graph(&record_tok)?
4070 .get_edge_including_deleted(LinkId::from(edge_id))
4071 .await?)
4072 }
4073
4074 pub const EDGE_LIST_MAX_LIMIT: u32 = 1000;
4079
4080 pub async fn list_edges(
4088 &self,
4089 token: &NamespaceToken,
4090 filter: crate::curation::EdgeListFilter,
4091 limit: u32,
4092 offset: u32,
4093 ) -> RuntimeResult<Vec<Edge>> {
4094 let limit = limit.clamp(1, Self::EDGE_LIST_MAX_LIMIT);
4095 let visible = token.visible_namespaces();
4096
4097 if let [ns] = visible {
4100 let temp = NamespaceToken::for_namespace(ns.clone());
4101 let page = self
4102 .graph(&temp)?
4103 .query_edges(
4104 filter.into(),
4105 vec![SortOrder {
4106 field: EdgeSortField::CreatedAt,
4107 direction: khive_storage::types::SortDirection::Asc,
4108 }],
4109 PageRequest {
4110 offset: offset.into(),
4111 limit,
4112 },
4113 )
4114 .await?;
4115 return Ok(page.items);
4116 }
4117
4118 let fetch_limit = offset.saturating_add(limit);
4122 let mut results = Vec::new();
4123 for ns in visible {
4124 let temp = NamespaceToken::for_namespace(ns.clone());
4125 let page = self
4126 .graph(&temp)?
4127 .query_edges(
4128 filter.clone().into(),
4129 vec![SortOrder {
4130 field: EdgeSortField::CreatedAt,
4131 direction: khive_storage::types::SortDirection::Asc,
4132 }],
4133 PageRequest {
4134 offset: 0,
4135 limit: fetch_limit,
4136 },
4137 )
4138 .await?;
4139 results.extend(page.items);
4140 }
4141 results.sort_by_key(|e| Uuid::from(e.id));
4142 results.dedup_by_key(|e| Uuid::from(e.id));
4143 let start = (offset as usize).min(results.len());
4144 let end = (start + limit as usize).min(results.len());
4145 Ok(results[start..end].to_vec())
4146 }
4147
4148 pub async fn list_edges_after(
4159 &self,
4160 token: &NamespaceToken,
4161 filter: crate::curation::EdgeListFilter,
4162 after: Option<Uuid>,
4163 limit: u32,
4164 ) -> RuntimeResult<(Vec<Edge>, Option<Uuid>)> {
4165 let limit = limit.clamp(1, Self::EDGE_LIST_MAX_LIMIT);
4166 let visible = token.visible_namespaces();
4167 let limit_usize = limit as usize;
4168
4169 if let [ns] = visible {
4170 let temp = NamespaceToken::for_namespace(ns.clone());
4171 let page = self
4172 .graph(&temp)?
4173 .query_edges_after(filter.into(), after, limit)
4174 .await?;
4175 return Ok((page.items, page.next_after));
4176 }
4177
4178 let probe_limit = limit + 1;
4182 let mut results = Vec::new();
4183 for ns in visible {
4184 let temp = NamespaceToken::for_namespace(ns.clone());
4185 let page = self
4186 .graph(&temp)?
4187 .query_edges_after(filter.clone().into(), after, probe_limit)
4188 .await?;
4189 results.extend(page.items);
4190 }
4191 results.sort_by_key(|e| Uuid::from(e.id));
4192 results.dedup_by_key(|e| Uuid::from(e.id));
4193 let has_more = results.len() > limit_usize;
4194 if has_more {
4195 results.truncate(limit_usize);
4196 }
4197 let next_after = if has_more {
4198 results.last().map(|e| Uuid::from(e.id))
4199 } else {
4200 None
4201 };
4202 Ok((results, next_after))
4203 }
4204
4205 pub async fn count_edges_by_relation(
4209 &self,
4210 token: &NamespaceToken,
4211 ) -> RuntimeResult<std::collections::HashMap<String, u64>> {
4212 let mut totals: std::collections::HashMap<String, u64> = std::collections::HashMap::new();
4213 for ns in token.visible_namespaces() {
4214 let temp = NamespaceToken::for_namespace(ns.clone());
4215 for (relation, count) in self.graph(&temp)?.count_edges_by_relation().await? {
4216 *totals.entry(relation.to_string()).or_insert(0) += count;
4217 }
4218 }
4219 Ok(totals)
4220 }
4221
4222 #[allow(clippy::too_many_arguments)]
4243 fn update_edge_symmetric_dml(
4244 conn: &rusqlite::Connection,
4245 ns: &str,
4246 edge_id_str: &str,
4247 canon_src_str: &str,
4248 canon_tgt_str: &str,
4249 relation_str: &str,
4250 weight: f64,
4251 metadata: Option<String>,
4252 target_backend: Option<String>,
4253 ) -> Result<Option<String>, SqliteError> {
4254 let now_ts = chrono::Utc::now().timestamp_micros();
4262
4263 let conflict_id: Option<String> = conn
4266 .query_row(
4267 khive_db::stores::graph::EDGE_SYMMETRIC_CONFLICT_PROBE_SQL,
4268 rusqlite::params![
4269 &ns,
4270 &canon_src_str,
4271 &canon_tgt_str,
4272 &relation_str,
4273 &edge_id_str
4274 ],
4275 |row| row.get(0),
4276 )
4277 .optional()
4278 .map_err(SqliteError::Rusqlite)?;
4279
4280 if let Some(existing_id) = conflict_id {
4281 conn.execute(
4285 khive_db::stores::graph::EDGE_SYMMETRIC_DELETE_NONCANONICAL_SQL,
4286 rusqlite::params![&ns, &edge_id_str],
4287 )
4288 .map_err(SqliteError::Rusqlite)?;
4289 let affected = conn
4290 .execute(
4291 khive_db::stores::graph::EDGE_SYMMETRIC_REFRESH_CANONICAL_SQL,
4292 rusqlite::params![weight, now_ts, target_backend, metadata, &ns, &existing_id],
4293 )
4294 .map_err(SqliteError::Rusqlite)?;
4295 if affected == 0 {
4296 return Err(SqliteError::InvalidData(format!(
4297 "update_edge: surviving canonical row {existing_id} vanished during update"
4298 )));
4299 }
4300 Ok(Some(existing_id))
4301 } else {
4302 let affected = conn
4305 .execute(
4306 khive_db::stores::graph::EDGE_SYMMETRIC_UPDATE_INPLACE_SQL,
4307 rusqlite::params![
4308 &canon_src_str,
4309 &canon_tgt_str,
4310 &relation_str,
4311 weight,
4312 now_ts,
4313 metadata,
4314 &ns,
4315 &edge_id_str,
4316 ],
4317 )
4318 .map_err(SqliteError::Rusqlite)?;
4319 if affected == 0 {
4320 return Err(SqliteError::InvalidData(format!(
4323 "update_edge: zero rows affected updating edge {edge_id_str} \
4324 in namespace {ns} — row vanished between fetch and update"
4325 )));
4326 }
4327 Ok(None)
4328 }
4329 }
4330
4331 pub async fn update_edge(
4344 &self,
4345 token: &NamespaceToken,
4346 edge_id: Uuid,
4347 patch: crate::curation::EdgePatch,
4348 ) -> RuntimeResult<Edge> {
4349 let graph_for_fetch = self.graph(token)?;
4352 let mut edge = graph_for_fetch
4353 .get_edge(LinkId::from(edge_id))
4354 .await?
4355 .ok_or_else(|| crate::RuntimeError::NotFound(format!("edge {edge_id}")))?;
4356
4357 let record_ns: String = edge.namespace.clone();
4362 let record_tok = NamespaceToken::for_namespace(
4363 khive_types::Namespace::parse(&record_ns)
4364 .map_err(|e| RuntimeError::Internal(format!("edge namespace invalid: {e}")))?,
4365 );
4366 let graph = self.graph(&record_tok)?;
4367
4368 let mut changed_fields: Vec<&'static str> = Vec::new();
4369 if let Some(r) = patch.relation {
4370 self.validate_edge_relation_endpoints(&record_tok, edge.source_id, edge.target_id, r)
4373 .await?;
4374 edge.relation = r;
4375 changed_fields.push("relation");
4376 }
4377 if let Some(w) = patch.weight {
4378 if !w.is_finite() || !(0.0..=1.0).contains(&w) {
4381 return Err(RuntimeError::InvalidInput(format!(
4382 "edge weight must be a finite value in [0.0, 1.0]; got {w}"
4383 )));
4384 }
4385 edge.weight = w;
4386 changed_fields.push("weight");
4387 }
4388 if let Some(props) = patch.properties {
4389 edge.metadata = Some(props);
4390 }
4391
4392 let (canon_src, canon_tgt) =
4402 canonical_edge_endpoints(edge.relation, edge.source_id, edge.target_id);
4403
4404 if edge.relation.is_symmetric() {
4405 let ns = record_ns.clone();
4409 let edge_id_str = edge_id.to_string();
4410 let relation_str = edge.relation.to_string();
4411 let canon_src_str = canon_src.to_string();
4412 let canon_tgt_str = canon_tgt.to_string();
4413 let weight = edge.weight;
4414 let metadata = edge
4415 .metadata
4416 .as_ref()
4417 .map(|v| serde_json::to_string(v).unwrap_or_default());
4418 let target_backend = edge.target_backend.clone();
4419
4420 let pool = self.backend().pool_arc();
4421 let writer_task = pool.writer_task_handle().ok().flatten();
4425
4426 let surviving_id: Option<String> = if let Some(writer_task) = writer_task {
4430 writer_task
4431 .send(move |conn| {
4432 Self::update_edge_symmetric_dml(
4433 conn,
4434 &ns,
4435 &edge_id_str,
4436 &canon_src_str,
4437 &canon_tgt_str,
4438 &relation_str,
4439 weight,
4440 metadata,
4441 target_backend,
4442 )
4443 .map_err(|e| {
4444 khive_storage::StorageError::driver(
4445 khive_storage::StorageCapability::Graph,
4446 "update_edge",
4447 e,
4448 )
4449 })
4450 })
4451 .await
4452 .map_err(RuntimeError::Storage)?
4453 } else {
4454 tokio::task::spawn_blocking(move || {
4455 let guard = pool.writer()?;
4456 guard.transaction(|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 })
4469 })
4470 .await
4471 .map_err(|e| {
4472 RuntimeError::Internal(format!("update_edge: spawn_blocking join: {e}"))
4473 })?
4474 .map_err(RuntimeError::Sqlite)?
4475 };
4476
4477 if let Some(sid) = surviving_id {
4478 let surviving_uuid = Uuid::parse_str(&sid).map_err(|e| {
4482 RuntimeError::Internal(format!("update_edge: surviving id parse failed: {e}"))
4483 })?;
4484 edge = self
4485 .get_edge(&record_tok, surviving_uuid)
4486 .await?
4487 .ok_or_else(|| {
4488 RuntimeError::Internal(format!(
4489 "update_edge: surviving canonical row {surviving_uuid} vanished after update"
4490 ))
4491 })?;
4492 } else {
4493 edge.source_id = canon_src;
4495 edge.target_id = canon_tgt;
4496 }
4497 } else {
4498 graph.upsert_edge(edge.clone()).await?;
4502 }
4503
4504 let event_store = self.events(&record_tok)?;
4506 let event = khive_storage::event::Event::new(
4507 record_ns.clone(),
4508 "update",
4509 EventKind::EdgeUpdated,
4510 SubstrateKind::Entity,
4511 "",
4512 )
4513 .with_target(edge_id)
4514 .with_payload(
4515 serde_json::json!({"id": edge_id, "namespace": record_ns, "changed_fields": changed_fields}),
4516 );
4517 event_store.append_event(event).await.map_err(|e| {
4518 RuntimeError::Internal(format!("update_edge: event store write failed: {e}"))
4519 })?;
4520
4521 Ok(edge)
4522 }
4523
4524 pub async fn delete_edge(
4535 &self,
4536 token: &NamespaceToken,
4537 edge_id: Uuid,
4538 hard: bool,
4539 ) -> RuntimeResult<bool> {
4540 let mode = if hard {
4541 DeleteMode::Hard
4542 } else {
4543 DeleteMode::Soft
4544 };
4545
4546 let edge = if hard {
4552 self.get_edge_including_deleted(token, edge_id).await?
4553 } else {
4554 self.get_edge(token, edge_id).await?
4555 };
4556 let Some(edge) = edge else {
4557 return Ok(false);
4558 };
4559
4560 let record_ns: String = edge.namespace.clone();
4562 let record_tok = NamespaceToken::for_namespace(
4563 khive_types::Namespace::parse(&record_ns)
4564 .map_err(|e| RuntimeError::Internal(format!("edge namespace invalid: {e}")))?,
4565 );
4566 let graph = self.graph(&record_tok)?;
4567
4568 let deleted = if hard {
4575 self.atomic_hard_delete_with_edge_purge(edge_hard_delete_statement(edge_id), edge_id)
4576 .await?
4577 } else {
4578 graph.delete_edge(LinkId::from(edge_id), mode).await?
4579 };
4580 if deleted {
4581 let event_store = self.events(&record_tok)?;
4583 let event = khive_storage::event::Event::new(
4584 record_ns.clone(),
4585 "delete",
4586 EventKind::EdgeDeleted,
4587 SubstrateKind::Entity,
4588 "",
4589 )
4590 .with_target(edge_id)
4591 .with_payload(serde_json::json!({"id": edge_id, "namespace": record_ns, "hard": hard}));
4592 event_store.append_event(event).await.map_err(|e| {
4593 RuntimeError::Internal(format!("delete_edge: event store write failed: {e}"))
4594 })?;
4595 }
4596 Ok(deleted)
4597 }
4598
4599 pub async fn count_edges(
4601 &self,
4602 token: &NamespaceToken,
4603 filter: crate::curation::EdgeListFilter,
4604 ) -> RuntimeResult<u64> {
4605 Ok(self.graph(token)?.count_edges(filter.into()).await?)
4606 }
4607
4608 pub async fn build_edge(&self, token: &NamespaceToken, spec: &LinkSpec) -> RuntimeResult<Edge> {
4619 let ns_str = match &spec.namespace {
4620 Some(s) => {
4621 let spec_ns = crate::Namespace::parse(s)
4622 .map_err(|e| RuntimeError::InvalidInput(format!("invalid namespace: {e}")))?;
4623 if &spec_ns != token.namespace() {
4624 return Err(RuntimeError::InvalidInput(
4625 "LinkSpec namespace does not match token namespace".into(),
4626 ));
4627 }
4628 s.as_str()
4629 }
4630 None => token.namespace().as_str(),
4631 };
4632 self.validate_edge_relation_endpoints(token, spec.source_id, spec.target_id, spec.relation)
4633 .await?;
4634 let (source_id, target_id) =
4635 canonical_edge_endpoints(spec.relation, spec.source_id, spec.target_id);
4636 let metadata = if spec.relation == EdgeRelation::DependsOn {
4637 match (
4642 self.resolve_edge_endpoint(token, source_id).await?,
4643 self.resolve_edge_endpoint(token, target_id).await?,
4644 ) {
4645 (Some(Resolved::Entity(src_e)), Some(Resolved::Entity(tgt_e))) => {
4646 merge_dependency_kind(&src_e.kind, &tgt_e.kind, spec.metadata.clone())
4647 }
4648 _ => spec.metadata.clone(),
4649 }
4650 } else {
4651 spec.metadata.clone()
4652 };
4653 validate_edge_metadata(spec.relation, metadata.as_ref())?;
4654 let now = chrono::Utc::now();
4655 Ok(Edge {
4656 id: LinkId::from(Uuid::new_v4()),
4657 namespace: ns_str.to_string(),
4658 source_id,
4659 target_id,
4660 relation: spec.relation,
4661 weight: spec.weight,
4662 created_at: now,
4663 updated_at: now,
4664 deleted_at: None,
4665 metadata,
4666 target_backend: None,
4667 })
4668 }
4669
4670 pub async fn link_many(
4687 &self,
4688 token: &NamespaceToken,
4689 specs: Vec<LinkSpec>,
4690 ) -> RuntimeResult<Vec<Edge>> {
4691 if specs.is_empty() {
4692 return Ok(vec![]);
4693 }
4694 let mut edges = Vec::with_capacity(specs.len());
4695 for spec in &specs {
4696 edges.push(self.build_edge(token, spec).await?);
4697 }
4698 let outcome = self
4707 .graph(token)?
4708 .upsert_edges_guarded(edges.clone())
4709 .await?;
4710 if let Some(refusal) = outcome.refused {
4711 return Err(RuntimeError::GuardedWriteFailed(GuardedWriteFailure {
4712 entry_index: Some(refusal.entry_index),
4713 missing_source: refusal
4714 .missing
4715 .source
4716 .then_some(edges[refusal.entry_index].source_id),
4717 missing_target: refusal
4718 .missing
4719 .target
4720 .then_some(edges[refusal.entry_index].target_id),
4721 }));
4722 }
4723 if outcome.summary.affected != edges.len() as u64 {
4724 return Err(RuntimeError::NotFound(format!(
4725 "link_many: one or more edge endpoints no longer exist at write time: {}",
4726 outcome.summary.first_error
4727 )));
4728 }
4729
4730 let mut persisted = Vec::with_capacity(edges.len());
4733 for edge in &edges {
4734 let row = self
4735 .list_edges(
4736 token,
4737 crate::curation::EdgeListFilter {
4738 source_id: Some(edge.source_id),
4739 target_id: Some(edge.target_id),
4740 relations: vec![edge.relation],
4741 ..Default::default()
4742 },
4743 1,
4744 0,
4745 )
4746 .await?
4747 .into_iter()
4748 .next()
4749 .ok_or_else(|| {
4750 crate::RuntimeError::Internal(format!(
4751 "upsert_edges succeeded but natural-key lookup for ({}, {}, {}) returned nothing",
4752 edge.source_id, edge.target_id, edge.relation.as_str()
4753 ))
4754 })?;
4755 persisted.push(row);
4756 }
4757 Ok(persisted)
4758 }
4759
4760 pub async fn create_many(
4776 &self,
4777 token: &NamespaceToken,
4778 specs: Vec<EntityCreateSpec>,
4779 ) -> RuntimeResult<Vec<Entity>> {
4780 if specs.is_empty() {
4781 return Ok(vec![]);
4782 }
4783 let ns = token.namespace().as_str();
4784
4785 let mut entities = Vec::with_capacity(specs.len());
4789 for spec in &specs {
4790 self.validate_entity_kind(&spec.kind)?;
4791 let validated_type =
4796 self.validate_entity_type_for_kind(&spec.kind, spec.entity_type.as_deref())?;
4797 if spec.name.trim().is_empty() {
4798 return Err(RuntimeError::InvalidInput("name must not be empty".into()));
4799 }
4800 crate::secret_gate::check(&spec.name)?;
4801 if let Some(d) = &spec.description {
4802 crate::secret_gate::check(d)?;
4803 }
4804 if let Some(ref p) = spec.properties {
4805 crate::secret_gate::check_json(p)?;
4806 }
4807 crate::secret_gate::check_tags(&spec.tags)?;
4808
4809 let mut entity =
4810 Entity::new(ns, &spec.kind, &spec.name).with_entity_type(validated_type.as_deref());
4811 if let Some(d) = &spec.description {
4812 entity = entity.with_description(d);
4813 }
4814 if let Some(p) = spec.properties.clone() {
4815 entity = entity.with_properties(p);
4816 }
4817 if !spec.tags.is_empty() {
4818 entity = entity.with_tags(spec.tags.clone());
4819 }
4820 entities.push(entity);
4821 }
4822
4823 let entity_summary = self
4834 .entities(token)?
4835 .upsert_entities(entities.clone())
4836 .await?;
4837
4838 if entity_summary.failed > 0 {
4839 if let Ok(store) = self.entities(token) {
4841 for entity in &entities {
4842 if let Err(ce) = store.delete_entity(entity.id, DeleteMode::Hard).await {
4843 tracing::error!(
4844 error = %ce,
4845 id = %entity.id,
4846 "create_many: failed to roll back entity row after partial entity write"
4847 );
4848 }
4849 }
4850 }
4851 return Err(RuntimeError::Internal(format!(
4852 "create_many: {}/{} entity rows failed to write (first error: {}); \
4853 all rows rolled back",
4854 entity_summary.failed, entity_summary.attempted, entity_summary.first_error
4855 )));
4856 }
4857
4858 let docs: Vec<_> = entities.iter().map(entity_fts_document).collect();
4871
4872 #[cfg(any(test, feature = "fault-injection"))]
4873 let fts_many_inject = {
4874 let mut g = FTS_FAIL_MANY_NS.lock().unwrap();
4875 if g.as_deref() == Some(ns) {
4876 *g = None;
4877 true
4878 } else {
4879 false
4880 }
4881 };
4882 #[cfg(not(any(test, feature = "fault-injection")))]
4883 let fts_many_inject = false;
4884
4885 #[cfg(any(test, feature = "fault-injection"))]
4888 let fts_many_inject_partial = {
4889 let mut g = FTS_FAIL_MANY_PARTIAL_NS.lock().unwrap();
4890 if g.as_deref() == Some(ns) {
4891 *g = None;
4892 true
4893 } else {
4894 false
4895 }
4896 };
4897 #[cfg(not(any(test, feature = "fault-injection")))]
4898 let fts_many_inject_partial = false;
4899
4900 let fts_summary_result: RuntimeResult<BatchWriteSummary> = if fts_many_inject {
4901 Err(RuntimeError::Internal(
4902 "injected FTS failure for create_many".to_string(),
4903 ))
4904 } else if fts_many_inject_partial {
4905 Ok(BatchWriteSummary {
4906 attempted: docs.len() as u64,
4907 affected: docs.len().saturating_sub(1) as u64,
4908 failed: 1,
4909 first_error: "injected partial FTS failure for create_many".to_string(),
4910 })
4911 } else {
4912 match self.text(token) {
4913 Ok(fts) => fts.upsert_documents(docs).await.map_err(RuntimeError::from),
4914 Err(e) => Err(e),
4915 }
4916 };
4917
4918 let fts_err: Option<RuntimeError> = match fts_summary_result {
4919 Err(e) => Some(e),
4920 Ok(summary) if summary.failed > 0 => Some(RuntimeError::Internal(format!(
4921 "create_many: {}/{} FTS rows failed to index (first error: {}); \
4922 all rows rolled back",
4923 summary.failed, summary.attempted, summary.first_error
4924 ))),
4925 Ok(_) => None,
4926 };
4927
4928 if let Some(e) = fts_err {
4929 if let Ok(fts) = self.text(token) {
4931 for entity in &entities {
4932 if let Err(ce) = fts.delete_document(ns, entity.id).await {
4933 tracing::error!(
4934 error = %ce,
4935 id = %entity.id,
4936 "create_many: failed to remove FTS doc during rollback"
4937 );
4938 }
4939 }
4940 }
4941 if let Ok(store) = self.entities(token) {
4942 for entity in &entities {
4943 if let Err(ce) = store.delete_entity(entity.id, DeleteMode::Hard).await {
4944 tracing::error!(
4945 error = %ce,
4946 id = %entity.id,
4947 "create_many: failed to roll back entity row after FTS failure"
4948 );
4949 }
4950 }
4951 }
4952 return Err(e);
4953 }
4954
4955 Ok(entities)
4957 }
4958}
4959
4960#[derive(Clone, Debug)]
4963pub struct LinkSpec {
4964 pub namespace: Option<String>,
4965 pub source_id: Uuid,
4966 pub target_id: Uuid,
4967 pub relation: EdgeRelation,
4968 pub weight: f64,
4969 pub metadata: Option<serde_json::Value>,
4970}
4971
4972#[derive(Clone, Debug)]
4980pub struct EntityCreateSpec {
4981 pub kind: String,
4982 pub entity_type: Option<String>,
4983 pub name: String,
4984 pub description: Option<String>,
4985 pub properties: Option<serde_json::Value>,
4986 pub tags: Vec<String>,
4987}
4988
4989#[cfg(test)]
4995mod tests {
4996 use super::*;
4997 use crate::curation::EdgeListFilter;
4998 use crate::embedder_registry::EmbedderProvider;
4999 use crate::error::RuntimeError;
5000 use crate::runtime::{KhiveRuntime, NamespaceToken};
5001 use crate::{ActorRef, Namespace};
5002 use async_trait::async_trait;
5003 use khive_storage::types::PathNode;
5004 use lattice_embed::{EmbedError, EmbeddingModel, EmbeddingService};
5005 use std::sync::atomic::{AtomicUsize, Ordering};
5006 use std::sync::Arc;
5007
5008 fn rt() -> KhiveRuntime {
5009 KhiveRuntime::memory().unwrap()
5010 }
5011
5012 struct ConstVecService {
5020 dims: usize,
5021 }
5022
5023 #[async_trait]
5024 impl EmbeddingService for ConstVecService {
5025 async fn embed(
5026 &self,
5027 texts: &[String],
5028 _model: EmbeddingModel,
5029 ) -> std::result::Result<Vec<Vec<f32>>, EmbedError> {
5030 Ok(texts.iter().map(|_| vec![1.0_f32; self.dims]).collect())
5031 }
5032
5033 fn supports_model(&self, _model: EmbeddingModel) -> bool {
5034 true
5035 }
5036
5037 fn name(&self) -> &'static str {
5038 "const-vec"
5039 }
5040 }
5041
5042 struct ConstVecProvider {
5043 provider_name: String,
5044 dims: usize,
5045 pub build_count: Arc<AtomicUsize>,
5046 }
5047
5048 impl ConstVecProvider {
5049 fn new(name: &str, dims: usize) -> (Self, Arc<AtomicUsize>) {
5050 let counter = Arc::new(AtomicUsize::new(0));
5051 let provider = Self {
5052 provider_name: name.to_owned(),
5053 dims,
5054 build_count: Arc::clone(&counter),
5055 };
5056 (provider, counter)
5057 }
5058 }
5059
5060 #[async_trait]
5061 impl EmbedderProvider for ConstVecProvider {
5062 fn name(&self) -> &str {
5063 &self.provider_name
5064 }
5065
5066 fn dimensions(&self) -> usize {
5067 self.dims
5068 }
5069
5070 async fn build(&self) -> crate::error::RuntimeResult<Arc<dyn EmbeddingService>> {
5071 self.build_count.fetch_add(1, Ordering::SeqCst);
5072 Ok(Arc::new(ConstVecService { dims: self.dims }))
5073 }
5074 }
5075
5076 #[tokio::test]
5081 async fn custom_embedder_only_runtime_fanout_stores_vector() {
5082 const MODEL_NAME: &str = "test-custom-encoder";
5083 const DIMS: usize = 8;
5084
5085 let rt = KhiveRuntime::memory().unwrap();
5087
5088 let (provider, _counter) = ConstVecProvider::new(MODEL_NAME, DIMS);
5090 rt.register_embedder(provider);
5091
5092 assert!(rt.config().embedding_model.is_none());
5094 assert_eq!(rt.registered_embedding_model_names(), vec![MODEL_NAME]);
5095
5096 let tok = NamespaceToken::local();
5097
5098 let note = rt
5100 .create_note(
5101 &tok,
5102 "memory",
5103 None,
5104 "custom embedder integration test content",
5105 Some(0.7),
5106 None,
5107 vec![],
5108 )
5109 .await
5110 .expect("create_note with custom-only embedder must succeed");
5111
5112 use khive_storage::types::VectorSearchRequest;
5114 let query_vec = vec![1.0_f32; DIMS];
5115 let hits = rt
5116 .vectors_for_model(&tok, MODEL_NAME)
5117 .expect("vector store for custom model must be accessible")
5118 .search(VectorSearchRequest {
5119 query_vectors: vec![query_vec],
5120 top_k: 5,
5121 namespace: Some(tok.namespace().as_str().to_string()),
5122 kind: Some(khive_types::SubstrateKind::Note),
5123 embedding_model: Some(MODEL_NAME.to_string()),
5124 filter: None,
5125 backend_hints: None,
5126 })
5127 .await
5128 .expect("vector search succeeds");
5129
5130 assert!(
5131 hits.iter().any(|h| h.subject_id == note.id),
5132 "custom embedder must have written a vector for note {}: hits={hits:?}",
5133 note.id
5134 );
5135 }
5136
5137 #[tokio::test]
5142 async fn embed_with_model_accepts_custom_provider_name() {
5143 const MODEL_NAME: &str = "my-custom-enc";
5144 const DIMS: usize = 4;
5145
5146 let rt = KhiveRuntime::memory().unwrap();
5147 let (provider, _counter) = ConstVecProvider::new(MODEL_NAME, DIMS);
5148 rt.register_embedder(provider);
5149
5150 let result = rt
5151 .embed_with_model(MODEL_NAME, "hello world")
5152 .await
5153 .expect("embed_with_model must accept custom provider names");
5154
5155 assert_eq!(
5156 result.len(),
5157 DIMS,
5158 "embedding dimension must match provider"
5159 );
5160 assert!(
5161 result.iter().all(|&v| (v - 1.0_f32).abs() < 1e-6),
5162 "ConstVecService must produce all-ones vector; got: {result:?}"
5163 );
5164 }
5165
5166 #[tokio::test]
5169 async fn embed_with_model_rejects_unregistered_name() {
5170 let rt = KhiveRuntime::memory().unwrap();
5171 let result = rt.embed_with_model("nonexistent-model", "hello").await;
5172 assert!(
5173 matches!(result.unwrap_err(), RuntimeError::UnknownModel(ref n) if n == "nonexistent-model"),
5174 "unregistered model name must return UnknownModel"
5175 );
5176 }
5177
5178 #[tokio::test]
5185 async fn no_embeddings_config_registers_zero_embedders() {
5186 let config = crate::config::RuntimeConfig {
5187 db_path: None,
5188 packs: vec!["kg".to_string()],
5189 ..crate::config::RuntimeConfig::no_embeddings()
5190 };
5191 let rt = KhiveRuntime::new(config).expect("runtime construction must succeed");
5192
5193 assert!(rt.config().embedding_model.is_none());
5194 assert!(
5195 rt.registered_embedding_model_names().is_empty(),
5196 "no_embeddings() runtime must register zero embedders"
5197 );
5198 }
5199
5200 #[tokio::test]
5201 async fn no_embeddings_runtime_create_note_succeeds_without_model_fanout() {
5202 let config = crate::config::RuntimeConfig {
5203 db_path: None,
5204 packs: vec!["kg".to_string()],
5205 ..crate::config::RuntimeConfig::no_embeddings()
5206 };
5207 let rt = KhiveRuntime::new(config).expect("runtime construction must succeed");
5208 let tok = NamespaceToken::local();
5209
5210 let note = rt
5214 .create_note(
5215 &tok,
5216 "memory",
5217 None,
5218 "issue-396 regression: model-less remember must succeed",
5219 Some(0.7),
5220 None,
5221 vec![],
5222 )
5223 .await
5224 .expect("create_note must succeed with zero registered embedders");
5225
5226 assert_eq!(
5227 note.content,
5228 "issue-396 regression: model-less remember must succeed"
5229 );
5230 }
5231
5232 #[tokio::test]
5233 async fn update_edge_changes_weight() {
5234 let rt = rt();
5235 let tok = NamespaceToken::local();
5236 let a = rt
5237 .create_entity(&tok, "concept", None, "A", None, None, vec![])
5238 .await
5239 .unwrap();
5240 let b = rt
5241 .create_entity(&tok, "concept", None, "B", None, None, vec![])
5242 .await
5243 .unwrap();
5244 let edge = rt
5245 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5246 .await
5247 .unwrap();
5248 let edge_id: Uuid = edge.id.into();
5249
5250 let updated = rt
5251 .update_edge(
5252 &tok,
5253 edge_id,
5254 crate::curation::EdgePatch {
5255 weight: Some(0.5),
5256 ..Default::default()
5257 },
5258 )
5259 .await
5260 .unwrap();
5261 assert!((updated.weight - 0.5).abs() < 0.001);
5262 }
5263
5264 #[tokio::test]
5274 async fn update_edge_symmetric_relation_stores_microsecond_updated_at() {
5275 let rt = rt();
5276 let tok = NamespaceToken::local();
5277 let a = rt
5278 .create_entity(&tok, "concept", None, "A", None, None, vec![])
5279 .await
5280 .unwrap();
5281 let b = rt
5282 .create_entity(&tok, "concept", None, "B", None, None, vec![])
5283 .await
5284 .unwrap();
5285 let edge = rt
5286 .link(&tok, a.id, b.id, EdgeRelation::CompetesWith, 1.0, None)
5287 .await
5288 .unwrap();
5289 let edge_id: Uuid = edge.id.into();
5290
5291 let before = chrono::Utc::now();
5292 let updated = rt
5293 .update_edge(
5294 &tok,
5295 edge_id,
5296 crate::curation::EdgePatch {
5297 weight: Some(0.5),
5298 ..Default::default()
5299 },
5300 )
5301 .await
5302 .unwrap();
5303
5304 let drift = (updated.updated_at - before).num_seconds().abs();
5305 assert!(
5306 drift < 60,
5307 "updated_at must round-trip as a recent timestamp (micros, not \
5308 seconds); got {:?}, expected within 60s of {:?}",
5309 updated.updated_at,
5310 before
5311 );
5312 }
5313
5314 #[tokio::test]
5315 async fn update_edge_changes_relation() {
5316 let rt = rt();
5317 let tok = NamespaceToken::local();
5318 let a = rt
5319 .create_entity(&tok, "concept", None, "A", None, None, vec![])
5320 .await
5321 .unwrap();
5322 let b = rt
5323 .create_entity(&tok, "concept", None, "B", None, None, vec![])
5324 .await
5325 .unwrap();
5326 let edge = rt
5327 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5328 .await
5329 .unwrap();
5330 let edge_id: Uuid = edge.id.into();
5331
5332 let updated = rt
5333 .update_edge(
5334 &tok,
5335 edge_id,
5336 crate::curation::EdgePatch {
5337 relation: Some(EdgeRelation::VariantOf),
5338 ..Default::default()
5339 },
5340 )
5341 .await
5342 .unwrap();
5343 assert_eq!(updated.relation, EdgeRelation::VariantOf);
5344 }
5345
5346 #[tokio::test]
5351 async fn update_edge_annotates_note_to_entity_set_supersedes_returns_invalid_input() {
5352 let rt = rt();
5353 let tok = NamespaceToken::local();
5354 let note = rt
5355 .create_note(&tok, "observation", None, "a note", Some(0.5), None, vec![])
5356 .await
5357 .unwrap();
5358 let entity = rt
5359 .create_entity(&tok, "concept", None, "E", None, None, vec![])
5360 .await
5361 .unwrap();
5362 let edge = rt
5364 .link(&tok, note.id, entity.id, EdgeRelation::Annotates, 1.0, None)
5365 .await
5366 .unwrap();
5367 let edge_id: Uuid = edge.id.into();
5368
5369 let result = rt
5371 .update_edge(
5372 &tok,
5373 edge_id,
5374 crate::curation::EdgePatch {
5375 relation: Some(EdgeRelation::Supersedes),
5376 ..Default::default()
5377 },
5378 )
5379 .await;
5380 assert!(
5381 matches!(result, Err(RuntimeError::InvalidInput(_))),
5382 "update to Supersedes on note→entity edge must return InvalidInput, got {result:?}"
5383 );
5384
5385 let fetched = rt.get_edge(&tok, edge_id).await.unwrap().unwrap();
5387 assert_eq!(
5388 fetched.relation,
5389 EdgeRelation::Annotates,
5390 "edge relation must be unchanged after failed update"
5391 );
5392 }
5393
5394 #[tokio::test]
5397 async fn update_edge_entity_to_entity_set_annotates_returns_invalid_input() {
5398 let rt = rt();
5399 let tok = NamespaceToken::local();
5400 let a = rt
5401 .create_entity(&tok, "concept", None, "A", None, None, vec![])
5402 .await
5403 .unwrap();
5404 let b = rt
5405 .create_entity(&tok, "concept", None, "B", None, None, vec![])
5406 .await
5407 .unwrap();
5408 let edge = rt
5409 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5410 .await
5411 .unwrap();
5412 let edge_id: Uuid = edge.id.into();
5413
5414 let result = rt
5415 .update_edge(
5416 &tok,
5417 edge_id,
5418 crate::curation::EdgePatch {
5419 relation: Some(EdgeRelation::Annotates),
5420 ..Default::default()
5421 },
5422 )
5423 .await;
5424 assert!(
5425 matches!(result, Err(RuntimeError::InvalidInput(_))),
5426 "update to Annotates on entity→entity edge must return InvalidInput, got {result:?}"
5427 );
5428 }
5429
5430 #[tokio::test]
5433 async fn update_edge_entity_to_entity_set_supersedes_succeeds() {
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 updated = rt
5451 .update_edge(
5452 &tok,
5453 edge_id,
5454 crate::curation::EdgePatch {
5455 relation: Some(EdgeRelation::Supersedes),
5456 ..Default::default()
5457 },
5458 )
5459 .await
5460 .unwrap();
5461 assert_eq!(updated.relation, EdgeRelation::Supersedes);
5462
5463 let fetched = rt.get_edge(&tok, edge_id).await.unwrap().unwrap();
5465 assert_eq!(fetched.relation, EdgeRelation::Supersedes);
5466 }
5467
5468 #[tokio::test]
5470 async fn update_edge_weight_only_skips_validation() {
5471 let rt = rt();
5472 let tok = NamespaceToken::local();
5473 let a = rt
5474 .create_entity(&tok, "concept", None, "A", None, None, vec![])
5475 .await
5476 .unwrap();
5477 let b = rt
5478 .create_entity(&tok, "concept", None, "B", None, None, vec![])
5479 .await
5480 .unwrap();
5481 let edge = rt
5482 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5483 .await
5484 .unwrap();
5485 let edge_id: Uuid = edge.id.into();
5486
5487 let updated = rt
5488 .update_edge(
5489 &tok,
5490 edge_id,
5491 crate::curation::EdgePatch {
5492 weight: Some(0.3),
5493 ..Default::default()
5494 },
5495 )
5496 .await
5497 .unwrap();
5498 assert_eq!(updated.relation, EdgeRelation::Extends);
5499 assert!((updated.weight - 0.3).abs() < 0.001);
5500 }
5501
5502 #[tokio::test]
5504 async fn update_edge_same_class_relation_change_succeeds() {
5505 let rt = rt();
5506 let tok = NamespaceToken::local();
5507 let a = rt
5508 .create_entity(&tok, "concept", None, "A", None, None, vec![])
5509 .await
5510 .unwrap();
5511 let b = rt
5512 .create_entity(&tok, "concept", None, "B", None, None, vec![])
5513 .await
5514 .unwrap();
5515 let edge = rt
5516 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5517 .await
5518 .unwrap();
5519 let edge_id: Uuid = edge.id.into();
5520
5521 let updated = rt
5522 .update_edge(
5523 &tok,
5524 edge_id,
5525 crate::curation::EdgePatch {
5526 relation: Some(EdgeRelation::VariantOf),
5527 ..Default::default()
5528 },
5529 )
5530 .await
5531 .unwrap();
5532 assert_eq!(updated.relation, EdgeRelation::VariantOf);
5533 }
5534
5535 #[tokio::test]
5536 async fn list_edges_filters_by_relation() {
5537 let rt = rt();
5538 let tok = NamespaceToken::local();
5539 let a = rt
5540 .create_entity(&tok, "concept", None, "A", None, None, vec![])
5541 .await
5542 .unwrap();
5543 let b = rt
5544 .create_entity(&tok, "concept", None, "B", None, None, vec![])
5545 .await
5546 .unwrap();
5547 let c = rt
5548 .create_entity(&tok, "concept", None, "C", None, None, vec![])
5549 .await
5550 .unwrap();
5551
5552 rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5553 .await
5554 .unwrap();
5555 rt.link(&tok, a.id, c.id, EdgeRelation::Enables, 1.0, None)
5556 .await
5557 .unwrap();
5558
5559 let filter = EdgeListFilter {
5560 relations: vec![EdgeRelation::Extends],
5561 ..Default::default()
5562 };
5563 let edges = rt.list_edges(&tok, filter, 100, 0).await.unwrap();
5564 assert_eq!(edges.len(), 1);
5565 assert_eq!(edges[0].relation, EdgeRelation::Extends);
5566 }
5567
5568 #[tokio::test]
5569 async fn list_edges_filters_by_source() {
5570 let rt = rt();
5571 let tok = NamespaceToken::local();
5572 let a = rt
5573 .create_entity(&tok, "concept", None, "A", None, None, vec![])
5574 .await
5575 .unwrap();
5576 let b = rt
5577 .create_entity(&tok, "concept", None, "B", None, None, vec![])
5578 .await
5579 .unwrap();
5580 let c = rt
5581 .create_entity(&tok, "concept", None, "C", None, None, vec![])
5582 .await
5583 .unwrap();
5584 let d = rt
5585 .create_entity(&tok, "concept", None, "D", None, None, vec![])
5586 .await
5587 .unwrap();
5588
5589 rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5590 .await
5591 .unwrap();
5592 rt.link(&tok, c.id, d.id, EdgeRelation::Extends, 1.0, None)
5593 .await
5594 .unwrap();
5595
5596 let filter = EdgeListFilter {
5597 source_id: Some(a.id),
5598 ..Default::default()
5599 };
5600 let edges = rt.list_edges(&tok, filter, 100, 0).await.unwrap();
5601 assert_eq!(edges.len(), 1);
5602 let src: Uuid = edges[0].source_id;
5603 assert_eq!(src, a.id);
5604 }
5605
5606 #[tokio::test]
5611 async fn list_edges_offset_pages_through_full_set() {
5612 let rt = rt();
5613 let tok = NamespaceToken::local();
5614 let a = rt
5615 .create_entity(&tok, "concept", None, "A", None, None, vec![])
5616 .await
5617 .unwrap();
5618 for i in 0..5 {
5619 let t = rt
5620 .create_entity(&tok, "concept", None, &format!("T{i}"), None, None, vec![])
5621 .await
5622 .unwrap();
5623 rt.link(&tok, a.id, t.id, EdgeRelation::Extends, 1.0, None)
5624 .await
5625 .unwrap();
5626 }
5627
5628 let filter = EdgeListFilter {
5629 source_id: Some(a.id),
5630 relations: vec![EdgeRelation::Extends],
5631 ..Default::default()
5632 };
5633
5634 let page0 = rt.list_edges(&tok, filter.clone(), 2, 0).await.unwrap();
5635 let page1 = rt.list_edges(&tok, filter.clone(), 2, 2).await.unwrap();
5636 let page2 = rt.list_edges(&tok, filter.clone(), 2, 4).await.unwrap();
5637 assert_eq!(page0.len(), 2);
5638 assert_eq!(page1.len(), 2);
5639 assert_eq!(page2.len(), 1);
5640
5641 let ids = |p: &[Edge]| p.iter().map(|e| Uuid::from(e.id)).collect::<Vec<_>>();
5642 assert_ne!(ids(&page0), ids(&page1), "page 2 must differ from page 1");
5643
5644 let mut all_ids: Vec<Uuid> = ids(&page0)
5645 .into_iter()
5646 .chain(ids(&page1))
5647 .chain(ids(&page2))
5648 .collect();
5649 all_ids.sort();
5650 all_ids.dedup();
5651 assert_eq!(all_ids.len(), 5, "pages must tile the full edge set");
5652
5653 let empty = rt.list_edges(&tok, filter.clone(), 2, 100).await.unwrap();
5654 assert!(
5655 empty.is_empty(),
5656 "offset past the end must return empty, not page 1"
5657 );
5658 }
5659
5660 #[tokio::test]
5664 async fn list_edges_after_keyset_tiles_full_set() {
5665 let rt = rt();
5666 let tok = NamespaceToken::local();
5667 let a = rt
5668 .create_entity(&tok, "concept", None, "A", None, None, vec![])
5669 .await
5670 .unwrap();
5671 for i in 0..5 {
5672 let t = rt
5673 .create_entity(&tok, "concept", None, &format!("K{i}"), None, None, vec![])
5674 .await
5675 .unwrap();
5676 rt.link(&tok, a.id, t.id, EdgeRelation::Extends, 1.0, None)
5677 .await
5678 .unwrap();
5679 }
5680
5681 let filter = EdgeListFilter {
5682 source_id: Some(a.id),
5683 relations: vec![EdgeRelation::Extends],
5684 ..Default::default()
5685 };
5686
5687 let mut seen = Vec::new();
5688 let mut cursor: Option<Uuid> = None;
5689 for _ in 0..20 {
5690 let (page, next) = rt
5691 .list_edges_after(&tok, filter.clone(), cursor, 2)
5692 .await
5693 .unwrap();
5694 if page.is_empty() {
5695 break;
5696 }
5697 seen.extend(page.iter().map(|e| Uuid::from(e.id)));
5698 if next.is_none() {
5699 break;
5700 }
5701 cursor = next;
5702 }
5703 seen.sort();
5704 seen.dedup();
5705 assert_eq!(seen.len(), 5, "keyset walk must tile the full edge set");
5706
5707 let (first_a, next_a) = rt
5713 .list_edges_after(&tok, filter.clone(), None, 2)
5714 .await
5715 .unwrap();
5716 let (first_b, next_b) = rt
5717 .list_edges_after(&tok, filter.clone(), None, 2)
5718 .await
5719 .unwrap();
5720 assert_eq!(
5721 first_a.iter().map(|e| e.id.0).collect::<Vec<_>>(),
5722 first_b.iter().map(|e| e.id.0).collect::<Vec<_>>(),
5723 );
5724 assert_eq!(next_a, next_b);
5725 }
5726
5727 #[tokio::test]
5728 async fn list_edges_after_single_namespace_exact_final_page_has_no_next_after() {
5729 let rt = rt();
5730 let tok = NamespaceToken::local();
5731 let a = rt
5732 .create_entity(&tok, "concept", None, "SingleCursorA", None, None, vec![])
5733 .await
5734 .unwrap();
5735 for i in 0..4 {
5736 let t = rt
5737 .create_entity(
5738 &tok,
5739 "concept",
5740 None,
5741 &format!("SingleCursorT{i}"),
5742 None,
5743 None,
5744 vec![],
5745 )
5746 .await
5747 .unwrap();
5748 rt.link(&tok, a.id, t.id, EdgeRelation::Extends, 1.0, None)
5749 .await
5750 .unwrap();
5751 }
5752
5753 let filter = EdgeListFilter {
5754 source_id: Some(a.id),
5755 relations: vec![EdgeRelation::Extends],
5756 ..Default::default()
5757 };
5758
5759 let (page1, next1) = rt
5760 .list_edges_after(&tok, filter.clone(), None, 2)
5761 .await
5762 .unwrap();
5763 assert_eq!(page1.len(), 2);
5764 let cursor = next1.expect("first page must report a cursor when two rows remain");
5765
5766 let (page2, next2) = rt
5767 .list_edges_after(&tok, filter, Some(cursor), 2)
5768 .await
5769 .unwrap();
5770 assert_eq!(page2.len(), 2);
5771 assert_eq!(
5772 next2, None,
5773 "an exact-size final single-namespace page must not report a cursor"
5774 );
5775 }
5776
5777 #[tokio::test]
5778 async fn list_edges_after_multi_namespace_exact_final_page_has_no_next_after() {
5779 let rt = rt();
5780 let ns_a = Namespace::parse("cursor-ns-a").unwrap();
5781 let ns_b = Namespace::parse("cursor-ns-b").unwrap();
5782 let tok_a = NamespaceToken::for_namespace(ns_a.clone());
5783 let tok_b = NamespaceToken::for_namespace(ns_b.clone());
5784 let visible = NamespaceToken::mint_with_visibility(ns_a, vec![ns_b], ActorRef::anonymous());
5785
5786 for (tok, prefix) in [(&tok_a, "A"), (&tok_b, "B")] {
5787 let source = rt
5788 .create_entity(
5789 tok,
5790 "concept",
5791 None,
5792 &format!("MultiCursor{prefix}Source"),
5793 None,
5794 None,
5795 vec![],
5796 )
5797 .await
5798 .unwrap();
5799 for i in 0..2 {
5800 let target = rt
5801 .create_entity(
5802 tok,
5803 "concept",
5804 None,
5805 &format!("MultiCursor{prefix}Target{i}"),
5806 None,
5807 None,
5808 vec![],
5809 )
5810 .await
5811 .unwrap();
5812 rt.link(tok, source.id, target.id, EdgeRelation::Extends, 1.0, None)
5813 .await
5814 .unwrap();
5815 }
5816 }
5817
5818 let filter = EdgeListFilter {
5819 relations: vec![EdgeRelation::Extends],
5820 ..Default::default()
5821 };
5822 let (page1, next1) = rt
5823 .list_edges_after(&visible, filter.clone(), None, 2)
5824 .await
5825 .unwrap();
5826 assert_eq!(page1.len(), 2);
5827 let cursor = next1.expect("first merged page must report a cursor when rows remain");
5828
5829 let (page2, next2) = rt
5830 .list_edges_after(&visible, filter, Some(cursor), 2)
5831 .await
5832 .unwrap();
5833 assert_eq!(page2.len(), 2);
5834 assert_eq!(
5835 next2, None,
5836 "an exact-size final multi-namespace page must not report a cursor"
5837 );
5838 }
5839
5840 #[tokio::test]
5843 async fn count_edges_by_relation_matches_fixtures() {
5844 let rt = rt();
5845 let tok = NamespaceToken::local();
5846 let a = rt
5847 .create_entity(&tok, "concept", None, "A", None, None, vec![])
5848 .await
5849 .unwrap();
5850 let b = rt
5851 .create_entity(&tok, "concept", None, "B", None, None, vec![])
5852 .await
5853 .unwrap();
5854 let c = rt
5855 .create_entity(&tok, "concept", None, "C", None, None, vec![])
5856 .await
5857 .unwrap();
5858
5859 rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5860 .await
5861 .unwrap();
5862 rt.link(&tok, a.id, c.id, EdgeRelation::Extends, 1.0, None)
5863 .await
5864 .unwrap();
5865 rt.link(&tok, b.id, c.id, EdgeRelation::Enables, 1.0, None)
5866 .await
5867 .unwrap();
5868
5869 let counts = rt.count_edges_by_relation(&tok).await.unwrap();
5870 assert_eq!(counts.get("extends").copied(), Some(2));
5871 assert_eq!(counts.get("enables").copied(), Some(1));
5872 }
5873
5874 #[tokio::test]
5875 async fn delete_edge_removes_from_storage() {
5876 let rt = rt();
5877 let tok = NamespaceToken::local();
5878 let a = rt
5879 .create_entity(&tok, "concept", None, "A", None, None, vec![])
5880 .await
5881 .unwrap();
5882 let b = rt
5883 .create_entity(&tok, "concept", None, "B", None, None, vec![])
5884 .await
5885 .unwrap();
5886 let edge = rt
5887 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5888 .await
5889 .unwrap();
5890 let edge_id: Uuid = edge.id.into();
5891
5892 let deleted = rt.delete_edge(&tok, edge_id, true).await.unwrap();
5893 assert!(deleted);
5894
5895 let fetched = rt.get_edge(&tok, edge_id).await.unwrap();
5896 assert!(fetched.is_none(), "edge should be gone after delete");
5897 }
5898
5899 #[tokio::test]
5900 async fn count_edges_matches_filter() {
5901 let rt = rt();
5902 let tok = NamespaceToken::local();
5903 let a = rt
5904 .create_entity(&tok, "concept", None, "A", None, None, vec![])
5905 .await
5906 .unwrap();
5907 let b = rt
5908 .create_entity(&tok, "concept", None, "B", None, None, vec![])
5909 .await
5910 .unwrap();
5911 let c = rt
5912 .create_entity(&tok, "concept", None, "C", None, None, vec![])
5913 .await
5914 .unwrap();
5915
5916 rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5917 .await
5918 .unwrap();
5919 rt.link(&tok, a.id, c.id, EdgeRelation::Enables, 1.0, None)
5920 .await
5921 .unwrap();
5922
5923 let all = rt
5924 .count_edges(&tok, EdgeListFilter::default())
5925 .await
5926 .unwrap();
5927 assert_eq!(all, 2);
5928
5929 let just_extends = rt
5930 .count_edges(
5931 &tok,
5932 EdgeListFilter {
5933 relations: vec![EdgeRelation::Extends],
5934 ..Default::default()
5935 },
5936 )
5937 .await
5938 .unwrap();
5939 assert_eq!(just_extends, 1);
5940 }
5941
5942 #[tokio::test]
5948 async fn edge_in_visible_namespace_reachable_as_graph_root() {
5949 let rt = rt();
5950 let ns_a = Namespace::parse("vis-edge-a").unwrap();
5951 let ns_b = Namespace::parse("vis-edge-b").unwrap();
5952
5953 let tok_b = NamespaceToken::for_namespace(ns_b.clone());
5955 let src = rt
5956 .create_entity(&tok_b, "concept", None, "SrcB", None, None, vec![])
5957 .await
5958 .unwrap();
5959 let tgt = rt
5960 .create_entity(&tok_b, "concept", None, "TgtB", None, None, vec![])
5961 .await
5962 .unwrap();
5963 let edge = rt
5964 .link(&tok_b, src.id, tgt.id, EdgeRelation::Extends, 1.0, None)
5965 .await
5966 .unwrap();
5967
5968 let tok_a_vis = rt
5971 .authorize_with_visibility(ns_a.clone(), vec![ns_b.clone()])
5972 .unwrap();
5973
5974 let got = rt.get_edge_visible(&tok_a_vis, edge.id.0).await.unwrap();
5976 assert!(
5977 got.is_some(),
5978 "edge in visible namespace must be retrievable via get_edge_visible"
5979 );
5980
5981 let neighbors = rt
5984 .neighbors(&tok_a_vis, src.id, Direction::Out, Some(16), None)
5985 .await
5986 .unwrap();
5987 assert!(
5988 neighbors.iter().any(|h| h.node_id == tgt.id),
5989 "neighbors of visible-ns node must include its visible-ns neighbor; got: {neighbors:?}"
5990 );
5991 }
5992
5993 #[tokio::test]
5997 async fn get_entity_cross_namespace_no_longer_denied() {
5998 let rt = rt();
5999 let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
6000 let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
6001 let entity = rt
6002 .create_entity(&ns_a, "concept", None, "Alpha", None, None, vec![])
6003 .await
6004 .unwrap();
6005
6006 let found = rt.get_entity(&ns_a, entity.id).await;
6008 assert!(found.is_ok(), "same-namespace get must succeed");
6009
6010 let cross = rt.get_entity(&ns_b, entity.id).await;
6012 assert!(
6013 cross.is_ok(),
6014 "cross-namespace get must succeed in shared-brain OSS (ADR-007 rule 2)"
6015 );
6016 assert_eq!(cross.unwrap().id, entity.id);
6017 }
6018
6019 #[tokio::test]
6020 async fn delete_entity_cross_namespace_no_longer_denied() {
6021 let rt = rt();
6022 let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
6023 let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
6024 let entity = rt
6025 .create_entity(&ns_a, "concept", None, "Beta", None, None, vec![])
6026 .await
6027 .unwrap();
6028
6029 let cross_ns_result = rt.delete_entity(&ns_b, entity.id, true).await;
6031 assert!(
6032 cross_ns_result.is_ok(),
6033 "cross-namespace delete must succeed in shared-brain OSS; got {:?}",
6034 cross_ns_result
6035 );
6036 assert!(cross_ns_result.unwrap(), "delete must return true");
6037
6038 let gone = rt.get_entity(&ns_a, entity.id).await;
6040 assert!(gone.is_err(), "entity must be gone after delete");
6041 }
6042
6043 #[tokio::test]
6046 async fn create_note_indexes_into_fts5() {
6047 let rt = rt();
6048 let tok = NamespaceToken::local();
6049 let note = rt
6050 .create_note(
6051 &tok,
6052 "observation",
6053 None,
6054 "FlashAttention reduces memory by using tiling",
6055 Some(0.8),
6056 None,
6057 vec![],
6058 )
6059 .await
6060 .unwrap();
6061
6062 let ns = tok.namespace().as_str().to_string();
6064 let hits = rt
6065 .text_for_notes(&tok)
6066 .unwrap()
6067 .search(khive_storage::types::TextSearchRequest {
6068 query: "FlashAttention".to_string(),
6069 mode: khive_storage::types::TextQueryMode::Plain,
6070 filter: Some(khive_storage::types::TextFilter {
6071 namespaces: vec![ns],
6072 ..Default::default()
6073 }),
6074 top_k: 10,
6075 snippet_chars: 100,
6076 })
6077 .await
6078 .unwrap();
6079
6080 assert!(
6081 hits.iter().any(|h| h.subject_id == note.id),
6082 "note should be indexed in FTS5 after create"
6083 );
6084 }
6085
6086 #[tokio::test]
6093 async fn search_notes_with_residual_fts5_char_now_sanitized() {
6094 let rt = rt();
6095 let tok = NamespaceToken::local();
6096 rt.create_note(
6097 &tok,
6098 "observation",
6099 None,
6100 "use foo@bar to chain calls",
6101 Some(0.5),
6102 None,
6103 vec![],
6104 )
6105 .await
6106 .unwrap();
6107
6108 let result = rt
6109 .search_notes(&tok, "foo@bar", None, 10, None, false, &[], None)
6110 .await;
6111
6112 let hits = result.unwrap_or_else(|e| {
6113 panic!("#916 search_notes must not fail on an '@'-bearing query, got: {e:?}")
6114 });
6115 assert!(
6116 !hits.is_empty(),
6117 "#916 '@'-bearing query must still find the seeded 'foo@bar' content via the \
6118 quoted-phrase alternative"
6119 );
6120 }
6121
6122 #[tokio::test]
6131 async fn search_notes_propagates_non_parser_fts_error() {
6132 let rt = rt();
6133 let tok = NamespaceToken::local();
6134 rt.create_note(
6135 &tok,
6136 "observation",
6137 None,
6138 "FlashAttention reduces memory by using tiling",
6139 Some(0.8),
6140 None,
6141 vec![],
6142 )
6143 .await
6144 .unwrap();
6145
6146 let ns = tok.namespace().as_str().to_string();
6147 arm_fts_search_fail(&ns);
6148
6149 let result = rt
6150 .search_notes(&tok, "FlashAttention", None, 10, None, false, &[], None)
6151 .await;
6152
6153 assert!(
6154 result.is_err(),
6155 "search_notes must propagate a non-parser FTS StorageError (Timeout) \
6156 as Err, not silently degrade it to an empty result, got: {:?}",
6157 result.ok()
6158 );
6159 assert!(
6160 matches!(
6161 result.unwrap_err(),
6162 RuntimeError::Storage(khive_storage::StorageError::Timeout { .. })
6163 ),
6164 "propagated error must be the injected StorageError::Timeout, unwrapped \
6165 through RuntimeError::Storage"
6166 );
6167 }
6168
6169 #[tokio::test]
6170 async fn create_note_with_properties() {
6171 let rt = rt();
6172 let tok = NamespaceToken::local();
6173 let props = serde_json::json!({"source": "arxiv:2205.14135"});
6174 let note = rt
6175 .create_note(
6176 &tok,
6177 "insight",
6178 None,
6179 "FlashAttention is IO-aware",
6180 Some(0.9),
6181 Some(props.clone()),
6182 vec![],
6183 )
6184 .await
6185 .unwrap();
6186
6187 assert_eq!(note.properties.as_ref().unwrap(), &props);
6188 }
6189
6190 #[tokio::test]
6191 async fn create_note_creates_annotates_edges() {
6192 let rt = rt();
6193 let tok = NamespaceToken::local();
6194 let entity = rt
6195 .create_entity(&tok, "concept", None, "FlashAttention", None, None, vec![])
6196 .await
6197 .unwrap();
6198
6199 let note = rt
6200 .create_note(
6201 &tok,
6202 "observation",
6203 None,
6204 "FlashAttention uses SRAM tiling for memory efficiency",
6205 Some(0.9),
6206 None,
6207 vec![entity.id],
6208 )
6209 .await
6210 .unwrap();
6211
6212 let out_neighbors = rt
6214 .neighbors(
6215 &tok,
6216 note.id,
6217 Direction::Out,
6218 None,
6219 Some(vec![EdgeRelation::Annotates]),
6220 )
6221 .await
6222 .unwrap();
6223 assert_eq!(out_neighbors.len(), 1);
6224 assert_eq!(out_neighbors[0].node_id, entity.id);
6225 assert_eq!(out_neighbors[0].relation, EdgeRelation::Annotates);
6226
6227 let in_neighbors = rt
6229 .neighbors(
6230 &tok,
6231 entity.id,
6232 Direction::In,
6233 None,
6234 Some(vec![EdgeRelation::Annotates]),
6235 )
6236 .await
6237 .unwrap();
6238 assert_eq!(in_neighbors.len(), 1);
6239 assert_eq!(in_neighbors[0].node_id, note.id);
6240 }
6241
6242 #[tokio::test]
6243 async fn neighbors_without_relation_filter_returns_all() {
6244 let rt = rt();
6245 let tok = NamespaceToken::local();
6246 let a = rt
6247 .create_entity(&tok, "concept", None, "A", None, None, vec![])
6248 .await
6249 .unwrap();
6250 let b = rt
6251 .create_entity(&tok, "concept", None, "B", None, None, vec![])
6252 .await
6253 .unwrap();
6254 let c = rt
6255 .create_entity(&tok, "concept", None, "C", None, None, vec![])
6256 .await
6257 .unwrap();
6258
6259 rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
6260 .await
6261 .unwrap();
6262 rt.link(&tok, a.id, c.id, EdgeRelation::Enables, 1.0, None)
6263 .await
6264 .unwrap();
6265
6266 let all = rt
6267 .neighbors(&tok, a.id, Direction::Out, None, None)
6268 .await
6269 .unwrap();
6270 assert_eq!(all.len(), 2);
6271 }
6272
6273 #[tokio::test]
6274 async fn neighbors_with_relation_filter_returns_subset() {
6275 let rt = rt();
6276 let tok = NamespaceToken::local();
6277 let a = rt
6278 .create_entity(&tok, "concept", None, "A", None, None, vec![])
6279 .await
6280 .unwrap();
6281 let b = rt
6282 .create_entity(&tok, "concept", None, "B", None, None, vec![])
6283 .await
6284 .unwrap();
6285 let c = rt
6286 .create_entity(&tok, "concept", None, "C", None, None, vec![])
6287 .await
6288 .unwrap();
6289
6290 rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
6291 .await
6292 .unwrap();
6293 rt.link(&tok, a.id, c.id, EdgeRelation::Enables, 1.0, None)
6294 .await
6295 .unwrap();
6296
6297 let filtered = rt
6298 .neighbors(
6299 &tok,
6300 a.id,
6301 Direction::Out,
6302 None,
6303 Some(vec![EdgeRelation::Extends]),
6304 )
6305 .await
6306 .unwrap();
6307 assert_eq!(filtered.len(), 1);
6308 assert_eq!(filtered[0].node_id, b.id);
6309 assert_eq!(filtered[0].relation, EdgeRelation::Extends);
6310 }
6311
6312 #[tokio::test]
6320 async fn neighbors_with_query_directed_preserves_self_loop_direction_parity() {
6321 let rt = rt();
6322 let tok = NamespaceToken::local();
6323 let centre = rt
6324 .create_entity(&tok, "concept", None, "Centre", None, None, vec![])
6325 .await
6326 .unwrap();
6327
6328 let now = chrono::Utc::now();
6329 rt.graph(&tok)
6330 .unwrap()
6331 .upsert_edge(Edge {
6332 id: LinkId::from(Uuid::new_v4()),
6333 namespace: "local".to_string(),
6334 source_id: centre.id,
6335 target_id: centre.id,
6336 relation: EdgeRelation::Extends,
6337 weight: 0.7,
6338 created_at: now,
6339 updated_at: now,
6340 deleted_at: None,
6341 metadata: None,
6342 target_backend: None,
6343 })
6344 .await
6345 .unwrap();
6346
6347 let directed = rt
6348 .neighbors_with_query_directed(
6349 &tok,
6350 centre.id,
6351 NeighborQuery {
6352 direction: Direction::Both,
6353 relations: None,
6354 limit: None,
6355 min_weight: None,
6356 },
6357 )
6358 .await
6359 .unwrap();
6360
6361 assert_eq!(
6362 directed.len(),
6363 2,
6364 "a self-loop edge must produce both an Out hit and an In hit, not one collapsed hit"
6365 );
6366 let directions: Vec<Direction> = directed.iter().map(|(_, d)| d.clone()).collect();
6367 assert!(
6368 directions.contains(&Direction::Out),
6369 "self-loop must retain its Out-tagged hit"
6370 );
6371 assert!(
6372 directions.contains(&Direction::In),
6373 "self-loop must retain its In-tagged hit"
6374 );
6375 }
6376
6377 #[tokio::test]
6378 async fn search_notes_returns_relevant_note() {
6379 let rt = rt();
6380 let tok = NamespaceToken::local();
6381 rt.create_note(
6382 &tok,
6383 "observation",
6384 None,
6385 "GQA reduces KV cache memory for large models",
6386 Some(0.8),
6387 None,
6388 vec![],
6389 )
6390 .await
6391 .unwrap();
6392
6393 let results = rt
6394 .search_notes(&tok, "GQA KV cache", None, 10, None, false, &[], None)
6395 .await
6396 .unwrap();
6397
6398 assert!(!results.is_empty(), "search should return the indexed note");
6399 let hit = &results[0];
6400 assert!(
6401 hit.title.is_some(),
6402 "note hit title should be populated (falls back to content)"
6403 );
6404 assert!(
6405 hit.snippet.is_some(),
6406 "note hit snippet should be populated"
6407 );
6408 }
6409
6410 #[tokio::test]
6411 async fn search_notes_excludes_soft_deleted() {
6412 let rt = rt();
6413 let tok = NamespaceToken::local();
6414 let note = rt
6415 .create_note(
6416 &tok,
6417 "observation",
6418 None,
6419 "RoPE positional encoding rotary embeddings",
6420 Some(0.7),
6421 None,
6422 vec![],
6423 )
6424 .await
6425 .unwrap();
6426
6427 rt.notes(&tok)
6429 .unwrap()
6430 .delete_note(note.id, DeleteMode::Soft)
6431 .await
6432 .unwrap();
6433
6434 let results = rt
6435 .search_notes(
6436 &tok,
6437 "RoPE rotary positional",
6438 None,
6439 10,
6440 None,
6441 false,
6442 &[],
6443 None,
6444 )
6445 .await
6446 .unwrap();
6447
6448 assert!(
6449 results.iter().all(|h| h.note_id != note.id),
6450 "soft-deleted note should be excluded from search"
6451 );
6452 }
6453
6454 #[tokio::test]
6468 async fn search_notes_tag_filter_pushed_before_truncation() {
6469 let rt = rt();
6470 let tok = NamespaceToken::local();
6471
6472 rt.create_note(
6474 &tok,
6475 "observation",
6476 None,
6477 "kappa lambda mu note decoy kappa lambda mu note decoy kappa lambda mu",
6478 Some(0.5),
6479 Some(serde_json::json!({"tags": ["other-note-tag"]})),
6480 vec![],
6481 )
6482 .await
6483 .unwrap();
6484
6485 let target = rt
6487 .create_note(
6488 &tok,
6489 "observation",
6490 None,
6491 "kappa lambda mu note target",
6492 Some(0.5),
6493 Some(serde_json::json!({"tags": ["note-target-tag"]})),
6494 vec![],
6495 )
6496 .await
6497 .unwrap();
6498
6499 let hits = rt
6502 .search_notes(
6503 &tok,
6504 "kappa lambda mu note",
6505 None,
6506 1,
6507 None,
6508 false,
6509 &["note-target-tag".to_string()],
6510 None,
6511 )
6512 .await
6513 .unwrap();
6514
6515 assert_eq!(
6516 hits.len(),
6517 1,
6518 "exactly one hit expected (tag-matching note)"
6519 );
6520 assert_eq!(
6521 hits[0].note_id, target.id,
6522 "tag-filtered note must be returned even when ranked below limit in raw fusion"
6523 );
6524 }
6525
6526 #[tokio::test]
6533 async fn search_notes_props_filter_pushed_before_truncation() {
6534 let rt = rt();
6535 let tok = NamespaceToken::local();
6536
6537 rt.create_note(
6538 &tok,
6539 "observation",
6540 None,
6541 "nu xi omicron note decoy nu xi omicron note decoy nu xi omicron",
6542 Some(0.5),
6543 Some(serde_json::json!({"source": "other"})),
6544 vec![],
6545 )
6546 .await
6547 .unwrap();
6548
6549 let target = rt
6550 .create_note(
6551 &tok,
6552 "observation",
6553 None,
6554 "nu xi omicron note target",
6555 Some(0.5),
6556 Some(serde_json::json!({"source": "target"})),
6557 vec![],
6558 )
6559 .await
6560 .unwrap();
6561
6562 let filter = serde_json::json!({"source": "target"});
6563 let hits = rt
6564 .search_notes(
6565 &tok,
6566 "nu xi omicron note",
6567 None,
6568 1,
6569 None,
6570 false,
6571 &[],
6572 Some(&filter),
6573 )
6574 .await
6575 .unwrap();
6576
6577 assert_eq!(
6578 hits.len(),
6579 1,
6580 "exactly one hit expected (properties-matching note)"
6581 );
6582 assert_eq!(
6583 hits[0].note_id, target.id,
6584 "properties-filtered note must be returned even when ranked below limit"
6585 );
6586 }
6587
6588 #[tokio::test]
6589 async fn resolve_returns_entity() {
6590 let rt = rt();
6591 let tok = NamespaceToken::local();
6592 let entity = rt
6593 .create_entity(&tok, "concept", None, "LoRA", None, None, vec![])
6594 .await
6595 .unwrap();
6596
6597 let resolved = rt.resolve(&tok, entity.id).await.unwrap();
6598 match resolved {
6599 Some(Resolved::Entity(e)) => assert_eq!(e.id, entity.id),
6600 other => panic!("expected Resolved::Entity, got {:?}", other),
6601 }
6602 }
6603
6604 #[tokio::test]
6605 async fn resolve_returns_note() {
6606 let rt = rt();
6607 let tok = NamespaceToken::local();
6608 let note = rt
6609 .create_note(
6610 &tok,
6611 "observation",
6612 None,
6613 "LoRA fine-tunes LLMs with low-rank adapters",
6614 Some(0.85),
6615 None,
6616 vec![],
6617 )
6618 .await
6619 .unwrap();
6620
6621 let resolved = rt.resolve(&tok, note.id).await.unwrap();
6622 match resolved {
6623 Some(Resolved::Note(n)) => assert_eq!(n.id, note.id),
6624 other => panic!("expected Resolved::Note, got {:?}", other),
6625 }
6626 }
6627
6628 #[tokio::test]
6629 async fn resolve_returns_none_for_unknown_uuid() {
6630 let rt = rt();
6631 let tok = NamespaceToken::local();
6632 let unknown = Uuid::new_v4();
6633 let resolved = rt.resolve(&tok, unknown).await.unwrap();
6634 assert!(resolved.is_none(), "unknown UUID should resolve to None");
6635 }
6636
6637 #[tokio::test]
6638 async fn resolve_prefix_finds_entity_in_own_namespace() {
6639 let rt = rt();
6640 let tok = NamespaceToken::local();
6641 let entity = rt
6642 .create_entity(&tok, "concept", None, "PrefixTest", None, None, vec![])
6643 .await
6644 .unwrap();
6645 let prefix = &entity.id.to_string()[..8];
6646
6647 let resolved = rt.resolve_prefix(&tok, prefix).await.unwrap();
6648 assert_eq!(resolved, Some(entity.id));
6649 }
6650
6651 #[test]
6652 fn hex_prefix_to_uuid_pattern_inserts_hyphens_at_canonical_boundaries() {
6653 let full = "aabbccdd112240008000000000000ab1";
6654 let cases: &[(usize, &str)] = &[
6655 (1, "a"),
6656 (7, "aabbccd"),
6657 (8, "aabbccdd"),
6658 (9, "aabbccdd-1"),
6659 (12, "aabbccdd-1122"),
6660 (13, "aabbccdd-1122-4"),
6661 (16, "aabbccdd-1122-4000"),
6662 (18, "aabbccdd-1122-4000-80"),
6663 (20, "aabbccdd-1122-4000-8000"),
6664 (23, "aabbccdd-1122-4000-8000-000"),
6665 (24, "aabbccdd-1122-4000-8000-0000"),
6666 (28, "aabbccdd-1122-4000-8000-00000000"),
6667 (31, "aabbccdd-1122-4000-8000-000000000ab"),
6668 ];
6669 for (len, expected) in cases {
6670 let input = &full[..*len];
6671 assert_eq!(
6672 hex_prefix_to_uuid_pattern(input),
6673 *expected,
6674 "len={len} input={input:?}"
6675 );
6676 }
6677 }
6678
6679 #[test]
6680 fn hex_prefix_to_uuid_pattern_full_32_char_matches_canonical_uuid() {
6681 let compact = "aabbccdd112240008000000000000ab1";
6682 let compact32 = &compact[..32];
6683 assert_eq!(
6684 hex_prefix_to_uuid_pattern(compact32),
6685 "aabbccdd-1122-4000-8000-000000000ab1"
6686 );
6687 }
6688
6689 #[test]
6695 fn hex_prefix_to_uuid_pattern_overlong_input_is_not_truncated() {
6696 let compact32 = "aabbccdd112240008000000000000ab1";
6697 let overlong = format!("{compact32}extrahex");
6698 let pattern = hex_prefix_to_uuid_pattern(&overlong);
6699 assert_eq!(
6700 pattern, "aabbccdd-1122-4000-8000-000000000ab1extrahex",
6701 "overlong input must keep its extra chars, not truncate to the valid UUID"
6702 );
6703 assert_ne!(
6704 pattern, "aabbccdd-1122-4000-8000-000000000ab1",
6705 "overlong pattern must not collapse to the canonical 36-char UUID form"
6706 );
6707 }
6708
6709 #[test]
6710 fn hex_prefix_to_uuid_pattern_passes_through_hyphenated_input() {
6711 let hyphenated = "aabbccdd-1122-4000-8000-000000000ab1";
6712 assert_eq!(hex_prefix_to_uuid_pattern(hyphenated), hyphenated);
6713
6714 let partial = "aabbccdd-11";
6715 assert_eq!(hex_prefix_to_uuid_pattern(partial), partial);
6716 }
6717
6718 #[tokio::test]
6719 async fn resolve_prefix_compact_9_to_31_char_matches() {
6720 let rt = rt();
6721 let tok = NamespaceToken::local();
6722 let entity = rt
6723 .create_entity(
6724 &tok,
6725 "concept",
6726 None,
6727 "CompactPrefixTest",
6728 None,
6729 None,
6730 vec![],
6731 )
6732 .await
6733 .unwrap();
6734 let compact = entity.id.simple().to_string();
6735
6736 for len in [9, 12, 16, 20, 24, 28, 31] {
6737 let prefix = &compact[..len];
6738 let resolved = rt.resolve_prefix(&tok, prefix).await.unwrap();
6739 assert_eq!(
6740 resolved,
6741 Some(entity.id),
6742 "compact prefix of len {len} should resolve"
6743 );
6744 }
6745 }
6746
6747 #[tokio::test]
6748 async fn resolve_prefix_compact_full_32_char_matches() {
6749 let rt = rt();
6750 let tok = NamespaceToken::local();
6751 let entity = rt
6752 .create_entity(&tok, "concept", None, "Full32Test", None, None, vec![])
6753 .await
6754 .unwrap();
6755 let compact = entity.id.simple().to_string();
6756 assert_eq!(compact.len(), 32);
6757
6758 let resolved = rt.resolve_prefix(&tok, &compact).await.unwrap();
6759 assert_eq!(resolved, Some(entity.id));
6760 }
6761
6762 #[tokio::test]
6765 async fn resolve_prefix_rejects_overlong_all_hex_input() {
6766 let rt = rt();
6767 let tok = NamespaceToken::local();
6768 let entity = rt
6769 .create_entity(&tok, "concept", None, "OverlongTest", None, None, vec![])
6770 .await
6771 .unwrap();
6772 let compact = entity.id.simple().to_string();
6773 assert_eq!(compact.len(), 32);
6774
6775 let overlong = format!("{compact}ab");
6776 let resolved = rt.resolve_prefix(&tok, &overlong).await.unwrap();
6777 assert_eq!(
6778 resolved, None,
6779 "a 32-char id plus extra hex chars must not resolve to the valid entity"
6780 );
6781 }
6782
6783 #[tokio::test]
6789 async fn resolve_prefix_rejects_like_wildcard_input() {
6790 let rt = rt();
6791 let tok = NamespaceToken::local();
6792 let entity = rt
6793 .create_entity(&tok, "concept", None, "WildcardTest", None, None, vec![])
6794 .await
6795 .unwrap();
6796 let compact = entity.id.simple().to_string();
6797 let wildcard_prefix = format!("{}%", &compact[..8]);
6801
6802 let resolved = rt.resolve_prefix(&tok, &wildcard_prefix).await.unwrap();
6803 assert_eq!(
6804 resolved, None,
6805 "prefix containing a LIKE wildcard must be rejected, not resolved"
6806 );
6807 }
6808
6809 #[tokio::test]
6810 async fn resolve_prefix_boundary_at_hyphen_positions() {
6811 let rt = rt();
6812 let tok = NamespaceToken::local();
6813 let entity = rt
6814 .create_entity(&tok, "concept", None, "BoundaryTest", None, None, vec![])
6815 .await
6816 .unwrap();
6817 let compact = entity.id.simple().to_string();
6818
6819 for len in [8, 12, 16, 20, 24] {
6820 let prefix = &compact[..len];
6821 let resolved = rt.resolve_prefix(&tok, prefix).await.unwrap();
6822 assert_eq!(
6823 resolved,
6824 Some(entity.id),
6825 "boundary prefix of len {len} should resolve"
6826 );
6827 }
6828 }
6829
6830 #[tokio::test]
6831 async fn resolve_prefix_ambiguous_still_detected_after_normalization() {
6832 use khive_storage::entity::Entity;
6833
6834 let rt = rt();
6835 let tok = NamespaceToken::local();
6836 let id_a = Uuid::parse_str("aabbccdd-1111-4000-8000-000000000001").unwrap();
6837 let id_b = Uuid::parse_str("aabbccdd-1111-4000-8000-000000000002").unwrap();
6838
6839 let mut entity_a = Entity::new("local", "concept", "AmbigCompactA");
6840 entity_a.id = id_a;
6841 let mut entity_b = Entity::new("local", "concept", "AmbigCompactB");
6842 entity_b.id = id_b;
6843
6844 let store = rt.entities(&tok).unwrap();
6845 store.upsert_entity(entity_a).await.unwrap();
6846 store.upsert_entity(entity_b).await.unwrap();
6847
6848 let shared_compact = &id_a.simple().to_string()[..20];
6850 let err = rt.resolve_prefix(&tok, shared_compact).await.unwrap_err();
6851 assert!(
6852 matches!(
6853 err,
6854 RuntimeError::AmbiguousPrefix { ref matches, .. } if matches.len() == 2
6855 ),
6856 "shared compact prefix must still return AmbiguousPrefix; got {err:?}"
6857 );
6858 }
6859
6860 #[tokio::test]
6861 async fn resolve_prefix_invisible_across_namespaces() {
6862 let rt = rt();
6863 let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
6864 let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
6865 let entity = rt
6866 .create_entity(&ns_a, "concept", None, "Invisible", None, None, vec![])
6867 .await
6868 .unwrap();
6869 let prefix = &entity.id.to_string()[..8];
6870
6871 let resolved = rt.resolve_prefix(&ns_b, prefix).await.unwrap();
6873 assert_eq!(resolved, None);
6874 }
6875
6876 #[tokio::test]
6877 async fn resolve_prefix_ambiguous_same_namespace() {
6878 use khive_storage::entity::Entity;
6879
6880 let rt = rt();
6881 let tok = NamespaceToken::local();
6882 let id_a = Uuid::parse_str("aabbccdd-1111-4000-8000-000000000001").unwrap();
6884 let id_b = Uuid::parse_str("aabbccdd-2222-4000-8000-000000000002").unwrap();
6885
6886 let mut entity_a = Entity::new("local", "concept", "AmbigA");
6887 entity_a.id = id_a;
6888 let mut entity_b = Entity::new("local", "concept", "AmbigB");
6889 entity_b.id = id_b;
6890
6891 let store = rt.entities(&tok).unwrap();
6892 store.upsert_entity(entity_a).await.unwrap();
6893 store.upsert_entity(entity_b).await.unwrap();
6894
6895 let err = rt.resolve_prefix(&tok, "aabbccdd").await.unwrap_err();
6896 assert!(
6897 matches!(
6898 err,
6899 RuntimeError::AmbiguousPrefix { ref prefix, ref matches }
6900 if prefix == "aabbccdd" && matches.len() == 2
6901 ),
6902 "shared 8-char prefix must return AmbiguousPrefix; got {err:?}"
6903 );
6904 }
6905
6906 #[tokio::test]
6911 async fn resolve_prefix_cross_table_duplicate_uuid_resolves_cleanly() {
6912 use khive_storage::entity::Entity;
6913
6914 let rt = rt();
6915 let tok = NamespaceToken::local();
6916 let shared_id = Uuid::parse_str("ccddeeff-1111-4000-8000-000000000001").unwrap();
6917
6918 let mut entity = Entity::new("local", "concept", "Nvk749Entity");
6919 entity.id = shared_id;
6920 rt.entities(&tok)
6921 .unwrap()
6922 .upsert_entity(entity)
6923 .await
6924 .unwrap();
6925
6926 let mut note = Note::new("local", "observation", "nvk749 note with the same id");
6927 note.id = shared_id;
6928 rt.notes(&tok).unwrap().upsert_note(note).await.unwrap();
6929
6930 let resolved = rt
6931 .resolve_prefix(&tok, "ccddeeff")
6932 .await
6933 .expect("#749: a UUID present in two tables must not be reported as ambiguous");
6934 assert_eq!(
6935 resolved,
6936 Some(shared_id),
6937 "#749: cross-table duplicate must resolve to the single shared UUID"
6938 );
6939 }
6940
6941 #[tokio::test]
6949 async fn resolve_prefix_early_exit_uses_deduped_match_count() {
6950 use khive_storage::entity::Entity;
6951
6952 let rt = rt();
6953 let tok = NamespaceToken::local();
6954 let shared_id = Uuid::parse_str("ddeeff11-2222-4000-8000-000000000002").unwrap();
6955
6956 let mut entity = Entity::new("local", "concept", "Nvk749bEntity");
6961 entity.id = shared_id;
6962 rt.entities(&tok)
6963 .unwrap()
6964 .upsert_entity(entity)
6965 .await
6966 .unwrap();
6967
6968 let mut note = Note::new("local", "observation", "nvk749b note with the same id");
6969 note.id = shared_id;
6970 rt.notes(&tok).unwrap().upsert_note(note).await.unwrap();
6971
6972 let resolved = rt
6973 .resolve_prefix(&tok, "ddeeff11")
6974 .await
6975 .expect("#749: deduped early-exit must not falsely report ambiguity");
6976 assert_eq!(resolved, Some(shared_id));
6977 }
6978
6979 #[tokio::test]
6986 async fn resolve_finds_event_by_full_uuid() {
6987 use khive_storage::Event;
6988 use khive_types::{EventKind, SubstrateKind};
6989
6990 let rt = rt();
6991 let tok = NamespaceToken::local();
6992 let ns = tok.namespace().as_str();
6993 let event = Event::new(
6994 ns,
6995 "test_verb",
6996 EventKind::Audit,
6997 SubstrateKind::Entity,
6998 "actor",
6999 );
7000 let event_id = event.id;
7001 rt.events(&tok).unwrap().append_event(event).await.unwrap();
7002
7003 let resolved = rt.resolve(&tok, event_id).await.unwrap();
7004 assert!(
7005 matches!(resolved, Some(Resolved::Event(_))),
7006 "event UUID must resolve to Resolved::Event, got {resolved:?}"
7007 );
7008 }
7009
7010 #[tokio::test]
7011 async fn resolve_prefix_finds_event() {
7012 use khive_storage::Event;
7013 use khive_types::{EventKind, SubstrateKind};
7014
7015 let rt = rt();
7016 let tok = NamespaceToken::local();
7017 let ns = tok.namespace().as_str();
7018 let event = Event::new(
7019 ns,
7020 "test_verb",
7021 EventKind::Audit,
7022 SubstrateKind::Entity,
7023 "actor",
7024 );
7025 let event_id = event.id;
7026 rt.events(&tok).unwrap().append_event(event).await.unwrap();
7027
7028 let prefix = &event_id.to_string()[..8];
7029 let resolved = rt.resolve_prefix(&tok, prefix).await.unwrap();
7030 assert_eq!(
7031 resolved,
7032 Some(event_id),
7033 "resolve_prefix must return event UUID for 8-char prefix"
7034 );
7035 }
7036
7037 #[tokio::test]
7040 async fn link_phantom_source_returns_not_found() {
7041 let rt = rt();
7042 let tok = NamespaceToken::local();
7043 let b = rt
7044 .create_entity(&tok, "concept", None, "B", None, None, vec![])
7045 .await
7046 .unwrap();
7047 let phantom = Uuid::new_v4();
7048
7049 let result = rt
7050 .link(&tok, phantom, b.id, EdgeRelation::Extends, 1.0, None)
7051 .await;
7052 match result {
7053 Err(RuntimeError::NotFound(msg)) => {
7054 assert!(
7055 msg.contains("source"),
7056 "error message must name 'source': {msg}"
7057 );
7058 }
7059 other => panic!("expected NotFound for phantom source, got {other:?}"),
7060 }
7061 }
7062
7063 #[tokio::test]
7064 async fn link_phantom_target_returns_not_found() {
7065 let rt = rt();
7066 let tok = NamespaceToken::local();
7067 let a = rt
7068 .create_entity(&tok, "concept", None, "A", None, None, vec![])
7069 .await
7070 .unwrap();
7071 let phantom = Uuid::new_v4();
7072
7073 let result = rt
7074 .link(&tok, a.id, phantom, EdgeRelation::Extends, 1.0, None)
7075 .await;
7076 match result {
7077 Err(RuntimeError::NotFound(msg)) => {
7078 assert!(
7079 msg.contains("target"),
7080 "error message must name 'target': {msg}"
7081 );
7082 }
7083 other => panic!("expected NotFound for phantom target, got {other:?}"),
7084 }
7085 }
7086
7087 #[tokio::test]
7088 async fn link_real_entities_succeeds() {
7089 let rt = rt();
7090 let tok = NamespaceToken::local();
7091 let a = rt
7092 .create_entity(&tok, "concept", None, "A", None, None, vec![])
7093 .await
7094 .unwrap();
7095 let b = rt
7096 .create_entity(&tok, "concept", None, "B", None, None, vec![])
7097 .await
7098 .unwrap();
7099
7100 let edge = rt
7101 .link(&tok, a.id, b.id, EdgeRelation::Extends, 0.8, None)
7102 .await
7103 .unwrap();
7104 assert_eq!(edge.source_id, a.id);
7105 assert_eq!(edge.target_id, b.id);
7106 assert_eq!(edge.relation, EdgeRelation::Extends);
7107 }
7108
7109 #[tokio::test]
7116 async fn link_write_time_guard_blocks_dangling_edge_after_target_vanishes() {
7117 let rt = rt();
7118 let tok = NamespaceToken::local();
7119 let a = rt
7120 .create_entity(&tok, "concept", None, "A", None, None, vec![])
7121 .await
7122 .unwrap();
7123 let x = rt
7124 .create_entity(&tok, "concept", None, "X", None, None, vec![])
7125 .await
7126 .unwrap();
7127
7128 rt.validate_edge_relation_endpoints(&tok, a.id, x.id, EdgeRelation::Extends)
7129 .await
7130 .expect("prepare-time validation must pass while X is live");
7131
7132 assert!(rt.delete_entity(&tok, x.id, true).await.unwrap());
7133
7134 let now = chrono::Utc::now();
7135 let edge = Edge {
7136 id: LinkId::from(Uuid::new_v4()),
7137 namespace: tok.namespace().as_str().to_string(),
7138 source_id: a.id,
7139 target_id: x.id,
7140 relation: EdgeRelation::Extends,
7141 weight: 1.0,
7142 created_at: now,
7143 updated_at: now,
7144 deleted_at: None,
7145 metadata: None,
7146 target_backend: None,
7147 };
7148 let outcome = rt
7149 .graph(&tok)
7150 .unwrap()
7151 .upsert_edge_guarded(edge)
7152 .await
7153 .unwrap();
7154 match outcome {
7155 khive_storage::GuardedWriteOutcome::Refused(missing) => {
7156 assert!(missing.target, "target must be reported missing");
7157 }
7158 other => panic!(
7159 "guarded write must refuse an edge whose target vanished before commit, got {other:?}"
7160 ),
7161 }
7162
7163 let edges = rt
7164 .list_edges(
7165 &tok,
7166 crate::curation::EdgeListFilter {
7167 source_id: Some(a.id),
7168 target_id: Some(x.id),
7169 relations: vec![EdgeRelation::Extends],
7170 ..Default::default()
7171 },
7172 10,
7173 0,
7174 )
7175 .await
7176 .unwrap();
7177 assert!(
7178 edges.is_empty(),
7179 "no dangling edge may be persisted after the guarded write refused it"
7180 );
7181 }
7182
7183 #[tokio::test]
7184 async fn link_many_writes_nothing_when_one_target_vanishes_before_write() {
7185 let rt = rt();
7186 let tok = NamespaceToken::local();
7187 let a = rt
7188 .create_entity(&tok, "concept", None, "A", None, None, vec![])
7189 .await
7190 .unwrap();
7191 let b = rt
7192 .create_entity(&tok, "concept", None, "B", None, None, vec![])
7193 .await
7194 .unwrap();
7195 let x = rt
7196 .create_entity(&tok, "concept", None, "X", None, None, vec![])
7197 .await
7198 .unwrap();
7199
7200 let specs = vec![
7201 LinkSpec {
7202 namespace: None,
7203 source_id: a.id,
7204 target_id: x.id,
7205 relation: EdgeRelation::Extends,
7206 weight: 1.0,
7207 metadata: None,
7208 },
7209 LinkSpec {
7210 namespace: None,
7211 source_id: a.id,
7212 target_id: b.id,
7213 relation: EdgeRelation::Extends,
7214 weight: 1.0,
7215 metadata: None,
7216 },
7217 ];
7218
7219 let mut edges = Vec::with_capacity(specs.len());
7221 for spec in &specs {
7222 edges.push(rt.build_edge(&tok, spec).await.unwrap());
7223 }
7224
7225 assert!(rt.delete_entity(&tok, x.id, true).await.unwrap());
7229
7230 let outcome = rt
7231 .graph(&tok)
7232 .unwrap()
7233 .upsert_edges_guarded(edges)
7234 .await
7235 .unwrap();
7236 assert_eq!(
7237 outcome.summary.affected, 0,
7238 "no edge from the batch may be persisted when any endpoint vanished"
7239 );
7240 assert!(
7241 outcome.refused.is_some(),
7242 "refused batch entry must be reported"
7243 );
7244
7245 let edges = rt
7246 .list_edges(
7247 &tok,
7248 crate::curation::EdgeListFilter {
7249 source_id: Some(a.id),
7250 relations: vec![EdgeRelation::Extends],
7251 ..Default::default()
7252 },
7253 10,
7254 0,
7255 )
7256 .await
7257 .unwrap();
7258 assert!(
7259 edges.is_empty(),
7260 "link_many's guarded batch must be all-or-nothing: the live A-B edge \
7261 must not have been persisted alongside the doomed A-X edge"
7262 );
7263 }
7264
7265 fn raw_edge(source_id: Uuid, target_id: Uuid, ns: &str) -> Edge {
7276 let now = chrono::Utc::now();
7277 Edge {
7278 id: LinkId::from(Uuid::new_v4()),
7279 namespace: ns.to_string(),
7280 source_id,
7281 target_id,
7282 relation: EdgeRelation::Extends,
7283 weight: 1.0,
7284 created_at: now,
7285 updated_at: now,
7286 deleted_at: None,
7287 metadata: None,
7288 target_backend: None,
7289 }
7290 }
7291
7292 async fn assert_no_edges_touch(rt: &KhiveRuntime, tok: &NamespaceToken, node_id: Uuid) {
7293 let as_source = rt
7294 .list_edges(
7295 tok,
7296 crate::curation::EdgeListFilter {
7297 source_id: Some(node_id),
7298 ..Default::default()
7299 },
7300 10,
7301 0,
7302 )
7303 .await
7304 .unwrap();
7305 let as_target = rt
7306 .list_edges(
7307 tok,
7308 crate::curation::EdgeListFilter {
7309 target_id: Some(node_id),
7310 ..Default::default()
7311 },
7312 10,
7313 0,
7314 )
7315 .await
7316 .unwrap();
7317 assert!(
7318 as_source.is_empty() && as_target.is_empty(),
7319 "no edge may reference hard-deleted node {node_id}: source-side={as_source:?} \
7320 target-side={as_target:?}"
7321 );
7322 }
7323
7324 #[tokio::test]
7325 async fn hard_delete_entity_purges_edge_written_before_delete() {
7326 let rt = rt();
7327 let tok = NamespaceToken::local();
7328 let a = rt
7329 .create_entity(&tok, "concept", None, "A", None, None, vec![])
7330 .await
7331 .unwrap();
7332 let x = rt
7333 .create_entity(&tok, "concept", None, "X", None, None, vec![])
7334 .await
7335 .unwrap();
7336
7337 let edge = raw_edge(a.id, x.id, tok.namespace().as_str());
7338 assert_eq!(
7339 rt.graph(&tok)
7340 .unwrap()
7341 .upsert_edge_guarded(edge)
7342 .await
7343 .unwrap(),
7344 khive_storage::GuardedWriteOutcome::Written
7345 );
7346
7347 assert!(rt.delete_entity(&tok, x.id, true).await.unwrap());
7348 assert_no_edges_touch(&rt, &tok, x.id).await;
7349 }
7350
7351 #[tokio::test]
7352 async fn hard_delete_entity_concurrent_with_guarded_write_never_leaves_dangling_edge() {
7353 let rt = std::sync::Arc::new(rt());
7354 let tok = NamespaceToken::local();
7355 let a = rt
7356 .create_entity(&tok, "concept", None, "A", None, None, vec![])
7357 .await
7358 .unwrap();
7359 let x = rt
7360 .create_entity(&tok, "concept", None, "X", None, None, vec![])
7361 .await
7362 .unwrap();
7363
7364 let delete_rt = std::sync::Arc::clone(&rt);
7365 let delete_tok = tok.clone();
7366 let delete_task =
7367 tokio::spawn(async move { delete_rt.delete_entity(&delete_tok, x.id, true).await });
7368
7369 let write_rt = std::sync::Arc::clone(&rt);
7370 let write_tok = tok.clone();
7371 let ns = tok.namespace().as_str().to_string();
7372 let write_task = tokio::spawn(async move {
7373 let edge = raw_edge(a.id, x.id, &ns);
7374 write_rt
7375 .graph(&write_tok)
7376 .unwrap()
7377 .upsert_edge_guarded(edge)
7378 .await
7379 });
7380
7381 let (deleted, _written) = tokio::join!(delete_task, write_task);
7382 deleted.unwrap().unwrap();
7383 assert_no_edges_touch(&rt, &tok, x.id).await;
7384 }
7385
7386 #[tokio::test]
7387 async fn hard_delete_note_purges_edge_written_before_delete() {
7388 let rt = rt();
7389 let tok = NamespaceToken::local();
7390 let a = rt
7391 .create_entity(&tok, "concept", None, "A", None, None, vec![])
7392 .await
7393 .unwrap();
7394 let n = rt
7395 .create_note(
7396 &tok,
7397 "observation",
7398 None,
7399 "note content",
7400 None,
7401 None,
7402 vec![],
7403 )
7404 .await
7405 .unwrap();
7406
7407 let edge = raw_edge(a.id, n.id, tok.namespace().as_str());
7408 assert_eq!(
7409 rt.graph(&tok)
7410 .unwrap()
7411 .upsert_edge_guarded(edge)
7412 .await
7413 .unwrap(),
7414 khive_storage::GuardedWriteOutcome::Written
7415 );
7416
7417 assert!(rt.delete_note(&tok, n.id, true).await.unwrap());
7418 assert_no_edges_touch(&rt, &tok, n.id).await;
7419 }
7420
7421 #[tokio::test]
7422 async fn hard_delete_note_concurrent_with_guarded_write_never_leaves_dangling_edge() {
7423 let rt = std::sync::Arc::new(rt());
7424 let tok = NamespaceToken::local();
7425 let a = rt
7426 .create_entity(&tok, "concept", None, "A", None, None, vec![])
7427 .await
7428 .unwrap();
7429 let n = rt
7430 .create_note(
7431 &tok,
7432 "observation",
7433 None,
7434 "note content",
7435 None,
7436 None,
7437 vec![],
7438 )
7439 .await
7440 .unwrap();
7441
7442 let delete_rt = std::sync::Arc::clone(&rt);
7443 let delete_tok = tok.clone();
7444 let note_id = n.id;
7445 let delete_task =
7446 tokio::spawn(async move { delete_rt.delete_note(&delete_tok, note_id, true).await });
7447
7448 let write_rt = std::sync::Arc::clone(&rt);
7449 let write_tok = tok.clone();
7450 let ns = tok.namespace().as_str().to_string();
7451 let write_task = tokio::spawn(async move {
7452 let edge = raw_edge(a.id, note_id, &ns);
7453 write_rt
7454 .graph(&write_tok)
7455 .unwrap()
7456 .upsert_edge_guarded(edge)
7457 .await
7458 });
7459
7460 let (deleted, _written) = tokio::join!(delete_task, write_task);
7461 deleted.unwrap().unwrap();
7462 assert_no_edges_touch(&rt, &tok, note_id).await;
7463 }
7464
7465 #[tokio::test]
7466 async fn hard_delete_edge_endpoint_purges_annotating_edge_written_before_delete() {
7467 let rt = rt();
7468 let tok = NamespaceToken::local();
7469 let a = rt
7470 .create_entity(&tok, "concept", None, "A", None, None, vec![])
7471 .await
7472 .unwrap();
7473 let b = rt
7474 .create_entity(&tok, "concept", None, "B", None, None, vec![])
7475 .await
7476 .unwrap();
7477 let n = rt
7478 .create_note(
7479 &tok,
7480 "observation",
7481 None,
7482 "note content",
7483 None,
7484 None,
7485 vec![],
7486 )
7487 .await
7488 .unwrap();
7489 let base_edge = rt
7490 .link(&tok, a.id, b.id, EdgeRelation::Extends, 0.8, None)
7491 .await
7492 .unwrap();
7493 let base_edge_id = Uuid::from(base_edge.id);
7494
7495 let annotating = raw_edge(n.id, base_edge_id, tok.namespace().as_str());
7498 assert_eq!(
7499 rt.graph(&tok)
7500 .unwrap()
7501 .upsert_edge_guarded(annotating)
7502 .await
7503 .unwrap(),
7504 khive_storage::GuardedWriteOutcome::Written
7505 );
7506
7507 assert!(rt.delete_edge(&tok, base_edge_id, true).await.unwrap());
7508 assert_no_edges_touch(&rt, &tok, base_edge_id).await;
7509 }
7510
7511 #[tokio::test]
7512 async fn hard_delete_edge_endpoint_concurrent_with_guarded_write_never_leaves_dangling_edge() {
7513 let rt = std::sync::Arc::new(rt());
7514 let tok = NamespaceToken::local();
7515 let a = rt
7516 .create_entity(&tok, "concept", None, "A", None, None, vec![])
7517 .await
7518 .unwrap();
7519 let b = rt
7520 .create_entity(&tok, "concept", None, "B", None, None, vec![])
7521 .await
7522 .unwrap();
7523 let n = rt
7524 .create_note(
7525 &tok,
7526 "observation",
7527 None,
7528 "note content",
7529 None,
7530 None,
7531 vec![],
7532 )
7533 .await
7534 .unwrap();
7535 let base_edge = rt
7536 .link(&tok, a.id, b.id, EdgeRelation::Extends, 0.8, None)
7537 .await
7538 .unwrap();
7539 let base_edge_id = Uuid::from(base_edge.id);
7540
7541 let delete_rt = std::sync::Arc::clone(&rt);
7542 let delete_tok = tok.clone();
7543 let delete_task =
7544 tokio::spawn(
7545 async move { delete_rt.delete_edge(&delete_tok, base_edge_id, true).await },
7546 );
7547
7548 let write_rt = std::sync::Arc::clone(&rt);
7549 let write_tok = tok.clone();
7550 let ns = tok.namespace().as_str().to_string();
7551 let write_task = tokio::spawn(async move {
7552 let edge = raw_edge(n.id, base_edge_id, &ns);
7553 write_rt
7554 .graph(&write_tok)
7555 .unwrap()
7556 .upsert_edge_guarded(edge)
7557 .await
7558 });
7559
7560 let (deleted, _written) = tokio::join!(delete_task, write_task);
7561 deleted.unwrap().unwrap();
7562 assert_no_edges_touch(&rt, &tok, base_edge_id).await;
7563 }
7564
7565 fn file_backed_runtime(
7583 dir: &tempfile::TempDir,
7584 name: &str,
7585 write_queue_enabled: bool,
7586 ) -> KhiveRuntime {
7587 let path = dir.path().join(name);
7588 if write_queue_enabled {
7589 std::env::set_var("KHIVE_WRITE_QUEUE", "1");
7590 } else {
7591 std::env::remove_var("KHIVE_WRITE_QUEUE");
7592 }
7593 let rt = KhiveRuntime::new(crate::config::RuntimeConfig {
7594 db_path: Some(path),
7595 packs: vec!["kg".to_string()],
7596 brain_profile: None,
7597 actor_id: None,
7598 ..crate::config::RuntimeConfig::no_embeddings()
7599 })
7600 .unwrap();
7601 std::env::remove_var("KHIVE_WRITE_QUEUE");
7602 rt
7603 }
7604
7605 async fn assert_guarded_write_committed_before_delete_is_swept(rt: &KhiveRuntime) {
7606 let tok = NamespaceToken::local();
7607 let a = rt
7608 .create_entity(&tok, "concept", None, "A", None, None, vec![])
7609 .await
7610 .unwrap();
7611 let x = rt
7612 .create_entity(&tok, "concept", None, "X", None, None, vec![])
7613 .await
7614 .unwrap();
7615
7616 let edge = raw_edge(a.id, x.id, tok.namespace().as_str());
7619 assert_eq!(
7620 rt.graph(&tok)
7621 .unwrap()
7622 .upsert_edge_guarded(edge)
7623 .await
7624 .unwrap(),
7625 khive_storage::GuardedWriteOutcome::Written,
7626 "write must succeed while both endpoints are still live"
7627 );
7628
7629 assert!(rt.delete_entity(&tok, x.id, true).await.unwrap());
7630 assert_no_edges_touch(rt, &tok, x.id).await;
7631 }
7632
7633 async fn assert_guarded_write_attempted_after_delete_is_refused(rt: &KhiveRuntime) {
7634 let tok = NamespaceToken::local();
7635 let a = rt
7636 .create_entity(&tok, "concept", None, "A", None, None, vec![])
7637 .await
7638 .unwrap();
7639 let x = rt
7640 .create_entity(&tok, "concept", None, "X", None, None, vec![])
7641 .await
7642 .unwrap();
7643
7644 assert!(rt.delete_entity(&tok, x.id, true).await.unwrap());
7647
7648 let edge = raw_edge(a.id, x.id, tok.namespace().as_str());
7649 let outcome = rt
7650 .graph(&tok)
7651 .unwrap()
7652 .upsert_edge_guarded(edge)
7653 .await
7654 .unwrap();
7655 match outcome {
7656 khive_storage::GuardedWriteOutcome::Refused(missing) => {
7657 assert!(
7658 missing.target,
7659 "target must be reported missing once the delete has committed"
7660 );
7661 assert!(!missing.source, "source was never deleted");
7662 }
7663 other => panic!(
7664 "guarded write attempted after the delete committed must be refused, got {other:?}"
7665 ),
7666 }
7667 assert_no_edges_touch(rt, &tok, x.id).await;
7668 }
7669
7670 #[tokio::test]
7671 #[serial_test::serial(khive_write_queue_env)]
7672 async fn guarded_write_before_delete_swept_file_backed_write_queue_off() {
7673 let dir = tempfile::tempdir().unwrap();
7674 let rt = file_backed_runtime(&dir, "guard_before_off.db", false);
7675 assert_guarded_write_committed_before_delete_is_swept(&rt).await;
7676 }
7677
7678 #[tokio::test]
7679 #[serial_test::serial(khive_write_queue_env)]
7680 async fn guarded_write_after_delete_refused_file_backed_write_queue_off() {
7681 let dir = tempfile::tempdir().unwrap();
7682 let rt = file_backed_runtime(&dir, "guard_after_off.db", false);
7683 assert_guarded_write_attempted_after_delete_is_refused(&rt).await;
7684 }
7685
7686 #[tokio::test]
7687 #[serial_test::serial(khive_write_queue_env)]
7688 async fn guarded_write_before_delete_swept_file_backed_write_queue_on() {
7689 let dir = tempfile::tempdir().unwrap();
7690 let rt = file_backed_runtime(&dir, "guard_before_on.db", true);
7691 assert_guarded_write_committed_before_delete_is_swept(&rt).await;
7692 }
7693
7694 #[tokio::test]
7695 #[serial_test::serial(khive_write_queue_env)]
7696 async fn guarded_write_after_delete_refused_file_backed_write_queue_on() {
7697 let dir = tempfile::tempdir().unwrap();
7698 let rt = file_backed_runtime(&dir, "guard_after_on.db", true);
7699 assert_guarded_write_attempted_after_delete_is_refused(&rt).await;
7700 }
7701
7702 #[tokio::test]
7703 async fn create_note_annotates_phantom_returns_not_found() {
7704 let rt = rt();
7705 let tok = NamespaceToken::local();
7706 let phantom = Uuid::new_v4();
7707
7708 let result = rt
7709 .create_note(
7710 &tok,
7711 "observation",
7712 None,
7713 "some content",
7714 Some(0.5),
7715 None,
7716 vec![phantom],
7717 )
7718 .await;
7719 assert!(
7720 matches!(result, Err(RuntimeError::NotFound(_))),
7721 "annotates with phantom uuid must return NotFound, got {result:?}"
7722 );
7723 }
7724
7725 #[tokio::test]
7726 async fn create_note_annotates_real_entity_succeeds() {
7727 let rt = rt();
7728 let tok = NamespaceToken::local();
7729 let entity = rt
7730 .create_entity(&tok, "concept", None, "RealTarget", None, None, vec![])
7731 .await
7732 .unwrap();
7733
7734 let note = rt
7735 .create_note(
7736 &tok,
7737 "observation",
7738 None,
7739 "content",
7740 Some(0.5),
7741 None,
7742 vec![entity.id],
7743 )
7744 .await
7745 .unwrap();
7746
7747 let neighbors = rt
7748 .neighbors(
7749 &tok,
7750 note.id,
7751 Direction::Out,
7752 None,
7753 Some(vec![EdgeRelation::Annotates]),
7754 )
7755 .await
7756 .unwrap();
7757 assert_eq!(neighbors.len(), 1);
7758 assert_eq!(neighbors[0].node_id, entity.id);
7759 }
7760
7761 #[tokio::test]
7763 async fn create_note_multi_annotates_creates_all_edges() {
7764 let rt = rt();
7765 let tok = NamespaceToken::local();
7766 let t1 = rt
7767 .create_entity(&tok, "concept", None, "Target1", None, None, vec![])
7768 .await
7769 .unwrap();
7770 let t2 = rt
7771 .create_entity(&tok, "concept", None, "Target2", None, None, vec![])
7772 .await
7773 .unwrap();
7774
7775 let note = rt
7776 .create_note(
7777 &tok,
7778 "observation",
7779 None,
7780 "content",
7781 Some(0.5),
7782 None,
7783 vec![t1.id, t2.id],
7784 )
7785 .await
7786 .unwrap();
7787
7788 let neighbors = rt
7789 .neighbors(
7790 &tok,
7791 note.id,
7792 Direction::Out,
7793 None,
7794 Some(vec![EdgeRelation::Annotates]),
7795 )
7796 .await
7797 .unwrap();
7798 assert_eq!(
7799 neighbors.len(),
7800 2,
7801 "multi-annotates note must have exactly 2 outbound annotates edges"
7802 );
7803 let target_ids: Vec<Uuid> = neighbors.iter().map(|n| n.node_id).collect();
7804 assert!(target_ids.contains(&t1.id));
7805 assert!(target_ids.contains(&t2.id));
7806 }
7807
7808 #[tokio::test]
7812 async fn link_target_in_different_namespace_succeeds() {
7813 let rt = rt();
7814 let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
7815 let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
7816 let a = rt
7817 .create_entity(&ns_a, "concept", None, "A", None, None, vec![])
7818 .await
7819 .unwrap();
7820 let b = rt
7821 .create_entity(&ns_b, "concept", None, "B", None, None, vec![])
7822 .await
7823 .unwrap();
7824
7825 let result = rt
7827 .link(&ns_a, a.id, b.id, EdgeRelation::Extends, 1.0, None)
7828 .await;
7829 assert!(
7830 result.is_ok(),
7831 "target in a different namespace than the caller must resolve (#631), got {result:?}"
7832 );
7833 }
7834
7835 #[tokio::test]
7836 async fn link_phantom_self_loop_returns_invalid_input() {
7837 let rt = rt();
7838 let tok = NamespaceToken::local();
7839 let phantom = Uuid::new_v4();
7840
7841 let result = rt
7842 .link(&tok, phantom, phantom, EdgeRelation::Extends, 1.0, None)
7843 .await;
7844 match result {
7845 Err(RuntimeError::InvalidInput(msg)) => {
7846 assert!(
7847 msg.contains("self-loop"),
7848 "self-loop must be rejected with self-loop message: {msg}"
7849 );
7850 }
7851 other => panic!("expected InvalidInput for self-loop, got {other:?}"),
7852 }
7853 }
7854
7855 #[tokio::test]
7858 async fn link_note_to_edge_annotates_succeeds() {
7859 let rt = rt();
7860 let tok = NamespaceToken::local();
7861 let a = rt
7862 .create_entity(&tok, "concept", None, "A", None, None, vec![])
7863 .await
7864 .unwrap();
7865 let b = rt
7866 .create_entity(&tok, "concept", None, "B", None, None, vec![])
7867 .await
7868 .unwrap();
7869 let edge = rt
7871 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
7872 .await
7873 .unwrap();
7874 let edge_uuid: Uuid = edge.id.into();
7875
7876 let note = rt
7878 .create_note(
7879 &tok,
7880 "observation",
7881 None,
7882 "edge note",
7883 Some(0.5),
7884 None,
7885 vec![],
7886 )
7887 .await
7888 .unwrap();
7889
7890 let result = rt
7891 .link(&tok, note.id, edge_uuid, EdgeRelation::Annotates, 1.0, None)
7892 .await;
7893 assert!(
7894 result.is_ok(),
7895 "note→edge Annotates must succeed, got {result:?}"
7896 );
7897 }
7898 #[tokio::test]
7905 async fn neighbors_edge_id_finds_annotating_note() {
7906 let rt = rt();
7907 let tok = NamespaceToken::local();
7908 let a = rt
7909 .create_entity(&tok, "concept", None, "A", None, None, vec![])
7910 .await
7911 .unwrap();
7912 let b = rt
7913 .create_entity(&tok, "concept", None, "B", None, None, vec![])
7914 .await
7915 .unwrap();
7916 let edge = rt
7917 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
7918 .await
7919 .unwrap();
7920 let edge_uuid: Uuid = edge.id.into();
7921
7922 let note = rt
7923 .create_note(
7924 &tok,
7925 "observation",
7926 None,
7927 "edge note",
7928 Some(0.5),
7929 None,
7930 vec![edge_uuid],
7931 )
7932 .await
7933 .unwrap();
7934
7935 let neighbors = rt
7936 .neighbors(
7937 &tok,
7938 edge_uuid,
7939 Direction::In,
7940 None,
7941 Some(vec![EdgeRelation::Annotates]),
7942 )
7943 .await
7944 .unwrap();
7945 assert_eq!(neighbors.len(), 1, "expected annotating note to show up");
7946 assert_eq!(neighbors[0].node_id, note.id);
7947 }
7948
7949 #[tokio::test]
7950 async fn create_note_annotates_real_edge_succeeds() {
7951 let rt = rt();
7952 let tok = NamespaceToken::local();
7953 let a = rt
7954 .create_entity(&tok, "concept", None, "A", None, None, vec![])
7955 .await
7956 .unwrap();
7957 let b = rt
7958 .create_entity(&tok, "concept", None, "B", None, None, vec![])
7959 .await
7960 .unwrap();
7961 let edge = rt
7962 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
7963 .await
7964 .unwrap();
7965 let edge_uuid: Uuid = edge.id.into();
7966
7967 let note = rt
7968 .create_note(
7969 &tok,
7970 "observation",
7971 None,
7972 "annotating an edge",
7973 Some(0.5),
7974 None,
7975 vec![edge_uuid],
7976 )
7977 .await
7978 .unwrap();
7979
7980 let neighbors = rt
7981 .neighbors(
7982 &tok,
7983 note.id,
7984 Direction::Out,
7985 None,
7986 Some(vec![EdgeRelation::Annotates]),
7987 )
7988 .await
7989 .unwrap();
7990 assert_eq!(neighbors.len(), 1);
7991 assert_eq!(neighbors[0].node_id, edge_uuid);
7992 }
7993
7994 #[tokio::test]
7995 async fn create_note_annotates_phantom_is_atomic_no_note_persisted() {
7996 let rt = rt();
7997 let tok = NamespaceToken::local();
7998 let phantom = Uuid::new_v4();
7999
8000 let before_count = rt.list_notes(&tok, None, 1000, 0).await.unwrap().len();
8001
8002 let result = rt
8003 .create_note(
8004 &tok,
8005 "observation",
8006 None,
8007 "should not persist",
8008 Some(0.5),
8009 None,
8010 vec![phantom],
8011 )
8012 .await;
8013 assert!(
8014 matches!(result, Err(RuntimeError::NotFound(_))),
8015 "phantom annotates target must return NotFound, got {result:?}"
8016 );
8017
8018 let after_count = rt.list_notes(&tok, None, 1000, 0).await.unwrap().len();
8020 assert_eq!(
8021 before_count, after_count,
8022 "failed create_note must not persist any note row (atomicity)"
8023 );
8024
8025 let search_hits = rt
8027 .search_notes(&tok, "should not persist", None, 10, None, false, &[], None)
8028 .await
8029 .unwrap();
8030 assert!(
8031 search_hits.is_empty(),
8032 "failed create_note must not index into FTS (atomicity)"
8033 );
8034 }
8037
8038 #[tokio::test]
8042 async fn link_entity_to_edge_uuid_non_annotates_returns_invalid_input() {
8043 let rt = rt();
8044 let tok = NamespaceToken::local();
8045 let a = rt
8046 .create_entity(&tok, "concept", None, "A", None, None, vec![])
8047 .await
8048 .unwrap();
8049 let b = rt
8050 .create_entity(&tok, "concept", None, "B", None, None, vec![])
8051 .await
8052 .unwrap();
8053 let edge = rt
8055 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
8056 .await
8057 .unwrap();
8058 let edge_uuid: Uuid = edge.id.into();
8059
8060 let result = rt
8061 .link(&tok, a.id, edge_uuid, EdgeRelation::Extends, 1.0, None)
8062 .await;
8063 match result {
8064 Err(RuntimeError::InvalidInput(msg)) => {
8065 assert!(
8066 msg.contains("target"),
8067 "error message must name 'target': {msg}"
8068 );
8069 }
8070 other => {
8071 panic!("expected InvalidInput for edge-uuid target with Extends, got {other:?}")
8072 }
8073 }
8074 }
8075
8076 #[tokio::test]
8078 async fn link_note_as_source_non_annotates_returns_invalid_input() {
8079 let rt = rt();
8080 let tok = NamespaceToken::local();
8081 let note = rt
8082 .create_note(&tok, "observation", None, "a note", Some(0.5), None, vec![])
8083 .await
8084 .unwrap();
8085 let entity = rt
8086 .create_entity(&tok, "concept", None, "E", None, None, vec![])
8087 .await
8088 .unwrap();
8089
8090 let result = rt
8091 .link(&tok, note.id, entity.id, EdgeRelation::DependsOn, 1.0, None)
8092 .await;
8093 match result {
8094 Err(RuntimeError::InvalidInput(msg)) => {
8095 assert!(
8096 msg.contains("source"),
8097 "error message must name 'source': {msg}"
8098 );
8099 }
8100 other => panic!("expected InvalidInput for note source with DependsOn, got {other:?}"),
8101 }
8102 }
8103
8104 #[tokio::test]
8106 async fn link_entity_as_annotates_source_returns_invalid_input() {
8107 let rt = rt();
8108 let tok = NamespaceToken::local();
8109 let a = rt
8110 .create_entity(&tok, "concept", None, "A", None, None, vec![])
8111 .await
8112 .unwrap();
8113 let b = rt
8114 .create_entity(&tok, "concept", None, "B", None, None, vec![])
8115 .await
8116 .unwrap();
8117
8118 let result = rt
8119 .link(&tok, a.id, b.id, EdgeRelation::Annotates, 1.0, None)
8120 .await;
8121 match result {
8122 Err(RuntimeError::InvalidInput(msg)) => {
8123 assert!(
8124 msg.contains("source") && msg.contains("note"),
8125 "error must say source must be a note: {msg}"
8126 );
8127 }
8128 other => {
8129 panic!("expected InvalidInput for entity source with Annotates, got {other:?}")
8130 }
8131 }
8132 }
8133
8134 #[tokio::test]
8135 async fn link_edge_as_annotates_source_returns_invalid_input() {
8136 let rt = rt();
8137 let tok = NamespaceToken::local();
8138 let a = rt
8139 .create_entity(&tok, "concept", None, "A", None, None, vec![])
8140 .await
8141 .unwrap();
8142 let b = rt
8143 .create_entity(&tok, "concept", None, "B", None, None, vec![])
8144 .await
8145 .unwrap();
8146 let edge = rt
8147 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
8148 .await
8149 .unwrap();
8150 let edge_uuid: Uuid = edge.id.into();
8151
8152 let result = rt
8154 .link(&tok, edge_uuid, a.id, EdgeRelation::Annotates, 1.0, None)
8155 .await;
8156 match result {
8157 Err(RuntimeError::InvalidInput(msg)) => {
8158 assert!(
8159 msg.contains("source") && msg.contains("note"),
8160 "edge-as-annotates-source must report wrong kind, not NotFound: {msg}"
8161 );
8162 }
8163 other => panic!("expected InvalidInput for edge source with Annotates, got {other:?}"),
8164 }
8165 }
8166
8167 #[tokio::test]
8169 async fn link_note_to_event_annotates_succeeds() {
8170 use khive_storage::Event;
8171 use khive_types::{EventKind, SubstrateKind};
8172
8173 let rt = rt();
8174 let tok = NamespaceToken::local();
8175 let note = rt
8176 .create_note(
8177 &tok,
8178 "observation",
8179 None,
8180 "observing an event",
8181 Some(0.6),
8182 None,
8183 vec![],
8184 )
8185 .await
8186 .unwrap();
8187
8188 let ns = tok.namespace().as_str();
8190 let event = Event::new(
8191 ns,
8192 "test_verb",
8193 EventKind::Audit,
8194 SubstrateKind::Entity,
8195 "test_actor",
8196 );
8197 let event_id = event.id;
8198 rt.events(&tok).unwrap().append_event(event).await.unwrap();
8199
8200 let result = rt
8201 .link(&tok, note.id, event_id, EdgeRelation::Annotates, 1.0, None)
8202 .await;
8203 assert!(
8204 result.is_ok(),
8205 "note→event Annotates must succeed, got {result:?}"
8206 );
8207 }
8208
8209 #[tokio::test]
8211 async fn create_note_annotates_event_succeeds() {
8212 use khive_storage::Event;
8213 use khive_types::{EventKind, SubstrateKind};
8214
8215 let rt = rt();
8216 let tok = NamespaceToken::local();
8217 let ns = tok.namespace().as_str();
8218 let event = Event::new(
8219 ns,
8220 "test_verb",
8221 EventKind::Audit,
8222 SubstrateKind::Entity,
8223 "test_actor",
8224 );
8225 let event_id = event.id;
8226 rt.events(&tok).unwrap().append_event(event).await.unwrap();
8227
8228 let result = rt
8229 .create_note(
8230 &tok,
8231 "observation",
8232 None,
8233 "note annotating an event",
8234 Some(0.5),
8235 None,
8236 vec![event_id],
8237 )
8238 .await;
8239 assert!(
8240 result.is_ok(),
8241 "create_note with event annotates target must succeed, got {result:?}"
8242 );
8243 let note = result.unwrap();
8245 let neighbors = rt
8246 .neighbors(
8247 &tok,
8248 note.id,
8249 Direction::Out,
8250 None,
8251 Some(vec![EdgeRelation::Annotates]),
8252 )
8253 .await
8254 .unwrap();
8255 assert_eq!(neighbors.len(), 1);
8256 assert_eq!(neighbors[0].node_id, event_id);
8257 }
8258
8259 #[tokio::test]
8263 async fn link_supersedes_note_to_note_succeeds() {
8264 let rt = rt();
8265 let tok = NamespaceToken::local();
8266 let old_note = rt
8267 .create_note(
8268 &tok,
8269 "observation",
8270 None,
8271 "old observation",
8272 Some(0.7),
8273 None,
8274 vec![],
8275 )
8276 .await
8277 .unwrap();
8278 let new_note = rt
8279 .create_note(
8280 &tok,
8281 "observation",
8282 None,
8283 "revised observation superseding the old one",
8284 Some(0.9),
8285 None,
8286 vec![],
8287 )
8288 .await
8289 .unwrap();
8290
8291 let result = rt
8292 .link(
8293 &tok,
8294 new_note.id,
8295 old_note.id,
8296 EdgeRelation::Supersedes,
8297 1.0,
8298 None,
8299 )
8300 .await;
8301 assert!(
8302 result.is_ok(),
8303 "note→note Supersedes must succeed (note supersession), got {result:?}"
8304 );
8305 }
8306
8307 #[tokio::test]
8308 async fn link_supersedes_entity_to_entity_succeeds() {
8309 let rt = rt();
8310 let tok = NamespaceToken::local();
8311 let old_entity = rt
8312 .create_entity(&tok, "concept", None, "OldConcept", None, None, vec![])
8313 .await
8314 .unwrap();
8315 let new_entity = rt
8316 .create_entity(&tok, "concept", None, "NewConcept", None, None, vec![])
8317 .await
8318 .unwrap();
8319
8320 let result = rt
8321 .link(
8322 &tok,
8323 new_entity.id,
8324 old_entity.id,
8325 EdgeRelation::Supersedes,
8326 1.0,
8327 None,
8328 )
8329 .await;
8330 assert!(
8331 result.is_ok(),
8332 "entity→entity Supersedes must succeed, got {result:?}"
8333 );
8334 }
8335
8336 #[tokio::test]
8337 async fn link_supersedes_note_to_entity_returns_invalid_input() {
8338 let rt = rt();
8339 let tok = NamespaceToken::local();
8340 let note = rt
8341 .create_note(&tok, "observation", None, "a note", Some(0.5), None, vec![])
8342 .await
8343 .unwrap();
8344 let entity = rt
8345 .create_entity(&tok, "concept", None, "SomeEntity", None, None, vec![])
8346 .await
8347 .unwrap();
8348
8349 let result = rt
8350 .link(
8351 &tok,
8352 note.id,
8353 entity.id,
8354 EdgeRelation::Supersedes,
8355 1.0,
8356 None,
8357 )
8358 .await;
8359 match result {
8360 Err(RuntimeError::InvalidInput(msg)) => {
8361 assert!(
8362 msg.contains("same substrate") || msg.contains("same-substrate"),
8363 "error must name the same-substrate rule: {msg}"
8364 );
8365 }
8366 other => panic!(
8367 "expected InvalidInput for note→entity Supersedes (cross-substrate), got {other:?}"
8368 ),
8369 }
8370 }
8371
8372 #[tokio::test]
8373 async fn link_supersedes_entity_to_note_returns_invalid_input() {
8374 let rt = rt();
8375 let tok = NamespaceToken::local();
8376 let entity = rt
8377 .create_entity(&tok, "concept", None, "SomeEntity", None, None, vec![])
8378 .await
8379 .unwrap();
8380 let note = rt
8381 .create_note(&tok, "observation", None, "a note", Some(0.5), None, vec![])
8382 .await
8383 .unwrap();
8384
8385 let result = rt
8386 .link(
8387 &tok,
8388 entity.id,
8389 note.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 entity→note Supersedes (cross-substrate), got {other:?}"
8404 ),
8405 }
8406 }
8407
8408 #[tokio::test]
8409 async fn link_supersedes_event_source_returns_invalid_input() {
8410 use khive_storage::Event;
8411 use khive_types::{EventKind, SubstrateKind};
8412
8413 let rt = rt();
8414 let tok = NamespaceToken::local();
8415 let ns = tok.namespace().as_str();
8416 let event = Event::new(
8417 ns,
8418 "test_verb",
8419 EventKind::Audit,
8420 SubstrateKind::Entity,
8421 "test_actor",
8422 );
8423 let event_id = event.id;
8424 rt.events(&tok).unwrap().append_event(event).await.unwrap();
8425
8426 let entity = rt
8427 .create_entity(&tok, "concept", None, "SomeEntity", None, None, vec![])
8428 .await
8429 .unwrap();
8430
8431 let result = rt
8432 .link(
8433 &tok,
8434 event_id,
8435 entity.id,
8436 EdgeRelation::Supersedes,
8437 1.0,
8438 None,
8439 )
8440 .await;
8441 match result {
8442 Err(RuntimeError::InvalidInput(msg)) => {
8443 assert!(msg.contains("event"), "error must mention 'event': {msg}");
8444 }
8445 other => {
8446 panic!("expected InvalidInput for event source with Supersedes, got {other:?}")
8447 }
8448 }
8449 }
8450
8451 #[tokio::test]
8452 async fn link_supersedes_event_target_returns_invalid_input() {
8453 use khive_storage::Event;
8454 use khive_types::{EventKind, SubstrateKind};
8455
8456 let rt = rt();
8457 let tok = NamespaceToken::local();
8458 let ns = tok.namespace().as_str();
8459 let event = Event::new(
8460 ns,
8461 "test_verb",
8462 EventKind::Audit,
8463 SubstrateKind::Entity,
8464 "test_actor",
8465 );
8466 let event_id = event.id;
8467 rt.events(&tok).unwrap().append_event(event).await.unwrap();
8468
8469 let entity = rt
8470 .create_entity(&tok, "concept", None, "SomeEntity", None, None, vec![])
8471 .await
8472 .unwrap();
8473
8474 let result = rt
8475 .link(
8476 &tok,
8477 entity.id,
8478 event_id,
8479 EdgeRelation::Supersedes,
8480 1.0,
8481 None,
8482 )
8483 .await;
8484 match result {
8485 Err(RuntimeError::InvalidInput(msg)) => {
8486 assert!(msg.contains("event"), "error must mention 'event': {msg}");
8487 }
8488 other => {
8489 panic!("expected InvalidInput for event target with Supersedes, got {other:?}")
8490 }
8491 }
8492 }
8493
8494 #[tokio::test]
8495 async fn link_supersedes_edge_source_returns_invalid_input() {
8496 let rt = rt();
8497 let tok = NamespaceToken::local();
8498 let a = rt
8499 .create_entity(&tok, "concept", None, "A", None, None, vec![])
8500 .await
8501 .unwrap();
8502 let b = rt
8503 .create_entity(&tok, "concept", None, "B", None, None, vec![])
8504 .await
8505 .unwrap();
8506 let edge = rt
8507 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
8508 .await
8509 .unwrap();
8510 let edge_uuid: Uuid = edge.id.into();
8511
8512 let result = rt
8513 .link(&tok, edge_uuid, a.id, EdgeRelation::Supersedes, 1.0, None)
8514 .await;
8515 match result {
8516 Err(RuntimeError::InvalidInput(msg)) => {
8517 assert!(msg.contains("source"), "error must name 'source': {msg}");
8518 }
8519 other => {
8520 panic!("expected InvalidInput for edge-uuid source with Supersedes, got {other:?}")
8521 }
8522 }
8523 }
8524
8525 #[tokio::test]
8526 async fn link_supersedes_edge_target_returns_invalid_input() {
8527 let rt = rt();
8528 let tok = NamespaceToken::local();
8529 let a = rt
8530 .create_entity(&tok, "concept", None, "A", None, None, vec![])
8531 .await
8532 .unwrap();
8533 let b = rt
8534 .create_entity(&tok, "concept", None, "B", None, None, vec![])
8535 .await
8536 .unwrap();
8537 let edge = rt
8538 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
8539 .await
8540 .unwrap();
8541 let edge_uuid: Uuid = edge.id.into();
8542
8543 let result = rt
8544 .link(&tok, a.id, edge_uuid, EdgeRelation::Supersedes, 1.0, None)
8545 .await;
8546 match result {
8547 Err(RuntimeError::InvalidInput(msg)) => {
8548 assert!(msg.contains("target"), "error must name 'target': {msg}");
8549 }
8550 other => {
8551 panic!("expected InvalidInput for edge-uuid target with Supersedes, got {other:?}")
8552 }
8553 }
8554 }
8555
8556 #[tokio::test]
8557 async fn link_supersedes_phantom_source_returns_not_found() {
8558 let rt = rt();
8559 let tok = NamespaceToken::local();
8560 let note = rt
8561 .create_note(
8562 &tok,
8563 "observation",
8564 None,
8565 "existing note",
8566 Some(0.5),
8567 None,
8568 vec![],
8569 )
8570 .await
8571 .unwrap();
8572 let phantom = Uuid::new_v4();
8573
8574 let result = rt
8575 .link(&tok, phantom, note.id, EdgeRelation::Supersedes, 1.0, None)
8576 .await;
8577 match result {
8578 Err(RuntimeError::NotFound(msg)) => {
8579 assert!(msg.contains("source"), "error must name 'source': {msg}");
8580 }
8581 other => panic!("expected NotFound for phantom source with Supersedes, got {other:?}"),
8582 }
8583 }
8584
8585 #[tokio::test]
8586 async fn link_supersedes_phantom_target_returns_not_found() {
8587 let rt = rt();
8588 let tok = NamespaceToken::local();
8589 let note = rt
8590 .create_note(
8591 &tok,
8592 "observation",
8593 None,
8594 "existing note",
8595 Some(0.5),
8596 None,
8597 vec![],
8598 )
8599 .await
8600 .unwrap();
8601 let phantom = Uuid::new_v4();
8602
8603 let result = rt
8604 .link(&tok, note.id, phantom, EdgeRelation::Supersedes, 1.0, None)
8605 .await;
8606 match result {
8607 Err(RuntimeError::NotFound(msg)) => {
8608 assert!(msg.contains("target"), "error must name 'target': {msg}");
8609 }
8610 other => panic!("expected NotFound for phantom target with Supersedes, got {other:?}"),
8611 }
8612 }
8613
8614 #[tokio::test]
8617 async fn link_supersedes_cross_namespace_source_succeeds() {
8618 let rt = rt();
8619 let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
8620 let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
8621 let note_a = rt
8622 .create_note(
8623 &ns_a,
8624 "observation",
8625 None,
8626 "note in ns-a",
8627 Some(0.5),
8628 None,
8629 vec![],
8630 )
8631 .await
8632 .unwrap();
8633 let note_b = rt
8634 .create_note(
8635 &ns_b,
8636 "observation",
8637 None,
8638 "note in ns-b",
8639 Some(0.5),
8640 None,
8641 vec![],
8642 )
8643 .await
8644 .unwrap();
8645
8646 let result = rt
8649 .link(
8650 &ns_a,
8651 note_b.id,
8652 note_a.id,
8653 EdgeRelation::Supersedes,
8654 1.0,
8655 None,
8656 )
8657 .await;
8658 assert!(
8659 result.is_ok(),
8660 "cross-namespace supersedes source must resolve (#631), got {result:?}"
8661 );
8662 }
8663
8664 #[tokio::test]
8666 async fn link_extends_note_source_still_returns_invalid_input() {
8667 let rt = rt();
8668 let tok = NamespaceToken::local();
8669 let note = rt
8670 .create_note(
8671 &tok,
8672 "observation",
8673 None,
8674 "a note that cannot be an extends source",
8675 Some(0.5),
8676 None,
8677 vec![],
8678 )
8679 .await
8680 .unwrap();
8681 let entity = rt
8682 .create_entity(&tok, "concept", None, "E", None, None, vec![])
8683 .await
8684 .unwrap();
8685
8686 let result = rt
8687 .link(&tok, note.id, entity.id, EdgeRelation::Extends, 1.0, None)
8688 .await;
8689 assert!(
8690 matches!(result, Err(RuntimeError::InvalidInput(_))),
8691 "note source with Extends must still return InvalidInput after this fix, got {result:?}"
8692 );
8693 }
8694
8695 #[tokio::test]
8697 async fn link_annotates_note_to_edge_still_succeeds_after_fix() {
8698 let rt = rt();
8699 let tok = NamespaceToken::local();
8700 let a = rt
8701 .create_entity(&tok, "concept", None, "A", None, None, vec![])
8702 .await
8703 .unwrap();
8704 let b = rt
8705 .create_entity(&tok, "concept", None, "B", None, None, vec![])
8706 .await
8707 .unwrap();
8708 let edge = rt
8709 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
8710 .await
8711 .unwrap();
8712 let edge_uuid: Uuid = edge.id.into();
8713
8714 let note = rt
8715 .create_note(
8716 &tok,
8717 "observation",
8718 None,
8719 "annotating an edge",
8720 Some(0.5),
8721 None,
8722 vec![],
8723 )
8724 .await
8725 .unwrap();
8726
8727 let result = rt
8728 .link(&tok, note.id, edge_uuid, EdgeRelation::Annotates, 1.0, None)
8729 .await;
8730 assert!(
8731 result.is_ok(),
8732 "note→edge Annotates must still succeed after supersedes fix, got {result:?}"
8733 );
8734 }
8735
8736 #[tokio::test]
8750 async fn create_note_multi_annotates_compensation_cleanup_restores_pristine_state() {
8751 let rt = rt();
8752 let tok = NamespaceToken::local();
8753 let t1 = rt
8754 .create_entity(&tok, "concept", None, "T1", None, None, vec![])
8755 .await
8756 .unwrap();
8757
8758 let note = rt
8761 .create_note(
8762 &tok,
8763 "observation",
8764 None,
8765 "partial note",
8766 Some(0.5),
8767 None,
8768 vec![t1.id],
8769 )
8770 .await
8771 .unwrap();
8772
8773 let before_notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
8775 assert_eq!(before_notes.len(), 1, "note must be present before cleanup");
8776 let before_edges = rt
8777 .neighbors(
8778 &tok,
8779 note.id,
8780 Direction::Out,
8781 None,
8782 Some(vec![EdgeRelation::Annotates]),
8783 )
8784 .await
8785 .unwrap();
8786 assert_eq!(
8787 before_edges.len(),
8788 1,
8789 "one annotates edge must exist before cleanup"
8790 );
8791 let edge_id: Uuid = before_edges[0].edge_id;
8792
8793 rt.delete_edge(&tok, edge_id, true).await.unwrap();
8795 rt.delete_note(&tok, note.id, true )
8796 .await
8797 .unwrap();
8798
8799 let after_notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
8801 assert!(
8802 after_notes.is_empty(),
8803 "compensation must remove the note row; got {after_notes:?}"
8804 );
8805 let search_hits = rt
8806 .search_notes(&tok, "partial note", None, 10, None, false, &[], None)
8807 .await
8808 .unwrap();
8809 assert!(
8810 search_hits.is_empty(),
8811 "compensation must clean the FTS index; got {search_hits:?}"
8812 );
8813 let after_edges = rt
8814 .neighbors(&tok, note.id, Direction::Out, None, None)
8815 .await
8816 .unwrap();
8817 assert!(
8818 after_edges.is_empty(),
8819 "compensation must remove all partial edges; got {after_edges:?}"
8820 );
8821 }
8822
8823 #[tokio::test]
8831 async fn annotated_entity_hard_delete_cascades_annotate_edge() {
8832 let rt = rt();
8833 let tok = NamespaceToken::local();
8834 let entity = rt
8835 .create_entity(&tok, "concept", None, "E", None, None, vec![])
8836 .await
8837 .unwrap();
8838 let note = rt
8839 .create_note(
8840 &tok,
8841 "observation",
8842 None,
8843 "note about entity",
8844 Some(0.5),
8845 None,
8846 vec![entity.id],
8847 )
8848 .await
8849 .unwrap();
8850
8851 let before = rt
8853 .neighbors(
8854 &tok,
8855 note.id,
8856 Direction::Out,
8857 None,
8858 Some(vec![EdgeRelation::Annotates]),
8859 )
8860 .await
8861 .unwrap();
8862 assert_eq!(
8863 before.len(),
8864 1,
8865 "annotates edge must exist before entity delete"
8866 );
8867
8868 let deleted = rt.delete_entity(&tok, entity.id, true).await.unwrap();
8870 assert!(deleted, "entity hard delete must return true");
8871
8872 let after = rt
8874 .neighbors(
8875 &tok,
8876 note.id,
8877 Direction::Out,
8878 None,
8879 Some(vec![EdgeRelation::Annotates]),
8880 )
8881 .await
8882 .unwrap();
8883 assert!(
8884 after.is_empty(),
8885 "annotates edge must be cascaded on entity hard delete; got {after:?}"
8886 );
8887 }
8888
8889 #[tokio::test]
8890 async fn annotated_note_hard_delete_cascades_annotate_edge() {
8891 let rt = rt();
8892 let tok = NamespaceToken::local();
8893 let note_target = rt
8895 .create_note(
8896 &tok,
8897 "observation",
8898 None,
8899 "target note",
8900 Some(0.5),
8901 None,
8902 vec![],
8903 )
8904 .await
8905 .unwrap();
8906 let note_source = rt
8908 .create_note(
8909 &tok,
8910 "insight",
8911 None,
8912 "annotation",
8913 Some(0.5),
8914 None,
8915 vec![note_target.id],
8916 )
8917 .await
8918 .unwrap();
8919
8920 let before = rt
8921 .neighbors(
8922 &tok,
8923 note_source.id,
8924 Direction::Out,
8925 None,
8926 Some(vec![EdgeRelation::Annotates]),
8927 )
8928 .await
8929 .unwrap();
8930 assert_eq!(
8931 before.len(),
8932 1,
8933 "annotates edge must exist before note delete"
8934 );
8935
8936 let deleted = rt.delete_note(&tok, note_target.id, true).await.unwrap();
8938 assert!(deleted, "note hard delete must return true");
8939
8940 let after = rt
8942 .neighbors(
8943 &tok,
8944 note_source.id,
8945 Direction::Out,
8946 None,
8947 Some(vec![EdgeRelation::Annotates]),
8948 )
8949 .await
8950 .unwrap();
8951 assert!(
8952 after.is_empty(),
8953 "annotates edge must be cascaded on note-target hard delete; got {after:?}"
8954 );
8955 }
8956
8957 #[tokio::test]
8958 async fn annotated_edge_delete_cascades_annotate_edge() {
8959 let rt = rt();
8960 let tok = NamespaceToken::local();
8961 let a = rt
8962 .create_entity(&tok, "concept", None, "A", None, None, vec![])
8963 .await
8964 .unwrap();
8965 let b = rt
8966 .create_entity(&tok, "concept", None, "B", None, None, vec![])
8967 .await
8968 .unwrap();
8969 let base_edge = rt
8971 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
8972 .await
8973 .unwrap();
8974 let base_edge_uuid: Uuid = base_edge.id.into();
8975
8976 let note = rt
8978 .create_note(
8979 &tok,
8980 "observation",
8981 None,
8982 "note about edge",
8983 Some(0.5),
8984 None,
8985 vec![base_edge_uuid],
8986 )
8987 .await
8988 .unwrap();
8989
8990 let before = rt
8991 .neighbors(
8992 &tok,
8993 note.id,
8994 Direction::Out,
8995 None,
8996 Some(vec![EdgeRelation::Annotates]),
8997 )
8998 .await
8999 .unwrap();
9000 assert_eq!(
9001 before.len(),
9002 1,
9003 "annotates edge must exist before base edge delete"
9004 );
9005
9006 let deleted = rt.delete_edge(&tok, base_edge_uuid, true).await.unwrap();
9008 assert!(deleted, "edge delete must return true");
9009
9010 let after = rt
9012 .neighbors(
9013 &tok,
9014 note.id,
9015 Direction::Out,
9016 None,
9017 Some(vec![EdgeRelation::Annotates]),
9018 )
9019 .await
9020 .unwrap();
9021 assert!(
9022 after.is_empty(),
9023 "annotates edge must be cascaded on base edge delete; got {after:?}"
9024 );
9025 }
9026
9027 #[tokio::test]
9028 async fn mixed_multi_annotates_partial_target_hard_delete_leaves_remaining_edges() {
9029 let rt = rt();
9030 let tok = NamespaceToken::local();
9031 let t1 = rt
9032 .create_entity(&tok, "concept", None, "T1", None, None, vec![])
9033 .await
9034 .unwrap();
9035 let t2 = rt
9036 .create_entity(&tok, "concept", None, "T2", None, None, vec![])
9037 .await
9038 .unwrap();
9039
9040 let note = rt
9042 .create_note(
9043 &tok,
9044 "observation",
9045 None,
9046 "multi-target note",
9047 Some(0.5),
9048 None,
9049 vec![t1.id, t2.id],
9050 )
9051 .await
9052 .unwrap();
9053
9054 let before = rt
9055 .neighbors(
9056 &tok,
9057 note.id,
9058 Direction::Out,
9059 None,
9060 Some(vec![EdgeRelation::Annotates]),
9061 )
9062 .await
9063 .unwrap();
9064 assert_eq!(
9065 before.len(),
9066 2,
9067 "must have 2 annotates edges before any delete"
9068 );
9069
9070 rt.delete_entity(&tok, t1.id, true).await.unwrap();
9072
9073 let after = rt
9075 .neighbors(
9076 &tok,
9077 note.id,
9078 Direction::Out,
9079 None,
9080 Some(vec![EdgeRelation::Annotates]),
9081 )
9082 .await
9083 .unwrap();
9084 assert_eq!(
9085 after.len(),
9086 1,
9087 "only the edge to t1 must be cascaded; t2 edge must remain"
9088 );
9089 assert_eq!(
9090 after[0].node_id, t2.id,
9091 "remaining annotates edge must point to t2"
9092 );
9093 }
9094
9095 #[tokio::test]
9096 async fn annotated_note_soft_delete_preserves_annotate_edge() {
9097 let rt = rt();
9098 let tok = NamespaceToken::local();
9099 let note_target = rt
9100 .create_note(&tok, "observation", None, "target", Some(0.5), None, vec![])
9101 .await
9102 .unwrap();
9103 let note_source = rt
9104 .create_note(
9105 &tok,
9106 "insight",
9107 None,
9108 "annotation",
9109 Some(0.5),
9110 None,
9111 vec![note_target.id],
9112 )
9113 .await
9114 .unwrap();
9115
9116 let before = rt
9117 .neighbors(
9118 &tok,
9119 note_source.id,
9120 Direction::Out,
9121 None,
9122 Some(vec![EdgeRelation::Annotates]),
9123 )
9124 .await
9125 .unwrap();
9126 assert_eq!(before.len(), 1);
9127 let edge_id = before[0].edge_id;
9128
9129 let deleted = rt.delete_note(&tok, note_target.id, false).await.unwrap();
9131 assert!(deleted, "soft delete must return true");
9132
9133 let edge_after = rt.get_edge(&tok, edge_id).await.unwrap();
9141 assert!(
9142 edge_after.is_some(),
9143 "soft delete must NOT cascade edges; get_edge returned None"
9144 );
9145
9146 let after = rt
9147 .neighbors(
9148 &tok,
9149 note_source.id,
9150 Direction::Out,
9151 None,
9152 Some(vec![EdgeRelation::Annotates]),
9153 )
9154 .await
9155 .unwrap();
9156 assert_eq!(
9157 after.len(),
9158 0,
9159 "#748: neighbors() must screen out the soft-deleted note target; got {after:?}"
9160 );
9161 }
9162
9163 #[tokio::test]
9170 async fn delete_edge_non_edge_uuid_has_no_side_effects() {
9171 let rt = rt();
9172 let tok = NamespaceToken::local();
9173
9174 let entity = rt
9176 .create_entity(&tok, "concept", None, "Target", None, None, vec![])
9177 .await
9178 .unwrap();
9179 let note = rt
9180 .create_note(
9181 &tok,
9182 "observation",
9183 None,
9184 "annotates the entity",
9185 Some(0.5),
9186 None,
9187 vec![entity.id],
9188 )
9189 .await
9190 .unwrap();
9191
9192 let before = rt
9194 .neighbors(
9195 &tok,
9196 note.id,
9197 Direction::Out,
9198 None,
9199 Some(vec![EdgeRelation::Annotates]),
9200 )
9201 .await
9202 .unwrap();
9203 assert_eq!(before.len(), 1, "annotates edge must exist before test");
9204 let annotates_edge_id: Uuid = before[0].edge_id;
9205
9206 let result = rt.delete_edge(&tok, entity.id, true).await;
9208 assert!(
9209 result.is_ok(),
9210 "delete_edge must not error on a non-edge UUID"
9211 );
9212 assert!(
9213 !result.unwrap(),
9214 "delete_edge must return false for a non-edge UUID"
9215 );
9216
9217 let after = rt
9219 .neighbors(
9220 &tok,
9221 note.id,
9222 Direction::Out,
9223 None,
9224 Some(vec![EdgeRelation::Annotates]),
9225 )
9226 .await
9227 .unwrap();
9228 assert_eq!(
9229 after.len(),
9230 1,
9231 "delete_edge with a non-edge UUID must not touch inbound annotates edges"
9232 );
9233 assert_eq!(
9234 after[0].edge_id, annotates_edge_id,
9235 "the original annotates edge must be unchanged"
9236 );
9237 }
9238
9239 #[tokio::test]
9250 async fn create_note_multi_annotates_second_link_failure_rolls_back_partial_write() {
9251 let rt = rt();
9252 let tok = NamespaceToken::local();
9253 let t1 = rt
9254 .create_entity(&tok, "concept", None, "T1", None, None, vec![])
9255 .await
9256 .unwrap();
9257 let t2 = rt
9258 .create_entity(&tok, "concept", None, "T2", None, None, vec![])
9259 .await
9260 .unwrap();
9261
9262 LINK_FAIL_AFTER.with(|cell| cell.set(2));
9264
9265 let result = rt
9266 .create_note(
9267 &tok,
9268 "observation",
9269 None,
9270 "rollback target",
9271 Some(0.5),
9272 None,
9273 vec![t1.id, t2.id],
9274 )
9275 .await;
9276
9277 assert!(
9279 result.is_err(),
9280 "create_note must propagate the injected link failure"
9281 );
9282 let err_msg = result.unwrap_err().to_string();
9283 assert!(
9284 err_msg.contains("injected link failure"),
9285 "error must carry injection message; got: {err_msg}"
9286 );
9287
9288 let notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
9290 assert!(
9291 notes.is_empty(),
9292 "compensation must remove the note row; got {notes:?}"
9293 );
9294
9295 let hits = rt
9297 .search_notes(&tok, "rollback target", None, 10, None, false, &[], None)
9298 .await
9299 .unwrap();
9300 assert!(
9301 hits.is_empty(),
9302 "compensation must clean FTS index; got {hits:?}"
9303 );
9304
9305 let edges_from_t1 = rt
9307 .neighbors(
9308 &tok,
9309 t1.id,
9310 Direction::In,
9311 None,
9312 Some(vec![EdgeRelation::Annotates]),
9313 )
9314 .await
9315 .unwrap();
9316 let edges_from_t2 = rt
9317 .neighbors(
9318 &tok,
9319 t2.id,
9320 Direction::In,
9321 None,
9322 Some(vec![EdgeRelation::Annotates]),
9323 )
9324 .await
9325 .unwrap();
9326 assert!(
9327 edges_from_t1.is_empty(),
9328 "compensation must delete the first annotates edge; got {edges_from_t1:?}"
9329 );
9330 assert!(
9331 edges_from_t2.is_empty(),
9332 "no second annotates edge must exist; got {edges_from_t2:?}"
9333 );
9334 }
9335
9336 #[tokio::test]
9340 async fn create_note_fts_failure_rolls_back_note_row() {
9341 let rt = rt();
9342 let ns = Namespace::parse("fault-fts-rollback").unwrap();
9348 let tok = NamespaceToken::for_namespace(ns.clone());
9349
9350 arm_fts_fail(ns.as_str());
9351
9352 let result = rt
9353 .create_note(
9354 &tok,
9355 "observation",
9356 None,
9357 "fts-fail rollback target",
9358 None,
9359 None,
9360 vec![],
9361 )
9362 .await;
9363
9364 assert!(
9365 result.is_err(),
9366 "create_note must propagate the injected FTS failure"
9367 );
9368 let err_msg = result.unwrap_err().to_string();
9369 assert!(
9370 err_msg.contains("injected FTS failure"),
9371 "error must carry injection message; got: {err_msg}"
9372 );
9373
9374 let notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
9376 assert!(
9377 notes.is_empty(),
9378 "compensation must remove the note row after FTS failure; got {notes:?}"
9379 );
9380 }
9381
9382 #[tokio::test]
9390 async fn create_note_vector_failure_rolls_back_note_row_and_fts() {
9391 const MODEL: &str = "test-vec-inject";
9392 const DIMS: usize = 4;
9393
9394 let rt = KhiveRuntime::memory().unwrap();
9395 let (provider, _counter) = ConstVecProvider::new(MODEL, DIMS);
9396 rt.register_embedder(provider);
9397
9398 let ns = Namespace::parse("fault-vec-rollback").unwrap();
9399 let tok = NamespaceToken::for_namespace(ns.clone());
9400
9401 arm_vector_fail(ns.as_str());
9402
9403 let result = rt
9404 .create_note(
9405 &tok,
9406 "observation",
9407 None,
9408 "vec-fail rollback target",
9409 None,
9410 None,
9411 vec![],
9412 )
9413 .await;
9414
9415 assert!(
9416 result.is_err(),
9417 "create_note must propagate the injected vector failure"
9418 );
9419 let err_msg = result.unwrap_err().to_string();
9420 assert!(
9421 err_msg.contains("injected vector failure"),
9422 "error must carry injection message; got: {err_msg}"
9423 );
9424
9425 let notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
9427 assert!(
9428 notes.is_empty(),
9429 "compensation must remove note row after vector failure; got {notes:?}"
9430 );
9431 }
9432
9433 #[tokio::test]
9439 async fn create_note_with_embedding_content_fts_failure_rolls_back_note_row() {
9440 let rt = rt();
9441 let ns = Namespace::parse("fault-fts-rollback-embedding-content").unwrap();
9442 let tok = NamespaceToken::for_namespace(ns.clone());
9443
9444 arm_fts_fail(ns.as_str());
9445
9446 let full = "fts-fail rollback target with an embedding-content override";
9447 let head = &full[.."fts-fail rollback target".len()];
9448 let result = rt
9449 .create_note_with_embedding_content(
9450 &tok,
9451 "observation",
9452 None,
9453 full,
9454 Some(head),
9455 None,
9456 None,
9457 vec![],
9458 )
9459 .await;
9460
9461 assert!(
9462 result.is_err(),
9463 "create_note_with_embedding_content must propagate the injected FTS failure"
9464 );
9465 let err_msg = result.unwrap_err().to_string();
9466 assert!(
9467 err_msg.contains("injected FTS failure"),
9468 "error must carry injection message; got: {err_msg}"
9469 );
9470
9471 let notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
9475 assert!(
9476 notes.is_empty(),
9477 "compensation must remove the note row after FTS failure; got {notes:?}"
9478 );
9479 }
9480
9481 #[tokio::test]
9482 async fn create_note_with_embedding_content_vector_failure_rolls_back_note_row_and_fts() {
9483 const MODEL: &str = "test-vec-inject-embedding-content";
9484 const DIMS: usize = 4;
9485
9486 let rt = KhiveRuntime::memory().unwrap();
9487 let (provider, _counter) = ConstVecProvider::new(MODEL, DIMS);
9488 rt.register_embedder(provider);
9489
9490 let ns = Namespace::parse("fault-vec-rollback-embedding-content").unwrap();
9491 let tok = NamespaceToken::for_namespace(ns.clone());
9492
9493 arm_vector_fail(ns.as_str());
9494
9495 let full = "vec-fail rollback target with an embedding-content override";
9496 let head = &full[.."vec-fail rollback target".len()];
9497 let result = rt
9498 .create_note_with_embedding_content(
9499 &tok,
9500 "observation",
9501 None,
9502 full,
9503 Some(head),
9504 None,
9505 None,
9506 vec![],
9507 )
9508 .await;
9509
9510 assert!(
9511 result.is_err(),
9512 "create_note_with_embedding_content must propagate the injected vector failure"
9513 );
9514 let err_msg = result.unwrap_err().to_string();
9515 assert!(
9516 err_msg.contains("injected vector failure"),
9517 "error must carry injection message; got: {err_msg}"
9518 );
9519
9520 let notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
9525 assert!(
9526 notes.is_empty(),
9527 "compensation must remove note row after vector failure; got {notes:?}"
9528 );
9529 }
9530
9531 #[tokio::test]
9534 async fn soft_delete_entity_removes_indexes() {
9535 let rt = rt();
9536 let tok = NamespaceToken::local();
9537 let entity = rt
9538 .create_entity(
9539 &tok,
9540 "concept",
9541 None,
9542 "QuantumEntanglement",
9543 Some("unique FTS term xzqjwv for soft delete test"),
9544 None,
9545 vec![],
9546 )
9547 .await
9548 .unwrap();
9549
9550 let ns = tok.namespace().as_str().to_string();
9551
9552 let before = rt
9553 .text(&tok)
9554 .unwrap()
9555 .search(TextSearchRequest {
9556 query: "xzqjwv".to_string(),
9557 mode: TextQueryMode::Plain,
9558 filter: Some(TextFilter {
9559 namespaces: vec![ns.clone()],
9560 ..Default::default()
9561 }),
9562 top_k: 10,
9563 snippet_chars: 100,
9564 })
9565 .await
9566 .unwrap();
9567 assert!(
9568 before.iter().any(|h| h.subject_id == entity.id),
9569 "entity must be in FTS before soft-delete"
9570 );
9571
9572 let deleted = rt.delete_entity(&tok, entity.id, false).await.unwrap();
9573 assert!(deleted, "soft delete must return true");
9574
9575 let after = rt
9576 .text(&tok)
9577 .unwrap()
9578 .search(TextSearchRequest {
9579 query: "xzqjwv".to_string(),
9580 mode: TextQueryMode::Plain,
9581 filter: Some(TextFilter {
9582 namespaces: vec![ns],
9583 ..Default::default()
9584 }),
9585 top_k: 10,
9586 snippet_chars: 100,
9587 })
9588 .await
9589 .unwrap();
9590 assert!(
9591 after.iter().all(|h| h.subject_id != entity.id),
9592 "soft-deleted entity must be removed from FTS index"
9593 );
9594 }
9595
9596 #[tokio::test]
9597 async fn soft_delete_note_removes_indexes() {
9598 let rt = rt();
9599 let tok = NamespaceToken::local();
9600 let note = rt
9601 .create_note(
9602 &tok,
9603 "observation",
9604 None,
9605 "SpectralDecomposition unique term yvwkqz for soft delete test",
9606 Some(0.7),
9607 None,
9608 vec![],
9609 )
9610 .await
9611 .unwrap();
9612
9613 let before = rt
9614 .search_notes(&tok, "yvwkqz", None, 10, None, false, &[], None)
9615 .await
9616 .unwrap();
9617 assert!(
9618 before.iter().any(|h| h.note_id == note.id),
9619 "note must be in FTS before soft-delete"
9620 );
9621
9622 let deleted = rt.delete_note(&tok, note.id, false).await.unwrap();
9623 assert!(deleted, "soft delete must return true");
9624
9625 let after = rt
9626 .search_notes(&tok, "yvwkqz", None, 10, None, false, &[], None)
9627 .await
9628 .unwrap();
9629 assert!(
9630 after.iter().all(|h| h.note_id != note.id),
9631 "soft-deleted note must be removed from FTS index"
9632 );
9633 }
9634
9635 #[tokio::test]
9638 async fn link_extends_document_to_document_returns_invalid_input() {
9639 let rt = rt();
9640 let tok = NamespaceToken::local();
9641 let d1 = rt
9642 .create_entity(&tok, "document", None, "DocA", None, None, vec![])
9643 .await
9644 .unwrap();
9645 let d2 = rt
9646 .create_entity(&tok, "document", None, "DocB", None, None, vec![])
9647 .await
9648 .unwrap();
9649 let result = rt
9650 .link(&tok, d1.id, d2.id, EdgeRelation::Extends, 1.0, None)
9651 .await;
9652 assert!(
9653 result.is_err(),
9654 "F010: document->document Extends must be rejected by the base allowlist; \
9655 current generic entity fallthrough incorrectly accepts it"
9656 );
9657 }
9658
9659 #[tokio::test]
9661 async fn link_extends_concept_to_concept_succeeds() {
9662 let rt = rt();
9663 let tok = NamespaceToken::local();
9664 let a = rt
9665 .create_entity(&tok, "concept", None, "CA", None, None, vec![])
9666 .await
9667 .unwrap();
9668 let b = rt
9669 .create_entity(&tok, "concept", None, "CB", None, None, vec![])
9670 .await
9671 .unwrap();
9672 let result = rt
9673 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
9674 .await;
9675 assert!(
9676 result.is_ok(),
9677 "F010: concept->concept Extends must be allowed (base allowlist)"
9678 );
9679 }
9680
9681 #[tokio::test]
9683 async fn link_symmetric_relation_canonicalizes_endpoint_order() {
9684 use khive_storage::EdgeFilter;
9685 let rt = rt();
9686 let tok = NamespaceToken::local();
9687 let a = rt
9688 .create_entity(&tok, "concept", None, "ConceptP", None, None, vec![])
9689 .await
9690 .unwrap();
9691 let b = rt
9692 .create_entity(&tok, "concept", None, "ConceptQ", None, None, vec![])
9693 .await
9694 .unwrap();
9695 rt.link(&tok, a.id, b.id, EdgeRelation::CompetesWith, 1.0, None)
9697 .await
9698 .unwrap();
9699 rt.link(&tok, b.id, a.id, EdgeRelation::CompetesWith, 1.0, None)
9700 .await
9701 .unwrap();
9702 let count = rt
9703 .graph(&tok)
9704 .unwrap()
9705 .count_edges(EdgeFilter::default())
9706 .await
9707 .unwrap();
9708 assert_eq!(
9709 count,
9710 1,
9711 "F012: CompetesWith is symmetric; A->B and B->A must deduplicate to one canonical row; \
9712 found {count} rows (canonicalization not yet implemented)"
9713 );
9714 }
9715
9716 #[tokio::test]
9718 async fn f010_supersedes_document_to_document_allowed() {
9719 let rt = rt();
9720 let tok = NamespaceToken::local();
9721 let a = rt
9722 .create_entity(&tok, "document", None, "DocA", None, None, vec![])
9723 .await
9724 .unwrap();
9725 let b = rt
9726 .create_entity(&tok, "document", None, "DocB", None, None, vec![])
9727 .await
9728 .unwrap();
9729 let result = rt
9730 .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
9731 .await;
9732 assert!(
9733 result.is_ok(),
9734 "document->document Supersedes must be allowed (allowlist), got {result:?}"
9735 );
9736 }
9737
9738 #[tokio::test]
9739 async fn f010_supersedes_artifact_to_artifact_allowed() {
9740 let rt = rt();
9741 let tok = NamespaceToken::local();
9742 let a = rt
9743 .create_entity(&tok, "artifact", None, "ArtA", None, None, vec![])
9744 .await
9745 .unwrap();
9746 let b = rt
9747 .create_entity(&tok, "artifact", None, "ArtB", None, None, vec![])
9748 .await
9749 .unwrap();
9750 let result = rt
9751 .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
9752 .await;
9753 assert!(
9754 result.is_ok(),
9755 "artifact->artifact Supersedes must be allowed (allowlist), got {result:?}"
9756 );
9757 }
9758
9759 #[tokio::test]
9760 async fn f010_supersedes_service_to_service_allowed() {
9761 let rt = rt();
9762 let tok = NamespaceToken::local();
9763 let a = rt
9764 .create_entity(&tok, "service", None, "SvcA", None, None, vec![])
9765 .await
9766 .unwrap();
9767 let b = rt
9768 .create_entity(&tok, "service", None, "SvcB", None, None, vec![])
9769 .await
9770 .unwrap();
9771 let result = rt
9772 .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
9773 .await;
9774 assert!(
9775 result.is_ok(),
9776 "service->service Supersedes must be allowed (allowlist), got {result:?}"
9777 );
9778 }
9779
9780 #[tokio::test]
9781 async fn f010_supersedes_dataset_to_dataset_allowed() {
9782 let rt = rt();
9783 let tok = NamespaceToken::local();
9784 let a = rt
9785 .create_entity(&tok, "dataset", None, "DataA", None, None, vec![])
9786 .await
9787 .unwrap();
9788 let b = rt
9789 .create_entity(&tok, "dataset", None, "DataB", None, None, vec![])
9790 .await
9791 .unwrap();
9792 let result = rt
9793 .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
9794 .await;
9795 assert!(
9796 result.is_ok(),
9797 "dataset->dataset Supersedes must be allowed (allowlist), got {result:?}"
9798 );
9799 }
9800
9801 #[tokio::test]
9803 async fn f010_supersedes_project_to_project_rejected() {
9804 let rt = rt();
9805 let tok = NamespaceToken::local();
9806 let a = rt
9807 .create_entity(&tok, "project", None, "ProjA", None, None, vec![])
9808 .await
9809 .unwrap();
9810 let b = rt
9811 .create_entity(&tok, "project", None, "ProjB", None, None, vec![])
9812 .await
9813 .unwrap();
9814 let result = rt
9815 .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
9816 .await;
9817 assert!(
9818 matches!(result, Err(RuntimeError::InvalidInput(_))),
9819 "project->project Supersedes must be rejected (not in allowlist), got {result:?}"
9820 );
9821 }
9822
9823 #[tokio::test]
9824 async fn f010_supersedes_person_to_person_rejected() {
9825 let rt = rt();
9826 let tok = NamespaceToken::local();
9827 let a = rt
9828 .create_entity(&tok, "person", None, "Alice", None, None, vec![])
9829 .await
9830 .unwrap();
9831 let b = rt
9832 .create_entity(&tok, "person", None, "Bob", None, None, vec![])
9833 .await
9834 .unwrap();
9835 let result = rt
9836 .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
9837 .await;
9838 assert!(
9839 matches!(result, Err(RuntimeError::InvalidInput(_))),
9840 "person->person Supersedes must be rejected (not in allowlist), got {result:?}"
9841 );
9842 }
9843
9844 #[tokio::test]
9845 async fn f010_supersedes_org_to_org_rejected() {
9846 let rt = rt();
9847 let tok = NamespaceToken::local();
9848 let a = rt
9849 .create_entity(&tok, "org", None, "OrgA", None, None, vec![])
9850 .await
9851 .unwrap();
9852 let b = rt
9853 .create_entity(&tok, "org", None, "OrgB", None, None, vec![])
9854 .await
9855 .unwrap();
9856 let result = rt
9857 .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
9858 .await;
9859 assert!(
9860 matches!(result, Err(RuntimeError::InvalidInput(_))),
9861 "org->org Supersedes must be rejected (not in allowlist), got {result:?}"
9862 );
9863 }
9864
9865 #[tokio::test]
9867 async fn f010_supersedes_same_kind_entity_allowed() {
9868 let rt = rt();
9869 let tok = NamespaceToken::local();
9870 let a = rt
9871 .create_entity(&tok, "concept", None, "OldV", None, None, vec![])
9872 .await
9873 .unwrap();
9874 let b = rt
9875 .create_entity(&tok, "concept", None, "NewV", None, None, vec![])
9876 .await
9877 .unwrap();
9878 let result = rt
9879 .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
9880 .await;
9881 assert!(
9882 result.is_ok(),
9883 "concept->concept Supersedes must be allowed by the base allowlist, got {result:?}"
9884 );
9885 }
9886
9887 #[tokio::test]
9891 async fn f161_link_always_writes_null_target_backend() {
9892 let rt = rt();
9893 let tok = NamespaceToken::local();
9894 let a = rt
9895 .create_entity(&tok, "concept", None, "A", None, None, vec![])
9896 .await
9897 .unwrap();
9898 let b = rt
9899 .create_entity(&tok, "concept", None, "B", None, None, vec![])
9900 .await
9901 .unwrap();
9902 let edge = rt
9903 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
9904 .await
9905 .unwrap();
9906 assert!(
9907 edge.target_backend.is_none(),
9908 "F161: target_backend must be None for locally-routed edges; got {:?}",
9909 edge.target_backend
9910 );
9911 }
9912
9913 #[tokio::test]
9915 async fn f161_link_many_always_writes_null_target_backend() {
9916 let rt = rt();
9917 let tok = NamespaceToken::local();
9918 let a = rt
9919 .create_entity(&tok, "concept", None, "A", None, None, vec![])
9920 .await
9921 .unwrap();
9922 let b = rt
9923 .create_entity(&tok, "concept", None, "B", None, None, vec![])
9924 .await
9925 .unwrap();
9926 let c = rt
9927 .create_entity(&tok, "concept", None, "C", None, None, vec![])
9928 .await
9929 .unwrap();
9930 let specs = vec![
9931 LinkSpec {
9932 namespace: None,
9933 source_id: a.id,
9934 target_id: b.id,
9935 relation: EdgeRelation::Extends,
9936 weight: 1.0,
9937 metadata: None,
9938 },
9939 LinkSpec {
9940 namespace: None,
9941 source_id: a.id,
9942 target_id: c.id,
9943 relation: EdgeRelation::Enables,
9944 weight: 1.0,
9945 metadata: None,
9946 },
9947 ];
9948 let edges = rt.link_many(&tok, specs).await.unwrap();
9949 for edge in &edges {
9950 assert!(
9951 edge.target_backend.is_none(),
9952 "F161: target_backend must be None for locally-routed edges in link_many; got {:?}",
9953 edge.target_backend
9954 );
9955 }
9956 }
9957
9958 #[tokio::test]
9961 async fn f012_symmetric_neighbors_visible_from_both_endpoints() {
9962 let rt = rt();
9963 let tok = NamespaceToken::local();
9964 let a = rt
9965 .create_entity(&tok, "concept", None, "A", None, None, vec![])
9966 .await
9967 .unwrap();
9968 let b = rt
9969 .create_entity(&tok, "concept", None, "B", None, None, vec![])
9970 .await
9971 .unwrap();
9972 rt.link(&tok, a.id, b.id, EdgeRelation::CompetesWith, 1.0, None)
9974 .await
9975 .unwrap();
9976 let from_a = rt
9978 .neighbors(
9979 &tok,
9980 a.id,
9981 Direction::Out,
9982 None,
9983 Some(vec![EdgeRelation::CompetesWith]),
9984 )
9985 .await
9986 .unwrap();
9987 let from_b = rt
9988 .neighbors(
9989 &tok,
9990 b.id,
9991 Direction::Out,
9992 None,
9993 Some(vec![EdgeRelation::CompetesWith]),
9994 )
9995 .await
9996 .unwrap();
9997 assert_eq!(
9998 from_a.len(),
9999 1,
10000 "node A must see competes_with neighbor from Direction::Out (F012); got {from_a:?}"
10001 );
10002 assert_eq!(
10003 from_b.len(),
10004 1,
10005 "node B must see competes_with neighbor from Direction::Out (F012); got {from_b:?}"
10006 );
10007 }
10008
10009 #[tokio::test]
10011 async fn f010_supersedes_cross_kind_entity_rejected() {
10012 let rt = rt();
10013 let tok = NamespaceToken::local();
10014 let concept = rt
10015 .create_entity(&tok, "concept", None, "MyConcept", None, None, vec![])
10016 .await
10017 .unwrap();
10018 let doc = rt
10019 .create_entity(&tok, "document", None, "MyDoc", None, None, vec![])
10020 .await
10021 .unwrap();
10022 let result = rt
10023 .link(
10024 &tok,
10025 concept.id,
10026 doc.id,
10027 EdgeRelation::Supersedes,
10028 1.0,
10029 None,
10030 )
10031 .await;
10032 assert!(
10033 matches!(result, Err(RuntimeError::InvalidInput(_))),
10034 "concept->document Supersedes must be rejected by the base allowlist, got {result:?}"
10035 );
10036 }
10037
10038 #[tokio::test]
10041 async fn delete_note_cross_namespace_succeeds() {
10042 let rt = rt();
10043 let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
10044 let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
10045 let note = rt
10046 .create_note(
10047 &ns_a,
10048 "observation",
10049 None,
10050 "note in ns-a",
10051 Some(0.8),
10052 None,
10053 vec![],
10054 )
10055 .await
10056 .unwrap();
10057
10058 let result = rt.delete_note(&ns_b, note.id, false).await;
10060 assert!(
10061 result.unwrap(),
10062 "cross-namespace delete_note (soft) must return Ok(true)"
10063 );
10064
10065 let note_store = rt.notes(&ns_a).unwrap();
10067 let gone = note_store.get_note(note.id).await.unwrap();
10068 assert!(
10069 gone.is_none(),
10070 "note must be soft-deleted in its home namespace after cross-ns delete"
10071 );
10072
10073 let note2 = rt
10075 .create_note(
10076 &ns_a,
10077 "observation",
10078 None,
10079 "note2 in ns-a",
10080 Some(0.5),
10081 None,
10082 vec![],
10083 )
10084 .await
10085 .unwrap();
10086 let hard_result = rt.delete_note(&ns_b, note2.id, true).await;
10087 assert!(
10088 hard_result.unwrap(),
10089 "cross-namespace hard delete_note must return Ok(true)"
10090 );
10091 let gone2 = rt
10092 .get_note_including_deleted(&ns_a, note2.id)
10093 .await
10094 .unwrap();
10095 assert!(
10096 gone2.is_none(),
10097 "hard-deleted note must not appear even in including_deleted query"
10098 );
10099 }
10100
10101 #[tokio::test]
10109 async fn link_many_overlapping_triple_returns_persisted_ids() {
10110 let rt = rt();
10111 let tok = NamespaceToken::local();
10112 let a = rt
10113 .create_entity(&tok, "concept", None, "A", None, None, vec![])
10114 .await
10115 .unwrap();
10116 let b = rt
10117 .create_entity(&tok, "concept", None, "B", None, None, vec![])
10118 .await
10119 .unwrap();
10120
10121 let spec = || LinkSpec {
10122 namespace: None,
10123 source_id: a.id,
10124 target_id: b.id,
10125 relation: EdgeRelation::Extends,
10126 weight: 1.0,
10127 metadata: None,
10128 };
10129
10130 let first = rt.link_many(&tok, vec![spec()]).await.unwrap();
10132 assert_eq!(first.len(), 1);
10133 let persisted_id: Uuid = first[0].id.into();
10134
10135 let second = rt.link_many(&tok, vec![spec()]).await.unwrap();
10138 assert_eq!(second.len(), 1);
10139 let second_id: Uuid = second[0].id.into();
10140
10141 assert_eq!(
10142 persisted_id, second_id,
10143 "link_many with an existing triple must return the persisted row ID ({persisted_id}), \
10144 not a new phantom ID ({second_id})"
10145 );
10146
10147 let count = rt
10149 .count_edges(&tok, crate::curation::EdgeListFilter::default())
10150 .await
10151 .unwrap();
10152 assert_eq!(count, 1, "upsert must not duplicate the edge row");
10153 }
10154
10155 #[tokio::test]
10158 async fn create_many_persists_all_entities() {
10159 let rt = rt();
10160 let tok = NamespaceToken::local();
10161
10162 let specs: Vec<EntityCreateSpec> = (0..5)
10163 .map(|i| EntityCreateSpec {
10164 kind: "concept".into(),
10165 entity_type: None,
10166 name: format!("BulkConcept-{i}"),
10167 description: Some(format!("desc {i}")),
10168 properties: None,
10169 tags: vec!["bulk-test".into()],
10170 })
10171 .collect();
10172
10173 let entities = rt.create_many(&tok, specs).await.unwrap();
10174 assert_eq!(entities.len(), 5, "all 5 entities must be returned");
10175
10176 for entity in &entities {
10178 let fetched = rt.get_entity(&tok, entity.id).await.unwrap();
10179 assert_eq!(fetched.id, entity.id);
10180 }
10181 }
10182
10183 #[tokio::test]
10184 async fn create_many_empty_name_rejects_atomically() {
10185 let rt = rt();
10186 let tok = NamespaceToken::local();
10187
10188 let specs = vec![
10189 EntityCreateSpec {
10190 kind: "concept".into(),
10191 entity_type: None,
10192 name: "ValidEntity".into(),
10193 description: None,
10194 properties: None,
10195 tags: vec![],
10196 },
10197 EntityCreateSpec {
10198 kind: "concept".into(),
10199 entity_type: None,
10200 name: "".into(), description: None,
10202 properties: None,
10203 tags: vec![],
10204 },
10205 ];
10206
10207 let result = rt.create_many(&tok, specs).await;
10208 assert!(
10209 matches!(result, Err(RuntimeError::InvalidInput(_))),
10210 "empty name must produce InvalidInput error"
10211 );
10212
10213 let rows = rt.list_entities(&tok, None, None, 100, 0).await.unwrap();
10215 assert_eq!(
10216 rows.len(),
10217 0,
10218 "atomic rejection must leave storage unchanged"
10219 );
10220 }
10221
10222 #[tokio::test]
10224 async fn create_many_rejects_unknown_entity_type_when_validator_installed() {
10225 let rt = rt();
10226 let tok = NamespaceToken::local();
10227
10228 rt.install_entity_type_validator(Arc::new(|kind, entity_type| {
10230 let Some(raw) = entity_type else {
10231 return Ok(None);
10232 };
10233 if kind == "concept" && raw == "algorithm" {
10234 return Ok(Some("algorithm".to_string()));
10235 }
10236 Err(RuntimeError::InvalidInput(format!(
10237 "unknown entity_type {raw:?} for {kind:?}; valid: algorithm"
10238 )))
10239 }));
10240
10241 let bad_spec = vec![EntityCreateSpec {
10242 kind: "concept".into(),
10243 entity_type: Some("not_a_registered_type".into()),
10244 name: "ShouldNotLand".into(),
10245 description: None,
10246 properties: None,
10247 tags: vec![],
10248 }];
10249
10250 let result = rt.create_many(&tok, bad_spec).await;
10251 assert!(
10252 matches!(result, Err(RuntimeError::InvalidInput(_))),
10253 "unknown entity_type must be rejected by the runtime-layer validator; got {result:?}"
10254 );
10255
10256 let rows = rt.list_entities(&tok, None, None, 100, 0).await.unwrap();
10258 assert_eq!(
10259 rows.len(),
10260 0,
10261 "validator rejection must leave storage empty"
10262 );
10263 }
10264
10265 #[tokio::test]
10267 async fn create_many_accepts_valid_entity_type_via_validator() {
10268 let rt = rt();
10269 let tok = NamespaceToken::local();
10270
10271 rt.install_entity_type_validator(Arc::new(|kind, entity_type| {
10272 let Some(raw) = entity_type else {
10273 return Ok(None);
10274 };
10275 if kind == "concept" && raw == "algorithm" {
10276 return Ok(Some("algorithm".to_string()));
10277 }
10278 Err(RuntimeError::InvalidInput(format!(
10279 "unknown entity_type {raw:?} for {kind:?}"
10280 )))
10281 }));
10282
10283 let specs = vec![EntityCreateSpec {
10284 kind: "concept".into(),
10285 entity_type: Some("algorithm".into()),
10286 name: "BubbleSort".into(),
10287 description: None,
10288 properties: None,
10289 tags: vec![],
10290 }];
10291
10292 let entities = rt.create_many(&tok, specs).await.unwrap();
10293 assert_eq!(entities.len(), 1, "valid entity_type must be accepted");
10294 assert_eq!(
10295 entities[0].entity_type.as_deref(),
10296 Some("algorithm"),
10297 "entity_type must be stored as returned by the validator"
10298 );
10299 }
10300
10301 #[tokio::test]
10306 async fn create_many_fts_failure_rolls_back_both_substrates() {
10307 let ns = format!("fts-fail-many-{}", uuid::Uuid::new_v4().as_simple());
10310 let rt = rt();
10311 let tok = NamespaceToken::for_namespace(Namespace::parse(&ns).unwrap());
10312
10313 let specs = vec![
10314 EntityCreateSpec {
10315 kind: "concept".into(),
10316 entity_type: None,
10317 name: "FtsRollbackA".into(),
10318 description: None,
10319 properties: None,
10320 tags: vec![],
10321 },
10322 EntityCreateSpec {
10323 kind: "concept".into(),
10324 entity_type: None,
10325 name: "FtsRollbackB".into(),
10326 description: None,
10327 properties: None,
10328 tags: vec![],
10329 },
10330 ];
10331
10332 arm_fts_fail_many(&ns);
10333 let result = rt.create_many(&tok, specs).await;
10334
10335 assert!(
10336 result.is_err(),
10337 "create_many must return Err when FTS write fails"
10338 );
10339
10340 let entity_rows = rt.list_entities(&tok, None, None, 100, 0).await.unwrap();
10342 assert_eq!(
10343 entity_rows.len(),
10344 0,
10345 "entity rows must be rolled back on FTS failure; found {entity_rows:?}"
10346 );
10347
10348 let fts = rt.text(&tok).unwrap();
10350 let fts_count = fts
10351 .count(TextFilter {
10352 ids: vec![],
10353 kinds: vec![],
10354 namespaces: vec![ns.clone()],
10355 })
10356 .await
10357 .unwrap();
10358 assert_eq!(
10359 fts_count, 0,
10360 "fts_entities must be empty after FTS-failure rollback; found {fts_count}"
10361 );
10362 }
10363
10364 #[tokio::test]
10373 async fn create_many_fts_partial_failure_rolls_back_both_substrates() {
10374 let ns = format!("fts-fail-partial-{}", uuid::Uuid::new_v4().as_simple());
10375 let rt = rt();
10376 let tok = NamespaceToken::for_namespace(Namespace::parse(&ns).unwrap());
10377
10378 let specs = vec![
10379 EntityCreateSpec {
10380 kind: "concept".into(),
10381 entity_type: None,
10382 name: "PartialRollbackA".into(),
10383 description: None,
10384 properties: None,
10385 tags: vec![],
10386 },
10387 EntityCreateSpec {
10388 kind: "concept".into(),
10389 entity_type: None,
10390 name: "PartialRollbackB".into(),
10391 description: None,
10392 properties: None,
10393 tags: vec![],
10394 },
10395 ];
10396
10397 arm_fts_fail_many_partial(&ns);
10398 let result = rt.create_many(&tok, specs).await;
10399
10400 assert!(
10401 result.is_err(),
10402 "create_many must return Err when FTS summary.failed > 0"
10403 );
10404
10405 let entity_rows = rt.list_entities(&tok, None, None, 100, 0).await.unwrap();
10407 assert_eq!(
10408 entity_rows.len(),
10409 0,
10410 "entity rows must be rolled back when FTS summary.failed > 0; found {entity_rows:?}"
10411 );
10412
10413 let fts = rt.text(&tok).unwrap();
10415 let fts_count = fts
10416 .count(TextFilter {
10417 ids: vec![],
10418 kinds: vec![],
10419 namespaces: vec![ns.clone()],
10420 })
10421 .await
10422 .unwrap();
10423 assert_eq!(
10424 fts_count, 0,
10425 "fts_entities must be empty after partial-FTS-failure rollback; found {fts_count}"
10426 );
10427 }
10428
10429 #[tokio::test]
10432 async fn get_edge_cross_namespace_succeeds() {
10433 let rt = rt();
10434 let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
10435 let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
10436
10437 let src = rt
10438 .create_entity(&ns_a, "concept", None, "Src", None, None, vec![])
10439 .await
10440 .unwrap();
10441 let tgt = rt
10442 .create_entity(&ns_a, "concept", None, "Tgt", None, None, vec![])
10443 .await
10444 .unwrap();
10445 let edge = rt
10446 .link(&ns_a, src.id, tgt.id, EdgeRelation::Extends, 1.0, None)
10447 .await
10448 .unwrap();
10449
10450 let own_ns = rt.get_edge(&ns_a, Uuid::from(edge.id)).await;
10452 assert!(
10453 own_ns.is_ok() && own_ns.unwrap().is_some(),
10454 "edge must be visible in its own namespace"
10455 );
10456
10457 let cross_ns = rt.get_edge(&ns_b, Uuid::from(edge.id)).await;
10459 assert!(
10460 matches!(cross_ns, Ok(Some(_))),
10461 "cross-namespace get_edge must return Ok(Some(_)) after PR-A1, got {cross_ns:?}"
10462 );
10463
10464 let absent = rt.get_edge(&ns_b, Uuid::new_v4()).await;
10466 assert!(
10467 matches!(absent, Ok(None)),
10468 "absent edge must return Ok(None), got {absent:?}"
10469 );
10470 }
10471
10472 #[tokio::test]
10479 async fn traverse_cross_namespace_root_is_accepted() {
10480 use khive_storage::types::TraversalOptions;
10481
10482 let rt = rt();
10483 let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
10484 let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
10485
10486 let a = rt
10487 .create_entity(&ns_a, "concept", None, "A", None, None, vec![])
10488 .await
10489 .unwrap();
10490 rt.create_entity(&ns_a, "concept", None, "B", None, None, vec![])
10491 .await
10492 .unwrap();
10493 rt.link(&ns_a, a.id, a.id, EdgeRelation::Extends, 1.0, None)
10494 .await
10495 .ok(); let result = rt
10500 .traverse(
10501 &ns_b,
10502 TraversalRequest {
10503 roots: vec![a.id],
10504 options: TraversalOptions {
10505 max_depth: 1,
10506 direction: Direction::Out,
10507 ..Default::default()
10508 },
10509 include_roots: true,
10510 include_properties: false,
10511 },
10512 )
10513 .await;
10514 assert!(result.is_ok(), "traverse must not error; got {:?}", result);
10515 }
10516
10517 async fn count_all_incident_edges(rt: &KhiveRuntime, node_id: Uuid, ns: &str) -> u64 {
10526 let mut reader = rt.sql().reader().await.expect("sql reader must open");
10527 let row = reader
10528 .query_scalar(SqlStatement {
10529 sql: "SELECT COUNT(*) FROM graph_edges \
10530 WHERE namespace = ?1 AND (source_id = ?2 OR target_id = ?2)"
10531 .into(),
10532 params: vec![
10533 SqlValue::Text(ns.to_string()),
10534 SqlValue::Text(node_id.to_string()),
10535 ],
10536 label: Some("count_all_incident_edges".into()),
10537 })
10538 .await
10539 .expect("count query must succeed");
10540 match row {
10541 Some(SqlValue::Integer(n)) => n as u64,
10542 _ => panic!("count must return an integer"),
10543 }
10544 }
10545
10546 #[tokio::test]
10547 async fn hard_delete_entity_purges_already_soft_deleted_incident_edge() {
10548 let rt = rt();
10549 let tok = NamespaceToken::local();
10550 let ns = tok.namespace().to_string();
10551
10552 let a = rt
10553 .create_entity(&tok, "concept", None, "SrcA", None, None, vec![])
10554 .await
10555 .unwrap();
10556 let b = rt
10557 .create_entity(&tok, "concept", None, "TgtB", None, None, vec![])
10558 .await
10559 .unwrap();
10560
10561 rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
10562 .await
10563 .unwrap();
10564
10565 let edge_hit = rt
10567 .neighbors(&tok, a.id, Direction::Out, None, None)
10568 .await
10569 .unwrap();
10570 assert_eq!(edge_hit.len(), 1, "edge must exist before soft-delete");
10571 let edge_uuid = edge_hit[0].edge_id;
10572 rt.delete_edge(&tok, edge_uuid, false).await.unwrap();
10573
10574 let visible = rt
10576 .neighbors(&tok, a.id, Direction::Out, None, None)
10577 .await
10578 .unwrap();
10579 assert!(visible.is_empty(), "soft-deleted edge must be invisible");
10580 let raw_before = count_all_incident_edges(&rt, a.id, &ns).await;
10581 assert_eq!(
10582 raw_before, 1,
10583 "soft-deleted edge must still be a physical row"
10584 );
10585
10586 rt.delete_entity(&tok, a.id, true).await.unwrap();
10588
10589 let raw_after = count_all_incident_edges(&rt, a.id, &ns).await;
10590 assert_eq!(
10591 raw_after, 0,
10592 "purge_incident_edges must physically remove soft-deleted edge rows (ADR-002)"
10593 );
10594 }
10595
10596 #[tokio::test]
10597 async fn hard_delete_note_purges_already_soft_deleted_incident_edge() {
10598 let rt = rt();
10599 let tok = NamespaceToken::local();
10600 let ns = tok.namespace().to_string();
10601
10602 let target = rt
10603 .create_note(
10604 &tok,
10605 "observation",
10606 None,
10607 "purge-cascade target note",
10608 Some(0.5),
10609 None,
10610 vec![],
10611 )
10612 .await
10613 .unwrap();
10614 let annotating = rt
10615 .create_note(
10616 &tok,
10617 "insight",
10618 None,
10619 "annotator note",
10620 Some(0.5),
10621 None,
10622 vec![target.id],
10623 )
10624 .await
10625 .unwrap();
10626
10627 let edge_hit = rt
10629 .neighbors(
10630 &tok,
10631 annotating.id,
10632 Direction::Out,
10633 None,
10634 Some(vec![EdgeRelation::Annotates]),
10635 )
10636 .await
10637 .unwrap();
10638 assert_eq!(edge_hit.len(), 1, "annotates edge must exist");
10639 let edge_uuid = edge_hit[0].edge_id;
10640 rt.delete_edge(&tok, edge_uuid, false).await.unwrap();
10641
10642 let raw_before = count_all_incident_edges(&rt, target.id, &ns).await;
10643 assert_eq!(
10644 raw_before, 1,
10645 "soft-deleted edge must still be a physical row before note purge"
10646 );
10647
10648 rt.delete_note(&tok, target.id, true).await.unwrap();
10650
10651 let raw_after = count_all_incident_edges(&rt, target.id, &ns).await;
10652 assert_eq!(
10653 raw_after, 0,
10654 "purge_incident_edges must physically remove soft-deleted edge rows on note purge (ADR-002)"
10655 );
10656 }
10657
10658 async fn count_all_incident_edges_global(rt: &KhiveRuntime, node_id: Uuid) -> u64 {
10666 let mut reader = rt.sql().reader().await.expect("sql reader must open");
10667 let row = reader
10668 .query_scalar(SqlStatement {
10669 sql: "SELECT COUNT(*) FROM graph_edges WHERE source_id = ?1 OR target_id = ?1"
10670 .into(),
10671 params: vec![SqlValue::Text(node_id.to_string())],
10672 label: Some("count_all_incident_edges_global".into()),
10673 })
10674 .await
10675 .expect("count query must succeed");
10676 match row {
10677 Some(SqlValue::Integer(n)) => n as u64,
10678 _ => panic!("count must return an integer"),
10679 }
10680 }
10681
10682 #[tokio::test]
10683 async fn cross_namespace_hard_delete_entity_purges_all_incident_edges() {
10684 let rt = rt();
10688 let ns_owner = NamespaceToken::for_namespace(Namespace::parse("ns-owner").unwrap());
10689 let ns_caller = NamespaceToken::for_namespace(Namespace::parse("ns-caller").unwrap());
10690
10691 let entity = rt
10692 .create_entity(
10693 &ns_owner,
10694 "concept",
10695 None,
10696 "ForeignEntity",
10697 None,
10698 None,
10699 vec![],
10700 )
10701 .await
10702 .unwrap();
10703 let peer = rt
10704 .create_entity(&ns_owner, "concept", None, "Peer", None, None, vec![])
10705 .await
10706 .unwrap();
10707 rt.link(
10709 &ns_owner,
10710 entity.id,
10711 peer.id,
10712 EdgeRelation::Extends,
10713 1.0,
10714 None,
10715 )
10716 .await
10717 .unwrap();
10718 rt.link(
10719 &ns_owner,
10720 peer.id,
10721 entity.id,
10722 EdgeRelation::Extends,
10723 1.0,
10724 None,
10725 )
10726 .await
10727 .unwrap();
10728
10729 let before = count_all_incident_edges_global(&rt, entity.id).await;
10730 assert_eq!(before, 2, "two incident edges must exist before delete");
10731
10732 let deleted = rt.delete_entity(&ns_caller, entity.id, true).await.unwrap();
10734 assert!(deleted, "cross-ns hard delete must return true");
10735
10736 let after = count_all_incident_edges_global(&rt, entity.id).await;
10738 assert_eq!(
10739 after, 0,
10740 "purge_incident_edges must remove all incident edges across namespaces (ADR-002, ADR-007)"
10741 );
10742 }
10743
10744 async fn count_edge_rows_by_id(rt: &KhiveRuntime, edge_id: Uuid, ns: &str) -> u64 {
10754 let mut reader = rt.sql().reader().await.expect("sql reader must open");
10755 let row = reader
10756 .query_scalar(SqlStatement {
10757 sql: "SELECT COUNT(*) FROM graph_edges WHERE namespace = ?1 AND id = ?2".into(),
10758 params: vec![
10759 SqlValue::Text(ns.to_string()),
10760 SqlValue::Text(edge_id.to_string()),
10761 ],
10762 label: Some("count_edge_rows_by_id".into()),
10763 })
10764 .await
10765 .expect("count query must succeed");
10766 match row {
10767 Some(SqlValue::Integer(n)) => n as u64,
10768 _ => panic!("count must return an integer"),
10769 }
10770 }
10771
10772 #[tokio::test]
10773 async fn hard_delete_edge_purges_already_soft_deleted_primary_edge() {
10774 let rt = rt();
10775 let tok = NamespaceToken::local();
10776 let ns = tok.namespace().to_string();
10777
10778 let a = rt
10779 .create_entity(&tok, "concept", None, "EA", None, None, vec![])
10780 .await
10781 .unwrap();
10782 let b = rt
10783 .create_entity(&tok, "concept", None, "EB", None, None, vec![])
10784 .await
10785 .unwrap();
10786
10787 let edge = rt
10788 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
10789 .await
10790 .unwrap();
10791 let edge_uuid: Uuid = edge.id.into();
10792
10793 let soft = rt.delete_edge(&tok, edge_uuid, false).await.unwrap();
10795 assert!(soft, "soft delete must succeed");
10796
10797 assert!(
10799 rt.get_edge(&tok, edge_uuid).await.unwrap().is_none(),
10800 "soft-deleted edge must be invisible to get_edge"
10801 );
10802 assert_eq!(
10803 count_edge_rows_by_id(&rt, edge_uuid, &ns).await,
10804 1,
10805 "soft-deleted edge must still be a physical row"
10806 );
10807
10808 let purged = rt.delete_edge(&tok, edge_uuid, true).await.unwrap();
10810 assert!(
10811 purged,
10812 "hard delete of a soft-deleted edge must return true"
10813 );
10814
10815 assert_eq!(
10816 count_edge_rows_by_id(&rt, edge_uuid, &ns).await,
10817 0,
10818 "hard-delete must physically remove the soft-deleted edge row (ADR-002)"
10819 );
10820 }
10821
10822 #[tokio::test]
10823 async fn hard_delete_base_edge_purges_already_soft_deleted_annotates_edge() {
10824 let rt = rt();
10825 let tok = NamespaceToken::local();
10826 let ns = tok.namespace().to_string();
10827
10828 let a = rt
10829 .create_entity(&tok, "concept", None, "CA", None, None, vec![])
10830 .await
10831 .unwrap();
10832 let b = rt
10833 .create_entity(&tok, "concept", None, "CB", None, None, vec![])
10834 .await
10835 .unwrap();
10836
10837 let base_edge = rt
10839 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
10840 .await
10841 .unwrap();
10842 let base_edge_uuid: Uuid = base_edge.id.into();
10843
10844 let note = rt
10846 .create_note(
10847 &tok,
10848 "observation",
10849 None,
10850 "note about base edge",
10851 Some(0.5),
10852 None,
10853 vec![base_edge_uuid],
10854 )
10855 .await
10856 .unwrap();
10857
10858 let ann_hits = rt
10860 .neighbors(
10861 &tok,
10862 note.id,
10863 Direction::Out,
10864 None,
10865 Some(vec![EdgeRelation::Annotates]),
10866 )
10867 .await
10868 .unwrap();
10869 assert_eq!(ann_hits.len(), 1, "annotates edge must exist");
10870 let ann_edge_uuid = ann_hits[0].edge_id;
10871
10872 rt.delete_edge(&tok, ann_edge_uuid, false).await.unwrap();
10874 assert_eq!(
10875 count_edge_rows_by_id(&rt, ann_edge_uuid, &ns).await,
10876 1,
10877 "soft-deleted annotates edge must still be a physical row"
10878 );
10879
10880 let purged = rt.delete_edge(&tok, base_edge_uuid, true).await.unwrap();
10882 assert!(purged, "hard delete of base edge must return true");
10883
10884 assert_eq!(
10885 count_edge_rows_by_id(&rt, ann_edge_uuid, &ns).await,
10886 0,
10887 "hard-delete of base edge must purge already-soft-deleted annotates edge row (ADR-002)"
10888 );
10889 assert_eq!(
10890 count_edge_rows_by_id(&rt, base_edge_uuid, &ns).await,
10891 0,
10892 "hard-delete must physically remove the base edge row"
10893 );
10894 }
10895
10896 #[tokio::test]
10903 async fn create_entity_fts_failure_rolls_back_entity_row() {
10904 let rt = KhiveRuntime::memory().unwrap();
10905 let ns = Namespace::parse("fault-entity-fts").unwrap();
10906 let tok = NamespaceToken::for_namespace(ns.clone());
10907
10908 arm_fts_fail(ns.as_str());
10909
10910 let result = rt
10911 .create_entity(
10912 &tok,
10913 "concept",
10914 None,
10915 "fts-fail rollback target",
10916 None,
10917 None,
10918 vec![],
10919 )
10920 .await;
10921
10922 assert!(
10923 result.is_err(),
10924 "create_entity must propagate the injected FTS failure"
10925 );
10926 let err_msg = result.unwrap_err().to_string();
10927 assert!(
10928 err_msg.contains("injected FTS failure"),
10929 "error must carry injection message; got: {err_msg}"
10930 );
10931
10932 let entities = rt.list_entities(&tok, None, None, 1000, 0).await.unwrap();
10933 assert!(
10934 entities.is_empty(),
10935 "compensation must remove the entity row after FTS failure; got {entities:?}"
10936 );
10937 }
10938
10939 #[tokio::test]
10943 async fn create_entity_vector_failure_rolls_back_entity_row_and_fts() {
10944 const MODEL: &str = "test-entity-vec-inject";
10945 const DIMS: usize = 4;
10946
10947 let rt = KhiveRuntime::memory().unwrap();
10948 let (provider, _counter) = ConstVecProvider::new(MODEL, DIMS);
10949 rt.register_embedder(provider);
10950
10951 let ns = Namespace::parse("fault-entity-vec").unwrap();
10952 let tok = NamespaceToken::for_namespace(ns.clone());
10953
10954 arm_vector_fail(ns.as_str());
10955
10956 let result = rt
10957 .create_entity(
10958 &tok,
10959 "concept",
10960 None,
10961 "vec-fail rollback target",
10962 Some("description so embed body is non-empty"),
10963 None,
10964 vec![],
10965 )
10966 .await;
10967
10968 assert!(
10969 result.is_err(),
10970 "create_entity must propagate the injected vector failure"
10971 );
10972 let err_msg = result.unwrap_err().to_string();
10973 assert!(
10974 err_msg.contains("injected vector failure"),
10975 "error must carry injection message; got: {err_msg}"
10976 );
10977
10978 let entities = rt.list_entities(&tok, None, None, 1000, 0).await.unwrap();
10979 assert!(
10980 entities.is_empty(),
10981 "compensation must remove entity row after vector failure; got {entities:?}"
10982 );
10983
10984 use khive_storage::types::{TextFilter, TextQueryMode, TextSearchRequest};
10986 let fts_hits = rt
10987 .text(&tok)
10988 .unwrap()
10989 .search(TextSearchRequest {
10990 query: "vec-fail rollback target".to_string(),
10991 mode: TextQueryMode::Plain,
10992 filter: Some(TextFilter {
10993 namespaces: vec![ns.as_str().to_string()],
10994 ..Default::default()
10995 }),
10996 top_k: 10,
10997 snippet_chars: 100,
10998 })
10999 .await
11000 .unwrap();
11001 assert!(
11002 fts_hits.is_empty(),
11003 "compensation must remove FTS document after vector failure; got {fts_hits:?}"
11004 );
11005 }
11006
11007 #[tokio::test]
11014 async fn create_entity_multi_model_second_vector_failure_rolls_back_all() {
11015 const DIMS: usize = 4;
11016
11017 let rt = KhiveRuntime::memory().unwrap();
11018 let (provider_a, _ca) = ConstVecProvider::new("model-a", DIMS);
11019 let (provider_b, _cb) = ConstVecProvider::new("model-b", DIMS);
11020 rt.register_embedder(provider_a);
11021 rt.register_embedder(provider_b);
11022
11023 let ns = Namespace::parse("fault-entity-multi").unwrap();
11024 let tok = NamespaceToken::for_namespace(ns.clone());
11025
11026 arm_vector_fail_after(1);
11028
11029 let result = rt
11030 .create_entity(
11031 &tok,
11032 "concept",
11033 None,
11034 "multi-model rollback target",
11035 Some("description for embedding"),
11036 None,
11037 vec![],
11038 )
11039 .await;
11040
11041 assert!(
11042 result.is_err(),
11043 "create_entity must propagate the injected multi-model vector failure"
11044 );
11045
11046 let entities = rt.list_entities(&tok, None, None, 1000, 0).await.unwrap();
11047 assert!(
11048 entities.is_empty(),
11049 "compensation must remove entity row; got {entities:?}"
11050 );
11051
11052 use khive_storage::types::VectorSearchRequest;
11057 let query_vec = vec![1.0_f32; DIMS];
11058 let hits_a = rt
11059 .vectors_for_model(&tok, "model-a")
11060 .unwrap()
11061 .search(VectorSearchRequest {
11062 query_vectors: vec![query_vec.clone()],
11063 top_k: 100,
11064 namespace: Some(ns.as_str().to_string()),
11065 kind: Some(khive_types::SubstrateKind::Entity),
11066 embedding_model: Some("model-a".to_string()),
11067 filter: None,
11068 backend_hints: None,
11069 })
11070 .await
11071 .unwrap();
11072 assert!(
11073 hits_a.is_empty(),
11074 "model-a vector store must be empty after rollback; got {hits_a:?}"
11075 );
11076 let hits_b = rt
11077 .vectors_for_model(&tok, "model-b")
11078 .unwrap()
11079 .search(VectorSearchRequest {
11080 query_vectors: vec![query_vec],
11081 top_k: 100,
11082 namespace: Some(ns.as_str().to_string()),
11083 kind: Some(khive_types::SubstrateKind::Entity),
11084 embedding_model: Some("model-b".to_string()),
11085 filter: None,
11086 backend_hints: None,
11087 })
11088 .await
11089 .unwrap();
11090 assert!(
11091 hits_b.is_empty(),
11092 "model-b vector store must be empty after rollback; got {hits_b:?}"
11093 );
11094 }
11095
11096 #[tokio::test]
11100 async fn update_entity_fans_out_to_all_registered_models() {
11101 const DIMS: usize = 4;
11102
11103 let rt = KhiveRuntime::memory().unwrap();
11104 let (provider_a, _ca) = ConstVecProvider::new("embed-a", DIMS);
11105 let (provider_b, _cb) = ConstVecProvider::new("embed-b", DIMS);
11106 rt.register_embedder(provider_a);
11107 rt.register_embedder(provider_b);
11108
11109 let ns = Namespace::parse("update-entity-fanout").unwrap();
11110 let tok = NamespaceToken::for_namespace(ns.clone());
11111
11112 let entity = rt
11113 .create_entity(
11114 &tok,
11115 "concept",
11116 None,
11117 "FanOutEntity",
11118 Some("initial description"),
11119 None,
11120 vec![],
11121 )
11122 .await
11123 .expect("create_entity must succeed");
11124
11125 use crate::curation::EntityPatch;
11126 let patch = EntityPatch {
11127 description: Some(Some("updated description after fan-out fix".to_string())),
11128 ..Default::default()
11129 };
11130 rt.update_entity(&tok, entity.id, patch)
11131 .await
11132 .expect("update_entity must succeed");
11133
11134 use khive_storage::types::VectorSearchRequest;
11135 let query_vec = vec![1.0_f32; DIMS];
11136
11137 let hits_a = rt
11138 .vectors_for_model(&tok, "embed-a")
11139 .unwrap()
11140 .search(VectorSearchRequest {
11141 query_vectors: vec![query_vec.clone()],
11142 top_k: 10,
11143 namespace: Some(ns.as_str().to_string()),
11144 kind: Some(khive_types::SubstrateKind::Entity),
11145 embedding_model: Some("embed-a".to_string()),
11146 filter: None,
11147 backend_hints: None,
11148 })
11149 .await
11150 .unwrap();
11151 assert!(
11152 hits_a.iter().any(|h| h.subject_id == entity.id),
11153 "embed-a must hold a vector for the entity after update; got {hits_a:?}"
11154 );
11155
11156 let hits_b = rt
11157 .vectors_for_model(&tok, "embed-b")
11158 .unwrap()
11159 .search(VectorSearchRequest {
11160 query_vectors: vec![query_vec],
11161 top_k: 10,
11162 namespace: Some(ns.as_str().to_string()),
11163 kind: Some(khive_types::SubstrateKind::Entity),
11164 embedding_model: Some("embed-b".to_string()),
11165 filter: None,
11166 backend_hints: None,
11167 })
11168 .await
11169 .unwrap();
11170 assert!(
11171 hits_b.iter().any(|h| h.subject_id == entity.id),
11172 "embed-b must hold a vector for the entity after update; got {hits_b:?}"
11173 );
11174 }
11175
11176 #[tokio::test]
11180 async fn update_note_fans_out_to_all_registered_models() {
11181 const DIMS: usize = 4;
11182
11183 let rt = KhiveRuntime::memory().unwrap();
11184 let (provider_a, _ca) = ConstVecProvider::new("embed-a", DIMS);
11185 let (provider_b, _cb) = ConstVecProvider::new("embed-b", DIMS);
11186 rt.register_embedder(provider_a);
11187 rt.register_embedder(provider_b);
11188
11189 let ns = Namespace::parse("update-note-fanout").unwrap();
11190 let tok = NamespaceToken::for_namespace(ns.clone());
11191
11192 let note = rt
11193 .create_note(
11194 &tok,
11195 "observation",
11196 None,
11197 "initial note content for fan-out test",
11198 None,
11199 None,
11200 vec![],
11201 )
11202 .await
11203 .expect("create_note must succeed");
11204
11205 use crate::curation::NotePatch;
11206 let patch = NotePatch {
11207 content: Some("updated content after fan-out fix".to_string()),
11208 ..Default::default()
11209 };
11210 rt.update_note(&tok, note.id, patch)
11211 .await
11212 .expect("update_note must succeed");
11213
11214 use khive_storage::types::VectorSearchRequest;
11215 let query_vec = vec![1.0_f32; DIMS];
11216
11217 let hits_a = rt
11218 .vectors_for_model(&tok, "embed-a")
11219 .unwrap()
11220 .search(VectorSearchRequest {
11221 query_vectors: vec![query_vec.clone()],
11222 top_k: 10,
11223 namespace: Some(ns.as_str().to_string()),
11224 kind: Some(khive_types::SubstrateKind::Note),
11225 embedding_model: Some("embed-a".to_string()),
11226 filter: None,
11227 backend_hints: None,
11228 })
11229 .await
11230 .unwrap();
11231 assert!(
11232 hits_a.iter().any(|h| h.subject_id == note.id),
11233 "embed-a must hold a vector for the note after update; got {hits_a:?}"
11234 );
11235
11236 let hits_b = rt
11237 .vectors_for_model(&tok, "embed-b")
11238 .unwrap()
11239 .search(VectorSearchRequest {
11240 query_vectors: vec![query_vec],
11241 top_k: 10,
11242 namespace: Some(ns.as_str().to_string()),
11243 kind: Some(khive_types::SubstrateKind::Note),
11244 embedding_model: Some("embed-b".to_string()),
11245 filter: None,
11246 backend_hints: None,
11247 })
11248 .await
11249 .unwrap();
11250 assert!(
11251 hits_b.iter().any(|h| h.subject_id == note.id),
11252 "embed-b must hold a vector for the note after update; got {hits_b:?}"
11253 );
11254 }
11255
11256 #[tokio::test]
11264 async fn get_entity_cross_namespace_succeeds() {
11265 let rt = rt();
11266 let leo_tok = NamespaceToken::for_namespace(Namespace::parse("lambda:leo").unwrap());
11268 let entity = rt
11269 .create_entity(&leo_tok, "concept", None, "Peer-Entity", None, None, vec![])
11270 .await
11271 .unwrap();
11272 assert_eq!(entity.namespace, "lambda:leo");
11273
11274 let local_tok = NamespaceToken::local();
11276 let fetched = rt.get_entity(&local_tok, entity.id).await;
11277 assert!(
11278 fetched.is_ok(),
11279 "get_entity from local token must find lambda:leo entity; got {:?}",
11280 fetched
11281 );
11282 assert_eq!(fetched.unwrap().id, entity.id);
11283 }
11284
11285 #[tokio::test]
11286 async fn update_entity_cross_namespace_succeeds() {
11287 let rt = rt();
11288 let leo_tok = NamespaceToken::for_namespace(Namespace::parse("lambda:leo").unwrap());
11289 let entity = rt
11290 .create_entity(
11291 &leo_tok,
11292 "concept",
11293 None,
11294 "Peer-Entity-Update",
11295 None,
11296 None,
11297 vec![],
11298 )
11299 .await
11300 .unwrap();
11301
11302 let local_tok = NamespaceToken::local();
11304 let patch = crate::curation::EntityPatch {
11305 name: Some("Peer-Entity-Updated".to_string()),
11306 ..Default::default()
11307 };
11308 let result = rt.update_entity(&local_tok, entity.id, patch).await;
11309 assert!(
11310 result.is_ok(),
11311 "update_entity from local token must succeed on lambda:leo entity; got {:?}",
11312 result
11313 );
11314 assert_eq!(result.unwrap().name, "Peer-Entity-Updated");
11315 }
11316
11317 #[tokio::test]
11318 async fn delete_entity_cross_namespace_succeeds() {
11319 let rt = rt();
11320 let leo_tok = NamespaceToken::for_namespace(Namespace::parse("lambda:leo").unwrap());
11321 let entity = rt
11322 .create_entity(
11323 &leo_tok,
11324 "concept",
11325 None,
11326 "Peer-Entity-Delete",
11327 None,
11328 None,
11329 vec![],
11330 )
11331 .await
11332 .unwrap();
11333
11334 let local_tok = NamespaceToken::local();
11336 let deleted = rt.delete_entity(&local_tok, entity.id, false).await;
11337 assert!(
11338 deleted.is_ok(),
11339 "delete_entity from local token must succeed on lambda:leo entity; got {:?}",
11340 deleted
11341 );
11342 assert!(
11343 deleted.unwrap(),
11344 "delete must return true when entity existed"
11345 );
11346 }
11347
11348 #[tokio::test]
11349 async fn namespace_preserved_on_entity_after_cross_namespace_get() {
11350 let rt = rt();
11351 let leo_tok = NamespaceToken::for_namespace(Namespace::parse("lambda:leo").unwrap());
11352 let entity = rt
11353 .create_entity(
11354 &leo_tok,
11355 "concept",
11356 None,
11357 "NS-Preserved",
11358 None,
11359 None,
11360 vec![],
11361 )
11362 .await
11363 .unwrap();
11364
11365 let local_tok = NamespaceToken::local();
11367 let fetched = rt.get_entity(&local_tok, entity.id).await.unwrap();
11368 assert_eq!(
11369 fetched.namespace, "lambda:leo",
11370 "namespace column must be preserved; not overwritten with caller's namespace"
11371 );
11372 }
11373
11374 use crate::pack::PackByIdResolver;
11377 use tokio::sync::Mutex as TokioMutex;
11378
11379 #[derive(Debug, Default)]
11380 struct MockResolverState {
11381 owned: Vec<Uuid>,
11382 deleted: Vec<Uuid>,
11383 delete_calls: Vec<(Uuid, bool)>,
11384 }
11385
11386 struct MockPackResolver(TokioMutex<MockResolverState>);
11387
11388 impl MockPackResolver {
11389 fn new() -> Self {
11390 Self(TokioMutex::new(MockResolverState::default()))
11391 }
11392 }
11393
11394 #[async_trait::async_trait]
11395 impl crate::pack::PackByIdResolver for MockPackResolver {
11396 async fn resolve_by_id(&self, id: Uuid) -> Result<Option<Resolved>, RuntimeError> {
11397 let state = self.0.lock().await;
11398 if state.owned.contains(&id) && !state.deleted.contains(&id) {
11399 Ok(Some(Resolved::PackRecord {
11400 pack: "mock".into(),
11401 kind: "widget".into(),
11402 data: serde_json::json!({ "id": id.to_string(), "name": "test-widget" }),
11403 }))
11404 } else {
11405 Ok(None)
11406 }
11407 }
11408
11409 async fn resolve_by_id_including_deleted(
11410 &self,
11411 id: Uuid,
11412 ) -> Result<Option<Resolved>, RuntimeError> {
11413 let state = self.0.lock().await;
11414 if state.owned.contains(&id) {
11415 Ok(Some(Resolved::PackRecord {
11416 pack: "mock".into(),
11417 kind: "widget".into(),
11418 data: serde_json::json!({ "id": id.to_string(), "name": "test-widget" }),
11419 }))
11420 } else {
11421 Ok(None)
11422 }
11423 }
11424
11425 async fn delete_by_id(
11426 &self,
11427 id: Uuid,
11428 hard: bool,
11429 ) -> Result<serde_json::Value, RuntimeError> {
11430 let mut state = self.0.lock().await;
11431 if !state.owned.contains(&id) {
11432 return Err(RuntimeError::NotFound(format!(
11433 "mock widget not found: {id}"
11434 )));
11435 }
11436 state.delete_calls.push((id, hard));
11437 if hard {
11438 state.owned.retain(|&x| x != id);
11439 state.deleted.retain(|&x| x != id);
11440 } else {
11441 state.deleted.push(id);
11442 }
11443 Ok(
11444 serde_json::json!({ "deleted": true, "id": id.to_string(), "kind": "widget", "hard": hard }),
11445 )
11446 }
11447 }
11448
11449 fn registry_with_mock_resolver(
11450 rt: KhiveRuntime,
11451 resolver: Box<dyn crate::pack::PackByIdResolver>,
11452 ) -> crate::VerbRegistry {
11453 use crate::pack::{PackRuntime, VerbRegistryBuilder};
11454 use khive_types::{HandlerDef, VerbCategory, Visibility};
11455
11456 static MINIMAL_HANDLERS: &[HandlerDef] = &[HandlerDef {
11457 name: "minimal.noop",
11458 description: "noop",
11459 visibility: Visibility::Verb,
11460 category: VerbCategory::Commissive,
11461 params: &[],
11462 }];
11463
11464 struct MinimalPack;
11465 impl khive_types::Pack for MinimalPack {
11466 const NAME: &'static str = "minimal";
11467 const NOTE_KINDS: &'static [&'static str] = &[];
11468 const ENTITY_KINDS: &'static [&'static str] = &[];
11469 const HANDLERS: &'static [HandlerDef] = MINIMAL_HANDLERS;
11470 }
11471 #[async_trait::async_trait]
11472 impl PackRuntime for MinimalPack {
11473 fn name(&self) -> &str {
11474 "minimal"
11475 }
11476 fn note_kinds(&self) -> &'static [&'static str] {
11477 &[]
11478 }
11479 fn entity_kinds(&self) -> &'static [&'static str] {
11480 &[]
11481 }
11482 fn handlers(&self) -> &'static [HandlerDef] {
11483 MINIMAL_HANDLERS
11484 }
11485 async fn dispatch(
11486 &self,
11487 _verb: &str,
11488 _params: serde_json::Value,
11489 _registry: &crate::VerbRegistry,
11490 _token: &NamespaceToken,
11491 ) -> Result<serde_json::Value, RuntimeError> {
11492 Err(RuntimeError::InvalidInput("stub".into()))
11493 }
11494 }
11495
11496 let _ = rt;
11497 let mut builder = VerbRegistryBuilder::new();
11498 builder.register(MinimalPack);
11499 builder.register_resolver("mock", resolver);
11500 builder.build().expect("registry build failed")
11501 }
11502
11503 #[tokio::test]
11504 async fn pack_record_resolved_pair_returns_none() {
11505 let pr = Resolved::PackRecord {
11506 pack: "knowledge".into(),
11507 kind: "atom".into(),
11508 data: serde_json::json!({}),
11509 };
11510 assert!(
11511 resolved_pair(Some(&pr)).is_none(),
11512 "PackRecord must not be a valid edge endpoint"
11513 );
11514 }
11515
11516 #[test]
11517 fn resolved_pair_surfaces_entity_type() {
11518 let e = Resolved::Entity(
11519 Entity::new("mathlib", "concept", "Nat.add_comm").with_entity_type(Some("theorem")),
11520 );
11521 assert_eq!(
11522 resolved_pair(Some(&e)),
11523 Some(("entity", "concept", Some("theorem"))),
11524 "entity_type subtype must be surfaced alongside base kind"
11525 );
11526 }
11527
11528 #[test]
11529 fn endpoint_of_type_matches_subtype_not_base_kind() {
11530 let kind = "concept";
11532 let et = Some("theorem");
11533
11534 assert!(endpoint_matches(
11536 &EndpointKind::EntityOfType {
11537 kind: "concept",
11538 entity_type: "theorem",
11539 },
11540 "entity",
11541 kind,
11542 et
11543 ));
11544 assert!(!endpoint_matches(
11545 &EndpointKind::EntityOfType {
11546 kind: "concept",
11547 entity_type: "definition",
11548 },
11549 "entity",
11550 kind,
11551 et
11552 ));
11553
11554 assert!(!endpoint_matches(
11557 &EndpointKind::EntityOfKind("theorem"),
11558 "entity",
11559 kind,
11560 et
11561 ));
11562 assert!(endpoint_matches(
11564 &EndpointKind::EntityOfKind("concept"),
11565 "entity",
11566 kind,
11567 et
11568 ));
11569
11570 assert!(!endpoint_matches(
11572 &EndpointKind::EntityOfType {
11573 kind: "concept",
11574 entity_type: "theorem",
11575 },
11576 "note",
11577 "task",
11578 None
11579 ));
11580 assert!(!endpoint_matches(
11581 &EndpointKind::EntityOfType {
11582 kind: "concept",
11583 entity_type: "theorem",
11584 },
11585 "entity",
11586 kind,
11587 None
11588 ));
11589 }
11590
11591 #[test]
11592 fn endpoint_of_type_requires_base_kind_match() {
11593 let wrong_base_kind = "project"; let et = Some("theorem");
11598
11599 assert!(
11603 !endpoint_matches(
11604 &EndpointKind::EntityOfType {
11605 kind: "concept",
11606 entity_type: "theorem",
11607 },
11608 "entity",
11609 wrong_base_kind,
11610 et
11611 ),
11612 "EntityOfType must reject an entity whose base kind != rule.kind \
11613 even when entity_type matches — the pre-fix bug admitted this"
11614 );
11615
11616 assert!(endpoint_matches(
11618 &EndpointKind::EntityOfType {
11619 kind: "concept",
11620 entity_type: "theorem",
11621 },
11622 "entity",
11623 "concept",
11624 et
11625 ));
11626 }
11627
11628 #[tokio::test]
11629 async fn registry_resolvers_accessor_returns_registered() {
11630 let resolver = Box::new(MockPackResolver::new());
11631 let registry = registry_with_mock_resolver(rt(), resolver);
11632 assert_eq!(registry.resolvers().len(), 1);
11633 assert_eq!(registry.resolvers()[0].0, "mock");
11634 }
11635
11636 #[tokio::test]
11637 async fn mock_resolver_resolve_by_id_returns_pack_record() {
11638 let id = Uuid::new_v4();
11639 let resolver: Box<dyn PackByIdResolver> = Box::new(MockPackResolver::new());
11640 let inner = MockPackResolver::new();
11642 inner.0.lock().await.owned.push(id);
11643 let result: Result<Option<Resolved>, RuntimeError> = inner.resolve_by_id(id).await;
11644 match result.unwrap() {
11645 Some(Resolved::PackRecord { pack, kind, data }) => {
11646 assert_eq!(pack, "mock");
11647 assert_eq!(kind, "widget");
11648 assert_eq!(data["id"].as_str().unwrap(), id.to_string());
11649 }
11650 other => panic!("expected PackRecord, got {:?}", other),
11651 }
11652 let _ = resolver;
11653 }
11654
11655 #[tokio::test]
11656 async fn mock_resolver_resolve_unknown_uuid_returns_none() {
11657 let inner = MockPackResolver::new();
11658 let id = Uuid::new_v4();
11659 let result: Result<Option<Resolved>, RuntimeError> = inner.resolve_by_id(id).await;
11660 assert!(result.unwrap().is_none());
11661 }
11662
11663 #[tokio::test]
11664 async fn mock_resolver_delete_soft_records_call() {
11665 let id = Uuid::new_v4();
11666 let inner = MockPackResolver::new();
11667 inner.0.lock().await.owned.push(id);
11668
11669 let result: Result<serde_json::Value, RuntimeError> = inner.delete_by_id(id, false).await;
11670 let result = result.unwrap();
11671 assert_eq!(result["deleted"], serde_json::json!(true));
11672 assert_eq!(result["hard"], serde_json::json!(false));
11673
11674 let live: Result<Option<Resolved>, RuntimeError> = inner.resolve_by_id(id).await;
11676 assert!(live.unwrap().is_none());
11677 let incl: Result<Option<Resolved>, RuntimeError> =
11678 inner.resolve_by_id_including_deleted(id).await;
11679 assert!(incl.unwrap().is_some());
11680 }
11681
11682 #[tokio::test]
11683 async fn mock_resolver_delete_hard_removes_record() {
11684 let id = Uuid::new_v4();
11685 let inner = MockPackResolver::new();
11686 inner.0.lock().await.owned.push(id);
11687
11688 let result: Result<serde_json::Value, RuntimeError> = inner.delete_by_id(id, true).await;
11689 assert_eq!(result.unwrap()["hard"], serde_json::json!(true));
11690
11691 let incl: Result<Option<Resolved>, RuntimeError> =
11693 inner.resolve_by_id_including_deleted(id).await;
11694 assert!(incl.unwrap().is_none());
11695 }
11696
11697 #[tokio::test]
11698 async fn pack_record_not_valid_context_entity() {
11699 let pr = Resolved::PackRecord {
11702 pack: "knowledge".into(),
11703 kind: "atom".into(),
11704 data: serde_json::json!({}),
11705 };
11706 assert!(matches!(pr, Resolved::PackRecord { .. }));
11709 }
11710
11711 fn neighbor_hit(node_id: Uuid) -> NeighborHit {
11714 NeighborHit {
11715 node_id,
11716 edge_id: Uuid::new_v4(),
11717 relation: EdgeRelation::Extends,
11718 weight: 1.0,
11719 name: None,
11720 kind: None,
11721 entity_type: None,
11722 }
11723 }
11724
11725 fn path_node(node_id: Uuid, depth: usize) -> PathNode {
11726 PathNode {
11727 node_id,
11728 via_edge: None,
11729 depth,
11730 name: None,
11731 kind: None,
11732 properties: None,
11733 }
11734 }
11735
11736 #[tokio::test]
11739 async fn enrich_neighbor_hits_batch_entity_note_and_bogus() {
11740 let rt = rt();
11741 let tok = NamespaceToken::local();
11742
11743 let entity = rt
11745 .create_entity(&tok, "concept", None, "MyEntity", None, None, vec![])
11746 .await
11747 .unwrap();
11748
11749 let note = rt
11751 .create_note(&tok, "observation", None, "body", Some(0.5), None, vec![])
11752 .await
11753 .unwrap();
11754
11755 let bogus_id = Uuid::new_v4();
11756
11757 let mut hits = vec![
11758 neighbor_hit(entity.id),
11759 neighbor_hit(note.id),
11760 neighbor_hit(bogus_id),
11761 ];
11762
11763 rt.enrich_neighbor_hits(&tok, &mut hits).await;
11764
11765 assert_eq!(hits[0].name.as_deref(), Some("MyEntity"));
11766 assert_eq!(hits[0].kind.as_deref(), Some("concept"));
11767
11768 assert_eq!(hits[1].name.as_deref(), Some("[observation]"));
11769 assert_eq!(hits[1].kind.as_deref(), Some("observation"));
11770
11771 assert!(hits[2].name.is_none());
11772 assert!(hits[2].kind.is_none());
11773 }
11774
11775 #[tokio::test]
11777 async fn enrich_neighbor_hits_note_with_name_uses_name() {
11778 let rt = rt();
11779 let tok = NamespaceToken::local();
11780
11781 let note = rt
11782 .create_note(
11783 &tok,
11784 "insight",
11785 Some("NoteTitle"),
11786 "body",
11787 Some(0.5),
11788 None,
11789 vec![],
11790 )
11791 .await
11792 .unwrap();
11793
11794 let mut hits = vec![neighbor_hit(note.id)];
11795 rt.enrich_neighbor_hits(&tok, &mut hits).await;
11796
11797 assert_eq!(hits[0].name.as_deref(), Some("NoteTitle"));
11798 assert_eq!(hits[0].kind.as_deref(), Some("insight"));
11799 }
11800
11801 #[tokio::test]
11804 async fn enrich_path_nodes_batch_dedup_and_unresolved() {
11805 let rt = rt();
11806 let tok = NamespaceToken::local();
11807
11808 let ea = rt
11809 .create_entity(&tok, "concept", None, "Alpha", None, None, vec![])
11810 .await
11811 .unwrap();
11812 let eb = rt
11813 .create_entity(&tok, "document", None, "Beta", None, None, vec![])
11814 .await
11815 .unwrap();
11816 let bogus_id = Uuid::new_v4();
11817
11818 let mut paths = vec![
11820 GraphPath {
11821 root_id: ea.id,
11822 nodes: vec![
11823 path_node(ea.id, 0),
11824 path_node(eb.id, 1),
11825 path_node(bogus_id, 2),
11826 ],
11827 total_weight: 1.0,
11828 },
11829 GraphPath {
11830 root_id: eb.id,
11831 nodes: vec![path_node(eb.id, 0), path_node(ea.id, 1)],
11832 total_weight: 1.0,
11833 },
11834 ];
11835
11836 rt.enrich_path_nodes(&tok, &mut paths, false).await;
11837
11838 assert_eq!(paths[0].nodes[0].name.as_deref(), Some("Alpha"));
11839 assert_eq!(paths[0].nodes[0].kind.as_deref(), Some("concept"));
11840 assert_eq!(paths[0].nodes[1].name.as_deref(), Some("Beta"));
11841 assert_eq!(paths[0].nodes[1].kind.as_deref(), Some("document"));
11842 assert!(paths[0].nodes[2].name.is_none());
11843 assert!(paths[0].nodes[2].kind.is_none());
11844
11845 assert_eq!(paths[1].nodes[0].name.as_deref(), Some("Beta"));
11847 assert_eq!(paths[1].nodes[0].kind.as_deref(), Some("document"));
11848 assert_eq!(paths[1].nodes[1].name.as_deref(), Some("Alpha"));
11849 assert_eq!(paths[1].nodes[1].kind.as_deref(), Some("concept"));
11850 }
11851
11852 #[tokio::test]
11860 async fn enrich_resolves_entities_in_extra_visible_namespace() {
11861 let rt = KhiveRuntime::memory().unwrap();
11862
11863 let ns_a = Namespace::parse("enrich-ns-a").unwrap();
11864 let ns_b = Namespace::parse("enrich-ns-b").unwrap();
11865
11866 let tok_b = rt.authorize(ns_b.clone()).unwrap();
11867
11868 let entity_b = rt
11870 .create_entity(&tok_b, "concept", None, "EntityInB", None, None, vec![])
11871 .await
11872 .unwrap();
11873 assert_eq!(entity_b.namespace, "enrich-ns-b");
11874
11875 let vis_tok = rt
11877 .authorize_with_visibility(ns_a.clone(), vec![ns_b.clone()])
11878 .unwrap();
11879
11880 let mut hits = vec![neighbor_hit(entity_b.id)];
11882 rt.enrich_neighbor_hits(&vis_tok, &mut hits).await;
11883
11884 assert_eq!(
11885 hits[0].name.as_deref(),
11886 Some("EntityInB"),
11887 "entity in extra-visible ns must be enriched by enrich_neighbor_hits"
11888 );
11889 assert_eq!(hits[0].kind.as_deref(), Some("concept"));
11890
11891 let mut paths = vec![GraphPath {
11893 root_id: entity_b.id,
11894 nodes: vec![path_node(entity_b.id, 0)],
11895 total_weight: 1.0,
11896 }];
11897 rt.enrich_path_nodes(&vis_tok, &mut paths, false).await;
11898
11899 assert_eq!(
11900 paths[0].nodes[0].name.as_deref(),
11901 Some("EntityInB"),
11902 "entity in extra-visible ns must be enriched by enrich_path_nodes"
11903 );
11904 assert_eq!(paths[0].nodes[0].kind.as_deref(), Some("concept"));
11905 }
11906
11907 #[tokio::test]
11911 async fn enrich_neighbor_hits_populates_entity_type() {
11912 let rt = rt();
11913 let tok = NamespaceToken::local();
11914
11915 let props = serde_json::json!({"domain": "attention"});
11916 let entity = rt
11917 .create_entity(
11918 &tok,
11919 "concept",
11920 Some("algorithm"),
11921 "FlashAttn",
11922 None,
11923 Some(props),
11924 vec![],
11925 )
11926 .await
11927 .unwrap();
11928
11929 let entity_no_type = rt
11930 .create_entity(&tok, "concept", None, "PlainConcept", None, None, vec![])
11931 .await
11932 .unwrap();
11933
11934 let mut hits = vec![neighbor_hit(entity.id), neighbor_hit(entity_no_type.id)];
11935 rt.enrich_neighbor_hits(&tok, &mut hits).await;
11936
11937 assert_eq!(hits[0].entity_type.as_deref(), Some("algorithm"));
11938 assert!(
11939 hits[1].entity_type.is_none(),
11940 "entity without entity_type must leave the field as None"
11941 );
11942 }
11943
11944 #[tokio::test]
11948 async fn enrich_path_nodes_populates_properties() {
11949 let rt = rt();
11950 let tok = NamespaceToken::local();
11951
11952 let props = serde_json::json!({"year": 2024, "venue": "NeurIPS"});
11953 let entity_with_props = rt
11954 .create_entity(
11955 &tok,
11956 "document",
11957 None,
11958 "AttentionPaper",
11959 None,
11960 Some(props.clone()),
11961 vec![],
11962 )
11963 .await
11964 .unwrap();
11965
11966 let entity_no_props = rt
11967 .create_entity(&tok, "concept", None, "BareConceptNode", None, None, vec![])
11968 .await
11969 .unwrap();
11970
11971 let mut paths = vec![GraphPath {
11972 root_id: entity_with_props.id,
11973 nodes: vec![
11974 path_node(entity_with_props.id, 0),
11975 path_node(entity_no_props.id, 1),
11976 ],
11977 total_weight: 1.0,
11978 }];
11979
11980 rt.enrich_path_nodes(&tok, &mut paths, true).await;
11981
11982 assert_eq!(
11983 paths[0].nodes[0].properties.as_ref(),
11984 Some(&props),
11985 "properties must be filled when entity has a non-null properties blob"
11986 );
11987 assert!(
11988 paths[0].nodes[1].properties.is_none(),
11989 "entity without properties must leave the field as None"
11990 );
11991 }
11992
11993 #[tokio::test]
12003 async fn traverse_chunks_root_binds_over_host_param_limit() {
12004 use khive_storage::types::TraversalOptions;
12005
12006 let rt = rt();
12007 let tok = NamespaceToken::local();
12008 let graph = rt.graph(&tok).unwrap();
12009
12010 const N: usize = 1_000;
12011 let now = chrono::Utc::now();
12012
12013 let mut roots: Vec<uuid::Uuid> = Vec::with_capacity(N);
12014 let mut expected_children: std::collections::HashMap<uuid::Uuid, uuid::Uuid> =
12015 std::collections::HashMap::with_capacity(N);
12016
12017 for _ in 0..N {
12018 let root = uuid::Uuid::new_v4();
12019 let child = uuid::Uuid::new_v4();
12020 graph
12021 .upsert_edge(Edge {
12022 id: LinkId::from(uuid::Uuid::new_v4()),
12023 namespace: "local".to_string(),
12024 source_id: root,
12025 target_id: child,
12026 relation: EdgeRelation::Extends,
12027 weight: 1.0,
12028 created_at: now,
12029 updated_at: now,
12030 deleted_at: None,
12031 metadata: None,
12032 target_backend: None,
12033 })
12034 .await
12035 .unwrap();
12036 roots.push(root);
12037 expected_children.insert(root, child);
12038 }
12039
12040 let paths = graph
12042 .traverse(TraversalRequest {
12043 roots: roots.clone(),
12044 options: TraversalOptions {
12045 max_depth: 1,
12046 direction: Direction::Out,
12047 relations: None,
12048 min_weight: None,
12049 limit: None,
12050 },
12051 include_roots: false,
12052 include_properties: false,
12053 })
12054 .await
12055 .unwrap();
12056
12057 assert_eq!(
12058 paths.len(),
12059 N,
12060 "traverse over {N} roots must return one GraphPath per root"
12061 );
12062
12063 for path in &paths {
12064 let expected_child = expected_children[&path.root_id];
12065 assert_eq!(
12066 path.nodes.len(),
12067 1,
12068 "root {:?} must reach exactly 1 node",
12069 path.root_id
12070 );
12071 assert_eq!(
12072 path.nodes[0].node_id, expected_child,
12073 "root {:?} must reach its direct child",
12074 path.root_id
12075 );
12076 }
12077 }
12078
12079 #[test]
12094 fn pack_entity_of_type_rules_do_not_shadow_base_entity_of_kind_rule() {
12095 let pack_rules: Vec<EdgeEndpointRule> = vec![
12098 EdgeEndpointRule {
12099 relation: EdgeRelation::VariantOf,
12100 source: EndpointKind::EntityOfType {
12101 kind: "concept",
12102 entity_type: "goal",
12103 },
12104 target: EndpointKind::EntityOfType {
12105 kind: "concept",
12106 entity_type: "theorem",
12107 },
12108 },
12109 EdgeEndpointRule {
12110 relation: EdgeRelation::VariantOf,
12111 source: EndpointKind::EntityOfType {
12112 kind: "concept",
12113 entity_type: "goal",
12114 },
12115 target: EndpointKind::EntityOfType {
12116 kind: "concept",
12117 entity_type: "definition",
12118 },
12119 },
12120 ];
12121
12122 let goal_a =
12123 Resolved::Entity(Entity::new("local", "concept", "G-a").with_entity_type(Some("goal")));
12124 let goal_b =
12125 Resolved::Entity(Entity::new("local", "concept", "G-b").with_entity_type(Some("goal")));
12126
12127 assert!(
12129 !pack_rule_allows(
12130 &pack_rules,
12131 EdgeRelation::VariantOf,
12132 Some(&goal_a),
12133 Some(&goal_b)
12134 ),
12135 "pack rules must not cover goal->goal variant_of (no such rule declared)"
12136 );
12137
12138 assert!(
12141 base_entity_rule_allows("concept", EdgeRelation::VariantOf, "concept"),
12142 "base contract must allow concept->concept variant_of regardless of pack EntityOfType rules"
12143 );
12144 }
12145
12146 #[tokio::test]
12151 async fn link_variant_of_goal_to_goal_allowed_when_pack_has_entity_of_type_rules() {
12152 let rt = rt();
12153 let tok = NamespaceToken::local();
12154
12155 rt.install_edge_rules(vec![
12158 EdgeEndpointRule {
12159 relation: EdgeRelation::VariantOf,
12160 source: EndpointKind::EntityOfType {
12161 kind: "concept",
12162 entity_type: "goal",
12163 },
12164 target: EndpointKind::EntityOfType {
12165 kind: "concept",
12166 entity_type: "theorem",
12167 },
12168 },
12169 EdgeEndpointRule {
12170 relation: EdgeRelation::VariantOf,
12171 source: EndpointKind::EntityOfType {
12172 kind: "concept",
12173 entity_type: "goal",
12174 },
12175 target: EndpointKind::EntityOfType {
12176 kind: "concept",
12177 entity_type: "definition",
12178 },
12179 },
12180 ]);
12181
12182 let a = rt
12183 .create_entity(
12184 &tok,
12185 "concept",
12186 Some("goal"),
12187 "Goal Alpha",
12188 None,
12189 None,
12190 vec![],
12191 )
12192 .await
12193 .unwrap();
12194 let b = rt
12195 .create_entity(
12196 &tok,
12197 "concept",
12198 Some("goal"),
12199 "Goal Beta",
12200 None,
12201 None,
12202 vec![],
12203 )
12204 .await
12205 .unwrap();
12206
12207 let result = rt
12210 .link(&tok, a.id, b.id, EdgeRelation::VariantOf, 1.0, None)
12211 .await;
12212 assert!(
12213 result.is_ok(),
12214 "goal->goal variant_of must be allowed via the base concept->concept rule \
12215 even when EntityOfType rules for variant_of are installed; got {result:?}"
12216 );
12217
12218 let p = rt
12223 .create_entity(&tok, "project", None, "Proj", None, None, vec![])
12224 .await
12225 .unwrap();
12226 let bad = rt
12227 .link(&tok, a.id, p.id, EdgeRelation::VariantOf, 1.0, None)
12228 .await;
12229 assert!(
12230 bad.is_err(),
12231 "additive pack rules must not make validation fail-open; \
12232 goal(concept)->project variant_of must be rejected (pack miss + base miss); \
12233 got {bad:?}"
12234 );
12235 }
12236
12237 #[tokio::test]
12242 async fn link_depends_on_theorem_to_definition_allowed_only_via_pack_rule() {
12243 let rt = rt();
12244 let tok = NamespaceToken::local();
12245
12246 assert!(
12249 !base_entity_rule_allows("concept", EdgeRelation::DependsOn, "concept"),
12250 "base contract must not allow concept->concept DependsOn; \
12251 test would be vacuous if this precondition fails"
12252 );
12253
12254 rt.install_edge_rules(vec![EdgeEndpointRule {
12258 relation: EdgeRelation::DependsOn,
12259 source: EndpointKind::EntityOfType {
12260 kind: "concept",
12261 entity_type: "theorem",
12262 },
12263 target: EndpointKind::EntityOfType {
12264 kind: "concept",
12265 entity_type: "definition",
12266 },
12267 }]);
12268
12269 let thm = rt
12270 .create_entity(&tok, "concept", Some("theorem"), "T1", None, None, vec![])
12271 .await
12272 .unwrap();
12273 let def = rt
12274 .create_entity(
12275 &tok,
12276 "concept",
12277 Some("definition"),
12278 "D1",
12279 None,
12280 None,
12281 vec![],
12282 )
12283 .await
12284 .unwrap();
12285
12286 let result = rt
12288 .link(&tok, thm.id, def.id, EdgeRelation::DependsOn, 1.0, None)
12289 .await;
12290 assert!(
12291 result.is_ok(),
12292 "theorem->definition DependsOn must be allowed by the installed pack rule; \
12293 the base contract has no concept->concept DependsOn row; got {result:?}"
12294 );
12295 }
12296
12297 #[tokio::test]
12304 async fn link_document_introduced_by_person_allowed() {
12305 let rt = rt();
12306 let tok = NamespaceToken::local();
12307
12308 let doc = rt
12309 .create_entity(&tok, "document", None, "Paper", None, None, vec![])
12310 .await
12311 .unwrap();
12312 let author = rt
12313 .create_entity(&tok, "person", None, "Author", None, None, vec![])
12314 .await
12315 .unwrap();
12316
12317 let result = rt
12318 .link(
12319 &tok,
12320 doc.id,
12321 author.id,
12322 EdgeRelation::IntroducedBy,
12323 1.0,
12324 None,
12325 )
12326 .await;
12327 assert!(
12328 result.is_ok(),
12329 "document->person introduced_by must be allowed by the ADR-002 \
12330 endpoint amendment; got {result:?}"
12331 );
12332 }
12333
12334 #[tokio::test]
12335 async fn link_document_introduced_by_org_allowed() {
12336 let rt = rt();
12337 let tok = NamespaceToken::local();
12338
12339 let doc = rt
12340 .create_entity(&tok, "document", None, "Whitepaper", None, None, vec![])
12341 .await
12342 .unwrap();
12343 let org = rt
12344 .create_entity(&tok, "org", None, "Publisher", None, None, vec![])
12345 .await
12346 .unwrap();
12347
12348 let result = rt
12349 .link(&tok, doc.id, org.id, EdgeRelation::IntroducedBy, 1.0, None)
12350 .await;
12351 assert!(
12352 result.is_ok(),
12353 "document->org introduced_by must be allowed by the ADR-002 \
12354 endpoint amendment; got {result:?}"
12355 );
12356 }
12357
12358 #[tokio::test]
12359 async fn link_concept_introduced_by_org_allowed() {
12360 let rt = rt();
12361 let tok = NamespaceToken::local();
12362
12363 let concept = rt
12364 .create_entity(&tok, "concept", None, "Architecture", None, None, vec![])
12365 .await
12366 .unwrap();
12367 let org = rt
12368 .create_entity(&tok, "org", None, "Originator", None, None, vec![])
12369 .await
12370 .unwrap();
12371
12372 let result = rt
12373 .link(
12374 &tok,
12375 concept.id,
12376 org.id,
12377 EdgeRelation::IntroducedBy,
12378 1.0,
12379 None,
12380 )
12381 .await;
12382 assert!(
12383 result.is_ok(),
12384 "concept->org introduced_by must be allowed by the ADR-002 \
12385 endpoint amendment; got {result:?}"
12386 );
12387 }
12388
12389 #[tokio::test]
12390 async fn link_document_depends_on_document_allowed() {
12391 let rt = rt();
12392 let tok = NamespaceToken::local();
12393
12394 let doc_a = rt
12395 .create_entity(&tok, "document", None, "Spec A", None, None, vec![])
12396 .await
12397 .unwrap();
12398 let doc_b = rt
12399 .create_entity(&tok, "document", None, "Spec B", None, None, vec![])
12400 .await
12401 .unwrap();
12402
12403 let result = rt
12404 .link(&tok, doc_a.id, doc_b.id, EdgeRelation::DependsOn, 1.0, None)
12405 .await;
12406 assert!(
12407 result.is_ok(),
12408 "document->document depends_on must be allowed by the ADR-002 \
12409 endpoint amendment; got {result:?}"
12410 );
12411 let edge = result.unwrap();
12412 let dk = edge
12413 .metadata
12414 .as_ref()
12415 .and_then(|m| m.get("dependency_kind"))
12416 .and_then(|v| v.as_str());
12417 assert_eq!(
12418 dk,
12419 Some("normative"),
12420 "document->document depends_on must infer dependency_kind=normative"
12421 );
12422 }
12423
12424 #[tokio::test]
12425 async fn link_org_introduced_by_document_rejected_direction_matters() {
12426 let rt = rt();
12427 let tok = NamespaceToken::local();
12428
12429 let org = rt
12430 .create_entity(&tok, "org", None, "Publisher", None, None, vec![])
12431 .await
12432 .unwrap();
12433 let doc = rt
12434 .create_entity(&tok, "document", None, "Paper", None, None, vec![])
12435 .await
12436 .unwrap();
12437
12438 let result = rt
12441 .link(&tok, org.id, doc.id, EdgeRelation::IntroducedBy, 1.0, None)
12442 .await;
12443 assert!(
12444 result.is_err(),
12445 "org->document introduced_by must remain rejected; only \
12446 document->org is permitted, not the reverse; got {result:?}"
12447 );
12448 }
12449
12450 struct CapturingVecService {
12457 dims: usize,
12458 captured: Arc<std::sync::Mutex<Vec<String>>>,
12459 }
12460
12461 #[async_trait]
12462 impl EmbeddingService for CapturingVecService {
12463 async fn embed(
12464 &self,
12465 texts: &[String],
12466 _model: EmbeddingModel,
12467 ) -> std::result::Result<Vec<Vec<f32>>, EmbedError> {
12468 self.captured.lock().unwrap().extend(texts.iter().cloned());
12469 Ok(texts.iter().map(|_| vec![1.0_f32; self.dims]).collect())
12470 }
12471
12472 fn supports_model(&self, _model: EmbeddingModel) -> bool {
12473 true
12474 }
12475
12476 fn name(&self) -> &'static str {
12477 "capturing-vec"
12478 }
12479 }
12480
12481 struct CapturingVecProvider {
12482 provider_name: String,
12483 dims: usize,
12484 captured: Arc<std::sync::Mutex<Vec<String>>>,
12485 }
12486
12487 #[async_trait]
12488 impl EmbedderProvider for CapturingVecProvider {
12489 fn name(&self) -> &str {
12490 &self.provider_name
12491 }
12492
12493 fn dimensions(&self) -> usize {
12494 self.dims
12495 }
12496
12497 async fn build(&self) -> crate::error::RuntimeResult<Arc<dyn EmbeddingService>> {
12498 Ok(Arc::new(CapturingVecService {
12499 dims: self.dims,
12500 captured: Arc::clone(&self.captured),
12501 }))
12502 }
12503 }
12504
12505 #[tokio::test]
12506 async fn create_note_with_embedding_content_none_matches_create_note() {
12507 let rt = rt();
12508 let tok = NamespaceToken::local();
12509 let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
12510 rt.register_embedder(CapturingVecProvider {
12511 provider_name: "capturing-vec".into(),
12512 dims: 4,
12513 captured: Arc::clone(&captured),
12514 });
12515
12516 let note = rt
12517 .create_note_with_embedding_content(
12518 &tok,
12519 "observation",
12520 None,
12521 "full content, no override",
12522 None,
12523 None,
12524 None,
12525 vec![],
12526 )
12527 .await
12528 .expect("create with None override must behave like create_note");
12529 assert_eq!(note.content, "full content, no override");
12530 let seen = captured.lock().unwrap().clone();
12531 assert_eq!(
12532 seen,
12533 vec!["full content, no override".to_string()],
12534 "with no override the embedder must see the full content"
12535 );
12536 }
12537
12538 #[tokio::test]
12539 async fn create_note_with_embedding_content_embeds_capped_override_and_stores_full_content() {
12540 let rt = rt();
12541 let tok = NamespaceToken::local();
12542 let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
12543 rt.register_embedder(CapturingVecProvider {
12544 provider_name: "capturing-vec".into(),
12545 dims: 4,
12546 captured: Arc::clone(&captured),
12547 });
12548
12549 let full = "head-term and then a very long tail-term that exceeds any cap";
12550 let head = &full[.."head-term and then a very long".len()];
12551
12552 let note = rt
12553 .create_note_with_embedding_content(
12554 &tok,
12555 "observation",
12556 None,
12557 full,
12558 Some(head),
12559 None,
12560 None,
12561 vec![],
12562 )
12563 .await
12564 .expect("proper-prefix override must be accepted");
12565 assert_eq!(note.content, full, "stored content must be the full text");
12566
12567 let seen = captured.lock().unwrap().clone();
12568 assert_eq!(
12569 seen,
12570 vec![head.to_string()],
12571 "embedder must see only the capped override"
12572 );
12573 }
12574
12575 #[tokio::test]
12576 async fn create_note_with_embedding_content_fans_out_identical_override_to_multiple_models() {
12577 let rt = rt();
12578 let tok = NamespaceToken::local();
12579 let captured_a = Arc::new(std::sync::Mutex::new(Vec::new()));
12580 let captured_b = Arc::new(std::sync::Mutex::new(Vec::new()));
12581 rt.register_embedder(CapturingVecProvider {
12582 provider_name: "capturing-vec-a".into(),
12583 dims: 4,
12584 captured: Arc::clone(&captured_a),
12585 });
12586 rt.register_embedder(CapturingVecProvider {
12587 provider_name: "capturing-vec-b".into(),
12588 dims: 4,
12589 captured: Arc::clone(&captured_b),
12590 });
12591
12592 let full = "head-only-embedded plus a long discarded tail";
12593 let head = &full[.."head-only-embedded".len()];
12594
12595 rt.create_note_with_embedding_content(
12596 &tok,
12597 "observation",
12598 None,
12599 full,
12600 Some(head),
12601 None,
12602 None,
12603 vec![],
12604 )
12605 .await
12606 .expect("create ok");
12607
12608 assert_eq!(
12609 captured_a.lock().unwrap().clone(),
12610 vec![head.to_string()],
12611 "model A must receive the identical capped override"
12612 );
12613 assert_eq!(
12614 captured_b.lock().unwrap().clone(),
12615 vec![head.to_string()],
12616 "model B must receive the identical capped override"
12617 );
12618
12619 let vs_a = rt
12622 .vectors_for_model(&tok, "capturing-vec-a")
12623 .expect("vector store for model A");
12624 assert_eq!(
12625 vs_a.count().await.expect("vector count A"),
12626 1,
12627 "model A must have exactly one vector row for the note"
12628 );
12629 let vs_b = rt
12630 .vectors_for_model(&tok, "capturing-vec-b")
12631 .expect("vector store for model B");
12632 assert_eq!(
12633 vs_b.count().await.expect("vector count B"),
12634 1,
12635 "model B must have exactly one vector row for the note"
12636 );
12637 }
12638
12639 #[tokio::test]
12640 async fn create_note_with_embedding_content_rejects_empty_override() {
12641 let rt = rt();
12642 let tok = NamespaceToken::local();
12643
12644 let err = rt
12645 .create_note_with_embedding_content(
12646 &tok,
12647 "observation",
12648 None,
12649 "some content",
12650 Some(""),
12651 None,
12652 None,
12653 vec![],
12654 )
12655 .await
12656 .expect_err("empty override must be rejected");
12657 assert!(matches!(err, RuntimeError::InvalidInput(_)));
12658 }
12659
12660 #[tokio::test]
12661 async fn create_note_with_embedding_content_rejects_non_prefix_override() {
12662 let rt = rt();
12663 let tok = NamespaceToken::local();
12664
12665 let err = rt
12666 .create_note_with_embedding_content(
12667 &tok,
12668 "observation",
12669 None,
12670 "the actual content",
12671 Some("an unrelated string"),
12672 None,
12673 None,
12674 vec![],
12675 )
12676 .await
12677 .expect_err("non-prefix override must be rejected");
12678 assert!(matches!(err, RuntimeError::InvalidInput(ref m) if m.contains("prefix")));
12679 }
12680
12681 #[tokio::test]
12682 async fn create_note_with_embedding_content_rejects_equal_length_override() {
12683 let rt = rt();
12684 let tok = NamespaceToken::local();
12685
12686 let err = rt
12688 .create_note_with_embedding_content(
12689 &tok,
12690 "observation",
12691 None,
12692 "identical text",
12693 Some("identical text"),
12694 None,
12695 None,
12696 vec![],
12697 )
12698 .await
12699 .expect_err("an equal-length override must be rejected as not a proper prefix");
12700 assert!(matches!(err, RuntimeError::InvalidInput(ref m) if m.contains("prefix")));
12701 }
12702
12703 #[tokio::test]
12704 async fn create_note_with_embedding_content_rejects_secret_bearing_override() {
12705 let rt = rt();
12706 let tok = NamespaceToken::local();
12707
12708 let token_span = "ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
12709 let content = format!("{token_span} plus extra trailing content beyond the override");
12710 let embedding_content = format!("{token_span} plus extra");
12711
12712 let err = rt
12713 .create_note_with_embedding_content(
12714 &tok,
12715 "observation",
12716 None,
12717 &content,
12718 Some(&embedding_content),
12719 None,
12720 None,
12721 vec![],
12722 )
12723 .await
12724 .expect_err("a credential-shaped override must fail the secret gate");
12725 assert!(
12726 matches!(err, RuntimeError::SecretDetected(_)),
12727 "expected SecretDetected, got {err:?}"
12728 );
12729
12730 let count = rt
12732 .notes(&tok)
12733 .unwrap()
12734 .count_notes(tok.namespace().as_str(), None)
12735 .await
12736 .unwrap();
12737 assert_eq!(count, 0, "a rejected create must leave no note behind");
12738 }
12739}