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 neighbors_with_query_directed(
1949 &self,
1950 token: &NamespaceToken,
1951 node_id: Uuid,
1952 query: NeighborQuery,
1953 ) -> RuntimeResult<Vec<(NeighborHit, Direction)>> {
1954 if !self.substrate_exists_in_ns(token, node_id).await? {
1955 return Ok(Vec::new());
1956 }
1957
1958 let mut hits: Vec<DirectedNeighborHit> = Vec::new();
1959 for ns in token.visible_namespaces() {
1960 let temp = NamespaceToken::for_namespace(ns.clone());
1961 let mut ns_hits = self
1962 .graph(&temp)?
1963 .neighbors_both_directions(node_id, query.clone())
1964 .await?;
1965 hits.append(&mut ns_hits);
1966 }
1967 hits.sort_by_key(|h| {
1971 (
1972 h.hit.node_id,
1973 h.hit.edge_id,
1974 direction_sort_rank(&h.direction),
1975 )
1976 });
1977 hits.dedup_by_key(|h| {
1978 (
1979 h.hit.node_id,
1980 h.hit.edge_id,
1981 direction_sort_rank(&h.direction),
1982 )
1983 });
1984
1985 let mut plain_hits: Vec<NeighborHit> = hits.iter().map(|h| h.hit.clone()).collect();
1986 self.enrich_neighbor_hits(token, &mut plain_hits).await;
1987 for (dh, enriched) in hits.iter_mut().zip(plain_hits) {
1988 dh.hit = enriched;
1989 }
1990
1991 let candidate_ids: Vec<Uuid> = hits.iter().map(|h| h.hit.node_id).collect();
1993 let deleted = self.deleted_entity_ids(candidate_ids).await;
1994 if !deleted.is_empty() {
1995 hits.retain(|h| !deleted.contains(&h.hit.node_id));
1996 }
1997 hits.sort_by(|a, b| {
2001 b.hit
2002 .weight
2003 .partial_cmp(&a.hit.weight)
2004 .unwrap_or(std::cmp::Ordering::Equal)
2005 .then(a.hit.node_id.cmp(&b.hit.node_id))
2006 });
2007 Ok(hits.into_iter().map(|h| (h.hit, h.direction)).collect())
2008 }
2009
2010 pub async fn traverse(
2015 &self,
2016 token: &NamespaceToken,
2017 request: TraversalRequest,
2018 ) -> RuntimeResult<Vec<GraphPath>> {
2019 let mut request = request;
2020 let mut visible_roots = Vec::with_capacity(request.roots.len());
2021 for root in request.roots.drain(..) {
2022 if self.substrate_exists_in_ns(token, root).await? {
2023 visible_roots.push(root);
2024 }
2025 }
2026 request.roots = visible_roots;
2027 if request.roots.is_empty() {
2028 return Ok(Vec::new());
2029 }
2030
2031 let mut paths = Vec::new();
2032 for ns in token.visible_namespaces() {
2033 let temp = NamespaceToken::for_namespace(ns.clone());
2034 let mut ns_paths = self.graph(&temp)?.traverse(request.clone()).await?;
2035 paths.append(&mut ns_paths);
2036 }
2037 self.enrich_path_nodes(token, &mut paths, request.include_properties)
2038 .await;
2039 let all_node_ids: Vec<Uuid> = paths
2041 .iter()
2042 .flat_map(|p| p.nodes.iter().map(|n| n.node_id))
2043 .collect();
2044 let deleted = self.deleted_entity_ids(all_node_ids).await;
2045 if !deleted.is_empty() {
2046 for path in paths.iter_mut() {
2047 path.nodes.retain(|n| !deleted.contains(&n.node_id));
2048 }
2049 paths.retain(|p| !p.nodes.is_empty());
2050 }
2051 Ok(paths)
2052 }
2053
2054 async fn deleted_entity_ids(&self, ids: Vec<Uuid>) -> std::collections::HashSet<Uuid> {
2067 if ids.is_empty() {
2068 return std::collections::HashSet::new();
2069 }
2070 let id_strs: Vec<String> = ids.iter().map(|u| u.to_string()).collect();
2071 let n = id_strs.len();
2072 let entities_placeholders = (0..n)
2079 .map(|i| format!("?{}", i + 1))
2080 .collect::<Vec<_>>()
2081 .join(",");
2082 let notes_placeholders = (0..n)
2083 .map(|i| format!("?{}", n + i + 1))
2084 .collect::<Vec<_>>()
2085 .join(",");
2086 let sql_str = format!(
2087 "SELECT id FROM entities WHERE id IN ({entities_placeholders}) AND deleted_at IS NOT NULL \
2088 UNION \
2089 SELECT id FROM notes WHERE id IN ({notes_placeholders}) AND deleted_at IS NOT NULL"
2090 );
2091 let params: Vec<SqlValue> = id_strs
2093 .iter()
2094 .chain(id_strs.iter())
2095 .cloned()
2096 .map(SqlValue::Text)
2097 .collect();
2098 let stmt = SqlStatement {
2099 sql: sql_str,
2100 params,
2101 label: Some("deleted_entity_ids".into()),
2102 };
2103 let mut out = std::collections::HashSet::new();
2104 let sql = self.sql();
2105 if let Ok(mut reader) = sql.reader().await {
2106 if let Ok(rows) = reader.query_all(stmt).await {
2107 for row in rows {
2108 if let Some(col) = row.columns.first() {
2109 if let SqlValue::Text(s) = &col.value {
2110 if let Ok(u) = s.parse::<Uuid>() {
2111 out.insert(u);
2112 }
2113 }
2114 }
2115 }
2116 }
2117 }
2119 out
2120 }
2121
2122 async fn enrich_neighbor_hits(&self, token: &NamespaceToken, hits: &mut [NeighborHit]) {
2131 if hits.is_empty() {
2132 return;
2133 }
2134
2135 let unique_ids: Vec<Uuid> = {
2137 let mut seen = std::collections::HashSet::new();
2138 hits.iter()
2139 .filter_map(|h| {
2140 if seen.insert(h.node_id) {
2141 Some(h.node_id)
2142 } else {
2143 None
2144 }
2145 })
2146 .collect()
2147 };
2148
2149 let entity_map: HashMap<Uuid, Entity> = self
2150 .get_entities_by_ids_visible(token, &unique_ids)
2151 .await
2152 .unwrap_or_default()
2153 .into_iter()
2154 .map(|e| (e.id, e))
2155 .collect();
2156
2157 let residual_ids: Vec<Uuid> = unique_ids
2159 .iter()
2160 .filter(|id| !entity_map.contains_key(id))
2161 .copied()
2162 .collect();
2163
2164 let note_map: HashMap<Uuid, Note> = if !residual_ids.is_empty() {
2165 if let Ok(store) = self.notes(token) {
2166 store
2167 .get_notes_batch(&residual_ids)
2168 .await
2169 .unwrap_or_default()
2170 .into_iter()
2171 .map(|n| (n.id, n))
2172 .collect()
2173 } else {
2174 HashMap::new()
2175 }
2176 } else {
2177 HashMap::new()
2178 };
2179
2180 for hit in hits.iter_mut() {
2181 if let Some(entity) = entity_map.get(&hit.node_id) {
2182 hit.name = Some(entity.name.clone());
2183 hit.kind = Some(entity.kind.clone());
2184 hit.entity_type = entity.entity_type.clone();
2185 } else if let Some(note) = note_map.get(&hit.node_id) {
2186 let kind = note.kind.clone();
2187 let name = note
2188 .name
2189 .as_deref()
2190 .filter(|s| !s.trim().is_empty())
2191 .map(|s| s.to_owned())
2192 .unwrap_or_else(|| format!("[{kind}]"));
2193 hit.name = Some(name);
2194 hit.kind = Some(kind);
2195 }
2196 }
2197 }
2198
2199 async fn enrich_path_nodes(
2210 &self,
2211 token: &NamespaceToken,
2212 paths: &mut [GraphPath],
2213 include_properties: bool,
2214 ) {
2215 if paths.is_empty() {
2216 return;
2217 }
2218
2219 let unique_ids: Vec<Uuid> = {
2221 let mut seen = std::collections::HashSet::new();
2222 paths
2223 .iter()
2224 .flat_map(|p| p.nodes.iter())
2225 .filter_map(|n| {
2226 if seen.insert(n.node_id) {
2227 Some(n.node_id)
2228 } else {
2229 None
2230 }
2231 })
2232 .collect()
2233 };
2234
2235 let entity_map: HashMap<Uuid, Entity> = self
2236 .get_entities_by_ids_visible(token, &unique_ids)
2237 .await
2238 .unwrap_or_default()
2239 .into_iter()
2240 .map(|e| (e.id, e))
2241 .collect();
2242
2243 for path in paths.iter_mut() {
2244 for node in path.nodes.iter_mut() {
2245 if let Some(entity) = entity_map.get(&node.node_id) {
2246 node.name = Some(entity.name.clone());
2247 node.kind = Some(entity.kind.clone());
2248 if include_properties {
2249 node.properties = entity.properties.clone();
2250 }
2251 }
2252 }
2253 }
2254 }
2255
2256 #[allow(clippy::too_many_arguments)]
2270 pub async fn create_note(
2271 &self,
2272 token: &NamespaceToken,
2273 kind: &str,
2274 name: Option<&str>,
2275 content: &str,
2276 salience: Option<f64>,
2277 properties: Option<serde_json::Value>,
2278 annotates: Vec<Uuid>,
2279 ) -> RuntimeResult<Note> {
2280 self.create_note_inner(
2281 token, kind, name, content, None, salience, None, properties, annotates, None,
2282 )
2283 .await
2284 }
2285
2286 #[allow(clippy::too_many_arguments)]
2297 pub async fn create_note_with_embedding_content(
2298 &self,
2299 token: &NamespaceToken,
2300 kind: &str,
2301 name: Option<&str>,
2302 content: &str,
2303 embedding_content: Option<&str>,
2304 salience: Option<f64>,
2305 properties: Option<serde_json::Value>,
2306 annotates: Vec<Uuid>,
2307 ) -> RuntimeResult<Note> {
2308 self.create_note_inner(
2309 token,
2310 kind,
2311 name,
2312 content,
2313 embedding_content,
2314 salience,
2315 None,
2316 properties,
2317 annotates,
2318 None,
2319 )
2320 .await
2321 }
2322
2323 #[allow(clippy::too_many_arguments)]
2327 pub async fn create_note_with_decay(
2328 &self,
2329 token: &NamespaceToken,
2330 kind: &str,
2331 name: Option<&str>,
2332 content: &str,
2333 salience: Option<f64>,
2334 decay_factor: f64,
2335 properties: Option<serde_json::Value>,
2336 annotates: Vec<Uuid>,
2337 ) -> RuntimeResult<Note> {
2338 self.create_note_with_decay_for_embedding_model(
2339 token,
2340 kind,
2341 name,
2342 content,
2343 salience,
2344 decay_factor,
2345 properties,
2346 annotates,
2347 None,
2348 )
2349 .await
2350 }
2351
2352 #[allow(clippy::too_many_arguments)]
2357 pub async fn create_note_with_decay_for_embedding_model(
2358 &self,
2359 token: &NamespaceToken,
2360 kind: &str,
2361 name: Option<&str>,
2362 content: &str,
2363 salience: Option<f64>,
2364 decay_factor: f64,
2365 properties: Option<serde_json::Value>,
2366 annotates: Vec<Uuid>,
2367 embedding_model: Option<&str>,
2368 ) -> RuntimeResult<Note> {
2369 self.create_note_inner(
2370 token,
2371 kind,
2372 name,
2373 content,
2374 None,
2375 salience,
2376 Some(decay_factor),
2377 properties,
2378 annotates,
2379 embedding_model,
2380 )
2381 .await
2382 }
2383
2384 pub async fn try_create_note(
2397 &self,
2398 token: &NamespaceToken,
2399 kind: &str,
2400 name: Option<&str>,
2401 content: &str,
2402 properties: Option<serde_json::Value>,
2403 ) -> RuntimeResult<Option<Note>> {
2404 self.validate_note_kind(kind)?;
2405 crate::secret_gate::check(content)?;
2406 if let Some(n) = name {
2407 crate::secret_gate::check(n)?;
2408 }
2409 if let Some(ref p) = properties {
2410 crate::secret_gate::check_json(p)?;
2411 }
2412
2413 let ns = token.namespace().as_str();
2414 let mut note = Note::new(ns, kind, content);
2415 if let Some(n) = name {
2416 note = note.with_name(n);
2417 }
2418 if let Some(p) = properties {
2419 note = note.with_properties(p);
2420 }
2421
2422 let inserted = self.notes(token)?.try_insert_note(note.clone()).await?;
2423 if !inserted {
2424 return Ok(None);
2425 }
2426
2427 if let Ok(fts) = self.text_for_notes(token) {
2429 if let Err(e) = fts.upsert_document(note_fts_document(¬e)).await {
2430 tracing::warn!(
2431 note_id = %note.id,
2432 error = %e,
2433 "try_create_note: FTS indexing failed (non-fatal)"
2434 );
2435 }
2436 }
2437
2438 let embed_model_names = self.registered_embedding_model_names();
2440 for model_name in &embed_model_names {
2441 match self
2442 .embed_document_with_model(model_name, ¬e.content)
2443 .await
2444 {
2445 Ok(vector) => {
2446 if let Ok(vs) = self.vectors_for_model(token, model_name) {
2447 if let Err(e) = vs
2448 .insert(
2449 note.id,
2450 SubstrateKind::Note,
2451 ns,
2452 "note.content",
2453 vec![vector],
2454 )
2455 .await
2456 {
2457 tracing::warn!(
2458 note_id = %note.id,
2459 model = %model_name,
2460 error = %e,
2461 "try_create_note: vector insert failed (non-fatal)"
2462 );
2463 }
2464 }
2465 }
2466 Err(e) => {
2467 tracing::warn!(
2468 note_id = %note.id,
2469 model = %model_name,
2470 error = %e,
2471 "try_create_note: embedding failed (non-fatal)"
2472 );
2473 }
2474 }
2475 }
2476
2477 Ok(Some(note))
2478 }
2479
2480 #[allow(clippy::too_many_arguments)]
2484 async fn create_note_inner(
2485 &self,
2486 token: &NamespaceToken,
2487 kind: &str,
2488 name: Option<&str>,
2489 content: &str,
2490 embedding_content: Option<&str>,
2491 salience: Option<f64>,
2492 decay_factor: Option<f64>,
2493 properties: Option<serde_json::Value>,
2494 annotates: Vec<Uuid>,
2495 embedding_model: Option<&str>,
2496 ) -> RuntimeResult<Note> {
2497 self.validate_note_kind(kind)?;
2498 crate::secret_gate::check(content)?;
2500 if let Some(n) = name {
2501 crate::secret_gate::check(n)?;
2502 }
2503 if let Some(ref p) = properties {
2504 crate::secret_gate::check_json(p)?;
2505 }
2506 if let Some(ec) = embedding_content {
2512 if ec.is_empty() {
2513 return Err(RuntimeError::InvalidInput(
2514 "embedding_content must not be empty".into(),
2515 ));
2516 }
2517 if ec.len() >= content.len() || !content.starts_with(ec) {
2518 return Err(RuntimeError::InvalidInput(
2519 "embedding_content must be a proper prefix of content".into(),
2520 ));
2521 }
2522 crate::secret_gate::check(ec)?;
2523 }
2524 let ns = token.namespace().as_str();
2525
2526 for &target_id in &annotates {
2529 if !self.substrate_exists_by_id(token, target_id).await? {
2530 return Err(RuntimeError::NotFound(format!(
2531 "create_note annotates target {target_id} not found"
2532 )));
2533 }
2534 }
2535
2536 if let Some(s) = salience {
2539 if !s.is_finite() || !(0.0..=1.0).contains(&s) {
2540 return Err(RuntimeError::InvalidInput(format!(
2541 "salience must be a finite value in [0.0, 1.0]; got {s}"
2542 )));
2543 }
2544 }
2545 if let Some(d) = decay_factor {
2546 if !d.is_finite() || d < 0.0 {
2547 return Err(RuntimeError::InvalidInput(format!(
2548 "decay_factor must be a finite value >= 0.0; got {d}"
2549 )));
2550 }
2551 }
2552
2553 if let Some(model_name) = embedding_model {
2557 self.resolve_embedding_model(Some(model_name))?;
2558 }
2559
2560 let mut note = Note::new(ns, kind, content);
2561 if let Some(s) = salience {
2562 note = note.with_salience(s);
2563 }
2564 if let Some(df) = decay_factor {
2565 note = note.with_decay(df);
2566 }
2567 if let Some(n) = name {
2568 note = note.with_name(n);
2569 }
2570 if let Some(p) = properties {
2571 note = note.with_properties(p);
2572 }
2573 self.notes(token)?.upsert_note(note.clone()).await?;
2574
2575 let embed_model_names: Vec<String> = if let Some(m) = embedding_model {
2581 vec![m.to_string()]
2582 } else {
2583 let names = self.registered_embedding_model_names();
2589 if names.is_empty() {
2590 vec![]
2592 } else {
2593 names
2594 }
2595 };
2596
2597 {
2599 #[cfg(any(test, feature = "fault-injection"))]
2605 let fts_inject = {
2606 let mut g = FTS_FAIL_NS.lock().unwrap();
2607 if g.as_deref() == Some(ns) {
2608 *g = None;
2609 true
2610 } else {
2611 false
2612 }
2613 };
2614 #[cfg(not(any(test, feature = "fault-injection")))]
2615 let fts_inject = false;
2616 let fts_result: RuntimeResult<()> = if fts_inject {
2617 Err(RuntimeError::Internal("injected FTS failure".to_string()))
2618 } else {
2619 match self.text_for_notes(token) {
2620 Ok(fts) => fts
2621 .upsert_document(note_fts_document(¬e))
2622 .await
2623 .map_err(RuntimeError::from),
2624 Err(e) => Err(e),
2625 }
2626 };
2627
2628 if let Err(e) = fts_result {
2629 if let Ok(store) = self.notes(token) {
2631 let _ = store.delete_note(note.id, DeleteMode::Hard).await;
2632 }
2633 return Err(e);
2634 }
2635 }
2636
2637 let embed_text: &str = embedding_content.unwrap_or(content);
2647
2648 if embed_model_names.len() == 1 {
2649 let model_name = &embed_model_names[0];
2651 let vec_result = self.embed_document_with_model(model_name, embed_text).await;
2652
2653 #[cfg(any(test, feature = "fault-injection"))]
2663 let vec_inject = {
2664 let ns_inject = {
2665 let mut g = VECTOR_FAIL_NS.lock().unwrap();
2666 if g.as_deref() == Some(ns) {
2667 *g = None;
2668 true
2669 } else {
2670 false
2671 }
2672 };
2673 let count_inject = VECTOR_FAIL_AFTER.with(|cell| match cell.get() {
2674 Some(0) => {
2675 cell.set(None);
2676 true
2677 }
2678 Some(n) => {
2679 cell.set(Some(n - 1));
2680 false
2681 }
2682 None => false,
2683 });
2684 ns_inject || count_inject
2685 };
2686 #[cfg(not(any(test, feature = "fault-injection")))]
2687 let vec_inject = false;
2688 let vec_result: RuntimeResult<Vec<f32>> = if vec_inject {
2689 Err(RuntimeError::Internal(
2690 "injected vector failure".to_string(),
2691 ))
2692 } else {
2693 vec_result
2694 };
2695
2696 let single_model_result: RuntimeResult<()> = match vec_result {
2697 Ok(vector) => match self.vectors_for_model(token, model_name) {
2698 Ok(vs) => vs
2699 .insert(
2700 note.id,
2701 SubstrateKind::Note,
2702 ns,
2703 "note.content",
2704 vec![vector],
2705 )
2706 .await
2707 .map_err(RuntimeError::from),
2708 Err(e) => Err(e),
2709 },
2710 Err(e) => Err(e),
2711 };
2712 if let Err(e) = single_model_result {
2713 if let Ok(store) = self.notes(token) {
2715 let _ = store.delete_note(note.id, DeleteMode::Hard).await;
2716 }
2717 if let Ok(fts) = self.text_for_notes(token) {
2718 let _ = fts.delete_document(ns, note.id).await;
2719 }
2720 return Err(e);
2721 }
2722 } else if !embed_model_names.is_empty() {
2723 let rt_clone = self.clone();
2726 let content_owned = embed_text.to_string();
2727 let mut handles = Vec::with_capacity(embed_model_names.len());
2728 for model_name in &embed_model_names {
2729 let rt = rt_clone.clone();
2730 let text = content_owned.clone();
2731 let name = model_name.clone();
2732 handles.push(tokio::spawn(async move {
2733 rt.embed_document_with_model(&name, &text).await
2734 }));
2735 }
2736 let mut vectors: Vec<Vec<f32>> = Vec::with_capacity(embed_model_names.len());
2737 for handle in handles {
2738 let join_result = handle
2739 .await
2740 .map_err(|e| RuntimeError::Internal(format!("embed task panicked: {e}")));
2741 match join_result {
2742 Err(e) => {
2743 if let Ok(store) = self.notes(token) {
2745 let _ = store.delete_note(note.id, DeleteMode::Hard).await;
2746 }
2747 if let Ok(fts) = self.text_for_notes(token) {
2748 let _ = fts.delete_document(ns, note.id).await;
2749 }
2750 return Err(e);
2751 }
2752 Ok(Err(e)) => {
2753 if let Ok(store) = self.notes(token) {
2755 let _ = store.delete_note(note.id, DeleteMode::Hard).await;
2756 }
2757 if let Ok(fts) = self.text_for_notes(token) {
2758 let _ = fts.delete_document(ns, note.id).await;
2759 }
2760 return Err(e);
2761 }
2762 Ok(Ok(vec)) => vectors.push(vec),
2763 }
2764 }
2765 let mut inserted_models: Vec<String> = Vec::with_capacity(embed_model_names.len());
2767 for (model_name, vector) in embed_model_names.iter().zip(vectors) {
2768 let insert_result = match self.vectors_for_model(token, model_name) {
2769 Ok(vs) => vs
2770 .insert(
2771 note.id,
2772 SubstrateKind::Note,
2773 ns,
2774 "note.content",
2775 vec![vector],
2776 )
2777 .await
2778 .map_err(RuntimeError::from),
2779 Err(e) => Err(e),
2780 };
2781 if let Err(e) = insert_result {
2782 if let Ok(store) = self.notes(token) {
2784 let _ = store.delete_note(note.id, DeleteMode::Hard).await;
2785 }
2786 if let Ok(fts) = self.text_for_notes(token) {
2787 let _ = fts.delete_document(ns, note.id).await;
2788 }
2789 for m in &inserted_models {
2790 if let Ok(vs) = self.vectors_for_model(token, m) {
2791 let _ = vs.delete(note.id).await;
2792 }
2793 }
2794 return Err(e);
2795 }
2796 inserted_models.push(model_name.clone());
2797 }
2798 }
2799
2800 let mut created_edges: Vec<Uuid> = Vec::with_capacity(annotates.len());
2806
2807 #[cfg(test)]
2810 let annotates_iter: Vec<(usize, Uuid)> = annotates
2811 .iter()
2812 .enumerate()
2813 .map(|(i, &id)| (i, id))
2814 .collect();
2815 #[cfg(test)]
2816 macro_rules! next_target {
2817 ($pair:expr) => {
2818 $pair.1
2819 };
2820 }
2821 #[cfg(not(test))]
2822 let annotates_iter: Vec<Uuid> = annotates.to_vec();
2823 #[cfg(not(test))]
2824 macro_rules! next_target {
2825 ($pair:expr) => {
2826 $pair
2827 };
2828 }
2829
2830 for pair in annotates_iter {
2831 let target_id = next_target!(pair);
2832
2833 #[cfg(test)]
2835 let injected_err: Option<RuntimeError> = {
2836 let call_idx = pair.0;
2837 LINK_FAIL_AFTER.with(|cell| {
2838 let n = cell.get();
2839 if n > 0 && call_idx + 1 == n {
2840 cell.set(0); Some(RuntimeError::Internal("injected link failure".to_string()))
2842 } else {
2843 None
2844 }
2845 })
2846 };
2847 #[cfg(not(test))]
2848 let injected_err: Option<RuntimeError> = None;
2849
2850 let link_result = if let Some(e) = injected_err {
2851 Err(e)
2852 } else {
2853 self.link(
2854 token,
2855 note.id,
2856 target_id,
2857 EdgeRelation::Annotates,
2858 1.0,
2859 None,
2860 )
2861 .await
2862 };
2863
2864 match link_result {
2865 Ok(edge) => created_edges.push(edge.id.into()),
2866 Err(e) => {
2867 for edge_id in created_edges {
2869 let _ = self.delete_edge(token, edge_id, true).await;
2870 }
2871 if let Ok(store) = self.notes(token) {
2872 let _ = store.delete_note(note.id, DeleteMode::Hard).await;
2873 }
2874 if let Ok(fts) = self.text_for_notes(token) {
2875 let _ = fts.delete_document(ns, note.id).await;
2876 }
2877 for model_name in &embed_model_names {
2878 if let Ok(vs) = self.vectors_for_model(token, model_name) {
2879 let _ = vs.delete(note.id).await;
2880 }
2881 }
2882 return Err(e);
2883 }
2884 }
2885 }
2886
2887 Ok(note)
2888 }
2889
2890 pub async fn list_notes(
2896 &self,
2897 token: &NamespaceToken,
2898 kind: Option<&str>,
2899 limit: u32,
2900 offset: u32,
2901 ) -> RuntimeResult<Vec<Note>> {
2902 let visible = token.visible_namespaces();
2903 if visible.len() == 1 {
2904 let page = self
2906 .notes(token)?
2907 .query_notes(
2908 token.namespace().as_str(),
2909 kind,
2910 PageRequest {
2911 offset: offset.into(),
2912 limit,
2913 },
2914 )
2915 .await?;
2916 return Ok(page.items);
2917 }
2918 use khive_storage::note::NoteFilter;
2920 let ns_strs: Vec<String> = visible.iter().map(|ns| ns.as_str().to_owned()).collect();
2921 let filter = NoteFilter {
2922 kind: kind.map(|k| k.to_string()),
2923 namespaces: ns_strs,
2924 ..Default::default()
2925 };
2926 let page = self
2927 .notes(token)?
2928 .query_notes_filtered(
2929 token.namespace().as_str(),
2930 &filter,
2931 PageRequest {
2932 offset: offset.into(),
2933 limit,
2934 },
2935 )
2936 .await?;
2937 Ok(page.items)
2938 }
2939
2940 #[allow(clippy::too_many_arguments)]
2960 pub async fn search_notes(
2961 &self,
2962 token: &NamespaceToken,
2963 query_text: &str,
2964 query_vector: Option<Vec<f32>>,
2965 limit: u32,
2966 note_kind: Option<&str>,
2967 include_superseded: bool,
2968 tags_any: &[String],
2969 properties_filter: Option<&serde_json::Value>,
2970 ) -> RuntimeResult<Vec<NoteSearchHit>> {
2971 const RRF_K: usize = 60;
2972 let candidates = limit.saturating_mul(4).max(limit);
2973 let visible_ns: Vec<String> = token
2974 .visible_namespaces()
2975 .iter()
2976 .map(|ns| ns.as_str().to_owned())
2977 .collect();
2978
2979 #[cfg(any(test, feature = "fault-injection"))]
2992 let fts_search_inject = {
2993 let mut g = FTS_SEARCH_FAIL_NS.lock().unwrap();
2994 match g.as_deref() {
2995 Some(armed) if visible_ns.iter().any(|ns| ns == armed) => {
2996 *g = None;
2997 true
2998 }
2999 _ => false,
3000 }
3001 };
3002 #[cfg(not(any(test, feature = "fault-injection")))]
3003 let fts_search_inject = false;
3004
3005 let text_search_result = if fts_search_inject {
3006 Err(khive_storage::StorageError::Timeout {
3007 operation: "fts_search".into(),
3008 })
3009 } else {
3010 self.text_for_notes(token)?
3011 .search(TextSearchRequest {
3012 query: query_text.to_string(),
3013 mode: TextQueryMode::Plain,
3014 filter: Some(TextFilter {
3015 namespaces: visible_ns.clone(),
3016 ..TextFilter::default()
3017 }),
3018 top_k: candidates,
3019 snippet_chars: 200,
3020 })
3021 .await
3022 };
3023
3024 let text_hits = crate::error::fts_text_leg_or_err(
3025 text_search_result.map_err(RuntimeError::from),
3026 "search_notes",
3027 query_text,
3028 )?;
3029
3030 let vector_hits = if query_vector.is_some() || self.config().embedding_model.is_some() {
3032 self.vector_search(
3033 token,
3034 query_vector,
3035 Some(query_text),
3036 candidates,
3037 Some(SubstrateKind::Note),
3038 )
3039 .await?
3040 } else {
3041 vec![]
3042 };
3043
3044 let fuse_k = text_hits.len() + vector_hits.len();
3050 let fused = crate::fusion::rrf_fuse_k(text_hits, vector_hits, RRF_K, fuse_k)?;
3051
3052 let candidate_ids: Vec<Uuid> = fused.iter().map(|hit| hit.entity_id).collect();
3053 if candidate_ids.is_empty() {
3054 return Ok(vec![]);
3055 }
3056
3057 let note_store = self.notes(token)?;
3062 let mut alive_notes: HashMap<Uuid, Note> = HashMap::new();
3063 for id in &candidate_ids {
3064 if let Some(note) = note_store.get_note(*id).await? {
3065 if note.deleted_at.is_some() {
3066 continue;
3067 }
3068 if let Some(want_kind) = note_kind {
3069 if note.kind != want_kind {
3070 continue;
3071 }
3072 }
3073 if !tags_any.is_empty() {
3078 let note_tags: Vec<String> = note
3079 .properties
3080 .as_ref()
3081 .and_then(|p| p.get("tags"))
3082 .and_then(serde_json::Value::as_array)
3083 .map(|arr| {
3084 arr.iter()
3085 .filter_map(serde_json::Value::as_str)
3086 .map(str::to_owned)
3087 .collect()
3088 })
3089 .unwrap_or_default();
3090 if !note_tags
3091 .iter()
3092 .any(|t| tags_any.iter().any(|w| t.eq_ignore_ascii_case(w)))
3093 {
3094 continue;
3095 }
3096 }
3097 if let Some(pf) = properties_filter {
3099 if !note_props_match(note.properties.as_ref(), pf) {
3100 continue;
3101 }
3102 }
3103 alive_notes.insert(*id, note);
3104 }
3105 }
3106
3107 if !include_superseded && !alive_notes.is_empty() {
3110 let graph = self.graph(token)?;
3111 let mut superseded: std::collections::HashSet<Uuid> = std::collections::HashSet::new();
3112 for ¬e_id in alive_notes.keys() {
3113 let inbound = graph
3114 .neighbors(
3115 note_id,
3116 NeighborQuery {
3117 direction: Direction::In,
3118 relations: Some(vec![EdgeRelation::Supersedes]),
3119 limit: Some(1),
3120 min_weight: None,
3121 },
3122 )
3123 .await?;
3124 if !inbound.is_empty() {
3125 superseded.insert(note_id);
3126 }
3127 }
3128 alive_notes.retain(|id, _| !superseded.contains(id));
3129 }
3130
3131 let mut hits: Vec<NoteSearchHit> = fused
3133 .into_iter()
3134 .filter_map(|hit| {
3135 let note = alive_notes.get(&hit.entity_id)?;
3136 let salience = note.salience.unwrap_or(0.5);
3137 let weight = 0.5 + 0.5 * salience;
3138 let weighted = DeterministicScore::from_f64(hit.score.to_f64() * weight);
3139 Some(NoteSearchHit {
3140 note_id: hit.entity_id,
3141 score: weighted,
3142 title: hit.title.or_else(|| note_title(note)),
3143 snippet: hit.snippet.or_else(|| note_snippet(note)),
3144 })
3145 })
3146 .collect();
3147
3148 hits.sort_by(|a, b| b.score.cmp(&a.score).then(a.note_id.cmp(&b.note_id)));
3149 hits.truncate(limit as usize);
3150 Ok(hits)
3151 }
3152
3153 pub async fn resolve_prefix(
3160 &self,
3161 token: &NamespaceToken,
3162 prefix: &str,
3163 ) -> RuntimeResult<Option<Uuid>> {
3164 let namespaces = [token.namespace().as_str().to_owned()];
3165 self.resolve_prefix_inner(Some(&namespaces), prefix, false)
3166 .await
3167 }
3168
3169 pub async fn resolve_prefix_including_deleted(
3170 &self,
3171 token: &NamespaceToken,
3172 prefix: &str,
3173 ) -> RuntimeResult<Option<Uuid>> {
3174 let namespaces = [token.namespace().as_str().to_owned()];
3175 self.resolve_prefix_inner(Some(&namespaces), prefix, true)
3176 .await
3177 }
3178
3179 pub async fn resolve_prefix_unfiltered(&self, prefix: &str) -> RuntimeResult<Option<Uuid>> {
3187 self.resolve_prefix_inner(None, prefix, false).await
3188 }
3189
3190 pub async fn resolve_prefix_unfiltered_including_deleted(
3193 &self,
3194 prefix: &str,
3195 ) -> RuntimeResult<Option<Uuid>> {
3196 self.resolve_prefix_inner(None, prefix, true).await
3197 }
3198
3199 async fn resolve_prefix_inner(
3211 &self,
3212 namespaces: Option<&[String]>,
3213 prefix: &str,
3214 include_deleted: bool,
3215 ) -> RuntimeResult<Option<Uuid>> {
3216 use khive_storage::types::{SqlStatement, SqlValue};
3217
3218 if !prefix.chars().all(|c| c.is_ascii_hexdigit() || c == '-') {
3227 return Ok(None);
3228 }
3229
3230 let pattern = format!("{}%", hex_prefix_to_uuid_pattern(prefix));
3231
3232 let tables = [
3233 ("entities", true),
3234 ("notes", true),
3235 ("events", false),
3236 ("graph_edges", false),
3237 ];
3238
3239 let ns_clause = namespaces.map(|ns| {
3240 let placeholders: Vec<String> = (0..ns.len()).map(|i| format!("?{}", i + 2)).collect();
3241 format!(" AND namespace IN ({})", placeholders.join(", "))
3242 });
3243
3244 let mut matches: Vec<String> = Vec::new();
3253 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
3254 let mut reader = self.sql().reader().await.map_err(RuntimeError::Storage)?;
3255
3256 for (table, has_deleted_at) in tables {
3257 let deleted_filter = if has_deleted_at && !include_deleted {
3258 " AND deleted_at IS NULL"
3259 } else {
3260 ""
3261 };
3262 let mut params = vec![SqlValue::Text(pattern.clone())];
3263 if let Some(ns) = namespaces {
3264 params.extend(ns.iter().map(|n| SqlValue::Text(n.clone())));
3265 }
3266 let sql = SqlStatement {
3267 sql: format!(
3268 "SELECT id FROM {table} WHERE id LIKE ?1{ns_clause}{deleted_filter} LIMIT 2",
3269 ns_clause = ns_clause.as_deref().unwrap_or("")
3270 ),
3271 params,
3272 label: Some("resolve_prefix".into()),
3273 };
3274 match reader.query_all(sql).await {
3275 Ok(rows) => {
3276 for row in rows {
3277 if let Some(col) = row.columns.first() {
3278 if let SqlValue::Text(s) = &col.value {
3279 if seen.insert(s.clone()) {
3280 matches.push(s.clone());
3281 }
3282 }
3283 }
3284 }
3285 }
3286 Err(e) => {
3287 let msg = e.to_string();
3288 if msg.contains("no such table") {
3289 continue;
3290 }
3291 return Err(RuntimeError::Storage(e));
3292 }
3293 }
3294 if matches.len() > 1 {
3295 break;
3296 }
3297 }
3298
3299 match matches.len() {
3300 0 => Ok(None),
3301 1 => {
3302 let uuid = Uuid::from_str(&matches[0])
3303 .map_err(|e| RuntimeError::Internal(format!("stored UUID is invalid: {e}")))?;
3304 Ok(Some(uuid))
3305 }
3306 _ => {
3307 let uuids: Vec<uuid::Uuid> = matches
3308 .iter()
3309 .filter_map(|s| Uuid::from_str(s).ok())
3310 .collect();
3311 Err(RuntimeError::AmbiguousPrefix {
3312 prefix: prefix.to_string(),
3313 matches: uuids,
3314 })
3315 }
3316 }
3317 }
3318
3319 pub async fn resolve_by_id(
3330 &self,
3331 token: &NamespaceToken,
3332 id: Uuid,
3333 ) -> RuntimeResult<Option<Resolved>> {
3334 if let Some(entity) = self.entities(token)?.get_entity(id).await? {
3336 return Ok(Some(Resolved::Entity(entity)));
3337 }
3338
3339 if let Some(note) = self.notes(token)?.get_note(id).await? {
3341 return Ok(Some(Resolved::Note(note)));
3342 }
3343
3344 Ok(None)
3347 }
3348
3349 pub async fn resolve_by_id_including_deleted(
3355 &self,
3356 token: &NamespaceToken,
3357 id: Uuid,
3358 ) -> RuntimeResult<Option<Resolved>> {
3359 if let Some(entity) = self
3361 .entities(token)?
3362 .get_entity_including_deleted(id)
3363 .await?
3364 {
3365 return Ok(Some(Resolved::Entity(entity)));
3366 }
3367
3368 if let Some(note) = self.notes(token)?.get_note_including_deleted(id).await? {
3370 return Ok(Some(Resolved::Note(note)));
3371 }
3372
3373 Ok(None)
3376 }
3377
3378 pub async fn resolve(
3383 &self,
3384 token: &NamespaceToken,
3385 id: Uuid,
3386 ) -> RuntimeResult<Option<Resolved>> {
3387 match self.get_entity(token, id).await {
3389 Ok(entity) => return Ok(Some(Resolved::Entity(entity))),
3390 Err(RuntimeError::NotFound(_) | RuntimeError::NamespaceMismatch { .. }) => {}
3391 Err(e) => return Err(e),
3392 }
3393
3394 if let Some(note) = self.notes(token)?.get_note(id).await? {
3396 if Self::ensure_namespace_visible(¬e.namespace, token).is_ok() {
3397 return Ok(Some(Resolved::Note(note)));
3398 }
3399 }
3400
3401 if let Some(event) = self.events(token)?.get_event(id).await? {
3403 if Self::ensure_namespace_visible(&event.namespace, token).is_ok() {
3404 return Ok(Some(Resolved::Event(event)));
3405 }
3406 }
3407
3408 Ok(None)
3409 }
3410
3411 pub async fn resolve_edge_endpoint(
3422 &self,
3423 token: &NamespaceToken,
3424 id: Uuid,
3425 ) -> RuntimeResult<Option<Resolved>> {
3426 if let Some(resolved) = self.resolve_by_id(token, id).await? {
3427 return Ok(Some(resolved));
3428 }
3429 if let Some(event) = self.events(token)?.get_event(id).await? {
3430 return Ok(Some(Resolved::Event(event)));
3431 }
3432 Ok(None)
3433 }
3434
3435 pub async fn resolve_primary(
3440 &self,
3441 token: &NamespaceToken,
3442 id: Uuid,
3443 ) -> RuntimeResult<Option<Resolved>> {
3444 let ns = token.namespace().as_str();
3445
3446 if let Some(entity) = self.entities(token)?.get_entity(id).await? {
3448 if Self::ensure_namespace(&entity.namespace, ns).is_ok() {
3449 return Ok(Some(Resolved::Entity(entity)));
3450 }
3451 }
3452
3453 if let Some(note) = self.notes(token)?.get_note(id).await? {
3455 if Self::ensure_namespace(¬e.namespace, ns).is_ok() {
3456 return Ok(Some(Resolved::Note(note)));
3457 }
3458 }
3459
3460 if let Some(event) = self.events(token)?.get_event(id).await? {
3462 if Self::ensure_namespace(&event.namespace, ns).is_ok() {
3463 return Ok(Some(Resolved::Event(event)));
3464 }
3465 }
3466
3467 Ok(None)
3468 }
3469
3470 pub async fn resolve_including_deleted(
3475 &self,
3476 token: &NamespaceToken,
3477 id: Uuid,
3478 ) -> RuntimeResult<Option<Resolved>> {
3479 let ns = token.namespace().as_str();
3480
3481 if let Some(entity) = self
3482 .entities(token)?
3483 .get_entity_including_deleted(id)
3484 .await?
3485 {
3486 if Self::ensure_namespace(&entity.namespace, ns).is_ok() {
3487 return Ok(Some(Resolved::Entity(entity)));
3488 }
3489 }
3490
3491 if let Some(note) = self.notes(token)?.get_note_including_deleted(id).await? {
3492 if Self::ensure_namespace(¬e.namespace, ns).is_ok() {
3493 return Ok(Some(Resolved::Note(note)));
3494 }
3495 }
3496
3497 if let Some(event) = self.events(token)?.get_event(id).await? {
3498 if Self::ensure_namespace(&event.namespace, ns).is_ok() {
3499 return Ok(Some(Resolved::Event(event)));
3500 }
3501 }
3502
3503 Ok(None)
3504 }
3505
3506 async fn atomic_hard_delete_with_edge_purge(
3531 &self,
3532 row_statement: SqlStatement,
3533 node_id: Uuid,
3534 ) -> RuntimeResult<bool> {
3535 let plan = AtomicOpPlan::Delete(DeletePlan {
3536 target_id: node_id,
3537 statements: vec![
3538 PlanStatement {
3539 statement: row_statement,
3540 guard: Some(AffectedRowGuard::exactly(1)),
3541 },
3542 PlanStatement {
3543 statement: purge_incident_edges_statement(node_id),
3544 guard: None,
3545 },
3546 ],
3547 post_commit: PostCommitEffect::None,
3548 });
3549 match run_atomic_unit(self.sql().as_ref(), vec![plan]).await {
3550 Ok(AtomicRunOutcome::Committed { .. }) => Ok(true),
3551 Ok(AtomicRunOutcome::RolledBack {
3552 failure: AtomicOpFailure::GuardFailed { .. },
3553 ..
3554 }) => Ok(false),
3555 Ok(AtomicRunOutcome::RolledBack {
3556 failure: AtomicOpFailure::SqlError { message, .. },
3557 ..
3558 }) => Err(RuntimeError::Internal(format!(
3559 "hard delete + edge purge for {node_id} failed: {message}"
3560 ))),
3561 Err(e) => Err(RuntimeError::Internal(format!(
3562 "hard delete + edge purge for {node_id}: atomic unit seam failure: {}",
3563 e.0
3564 ))),
3565 }
3566 }
3567
3568 pub async fn delete_note(
3579 &self,
3580 token: &NamespaceToken,
3581 id: Uuid,
3582 hard: bool,
3583 ) -> RuntimeResult<bool> {
3584 let note_store = self.notes(token)?;
3585 let note = if hard {
3586 match note_store.get_note_including_deleted(id).await? {
3587 Some(n) => n,
3588 None => return Ok(false),
3589 }
3590 } else {
3591 match note_store.get_note(id).await? {
3592 Some(n) => n,
3593 None => return Ok(false),
3594 }
3595 };
3596 let mode = if hard {
3597 DeleteMode::Hard
3598 } else {
3599 DeleteMode::Soft
3600 };
3601
3602 let record_tok = NamespaceToken::for_namespace(
3604 khive_types::Namespace::parse(¬e.namespace)
3605 .map_err(|e| RuntimeError::Internal(format!("note namespace invalid: {e}")))?,
3606 );
3607 let record_ns = note.namespace.clone();
3608
3609 let deleted = if hard {
3614 let deleted = self
3615 .atomic_hard_delete_with_edge_purge(note_hard_delete_statement(id), id)
3616 .await?;
3617 self.text_for_notes(&record_tok)?
3618 .delete_document(&record_ns, id)
3619 .await?;
3620 for model_name in self.registered_embedding_model_names() {
3623 self.vectors_for_model(&record_tok, &model_name)?
3624 .delete(id)
3625 .await?;
3626 }
3627 deleted
3628 } else {
3629 let deleted = note_store.delete_note(id, mode).await?;
3630 if deleted {
3631 self.text_for_notes(&record_tok)?
3632 .delete_document(&record_ns, id)
3633 .await?;
3634 for model_name in self.registered_embedding_model_names() {
3635 self.vectors_for_model(&record_tok, &model_name)?
3636 .delete(id)
3637 .await?;
3638 }
3639 }
3640 deleted
3641 };
3642 if deleted {
3643 let event_store = self.events(token)?;
3644 let event = khive_storage::event::Event::new(
3645 record_ns.clone(),
3646 "delete",
3647 EventKind::NoteDeleted,
3648 SubstrateKind::Note,
3649 "",
3650 )
3651 .with_target(id)
3652 .with_payload(serde_json::json!({"id": id, "namespace": record_ns, "hard": hard}));
3653 event_store.append_event(event).await.map_err(|e| {
3654 RuntimeError::Internal(format!("delete_note: event store write failed: {e}"))
3655 })?;
3656 self.fire_note_mutation_hook(¬e.kind, id).await;
3663 }
3664 Ok(deleted)
3665 }
3666
3667 pub async fn delete_note_row_first_for_compensation(
3686 &self,
3687 token: &NamespaceToken,
3688 id: Uuid,
3689 ) -> RuntimeResult<()> {
3690 let note_store = self.notes(token)?;
3691 let Some(note) = note_store.get_note_including_deleted(id).await? else {
3692 return Ok(());
3693 };
3694 let record_tok = NamespaceToken::for_namespace(
3695 khive_types::Namespace::parse(¬e.namespace)
3696 .map_err(|e| RuntimeError::Internal(format!("note namespace invalid: {e}")))?,
3697 );
3698 let record_ns = note.namespace.clone();
3699
3700 note_store.delete_note(id, DeleteMode::Hard).await?;
3702
3703 #[cfg(any(test, feature = "fault-injection"))]
3704 {
3705 let armed = ROLLBACK_CLEANUP_FAIL_NS.lock().unwrap().take();
3706 if armed.as_deref() == Some(record_ns.as_str()) {
3707 return Err(RuntimeError::Internal(
3708 "row removed but compensation cleanup failed: injected=true".to_string(),
3709 ));
3710 }
3711 }
3712
3713 let mut cleanup_errors = Vec::new();
3714 if let Err(e) = self.graph(&record_tok)?.purge_incident_edges(id).await {
3715 cleanup_errors.push(format!("graph={e}"));
3716 }
3717 if let Err(e) = self
3718 .text_for_notes(&record_tok)?
3719 .delete_document(&record_ns, id)
3720 .await
3721 {
3722 cleanup_errors.push(format!("fts={e}"));
3723 }
3724 for model_name in self.registered_embedding_model_names() {
3725 if let Err(e) = self
3726 .vectors_for_model(&record_tok, &model_name)?
3727 .delete(id)
3728 .await
3729 {
3730 cleanup_errors.push(format!("vector[{model_name}]={e}"));
3731 }
3732 }
3733 if cleanup_errors.is_empty() {
3734 Ok(())
3735 } else {
3736 Err(RuntimeError::Internal(format!(
3737 "row removed but compensation cleanup failed: {}",
3738 cleanup_errors.join("; ")
3739 )))
3740 }
3741 }
3742}
3743
3744#[derive(Clone, Debug, Serialize)]
3746pub struct QueryResult {
3747 pub rows: Vec<SqlRow>,
3748 #[serde(skip_serializing_if = "Vec::is_empty")]
3749 pub warnings: Vec<String>,
3750}
3751
3752impl KhiveRuntime {
3753 pub async fn query(&self, token: &NamespaceToken, query: &str) -> RuntimeResult<Vec<SqlRow>> {
3761 Ok(self
3762 .query_with_metadata(token, query, khive_query::CompileOptions::default())
3763 .await?
3764 .rows)
3765 }
3766
3767 pub async fn query_with_metadata(
3769 &self,
3770 token: &NamespaceToken,
3771 query: &str,
3772 mut opts: khive_query::CompileOptions,
3773 ) -> RuntimeResult<QueryResult> {
3774 use khive_query::QueryValue;
3775 use khive_storage::types::SqlValue;
3776
3777 let ast = khive_query::parse_auto(query)?;
3778 opts.scopes = token
3779 .visible_namespaces()
3780 .iter()
3781 .map(|ns| ns.as_str().to_string())
3782 .collect();
3783 let compiled = khive_query::compile(&ast, &opts)?;
3784 let mut warnings = compiled.warnings;
3785 let truncation_check = compiled.truncation_check;
3786
3787 let params: Vec<SqlValue> = compiled
3790 .params
3791 .into_iter()
3792 .map(|qv| match qv {
3793 QueryValue::Null => SqlValue::Null,
3794 QueryValue::Integer(n) => SqlValue::Integer(n),
3795 QueryValue::Float(f) => SqlValue::Float(f),
3796 QueryValue::Text(s) => SqlValue::Text(s),
3797 QueryValue::Blob(b) => SqlValue::Blob(b),
3798 })
3799 .collect();
3800
3801 let mut reader = self.sql().reader().await?;
3802 let stmt = SqlStatement {
3803 sql: compiled.sql,
3804 params,
3805 label: None,
3806 };
3807 let mut rows = reader.query_all(stmt).await?;
3808
3809 if let Some(check) = truncation_check {
3815 if rows.len() > check.max_limit {
3816 rows.truncate(check.max_limit);
3817 warnings.push(match check.requested_limit {
3818 Some(requested) => format!(
3819 "result set capped at {} rows; requested limit {requested} exceeds the \
3820 cap — use LIMIT/OFFSET to page through the remaining results",
3821 check.max_limit
3822 ),
3823 None => format!(
3824 "result set capped at {} rows; more than {} rows matched with no LIMIT \
3825 clause — use LIMIT/OFFSET to page through the remaining results",
3826 check.max_limit, check.max_limit
3827 ),
3828 });
3829 }
3830 }
3831
3832 Ok(QueryResult { rows, warnings })
3833 }
3834
3835 pub async fn delete_entity(
3843 &self,
3844 token: &NamespaceToken,
3845 id: Uuid,
3846 hard: bool,
3847 ) -> RuntimeResult<bool> {
3848 let entity = if hard {
3849 match self
3850 .entities(token)?
3851 .get_entity_including_deleted(id)
3852 .await?
3853 {
3854 Some(e) => e,
3855 None => return Ok(false),
3856 }
3857 } else {
3858 match self.entities(token)?.get_entity(id).await? {
3859 Some(e) => e,
3860 None => return Ok(false),
3861 }
3862 };
3863 let mode = if hard {
3864 DeleteMode::Hard
3865 } else {
3866 DeleteMode::Soft
3867 };
3868
3869 let record_tok = NamespaceToken::for_namespace(
3871 khive_types::Namespace::parse(&entity.namespace)
3872 .map_err(|e| RuntimeError::Internal(format!("entity namespace invalid: {e}")))?,
3873 );
3874
3875 let deleted = if hard {
3880 let deleted = self
3881 .atomic_hard_delete_with_edge_purge(entity_hard_delete_statement(id), id)
3882 .await?;
3883 self.remove_from_indexes(&record_tok, id).await?;
3884 deleted
3885 } else {
3886 let deleted = self.entities(token)?.delete_entity(id, mode).await?;
3887 if deleted {
3888 self.remove_from_indexes(&record_tok, id).await?;
3889 }
3890 deleted
3891 };
3892 if deleted {
3893 let event_store = self.events(token)?;
3894 let ns = entity.namespace.clone();
3895 let event = khive_storage::event::Event::new(
3896 ns.clone(),
3897 "delete",
3898 EventKind::EntityDeleted,
3899 SubstrateKind::Entity,
3900 "",
3901 )
3902 .with_target(id)
3903 .with_payload(serde_json::json!({"id": id, "namespace": ns, "hard": hard}));
3904 event_store.append_event(event).await.map_err(|e| {
3905 RuntimeError::Internal(format!("delete_entity: event store write failed: {e}"))
3906 })?;
3907 }
3908 Ok(deleted)
3909 }
3910
3911 pub async fn count_entities(
3913 &self,
3914 token: &NamespaceToken,
3915 kind: Option<&str>,
3916 ) -> RuntimeResult<u64> {
3917 let filter = EntityFilter {
3918 kinds: match kind {
3919 Some(k) => vec![k.to_string()],
3920 None => vec![],
3921 },
3922 ..Default::default()
3923 };
3924 Ok(self
3925 .entities(token)?
3926 .count_entities(token.namespace().as_str(), filter)
3927 .await?)
3928 }
3929
3930 pub async fn get_edge(
3937 &self,
3938 _token: &NamespaceToken,
3939 edge_id: Uuid,
3940 ) -> RuntimeResult<Option<Edge>> {
3941 let mut reader = self.sql().reader().await?;
3942 let record_ns = reader
3943 .query_scalar(SqlStatement {
3944 sql: "SELECT namespace FROM graph_edges \
3945 WHERE id = ?1 AND deleted_at IS NULL LIMIT 1"
3946 .into(),
3947 params: vec![SqlValue::Text(edge_id.to_string())],
3948 label: Some("get_edge_namespace".into()),
3949 })
3950 .await?;
3951
3952 let Some(SqlValue::Text(record_ns)) = record_ns else {
3953 return Ok(None);
3954 };
3955 let record_tok = NamespaceToken::for_namespace(
3958 khive_types::Namespace::parse(&record_ns)
3959 .map_err(|e| RuntimeError::Internal(format!("edge namespace invalid: {e}")))?,
3960 );
3961 Ok(self
3962 .graph(&record_tok)?
3963 .get_edge(LinkId::from(edge_id))
3964 .await?)
3965 }
3966
3967 pub async fn get_edge_visible(
3972 &self,
3973 token: &NamespaceToken,
3974 edge_id: Uuid,
3975 ) -> RuntimeResult<Option<Edge>> {
3976 self.get_edge(token, edge_id).await
3977 }
3978
3979 pub async fn get_edge_including_deleted(
3985 &self,
3986 _token: &NamespaceToken,
3987 edge_id: Uuid,
3988 ) -> RuntimeResult<Option<Edge>> {
3989 let mut reader = self.sql().reader().await?;
3990 let record_ns = reader
3991 .query_scalar(SqlStatement {
3992 sql: "SELECT namespace FROM graph_edges WHERE id = ?1 LIMIT 1".into(),
3993 params: vec![SqlValue::Text(edge_id.to_string())],
3994 label: Some("get_edge_including_deleted_namespace".into()),
3995 })
3996 .await?;
3997
3998 let Some(SqlValue::Text(record_ns)) = record_ns else {
3999 return Ok(None);
4000 };
4001 let record_tok = NamespaceToken::for_namespace(
4003 khive_types::Namespace::parse(&record_ns)
4004 .map_err(|e| RuntimeError::Internal(format!("edge namespace invalid: {e}")))?,
4005 );
4006 Ok(self
4007 .graph(&record_tok)?
4008 .get_edge_including_deleted(LinkId::from(edge_id))
4009 .await?)
4010 }
4011
4012 pub const EDGE_LIST_MAX_LIMIT: u32 = 1000;
4017
4018 pub async fn list_edges(
4026 &self,
4027 token: &NamespaceToken,
4028 filter: crate::curation::EdgeListFilter,
4029 limit: u32,
4030 offset: u32,
4031 ) -> RuntimeResult<Vec<Edge>> {
4032 let limit = limit.clamp(1, Self::EDGE_LIST_MAX_LIMIT);
4033 let visible = token.visible_namespaces();
4034
4035 if let [ns] = visible {
4038 let temp = NamespaceToken::for_namespace(ns.clone());
4039 let page = self
4040 .graph(&temp)?
4041 .query_edges(
4042 filter.into(),
4043 vec![SortOrder {
4044 field: EdgeSortField::CreatedAt,
4045 direction: khive_storage::types::SortDirection::Asc,
4046 }],
4047 PageRequest {
4048 offset: offset.into(),
4049 limit,
4050 },
4051 )
4052 .await?;
4053 return Ok(page.items);
4054 }
4055
4056 let fetch_limit = offset.saturating_add(limit);
4060 let mut results = Vec::new();
4061 for ns in visible {
4062 let temp = NamespaceToken::for_namespace(ns.clone());
4063 let page = self
4064 .graph(&temp)?
4065 .query_edges(
4066 filter.clone().into(),
4067 vec![SortOrder {
4068 field: EdgeSortField::CreatedAt,
4069 direction: khive_storage::types::SortDirection::Asc,
4070 }],
4071 PageRequest {
4072 offset: 0,
4073 limit: fetch_limit,
4074 },
4075 )
4076 .await?;
4077 results.extend(page.items);
4078 }
4079 results.sort_by_key(|e| Uuid::from(e.id));
4080 results.dedup_by_key(|e| Uuid::from(e.id));
4081 let start = (offset as usize).min(results.len());
4082 let end = (start + limit as usize).min(results.len());
4083 Ok(results[start..end].to_vec())
4084 }
4085
4086 pub async fn list_edges_after(
4097 &self,
4098 token: &NamespaceToken,
4099 filter: crate::curation::EdgeListFilter,
4100 after: Option<Uuid>,
4101 limit: u32,
4102 ) -> RuntimeResult<(Vec<Edge>, Option<Uuid>)> {
4103 let limit = limit.clamp(1, Self::EDGE_LIST_MAX_LIMIT);
4104 let visible = token.visible_namespaces();
4105 let limit_usize = limit as usize;
4106
4107 if let [ns] = visible {
4108 let temp = NamespaceToken::for_namespace(ns.clone());
4109 let page = self
4110 .graph(&temp)?
4111 .query_edges_after(filter.into(), after, limit)
4112 .await?;
4113 return Ok((page.items, page.next_after));
4114 }
4115
4116 let probe_limit = limit + 1;
4120 let mut results = Vec::new();
4121 for ns in visible {
4122 let temp = NamespaceToken::for_namespace(ns.clone());
4123 let page = self
4124 .graph(&temp)?
4125 .query_edges_after(filter.clone().into(), after, probe_limit)
4126 .await?;
4127 results.extend(page.items);
4128 }
4129 results.sort_by_key(|e| Uuid::from(e.id));
4130 results.dedup_by_key(|e| Uuid::from(e.id));
4131 let has_more = results.len() > limit_usize;
4132 if has_more {
4133 results.truncate(limit_usize);
4134 }
4135 let next_after = if has_more {
4136 results.last().map(|e| Uuid::from(e.id))
4137 } else {
4138 None
4139 };
4140 Ok((results, next_after))
4141 }
4142
4143 pub async fn count_edges_by_relation(
4147 &self,
4148 token: &NamespaceToken,
4149 ) -> RuntimeResult<std::collections::HashMap<String, u64>> {
4150 let mut totals: std::collections::HashMap<String, u64> = std::collections::HashMap::new();
4151 for ns in token.visible_namespaces() {
4152 let temp = NamespaceToken::for_namespace(ns.clone());
4153 for (relation, count) in self.graph(&temp)?.count_edges_by_relation().await? {
4154 *totals.entry(relation.to_string()).or_insert(0) += count;
4155 }
4156 }
4157 Ok(totals)
4158 }
4159
4160 #[allow(clippy::too_many_arguments)]
4181 fn update_edge_symmetric_dml(
4182 conn: &rusqlite::Connection,
4183 ns: &str,
4184 edge_id_str: &str,
4185 canon_src_str: &str,
4186 canon_tgt_str: &str,
4187 relation_str: &str,
4188 weight: f64,
4189 metadata: Option<String>,
4190 target_backend: Option<String>,
4191 ) -> Result<Option<String>, SqliteError> {
4192 let now_ts = chrono::Utc::now().timestamp_micros();
4200
4201 let conflict_id: Option<String> = conn
4204 .query_row(
4205 khive_db::stores::graph::EDGE_SYMMETRIC_CONFLICT_PROBE_SQL,
4206 rusqlite::params![
4207 &ns,
4208 &canon_src_str,
4209 &canon_tgt_str,
4210 &relation_str,
4211 &edge_id_str
4212 ],
4213 |row| row.get(0),
4214 )
4215 .optional()
4216 .map_err(SqliteError::Rusqlite)?;
4217
4218 if let Some(existing_id) = conflict_id {
4219 conn.execute(
4223 khive_db::stores::graph::EDGE_SYMMETRIC_DELETE_NONCANONICAL_SQL,
4224 rusqlite::params![&ns, &edge_id_str],
4225 )
4226 .map_err(SqliteError::Rusqlite)?;
4227 let affected = conn
4228 .execute(
4229 khive_db::stores::graph::EDGE_SYMMETRIC_REFRESH_CANONICAL_SQL,
4230 rusqlite::params![weight, now_ts, target_backend, metadata, &ns, &existing_id],
4231 )
4232 .map_err(SqliteError::Rusqlite)?;
4233 if affected == 0 {
4234 return Err(SqliteError::InvalidData(format!(
4235 "update_edge: surviving canonical row {existing_id} vanished during update"
4236 )));
4237 }
4238 Ok(Some(existing_id))
4239 } else {
4240 let affected = conn
4243 .execute(
4244 khive_db::stores::graph::EDGE_SYMMETRIC_UPDATE_INPLACE_SQL,
4245 rusqlite::params![
4246 &canon_src_str,
4247 &canon_tgt_str,
4248 &relation_str,
4249 weight,
4250 now_ts,
4251 metadata,
4252 &ns,
4253 &edge_id_str,
4254 ],
4255 )
4256 .map_err(SqliteError::Rusqlite)?;
4257 if affected == 0 {
4258 return Err(SqliteError::InvalidData(format!(
4261 "update_edge: zero rows affected updating edge {edge_id_str} \
4262 in namespace {ns} — row vanished between fetch and update"
4263 )));
4264 }
4265 Ok(None)
4266 }
4267 }
4268
4269 pub async fn update_edge(
4282 &self,
4283 token: &NamespaceToken,
4284 edge_id: Uuid,
4285 patch: crate::curation::EdgePatch,
4286 ) -> RuntimeResult<Edge> {
4287 let graph_for_fetch = self.graph(token)?;
4290 let mut edge = graph_for_fetch
4291 .get_edge(LinkId::from(edge_id))
4292 .await?
4293 .ok_or_else(|| crate::RuntimeError::NotFound(format!("edge {edge_id}")))?;
4294
4295 let record_ns: String = edge.namespace.clone();
4300 let record_tok = NamespaceToken::for_namespace(
4301 khive_types::Namespace::parse(&record_ns)
4302 .map_err(|e| RuntimeError::Internal(format!("edge namespace invalid: {e}")))?,
4303 );
4304 let graph = self.graph(&record_tok)?;
4305
4306 let mut changed_fields: Vec<&'static str> = Vec::new();
4307 if let Some(r) = patch.relation {
4308 self.validate_edge_relation_endpoints(&record_tok, edge.source_id, edge.target_id, r)
4311 .await?;
4312 edge.relation = r;
4313 changed_fields.push("relation");
4314 }
4315 if let Some(w) = patch.weight {
4316 if !w.is_finite() || !(0.0..=1.0).contains(&w) {
4319 return Err(RuntimeError::InvalidInput(format!(
4320 "edge weight must be a finite value in [0.0, 1.0]; got {w}"
4321 )));
4322 }
4323 edge.weight = w;
4324 changed_fields.push("weight");
4325 }
4326 if let Some(props) = patch.properties {
4327 edge.metadata = Some(props);
4328 }
4329
4330 let (canon_src, canon_tgt) =
4340 canonical_edge_endpoints(edge.relation, edge.source_id, edge.target_id);
4341
4342 if edge.relation.is_symmetric() {
4343 let ns = record_ns.clone();
4347 let edge_id_str = edge_id.to_string();
4348 let relation_str = edge.relation.to_string();
4349 let canon_src_str = canon_src.to_string();
4350 let canon_tgt_str = canon_tgt.to_string();
4351 let weight = edge.weight;
4352 let metadata = edge
4353 .metadata
4354 .as_ref()
4355 .map(|v| serde_json::to_string(v).unwrap_or_default());
4356 let target_backend = edge.target_backend.clone();
4357
4358 let pool = self.backend().pool_arc();
4359 let writer_task = pool.writer_task_handle().ok().flatten();
4363
4364 let surviving_id: Option<String> = if let Some(writer_task) = writer_task {
4368 writer_task
4369 .send(move |conn| {
4370 Self::update_edge_symmetric_dml(
4371 conn,
4372 &ns,
4373 &edge_id_str,
4374 &canon_src_str,
4375 &canon_tgt_str,
4376 &relation_str,
4377 weight,
4378 metadata,
4379 target_backend,
4380 )
4381 .map_err(|e| {
4382 khive_storage::StorageError::driver(
4383 khive_storage::StorageCapability::Graph,
4384 "update_edge",
4385 e,
4386 )
4387 })
4388 })
4389 .await
4390 .map_err(RuntimeError::Storage)?
4391 } else {
4392 tokio::task::spawn_blocking(move || {
4393 let guard = pool.writer()?;
4394 guard.transaction(|conn| {
4395 Self::update_edge_symmetric_dml(
4396 conn,
4397 &ns,
4398 &edge_id_str,
4399 &canon_src_str,
4400 &canon_tgt_str,
4401 &relation_str,
4402 weight,
4403 metadata,
4404 target_backend,
4405 )
4406 })
4407 })
4408 .await
4409 .map_err(|e| {
4410 RuntimeError::Internal(format!("update_edge: spawn_blocking join: {e}"))
4411 })?
4412 .map_err(RuntimeError::Sqlite)?
4413 };
4414
4415 if let Some(sid) = surviving_id {
4416 let surviving_uuid = Uuid::parse_str(&sid).map_err(|e| {
4420 RuntimeError::Internal(format!("update_edge: surviving id parse failed: {e}"))
4421 })?;
4422 edge = self
4423 .get_edge(&record_tok, surviving_uuid)
4424 .await?
4425 .ok_or_else(|| {
4426 RuntimeError::Internal(format!(
4427 "update_edge: surviving canonical row {surviving_uuid} vanished after update"
4428 ))
4429 })?;
4430 } else {
4431 edge.source_id = canon_src;
4433 edge.target_id = canon_tgt;
4434 }
4435 } else {
4436 graph.upsert_edge(edge.clone()).await?;
4440 }
4441
4442 let event_store = self.events(&record_tok)?;
4444 let event = khive_storage::event::Event::new(
4445 record_ns.clone(),
4446 "update",
4447 EventKind::EdgeUpdated,
4448 SubstrateKind::Entity,
4449 "",
4450 )
4451 .with_target(edge_id)
4452 .with_payload(
4453 serde_json::json!({"id": edge_id, "namespace": record_ns, "changed_fields": changed_fields}),
4454 );
4455 event_store.append_event(event).await.map_err(|e| {
4456 RuntimeError::Internal(format!("update_edge: event store write failed: {e}"))
4457 })?;
4458
4459 Ok(edge)
4460 }
4461
4462 pub async fn delete_edge(
4473 &self,
4474 token: &NamespaceToken,
4475 edge_id: Uuid,
4476 hard: bool,
4477 ) -> RuntimeResult<bool> {
4478 let mode = if hard {
4479 DeleteMode::Hard
4480 } else {
4481 DeleteMode::Soft
4482 };
4483
4484 let edge = if hard {
4490 self.get_edge_including_deleted(token, edge_id).await?
4491 } else {
4492 self.get_edge(token, edge_id).await?
4493 };
4494 let Some(edge) = edge else {
4495 return Ok(false);
4496 };
4497
4498 let record_ns: String = edge.namespace.clone();
4500 let record_tok = NamespaceToken::for_namespace(
4501 khive_types::Namespace::parse(&record_ns)
4502 .map_err(|e| RuntimeError::Internal(format!("edge namespace invalid: {e}")))?,
4503 );
4504 let graph = self.graph(&record_tok)?;
4505
4506 let deleted = if hard {
4513 self.atomic_hard_delete_with_edge_purge(edge_hard_delete_statement(edge_id), edge_id)
4514 .await?
4515 } else {
4516 graph.delete_edge(LinkId::from(edge_id), mode).await?
4517 };
4518 if deleted {
4519 let event_store = self.events(&record_tok)?;
4521 let event = khive_storage::event::Event::new(
4522 record_ns.clone(),
4523 "delete",
4524 EventKind::EdgeDeleted,
4525 SubstrateKind::Entity,
4526 "",
4527 )
4528 .with_target(edge_id)
4529 .with_payload(serde_json::json!({"id": edge_id, "namespace": record_ns, "hard": hard}));
4530 event_store.append_event(event).await.map_err(|e| {
4531 RuntimeError::Internal(format!("delete_edge: event store write failed: {e}"))
4532 })?;
4533 }
4534 Ok(deleted)
4535 }
4536
4537 pub async fn count_edges(
4539 &self,
4540 token: &NamespaceToken,
4541 filter: crate::curation::EdgeListFilter,
4542 ) -> RuntimeResult<u64> {
4543 Ok(self.graph(token)?.count_edges(filter.into()).await?)
4544 }
4545
4546 pub async fn build_edge(&self, token: &NamespaceToken, spec: &LinkSpec) -> RuntimeResult<Edge> {
4557 let ns_str = match &spec.namespace {
4558 Some(s) => {
4559 let spec_ns = crate::Namespace::parse(s)
4560 .map_err(|e| RuntimeError::InvalidInput(format!("invalid namespace: {e}")))?;
4561 if &spec_ns != token.namespace() {
4562 return Err(RuntimeError::InvalidInput(
4563 "LinkSpec namespace does not match token namespace".into(),
4564 ));
4565 }
4566 s.as_str()
4567 }
4568 None => token.namespace().as_str(),
4569 };
4570 self.validate_edge_relation_endpoints(token, spec.source_id, spec.target_id, spec.relation)
4571 .await?;
4572 let (source_id, target_id) =
4573 canonical_edge_endpoints(spec.relation, spec.source_id, spec.target_id);
4574 let metadata = if spec.relation == EdgeRelation::DependsOn {
4575 match (
4580 self.resolve_edge_endpoint(token, source_id).await?,
4581 self.resolve_edge_endpoint(token, target_id).await?,
4582 ) {
4583 (Some(Resolved::Entity(src_e)), Some(Resolved::Entity(tgt_e))) => {
4584 merge_dependency_kind(&src_e.kind, &tgt_e.kind, spec.metadata.clone())
4585 }
4586 _ => spec.metadata.clone(),
4587 }
4588 } else {
4589 spec.metadata.clone()
4590 };
4591 validate_edge_metadata(spec.relation, metadata.as_ref())?;
4592 let now = chrono::Utc::now();
4593 Ok(Edge {
4594 id: LinkId::from(Uuid::new_v4()),
4595 namespace: ns_str.to_string(),
4596 source_id,
4597 target_id,
4598 relation: spec.relation,
4599 weight: spec.weight,
4600 created_at: now,
4601 updated_at: now,
4602 deleted_at: None,
4603 metadata,
4604 target_backend: None,
4605 })
4606 }
4607
4608 pub async fn link_many(
4625 &self,
4626 token: &NamespaceToken,
4627 specs: Vec<LinkSpec>,
4628 ) -> RuntimeResult<Vec<Edge>> {
4629 if specs.is_empty() {
4630 return Ok(vec![]);
4631 }
4632 let mut edges = Vec::with_capacity(specs.len());
4633 for spec in &specs {
4634 edges.push(self.build_edge(token, spec).await?);
4635 }
4636 let outcome = self
4645 .graph(token)?
4646 .upsert_edges_guarded(edges.clone())
4647 .await?;
4648 if let Some(refusal) = outcome.refused {
4649 return Err(RuntimeError::GuardedWriteFailed(GuardedWriteFailure {
4650 entry_index: Some(refusal.entry_index),
4651 missing_source: refusal
4652 .missing
4653 .source
4654 .then_some(edges[refusal.entry_index].source_id),
4655 missing_target: refusal
4656 .missing
4657 .target
4658 .then_some(edges[refusal.entry_index].target_id),
4659 }));
4660 }
4661 if outcome.summary.affected != edges.len() as u64 {
4662 return Err(RuntimeError::NotFound(format!(
4663 "link_many: one or more edge endpoints no longer exist at write time: {}",
4664 outcome.summary.first_error
4665 )));
4666 }
4667
4668 let mut persisted = Vec::with_capacity(edges.len());
4671 for edge in &edges {
4672 let row = self
4673 .list_edges(
4674 token,
4675 crate::curation::EdgeListFilter {
4676 source_id: Some(edge.source_id),
4677 target_id: Some(edge.target_id),
4678 relations: vec![edge.relation],
4679 ..Default::default()
4680 },
4681 1,
4682 0,
4683 )
4684 .await?
4685 .into_iter()
4686 .next()
4687 .ok_or_else(|| {
4688 crate::RuntimeError::Internal(format!(
4689 "upsert_edges succeeded but natural-key lookup for ({}, {}, {}) returned nothing",
4690 edge.source_id, edge.target_id, edge.relation.as_str()
4691 ))
4692 })?;
4693 persisted.push(row);
4694 }
4695 Ok(persisted)
4696 }
4697
4698 pub async fn create_many(
4714 &self,
4715 token: &NamespaceToken,
4716 specs: Vec<EntityCreateSpec>,
4717 ) -> RuntimeResult<Vec<Entity>> {
4718 if specs.is_empty() {
4719 return Ok(vec![]);
4720 }
4721 let ns = token.namespace().as_str();
4722
4723 let mut entities = Vec::with_capacity(specs.len());
4727 for spec in &specs {
4728 self.validate_entity_kind(&spec.kind)?;
4729 let validated_type =
4734 self.validate_entity_type_for_kind(&spec.kind, spec.entity_type.as_deref())?;
4735 if spec.name.trim().is_empty() {
4736 return Err(RuntimeError::InvalidInput("name must not be empty".into()));
4737 }
4738 crate::secret_gate::check(&spec.name)?;
4739 if let Some(d) = &spec.description {
4740 crate::secret_gate::check(d)?;
4741 }
4742 if let Some(ref p) = spec.properties {
4743 crate::secret_gate::check_json(p)?;
4744 }
4745 crate::secret_gate::check_tags(&spec.tags)?;
4746
4747 let mut entity =
4748 Entity::new(ns, &spec.kind, &spec.name).with_entity_type(validated_type.as_deref());
4749 if let Some(d) = &spec.description {
4750 entity = entity.with_description(d);
4751 }
4752 if let Some(p) = spec.properties.clone() {
4753 entity = entity.with_properties(p);
4754 }
4755 if !spec.tags.is_empty() {
4756 entity = entity.with_tags(spec.tags.clone());
4757 }
4758 entities.push(entity);
4759 }
4760
4761 let entity_summary = self
4772 .entities(token)?
4773 .upsert_entities(entities.clone())
4774 .await?;
4775
4776 if entity_summary.failed > 0 {
4777 if let Ok(store) = self.entities(token) {
4779 for entity in &entities {
4780 if let Err(ce) = store.delete_entity(entity.id, DeleteMode::Hard).await {
4781 tracing::error!(
4782 error = %ce,
4783 id = %entity.id,
4784 "create_many: failed to roll back entity row after partial entity write"
4785 );
4786 }
4787 }
4788 }
4789 return Err(RuntimeError::Internal(format!(
4790 "create_many: {}/{} entity rows failed to write (first error: {}); \
4791 all rows rolled back",
4792 entity_summary.failed, entity_summary.attempted, entity_summary.first_error
4793 )));
4794 }
4795
4796 let docs: Vec<_> = entities.iter().map(entity_fts_document).collect();
4809
4810 #[cfg(any(test, feature = "fault-injection"))]
4811 let fts_many_inject = {
4812 let mut g = FTS_FAIL_MANY_NS.lock().unwrap();
4813 if g.as_deref() == Some(ns) {
4814 *g = None;
4815 true
4816 } else {
4817 false
4818 }
4819 };
4820 #[cfg(not(any(test, feature = "fault-injection")))]
4821 let fts_many_inject = false;
4822
4823 #[cfg(any(test, feature = "fault-injection"))]
4826 let fts_many_inject_partial = {
4827 let mut g = FTS_FAIL_MANY_PARTIAL_NS.lock().unwrap();
4828 if g.as_deref() == Some(ns) {
4829 *g = None;
4830 true
4831 } else {
4832 false
4833 }
4834 };
4835 #[cfg(not(any(test, feature = "fault-injection")))]
4836 let fts_many_inject_partial = false;
4837
4838 let fts_summary_result: RuntimeResult<BatchWriteSummary> = if fts_many_inject {
4839 Err(RuntimeError::Internal(
4840 "injected FTS failure for create_many".to_string(),
4841 ))
4842 } else if fts_many_inject_partial {
4843 Ok(BatchWriteSummary {
4844 attempted: docs.len() as u64,
4845 affected: docs.len().saturating_sub(1) as u64,
4846 failed: 1,
4847 first_error: "injected partial FTS failure for create_many".to_string(),
4848 })
4849 } else {
4850 match self.text(token) {
4851 Ok(fts) => fts.upsert_documents(docs).await.map_err(RuntimeError::from),
4852 Err(e) => Err(e),
4853 }
4854 };
4855
4856 let fts_err: Option<RuntimeError> = match fts_summary_result {
4857 Err(e) => Some(e),
4858 Ok(summary) if summary.failed > 0 => Some(RuntimeError::Internal(format!(
4859 "create_many: {}/{} FTS rows failed to index (first error: {}); \
4860 all rows rolled back",
4861 summary.failed, summary.attempted, summary.first_error
4862 ))),
4863 Ok(_) => None,
4864 };
4865
4866 if let Some(e) = fts_err {
4867 if let Ok(fts) = self.text(token) {
4869 for entity in &entities {
4870 if let Err(ce) = fts.delete_document(ns, entity.id).await {
4871 tracing::error!(
4872 error = %ce,
4873 id = %entity.id,
4874 "create_many: failed to remove FTS doc during rollback"
4875 );
4876 }
4877 }
4878 }
4879 if let Ok(store) = self.entities(token) {
4880 for entity in &entities {
4881 if let Err(ce) = store.delete_entity(entity.id, DeleteMode::Hard).await {
4882 tracing::error!(
4883 error = %ce,
4884 id = %entity.id,
4885 "create_many: failed to roll back entity row after FTS failure"
4886 );
4887 }
4888 }
4889 }
4890 return Err(e);
4891 }
4892
4893 Ok(entities)
4895 }
4896}
4897
4898#[derive(Clone, Debug)]
4901pub struct LinkSpec {
4902 pub namespace: Option<String>,
4903 pub source_id: Uuid,
4904 pub target_id: Uuid,
4905 pub relation: EdgeRelation,
4906 pub weight: f64,
4907 pub metadata: Option<serde_json::Value>,
4908}
4909
4910#[derive(Clone, Debug)]
4918pub struct EntityCreateSpec {
4919 pub kind: String,
4920 pub entity_type: Option<String>,
4921 pub name: String,
4922 pub description: Option<String>,
4923 pub properties: Option<serde_json::Value>,
4924 pub tags: Vec<String>,
4925}
4926
4927#[cfg(test)]
4933mod tests {
4934 use super::*;
4935 use crate::curation::EdgeListFilter;
4936 use crate::embedder_registry::EmbedderProvider;
4937 use crate::error::RuntimeError;
4938 use crate::runtime::{KhiveRuntime, NamespaceToken};
4939 use crate::{ActorRef, Namespace};
4940 use async_trait::async_trait;
4941 use khive_storage::types::PathNode;
4942 use lattice_embed::{EmbedError, EmbeddingModel, EmbeddingService};
4943 use std::sync::atomic::{AtomicUsize, Ordering};
4944 use std::sync::Arc;
4945
4946 fn rt() -> KhiveRuntime {
4947 KhiveRuntime::memory().unwrap()
4948 }
4949
4950 struct ConstVecService {
4958 dims: usize,
4959 }
4960
4961 #[async_trait]
4962 impl EmbeddingService for ConstVecService {
4963 async fn embed(
4964 &self,
4965 texts: &[String],
4966 _model: EmbeddingModel,
4967 ) -> std::result::Result<Vec<Vec<f32>>, EmbedError> {
4968 Ok(texts.iter().map(|_| vec![1.0_f32; self.dims]).collect())
4969 }
4970
4971 fn supports_model(&self, _model: EmbeddingModel) -> bool {
4972 true
4973 }
4974
4975 fn name(&self) -> &'static str {
4976 "const-vec"
4977 }
4978 }
4979
4980 struct ConstVecProvider {
4981 provider_name: String,
4982 dims: usize,
4983 pub build_count: Arc<AtomicUsize>,
4984 }
4985
4986 impl ConstVecProvider {
4987 fn new(name: &str, dims: usize) -> (Self, Arc<AtomicUsize>) {
4988 let counter = Arc::new(AtomicUsize::new(0));
4989 let provider = Self {
4990 provider_name: name.to_owned(),
4991 dims,
4992 build_count: Arc::clone(&counter),
4993 };
4994 (provider, counter)
4995 }
4996 }
4997
4998 #[async_trait]
4999 impl EmbedderProvider for ConstVecProvider {
5000 fn name(&self) -> &str {
5001 &self.provider_name
5002 }
5003
5004 fn dimensions(&self) -> usize {
5005 self.dims
5006 }
5007
5008 async fn build(&self) -> crate::error::RuntimeResult<Arc<dyn EmbeddingService>> {
5009 self.build_count.fetch_add(1, Ordering::SeqCst);
5010 Ok(Arc::new(ConstVecService { dims: self.dims }))
5011 }
5012 }
5013
5014 #[tokio::test]
5019 async fn custom_embedder_only_runtime_fanout_stores_vector() {
5020 const MODEL_NAME: &str = "test-custom-encoder";
5021 const DIMS: usize = 8;
5022
5023 let rt = KhiveRuntime::memory().unwrap();
5025
5026 let (provider, _counter) = ConstVecProvider::new(MODEL_NAME, DIMS);
5028 rt.register_embedder(provider);
5029
5030 assert!(rt.config().embedding_model.is_none());
5032 assert_eq!(rt.registered_embedding_model_names(), vec![MODEL_NAME]);
5033
5034 let tok = NamespaceToken::local();
5035
5036 let note = rt
5038 .create_note(
5039 &tok,
5040 "memory",
5041 None,
5042 "custom embedder integration test content",
5043 Some(0.7),
5044 None,
5045 vec![],
5046 )
5047 .await
5048 .expect("create_note with custom-only embedder must succeed");
5049
5050 use khive_storage::types::VectorSearchRequest;
5052 let query_vec = vec![1.0_f32; DIMS];
5053 let hits = rt
5054 .vectors_for_model(&tok, MODEL_NAME)
5055 .expect("vector store for custom model must be accessible")
5056 .search(VectorSearchRequest {
5057 query_vectors: vec![query_vec],
5058 top_k: 5,
5059 namespace: Some(tok.namespace().as_str().to_string()),
5060 kind: Some(khive_types::SubstrateKind::Note),
5061 embedding_model: Some(MODEL_NAME.to_string()),
5062 filter: None,
5063 backend_hints: None,
5064 })
5065 .await
5066 .expect("vector search succeeds");
5067
5068 assert!(
5069 hits.iter().any(|h| h.subject_id == note.id),
5070 "custom embedder must have written a vector for note {}: hits={hits:?}",
5071 note.id
5072 );
5073 }
5074
5075 #[tokio::test]
5080 async fn embed_with_model_accepts_custom_provider_name() {
5081 const MODEL_NAME: &str = "my-custom-enc";
5082 const DIMS: usize = 4;
5083
5084 let rt = KhiveRuntime::memory().unwrap();
5085 let (provider, _counter) = ConstVecProvider::new(MODEL_NAME, DIMS);
5086 rt.register_embedder(provider);
5087
5088 let result = rt
5089 .embed_with_model(MODEL_NAME, "hello world")
5090 .await
5091 .expect("embed_with_model must accept custom provider names");
5092
5093 assert_eq!(
5094 result.len(),
5095 DIMS,
5096 "embedding dimension must match provider"
5097 );
5098 assert!(
5099 result.iter().all(|&v| (v - 1.0_f32).abs() < 1e-6),
5100 "ConstVecService must produce all-ones vector; got: {result:?}"
5101 );
5102 }
5103
5104 #[tokio::test]
5107 async fn embed_with_model_rejects_unregistered_name() {
5108 let rt = KhiveRuntime::memory().unwrap();
5109 let result = rt.embed_with_model("nonexistent-model", "hello").await;
5110 assert!(
5111 matches!(result.unwrap_err(), RuntimeError::UnknownModel(ref n) if n == "nonexistent-model"),
5112 "unregistered model name must return UnknownModel"
5113 );
5114 }
5115
5116 #[tokio::test]
5123 async fn no_embeddings_config_registers_zero_embedders() {
5124 let config = crate::config::RuntimeConfig {
5125 db_path: None,
5126 packs: vec!["kg".to_string()],
5127 ..crate::config::RuntimeConfig::no_embeddings()
5128 };
5129 let rt = KhiveRuntime::new(config).expect("runtime construction must succeed");
5130
5131 assert!(rt.config().embedding_model.is_none());
5132 assert!(
5133 rt.registered_embedding_model_names().is_empty(),
5134 "no_embeddings() runtime must register zero embedders"
5135 );
5136 }
5137
5138 #[tokio::test]
5139 async fn no_embeddings_runtime_create_note_succeeds_without_model_fanout() {
5140 let config = crate::config::RuntimeConfig {
5141 db_path: None,
5142 packs: vec!["kg".to_string()],
5143 ..crate::config::RuntimeConfig::no_embeddings()
5144 };
5145 let rt = KhiveRuntime::new(config).expect("runtime construction must succeed");
5146 let tok = NamespaceToken::local();
5147
5148 let note = rt
5152 .create_note(
5153 &tok,
5154 "memory",
5155 None,
5156 "issue-396 regression: model-less remember must succeed",
5157 Some(0.7),
5158 None,
5159 vec![],
5160 )
5161 .await
5162 .expect("create_note must succeed with zero registered embedders");
5163
5164 assert_eq!(
5165 note.content,
5166 "issue-396 regression: model-less remember must succeed"
5167 );
5168 }
5169
5170 #[tokio::test]
5171 async fn update_edge_changes_weight() {
5172 let rt = rt();
5173 let tok = NamespaceToken::local();
5174 let a = rt
5175 .create_entity(&tok, "concept", None, "A", None, None, vec![])
5176 .await
5177 .unwrap();
5178 let b = rt
5179 .create_entity(&tok, "concept", None, "B", None, None, vec![])
5180 .await
5181 .unwrap();
5182 let edge = rt
5183 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5184 .await
5185 .unwrap();
5186 let edge_id: Uuid = edge.id.into();
5187
5188 let updated = rt
5189 .update_edge(
5190 &tok,
5191 edge_id,
5192 crate::curation::EdgePatch {
5193 weight: Some(0.5),
5194 ..Default::default()
5195 },
5196 )
5197 .await
5198 .unwrap();
5199 assert!((updated.weight - 0.5).abs() < 0.001);
5200 }
5201
5202 #[tokio::test]
5212 async fn update_edge_symmetric_relation_stores_microsecond_updated_at() {
5213 let rt = rt();
5214 let tok = NamespaceToken::local();
5215 let a = rt
5216 .create_entity(&tok, "concept", None, "A", None, None, vec![])
5217 .await
5218 .unwrap();
5219 let b = rt
5220 .create_entity(&tok, "concept", None, "B", None, None, vec![])
5221 .await
5222 .unwrap();
5223 let edge = rt
5224 .link(&tok, a.id, b.id, EdgeRelation::CompetesWith, 1.0, None)
5225 .await
5226 .unwrap();
5227 let edge_id: Uuid = edge.id.into();
5228
5229 let before = chrono::Utc::now();
5230 let updated = rt
5231 .update_edge(
5232 &tok,
5233 edge_id,
5234 crate::curation::EdgePatch {
5235 weight: Some(0.5),
5236 ..Default::default()
5237 },
5238 )
5239 .await
5240 .unwrap();
5241
5242 let drift = (updated.updated_at - before).num_seconds().abs();
5243 assert!(
5244 drift < 60,
5245 "updated_at must round-trip as a recent timestamp (micros, not \
5246 seconds); got {:?}, expected within 60s of {:?}",
5247 updated.updated_at,
5248 before
5249 );
5250 }
5251
5252 #[tokio::test]
5253 async fn update_edge_changes_relation() {
5254 let rt = rt();
5255 let tok = NamespaceToken::local();
5256 let a = rt
5257 .create_entity(&tok, "concept", None, "A", None, None, vec![])
5258 .await
5259 .unwrap();
5260 let b = rt
5261 .create_entity(&tok, "concept", None, "B", None, None, vec![])
5262 .await
5263 .unwrap();
5264 let edge = rt
5265 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5266 .await
5267 .unwrap();
5268 let edge_id: Uuid = edge.id.into();
5269
5270 let updated = rt
5271 .update_edge(
5272 &tok,
5273 edge_id,
5274 crate::curation::EdgePatch {
5275 relation: Some(EdgeRelation::VariantOf),
5276 ..Default::default()
5277 },
5278 )
5279 .await
5280 .unwrap();
5281 assert_eq!(updated.relation, EdgeRelation::VariantOf);
5282 }
5283
5284 #[tokio::test]
5289 async fn update_edge_annotates_note_to_entity_set_supersedes_returns_invalid_input() {
5290 let rt = rt();
5291 let tok = NamespaceToken::local();
5292 let note = rt
5293 .create_note(&tok, "observation", None, "a note", Some(0.5), None, vec![])
5294 .await
5295 .unwrap();
5296 let entity = rt
5297 .create_entity(&tok, "concept", None, "E", None, None, vec![])
5298 .await
5299 .unwrap();
5300 let edge = rt
5302 .link(&tok, note.id, entity.id, EdgeRelation::Annotates, 1.0, None)
5303 .await
5304 .unwrap();
5305 let edge_id: Uuid = edge.id.into();
5306
5307 let result = rt
5309 .update_edge(
5310 &tok,
5311 edge_id,
5312 crate::curation::EdgePatch {
5313 relation: Some(EdgeRelation::Supersedes),
5314 ..Default::default()
5315 },
5316 )
5317 .await;
5318 assert!(
5319 matches!(result, Err(RuntimeError::InvalidInput(_))),
5320 "update to Supersedes on note→entity edge must return InvalidInput, got {result:?}"
5321 );
5322
5323 let fetched = rt.get_edge(&tok, edge_id).await.unwrap().unwrap();
5325 assert_eq!(
5326 fetched.relation,
5327 EdgeRelation::Annotates,
5328 "edge relation must be unchanged after failed update"
5329 );
5330 }
5331
5332 #[tokio::test]
5335 async fn update_edge_entity_to_entity_set_annotates_returns_invalid_input() {
5336 let rt = rt();
5337 let tok = NamespaceToken::local();
5338 let a = rt
5339 .create_entity(&tok, "concept", None, "A", None, None, vec![])
5340 .await
5341 .unwrap();
5342 let b = rt
5343 .create_entity(&tok, "concept", None, "B", None, None, vec![])
5344 .await
5345 .unwrap();
5346 let edge = rt
5347 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5348 .await
5349 .unwrap();
5350 let edge_id: Uuid = edge.id.into();
5351
5352 let result = rt
5353 .update_edge(
5354 &tok,
5355 edge_id,
5356 crate::curation::EdgePatch {
5357 relation: Some(EdgeRelation::Annotates),
5358 ..Default::default()
5359 },
5360 )
5361 .await;
5362 assert!(
5363 matches!(result, Err(RuntimeError::InvalidInput(_))),
5364 "update to Annotates on entity→entity edge must return InvalidInput, got {result:?}"
5365 );
5366 }
5367
5368 #[tokio::test]
5371 async fn update_edge_entity_to_entity_set_supersedes_succeeds() {
5372 let rt = rt();
5373 let tok = NamespaceToken::local();
5374 let a = rt
5375 .create_entity(&tok, "concept", None, "A", None, None, vec![])
5376 .await
5377 .unwrap();
5378 let b = rt
5379 .create_entity(&tok, "concept", None, "B", None, None, vec![])
5380 .await
5381 .unwrap();
5382 let edge = rt
5383 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5384 .await
5385 .unwrap();
5386 let edge_id: Uuid = edge.id.into();
5387
5388 let updated = rt
5389 .update_edge(
5390 &tok,
5391 edge_id,
5392 crate::curation::EdgePatch {
5393 relation: Some(EdgeRelation::Supersedes),
5394 ..Default::default()
5395 },
5396 )
5397 .await
5398 .unwrap();
5399 assert_eq!(updated.relation, EdgeRelation::Supersedes);
5400
5401 let fetched = rt.get_edge(&tok, edge_id).await.unwrap().unwrap();
5403 assert_eq!(fetched.relation, EdgeRelation::Supersedes);
5404 }
5405
5406 #[tokio::test]
5408 async fn update_edge_weight_only_skips_validation() {
5409 let rt = rt();
5410 let tok = NamespaceToken::local();
5411 let a = rt
5412 .create_entity(&tok, "concept", None, "A", None, None, vec![])
5413 .await
5414 .unwrap();
5415 let b = rt
5416 .create_entity(&tok, "concept", None, "B", None, None, vec![])
5417 .await
5418 .unwrap();
5419 let edge = rt
5420 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5421 .await
5422 .unwrap();
5423 let edge_id: Uuid = edge.id.into();
5424
5425 let updated = rt
5426 .update_edge(
5427 &tok,
5428 edge_id,
5429 crate::curation::EdgePatch {
5430 weight: Some(0.3),
5431 ..Default::default()
5432 },
5433 )
5434 .await
5435 .unwrap();
5436 assert_eq!(updated.relation, EdgeRelation::Extends);
5437 assert!((updated.weight - 0.3).abs() < 0.001);
5438 }
5439
5440 #[tokio::test]
5442 async fn update_edge_same_class_relation_change_succeeds() {
5443 let rt = rt();
5444 let tok = NamespaceToken::local();
5445 let a = rt
5446 .create_entity(&tok, "concept", None, "A", None, None, vec![])
5447 .await
5448 .unwrap();
5449 let b = rt
5450 .create_entity(&tok, "concept", None, "B", None, None, vec![])
5451 .await
5452 .unwrap();
5453 let edge = rt
5454 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5455 .await
5456 .unwrap();
5457 let edge_id: Uuid = edge.id.into();
5458
5459 let updated = rt
5460 .update_edge(
5461 &tok,
5462 edge_id,
5463 crate::curation::EdgePatch {
5464 relation: Some(EdgeRelation::VariantOf),
5465 ..Default::default()
5466 },
5467 )
5468 .await
5469 .unwrap();
5470 assert_eq!(updated.relation, EdgeRelation::VariantOf);
5471 }
5472
5473 #[tokio::test]
5474 async fn list_edges_filters_by_relation() {
5475 let rt = rt();
5476 let tok = NamespaceToken::local();
5477 let a = rt
5478 .create_entity(&tok, "concept", None, "A", None, None, vec![])
5479 .await
5480 .unwrap();
5481 let b = rt
5482 .create_entity(&tok, "concept", None, "B", None, None, vec![])
5483 .await
5484 .unwrap();
5485 let c = rt
5486 .create_entity(&tok, "concept", None, "C", None, None, vec![])
5487 .await
5488 .unwrap();
5489
5490 rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5491 .await
5492 .unwrap();
5493 rt.link(&tok, a.id, c.id, EdgeRelation::Enables, 1.0, None)
5494 .await
5495 .unwrap();
5496
5497 let filter = EdgeListFilter {
5498 relations: vec![EdgeRelation::Extends],
5499 ..Default::default()
5500 };
5501 let edges = rt.list_edges(&tok, filter, 100, 0).await.unwrap();
5502 assert_eq!(edges.len(), 1);
5503 assert_eq!(edges[0].relation, EdgeRelation::Extends);
5504 }
5505
5506 #[tokio::test]
5507 async fn list_edges_filters_by_source() {
5508 let rt = rt();
5509 let tok = NamespaceToken::local();
5510 let a = rt
5511 .create_entity(&tok, "concept", None, "A", None, None, vec![])
5512 .await
5513 .unwrap();
5514 let b = rt
5515 .create_entity(&tok, "concept", None, "B", None, None, vec![])
5516 .await
5517 .unwrap();
5518 let c = rt
5519 .create_entity(&tok, "concept", None, "C", None, None, vec![])
5520 .await
5521 .unwrap();
5522 let d = rt
5523 .create_entity(&tok, "concept", None, "D", None, None, vec![])
5524 .await
5525 .unwrap();
5526
5527 rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5528 .await
5529 .unwrap();
5530 rt.link(&tok, c.id, d.id, EdgeRelation::Extends, 1.0, None)
5531 .await
5532 .unwrap();
5533
5534 let filter = EdgeListFilter {
5535 source_id: Some(a.id),
5536 ..Default::default()
5537 };
5538 let edges = rt.list_edges(&tok, filter, 100, 0).await.unwrap();
5539 assert_eq!(edges.len(), 1);
5540 let src: Uuid = edges[0].source_id;
5541 assert_eq!(src, a.id);
5542 }
5543
5544 #[tokio::test]
5549 async fn list_edges_offset_pages_through_full_set() {
5550 let rt = rt();
5551 let tok = NamespaceToken::local();
5552 let a = rt
5553 .create_entity(&tok, "concept", None, "A", None, None, vec![])
5554 .await
5555 .unwrap();
5556 for i in 0..5 {
5557 let t = rt
5558 .create_entity(&tok, "concept", None, &format!("T{i}"), None, None, vec![])
5559 .await
5560 .unwrap();
5561 rt.link(&tok, a.id, t.id, EdgeRelation::Extends, 1.0, None)
5562 .await
5563 .unwrap();
5564 }
5565
5566 let filter = EdgeListFilter {
5567 source_id: Some(a.id),
5568 relations: vec![EdgeRelation::Extends],
5569 ..Default::default()
5570 };
5571
5572 let page0 = rt.list_edges(&tok, filter.clone(), 2, 0).await.unwrap();
5573 let page1 = rt.list_edges(&tok, filter.clone(), 2, 2).await.unwrap();
5574 let page2 = rt.list_edges(&tok, filter.clone(), 2, 4).await.unwrap();
5575 assert_eq!(page0.len(), 2);
5576 assert_eq!(page1.len(), 2);
5577 assert_eq!(page2.len(), 1);
5578
5579 let ids = |p: &[Edge]| p.iter().map(|e| Uuid::from(e.id)).collect::<Vec<_>>();
5580 assert_ne!(ids(&page0), ids(&page1), "page 2 must differ from page 1");
5581
5582 let mut all_ids: Vec<Uuid> = ids(&page0)
5583 .into_iter()
5584 .chain(ids(&page1))
5585 .chain(ids(&page2))
5586 .collect();
5587 all_ids.sort();
5588 all_ids.dedup();
5589 assert_eq!(all_ids.len(), 5, "pages must tile the full edge set");
5590
5591 let empty = rt.list_edges(&tok, filter.clone(), 2, 100).await.unwrap();
5592 assert!(
5593 empty.is_empty(),
5594 "offset past the end must return empty, not page 1"
5595 );
5596 }
5597
5598 #[tokio::test]
5602 async fn list_edges_after_keyset_tiles_full_set() {
5603 let rt = rt();
5604 let tok = NamespaceToken::local();
5605 let a = rt
5606 .create_entity(&tok, "concept", None, "A", None, None, vec![])
5607 .await
5608 .unwrap();
5609 for i in 0..5 {
5610 let t = rt
5611 .create_entity(&tok, "concept", None, &format!("K{i}"), None, None, vec![])
5612 .await
5613 .unwrap();
5614 rt.link(&tok, a.id, t.id, EdgeRelation::Extends, 1.0, None)
5615 .await
5616 .unwrap();
5617 }
5618
5619 let filter = EdgeListFilter {
5620 source_id: Some(a.id),
5621 relations: vec![EdgeRelation::Extends],
5622 ..Default::default()
5623 };
5624
5625 let mut seen = Vec::new();
5626 let mut cursor: Option<Uuid> = None;
5627 for _ in 0..20 {
5628 let (page, next) = rt
5629 .list_edges_after(&tok, filter.clone(), cursor, 2)
5630 .await
5631 .unwrap();
5632 if page.is_empty() {
5633 break;
5634 }
5635 seen.extend(page.iter().map(|e| Uuid::from(e.id)));
5636 if next.is_none() {
5637 break;
5638 }
5639 cursor = next;
5640 }
5641 seen.sort();
5642 seen.dedup();
5643 assert_eq!(seen.len(), 5, "keyset walk must tile the full edge set");
5644
5645 let (first_a, next_a) = rt
5651 .list_edges_after(&tok, filter.clone(), None, 2)
5652 .await
5653 .unwrap();
5654 let (first_b, next_b) = rt
5655 .list_edges_after(&tok, filter.clone(), None, 2)
5656 .await
5657 .unwrap();
5658 assert_eq!(
5659 first_a.iter().map(|e| e.id.0).collect::<Vec<_>>(),
5660 first_b.iter().map(|e| e.id.0).collect::<Vec<_>>(),
5661 );
5662 assert_eq!(next_a, next_b);
5663 }
5664
5665 #[tokio::test]
5666 async fn list_edges_after_single_namespace_exact_final_page_has_no_next_after() {
5667 let rt = rt();
5668 let tok = NamespaceToken::local();
5669 let a = rt
5670 .create_entity(&tok, "concept", None, "SingleCursorA", None, None, vec![])
5671 .await
5672 .unwrap();
5673 for i in 0..4 {
5674 let t = rt
5675 .create_entity(
5676 &tok,
5677 "concept",
5678 None,
5679 &format!("SingleCursorT{i}"),
5680 None,
5681 None,
5682 vec![],
5683 )
5684 .await
5685 .unwrap();
5686 rt.link(&tok, a.id, t.id, EdgeRelation::Extends, 1.0, None)
5687 .await
5688 .unwrap();
5689 }
5690
5691 let filter = EdgeListFilter {
5692 source_id: Some(a.id),
5693 relations: vec![EdgeRelation::Extends],
5694 ..Default::default()
5695 };
5696
5697 let (page1, next1) = rt
5698 .list_edges_after(&tok, filter.clone(), None, 2)
5699 .await
5700 .unwrap();
5701 assert_eq!(page1.len(), 2);
5702 let cursor = next1.expect("first page must report a cursor when two rows remain");
5703
5704 let (page2, next2) = rt
5705 .list_edges_after(&tok, filter, Some(cursor), 2)
5706 .await
5707 .unwrap();
5708 assert_eq!(page2.len(), 2);
5709 assert_eq!(
5710 next2, None,
5711 "an exact-size final single-namespace page must not report a cursor"
5712 );
5713 }
5714
5715 #[tokio::test]
5716 async fn list_edges_after_multi_namespace_exact_final_page_has_no_next_after() {
5717 let rt = rt();
5718 let ns_a = Namespace::parse("cursor-ns-a").unwrap();
5719 let ns_b = Namespace::parse("cursor-ns-b").unwrap();
5720 let tok_a = NamespaceToken::for_namespace(ns_a.clone());
5721 let tok_b = NamespaceToken::for_namespace(ns_b.clone());
5722 let visible = NamespaceToken::mint_with_visibility(ns_a, vec![ns_b], ActorRef::anonymous());
5723
5724 for (tok, prefix) in [(&tok_a, "A"), (&tok_b, "B")] {
5725 let source = rt
5726 .create_entity(
5727 tok,
5728 "concept",
5729 None,
5730 &format!("MultiCursor{prefix}Source"),
5731 None,
5732 None,
5733 vec![],
5734 )
5735 .await
5736 .unwrap();
5737 for i in 0..2 {
5738 let target = rt
5739 .create_entity(
5740 tok,
5741 "concept",
5742 None,
5743 &format!("MultiCursor{prefix}Target{i}"),
5744 None,
5745 None,
5746 vec![],
5747 )
5748 .await
5749 .unwrap();
5750 rt.link(tok, source.id, target.id, EdgeRelation::Extends, 1.0, None)
5751 .await
5752 .unwrap();
5753 }
5754 }
5755
5756 let filter = EdgeListFilter {
5757 relations: vec![EdgeRelation::Extends],
5758 ..Default::default()
5759 };
5760 let (page1, next1) = rt
5761 .list_edges_after(&visible, filter.clone(), None, 2)
5762 .await
5763 .unwrap();
5764 assert_eq!(page1.len(), 2);
5765 let cursor = next1.expect("first merged page must report a cursor when rows remain");
5766
5767 let (page2, next2) = rt
5768 .list_edges_after(&visible, filter, Some(cursor), 2)
5769 .await
5770 .unwrap();
5771 assert_eq!(page2.len(), 2);
5772 assert_eq!(
5773 next2, None,
5774 "an exact-size final multi-namespace page must not report a cursor"
5775 );
5776 }
5777
5778 #[tokio::test]
5781 async fn count_edges_by_relation_matches_fixtures() {
5782 let rt = rt();
5783 let tok = NamespaceToken::local();
5784 let a = rt
5785 .create_entity(&tok, "concept", None, "A", None, None, vec![])
5786 .await
5787 .unwrap();
5788 let b = rt
5789 .create_entity(&tok, "concept", None, "B", None, None, vec![])
5790 .await
5791 .unwrap();
5792 let c = rt
5793 .create_entity(&tok, "concept", None, "C", None, None, vec![])
5794 .await
5795 .unwrap();
5796
5797 rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5798 .await
5799 .unwrap();
5800 rt.link(&tok, a.id, c.id, EdgeRelation::Extends, 1.0, None)
5801 .await
5802 .unwrap();
5803 rt.link(&tok, b.id, c.id, EdgeRelation::Enables, 1.0, None)
5804 .await
5805 .unwrap();
5806
5807 let counts = rt.count_edges_by_relation(&tok).await.unwrap();
5808 assert_eq!(counts.get("extends").copied(), Some(2));
5809 assert_eq!(counts.get("enables").copied(), Some(1));
5810 }
5811
5812 #[tokio::test]
5813 async fn delete_edge_removes_from_storage() {
5814 let rt = rt();
5815 let tok = NamespaceToken::local();
5816 let a = rt
5817 .create_entity(&tok, "concept", None, "A", None, None, vec![])
5818 .await
5819 .unwrap();
5820 let b = rt
5821 .create_entity(&tok, "concept", None, "B", None, None, vec![])
5822 .await
5823 .unwrap();
5824 let edge = rt
5825 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5826 .await
5827 .unwrap();
5828 let edge_id: Uuid = edge.id.into();
5829
5830 let deleted = rt.delete_edge(&tok, edge_id, true).await.unwrap();
5831 assert!(deleted);
5832
5833 let fetched = rt.get_edge(&tok, edge_id).await.unwrap();
5834 assert!(fetched.is_none(), "edge should be gone after delete");
5835 }
5836
5837 #[tokio::test]
5838 async fn count_edges_matches_filter() {
5839 let rt = rt();
5840 let tok = NamespaceToken::local();
5841 let a = rt
5842 .create_entity(&tok, "concept", None, "A", None, None, vec![])
5843 .await
5844 .unwrap();
5845 let b = rt
5846 .create_entity(&tok, "concept", None, "B", None, None, vec![])
5847 .await
5848 .unwrap();
5849 let c = rt
5850 .create_entity(&tok, "concept", None, "C", None, None, vec![])
5851 .await
5852 .unwrap();
5853
5854 rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5855 .await
5856 .unwrap();
5857 rt.link(&tok, a.id, c.id, EdgeRelation::Enables, 1.0, None)
5858 .await
5859 .unwrap();
5860
5861 let all = rt
5862 .count_edges(&tok, EdgeListFilter::default())
5863 .await
5864 .unwrap();
5865 assert_eq!(all, 2);
5866
5867 let just_extends = rt
5868 .count_edges(
5869 &tok,
5870 EdgeListFilter {
5871 relations: vec![EdgeRelation::Extends],
5872 ..Default::default()
5873 },
5874 )
5875 .await
5876 .unwrap();
5877 assert_eq!(just_extends, 1);
5878 }
5879
5880 #[tokio::test]
5886 async fn edge_in_visible_namespace_reachable_as_graph_root() {
5887 let rt = rt();
5888 let ns_a = Namespace::parse("vis-edge-a").unwrap();
5889 let ns_b = Namespace::parse("vis-edge-b").unwrap();
5890
5891 let tok_b = NamespaceToken::for_namespace(ns_b.clone());
5893 let src = rt
5894 .create_entity(&tok_b, "concept", None, "SrcB", None, None, vec![])
5895 .await
5896 .unwrap();
5897 let tgt = rt
5898 .create_entity(&tok_b, "concept", None, "TgtB", None, None, vec![])
5899 .await
5900 .unwrap();
5901 let edge = rt
5902 .link(&tok_b, src.id, tgt.id, EdgeRelation::Extends, 1.0, None)
5903 .await
5904 .unwrap();
5905
5906 let tok_a_vis = rt
5909 .authorize_with_visibility(ns_a.clone(), vec![ns_b.clone()])
5910 .unwrap();
5911
5912 let got = rt.get_edge_visible(&tok_a_vis, edge.id.0).await.unwrap();
5914 assert!(
5915 got.is_some(),
5916 "edge in visible namespace must be retrievable via get_edge_visible"
5917 );
5918
5919 let neighbors = rt
5922 .neighbors(&tok_a_vis, src.id, Direction::Out, Some(16), None)
5923 .await
5924 .unwrap();
5925 assert!(
5926 neighbors.iter().any(|h| h.node_id == tgt.id),
5927 "neighbors of visible-ns node must include its visible-ns neighbor; got: {neighbors:?}"
5928 );
5929 }
5930
5931 #[tokio::test]
5935 async fn get_entity_cross_namespace_no_longer_denied() {
5936 let rt = rt();
5937 let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
5938 let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
5939 let entity = rt
5940 .create_entity(&ns_a, "concept", None, "Alpha", None, None, vec![])
5941 .await
5942 .unwrap();
5943
5944 let found = rt.get_entity(&ns_a, entity.id).await;
5946 assert!(found.is_ok(), "same-namespace get must succeed");
5947
5948 let cross = rt.get_entity(&ns_b, entity.id).await;
5950 assert!(
5951 cross.is_ok(),
5952 "cross-namespace get must succeed in shared-brain OSS (ADR-007 rule 2)"
5953 );
5954 assert_eq!(cross.unwrap().id, entity.id);
5955 }
5956
5957 #[tokio::test]
5958 async fn delete_entity_cross_namespace_no_longer_denied() {
5959 let rt = rt();
5960 let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
5961 let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
5962 let entity = rt
5963 .create_entity(&ns_a, "concept", None, "Beta", None, None, vec![])
5964 .await
5965 .unwrap();
5966
5967 let cross_ns_result = rt.delete_entity(&ns_b, entity.id, true).await;
5969 assert!(
5970 cross_ns_result.is_ok(),
5971 "cross-namespace delete must succeed in shared-brain OSS; got {:?}",
5972 cross_ns_result
5973 );
5974 assert!(cross_ns_result.unwrap(), "delete must return true");
5975
5976 let gone = rt.get_entity(&ns_a, entity.id).await;
5978 assert!(gone.is_err(), "entity must be gone after delete");
5979 }
5980
5981 #[tokio::test]
5984 async fn create_note_indexes_into_fts5() {
5985 let rt = rt();
5986 let tok = NamespaceToken::local();
5987 let note = rt
5988 .create_note(
5989 &tok,
5990 "observation",
5991 None,
5992 "FlashAttention reduces memory by using tiling",
5993 Some(0.8),
5994 None,
5995 vec![],
5996 )
5997 .await
5998 .unwrap();
5999
6000 let ns = tok.namespace().as_str().to_string();
6002 let hits = rt
6003 .text_for_notes(&tok)
6004 .unwrap()
6005 .search(khive_storage::types::TextSearchRequest {
6006 query: "FlashAttention".to_string(),
6007 mode: khive_storage::types::TextQueryMode::Plain,
6008 filter: Some(khive_storage::types::TextFilter {
6009 namespaces: vec![ns],
6010 ..Default::default()
6011 }),
6012 top_k: 10,
6013 snippet_chars: 100,
6014 })
6015 .await
6016 .unwrap();
6017
6018 assert!(
6019 hits.iter().any(|h| h.subject_id == note.id),
6020 "note should be indexed in FTS5 after create"
6021 );
6022 }
6023
6024 #[tokio::test]
6031 async fn search_notes_with_residual_fts5_char_fails_loud() {
6032 let rt = rt();
6033 let tok = NamespaceToken::local();
6034 rt.create_note(
6035 &tok,
6036 "observation",
6037 None,
6038 "use foo@bar to chain calls",
6039 Some(0.5),
6040 None,
6041 vec![],
6042 )
6043 .await
6044 .unwrap();
6045
6046 let result = rt
6047 .search_notes(&tok, "foo@bar", None, 10, None, false, &[], None)
6048 .await;
6049
6050 assert!(
6051 result.is_err(),
6052 "#569 search_notes must fail loud when the FTS leg errors on a residual \
6053 FTS5 char ('@'), not silently degrade to vector-only fusion, got: {:?}",
6054 result.ok()
6055 );
6056 assert!(
6057 matches!(result.unwrap_err(), RuntimeError::InvalidInput(_)),
6058 "residual FTS5 parser failure must surface as RuntimeError::InvalidInput"
6059 );
6060 }
6061
6062 #[tokio::test]
6071 async fn search_notes_propagates_non_parser_fts_error() {
6072 let rt = rt();
6073 let tok = NamespaceToken::local();
6074 rt.create_note(
6075 &tok,
6076 "observation",
6077 None,
6078 "FlashAttention reduces memory by using tiling",
6079 Some(0.8),
6080 None,
6081 vec![],
6082 )
6083 .await
6084 .unwrap();
6085
6086 let ns = tok.namespace().as_str().to_string();
6087 arm_fts_search_fail(&ns);
6088
6089 let result = rt
6090 .search_notes(&tok, "FlashAttention", None, 10, None, false, &[], None)
6091 .await;
6092
6093 assert!(
6094 result.is_err(),
6095 "search_notes must propagate a non-parser FTS StorageError (Timeout) \
6096 as Err, not silently degrade it to an empty result, got: {:?}",
6097 result.ok()
6098 );
6099 assert!(
6100 matches!(
6101 result.unwrap_err(),
6102 RuntimeError::Storage(khive_storage::StorageError::Timeout { .. })
6103 ),
6104 "propagated error must be the injected StorageError::Timeout, unwrapped \
6105 through RuntimeError::Storage"
6106 );
6107 }
6108
6109 #[tokio::test]
6110 async fn create_note_with_properties() {
6111 let rt = rt();
6112 let tok = NamespaceToken::local();
6113 let props = serde_json::json!({"source": "arxiv:2205.14135"});
6114 let note = rt
6115 .create_note(
6116 &tok,
6117 "insight",
6118 None,
6119 "FlashAttention is IO-aware",
6120 Some(0.9),
6121 Some(props.clone()),
6122 vec![],
6123 )
6124 .await
6125 .unwrap();
6126
6127 assert_eq!(note.properties.as_ref().unwrap(), &props);
6128 }
6129
6130 #[tokio::test]
6131 async fn create_note_creates_annotates_edges() {
6132 let rt = rt();
6133 let tok = NamespaceToken::local();
6134 let entity = rt
6135 .create_entity(&tok, "concept", None, "FlashAttention", None, None, vec![])
6136 .await
6137 .unwrap();
6138
6139 let note = rt
6140 .create_note(
6141 &tok,
6142 "observation",
6143 None,
6144 "FlashAttention uses SRAM tiling for memory efficiency",
6145 Some(0.9),
6146 None,
6147 vec![entity.id],
6148 )
6149 .await
6150 .unwrap();
6151
6152 let out_neighbors = rt
6154 .neighbors(
6155 &tok,
6156 note.id,
6157 Direction::Out,
6158 None,
6159 Some(vec![EdgeRelation::Annotates]),
6160 )
6161 .await
6162 .unwrap();
6163 assert_eq!(out_neighbors.len(), 1);
6164 assert_eq!(out_neighbors[0].node_id, entity.id);
6165 assert_eq!(out_neighbors[0].relation, EdgeRelation::Annotates);
6166
6167 let in_neighbors = rt
6169 .neighbors(
6170 &tok,
6171 entity.id,
6172 Direction::In,
6173 None,
6174 Some(vec![EdgeRelation::Annotates]),
6175 )
6176 .await
6177 .unwrap();
6178 assert_eq!(in_neighbors.len(), 1);
6179 assert_eq!(in_neighbors[0].node_id, note.id);
6180 }
6181
6182 #[tokio::test]
6183 async fn neighbors_without_relation_filter_returns_all() {
6184 let rt = rt();
6185 let tok = NamespaceToken::local();
6186 let a = rt
6187 .create_entity(&tok, "concept", None, "A", None, None, vec![])
6188 .await
6189 .unwrap();
6190 let b = rt
6191 .create_entity(&tok, "concept", None, "B", None, None, vec![])
6192 .await
6193 .unwrap();
6194 let c = rt
6195 .create_entity(&tok, "concept", None, "C", None, None, vec![])
6196 .await
6197 .unwrap();
6198
6199 rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
6200 .await
6201 .unwrap();
6202 rt.link(&tok, a.id, c.id, EdgeRelation::Enables, 1.0, None)
6203 .await
6204 .unwrap();
6205
6206 let all = rt
6207 .neighbors(&tok, a.id, Direction::Out, None, None)
6208 .await
6209 .unwrap();
6210 assert_eq!(all.len(), 2);
6211 }
6212
6213 #[tokio::test]
6214 async fn neighbors_with_relation_filter_returns_subset() {
6215 let rt = rt();
6216 let tok = NamespaceToken::local();
6217 let a = rt
6218 .create_entity(&tok, "concept", None, "A", None, None, vec![])
6219 .await
6220 .unwrap();
6221 let b = rt
6222 .create_entity(&tok, "concept", None, "B", None, None, vec![])
6223 .await
6224 .unwrap();
6225 let c = rt
6226 .create_entity(&tok, "concept", None, "C", None, None, vec![])
6227 .await
6228 .unwrap();
6229
6230 rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
6231 .await
6232 .unwrap();
6233 rt.link(&tok, a.id, c.id, EdgeRelation::Enables, 1.0, None)
6234 .await
6235 .unwrap();
6236
6237 let filtered = rt
6238 .neighbors(
6239 &tok,
6240 a.id,
6241 Direction::Out,
6242 None,
6243 Some(vec![EdgeRelation::Extends]),
6244 )
6245 .await
6246 .unwrap();
6247 assert_eq!(filtered.len(), 1);
6248 assert_eq!(filtered[0].node_id, b.id);
6249 assert_eq!(filtered[0].relation, EdgeRelation::Extends);
6250 }
6251
6252 #[tokio::test]
6260 async fn neighbors_with_query_directed_preserves_self_loop_direction_parity() {
6261 let rt = rt();
6262 let tok = NamespaceToken::local();
6263 let centre = rt
6264 .create_entity(&tok, "concept", None, "Centre", None, None, vec![])
6265 .await
6266 .unwrap();
6267
6268 let now = chrono::Utc::now();
6269 rt.graph(&tok)
6270 .unwrap()
6271 .upsert_edge(Edge {
6272 id: LinkId::from(Uuid::new_v4()),
6273 namespace: "local".to_string(),
6274 source_id: centre.id,
6275 target_id: centre.id,
6276 relation: EdgeRelation::Extends,
6277 weight: 0.7,
6278 created_at: now,
6279 updated_at: now,
6280 deleted_at: None,
6281 metadata: None,
6282 target_backend: None,
6283 })
6284 .await
6285 .unwrap();
6286
6287 let directed = rt
6288 .neighbors_with_query_directed(
6289 &tok,
6290 centre.id,
6291 NeighborQuery {
6292 direction: Direction::Both,
6293 relations: None,
6294 limit: None,
6295 min_weight: None,
6296 },
6297 )
6298 .await
6299 .unwrap();
6300
6301 assert_eq!(
6302 directed.len(),
6303 2,
6304 "a self-loop edge must produce both an Out hit and an In hit, not one collapsed hit"
6305 );
6306 let directions: Vec<Direction> = directed.iter().map(|(_, d)| d.clone()).collect();
6307 assert!(
6308 directions.contains(&Direction::Out),
6309 "self-loop must retain its Out-tagged hit"
6310 );
6311 assert!(
6312 directions.contains(&Direction::In),
6313 "self-loop must retain its In-tagged hit"
6314 );
6315 }
6316
6317 #[tokio::test]
6318 async fn search_notes_returns_relevant_note() {
6319 let rt = rt();
6320 let tok = NamespaceToken::local();
6321 rt.create_note(
6322 &tok,
6323 "observation",
6324 None,
6325 "GQA reduces KV cache memory for large models",
6326 Some(0.8),
6327 None,
6328 vec![],
6329 )
6330 .await
6331 .unwrap();
6332
6333 let results = rt
6334 .search_notes(&tok, "GQA KV cache", None, 10, None, false, &[], None)
6335 .await
6336 .unwrap();
6337
6338 assert!(!results.is_empty(), "search should return the indexed note");
6339 let hit = &results[0];
6340 assert!(
6341 hit.title.is_some(),
6342 "note hit title should be populated (falls back to content)"
6343 );
6344 assert!(
6345 hit.snippet.is_some(),
6346 "note hit snippet should be populated"
6347 );
6348 }
6349
6350 #[tokio::test]
6351 async fn search_notes_excludes_soft_deleted() {
6352 let rt = rt();
6353 let tok = NamespaceToken::local();
6354 let note = rt
6355 .create_note(
6356 &tok,
6357 "observation",
6358 None,
6359 "RoPE positional encoding rotary embeddings",
6360 Some(0.7),
6361 None,
6362 vec![],
6363 )
6364 .await
6365 .unwrap();
6366
6367 rt.notes(&tok)
6369 .unwrap()
6370 .delete_note(note.id, DeleteMode::Soft)
6371 .await
6372 .unwrap();
6373
6374 let results = rt
6375 .search_notes(
6376 &tok,
6377 "RoPE rotary positional",
6378 None,
6379 10,
6380 None,
6381 false,
6382 &[],
6383 None,
6384 )
6385 .await
6386 .unwrap();
6387
6388 assert!(
6389 results.iter().all(|h| h.note_id != note.id),
6390 "soft-deleted note should be excluded from search"
6391 );
6392 }
6393
6394 #[tokio::test]
6408 async fn search_notes_tag_filter_pushed_before_truncation() {
6409 let rt = rt();
6410 let tok = NamespaceToken::local();
6411
6412 rt.create_note(
6414 &tok,
6415 "observation",
6416 None,
6417 "kappa lambda mu note decoy kappa lambda mu note decoy kappa lambda mu",
6418 Some(0.5),
6419 Some(serde_json::json!({"tags": ["other-note-tag"]})),
6420 vec![],
6421 )
6422 .await
6423 .unwrap();
6424
6425 let target = rt
6427 .create_note(
6428 &tok,
6429 "observation",
6430 None,
6431 "kappa lambda mu note target",
6432 Some(0.5),
6433 Some(serde_json::json!({"tags": ["note-target-tag"]})),
6434 vec![],
6435 )
6436 .await
6437 .unwrap();
6438
6439 let hits = rt
6442 .search_notes(
6443 &tok,
6444 "kappa lambda mu note",
6445 None,
6446 1,
6447 None,
6448 false,
6449 &["note-target-tag".to_string()],
6450 None,
6451 )
6452 .await
6453 .unwrap();
6454
6455 assert_eq!(
6456 hits.len(),
6457 1,
6458 "exactly one hit expected (tag-matching note)"
6459 );
6460 assert_eq!(
6461 hits[0].note_id, target.id,
6462 "tag-filtered note must be returned even when ranked below limit in raw fusion"
6463 );
6464 }
6465
6466 #[tokio::test]
6473 async fn search_notes_props_filter_pushed_before_truncation() {
6474 let rt = rt();
6475 let tok = NamespaceToken::local();
6476
6477 rt.create_note(
6478 &tok,
6479 "observation",
6480 None,
6481 "nu xi omicron note decoy nu xi omicron note decoy nu xi omicron",
6482 Some(0.5),
6483 Some(serde_json::json!({"source": "other"})),
6484 vec![],
6485 )
6486 .await
6487 .unwrap();
6488
6489 let target = rt
6490 .create_note(
6491 &tok,
6492 "observation",
6493 None,
6494 "nu xi omicron note target",
6495 Some(0.5),
6496 Some(serde_json::json!({"source": "target"})),
6497 vec![],
6498 )
6499 .await
6500 .unwrap();
6501
6502 let filter = serde_json::json!({"source": "target"});
6503 let hits = rt
6504 .search_notes(
6505 &tok,
6506 "nu xi omicron note",
6507 None,
6508 1,
6509 None,
6510 false,
6511 &[],
6512 Some(&filter),
6513 )
6514 .await
6515 .unwrap();
6516
6517 assert_eq!(
6518 hits.len(),
6519 1,
6520 "exactly one hit expected (properties-matching note)"
6521 );
6522 assert_eq!(
6523 hits[0].note_id, target.id,
6524 "properties-filtered note must be returned even when ranked below limit"
6525 );
6526 }
6527
6528 #[tokio::test]
6529 async fn resolve_returns_entity() {
6530 let rt = rt();
6531 let tok = NamespaceToken::local();
6532 let entity = rt
6533 .create_entity(&tok, "concept", None, "LoRA", None, None, vec![])
6534 .await
6535 .unwrap();
6536
6537 let resolved = rt.resolve(&tok, entity.id).await.unwrap();
6538 match resolved {
6539 Some(Resolved::Entity(e)) => assert_eq!(e.id, entity.id),
6540 other => panic!("expected Resolved::Entity, got {:?}", other),
6541 }
6542 }
6543
6544 #[tokio::test]
6545 async fn resolve_returns_note() {
6546 let rt = rt();
6547 let tok = NamespaceToken::local();
6548 let note = rt
6549 .create_note(
6550 &tok,
6551 "observation",
6552 None,
6553 "LoRA fine-tunes LLMs with low-rank adapters",
6554 Some(0.85),
6555 None,
6556 vec![],
6557 )
6558 .await
6559 .unwrap();
6560
6561 let resolved = rt.resolve(&tok, note.id).await.unwrap();
6562 match resolved {
6563 Some(Resolved::Note(n)) => assert_eq!(n.id, note.id),
6564 other => panic!("expected Resolved::Note, got {:?}", other),
6565 }
6566 }
6567
6568 #[tokio::test]
6569 async fn resolve_returns_none_for_unknown_uuid() {
6570 let rt = rt();
6571 let tok = NamespaceToken::local();
6572 let unknown = Uuid::new_v4();
6573 let resolved = rt.resolve(&tok, unknown).await.unwrap();
6574 assert!(resolved.is_none(), "unknown UUID should resolve to None");
6575 }
6576
6577 #[tokio::test]
6578 async fn resolve_prefix_finds_entity_in_own_namespace() {
6579 let rt = rt();
6580 let tok = NamespaceToken::local();
6581 let entity = rt
6582 .create_entity(&tok, "concept", None, "PrefixTest", None, None, vec![])
6583 .await
6584 .unwrap();
6585 let prefix = &entity.id.to_string()[..8];
6586
6587 let resolved = rt.resolve_prefix(&tok, prefix).await.unwrap();
6588 assert_eq!(resolved, Some(entity.id));
6589 }
6590
6591 #[test]
6592 fn hex_prefix_to_uuid_pattern_inserts_hyphens_at_canonical_boundaries() {
6593 let full = "aabbccdd112240008000000000000ab1";
6594 let cases: &[(usize, &str)] = &[
6595 (1, "a"),
6596 (7, "aabbccd"),
6597 (8, "aabbccdd"),
6598 (9, "aabbccdd-1"),
6599 (12, "aabbccdd-1122"),
6600 (13, "aabbccdd-1122-4"),
6601 (16, "aabbccdd-1122-4000"),
6602 (18, "aabbccdd-1122-4000-80"),
6603 (20, "aabbccdd-1122-4000-8000"),
6604 (23, "aabbccdd-1122-4000-8000-000"),
6605 (24, "aabbccdd-1122-4000-8000-0000"),
6606 (28, "aabbccdd-1122-4000-8000-00000000"),
6607 (31, "aabbccdd-1122-4000-8000-000000000ab"),
6608 ];
6609 for (len, expected) in cases {
6610 let input = &full[..*len];
6611 assert_eq!(
6612 hex_prefix_to_uuid_pattern(input),
6613 *expected,
6614 "len={len} input={input:?}"
6615 );
6616 }
6617 }
6618
6619 #[test]
6620 fn hex_prefix_to_uuid_pattern_full_32_char_matches_canonical_uuid() {
6621 let compact = "aabbccdd112240008000000000000ab1";
6622 let compact32 = &compact[..32];
6623 assert_eq!(
6624 hex_prefix_to_uuid_pattern(compact32),
6625 "aabbccdd-1122-4000-8000-000000000ab1"
6626 );
6627 }
6628
6629 #[test]
6635 fn hex_prefix_to_uuid_pattern_overlong_input_is_not_truncated() {
6636 let compact32 = "aabbccdd112240008000000000000ab1";
6637 let overlong = format!("{compact32}extrahex");
6638 let pattern = hex_prefix_to_uuid_pattern(&overlong);
6639 assert_eq!(
6640 pattern, "aabbccdd-1122-4000-8000-000000000ab1extrahex",
6641 "overlong input must keep its extra chars, not truncate to the valid UUID"
6642 );
6643 assert_ne!(
6644 pattern, "aabbccdd-1122-4000-8000-000000000ab1",
6645 "overlong pattern must not collapse to the canonical 36-char UUID form"
6646 );
6647 }
6648
6649 #[test]
6650 fn hex_prefix_to_uuid_pattern_passes_through_hyphenated_input() {
6651 let hyphenated = "aabbccdd-1122-4000-8000-000000000ab1";
6652 assert_eq!(hex_prefix_to_uuid_pattern(hyphenated), hyphenated);
6653
6654 let partial = "aabbccdd-11";
6655 assert_eq!(hex_prefix_to_uuid_pattern(partial), partial);
6656 }
6657
6658 #[tokio::test]
6659 async fn resolve_prefix_compact_9_to_31_char_matches() {
6660 let rt = rt();
6661 let tok = NamespaceToken::local();
6662 let entity = rt
6663 .create_entity(
6664 &tok,
6665 "concept",
6666 None,
6667 "CompactPrefixTest",
6668 None,
6669 None,
6670 vec![],
6671 )
6672 .await
6673 .unwrap();
6674 let compact = entity.id.simple().to_string();
6675
6676 for len in [9, 12, 16, 20, 24, 28, 31] {
6677 let prefix = &compact[..len];
6678 let resolved = rt.resolve_prefix(&tok, prefix).await.unwrap();
6679 assert_eq!(
6680 resolved,
6681 Some(entity.id),
6682 "compact prefix of len {len} should resolve"
6683 );
6684 }
6685 }
6686
6687 #[tokio::test]
6688 async fn resolve_prefix_compact_full_32_char_matches() {
6689 let rt = rt();
6690 let tok = NamespaceToken::local();
6691 let entity = rt
6692 .create_entity(&tok, "concept", None, "Full32Test", None, None, vec![])
6693 .await
6694 .unwrap();
6695 let compact = entity.id.simple().to_string();
6696 assert_eq!(compact.len(), 32);
6697
6698 let resolved = rt.resolve_prefix(&tok, &compact).await.unwrap();
6699 assert_eq!(resolved, Some(entity.id));
6700 }
6701
6702 #[tokio::test]
6705 async fn resolve_prefix_rejects_overlong_all_hex_input() {
6706 let rt = rt();
6707 let tok = NamespaceToken::local();
6708 let entity = rt
6709 .create_entity(&tok, "concept", None, "OverlongTest", None, None, vec![])
6710 .await
6711 .unwrap();
6712 let compact = entity.id.simple().to_string();
6713 assert_eq!(compact.len(), 32);
6714
6715 let overlong = format!("{compact}ab");
6716 let resolved = rt.resolve_prefix(&tok, &overlong).await.unwrap();
6717 assert_eq!(
6718 resolved, None,
6719 "a 32-char id plus extra hex chars must not resolve to the valid entity"
6720 );
6721 }
6722
6723 #[tokio::test]
6729 async fn resolve_prefix_rejects_like_wildcard_input() {
6730 let rt = rt();
6731 let tok = NamespaceToken::local();
6732 let entity = rt
6733 .create_entity(&tok, "concept", None, "WildcardTest", None, None, vec![])
6734 .await
6735 .unwrap();
6736 let compact = entity.id.simple().to_string();
6737 let wildcard_prefix = format!("{}%", &compact[..8]);
6741
6742 let resolved = rt.resolve_prefix(&tok, &wildcard_prefix).await.unwrap();
6743 assert_eq!(
6744 resolved, None,
6745 "prefix containing a LIKE wildcard must be rejected, not resolved"
6746 );
6747 }
6748
6749 #[tokio::test]
6750 async fn resolve_prefix_boundary_at_hyphen_positions() {
6751 let rt = rt();
6752 let tok = NamespaceToken::local();
6753 let entity = rt
6754 .create_entity(&tok, "concept", None, "BoundaryTest", None, None, vec![])
6755 .await
6756 .unwrap();
6757 let compact = entity.id.simple().to_string();
6758
6759 for len in [8, 12, 16, 20, 24] {
6760 let prefix = &compact[..len];
6761 let resolved = rt.resolve_prefix(&tok, prefix).await.unwrap();
6762 assert_eq!(
6763 resolved,
6764 Some(entity.id),
6765 "boundary prefix of len {len} should resolve"
6766 );
6767 }
6768 }
6769
6770 #[tokio::test]
6771 async fn resolve_prefix_ambiguous_still_detected_after_normalization() {
6772 use khive_storage::entity::Entity;
6773
6774 let rt = rt();
6775 let tok = NamespaceToken::local();
6776 let id_a = Uuid::parse_str("aabbccdd-1111-4000-8000-000000000001").unwrap();
6777 let id_b = Uuid::parse_str("aabbccdd-1111-4000-8000-000000000002").unwrap();
6778
6779 let mut entity_a = Entity::new("local", "concept", "AmbigCompactA");
6780 entity_a.id = id_a;
6781 let mut entity_b = Entity::new("local", "concept", "AmbigCompactB");
6782 entity_b.id = id_b;
6783
6784 let store = rt.entities(&tok).unwrap();
6785 store.upsert_entity(entity_a).await.unwrap();
6786 store.upsert_entity(entity_b).await.unwrap();
6787
6788 let shared_compact = &id_a.simple().to_string()[..20];
6790 let err = rt.resolve_prefix(&tok, shared_compact).await.unwrap_err();
6791 assert!(
6792 matches!(
6793 err,
6794 RuntimeError::AmbiguousPrefix { ref matches, .. } if matches.len() == 2
6795 ),
6796 "shared compact prefix must still return AmbiguousPrefix; got {err:?}"
6797 );
6798 }
6799
6800 #[tokio::test]
6801 async fn resolve_prefix_invisible_across_namespaces() {
6802 let rt = rt();
6803 let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
6804 let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
6805 let entity = rt
6806 .create_entity(&ns_a, "concept", None, "Invisible", None, None, vec![])
6807 .await
6808 .unwrap();
6809 let prefix = &entity.id.to_string()[..8];
6810
6811 let resolved = rt.resolve_prefix(&ns_b, prefix).await.unwrap();
6813 assert_eq!(resolved, None);
6814 }
6815
6816 #[tokio::test]
6817 async fn resolve_prefix_ambiguous_same_namespace() {
6818 use khive_storage::entity::Entity;
6819
6820 let rt = rt();
6821 let tok = NamespaceToken::local();
6822 let id_a = Uuid::parse_str("aabbccdd-1111-4000-8000-000000000001").unwrap();
6824 let id_b = Uuid::parse_str("aabbccdd-2222-4000-8000-000000000002").unwrap();
6825
6826 let mut entity_a = Entity::new("local", "concept", "AmbigA");
6827 entity_a.id = id_a;
6828 let mut entity_b = Entity::new("local", "concept", "AmbigB");
6829 entity_b.id = id_b;
6830
6831 let store = rt.entities(&tok).unwrap();
6832 store.upsert_entity(entity_a).await.unwrap();
6833 store.upsert_entity(entity_b).await.unwrap();
6834
6835 let err = rt.resolve_prefix(&tok, "aabbccdd").await.unwrap_err();
6836 assert!(
6837 matches!(
6838 err,
6839 RuntimeError::AmbiguousPrefix { ref prefix, ref matches }
6840 if prefix == "aabbccdd" && matches.len() == 2
6841 ),
6842 "shared 8-char prefix must return AmbiguousPrefix; got {err:?}"
6843 );
6844 }
6845
6846 #[tokio::test]
6851 async fn resolve_prefix_cross_table_duplicate_uuid_resolves_cleanly() {
6852 use khive_storage::entity::Entity;
6853
6854 let rt = rt();
6855 let tok = NamespaceToken::local();
6856 let shared_id = Uuid::parse_str("ccddeeff-1111-4000-8000-000000000001").unwrap();
6857
6858 let mut entity = Entity::new("local", "concept", "Nvk749Entity");
6859 entity.id = shared_id;
6860 rt.entities(&tok)
6861 .unwrap()
6862 .upsert_entity(entity)
6863 .await
6864 .unwrap();
6865
6866 let mut note = Note::new("local", "observation", "nvk749 note with the same id");
6867 note.id = shared_id;
6868 rt.notes(&tok).unwrap().upsert_note(note).await.unwrap();
6869
6870 let resolved = rt
6871 .resolve_prefix(&tok, "ccddeeff")
6872 .await
6873 .expect("#749: a UUID present in two tables must not be reported as ambiguous");
6874 assert_eq!(
6875 resolved,
6876 Some(shared_id),
6877 "#749: cross-table duplicate must resolve to the single shared UUID"
6878 );
6879 }
6880
6881 #[tokio::test]
6889 async fn resolve_prefix_early_exit_uses_deduped_match_count() {
6890 use khive_storage::entity::Entity;
6891
6892 let rt = rt();
6893 let tok = NamespaceToken::local();
6894 let shared_id = Uuid::parse_str("ddeeff11-2222-4000-8000-000000000002").unwrap();
6895
6896 let mut entity = Entity::new("local", "concept", "Nvk749bEntity");
6901 entity.id = shared_id;
6902 rt.entities(&tok)
6903 .unwrap()
6904 .upsert_entity(entity)
6905 .await
6906 .unwrap();
6907
6908 let mut note = Note::new("local", "observation", "nvk749b note with the same id");
6909 note.id = shared_id;
6910 rt.notes(&tok).unwrap().upsert_note(note).await.unwrap();
6911
6912 let resolved = rt
6913 .resolve_prefix(&tok, "ddeeff11")
6914 .await
6915 .expect("#749: deduped early-exit must not falsely report ambiguity");
6916 assert_eq!(resolved, Some(shared_id));
6917 }
6918
6919 #[tokio::test]
6926 async fn resolve_finds_event_by_full_uuid() {
6927 use khive_storage::Event;
6928 use khive_types::{EventKind, SubstrateKind};
6929
6930 let rt = rt();
6931 let tok = NamespaceToken::local();
6932 let ns = tok.namespace().as_str();
6933 let event = Event::new(
6934 ns,
6935 "test_verb",
6936 EventKind::Audit,
6937 SubstrateKind::Entity,
6938 "actor",
6939 );
6940 let event_id = event.id;
6941 rt.events(&tok).unwrap().append_event(event).await.unwrap();
6942
6943 let resolved = rt.resolve(&tok, event_id).await.unwrap();
6944 assert!(
6945 matches!(resolved, Some(Resolved::Event(_))),
6946 "event UUID must resolve to Resolved::Event, got {resolved:?}"
6947 );
6948 }
6949
6950 #[tokio::test]
6951 async fn resolve_prefix_finds_event() {
6952 use khive_storage::Event;
6953 use khive_types::{EventKind, SubstrateKind};
6954
6955 let rt = rt();
6956 let tok = NamespaceToken::local();
6957 let ns = tok.namespace().as_str();
6958 let event = Event::new(
6959 ns,
6960 "test_verb",
6961 EventKind::Audit,
6962 SubstrateKind::Entity,
6963 "actor",
6964 );
6965 let event_id = event.id;
6966 rt.events(&tok).unwrap().append_event(event).await.unwrap();
6967
6968 let prefix = &event_id.to_string()[..8];
6969 let resolved = rt.resolve_prefix(&tok, prefix).await.unwrap();
6970 assert_eq!(
6971 resolved,
6972 Some(event_id),
6973 "resolve_prefix must return event UUID for 8-char prefix"
6974 );
6975 }
6976
6977 #[tokio::test]
6980 async fn link_phantom_source_returns_not_found() {
6981 let rt = rt();
6982 let tok = NamespaceToken::local();
6983 let b = rt
6984 .create_entity(&tok, "concept", None, "B", None, None, vec![])
6985 .await
6986 .unwrap();
6987 let phantom = Uuid::new_v4();
6988
6989 let result = rt
6990 .link(&tok, phantom, b.id, EdgeRelation::Extends, 1.0, None)
6991 .await;
6992 match result {
6993 Err(RuntimeError::NotFound(msg)) => {
6994 assert!(
6995 msg.contains("source"),
6996 "error message must name 'source': {msg}"
6997 );
6998 }
6999 other => panic!("expected NotFound for phantom source, got {other:?}"),
7000 }
7001 }
7002
7003 #[tokio::test]
7004 async fn link_phantom_target_returns_not_found() {
7005 let rt = rt();
7006 let tok = NamespaceToken::local();
7007 let a = rt
7008 .create_entity(&tok, "concept", None, "A", None, None, vec![])
7009 .await
7010 .unwrap();
7011 let phantom = Uuid::new_v4();
7012
7013 let result = rt
7014 .link(&tok, a.id, phantom, EdgeRelation::Extends, 1.0, None)
7015 .await;
7016 match result {
7017 Err(RuntimeError::NotFound(msg)) => {
7018 assert!(
7019 msg.contains("target"),
7020 "error message must name 'target': {msg}"
7021 );
7022 }
7023 other => panic!("expected NotFound for phantom target, got {other:?}"),
7024 }
7025 }
7026
7027 #[tokio::test]
7028 async fn link_real_entities_succeeds() {
7029 let rt = rt();
7030 let tok = NamespaceToken::local();
7031 let a = rt
7032 .create_entity(&tok, "concept", None, "A", None, None, vec![])
7033 .await
7034 .unwrap();
7035 let b = rt
7036 .create_entity(&tok, "concept", None, "B", None, None, vec![])
7037 .await
7038 .unwrap();
7039
7040 let edge = rt
7041 .link(&tok, a.id, b.id, EdgeRelation::Extends, 0.8, None)
7042 .await
7043 .unwrap();
7044 assert_eq!(edge.source_id, a.id);
7045 assert_eq!(edge.target_id, b.id);
7046 assert_eq!(edge.relation, EdgeRelation::Extends);
7047 }
7048
7049 #[tokio::test]
7056 async fn link_write_time_guard_blocks_dangling_edge_after_target_vanishes() {
7057 let rt = rt();
7058 let tok = NamespaceToken::local();
7059 let a = rt
7060 .create_entity(&tok, "concept", None, "A", None, None, vec![])
7061 .await
7062 .unwrap();
7063 let x = rt
7064 .create_entity(&tok, "concept", None, "X", None, None, vec![])
7065 .await
7066 .unwrap();
7067
7068 rt.validate_edge_relation_endpoints(&tok, a.id, x.id, EdgeRelation::Extends)
7069 .await
7070 .expect("prepare-time validation must pass while X is live");
7071
7072 assert!(rt.delete_entity(&tok, x.id, true).await.unwrap());
7073
7074 let now = chrono::Utc::now();
7075 let edge = Edge {
7076 id: LinkId::from(Uuid::new_v4()),
7077 namespace: tok.namespace().as_str().to_string(),
7078 source_id: a.id,
7079 target_id: x.id,
7080 relation: EdgeRelation::Extends,
7081 weight: 1.0,
7082 created_at: now,
7083 updated_at: now,
7084 deleted_at: None,
7085 metadata: None,
7086 target_backend: None,
7087 };
7088 let outcome = rt
7089 .graph(&tok)
7090 .unwrap()
7091 .upsert_edge_guarded(edge)
7092 .await
7093 .unwrap();
7094 match outcome {
7095 khive_storage::GuardedWriteOutcome::Refused(missing) => {
7096 assert!(missing.target, "target must be reported missing");
7097 }
7098 other => panic!(
7099 "guarded write must refuse an edge whose target vanished before commit, got {other:?}"
7100 ),
7101 }
7102
7103 let edges = rt
7104 .list_edges(
7105 &tok,
7106 crate::curation::EdgeListFilter {
7107 source_id: Some(a.id),
7108 target_id: Some(x.id),
7109 relations: vec![EdgeRelation::Extends],
7110 ..Default::default()
7111 },
7112 10,
7113 0,
7114 )
7115 .await
7116 .unwrap();
7117 assert!(
7118 edges.is_empty(),
7119 "no dangling edge may be persisted after the guarded write refused it"
7120 );
7121 }
7122
7123 #[tokio::test]
7124 async fn link_many_writes_nothing_when_one_target_vanishes_before_write() {
7125 let rt = rt();
7126 let tok = NamespaceToken::local();
7127 let a = rt
7128 .create_entity(&tok, "concept", None, "A", None, None, vec![])
7129 .await
7130 .unwrap();
7131 let b = rt
7132 .create_entity(&tok, "concept", None, "B", None, None, vec![])
7133 .await
7134 .unwrap();
7135 let x = rt
7136 .create_entity(&tok, "concept", None, "X", None, None, vec![])
7137 .await
7138 .unwrap();
7139
7140 let specs = vec![
7141 LinkSpec {
7142 namespace: None,
7143 source_id: a.id,
7144 target_id: x.id,
7145 relation: EdgeRelation::Extends,
7146 weight: 1.0,
7147 metadata: None,
7148 },
7149 LinkSpec {
7150 namespace: None,
7151 source_id: a.id,
7152 target_id: b.id,
7153 relation: EdgeRelation::Extends,
7154 weight: 1.0,
7155 metadata: None,
7156 },
7157 ];
7158
7159 let mut edges = Vec::with_capacity(specs.len());
7161 for spec in &specs {
7162 edges.push(rt.build_edge(&tok, spec).await.unwrap());
7163 }
7164
7165 assert!(rt.delete_entity(&tok, x.id, true).await.unwrap());
7169
7170 let outcome = rt
7171 .graph(&tok)
7172 .unwrap()
7173 .upsert_edges_guarded(edges)
7174 .await
7175 .unwrap();
7176 assert_eq!(
7177 outcome.summary.affected, 0,
7178 "no edge from the batch may be persisted when any endpoint vanished"
7179 );
7180 assert!(
7181 outcome.refused.is_some(),
7182 "refused batch entry must be reported"
7183 );
7184
7185 let edges = rt
7186 .list_edges(
7187 &tok,
7188 crate::curation::EdgeListFilter {
7189 source_id: Some(a.id),
7190 relations: vec![EdgeRelation::Extends],
7191 ..Default::default()
7192 },
7193 10,
7194 0,
7195 )
7196 .await
7197 .unwrap();
7198 assert!(
7199 edges.is_empty(),
7200 "link_many's guarded batch must be all-or-nothing: the live A-B edge \
7201 must not have been persisted alongside the doomed A-X edge"
7202 );
7203 }
7204
7205 fn raw_edge(source_id: Uuid, target_id: Uuid, ns: &str) -> Edge {
7216 let now = chrono::Utc::now();
7217 Edge {
7218 id: LinkId::from(Uuid::new_v4()),
7219 namespace: ns.to_string(),
7220 source_id,
7221 target_id,
7222 relation: EdgeRelation::Extends,
7223 weight: 1.0,
7224 created_at: now,
7225 updated_at: now,
7226 deleted_at: None,
7227 metadata: None,
7228 target_backend: None,
7229 }
7230 }
7231
7232 async fn assert_no_edges_touch(rt: &KhiveRuntime, tok: &NamespaceToken, node_id: Uuid) {
7233 let as_source = rt
7234 .list_edges(
7235 tok,
7236 crate::curation::EdgeListFilter {
7237 source_id: Some(node_id),
7238 ..Default::default()
7239 },
7240 10,
7241 0,
7242 )
7243 .await
7244 .unwrap();
7245 let as_target = rt
7246 .list_edges(
7247 tok,
7248 crate::curation::EdgeListFilter {
7249 target_id: Some(node_id),
7250 ..Default::default()
7251 },
7252 10,
7253 0,
7254 )
7255 .await
7256 .unwrap();
7257 assert!(
7258 as_source.is_empty() && as_target.is_empty(),
7259 "no edge may reference hard-deleted node {node_id}: source-side={as_source:?} \
7260 target-side={as_target:?}"
7261 );
7262 }
7263
7264 #[tokio::test]
7265 async fn hard_delete_entity_purges_edge_written_before_delete() {
7266 let rt = rt();
7267 let tok = NamespaceToken::local();
7268 let a = rt
7269 .create_entity(&tok, "concept", None, "A", None, None, vec![])
7270 .await
7271 .unwrap();
7272 let x = rt
7273 .create_entity(&tok, "concept", None, "X", None, None, vec![])
7274 .await
7275 .unwrap();
7276
7277 let edge = raw_edge(a.id, x.id, tok.namespace().as_str());
7278 assert_eq!(
7279 rt.graph(&tok)
7280 .unwrap()
7281 .upsert_edge_guarded(edge)
7282 .await
7283 .unwrap(),
7284 khive_storage::GuardedWriteOutcome::Written
7285 );
7286
7287 assert!(rt.delete_entity(&tok, x.id, true).await.unwrap());
7288 assert_no_edges_touch(&rt, &tok, x.id).await;
7289 }
7290
7291 #[tokio::test]
7292 async fn hard_delete_entity_concurrent_with_guarded_write_never_leaves_dangling_edge() {
7293 let rt = std::sync::Arc::new(rt());
7294 let tok = NamespaceToken::local();
7295 let a = rt
7296 .create_entity(&tok, "concept", None, "A", None, None, vec![])
7297 .await
7298 .unwrap();
7299 let x = rt
7300 .create_entity(&tok, "concept", None, "X", None, None, vec![])
7301 .await
7302 .unwrap();
7303
7304 let delete_rt = std::sync::Arc::clone(&rt);
7305 let delete_tok = tok.clone();
7306 let delete_task =
7307 tokio::spawn(async move { delete_rt.delete_entity(&delete_tok, x.id, true).await });
7308
7309 let write_rt = std::sync::Arc::clone(&rt);
7310 let write_tok = tok.clone();
7311 let ns = tok.namespace().as_str().to_string();
7312 let write_task = tokio::spawn(async move {
7313 let edge = raw_edge(a.id, x.id, &ns);
7314 write_rt
7315 .graph(&write_tok)
7316 .unwrap()
7317 .upsert_edge_guarded(edge)
7318 .await
7319 });
7320
7321 let (deleted, _written) = tokio::join!(delete_task, write_task);
7322 deleted.unwrap().unwrap();
7323 assert_no_edges_touch(&rt, &tok, x.id).await;
7324 }
7325
7326 #[tokio::test]
7327 async fn hard_delete_note_purges_edge_written_before_delete() {
7328 let rt = rt();
7329 let tok = NamespaceToken::local();
7330 let a = rt
7331 .create_entity(&tok, "concept", None, "A", None, None, vec![])
7332 .await
7333 .unwrap();
7334 let n = rt
7335 .create_note(
7336 &tok,
7337 "observation",
7338 None,
7339 "note content",
7340 None,
7341 None,
7342 vec![],
7343 )
7344 .await
7345 .unwrap();
7346
7347 let edge = raw_edge(a.id, n.id, tok.namespace().as_str());
7348 assert_eq!(
7349 rt.graph(&tok)
7350 .unwrap()
7351 .upsert_edge_guarded(edge)
7352 .await
7353 .unwrap(),
7354 khive_storage::GuardedWriteOutcome::Written
7355 );
7356
7357 assert!(rt.delete_note(&tok, n.id, true).await.unwrap());
7358 assert_no_edges_touch(&rt, &tok, n.id).await;
7359 }
7360
7361 #[tokio::test]
7362 async fn hard_delete_note_concurrent_with_guarded_write_never_leaves_dangling_edge() {
7363 let rt = std::sync::Arc::new(rt());
7364 let tok = NamespaceToken::local();
7365 let a = rt
7366 .create_entity(&tok, "concept", None, "A", None, None, vec![])
7367 .await
7368 .unwrap();
7369 let n = rt
7370 .create_note(
7371 &tok,
7372 "observation",
7373 None,
7374 "note content",
7375 None,
7376 None,
7377 vec![],
7378 )
7379 .await
7380 .unwrap();
7381
7382 let delete_rt = std::sync::Arc::clone(&rt);
7383 let delete_tok = tok.clone();
7384 let note_id = n.id;
7385 let delete_task =
7386 tokio::spawn(async move { delete_rt.delete_note(&delete_tok, note_id, true).await });
7387
7388 let write_rt = std::sync::Arc::clone(&rt);
7389 let write_tok = tok.clone();
7390 let ns = tok.namespace().as_str().to_string();
7391 let write_task = tokio::spawn(async move {
7392 let edge = raw_edge(a.id, note_id, &ns);
7393 write_rt
7394 .graph(&write_tok)
7395 .unwrap()
7396 .upsert_edge_guarded(edge)
7397 .await
7398 });
7399
7400 let (deleted, _written) = tokio::join!(delete_task, write_task);
7401 deleted.unwrap().unwrap();
7402 assert_no_edges_touch(&rt, &tok, note_id).await;
7403 }
7404
7405 #[tokio::test]
7406 async fn hard_delete_edge_endpoint_purges_annotating_edge_written_before_delete() {
7407 let rt = rt();
7408 let tok = NamespaceToken::local();
7409 let a = rt
7410 .create_entity(&tok, "concept", None, "A", None, None, vec![])
7411 .await
7412 .unwrap();
7413 let b = rt
7414 .create_entity(&tok, "concept", None, "B", None, None, vec![])
7415 .await
7416 .unwrap();
7417 let n = rt
7418 .create_note(
7419 &tok,
7420 "observation",
7421 None,
7422 "note content",
7423 None,
7424 None,
7425 vec![],
7426 )
7427 .await
7428 .unwrap();
7429 let base_edge = rt
7430 .link(&tok, a.id, b.id, EdgeRelation::Extends, 0.8, None)
7431 .await
7432 .unwrap();
7433 let base_edge_id = Uuid::from(base_edge.id);
7434
7435 let annotating = raw_edge(n.id, base_edge_id, tok.namespace().as_str());
7438 assert_eq!(
7439 rt.graph(&tok)
7440 .unwrap()
7441 .upsert_edge_guarded(annotating)
7442 .await
7443 .unwrap(),
7444 khive_storage::GuardedWriteOutcome::Written
7445 );
7446
7447 assert!(rt.delete_edge(&tok, base_edge_id, true).await.unwrap());
7448 assert_no_edges_touch(&rt, &tok, base_edge_id).await;
7449 }
7450
7451 #[tokio::test]
7452 async fn hard_delete_edge_endpoint_concurrent_with_guarded_write_never_leaves_dangling_edge() {
7453 let rt = std::sync::Arc::new(rt());
7454 let tok = NamespaceToken::local();
7455 let a = rt
7456 .create_entity(&tok, "concept", None, "A", None, None, vec![])
7457 .await
7458 .unwrap();
7459 let b = rt
7460 .create_entity(&tok, "concept", None, "B", None, None, vec![])
7461 .await
7462 .unwrap();
7463 let n = rt
7464 .create_note(
7465 &tok,
7466 "observation",
7467 None,
7468 "note content",
7469 None,
7470 None,
7471 vec![],
7472 )
7473 .await
7474 .unwrap();
7475 let base_edge = rt
7476 .link(&tok, a.id, b.id, EdgeRelation::Extends, 0.8, None)
7477 .await
7478 .unwrap();
7479 let base_edge_id = Uuid::from(base_edge.id);
7480
7481 let delete_rt = std::sync::Arc::clone(&rt);
7482 let delete_tok = tok.clone();
7483 let delete_task =
7484 tokio::spawn(
7485 async move { delete_rt.delete_edge(&delete_tok, base_edge_id, true).await },
7486 );
7487
7488 let write_rt = std::sync::Arc::clone(&rt);
7489 let write_tok = tok.clone();
7490 let ns = tok.namespace().as_str().to_string();
7491 let write_task = tokio::spawn(async move {
7492 let edge = raw_edge(n.id, base_edge_id, &ns);
7493 write_rt
7494 .graph(&write_tok)
7495 .unwrap()
7496 .upsert_edge_guarded(edge)
7497 .await
7498 });
7499
7500 let (deleted, _written) = tokio::join!(delete_task, write_task);
7501 deleted.unwrap().unwrap();
7502 assert_no_edges_touch(&rt, &tok, base_edge_id).await;
7503 }
7504
7505 fn file_backed_runtime(
7523 dir: &tempfile::TempDir,
7524 name: &str,
7525 write_queue_enabled: bool,
7526 ) -> KhiveRuntime {
7527 let path = dir.path().join(name);
7528 if write_queue_enabled {
7529 std::env::set_var("KHIVE_WRITE_QUEUE", "1");
7530 } else {
7531 std::env::remove_var("KHIVE_WRITE_QUEUE");
7532 }
7533 let rt = KhiveRuntime::new(crate::config::RuntimeConfig {
7534 db_path: Some(path),
7535 packs: vec!["kg".to_string()],
7536 brain_profile: None,
7537 actor_id: None,
7538 ..crate::config::RuntimeConfig::no_embeddings()
7539 })
7540 .unwrap();
7541 std::env::remove_var("KHIVE_WRITE_QUEUE");
7542 rt
7543 }
7544
7545 async fn assert_guarded_write_committed_before_delete_is_swept(rt: &KhiveRuntime) {
7546 let tok = NamespaceToken::local();
7547 let a = rt
7548 .create_entity(&tok, "concept", None, "A", None, None, vec![])
7549 .await
7550 .unwrap();
7551 let x = rt
7552 .create_entity(&tok, "concept", None, "X", None, None, vec![])
7553 .await
7554 .unwrap();
7555
7556 let edge = raw_edge(a.id, x.id, tok.namespace().as_str());
7559 assert_eq!(
7560 rt.graph(&tok)
7561 .unwrap()
7562 .upsert_edge_guarded(edge)
7563 .await
7564 .unwrap(),
7565 khive_storage::GuardedWriteOutcome::Written,
7566 "write must succeed while both endpoints are still live"
7567 );
7568
7569 assert!(rt.delete_entity(&tok, x.id, true).await.unwrap());
7570 assert_no_edges_touch(rt, &tok, x.id).await;
7571 }
7572
7573 async fn assert_guarded_write_attempted_after_delete_is_refused(rt: &KhiveRuntime) {
7574 let tok = NamespaceToken::local();
7575 let a = rt
7576 .create_entity(&tok, "concept", None, "A", None, None, vec![])
7577 .await
7578 .unwrap();
7579 let x = rt
7580 .create_entity(&tok, "concept", None, "X", None, None, vec![])
7581 .await
7582 .unwrap();
7583
7584 assert!(rt.delete_entity(&tok, x.id, true).await.unwrap());
7587
7588 let edge = raw_edge(a.id, x.id, tok.namespace().as_str());
7589 let outcome = rt
7590 .graph(&tok)
7591 .unwrap()
7592 .upsert_edge_guarded(edge)
7593 .await
7594 .unwrap();
7595 match outcome {
7596 khive_storage::GuardedWriteOutcome::Refused(missing) => {
7597 assert!(
7598 missing.target,
7599 "target must be reported missing once the delete has committed"
7600 );
7601 assert!(!missing.source, "source was never deleted");
7602 }
7603 other => panic!(
7604 "guarded write attempted after the delete committed must be refused, got {other:?}"
7605 ),
7606 }
7607 assert_no_edges_touch(rt, &tok, x.id).await;
7608 }
7609
7610 #[tokio::test]
7611 #[serial_test::serial(khive_write_queue_env)]
7612 async fn guarded_write_before_delete_swept_file_backed_write_queue_off() {
7613 let dir = tempfile::tempdir().unwrap();
7614 let rt = file_backed_runtime(&dir, "guard_before_off.db", false);
7615 assert_guarded_write_committed_before_delete_is_swept(&rt).await;
7616 }
7617
7618 #[tokio::test]
7619 #[serial_test::serial(khive_write_queue_env)]
7620 async fn guarded_write_after_delete_refused_file_backed_write_queue_off() {
7621 let dir = tempfile::tempdir().unwrap();
7622 let rt = file_backed_runtime(&dir, "guard_after_off.db", false);
7623 assert_guarded_write_attempted_after_delete_is_refused(&rt).await;
7624 }
7625
7626 #[tokio::test]
7627 #[serial_test::serial(khive_write_queue_env)]
7628 async fn guarded_write_before_delete_swept_file_backed_write_queue_on() {
7629 let dir = tempfile::tempdir().unwrap();
7630 let rt = file_backed_runtime(&dir, "guard_before_on.db", true);
7631 assert_guarded_write_committed_before_delete_is_swept(&rt).await;
7632 }
7633
7634 #[tokio::test]
7635 #[serial_test::serial(khive_write_queue_env)]
7636 async fn guarded_write_after_delete_refused_file_backed_write_queue_on() {
7637 let dir = tempfile::tempdir().unwrap();
7638 let rt = file_backed_runtime(&dir, "guard_after_on.db", true);
7639 assert_guarded_write_attempted_after_delete_is_refused(&rt).await;
7640 }
7641
7642 #[tokio::test]
7643 async fn create_note_annotates_phantom_returns_not_found() {
7644 let rt = rt();
7645 let tok = NamespaceToken::local();
7646 let phantom = Uuid::new_v4();
7647
7648 let result = rt
7649 .create_note(
7650 &tok,
7651 "observation",
7652 None,
7653 "some content",
7654 Some(0.5),
7655 None,
7656 vec![phantom],
7657 )
7658 .await;
7659 assert!(
7660 matches!(result, Err(RuntimeError::NotFound(_))),
7661 "annotates with phantom uuid must return NotFound, got {result:?}"
7662 );
7663 }
7664
7665 #[tokio::test]
7666 async fn create_note_annotates_real_entity_succeeds() {
7667 let rt = rt();
7668 let tok = NamespaceToken::local();
7669 let entity = rt
7670 .create_entity(&tok, "concept", None, "RealTarget", None, None, vec![])
7671 .await
7672 .unwrap();
7673
7674 let note = rt
7675 .create_note(
7676 &tok,
7677 "observation",
7678 None,
7679 "content",
7680 Some(0.5),
7681 None,
7682 vec![entity.id],
7683 )
7684 .await
7685 .unwrap();
7686
7687 let neighbors = rt
7688 .neighbors(
7689 &tok,
7690 note.id,
7691 Direction::Out,
7692 None,
7693 Some(vec![EdgeRelation::Annotates]),
7694 )
7695 .await
7696 .unwrap();
7697 assert_eq!(neighbors.len(), 1);
7698 assert_eq!(neighbors[0].node_id, entity.id);
7699 }
7700
7701 #[tokio::test]
7703 async fn create_note_multi_annotates_creates_all_edges() {
7704 let rt = rt();
7705 let tok = NamespaceToken::local();
7706 let t1 = rt
7707 .create_entity(&tok, "concept", None, "Target1", None, None, vec![])
7708 .await
7709 .unwrap();
7710 let t2 = rt
7711 .create_entity(&tok, "concept", None, "Target2", None, None, vec![])
7712 .await
7713 .unwrap();
7714
7715 let note = rt
7716 .create_note(
7717 &tok,
7718 "observation",
7719 None,
7720 "content",
7721 Some(0.5),
7722 None,
7723 vec![t1.id, t2.id],
7724 )
7725 .await
7726 .unwrap();
7727
7728 let neighbors = rt
7729 .neighbors(
7730 &tok,
7731 note.id,
7732 Direction::Out,
7733 None,
7734 Some(vec![EdgeRelation::Annotates]),
7735 )
7736 .await
7737 .unwrap();
7738 assert_eq!(
7739 neighbors.len(),
7740 2,
7741 "multi-annotates note must have exactly 2 outbound annotates edges"
7742 );
7743 let target_ids: Vec<Uuid> = neighbors.iter().map(|n| n.node_id).collect();
7744 assert!(target_ids.contains(&t1.id));
7745 assert!(target_ids.contains(&t2.id));
7746 }
7747
7748 #[tokio::test]
7752 async fn link_target_in_different_namespace_succeeds() {
7753 let rt = rt();
7754 let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
7755 let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
7756 let a = rt
7757 .create_entity(&ns_a, "concept", None, "A", None, None, vec![])
7758 .await
7759 .unwrap();
7760 let b = rt
7761 .create_entity(&ns_b, "concept", None, "B", None, None, vec![])
7762 .await
7763 .unwrap();
7764
7765 let result = rt
7767 .link(&ns_a, a.id, b.id, EdgeRelation::Extends, 1.0, None)
7768 .await;
7769 assert!(
7770 result.is_ok(),
7771 "target in a different namespace than the caller must resolve (#631), got {result:?}"
7772 );
7773 }
7774
7775 #[tokio::test]
7776 async fn link_phantom_self_loop_returns_invalid_input() {
7777 let rt = rt();
7778 let tok = NamespaceToken::local();
7779 let phantom = Uuid::new_v4();
7780
7781 let result = rt
7782 .link(&tok, phantom, phantom, EdgeRelation::Extends, 1.0, None)
7783 .await;
7784 match result {
7785 Err(RuntimeError::InvalidInput(msg)) => {
7786 assert!(
7787 msg.contains("self-loop"),
7788 "self-loop must be rejected with self-loop message: {msg}"
7789 );
7790 }
7791 other => panic!("expected InvalidInput for self-loop, got {other:?}"),
7792 }
7793 }
7794
7795 #[tokio::test]
7798 async fn link_note_to_edge_annotates_succeeds() {
7799 let rt = rt();
7800 let tok = NamespaceToken::local();
7801 let a = rt
7802 .create_entity(&tok, "concept", None, "A", None, None, vec![])
7803 .await
7804 .unwrap();
7805 let b = rt
7806 .create_entity(&tok, "concept", None, "B", None, None, vec![])
7807 .await
7808 .unwrap();
7809 let edge = rt
7811 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
7812 .await
7813 .unwrap();
7814 let edge_uuid: Uuid = edge.id.into();
7815
7816 let note = rt
7818 .create_note(
7819 &tok,
7820 "observation",
7821 None,
7822 "edge note",
7823 Some(0.5),
7824 None,
7825 vec![],
7826 )
7827 .await
7828 .unwrap();
7829
7830 let result = rt
7831 .link(&tok, note.id, edge_uuid, EdgeRelation::Annotates, 1.0, None)
7832 .await;
7833 assert!(
7834 result.is_ok(),
7835 "note→edge Annotates must succeed, got {result:?}"
7836 );
7837 }
7838
7839 #[tokio::test]
7840 async fn create_note_annotates_real_edge_succeeds() {
7841 let rt = rt();
7842 let tok = NamespaceToken::local();
7843 let a = rt
7844 .create_entity(&tok, "concept", None, "A", None, None, vec![])
7845 .await
7846 .unwrap();
7847 let b = rt
7848 .create_entity(&tok, "concept", None, "B", None, None, vec![])
7849 .await
7850 .unwrap();
7851 let edge = rt
7852 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
7853 .await
7854 .unwrap();
7855 let edge_uuid: Uuid = edge.id.into();
7856
7857 let note = rt
7858 .create_note(
7859 &tok,
7860 "observation",
7861 None,
7862 "annotating an edge",
7863 Some(0.5),
7864 None,
7865 vec![edge_uuid],
7866 )
7867 .await
7868 .unwrap();
7869
7870 let neighbors = rt
7871 .neighbors(
7872 &tok,
7873 note.id,
7874 Direction::Out,
7875 None,
7876 Some(vec![EdgeRelation::Annotates]),
7877 )
7878 .await
7879 .unwrap();
7880 assert_eq!(neighbors.len(), 1);
7881 assert_eq!(neighbors[0].node_id, edge_uuid);
7882 }
7883
7884 #[tokio::test]
7885 async fn create_note_annotates_phantom_is_atomic_no_note_persisted() {
7886 let rt = rt();
7887 let tok = NamespaceToken::local();
7888 let phantom = Uuid::new_v4();
7889
7890 let before_count = rt.list_notes(&tok, None, 1000, 0).await.unwrap().len();
7891
7892 let result = rt
7893 .create_note(
7894 &tok,
7895 "observation",
7896 None,
7897 "should not persist",
7898 Some(0.5),
7899 None,
7900 vec![phantom],
7901 )
7902 .await;
7903 assert!(
7904 matches!(result, Err(RuntimeError::NotFound(_))),
7905 "phantom annotates target must return NotFound, got {result:?}"
7906 );
7907
7908 let after_count = rt.list_notes(&tok, None, 1000, 0).await.unwrap().len();
7910 assert_eq!(
7911 before_count, after_count,
7912 "failed create_note must not persist any note row (atomicity)"
7913 );
7914
7915 let search_hits = rt
7917 .search_notes(&tok, "should not persist", None, 10, None, false, &[], None)
7918 .await
7919 .unwrap();
7920 assert!(
7921 search_hits.is_empty(),
7922 "failed create_note must not index into FTS (atomicity)"
7923 );
7924 }
7927
7928 #[tokio::test]
7932 async fn link_entity_to_edge_uuid_non_annotates_returns_invalid_input() {
7933 let rt = rt();
7934 let tok = NamespaceToken::local();
7935 let a = rt
7936 .create_entity(&tok, "concept", None, "A", None, None, vec![])
7937 .await
7938 .unwrap();
7939 let b = rt
7940 .create_entity(&tok, "concept", None, "B", None, None, vec![])
7941 .await
7942 .unwrap();
7943 let edge = rt
7945 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
7946 .await
7947 .unwrap();
7948 let edge_uuid: Uuid = edge.id.into();
7949
7950 let result = rt
7951 .link(&tok, a.id, edge_uuid, EdgeRelation::Extends, 1.0, None)
7952 .await;
7953 match result {
7954 Err(RuntimeError::InvalidInput(msg)) => {
7955 assert!(
7956 msg.contains("target"),
7957 "error message must name 'target': {msg}"
7958 );
7959 }
7960 other => {
7961 panic!("expected InvalidInput for edge-uuid target with Extends, got {other:?}")
7962 }
7963 }
7964 }
7965
7966 #[tokio::test]
7968 async fn link_note_as_source_non_annotates_returns_invalid_input() {
7969 let rt = rt();
7970 let tok = NamespaceToken::local();
7971 let note = rt
7972 .create_note(&tok, "observation", None, "a note", Some(0.5), None, vec![])
7973 .await
7974 .unwrap();
7975 let entity = rt
7976 .create_entity(&tok, "concept", None, "E", None, None, vec![])
7977 .await
7978 .unwrap();
7979
7980 let result = rt
7981 .link(&tok, note.id, entity.id, EdgeRelation::DependsOn, 1.0, None)
7982 .await;
7983 match result {
7984 Err(RuntimeError::InvalidInput(msg)) => {
7985 assert!(
7986 msg.contains("source"),
7987 "error message must name 'source': {msg}"
7988 );
7989 }
7990 other => panic!("expected InvalidInput for note source with DependsOn, got {other:?}"),
7991 }
7992 }
7993
7994 #[tokio::test]
7996 async fn link_entity_as_annotates_source_returns_invalid_input() {
7997 let rt = rt();
7998 let tok = NamespaceToken::local();
7999 let a = rt
8000 .create_entity(&tok, "concept", None, "A", None, None, vec![])
8001 .await
8002 .unwrap();
8003 let b = rt
8004 .create_entity(&tok, "concept", None, "B", None, None, vec![])
8005 .await
8006 .unwrap();
8007
8008 let result = rt
8009 .link(&tok, a.id, b.id, EdgeRelation::Annotates, 1.0, None)
8010 .await;
8011 match result {
8012 Err(RuntimeError::InvalidInput(msg)) => {
8013 assert!(
8014 msg.contains("source") && msg.contains("note"),
8015 "error must say source must be a note: {msg}"
8016 );
8017 }
8018 other => {
8019 panic!("expected InvalidInput for entity source with Annotates, got {other:?}")
8020 }
8021 }
8022 }
8023
8024 #[tokio::test]
8025 async fn link_edge_as_annotates_source_returns_invalid_input() {
8026 let rt = rt();
8027 let tok = NamespaceToken::local();
8028 let a = rt
8029 .create_entity(&tok, "concept", None, "A", None, None, vec![])
8030 .await
8031 .unwrap();
8032 let b = rt
8033 .create_entity(&tok, "concept", None, "B", None, None, vec![])
8034 .await
8035 .unwrap();
8036 let edge = rt
8037 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
8038 .await
8039 .unwrap();
8040 let edge_uuid: Uuid = edge.id.into();
8041
8042 let result = rt
8044 .link(&tok, edge_uuid, a.id, EdgeRelation::Annotates, 1.0, None)
8045 .await;
8046 match result {
8047 Err(RuntimeError::InvalidInput(msg)) => {
8048 assert!(
8049 msg.contains("source") && msg.contains("note"),
8050 "edge-as-annotates-source must report wrong kind, not NotFound: {msg}"
8051 );
8052 }
8053 other => panic!("expected InvalidInput for edge source with Annotates, got {other:?}"),
8054 }
8055 }
8056
8057 #[tokio::test]
8059 async fn link_note_to_event_annotates_succeeds() {
8060 use khive_storage::Event;
8061 use khive_types::{EventKind, SubstrateKind};
8062
8063 let rt = rt();
8064 let tok = NamespaceToken::local();
8065 let note = rt
8066 .create_note(
8067 &tok,
8068 "observation",
8069 None,
8070 "observing an event",
8071 Some(0.6),
8072 None,
8073 vec![],
8074 )
8075 .await
8076 .unwrap();
8077
8078 let ns = tok.namespace().as_str();
8080 let event = Event::new(
8081 ns,
8082 "test_verb",
8083 EventKind::Audit,
8084 SubstrateKind::Entity,
8085 "test_actor",
8086 );
8087 let event_id = event.id;
8088 rt.events(&tok).unwrap().append_event(event).await.unwrap();
8089
8090 let result = rt
8091 .link(&tok, note.id, event_id, EdgeRelation::Annotates, 1.0, None)
8092 .await;
8093 assert!(
8094 result.is_ok(),
8095 "note→event Annotates must succeed, got {result:?}"
8096 );
8097 }
8098
8099 #[tokio::test]
8101 async fn create_note_annotates_event_succeeds() {
8102 use khive_storage::Event;
8103 use khive_types::{EventKind, SubstrateKind};
8104
8105 let rt = rt();
8106 let tok = NamespaceToken::local();
8107 let ns = tok.namespace().as_str();
8108 let event = Event::new(
8109 ns,
8110 "test_verb",
8111 EventKind::Audit,
8112 SubstrateKind::Entity,
8113 "test_actor",
8114 );
8115 let event_id = event.id;
8116 rt.events(&tok).unwrap().append_event(event).await.unwrap();
8117
8118 let result = rt
8119 .create_note(
8120 &tok,
8121 "observation",
8122 None,
8123 "note annotating an event",
8124 Some(0.5),
8125 None,
8126 vec![event_id],
8127 )
8128 .await;
8129 assert!(
8130 result.is_ok(),
8131 "create_note with event annotates target must succeed, got {result:?}"
8132 );
8133 let note = result.unwrap();
8135 let neighbors = rt
8136 .neighbors(
8137 &tok,
8138 note.id,
8139 Direction::Out,
8140 None,
8141 Some(vec![EdgeRelation::Annotates]),
8142 )
8143 .await
8144 .unwrap();
8145 assert_eq!(neighbors.len(), 1);
8146 assert_eq!(neighbors[0].node_id, event_id);
8147 }
8148
8149 #[tokio::test]
8153 async fn link_supersedes_note_to_note_succeeds() {
8154 let rt = rt();
8155 let tok = NamespaceToken::local();
8156 let old_note = rt
8157 .create_note(
8158 &tok,
8159 "observation",
8160 None,
8161 "old observation",
8162 Some(0.7),
8163 None,
8164 vec![],
8165 )
8166 .await
8167 .unwrap();
8168 let new_note = rt
8169 .create_note(
8170 &tok,
8171 "observation",
8172 None,
8173 "revised observation superseding the old one",
8174 Some(0.9),
8175 None,
8176 vec![],
8177 )
8178 .await
8179 .unwrap();
8180
8181 let result = rt
8182 .link(
8183 &tok,
8184 new_note.id,
8185 old_note.id,
8186 EdgeRelation::Supersedes,
8187 1.0,
8188 None,
8189 )
8190 .await;
8191 assert!(
8192 result.is_ok(),
8193 "note→note Supersedes must succeed (note supersession), got {result:?}"
8194 );
8195 }
8196
8197 #[tokio::test]
8198 async fn link_supersedes_entity_to_entity_succeeds() {
8199 let rt = rt();
8200 let tok = NamespaceToken::local();
8201 let old_entity = rt
8202 .create_entity(&tok, "concept", None, "OldConcept", None, None, vec![])
8203 .await
8204 .unwrap();
8205 let new_entity = rt
8206 .create_entity(&tok, "concept", None, "NewConcept", None, None, vec![])
8207 .await
8208 .unwrap();
8209
8210 let result = rt
8211 .link(
8212 &tok,
8213 new_entity.id,
8214 old_entity.id,
8215 EdgeRelation::Supersedes,
8216 1.0,
8217 None,
8218 )
8219 .await;
8220 assert!(
8221 result.is_ok(),
8222 "entity→entity Supersedes must succeed, got {result:?}"
8223 );
8224 }
8225
8226 #[tokio::test]
8227 async fn link_supersedes_note_to_entity_returns_invalid_input() {
8228 let rt = rt();
8229 let tok = NamespaceToken::local();
8230 let note = rt
8231 .create_note(&tok, "observation", None, "a note", Some(0.5), None, vec![])
8232 .await
8233 .unwrap();
8234 let entity = rt
8235 .create_entity(&tok, "concept", None, "SomeEntity", None, None, vec![])
8236 .await
8237 .unwrap();
8238
8239 let result = rt
8240 .link(
8241 &tok,
8242 note.id,
8243 entity.id,
8244 EdgeRelation::Supersedes,
8245 1.0,
8246 None,
8247 )
8248 .await;
8249 match result {
8250 Err(RuntimeError::InvalidInput(msg)) => {
8251 assert!(
8252 msg.contains("same substrate") || msg.contains("same-substrate"),
8253 "error must name the same-substrate rule: {msg}"
8254 );
8255 }
8256 other => panic!(
8257 "expected InvalidInput for note→entity Supersedes (cross-substrate), got {other:?}"
8258 ),
8259 }
8260 }
8261
8262 #[tokio::test]
8263 async fn link_supersedes_entity_to_note_returns_invalid_input() {
8264 let rt = rt();
8265 let tok = NamespaceToken::local();
8266 let entity = rt
8267 .create_entity(&tok, "concept", None, "SomeEntity", None, None, vec![])
8268 .await
8269 .unwrap();
8270 let note = rt
8271 .create_note(&tok, "observation", None, "a note", Some(0.5), None, vec![])
8272 .await
8273 .unwrap();
8274
8275 let result = rt
8276 .link(
8277 &tok,
8278 entity.id,
8279 note.id,
8280 EdgeRelation::Supersedes,
8281 1.0,
8282 None,
8283 )
8284 .await;
8285 match result {
8286 Err(RuntimeError::InvalidInput(msg)) => {
8287 assert!(
8288 msg.contains("same substrate") || msg.contains("same-substrate"),
8289 "error must name the same-substrate rule: {msg}"
8290 );
8291 }
8292 other => panic!(
8293 "expected InvalidInput for entity→note Supersedes (cross-substrate), got {other:?}"
8294 ),
8295 }
8296 }
8297
8298 #[tokio::test]
8299 async fn link_supersedes_event_source_returns_invalid_input() {
8300 use khive_storage::Event;
8301 use khive_types::{EventKind, SubstrateKind};
8302
8303 let rt = rt();
8304 let tok = NamespaceToken::local();
8305 let ns = tok.namespace().as_str();
8306 let event = Event::new(
8307 ns,
8308 "test_verb",
8309 EventKind::Audit,
8310 SubstrateKind::Entity,
8311 "test_actor",
8312 );
8313 let event_id = event.id;
8314 rt.events(&tok).unwrap().append_event(event).await.unwrap();
8315
8316 let entity = rt
8317 .create_entity(&tok, "concept", None, "SomeEntity", None, None, vec![])
8318 .await
8319 .unwrap();
8320
8321 let result = rt
8322 .link(
8323 &tok,
8324 event_id,
8325 entity.id,
8326 EdgeRelation::Supersedes,
8327 1.0,
8328 None,
8329 )
8330 .await;
8331 match result {
8332 Err(RuntimeError::InvalidInput(msg)) => {
8333 assert!(msg.contains("event"), "error must mention 'event': {msg}");
8334 }
8335 other => {
8336 panic!("expected InvalidInput for event source with Supersedes, got {other:?}")
8337 }
8338 }
8339 }
8340
8341 #[tokio::test]
8342 async fn link_supersedes_event_target_returns_invalid_input() {
8343 use khive_storage::Event;
8344 use khive_types::{EventKind, SubstrateKind};
8345
8346 let rt = rt();
8347 let tok = NamespaceToken::local();
8348 let ns = tok.namespace().as_str();
8349 let event = Event::new(
8350 ns,
8351 "test_verb",
8352 EventKind::Audit,
8353 SubstrateKind::Entity,
8354 "test_actor",
8355 );
8356 let event_id = event.id;
8357 rt.events(&tok).unwrap().append_event(event).await.unwrap();
8358
8359 let entity = rt
8360 .create_entity(&tok, "concept", None, "SomeEntity", None, None, vec![])
8361 .await
8362 .unwrap();
8363
8364 let result = rt
8365 .link(
8366 &tok,
8367 entity.id,
8368 event_id,
8369 EdgeRelation::Supersedes,
8370 1.0,
8371 None,
8372 )
8373 .await;
8374 match result {
8375 Err(RuntimeError::InvalidInput(msg)) => {
8376 assert!(msg.contains("event"), "error must mention 'event': {msg}");
8377 }
8378 other => {
8379 panic!("expected InvalidInput for event target with Supersedes, got {other:?}")
8380 }
8381 }
8382 }
8383
8384 #[tokio::test]
8385 async fn link_supersedes_edge_source_returns_invalid_input() {
8386 let rt = rt();
8387 let tok = NamespaceToken::local();
8388 let a = rt
8389 .create_entity(&tok, "concept", None, "A", None, None, vec![])
8390 .await
8391 .unwrap();
8392 let b = rt
8393 .create_entity(&tok, "concept", None, "B", None, None, vec![])
8394 .await
8395 .unwrap();
8396 let edge = rt
8397 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
8398 .await
8399 .unwrap();
8400 let edge_uuid: Uuid = edge.id.into();
8401
8402 let result = rt
8403 .link(&tok, edge_uuid, a.id, EdgeRelation::Supersedes, 1.0, None)
8404 .await;
8405 match result {
8406 Err(RuntimeError::InvalidInput(msg)) => {
8407 assert!(msg.contains("source"), "error must name 'source': {msg}");
8408 }
8409 other => {
8410 panic!("expected InvalidInput for edge-uuid source with Supersedes, got {other:?}")
8411 }
8412 }
8413 }
8414
8415 #[tokio::test]
8416 async fn link_supersedes_edge_target_returns_invalid_input() {
8417 let rt = rt();
8418 let tok = NamespaceToken::local();
8419 let a = rt
8420 .create_entity(&tok, "concept", None, "A", None, None, vec![])
8421 .await
8422 .unwrap();
8423 let b = rt
8424 .create_entity(&tok, "concept", None, "B", None, None, vec![])
8425 .await
8426 .unwrap();
8427 let edge = rt
8428 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
8429 .await
8430 .unwrap();
8431 let edge_uuid: Uuid = edge.id.into();
8432
8433 let result = rt
8434 .link(&tok, a.id, edge_uuid, EdgeRelation::Supersedes, 1.0, None)
8435 .await;
8436 match result {
8437 Err(RuntimeError::InvalidInput(msg)) => {
8438 assert!(msg.contains("target"), "error must name 'target': {msg}");
8439 }
8440 other => {
8441 panic!("expected InvalidInput for edge-uuid target with Supersedes, got {other:?}")
8442 }
8443 }
8444 }
8445
8446 #[tokio::test]
8447 async fn link_supersedes_phantom_source_returns_not_found() {
8448 let rt = rt();
8449 let tok = NamespaceToken::local();
8450 let note = rt
8451 .create_note(
8452 &tok,
8453 "observation",
8454 None,
8455 "existing note",
8456 Some(0.5),
8457 None,
8458 vec![],
8459 )
8460 .await
8461 .unwrap();
8462 let phantom = Uuid::new_v4();
8463
8464 let result = rt
8465 .link(&tok, phantom, note.id, EdgeRelation::Supersedes, 1.0, None)
8466 .await;
8467 match result {
8468 Err(RuntimeError::NotFound(msg)) => {
8469 assert!(msg.contains("source"), "error must name 'source': {msg}");
8470 }
8471 other => panic!("expected NotFound for phantom source with Supersedes, got {other:?}"),
8472 }
8473 }
8474
8475 #[tokio::test]
8476 async fn link_supersedes_phantom_target_returns_not_found() {
8477 let rt = rt();
8478 let tok = NamespaceToken::local();
8479 let note = rt
8480 .create_note(
8481 &tok,
8482 "observation",
8483 None,
8484 "existing note",
8485 Some(0.5),
8486 None,
8487 vec![],
8488 )
8489 .await
8490 .unwrap();
8491 let phantom = Uuid::new_v4();
8492
8493 let result = rt
8494 .link(&tok, note.id, phantom, EdgeRelation::Supersedes, 1.0, None)
8495 .await;
8496 match result {
8497 Err(RuntimeError::NotFound(msg)) => {
8498 assert!(msg.contains("target"), "error must name 'target': {msg}");
8499 }
8500 other => panic!("expected NotFound for phantom target with Supersedes, got {other:?}"),
8501 }
8502 }
8503
8504 #[tokio::test]
8507 async fn link_supersedes_cross_namespace_source_succeeds() {
8508 let rt = rt();
8509 let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
8510 let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
8511 let note_a = rt
8512 .create_note(
8513 &ns_a,
8514 "observation",
8515 None,
8516 "note in ns-a",
8517 Some(0.5),
8518 None,
8519 vec![],
8520 )
8521 .await
8522 .unwrap();
8523 let note_b = rt
8524 .create_note(
8525 &ns_b,
8526 "observation",
8527 None,
8528 "note in ns-b",
8529 Some(0.5),
8530 None,
8531 vec![],
8532 )
8533 .await
8534 .unwrap();
8535
8536 let result = rt
8539 .link(
8540 &ns_a,
8541 note_b.id,
8542 note_a.id,
8543 EdgeRelation::Supersedes,
8544 1.0,
8545 None,
8546 )
8547 .await;
8548 assert!(
8549 result.is_ok(),
8550 "cross-namespace supersedes source must resolve (#631), got {result:?}"
8551 );
8552 }
8553
8554 #[tokio::test]
8556 async fn link_extends_note_source_still_returns_invalid_input() {
8557 let rt = rt();
8558 let tok = NamespaceToken::local();
8559 let note = rt
8560 .create_note(
8561 &tok,
8562 "observation",
8563 None,
8564 "a note that cannot be an extends source",
8565 Some(0.5),
8566 None,
8567 vec![],
8568 )
8569 .await
8570 .unwrap();
8571 let entity = rt
8572 .create_entity(&tok, "concept", None, "E", None, None, vec![])
8573 .await
8574 .unwrap();
8575
8576 let result = rt
8577 .link(&tok, note.id, entity.id, EdgeRelation::Extends, 1.0, None)
8578 .await;
8579 assert!(
8580 matches!(result, Err(RuntimeError::InvalidInput(_))),
8581 "note source with Extends must still return InvalidInput after this fix, got {result:?}"
8582 );
8583 }
8584
8585 #[tokio::test]
8587 async fn link_annotates_note_to_edge_still_succeeds_after_fix() {
8588 let rt = rt();
8589 let tok = NamespaceToken::local();
8590 let a = rt
8591 .create_entity(&tok, "concept", None, "A", None, None, vec![])
8592 .await
8593 .unwrap();
8594 let b = rt
8595 .create_entity(&tok, "concept", None, "B", None, None, vec![])
8596 .await
8597 .unwrap();
8598 let edge = rt
8599 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
8600 .await
8601 .unwrap();
8602 let edge_uuid: Uuid = edge.id.into();
8603
8604 let note = rt
8605 .create_note(
8606 &tok,
8607 "observation",
8608 None,
8609 "annotating an edge",
8610 Some(0.5),
8611 None,
8612 vec![],
8613 )
8614 .await
8615 .unwrap();
8616
8617 let result = rt
8618 .link(&tok, note.id, edge_uuid, EdgeRelation::Annotates, 1.0, None)
8619 .await;
8620 assert!(
8621 result.is_ok(),
8622 "note→edge Annotates must still succeed after supersedes fix, got {result:?}"
8623 );
8624 }
8625
8626 #[tokio::test]
8640 async fn create_note_multi_annotates_compensation_cleanup_restores_pristine_state() {
8641 let rt = rt();
8642 let tok = NamespaceToken::local();
8643 let t1 = rt
8644 .create_entity(&tok, "concept", None, "T1", None, None, vec![])
8645 .await
8646 .unwrap();
8647
8648 let note = rt
8651 .create_note(
8652 &tok,
8653 "observation",
8654 None,
8655 "partial note",
8656 Some(0.5),
8657 None,
8658 vec![t1.id],
8659 )
8660 .await
8661 .unwrap();
8662
8663 let before_notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
8665 assert_eq!(before_notes.len(), 1, "note must be present before cleanup");
8666 let before_edges = rt
8667 .neighbors(
8668 &tok,
8669 note.id,
8670 Direction::Out,
8671 None,
8672 Some(vec![EdgeRelation::Annotates]),
8673 )
8674 .await
8675 .unwrap();
8676 assert_eq!(
8677 before_edges.len(),
8678 1,
8679 "one annotates edge must exist before cleanup"
8680 );
8681 let edge_id: Uuid = before_edges[0].edge_id;
8682
8683 rt.delete_edge(&tok, edge_id, true).await.unwrap();
8685 rt.delete_note(&tok, note.id, true )
8686 .await
8687 .unwrap();
8688
8689 let after_notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
8691 assert!(
8692 after_notes.is_empty(),
8693 "compensation must remove the note row; got {after_notes:?}"
8694 );
8695 let search_hits = rt
8696 .search_notes(&tok, "partial note", None, 10, None, false, &[], None)
8697 .await
8698 .unwrap();
8699 assert!(
8700 search_hits.is_empty(),
8701 "compensation must clean the FTS index; got {search_hits:?}"
8702 );
8703 let after_edges = rt
8704 .neighbors(&tok, note.id, Direction::Out, None, None)
8705 .await
8706 .unwrap();
8707 assert!(
8708 after_edges.is_empty(),
8709 "compensation must remove all partial edges; got {after_edges:?}"
8710 );
8711 }
8712
8713 #[tokio::test]
8721 async fn annotated_entity_hard_delete_cascades_annotate_edge() {
8722 let rt = rt();
8723 let tok = NamespaceToken::local();
8724 let entity = rt
8725 .create_entity(&tok, "concept", None, "E", None, None, vec![])
8726 .await
8727 .unwrap();
8728 let note = rt
8729 .create_note(
8730 &tok,
8731 "observation",
8732 None,
8733 "note about entity",
8734 Some(0.5),
8735 None,
8736 vec![entity.id],
8737 )
8738 .await
8739 .unwrap();
8740
8741 let before = rt
8743 .neighbors(
8744 &tok,
8745 note.id,
8746 Direction::Out,
8747 None,
8748 Some(vec![EdgeRelation::Annotates]),
8749 )
8750 .await
8751 .unwrap();
8752 assert_eq!(
8753 before.len(),
8754 1,
8755 "annotates edge must exist before entity delete"
8756 );
8757
8758 let deleted = rt.delete_entity(&tok, entity.id, true).await.unwrap();
8760 assert!(deleted, "entity hard delete must return true");
8761
8762 let after = rt
8764 .neighbors(
8765 &tok,
8766 note.id,
8767 Direction::Out,
8768 None,
8769 Some(vec![EdgeRelation::Annotates]),
8770 )
8771 .await
8772 .unwrap();
8773 assert!(
8774 after.is_empty(),
8775 "annotates edge must be cascaded on entity hard delete; got {after:?}"
8776 );
8777 }
8778
8779 #[tokio::test]
8780 async fn annotated_note_hard_delete_cascades_annotate_edge() {
8781 let rt = rt();
8782 let tok = NamespaceToken::local();
8783 let note_target = rt
8785 .create_note(
8786 &tok,
8787 "observation",
8788 None,
8789 "target note",
8790 Some(0.5),
8791 None,
8792 vec![],
8793 )
8794 .await
8795 .unwrap();
8796 let note_source = rt
8798 .create_note(
8799 &tok,
8800 "insight",
8801 None,
8802 "annotation",
8803 Some(0.5),
8804 None,
8805 vec![note_target.id],
8806 )
8807 .await
8808 .unwrap();
8809
8810 let before = rt
8811 .neighbors(
8812 &tok,
8813 note_source.id,
8814 Direction::Out,
8815 None,
8816 Some(vec![EdgeRelation::Annotates]),
8817 )
8818 .await
8819 .unwrap();
8820 assert_eq!(
8821 before.len(),
8822 1,
8823 "annotates edge must exist before note delete"
8824 );
8825
8826 let deleted = rt.delete_note(&tok, note_target.id, true).await.unwrap();
8828 assert!(deleted, "note hard delete must return true");
8829
8830 let after = rt
8832 .neighbors(
8833 &tok,
8834 note_source.id,
8835 Direction::Out,
8836 None,
8837 Some(vec![EdgeRelation::Annotates]),
8838 )
8839 .await
8840 .unwrap();
8841 assert!(
8842 after.is_empty(),
8843 "annotates edge must be cascaded on note-target hard delete; got {after:?}"
8844 );
8845 }
8846
8847 #[tokio::test]
8848 async fn annotated_edge_delete_cascades_annotate_edge() {
8849 let rt = rt();
8850 let tok = NamespaceToken::local();
8851 let a = rt
8852 .create_entity(&tok, "concept", None, "A", None, None, vec![])
8853 .await
8854 .unwrap();
8855 let b = rt
8856 .create_entity(&tok, "concept", None, "B", None, None, vec![])
8857 .await
8858 .unwrap();
8859 let base_edge = rt
8861 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
8862 .await
8863 .unwrap();
8864 let base_edge_uuid: Uuid = base_edge.id.into();
8865
8866 let note = rt
8868 .create_note(
8869 &tok,
8870 "observation",
8871 None,
8872 "note about edge",
8873 Some(0.5),
8874 None,
8875 vec![base_edge_uuid],
8876 )
8877 .await
8878 .unwrap();
8879
8880 let before = rt
8881 .neighbors(
8882 &tok,
8883 note.id,
8884 Direction::Out,
8885 None,
8886 Some(vec![EdgeRelation::Annotates]),
8887 )
8888 .await
8889 .unwrap();
8890 assert_eq!(
8891 before.len(),
8892 1,
8893 "annotates edge must exist before base edge delete"
8894 );
8895
8896 let deleted = rt.delete_edge(&tok, base_edge_uuid, true).await.unwrap();
8898 assert!(deleted, "edge delete must return true");
8899
8900 let after = rt
8902 .neighbors(
8903 &tok,
8904 note.id,
8905 Direction::Out,
8906 None,
8907 Some(vec![EdgeRelation::Annotates]),
8908 )
8909 .await
8910 .unwrap();
8911 assert!(
8912 after.is_empty(),
8913 "annotates edge must be cascaded on base edge delete; got {after:?}"
8914 );
8915 }
8916
8917 #[tokio::test]
8918 async fn mixed_multi_annotates_partial_target_hard_delete_leaves_remaining_edges() {
8919 let rt = rt();
8920 let tok = NamespaceToken::local();
8921 let t1 = rt
8922 .create_entity(&tok, "concept", None, "T1", None, None, vec![])
8923 .await
8924 .unwrap();
8925 let t2 = rt
8926 .create_entity(&tok, "concept", None, "T2", None, None, vec![])
8927 .await
8928 .unwrap();
8929
8930 let note = rt
8932 .create_note(
8933 &tok,
8934 "observation",
8935 None,
8936 "multi-target note",
8937 Some(0.5),
8938 None,
8939 vec![t1.id, t2.id],
8940 )
8941 .await
8942 .unwrap();
8943
8944 let before = rt
8945 .neighbors(
8946 &tok,
8947 note.id,
8948 Direction::Out,
8949 None,
8950 Some(vec![EdgeRelation::Annotates]),
8951 )
8952 .await
8953 .unwrap();
8954 assert_eq!(
8955 before.len(),
8956 2,
8957 "must have 2 annotates edges before any delete"
8958 );
8959
8960 rt.delete_entity(&tok, t1.id, true).await.unwrap();
8962
8963 let after = rt
8965 .neighbors(
8966 &tok,
8967 note.id,
8968 Direction::Out,
8969 None,
8970 Some(vec![EdgeRelation::Annotates]),
8971 )
8972 .await
8973 .unwrap();
8974 assert_eq!(
8975 after.len(),
8976 1,
8977 "only the edge to t1 must be cascaded; t2 edge must remain"
8978 );
8979 assert_eq!(
8980 after[0].node_id, t2.id,
8981 "remaining annotates edge must point to t2"
8982 );
8983 }
8984
8985 #[tokio::test]
8986 async fn annotated_note_soft_delete_preserves_annotate_edge() {
8987 let rt = rt();
8988 let tok = NamespaceToken::local();
8989 let note_target = rt
8990 .create_note(&tok, "observation", None, "target", Some(0.5), None, vec![])
8991 .await
8992 .unwrap();
8993 let note_source = rt
8994 .create_note(
8995 &tok,
8996 "insight",
8997 None,
8998 "annotation",
8999 Some(0.5),
9000 None,
9001 vec![note_target.id],
9002 )
9003 .await
9004 .unwrap();
9005
9006 let before = rt
9007 .neighbors(
9008 &tok,
9009 note_source.id,
9010 Direction::Out,
9011 None,
9012 Some(vec![EdgeRelation::Annotates]),
9013 )
9014 .await
9015 .unwrap();
9016 assert_eq!(before.len(), 1);
9017 let edge_id = before[0].edge_id;
9018
9019 let deleted = rt.delete_note(&tok, note_target.id, false).await.unwrap();
9021 assert!(deleted, "soft delete must return true");
9022
9023 let edge_after = rt.get_edge(&tok, edge_id).await.unwrap();
9031 assert!(
9032 edge_after.is_some(),
9033 "soft delete must NOT cascade edges; get_edge returned None"
9034 );
9035
9036 let after = rt
9037 .neighbors(
9038 &tok,
9039 note_source.id,
9040 Direction::Out,
9041 None,
9042 Some(vec![EdgeRelation::Annotates]),
9043 )
9044 .await
9045 .unwrap();
9046 assert_eq!(
9047 after.len(),
9048 0,
9049 "#748: neighbors() must screen out the soft-deleted note target; got {after:?}"
9050 );
9051 }
9052
9053 #[tokio::test]
9060 async fn delete_edge_non_edge_uuid_has_no_side_effects() {
9061 let rt = rt();
9062 let tok = NamespaceToken::local();
9063
9064 let entity = rt
9066 .create_entity(&tok, "concept", None, "Target", None, None, vec![])
9067 .await
9068 .unwrap();
9069 let note = rt
9070 .create_note(
9071 &tok,
9072 "observation",
9073 None,
9074 "annotates the entity",
9075 Some(0.5),
9076 None,
9077 vec![entity.id],
9078 )
9079 .await
9080 .unwrap();
9081
9082 let before = rt
9084 .neighbors(
9085 &tok,
9086 note.id,
9087 Direction::Out,
9088 None,
9089 Some(vec![EdgeRelation::Annotates]),
9090 )
9091 .await
9092 .unwrap();
9093 assert_eq!(before.len(), 1, "annotates edge must exist before test");
9094 let annotates_edge_id: Uuid = before[0].edge_id;
9095
9096 let result = rt.delete_edge(&tok, entity.id, true).await;
9098 assert!(
9099 result.is_ok(),
9100 "delete_edge must not error on a non-edge UUID"
9101 );
9102 assert!(
9103 !result.unwrap(),
9104 "delete_edge must return false for a non-edge UUID"
9105 );
9106
9107 let after = rt
9109 .neighbors(
9110 &tok,
9111 note.id,
9112 Direction::Out,
9113 None,
9114 Some(vec![EdgeRelation::Annotates]),
9115 )
9116 .await
9117 .unwrap();
9118 assert_eq!(
9119 after.len(),
9120 1,
9121 "delete_edge with a non-edge UUID must not touch inbound annotates edges"
9122 );
9123 assert_eq!(
9124 after[0].edge_id, annotates_edge_id,
9125 "the original annotates edge must be unchanged"
9126 );
9127 }
9128
9129 #[tokio::test]
9140 async fn create_note_multi_annotates_second_link_failure_rolls_back_partial_write() {
9141 let rt = rt();
9142 let tok = NamespaceToken::local();
9143 let t1 = rt
9144 .create_entity(&tok, "concept", None, "T1", None, None, vec![])
9145 .await
9146 .unwrap();
9147 let t2 = rt
9148 .create_entity(&tok, "concept", None, "T2", None, None, vec![])
9149 .await
9150 .unwrap();
9151
9152 LINK_FAIL_AFTER.with(|cell| cell.set(2));
9154
9155 let result = rt
9156 .create_note(
9157 &tok,
9158 "observation",
9159 None,
9160 "rollback target",
9161 Some(0.5),
9162 None,
9163 vec![t1.id, t2.id],
9164 )
9165 .await;
9166
9167 assert!(
9169 result.is_err(),
9170 "create_note must propagate the injected link failure"
9171 );
9172 let err_msg = result.unwrap_err().to_string();
9173 assert!(
9174 err_msg.contains("injected link failure"),
9175 "error must carry injection message; got: {err_msg}"
9176 );
9177
9178 let notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
9180 assert!(
9181 notes.is_empty(),
9182 "compensation must remove the note row; got {notes:?}"
9183 );
9184
9185 let hits = rt
9187 .search_notes(&tok, "rollback target", None, 10, None, false, &[], None)
9188 .await
9189 .unwrap();
9190 assert!(
9191 hits.is_empty(),
9192 "compensation must clean FTS index; got {hits:?}"
9193 );
9194
9195 let edges_from_t1 = rt
9197 .neighbors(
9198 &tok,
9199 t1.id,
9200 Direction::In,
9201 None,
9202 Some(vec![EdgeRelation::Annotates]),
9203 )
9204 .await
9205 .unwrap();
9206 let edges_from_t2 = rt
9207 .neighbors(
9208 &tok,
9209 t2.id,
9210 Direction::In,
9211 None,
9212 Some(vec![EdgeRelation::Annotates]),
9213 )
9214 .await
9215 .unwrap();
9216 assert!(
9217 edges_from_t1.is_empty(),
9218 "compensation must delete the first annotates edge; got {edges_from_t1:?}"
9219 );
9220 assert!(
9221 edges_from_t2.is_empty(),
9222 "no second annotates edge must exist; got {edges_from_t2:?}"
9223 );
9224 }
9225
9226 #[tokio::test]
9230 async fn create_note_fts_failure_rolls_back_note_row() {
9231 let rt = rt();
9232 let ns = Namespace::parse("fault-fts-rollback").unwrap();
9238 let tok = NamespaceToken::for_namespace(ns.clone());
9239
9240 arm_fts_fail(ns.as_str());
9241
9242 let result = rt
9243 .create_note(
9244 &tok,
9245 "observation",
9246 None,
9247 "fts-fail rollback target",
9248 None,
9249 None,
9250 vec![],
9251 )
9252 .await;
9253
9254 assert!(
9255 result.is_err(),
9256 "create_note must propagate the injected FTS failure"
9257 );
9258 let err_msg = result.unwrap_err().to_string();
9259 assert!(
9260 err_msg.contains("injected FTS failure"),
9261 "error must carry injection message; got: {err_msg}"
9262 );
9263
9264 let notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
9266 assert!(
9267 notes.is_empty(),
9268 "compensation must remove the note row after FTS failure; got {notes:?}"
9269 );
9270 }
9271
9272 #[tokio::test]
9280 async fn create_note_vector_failure_rolls_back_note_row_and_fts() {
9281 const MODEL: &str = "test-vec-inject";
9282 const DIMS: usize = 4;
9283
9284 let rt = KhiveRuntime::memory().unwrap();
9285 let (provider, _counter) = ConstVecProvider::new(MODEL, DIMS);
9286 rt.register_embedder(provider);
9287
9288 let ns = Namespace::parse("fault-vec-rollback").unwrap();
9289 let tok = NamespaceToken::for_namespace(ns.clone());
9290
9291 arm_vector_fail(ns.as_str());
9292
9293 let result = rt
9294 .create_note(
9295 &tok,
9296 "observation",
9297 None,
9298 "vec-fail rollback target",
9299 None,
9300 None,
9301 vec![],
9302 )
9303 .await;
9304
9305 assert!(
9306 result.is_err(),
9307 "create_note must propagate the injected vector failure"
9308 );
9309 let err_msg = result.unwrap_err().to_string();
9310 assert!(
9311 err_msg.contains("injected vector failure"),
9312 "error must carry injection message; got: {err_msg}"
9313 );
9314
9315 let notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
9317 assert!(
9318 notes.is_empty(),
9319 "compensation must remove note row after vector failure; got {notes:?}"
9320 );
9321 }
9322
9323 #[tokio::test]
9329 async fn create_note_with_embedding_content_fts_failure_rolls_back_note_row() {
9330 let rt = rt();
9331 let ns = Namespace::parse("fault-fts-rollback-embedding-content").unwrap();
9332 let tok = NamespaceToken::for_namespace(ns.clone());
9333
9334 arm_fts_fail(ns.as_str());
9335
9336 let full = "fts-fail rollback target with an embedding-content override";
9337 let head = &full[.."fts-fail rollback target".len()];
9338 let result = rt
9339 .create_note_with_embedding_content(
9340 &tok,
9341 "observation",
9342 None,
9343 full,
9344 Some(head),
9345 None,
9346 None,
9347 vec![],
9348 )
9349 .await;
9350
9351 assert!(
9352 result.is_err(),
9353 "create_note_with_embedding_content must propagate the injected FTS failure"
9354 );
9355 let err_msg = result.unwrap_err().to_string();
9356 assert!(
9357 err_msg.contains("injected FTS failure"),
9358 "error must carry injection message; got: {err_msg}"
9359 );
9360
9361 let notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
9365 assert!(
9366 notes.is_empty(),
9367 "compensation must remove the note row after FTS failure; got {notes:?}"
9368 );
9369 }
9370
9371 #[tokio::test]
9372 async fn create_note_with_embedding_content_vector_failure_rolls_back_note_row_and_fts() {
9373 const MODEL: &str = "test-vec-inject-embedding-content";
9374 const DIMS: usize = 4;
9375
9376 let rt = KhiveRuntime::memory().unwrap();
9377 let (provider, _counter) = ConstVecProvider::new(MODEL, DIMS);
9378 rt.register_embedder(provider);
9379
9380 let ns = Namespace::parse("fault-vec-rollback-embedding-content").unwrap();
9381 let tok = NamespaceToken::for_namespace(ns.clone());
9382
9383 arm_vector_fail(ns.as_str());
9384
9385 let full = "vec-fail rollback target with an embedding-content override";
9386 let head = &full[.."vec-fail rollback target".len()];
9387 let result = rt
9388 .create_note_with_embedding_content(
9389 &tok,
9390 "observation",
9391 None,
9392 full,
9393 Some(head),
9394 None,
9395 None,
9396 vec![],
9397 )
9398 .await;
9399
9400 assert!(
9401 result.is_err(),
9402 "create_note_with_embedding_content must propagate the injected vector failure"
9403 );
9404 let err_msg = result.unwrap_err().to_string();
9405 assert!(
9406 err_msg.contains("injected vector failure"),
9407 "error must carry injection message; got: {err_msg}"
9408 );
9409
9410 let notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
9415 assert!(
9416 notes.is_empty(),
9417 "compensation must remove note row after vector failure; got {notes:?}"
9418 );
9419 }
9420
9421 #[tokio::test]
9424 async fn soft_delete_entity_removes_indexes() {
9425 let rt = rt();
9426 let tok = NamespaceToken::local();
9427 let entity = rt
9428 .create_entity(
9429 &tok,
9430 "concept",
9431 None,
9432 "QuantumEntanglement",
9433 Some("unique FTS term xzqjwv for soft delete test"),
9434 None,
9435 vec![],
9436 )
9437 .await
9438 .unwrap();
9439
9440 let ns = tok.namespace().as_str().to_string();
9441
9442 let before = rt
9443 .text(&tok)
9444 .unwrap()
9445 .search(TextSearchRequest {
9446 query: "xzqjwv".to_string(),
9447 mode: TextQueryMode::Plain,
9448 filter: Some(TextFilter {
9449 namespaces: vec![ns.clone()],
9450 ..Default::default()
9451 }),
9452 top_k: 10,
9453 snippet_chars: 100,
9454 })
9455 .await
9456 .unwrap();
9457 assert!(
9458 before.iter().any(|h| h.subject_id == entity.id),
9459 "entity must be in FTS before soft-delete"
9460 );
9461
9462 let deleted = rt.delete_entity(&tok, entity.id, false).await.unwrap();
9463 assert!(deleted, "soft delete must return true");
9464
9465 let after = rt
9466 .text(&tok)
9467 .unwrap()
9468 .search(TextSearchRequest {
9469 query: "xzqjwv".to_string(),
9470 mode: TextQueryMode::Plain,
9471 filter: Some(TextFilter {
9472 namespaces: vec![ns],
9473 ..Default::default()
9474 }),
9475 top_k: 10,
9476 snippet_chars: 100,
9477 })
9478 .await
9479 .unwrap();
9480 assert!(
9481 after.iter().all(|h| h.subject_id != entity.id),
9482 "soft-deleted entity must be removed from FTS index"
9483 );
9484 }
9485
9486 #[tokio::test]
9487 async fn soft_delete_note_removes_indexes() {
9488 let rt = rt();
9489 let tok = NamespaceToken::local();
9490 let note = rt
9491 .create_note(
9492 &tok,
9493 "observation",
9494 None,
9495 "SpectralDecomposition unique term yvwkqz for soft delete test",
9496 Some(0.7),
9497 None,
9498 vec![],
9499 )
9500 .await
9501 .unwrap();
9502
9503 let before = rt
9504 .search_notes(&tok, "yvwkqz", None, 10, None, false, &[], None)
9505 .await
9506 .unwrap();
9507 assert!(
9508 before.iter().any(|h| h.note_id == note.id),
9509 "note must be in FTS before soft-delete"
9510 );
9511
9512 let deleted = rt.delete_note(&tok, note.id, false).await.unwrap();
9513 assert!(deleted, "soft delete must return true");
9514
9515 let after = rt
9516 .search_notes(&tok, "yvwkqz", None, 10, None, false, &[], None)
9517 .await
9518 .unwrap();
9519 assert!(
9520 after.iter().all(|h| h.note_id != note.id),
9521 "soft-deleted note must be removed from FTS index"
9522 );
9523 }
9524
9525 #[tokio::test]
9528 async fn link_extends_document_to_document_returns_invalid_input() {
9529 let rt = rt();
9530 let tok = NamespaceToken::local();
9531 let d1 = rt
9532 .create_entity(&tok, "document", None, "DocA", None, None, vec![])
9533 .await
9534 .unwrap();
9535 let d2 = rt
9536 .create_entity(&tok, "document", None, "DocB", None, None, vec![])
9537 .await
9538 .unwrap();
9539 let result = rt
9540 .link(&tok, d1.id, d2.id, EdgeRelation::Extends, 1.0, None)
9541 .await;
9542 assert!(
9543 result.is_err(),
9544 "F010: document->document Extends must be rejected by the base allowlist; \
9545 current generic entity fallthrough incorrectly accepts it"
9546 );
9547 }
9548
9549 #[tokio::test]
9551 async fn link_extends_concept_to_concept_succeeds() {
9552 let rt = rt();
9553 let tok = NamespaceToken::local();
9554 let a = rt
9555 .create_entity(&tok, "concept", None, "CA", None, None, vec![])
9556 .await
9557 .unwrap();
9558 let b = rt
9559 .create_entity(&tok, "concept", None, "CB", None, None, vec![])
9560 .await
9561 .unwrap();
9562 let result = rt
9563 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
9564 .await;
9565 assert!(
9566 result.is_ok(),
9567 "F010: concept->concept Extends must be allowed (base allowlist)"
9568 );
9569 }
9570
9571 #[tokio::test]
9573 async fn link_symmetric_relation_canonicalizes_endpoint_order() {
9574 use khive_storage::EdgeFilter;
9575 let rt = rt();
9576 let tok = NamespaceToken::local();
9577 let a = rt
9578 .create_entity(&tok, "concept", None, "ConceptP", None, None, vec![])
9579 .await
9580 .unwrap();
9581 let b = rt
9582 .create_entity(&tok, "concept", None, "ConceptQ", None, None, vec![])
9583 .await
9584 .unwrap();
9585 rt.link(&tok, a.id, b.id, EdgeRelation::CompetesWith, 1.0, None)
9587 .await
9588 .unwrap();
9589 rt.link(&tok, b.id, a.id, EdgeRelation::CompetesWith, 1.0, None)
9590 .await
9591 .unwrap();
9592 let count = rt
9593 .graph(&tok)
9594 .unwrap()
9595 .count_edges(EdgeFilter::default())
9596 .await
9597 .unwrap();
9598 assert_eq!(
9599 count,
9600 1,
9601 "F012: CompetesWith is symmetric; A->B and B->A must deduplicate to one canonical row; \
9602 found {count} rows (canonicalization not yet implemented)"
9603 );
9604 }
9605
9606 #[tokio::test]
9608 async fn f010_supersedes_document_to_document_allowed() {
9609 let rt = rt();
9610 let tok = NamespaceToken::local();
9611 let a = rt
9612 .create_entity(&tok, "document", None, "DocA", None, None, vec![])
9613 .await
9614 .unwrap();
9615 let b = rt
9616 .create_entity(&tok, "document", None, "DocB", None, None, vec![])
9617 .await
9618 .unwrap();
9619 let result = rt
9620 .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
9621 .await;
9622 assert!(
9623 result.is_ok(),
9624 "document->document Supersedes must be allowed (allowlist), got {result:?}"
9625 );
9626 }
9627
9628 #[tokio::test]
9629 async fn f010_supersedes_artifact_to_artifact_allowed() {
9630 let rt = rt();
9631 let tok = NamespaceToken::local();
9632 let a = rt
9633 .create_entity(&tok, "artifact", None, "ArtA", None, None, vec![])
9634 .await
9635 .unwrap();
9636 let b = rt
9637 .create_entity(&tok, "artifact", None, "ArtB", None, None, vec![])
9638 .await
9639 .unwrap();
9640 let result = rt
9641 .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
9642 .await;
9643 assert!(
9644 result.is_ok(),
9645 "artifact->artifact Supersedes must be allowed (allowlist), got {result:?}"
9646 );
9647 }
9648
9649 #[tokio::test]
9650 async fn f010_supersedes_service_to_service_allowed() {
9651 let rt = rt();
9652 let tok = NamespaceToken::local();
9653 let a = rt
9654 .create_entity(&tok, "service", None, "SvcA", None, None, vec![])
9655 .await
9656 .unwrap();
9657 let b = rt
9658 .create_entity(&tok, "service", None, "SvcB", None, None, vec![])
9659 .await
9660 .unwrap();
9661 let result = rt
9662 .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
9663 .await;
9664 assert!(
9665 result.is_ok(),
9666 "service->service Supersedes must be allowed (allowlist), got {result:?}"
9667 );
9668 }
9669
9670 #[tokio::test]
9671 async fn f010_supersedes_dataset_to_dataset_allowed() {
9672 let rt = rt();
9673 let tok = NamespaceToken::local();
9674 let a = rt
9675 .create_entity(&tok, "dataset", None, "DataA", None, None, vec![])
9676 .await
9677 .unwrap();
9678 let b = rt
9679 .create_entity(&tok, "dataset", None, "DataB", None, None, vec![])
9680 .await
9681 .unwrap();
9682 let result = rt
9683 .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
9684 .await;
9685 assert!(
9686 result.is_ok(),
9687 "dataset->dataset Supersedes must be allowed (allowlist), got {result:?}"
9688 );
9689 }
9690
9691 #[tokio::test]
9693 async fn f010_supersedes_project_to_project_rejected() {
9694 let rt = rt();
9695 let tok = NamespaceToken::local();
9696 let a = rt
9697 .create_entity(&tok, "project", None, "ProjA", None, None, vec![])
9698 .await
9699 .unwrap();
9700 let b = rt
9701 .create_entity(&tok, "project", None, "ProjB", None, None, vec![])
9702 .await
9703 .unwrap();
9704 let result = rt
9705 .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
9706 .await;
9707 assert!(
9708 matches!(result, Err(RuntimeError::InvalidInput(_))),
9709 "project->project Supersedes must be rejected (not in allowlist), got {result:?}"
9710 );
9711 }
9712
9713 #[tokio::test]
9714 async fn f010_supersedes_person_to_person_rejected() {
9715 let rt = rt();
9716 let tok = NamespaceToken::local();
9717 let a = rt
9718 .create_entity(&tok, "person", None, "Alice", None, None, vec![])
9719 .await
9720 .unwrap();
9721 let b = rt
9722 .create_entity(&tok, "person", None, "Bob", None, None, vec![])
9723 .await
9724 .unwrap();
9725 let result = rt
9726 .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
9727 .await;
9728 assert!(
9729 matches!(result, Err(RuntimeError::InvalidInput(_))),
9730 "person->person Supersedes must be rejected (not in allowlist), got {result:?}"
9731 );
9732 }
9733
9734 #[tokio::test]
9735 async fn f010_supersedes_org_to_org_rejected() {
9736 let rt = rt();
9737 let tok = NamespaceToken::local();
9738 let a = rt
9739 .create_entity(&tok, "org", None, "OrgA", None, None, vec![])
9740 .await
9741 .unwrap();
9742 let b = rt
9743 .create_entity(&tok, "org", None, "OrgB", None, None, vec![])
9744 .await
9745 .unwrap();
9746 let result = rt
9747 .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
9748 .await;
9749 assert!(
9750 matches!(result, Err(RuntimeError::InvalidInput(_))),
9751 "org->org Supersedes must be rejected (not in allowlist), got {result:?}"
9752 );
9753 }
9754
9755 #[tokio::test]
9757 async fn f010_supersedes_same_kind_entity_allowed() {
9758 let rt = rt();
9759 let tok = NamespaceToken::local();
9760 let a = rt
9761 .create_entity(&tok, "concept", None, "OldV", None, None, vec![])
9762 .await
9763 .unwrap();
9764 let b = rt
9765 .create_entity(&tok, "concept", None, "NewV", None, None, vec![])
9766 .await
9767 .unwrap();
9768 let result = rt
9769 .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
9770 .await;
9771 assert!(
9772 result.is_ok(),
9773 "concept->concept Supersedes must be allowed by the base allowlist, got {result:?}"
9774 );
9775 }
9776
9777 #[tokio::test]
9781 async fn f161_link_always_writes_null_target_backend() {
9782 let rt = rt();
9783 let tok = NamespaceToken::local();
9784 let a = rt
9785 .create_entity(&tok, "concept", None, "A", None, None, vec![])
9786 .await
9787 .unwrap();
9788 let b = rt
9789 .create_entity(&tok, "concept", None, "B", None, None, vec![])
9790 .await
9791 .unwrap();
9792 let edge = rt
9793 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
9794 .await
9795 .unwrap();
9796 assert!(
9797 edge.target_backend.is_none(),
9798 "F161: target_backend must be None for locally-routed edges; got {:?}",
9799 edge.target_backend
9800 );
9801 }
9802
9803 #[tokio::test]
9805 async fn f161_link_many_always_writes_null_target_backend() {
9806 let rt = rt();
9807 let tok = NamespaceToken::local();
9808 let a = rt
9809 .create_entity(&tok, "concept", None, "A", None, None, vec![])
9810 .await
9811 .unwrap();
9812 let b = rt
9813 .create_entity(&tok, "concept", None, "B", None, None, vec![])
9814 .await
9815 .unwrap();
9816 let c = rt
9817 .create_entity(&tok, "concept", None, "C", None, None, vec![])
9818 .await
9819 .unwrap();
9820 let specs = vec![
9821 LinkSpec {
9822 namespace: None,
9823 source_id: a.id,
9824 target_id: b.id,
9825 relation: EdgeRelation::Extends,
9826 weight: 1.0,
9827 metadata: None,
9828 },
9829 LinkSpec {
9830 namespace: None,
9831 source_id: a.id,
9832 target_id: c.id,
9833 relation: EdgeRelation::Enables,
9834 weight: 1.0,
9835 metadata: None,
9836 },
9837 ];
9838 let edges = rt.link_many(&tok, specs).await.unwrap();
9839 for edge in &edges {
9840 assert!(
9841 edge.target_backend.is_none(),
9842 "F161: target_backend must be None for locally-routed edges in link_many; got {:?}",
9843 edge.target_backend
9844 );
9845 }
9846 }
9847
9848 #[tokio::test]
9851 async fn f012_symmetric_neighbors_visible_from_both_endpoints() {
9852 let rt = rt();
9853 let tok = NamespaceToken::local();
9854 let a = rt
9855 .create_entity(&tok, "concept", None, "A", None, None, vec![])
9856 .await
9857 .unwrap();
9858 let b = rt
9859 .create_entity(&tok, "concept", None, "B", None, None, vec![])
9860 .await
9861 .unwrap();
9862 rt.link(&tok, a.id, b.id, EdgeRelation::CompetesWith, 1.0, None)
9864 .await
9865 .unwrap();
9866 let from_a = rt
9868 .neighbors(
9869 &tok,
9870 a.id,
9871 Direction::Out,
9872 None,
9873 Some(vec![EdgeRelation::CompetesWith]),
9874 )
9875 .await
9876 .unwrap();
9877 let from_b = rt
9878 .neighbors(
9879 &tok,
9880 b.id,
9881 Direction::Out,
9882 None,
9883 Some(vec![EdgeRelation::CompetesWith]),
9884 )
9885 .await
9886 .unwrap();
9887 assert_eq!(
9888 from_a.len(),
9889 1,
9890 "node A must see competes_with neighbor from Direction::Out (F012); got {from_a:?}"
9891 );
9892 assert_eq!(
9893 from_b.len(),
9894 1,
9895 "node B must see competes_with neighbor from Direction::Out (F012); got {from_b:?}"
9896 );
9897 }
9898
9899 #[tokio::test]
9901 async fn f010_supersedes_cross_kind_entity_rejected() {
9902 let rt = rt();
9903 let tok = NamespaceToken::local();
9904 let concept = rt
9905 .create_entity(&tok, "concept", None, "MyConcept", None, None, vec![])
9906 .await
9907 .unwrap();
9908 let doc = rt
9909 .create_entity(&tok, "document", None, "MyDoc", None, None, vec![])
9910 .await
9911 .unwrap();
9912 let result = rt
9913 .link(
9914 &tok,
9915 concept.id,
9916 doc.id,
9917 EdgeRelation::Supersedes,
9918 1.0,
9919 None,
9920 )
9921 .await;
9922 assert!(
9923 matches!(result, Err(RuntimeError::InvalidInput(_))),
9924 "concept->document Supersedes must be rejected by the base allowlist, got {result:?}"
9925 );
9926 }
9927
9928 #[tokio::test]
9931 async fn delete_note_cross_namespace_succeeds() {
9932 let rt = rt();
9933 let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
9934 let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
9935 let note = rt
9936 .create_note(
9937 &ns_a,
9938 "observation",
9939 None,
9940 "note in ns-a",
9941 Some(0.8),
9942 None,
9943 vec![],
9944 )
9945 .await
9946 .unwrap();
9947
9948 let result = rt.delete_note(&ns_b, note.id, false).await;
9950 assert!(
9951 result.unwrap(),
9952 "cross-namespace delete_note (soft) must return Ok(true)"
9953 );
9954
9955 let note_store = rt.notes(&ns_a).unwrap();
9957 let gone = note_store.get_note(note.id).await.unwrap();
9958 assert!(
9959 gone.is_none(),
9960 "note must be soft-deleted in its home namespace after cross-ns delete"
9961 );
9962
9963 let note2 = rt
9965 .create_note(
9966 &ns_a,
9967 "observation",
9968 None,
9969 "note2 in ns-a",
9970 Some(0.5),
9971 None,
9972 vec![],
9973 )
9974 .await
9975 .unwrap();
9976 let hard_result = rt.delete_note(&ns_b, note2.id, true).await;
9977 assert!(
9978 hard_result.unwrap(),
9979 "cross-namespace hard delete_note must return Ok(true)"
9980 );
9981 let gone2 = rt
9982 .get_note_including_deleted(&ns_a, note2.id)
9983 .await
9984 .unwrap();
9985 assert!(
9986 gone2.is_none(),
9987 "hard-deleted note must not appear even in including_deleted query"
9988 );
9989 }
9990
9991 #[tokio::test]
9999 async fn link_many_overlapping_triple_returns_persisted_ids() {
10000 let rt = rt();
10001 let tok = NamespaceToken::local();
10002 let a = rt
10003 .create_entity(&tok, "concept", None, "A", None, None, vec![])
10004 .await
10005 .unwrap();
10006 let b = rt
10007 .create_entity(&tok, "concept", None, "B", None, None, vec![])
10008 .await
10009 .unwrap();
10010
10011 let spec = || LinkSpec {
10012 namespace: None,
10013 source_id: a.id,
10014 target_id: b.id,
10015 relation: EdgeRelation::Extends,
10016 weight: 1.0,
10017 metadata: None,
10018 };
10019
10020 let first = rt.link_many(&tok, vec![spec()]).await.unwrap();
10022 assert_eq!(first.len(), 1);
10023 let persisted_id: Uuid = first[0].id.into();
10024
10025 let second = rt.link_many(&tok, vec![spec()]).await.unwrap();
10028 assert_eq!(second.len(), 1);
10029 let second_id: Uuid = second[0].id.into();
10030
10031 assert_eq!(
10032 persisted_id, second_id,
10033 "link_many with an existing triple must return the persisted row ID ({persisted_id}), \
10034 not a new phantom ID ({second_id})"
10035 );
10036
10037 let count = rt
10039 .count_edges(&tok, crate::curation::EdgeListFilter::default())
10040 .await
10041 .unwrap();
10042 assert_eq!(count, 1, "upsert must not duplicate the edge row");
10043 }
10044
10045 #[tokio::test]
10048 async fn create_many_persists_all_entities() {
10049 let rt = rt();
10050 let tok = NamespaceToken::local();
10051
10052 let specs: Vec<EntityCreateSpec> = (0..5)
10053 .map(|i| EntityCreateSpec {
10054 kind: "concept".into(),
10055 entity_type: None,
10056 name: format!("BulkConcept-{i}"),
10057 description: Some(format!("desc {i}")),
10058 properties: None,
10059 tags: vec!["bulk-test".into()],
10060 })
10061 .collect();
10062
10063 let entities = rt.create_many(&tok, specs).await.unwrap();
10064 assert_eq!(entities.len(), 5, "all 5 entities must be returned");
10065
10066 for entity in &entities {
10068 let fetched = rt.get_entity(&tok, entity.id).await.unwrap();
10069 assert_eq!(fetched.id, entity.id);
10070 }
10071 }
10072
10073 #[tokio::test]
10074 async fn create_many_empty_name_rejects_atomically() {
10075 let rt = rt();
10076 let tok = NamespaceToken::local();
10077
10078 let specs = vec![
10079 EntityCreateSpec {
10080 kind: "concept".into(),
10081 entity_type: None,
10082 name: "ValidEntity".into(),
10083 description: None,
10084 properties: None,
10085 tags: vec![],
10086 },
10087 EntityCreateSpec {
10088 kind: "concept".into(),
10089 entity_type: None,
10090 name: "".into(), description: None,
10092 properties: None,
10093 tags: vec![],
10094 },
10095 ];
10096
10097 let result = rt.create_many(&tok, specs).await;
10098 assert!(
10099 matches!(result, Err(RuntimeError::InvalidInput(_))),
10100 "empty name must produce InvalidInput error"
10101 );
10102
10103 let rows = rt.list_entities(&tok, None, None, 100, 0).await.unwrap();
10105 assert_eq!(
10106 rows.len(),
10107 0,
10108 "atomic rejection must leave storage unchanged"
10109 );
10110 }
10111
10112 #[tokio::test]
10114 async fn create_many_rejects_unknown_entity_type_when_validator_installed() {
10115 let rt = rt();
10116 let tok = NamespaceToken::local();
10117
10118 rt.install_entity_type_validator(Arc::new(|kind, entity_type| {
10120 let Some(raw) = entity_type else {
10121 return Ok(None);
10122 };
10123 if kind == "concept" && raw == "algorithm" {
10124 return Ok(Some("algorithm".to_string()));
10125 }
10126 Err(RuntimeError::InvalidInput(format!(
10127 "unknown entity_type {raw:?} for {kind:?}; valid: algorithm"
10128 )))
10129 }));
10130
10131 let bad_spec = vec![EntityCreateSpec {
10132 kind: "concept".into(),
10133 entity_type: Some("not_a_registered_type".into()),
10134 name: "ShouldNotLand".into(),
10135 description: None,
10136 properties: None,
10137 tags: vec![],
10138 }];
10139
10140 let result = rt.create_many(&tok, bad_spec).await;
10141 assert!(
10142 matches!(result, Err(RuntimeError::InvalidInput(_))),
10143 "unknown entity_type must be rejected by the runtime-layer validator; got {result:?}"
10144 );
10145
10146 let rows = rt.list_entities(&tok, None, None, 100, 0).await.unwrap();
10148 assert_eq!(
10149 rows.len(),
10150 0,
10151 "validator rejection must leave storage empty"
10152 );
10153 }
10154
10155 #[tokio::test]
10157 async fn create_many_accepts_valid_entity_type_via_validator() {
10158 let rt = rt();
10159 let tok = NamespaceToken::local();
10160
10161 rt.install_entity_type_validator(Arc::new(|kind, entity_type| {
10162 let Some(raw) = entity_type else {
10163 return Ok(None);
10164 };
10165 if kind == "concept" && raw == "algorithm" {
10166 return Ok(Some("algorithm".to_string()));
10167 }
10168 Err(RuntimeError::InvalidInput(format!(
10169 "unknown entity_type {raw:?} for {kind:?}"
10170 )))
10171 }));
10172
10173 let specs = vec![EntityCreateSpec {
10174 kind: "concept".into(),
10175 entity_type: Some("algorithm".into()),
10176 name: "BubbleSort".into(),
10177 description: None,
10178 properties: None,
10179 tags: vec![],
10180 }];
10181
10182 let entities = rt.create_many(&tok, specs).await.unwrap();
10183 assert_eq!(entities.len(), 1, "valid entity_type must be accepted");
10184 assert_eq!(
10185 entities[0].entity_type.as_deref(),
10186 Some("algorithm"),
10187 "entity_type must be stored as returned by the validator"
10188 );
10189 }
10190
10191 #[tokio::test]
10196 async fn create_many_fts_failure_rolls_back_both_substrates() {
10197 let ns = format!("fts-fail-many-{}", uuid::Uuid::new_v4().as_simple());
10200 let rt = rt();
10201 let tok = NamespaceToken::for_namespace(Namespace::parse(&ns).unwrap());
10202
10203 let specs = vec![
10204 EntityCreateSpec {
10205 kind: "concept".into(),
10206 entity_type: None,
10207 name: "FtsRollbackA".into(),
10208 description: None,
10209 properties: None,
10210 tags: vec![],
10211 },
10212 EntityCreateSpec {
10213 kind: "concept".into(),
10214 entity_type: None,
10215 name: "FtsRollbackB".into(),
10216 description: None,
10217 properties: None,
10218 tags: vec![],
10219 },
10220 ];
10221
10222 arm_fts_fail_many(&ns);
10223 let result = rt.create_many(&tok, specs).await;
10224
10225 assert!(
10226 result.is_err(),
10227 "create_many must return Err when FTS write fails"
10228 );
10229
10230 let entity_rows = rt.list_entities(&tok, None, None, 100, 0).await.unwrap();
10232 assert_eq!(
10233 entity_rows.len(),
10234 0,
10235 "entity rows must be rolled back on FTS failure; found {entity_rows:?}"
10236 );
10237
10238 let fts = rt.text(&tok).unwrap();
10240 let fts_count = fts
10241 .count(TextFilter {
10242 ids: vec![],
10243 kinds: vec![],
10244 namespaces: vec![ns.clone()],
10245 })
10246 .await
10247 .unwrap();
10248 assert_eq!(
10249 fts_count, 0,
10250 "fts_entities must be empty after FTS-failure rollback; found {fts_count}"
10251 );
10252 }
10253
10254 #[tokio::test]
10263 async fn create_many_fts_partial_failure_rolls_back_both_substrates() {
10264 let ns = format!("fts-fail-partial-{}", uuid::Uuid::new_v4().as_simple());
10265 let rt = rt();
10266 let tok = NamespaceToken::for_namespace(Namespace::parse(&ns).unwrap());
10267
10268 let specs = vec![
10269 EntityCreateSpec {
10270 kind: "concept".into(),
10271 entity_type: None,
10272 name: "PartialRollbackA".into(),
10273 description: None,
10274 properties: None,
10275 tags: vec![],
10276 },
10277 EntityCreateSpec {
10278 kind: "concept".into(),
10279 entity_type: None,
10280 name: "PartialRollbackB".into(),
10281 description: None,
10282 properties: None,
10283 tags: vec![],
10284 },
10285 ];
10286
10287 arm_fts_fail_many_partial(&ns);
10288 let result = rt.create_many(&tok, specs).await;
10289
10290 assert!(
10291 result.is_err(),
10292 "create_many must return Err when FTS summary.failed > 0"
10293 );
10294
10295 let entity_rows = rt.list_entities(&tok, None, None, 100, 0).await.unwrap();
10297 assert_eq!(
10298 entity_rows.len(),
10299 0,
10300 "entity rows must be rolled back when FTS summary.failed > 0; found {entity_rows:?}"
10301 );
10302
10303 let fts = rt.text(&tok).unwrap();
10305 let fts_count = fts
10306 .count(TextFilter {
10307 ids: vec![],
10308 kinds: vec![],
10309 namespaces: vec![ns.clone()],
10310 })
10311 .await
10312 .unwrap();
10313 assert_eq!(
10314 fts_count, 0,
10315 "fts_entities must be empty after partial-FTS-failure rollback; found {fts_count}"
10316 );
10317 }
10318
10319 #[tokio::test]
10322 async fn get_edge_cross_namespace_succeeds() {
10323 let rt = rt();
10324 let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
10325 let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
10326
10327 let src = rt
10328 .create_entity(&ns_a, "concept", None, "Src", None, None, vec![])
10329 .await
10330 .unwrap();
10331 let tgt = rt
10332 .create_entity(&ns_a, "concept", None, "Tgt", None, None, vec![])
10333 .await
10334 .unwrap();
10335 let edge = rt
10336 .link(&ns_a, src.id, tgt.id, EdgeRelation::Extends, 1.0, None)
10337 .await
10338 .unwrap();
10339
10340 let own_ns = rt.get_edge(&ns_a, Uuid::from(edge.id)).await;
10342 assert!(
10343 own_ns.is_ok() && own_ns.unwrap().is_some(),
10344 "edge must be visible in its own namespace"
10345 );
10346
10347 let cross_ns = rt.get_edge(&ns_b, Uuid::from(edge.id)).await;
10349 assert!(
10350 matches!(cross_ns, Ok(Some(_))),
10351 "cross-namespace get_edge must return Ok(Some(_)) after PR-A1, got {cross_ns:?}"
10352 );
10353
10354 let absent = rt.get_edge(&ns_b, Uuid::new_v4()).await;
10356 assert!(
10357 matches!(absent, Ok(None)),
10358 "absent edge must return Ok(None), got {absent:?}"
10359 );
10360 }
10361
10362 #[tokio::test]
10369 async fn traverse_cross_namespace_root_is_accepted() {
10370 use khive_storage::types::TraversalOptions;
10371
10372 let rt = rt();
10373 let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
10374 let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
10375
10376 let a = rt
10377 .create_entity(&ns_a, "concept", None, "A", None, None, vec![])
10378 .await
10379 .unwrap();
10380 rt.create_entity(&ns_a, "concept", None, "B", None, None, vec![])
10381 .await
10382 .unwrap();
10383 rt.link(&ns_a, a.id, a.id, EdgeRelation::Extends, 1.0, None)
10384 .await
10385 .ok(); let result = rt
10390 .traverse(
10391 &ns_b,
10392 TraversalRequest {
10393 roots: vec![a.id],
10394 options: TraversalOptions {
10395 max_depth: 1,
10396 direction: Direction::Out,
10397 ..Default::default()
10398 },
10399 include_roots: true,
10400 include_properties: false,
10401 },
10402 )
10403 .await;
10404 assert!(result.is_ok(), "traverse must not error; got {:?}", result);
10405 }
10406
10407 async fn count_all_incident_edges(rt: &KhiveRuntime, node_id: Uuid, ns: &str) -> u64 {
10416 let mut reader = rt.sql().reader().await.expect("sql reader must open");
10417 let row = reader
10418 .query_scalar(SqlStatement {
10419 sql: "SELECT COUNT(*) FROM graph_edges \
10420 WHERE namespace = ?1 AND (source_id = ?2 OR target_id = ?2)"
10421 .into(),
10422 params: vec![
10423 SqlValue::Text(ns.to_string()),
10424 SqlValue::Text(node_id.to_string()),
10425 ],
10426 label: Some("count_all_incident_edges".into()),
10427 })
10428 .await
10429 .expect("count query must succeed");
10430 match row {
10431 Some(SqlValue::Integer(n)) => n as u64,
10432 _ => panic!("count must return an integer"),
10433 }
10434 }
10435
10436 #[tokio::test]
10437 async fn hard_delete_entity_purges_already_soft_deleted_incident_edge() {
10438 let rt = rt();
10439 let tok = NamespaceToken::local();
10440 let ns = tok.namespace().to_string();
10441
10442 let a = rt
10443 .create_entity(&tok, "concept", None, "SrcA", None, None, vec![])
10444 .await
10445 .unwrap();
10446 let b = rt
10447 .create_entity(&tok, "concept", None, "TgtB", None, None, vec![])
10448 .await
10449 .unwrap();
10450
10451 rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
10452 .await
10453 .unwrap();
10454
10455 let edge_hit = rt
10457 .neighbors(&tok, a.id, Direction::Out, None, None)
10458 .await
10459 .unwrap();
10460 assert_eq!(edge_hit.len(), 1, "edge must exist before soft-delete");
10461 let edge_uuid = edge_hit[0].edge_id;
10462 rt.delete_edge(&tok, edge_uuid, false).await.unwrap();
10463
10464 let visible = rt
10466 .neighbors(&tok, a.id, Direction::Out, None, None)
10467 .await
10468 .unwrap();
10469 assert!(visible.is_empty(), "soft-deleted edge must be invisible");
10470 let raw_before = count_all_incident_edges(&rt, a.id, &ns).await;
10471 assert_eq!(
10472 raw_before, 1,
10473 "soft-deleted edge must still be a physical row"
10474 );
10475
10476 rt.delete_entity(&tok, a.id, true).await.unwrap();
10478
10479 let raw_after = count_all_incident_edges(&rt, a.id, &ns).await;
10480 assert_eq!(
10481 raw_after, 0,
10482 "purge_incident_edges must physically remove soft-deleted edge rows (ADR-002)"
10483 );
10484 }
10485
10486 #[tokio::test]
10487 async fn hard_delete_note_purges_already_soft_deleted_incident_edge() {
10488 let rt = rt();
10489 let tok = NamespaceToken::local();
10490 let ns = tok.namespace().to_string();
10491
10492 let target = rt
10493 .create_note(
10494 &tok,
10495 "observation",
10496 None,
10497 "purge-cascade target note",
10498 Some(0.5),
10499 None,
10500 vec![],
10501 )
10502 .await
10503 .unwrap();
10504 let annotating = rt
10505 .create_note(
10506 &tok,
10507 "insight",
10508 None,
10509 "annotator note",
10510 Some(0.5),
10511 None,
10512 vec![target.id],
10513 )
10514 .await
10515 .unwrap();
10516
10517 let edge_hit = rt
10519 .neighbors(
10520 &tok,
10521 annotating.id,
10522 Direction::Out,
10523 None,
10524 Some(vec![EdgeRelation::Annotates]),
10525 )
10526 .await
10527 .unwrap();
10528 assert_eq!(edge_hit.len(), 1, "annotates edge must exist");
10529 let edge_uuid = edge_hit[0].edge_id;
10530 rt.delete_edge(&tok, edge_uuid, false).await.unwrap();
10531
10532 let raw_before = count_all_incident_edges(&rt, target.id, &ns).await;
10533 assert_eq!(
10534 raw_before, 1,
10535 "soft-deleted edge must still be a physical row before note purge"
10536 );
10537
10538 rt.delete_note(&tok, target.id, true).await.unwrap();
10540
10541 let raw_after = count_all_incident_edges(&rt, target.id, &ns).await;
10542 assert_eq!(
10543 raw_after, 0,
10544 "purge_incident_edges must physically remove soft-deleted edge rows on note purge (ADR-002)"
10545 );
10546 }
10547
10548 async fn count_all_incident_edges_global(rt: &KhiveRuntime, node_id: Uuid) -> u64 {
10556 let mut reader = rt.sql().reader().await.expect("sql reader must open");
10557 let row = reader
10558 .query_scalar(SqlStatement {
10559 sql: "SELECT COUNT(*) FROM graph_edges WHERE source_id = ?1 OR target_id = ?1"
10560 .into(),
10561 params: vec![SqlValue::Text(node_id.to_string())],
10562 label: Some("count_all_incident_edges_global".into()),
10563 })
10564 .await
10565 .expect("count query must succeed");
10566 match row {
10567 Some(SqlValue::Integer(n)) => n as u64,
10568 _ => panic!("count must return an integer"),
10569 }
10570 }
10571
10572 #[tokio::test]
10573 async fn cross_namespace_hard_delete_entity_purges_all_incident_edges() {
10574 let rt = rt();
10578 let ns_owner = NamespaceToken::for_namespace(Namespace::parse("ns-owner").unwrap());
10579 let ns_caller = NamespaceToken::for_namespace(Namespace::parse("ns-caller").unwrap());
10580
10581 let entity = rt
10582 .create_entity(
10583 &ns_owner,
10584 "concept",
10585 None,
10586 "ForeignEntity",
10587 None,
10588 None,
10589 vec![],
10590 )
10591 .await
10592 .unwrap();
10593 let peer = rt
10594 .create_entity(&ns_owner, "concept", None, "Peer", None, None, vec![])
10595 .await
10596 .unwrap();
10597 rt.link(
10599 &ns_owner,
10600 entity.id,
10601 peer.id,
10602 EdgeRelation::Extends,
10603 1.0,
10604 None,
10605 )
10606 .await
10607 .unwrap();
10608 rt.link(
10609 &ns_owner,
10610 peer.id,
10611 entity.id,
10612 EdgeRelation::Extends,
10613 1.0,
10614 None,
10615 )
10616 .await
10617 .unwrap();
10618
10619 let before = count_all_incident_edges_global(&rt, entity.id).await;
10620 assert_eq!(before, 2, "two incident edges must exist before delete");
10621
10622 let deleted = rt.delete_entity(&ns_caller, entity.id, true).await.unwrap();
10624 assert!(deleted, "cross-ns hard delete must return true");
10625
10626 let after = count_all_incident_edges_global(&rt, entity.id).await;
10628 assert_eq!(
10629 after, 0,
10630 "purge_incident_edges must remove all incident edges across namespaces (ADR-002, ADR-007)"
10631 );
10632 }
10633
10634 async fn count_edge_rows_by_id(rt: &KhiveRuntime, edge_id: Uuid, ns: &str) -> u64 {
10644 let mut reader = rt.sql().reader().await.expect("sql reader must open");
10645 let row = reader
10646 .query_scalar(SqlStatement {
10647 sql: "SELECT COUNT(*) FROM graph_edges WHERE namespace = ?1 AND id = ?2".into(),
10648 params: vec![
10649 SqlValue::Text(ns.to_string()),
10650 SqlValue::Text(edge_id.to_string()),
10651 ],
10652 label: Some("count_edge_rows_by_id".into()),
10653 })
10654 .await
10655 .expect("count query must succeed");
10656 match row {
10657 Some(SqlValue::Integer(n)) => n as u64,
10658 _ => panic!("count must return an integer"),
10659 }
10660 }
10661
10662 #[tokio::test]
10663 async fn hard_delete_edge_purges_already_soft_deleted_primary_edge() {
10664 let rt = rt();
10665 let tok = NamespaceToken::local();
10666 let ns = tok.namespace().to_string();
10667
10668 let a = rt
10669 .create_entity(&tok, "concept", None, "EA", None, None, vec![])
10670 .await
10671 .unwrap();
10672 let b = rt
10673 .create_entity(&tok, "concept", None, "EB", None, None, vec![])
10674 .await
10675 .unwrap();
10676
10677 let edge = rt
10678 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
10679 .await
10680 .unwrap();
10681 let edge_uuid: Uuid = edge.id.into();
10682
10683 let soft = rt.delete_edge(&tok, edge_uuid, false).await.unwrap();
10685 assert!(soft, "soft delete must succeed");
10686
10687 assert!(
10689 rt.get_edge(&tok, edge_uuid).await.unwrap().is_none(),
10690 "soft-deleted edge must be invisible to get_edge"
10691 );
10692 assert_eq!(
10693 count_edge_rows_by_id(&rt, edge_uuid, &ns).await,
10694 1,
10695 "soft-deleted edge must still be a physical row"
10696 );
10697
10698 let purged = rt.delete_edge(&tok, edge_uuid, true).await.unwrap();
10700 assert!(
10701 purged,
10702 "hard delete of a soft-deleted edge must return true"
10703 );
10704
10705 assert_eq!(
10706 count_edge_rows_by_id(&rt, edge_uuid, &ns).await,
10707 0,
10708 "hard-delete must physically remove the soft-deleted edge row (ADR-002)"
10709 );
10710 }
10711
10712 #[tokio::test]
10713 async fn hard_delete_base_edge_purges_already_soft_deleted_annotates_edge() {
10714 let rt = rt();
10715 let tok = NamespaceToken::local();
10716 let ns = tok.namespace().to_string();
10717
10718 let a = rt
10719 .create_entity(&tok, "concept", None, "CA", None, None, vec![])
10720 .await
10721 .unwrap();
10722 let b = rt
10723 .create_entity(&tok, "concept", None, "CB", None, None, vec![])
10724 .await
10725 .unwrap();
10726
10727 let base_edge = rt
10729 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
10730 .await
10731 .unwrap();
10732 let base_edge_uuid: Uuid = base_edge.id.into();
10733
10734 let note = rt
10736 .create_note(
10737 &tok,
10738 "observation",
10739 None,
10740 "note about base edge",
10741 Some(0.5),
10742 None,
10743 vec![base_edge_uuid],
10744 )
10745 .await
10746 .unwrap();
10747
10748 let ann_hits = rt
10750 .neighbors(
10751 &tok,
10752 note.id,
10753 Direction::Out,
10754 None,
10755 Some(vec![EdgeRelation::Annotates]),
10756 )
10757 .await
10758 .unwrap();
10759 assert_eq!(ann_hits.len(), 1, "annotates edge must exist");
10760 let ann_edge_uuid = ann_hits[0].edge_id;
10761
10762 rt.delete_edge(&tok, ann_edge_uuid, false).await.unwrap();
10764 assert_eq!(
10765 count_edge_rows_by_id(&rt, ann_edge_uuid, &ns).await,
10766 1,
10767 "soft-deleted annotates edge must still be a physical row"
10768 );
10769
10770 let purged = rt.delete_edge(&tok, base_edge_uuid, true).await.unwrap();
10772 assert!(purged, "hard delete of base edge must return true");
10773
10774 assert_eq!(
10775 count_edge_rows_by_id(&rt, ann_edge_uuid, &ns).await,
10776 0,
10777 "hard-delete of base edge must purge already-soft-deleted annotates edge row (ADR-002)"
10778 );
10779 assert_eq!(
10780 count_edge_rows_by_id(&rt, base_edge_uuid, &ns).await,
10781 0,
10782 "hard-delete must physically remove the base edge row"
10783 );
10784 }
10785
10786 #[tokio::test]
10793 async fn create_entity_fts_failure_rolls_back_entity_row() {
10794 let rt = KhiveRuntime::memory().unwrap();
10795 let ns = Namespace::parse("fault-entity-fts").unwrap();
10796 let tok = NamespaceToken::for_namespace(ns.clone());
10797
10798 arm_fts_fail(ns.as_str());
10799
10800 let result = rt
10801 .create_entity(
10802 &tok,
10803 "concept",
10804 None,
10805 "fts-fail rollback target",
10806 None,
10807 None,
10808 vec![],
10809 )
10810 .await;
10811
10812 assert!(
10813 result.is_err(),
10814 "create_entity must propagate the injected FTS failure"
10815 );
10816 let err_msg = result.unwrap_err().to_string();
10817 assert!(
10818 err_msg.contains("injected FTS failure"),
10819 "error must carry injection message; got: {err_msg}"
10820 );
10821
10822 let entities = rt.list_entities(&tok, None, None, 1000, 0).await.unwrap();
10823 assert!(
10824 entities.is_empty(),
10825 "compensation must remove the entity row after FTS failure; got {entities:?}"
10826 );
10827 }
10828
10829 #[tokio::test]
10833 async fn create_entity_vector_failure_rolls_back_entity_row_and_fts() {
10834 const MODEL: &str = "test-entity-vec-inject";
10835 const DIMS: usize = 4;
10836
10837 let rt = KhiveRuntime::memory().unwrap();
10838 let (provider, _counter) = ConstVecProvider::new(MODEL, DIMS);
10839 rt.register_embedder(provider);
10840
10841 let ns = Namespace::parse("fault-entity-vec").unwrap();
10842 let tok = NamespaceToken::for_namespace(ns.clone());
10843
10844 arm_vector_fail(ns.as_str());
10845
10846 let result = rt
10847 .create_entity(
10848 &tok,
10849 "concept",
10850 None,
10851 "vec-fail rollback target",
10852 Some("description so embed body is non-empty"),
10853 None,
10854 vec![],
10855 )
10856 .await;
10857
10858 assert!(
10859 result.is_err(),
10860 "create_entity must propagate the injected vector failure"
10861 );
10862 let err_msg = result.unwrap_err().to_string();
10863 assert!(
10864 err_msg.contains("injected vector failure"),
10865 "error must carry injection message; got: {err_msg}"
10866 );
10867
10868 let entities = rt.list_entities(&tok, None, None, 1000, 0).await.unwrap();
10869 assert!(
10870 entities.is_empty(),
10871 "compensation must remove entity row after vector failure; got {entities:?}"
10872 );
10873
10874 use khive_storage::types::{TextFilter, TextQueryMode, TextSearchRequest};
10876 let fts_hits = rt
10877 .text(&tok)
10878 .unwrap()
10879 .search(TextSearchRequest {
10880 query: "vec-fail rollback target".to_string(),
10881 mode: TextQueryMode::Plain,
10882 filter: Some(TextFilter {
10883 namespaces: vec![ns.as_str().to_string()],
10884 ..Default::default()
10885 }),
10886 top_k: 10,
10887 snippet_chars: 100,
10888 })
10889 .await
10890 .unwrap();
10891 assert!(
10892 fts_hits.is_empty(),
10893 "compensation must remove FTS document after vector failure; got {fts_hits:?}"
10894 );
10895 }
10896
10897 #[tokio::test]
10904 async fn create_entity_multi_model_second_vector_failure_rolls_back_all() {
10905 const DIMS: usize = 4;
10906
10907 let rt = KhiveRuntime::memory().unwrap();
10908 let (provider_a, _ca) = ConstVecProvider::new("model-a", DIMS);
10909 let (provider_b, _cb) = ConstVecProvider::new("model-b", DIMS);
10910 rt.register_embedder(provider_a);
10911 rt.register_embedder(provider_b);
10912
10913 let ns = Namespace::parse("fault-entity-multi").unwrap();
10914 let tok = NamespaceToken::for_namespace(ns.clone());
10915
10916 arm_vector_fail_after(1);
10918
10919 let result = rt
10920 .create_entity(
10921 &tok,
10922 "concept",
10923 None,
10924 "multi-model rollback target",
10925 Some("description for embedding"),
10926 None,
10927 vec![],
10928 )
10929 .await;
10930
10931 assert!(
10932 result.is_err(),
10933 "create_entity must propagate the injected multi-model vector failure"
10934 );
10935
10936 let entities = rt.list_entities(&tok, None, None, 1000, 0).await.unwrap();
10937 assert!(
10938 entities.is_empty(),
10939 "compensation must remove entity row; got {entities:?}"
10940 );
10941
10942 use khive_storage::types::VectorSearchRequest;
10947 let query_vec = vec![1.0_f32; DIMS];
10948 let hits_a = rt
10949 .vectors_for_model(&tok, "model-a")
10950 .unwrap()
10951 .search(VectorSearchRequest {
10952 query_vectors: vec![query_vec.clone()],
10953 top_k: 100,
10954 namespace: Some(ns.as_str().to_string()),
10955 kind: Some(khive_types::SubstrateKind::Entity),
10956 embedding_model: Some("model-a".to_string()),
10957 filter: None,
10958 backend_hints: None,
10959 })
10960 .await
10961 .unwrap();
10962 assert!(
10963 hits_a.is_empty(),
10964 "model-a vector store must be empty after rollback; got {hits_a:?}"
10965 );
10966 let hits_b = rt
10967 .vectors_for_model(&tok, "model-b")
10968 .unwrap()
10969 .search(VectorSearchRequest {
10970 query_vectors: vec![query_vec],
10971 top_k: 100,
10972 namespace: Some(ns.as_str().to_string()),
10973 kind: Some(khive_types::SubstrateKind::Entity),
10974 embedding_model: Some("model-b".to_string()),
10975 filter: None,
10976 backend_hints: None,
10977 })
10978 .await
10979 .unwrap();
10980 assert!(
10981 hits_b.is_empty(),
10982 "model-b vector store must be empty after rollback; got {hits_b:?}"
10983 );
10984 }
10985
10986 #[tokio::test]
10990 async fn update_entity_fans_out_to_all_registered_models() {
10991 const DIMS: usize = 4;
10992
10993 let rt = KhiveRuntime::memory().unwrap();
10994 let (provider_a, _ca) = ConstVecProvider::new("embed-a", DIMS);
10995 let (provider_b, _cb) = ConstVecProvider::new("embed-b", DIMS);
10996 rt.register_embedder(provider_a);
10997 rt.register_embedder(provider_b);
10998
10999 let ns = Namespace::parse("update-entity-fanout").unwrap();
11000 let tok = NamespaceToken::for_namespace(ns.clone());
11001
11002 let entity = rt
11003 .create_entity(
11004 &tok,
11005 "concept",
11006 None,
11007 "FanOutEntity",
11008 Some("initial description"),
11009 None,
11010 vec![],
11011 )
11012 .await
11013 .expect("create_entity must succeed");
11014
11015 use crate::curation::EntityPatch;
11016 let patch = EntityPatch {
11017 description: Some(Some("updated description after fan-out fix".to_string())),
11018 ..Default::default()
11019 };
11020 rt.update_entity(&tok, entity.id, patch)
11021 .await
11022 .expect("update_entity must succeed");
11023
11024 use khive_storage::types::VectorSearchRequest;
11025 let query_vec = vec![1.0_f32; DIMS];
11026
11027 let hits_a = rt
11028 .vectors_for_model(&tok, "embed-a")
11029 .unwrap()
11030 .search(VectorSearchRequest {
11031 query_vectors: vec![query_vec.clone()],
11032 top_k: 10,
11033 namespace: Some(ns.as_str().to_string()),
11034 kind: Some(khive_types::SubstrateKind::Entity),
11035 embedding_model: Some("embed-a".to_string()),
11036 filter: None,
11037 backend_hints: None,
11038 })
11039 .await
11040 .unwrap();
11041 assert!(
11042 hits_a.iter().any(|h| h.subject_id == entity.id),
11043 "embed-a must hold a vector for the entity after update; got {hits_a:?}"
11044 );
11045
11046 let hits_b = rt
11047 .vectors_for_model(&tok, "embed-b")
11048 .unwrap()
11049 .search(VectorSearchRequest {
11050 query_vectors: vec![query_vec],
11051 top_k: 10,
11052 namespace: Some(ns.as_str().to_string()),
11053 kind: Some(khive_types::SubstrateKind::Entity),
11054 embedding_model: Some("embed-b".to_string()),
11055 filter: None,
11056 backend_hints: None,
11057 })
11058 .await
11059 .unwrap();
11060 assert!(
11061 hits_b.iter().any(|h| h.subject_id == entity.id),
11062 "embed-b must hold a vector for the entity after update; got {hits_b:?}"
11063 );
11064 }
11065
11066 #[tokio::test]
11070 async fn update_note_fans_out_to_all_registered_models() {
11071 const DIMS: usize = 4;
11072
11073 let rt = KhiveRuntime::memory().unwrap();
11074 let (provider_a, _ca) = ConstVecProvider::new("embed-a", DIMS);
11075 let (provider_b, _cb) = ConstVecProvider::new("embed-b", DIMS);
11076 rt.register_embedder(provider_a);
11077 rt.register_embedder(provider_b);
11078
11079 let ns = Namespace::parse("update-note-fanout").unwrap();
11080 let tok = NamespaceToken::for_namespace(ns.clone());
11081
11082 let note = rt
11083 .create_note(
11084 &tok,
11085 "observation",
11086 None,
11087 "initial note content for fan-out test",
11088 None,
11089 None,
11090 vec![],
11091 )
11092 .await
11093 .expect("create_note must succeed");
11094
11095 use crate::curation::NotePatch;
11096 let patch = NotePatch {
11097 content: Some("updated content after fan-out fix".to_string()),
11098 ..Default::default()
11099 };
11100 rt.update_note(&tok, note.id, patch)
11101 .await
11102 .expect("update_note must succeed");
11103
11104 use khive_storage::types::VectorSearchRequest;
11105 let query_vec = vec![1.0_f32; DIMS];
11106
11107 let hits_a = rt
11108 .vectors_for_model(&tok, "embed-a")
11109 .unwrap()
11110 .search(VectorSearchRequest {
11111 query_vectors: vec![query_vec.clone()],
11112 top_k: 10,
11113 namespace: Some(ns.as_str().to_string()),
11114 kind: Some(khive_types::SubstrateKind::Note),
11115 embedding_model: Some("embed-a".to_string()),
11116 filter: None,
11117 backend_hints: None,
11118 })
11119 .await
11120 .unwrap();
11121 assert!(
11122 hits_a.iter().any(|h| h.subject_id == note.id),
11123 "embed-a must hold a vector for the note after update; got {hits_a:?}"
11124 );
11125
11126 let hits_b = rt
11127 .vectors_for_model(&tok, "embed-b")
11128 .unwrap()
11129 .search(VectorSearchRequest {
11130 query_vectors: vec![query_vec],
11131 top_k: 10,
11132 namespace: Some(ns.as_str().to_string()),
11133 kind: Some(khive_types::SubstrateKind::Note),
11134 embedding_model: Some("embed-b".to_string()),
11135 filter: None,
11136 backend_hints: None,
11137 })
11138 .await
11139 .unwrap();
11140 assert!(
11141 hits_b.iter().any(|h| h.subject_id == note.id),
11142 "embed-b must hold a vector for the note after update; got {hits_b:?}"
11143 );
11144 }
11145
11146 #[tokio::test]
11154 async fn get_entity_cross_namespace_succeeds() {
11155 let rt = rt();
11156 let leo_tok = NamespaceToken::for_namespace(Namespace::parse("lambda:leo").unwrap());
11158 let entity = rt
11159 .create_entity(&leo_tok, "concept", None, "Peer-Entity", None, None, vec![])
11160 .await
11161 .unwrap();
11162 assert_eq!(entity.namespace, "lambda:leo");
11163
11164 let local_tok = NamespaceToken::local();
11166 let fetched = rt.get_entity(&local_tok, entity.id).await;
11167 assert!(
11168 fetched.is_ok(),
11169 "get_entity from local token must find lambda:leo entity; got {:?}",
11170 fetched
11171 );
11172 assert_eq!(fetched.unwrap().id, entity.id);
11173 }
11174
11175 #[tokio::test]
11176 async fn update_entity_cross_namespace_succeeds() {
11177 let rt = rt();
11178 let leo_tok = NamespaceToken::for_namespace(Namespace::parse("lambda:leo").unwrap());
11179 let entity = rt
11180 .create_entity(
11181 &leo_tok,
11182 "concept",
11183 None,
11184 "Peer-Entity-Update",
11185 None,
11186 None,
11187 vec![],
11188 )
11189 .await
11190 .unwrap();
11191
11192 let local_tok = NamespaceToken::local();
11194 let patch = crate::curation::EntityPatch {
11195 name: Some("Peer-Entity-Updated".to_string()),
11196 ..Default::default()
11197 };
11198 let result = rt.update_entity(&local_tok, entity.id, patch).await;
11199 assert!(
11200 result.is_ok(),
11201 "update_entity from local token must succeed on lambda:leo entity; got {:?}",
11202 result
11203 );
11204 assert_eq!(result.unwrap().name, "Peer-Entity-Updated");
11205 }
11206
11207 #[tokio::test]
11208 async fn delete_entity_cross_namespace_succeeds() {
11209 let rt = rt();
11210 let leo_tok = NamespaceToken::for_namespace(Namespace::parse("lambda:leo").unwrap());
11211 let entity = rt
11212 .create_entity(
11213 &leo_tok,
11214 "concept",
11215 None,
11216 "Peer-Entity-Delete",
11217 None,
11218 None,
11219 vec![],
11220 )
11221 .await
11222 .unwrap();
11223
11224 let local_tok = NamespaceToken::local();
11226 let deleted = rt.delete_entity(&local_tok, entity.id, false).await;
11227 assert!(
11228 deleted.is_ok(),
11229 "delete_entity from local token must succeed on lambda:leo entity; got {:?}",
11230 deleted
11231 );
11232 assert!(
11233 deleted.unwrap(),
11234 "delete must return true when entity existed"
11235 );
11236 }
11237
11238 #[tokio::test]
11239 async fn namespace_preserved_on_entity_after_cross_namespace_get() {
11240 let rt = rt();
11241 let leo_tok = NamespaceToken::for_namespace(Namespace::parse("lambda:leo").unwrap());
11242 let entity = rt
11243 .create_entity(
11244 &leo_tok,
11245 "concept",
11246 None,
11247 "NS-Preserved",
11248 None,
11249 None,
11250 vec![],
11251 )
11252 .await
11253 .unwrap();
11254
11255 let local_tok = NamespaceToken::local();
11257 let fetched = rt.get_entity(&local_tok, entity.id).await.unwrap();
11258 assert_eq!(
11259 fetched.namespace, "lambda:leo",
11260 "namespace column must be preserved; not overwritten with caller's namespace"
11261 );
11262 }
11263
11264 use crate::pack::PackByIdResolver;
11267 use tokio::sync::Mutex as TokioMutex;
11268
11269 #[derive(Debug, Default)]
11270 struct MockResolverState {
11271 owned: Vec<Uuid>,
11272 deleted: Vec<Uuid>,
11273 delete_calls: Vec<(Uuid, bool)>,
11274 }
11275
11276 struct MockPackResolver(TokioMutex<MockResolverState>);
11277
11278 impl MockPackResolver {
11279 fn new() -> Self {
11280 Self(TokioMutex::new(MockResolverState::default()))
11281 }
11282 }
11283
11284 #[async_trait::async_trait]
11285 impl crate::pack::PackByIdResolver for MockPackResolver {
11286 async fn resolve_by_id(&self, id: Uuid) -> Result<Option<Resolved>, RuntimeError> {
11287 let state = self.0.lock().await;
11288 if state.owned.contains(&id) && !state.deleted.contains(&id) {
11289 Ok(Some(Resolved::PackRecord {
11290 pack: "mock".into(),
11291 kind: "widget".into(),
11292 data: serde_json::json!({ "id": id.to_string(), "name": "test-widget" }),
11293 }))
11294 } else {
11295 Ok(None)
11296 }
11297 }
11298
11299 async fn resolve_by_id_including_deleted(
11300 &self,
11301 id: Uuid,
11302 ) -> Result<Option<Resolved>, RuntimeError> {
11303 let state = self.0.lock().await;
11304 if state.owned.contains(&id) {
11305 Ok(Some(Resolved::PackRecord {
11306 pack: "mock".into(),
11307 kind: "widget".into(),
11308 data: serde_json::json!({ "id": id.to_string(), "name": "test-widget" }),
11309 }))
11310 } else {
11311 Ok(None)
11312 }
11313 }
11314
11315 async fn delete_by_id(
11316 &self,
11317 id: Uuid,
11318 hard: bool,
11319 ) -> Result<serde_json::Value, RuntimeError> {
11320 let mut state = self.0.lock().await;
11321 if !state.owned.contains(&id) {
11322 return Err(RuntimeError::NotFound(format!(
11323 "mock widget not found: {id}"
11324 )));
11325 }
11326 state.delete_calls.push((id, hard));
11327 if hard {
11328 state.owned.retain(|&x| x != id);
11329 state.deleted.retain(|&x| x != id);
11330 } else {
11331 state.deleted.push(id);
11332 }
11333 Ok(
11334 serde_json::json!({ "deleted": true, "id": id.to_string(), "kind": "widget", "hard": hard }),
11335 )
11336 }
11337 }
11338
11339 fn registry_with_mock_resolver(
11340 rt: KhiveRuntime,
11341 resolver: Box<dyn crate::pack::PackByIdResolver>,
11342 ) -> crate::VerbRegistry {
11343 use crate::pack::{PackRuntime, VerbRegistryBuilder};
11344 use khive_types::{HandlerDef, VerbCategory, Visibility};
11345
11346 static MINIMAL_HANDLERS: &[HandlerDef] = &[HandlerDef {
11347 name: "minimal.noop",
11348 description: "noop",
11349 visibility: Visibility::Verb,
11350 category: VerbCategory::Commissive,
11351 params: &[],
11352 }];
11353
11354 struct MinimalPack;
11355 impl khive_types::Pack for MinimalPack {
11356 const NAME: &'static str = "minimal";
11357 const NOTE_KINDS: &'static [&'static str] = &[];
11358 const ENTITY_KINDS: &'static [&'static str] = &[];
11359 const HANDLERS: &'static [HandlerDef] = MINIMAL_HANDLERS;
11360 }
11361 #[async_trait::async_trait]
11362 impl PackRuntime for MinimalPack {
11363 fn name(&self) -> &str {
11364 "minimal"
11365 }
11366 fn note_kinds(&self) -> &'static [&'static str] {
11367 &[]
11368 }
11369 fn entity_kinds(&self) -> &'static [&'static str] {
11370 &[]
11371 }
11372 fn handlers(&self) -> &'static [HandlerDef] {
11373 MINIMAL_HANDLERS
11374 }
11375 async fn dispatch(
11376 &self,
11377 _verb: &str,
11378 _params: serde_json::Value,
11379 _registry: &crate::VerbRegistry,
11380 _token: &NamespaceToken,
11381 ) -> Result<serde_json::Value, RuntimeError> {
11382 Err(RuntimeError::InvalidInput("stub".into()))
11383 }
11384 }
11385
11386 let _ = rt;
11387 let mut builder = VerbRegistryBuilder::new();
11388 builder.register(MinimalPack);
11389 builder.register_resolver("mock", resolver);
11390 builder.build().expect("registry build failed")
11391 }
11392
11393 #[tokio::test]
11394 async fn pack_record_resolved_pair_returns_none() {
11395 let pr = Resolved::PackRecord {
11396 pack: "knowledge".into(),
11397 kind: "atom".into(),
11398 data: serde_json::json!({}),
11399 };
11400 assert!(
11401 resolved_pair(Some(&pr)).is_none(),
11402 "PackRecord must not be a valid edge endpoint"
11403 );
11404 }
11405
11406 #[test]
11407 fn resolved_pair_surfaces_entity_type() {
11408 let e = Resolved::Entity(
11409 Entity::new("mathlib", "concept", "Nat.add_comm").with_entity_type(Some("theorem")),
11410 );
11411 assert_eq!(
11412 resolved_pair(Some(&e)),
11413 Some(("entity", "concept", Some("theorem"))),
11414 "entity_type subtype must be surfaced alongside base kind"
11415 );
11416 }
11417
11418 #[test]
11419 fn endpoint_of_type_matches_subtype_not_base_kind() {
11420 let kind = "concept";
11422 let et = Some("theorem");
11423
11424 assert!(endpoint_matches(
11426 &EndpointKind::EntityOfType {
11427 kind: "concept",
11428 entity_type: "theorem",
11429 },
11430 "entity",
11431 kind,
11432 et
11433 ));
11434 assert!(!endpoint_matches(
11435 &EndpointKind::EntityOfType {
11436 kind: "concept",
11437 entity_type: "definition",
11438 },
11439 "entity",
11440 kind,
11441 et
11442 ));
11443
11444 assert!(!endpoint_matches(
11447 &EndpointKind::EntityOfKind("theorem"),
11448 "entity",
11449 kind,
11450 et
11451 ));
11452 assert!(endpoint_matches(
11454 &EndpointKind::EntityOfKind("concept"),
11455 "entity",
11456 kind,
11457 et
11458 ));
11459
11460 assert!(!endpoint_matches(
11462 &EndpointKind::EntityOfType {
11463 kind: "concept",
11464 entity_type: "theorem",
11465 },
11466 "note",
11467 "task",
11468 None
11469 ));
11470 assert!(!endpoint_matches(
11471 &EndpointKind::EntityOfType {
11472 kind: "concept",
11473 entity_type: "theorem",
11474 },
11475 "entity",
11476 kind,
11477 None
11478 ));
11479 }
11480
11481 #[test]
11482 fn endpoint_of_type_requires_base_kind_match() {
11483 let wrong_base_kind = "project"; let et = Some("theorem");
11488
11489 assert!(
11493 !endpoint_matches(
11494 &EndpointKind::EntityOfType {
11495 kind: "concept",
11496 entity_type: "theorem",
11497 },
11498 "entity",
11499 wrong_base_kind,
11500 et
11501 ),
11502 "EntityOfType must reject an entity whose base kind != rule.kind \
11503 even when entity_type matches — the pre-fix bug admitted this"
11504 );
11505
11506 assert!(endpoint_matches(
11508 &EndpointKind::EntityOfType {
11509 kind: "concept",
11510 entity_type: "theorem",
11511 },
11512 "entity",
11513 "concept",
11514 et
11515 ));
11516 }
11517
11518 #[tokio::test]
11519 async fn registry_resolvers_accessor_returns_registered() {
11520 let resolver = Box::new(MockPackResolver::new());
11521 let registry = registry_with_mock_resolver(rt(), resolver);
11522 assert_eq!(registry.resolvers().len(), 1);
11523 assert_eq!(registry.resolvers()[0].0, "mock");
11524 }
11525
11526 #[tokio::test]
11527 async fn mock_resolver_resolve_by_id_returns_pack_record() {
11528 let id = Uuid::new_v4();
11529 let resolver: Box<dyn PackByIdResolver> = Box::new(MockPackResolver::new());
11530 let inner = MockPackResolver::new();
11532 inner.0.lock().await.owned.push(id);
11533 let result: Result<Option<Resolved>, RuntimeError> = inner.resolve_by_id(id).await;
11534 match result.unwrap() {
11535 Some(Resolved::PackRecord { pack, kind, data }) => {
11536 assert_eq!(pack, "mock");
11537 assert_eq!(kind, "widget");
11538 assert_eq!(data["id"].as_str().unwrap(), id.to_string());
11539 }
11540 other => panic!("expected PackRecord, got {:?}", other),
11541 }
11542 let _ = resolver;
11543 }
11544
11545 #[tokio::test]
11546 async fn mock_resolver_resolve_unknown_uuid_returns_none() {
11547 let inner = MockPackResolver::new();
11548 let id = Uuid::new_v4();
11549 let result: Result<Option<Resolved>, RuntimeError> = inner.resolve_by_id(id).await;
11550 assert!(result.unwrap().is_none());
11551 }
11552
11553 #[tokio::test]
11554 async fn mock_resolver_delete_soft_records_call() {
11555 let id = Uuid::new_v4();
11556 let inner = MockPackResolver::new();
11557 inner.0.lock().await.owned.push(id);
11558
11559 let result: Result<serde_json::Value, RuntimeError> = inner.delete_by_id(id, false).await;
11560 let result = result.unwrap();
11561 assert_eq!(result["deleted"], serde_json::json!(true));
11562 assert_eq!(result["hard"], serde_json::json!(false));
11563
11564 let live: Result<Option<Resolved>, RuntimeError> = inner.resolve_by_id(id).await;
11566 assert!(live.unwrap().is_none());
11567 let incl: Result<Option<Resolved>, RuntimeError> =
11568 inner.resolve_by_id_including_deleted(id).await;
11569 assert!(incl.unwrap().is_some());
11570 }
11571
11572 #[tokio::test]
11573 async fn mock_resolver_delete_hard_removes_record() {
11574 let id = Uuid::new_v4();
11575 let inner = MockPackResolver::new();
11576 inner.0.lock().await.owned.push(id);
11577
11578 let result: Result<serde_json::Value, RuntimeError> = inner.delete_by_id(id, true).await;
11579 assert_eq!(result.unwrap()["hard"], serde_json::json!(true));
11580
11581 let incl: Result<Option<Resolved>, RuntimeError> =
11583 inner.resolve_by_id_including_deleted(id).await;
11584 assert!(incl.unwrap().is_none());
11585 }
11586
11587 #[tokio::test]
11588 async fn pack_record_not_valid_context_entity() {
11589 let pr = Resolved::PackRecord {
11592 pack: "knowledge".into(),
11593 kind: "atom".into(),
11594 data: serde_json::json!({}),
11595 };
11596 assert!(matches!(pr, Resolved::PackRecord { .. }));
11599 }
11600
11601 fn neighbor_hit(node_id: Uuid) -> NeighborHit {
11604 NeighborHit {
11605 node_id,
11606 edge_id: Uuid::new_v4(),
11607 relation: EdgeRelation::Extends,
11608 weight: 1.0,
11609 name: None,
11610 kind: None,
11611 entity_type: None,
11612 }
11613 }
11614
11615 fn path_node(node_id: Uuid, depth: usize) -> PathNode {
11616 PathNode {
11617 node_id,
11618 via_edge: None,
11619 depth,
11620 name: None,
11621 kind: None,
11622 properties: None,
11623 }
11624 }
11625
11626 #[tokio::test]
11629 async fn enrich_neighbor_hits_batch_entity_note_and_bogus() {
11630 let rt = rt();
11631 let tok = NamespaceToken::local();
11632
11633 let entity = rt
11635 .create_entity(&tok, "concept", None, "MyEntity", None, None, vec![])
11636 .await
11637 .unwrap();
11638
11639 let note = rt
11641 .create_note(&tok, "observation", None, "body", Some(0.5), None, vec![])
11642 .await
11643 .unwrap();
11644
11645 let bogus_id = Uuid::new_v4();
11646
11647 let mut hits = vec![
11648 neighbor_hit(entity.id),
11649 neighbor_hit(note.id),
11650 neighbor_hit(bogus_id),
11651 ];
11652
11653 rt.enrich_neighbor_hits(&tok, &mut hits).await;
11654
11655 assert_eq!(hits[0].name.as_deref(), Some("MyEntity"));
11656 assert_eq!(hits[0].kind.as_deref(), Some("concept"));
11657
11658 assert_eq!(hits[1].name.as_deref(), Some("[observation]"));
11659 assert_eq!(hits[1].kind.as_deref(), Some("observation"));
11660
11661 assert!(hits[2].name.is_none());
11662 assert!(hits[2].kind.is_none());
11663 }
11664
11665 #[tokio::test]
11667 async fn enrich_neighbor_hits_note_with_name_uses_name() {
11668 let rt = rt();
11669 let tok = NamespaceToken::local();
11670
11671 let note = rt
11672 .create_note(
11673 &tok,
11674 "insight",
11675 Some("NoteTitle"),
11676 "body",
11677 Some(0.5),
11678 None,
11679 vec![],
11680 )
11681 .await
11682 .unwrap();
11683
11684 let mut hits = vec![neighbor_hit(note.id)];
11685 rt.enrich_neighbor_hits(&tok, &mut hits).await;
11686
11687 assert_eq!(hits[0].name.as_deref(), Some("NoteTitle"));
11688 assert_eq!(hits[0].kind.as_deref(), Some("insight"));
11689 }
11690
11691 #[tokio::test]
11694 async fn enrich_path_nodes_batch_dedup_and_unresolved() {
11695 let rt = rt();
11696 let tok = NamespaceToken::local();
11697
11698 let ea = rt
11699 .create_entity(&tok, "concept", None, "Alpha", None, None, vec![])
11700 .await
11701 .unwrap();
11702 let eb = rt
11703 .create_entity(&tok, "document", None, "Beta", None, None, vec![])
11704 .await
11705 .unwrap();
11706 let bogus_id = Uuid::new_v4();
11707
11708 let mut paths = vec![
11710 GraphPath {
11711 root_id: ea.id,
11712 nodes: vec![
11713 path_node(ea.id, 0),
11714 path_node(eb.id, 1),
11715 path_node(bogus_id, 2),
11716 ],
11717 total_weight: 1.0,
11718 },
11719 GraphPath {
11720 root_id: eb.id,
11721 nodes: vec![path_node(eb.id, 0), path_node(ea.id, 1)],
11722 total_weight: 1.0,
11723 },
11724 ];
11725
11726 rt.enrich_path_nodes(&tok, &mut paths, false).await;
11727
11728 assert_eq!(paths[0].nodes[0].name.as_deref(), Some("Alpha"));
11729 assert_eq!(paths[0].nodes[0].kind.as_deref(), Some("concept"));
11730 assert_eq!(paths[0].nodes[1].name.as_deref(), Some("Beta"));
11731 assert_eq!(paths[0].nodes[1].kind.as_deref(), Some("document"));
11732 assert!(paths[0].nodes[2].name.is_none());
11733 assert!(paths[0].nodes[2].kind.is_none());
11734
11735 assert_eq!(paths[1].nodes[0].name.as_deref(), Some("Beta"));
11737 assert_eq!(paths[1].nodes[0].kind.as_deref(), Some("document"));
11738 assert_eq!(paths[1].nodes[1].name.as_deref(), Some("Alpha"));
11739 assert_eq!(paths[1].nodes[1].kind.as_deref(), Some("concept"));
11740 }
11741
11742 #[tokio::test]
11750 async fn enrich_resolves_entities_in_extra_visible_namespace() {
11751 let rt = KhiveRuntime::memory().unwrap();
11752
11753 let ns_a = Namespace::parse("enrich-ns-a").unwrap();
11754 let ns_b = Namespace::parse("enrich-ns-b").unwrap();
11755
11756 let tok_b = rt.authorize(ns_b.clone()).unwrap();
11757
11758 let entity_b = rt
11760 .create_entity(&tok_b, "concept", None, "EntityInB", None, None, vec![])
11761 .await
11762 .unwrap();
11763 assert_eq!(entity_b.namespace, "enrich-ns-b");
11764
11765 let vis_tok = rt
11767 .authorize_with_visibility(ns_a.clone(), vec![ns_b.clone()])
11768 .unwrap();
11769
11770 let mut hits = vec![neighbor_hit(entity_b.id)];
11772 rt.enrich_neighbor_hits(&vis_tok, &mut hits).await;
11773
11774 assert_eq!(
11775 hits[0].name.as_deref(),
11776 Some("EntityInB"),
11777 "entity in extra-visible ns must be enriched by enrich_neighbor_hits"
11778 );
11779 assert_eq!(hits[0].kind.as_deref(), Some("concept"));
11780
11781 let mut paths = vec![GraphPath {
11783 root_id: entity_b.id,
11784 nodes: vec![path_node(entity_b.id, 0)],
11785 total_weight: 1.0,
11786 }];
11787 rt.enrich_path_nodes(&vis_tok, &mut paths, false).await;
11788
11789 assert_eq!(
11790 paths[0].nodes[0].name.as_deref(),
11791 Some("EntityInB"),
11792 "entity in extra-visible ns must be enriched by enrich_path_nodes"
11793 );
11794 assert_eq!(paths[0].nodes[0].kind.as_deref(), Some("concept"));
11795 }
11796
11797 #[tokio::test]
11801 async fn enrich_neighbor_hits_populates_entity_type() {
11802 let rt = rt();
11803 let tok = NamespaceToken::local();
11804
11805 let props = serde_json::json!({"domain": "attention"});
11806 let entity = rt
11807 .create_entity(
11808 &tok,
11809 "concept",
11810 Some("algorithm"),
11811 "FlashAttn",
11812 None,
11813 Some(props),
11814 vec![],
11815 )
11816 .await
11817 .unwrap();
11818
11819 let entity_no_type = rt
11820 .create_entity(&tok, "concept", None, "PlainConcept", None, None, vec![])
11821 .await
11822 .unwrap();
11823
11824 let mut hits = vec![neighbor_hit(entity.id), neighbor_hit(entity_no_type.id)];
11825 rt.enrich_neighbor_hits(&tok, &mut hits).await;
11826
11827 assert_eq!(hits[0].entity_type.as_deref(), Some("algorithm"));
11828 assert!(
11829 hits[1].entity_type.is_none(),
11830 "entity without entity_type must leave the field as None"
11831 );
11832 }
11833
11834 #[tokio::test]
11838 async fn enrich_path_nodes_populates_properties() {
11839 let rt = rt();
11840 let tok = NamespaceToken::local();
11841
11842 let props = serde_json::json!({"year": 2024, "venue": "NeurIPS"});
11843 let entity_with_props = rt
11844 .create_entity(
11845 &tok,
11846 "document",
11847 None,
11848 "AttentionPaper",
11849 None,
11850 Some(props.clone()),
11851 vec![],
11852 )
11853 .await
11854 .unwrap();
11855
11856 let entity_no_props = rt
11857 .create_entity(&tok, "concept", None, "BareConceptNode", None, None, vec![])
11858 .await
11859 .unwrap();
11860
11861 let mut paths = vec![GraphPath {
11862 root_id: entity_with_props.id,
11863 nodes: vec![
11864 path_node(entity_with_props.id, 0),
11865 path_node(entity_no_props.id, 1),
11866 ],
11867 total_weight: 1.0,
11868 }];
11869
11870 rt.enrich_path_nodes(&tok, &mut paths, true).await;
11871
11872 assert_eq!(
11873 paths[0].nodes[0].properties.as_ref(),
11874 Some(&props),
11875 "properties must be filled when entity has a non-null properties blob"
11876 );
11877 assert!(
11878 paths[0].nodes[1].properties.is_none(),
11879 "entity without properties must leave the field as None"
11880 );
11881 }
11882
11883 #[tokio::test]
11893 async fn traverse_chunks_root_binds_over_host_param_limit() {
11894 use khive_storage::types::TraversalOptions;
11895
11896 let rt = rt();
11897 let tok = NamespaceToken::local();
11898 let graph = rt.graph(&tok).unwrap();
11899
11900 const N: usize = 1_000;
11901 let now = chrono::Utc::now();
11902
11903 let mut roots: Vec<uuid::Uuid> = Vec::with_capacity(N);
11904 let mut expected_children: std::collections::HashMap<uuid::Uuid, uuid::Uuid> =
11905 std::collections::HashMap::with_capacity(N);
11906
11907 for _ in 0..N {
11908 let root = uuid::Uuid::new_v4();
11909 let child = uuid::Uuid::new_v4();
11910 graph
11911 .upsert_edge(Edge {
11912 id: LinkId::from(uuid::Uuid::new_v4()),
11913 namespace: "local".to_string(),
11914 source_id: root,
11915 target_id: child,
11916 relation: EdgeRelation::Extends,
11917 weight: 1.0,
11918 created_at: now,
11919 updated_at: now,
11920 deleted_at: None,
11921 metadata: None,
11922 target_backend: None,
11923 })
11924 .await
11925 .unwrap();
11926 roots.push(root);
11927 expected_children.insert(root, child);
11928 }
11929
11930 let paths = graph
11932 .traverse(TraversalRequest {
11933 roots: roots.clone(),
11934 options: TraversalOptions {
11935 max_depth: 1,
11936 direction: Direction::Out,
11937 relations: None,
11938 min_weight: None,
11939 limit: None,
11940 },
11941 include_roots: false,
11942 include_properties: false,
11943 })
11944 .await
11945 .unwrap();
11946
11947 assert_eq!(
11948 paths.len(),
11949 N,
11950 "traverse over {N} roots must return one GraphPath per root"
11951 );
11952
11953 for path in &paths {
11954 let expected_child = expected_children[&path.root_id];
11955 assert_eq!(
11956 path.nodes.len(),
11957 1,
11958 "root {:?} must reach exactly 1 node",
11959 path.root_id
11960 );
11961 assert_eq!(
11962 path.nodes[0].node_id, expected_child,
11963 "root {:?} must reach its direct child",
11964 path.root_id
11965 );
11966 }
11967 }
11968
11969 #[test]
11984 fn pack_entity_of_type_rules_do_not_shadow_base_entity_of_kind_rule() {
11985 let pack_rules: Vec<EdgeEndpointRule> = vec![
11988 EdgeEndpointRule {
11989 relation: EdgeRelation::VariantOf,
11990 source: EndpointKind::EntityOfType {
11991 kind: "concept",
11992 entity_type: "goal",
11993 },
11994 target: EndpointKind::EntityOfType {
11995 kind: "concept",
11996 entity_type: "theorem",
11997 },
11998 },
11999 EdgeEndpointRule {
12000 relation: EdgeRelation::VariantOf,
12001 source: EndpointKind::EntityOfType {
12002 kind: "concept",
12003 entity_type: "goal",
12004 },
12005 target: EndpointKind::EntityOfType {
12006 kind: "concept",
12007 entity_type: "definition",
12008 },
12009 },
12010 ];
12011
12012 let goal_a =
12013 Resolved::Entity(Entity::new("local", "concept", "G-a").with_entity_type(Some("goal")));
12014 let goal_b =
12015 Resolved::Entity(Entity::new("local", "concept", "G-b").with_entity_type(Some("goal")));
12016
12017 assert!(
12019 !pack_rule_allows(
12020 &pack_rules,
12021 EdgeRelation::VariantOf,
12022 Some(&goal_a),
12023 Some(&goal_b)
12024 ),
12025 "pack rules must not cover goal->goal variant_of (no such rule declared)"
12026 );
12027
12028 assert!(
12031 base_entity_rule_allows("concept", EdgeRelation::VariantOf, "concept"),
12032 "base contract must allow concept->concept variant_of regardless of pack EntityOfType rules"
12033 );
12034 }
12035
12036 #[tokio::test]
12041 async fn link_variant_of_goal_to_goal_allowed_when_pack_has_entity_of_type_rules() {
12042 let rt = rt();
12043 let tok = NamespaceToken::local();
12044
12045 rt.install_edge_rules(vec![
12048 EdgeEndpointRule {
12049 relation: EdgeRelation::VariantOf,
12050 source: EndpointKind::EntityOfType {
12051 kind: "concept",
12052 entity_type: "goal",
12053 },
12054 target: EndpointKind::EntityOfType {
12055 kind: "concept",
12056 entity_type: "theorem",
12057 },
12058 },
12059 EdgeEndpointRule {
12060 relation: EdgeRelation::VariantOf,
12061 source: EndpointKind::EntityOfType {
12062 kind: "concept",
12063 entity_type: "goal",
12064 },
12065 target: EndpointKind::EntityOfType {
12066 kind: "concept",
12067 entity_type: "definition",
12068 },
12069 },
12070 ]);
12071
12072 let a = rt
12073 .create_entity(
12074 &tok,
12075 "concept",
12076 Some("goal"),
12077 "Goal Alpha",
12078 None,
12079 None,
12080 vec![],
12081 )
12082 .await
12083 .unwrap();
12084 let b = rt
12085 .create_entity(
12086 &tok,
12087 "concept",
12088 Some("goal"),
12089 "Goal Beta",
12090 None,
12091 None,
12092 vec![],
12093 )
12094 .await
12095 .unwrap();
12096
12097 let result = rt
12100 .link(&tok, a.id, b.id, EdgeRelation::VariantOf, 1.0, None)
12101 .await;
12102 assert!(
12103 result.is_ok(),
12104 "goal->goal variant_of must be allowed via the base concept->concept rule \
12105 even when EntityOfType rules for variant_of are installed; got {result:?}"
12106 );
12107
12108 let p = rt
12113 .create_entity(&tok, "project", None, "Proj", None, None, vec![])
12114 .await
12115 .unwrap();
12116 let bad = rt
12117 .link(&tok, a.id, p.id, EdgeRelation::VariantOf, 1.0, None)
12118 .await;
12119 assert!(
12120 bad.is_err(),
12121 "additive pack rules must not make validation fail-open; \
12122 goal(concept)->project variant_of must be rejected (pack miss + base miss); \
12123 got {bad:?}"
12124 );
12125 }
12126
12127 #[tokio::test]
12132 async fn link_depends_on_theorem_to_definition_allowed_only_via_pack_rule() {
12133 let rt = rt();
12134 let tok = NamespaceToken::local();
12135
12136 assert!(
12139 !base_entity_rule_allows("concept", EdgeRelation::DependsOn, "concept"),
12140 "base contract must not allow concept->concept DependsOn; \
12141 test would be vacuous if this precondition fails"
12142 );
12143
12144 rt.install_edge_rules(vec![EdgeEndpointRule {
12148 relation: EdgeRelation::DependsOn,
12149 source: EndpointKind::EntityOfType {
12150 kind: "concept",
12151 entity_type: "theorem",
12152 },
12153 target: EndpointKind::EntityOfType {
12154 kind: "concept",
12155 entity_type: "definition",
12156 },
12157 }]);
12158
12159 let thm = rt
12160 .create_entity(&tok, "concept", Some("theorem"), "T1", None, None, vec![])
12161 .await
12162 .unwrap();
12163 let def = rt
12164 .create_entity(
12165 &tok,
12166 "concept",
12167 Some("definition"),
12168 "D1",
12169 None,
12170 None,
12171 vec![],
12172 )
12173 .await
12174 .unwrap();
12175
12176 let result = rt
12178 .link(&tok, thm.id, def.id, EdgeRelation::DependsOn, 1.0, None)
12179 .await;
12180 assert!(
12181 result.is_ok(),
12182 "theorem->definition DependsOn must be allowed by the installed pack rule; \
12183 the base contract has no concept->concept DependsOn row; got {result:?}"
12184 );
12185 }
12186
12187 #[tokio::test]
12194 async fn link_document_introduced_by_person_allowed() {
12195 let rt = rt();
12196 let tok = NamespaceToken::local();
12197
12198 let doc = rt
12199 .create_entity(&tok, "document", None, "Paper", None, None, vec![])
12200 .await
12201 .unwrap();
12202 let author = rt
12203 .create_entity(&tok, "person", None, "Author", None, None, vec![])
12204 .await
12205 .unwrap();
12206
12207 let result = rt
12208 .link(
12209 &tok,
12210 doc.id,
12211 author.id,
12212 EdgeRelation::IntroducedBy,
12213 1.0,
12214 None,
12215 )
12216 .await;
12217 assert!(
12218 result.is_ok(),
12219 "document->person introduced_by must be allowed by the ADR-002 \
12220 endpoint amendment; got {result:?}"
12221 );
12222 }
12223
12224 #[tokio::test]
12225 async fn link_document_introduced_by_org_allowed() {
12226 let rt = rt();
12227 let tok = NamespaceToken::local();
12228
12229 let doc = rt
12230 .create_entity(&tok, "document", None, "Whitepaper", None, None, vec![])
12231 .await
12232 .unwrap();
12233 let org = rt
12234 .create_entity(&tok, "org", None, "Publisher", None, None, vec![])
12235 .await
12236 .unwrap();
12237
12238 let result = rt
12239 .link(&tok, doc.id, org.id, EdgeRelation::IntroducedBy, 1.0, None)
12240 .await;
12241 assert!(
12242 result.is_ok(),
12243 "document->org introduced_by must be allowed by the ADR-002 \
12244 endpoint amendment; got {result:?}"
12245 );
12246 }
12247
12248 #[tokio::test]
12249 async fn link_concept_introduced_by_org_allowed() {
12250 let rt = rt();
12251 let tok = NamespaceToken::local();
12252
12253 let concept = rt
12254 .create_entity(&tok, "concept", None, "Architecture", None, None, vec![])
12255 .await
12256 .unwrap();
12257 let org = rt
12258 .create_entity(&tok, "org", None, "Originator", None, None, vec![])
12259 .await
12260 .unwrap();
12261
12262 let result = rt
12263 .link(
12264 &tok,
12265 concept.id,
12266 org.id,
12267 EdgeRelation::IntroducedBy,
12268 1.0,
12269 None,
12270 )
12271 .await;
12272 assert!(
12273 result.is_ok(),
12274 "concept->org introduced_by must be allowed by the ADR-002 \
12275 endpoint amendment; got {result:?}"
12276 );
12277 }
12278
12279 #[tokio::test]
12280 async fn link_document_depends_on_document_allowed() {
12281 let rt = rt();
12282 let tok = NamespaceToken::local();
12283
12284 let doc_a = rt
12285 .create_entity(&tok, "document", None, "Spec A", None, None, vec![])
12286 .await
12287 .unwrap();
12288 let doc_b = rt
12289 .create_entity(&tok, "document", None, "Spec B", None, None, vec![])
12290 .await
12291 .unwrap();
12292
12293 let result = rt
12294 .link(&tok, doc_a.id, doc_b.id, EdgeRelation::DependsOn, 1.0, None)
12295 .await;
12296 assert!(
12297 result.is_ok(),
12298 "document->document depends_on must be allowed by the ADR-002 \
12299 endpoint amendment; got {result:?}"
12300 );
12301 let edge = result.unwrap();
12302 let dk = edge
12303 .metadata
12304 .as_ref()
12305 .and_then(|m| m.get("dependency_kind"))
12306 .and_then(|v| v.as_str());
12307 assert_eq!(
12308 dk,
12309 Some("normative"),
12310 "document->document depends_on must infer dependency_kind=normative"
12311 );
12312 }
12313
12314 #[tokio::test]
12315 async fn link_org_introduced_by_document_rejected_direction_matters() {
12316 let rt = rt();
12317 let tok = NamespaceToken::local();
12318
12319 let org = rt
12320 .create_entity(&tok, "org", None, "Publisher", None, None, vec![])
12321 .await
12322 .unwrap();
12323 let doc = rt
12324 .create_entity(&tok, "document", None, "Paper", None, None, vec![])
12325 .await
12326 .unwrap();
12327
12328 let result = rt
12331 .link(&tok, org.id, doc.id, EdgeRelation::IntroducedBy, 1.0, None)
12332 .await;
12333 assert!(
12334 result.is_err(),
12335 "org->document introduced_by must remain rejected; only \
12336 document->org is permitted, not the reverse; got {result:?}"
12337 );
12338 }
12339
12340 struct CapturingVecService {
12347 dims: usize,
12348 captured: Arc<std::sync::Mutex<Vec<String>>>,
12349 }
12350
12351 #[async_trait]
12352 impl EmbeddingService for CapturingVecService {
12353 async fn embed(
12354 &self,
12355 texts: &[String],
12356 _model: EmbeddingModel,
12357 ) -> std::result::Result<Vec<Vec<f32>>, EmbedError> {
12358 self.captured.lock().unwrap().extend(texts.iter().cloned());
12359 Ok(texts.iter().map(|_| vec![1.0_f32; self.dims]).collect())
12360 }
12361
12362 fn supports_model(&self, _model: EmbeddingModel) -> bool {
12363 true
12364 }
12365
12366 fn name(&self) -> &'static str {
12367 "capturing-vec"
12368 }
12369 }
12370
12371 struct CapturingVecProvider {
12372 provider_name: String,
12373 dims: usize,
12374 captured: Arc<std::sync::Mutex<Vec<String>>>,
12375 }
12376
12377 #[async_trait]
12378 impl EmbedderProvider for CapturingVecProvider {
12379 fn name(&self) -> &str {
12380 &self.provider_name
12381 }
12382
12383 fn dimensions(&self) -> usize {
12384 self.dims
12385 }
12386
12387 async fn build(&self) -> crate::error::RuntimeResult<Arc<dyn EmbeddingService>> {
12388 Ok(Arc::new(CapturingVecService {
12389 dims: self.dims,
12390 captured: Arc::clone(&self.captured),
12391 }))
12392 }
12393 }
12394
12395 #[tokio::test]
12396 async fn create_note_with_embedding_content_none_matches_create_note() {
12397 let rt = rt();
12398 let tok = NamespaceToken::local();
12399 let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
12400 rt.register_embedder(CapturingVecProvider {
12401 provider_name: "capturing-vec".into(),
12402 dims: 4,
12403 captured: Arc::clone(&captured),
12404 });
12405
12406 let note = rt
12407 .create_note_with_embedding_content(
12408 &tok,
12409 "observation",
12410 None,
12411 "full content, no override",
12412 None,
12413 None,
12414 None,
12415 vec![],
12416 )
12417 .await
12418 .expect("create with None override must behave like create_note");
12419 assert_eq!(note.content, "full content, no override");
12420 let seen = captured.lock().unwrap().clone();
12421 assert_eq!(
12422 seen,
12423 vec!["full content, no override".to_string()],
12424 "with no override the embedder must see the full content"
12425 );
12426 }
12427
12428 #[tokio::test]
12429 async fn create_note_with_embedding_content_embeds_capped_override_and_stores_full_content() {
12430 let rt = rt();
12431 let tok = NamespaceToken::local();
12432 let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
12433 rt.register_embedder(CapturingVecProvider {
12434 provider_name: "capturing-vec".into(),
12435 dims: 4,
12436 captured: Arc::clone(&captured),
12437 });
12438
12439 let full = "head-term and then a very long tail-term that exceeds any cap";
12440 let head = &full[.."head-term and then a very long".len()];
12441
12442 let note = rt
12443 .create_note_with_embedding_content(
12444 &tok,
12445 "observation",
12446 None,
12447 full,
12448 Some(head),
12449 None,
12450 None,
12451 vec![],
12452 )
12453 .await
12454 .expect("proper-prefix override must be accepted");
12455 assert_eq!(note.content, full, "stored content must be the full text");
12456
12457 let seen = captured.lock().unwrap().clone();
12458 assert_eq!(
12459 seen,
12460 vec![head.to_string()],
12461 "embedder must see only the capped override"
12462 );
12463 }
12464
12465 #[tokio::test]
12466 async fn create_note_with_embedding_content_fans_out_identical_override_to_multiple_models() {
12467 let rt = rt();
12468 let tok = NamespaceToken::local();
12469 let captured_a = Arc::new(std::sync::Mutex::new(Vec::new()));
12470 let captured_b = Arc::new(std::sync::Mutex::new(Vec::new()));
12471 rt.register_embedder(CapturingVecProvider {
12472 provider_name: "capturing-vec-a".into(),
12473 dims: 4,
12474 captured: Arc::clone(&captured_a),
12475 });
12476 rt.register_embedder(CapturingVecProvider {
12477 provider_name: "capturing-vec-b".into(),
12478 dims: 4,
12479 captured: Arc::clone(&captured_b),
12480 });
12481
12482 let full = "head-only-embedded plus a long discarded tail";
12483 let head = &full[.."head-only-embedded".len()];
12484
12485 rt.create_note_with_embedding_content(
12486 &tok,
12487 "observation",
12488 None,
12489 full,
12490 Some(head),
12491 None,
12492 None,
12493 vec![],
12494 )
12495 .await
12496 .expect("create ok");
12497
12498 assert_eq!(
12499 captured_a.lock().unwrap().clone(),
12500 vec![head.to_string()],
12501 "model A must receive the identical capped override"
12502 );
12503 assert_eq!(
12504 captured_b.lock().unwrap().clone(),
12505 vec![head.to_string()],
12506 "model B must receive the identical capped override"
12507 );
12508
12509 let vs_a = rt
12512 .vectors_for_model(&tok, "capturing-vec-a")
12513 .expect("vector store for model A");
12514 assert_eq!(
12515 vs_a.count().await.expect("vector count A"),
12516 1,
12517 "model A must have exactly one vector row for the note"
12518 );
12519 let vs_b = rt
12520 .vectors_for_model(&tok, "capturing-vec-b")
12521 .expect("vector store for model B");
12522 assert_eq!(
12523 vs_b.count().await.expect("vector count B"),
12524 1,
12525 "model B must have exactly one vector row for the note"
12526 );
12527 }
12528
12529 #[tokio::test]
12530 async fn create_note_with_embedding_content_rejects_empty_override() {
12531 let rt = rt();
12532 let tok = NamespaceToken::local();
12533
12534 let err = rt
12535 .create_note_with_embedding_content(
12536 &tok,
12537 "observation",
12538 None,
12539 "some content",
12540 Some(""),
12541 None,
12542 None,
12543 vec![],
12544 )
12545 .await
12546 .expect_err("empty override must be rejected");
12547 assert!(matches!(err, RuntimeError::InvalidInput(_)));
12548 }
12549
12550 #[tokio::test]
12551 async fn create_note_with_embedding_content_rejects_non_prefix_override() {
12552 let rt = rt();
12553 let tok = NamespaceToken::local();
12554
12555 let err = rt
12556 .create_note_with_embedding_content(
12557 &tok,
12558 "observation",
12559 None,
12560 "the actual content",
12561 Some("an unrelated string"),
12562 None,
12563 None,
12564 vec![],
12565 )
12566 .await
12567 .expect_err("non-prefix override must be rejected");
12568 assert!(matches!(err, RuntimeError::InvalidInput(ref m) if m.contains("prefix")));
12569 }
12570
12571 #[tokio::test]
12572 async fn create_note_with_embedding_content_rejects_equal_length_override() {
12573 let rt = rt();
12574 let tok = NamespaceToken::local();
12575
12576 let err = rt
12578 .create_note_with_embedding_content(
12579 &tok,
12580 "observation",
12581 None,
12582 "identical text",
12583 Some("identical text"),
12584 None,
12585 None,
12586 vec![],
12587 )
12588 .await
12589 .expect_err("an equal-length override must be rejected as not a proper prefix");
12590 assert!(matches!(err, RuntimeError::InvalidInput(ref m) if m.contains("prefix")));
12591 }
12592
12593 #[tokio::test]
12594 async fn create_note_with_embedding_content_rejects_secret_bearing_override() {
12595 let rt = rt();
12596 let tok = NamespaceToken::local();
12597
12598 let token_span = "ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
12599 let content = format!("{token_span} plus extra trailing content beyond the override");
12600 let embedding_content = format!("{token_span} plus extra");
12601
12602 let err = rt
12603 .create_note_with_embedding_content(
12604 &tok,
12605 "observation",
12606 None,
12607 &content,
12608 Some(&embedding_content),
12609 None,
12610 None,
12611 vec![],
12612 )
12613 .await
12614 .expect_err("a credential-shaped override must fail the secret gate");
12615 assert!(
12616 matches!(err, RuntimeError::SecretDetected(_)),
12617 "expected SecretDetected, got {err:?}"
12618 );
12619
12620 let count = rt
12622 .notes(&tok)
12623 .unwrap()
12624 .count_notes(tok.namespace().as_str(), None)
12625 .await
12626 .unwrap();
12627 assert_eq!(count, 0, "a rejected create must leave no note behind");
12628 }
12629}