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, Direction, EdgeSortField, GraphPath, LinkId, NeighborHit,
21 NeighborQuery, Page, PageRequest, SortOrder, SqlRow, SqlStatement, SqlValue, TextFilter,
22 TextQueryMode, TextSearchRequest, TraversalRequest,
23};
24use khive_storage::{Edge, EdgeRelation, Entity, EntityFilter, Event, EventFilter};
25use khive_types::{EdgeEndpointRule, EndpointKind, EventKind, SubstrateKind};
26
27use khive_db::SqliteError;
28use rusqlite::OptionalExtension;
29
30use crate::curation::{entity_fts_document, note_fts_document};
31use crate::error::{RuntimeError, RuntimeResult};
32use crate::runtime::{KhiveRuntime, NamespaceToken};
33
34#[cfg(test)]
52std::thread_local! {
53 static LINK_FAIL_AFTER: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
54}
55
56#[cfg(any(test, feature = "fault-injection"))]
62std::thread_local! {
63 static VECTOR_FAIL_AFTER: std::cell::Cell<Option<usize>> =
64 const { std::cell::Cell::new(None) };
65}
66
67#[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
89#[cfg(any(test, feature = "fault-injection"))]
96pub fn arm_fts_fail(ns: &str) {
97 *FTS_FAIL_NS.lock().unwrap() = Some(ns.to_string());
98}
99
100#[cfg(any(test, feature = "fault-injection"))]
107pub fn arm_fts_fail_many(ns: &str) {
108 *FTS_FAIL_MANY_NS.lock().unwrap() = Some(ns.to_string());
109}
110
111#[cfg(any(test, feature = "fault-injection"))]
119pub fn arm_fts_fail_many_partial(ns: &str) {
120 *FTS_FAIL_MANY_PARTIAL_NS.lock().unwrap() = Some(ns.to_string());
121}
122
123#[cfg(any(test, feature = "fault-injection"))]
130pub fn arm_vector_fail(ns: &str) {
131 *VECTOR_FAIL_NS.lock().unwrap() = Some(ns.to_string());
132}
133
134#[derive(Clone, Debug)]
136pub struct NoteSearchHit {
137 pub note_id: Uuid,
138 pub score: DeterministicScore,
139 pub title: Option<String>,
140 pub snippet: Option<String>,
141}
142
143fn text_preview(text: &str, max_chars: usize) -> Option<String> {
144 let trimmed = text.trim();
145 if trimmed.is_empty() {
146 None
147 } else {
148 Some(trimmed.chars().take(max_chars).collect())
149 }
150}
151
152fn normalize_symmetric_direction(
158 direction: Direction,
159 relations: Option<&[EdgeRelation]>,
160) -> Direction {
161 let Some(rels) = relations else {
162 return direction;
163 };
164 if rels.is_empty() {
165 return direction;
166 }
167 let all_symmetric = rels
168 .iter()
169 .all(|r| matches!(r, EdgeRelation::CompetesWith | EdgeRelation::ComposedWith));
170 if all_symmetric {
171 Direction::Both
172 } else {
173 direction
174 }
175}
176
177fn note_title(note: &Note) -> Option<String> {
178 note.name
179 .clone()
180 .filter(|s| !s.trim().is_empty())
181 .or_else(|| Some(format!("[{}]", note.kind.as_str())))
182}
183
184fn note_snippet(note: &Note) -> Option<String> {
185 text_preview(¬e.content, 200)
186}
187
188#[derive(Clone, Debug)]
190pub enum Resolved {
191 Entity(Entity),
192 Note(Note),
193 Event(Event),
194 PackRecord {
201 pack: String,
202 kind: String,
203 data: serde_json::Value,
204 },
205}
206
207fn resolved_pair(r: Option<&Resolved>) -> Option<(&'static str, &str, Option<&str>)> {
213 match r? {
214 Resolved::Entity(e) => Some(("entity", e.kind.as_str(), e.entity_type.as_deref())),
215 Resolved::Note(n) => Some(("note", n.kind.as_str(), None)),
216 Resolved::Event(_) => None,
217 Resolved::PackRecord { .. } => None,
218 }
219}
220
221fn endpoint_matches(
223 spec: &EndpointKind,
224 substrate: &str,
225 kind: &str,
226 entity_type: Option<&str>,
227) -> bool {
228 match spec {
229 EndpointKind::EntityOfKind(k) => substrate == "entity" && *k == kind,
230 EndpointKind::NoteOfKind(k) => substrate == "note" && *k == kind,
231 EndpointKind::EntityOfType {
232 kind: k,
233 entity_type: t,
234 } => substrate == "entity" && *k == kind && entity_type == Some(*t),
235 }
236}
237
238fn pack_rule_allows(
241 rules: &[EdgeEndpointRule],
242 relation: EdgeRelation,
243 src: Option<&Resolved>,
244 tgt: Option<&Resolved>,
245) -> bool {
246 let Some((src_sub, src_kind, src_type)) = resolved_pair(src) else {
247 return false;
248 };
249 let Some((tgt_sub, tgt_kind, tgt_type)) = resolved_pair(tgt) else {
250 return false;
251 };
252 rules.iter().any(|r| {
253 r.relation == relation
254 && endpoint_matches(&r.source, src_sub, src_kind, src_type)
255 && endpoint_matches(&r.target, tgt_sub, tgt_kind, tgt_type)
256 })
257}
258
259pub const BASE_ENTITY_ENDPOINT_RULES: &[(&str, EdgeRelation, &str)] = &[
269 ("concept", EdgeRelation::Contains, "concept"),
271 ("project", EdgeRelation::Contains, "project"),
272 ("project", EdgeRelation::Contains, "artifact"),
273 ("org", EdgeRelation::Contains, "project"),
274 ("org", EdgeRelation::Contains, "service"),
275 ("concept", EdgeRelation::PartOf, "concept"),
276 ("project", EdgeRelation::PartOf, "project"),
277 ("project", EdgeRelation::PartOf, "org"),
278 ("*", EdgeRelation::InstanceOf, "concept"),
279 ("service", EdgeRelation::InstanceOf, "project"),
280 ("concept", EdgeRelation::Extends, "concept"),
282 ("concept", EdgeRelation::VariantOf, "concept"),
283 ("artifact", EdgeRelation::VariantOf, "artifact"),
284 ("concept", EdgeRelation::IntroducedBy, "document"),
285 ("concept", EdgeRelation::IntroducedBy, "person"),
286 ("artifact", EdgeRelation::IntroducedBy, "document"),
287 ("artifact", EdgeRelation::DerivedFrom, "dataset"),
289 ("artifact", EdgeRelation::DerivedFrom, "document"),
290 ("artifact", EdgeRelation::DerivedFrom, "project"),
291 ("artifact", EdgeRelation::DerivedFrom, "artifact"),
292 ("document", EdgeRelation::Precedes, "document"),
294 ("dataset", EdgeRelation::Precedes, "dataset"),
295 ("artifact", EdgeRelation::Precedes, "artifact"),
296 ("service", EdgeRelation::Precedes, "service"),
297 ("project", EdgeRelation::Precedes, "project"),
298 ("project", EdgeRelation::DependsOn, "project"),
300 ("service", EdgeRelation::DependsOn, "project"),
301 ("service", EdgeRelation::DependsOn, "service"),
302 ("service", EdgeRelation::DependsOn, "artifact"),
303 ("service", EdgeRelation::DependsOn, "dataset"),
304 ("artifact", EdgeRelation::DependsOn, "project"),
305 ("artifact", EdgeRelation::DependsOn, "service"),
306 ("concept", EdgeRelation::Enables, "concept"),
307 ("service", EdgeRelation::Enables, "concept"),
308 ("dataset", EdgeRelation::Enables, "concept"),
309 ("project", EdgeRelation::Implements, "concept"),
311 ("service", EdgeRelation::Implements, "concept"),
312 ("concept", EdgeRelation::CompetesWith, "concept"),
314 ("project", EdgeRelation::CompetesWith, "project"),
315 ("service", EdgeRelation::CompetesWith, "service"),
316 ("concept", EdgeRelation::ComposedWith, "concept"),
317 ("project", EdgeRelation::ComposedWith, "project"),
318 ("concept", EdgeRelation::Supersedes, "concept"),
320 ("document", EdgeRelation::Supersedes, "document"),
321 ("artifact", EdgeRelation::Supersedes, "artifact"),
322 ("service", EdgeRelation::Supersedes, "service"),
323 ("dataset", EdgeRelation::Supersedes, "dataset"),
324 ("concept", EdgeRelation::Supports, "concept"),
326 ("document", EdgeRelation::Supports, "concept"),
327 ("dataset", EdgeRelation::Supports, "concept"),
328 ("artifact", EdgeRelation::Supports, "concept"),
329 ("concept", EdgeRelation::Refutes, "concept"),
330 ("document", EdgeRelation::Refutes, "concept"),
331 ("dataset", EdgeRelation::Refutes, "concept"),
332 ("artifact", EdgeRelation::Refutes, "concept"),
333];
334
335pub fn base_entity_endpoint_rules() -> &'static [(&'static str, EdgeRelation, &'static str)] {
341 BASE_ENTITY_ENDPOINT_RULES
342}
343
344fn base_entity_rule_allows(src_kind: &str, relation: EdgeRelation, tgt_kind: &str) -> bool {
345 BASE_ENTITY_ENDPOINT_RULES.iter().any(|(src, rel, tgt)| {
346 *rel == relation && (*src == "*" || *src == src_kind) && *tgt == tgt_kind
347 })
348}
349
350pub(crate) fn canonical_edge_endpoints(
356 relation: EdgeRelation,
357 source_id: Uuid,
358 target_id: Uuid,
359) -> (Uuid, Uuid) {
360 if relation.is_symmetric() && target_id < source_id {
361 (target_id, source_id)
362 } else {
363 (source_id, target_id)
364 }
365}
366
367fn infer_dependency_kind(src_kind: &str, tgt_kind: &str) -> Option<&'static str> {
369 match (src_kind, tgt_kind) {
370 ("project", "project") => Some("build"),
371 ("service", "service") => Some("runtime"),
372 ("service", "dataset") => Some("data"),
373 ("service", "artifact") => Some("artifact"),
374 ("artifact", "project") | ("artifact", "service") => Some("tooling"),
375 _ => None,
376 }
377}
378
379fn merge_dependency_kind(
386 src_kind: &str,
387 tgt_kind: &str,
388 metadata: Option<serde_json::Value>,
389) -> Option<serde_json::Value> {
390 if let Some(ref m) = metadata {
391 if m.get("dependency_kind").is_some() {
392 return metadata;
393 }
394 }
395 let inferred = infer_dependency_kind(src_kind, tgt_kind)?;
396 let mut obj = metadata.unwrap_or_else(|| serde_json::json!({}));
397 if let Some(o) = obj.as_object_mut() {
398 o.insert("dependency_kind".to_string(), serde_json::json!(inferred));
399 }
400 Some(obj)
401}
402
403const VALID_DEPENDENCY_KINDS: &[&str] = &["build", "runtime", "data", "artifact", "tooling"];
405
406pub(crate) fn validate_edge_weight(weight: f64) -> RuntimeResult<()> {
412 if !weight.is_finite() || !(0.0..=1.0).contains(&weight) {
413 return Err(RuntimeError::InvalidInput(format!(
414 "edge weight must be finite and in [0.0, 1.0], got {weight}"
415 )));
416 }
417 Ok(())
418}
419
420fn validate_edge_metadata(
426 relation: EdgeRelation,
427 metadata: Option<&serde_json::Value>,
428) -> RuntimeResult<()> {
429 let Some(meta) = metadata else {
430 return Ok(());
431 };
432 if let Some(dk) = meta.get("dependency_kind") {
433 if relation != EdgeRelation::DependsOn {
434 return Err(RuntimeError::InvalidInput(format!(
435 "dependency_kind is only valid on depends_on edges (got {})",
436 relation.as_str()
437 )));
438 }
439 let dk_str = dk
440 .as_str()
441 .ok_or_else(|| RuntimeError::InvalidInput("dependency_kind must be a string".into()))?;
442 if !VALID_DEPENDENCY_KINDS.contains(&dk_str) {
443 return Err(RuntimeError::InvalidInput(format!(
444 "unknown dependency_kind {dk_str:?}; valid: {}",
445 VALID_DEPENDENCY_KINDS.join(" | ")
446 )));
447 }
448 }
449 Ok(())
450}
451
452fn note_props_match(note_props: Option<&serde_json::Value>, filter: &serde_json::Value) -> bool {
457 let required = match filter.as_object() {
458 Some(obj) if !obj.is_empty() => obj,
459 _ => return true,
460 };
461 let actual = match note_props.and_then(serde_json::Value::as_object) {
462 Some(obj) => obj,
463 None => return false,
464 };
465 required
466 .iter()
467 .all(|(k, v)| actual.get(k).is_some_and(|av| av == v))
468}
469
470impl KhiveRuntime {
471 #[allow(clippy::too_many_arguments)]
478 pub async fn create_entity(
479 &self,
480 token: &NamespaceToken,
481 kind: &str,
482 entity_type: Option<&str>,
483 name: &str,
484 description: Option<&str>,
485 properties: Option<serde_json::Value>,
486 tags: Vec<String>,
487 ) -> RuntimeResult<Entity> {
488 self.validate_entity_kind(kind)?;
489 crate::secret_gate::check(name)?;
491 if let Some(d) = description {
492 crate::secret_gate::check(d)?;
493 }
494 if let Some(ref p) = properties {
495 crate::secret_gate::check_json(p)?;
496 }
497 crate::secret_gate::check_tags(&tags)?;
498 let ns = token.namespace().as_str();
499 let mut entity = Entity::new(ns, kind, name).with_entity_type(entity_type);
500 if let Some(d) = description {
501 entity = entity.with_description(d);
502 }
503 if let Some(p) = properties {
504 entity = entity.with_properties(p);
505 }
506 if !tags.is_empty() {
507 entity = entity.with_tags(tags);
508 }
509 self.entities(token)?.upsert_entity(entity.clone()).await?;
510
511 let doc = entity_fts_document(&entity);
512 let embed_body = doc.body.clone();
513
514 {
516 #[cfg(any(test, feature = "fault-injection"))]
517 let fts_inject = {
518 let mut g = FTS_FAIL_NS.lock().unwrap();
519 if g.as_deref() == Some(ns) {
520 *g = None;
521 true
522 } else {
523 false
524 }
525 };
526 #[cfg(not(any(test, feature = "fault-injection")))]
527 let fts_inject = false;
528 let fts_result: RuntimeResult<()> = if fts_inject {
529 Err(RuntimeError::Internal("injected FTS failure".to_string()))
530 } else {
531 match self.text(token) {
532 Ok(fts) => fts.upsert_document(doc).await.map_err(RuntimeError::from),
533 Err(e) => Err(e),
534 }
535 };
536 if let Err(e) = fts_result {
537 if let Ok(store) = self.entities(token) {
538 if let Err(ce) = store.delete_entity(entity.id, DeleteMode::Hard).await {
539 tracing::error!(
540 error = %ce,
541 id = %entity.id,
542 "create_entity: failed to roll back entity row after FTS failure"
543 );
544 }
545 }
546 return Err(e);
547 }
548 }
549
550 let embed_model_names = {
553 let names = self.registered_embedding_model_names();
554 if names.is_empty() {
555 vec![]
556 } else {
557 names
558 }
559 };
560
561 if embed_model_names.len() == 1 {
562 let model_name = &embed_model_names[0];
563 let vec_result = self
564 .embed_document_with_model(model_name, &embed_body)
565 .await;
566
567 #[cfg(any(test, feature = "fault-injection"))]
568 let vec_inject = {
569 let mut g = VECTOR_FAIL_NS.lock().unwrap();
570 if g.as_deref() == Some(ns) {
571 *g = None;
572 true
573 } else {
574 false
575 }
576 };
577 #[cfg(not(any(test, feature = "fault-injection")))]
578 let vec_inject = false;
579 let vec_result: RuntimeResult<Vec<f32>> = if vec_inject {
580 Err(RuntimeError::Internal(
581 "injected vector failure".to_string(),
582 ))
583 } else {
584 vec_result
585 };
586
587 let single_result: RuntimeResult<()> = match vec_result {
588 Ok(vector) => match self.vectors_for_model(token, model_name) {
589 Ok(vs) => vs
590 .insert(
591 entity.id,
592 SubstrateKind::Entity,
593 ns,
594 "entity.body",
595 vec![vector],
596 )
597 .await
598 .map_err(RuntimeError::from),
599 Err(e) => Err(e),
600 },
601 Err(e) => Err(e),
602 };
603 if let Err(e) = single_result {
604 if let Ok(store) = self.entities(token) {
605 if let Err(ce) = store.delete_entity(entity.id, DeleteMode::Hard).await {
606 tracing::error!(
607 error = %ce,
608 id = %entity.id,
609 "create_entity: failed to roll back entity row after vector failure"
610 );
611 }
612 }
613 if let Ok(fts) = self.text(token) {
614 if let Err(ce) = fts.delete_document(ns, entity.id).await {
615 tracing::error!(
616 error = %ce,
617 id = %entity.id,
618 "create_entity: failed to roll back FTS document after vector failure"
619 );
620 }
621 }
622 return Err(e);
623 }
624 } else if !embed_model_names.is_empty() {
625 let rt_clone = self.clone();
628 let body_owned = embed_body.clone();
629 let mut handles = Vec::with_capacity(embed_model_names.len());
630 for model_name in &embed_model_names {
631 let rt = rt_clone.clone();
632 let text = body_owned.clone();
633 let name = model_name.clone();
634 handles.push(tokio::spawn(async move {
635 rt.embed_document_with_model(&name, &text).await
636 }));
637 }
638 let mut vectors: Vec<Vec<f32>> = Vec::with_capacity(embed_model_names.len());
639 for handle in handles {
640 let join_result = handle
641 .await
642 .map_err(|e| RuntimeError::Internal(format!("embed task panicked: {e}")));
643 match join_result {
644 Err(e) => {
645 if let Ok(store) = self.entities(token) {
646 if let Err(ce) = store.delete_entity(entity.id, DeleteMode::Hard).await
647 {
648 tracing::error!(
649 error = %ce,
650 id = %entity.id,
651 "create_entity: failed to roll back entity row after embed task panic"
652 );
653 }
654 }
655 if let Ok(fts) = self.text(token) {
656 if let Err(ce) = fts.delete_document(ns, entity.id).await {
657 tracing::error!(
658 error = %ce,
659 id = %entity.id,
660 "create_entity: failed to roll back FTS document after embed task panic"
661 );
662 }
663 }
664 return Err(e);
665 }
666 Ok(Err(e)) => {
667 if let Ok(store) = self.entities(token) {
668 if let Err(ce) = store.delete_entity(entity.id, DeleteMode::Hard).await
669 {
670 tracing::error!(
671 error = %ce,
672 id = %entity.id,
673 "create_entity: failed to roll back entity row after embed failure"
674 );
675 }
676 }
677 if let Ok(fts) = self.text(token) {
678 if let Err(ce) = fts.delete_document(ns, entity.id).await {
679 tracing::error!(
680 error = %ce,
681 id = %entity.id,
682 "create_entity: failed to roll back FTS document after embed failure"
683 );
684 }
685 }
686 return Err(e);
687 }
688 Ok(Ok(vec)) => vectors.push(vec),
689 }
690 }
691 let mut inserted_models: Vec<String> = Vec::with_capacity(embed_model_names.len());
693 for (model_name, vector) in embed_model_names.iter().zip(vectors.into_iter()) {
694 #[cfg(any(test, feature = "fault-injection"))]
696 let count_inject = VECTOR_FAIL_AFTER.with(|cell| match cell.get() {
697 Some(0) => {
698 cell.set(None);
699 true
700 }
701 Some(n) => {
702 cell.set(Some(n - 1));
703 false
704 }
705 None => false,
706 });
707 #[cfg(not(any(test, feature = "fault-injection")))]
708 let count_inject = false;
709
710 let insert_result = if count_inject {
711 Err(RuntimeError::Internal(
712 "injected vector insert failure".to_string(),
713 ))
714 } else {
715 match self.vectors_for_model(token, model_name) {
716 Ok(vs) => vs
717 .insert(
718 entity.id,
719 SubstrateKind::Entity,
720 ns,
721 "entity.body",
722 vec![vector],
723 )
724 .await
725 .map_err(RuntimeError::from),
726 Err(e) => Err(e),
727 }
728 };
729 if let Err(e) = insert_result {
730 if let Ok(store) = self.entities(token) {
732 if let Err(ce) = store.delete_entity(entity.id, DeleteMode::Hard).await {
733 tracing::error!(
734 error = %ce,
735 id = %entity.id,
736 "create_entity: failed to roll back entity row after vector insert failure"
737 );
738 }
739 }
740 if let Ok(fts) = self.text(token) {
741 if let Err(ce) = fts.delete_document(ns, entity.id).await {
742 tracing::error!(
743 error = %ce,
744 id = %entity.id,
745 "create_entity: failed to roll back FTS document after vector insert failure"
746 );
747 }
748 }
749 for m in &inserted_models {
750 if let Ok(vs) = self.vectors_for_model(token, m) {
751 if let Err(ce) = vs.delete(entity.id).await {
752 tracing::error!(
753 error = %ce,
754 model = m,
755 id = %entity.id,
756 "create_entity: failed to roll back vector for model after insert failure"
757 );
758 }
759 }
760 }
761 return Err(e);
762 }
763 inserted_models.push(model_name.clone());
764 }
765 }
766
767 Ok(entity)
768 }
769
770 pub async fn get_entity(&self, token: &NamespaceToken, id: Uuid) -> RuntimeResult<Entity> {
774 self.entities(token)?
775 .get_entity(id)
776 .await?
777 .ok_or_else(|| RuntimeError::NotFound(format!("entity {id}")))
778 }
779
780 pub async fn get_entity_including_deleted(
784 &self,
785 token: &NamespaceToken,
786 id: Uuid,
787 ) -> RuntimeResult<Option<Entity>> {
788 self.entities(token)?
789 .get_entity_including_deleted(id)
790 .await
791 .map_err(Into::into)
792 }
793
794 pub async fn get_note_including_deleted(
798 &self,
799 token: &NamespaceToken,
800 id: Uuid,
801 ) -> RuntimeResult<Option<khive_storage::note::Note>> {
802 self.notes(token)?
803 .get_note_including_deleted(id)
804 .await
805 .map_err(Into::into)
806 }
807
808 pub async fn get_entities_by_ids(
812 &self,
813 token: &NamespaceToken,
814 ids: &[Uuid],
815 ) -> RuntimeResult<Vec<Entity>> {
816 if ids.is_empty() {
817 return Ok(vec![]);
818 }
819 let filter = EntityFilter {
820 ids: ids.to_vec(),
821 ..Default::default()
822 };
823 let page = self
824 .entities(token)?
825 .query_entities(
826 token.namespace().as_str(),
827 filter,
828 PageRequest {
829 offset: 0,
830 limit: ids.len() as u32,
831 },
832 )
833 .await?;
834 Ok(page.items)
835 }
836
837 async fn get_entities_by_ids_visible(
846 &self,
847 token: &NamespaceToken,
848 ids: &[Uuid],
849 ) -> RuntimeResult<Vec<Entity>> {
850 if ids.is_empty() {
851 return Ok(vec![]);
852 }
853 let namespaces: Vec<String> = token
854 .visible_namespaces()
855 .iter()
856 .map(|ns| ns.as_str().to_owned())
857 .collect();
858 let filter = EntityFilter {
859 ids: ids.to_vec(),
860 namespaces,
861 ..Default::default()
862 };
863 let page = self
864 .entities(token)?
865 .query_entities(
866 token.namespace().as_str(),
867 filter,
868 PageRequest {
869 offset: 0,
870 limit: ids.len() as u32,
871 },
872 )
873 .await?;
874 Ok(page.items)
875 }
876
877 pub(crate) fn ensure_namespace(record_ns: &str, caller_primary_ns: &str) -> RuntimeResult<()> {
886 if record_ns == caller_primary_ns {
887 return Ok(());
888 }
889 Err(RuntimeError::NotFound("not found in this namespace".into()))
890 }
891
892 pub(crate) fn ensure_namespace_visible(
898 record_ns: &str,
899 token: &NamespaceToken,
900 ) -> RuntimeResult<()> {
901 for ns in token.visible_namespaces() {
902 if record_ns == ns.as_str() {
903 return Ok(());
904 }
905 }
906 Err(RuntimeError::NotFound("not found in this namespace".into()))
907 }
908
909 pub async fn list_entities(
915 &self,
916 token: &NamespaceToken,
917 kind: Option<&str>,
918 entity_type: Option<&str>,
919 limit: u32,
920 offset: u32,
921 ) -> RuntimeResult<Vec<Entity>> {
922 let ns_strs: Vec<String> = token
923 .visible_namespaces()
924 .iter()
925 .map(|ns| ns.as_str().to_owned())
926 .collect();
927 let filter = EntityFilter {
928 kinds: match kind {
929 Some(k) => vec![k.to_string()],
930 None => vec![],
931 },
932 entity_types: match entity_type {
933 Some(t) => vec![t.to_string()],
934 None => vec![],
935 },
936 namespaces: ns_strs,
937 ..Default::default()
938 };
939 let page = self
940 .entities(token)?
941 .query_entities(
942 token.namespace().as_str(),
943 filter,
944 PageRequest {
945 offset: offset.into(),
946 limit,
947 },
948 )
949 .await?;
950 Ok(page.items)
951 }
952
953 pub async fn list_entities_tagged(
960 &self,
961 token: &NamespaceToken,
962 kind: Option<&str>,
963 domain_tag: Option<&str>,
964 limit: u32,
965 offset: u32,
966 ) -> RuntimeResult<Vec<Entity>> {
967 let ns_strs: Vec<String> = token
968 .visible_namespaces()
969 .iter()
970 .map(|ns| ns.as_str().to_owned())
971 .collect();
972 let filter = EntityFilter {
973 kinds: match kind {
974 Some(k) => vec![k.to_string()],
975 None => vec![],
976 },
977 tags_any: match domain_tag {
978 Some(t) if !t.is_empty() => vec![t.to_string()],
979 _ => vec![],
980 },
981 namespaces: ns_strs,
982 ..Default::default()
983 };
984 let page = self
985 .entities(token)?
986 .query_entities(
987 token.namespace().as_str(),
988 filter,
989 PageRequest {
990 offset: offset.into(),
991 limit,
992 },
993 )
994 .await?;
995 Ok(page.items)
996 }
997
998 pub async fn count_entities_tagged(
1003 &self,
1004 token: &NamespaceToken,
1005 kind: Option<&str>,
1006 domain_tag: Option<&str>,
1007 ) -> RuntimeResult<u64> {
1008 let ns_strs: Vec<String> = token
1009 .visible_namespaces()
1010 .iter()
1011 .map(|ns| ns.as_str().to_owned())
1012 .collect();
1013 let filter = EntityFilter {
1014 kinds: match kind {
1015 Some(k) => vec![k.to_string()],
1016 None => vec![],
1017 },
1018 tags_any: match domain_tag {
1019 Some(t) if !t.is_empty() => vec![t.to_string()],
1020 _ => vec![],
1021 },
1022 namespaces: ns_strs,
1023 ..Default::default()
1024 };
1025 Ok(self
1026 .entities(token)?
1027 .count_entities(token.namespace().as_str(), filter)
1028 .await?)
1029 }
1030
1031 pub async fn list_events(
1033 &self,
1034 token: &NamespaceToken,
1035 filter: EventFilter,
1036 page: PageRequest,
1037 ) -> RuntimeResult<Page<Event>> {
1038 self.events(token)?
1039 .query_events(filter, page)
1040 .await
1041 .map_err(Into::into)
1042 }
1043
1044 async fn validate_edge_relation_endpoints(
1058 &self,
1059 token: &NamespaceToken,
1060 source_id: Uuid,
1061 target_id: Uuid,
1062 relation: EdgeRelation,
1063 ) -> RuntimeResult<()> {
1064 if source_id == target_id {
1065 return Err(RuntimeError::InvalidInput(
1066 "self-loop edges are not allowed: source_id and target_id must be different".into(),
1067 ));
1068 }
1069 if relation == EdgeRelation::Annotates {
1070 match self.resolve_primary(token, source_id).await? {
1072 Some(Resolved::Note(_)) => {}
1073 Some(_) => {
1074 return Err(RuntimeError::InvalidInput(format!(
1075 "annotates source {source_id} must be a note"
1076 )));
1077 }
1078 None => {
1079 if self.get_edge(token, source_id).await?.is_some() {
1081 return Err(RuntimeError::InvalidInput(format!(
1082 "annotates source {source_id} must be a note"
1083 )));
1084 }
1085 return Err(RuntimeError::NotFound(format!(
1086 "link source {source_id} not found in namespace"
1087 )));
1088 }
1089 }
1090 if !self.substrate_exists_in_primary(token, target_id).await? {
1092 return Err(RuntimeError::NotFound(format!(
1093 "link target {target_id} not found in namespace"
1094 )));
1095 }
1096 } else if matches!(
1097 relation,
1098 EdgeRelation::Supersedes | EdgeRelation::Supports | EdgeRelation::Refutes
1099 ) {
1100 let rel_name = relation.as_str();
1103 let src = match self.resolve_primary(token, source_id).await? {
1104 Some(r) => r,
1105 None => {
1106 if self.get_edge(token, source_id).await?.is_some() {
1107 return Err(RuntimeError::InvalidInput(format!(
1108 "{rel_name} source {source_id} must be a note or entity (got edge)"
1109 )));
1110 }
1111 return Err(RuntimeError::NotFound(format!(
1112 "link source {source_id} not found in namespace"
1113 )));
1114 }
1115 };
1116 let tgt = match self.resolve_primary(token, target_id).await? {
1117 Some(r) => r,
1118 None => {
1119 if self.get_edge(token, target_id).await?.is_some() {
1120 return Err(RuntimeError::InvalidInput(format!(
1121 "{rel_name} target {target_id} must be a note or entity (got edge)"
1122 )));
1123 }
1124 return Err(RuntimeError::NotFound(format!(
1125 "link target {target_id} not found in namespace"
1126 )));
1127 }
1128 };
1129 match (&src, &tgt) {
1130 (Resolved::Entity(src_e), Resolved::Entity(tgt_e)) => {
1131 if !base_entity_rule_allows(&src_e.kind, relation, &tgt_e.kind) {
1132 let rule_hint = match relation {
1133 EdgeRelation::Supports | EdgeRelation::Refutes => {
1134 "requires concept|document|dataset|artifact -> concept \
1135 (or same-substrate note -> note)"
1136 }
1137 _ => "requires same-kind entity endpoints",
1138 };
1139 return Err(RuntimeError::InvalidInput(format!(
1140 "({}) -[{rel_name}]-> ({}) is not in the base endpoint \
1141 allowlist; {rel_name} {rule_hint}",
1142 src_e.kind, tgt_e.kind
1143 )));
1144 }
1145 }
1146 (Resolved::Note(_), Resolved::Note(_)) => {}
1147 (Resolved::Event(_), _) => {
1148 return Err(RuntimeError::InvalidInput(format!(
1149 "{rel_name} does not apply to events; source {source_id} is an event"
1150 )));
1151 }
1152 (_, Resolved::Event(_)) => {
1153 return Err(RuntimeError::InvalidInput(format!(
1154 "{rel_name} does not apply to events; target {target_id} is an event"
1155 )));
1156 }
1157 (Resolved::Entity(_), Resolved::Note(_)) => {
1158 return Err(RuntimeError::InvalidInput(format!(
1159 "{rel_name} endpoints must be the same substrate (note→note or entity→entity); \
1160 got source={source_id} (entity) target={target_id} (note)"
1161 )));
1162 }
1163 (Resolved::Note(_), Resolved::Entity(_)) => {
1164 return Err(RuntimeError::InvalidInput(format!(
1165 "{rel_name} endpoints must be the same substrate (note→note or entity→entity); \
1166 got source={source_id} (note) target={target_id} (entity)"
1167 )));
1168 }
1169 (Resolved::PackRecord { .. }, _) | (_, Resolved::PackRecord { .. }) => {
1170 return Err(RuntimeError::InvalidInput(format!(
1171 "pack-private record is not a valid edge endpoint for {rel_name}"
1172 )));
1173 }
1174 }
1175 } else {
1176 let src_res = self.resolve_primary(token, source_id).await?;
1183 let tgt_res = self.resolve_primary(token, target_id).await?;
1184
1185 if pack_rule_allows(
1186 &self.pack_edge_rules(),
1187 relation,
1188 src_res.as_ref(),
1189 tgt_res.as_ref(),
1190 ) {
1191 return Ok(());
1192 }
1193
1194 let src_kind = match src_res {
1196 Some(Resolved::Entity(e)) => e.kind,
1197 Some(_) => {
1198 return Err(RuntimeError::InvalidInput(format!(
1199 "link source {source_id} must be an entity for relation {relation:?} \
1200 (only `annotates` crosses substrates)"
1201 )));
1202 }
1203 None => {
1204 if self.get_edge(token, source_id).await?.is_some() {
1205 return Err(RuntimeError::InvalidInput(format!(
1206 "link source {source_id} must be an entity for relation {relation:?} \
1207 (only `annotates` crosses substrates)"
1208 )));
1209 }
1210 return Err(RuntimeError::NotFound(format!(
1211 "link source {source_id} not found in namespace"
1212 )));
1213 }
1214 };
1215 let tgt_kind = match tgt_res {
1216 Some(Resolved::Entity(e)) => e.kind,
1217 Some(_) => {
1218 return Err(RuntimeError::InvalidInput(format!(
1219 "link target {target_id} must be an entity for relation {relation:?} \
1220 (only `annotates` crosses substrates)"
1221 )));
1222 }
1223 None => {
1224 if self.get_edge(token, target_id).await?.is_some() {
1225 return Err(RuntimeError::InvalidInput(format!(
1226 "link target {target_id} must be an entity for relation {relation:?} \
1227 (only `annotates` crosses substrates)"
1228 )));
1229 }
1230 return Err(RuntimeError::NotFound(format!(
1231 "link target {target_id} not found in namespace"
1232 )));
1233 }
1234 };
1235 if !base_entity_rule_allows(&src_kind, relation, &tgt_kind) {
1236 return Err(RuntimeError::InvalidInput(format!(
1237 "({src_kind}) -[{}]-> ({tgt_kind}) is not in the base endpoint \
1238 allowlist; use pack EDGE_RULES to extend the allowlist",
1239 relation.as_str()
1240 )));
1241 }
1242 }
1243 Ok(())
1244 }
1245
1246 pub async fn validate_link_endpoints(
1251 &self,
1252 token: &NamespaceToken,
1253 source_id: Uuid,
1254 target_id: Uuid,
1255 relation: EdgeRelation,
1256 ) -> RuntimeResult<()> {
1257 self.validate_edge_relation_endpoints(token, source_id, target_id, relation)
1258 .await
1259 }
1260
1261 pub fn validate_link_endpoints_by_resolved(
1272 &self,
1273 source_id: Uuid,
1274 target_id: Uuid,
1275 relation: EdgeRelation,
1276 src: Option<&Resolved>,
1277 tgt: Option<&Resolved>,
1278 ) -> RuntimeResult<()> {
1279 if source_id == target_id {
1280 return Err(RuntimeError::InvalidInput(
1281 "self-loop edges are not allowed: source_id and target_id must be different".into(),
1282 ));
1283 }
1284
1285 if relation == EdgeRelation::Annotates {
1286 match src {
1287 Some(Resolved::Note(_)) => {}
1288 Some(_) => {
1289 return Err(RuntimeError::InvalidInput(format!(
1290 "annotates source {source_id} must be a note"
1291 )));
1292 }
1293 None => {
1294 return Err(RuntimeError::NotFound(format!(
1295 "link source {source_id} not found"
1296 )));
1297 }
1298 }
1299 if tgt.is_none() {
1300 return Err(RuntimeError::NotFound(format!(
1301 "link target {target_id} not found"
1302 )));
1303 }
1304 return Ok(());
1305 }
1306
1307 if matches!(
1308 relation,
1309 EdgeRelation::Supersedes | EdgeRelation::Supports | EdgeRelation::Refutes
1310 ) {
1311 let rel_name = relation.as_str();
1312 let src = src.ok_or_else(|| {
1313 RuntimeError::NotFound(format!("link source {source_id} not found"))
1314 })?;
1315 let tgt = tgt.ok_or_else(|| {
1316 RuntimeError::NotFound(format!("link target {target_id} not found"))
1317 })?;
1318 match (src, tgt) {
1319 (Resolved::Entity(src_e), Resolved::Entity(tgt_e)) => {
1320 if !base_entity_rule_allows(&src_e.kind, relation, &tgt_e.kind) {
1321 let rule_hint = match relation {
1322 EdgeRelation::Supports | EdgeRelation::Refutes => {
1323 "requires concept|document|dataset|artifact -> concept \
1324 (or same-substrate note -> note)"
1325 }
1326 _ => "requires same-kind entity endpoints",
1327 };
1328 return Err(RuntimeError::InvalidInput(format!(
1329 "({}) -[{rel_name}]-> ({}) is not in the base endpoint \
1330 allowlist; {rel_name} {rule_hint}",
1331 src_e.kind, tgt_e.kind
1332 )));
1333 }
1334 }
1335 (Resolved::Note(_), Resolved::Note(_)) => {}
1336 (Resolved::Entity(_), Resolved::Note(_)) => {
1337 return Err(RuntimeError::InvalidInput(format!(
1338 "{rel_name} endpoints must be the same substrate \
1339 (note→note or entity→entity); got source={source_id} (entity) \
1340 target={target_id} (note)"
1341 )));
1342 }
1343 (Resolved::Note(_), Resolved::Entity(_)) => {
1344 return Err(RuntimeError::InvalidInput(format!(
1345 "{rel_name} endpoints must be the same substrate \
1346 (note→note or entity→entity); got source={source_id} (note) \
1347 target={target_id} (entity)"
1348 )));
1349 }
1350 (Resolved::PackRecord { .. }, _) | (_, Resolved::PackRecord { .. }) => {
1351 return Err(RuntimeError::InvalidInput(format!(
1352 "pack-private record is not a valid edge endpoint for {rel_name}"
1353 )));
1354 }
1355 _ => {
1356 return Err(RuntimeError::InvalidInput(format!(
1357 "{rel_name} endpoints must be notes or entities (not events)"
1358 )));
1359 }
1360 }
1361 return Ok(());
1362 }
1363
1364 if pack_rule_allows(&self.pack_edge_rules(), relation, src, tgt) {
1367 return Ok(());
1368 }
1369
1370 let src_kind = match src {
1371 Some(Resolved::Entity(e)) => &e.kind,
1372 Some(_) => {
1373 return Err(RuntimeError::InvalidInput(format!(
1374 "link source {source_id} must be an entity for relation {relation:?} \
1375 (only `annotates` crosses substrates)"
1376 )));
1377 }
1378 None => {
1379 return Err(RuntimeError::NotFound(format!(
1380 "link source {source_id} not found"
1381 )));
1382 }
1383 };
1384 let tgt_kind = match tgt {
1385 Some(Resolved::Entity(e)) => &e.kind,
1386 Some(_) => {
1387 return Err(RuntimeError::InvalidInput(format!(
1388 "link target {target_id} must be an entity for relation {relation:?} \
1389 (only `annotates` crosses substrates)"
1390 )));
1391 }
1392 None => {
1393 return Err(RuntimeError::NotFound(format!(
1394 "link target {target_id} not found"
1395 )));
1396 }
1397 };
1398
1399 if !base_entity_rule_allows(src_kind, relation, tgt_kind) {
1400 return Err(RuntimeError::InvalidInput(format!(
1401 "({src_kind}) -[{}]-> ({tgt_kind}) is not in the base endpoint \
1402 allowlist; use pack EDGE_RULES to extend the allowlist",
1403 relation.as_str()
1404 )));
1405 }
1406
1407 Ok(())
1408 }
1409
1410 pub async fn link(
1429 &self,
1430 token: &NamespaceToken,
1431 source_id: Uuid,
1432 target_id: Uuid,
1433 relation: EdgeRelation,
1434 weight: f64,
1435 metadata: Option<serde_json::Value>,
1436 ) -> RuntimeResult<Edge> {
1437 validate_edge_weight(weight)?;
1438 self.validate_edge_relation_endpoints(token, source_id, target_id, relation)
1439 .await?;
1440 let (source_id, target_id) = canonical_edge_endpoints(relation, source_id, target_id);
1441 let metadata = if relation == EdgeRelation::DependsOn {
1442 match (
1443 self.resolve(token, source_id).await?,
1444 self.resolve(token, target_id).await?,
1445 ) {
1446 (Some(Resolved::Entity(src_e)), Some(Resolved::Entity(tgt_e))) => {
1447 merge_dependency_kind(&src_e.kind, &tgt_e.kind, metadata)
1448 }
1449 _ => metadata,
1450 }
1451 } else {
1452 metadata
1453 };
1454 validate_edge_metadata(relation, metadata.as_ref())?;
1455 let now = chrono::Utc::now();
1456 let ns = token.namespace().as_str();
1457 let edge = Edge {
1458 id: LinkId::from(Uuid::new_v4()),
1459 namespace: ns.to_string(),
1460 source_id,
1461 target_id,
1462 relation,
1463 weight,
1464 created_at: now,
1465 updated_at: now,
1466 deleted_at: None,
1467 metadata,
1468 target_backend: None,
1469 };
1470 self.graph(token)?.upsert_edge(edge).await?;
1471
1472 let persisted = self
1478 .list_edges(
1479 token,
1480 crate::curation::EdgeListFilter {
1481 source_id: Some(source_id),
1482 target_id: Some(target_id),
1483 relations: vec![relation],
1484 ..Default::default()
1485 },
1486 1,
1487 )
1488 .await?
1489 .into_iter()
1490 .next()
1491 .ok_or_else(|| {
1492 crate::RuntimeError::Internal(format!(
1493 "upsert_edge succeeded but natural-key lookup for ({source_id}, {target_id}, {relation}) returned nothing"
1494 ))
1495 })?;
1496 Ok(persisted)
1497 }
1498
1499 #[allow(clippy::too_many_arguments)]
1506 pub async fn link_with_target_backend(
1507 &self,
1508 token: &NamespaceToken,
1509 source_id: Uuid,
1510 target_id: Uuid,
1511 relation: EdgeRelation,
1512 weight: f64,
1513 metadata: Option<serde_json::Value>,
1514 target_backend: Option<String>,
1515 ) -> RuntimeResult<Edge> {
1516 validate_edge_weight(weight)?;
1517 let (source_id, target_id) = canonical_edge_endpoints(relation, source_id, target_id);
1518 validate_edge_metadata(relation, metadata.as_ref())?;
1519 let now = chrono::Utc::now();
1520 let ns = token.namespace().as_str();
1521 let edge = Edge {
1522 id: LinkId::from(Uuid::new_v4()),
1523 namespace: ns.to_string(),
1524 source_id,
1525 target_id,
1526 relation,
1527 weight,
1528 created_at: now,
1529 updated_at: now,
1530 deleted_at: None,
1531 metadata,
1532 target_backend,
1533 };
1534 self.graph(token)?.upsert_edge(edge).await?;
1535 let persisted = self
1536 .list_edges(
1537 token,
1538 crate::curation::EdgeListFilter {
1539 source_id: Some(source_id),
1540 target_id: Some(target_id),
1541 relations: vec![relation],
1542 ..Default::default()
1543 },
1544 1,
1545 )
1546 .await?
1547 .into_iter()
1548 .next()
1549 .ok_or_else(|| {
1550 crate::RuntimeError::Internal(format!(
1551 "upsert_edge succeeded but natural-key lookup for ({source_id}, {target_id}, {relation}) returned nothing"
1552 ))
1553 })?;
1554 Ok(persisted)
1555 }
1556
1557 pub(crate) async fn substrate_exists_in_ns(
1564 &self,
1565 token: &NamespaceToken,
1566 id: Uuid,
1567 ) -> RuntimeResult<bool> {
1568 if self.resolve(token, id).await?.is_some() {
1569 return Ok(true);
1570 }
1571 match self.get_edge_visible(token, id).await {
1572 Ok(Some(_)) => Ok(true),
1573 Ok(None) | Err(RuntimeError::NotFound(_)) => Ok(false),
1574 Err(err) => Err(err),
1575 }
1576 }
1577
1578 pub(crate) async fn substrate_exists_in_primary(
1583 &self,
1584 token: &NamespaceToken,
1585 id: Uuid,
1586 ) -> RuntimeResult<bool> {
1587 if self.resolve_primary(token, id).await?.is_some() {
1588 return Ok(true);
1589 }
1590 match self.get_edge(token, id).await {
1591 Ok(Some(_)) => Ok(true),
1592 Ok(None) | Err(RuntimeError::NotFound(_)) => Ok(false),
1593 Err(err) => Err(err),
1594 }
1595 }
1596
1597 pub async fn neighbors(
1606 &self,
1607 token: &NamespaceToken,
1608 node_id: Uuid,
1609 direction: Direction,
1610 limit: Option<u32>,
1611 relations: Option<Vec<EdgeRelation>>,
1612 ) -> RuntimeResult<Vec<NeighborHit>> {
1613 self.neighbors_with_query(
1614 token,
1615 node_id,
1616 NeighborQuery {
1617 direction,
1618 relations,
1619 limit,
1620 min_weight: None,
1621 },
1622 )
1623 .await
1624 }
1625
1626 pub async fn neighbors_with_query(
1636 &self,
1637 token: &NamespaceToken,
1638 node_id: Uuid,
1639 mut query: NeighborQuery,
1640 ) -> RuntimeResult<Vec<NeighborHit>> {
1641 if !self.substrate_exists_in_ns(token, node_id).await? {
1642 return Ok(Vec::new());
1643 }
1644
1645 query.direction =
1646 normalize_symmetric_direction(query.direction, query.relations.as_deref());
1647 let mut hits = Vec::new();
1648 for ns in token.visible_namespaces() {
1649 let temp = NamespaceToken::for_namespace(ns.clone());
1650 let mut ns_hits = self.graph(&temp)?.neighbors(node_id, query.clone()).await?;
1651 hits.append(&mut ns_hits);
1652 }
1653 hits.sort_by_key(|h| (h.node_id, h.edge_id));
1654 hits.dedup_by_key(|h| (h.node_id, h.edge_id));
1655 self.enrich_neighbor_hits(token, &mut hits).await;
1656 let candidate_ids: Vec<Uuid> = hits.iter().map(|h| h.node_id).collect();
1658 let deleted = self.deleted_entity_ids(candidate_ids).await;
1659 if !deleted.is_empty() {
1660 hits.retain(|h| !deleted.contains(&h.node_id));
1661 }
1662 Ok(hits)
1663 }
1664
1665 pub async fn traverse(
1670 &self,
1671 token: &NamespaceToken,
1672 request: TraversalRequest,
1673 ) -> RuntimeResult<Vec<GraphPath>> {
1674 let mut request = request;
1675 let mut visible_roots = Vec::with_capacity(request.roots.len());
1676 for root in request.roots.drain(..) {
1677 if self.substrate_exists_in_ns(token, root).await? {
1678 visible_roots.push(root);
1679 }
1680 }
1681 request.roots = visible_roots;
1682 if request.roots.is_empty() {
1683 return Ok(Vec::new());
1684 }
1685
1686 let mut paths = Vec::new();
1687 for ns in token.visible_namespaces() {
1688 let temp = NamespaceToken::for_namespace(ns.clone());
1689 let mut ns_paths = self.graph(&temp)?.traverse(request.clone()).await?;
1690 paths.append(&mut ns_paths);
1691 }
1692 self.enrich_path_nodes(token, &mut paths, request.include_properties)
1693 .await;
1694 let all_node_ids: Vec<Uuid> = paths
1696 .iter()
1697 .flat_map(|p| p.nodes.iter().map(|n| n.node_id))
1698 .collect();
1699 let deleted = self.deleted_entity_ids(all_node_ids).await;
1700 if !deleted.is_empty() {
1701 for path in paths.iter_mut() {
1702 path.nodes.retain(|n| !deleted.contains(&n.node_id));
1703 }
1704 paths.retain(|p| !p.nodes.is_empty());
1705 }
1706 Ok(paths)
1707 }
1708
1709 async fn deleted_entity_ids(&self, ids: Vec<Uuid>) -> std::collections::HashSet<Uuid> {
1715 if ids.is_empty() {
1716 return std::collections::HashSet::new();
1717 }
1718 let id_strs: Vec<String> = ids.iter().map(|u| u.to_string()).collect();
1719 let placeholders = id_strs
1720 .iter()
1721 .enumerate()
1722 .map(|(i, _)| format!("?{}", i + 1))
1723 .collect::<Vec<_>>()
1724 .join(",");
1725 let sql_str = format!(
1726 "SELECT id FROM entities WHERE id IN ({placeholders}) AND deleted_at IS NOT NULL"
1727 );
1728 let params: Vec<SqlValue> = id_strs.into_iter().map(SqlValue::Text).collect();
1729 let stmt = SqlStatement {
1730 sql: sql_str,
1731 params,
1732 label: Some("deleted_entity_ids".into()),
1733 };
1734 let mut out = std::collections::HashSet::new();
1735 let sql = self.sql();
1736 if let Ok(mut reader) = sql.reader().await {
1737 if let Ok(rows) = reader.query_all(stmt).await {
1738 for row in rows {
1739 if let Some(col) = row.columns.first() {
1740 if let SqlValue::Text(s) = &col.value {
1741 if let Ok(u) = s.parse::<Uuid>() {
1742 out.insert(u);
1743 }
1744 }
1745 }
1746 }
1747 }
1748 }
1750 out
1751 }
1752
1753 async fn enrich_neighbor_hits(&self, token: &NamespaceToken, hits: &mut [NeighborHit]) {
1762 if hits.is_empty() {
1763 return;
1764 }
1765
1766 let unique_ids: Vec<Uuid> = {
1768 let mut seen = std::collections::HashSet::new();
1769 hits.iter()
1770 .filter_map(|h| {
1771 if seen.insert(h.node_id) {
1772 Some(h.node_id)
1773 } else {
1774 None
1775 }
1776 })
1777 .collect()
1778 };
1779
1780 let entity_map: HashMap<Uuid, Entity> = self
1781 .get_entities_by_ids_visible(token, &unique_ids)
1782 .await
1783 .unwrap_or_default()
1784 .into_iter()
1785 .map(|e| (e.id, e))
1786 .collect();
1787
1788 let residual_ids: Vec<Uuid> = unique_ids
1790 .iter()
1791 .filter(|id| !entity_map.contains_key(id))
1792 .copied()
1793 .collect();
1794
1795 let note_map: HashMap<Uuid, Note> = if !residual_ids.is_empty() {
1796 if let Ok(store) = self.notes(token) {
1797 store
1798 .get_notes_batch(&residual_ids)
1799 .await
1800 .unwrap_or_default()
1801 .into_iter()
1802 .map(|n| (n.id, n))
1803 .collect()
1804 } else {
1805 HashMap::new()
1806 }
1807 } else {
1808 HashMap::new()
1809 };
1810
1811 for hit in hits.iter_mut() {
1812 if let Some(entity) = entity_map.get(&hit.node_id) {
1813 hit.name = Some(entity.name.clone());
1814 hit.kind = Some(entity.kind.clone());
1815 hit.entity_type = entity.entity_type.clone();
1816 } else if let Some(note) = note_map.get(&hit.node_id) {
1817 let kind = note.kind.clone();
1818 let name = note
1819 .name
1820 .as_deref()
1821 .filter(|s| !s.trim().is_empty())
1822 .map(|s| s.to_owned())
1823 .unwrap_or_else(|| format!("[{kind}]"));
1824 hit.name = Some(name);
1825 hit.kind = Some(kind);
1826 }
1827 }
1828 }
1829
1830 async fn enrich_path_nodes(
1841 &self,
1842 token: &NamespaceToken,
1843 paths: &mut [GraphPath],
1844 include_properties: bool,
1845 ) {
1846 if paths.is_empty() {
1847 return;
1848 }
1849
1850 let unique_ids: Vec<Uuid> = {
1852 let mut seen = std::collections::HashSet::new();
1853 paths
1854 .iter()
1855 .flat_map(|p| p.nodes.iter())
1856 .filter_map(|n| {
1857 if seen.insert(n.node_id) {
1858 Some(n.node_id)
1859 } else {
1860 None
1861 }
1862 })
1863 .collect()
1864 };
1865
1866 let entity_map: HashMap<Uuid, Entity> = self
1867 .get_entities_by_ids_visible(token, &unique_ids)
1868 .await
1869 .unwrap_or_default()
1870 .into_iter()
1871 .map(|e| (e.id, e))
1872 .collect();
1873
1874 for path in paths.iter_mut() {
1875 for node in path.nodes.iter_mut() {
1876 if let Some(entity) = entity_map.get(&node.node_id) {
1877 node.name = Some(entity.name.clone());
1878 node.kind = Some(entity.kind.clone());
1879 if include_properties {
1880 node.properties = entity.properties.clone();
1881 }
1882 }
1883 }
1884 }
1885 }
1886
1887 #[allow(clippy::too_many_arguments)]
1901 pub async fn create_note(
1902 &self,
1903 token: &NamespaceToken,
1904 kind: &str,
1905 name: Option<&str>,
1906 content: &str,
1907 salience: Option<f64>,
1908 properties: Option<serde_json::Value>,
1909 annotates: Vec<Uuid>,
1910 ) -> RuntimeResult<Note> {
1911 self.create_note_inner(
1912 token, kind, name, content, salience, None, properties, annotates, None,
1913 )
1914 .await
1915 }
1916
1917 #[allow(clippy::too_many_arguments)]
1921 pub async fn create_note_with_decay(
1922 &self,
1923 token: &NamespaceToken,
1924 kind: &str,
1925 name: Option<&str>,
1926 content: &str,
1927 salience: Option<f64>,
1928 decay_factor: f64,
1929 properties: Option<serde_json::Value>,
1930 annotates: Vec<Uuid>,
1931 ) -> RuntimeResult<Note> {
1932 self.create_note_with_decay_for_embedding_model(
1933 token,
1934 kind,
1935 name,
1936 content,
1937 salience,
1938 decay_factor,
1939 properties,
1940 annotates,
1941 None,
1942 )
1943 .await
1944 }
1945
1946 #[allow(clippy::too_many_arguments)]
1951 pub async fn create_note_with_decay_for_embedding_model(
1952 &self,
1953 token: &NamespaceToken,
1954 kind: &str,
1955 name: Option<&str>,
1956 content: &str,
1957 salience: Option<f64>,
1958 decay_factor: f64,
1959 properties: Option<serde_json::Value>,
1960 annotates: Vec<Uuid>,
1961 embedding_model: Option<&str>,
1962 ) -> RuntimeResult<Note> {
1963 self.create_note_inner(
1964 token,
1965 kind,
1966 name,
1967 content,
1968 salience,
1969 Some(decay_factor),
1970 properties,
1971 annotates,
1972 embedding_model,
1973 )
1974 .await
1975 }
1976
1977 pub async fn try_create_note(
1990 &self,
1991 token: &NamespaceToken,
1992 kind: &str,
1993 name: Option<&str>,
1994 content: &str,
1995 properties: Option<serde_json::Value>,
1996 ) -> RuntimeResult<Option<Note>> {
1997 self.validate_note_kind(kind)?;
1998 crate::secret_gate::check(content)?;
1999 if let Some(n) = name {
2000 crate::secret_gate::check(n)?;
2001 }
2002 if let Some(ref p) = properties {
2003 crate::secret_gate::check_json(p)?;
2004 }
2005
2006 let ns = token.namespace().as_str();
2007 let mut note = Note::new(ns, kind, content);
2008 if let Some(n) = name {
2009 note = note.with_name(n);
2010 }
2011 if let Some(p) = properties {
2012 note = note.with_properties(p);
2013 }
2014
2015 let inserted = self.notes(token)?.try_insert_note(note.clone()).await?;
2016 if !inserted {
2017 return Ok(None);
2018 }
2019
2020 if let Ok(fts) = self.text_for_notes(token) {
2022 if let Err(e) = fts.upsert_document(note_fts_document(¬e)).await {
2023 tracing::warn!(
2024 note_id = %note.id,
2025 error = %e,
2026 "try_create_note: FTS indexing failed (non-fatal)"
2027 );
2028 }
2029 }
2030
2031 let embed_model_names = self.registered_embedding_model_names();
2033 for model_name in &embed_model_names {
2034 match self
2035 .embed_document_with_model(model_name, ¬e.content)
2036 .await
2037 {
2038 Ok(vector) => {
2039 if let Ok(vs) = self.vectors_for_model(token, model_name) {
2040 if let Err(e) = vs
2041 .insert(
2042 note.id,
2043 SubstrateKind::Note,
2044 ns,
2045 "note.content",
2046 vec![vector],
2047 )
2048 .await
2049 {
2050 tracing::warn!(
2051 note_id = %note.id,
2052 model = %model_name,
2053 error = %e,
2054 "try_create_note: vector insert failed (non-fatal)"
2055 );
2056 }
2057 }
2058 }
2059 Err(e) => {
2060 tracing::warn!(
2061 note_id = %note.id,
2062 model = %model_name,
2063 error = %e,
2064 "try_create_note: embedding failed (non-fatal)"
2065 );
2066 }
2067 }
2068 }
2069
2070 Ok(Some(note))
2071 }
2072
2073 #[allow(clippy::too_many_arguments)]
2077 async fn create_note_inner(
2078 &self,
2079 token: &NamespaceToken,
2080 kind: &str,
2081 name: Option<&str>,
2082 content: &str,
2083 salience: Option<f64>,
2084 decay_factor: Option<f64>,
2085 properties: Option<serde_json::Value>,
2086 annotates: Vec<Uuid>,
2087 embedding_model: Option<&str>,
2088 ) -> RuntimeResult<Note> {
2089 self.validate_note_kind(kind)?;
2090 crate::secret_gate::check(content)?;
2092 if let Some(n) = name {
2093 crate::secret_gate::check(n)?;
2094 }
2095 if let Some(ref p) = properties {
2096 crate::secret_gate::check_json(p)?;
2097 }
2098 let ns = token.namespace().as_str();
2099
2100 for &target_id in &annotates {
2104 if !self.substrate_exists_in_primary(token, target_id).await? {
2105 return Err(RuntimeError::NotFound(format!(
2106 "create_note annotates target {target_id} not found in namespace"
2107 )));
2108 }
2109 }
2110
2111 if let Some(s) = salience {
2114 if !s.is_finite() || !(0.0..=1.0).contains(&s) {
2115 return Err(RuntimeError::InvalidInput(format!(
2116 "salience must be a finite value in [0.0, 1.0]; got {s}"
2117 )));
2118 }
2119 }
2120 if let Some(d) = decay_factor {
2121 if !d.is_finite() || d < 0.0 {
2122 return Err(RuntimeError::InvalidInput(format!(
2123 "decay_factor must be a finite value >= 0.0; got {d}"
2124 )));
2125 }
2126 }
2127
2128 if let Some(model_name) = embedding_model {
2133 self.resolve_embedding_model(Some(model_name))?;
2134 }
2135
2136 let mut note = Note::new(ns, kind, content);
2137 if let Some(s) = salience {
2138 note = note.with_salience(s);
2139 }
2140 if let Some(df) = decay_factor {
2141 note = note.with_decay(df);
2142 }
2143 if let Some(n) = name {
2144 note = note.with_name(n);
2145 }
2146 if let Some(p) = properties {
2147 note = note.with_properties(p);
2148 }
2149 self.notes(token)?.upsert_note(note.clone()).await?;
2150
2151 let embed_model_names: Vec<String> = if let Some(m) = embedding_model {
2164 vec![m.to_string()]
2165 } else {
2166 let names = self.registered_embedding_model_names();
2172 if names.is_empty() {
2173 vec![]
2175 } else {
2176 names
2177 }
2178 };
2179
2180 {
2182 #[cfg(any(test, feature = "fault-injection"))]
2188 let fts_inject = {
2189 let mut g = FTS_FAIL_NS.lock().unwrap();
2190 if g.as_deref() == Some(ns) {
2191 *g = None;
2192 true
2193 } else {
2194 false
2195 }
2196 };
2197 #[cfg(not(any(test, feature = "fault-injection")))]
2198 let fts_inject = false;
2199 let fts_result: RuntimeResult<()> = if fts_inject {
2200 Err(RuntimeError::Internal("injected FTS failure".to_string()))
2201 } else {
2202 match self.text_for_notes(token) {
2203 Ok(fts) => fts
2204 .upsert_document(note_fts_document(¬e))
2205 .await
2206 .map_err(RuntimeError::from),
2207 Err(e) => Err(e),
2208 }
2209 };
2210
2211 if let Err(e) = fts_result {
2212 if let Ok(store) = self.notes(token) {
2214 let _ = store.delete_note(note.id, DeleteMode::Hard).await;
2215 }
2216 return Err(e);
2217 }
2218 }
2219
2220 if embed_model_names.len() == 1 {
2226 let model_name = &embed_model_names[0];
2228 let vec_result = self
2229 .embed_document_with_model(model_name, ¬e.content)
2230 .await;
2231
2232 #[cfg(any(test, feature = "fault-injection"))]
2237 let vec_inject = {
2238 let mut g = VECTOR_FAIL_NS.lock().unwrap();
2239 if g.as_deref() == Some(ns) {
2240 *g = None;
2241 true
2242 } else {
2243 false
2244 }
2245 };
2246 #[cfg(not(any(test, feature = "fault-injection")))]
2247 let vec_inject = false;
2248 let vec_result: RuntimeResult<Vec<f32>> = if vec_inject {
2249 Err(RuntimeError::Internal(
2250 "injected vector failure".to_string(),
2251 ))
2252 } else {
2253 vec_result
2254 };
2255
2256 let single_model_result: RuntimeResult<()> = match vec_result {
2257 Ok(vector) => match self.vectors_for_model(token, model_name) {
2258 Ok(vs) => vs
2259 .insert(
2260 note.id,
2261 SubstrateKind::Note,
2262 ns,
2263 "note.content",
2264 vec![vector],
2265 )
2266 .await
2267 .map_err(RuntimeError::from),
2268 Err(e) => Err(e),
2269 },
2270 Err(e) => Err(e),
2271 };
2272 if let Err(e) = single_model_result {
2273 if let Ok(store) = self.notes(token) {
2275 let _ = store.delete_note(note.id, DeleteMode::Hard).await;
2276 }
2277 if let Ok(fts) = self.text_for_notes(token) {
2278 let _ = fts.delete_document(ns, note.id).await;
2279 }
2280 return Err(e);
2281 }
2282 } else if !embed_model_names.is_empty() {
2283 let rt_clone = self.clone();
2286 let content_owned = note.content.clone();
2287 let mut handles = Vec::with_capacity(embed_model_names.len());
2288 for model_name in &embed_model_names {
2289 let rt = rt_clone.clone();
2290 let text = content_owned.clone();
2291 let name = model_name.clone();
2292 handles.push(tokio::spawn(async move {
2293 rt.embed_document_with_model(&name, &text).await
2294 }));
2295 }
2296 let mut vectors: Vec<Vec<f32>> = Vec::with_capacity(embed_model_names.len());
2297 for handle in handles {
2298 let join_result = handle
2299 .await
2300 .map_err(|e| RuntimeError::Internal(format!("embed task panicked: {e}")));
2301 match join_result {
2302 Err(e) => {
2303 if let Ok(store) = self.notes(token) {
2305 let _ = store.delete_note(note.id, DeleteMode::Hard).await;
2306 }
2307 if let Ok(fts) = self.text_for_notes(token) {
2308 let _ = fts.delete_document(ns, note.id).await;
2309 }
2310 return Err(e);
2311 }
2312 Ok(Err(e)) => {
2313 if let Ok(store) = self.notes(token) {
2315 let _ = store.delete_note(note.id, DeleteMode::Hard).await;
2316 }
2317 if let Ok(fts) = self.text_for_notes(token) {
2318 let _ = fts.delete_document(ns, note.id).await;
2319 }
2320 return Err(e);
2321 }
2322 Ok(Ok(vec)) => vectors.push(vec),
2323 }
2324 }
2325 let mut inserted_models: Vec<String> = Vec::with_capacity(embed_model_names.len());
2327 for (model_name, vector) in embed_model_names.iter().zip(vectors.into_iter()) {
2328 let insert_result = match self.vectors_for_model(token, model_name) {
2329 Ok(vs) => vs
2330 .insert(
2331 note.id,
2332 SubstrateKind::Note,
2333 ns,
2334 "note.content",
2335 vec![vector],
2336 )
2337 .await
2338 .map_err(RuntimeError::from),
2339 Err(e) => Err(e),
2340 };
2341 if let Err(e) = insert_result {
2342 if let Ok(store) = self.notes(token) {
2344 let _ = store.delete_note(note.id, DeleteMode::Hard).await;
2345 }
2346 if let Ok(fts) = self.text_for_notes(token) {
2347 let _ = fts.delete_document(ns, note.id).await;
2348 }
2349 for m in &inserted_models {
2350 if let Ok(vs) = self.vectors_for_model(token, m) {
2351 let _ = vs.delete(note.id).await;
2352 }
2353 }
2354 return Err(e);
2355 }
2356 inserted_models.push(model_name.clone());
2357 }
2358 }
2359
2360 let mut created_edges: Vec<Uuid> = Vec::with_capacity(annotates.len());
2366
2367 #[cfg(test)]
2370 let annotates_iter: Vec<(usize, Uuid)> = annotates
2371 .iter()
2372 .enumerate()
2373 .map(|(i, &id)| (i, id))
2374 .collect();
2375 #[cfg(test)]
2376 macro_rules! next_target {
2377 ($pair:expr) => {
2378 $pair.1
2379 };
2380 }
2381 #[cfg(not(test))]
2382 let annotates_iter: Vec<Uuid> = annotates.to_vec();
2383 #[cfg(not(test))]
2384 macro_rules! next_target {
2385 ($pair:expr) => {
2386 $pair
2387 };
2388 }
2389
2390 for pair in annotates_iter {
2391 let target_id = next_target!(pair);
2392
2393 #[cfg(test)]
2395 let injected_err: Option<RuntimeError> = {
2396 let call_idx = pair.0;
2397 LINK_FAIL_AFTER.with(|cell| {
2398 let n = cell.get();
2399 if n > 0 && call_idx + 1 == n {
2400 cell.set(0); Some(RuntimeError::Internal("injected link failure".to_string()))
2402 } else {
2403 None
2404 }
2405 })
2406 };
2407 #[cfg(not(test))]
2408 let injected_err: Option<RuntimeError> = None;
2409
2410 let link_result = if let Some(e) = injected_err {
2411 Err(e)
2412 } else {
2413 self.link(
2414 token,
2415 note.id,
2416 target_id,
2417 EdgeRelation::Annotates,
2418 1.0,
2419 None,
2420 )
2421 .await
2422 };
2423
2424 match link_result {
2425 Ok(edge) => created_edges.push(edge.id.into()),
2426 Err(e) => {
2427 for edge_id in created_edges {
2429 let _ = self.delete_edge(token, edge_id, true).await;
2430 }
2431 if let Ok(store) = self.notes(token) {
2432 let _ = store.delete_note(note.id, DeleteMode::Hard).await;
2433 }
2434 if let Ok(fts) = self.text_for_notes(token) {
2435 let _ = fts.delete_document(ns, note.id).await;
2436 }
2437 for model_name in &embed_model_names {
2438 if let Ok(vs) = self.vectors_for_model(token, model_name) {
2439 let _ = vs.delete(note.id).await;
2440 }
2441 }
2442 return Err(e);
2443 }
2444 }
2445 }
2446
2447 Ok(note)
2448 }
2449
2450 pub async fn list_notes(
2456 &self,
2457 token: &NamespaceToken,
2458 kind: Option<&str>,
2459 limit: u32,
2460 offset: u32,
2461 ) -> RuntimeResult<Vec<Note>> {
2462 let visible = token.visible_namespaces();
2463 if visible.len() == 1 {
2464 let page = self
2466 .notes(token)?
2467 .query_notes(
2468 token.namespace().as_str(),
2469 kind,
2470 PageRequest {
2471 offset: offset.into(),
2472 limit,
2473 },
2474 )
2475 .await?;
2476 return Ok(page.items);
2477 }
2478 use khive_storage::note::NoteFilter;
2480 let ns_strs: Vec<String> = visible.iter().map(|ns| ns.as_str().to_owned()).collect();
2481 let filter = NoteFilter {
2482 kind: kind.map(|k| k.to_string()),
2483 namespaces: ns_strs,
2484 ..Default::default()
2485 };
2486 let page = self
2487 .notes(token)?
2488 .query_notes_filtered(
2489 token.namespace().as_str(),
2490 &filter,
2491 PageRequest {
2492 offset: offset.into(),
2493 limit,
2494 },
2495 )
2496 .await?;
2497 Ok(page.items)
2498 }
2499
2500 #[allow(clippy::too_many_arguments)]
2520 pub async fn search_notes(
2521 &self,
2522 token: &NamespaceToken,
2523 query_text: &str,
2524 query_vector: Option<Vec<f32>>,
2525 limit: u32,
2526 note_kind: Option<&str>,
2527 include_superseded: bool,
2528 tags_any: &[String],
2529 properties_filter: Option<&serde_json::Value>,
2530 ) -> RuntimeResult<Vec<NoteSearchHit>> {
2531 const RRF_K: usize = 60;
2532 let candidates = limit.saturating_mul(4).max(limit);
2533 let visible_ns: Vec<String> = token
2534 .visible_namespaces()
2535 .iter()
2536 .map(|ns| ns.as_str().to_owned())
2537 .collect();
2538
2539 let text_hits = self
2541 .text_for_notes(token)?
2542 .search(TextSearchRequest {
2543 query: query_text.to_string(),
2544 mode: TextQueryMode::Plain,
2545 filter: Some(TextFilter {
2546 namespaces: visible_ns.clone(),
2547 ..TextFilter::default()
2548 }),
2549 top_k: candidates,
2550 snippet_chars: 200,
2551 })
2552 .await?;
2553
2554 let vector_hits = if query_vector.is_some() || self.config().embedding_model.is_some() {
2556 self.vector_search(
2557 token,
2558 query_vector,
2559 Some(query_text),
2560 candidates,
2561 Some(SubstrateKind::Note),
2562 )
2563 .await?
2564 } else {
2565 vec![]
2566 };
2567
2568 let fuse_k = text_hits.len() + vector_hits.len();
2574 let fused = crate::fusion::rrf_fuse_k(text_hits, vector_hits, RRF_K, fuse_k)?;
2575
2576 let candidate_ids: Vec<Uuid> = fused.iter().map(|hit| hit.entity_id).collect();
2577 if candidate_ids.is_empty() {
2578 return Ok(vec![]);
2579 }
2580
2581 let note_store = self.notes(token)?;
2586 let mut alive_notes: HashMap<Uuid, Note> = HashMap::new();
2587 for id in &candidate_ids {
2588 if let Some(note) = note_store.get_note(*id).await? {
2589 if note.deleted_at.is_some() {
2590 continue;
2591 }
2592 if let Some(want_kind) = note_kind {
2593 if note.kind != want_kind {
2594 continue;
2595 }
2596 }
2597 if !tags_any.is_empty() {
2602 let note_tags: Vec<String> = note
2603 .properties
2604 .as_ref()
2605 .and_then(|p| p.get("tags"))
2606 .and_then(serde_json::Value::as_array)
2607 .map(|arr| {
2608 arr.iter()
2609 .filter_map(serde_json::Value::as_str)
2610 .map(str::to_owned)
2611 .collect()
2612 })
2613 .unwrap_or_default();
2614 if !note_tags
2615 .iter()
2616 .any(|t| tags_any.iter().any(|w| t.eq_ignore_ascii_case(w)))
2617 {
2618 continue;
2619 }
2620 }
2621 if let Some(pf) = properties_filter {
2623 if !note_props_match(note.properties.as_ref(), pf) {
2624 continue;
2625 }
2626 }
2627 alive_notes.insert(*id, note);
2628 }
2629 }
2630
2631 if !include_superseded && !alive_notes.is_empty() {
2634 let graph = self.graph(token)?;
2635 let mut superseded: std::collections::HashSet<Uuid> = std::collections::HashSet::new();
2636 for ¬e_id in alive_notes.keys() {
2637 let inbound = graph
2638 .neighbors(
2639 note_id,
2640 NeighborQuery {
2641 direction: Direction::In,
2642 relations: Some(vec![EdgeRelation::Supersedes]),
2643 limit: Some(1),
2644 min_weight: None,
2645 },
2646 )
2647 .await?;
2648 if !inbound.is_empty() {
2649 superseded.insert(note_id);
2650 }
2651 }
2652 alive_notes.retain(|id, _| !superseded.contains(id));
2653 }
2654
2655 let mut hits: Vec<NoteSearchHit> = fused
2657 .into_iter()
2658 .filter_map(|hit| {
2659 let note = alive_notes.get(&hit.entity_id)?;
2660 let salience = note.salience.unwrap_or(0.5);
2661 let weight = 0.5 + 0.5 * salience;
2662 let weighted = DeterministicScore::from_f64(hit.score.to_f64() * weight);
2663 Some(NoteSearchHit {
2664 note_id: hit.entity_id,
2665 score: weighted,
2666 title: hit.title.or_else(|| note_title(note)),
2667 snippet: hit.snippet.or_else(|| note_snippet(note)),
2668 })
2669 })
2670 .collect();
2671
2672 hits.sort_by(|a, b| b.score.cmp(&a.score).then(a.note_id.cmp(&b.note_id)));
2673 hits.truncate(limit as usize);
2674 Ok(hits)
2675 }
2676
2677 pub async fn resolve_prefix(
2684 &self,
2685 token: &NamespaceToken,
2686 prefix: &str,
2687 ) -> RuntimeResult<Option<Uuid>> {
2688 self.resolve_prefix_inner(token, prefix, false).await
2689 }
2690
2691 pub async fn resolve_prefix_including_deleted(
2692 &self,
2693 token: &NamespaceToken,
2694 prefix: &str,
2695 ) -> RuntimeResult<Option<Uuid>> {
2696 self.resolve_prefix_inner(token, prefix, true).await
2697 }
2698
2699 async fn resolve_prefix_inner(
2700 &self,
2701 token: &NamespaceToken,
2702 prefix: &str,
2703 include_deleted: bool,
2704 ) -> RuntimeResult<Option<Uuid>> {
2705 use khive_storage::types::{SqlStatement, SqlValue};
2706
2707 let ns = token.namespace().as_str().to_owned();
2708 let pattern = format!("{}%", prefix);
2709
2710 let tables = [
2711 ("entities", true),
2712 ("notes", true),
2713 ("events", false),
2714 ("graph_edges", false),
2715 ];
2716
2717 let mut matches: Vec<String> = Vec::new();
2718 let mut reader = self.sql().reader().await.map_err(RuntimeError::Storage)?;
2719
2720 for (table, has_deleted_at) in tables {
2721 let deleted_filter = if has_deleted_at && !include_deleted {
2722 " AND deleted_at IS NULL"
2723 } else {
2724 ""
2725 };
2726 let sql = SqlStatement {
2727 sql: format!(
2728 "SELECT id FROM {table} WHERE id LIKE ?1 AND namespace = ?2{deleted_filter} LIMIT 2"
2729 ),
2730 params: vec![
2731 SqlValue::Text(pattern.clone()),
2732 SqlValue::Text(ns.clone()),
2733 ],
2734 label: Some("resolve_prefix".into()),
2735 };
2736 match reader.query_all(sql).await {
2737 Ok(rows) => {
2738 for row in rows {
2739 if let Some(col) = row.columns.first() {
2740 if let SqlValue::Text(s) = &col.value {
2741 matches.push(s.clone());
2742 }
2743 }
2744 }
2745 }
2746 Err(e) => {
2747 let msg = e.to_string();
2748 if msg.contains("no such table") {
2749 continue;
2750 }
2751 return Err(RuntimeError::Storage(e));
2752 }
2753 }
2754 if matches.len() > 1 {
2755 break;
2756 }
2757 }
2758
2759 match matches.len() {
2760 0 => Ok(None),
2761 1 => {
2762 let uuid = Uuid::from_str(&matches[0])
2763 .map_err(|e| RuntimeError::Internal(format!("stored UUID is invalid: {e}")))?;
2764 Ok(Some(uuid))
2765 }
2766 _ => {
2767 let uuids: Vec<uuid::Uuid> = matches
2768 .iter()
2769 .filter_map(|s| Uuid::from_str(s).ok())
2770 .collect();
2771 Err(RuntimeError::AmbiguousPrefix {
2772 prefix: prefix.to_string(),
2773 matches: uuids,
2774 })
2775 }
2776 }
2777 }
2778
2779 pub async fn resolve_by_id(
2790 &self,
2791 token: &NamespaceToken,
2792 id: Uuid,
2793 ) -> RuntimeResult<Option<Resolved>> {
2794 if let Some(entity) = self.entities(token)?.get_entity(id).await? {
2796 return Ok(Some(Resolved::Entity(entity)));
2797 }
2798
2799 if let Some(note) = self.notes(token)?.get_note(id).await? {
2801 return Ok(Some(Resolved::Note(note)));
2802 }
2803
2804 Ok(None)
2807 }
2808
2809 pub async fn resolve_by_id_including_deleted(
2815 &self,
2816 token: &NamespaceToken,
2817 id: Uuid,
2818 ) -> RuntimeResult<Option<Resolved>> {
2819 if let Some(entity) = self
2821 .entities(token)?
2822 .get_entity_including_deleted(id)
2823 .await?
2824 {
2825 return Ok(Some(Resolved::Entity(entity)));
2826 }
2827
2828 if let Some(note) = self.notes(token)?.get_note_including_deleted(id).await? {
2830 return Ok(Some(Resolved::Note(note)));
2831 }
2832
2833 Ok(None)
2836 }
2837
2838 pub async fn resolve(
2843 &self,
2844 token: &NamespaceToken,
2845 id: Uuid,
2846 ) -> RuntimeResult<Option<Resolved>> {
2847 match self.get_entity(token, id).await {
2849 Ok(entity) => return Ok(Some(Resolved::Entity(entity))),
2850 Err(RuntimeError::NotFound(_) | RuntimeError::NamespaceMismatch { .. }) => {}
2851 Err(e) => return Err(e),
2852 }
2853
2854 if let Some(note) = self.notes(token)?.get_note(id).await? {
2856 if Self::ensure_namespace_visible(¬e.namespace, token).is_ok() {
2857 return Ok(Some(Resolved::Note(note)));
2858 }
2859 }
2860
2861 if let Some(event) = self.events(token)?.get_event(id).await? {
2863 if Self::ensure_namespace_visible(&event.namespace, token).is_ok() {
2864 return Ok(Some(Resolved::Event(event)));
2865 }
2866 }
2867
2868 Ok(None)
2869 }
2870
2871 pub async fn resolve_primary(
2876 &self,
2877 token: &NamespaceToken,
2878 id: Uuid,
2879 ) -> RuntimeResult<Option<Resolved>> {
2880 let ns = token.namespace().as_str();
2881
2882 if let Some(entity) = self.entities(token)?.get_entity(id).await? {
2884 if Self::ensure_namespace(&entity.namespace, ns).is_ok() {
2885 return Ok(Some(Resolved::Entity(entity)));
2886 }
2887 }
2888
2889 if let Some(note) = self.notes(token)?.get_note(id).await? {
2891 if Self::ensure_namespace(¬e.namespace, ns).is_ok() {
2892 return Ok(Some(Resolved::Note(note)));
2893 }
2894 }
2895
2896 if let Some(event) = self.events(token)?.get_event(id).await? {
2898 if Self::ensure_namespace(&event.namespace, ns).is_ok() {
2899 return Ok(Some(Resolved::Event(event)));
2900 }
2901 }
2902
2903 Ok(None)
2904 }
2905
2906 pub async fn resolve_including_deleted(
2911 &self,
2912 token: &NamespaceToken,
2913 id: Uuid,
2914 ) -> RuntimeResult<Option<Resolved>> {
2915 let ns = token.namespace().as_str();
2916
2917 if let Some(entity) = self
2918 .entities(token)?
2919 .get_entity_including_deleted(id)
2920 .await?
2921 {
2922 if Self::ensure_namespace(&entity.namespace, ns).is_ok() {
2923 return Ok(Some(Resolved::Entity(entity)));
2924 }
2925 }
2926
2927 if let Some(note) = self.notes(token)?.get_note_including_deleted(id).await? {
2928 if Self::ensure_namespace(¬e.namespace, ns).is_ok() {
2929 return Ok(Some(Resolved::Note(note)));
2930 }
2931 }
2932
2933 if let Some(event) = self.events(token)?.get_event(id).await? {
2934 if Self::ensure_namespace(&event.namespace, ns).is_ok() {
2935 return Ok(Some(Resolved::Event(event)));
2936 }
2937 }
2938
2939 Ok(None)
2940 }
2941
2942 pub async fn delete_note(
2956 &self,
2957 token: &NamespaceToken,
2958 id: Uuid,
2959 hard: bool,
2960 ) -> RuntimeResult<bool> {
2961 let note_store = self.notes(token)?;
2962 let note = if hard {
2963 match note_store.get_note_including_deleted(id).await? {
2964 Some(n) => n,
2965 None => return Ok(false),
2966 }
2967 } else {
2968 match note_store.get_note(id).await? {
2969 Some(n) => n,
2970 None => return Ok(false),
2971 }
2972 };
2973 let mode = if hard {
2974 DeleteMode::Hard
2975 } else {
2976 DeleteMode::Soft
2977 };
2978
2979 let record_tok = NamespaceToken::for_namespace(
2981 khive_types::Namespace::parse(¬e.namespace)
2982 .map_err(|e| RuntimeError::Internal(format!("note namespace invalid: {e}")))?,
2983 );
2984 let record_ns = note.namespace.clone();
2985
2986 if hard {
2990 let graph = self.graph(&record_tok)?;
2991 graph.purge_incident_edges(id).await?;
2992 self.text_for_notes(&record_tok)?
2993 .delete_document(&record_ns, id)
2994 .await?;
2995 for model_name in self.registered_embedding_model_names() {
2999 self.vectors_for_model(&record_tok, &model_name)?
3000 .delete(id)
3001 .await?;
3002 }
3003 }
3004
3005 let deleted = note_store.delete_note(id, mode).await?;
3006 if !hard && deleted {
3007 self.text_for_notes(&record_tok)?
3008 .delete_document(&record_ns, id)
3009 .await?;
3010 for model_name in self.registered_embedding_model_names() {
3011 self.vectors_for_model(&record_tok, &model_name)?
3012 .delete(id)
3013 .await?;
3014 }
3015 }
3016 if deleted {
3017 let event_store = self.events(token)?;
3018 let event = khive_storage::event::Event::new(
3019 record_ns.clone(),
3020 "delete",
3021 EventKind::NoteDeleted,
3022 SubstrateKind::Note,
3023 "",
3024 )
3025 .with_target(id)
3026 .with_payload(serde_json::json!({"id": id, "namespace": record_ns, "hard": hard}));
3027 event_store.append_event(event).await.map_err(|e| {
3028 RuntimeError::Internal(format!("delete_note: event store write failed: {e}"))
3029 })?;
3030 }
3031 Ok(deleted)
3032 }
3033}
3034
3035#[derive(Clone, Debug, Serialize)]
3037pub struct QueryResult {
3038 pub rows: Vec<SqlRow>,
3039 #[serde(skip_serializing_if = "Vec::is_empty")]
3040 pub warnings: Vec<String>,
3041}
3042
3043impl KhiveRuntime {
3044 pub async fn query(&self, token: &NamespaceToken, query: &str) -> RuntimeResult<Vec<SqlRow>> {
3052 Ok(self
3053 .query_with_metadata(token, query, khive_query::CompileOptions::default())
3054 .await?
3055 .rows)
3056 }
3057
3058 pub async fn query_with_metadata(
3060 &self,
3061 token: &NamespaceToken,
3062 query: &str,
3063 mut opts: khive_query::CompileOptions,
3064 ) -> RuntimeResult<QueryResult> {
3065 use khive_query::QueryValue;
3066 use khive_storage::types::SqlValue;
3067
3068 let ast = khive_query::parse_auto(query)?;
3069 opts.scopes = token
3070 .visible_namespaces()
3071 .iter()
3072 .map(|ns| ns.as_str().to_string())
3073 .collect();
3074 let compiled = khive_query::compile(&ast, &opts)?;
3075 let warnings = compiled.warnings;
3076
3077 let params: Vec<SqlValue> = compiled
3080 .params
3081 .into_iter()
3082 .map(|qv| match qv {
3083 QueryValue::Null => SqlValue::Null,
3084 QueryValue::Integer(n) => SqlValue::Integer(n),
3085 QueryValue::Float(f) => SqlValue::Float(f),
3086 QueryValue::Text(s) => SqlValue::Text(s),
3087 QueryValue::Blob(b) => SqlValue::Blob(b),
3088 })
3089 .collect();
3090
3091 let mut reader = self.sql().reader().await?;
3092 let stmt = SqlStatement {
3093 sql: compiled.sql,
3094 params,
3095 label: None,
3096 };
3097 let rows = reader.query_all(stmt).await?;
3098 Ok(QueryResult { rows, warnings })
3099 }
3100
3101 pub async fn delete_entity(
3111 &self,
3112 token: &NamespaceToken,
3113 id: Uuid,
3114 hard: bool,
3115 ) -> RuntimeResult<bool> {
3116 let entity = if hard {
3117 match self
3118 .entities(token)?
3119 .get_entity_including_deleted(id)
3120 .await?
3121 {
3122 Some(e) => e,
3123 None => return Ok(false),
3124 }
3125 } else {
3126 match self.entities(token)?.get_entity(id).await? {
3127 Some(e) => e,
3128 None => return Ok(false),
3129 }
3130 };
3131 let mode = if hard {
3132 DeleteMode::Hard
3133 } else {
3134 DeleteMode::Soft
3135 };
3136
3137 let record_tok = NamespaceToken::for_namespace(
3139 khive_types::Namespace::parse(&entity.namespace)
3140 .map_err(|e| RuntimeError::Internal(format!("entity namespace invalid: {e}")))?,
3141 );
3142
3143 if hard {
3147 let graph = self.graph(&record_tok)?;
3148 graph.purge_incident_edges(id).await?;
3149 self.remove_from_indexes(&record_tok, id).await?;
3150 }
3151
3152 let deleted = self.entities(token)?.delete_entity(id, mode).await?;
3153 if !hard && deleted {
3154 self.remove_from_indexes(&record_tok, id).await?;
3155 }
3156 if deleted {
3157 let event_store = self.events(token)?;
3158 let ns = entity.namespace.clone();
3159 let event = khive_storage::event::Event::new(
3160 ns.clone(),
3161 "delete",
3162 EventKind::EntityDeleted,
3163 SubstrateKind::Entity,
3164 "",
3165 )
3166 .with_target(id)
3167 .with_payload(serde_json::json!({"id": id, "namespace": ns, "hard": hard}));
3168 event_store.append_event(event).await.map_err(|e| {
3169 RuntimeError::Internal(format!("delete_entity: event store write failed: {e}"))
3170 })?;
3171 }
3172 Ok(deleted)
3173 }
3174
3175 pub async fn count_entities(
3177 &self,
3178 token: &NamespaceToken,
3179 kind: Option<&str>,
3180 ) -> RuntimeResult<u64> {
3181 let filter = EntityFilter {
3182 kinds: match kind {
3183 Some(k) => vec![k.to_string()],
3184 None => vec![],
3185 },
3186 ..Default::default()
3187 };
3188 Ok(self
3189 .entities(token)?
3190 .count_entities(token.namespace().as_str(), filter)
3191 .await?)
3192 }
3193
3194 pub async fn get_edge(
3201 &self,
3202 _token: &NamespaceToken,
3203 edge_id: Uuid,
3204 ) -> RuntimeResult<Option<Edge>> {
3205 let mut reader = self.sql().reader().await?;
3206 let record_ns = reader
3207 .query_scalar(SqlStatement {
3208 sql: "SELECT namespace FROM graph_edges \
3209 WHERE id = ?1 AND deleted_at IS NULL LIMIT 1"
3210 .into(),
3211 params: vec![SqlValue::Text(edge_id.to_string())],
3212 label: Some("get_edge_namespace".into()),
3213 })
3214 .await?;
3215
3216 let Some(SqlValue::Text(record_ns)) = record_ns else {
3217 return Ok(None);
3218 };
3219 let record_tok = NamespaceToken::for_namespace(
3222 khive_types::Namespace::parse(&record_ns)
3223 .map_err(|e| RuntimeError::Internal(format!("edge namespace invalid: {e}")))?,
3224 );
3225 Ok(self
3226 .graph(&record_tok)?
3227 .get_edge(LinkId::from(edge_id))
3228 .await?)
3229 }
3230
3231 pub async fn get_edge_visible(
3236 &self,
3237 token: &NamespaceToken,
3238 edge_id: Uuid,
3239 ) -> RuntimeResult<Option<Edge>> {
3240 self.get_edge(token, edge_id).await
3241 }
3242
3243 pub async fn get_edge_including_deleted(
3249 &self,
3250 _token: &NamespaceToken,
3251 edge_id: Uuid,
3252 ) -> RuntimeResult<Option<Edge>> {
3253 let mut reader = self.sql().reader().await?;
3254 let record_ns = reader
3255 .query_scalar(SqlStatement {
3256 sql: "SELECT namespace FROM graph_edges WHERE id = ?1 LIMIT 1".into(),
3257 params: vec![SqlValue::Text(edge_id.to_string())],
3258 label: Some("get_edge_including_deleted_namespace".into()),
3259 })
3260 .await?;
3261
3262 let Some(SqlValue::Text(record_ns)) = record_ns else {
3263 return Ok(None);
3264 };
3265 let record_tok = NamespaceToken::for_namespace(
3267 khive_types::Namespace::parse(&record_ns)
3268 .map_err(|e| RuntimeError::Internal(format!("edge namespace invalid: {e}")))?,
3269 );
3270 Ok(self
3271 .graph(&record_tok)?
3272 .get_edge_including_deleted(LinkId::from(edge_id))
3273 .await?)
3274 }
3275
3276 pub async fn list_edges(
3278 &self,
3279 token: &NamespaceToken,
3280 filter: crate::curation::EdgeListFilter,
3281 limit: u32,
3282 ) -> RuntimeResult<Vec<Edge>> {
3283 let limit = limit.clamp(1, 1000);
3284 let mut results = Vec::new();
3285 for ns in token.visible_namespaces() {
3286 let temp = NamespaceToken::for_namespace(ns.clone());
3287 let page = self
3288 .graph(&temp)?
3289 .query_edges(
3290 filter.clone().into(),
3291 vec![SortOrder {
3292 field: EdgeSortField::CreatedAt,
3293 direction: khive_storage::types::SortDirection::Asc,
3294 }],
3295 PageRequest { offset: 0, limit },
3296 )
3297 .await?;
3298 results.extend(page.items);
3299 }
3300 results.sort_by_key(|e| Uuid::from(e.id));
3301 results.dedup_by_key(|e| Uuid::from(e.id));
3302 results.truncate(limit as usize);
3303 Ok(results)
3304 }
3305
3306 pub async fn update_edge(
3319 &self,
3320 token: &NamespaceToken,
3321 edge_id: Uuid,
3322 patch: crate::curation::EdgePatch,
3323 ) -> RuntimeResult<Edge> {
3324 let graph_for_fetch = self.graph(token)?;
3327 let mut edge = graph_for_fetch
3328 .get_edge(LinkId::from(edge_id))
3329 .await?
3330 .ok_or_else(|| crate::RuntimeError::NotFound(format!("edge {edge_id}")))?;
3331
3332 let record_ns: String = edge.namespace.clone();
3337 let record_tok = NamespaceToken::for_namespace(
3338 khive_types::Namespace::parse(&record_ns)
3339 .map_err(|e| RuntimeError::Internal(format!("edge namespace invalid: {e}")))?,
3340 );
3341 let graph = self.graph(&record_tok)?;
3342
3343 let mut changed_fields: Vec<&'static str> = Vec::new();
3344 if let Some(r) = patch.relation {
3345 self.validate_edge_relation_endpoints(&record_tok, edge.source_id, edge.target_id, r)
3349 .await?;
3350 edge.relation = r;
3351 changed_fields.push("relation");
3352 }
3353 if let Some(w) = patch.weight {
3354 if !w.is_finite() || !(0.0..=1.0).contains(&w) {
3357 return Err(RuntimeError::InvalidInput(format!(
3358 "edge weight must be a finite value in [0.0, 1.0]; got {w}"
3359 )));
3360 }
3361 edge.weight = w;
3362 changed_fields.push("weight");
3363 }
3364 if let Some(props) = patch.properties {
3365 edge.metadata = Some(props);
3366 }
3367
3368 let (canon_src, canon_tgt) =
3378 canonical_edge_endpoints(edge.relation, edge.source_id, edge.target_id);
3379
3380 if edge.relation.is_symmetric() {
3381 let ns = record_ns.clone();
3385 let edge_id_str = edge_id.to_string();
3386 let relation_str = edge.relation.to_string();
3387 let canon_src_str = canon_src.to_string();
3388 let canon_tgt_str = canon_tgt.to_string();
3389 let weight = edge.weight;
3390 let metadata = edge
3391 .metadata
3392 .as_ref()
3393 .map(|v| serde_json::to_string(v).unwrap_or_default());
3394 let target_backend = edge.target_backend.clone();
3395
3396 let pool = self.backend().pool_arc();
3397
3398 let surviving_id: Option<String> = tokio::task::spawn_blocking(move || {
3402 let guard = pool.writer()?;
3403 guard.transaction(|conn| {
3404 let now_ts = chrono::Utc::now().timestamp();
3405
3406 let conflict_id: Option<String> = conn
3410 .query_row(
3411 "SELECT id FROM graph_edges \
3412 WHERE namespace = ?1 AND source_id = ?2 AND target_id = ?3 \
3413 AND relation = ?4 AND id != ?5",
3414 rusqlite::params![
3415 &ns,
3416 &canon_src_str,
3417 &canon_tgt_str,
3418 &relation_str,
3419 &edge_id_str,
3420 ],
3421 |row| row.get(0),
3422 )
3423 .optional()
3424 .map_err(SqliteError::Rusqlite)?;
3425
3426 if let Some(existing_id) = conflict_id {
3427 conn.execute(
3432 "DELETE FROM graph_edges WHERE namespace = ?1 AND id = ?2",
3433 rusqlite::params![&ns, &edge_id_str],
3434 )
3435 .map_err(SqliteError::Rusqlite)?;
3436 let affected = conn
3437 .execute(
3438 "UPDATE graph_edges SET \
3439 weight = ?1, updated_at = ?2, deleted_at = NULL, \
3440 target_backend = ?3, metadata = ?4 \
3441 WHERE namespace = ?5 AND id = ?6",
3442 rusqlite::params![
3443 weight,
3444 now_ts,
3445 target_backend,
3446 metadata,
3447 &ns,
3448 &existing_id,
3449 ],
3450 )
3451 .map_err(SqliteError::Rusqlite)?;
3452 if affected == 0 {
3453 return Err(SqliteError::InvalidData(format!(
3454 "update_edge: surviving canonical row {existing_id} vanished during update"
3455 )));
3456 }
3457 Ok(Some(existing_id))
3458 } else {
3459 let affected = conn
3462 .execute(
3463 "UPDATE graph_edges SET \
3464 source_id = ?1, target_id = ?2, relation = ?3, \
3465 weight = ?4, updated_at = ?5, metadata = ?6 \
3466 WHERE namespace = ?7 AND id = ?8",
3467 rusqlite::params![
3468 &canon_src_str,
3469 &canon_tgt_str,
3470 &relation_str,
3471 weight,
3472 now_ts,
3473 metadata,
3474 &ns,
3475 &edge_id_str,
3476 ],
3477 )
3478 .map_err(SqliteError::Rusqlite)?;
3479 if affected == 0 {
3480 return Err(SqliteError::InvalidData(format!(
3483 "update_edge: zero rows affected updating edge {edge_id_str} \
3484 in namespace {ns} — row vanished between fetch and update"
3485 )));
3486 }
3487 Ok(None)
3488 }
3489 })
3490 })
3491 .await
3492 .map_err(|e| RuntimeError::Internal(format!("update_edge: spawn_blocking join: {e}")))?
3493 .map_err(RuntimeError::Sqlite)?;
3494
3495 if let Some(sid) = surviving_id {
3496 let surviving_uuid = Uuid::parse_str(&sid).map_err(|e| {
3500 RuntimeError::Internal(format!("update_edge: surviving id parse failed: {e}"))
3501 })?;
3502 edge = self
3503 .get_edge(&record_tok, surviving_uuid)
3504 .await?
3505 .ok_or_else(|| {
3506 RuntimeError::Internal(format!(
3507 "update_edge: surviving canonical row {surviving_uuid} vanished after update"
3508 ))
3509 })?;
3510 } else {
3511 edge.source_id = canon_src;
3513 edge.target_id = canon_tgt;
3514 }
3515 } else {
3516 graph.upsert_edge(edge.clone()).await?;
3520 }
3521
3522 let event_store = self.events(&record_tok)?;
3524 let event = khive_storage::event::Event::new(
3525 record_ns.clone(),
3526 "update",
3527 EventKind::EdgeUpdated,
3528 SubstrateKind::Entity,
3529 "",
3530 )
3531 .with_target(edge_id)
3532 .with_payload(
3533 serde_json::json!({"id": edge_id, "namespace": record_ns, "changed_fields": changed_fields}),
3534 );
3535 event_store.append_event(event).await.map_err(|e| {
3536 RuntimeError::Internal(format!("update_edge: event store write failed: {e}"))
3537 })?;
3538
3539 Ok(edge)
3540 }
3541
3542 pub async fn delete_edge(
3553 &self,
3554 token: &NamespaceToken,
3555 edge_id: Uuid,
3556 hard: bool,
3557 ) -> RuntimeResult<bool> {
3558 let mode = if hard {
3559 DeleteMode::Hard
3560 } else {
3561 DeleteMode::Soft
3562 };
3563
3564 let edge = if hard {
3570 self.get_edge_including_deleted(token, edge_id).await?
3571 } else {
3572 self.get_edge(token, edge_id).await?
3573 };
3574 let Some(edge) = edge else {
3575 return Ok(false);
3576 };
3577
3578 let record_ns: String = edge.namespace.clone();
3580 let record_tok = NamespaceToken::for_namespace(
3581 khive_types::Namespace::parse(&record_ns)
3582 .map_err(|e| RuntimeError::Internal(format!("edge namespace invalid: {e}")))?,
3583 );
3584 let graph = self.graph(&record_tok)?;
3585
3586 if hard {
3591 graph.purge_incident_edges(edge_id).await?;
3592 }
3593
3594 let deleted = graph.delete_edge(LinkId::from(edge_id), mode).await?;
3595 if deleted {
3596 let event_store = self.events(&record_tok)?;
3598 let event = khive_storage::event::Event::new(
3599 record_ns.clone(),
3600 "delete",
3601 EventKind::EdgeDeleted,
3602 SubstrateKind::Entity,
3603 "",
3604 )
3605 .with_target(edge_id)
3606 .with_payload(serde_json::json!({"id": edge_id, "namespace": record_ns, "hard": hard}));
3607 event_store.append_event(event).await.map_err(|e| {
3608 RuntimeError::Internal(format!("delete_edge: event store write failed: {e}"))
3609 })?;
3610 }
3611 Ok(deleted)
3612 }
3613
3614 pub async fn count_edges(
3616 &self,
3617 token: &NamespaceToken,
3618 filter: crate::curation::EdgeListFilter,
3619 ) -> RuntimeResult<u64> {
3620 Ok(self.graph(token)?.count_edges(filter.into()).await?)
3621 }
3622
3623 pub async fn build_edge(&self, token: &NamespaceToken, spec: &LinkSpec) -> RuntimeResult<Edge> {
3634 let ns_str = match &spec.namespace {
3635 Some(s) => {
3636 let spec_ns = crate::Namespace::parse(s)
3637 .map_err(|e| RuntimeError::InvalidInput(format!("invalid namespace: {e}")))?;
3638 if &spec_ns != token.namespace() {
3639 return Err(RuntimeError::InvalidInput(
3640 "LinkSpec namespace does not match token namespace".into(),
3641 ));
3642 }
3643 s.as_str()
3644 }
3645 None => token.namespace().as_str(),
3646 };
3647 self.validate_edge_relation_endpoints(token, spec.source_id, spec.target_id, spec.relation)
3648 .await?;
3649 let (source_id, target_id) =
3650 canonical_edge_endpoints(spec.relation, spec.source_id, spec.target_id);
3651 let metadata = if spec.relation == EdgeRelation::DependsOn {
3652 match (
3653 self.resolve(token, source_id).await?,
3654 self.resolve(token, target_id).await?,
3655 ) {
3656 (Some(Resolved::Entity(src_e)), Some(Resolved::Entity(tgt_e))) => {
3657 merge_dependency_kind(&src_e.kind, &tgt_e.kind, spec.metadata.clone())
3658 }
3659 _ => spec.metadata.clone(),
3660 }
3661 } else {
3662 spec.metadata.clone()
3663 };
3664 validate_edge_metadata(spec.relation, metadata.as_ref())?;
3665 let now = chrono::Utc::now();
3666 Ok(Edge {
3667 id: LinkId::from(Uuid::new_v4()),
3668 namespace: ns_str.to_string(),
3669 source_id,
3670 target_id,
3671 relation: spec.relation,
3672 weight: spec.weight,
3673 created_at: now,
3674 updated_at: now,
3675 deleted_at: None,
3676 metadata,
3677 target_backend: None,
3678 })
3679 }
3680
3681 pub async fn link_many(
3698 &self,
3699 token: &NamespaceToken,
3700 specs: Vec<LinkSpec>,
3701 ) -> RuntimeResult<Vec<Edge>> {
3702 if specs.is_empty() {
3703 return Ok(vec![]);
3704 }
3705 let mut edges = Vec::with_capacity(specs.len());
3706 for spec in &specs {
3707 edges.push(self.build_edge(token, spec).await?);
3708 }
3709 self.graph(token)?.upsert_edges(edges.clone()).await?;
3710
3711 let mut persisted = Vec::with_capacity(edges.len());
3714 for edge in &edges {
3715 let row = self
3716 .list_edges(
3717 token,
3718 crate::curation::EdgeListFilter {
3719 source_id: Some(edge.source_id),
3720 target_id: Some(edge.target_id),
3721 relations: vec![edge.relation],
3722 ..Default::default()
3723 },
3724 1,
3725 )
3726 .await?
3727 .into_iter()
3728 .next()
3729 .ok_or_else(|| {
3730 crate::RuntimeError::Internal(format!(
3731 "upsert_edges succeeded but natural-key lookup for ({}, {}, {}) returned nothing",
3732 edge.source_id, edge.target_id, edge.relation.as_str()
3733 ))
3734 })?;
3735 persisted.push(row);
3736 }
3737 Ok(persisted)
3738 }
3739
3740 pub async fn create_many(
3756 &self,
3757 token: &NamespaceToken,
3758 specs: Vec<EntityCreateSpec>,
3759 ) -> RuntimeResult<Vec<Entity>> {
3760 if specs.is_empty() {
3761 return Ok(vec![]);
3762 }
3763 let ns = token.namespace().as_str();
3764
3765 let mut entities = Vec::with_capacity(specs.len());
3769 for spec in &specs {
3770 self.validate_entity_kind(&spec.kind)?;
3771 let validated_type =
3776 self.validate_entity_type_for_kind(&spec.kind, spec.entity_type.as_deref())?;
3777 if spec.name.trim().is_empty() {
3778 return Err(RuntimeError::InvalidInput("name must not be empty".into()));
3779 }
3780 crate::secret_gate::check(&spec.name)?;
3781 if let Some(d) = &spec.description {
3782 crate::secret_gate::check(d)?;
3783 }
3784 if let Some(ref p) = spec.properties {
3785 crate::secret_gate::check_json(p)?;
3786 }
3787 crate::secret_gate::check_tags(&spec.tags)?;
3788
3789 let mut entity =
3790 Entity::new(ns, &spec.kind, &spec.name).with_entity_type(validated_type.as_deref());
3791 if let Some(d) = &spec.description {
3792 entity = entity.with_description(d);
3793 }
3794 if let Some(p) = spec.properties.clone() {
3795 entity = entity.with_properties(p);
3796 }
3797 if !spec.tags.is_empty() {
3798 entity = entity.with_tags(spec.tags.clone());
3799 }
3800 entities.push(entity);
3801 }
3802
3803 let entity_summary = self
3814 .entities(token)?
3815 .upsert_entities(entities.clone())
3816 .await?;
3817
3818 if entity_summary.failed > 0 {
3819 if let Ok(store) = self.entities(token) {
3821 for entity in &entities {
3822 if let Err(ce) = store.delete_entity(entity.id, DeleteMode::Hard).await {
3823 tracing::error!(
3824 error = %ce,
3825 id = %entity.id,
3826 "create_many: failed to roll back entity row after partial entity write"
3827 );
3828 }
3829 }
3830 }
3831 return Err(RuntimeError::Internal(format!(
3832 "create_many: {}/{} entity rows failed to write (first error: {}); \
3833 all rows rolled back",
3834 entity_summary.failed, entity_summary.attempted, entity_summary.first_error
3835 )));
3836 }
3837
3838 let docs: Vec<_> = entities.iter().map(entity_fts_document).collect();
3851
3852 #[cfg(any(test, feature = "fault-injection"))]
3853 let fts_many_inject = {
3854 let mut g = FTS_FAIL_MANY_NS.lock().unwrap();
3855 if g.as_deref() == Some(ns) {
3856 *g = None;
3857 true
3858 } else {
3859 false
3860 }
3861 };
3862 #[cfg(not(any(test, feature = "fault-injection")))]
3863 let fts_many_inject = false;
3864
3865 #[cfg(any(test, feature = "fault-injection"))]
3868 let fts_many_inject_partial = {
3869 let mut g = FTS_FAIL_MANY_PARTIAL_NS.lock().unwrap();
3870 if g.as_deref() == Some(ns) {
3871 *g = None;
3872 true
3873 } else {
3874 false
3875 }
3876 };
3877 #[cfg(not(any(test, feature = "fault-injection")))]
3878 let fts_many_inject_partial = false;
3879
3880 let fts_summary_result: RuntimeResult<BatchWriteSummary> = if fts_many_inject {
3881 Err(RuntimeError::Internal(
3882 "injected FTS failure for create_many".to_string(),
3883 ))
3884 } else if fts_many_inject_partial {
3885 Ok(BatchWriteSummary {
3886 attempted: docs.len() as u64,
3887 affected: docs.len().saturating_sub(1) as u64,
3888 failed: 1,
3889 first_error: "injected partial FTS failure for create_many".to_string(),
3890 })
3891 } else {
3892 match self.text(token) {
3893 Ok(fts) => fts.upsert_documents(docs).await.map_err(RuntimeError::from),
3894 Err(e) => Err(e),
3895 }
3896 };
3897
3898 let fts_err: Option<RuntimeError> = match fts_summary_result {
3899 Err(e) => Some(e),
3900 Ok(summary) if summary.failed > 0 => Some(RuntimeError::Internal(format!(
3901 "create_many: {}/{} FTS rows failed to index (first error: {}); \
3902 all rows rolled back",
3903 summary.failed, summary.attempted, summary.first_error
3904 ))),
3905 Ok(_) => None,
3906 };
3907
3908 if let Some(e) = fts_err {
3909 if let Ok(fts) = self.text(token) {
3911 for entity in &entities {
3912 if let Err(ce) = fts.delete_document(ns, entity.id).await {
3913 tracing::error!(
3914 error = %ce,
3915 id = %entity.id,
3916 "create_many: failed to remove FTS doc during rollback"
3917 );
3918 }
3919 }
3920 }
3921 if let Ok(store) = self.entities(token) {
3922 for entity in &entities {
3923 if let Err(ce) = store.delete_entity(entity.id, DeleteMode::Hard).await {
3924 tracing::error!(
3925 error = %ce,
3926 id = %entity.id,
3927 "create_many: failed to roll back entity row after FTS failure"
3928 );
3929 }
3930 }
3931 }
3932 return Err(e);
3933 }
3934
3935 Ok(entities)
3937 }
3938}
3939
3940#[derive(Clone, Debug)]
3943pub struct LinkSpec {
3944 pub namespace: Option<String>,
3945 pub source_id: Uuid,
3946 pub target_id: Uuid,
3947 pub relation: EdgeRelation,
3948 pub weight: f64,
3949 pub metadata: Option<serde_json::Value>,
3950}
3951
3952#[derive(Clone, Debug)]
3960pub struct EntityCreateSpec {
3961 pub kind: String,
3962 pub entity_type: Option<String>,
3963 pub name: String,
3964 pub description: Option<String>,
3965 pub properties: Option<serde_json::Value>,
3966 pub tags: Vec<String>,
3967}
3968
3969#[cfg(test)]
3975mod tests {
3976 use super::*;
3977 use crate::curation::EdgeListFilter;
3978 use crate::embedder_registry::EmbedderProvider;
3979 use crate::error::RuntimeError;
3980 use crate::runtime::{KhiveRuntime, NamespaceToken};
3981 use crate::Namespace;
3982 use async_trait::async_trait;
3983 use khive_storage::types::PathNode;
3984 use lattice_embed::{EmbedError, EmbeddingModel, EmbeddingService};
3985 use std::sync::atomic::{AtomicUsize, Ordering};
3986 use std::sync::Arc;
3987
3988 fn rt() -> KhiveRuntime {
3989 KhiveRuntime::memory().unwrap()
3990 }
3991
3992 struct ConstVecService {
4000 dims: usize,
4001 }
4002
4003 #[async_trait]
4004 impl EmbeddingService for ConstVecService {
4005 async fn embed(
4006 &self,
4007 texts: &[String],
4008 _model: EmbeddingModel,
4009 ) -> std::result::Result<Vec<Vec<f32>>, EmbedError> {
4010 Ok(texts.iter().map(|_| vec![1.0_f32; self.dims]).collect())
4011 }
4012
4013 fn supports_model(&self, _model: EmbeddingModel) -> bool {
4014 true
4015 }
4016
4017 fn name(&self) -> &'static str {
4018 "const-vec"
4019 }
4020 }
4021
4022 struct ConstVecProvider {
4023 provider_name: String,
4024 dims: usize,
4025 pub build_count: Arc<AtomicUsize>,
4026 }
4027
4028 impl ConstVecProvider {
4029 fn new(name: &str, dims: usize) -> (Self, Arc<AtomicUsize>) {
4030 let counter = Arc::new(AtomicUsize::new(0));
4031 let provider = Self {
4032 provider_name: name.to_owned(),
4033 dims,
4034 build_count: Arc::clone(&counter),
4035 };
4036 (provider, counter)
4037 }
4038 }
4039
4040 #[async_trait]
4041 impl EmbedderProvider for ConstVecProvider {
4042 fn name(&self) -> &str {
4043 &self.provider_name
4044 }
4045
4046 fn dimensions(&self) -> usize {
4047 self.dims
4048 }
4049
4050 async fn build(&self) -> crate::error::RuntimeResult<Arc<dyn EmbeddingService>> {
4051 self.build_count.fetch_add(1, Ordering::SeqCst);
4052 Ok(Arc::new(ConstVecService { dims: self.dims }))
4053 }
4054 }
4055
4056 #[tokio::test]
4065 async fn custom_embedder_only_runtime_fanout_stores_vector() {
4066 const MODEL_NAME: &str = "test-custom-encoder";
4067 const DIMS: usize = 8;
4068
4069 let rt = KhiveRuntime::memory().unwrap();
4071
4072 let (provider, _counter) = ConstVecProvider::new(MODEL_NAME, DIMS);
4074 rt.register_embedder(provider);
4075
4076 assert!(rt.config().embedding_model.is_none());
4078 assert_eq!(rt.registered_embedding_model_names(), vec![MODEL_NAME]);
4079
4080 let tok = NamespaceToken::local();
4081
4082 let note = rt
4084 .create_note(
4085 &tok,
4086 "memory",
4087 None,
4088 "custom embedder integration test content",
4089 Some(0.7),
4090 None,
4091 vec![],
4092 )
4093 .await
4094 .expect("create_note with custom-only embedder must succeed");
4095
4096 use khive_storage::types::VectorSearchRequest;
4098 let query_vec = vec![1.0_f32; DIMS];
4099 let hits = rt
4100 .vectors_for_model(&tok, MODEL_NAME)
4101 .expect("vector store for custom model must be accessible")
4102 .search(VectorSearchRequest {
4103 query_vectors: vec![query_vec],
4104 top_k: 5,
4105 namespace: Some(tok.namespace().as_str().to_string()),
4106 kind: Some(khive_types::SubstrateKind::Note),
4107 embedding_model: Some(MODEL_NAME.to_string()),
4108 filter: None,
4109 backend_hints: None,
4110 })
4111 .await
4112 .expect("vector search succeeds");
4113
4114 assert!(
4115 hits.iter().any(|h| h.subject_id == note.id),
4116 "custom embedder must have written a vector for note {}: hits={hits:?}",
4117 note.id
4118 );
4119 }
4120
4121 #[tokio::test]
4129 async fn embed_with_model_accepts_custom_provider_name() {
4130 const MODEL_NAME: &str = "my-custom-enc";
4131 const DIMS: usize = 4;
4132
4133 let rt = KhiveRuntime::memory().unwrap();
4134 let (provider, _counter) = ConstVecProvider::new(MODEL_NAME, DIMS);
4135 rt.register_embedder(provider);
4136
4137 let result = rt
4138 .embed_with_model(MODEL_NAME, "hello world")
4139 .await
4140 .expect("embed_with_model must accept custom provider names");
4141
4142 assert_eq!(
4143 result.len(),
4144 DIMS,
4145 "embedding dimension must match provider"
4146 );
4147 assert!(
4148 result.iter().all(|&v| (v - 1.0_f32).abs() < 1e-6),
4149 "ConstVecService must produce all-ones vector; got: {result:?}"
4150 );
4151 }
4152
4153 #[tokio::test]
4156 async fn embed_with_model_rejects_unregistered_name() {
4157 let rt = KhiveRuntime::memory().unwrap();
4158 let result = rt.embed_with_model("nonexistent-model", "hello").await;
4159 assert!(
4160 matches!(result.unwrap_err(), RuntimeError::UnknownModel(ref n) if n == "nonexistent-model"),
4161 "unregistered model name must return UnknownModel"
4162 );
4163 }
4164
4165 #[tokio::test]
4166 async fn update_edge_changes_weight() {
4167 let rt = rt();
4168 let tok = NamespaceToken::local();
4169 let a = rt
4170 .create_entity(&tok, "concept", None, "A", None, None, vec![])
4171 .await
4172 .unwrap();
4173 let b = rt
4174 .create_entity(&tok, "concept", None, "B", None, None, vec![])
4175 .await
4176 .unwrap();
4177 let edge = rt
4178 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
4179 .await
4180 .unwrap();
4181 let edge_id: Uuid = edge.id.into();
4182
4183 let updated = rt
4184 .update_edge(
4185 &tok,
4186 edge_id,
4187 crate::curation::EdgePatch {
4188 weight: Some(0.5),
4189 ..Default::default()
4190 },
4191 )
4192 .await
4193 .unwrap();
4194 assert!((updated.weight - 0.5).abs() < 0.001);
4195 }
4196
4197 #[tokio::test]
4198 async fn update_edge_changes_relation() {
4199 let rt = rt();
4200 let tok = NamespaceToken::local();
4201 let a = rt
4202 .create_entity(&tok, "concept", None, "A", None, None, vec![])
4203 .await
4204 .unwrap();
4205 let b = rt
4206 .create_entity(&tok, "concept", None, "B", None, None, vec![])
4207 .await
4208 .unwrap();
4209 let edge = rt
4210 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
4211 .await
4212 .unwrap();
4213 let edge_id: Uuid = edge.id.into();
4214
4215 let updated = rt
4216 .update_edge(
4217 &tok,
4218 edge_id,
4219 crate::curation::EdgePatch {
4220 relation: Some(EdgeRelation::VariantOf),
4221 ..Default::default()
4222 },
4223 )
4224 .await
4225 .unwrap();
4226 assert_eq!(updated.relation, EdgeRelation::VariantOf);
4227 }
4228
4229 #[tokio::test]
4234 async fn update_edge_annotates_note_to_entity_set_supersedes_returns_invalid_input() {
4235 let rt = rt();
4236 let tok = NamespaceToken::local();
4237 let note = rt
4238 .create_note(&tok, "observation", None, "a note", Some(0.5), None, vec![])
4239 .await
4240 .unwrap();
4241 let entity = rt
4242 .create_entity(&tok, "concept", None, "E", None, None, vec![])
4243 .await
4244 .unwrap();
4245 let edge = rt
4247 .link(&tok, note.id, entity.id, EdgeRelation::Annotates, 1.0, None)
4248 .await
4249 .unwrap();
4250 let edge_id: Uuid = edge.id.into();
4251
4252 let result = rt
4254 .update_edge(
4255 &tok,
4256 edge_id,
4257 crate::curation::EdgePatch {
4258 relation: Some(EdgeRelation::Supersedes),
4259 ..Default::default()
4260 },
4261 )
4262 .await;
4263 assert!(
4264 matches!(result, Err(RuntimeError::InvalidInput(_))),
4265 "update to Supersedes on note→entity edge must return InvalidInput, got {result:?}"
4266 );
4267
4268 let fetched = rt.get_edge(&tok, edge_id).await.unwrap().unwrap();
4270 assert_eq!(
4271 fetched.relation,
4272 EdgeRelation::Annotates,
4273 "edge relation must be unchanged after failed update"
4274 );
4275 }
4276
4277 #[tokio::test]
4280 async fn update_edge_entity_to_entity_set_annotates_returns_invalid_input() {
4281 let rt = rt();
4282 let tok = NamespaceToken::local();
4283 let a = rt
4284 .create_entity(&tok, "concept", None, "A", None, None, vec![])
4285 .await
4286 .unwrap();
4287 let b = rt
4288 .create_entity(&tok, "concept", None, "B", None, None, vec![])
4289 .await
4290 .unwrap();
4291 let edge = rt
4292 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
4293 .await
4294 .unwrap();
4295 let edge_id: Uuid = edge.id.into();
4296
4297 let result = rt
4298 .update_edge(
4299 &tok,
4300 edge_id,
4301 crate::curation::EdgePatch {
4302 relation: Some(EdgeRelation::Annotates),
4303 ..Default::default()
4304 },
4305 )
4306 .await;
4307 assert!(
4308 matches!(result, Err(RuntimeError::InvalidInput(_))),
4309 "update to Annotates on entity→entity edge must return InvalidInput, got {result:?}"
4310 );
4311 }
4312
4313 #[tokio::test]
4316 async fn update_edge_entity_to_entity_set_supersedes_succeeds() {
4317 let rt = rt();
4318 let tok = NamespaceToken::local();
4319 let a = rt
4320 .create_entity(&tok, "concept", None, "A", None, None, vec![])
4321 .await
4322 .unwrap();
4323 let b = rt
4324 .create_entity(&tok, "concept", None, "B", None, None, vec![])
4325 .await
4326 .unwrap();
4327 let edge = rt
4328 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
4329 .await
4330 .unwrap();
4331 let edge_id: Uuid = edge.id.into();
4332
4333 let updated = rt
4334 .update_edge(
4335 &tok,
4336 edge_id,
4337 crate::curation::EdgePatch {
4338 relation: Some(EdgeRelation::Supersedes),
4339 ..Default::default()
4340 },
4341 )
4342 .await
4343 .unwrap();
4344 assert_eq!(updated.relation, EdgeRelation::Supersedes);
4345
4346 let fetched = rt.get_edge(&tok, edge_id).await.unwrap().unwrap();
4348 assert_eq!(fetched.relation, EdgeRelation::Supersedes);
4349 }
4350
4351 #[tokio::test]
4353 async fn update_edge_weight_only_skips_validation() {
4354 let rt = rt();
4355 let tok = NamespaceToken::local();
4356 let a = rt
4357 .create_entity(&tok, "concept", None, "A", None, None, vec![])
4358 .await
4359 .unwrap();
4360 let b = rt
4361 .create_entity(&tok, "concept", None, "B", None, None, vec![])
4362 .await
4363 .unwrap();
4364 let edge = rt
4365 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
4366 .await
4367 .unwrap();
4368 let edge_id: Uuid = edge.id.into();
4369
4370 let updated = rt
4371 .update_edge(
4372 &tok,
4373 edge_id,
4374 crate::curation::EdgePatch {
4375 weight: Some(0.3),
4376 ..Default::default()
4377 },
4378 )
4379 .await
4380 .unwrap();
4381 assert_eq!(updated.relation, EdgeRelation::Extends);
4382 assert!((updated.weight - 0.3).abs() < 0.001);
4383 }
4384
4385 #[tokio::test]
4387 async fn update_edge_same_class_relation_change_succeeds() {
4388 let rt = rt();
4389 let tok = NamespaceToken::local();
4390 let a = rt
4391 .create_entity(&tok, "concept", None, "A", None, None, vec![])
4392 .await
4393 .unwrap();
4394 let b = rt
4395 .create_entity(&tok, "concept", None, "B", None, None, vec![])
4396 .await
4397 .unwrap();
4398 let edge = rt
4399 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
4400 .await
4401 .unwrap();
4402 let edge_id: Uuid = edge.id.into();
4403
4404 let updated = rt
4405 .update_edge(
4406 &tok,
4407 edge_id,
4408 crate::curation::EdgePatch {
4409 relation: Some(EdgeRelation::VariantOf),
4410 ..Default::default()
4411 },
4412 )
4413 .await
4414 .unwrap();
4415 assert_eq!(updated.relation, EdgeRelation::VariantOf);
4416 }
4417
4418 #[tokio::test]
4419 async fn list_edges_filters_by_relation() {
4420 let rt = rt();
4421 let tok = NamespaceToken::local();
4422 let a = rt
4423 .create_entity(&tok, "concept", None, "A", None, None, vec![])
4424 .await
4425 .unwrap();
4426 let b = rt
4427 .create_entity(&tok, "concept", None, "B", None, None, vec![])
4428 .await
4429 .unwrap();
4430 let c = rt
4431 .create_entity(&tok, "concept", None, "C", None, None, vec![])
4432 .await
4433 .unwrap();
4434
4435 rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
4436 .await
4437 .unwrap();
4438 rt.link(&tok, a.id, c.id, EdgeRelation::Enables, 1.0, None)
4439 .await
4440 .unwrap();
4441
4442 let filter = EdgeListFilter {
4443 relations: vec![EdgeRelation::Extends],
4444 ..Default::default()
4445 };
4446 let edges = rt.list_edges(&tok, filter, 100).await.unwrap();
4447 assert_eq!(edges.len(), 1);
4448 assert_eq!(edges[0].relation, EdgeRelation::Extends);
4449 }
4450
4451 #[tokio::test]
4452 async fn list_edges_filters_by_source() {
4453 let rt = rt();
4454 let tok = NamespaceToken::local();
4455 let a = rt
4456 .create_entity(&tok, "concept", None, "A", None, None, vec![])
4457 .await
4458 .unwrap();
4459 let b = rt
4460 .create_entity(&tok, "concept", None, "B", None, None, vec![])
4461 .await
4462 .unwrap();
4463 let c = rt
4464 .create_entity(&tok, "concept", None, "C", None, None, vec![])
4465 .await
4466 .unwrap();
4467 let d = rt
4468 .create_entity(&tok, "concept", None, "D", None, None, vec![])
4469 .await
4470 .unwrap();
4471
4472 rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
4473 .await
4474 .unwrap();
4475 rt.link(&tok, c.id, d.id, EdgeRelation::Extends, 1.0, None)
4476 .await
4477 .unwrap();
4478
4479 let filter = EdgeListFilter {
4480 source_id: Some(a.id),
4481 ..Default::default()
4482 };
4483 let edges = rt.list_edges(&tok, filter, 100).await.unwrap();
4484 assert_eq!(edges.len(), 1);
4485 let src: Uuid = edges[0].source_id;
4486 assert_eq!(src, a.id);
4487 }
4488
4489 #[tokio::test]
4490 async fn delete_edge_removes_from_storage() {
4491 let rt = rt();
4492 let tok = NamespaceToken::local();
4493 let a = rt
4494 .create_entity(&tok, "concept", None, "A", None, None, vec![])
4495 .await
4496 .unwrap();
4497 let b = rt
4498 .create_entity(&tok, "concept", None, "B", None, None, vec![])
4499 .await
4500 .unwrap();
4501 let edge = rt
4502 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
4503 .await
4504 .unwrap();
4505 let edge_id: Uuid = edge.id.into();
4506
4507 let deleted = rt.delete_edge(&tok, edge_id, true).await.unwrap();
4508 assert!(deleted);
4509
4510 let fetched = rt.get_edge(&tok, edge_id).await.unwrap();
4511 assert!(fetched.is_none(), "edge should be gone after delete");
4512 }
4513
4514 #[tokio::test]
4515 async fn count_edges_matches_filter() {
4516 let rt = rt();
4517 let tok = NamespaceToken::local();
4518 let a = rt
4519 .create_entity(&tok, "concept", None, "A", None, None, vec![])
4520 .await
4521 .unwrap();
4522 let b = rt
4523 .create_entity(&tok, "concept", None, "B", None, None, vec![])
4524 .await
4525 .unwrap();
4526 let c = rt
4527 .create_entity(&tok, "concept", None, "C", None, None, vec![])
4528 .await
4529 .unwrap();
4530
4531 rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
4532 .await
4533 .unwrap();
4534 rt.link(&tok, a.id, c.id, EdgeRelation::Enables, 1.0, None)
4535 .await
4536 .unwrap();
4537
4538 let all = rt
4539 .count_edges(&tok, EdgeListFilter::default())
4540 .await
4541 .unwrap();
4542 assert_eq!(all, 2);
4543
4544 let just_extends = rt
4545 .count_edges(
4546 &tok,
4547 EdgeListFilter {
4548 relations: vec![EdgeRelation::Extends],
4549 ..Default::default()
4550 },
4551 )
4552 .await
4553 .unwrap();
4554 assert_eq!(just_extends, 1);
4555 }
4556
4557 #[tokio::test]
4563 async fn edge_in_visible_namespace_reachable_as_graph_root() {
4564 let rt = rt();
4565 let ns_a = Namespace::parse("vis-edge-a").unwrap();
4566 let ns_b = Namespace::parse("vis-edge-b").unwrap();
4567
4568 let tok_b = NamespaceToken::for_namespace(ns_b.clone());
4570 let src = rt
4571 .create_entity(&tok_b, "concept", None, "SrcB", None, None, vec![])
4572 .await
4573 .unwrap();
4574 let tgt = rt
4575 .create_entity(&tok_b, "concept", None, "TgtB", None, None, vec![])
4576 .await
4577 .unwrap();
4578 let edge = rt
4579 .link(&tok_b, src.id, tgt.id, EdgeRelation::Extends, 1.0, None)
4580 .await
4581 .unwrap();
4582
4583 let tok_a_vis = rt
4586 .authorize_with_visibility(ns_a.clone(), vec![ns_b.clone()])
4587 .unwrap();
4588
4589 let got = rt.get_edge_visible(&tok_a_vis, edge.id.0).await.unwrap();
4591 assert!(
4592 got.is_some(),
4593 "edge in visible namespace must be retrievable via get_edge_visible"
4594 );
4595
4596 let neighbors = rt
4599 .neighbors(&tok_a_vis, src.id, Direction::Out, Some(16), None)
4600 .await
4601 .unwrap();
4602 assert!(
4603 neighbors.iter().any(|h| h.node_id == tgt.id),
4604 "neighbors of visible-ns node must include its visible-ns neighbor; got: {neighbors:?}"
4605 );
4606 }
4607
4608 #[tokio::test]
4612 async fn get_entity_cross_namespace_no_longer_denied() {
4613 let rt = rt();
4614 let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
4615 let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
4616 let entity = rt
4617 .create_entity(&ns_a, "concept", None, "Alpha", None, None, vec![])
4618 .await
4619 .unwrap();
4620
4621 let found = rt.get_entity(&ns_a, entity.id).await;
4623 assert!(found.is_ok(), "same-namespace get must succeed");
4624
4625 let cross = rt.get_entity(&ns_b, entity.id).await;
4627 assert!(
4628 cross.is_ok(),
4629 "cross-namespace get must succeed in shared-brain OSS (ADR-007 rule 2)"
4630 );
4631 assert_eq!(cross.unwrap().id, entity.id);
4632 }
4633
4634 #[tokio::test]
4635 async fn delete_entity_cross_namespace_no_longer_denied() {
4636 let rt = rt();
4637 let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
4638 let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
4639 let entity = rt
4640 .create_entity(&ns_a, "concept", None, "Beta", None, None, vec![])
4641 .await
4642 .unwrap();
4643
4644 let cross_ns_result = rt.delete_entity(&ns_b, entity.id, true).await;
4646 assert!(
4647 cross_ns_result.is_ok(),
4648 "cross-namespace delete must succeed in shared-brain OSS; got {:?}",
4649 cross_ns_result
4650 );
4651 assert!(cross_ns_result.unwrap(), "delete must return true");
4652
4653 let gone = rt.get_entity(&ns_a, entity.id).await;
4655 assert!(gone.is_err(), "entity must be gone after delete");
4656 }
4657
4658 #[tokio::test]
4661 async fn create_note_indexes_into_fts5() {
4662 let rt = rt();
4663 let tok = NamespaceToken::local();
4664 let note = rt
4665 .create_note(
4666 &tok,
4667 "observation",
4668 None,
4669 "FlashAttention reduces memory by using tiling",
4670 Some(0.8),
4671 None,
4672 vec![],
4673 )
4674 .await
4675 .unwrap();
4676
4677 let ns = tok.namespace().as_str().to_string();
4679 let hits = rt
4680 .text_for_notes(&tok)
4681 .unwrap()
4682 .search(khive_storage::types::TextSearchRequest {
4683 query: "FlashAttention".to_string(),
4684 mode: khive_storage::types::TextQueryMode::Plain,
4685 filter: Some(khive_storage::types::TextFilter {
4686 namespaces: vec![ns],
4687 ..Default::default()
4688 }),
4689 top_k: 10,
4690 snippet_chars: 100,
4691 })
4692 .await
4693 .unwrap();
4694
4695 assert!(
4696 hits.iter().any(|h| h.subject_id == note.id),
4697 "note should be indexed in FTS5 after create"
4698 );
4699 }
4700
4701 #[tokio::test]
4702 async fn create_note_with_properties() {
4703 let rt = rt();
4704 let tok = NamespaceToken::local();
4705 let props = serde_json::json!({"source": "arxiv:2205.14135"});
4706 let note = rt
4707 .create_note(
4708 &tok,
4709 "insight",
4710 None,
4711 "FlashAttention is IO-aware",
4712 Some(0.9),
4713 Some(props.clone()),
4714 vec![],
4715 )
4716 .await
4717 .unwrap();
4718
4719 assert_eq!(note.properties.as_ref().unwrap(), &props);
4720 }
4721
4722 #[tokio::test]
4723 async fn create_note_creates_annotates_edges() {
4724 let rt = rt();
4725 let tok = NamespaceToken::local();
4726 let entity = rt
4727 .create_entity(&tok, "concept", None, "FlashAttention", None, None, vec![])
4728 .await
4729 .unwrap();
4730
4731 let note = rt
4732 .create_note(
4733 &tok,
4734 "observation",
4735 None,
4736 "FlashAttention uses SRAM tiling for memory efficiency",
4737 Some(0.9),
4738 None,
4739 vec![entity.id],
4740 )
4741 .await
4742 .unwrap();
4743
4744 let out_neighbors = rt
4746 .neighbors(
4747 &tok,
4748 note.id,
4749 Direction::Out,
4750 None,
4751 Some(vec![EdgeRelation::Annotates]),
4752 )
4753 .await
4754 .unwrap();
4755 assert_eq!(out_neighbors.len(), 1);
4756 assert_eq!(out_neighbors[0].node_id, entity.id);
4757 assert_eq!(out_neighbors[0].relation, EdgeRelation::Annotates);
4758
4759 let in_neighbors = rt
4761 .neighbors(
4762 &tok,
4763 entity.id,
4764 Direction::In,
4765 None,
4766 Some(vec![EdgeRelation::Annotates]),
4767 )
4768 .await
4769 .unwrap();
4770 assert_eq!(in_neighbors.len(), 1);
4771 assert_eq!(in_neighbors[0].node_id, note.id);
4772 }
4773
4774 #[tokio::test]
4775 async fn neighbors_without_relation_filter_returns_all() {
4776 let rt = rt();
4777 let tok = NamespaceToken::local();
4778 let a = rt
4779 .create_entity(&tok, "concept", None, "A", None, None, vec![])
4780 .await
4781 .unwrap();
4782 let b = rt
4783 .create_entity(&tok, "concept", None, "B", None, None, vec![])
4784 .await
4785 .unwrap();
4786 let c = rt
4787 .create_entity(&tok, "concept", None, "C", None, None, vec![])
4788 .await
4789 .unwrap();
4790
4791 rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
4792 .await
4793 .unwrap();
4794 rt.link(&tok, a.id, c.id, EdgeRelation::Enables, 1.0, None)
4795 .await
4796 .unwrap();
4797
4798 let all = rt
4799 .neighbors(&tok, a.id, Direction::Out, None, None)
4800 .await
4801 .unwrap();
4802 assert_eq!(all.len(), 2);
4803 }
4804
4805 #[tokio::test]
4806 async fn neighbors_with_relation_filter_returns_subset() {
4807 let rt = rt();
4808 let tok = NamespaceToken::local();
4809 let a = rt
4810 .create_entity(&tok, "concept", None, "A", None, None, vec![])
4811 .await
4812 .unwrap();
4813 let b = rt
4814 .create_entity(&tok, "concept", None, "B", None, None, vec![])
4815 .await
4816 .unwrap();
4817 let c = rt
4818 .create_entity(&tok, "concept", None, "C", None, None, vec![])
4819 .await
4820 .unwrap();
4821
4822 rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
4823 .await
4824 .unwrap();
4825 rt.link(&tok, a.id, c.id, EdgeRelation::Enables, 1.0, None)
4826 .await
4827 .unwrap();
4828
4829 let filtered = rt
4830 .neighbors(
4831 &tok,
4832 a.id,
4833 Direction::Out,
4834 None,
4835 Some(vec![EdgeRelation::Extends]),
4836 )
4837 .await
4838 .unwrap();
4839 assert_eq!(filtered.len(), 1);
4840 assert_eq!(filtered[0].node_id, b.id);
4841 assert_eq!(filtered[0].relation, EdgeRelation::Extends);
4842 }
4843
4844 #[tokio::test]
4845 async fn search_notes_returns_relevant_note() {
4846 let rt = rt();
4847 let tok = NamespaceToken::local();
4848 rt.create_note(
4849 &tok,
4850 "observation",
4851 None,
4852 "GQA reduces KV cache memory for large models",
4853 Some(0.8),
4854 None,
4855 vec![],
4856 )
4857 .await
4858 .unwrap();
4859
4860 let results = rt
4861 .search_notes(&tok, "GQA KV cache", None, 10, None, false, &[], None)
4862 .await
4863 .unwrap();
4864
4865 assert!(!results.is_empty(), "search should return the indexed note");
4866 let hit = &results[0];
4867 assert!(
4868 hit.title.is_some(),
4869 "note hit title should be populated (falls back to content)"
4870 );
4871 assert!(
4872 hit.snippet.is_some(),
4873 "note hit snippet should be populated"
4874 );
4875 }
4876
4877 #[tokio::test]
4878 async fn search_notes_excludes_soft_deleted() {
4879 let rt = rt();
4880 let tok = NamespaceToken::local();
4881 let note = rt
4882 .create_note(
4883 &tok,
4884 "observation",
4885 None,
4886 "RoPE positional encoding rotary embeddings",
4887 Some(0.7),
4888 None,
4889 vec![],
4890 )
4891 .await
4892 .unwrap();
4893
4894 rt.notes(&tok)
4896 .unwrap()
4897 .delete_note(note.id, DeleteMode::Soft)
4898 .await
4899 .unwrap();
4900
4901 let results = rt
4902 .search_notes(
4903 &tok,
4904 "RoPE rotary positional",
4905 None,
4906 10,
4907 None,
4908 false,
4909 &[],
4910 None,
4911 )
4912 .await
4913 .unwrap();
4914
4915 assert!(
4916 results.iter().all(|h| h.note_id != note.id),
4917 "soft-deleted note should be excluded from search"
4918 );
4919 }
4920
4921 #[tokio::test]
4939 async fn search_notes_tag_filter_pushed_before_truncation() {
4940 let rt = rt();
4941 let tok = NamespaceToken::local();
4942
4943 rt.create_note(
4945 &tok,
4946 "observation",
4947 None,
4948 "kappa lambda mu note decoy kappa lambda mu note decoy kappa lambda mu",
4949 Some(0.5),
4950 Some(serde_json::json!({"tags": ["other-note-tag"]})),
4951 vec![],
4952 )
4953 .await
4954 .unwrap();
4955
4956 let target = rt
4958 .create_note(
4959 &tok,
4960 "observation",
4961 None,
4962 "kappa lambda mu note target",
4963 Some(0.5),
4964 Some(serde_json::json!({"tags": ["note-target-tag"]})),
4965 vec![],
4966 )
4967 .await
4968 .unwrap();
4969
4970 let hits = rt
4973 .search_notes(
4974 &tok,
4975 "kappa lambda mu note",
4976 None,
4977 1,
4978 None,
4979 false,
4980 &["note-target-tag".to_string()],
4981 None,
4982 )
4983 .await
4984 .unwrap();
4985
4986 assert_eq!(
4987 hits.len(),
4988 1,
4989 "exactly one hit expected (tag-matching note)"
4990 );
4991 assert_eq!(
4992 hits[0].note_id, target.id,
4993 "tag-filtered note must be returned even when ranked below limit in raw fusion"
4994 );
4995 }
4996
4997 #[tokio::test]
5006 async fn search_notes_props_filter_pushed_before_truncation() {
5007 let rt = rt();
5008 let tok = NamespaceToken::local();
5009
5010 rt.create_note(
5011 &tok,
5012 "observation",
5013 None,
5014 "nu xi omicron note decoy nu xi omicron note decoy nu xi omicron",
5015 Some(0.5),
5016 Some(serde_json::json!({"source": "other"})),
5017 vec![],
5018 )
5019 .await
5020 .unwrap();
5021
5022 let target = rt
5023 .create_note(
5024 &tok,
5025 "observation",
5026 None,
5027 "nu xi omicron note target",
5028 Some(0.5),
5029 Some(serde_json::json!({"source": "target"})),
5030 vec![],
5031 )
5032 .await
5033 .unwrap();
5034
5035 let filter = serde_json::json!({"source": "target"});
5036 let hits = rt
5037 .search_notes(
5038 &tok,
5039 "nu xi omicron note",
5040 None,
5041 1,
5042 None,
5043 false,
5044 &[],
5045 Some(&filter),
5046 )
5047 .await
5048 .unwrap();
5049
5050 assert_eq!(
5051 hits.len(),
5052 1,
5053 "exactly one hit expected (properties-matching note)"
5054 );
5055 assert_eq!(
5056 hits[0].note_id, target.id,
5057 "properties-filtered note must be returned even when ranked below limit"
5058 );
5059 }
5060
5061 #[tokio::test]
5062 async fn resolve_returns_entity() {
5063 let rt = rt();
5064 let tok = NamespaceToken::local();
5065 let entity = rt
5066 .create_entity(&tok, "concept", None, "LoRA", None, None, vec![])
5067 .await
5068 .unwrap();
5069
5070 let resolved = rt.resolve(&tok, entity.id).await.unwrap();
5071 match resolved {
5072 Some(Resolved::Entity(e)) => assert_eq!(e.id, entity.id),
5073 other => panic!("expected Resolved::Entity, got {:?}", other),
5074 }
5075 }
5076
5077 #[tokio::test]
5078 async fn resolve_returns_note() {
5079 let rt = rt();
5080 let tok = NamespaceToken::local();
5081 let note = rt
5082 .create_note(
5083 &tok,
5084 "observation",
5085 None,
5086 "LoRA fine-tunes LLMs with low-rank adapters",
5087 Some(0.85),
5088 None,
5089 vec![],
5090 )
5091 .await
5092 .unwrap();
5093
5094 let resolved = rt.resolve(&tok, note.id).await.unwrap();
5095 match resolved {
5096 Some(Resolved::Note(n)) => assert_eq!(n.id, note.id),
5097 other => panic!("expected Resolved::Note, got {:?}", other),
5098 }
5099 }
5100
5101 #[tokio::test]
5102 async fn resolve_returns_none_for_unknown_uuid() {
5103 let rt = rt();
5104 let tok = NamespaceToken::local();
5105 let unknown = Uuid::new_v4();
5106 let resolved = rt.resolve(&tok, unknown).await.unwrap();
5107 assert!(resolved.is_none(), "unknown UUID should resolve to None");
5108 }
5109
5110 #[tokio::test]
5111 async fn resolve_prefix_finds_entity_in_own_namespace() {
5112 let rt = rt();
5113 let tok = NamespaceToken::local();
5114 let entity = rt
5115 .create_entity(&tok, "concept", None, "PrefixTest", None, None, vec![])
5116 .await
5117 .unwrap();
5118 let prefix = &entity.id.to_string()[..8];
5119
5120 let resolved = rt.resolve_prefix(&tok, prefix).await.unwrap();
5121 assert_eq!(resolved, Some(entity.id));
5122 }
5123
5124 #[tokio::test]
5125 async fn resolve_prefix_invisible_across_namespaces() {
5126 let rt = rt();
5127 let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
5128 let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
5129 let entity = rt
5130 .create_entity(&ns_a, "concept", None, "Invisible", None, None, vec![])
5131 .await
5132 .unwrap();
5133 let prefix = &entity.id.to_string()[..8];
5134
5135 let resolved = rt.resolve_prefix(&ns_b, prefix).await.unwrap();
5137 assert_eq!(resolved, None);
5138 }
5139
5140 #[tokio::test]
5141 async fn resolve_prefix_ambiguous_same_namespace() {
5142 use khive_storage::entity::Entity;
5143
5144 let rt = rt();
5145 let tok = NamespaceToken::local();
5146 let id_a = Uuid::parse_str("aabbccdd-1111-4000-8000-000000000001").unwrap();
5148 let id_b = Uuid::parse_str("aabbccdd-2222-4000-8000-000000000002").unwrap();
5149
5150 let mut entity_a = Entity::new("local", "concept", "AmbigA");
5151 entity_a.id = id_a;
5152 let mut entity_b = Entity::new("local", "concept", "AmbigB");
5153 entity_b.id = id_b;
5154
5155 let store = rt.entities(&tok).unwrap();
5156 store.upsert_entity(entity_a).await.unwrap();
5157 store.upsert_entity(entity_b).await.unwrap();
5158
5159 let err = rt.resolve_prefix(&tok, "aabbccdd").await.unwrap_err();
5160 assert!(
5161 matches!(
5162 err,
5163 RuntimeError::AmbiguousPrefix { ref prefix, ref matches }
5164 if prefix == "aabbccdd" && matches.len() == 2
5165 ),
5166 "shared 8-char prefix must return AmbiguousPrefix; got {err:?}"
5167 );
5168 }
5169
5170 #[tokio::test]
5177 async fn resolve_finds_event_by_full_uuid() {
5178 use khive_storage::Event;
5179 use khive_types::{EventKind, SubstrateKind};
5180
5181 let rt = rt();
5182 let tok = NamespaceToken::local();
5183 let ns = tok.namespace().as_str();
5184 let event = Event::new(
5185 ns,
5186 "test_verb",
5187 EventKind::Audit,
5188 SubstrateKind::Entity,
5189 "actor",
5190 );
5191 let event_id = event.id;
5192 rt.events(&tok).unwrap().append_event(event).await.unwrap();
5193
5194 let resolved = rt.resolve(&tok, event_id).await.unwrap();
5195 assert!(
5196 matches!(resolved, Some(Resolved::Event(_))),
5197 "event UUID must resolve to Resolved::Event, got {resolved:?}"
5198 );
5199 }
5200
5201 #[tokio::test]
5202 async fn resolve_prefix_finds_event() {
5203 use khive_storage::Event;
5204 use khive_types::{EventKind, SubstrateKind};
5205
5206 let rt = rt();
5207 let tok = NamespaceToken::local();
5208 let ns = tok.namespace().as_str();
5209 let event = Event::new(
5210 ns,
5211 "test_verb",
5212 EventKind::Audit,
5213 SubstrateKind::Entity,
5214 "actor",
5215 );
5216 let event_id = event.id;
5217 rt.events(&tok).unwrap().append_event(event).await.unwrap();
5218
5219 let prefix = &event_id.to_string()[..8];
5220 let resolved = rt.resolve_prefix(&tok, prefix).await.unwrap();
5221 assert_eq!(
5222 resolved,
5223 Some(event_id),
5224 "resolve_prefix must return event UUID for 8-char prefix"
5225 );
5226 }
5227
5228 #[tokio::test]
5231 async fn link_phantom_source_returns_not_found() {
5232 let rt = rt();
5233 let tok = NamespaceToken::local();
5234 let b = rt
5235 .create_entity(&tok, "concept", None, "B", None, None, vec![])
5236 .await
5237 .unwrap();
5238 let phantom = Uuid::new_v4();
5239
5240 let result = rt
5241 .link(&tok, phantom, b.id, EdgeRelation::Extends, 1.0, None)
5242 .await;
5243 match result {
5244 Err(RuntimeError::NotFound(msg)) => {
5245 assert!(
5246 msg.contains("source"),
5247 "error message must name 'source': {msg}"
5248 );
5249 }
5250 other => panic!("expected NotFound for phantom source, got {other:?}"),
5251 }
5252 }
5253
5254 #[tokio::test]
5255 async fn link_phantom_target_returns_not_found() {
5256 let rt = rt();
5257 let tok = NamespaceToken::local();
5258 let a = rt
5259 .create_entity(&tok, "concept", None, "A", None, None, vec![])
5260 .await
5261 .unwrap();
5262 let phantom = Uuid::new_v4();
5263
5264 let result = rt
5265 .link(&tok, a.id, phantom, EdgeRelation::Extends, 1.0, None)
5266 .await;
5267 match result {
5268 Err(RuntimeError::NotFound(msg)) => {
5269 assert!(
5270 msg.contains("target"),
5271 "error message must name 'target': {msg}"
5272 );
5273 }
5274 other => panic!("expected NotFound for phantom target, got {other:?}"),
5275 }
5276 }
5277
5278 #[tokio::test]
5279 async fn link_real_entities_succeeds() {
5280 let rt = rt();
5281 let tok = NamespaceToken::local();
5282 let a = rt
5283 .create_entity(&tok, "concept", None, "A", None, None, vec![])
5284 .await
5285 .unwrap();
5286 let b = rt
5287 .create_entity(&tok, "concept", None, "B", None, None, vec![])
5288 .await
5289 .unwrap();
5290
5291 let edge = rt
5292 .link(&tok, a.id, b.id, EdgeRelation::Extends, 0.8, None)
5293 .await
5294 .unwrap();
5295 assert_eq!(edge.source_id, a.id);
5296 assert_eq!(edge.target_id, b.id);
5297 assert_eq!(edge.relation, EdgeRelation::Extends);
5298 }
5299
5300 #[tokio::test]
5301 async fn create_note_annotates_phantom_returns_not_found() {
5302 let rt = rt();
5303 let tok = NamespaceToken::local();
5304 let phantom = Uuid::new_v4();
5305
5306 let result = rt
5307 .create_note(
5308 &tok,
5309 "observation",
5310 None,
5311 "some content",
5312 Some(0.5),
5313 None,
5314 vec![phantom],
5315 )
5316 .await;
5317 assert!(
5318 matches!(result, Err(RuntimeError::NotFound(_))),
5319 "annotates with phantom uuid must return NotFound, got {result:?}"
5320 );
5321 }
5322
5323 #[tokio::test]
5324 async fn create_note_annotates_real_entity_succeeds() {
5325 let rt = rt();
5326 let tok = NamespaceToken::local();
5327 let entity = rt
5328 .create_entity(&tok, "concept", None, "RealTarget", None, None, vec![])
5329 .await
5330 .unwrap();
5331
5332 let note = rt
5333 .create_note(
5334 &tok,
5335 "observation",
5336 None,
5337 "content",
5338 Some(0.5),
5339 None,
5340 vec![entity.id],
5341 )
5342 .await
5343 .unwrap();
5344
5345 let neighbors = rt
5346 .neighbors(
5347 &tok,
5348 note.id,
5349 Direction::Out,
5350 None,
5351 Some(vec![EdgeRelation::Annotates]),
5352 )
5353 .await
5354 .unwrap();
5355 assert_eq!(neighbors.len(), 1);
5356 assert_eq!(neighbors[0].node_id, entity.id);
5357 }
5358
5359 #[tokio::test]
5361 async fn create_note_multi_annotates_creates_all_edges() {
5362 let rt = rt();
5363 let tok = NamespaceToken::local();
5364 let t1 = rt
5365 .create_entity(&tok, "concept", None, "Target1", None, None, vec![])
5366 .await
5367 .unwrap();
5368 let t2 = rt
5369 .create_entity(&tok, "concept", None, "Target2", None, None, vec![])
5370 .await
5371 .unwrap();
5372
5373 let note = rt
5374 .create_note(
5375 &tok,
5376 "observation",
5377 None,
5378 "content",
5379 Some(0.5),
5380 None,
5381 vec![t1.id, t2.id],
5382 )
5383 .await
5384 .unwrap();
5385
5386 let neighbors = rt
5387 .neighbors(
5388 &tok,
5389 note.id,
5390 Direction::Out,
5391 None,
5392 Some(vec![EdgeRelation::Annotates]),
5393 )
5394 .await
5395 .unwrap();
5396 assert_eq!(
5397 neighbors.len(),
5398 2,
5399 "multi-annotates note must have exactly 2 outbound annotates edges"
5400 );
5401 let target_ids: Vec<Uuid> = neighbors.iter().map(|n| n.node_id).collect();
5402 assert!(target_ids.contains(&t1.id));
5403 assert!(target_ids.contains(&t2.id));
5404 }
5405
5406 #[tokio::test]
5407 async fn link_target_in_different_namespace_returns_not_found() {
5408 let rt = rt();
5409 let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
5410 let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
5411 let a = rt
5412 .create_entity(&ns_a, "concept", None, "A", None, None, vec![])
5413 .await
5414 .unwrap();
5415 let b = rt
5416 .create_entity(&ns_b, "concept", None, "B", None, None, vec![])
5417 .await
5418 .unwrap();
5419
5420 let result = rt
5422 .link(&ns_a, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5423 .await;
5424 assert!(
5425 matches!(result, Err(RuntimeError::NotFound(_))),
5426 "target in different namespace must return NotFound (fail-closed), got {result:?}"
5427 );
5428 }
5429
5430 #[tokio::test]
5431 async fn link_phantom_self_loop_returns_invalid_input() {
5432 let rt = rt();
5433 let tok = NamespaceToken::local();
5434 let phantom = Uuid::new_v4();
5435
5436 let result = rt
5437 .link(&tok, phantom, phantom, EdgeRelation::Extends, 1.0, None)
5438 .await;
5439 match result {
5440 Err(RuntimeError::InvalidInput(msg)) => {
5441 assert!(
5442 msg.contains("self-loop"),
5443 "self-loop must be rejected with self-loop message: {msg}"
5444 );
5445 }
5446 other => panic!("expected InvalidInput for self-loop, got {other:?}"),
5447 }
5448 }
5449
5450 #[tokio::test]
5453 async fn link_note_to_edge_annotates_succeeds() {
5454 let rt = rt();
5455 let tok = NamespaceToken::local();
5456 let a = rt
5457 .create_entity(&tok, "concept", None, "A", None, None, vec![])
5458 .await
5459 .unwrap();
5460 let b = rt
5461 .create_entity(&tok, "concept", None, "B", None, None, vec![])
5462 .await
5463 .unwrap();
5464 let edge = rt
5466 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5467 .await
5468 .unwrap();
5469 let edge_uuid: Uuid = edge.id.into();
5470
5471 let note = rt
5473 .create_note(
5474 &tok,
5475 "observation",
5476 None,
5477 "edge note",
5478 Some(0.5),
5479 None,
5480 vec![],
5481 )
5482 .await
5483 .unwrap();
5484
5485 let result = rt
5486 .link(&tok, note.id, edge_uuid, EdgeRelation::Annotates, 1.0, None)
5487 .await;
5488 assert!(
5489 result.is_ok(),
5490 "note→edge Annotates must succeed, got {result:?}"
5491 );
5492 }
5493
5494 #[tokio::test]
5495 async fn create_note_annotates_real_edge_succeeds() {
5496 let rt = rt();
5497 let tok = NamespaceToken::local();
5498 let a = rt
5499 .create_entity(&tok, "concept", None, "A", None, None, vec![])
5500 .await
5501 .unwrap();
5502 let b = rt
5503 .create_entity(&tok, "concept", None, "B", None, None, vec![])
5504 .await
5505 .unwrap();
5506 let edge = rt
5507 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5508 .await
5509 .unwrap();
5510 let edge_uuid: Uuid = edge.id.into();
5511
5512 let note = rt
5513 .create_note(
5514 &tok,
5515 "observation",
5516 None,
5517 "annotating an edge",
5518 Some(0.5),
5519 None,
5520 vec![edge_uuid],
5521 )
5522 .await
5523 .unwrap();
5524
5525 let neighbors = rt
5526 .neighbors(
5527 &tok,
5528 note.id,
5529 Direction::Out,
5530 None,
5531 Some(vec![EdgeRelation::Annotates]),
5532 )
5533 .await
5534 .unwrap();
5535 assert_eq!(neighbors.len(), 1);
5536 assert_eq!(neighbors[0].node_id, edge_uuid);
5537 }
5538
5539 #[tokio::test]
5540 async fn create_note_annotates_phantom_is_atomic_no_note_persisted() {
5541 let rt = rt();
5542 let tok = NamespaceToken::local();
5543 let phantom = Uuid::new_v4();
5544
5545 let before_count = rt.list_notes(&tok, None, 1000, 0).await.unwrap().len();
5546
5547 let result = rt
5548 .create_note(
5549 &tok,
5550 "observation",
5551 None,
5552 "should not persist",
5553 Some(0.5),
5554 None,
5555 vec![phantom],
5556 )
5557 .await;
5558 assert!(
5559 matches!(result, Err(RuntimeError::NotFound(_))),
5560 "phantom annotates target must return NotFound, got {result:?}"
5561 );
5562
5563 let after_count = rt.list_notes(&tok, None, 1000, 0).await.unwrap().len();
5565 assert_eq!(
5566 before_count, after_count,
5567 "failed create_note must not persist any note row (atomicity)"
5568 );
5569
5570 let search_hits = rt
5572 .search_notes(&tok, "should not persist", None, 10, None, false, &[], None)
5573 .await
5574 .unwrap();
5575 assert!(
5576 search_hits.is_empty(),
5577 "failed create_note must not index into FTS (atomicity)"
5578 );
5579 }
5582
5583 #[tokio::test]
5587 async fn link_entity_to_edge_uuid_non_annotates_returns_invalid_input() {
5588 let rt = rt();
5589 let tok = NamespaceToken::local();
5590 let a = rt
5591 .create_entity(&tok, "concept", None, "A", None, None, vec![])
5592 .await
5593 .unwrap();
5594 let b = rt
5595 .create_entity(&tok, "concept", None, "B", None, None, vec![])
5596 .await
5597 .unwrap();
5598 let edge = rt
5600 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5601 .await
5602 .unwrap();
5603 let edge_uuid: Uuid = edge.id.into();
5604
5605 let result = rt
5606 .link(&tok, a.id, edge_uuid, EdgeRelation::Extends, 1.0, None)
5607 .await;
5608 match result {
5609 Err(RuntimeError::InvalidInput(msg)) => {
5610 assert!(
5611 msg.contains("target"),
5612 "error message must name 'target': {msg}"
5613 );
5614 }
5615 other => {
5616 panic!("expected InvalidInput for edge-uuid target with Extends, got {other:?}")
5617 }
5618 }
5619 }
5620
5621 #[tokio::test]
5623 async fn link_note_as_source_non_annotates_returns_invalid_input() {
5624 let rt = rt();
5625 let tok = NamespaceToken::local();
5626 let note = rt
5627 .create_note(&tok, "observation", None, "a note", Some(0.5), None, vec![])
5628 .await
5629 .unwrap();
5630 let entity = rt
5631 .create_entity(&tok, "concept", None, "E", None, None, vec![])
5632 .await
5633 .unwrap();
5634
5635 let result = rt
5636 .link(&tok, note.id, entity.id, EdgeRelation::DependsOn, 1.0, None)
5637 .await;
5638 match result {
5639 Err(RuntimeError::InvalidInput(msg)) => {
5640 assert!(
5641 msg.contains("source"),
5642 "error message must name 'source': {msg}"
5643 );
5644 }
5645 other => panic!("expected InvalidInput for note source with DependsOn, got {other:?}"),
5646 }
5647 }
5648
5649 #[tokio::test]
5651 async fn link_entity_as_annotates_source_returns_invalid_input() {
5652 let rt = rt();
5653 let tok = NamespaceToken::local();
5654 let a = rt
5655 .create_entity(&tok, "concept", None, "A", None, None, vec![])
5656 .await
5657 .unwrap();
5658 let b = rt
5659 .create_entity(&tok, "concept", None, "B", None, None, vec![])
5660 .await
5661 .unwrap();
5662
5663 let result = rt
5664 .link(&tok, a.id, b.id, EdgeRelation::Annotates, 1.0, None)
5665 .await;
5666 match result {
5667 Err(RuntimeError::InvalidInput(msg)) => {
5668 assert!(
5669 msg.contains("source") && msg.contains("note"),
5670 "error must say source must be a note: {msg}"
5671 );
5672 }
5673 other => {
5674 panic!("expected InvalidInput for entity source with Annotates, got {other:?}")
5675 }
5676 }
5677 }
5678
5679 #[tokio::test]
5680 async fn link_edge_as_annotates_source_returns_invalid_input() {
5681 let rt = rt();
5682 let tok = NamespaceToken::local();
5683 let a = rt
5684 .create_entity(&tok, "concept", None, "A", None, None, vec![])
5685 .await
5686 .unwrap();
5687 let b = rt
5688 .create_entity(&tok, "concept", None, "B", None, None, vec![])
5689 .await
5690 .unwrap();
5691 let edge = rt
5692 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
5693 .await
5694 .unwrap();
5695 let edge_uuid: Uuid = edge.id.into();
5696
5697 let result = rt
5699 .link(&tok, edge_uuid, a.id, EdgeRelation::Annotates, 1.0, None)
5700 .await;
5701 match result {
5702 Err(RuntimeError::InvalidInput(msg)) => {
5703 assert!(
5704 msg.contains("source") && msg.contains("note"),
5705 "edge-as-annotates-source must report wrong kind, not NotFound: {msg}"
5706 );
5707 }
5708 other => panic!("expected InvalidInput for edge source with Annotates, got {other:?}"),
5709 }
5710 }
5711
5712 #[tokio::test]
5714 async fn link_note_to_event_annotates_succeeds() {
5715 use khive_storage::Event;
5716 use khive_types::{EventKind, SubstrateKind};
5717
5718 let rt = rt();
5719 let tok = NamespaceToken::local();
5720 let note = rt
5721 .create_note(
5722 &tok,
5723 "observation",
5724 None,
5725 "observing an event",
5726 Some(0.6),
5727 None,
5728 vec![],
5729 )
5730 .await
5731 .unwrap();
5732
5733 let ns = tok.namespace().as_str();
5735 let event = Event::new(
5736 ns,
5737 "test_verb",
5738 EventKind::Audit,
5739 SubstrateKind::Entity,
5740 "test_actor",
5741 );
5742 let event_id = event.id;
5743 rt.events(&tok).unwrap().append_event(event).await.unwrap();
5744
5745 let result = rt
5746 .link(&tok, note.id, event_id, EdgeRelation::Annotates, 1.0, None)
5747 .await;
5748 assert!(
5749 result.is_ok(),
5750 "note→event Annotates must succeed, got {result:?}"
5751 );
5752 }
5753
5754 #[tokio::test]
5756 async fn create_note_annotates_event_succeeds() {
5757 use khive_storage::Event;
5758 use khive_types::{EventKind, SubstrateKind};
5759
5760 let rt = rt();
5761 let tok = NamespaceToken::local();
5762 let ns = tok.namespace().as_str();
5763 let event = Event::new(
5764 ns,
5765 "test_verb",
5766 EventKind::Audit,
5767 SubstrateKind::Entity,
5768 "test_actor",
5769 );
5770 let event_id = event.id;
5771 rt.events(&tok).unwrap().append_event(event).await.unwrap();
5772
5773 let result = rt
5774 .create_note(
5775 &tok,
5776 "observation",
5777 None,
5778 "note annotating an event",
5779 Some(0.5),
5780 None,
5781 vec![event_id],
5782 )
5783 .await;
5784 assert!(
5785 result.is_ok(),
5786 "create_note with event annotates target must succeed, got {result:?}"
5787 );
5788 let note = result.unwrap();
5790 let neighbors = rt
5791 .neighbors(
5792 &tok,
5793 note.id,
5794 Direction::Out,
5795 None,
5796 Some(vec![EdgeRelation::Annotates]),
5797 )
5798 .await
5799 .unwrap();
5800 assert_eq!(neighbors.len(), 1);
5801 assert_eq!(neighbors[0].node_id, event_id);
5802 }
5803
5804 #[tokio::test]
5808 async fn link_supersedes_note_to_note_succeeds() {
5809 let rt = rt();
5810 let tok = NamespaceToken::local();
5811 let old_note = rt
5812 .create_note(
5813 &tok,
5814 "observation",
5815 None,
5816 "old observation",
5817 Some(0.7),
5818 None,
5819 vec![],
5820 )
5821 .await
5822 .unwrap();
5823 let new_note = rt
5824 .create_note(
5825 &tok,
5826 "observation",
5827 None,
5828 "revised observation superseding the old one",
5829 Some(0.9),
5830 None,
5831 vec![],
5832 )
5833 .await
5834 .unwrap();
5835
5836 let result = rt
5837 .link(
5838 &tok,
5839 new_note.id,
5840 old_note.id,
5841 EdgeRelation::Supersedes,
5842 1.0,
5843 None,
5844 )
5845 .await;
5846 assert!(
5847 result.is_ok(),
5848 "note→note Supersedes must succeed (note supersession), got {result:?}"
5849 );
5850 }
5851
5852 #[tokio::test]
5853 async fn link_supersedes_entity_to_entity_succeeds() {
5854 let rt = rt();
5855 let tok = NamespaceToken::local();
5856 let old_entity = rt
5857 .create_entity(&tok, "concept", None, "OldConcept", None, None, vec![])
5858 .await
5859 .unwrap();
5860 let new_entity = rt
5861 .create_entity(&tok, "concept", None, "NewConcept", None, None, vec![])
5862 .await
5863 .unwrap();
5864
5865 let result = rt
5866 .link(
5867 &tok,
5868 new_entity.id,
5869 old_entity.id,
5870 EdgeRelation::Supersedes,
5871 1.0,
5872 None,
5873 )
5874 .await;
5875 assert!(
5876 result.is_ok(),
5877 "entity→entity Supersedes must succeed, got {result:?}"
5878 );
5879 }
5880
5881 #[tokio::test]
5882 async fn link_supersedes_note_to_entity_returns_invalid_input() {
5883 let rt = rt();
5884 let tok = NamespaceToken::local();
5885 let note = rt
5886 .create_note(&tok, "observation", None, "a note", Some(0.5), None, vec![])
5887 .await
5888 .unwrap();
5889 let entity = rt
5890 .create_entity(&tok, "concept", None, "SomeEntity", None, None, vec![])
5891 .await
5892 .unwrap();
5893
5894 let result = rt
5895 .link(
5896 &tok,
5897 note.id,
5898 entity.id,
5899 EdgeRelation::Supersedes,
5900 1.0,
5901 None,
5902 )
5903 .await;
5904 match result {
5905 Err(RuntimeError::InvalidInput(msg)) => {
5906 assert!(
5907 msg.contains("same substrate") || msg.contains("same-substrate"),
5908 "error must name the same-substrate rule: {msg}"
5909 );
5910 }
5911 other => panic!(
5912 "expected InvalidInput for note→entity Supersedes (cross-substrate), got {other:?}"
5913 ),
5914 }
5915 }
5916
5917 #[tokio::test]
5918 async fn link_supersedes_entity_to_note_returns_invalid_input() {
5919 let rt = rt();
5920 let tok = NamespaceToken::local();
5921 let entity = rt
5922 .create_entity(&tok, "concept", None, "SomeEntity", None, None, vec![])
5923 .await
5924 .unwrap();
5925 let note = rt
5926 .create_note(&tok, "observation", None, "a note", Some(0.5), None, vec![])
5927 .await
5928 .unwrap();
5929
5930 let result = rt
5931 .link(
5932 &tok,
5933 entity.id,
5934 note.id,
5935 EdgeRelation::Supersedes,
5936 1.0,
5937 None,
5938 )
5939 .await;
5940 match result {
5941 Err(RuntimeError::InvalidInput(msg)) => {
5942 assert!(
5943 msg.contains("same substrate") || msg.contains("same-substrate"),
5944 "error must name the same-substrate rule: {msg}"
5945 );
5946 }
5947 other => panic!(
5948 "expected InvalidInput for entity→note Supersedes (cross-substrate), got {other:?}"
5949 ),
5950 }
5951 }
5952
5953 #[tokio::test]
5954 async fn link_supersedes_event_source_returns_invalid_input() {
5955 use khive_storage::Event;
5956 use khive_types::{EventKind, SubstrateKind};
5957
5958 let rt = rt();
5959 let tok = NamespaceToken::local();
5960 let ns = tok.namespace().as_str();
5961 let event = Event::new(
5962 ns,
5963 "test_verb",
5964 EventKind::Audit,
5965 SubstrateKind::Entity,
5966 "test_actor",
5967 );
5968 let event_id = event.id;
5969 rt.events(&tok).unwrap().append_event(event).await.unwrap();
5970
5971 let entity = rt
5972 .create_entity(&tok, "concept", None, "SomeEntity", None, None, vec![])
5973 .await
5974 .unwrap();
5975
5976 let result = rt
5977 .link(
5978 &tok,
5979 event_id,
5980 entity.id,
5981 EdgeRelation::Supersedes,
5982 1.0,
5983 None,
5984 )
5985 .await;
5986 match result {
5987 Err(RuntimeError::InvalidInput(msg)) => {
5988 assert!(msg.contains("event"), "error must mention 'event': {msg}");
5989 }
5990 other => {
5991 panic!("expected InvalidInput for event source with Supersedes, got {other:?}")
5992 }
5993 }
5994 }
5995
5996 #[tokio::test]
5997 async fn link_supersedes_event_target_returns_invalid_input() {
5998 use khive_storage::Event;
5999 use khive_types::{EventKind, SubstrateKind};
6000
6001 let rt = rt();
6002 let tok = NamespaceToken::local();
6003 let ns = tok.namespace().as_str();
6004 let event = Event::new(
6005 ns,
6006 "test_verb",
6007 EventKind::Audit,
6008 SubstrateKind::Entity,
6009 "test_actor",
6010 );
6011 let event_id = event.id;
6012 rt.events(&tok).unwrap().append_event(event).await.unwrap();
6013
6014 let entity = rt
6015 .create_entity(&tok, "concept", None, "SomeEntity", None, None, vec![])
6016 .await
6017 .unwrap();
6018
6019 let result = rt
6020 .link(
6021 &tok,
6022 entity.id,
6023 event_id,
6024 EdgeRelation::Supersedes,
6025 1.0,
6026 None,
6027 )
6028 .await;
6029 match result {
6030 Err(RuntimeError::InvalidInput(msg)) => {
6031 assert!(msg.contains("event"), "error must mention 'event': {msg}");
6032 }
6033 other => {
6034 panic!("expected InvalidInput for event target with Supersedes, got {other:?}")
6035 }
6036 }
6037 }
6038
6039 #[tokio::test]
6040 async fn link_supersedes_edge_source_returns_invalid_input() {
6041 let rt = rt();
6042 let tok = NamespaceToken::local();
6043 let a = rt
6044 .create_entity(&tok, "concept", None, "A", None, None, vec![])
6045 .await
6046 .unwrap();
6047 let b = rt
6048 .create_entity(&tok, "concept", None, "B", None, None, vec![])
6049 .await
6050 .unwrap();
6051 let edge = rt
6052 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
6053 .await
6054 .unwrap();
6055 let edge_uuid: Uuid = edge.id.into();
6056
6057 let result = rt
6058 .link(&tok, edge_uuid, a.id, EdgeRelation::Supersedes, 1.0, None)
6059 .await;
6060 match result {
6061 Err(RuntimeError::InvalidInput(msg)) => {
6062 assert!(msg.contains("source"), "error must name 'source': {msg}");
6063 }
6064 other => {
6065 panic!("expected InvalidInput for edge-uuid source with Supersedes, got {other:?}")
6066 }
6067 }
6068 }
6069
6070 #[tokio::test]
6071 async fn link_supersedes_edge_target_returns_invalid_input() {
6072 let rt = rt();
6073 let tok = NamespaceToken::local();
6074 let a = rt
6075 .create_entity(&tok, "concept", None, "A", None, None, vec![])
6076 .await
6077 .unwrap();
6078 let b = rt
6079 .create_entity(&tok, "concept", None, "B", None, None, vec![])
6080 .await
6081 .unwrap();
6082 let edge = rt
6083 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
6084 .await
6085 .unwrap();
6086 let edge_uuid: Uuid = edge.id.into();
6087
6088 let result = rt
6089 .link(&tok, a.id, edge_uuid, EdgeRelation::Supersedes, 1.0, None)
6090 .await;
6091 match result {
6092 Err(RuntimeError::InvalidInput(msg)) => {
6093 assert!(msg.contains("target"), "error must name 'target': {msg}");
6094 }
6095 other => {
6096 panic!("expected InvalidInput for edge-uuid target with Supersedes, got {other:?}")
6097 }
6098 }
6099 }
6100
6101 #[tokio::test]
6102 async fn link_supersedes_phantom_source_returns_not_found() {
6103 let rt = rt();
6104 let tok = NamespaceToken::local();
6105 let note = rt
6106 .create_note(
6107 &tok,
6108 "observation",
6109 None,
6110 "existing note",
6111 Some(0.5),
6112 None,
6113 vec![],
6114 )
6115 .await
6116 .unwrap();
6117 let phantom = Uuid::new_v4();
6118
6119 let result = rt
6120 .link(&tok, phantom, note.id, EdgeRelation::Supersedes, 1.0, None)
6121 .await;
6122 match result {
6123 Err(RuntimeError::NotFound(msg)) => {
6124 assert!(msg.contains("source"), "error must name 'source': {msg}");
6125 }
6126 other => panic!("expected NotFound for phantom source with Supersedes, got {other:?}"),
6127 }
6128 }
6129
6130 #[tokio::test]
6131 async fn link_supersedes_phantom_target_returns_not_found() {
6132 let rt = rt();
6133 let tok = NamespaceToken::local();
6134 let note = rt
6135 .create_note(
6136 &tok,
6137 "observation",
6138 None,
6139 "existing note",
6140 Some(0.5),
6141 None,
6142 vec![],
6143 )
6144 .await
6145 .unwrap();
6146 let phantom = Uuid::new_v4();
6147
6148 let result = rt
6149 .link(&tok, note.id, phantom, EdgeRelation::Supersedes, 1.0, None)
6150 .await;
6151 match result {
6152 Err(RuntimeError::NotFound(msg)) => {
6153 assert!(msg.contains("target"), "error must name 'target': {msg}");
6154 }
6155 other => panic!("expected NotFound for phantom target with Supersedes, got {other:?}"),
6156 }
6157 }
6158
6159 #[tokio::test]
6160 async fn link_supersedes_cross_namespace_source_returns_not_found() {
6161 let rt = rt();
6162 let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
6163 let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
6164 let note_a = rt
6165 .create_note(
6166 &ns_a,
6167 "observation",
6168 None,
6169 "note in ns-a",
6170 Some(0.5),
6171 None,
6172 vec![],
6173 )
6174 .await
6175 .unwrap();
6176 let note_b = rt
6177 .create_note(
6178 &ns_b,
6179 "observation",
6180 None,
6181 "note in ns-b",
6182 Some(0.5),
6183 None,
6184 vec![],
6185 )
6186 .await
6187 .unwrap();
6188
6189 let result = rt
6191 .link(
6192 &ns_a,
6193 note_b.id,
6194 note_a.id,
6195 EdgeRelation::Supersedes,
6196 1.0,
6197 None,
6198 )
6199 .await;
6200 assert!(
6201 matches!(result, Err(RuntimeError::NotFound(_))),
6202 "cross-namespace source with Supersedes must return NotFound (fail-closed), got {result:?}"
6203 );
6204 }
6205
6206 #[tokio::test]
6208 async fn link_extends_note_source_still_returns_invalid_input() {
6209 let rt = rt();
6210 let tok = NamespaceToken::local();
6211 let note = rt
6212 .create_note(
6213 &tok,
6214 "observation",
6215 None,
6216 "a note that cannot be an extends source",
6217 Some(0.5),
6218 None,
6219 vec![],
6220 )
6221 .await
6222 .unwrap();
6223 let entity = rt
6224 .create_entity(&tok, "concept", None, "E", None, None, vec![])
6225 .await
6226 .unwrap();
6227
6228 let result = rt
6229 .link(&tok, note.id, entity.id, EdgeRelation::Extends, 1.0, None)
6230 .await;
6231 assert!(
6232 matches!(result, Err(RuntimeError::InvalidInput(_))),
6233 "note source with Extends must still return InvalidInput after this fix, got {result:?}"
6234 );
6235 }
6236
6237 #[tokio::test]
6239 async fn link_annotates_note_to_edge_still_succeeds_after_fix() {
6240 let rt = rt();
6241 let tok = NamespaceToken::local();
6242 let a = rt
6243 .create_entity(&tok, "concept", None, "A", None, None, vec![])
6244 .await
6245 .unwrap();
6246 let b = rt
6247 .create_entity(&tok, "concept", None, "B", None, None, vec![])
6248 .await
6249 .unwrap();
6250 let edge = rt
6251 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
6252 .await
6253 .unwrap();
6254 let edge_uuid: Uuid = edge.id.into();
6255
6256 let note = rt
6257 .create_note(
6258 &tok,
6259 "observation",
6260 None,
6261 "annotating an edge",
6262 Some(0.5),
6263 None,
6264 vec![],
6265 )
6266 .await
6267 .unwrap();
6268
6269 let result = rt
6270 .link(&tok, note.id, edge_uuid, EdgeRelation::Annotates, 1.0, None)
6271 .await;
6272 assert!(
6273 result.is_ok(),
6274 "note→edge Annotates must still succeed after supersedes fix, got {result:?}"
6275 );
6276 }
6277
6278 #[tokio::test]
6292 async fn create_note_multi_annotates_compensation_cleanup_restores_pristine_state() {
6293 let rt = rt();
6294 let tok = NamespaceToken::local();
6295 let t1 = rt
6296 .create_entity(&tok, "concept", None, "T1", None, None, vec![])
6297 .await
6298 .unwrap();
6299
6300 let note = rt
6303 .create_note(
6304 &tok,
6305 "observation",
6306 None,
6307 "partial note",
6308 Some(0.5),
6309 None,
6310 vec![t1.id],
6311 )
6312 .await
6313 .unwrap();
6314
6315 let before_notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
6317 assert_eq!(before_notes.len(), 1, "note must be present before cleanup");
6318 let before_edges = rt
6319 .neighbors(
6320 &tok,
6321 note.id,
6322 Direction::Out,
6323 None,
6324 Some(vec![EdgeRelation::Annotates]),
6325 )
6326 .await
6327 .unwrap();
6328 assert_eq!(
6329 before_edges.len(),
6330 1,
6331 "one annotates edge must exist before cleanup"
6332 );
6333 let edge_id: Uuid = before_edges[0].edge_id;
6334
6335 rt.delete_edge(&tok, edge_id, true).await.unwrap();
6337 rt.delete_note(&tok, note.id, true )
6338 .await
6339 .unwrap();
6340
6341 let after_notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
6343 assert!(
6344 after_notes.is_empty(),
6345 "compensation must remove the note row; got {after_notes:?}"
6346 );
6347 let search_hits = rt
6348 .search_notes(&tok, "partial note", None, 10, None, false, &[], None)
6349 .await
6350 .unwrap();
6351 assert!(
6352 search_hits.is_empty(),
6353 "compensation must clean the FTS index; got {search_hits:?}"
6354 );
6355 let after_edges = rt
6356 .neighbors(&tok, note.id, Direction::Out, None, None)
6357 .await
6358 .unwrap();
6359 assert!(
6360 after_edges.is_empty(),
6361 "compensation must remove all partial edges; got {after_edges:?}"
6362 );
6363 }
6364
6365 #[tokio::test]
6373 async fn annotated_entity_hard_delete_cascades_annotate_edge() {
6374 let rt = rt();
6375 let tok = NamespaceToken::local();
6376 let entity = rt
6377 .create_entity(&tok, "concept", None, "E", None, None, vec![])
6378 .await
6379 .unwrap();
6380 let note = rt
6381 .create_note(
6382 &tok,
6383 "observation",
6384 None,
6385 "note about entity",
6386 Some(0.5),
6387 None,
6388 vec![entity.id],
6389 )
6390 .await
6391 .unwrap();
6392
6393 let before = rt
6395 .neighbors(
6396 &tok,
6397 note.id,
6398 Direction::Out,
6399 None,
6400 Some(vec![EdgeRelation::Annotates]),
6401 )
6402 .await
6403 .unwrap();
6404 assert_eq!(
6405 before.len(),
6406 1,
6407 "annotates edge must exist before entity delete"
6408 );
6409
6410 let deleted = rt.delete_entity(&tok, entity.id, true).await.unwrap();
6412 assert!(deleted, "entity hard delete must return true");
6413
6414 let after = rt
6416 .neighbors(
6417 &tok,
6418 note.id,
6419 Direction::Out,
6420 None,
6421 Some(vec![EdgeRelation::Annotates]),
6422 )
6423 .await
6424 .unwrap();
6425 assert!(
6426 after.is_empty(),
6427 "annotates edge must be cascaded on entity hard delete; got {after:?}"
6428 );
6429 }
6430
6431 #[tokio::test]
6432 async fn annotated_note_hard_delete_cascades_annotate_edge() {
6433 let rt = rt();
6434 let tok = NamespaceToken::local();
6435 let note_target = rt
6437 .create_note(
6438 &tok,
6439 "observation",
6440 None,
6441 "target note",
6442 Some(0.5),
6443 None,
6444 vec![],
6445 )
6446 .await
6447 .unwrap();
6448 let note_source = rt
6450 .create_note(
6451 &tok,
6452 "insight",
6453 None,
6454 "annotation",
6455 Some(0.5),
6456 None,
6457 vec![note_target.id],
6458 )
6459 .await
6460 .unwrap();
6461
6462 let before = rt
6463 .neighbors(
6464 &tok,
6465 note_source.id,
6466 Direction::Out,
6467 None,
6468 Some(vec![EdgeRelation::Annotates]),
6469 )
6470 .await
6471 .unwrap();
6472 assert_eq!(
6473 before.len(),
6474 1,
6475 "annotates edge must exist before note delete"
6476 );
6477
6478 let deleted = rt.delete_note(&tok, note_target.id, true).await.unwrap();
6480 assert!(deleted, "note hard delete must return true");
6481
6482 let after = rt
6484 .neighbors(
6485 &tok,
6486 note_source.id,
6487 Direction::Out,
6488 None,
6489 Some(vec![EdgeRelation::Annotates]),
6490 )
6491 .await
6492 .unwrap();
6493 assert!(
6494 after.is_empty(),
6495 "annotates edge must be cascaded on note-target hard delete; got {after:?}"
6496 );
6497 }
6498
6499 #[tokio::test]
6500 async fn annotated_edge_delete_cascades_annotate_edge() {
6501 let rt = rt();
6502 let tok = NamespaceToken::local();
6503 let a = rt
6504 .create_entity(&tok, "concept", None, "A", None, None, vec![])
6505 .await
6506 .unwrap();
6507 let b = rt
6508 .create_entity(&tok, "concept", None, "B", None, None, vec![])
6509 .await
6510 .unwrap();
6511 let base_edge = rt
6513 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
6514 .await
6515 .unwrap();
6516 let base_edge_uuid: Uuid = base_edge.id.into();
6517
6518 let note = rt
6520 .create_note(
6521 &tok,
6522 "observation",
6523 None,
6524 "note about edge",
6525 Some(0.5),
6526 None,
6527 vec![base_edge_uuid],
6528 )
6529 .await
6530 .unwrap();
6531
6532 let before = rt
6533 .neighbors(
6534 &tok,
6535 note.id,
6536 Direction::Out,
6537 None,
6538 Some(vec![EdgeRelation::Annotates]),
6539 )
6540 .await
6541 .unwrap();
6542 assert_eq!(
6543 before.len(),
6544 1,
6545 "annotates edge must exist before base edge delete"
6546 );
6547
6548 let deleted = rt.delete_edge(&tok, base_edge_uuid, true).await.unwrap();
6550 assert!(deleted, "edge delete must return true");
6551
6552 let after = rt
6554 .neighbors(
6555 &tok,
6556 note.id,
6557 Direction::Out,
6558 None,
6559 Some(vec![EdgeRelation::Annotates]),
6560 )
6561 .await
6562 .unwrap();
6563 assert!(
6564 after.is_empty(),
6565 "annotates edge must be cascaded on base edge delete; got {after:?}"
6566 );
6567 }
6568
6569 #[tokio::test]
6570 async fn mixed_multi_annotates_partial_target_hard_delete_leaves_remaining_edges() {
6571 let rt = rt();
6572 let tok = NamespaceToken::local();
6573 let t1 = rt
6574 .create_entity(&tok, "concept", None, "T1", None, None, vec![])
6575 .await
6576 .unwrap();
6577 let t2 = rt
6578 .create_entity(&tok, "concept", None, "T2", None, None, vec![])
6579 .await
6580 .unwrap();
6581
6582 let note = rt
6584 .create_note(
6585 &tok,
6586 "observation",
6587 None,
6588 "multi-target note",
6589 Some(0.5),
6590 None,
6591 vec![t1.id, t2.id],
6592 )
6593 .await
6594 .unwrap();
6595
6596 let before = rt
6597 .neighbors(
6598 &tok,
6599 note.id,
6600 Direction::Out,
6601 None,
6602 Some(vec![EdgeRelation::Annotates]),
6603 )
6604 .await
6605 .unwrap();
6606 assert_eq!(
6607 before.len(),
6608 2,
6609 "must have 2 annotates edges before any delete"
6610 );
6611
6612 rt.delete_entity(&tok, t1.id, true).await.unwrap();
6614
6615 let after = rt
6617 .neighbors(
6618 &tok,
6619 note.id,
6620 Direction::Out,
6621 None,
6622 Some(vec![EdgeRelation::Annotates]),
6623 )
6624 .await
6625 .unwrap();
6626 assert_eq!(
6627 after.len(),
6628 1,
6629 "only the edge to t1 must be cascaded; t2 edge must remain"
6630 );
6631 assert_eq!(
6632 after[0].node_id, t2.id,
6633 "remaining annotates edge must point to t2"
6634 );
6635 }
6636
6637 #[tokio::test]
6638 async fn annotated_note_soft_delete_preserves_annotate_edge() {
6639 let rt = rt();
6640 let tok = NamespaceToken::local();
6641 let note_target = rt
6642 .create_note(&tok, "observation", None, "target", Some(0.5), None, vec![])
6643 .await
6644 .unwrap();
6645 let note_source = rt
6646 .create_note(
6647 &tok,
6648 "insight",
6649 None,
6650 "annotation",
6651 Some(0.5),
6652 None,
6653 vec![note_target.id],
6654 )
6655 .await
6656 .unwrap();
6657
6658 let before = rt
6659 .neighbors(
6660 &tok,
6661 note_source.id,
6662 Direction::Out,
6663 None,
6664 Some(vec![EdgeRelation::Annotates]),
6665 )
6666 .await
6667 .unwrap();
6668 assert_eq!(before.len(), 1);
6669
6670 let deleted = rt.delete_note(&tok, note_target.id, false).await.unwrap();
6672 assert!(deleted, "soft delete must return true");
6673
6674 let after = rt
6675 .neighbors(
6676 &tok,
6677 note_source.id,
6678 Direction::Out,
6679 None,
6680 Some(vec![EdgeRelation::Annotates]),
6681 )
6682 .await
6683 .unwrap();
6684 assert_eq!(
6685 after.len(),
6686 1,
6687 "soft delete must NOT cascade edges; got {after:?}"
6688 );
6689 }
6690
6691 #[tokio::test]
6698 async fn delete_edge_non_edge_uuid_has_no_side_effects() {
6699 let rt = rt();
6700 let tok = NamespaceToken::local();
6701
6702 let entity = rt
6704 .create_entity(&tok, "concept", None, "Target", None, None, vec![])
6705 .await
6706 .unwrap();
6707 let note = rt
6708 .create_note(
6709 &tok,
6710 "observation",
6711 None,
6712 "annotates the entity",
6713 Some(0.5),
6714 None,
6715 vec![entity.id],
6716 )
6717 .await
6718 .unwrap();
6719
6720 let before = rt
6722 .neighbors(
6723 &tok,
6724 note.id,
6725 Direction::Out,
6726 None,
6727 Some(vec![EdgeRelation::Annotates]),
6728 )
6729 .await
6730 .unwrap();
6731 assert_eq!(before.len(), 1, "annotates edge must exist before test");
6732 let annotates_edge_id: Uuid = before[0].edge_id;
6733
6734 let result = rt.delete_edge(&tok, entity.id, true).await;
6736 assert!(
6737 result.is_ok(),
6738 "delete_edge must not error on a non-edge UUID"
6739 );
6740 assert!(
6741 !result.unwrap(),
6742 "delete_edge must return false for a non-edge UUID"
6743 );
6744
6745 let after = rt
6747 .neighbors(
6748 &tok,
6749 note.id,
6750 Direction::Out,
6751 None,
6752 Some(vec![EdgeRelation::Annotates]),
6753 )
6754 .await
6755 .unwrap();
6756 assert_eq!(
6757 after.len(),
6758 1,
6759 "delete_edge with a non-edge UUID must not touch inbound annotates edges"
6760 );
6761 assert_eq!(
6762 after[0].edge_id, annotates_edge_id,
6763 "the original annotates edge must be unchanged"
6764 );
6765 }
6766
6767 #[tokio::test]
6778 async fn create_note_multi_annotates_second_link_failure_rolls_back_partial_write() {
6779 let rt = rt();
6780 let tok = NamespaceToken::local();
6781 let t1 = rt
6782 .create_entity(&tok, "concept", None, "T1", None, None, vec![])
6783 .await
6784 .unwrap();
6785 let t2 = rt
6786 .create_entity(&tok, "concept", None, "T2", None, None, vec![])
6787 .await
6788 .unwrap();
6789
6790 LINK_FAIL_AFTER.with(|cell| cell.set(2));
6792
6793 let result = rt
6794 .create_note(
6795 &tok,
6796 "observation",
6797 None,
6798 "rollback target",
6799 Some(0.5),
6800 None,
6801 vec![t1.id, t2.id],
6802 )
6803 .await;
6804
6805 assert!(
6807 result.is_err(),
6808 "create_note must propagate the injected link failure"
6809 );
6810 let err_msg = result.unwrap_err().to_string();
6811 assert!(
6812 err_msg.contains("injected link failure"),
6813 "error must carry injection message; got: {err_msg}"
6814 );
6815
6816 let notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
6818 assert!(
6819 notes.is_empty(),
6820 "compensation must remove the note row; got {notes:?}"
6821 );
6822
6823 let hits = rt
6825 .search_notes(&tok, "rollback target", None, 10, None, false, &[], None)
6826 .await
6827 .unwrap();
6828 assert!(
6829 hits.is_empty(),
6830 "compensation must clean FTS index; got {hits:?}"
6831 );
6832
6833 let edges_from_t1 = rt
6835 .neighbors(
6836 &tok,
6837 t1.id,
6838 Direction::In,
6839 None,
6840 Some(vec![EdgeRelation::Annotates]),
6841 )
6842 .await
6843 .unwrap();
6844 let edges_from_t2 = rt
6845 .neighbors(
6846 &tok,
6847 t2.id,
6848 Direction::In,
6849 None,
6850 Some(vec![EdgeRelation::Annotates]),
6851 )
6852 .await
6853 .unwrap();
6854 assert!(
6855 edges_from_t1.is_empty(),
6856 "compensation must delete the first annotates edge; got {edges_from_t1:?}"
6857 );
6858 assert!(
6859 edges_from_t2.is_empty(),
6860 "no second annotates edge must exist; got {edges_from_t2:?}"
6861 );
6862 }
6863
6864 #[tokio::test]
6868 async fn create_note_fts_failure_rolls_back_note_row() {
6869 let rt = rt();
6870 let ns = Namespace::parse("fault-fts-rollback").unwrap();
6876 let tok = NamespaceToken::for_namespace(ns.clone());
6877
6878 arm_fts_fail(ns.as_str());
6879
6880 let result = rt
6881 .create_note(
6882 &tok,
6883 "observation",
6884 None,
6885 "fts-fail rollback target",
6886 None,
6887 None,
6888 vec![],
6889 )
6890 .await;
6891
6892 assert!(
6893 result.is_err(),
6894 "create_note must propagate the injected FTS failure"
6895 );
6896 let err_msg = result.unwrap_err().to_string();
6897 assert!(
6898 err_msg.contains("injected FTS failure"),
6899 "error must carry injection message; got: {err_msg}"
6900 );
6901
6902 let notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
6904 assert!(
6905 notes.is_empty(),
6906 "compensation must remove the note row after FTS failure; got {notes:?}"
6907 );
6908 }
6909
6910 #[tokio::test]
6918 async fn create_note_vector_failure_rolls_back_note_row_and_fts() {
6919 const MODEL: &str = "test-vec-inject";
6920 const DIMS: usize = 4;
6921
6922 let rt = KhiveRuntime::memory().unwrap();
6923 let (provider, _counter) = ConstVecProvider::new(MODEL, DIMS);
6924 rt.register_embedder(provider);
6925
6926 let ns = Namespace::parse("fault-vec-rollback").unwrap();
6927 let tok = NamespaceToken::for_namespace(ns.clone());
6928
6929 arm_vector_fail(ns.as_str());
6930
6931 let result = rt
6932 .create_note(
6933 &tok,
6934 "observation",
6935 None,
6936 "vec-fail rollback target",
6937 None,
6938 None,
6939 vec![],
6940 )
6941 .await;
6942
6943 assert!(
6944 result.is_err(),
6945 "create_note must propagate the injected vector failure"
6946 );
6947 let err_msg = result.unwrap_err().to_string();
6948 assert!(
6949 err_msg.contains("injected vector failure"),
6950 "error must carry injection message; got: {err_msg}"
6951 );
6952
6953 let notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
6955 assert!(
6956 notes.is_empty(),
6957 "compensation must remove note row after vector failure; got {notes:?}"
6958 );
6959 }
6960
6961 #[tokio::test]
6964 async fn soft_delete_entity_removes_indexes() {
6965 let rt = rt();
6966 let tok = NamespaceToken::local();
6967 let entity = rt
6968 .create_entity(
6969 &tok,
6970 "concept",
6971 None,
6972 "QuantumEntanglement",
6973 Some("unique FTS term xzqjwv for soft delete test"),
6974 None,
6975 vec![],
6976 )
6977 .await
6978 .unwrap();
6979
6980 let ns = tok.namespace().as_str().to_string();
6981
6982 let before = rt
6983 .text(&tok)
6984 .unwrap()
6985 .search(TextSearchRequest {
6986 query: "xzqjwv".to_string(),
6987 mode: TextQueryMode::Plain,
6988 filter: Some(TextFilter {
6989 namespaces: vec![ns.clone()],
6990 ..Default::default()
6991 }),
6992 top_k: 10,
6993 snippet_chars: 100,
6994 })
6995 .await
6996 .unwrap();
6997 assert!(
6998 before.iter().any(|h| h.subject_id == entity.id),
6999 "entity must be in FTS before soft-delete"
7000 );
7001
7002 let deleted = rt.delete_entity(&tok, entity.id, false).await.unwrap();
7003 assert!(deleted, "soft delete must return true");
7004
7005 let after = rt
7006 .text(&tok)
7007 .unwrap()
7008 .search(TextSearchRequest {
7009 query: "xzqjwv".to_string(),
7010 mode: TextQueryMode::Plain,
7011 filter: Some(TextFilter {
7012 namespaces: vec![ns],
7013 ..Default::default()
7014 }),
7015 top_k: 10,
7016 snippet_chars: 100,
7017 })
7018 .await
7019 .unwrap();
7020 assert!(
7021 after.iter().all(|h| h.subject_id != entity.id),
7022 "soft-deleted entity must be removed from FTS index"
7023 );
7024 }
7025
7026 #[tokio::test]
7027 async fn soft_delete_note_removes_indexes() {
7028 let rt = rt();
7029 let tok = NamespaceToken::local();
7030 let note = rt
7031 .create_note(
7032 &tok,
7033 "observation",
7034 None,
7035 "SpectralDecomposition unique term yvwkqz for soft delete test",
7036 Some(0.7),
7037 None,
7038 vec![],
7039 )
7040 .await
7041 .unwrap();
7042
7043 let before = rt
7044 .search_notes(&tok, "yvwkqz", None, 10, None, false, &[], None)
7045 .await
7046 .unwrap();
7047 assert!(
7048 before.iter().any(|h| h.note_id == note.id),
7049 "note must be in FTS before soft-delete"
7050 );
7051
7052 let deleted = rt.delete_note(&tok, note.id, false).await.unwrap();
7053 assert!(deleted, "soft delete must return true");
7054
7055 let after = rt
7056 .search_notes(&tok, "yvwkqz", None, 10, None, false, &[], None)
7057 .await
7058 .unwrap();
7059 assert!(
7060 after.iter().all(|h| h.note_id != note.id),
7061 "soft-deleted note must be removed from FTS index"
7062 );
7063 }
7064
7065 #[tokio::test]
7068 async fn link_extends_document_to_document_returns_invalid_input() {
7069 let rt = rt();
7070 let tok = NamespaceToken::local();
7071 let d1 = rt
7072 .create_entity(&tok, "document", None, "DocA", None, None, vec![])
7073 .await
7074 .unwrap();
7075 let d2 = rt
7076 .create_entity(&tok, "document", None, "DocB", None, None, vec![])
7077 .await
7078 .unwrap();
7079 let result = rt
7080 .link(&tok, d1.id, d2.id, EdgeRelation::Extends, 1.0, None)
7081 .await;
7082 assert!(
7083 result.is_err(),
7084 "F010: document->document Extends must be rejected by the base allowlist; \
7085 current generic entity fallthrough incorrectly accepts it"
7086 );
7087 }
7088
7089 #[tokio::test]
7091 async fn link_extends_concept_to_concept_succeeds() {
7092 let rt = rt();
7093 let tok = NamespaceToken::local();
7094 let a = rt
7095 .create_entity(&tok, "concept", None, "CA", None, None, vec![])
7096 .await
7097 .unwrap();
7098 let b = rt
7099 .create_entity(&tok, "concept", None, "CB", None, None, vec![])
7100 .await
7101 .unwrap();
7102 let result = rt
7103 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
7104 .await;
7105 assert!(
7106 result.is_ok(),
7107 "F010: concept->concept Extends must be allowed (base allowlist)"
7108 );
7109 }
7110
7111 #[tokio::test]
7114 async fn link_symmetric_relation_canonicalizes_endpoint_order() {
7115 use khive_storage::EdgeFilter;
7116 let rt = rt();
7117 let tok = NamespaceToken::local();
7118 let a = rt
7119 .create_entity(&tok, "concept", None, "ConceptP", None, None, vec![])
7120 .await
7121 .unwrap();
7122 let b = rt
7123 .create_entity(&tok, "concept", None, "ConceptQ", None, None, vec![])
7124 .await
7125 .unwrap();
7126 rt.link(&tok, a.id, b.id, EdgeRelation::CompetesWith, 1.0, None)
7128 .await
7129 .unwrap();
7130 rt.link(&tok, b.id, a.id, EdgeRelation::CompetesWith, 1.0, None)
7131 .await
7132 .unwrap();
7133 let count = rt
7134 .graph(&tok)
7135 .unwrap()
7136 .count_edges(EdgeFilter::default())
7137 .await
7138 .unwrap();
7139 assert_eq!(
7140 count,
7141 1,
7142 "F012: CompetesWith is symmetric; A->B and B->A must deduplicate to one canonical row; \
7143 found {count} rows (canonicalization not yet implemented)"
7144 );
7145 }
7146
7147 #[tokio::test]
7149 async fn f010_supersedes_document_to_document_allowed() {
7150 let rt = rt();
7151 let tok = NamespaceToken::local();
7152 let a = rt
7153 .create_entity(&tok, "document", None, "DocA", None, None, vec![])
7154 .await
7155 .unwrap();
7156 let b = rt
7157 .create_entity(&tok, "document", None, "DocB", None, None, vec![])
7158 .await
7159 .unwrap();
7160 let result = rt
7161 .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
7162 .await;
7163 assert!(
7164 result.is_ok(),
7165 "document->document Supersedes must be allowed (allowlist), got {result:?}"
7166 );
7167 }
7168
7169 #[tokio::test]
7170 async fn f010_supersedes_artifact_to_artifact_allowed() {
7171 let rt = rt();
7172 let tok = NamespaceToken::local();
7173 let a = rt
7174 .create_entity(&tok, "artifact", None, "ArtA", None, None, vec![])
7175 .await
7176 .unwrap();
7177 let b = rt
7178 .create_entity(&tok, "artifact", None, "ArtB", None, None, vec![])
7179 .await
7180 .unwrap();
7181 let result = rt
7182 .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
7183 .await;
7184 assert!(
7185 result.is_ok(),
7186 "artifact->artifact Supersedes must be allowed (allowlist), got {result:?}"
7187 );
7188 }
7189
7190 #[tokio::test]
7191 async fn f010_supersedes_service_to_service_allowed() {
7192 let rt = rt();
7193 let tok = NamespaceToken::local();
7194 let a = rt
7195 .create_entity(&tok, "service", None, "SvcA", None, None, vec![])
7196 .await
7197 .unwrap();
7198 let b = rt
7199 .create_entity(&tok, "service", None, "SvcB", None, None, vec![])
7200 .await
7201 .unwrap();
7202 let result = rt
7203 .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
7204 .await;
7205 assert!(
7206 result.is_ok(),
7207 "service->service Supersedes must be allowed (allowlist), got {result:?}"
7208 );
7209 }
7210
7211 #[tokio::test]
7212 async fn f010_supersedes_dataset_to_dataset_allowed() {
7213 let rt = rt();
7214 let tok = NamespaceToken::local();
7215 let a = rt
7216 .create_entity(&tok, "dataset", None, "DataA", None, None, vec![])
7217 .await
7218 .unwrap();
7219 let b = rt
7220 .create_entity(&tok, "dataset", None, "DataB", None, None, vec![])
7221 .await
7222 .unwrap();
7223 let result = rt
7224 .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
7225 .await;
7226 assert!(
7227 result.is_ok(),
7228 "dataset->dataset Supersedes must be allowed (allowlist), got {result:?}"
7229 );
7230 }
7231
7232 #[tokio::test]
7234 async fn f010_supersedes_project_to_project_rejected() {
7235 let rt = rt();
7236 let tok = NamespaceToken::local();
7237 let a = rt
7238 .create_entity(&tok, "project", None, "ProjA", None, None, vec![])
7239 .await
7240 .unwrap();
7241 let b = rt
7242 .create_entity(&tok, "project", None, "ProjB", None, None, vec![])
7243 .await
7244 .unwrap();
7245 let result = rt
7246 .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
7247 .await;
7248 assert!(
7249 matches!(result, Err(RuntimeError::InvalidInput(_))),
7250 "project->project Supersedes must be rejected (not in allowlist), got {result:?}"
7251 );
7252 }
7253
7254 #[tokio::test]
7255 async fn f010_supersedes_person_to_person_rejected() {
7256 let rt = rt();
7257 let tok = NamespaceToken::local();
7258 let a = rt
7259 .create_entity(&tok, "person", None, "Alice", None, None, vec![])
7260 .await
7261 .unwrap();
7262 let b = rt
7263 .create_entity(&tok, "person", None, "Bob", None, None, vec![])
7264 .await
7265 .unwrap();
7266 let result = rt
7267 .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
7268 .await;
7269 assert!(
7270 matches!(result, Err(RuntimeError::InvalidInput(_))),
7271 "person->person Supersedes must be rejected (not in allowlist), got {result:?}"
7272 );
7273 }
7274
7275 #[tokio::test]
7276 async fn f010_supersedes_org_to_org_rejected() {
7277 let rt = rt();
7278 let tok = NamespaceToken::local();
7279 let a = rt
7280 .create_entity(&tok, "org", None, "OrgA", None, None, vec![])
7281 .await
7282 .unwrap();
7283 let b = rt
7284 .create_entity(&tok, "org", None, "OrgB", None, None, vec![])
7285 .await
7286 .unwrap();
7287 let result = rt
7288 .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
7289 .await;
7290 assert!(
7291 matches!(result, Err(RuntimeError::InvalidInput(_))),
7292 "org->org Supersedes must be rejected (not in allowlist), got {result:?}"
7293 );
7294 }
7295
7296 #[tokio::test]
7298 async fn f010_supersedes_same_kind_entity_allowed() {
7299 let rt = rt();
7300 let tok = NamespaceToken::local();
7301 let a = rt
7302 .create_entity(&tok, "concept", None, "OldV", None, None, vec![])
7303 .await
7304 .unwrap();
7305 let b = rt
7306 .create_entity(&tok, "concept", None, "NewV", None, None, vec![])
7307 .await
7308 .unwrap();
7309 let result = rt
7310 .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
7311 .await;
7312 assert!(
7313 result.is_ok(),
7314 "concept->concept Supersedes must be allowed by the base allowlist, got {result:?}"
7315 );
7316 }
7317
7318 #[tokio::test]
7322 async fn f161_link_always_writes_null_target_backend() {
7323 let rt = rt();
7324 let tok = NamespaceToken::local();
7325 let a = rt
7326 .create_entity(&tok, "concept", None, "A", None, None, vec![])
7327 .await
7328 .unwrap();
7329 let b = rt
7330 .create_entity(&tok, "concept", None, "B", None, None, vec![])
7331 .await
7332 .unwrap();
7333 let edge = rt
7334 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
7335 .await
7336 .unwrap();
7337 assert!(
7338 edge.target_backend.is_none(),
7339 "F161: target_backend must be None for locally-routed edges; got {:?}",
7340 edge.target_backend
7341 );
7342 }
7343
7344 #[tokio::test]
7346 async fn f161_link_many_always_writes_null_target_backend() {
7347 let rt = rt();
7348 let tok = NamespaceToken::local();
7349 let a = rt
7350 .create_entity(&tok, "concept", None, "A", None, None, vec![])
7351 .await
7352 .unwrap();
7353 let b = rt
7354 .create_entity(&tok, "concept", None, "B", None, None, vec![])
7355 .await
7356 .unwrap();
7357 let c = rt
7358 .create_entity(&tok, "concept", None, "C", None, None, vec![])
7359 .await
7360 .unwrap();
7361 let specs = vec![
7362 LinkSpec {
7363 namespace: None,
7364 source_id: a.id,
7365 target_id: b.id,
7366 relation: EdgeRelation::Extends,
7367 weight: 1.0,
7368 metadata: None,
7369 },
7370 LinkSpec {
7371 namespace: None,
7372 source_id: a.id,
7373 target_id: c.id,
7374 relation: EdgeRelation::Enables,
7375 weight: 1.0,
7376 metadata: None,
7377 },
7378 ];
7379 let edges = rt.link_many(&tok, specs).await.unwrap();
7380 for edge in &edges {
7381 assert!(
7382 edge.target_backend.is_none(),
7383 "F161: target_backend must be None for locally-routed edges in link_many; got {:?}",
7384 edge.target_backend
7385 );
7386 }
7387 }
7388
7389 #[tokio::test]
7392 async fn f012_symmetric_neighbors_visible_from_both_endpoints() {
7393 let rt = rt();
7394 let tok = NamespaceToken::local();
7395 let a = rt
7396 .create_entity(&tok, "concept", None, "A", None, None, vec![])
7397 .await
7398 .unwrap();
7399 let b = rt
7400 .create_entity(&tok, "concept", None, "B", None, None, vec![])
7401 .await
7402 .unwrap();
7403 rt.link(&tok, a.id, b.id, EdgeRelation::CompetesWith, 1.0, None)
7405 .await
7406 .unwrap();
7407 let from_a = rt
7409 .neighbors(
7410 &tok,
7411 a.id,
7412 Direction::Out,
7413 None,
7414 Some(vec![EdgeRelation::CompetesWith]),
7415 )
7416 .await
7417 .unwrap();
7418 let from_b = rt
7419 .neighbors(
7420 &tok,
7421 b.id,
7422 Direction::Out,
7423 None,
7424 Some(vec![EdgeRelation::CompetesWith]),
7425 )
7426 .await
7427 .unwrap();
7428 assert_eq!(
7429 from_a.len(),
7430 1,
7431 "node A must see competes_with neighbor from Direction::Out (F012); got {from_a:?}"
7432 );
7433 assert_eq!(
7434 from_b.len(),
7435 1,
7436 "node B must see competes_with neighbor from Direction::Out (F012); got {from_b:?}"
7437 );
7438 }
7439
7440 #[tokio::test]
7442 async fn f010_supersedes_cross_kind_entity_rejected() {
7443 let rt = rt();
7444 let tok = NamespaceToken::local();
7445 let concept = rt
7446 .create_entity(&tok, "concept", None, "MyConcept", None, None, vec![])
7447 .await
7448 .unwrap();
7449 let doc = rt
7450 .create_entity(&tok, "document", None, "MyDoc", None, None, vec![])
7451 .await
7452 .unwrap();
7453 let result = rt
7454 .link(
7455 &tok,
7456 concept.id,
7457 doc.id,
7458 EdgeRelation::Supersedes,
7459 1.0,
7460 None,
7461 )
7462 .await;
7463 assert!(
7464 matches!(result, Err(RuntimeError::InvalidInput(_))),
7465 "concept->document Supersedes must be rejected by the base allowlist, got {result:?}"
7466 );
7467 }
7468
7469 #[tokio::test]
7472 async fn delete_note_cross_namespace_succeeds() {
7473 let rt = rt();
7474 let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
7475 let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
7476 let note = rt
7477 .create_note(
7478 &ns_a,
7479 "observation",
7480 None,
7481 "note in ns-a",
7482 Some(0.8),
7483 None,
7484 vec![],
7485 )
7486 .await
7487 .unwrap();
7488
7489 let result = rt.delete_note(&ns_b, note.id, false).await;
7491 assert!(
7492 result.unwrap(),
7493 "cross-namespace delete_note (soft) must return Ok(true)"
7494 );
7495
7496 let note_store = rt.notes(&ns_a).unwrap();
7498 let gone = note_store.get_note(note.id).await.unwrap();
7499 assert!(
7500 gone.is_none(),
7501 "note must be soft-deleted in its home namespace after cross-ns delete"
7502 );
7503
7504 let note2 = rt
7506 .create_note(
7507 &ns_a,
7508 "observation",
7509 None,
7510 "note2 in ns-a",
7511 Some(0.5),
7512 None,
7513 vec![],
7514 )
7515 .await
7516 .unwrap();
7517 let hard_result = rt.delete_note(&ns_b, note2.id, true).await;
7518 assert!(
7519 hard_result.unwrap(),
7520 "cross-namespace hard delete_note must return Ok(true)"
7521 );
7522 let gone2 = rt
7523 .get_note_including_deleted(&ns_a, note2.id)
7524 .await
7525 .unwrap();
7526 assert!(
7527 gone2.is_none(),
7528 "hard-deleted note must not appear even in including_deleted query"
7529 );
7530 }
7531
7532 #[tokio::test]
7540 async fn link_many_overlapping_triple_returns_persisted_ids() {
7541 let rt = rt();
7542 let tok = NamespaceToken::local();
7543 let a = rt
7544 .create_entity(&tok, "concept", None, "A", None, None, vec![])
7545 .await
7546 .unwrap();
7547 let b = rt
7548 .create_entity(&tok, "concept", None, "B", None, None, vec![])
7549 .await
7550 .unwrap();
7551
7552 let spec = || LinkSpec {
7553 namespace: None,
7554 source_id: a.id,
7555 target_id: b.id,
7556 relation: EdgeRelation::Extends,
7557 weight: 1.0,
7558 metadata: None,
7559 };
7560
7561 let first = rt.link_many(&tok, vec![spec()]).await.unwrap();
7563 assert_eq!(first.len(), 1);
7564 let persisted_id: Uuid = first[0].id.into();
7565
7566 let second = rt.link_many(&tok, vec![spec()]).await.unwrap();
7569 assert_eq!(second.len(), 1);
7570 let second_id: Uuid = second[0].id.into();
7571
7572 assert_eq!(
7573 persisted_id, second_id,
7574 "link_many with an existing triple must return the persisted row ID ({persisted_id}), \
7575 not a new phantom ID ({second_id})"
7576 );
7577
7578 let count = rt
7580 .count_edges(&tok, crate::curation::EdgeListFilter::default())
7581 .await
7582 .unwrap();
7583 assert_eq!(count, 1, "upsert must not duplicate the edge row");
7584 }
7585
7586 #[tokio::test]
7589 async fn create_many_persists_all_entities() {
7590 let rt = rt();
7591 let tok = NamespaceToken::local();
7592
7593 let specs: Vec<EntityCreateSpec> = (0..5)
7594 .map(|i| EntityCreateSpec {
7595 kind: "concept".into(),
7596 entity_type: None,
7597 name: format!("BulkConcept-{i}"),
7598 description: Some(format!("desc {i}")),
7599 properties: None,
7600 tags: vec!["bulk-test".into()],
7601 })
7602 .collect();
7603
7604 let entities = rt.create_many(&tok, specs).await.unwrap();
7605 assert_eq!(entities.len(), 5, "all 5 entities must be returned");
7606
7607 for entity in &entities {
7609 let fetched = rt.get_entity(&tok, entity.id).await.unwrap();
7610 assert_eq!(fetched.id, entity.id);
7611 }
7612 }
7613
7614 #[tokio::test]
7615 async fn create_many_empty_name_rejects_atomically() {
7616 let rt = rt();
7617 let tok = NamespaceToken::local();
7618
7619 let specs = vec![
7620 EntityCreateSpec {
7621 kind: "concept".into(),
7622 entity_type: None,
7623 name: "ValidEntity".into(),
7624 description: None,
7625 properties: None,
7626 tags: vec![],
7627 },
7628 EntityCreateSpec {
7629 kind: "concept".into(),
7630 entity_type: None,
7631 name: "".into(), description: None,
7633 properties: None,
7634 tags: vec![],
7635 },
7636 ];
7637
7638 let result = rt.create_many(&tok, specs).await;
7639 assert!(
7640 matches!(result, Err(RuntimeError::InvalidInput(_))),
7641 "empty name must produce InvalidInput error"
7642 );
7643
7644 let rows = rt.list_entities(&tok, None, None, 100, 0).await.unwrap();
7646 assert_eq!(
7647 rows.len(),
7648 0,
7649 "atomic rejection must leave storage unchanged"
7650 );
7651 }
7652
7653 #[tokio::test]
7655 async fn create_many_rejects_unknown_entity_type_when_validator_installed() {
7656 let rt = rt();
7657 let tok = NamespaceToken::local();
7658
7659 rt.install_entity_type_validator(Arc::new(|kind, entity_type| {
7661 let Some(raw) = entity_type else {
7662 return Ok(None);
7663 };
7664 if kind == "concept" && raw == "algorithm" {
7665 return Ok(Some("algorithm".to_string()));
7666 }
7667 Err(RuntimeError::InvalidInput(format!(
7668 "unknown entity_type {raw:?} for {kind:?}; valid: algorithm"
7669 )))
7670 }));
7671
7672 let bad_spec = vec![EntityCreateSpec {
7673 kind: "concept".into(),
7674 entity_type: Some("not_a_registered_type".into()),
7675 name: "ShouldNotLand".into(),
7676 description: None,
7677 properties: None,
7678 tags: vec![],
7679 }];
7680
7681 let result = rt.create_many(&tok, bad_spec).await;
7682 assert!(
7683 matches!(result, Err(RuntimeError::InvalidInput(_))),
7684 "unknown entity_type must be rejected by the runtime-layer validator; got {result:?}"
7685 );
7686
7687 let rows = rt.list_entities(&tok, None, None, 100, 0).await.unwrap();
7689 assert_eq!(
7690 rows.len(),
7691 0,
7692 "validator rejection must leave storage empty"
7693 );
7694 }
7695
7696 #[tokio::test]
7698 async fn create_many_accepts_valid_entity_type_via_validator() {
7699 let rt = rt();
7700 let tok = NamespaceToken::local();
7701
7702 rt.install_entity_type_validator(Arc::new(|kind, entity_type| {
7703 let Some(raw) = entity_type else {
7704 return Ok(None);
7705 };
7706 if kind == "concept" && raw == "algorithm" {
7707 return Ok(Some("algorithm".to_string()));
7708 }
7709 Err(RuntimeError::InvalidInput(format!(
7710 "unknown entity_type {raw:?} for {kind:?}"
7711 )))
7712 }));
7713
7714 let specs = vec![EntityCreateSpec {
7715 kind: "concept".into(),
7716 entity_type: Some("algorithm".into()),
7717 name: "BubbleSort".into(),
7718 description: None,
7719 properties: None,
7720 tags: vec![],
7721 }];
7722
7723 let entities = rt.create_many(&tok, specs).await.unwrap();
7724 assert_eq!(entities.len(), 1, "valid entity_type must be accepted");
7725 assert_eq!(
7726 entities[0].entity_type.as_deref(),
7727 Some("algorithm"),
7728 "entity_type must be stored as returned by the validator"
7729 );
7730 }
7731
7732 #[tokio::test]
7737 async fn create_many_fts_failure_rolls_back_both_substrates() {
7738 let ns = format!("fts-fail-many-{}", uuid::Uuid::new_v4().as_simple());
7741 let rt = rt();
7742 let tok = NamespaceToken::for_namespace(Namespace::parse(&ns).unwrap());
7743
7744 let specs = vec![
7745 EntityCreateSpec {
7746 kind: "concept".into(),
7747 entity_type: None,
7748 name: "FtsRollbackA".into(),
7749 description: None,
7750 properties: None,
7751 tags: vec![],
7752 },
7753 EntityCreateSpec {
7754 kind: "concept".into(),
7755 entity_type: None,
7756 name: "FtsRollbackB".into(),
7757 description: None,
7758 properties: None,
7759 tags: vec![],
7760 },
7761 ];
7762
7763 arm_fts_fail_many(&ns);
7764 let result = rt.create_many(&tok, specs).await;
7765
7766 assert!(
7767 result.is_err(),
7768 "create_many must return Err when FTS write fails"
7769 );
7770
7771 let entity_rows = rt.list_entities(&tok, None, None, 100, 0).await.unwrap();
7773 assert_eq!(
7774 entity_rows.len(),
7775 0,
7776 "entity rows must be rolled back on FTS failure; found {entity_rows:?}"
7777 );
7778
7779 let fts = rt.text(&tok).unwrap();
7781 let fts_count = fts
7782 .count(TextFilter {
7783 ids: vec![],
7784 kinds: vec![],
7785 namespaces: vec![ns.clone()],
7786 })
7787 .await
7788 .unwrap();
7789 assert_eq!(
7790 fts_count, 0,
7791 "fts_entities must be empty after FTS-failure rollback; found {fts_count}"
7792 );
7793 }
7794
7795 #[tokio::test]
7804 async fn create_many_fts_partial_failure_rolls_back_both_substrates() {
7805 let ns = format!("fts-fail-partial-{}", uuid::Uuid::new_v4().as_simple());
7806 let rt = rt();
7807 let tok = NamespaceToken::for_namespace(Namespace::parse(&ns).unwrap());
7808
7809 let specs = vec![
7810 EntityCreateSpec {
7811 kind: "concept".into(),
7812 entity_type: None,
7813 name: "PartialRollbackA".into(),
7814 description: None,
7815 properties: None,
7816 tags: vec![],
7817 },
7818 EntityCreateSpec {
7819 kind: "concept".into(),
7820 entity_type: None,
7821 name: "PartialRollbackB".into(),
7822 description: None,
7823 properties: None,
7824 tags: vec![],
7825 },
7826 ];
7827
7828 arm_fts_fail_many_partial(&ns);
7829 let result = rt.create_many(&tok, specs).await;
7830
7831 assert!(
7832 result.is_err(),
7833 "create_many must return Err when FTS summary.failed > 0"
7834 );
7835
7836 let entity_rows = rt.list_entities(&tok, None, None, 100, 0).await.unwrap();
7838 assert_eq!(
7839 entity_rows.len(),
7840 0,
7841 "entity rows must be rolled back when FTS summary.failed > 0; found {entity_rows:?}"
7842 );
7843
7844 let fts = rt.text(&tok).unwrap();
7846 let fts_count = fts
7847 .count(TextFilter {
7848 ids: vec![],
7849 kinds: vec![],
7850 namespaces: vec![ns.clone()],
7851 })
7852 .await
7853 .unwrap();
7854 assert_eq!(
7855 fts_count, 0,
7856 "fts_entities must be empty after partial-FTS-failure rollback; found {fts_count}"
7857 );
7858 }
7859
7860 #[tokio::test]
7863 async fn get_edge_cross_namespace_succeeds() {
7864 let rt = rt();
7865 let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
7866 let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
7867
7868 let src = rt
7869 .create_entity(&ns_a, "concept", None, "Src", None, None, vec![])
7870 .await
7871 .unwrap();
7872 let tgt = rt
7873 .create_entity(&ns_a, "concept", None, "Tgt", None, None, vec![])
7874 .await
7875 .unwrap();
7876 let edge = rt
7877 .link(&ns_a, src.id, tgt.id, EdgeRelation::Extends, 1.0, None)
7878 .await
7879 .unwrap();
7880
7881 let own_ns = rt.get_edge(&ns_a, Uuid::from(edge.id)).await;
7883 assert!(
7884 own_ns.is_ok() && own_ns.unwrap().is_some(),
7885 "edge must be visible in its own namespace"
7886 );
7887
7888 let cross_ns = rt.get_edge(&ns_b, Uuid::from(edge.id)).await;
7890 assert!(
7891 matches!(cross_ns, Ok(Some(_))),
7892 "cross-namespace get_edge must return Ok(Some(_)) after PR-A1, got {cross_ns:?}"
7893 );
7894
7895 let absent = rt.get_edge(&ns_b, Uuid::new_v4()).await;
7897 assert!(
7898 matches!(absent, Ok(None)),
7899 "absent edge must return Ok(None), got {absent:?}"
7900 );
7901 }
7902
7903 #[tokio::test]
7911 async fn traverse_cross_namespace_root_is_accepted() {
7912 use khive_storage::types::TraversalOptions;
7913
7914 let rt = rt();
7915 let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
7916 let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
7917
7918 let a = rt
7919 .create_entity(&ns_a, "concept", None, "A", None, None, vec![])
7920 .await
7921 .unwrap();
7922 rt.create_entity(&ns_a, "concept", None, "B", None, None, vec![])
7923 .await
7924 .unwrap();
7925 rt.link(&ns_a, a.id, a.id, EdgeRelation::Extends, 1.0, None)
7926 .await
7927 .ok(); let result = rt
7932 .traverse(
7933 &ns_b,
7934 TraversalRequest {
7935 roots: vec![a.id],
7936 options: TraversalOptions {
7937 max_depth: 1,
7938 direction: Direction::Out,
7939 ..Default::default()
7940 },
7941 include_roots: true,
7942 include_properties: false,
7943 },
7944 )
7945 .await;
7946 assert!(result.is_ok(), "traverse must not error; got {:?}", result);
7947 }
7948
7949 async fn count_all_incident_edges(rt: &KhiveRuntime, node_id: Uuid, ns: &str) -> u64 {
7958 let mut reader = rt.sql().reader().await.expect("sql reader must open");
7959 let row = reader
7960 .query_scalar(SqlStatement {
7961 sql: "SELECT COUNT(*) FROM graph_edges \
7962 WHERE namespace = ?1 AND (source_id = ?2 OR target_id = ?2)"
7963 .into(),
7964 params: vec![
7965 SqlValue::Text(ns.to_string()),
7966 SqlValue::Text(node_id.to_string()),
7967 ],
7968 label: Some("count_all_incident_edges".into()),
7969 })
7970 .await
7971 .expect("count query must succeed");
7972 match row {
7973 Some(SqlValue::Integer(n)) => n as u64,
7974 _ => panic!("count must return an integer"),
7975 }
7976 }
7977
7978 #[tokio::test]
7979 async fn hard_delete_entity_purges_already_soft_deleted_incident_edge() {
7980 let rt = rt();
7981 let tok = NamespaceToken::local();
7982 let ns = tok.namespace().to_string();
7983
7984 let a = rt
7985 .create_entity(&tok, "concept", None, "SrcA", None, None, vec![])
7986 .await
7987 .unwrap();
7988 let b = rt
7989 .create_entity(&tok, "concept", None, "TgtB", None, None, vec![])
7990 .await
7991 .unwrap();
7992
7993 rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
7994 .await
7995 .unwrap();
7996
7997 let edge_hit = rt
7999 .neighbors(&tok, a.id, Direction::Out, None, None)
8000 .await
8001 .unwrap();
8002 assert_eq!(edge_hit.len(), 1, "edge must exist before soft-delete");
8003 let edge_uuid = edge_hit[0].edge_id;
8004 rt.delete_edge(&tok, edge_uuid, false).await.unwrap();
8005
8006 let visible = rt
8008 .neighbors(&tok, a.id, Direction::Out, None, None)
8009 .await
8010 .unwrap();
8011 assert!(visible.is_empty(), "soft-deleted edge must be invisible");
8012 let raw_before = count_all_incident_edges(&rt, a.id, &ns).await;
8013 assert_eq!(
8014 raw_before, 1,
8015 "soft-deleted edge must still be a physical row"
8016 );
8017
8018 rt.delete_entity(&tok, a.id, true).await.unwrap();
8020
8021 let raw_after = count_all_incident_edges(&rt, a.id, &ns).await;
8022 assert_eq!(
8023 raw_after, 0,
8024 "purge_incident_edges must physically remove soft-deleted edge rows (ADR-002)"
8025 );
8026 }
8027
8028 #[tokio::test]
8029 async fn hard_delete_note_purges_already_soft_deleted_incident_edge() {
8030 let rt = rt();
8031 let tok = NamespaceToken::local();
8032 let ns = tok.namespace().to_string();
8033
8034 let target = rt
8035 .create_note(
8036 &tok,
8037 "observation",
8038 None,
8039 "purge-cascade target note",
8040 Some(0.5),
8041 None,
8042 vec![],
8043 )
8044 .await
8045 .unwrap();
8046 let annotating = rt
8047 .create_note(
8048 &tok,
8049 "insight",
8050 None,
8051 "annotator note",
8052 Some(0.5),
8053 None,
8054 vec![target.id],
8055 )
8056 .await
8057 .unwrap();
8058
8059 let edge_hit = rt
8061 .neighbors(
8062 &tok,
8063 annotating.id,
8064 Direction::Out,
8065 None,
8066 Some(vec![EdgeRelation::Annotates]),
8067 )
8068 .await
8069 .unwrap();
8070 assert_eq!(edge_hit.len(), 1, "annotates edge must exist");
8071 let edge_uuid = edge_hit[0].edge_id;
8072 rt.delete_edge(&tok, edge_uuid, false).await.unwrap();
8073
8074 let raw_before = count_all_incident_edges(&rt, target.id, &ns).await;
8075 assert_eq!(
8076 raw_before, 1,
8077 "soft-deleted edge must still be a physical row before note purge"
8078 );
8079
8080 rt.delete_note(&tok, target.id, true).await.unwrap();
8082
8083 let raw_after = count_all_incident_edges(&rt, target.id, &ns).await;
8084 assert_eq!(
8085 raw_after, 0,
8086 "purge_incident_edges must physically remove soft-deleted edge rows on note purge (ADR-002)"
8087 );
8088 }
8089
8090 async fn count_all_incident_edges_global(rt: &KhiveRuntime, node_id: Uuid) -> u64 {
8097 let mut reader = rt.sql().reader().await.expect("sql reader must open");
8098 let row = reader
8099 .query_scalar(SqlStatement {
8100 sql: "SELECT COUNT(*) FROM graph_edges WHERE source_id = ?1 OR target_id = ?1"
8101 .into(),
8102 params: vec![SqlValue::Text(node_id.to_string())],
8103 label: Some("count_all_incident_edges_global".into()),
8104 })
8105 .await
8106 .expect("count query must succeed");
8107 match row {
8108 Some(SqlValue::Integer(n)) => n as u64,
8109 _ => panic!("count must return an integer"),
8110 }
8111 }
8112
8113 #[tokio::test]
8114 async fn cross_namespace_hard_delete_entity_purges_all_incident_edges() {
8115 let rt = rt();
8119 let ns_owner = NamespaceToken::for_namespace(Namespace::parse("ns-owner").unwrap());
8120 let ns_caller = NamespaceToken::for_namespace(Namespace::parse("ns-caller").unwrap());
8121
8122 let entity = rt
8123 .create_entity(
8124 &ns_owner,
8125 "concept",
8126 None,
8127 "ForeignEntity",
8128 None,
8129 None,
8130 vec![],
8131 )
8132 .await
8133 .unwrap();
8134 let peer = rt
8135 .create_entity(&ns_owner, "concept", None, "Peer", None, None, vec![])
8136 .await
8137 .unwrap();
8138 rt.link(
8140 &ns_owner,
8141 entity.id,
8142 peer.id,
8143 EdgeRelation::Extends,
8144 1.0,
8145 None,
8146 )
8147 .await
8148 .unwrap();
8149 rt.link(
8150 &ns_owner,
8151 peer.id,
8152 entity.id,
8153 EdgeRelation::Extends,
8154 1.0,
8155 None,
8156 )
8157 .await
8158 .unwrap();
8159
8160 let before = count_all_incident_edges_global(&rt, entity.id).await;
8161 assert_eq!(before, 2, "two incident edges must exist before delete");
8162
8163 let deleted = rt.delete_entity(&ns_caller, entity.id, true).await.unwrap();
8165 assert!(deleted, "cross-ns hard delete must return true");
8166
8167 let after = count_all_incident_edges_global(&rt, entity.id).await;
8169 assert_eq!(
8170 after, 0,
8171 "purge_incident_edges must remove all incident edges across namespaces (ADR-002, ADR-007)"
8172 );
8173 }
8174
8175 async fn count_edge_rows_by_id(rt: &KhiveRuntime, edge_id: Uuid, ns: &str) -> u64 {
8185 let mut reader = rt.sql().reader().await.expect("sql reader must open");
8186 let row = reader
8187 .query_scalar(SqlStatement {
8188 sql: "SELECT COUNT(*) FROM graph_edges WHERE namespace = ?1 AND id = ?2".into(),
8189 params: vec![
8190 SqlValue::Text(ns.to_string()),
8191 SqlValue::Text(edge_id.to_string()),
8192 ],
8193 label: Some("count_edge_rows_by_id".into()),
8194 })
8195 .await
8196 .expect("count query must succeed");
8197 match row {
8198 Some(SqlValue::Integer(n)) => n as u64,
8199 _ => panic!("count must return an integer"),
8200 }
8201 }
8202
8203 #[tokio::test]
8204 async fn hard_delete_edge_purges_already_soft_deleted_primary_edge() {
8205 let rt = rt();
8206 let tok = NamespaceToken::local();
8207 let ns = tok.namespace().to_string();
8208
8209 let a = rt
8210 .create_entity(&tok, "concept", None, "EA", None, None, vec![])
8211 .await
8212 .unwrap();
8213 let b = rt
8214 .create_entity(&tok, "concept", None, "EB", None, None, vec![])
8215 .await
8216 .unwrap();
8217
8218 let edge = rt
8219 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
8220 .await
8221 .unwrap();
8222 let edge_uuid: Uuid = edge.id.into();
8223
8224 let soft = rt.delete_edge(&tok, edge_uuid, false).await.unwrap();
8226 assert!(soft, "soft delete must succeed");
8227
8228 assert!(
8230 rt.get_edge(&tok, edge_uuid).await.unwrap().is_none(),
8231 "soft-deleted edge must be invisible to get_edge"
8232 );
8233 assert_eq!(
8234 count_edge_rows_by_id(&rt, edge_uuid, &ns).await,
8235 1,
8236 "soft-deleted edge must still be a physical row"
8237 );
8238
8239 let purged = rt.delete_edge(&tok, edge_uuid, true).await.unwrap();
8241 assert!(
8242 purged,
8243 "hard delete of a soft-deleted edge must return true"
8244 );
8245
8246 assert_eq!(
8247 count_edge_rows_by_id(&rt, edge_uuid, &ns).await,
8248 0,
8249 "hard-delete must physically remove the soft-deleted edge row (ADR-002)"
8250 );
8251 }
8252
8253 #[tokio::test]
8254 async fn hard_delete_base_edge_purges_already_soft_deleted_annotates_edge() {
8255 let rt = rt();
8256 let tok = NamespaceToken::local();
8257 let ns = tok.namespace().to_string();
8258
8259 let a = rt
8260 .create_entity(&tok, "concept", None, "CA", None, None, vec![])
8261 .await
8262 .unwrap();
8263 let b = rt
8264 .create_entity(&tok, "concept", None, "CB", None, None, vec![])
8265 .await
8266 .unwrap();
8267
8268 let base_edge = rt
8270 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
8271 .await
8272 .unwrap();
8273 let base_edge_uuid: Uuid = base_edge.id.into();
8274
8275 let note = rt
8277 .create_note(
8278 &tok,
8279 "observation",
8280 None,
8281 "note about base edge",
8282 Some(0.5),
8283 None,
8284 vec![base_edge_uuid],
8285 )
8286 .await
8287 .unwrap();
8288
8289 let ann_hits = rt
8291 .neighbors(
8292 &tok,
8293 note.id,
8294 Direction::Out,
8295 None,
8296 Some(vec![EdgeRelation::Annotates]),
8297 )
8298 .await
8299 .unwrap();
8300 assert_eq!(ann_hits.len(), 1, "annotates edge must exist");
8301 let ann_edge_uuid = ann_hits[0].edge_id;
8302
8303 rt.delete_edge(&tok, ann_edge_uuid, false).await.unwrap();
8305 assert_eq!(
8306 count_edge_rows_by_id(&rt, ann_edge_uuid, &ns).await,
8307 1,
8308 "soft-deleted annotates edge must still be a physical row"
8309 );
8310
8311 let purged = rt.delete_edge(&tok, base_edge_uuid, true).await.unwrap();
8313 assert!(purged, "hard delete of base edge must return true");
8314
8315 assert_eq!(
8316 count_edge_rows_by_id(&rt, ann_edge_uuid, &ns).await,
8317 0,
8318 "hard-delete of base edge must purge already-soft-deleted annotates edge row (ADR-002)"
8319 );
8320 assert_eq!(
8321 count_edge_rows_by_id(&rt, base_edge_uuid, &ns).await,
8322 0,
8323 "hard-delete must physically remove the base edge row"
8324 );
8325 }
8326
8327 #[tokio::test]
8334 async fn create_entity_fts_failure_rolls_back_entity_row() {
8335 let rt = KhiveRuntime::memory().unwrap();
8336 let ns = Namespace::parse("fault-entity-fts").unwrap();
8337 let tok = NamespaceToken::for_namespace(ns.clone());
8338
8339 arm_fts_fail(ns.as_str());
8340
8341 let result = rt
8342 .create_entity(
8343 &tok,
8344 "concept",
8345 None,
8346 "fts-fail rollback target",
8347 None,
8348 None,
8349 vec![],
8350 )
8351 .await;
8352
8353 assert!(
8354 result.is_err(),
8355 "create_entity must propagate the injected FTS failure"
8356 );
8357 let err_msg = result.unwrap_err().to_string();
8358 assert!(
8359 err_msg.contains("injected FTS failure"),
8360 "error must carry injection message; got: {err_msg}"
8361 );
8362
8363 let entities = rt.list_entities(&tok, None, None, 1000, 0).await.unwrap();
8364 assert!(
8365 entities.is_empty(),
8366 "compensation must remove the entity row after FTS failure; got {entities:?}"
8367 );
8368 }
8369
8370 #[tokio::test]
8374 async fn create_entity_vector_failure_rolls_back_entity_row_and_fts() {
8375 const MODEL: &str = "test-entity-vec-inject";
8376 const DIMS: usize = 4;
8377
8378 let rt = KhiveRuntime::memory().unwrap();
8379 let (provider, _counter) = ConstVecProvider::new(MODEL, DIMS);
8380 rt.register_embedder(provider);
8381
8382 let ns = Namespace::parse("fault-entity-vec").unwrap();
8383 let tok = NamespaceToken::for_namespace(ns.clone());
8384
8385 arm_vector_fail(ns.as_str());
8386
8387 let result = rt
8388 .create_entity(
8389 &tok,
8390 "concept",
8391 None,
8392 "vec-fail rollback target",
8393 Some("description so embed body is non-empty"),
8394 None,
8395 vec![],
8396 )
8397 .await;
8398
8399 assert!(
8400 result.is_err(),
8401 "create_entity must propagate the injected vector failure"
8402 );
8403 let err_msg = result.unwrap_err().to_string();
8404 assert!(
8405 err_msg.contains("injected vector failure"),
8406 "error must carry injection message; got: {err_msg}"
8407 );
8408
8409 let entities = rt.list_entities(&tok, None, None, 1000, 0).await.unwrap();
8410 assert!(
8411 entities.is_empty(),
8412 "compensation must remove entity row after vector failure; got {entities:?}"
8413 );
8414
8415 use khive_storage::types::{TextFilter, TextQueryMode, TextSearchRequest};
8417 let fts_hits = rt
8418 .text(&tok)
8419 .unwrap()
8420 .search(TextSearchRequest {
8421 query: "vec-fail rollback target".to_string(),
8422 mode: TextQueryMode::Plain,
8423 filter: Some(TextFilter {
8424 namespaces: vec![ns.as_str().to_string()],
8425 ..Default::default()
8426 }),
8427 top_k: 10,
8428 snippet_chars: 100,
8429 })
8430 .await
8431 .unwrap();
8432 assert!(
8433 fts_hits.is_empty(),
8434 "compensation must remove FTS document after vector failure; got {fts_hits:?}"
8435 );
8436 }
8437
8438 #[tokio::test]
8445 async fn create_entity_multi_model_second_vector_failure_rolls_back_all() {
8446 const DIMS: usize = 4;
8447
8448 let rt = KhiveRuntime::memory().unwrap();
8449 let (provider_a, _ca) = ConstVecProvider::new("model-a", DIMS);
8450 let (provider_b, _cb) = ConstVecProvider::new("model-b", DIMS);
8451 rt.register_embedder(provider_a);
8452 rt.register_embedder(provider_b);
8453
8454 let ns = Namespace::parse("fault-entity-multi").unwrap();
8455 let tok = NamespaceToken::for_namespace(ns.clone());
8456
8457 arm_vector_fail_after(1);
8459
8460 let result = rt
8461 .create_entity(
8462 &tok,
8463 "concept",
8464 None,
8465 "multi-model rollback target",
8466 Some("description for embedding"),
8467 None,
8468 vec![],
8469 )
8470 .await;
8471
8472 assert!(
8473 result.is_err(),
8474 "create_entity must propagate the injected multi-model vector failure"
8475 );
8476
8477 let entities = rt.list_entities(&tok, None, None, 1000, 0).await.unwrap();
8478 assert!(
8479 entities.is_empty(),
8480 "compensation must remove entity row; got {entities:?}"
8481 );
8482
8483 use khive_storage::types::VectorSearchRequest;
8488 let query_vec = vec![1.0_f32; DIMS];
8489 let hits_a = rt
8490 .vectors_for_model(&tok, "model-a")
8491 .unwrap()
8492 .search(VectorSearchRequest {
8493 query_vectors: vec![query_vec.clone()],
8494 top_k: 100,
8495 namespace: Some(ns.as_str().to_string()),
8496 kind: Some(khive_types::SubstrateKind::Entity),
8497 embedding_model: Some("model-a".to_string()),
8498 filter: None,
8499 backend_hints: None,
8500 })
8501 .await
8502 .unwrap();
8503 assert!(
8504 hits_a.is_empty(),
8505 "model-a vector store must be empty after rollback; got {hits_a:?}"
8506 );
8507 let hits_b = rt
8508 .vectors_for_model(&tok, "model-b")
8509 .unwrap()
8510 .search(VectorSearchRequest {
8511 query_vectors: vec![query_vec],
8512 top_k: 100,
8513 namespace: Some(ns.as_str().to_string()),
8514 kind: Some(khive_types::SubstrateKind::Entity),
8515 embedding_model: Some("model-b".to_string()),
8516 filter: None,
8517 backend_hints: None,
8518 })
8519 .await
8520 .unwrap();
8521 assert!(
8522 hits_b.is_empty(),
8523 "model-b vector store must be empty after rollback; got {hits_b:?}"
8524 );
8525 }
8526
8527 #[tokio::test]
8531 async fn update_entity_fans_out_to_all_registered_models() {
8532 const DIMS: usize = 4;
8533
8534 let rt = KhiveRuntime::memory().unwrap();
8535 let (provider_a, _ca) = ConstVecProvider::new("embed-a", DIMS);
8536 let (provider_b, _cb) = ConstVecProvider::new("embed-b", DIMS);
8537 rt.register_embedder(provider_a);
8538 rt.register_embedder(provider_b);
8539
8540 let ns = Namespace::parse("update-entity-fanout").unwrap();
8541 let tok = NamespaceToken::for_namespace(ns.clone());
8542
8543 let entity = rt
8544 .create_entity(
8545 &tok,
8546 "concept",
8547 None,
8548 "FanOutEntity",
8549 Some("initial description"),
8550 None,
8551 vec![],
8552 )
8553 .await
8554 .expect("create_entity must succeed");
8555
8556 use crate::curation::EntityPatch;
8557 let patch = EntityPatch {
8558 description: Some(Some("updated description after fan-out fix".to_string())),
8559 ..Default::default()
8560 };
8561 rt.update_entity(&tok, entity.id, patch)
8562 .await
8563 .expect("update_entity must succeed");
8564
8565 use khive_storage::types::VectorSearchRequest;
8566 let query_vec = vec![1.0_f32; DIMS];
8567
8568 let hits_a = rt
8569 .vectors_for_model(&tok, "embed-a")
8570 .unwrap()
8571 .search(VectorSearchRequest {
8572 query_vectors: vec![query_vec.clone()],
8573 top_k: 10,
8574 namespace: Some(ns.as_str().to_string()),
8575 kind: Some(khive_types::SubstrateKind::Entity),
8576 embedding_model: Some("embed-a".to_string()),
8577 filter: None,
8578 backend_hints: None,
8579 })
8580 .await
8581 .unwrap();
8582 assert!(
8583 hits_a.iter().any(|h| h.subject_id == entity.id),
8584 "embed-a must hold a vector for the entity after update; got {hits_a:?}"
8585 );
8586
8587 let hits_b = rt
8588 .vectors_for_model(&tok, "embed-b")
8589 .unwrap()
8590 .search(VectorSearchRequest {
8591 query_vectors: vec![query_vec],
8592 top_k: 10,
8593 namespace: Some(ns.as_str().to_string()),
8594 kind: Some(khive_types::SubstrateKind::Entity),
8595 embedding_model: Some("embed-b".to_string()),
8596 filter: None,
8597 backend_hints: None,
8598 })
8599 .await
8600 .unwrap();
8601 assert!(
8602 hits_b.iter().any(|h| h.subject_id == entity.id),
8603 "embed-b must hold a vector for the entity after update; got {hits_b:?}"
8604 );
8605 }
8606
8607 #[tokio::test]
8611 async fn update_note_fans_out_to_all_registered_models() {
8612 const DIMS: usize = 4;
8613
8614 let rt = KhiveRuntime::memory().unwrap();
8615 let (provider_a, _ca) = ConstVecProvider::new("embed-a", DIMS);
8616 let (provider_b, _cb) = ConstVecProvider::new("embed-b", DIMS);
8617 rt.register_embedder(provider_a);
8618 rt.register_embedder(provider_b);
8619
8620 let ns = Namespace::parse("update-note-fanout").unwrap();
8621 let tok = NamespaceToken::for_namespace(ns.clone());
8622
8623 let note = rt
8624 .create_note(
8625 &tok,
8626 "observation",
8627 None,
8628 "initial note content for fan-out test",
8629 None,
8630 None,
8631 vec![],
8632 )
8633 .await
8634 .expect("create_note must succeed");
8635
8636 use crate::curation::NotePatch;
8637 let patch = NotePatch {
8638 content: Some("updated content after fan-out fix".to_string()),
8639 ..Default::default()
8640 };
8641 rt.update_note(&tok, note.id, patch)
8642 .await
8643 .expect("update_note must succeed");
8644
8645 use khive_storage::types::VectorSearchRequest;
8646 let query_vec = vec![1.0_f32; DIMS];
8647
8648 let hits_a = rt
8649 .vectors_for_model(&tok, "embed-a")
8650 .unwrap()
8651 .search(VectorSearchRequest {
8652 query_vectors: vec![query_vec.clone()],
8653 top_k: 10,
8654 namespace: Some(ns.as_str().to_string()),
8655 kind: Some(khive_types::SubstrateKind::Note),
8656 embedding_model: Some("embed-a".to_string()),
8657 filter: None,
8658 backend_hints: None,
8659 })
8660 .await
8661 .unwrap();
8662 assert!(
8663 hits_a.iter().any(|h| h.subject_id == note.id),
8664 "embed-a must hold a vector for the note after update; got {hits_a:?}"
8665 );
8666
8667 let hits_b = rt
8668 .vectors_for_model(&tok, "embed-b")
8669 .unwrap()
8670 .search(VectorSearchRequest {
8671 query_vectors: vec![query_vec],
8672 top_k: 10,
8673 namespace: Some(ns.as_str().to_string()),
8674 kind: Some(khive_types::SubstrateKind::Note),
8675 embedding_model: Some("embed-b".to_string()),
8676 filter: None,
8677 backend_hints: None,
8678 })
8679 .await
8680 .unwrap();
8681 assert!(
8682 hits_b.iter().any(|h| h.subject_id == note.id),
8683 "embed-b must hold a vector for the note after update; got {hits_b:?}"
8684 );
8685 }
8686
8687 #[tokio::test]
8695 async fn get_entity_cross_namespace_succeeds() {
8696 let rt = rt();
8697 let leo_tok = NamespaceToken::for_namespace(Namespace::parse("lambda:leo").unwrap());
8699 let entity = rt
8700 .create_entity(&leo_tok, "concept", None, "Leo-Entity", None, None, vec![])
8701 .await
8702 .unwrap();
8703 assert_eq!(entity.namespace, "lambda:leo");
8704
8705 let local_tok = NamespaceToken::local();
8707 let fetched = rt.get_entity(&local_tok, entity.id).await;
8708 assert!(
8709 fetched.is_ok(),
8710 "get_entity from local token must find lambda:leo entity; got {:?}",
8711 fetched
8712 );
8713 assert_eq!(fetched.unwrap().id, entity.id);
8714 }
8715
8716 #[tokio::test]
8717 async fn update_entity_cross_namespace_succeeds() {
8718 let rt = rt();
8719 let leo_tok = NamespaceToken::for_namespace(Namespace::parse("lambda:leo").unwrap());
8720 let entity = rt
8721 .create_entity(
8722 &leo_tok,
8723 "concept",
8724 None,
8725 "Leo-Entity-Update",
8726 None,
8727 None,
8728 vec![],
8729 )
8730 .await
8731 .unwrap();
8732
8733 let local_tok = NamespaceToken::local();
8735 let patch = crate::curation::EntityPatch {
8736 name: Some("Leo-Entity-Updated".to_string()),
8737 ..Default::default()
8738 };
8739 let result = rt.update_entity(&local_tok, entity.id, patch).await;
8740 assert!(
8741 result.is_ok(),
8742 "update_entity from local token must succeed on lambda:leo entity; got {:?}",
8743 result
8744 );
8745 assert_eq!(result.unwrap().name, "Leo-Entity-Updated");
8746 }
8747
8748 #[tokio::test]
8749 async fn delete_entity_cross_namespace_succeeds() {
8750 let rt = rt();
8751 let leo_tok = NamespaceToken::for_namespace(Namespace::parse("lambda:leo").unwrap());
8752 let entity = rt
8753 .create_entity(
8754 &leo_tok,
8755 "concept",
8756 None,
8757 "Leo-Entity-Delete",
8758 None,
8759 None,
8760 vec![],
8761 )
8762 .await
8763 .unwrap();
8764
8765 let local_tok = NamespaceToken::local();
8767 let deleted = rt.delete_entity(&local_tok, entity.id, false).await;
8768 assert!(
8769 deleted.is_ok(),
8770 "delete_entity from local token must succeed on lambda:leo entity; got {:?}",
8771 deleted
8772 );
8773 assert!(
8774 deleted.unwrap(),
8775 "delete must return true when entity existed"
8776 );
8777 }
8778
8779 #[tokio::test]
8780 async fn namespace_preserved_on_entity_after_cross_namespace_get() {
8781 let rt = rt();
8782 let leo_tok = NamespaceToken::for_namespace(Namespace::parse("lambda:leo").unwrap());
8783 let entity = rt
8784 .create_entity(
8785 &leo_tok,
8786 "concept",
8787 None,
8788 "NS-Preserved",
8789 None,
8790 None,
8791 vec![],
8792 )
8793 .await
8794 .unwrap();
8795
8796 let local_tok = NamespaceToken::local();
8798 let fetched = rt.get_entity(&local_tok, entity.id).await.unwrap();
8799 assert_eq!(
8800 fetched.namespace, "lambda:leo",
8801 "namespace column must be preserved; not overwritten with caller's namespace"
8802 );
8803 }
8804
8805 use crate::pack::PackByIdResolver;
8808 use tokio::sync::Mutex as TokioMutex;
8809
8810 #[derive(Debug, Default)]
8811 struct MockResolverState {
8812 owned: Vec<Uuid>,
8813 deleted: Vec<Uuid>,
8814 delete_calls: Vec<(Uuid, bool)>,
8815 }
8816
8817 struct MockPackResolver(TokioMutex<MockResolverState>);
8818
8819 impl MockPackResolver {
8820 fn new() -> Self {
8821 Self(TokioMutex::new(MockResolverState::default()))
8822 }
8823 }
8824
8825 #[async_trait::async_trait]
8826 impl crate::pack::PackByIdResolver for MockPackResolver {
8827 async fn resolve_by_id(&self, id: Uuid) -> Result<Option<Resolved>, RuntimeError> {
8828 let state = self.0.lock().await;
8829 if state.owned.contains(&id) && !state.deleted.contains(&id) {
8830 Ok(Some(Resolved::PackRecord {
8831 pack: "mock".into(),
8832 kind: "widget".into(),
8833 data: serde_json::json!({ "id": id.to_string(), "name": "test-widget" }),
8834 }))
8835 } else {
8836 Ok(None)
8837 }
8838 }
8839
8840 async fn resolve_by_id_including_deleted(
8841 &self,
8842 id: Uuid,
8843 ) -> Result<Option<Resolved>, RuntimeError> {
8844 let state = self.0.lock().await;
8845 if state.owned.contains(&id) {
8846 Ok(Some(Resolved::PackRecord {
8847 pack: "mock".into(),
8848 kind: "widget".into(),
8849 data: serde_json::json!({ "id": id.to_string(), "name": "test-widget" }),
8850 }))
8851 } else {
8852 Ok(None)
8853 }
8854 }
8855
8856 async fn delete_by_id(
8857 &self,
8858 id: Uuid,
8859 hard: bool,
8860 ) -> Result<serde_json::Value, RuntimeError> {
8861 let mut state = self.0.lock().await;
8862 if !state.owned.contains(&id) {
8863 return Err(RuntimeError::NotFound(format!(
8864 "mock widget not found: {id}"
8865 )));
8866 }
8867 state.delete_calls.push((id, hard));
8868 if hard {
8869 state.owned.retain(|&x| x != id);
8870 state.deleted.retain(|&x| x != id);
8871 } else {
8872 state.deleted.push(id);
8873 }
8874 Ok(
8875 serde_json::json!({ "deleted": true, "id": id.to_string(), "kind": "widget", "hard": hard }),
8876 )
8877 }
8878 }
8879
8880 fn registry_with_mock_resolver(
8881 rt: KhiveRuntime,
8882 resolver: Box<dyn crate::pack::PackByIdResolver>,
8883 ) -> crate::VerbRegistry {
8884 use crate::pack::{PackRuntime, VerbRegistryBuilder};
8885 use khive_types::{HandlerDef, VerbCategory, Visibility};
8886
8887 static MINIMAL_HANDLERS: &[HandlerDef] = &[HandlerDef {
8888 name: "minimal.noop",
8889 description: "noop",
8890 visibility: Visibility::Verb,
8891 category: VerbCategory::Commissive,
8892 params: &[],
8893 }];
8894
8895 struct MinimalPack;
8896 impl khive_types::Pack for MinimalPack {
8897 const NAME: &'static str = "minimal";
8898 const NOTE_KINDS: &'static [&'static str] = &[];
8899 const ENTITY_KINDS: &'static [&'static str] = &[];
8900 const HANDLERS: &'static [HandlerDef] = MINIMAL_HANDLERS;
8901 }
8902 #[async_trait::async_trait]
8903 impl PackRuntime for MinimalPack {
8904 fn name(&self) -> &str {
8905 "minimal"
8906 }
8907 fn note_kinds(&self) -> &'static [&'static str] {
8908 &[]
8909 }
8910 fn entity_kinds(&self) -> &'static [&'static str] {
8911 &[]
8912 }
8913 fn handlers(&self) -> &'static [HandlerDef] {
8914 MINIMAL_HANDLERS
8915 }
8916 async fn dispatch(
8917 &self,
8918 _verb: &str,
8919 _params: serde_json::Value,
8920 _registry: &crate::VerbRegistry,
8921 _token: &NamespaceToken,
8922 ) -> Result<serde_json::Value, RuntimeError> {
8923 Err(RuntimeError::InvalidInput("stub".into()))
8924 }
8925 }
8926
8927 let _ = rt;
8928 let mut builder = VerbRegistryBuilder::new();
8929 builder.register(MinimalPack);
8930 builder.register_resolver("mock", resolver);
8931 builder.build().expect("registry build failed")
8932 }
8933
8934 #[tokio::test]
8935 async fn pack_record_resolved_pair_returns_none() {
8936 let pr = Resolved::PackRecord {
8937 pack: "knowledge".into(),
8938 kind: "atom".into(),
8939 data: serde_json::json!({}),
8940 };
8941 assert!(
8942 resolved_pair(Some(&pr)).is_none(),
8943 "PackRecord must not be a valid edge endpoint"
8944 );
8945 }
8946
8947 #[test]
8948 fn resolved_pair_surfaces_entity_type() {
8949 let e = Resolved::Entity(
8950 Entity::new("mathlib", "concept", "Nat.add_comm").with_entity_type(Some("theorem")),
8951 );
8952 assert_eq!(
8953 resolved_pair(Some(&e)),
8954 Some(("entity", "concept", Some("theorem"))),
8955 "entity_type subtype must be surfaced alongside base kind"
8956 );
8957 }
8958
8959 #[test]
8960 fn endpoint_of_type_matches_subtype_not_base_kind() {
8961 let kind = "concept";
8963 let et = Some("theorem");
8964
8965 assert!(endpoint_matches(
8967 &EndpointKind::EntityOfType {
8968 kind: "concept",
8969 entity_type: "theorem",
8970 },
8971 "entity",
8972 kind,
8973 et
8974 ));
8975 assert!(!endpoint_matches(
8976 &EndpointKind::EntityOfType {
8977 kind: "concept",
8978 entity_type: "definition",
8979 },
8980 "entity",
8981 kind,
8982 et
8983 ));
8984
8985 assert!(!endpoint_matches(
8988 &EndpointKind::EntityOfKind("theorem"),
8989 "entity",
8990 kind,
8991 et
8992 ));
8993 assert!(endpoint_matches(
8995 &EndpointKind::EntityOfKind("concept"),
8996 "entity",
8997 kind,
8998 et
8999 ));
9000
9001 assert!(!endpoint_matches(
9003 &EndpointKind::EntityOfType {
9004 kind: "concept",
9005 entity_type: "theorem",
9006 },
9007 "note",
9008 "task",
9009 None
9010 ));
9011 assert!(!endpoint_matches(
9012 &EndpointKind::EntityOfType {
9013 kind: "concept",
9014 entity_type: "theorem",
9015 },
9016 "entity",
9017 kind,
9018 None
9019 ));
9020 }
9021
9022 #[test]
9023 fn endpoint_of_type_requires_base_kind_match() {
9024 let wrong_base_kind = "project"; let et = Some("theorem");
9029
9030 assert!(
9034 !endpoint_matches(
9035 &EndpointKind::EntityOfType {
9036 kind: "concept",
9037 entity_type: "theorem",
9038 },
9039 "entity",
9040 wrong_base_kind,
9041 et
9042 ),
9043 "EntityOfType must reject an entity whose base kind != rule.kind \
9044 even when entity_type matches — the pre-fix bug admitted this"
9045 );
9046
9047 assert!(endpoint_matches(
9049 &EndpointKind::EntityOfType {
9050 kind: "concept",
9051 entity_type: "theorem",
9052 },
9053 "entity",
9054 "concept",
9055 et
9056 ));
9057 }
9058
9059 #[tokio::test]
9060 async fn registry_resolvers_accessor_returns_registered() {
9061 let resolver = Box::new(MockPackResolver::new());
9062 let registry = registry_with_mock_resolver(rt(), resolver);
9063 assert_eq!(registry.resolvers().len(), 1);
9064 assert_eq!(registry.resolvers()[0].0, "mock");
9065 }
9066
9067 #[tokio::test]
9068 async fn mock_resolver_resolve_by_id_returns_pack_record() {
9069 let id = Uuid::new_v4();
9070 let resolver: Box<dyn PackByIdResolver> = Box::new(MockPackResolver::new());
9071 let inner = MockPackResolver::new();
9073 inner.0.lock().await.owned.push(id);
9074 let result: Result<Option<Resolved>, RuntimeError> = inner.resolve_by_id(id).await;
9075 match result.unwrap() {
9076 Some(Resolved::PackRecord { pack, kind, data }) => {
9077 assert_eq!(pack, "mock");
9078 assert_eq!(kind, "widget");
9079 assert_eq!(data["id"].as_str().unwrap(), id.to_string());
9080 }
9081 other => panic!("expected PackRecord, got {:?}", other),
9082 }
9083 let _ = resolver;
9084 }
9085
9086 #[tokio::test]
9087 async fn mock_resolver_resolve_unknown_uuid_returns_none() {
9088 let inner = MockPackResolver::new();
9089 let id = Uuid::new_v4();
9090 let result: Result<Option<Resolved>, RuntimeError> = inner.resolve_by_id(id).await;
9091 assert!(result.unwrap().is_none());
9092 }
9093
9094 #[tokio::test]
9095 async fn mock_resolver_delete_soft_records_call() {
9096 let id = Uuid::new_v4();
9097 let inner = MockPackResolver::new();
9098 inner.0.lock().await.owned.push(id);
9099
9100 let result: Result<serde_json::Value, RuntimeError> = inner.delete_by_id(id, false).await;
9101 let result = result.unwrap();
9102 assert_eq!(result["deleted"], serde_json::json!(true));
9103 assert_eq!(result["hard"], serde_json::json!(false));
9104
9105 let live: Result<Option<Resolved>, RuntimeError> = inner.resolve_by_id(id).await;
9107 assert!(live.unwrap().is_none());
9108 let incl: Result<Option<Resolved>, RuntimeError> =
9109 inner.resolve_by_id_including_deleted(id).await;
9110 assert!(incl.unwrap().is_some());
9111 }
9112
9113 #[tokio::test]
9114 async fn mock_resolver_delete_hard_removes_record() {
9115 let id = Uuid::new_v4();
9116 let inner = MockPackResolver::new();
9117 inner.0.lock().await.owned.push(id);
9118
9119 let result: Result<serde_json::Value, RuntimeError> = inner.delete_by_id(id, true).await;
9120 assert_eq!(result.unwrap()["hard"], serde_json::json!(true));
9121
9122 let incl: Result<Option<Resolved>, RuntimeError> =
9124 inner.resolve_by_id_including_deleted(id).await;
9125 assert!(incl.unwrap().is_none());
9126 }
9127
9128 #[tokio::test]
9129 async fn pack_record_not_valid_context_entity() {
9130 let pr = Resolved::PackRecord {
9133 pack: "knowledge".into(),
9134 kind: "atom".into(),
9135 data: serde_json::json!({}),
9136 };
9137 assert!(matches!(pr, Resolved::PackRecord { .. }));
9140 }
9141
9142 fn neighbor_hit(node_id: Uuid) -> NeighborHit {
9145 NeighborHit {
9146 node_id,
9147 edge_id: Uuid::new_v4(),
9148 relation: EdgeRelation::Extends,
9149 weight: 1.0,
9150 name: None,
9151 kind: None,
9152 entity_type: None,
9153 }
9154 }
9155
9156 fn path_node(node_id: Uuid, depth: usize) -> PathNode {
9157 PathNode {
9158 node_id,
9159 via_edge: None,
9160 depth,
9161 name: None,
9162 kind: None,
9163 properties: None,
9164 }
9165 }
9166
9167 #[tokio::test]
9170 async fn enrich_neighbor_hits_batch_entity_note_and_bogus() {
9171 let rt = rt();
9172 let tok = NamespaceToken::local();
9173
9174 let entity = rt
9176 .create_entity(&tok, "concept", None, "MyEntity", None, None, vec![])
9177 .await
9178 .unwrap();
9179
9180 let note = rt
9182 .create_note(&tok, "observation", None, "body", Some(0.5), None, vec![])
9183 .await
9184 .unwrap();
9185
9186 let bogus_id = Uuid::new_v4();
9187
9188 let mut hits = vec![
9189 neighbor_hit(entity.id),
9190 neighbor_hit(note.id),
9191 neighbor_hit(bogus_id),
9192 ];
9193
9194 rt.enrich_neighbor_hits(&tok, &mut hits).await;
9195
9196 assert_eq!(hits[0].name.as_deref(), Some("MyEntity"));
9197 assert_eq!(hits[0].kind.as_deref(), Some("concept"));
9198
9199 assert_eq!(hits[1].name.as_deref(), Some("[observation]"));
9200 assert_eq!(hits[1].kind.as_deref(), Some("observation"));
9201
9202 assert!(hits[2].name.is_none());
9203 assert!(hits[2].kind.is_none());
9204 }
9205
9206 #[tokio::test]
9208 async fn enrich_neighbor_hits_note_with_name_uses_name() {
9209 let rt = rt();
9210 let tok = NamespaceToken::local();
9211
9212 let note = rt
9213 .create_note(
9214 &tok,
9215 "insight",
9216 Some("NoteTitle"),
9217 "body",
9218 Some(0.5),
9219 None,
9220 vec![],
9221 )
9222 .await
9223 .unwrap();
9224
9225 let mut hits = vec![neighbor_hit(note.id)];
9226 rt.enrich_neighbor_hits(&tok, &mut hits).await;
9227
9228 assert_eq!(hits[0].name.as_deref(), Some("NoteTitle"));
9229 assert_eq!(hits[0].kind.as_deref(), Some("insight"));
9230 }
9231
9232 #[tokio::test]
9235 async fn enrich_path_nodes_batch_dedup_and_unresolved() {
9236 let rt = rt();
9237 let tok = NamespaceToken::local();
9238
9239 let ea = rt
9240 .create_entity(&tok, "concept", None, "Alpha", None, None, vec![])
9241 .await
9242 .unwrap();
9243 let eb = rt
9244 .create_entity(&tok, "document", None, "Beta", None, None, vec![])
9245 .await
9246 .unwrap();
9247 let bogus_id = Uuid::new_v4();
9248
9249 let mut paths = vec![
9251 GraphPath {
9252 root_id: ea.id,
9253 nodes: vec![
9254 path_node(ea.id, 0),
9255 path_node(eb.id, 1),
9256 path_node(bogus_id, 2),
9257 ],
9258 total_weight: 1.0,
9259 },
9260 GraphPath {
9261 root_id: eb.id,
9262 nodes: vec![path_node(eb.id, 0), path_node(ea.id, 1)],
9263 total_weight: 1.0,
9264 },
9265 ];
9266
9267 rt.enrich_path_nodes(&tok, &mut paths, false).await;
9268
9269 assert_eq!(paths[0].nodes[0].name.as_deref(), Some("Alpha"));
9270 assert_eq!(paths[0].nodes[0].kind.as_deref(), Some("concept"));
9271 assert_eq!(paths[0].nodes[1].name.as_deref(), Some("Beta"));
9272 assert_eq!(paths[0].nodes[1].kind.as_deref(), Some("document"));
9273 assert!(paths[0].nodes[2].name.is_none());
9274 assert!(paths[0].nodes[2].kind.is_none());
9275
9276 assert_eq!(paths[1].nodes[0].name.as_deref(), Some("Beta"));
9278 assert_eq!(paths[1].nodes[0].kind.as_deref(), Some("document"));
9279 assert_eq!(paths[1].nodes[1].name.as_deref(), Some("Alpha"));
9280 assert_eq!(paths[1].nodes[1].kind.as_deref(), Some("concept"));
9281 }
9282
9283 #[tokio::test]
9291 async fn enrich_resolves_entities_in_extra_visible_namespace() {
9292 let rt = KhiveRuntime::memory().unwrap();
9293
9294 let ns_a = Namespace::parse("enrich-ns-a").unwrap();
9295 let ns_b = Namespace::parse("enrich-ns-b").unwrap();
9296
9297 let tok_b = rt.authorize(ns_b.clone()).unwrap();
9298
9299 let entity_b = rt
9301 .create_entity(&tok_b, "concept", None, "EntityInB", None, None, vec![])
9302 .await
9303 .unwrap();
9304 assert_eq!(entity_b.namespace, "enrich-ns-b");
9305
9306 let vis_tok = rt
9308 .authorize_with_visibility(ns_a.clone(), vec![ns_b.clone()])
9309 .unwrap();
9310
9311 let mut hits = vec![neighbor_hit(entity_b.id)];
9313 rt.enrich_neighbor_hits(&vis_tok, &mut hits).await;
9314
9315 assert_eq!(
9316 hits[0].name.as_deref(),
9317 Some("EntityInB"),
9318 "entity in extra-visible ns must be enriched by enrich_neighbor_hits"
9319 );
9320 assert_eq!(hits[0].kind.as_deref(), Some("concept"));
9321
9322 let mut paths = vec![GraphPath {
9324 root_id: entity_b.id,
9325 nodes: vec![path_node(entity_b.id, 0)],
9326 total_weight: 1.0,
9327 }];
9328 rt.enrich_path_nodes(&vis_tok, &mut paths, false).await;
9329
9330 assert_eq!(
9331 paths[0].nodes[0].name.as_deref(),
9332 Some("EntityInB"),
9333 "entity in extra-visible ns must be enriched by enrich_path_nodes"
9334 );
9335 assert_eq!(paths[0].nodes[0].kind.as_deref(), Some("concept"));
9336 }
9337
9338 #[tokio::test]
9342 async fn enrich_neighbor_hits_populates_entity_type() {
9343 let rt = rt();
9344 let tok = NamespaceToken::local();
9345
9346 let props = serde_json::json!({"domain": "attention"});
9347 let entity = rt
9348 .create_entity(
9349 &tok,
9350 "concept",
9351 Some("algorithm"),
9352 "FlashAttn",
9353 None,
9354 Some(props),
9355 vec![],
9356 )
9357 .await
9358 .unwrap();
9359
9360 let entity_no_type = rt
9361 .create_entity(&tok, "concept", None, "PlainConcept", None, None, vec![])
9362 .await
9363 .unwrap();
9364
9365 let mut hits = vec![neighbor_hit(entity.id), neighbor_hit(entity_no_type.id)];
9366 rt.enrich_neighbor_hits(&tok, &mut hits).await;
9367
9368 assert_eq!(hits[0].entity_type.as_deref(), Some("algorithm"));
9369 assert!(
9370 hits[1].entity_type.is_none(),
9371 "entity without entity_type must leave the field as None"
9372 );
9373 }
9374
9375 #[tokio::test]
9379 async fn enrich_path_nodes_populates_properties() {
9380 let rt = rt();
9381 let tok = NamespaceToken::local();
9382
9383 let props = serde_json::json!({"year": 2024, "venue": "NeurIPS"});
9384 let entity_with_props = rt
9385 .create_entity(
9386 &tok,
9387 "document",
9388 None,
9389 "AttentionPaper",
9390 None,
9391 Some(props.clone()),
9392 vec![],
9393 )
9394 .await
9395 .unwrap();
9396
9397 let entity_no_props = rt
9398 .create_entity(&tok, "concept", None, "BareConceptNode", None, None, vec![])
9399 .await
9400 .unwrap();
9401
9402 let mut paths = vec![GraphPath {
9403 root_id: entity_with_props.id,
9404 nodes: vec![
9405 path_node(entity_with_props.id, 0),
9406 path_node(entity_no_props.id, 1),
9407 ],
9408 total_weight: 1.0,
9409 }];
9410
9411 rt.enrich_path_nodes(&tok, &mut paths, true).await;
9412
9413 assert_eq!(
9414 paths[0].nodes[0].properties.as_ref(),
9415 Some(&props),
9416 "properties must be filled when entity has a non-null properties blob"
9417 );
9418 assert!(
9419 paths[0].nodes[1].properties.is_none(),
9420 "entity without properties must leave the field as None"
9421 );
9422 }
9423
9424 #[tokio::test]
9434 async fn traverse_chunks_root_binds_over_host_param_limit() {
9435 use khive_storage::types::TraversalOptions;
9436
9437 let rt = rt();
9438 let tok = NamespaceToken::local();
9439 let graph = rt.graph(&tok).unwrap();
9440
9441 const N: usize = 1_000;
9442 let now = chrono::Utc::now();
9443
9444 let mut roots: Vec<uuid::Uuid> = Vec::with_capacity(N);
9445 let mut expected_children: std::collections::HashMap<uuid::Uuid, uuid::Uuid> =
9446 std::collections::HashMap::with_capacity(N);
9447
9448 for _ in 0..N {
9449 let root = uuid::Uuid::new_v4();
9450 let child = uuid::Uuid::new_v4();
9451 graph
9452 .upsert_edge(Edge {
9453 id: LinkId::from(uuid::Uuid::new_v4()),
9454 namespace: "local".to_string(),
9455 source_id: root,
9456 target_id: child,
9457 relation: EdgeRelation::Extends,
9458 weight: 1.0,
9459 created_at: now,
9460 updated_at: now,
9461 deleted_at: None,
9462 metadata: None,
9463 target_backend: None,
9464 })
9465 .await
9466 .unwrap();
9467 roots.push(root);
9468 expected_children.insert(root, child);
9469 }
9470
9471 let paths = graph
9473 .traverse(TraversalRequest {
9474 roots: roots.clone(),
9475 options: TraversalOptions {
9476 max_depth: 1,
9477 direction: Direction::Out,
9478 relations: None,
9479 min_weight: None,
9480 limit: None,
9481 },
9482 include_roots: false,
9483 include_properties: false,
9484 })
9485 .await
9486 .unwrap();
9487
9488 assert_eq!(
9489 paths.len(),
9490 N,
9491 "traverse over {N} roots must return one GraphPath per root"
9492 );
9493
9494 for path in &paths {
9495 let expected_child = expected_children[&path.root_id];
9496 assert_eq!(
9497 path.nodes.len(),
9498 1,
9499 "root {:?} must reach exactly 1 node",
9500 path.root_id
9501 );
9502 assert_eq!(
9503 path.nodes[0].node_id, expected_child,
9504 "root {:?} must reach its direct child",
9505 path.root_id
9506 );
9507 }
9508 }
9509
9510 #[test]
9525 fn pack_entity_of_type_rules_do_not_shadow_base_entity_of_kind_rule() {
9526 let pack_rules: Vec<EdgeEndpointRule> = vec![
9529 EdgeEndpointRule {
9530 relation: EdgeRelation::VariantOf,
9531 source: EndpointKind::EntityOfType {
9532 kind: "concept",
9533 entity_type: "goal",
9534 },
9535 target: EndpointKind::EntityOfType {
9536 kind: "concept",
9537 entity_type: "theorem",
9538 },
9539 },
9540 EdgeEndpointRule {
9541 relation: EdgeRelation::VariantOf,
9542 source: EndpointKind::EntityOfType {
9543 kind: "concept",
9544 entity_type: "goal",
9545 },
9546 target: EndpointKind::EntityOfType {
9547 kind: "concept",
9548 entity_type: "definition",
9549 },
9550 },
9551 ];
9552
9553 let goal_a =
9554 Resolved::Entity(Entity::new("local", "concept", "G-a").with_entity_type(Some("goal")));
9555 let goal_b =
9556 Resolved::Entity(Entity::new("local", "concept", "G-b").with_entity_type(Some("goal")));
9557
9558 assert!(
9560 !pack_rule_allows(
9561 &pack_rules,
9562 EdgeRelation::VariantOf,
9563 Some(&goal_a),
9564 Some(&goal_b)
9565 ),
9566 "pack rules must not cover goal->goal variant_of (no such rule declared)"
9567 );
9568
9569 assert!(
9572 base_entity_rule_allows("concept", EdgeRelation::VariantOf, "concept"),
9573 "base contract must allow concept->concept variant_of regardless of pack EntityOfType rules"
9574 );
9575 }
9576
9577 #[tokio::test]
9582 async fn link_variant_of_goal_to_goal_allowed_when_pack_has_entity_of_type_rules() {
9583 let rt = rt();
9584 let tok = NamespaceToken::local();
9585
9586 rt.install_edge_rules(vec![
9589 EdgeEndpointRule {
9590 relation: EdgeRelation::VariantOf,
9591 source: EndpointKind::EntityOfType {
9592 kind: "concept",
9593 entity_type: "goal",
9594 },
9595 target: EndpointKind::EntityOfType {
9596 kind: "concept",
9597 entity_type: "theorem",
9598 },
9599 },
9600 EdgeEndpointRule {
9601 relation: EdgeRelation::VariantOf,
9602 source: EndpointKind::EntityOfType {
9603 kind: "concept",
9604 entity_type: "goal",
9605 },
9606 target: EndpointKind::EntityOfType {
9607 kind: "concept",
9608 entity_type: "definition",
9609 },
9610 },
9611 ]);
9612
9613 let a = rt
9614 .create_entity(
9615 &tok,
9616 "concept",
9617 Some("goal"),
9618 "Goal Alpha",
9619 None,
9620 None,
9621 vec![],
9622 )
9623 .await
9624 .unwrap();
9625 let b = rt
9626 .create_entity(
9627 &tok,
9628 "concept",
9629 Some("goal"),
9630 "Goal Beta",
9631 None,
9632 None,
9633 vec![],
9634 )
9635 .await
9636 .unwrap();
9637
9638 let result = rt
9641 .link(&tok, a.id, b.id, EdgeRelation::VariantOf, 1.0, None)
9642 .await;
9643 assert!(
9644 result.is_ok(),
9645 "goal->goal variant_of must be allowed via the base concept->concept rule \
9646 even when EntityOfType rules for variant_of are installed; got {result:?}"
9647 );
9648
9649 let p = rt
9654 .create_entity(&tok, "project", None, "Proj", None, None, vec![])
9655 .await
9656 .unwrap();
9657 let bad = rt
9658 .link(&tok, a.id, p.id, EdgeRelation::VariantOf, 1.0, None)
9659 .await;
9660 assert!(
9661 bad.is_err(),
9662 "additive pack rules must not make validation fail-open; \
9663 goal(concept)->project variant_of must be rejected (pack miss + base miss); \
9664 got {bad:?}"
9665 );
9666 }
9667
9668 #[tokio::test]
9673 async fn link_depends_on_theorem_to_definition_allowed_only_via_pack_rule() {
9674 let rt = rt();
9675 let tok = NamespaceToken::local();
9676
9677 assert!(
9680 !base_entity_rule_allows("concept", EdgeRelation::DependsOn, "concept"),
9681 "base contract must not allow concept->concept DependsOn; \
9682 test would be vacuous if this precondition fails"
9683 );
9684
9685 rt.install_edge_rules(vec![EdgeEndpointRule {
9689 relation: EdgeRelation::DependsOn,
9690 source: EndpointKind::EntityOfType {
9691 kind: "concept",
9692 entity_type: "theorem",
9693 },
9694 target: EndpointKind::EntityOfType {
9695 kind: "concept",
9696 entity_type: "definition",
9697 },
9698 }]);
9699
9700 let thm = rt
9701 .create_entity(&tok, "concept", Some("theorem"), "T1", None, None, vec![])
9702 .await
9703 .unwrap();
9704 let def = rt
9705 .create_entity(
9706 &tok,
9707 "concept",
9708 Some("definition"),
9709 "D1",
9710 None,
9711 None,
9712 vec![],
9713 )
9714 .await
9715 .unwrap();
9716
9717 let result = rt
9719 .link(&tok, thm.id, def.id, EdgeRelation::DependsOn, 1.0, None)
9720 .await;
9721 assert!(
9722 result.is_ok(),
9723 "theorem->definition DependsOn must be allowed by the installed pack rule; \
9724 the base contract has no concept->concept DependsOn row; got {result:?}"
9725 );
9726 }
9727}