1use std::collections::HashMap;
7use std::str::FromStr;
8
9use chrono::Utc;
10use serde::Serialize;
11use uuid::Uuid;
12
13use khive_score::DeterministicScore;
14use khive_storage::note::Note;
15use khive_storage::types::{
16 DeleteMode, DirectedNeighborHit, Direction, EdgeSortField, EdgeUpsertDisposition,
17 EdgeUpsertRefusal, EdgeUpsertRequest, EdgeUpsertResult, GraphPath, GuardedEdgeUpsertOutcome,
18 LinkId, NeighborCursor, NeighborHit, NeighborQuery, Page, PageRequest, SeekCursor, SortOrder,
19 SqlRow, SqlStatement, SqlValue, TextFilter, TextQueryMode, TextSearchRequest, TraversalRequest,
20};
21use khive_storage::{
22 Attachment, AttachmentSubstrate, Edge, EdgeRelation, Entity, EntityFilter, Event, EventFilter,
23 NewAttachment,
24};
25use khive_types::{EdgeEndpointRule, EndpointKind, EventKind, KhiveError, SubstrateKind};
26
27use khive_db::stores::entity::{entity_hard_delete_statement, entity_upsert_statement};
28use khive_db::stores::event::hard_delete_lineage_warning_statements;
29use khive_db::stores::graph::{edge_hard_delete_statement, purge_incident_edges_statement};
30use khive_db::stores::note::note_hard_delete_statement;
31use khive_db::stores::text::insert_document_statements;
32use khive_db::{pool::RuntimeWriteOperation, SqliteError};
33use rusqlite::OptionalExtension;
34
35fn restore_reindex_failed(kind: &str, id: Uuid, error: RuntimeError) -> RuntimeError {
39 RuntimeError::Internal(format!(
40 "{kind} {id} is restored and text-indexed, but its embedding rebuild failed \
41 and will be retried by the next reindex: {error}"
42 ))
43}
44
45fn merge_tombstone_restore_refused(id: Uuid, kept_id: impl std::fmt::Display) -> RuntimeError {
46 KhiveError::conflict(format!(
47 "merge_tombstone: {id} was merged into {kept_id}; a merge tombstone is not restorable, query the kept id"
48 ))
49 .with_details(khive_types::Details::new_owned([
50 ("reason", "merge_tombstone".into()),
51 ("merged_into", kept_id.to_string()),
52 ]))
53 .into()
54}
55
56fn live_merged_entity_refused(id: Uuid, kept_id: impl std::fmt::Display) -> RuntimeError {
57 KhiveError::conflict(format!(
58 "live_merged_entity: {id} is live but still carries merged_into {kept_id}; a row an \
59 earlier restore left live over its merge is not restorable, re-tombstone it or query the \
60 kept id"
61 ))
62 .with_details(khive_types::Details::new_owned([
63 ("reason", "live_merged_entity".into()),
64 ("merged_into", kept_id.to_string()),
65 ]))
66 .into()
67}
68
69fn restore_key_conflict(key: &str, holder: &Note) -> RuntimeError {
70 KhiveError::conflict(format!(
71 "restore_key_conflict: key {key:?} is already held by live note {}",
72 holder.id
73 ))
74 .with_details(khive_types::Details::new_owned([
75 ("reason", "restore_key_conflict".into()),
76 ("key", key.to_owned()),
77 ("existing_id", holder.id.to_string()),
78 ]))
79 .into()
80}
81
82use crate::atomic_plan::{
83 AddEntityPlan, AffectedRowGuard, DeletePlan, PlanStatement, PostCommitEffect, UpdatePlan,
84};
85use crate::atomic_runner::{run_atomic_unit, AtomicOpFailure, AtomicOpPlan, AtomicRunOutcome};
86use crate::curation::{entity_fts_document, note_embedding_text_ref, note_fts_document};
87use crate::error::{GuardedWriteFailure, RuntimeError, RuntimeResult};
88use crate::runtime::{KhiveRuntime, NamespaceToken};
89
90#[cfg(test)]
92std::thread_local! {
93 static LINK_FAIL_AFTER: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
94}
95
96#[cfg(any(test, feature = "fault-injection"))]
97std::thread_local! {
98 static VECTOR_FAIL_AFTER: std::cell::Cell<Option<usize>> =
99 const { std::cell::Cell::new(None) };
100}
101
102#[cfg(any(test, feature = "fault-injection"))]
105pub fn arm_vector_fail_after(n: usize) {
106 VECTOR_FAIL_AFTER.with(|cell| cell.set(Some(n)));
107}
108
109#[cfg(any(test, feature = "fault-injection"))]
112type FaultArmSet = std::sync::Mutex<std::collections::HashMap<String, std::sync::Arc<()>>>;
113#[cfg(any(test, feature = "fault-injection"))]
114const MAX_FAULT_ARMS: usize = 64;
115#[cfg(any(test, feature = "fault-injection"))]
116static FTS_FAIL_NS: std::sync::LazyLock<FaultArmSet> =
117 std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
118#[cfg(any(test, feature = "fault-injection"))]
119static VECTOR_FAIL_NS: std::sync::LazyLock<FaultArmSet> =
120 std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
121#[cfg(any(test, feature = "fault-injection"))]
123static ENTITY_COMPENSATION_FAIL_NS: std::sync::LazyLock<FaultArmSet> =
124 std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
125#[cfg(any(test, feature = "fault-injection"))]
128static FTS_FAIL_MANY_NS: std::sync::LazyLock<FaultArmSet> =
129 std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
130#[cfg(any(test, feature = "fault-injection"))]
133static FTS_FAIL_MANY_PARTIAL_NS: std::sync::LazyLock<FaultArmSet> =
134 std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
135#[cfg(any(test, feature = "fault-injection"))]
140static PREFIX_RESOLVE_FAIL_NS: std::sync::LazyLock<FaultArmSet> =
141 std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
142
143#[cfg(any(test, feature = "fault-injection"))]
145#[must_use = "the fault injection is disarmed when this guard is dropped"]
146pub struct FaultInjectionArm {
147 namespace: String,
148 token: std::sync::Arc<()>,
149 arms: &'static FaultArmSet,
150}
151
152#[cfg(any(test, feature = "fault-injection"))]
153impl Drop for FaultInjectionArm {
154 fn drop(&mut self) {
155 let mut arms = self.arms.lock().unwrap();
156 if arms
157 .get(&self.namespace)
158 .is_some_and(|token| std::sync::Arc::ptr_eq(token, &self.token))
159 {
160 arms.remove(&self.namespace);
161 }
162 }
163}
164
165#[cfg(any(test, feature = "fault-injection"))]
166fn arm_fault(arms: &'static FaultArmSet, namespace: &str, max_arms: usize) -> FaultInjectionArm {
167 let token = std::sync::Arc::new(());
168 let refusal = {
169 let mut active = arms.lock().unwrap();
170 if active.contains_key(namespace) {
171 Some("the namespace is already armed")
172 } else if active.len() >= max_arms {
173 Some("the arm set is at capacity")
174 } else {
175 active.insert(namespace.to_string(), std::sync::Arc::clone(&token));
176 None
177 }
178 };
179 if let Some(reason) = refusal {
180 panic!("cannot arm fault injection for namespace `{namespace}`: {reason}");
181 }
182 FaultInjectionArm {
183 namespace: namespace.to_string(),
184 token,
185 arms,
186 }
187}
188
189#[cfg(any(test, feature = "fault-injection"))]
190fn consume_fault(arms: &FaultArmSet, namespace: &str) -> bool {
191 arms.lock().unwrap().remove(namespace).is_some()
192}
193#[cfg(any(test, feature = "fault-injection"))]
200static FTS_SEARCH_FAIL_NS: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
201
202#[cfg(any(test, feature = "fault-injection"))]
217pub fn arm_fts_fail_scoped(ns: &str) -> FaultInjectionArm {
218 arm_fault(&FTS_FAIL_NS, ns, MAX_FAULT_ARMS)
219}
220
221#[cfg(any(test, feature = "fault-injection"))]
231pub fn arm_fts_fail_many_scoped(ns: &str) -> FaultInjectionArm {
232 arm_fault(&FTS_FAIL_MANY_NS, ns, MAX_FAULT_ARMS)
233}
234
235#[cfg(any(test, feature = "fault-injection"))]
244pub fn arm_fts_fail_many_partial_scoped(ns: &str) -> FaultInjectionArm {
245 arm_fault(&FTS_FAIL_MANY_PARTIAL_NS, ns, MAX_FAULT_ARMS)
246}
247
248#[cfg(any(test, feature = "fault-injection"))]
258pub fn arm_fts_search_fail(ns: &str) {
259 *FTS_SEARCH_FAIL_NS.lock().unwrap() = Some(ns.to_string());
260}
261
262#[cfg(any(test, feature = "fault-injection"))]
272pub fn arm_vector_fail_scoped(ns: &str) -> FaultInjectionArm {
273 arm_fault(&VECTOR_FAIL_NS, ns, MAX_FAULT_ARMS)
274}
275
276#[cfg(any(test, feature = "fault-injection"))]
279pub fn arm_entity_compensation_fail_scoped(ns: &str) -> FaultInjectionArm {
280 arm_fault(&ENTITY_COMPENSATION_FAIL_NS, ns, MAX_FAULT_ARMS)
281}
282
283#[cfg(any(test, feature = "fault-injection"))]
295pub fn arm_prefix_resolve_fail_scoped(prefix: &str) -> FaultInjectionArm {
296 arm_fault(&PREFIX_RESOLVE_FAIL_NS, prefix, MAX_FAULT_ARMS)
297}
298
299#[cfg(any(test, feature = "fault-injection"))]
304static ROLLBACK_CLEANUP_FAIL_NS: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
305
306#[cfg(any(test, feature = "fault-injection"))]
313pub fn arm_rollback_cleanup_fail(ns: &str) {
314 *ROLLBACK_CLEANUP_FAIL_NS.lock().unwrap() = Some(ns.to_string());
315}
316
317#[cfg(any(test, feature = "fault-injection"))]
326pub(crate) fn consume_fts_fail_fault(ns: &str) -> bool {
327 consume_fault(&FTS_FAIL_NS, ns)
328}
329#[cfg(any(test, feature = "fault-injection"))]
330pub(crate) fn consume_vector_fail_fault(ns: &str) -> bool {
331 consume_fault(&VECTOR_FAIL_NS, ns)
332}
333
334#[derive(Clone, Debug)]
336pub struct NoteSearchHit {
337 pub note_id: Uuid,
338 pub score: DeterministicScore,
339 pub rank_score_kind: crate::RankScoreKind,
340 pub signals: crate::SearchSignals,
341 pub source: crate::SearchSource,
342 pub title: Option<String>,
343 pub snippet: Option<String>,
344}
345
346fn salience_weighted_rank(score: DeterministicScore, salience: Option<f64>) -> DeterministicScore {
347 const SCALE_RAW: i128 = 1_i128 << 32;
348 let salience = DeterministicScore::from_f64(salience.unwrap_or(0.5));
349 let weight_raw = SCALE_RAW / 2 + i128::from(salience.to_raw()) / 2;
350 let weighted_raw = i128::from(score.to_raw()) * weight_raw / SCALE_RAW;
353 DeterministicScore::from_raw(weighted_raw.clamp(
354 i128::from(DeterministicScore::NEG_INF.to_raw()),
355 i128::from(DeterministicScore::MAX.to_raw()),
356 ) as i64)
357}
358
359#[derive(Clone, Debug)]
363pub struct NoteSearchOutcome {
364 pub hits: Vec<NoteSearchHit>,
365 pub vector_error: Option<String>,
366}
367
368pub fn hex_prefix_to_uuid_pattern(prefix: &str) -> String {
379 if prefix.contains('-') {
380 return prefix.to_string();
381 }
382 const BOUNDARIES: [usize; 4] = [8, 13, 18, 23]; let mut out = String::with_capacity(36);
384 for c in prefix.chars() {
385 if BOUNDARIES.contains(&out.len()) {
386 out.push('-');
387 }
388 out.push(c);
389 }
390 out
391}
392
393pub fn uuid_prefix_bounds(prefix: &str) -> Option<(String, String)> {
403 const HYPHEN_POSITIONS: [usize; 4] = [8, 13, 18, 23];
404
405 let compact = if prefix.contains('-') {
406 if prefix.len() > 36 {
407 return None;
408 }
409 let mut compact = String::with_capacity(32);
410 for (index, byte) in prefix.bytes().enumerate() {
411 if HYPHEN_POSITIONS.contains(&index) {
412 if byte != b'-' {
413 return None;
414 }
415 } else if byte.is_ascii_hexdigit() {
416 compact.push(char::from(byte.to_ascii_lowercase()));
417 } else {
418 return None;
419 }
420 }
421 compact
422 } else {
423 if prefix.is_empty()
424 || prefix.len() > 32
425 || !prefix.bytes().all(|byte| byte.is_ascii_hexdigit())
426 {
427 return None;
428 }
429 prefix.to_ascii_lowercase()
430 };
431
432 if compact.is_empty() || compact.len() > 32 {
433 return None;
434 }
435
436 let lower = hex_prefix_to_uuid_pattern(&compact);
437 let mut successor = compact.into_bytes();
438 let mut carried_past_start = true;
439 for index in (0..successor.len()).rev() {
440 let next = match successor[index] {
441 b'0'..=b'8' | b'a'..=b'e' => Some(successor[index] + 1),
442 b'9' => Some(b'a'),
443 b'f' => None,
444 _ => return None,
445 };
446 if let Some(next) = next {
447 successor[index] = next;
448 successor.truncate(index + 1);
449 carried_past_start = false;
450 break;
451 }
452 }
453
454 let upper = if carried_past_start {
455 "g".to_string()
456 } else {
457 let compact_upper = String::from_utf8(successor).ok()?;
458 hex_prefix_to_uuid_pattern(&compact_upper)
459 };
460 Some((lower, upper))
461}
462
463fn resolve_prefix_statement(
464 table: &str,
465 has_deleted_at: bool,
466 include_deleted: bool,
467 namespaces: Option<&[String]>,
468 lower: &str,
469 upper: &str,
470) -> SqlStatement {
471 let namespace_clause = namespaces.map(|namespaces| {
472 let placeholders: Vec<String> = (0..namespaces.len())
473 .map(|index| format!("?{}", index + 3))
474 .collect();
475 format!(" AND namespace IN ({})", placeholders.join(", "))
476 });
477 let deleted_filter = if has_deleted_at && !include_deleted {
478 " AND deleted_at IS NULL"
479 } else {
480 ""
481 };
482 let mut params = vec![
483 SqlValue::Text(lower.to_owned()),
484 SqlValue::Text(upper.to_owned()),
485 ];
486 if let Some(namespaces) = namespaces {
487 params.extend(
488 namespaces
489 .iter()
490 .map(|namespace| SqlValue::Text(namespace.clone())),
491 );
492 }
493
494 SqlStatement {
495 sql: format!(
496 "SELECT id FROM {table} \
497 WHERE id >= ?1 AND id < ?2{namespace_clause}{deleted_filter} LIMIT 2",
498 namespace_clause = namespace_clause.as_deref().unwrap_or("")
499 ),
500 params,
501 label: Some("resolve_prefix".into()),
502 }
503}
504
505fn text_preview(text: &str, max_chars: usize) -> Option<String> {
506 let trimmed = text.trim();
507 if trimmed.is_empty() {
508 None
509 } else {
510 Some(trimmed.chars().take(max_chars).collect())
511 }
512}
513
514fn normalize_symmetric_direction(
520 direction: Direction,
521 relations: Option<&[EdgeRelation]>,
522) -> Direction {
523 let Some(rels) = relations else {
524 return direction;
525 };
526 if rels.is_empty() {
527 return direction;
528 }
529 let all_symmetric = rels
530 .iter()
531 .all(|r| matches!(r, EdgeRelation::CompetesWith | EdgeRelation::ComposedWith));
532 if all_symmetric {
533 Direction::Both
534 } else {
535 direction
536 }
537}
538
539fn direction_sort_rank(direction: &Direction) -> u8 {
546 match direction {
547 Direction::Out => 0,
548 Direction::In => 1,
549 Direction::Both => 2,
550 }
551}
552
553fn note_title(note: &Note) -> Option<String> {
554 note.name
555 .clone()
556 .filter(|s| !s.trim().is_empty())
557 .or_else(|| Some(format!("[{}]", note.kind.as_str())))
558}
559
560fn note_snippet(note: &Note) -> Option<String> {
561 text_preview(¬e.content, 200)
562}
563
564const TRANSPORT_OWNED_MESSAGE_PROPERTIES: &[&str] =
572 &["quarantined", "channel_kind", "channel_slug"];
573
574fn transport_owned_message_property_named_in(
575 properties: &serde_json::Map<String, serde_json::Value>,
576) -> Option<&'static str> {
577 TRANSPORT_OWNED_MESSAGE_PROPERTIES
578 .iter()
579 .copied()
580 .find(|key| properties.contains_key(*key))
581}
582
583#[derive(Clone, Debug)]
585pub enum Resolved {
586 Entity(Entity),
587 Note(Note),
588 Event(Event),
589 PackRecord {
596 pack: String,
597 kind: String,
598 data: serde_json::Value,
599 },
600}
601
602#[derive(Clone, Copy, Debug, Eq, PartialEq)]
609pub enum EdgeEndpointKind {
610 Entity,
611 Note,
612 Event,
613 Edge,
614}
615
616fn resolved_pair(r: Option<&Resolved>) -> Option<(&'static str, &str, Option<&str>)> {
622 match r? {
623 Resolved::Entity(e) => Some(("entity", e.kind.as_str(), e.entity_type.as_deref())),
624 Resolved::Note(n) => Some(("note", n.kind.as_str(), None)),
625 Resolved::Event(_) => None,
626 Resolved::PackRecord { .. } => None,
627 }
628}
629
630pub fn endpoint_matches(
638 spec: &EndpointKind,
639 substrate: &str,
640 kind: &str,
641 entity_type: Option<&str>,
642) -> bool {
643 match spec {
644 EndpointKind::EntityOfKind(k) => substrate == "entity" && *k == kind,
645 EndpointKind::NoteOfKind(k) => substrate == "note" && *k == kind,
646 EndpointKind::EntityOfType {
647 kind: k,
648 entity_type: t,
649 } => substrate == "entity" && *k == kind && entity_type == Some(*t),
650 }
651}
652
653fn pattern_endpoint_matches(
667 spec: &EndpointKind,
668 substrate: &str,
669 kind: &str,
670 entity_type: Option<&str>,
671) -> bool {
672 match spec {
673 EndpointKind::EntityOfType {
674 kind: k,
675 entity_type: t,
676 } => substrate == "entity" && *k == kind && entity_type.is_none_or(|et| et == *t),
677 _ => endpoint_matches(spec, substrate, kind, entity_type),
678 }
679}
680
681pub fn accepted_pack_relations_for_entities(
695 rules: &[EdgeEndpointRule],
696 src_kind: &str,
697 src_entity_type: Option<&str>,
698 tgt_kind: &str,
699 tgt_entity_type: Option<&str>,
700) -> Vec<EdgeRelation> {
701 let mut relations: Vec<EdgeRelation> = rules
702 .iter()
703 .filter(|r| {
704 endpoint_matches(&r.source, "entity", src_kind, src_entity_type)
705 && endpoint_matches(&r.target, "entity", tgt_kind, tgt_entity_type)
706 })
707 .map(|r| r.relation)
708 .collect();
709 relations.sort_by_key(|r| r.as_str());
710 relations.dedup();
711 relations
712}
713
714pub fn accepted_entity_relations_for_entities(
725 rules: &[EdgeEndpointRule],
726 src_kind: &str,
727 src_entity_type: Option<&str>,
728 tgt_kind: &str,
729 tgt_entity_type: Option<&str>,
730) -> Vec<EdgeRelation> {
731 let mut relations: Vec<EdgeRelation> = BASE_ENTITY_ENDPOINT_RULES
732 .iter()
733 .filter(|(src, _relation, tgt)| (*src == "*" || *src == src_kind) && *tgt == tgt_kind)
734 .map(|(_src, relation, _tgt)| *relation)
735 .collect();
736 relations.extend(
737 accepted_pack_relations_for_entities(
738 rules,
739 src_kind,
740 src_entity_type,
741 tgt_kind,
742 tgt_entity_type,
743 )
744 .into_iter()
745 .filter(|relation| {
746 *relation != EdgeRelation::Annotates && !crate::pack::is_special_relation(*relation)
747 }),
748 );
749 relations.sort_by_key(|relation| relation.as_str());
750 relations.dedup();
751 relations
752}
753
754fn accepted_entity_relations_description(
755 rules: &[EdgeEndpointRule],
756 src_kind: &str,
757 src_entity_type: Option<&str>,
758 tgt_kind: &str,
759 tgt_entity_type: Option<&str>,
760) -> String {
761 let relations = accepted_entity_relations_for_entities(
762 rules,
763 src_kind,
764 src_entity_type,
765 tgt_kind,
766 tgt_entity_type,
767 );
768 if relations.is_empty() {
769 "none".to_string()
770 } else {
771 relations
772 .iter()
773 .map(EdgeRelation::as_str)
774 .collect::<Vec<_>>()
775 .join(", ")
776 }
777}
778
779fn accepted_pack_relations_for_pattern_entities(
785 rules: &[EdgeEndpointRule],
786 src_kind: &str,
787 src_entity_type: Option<&str>,
788 tgt_kind: &str,
789 tgt_entity_type: Option<&str>,
790) -> Vec<EdgeRelation> {
791 let mut relations: Vec<EdgeRelation> = rules
792 .iter()
793 .filter(|r| {
794 pattern_endpoint_matches(&r.source, "entity", src_kind, src_entity_type)
795 && pattern_endpoint_matches(&r.target, "entity", tgt_kind, tgt_entity_type)
796 })
797 .map(|r| r.relation)
798 .collect();
799 relations.sort_by_key(|r| r.as_str());
800 relations.dedup();
801 relations
802}
803
804fn accepted_entity_kind_pairs_for_relation(
815 pack_rules: &[EdgeEndpointRule],
816 relation: EdgeRelation,
817) -> Vec<(&'static str, &'static str)> {
818 let mut pairs = Vec::new();
819 for src in khive_types::EntityKind::ALL {
820 for tgt in khive_types::EntityKind::ALL {
821 let allowed = base_entity_rule_allows(src.name(), relation, tgt.name())
822 || (!crate::pack::is_special_relation(relation)
823 && accepted_pack_relations_for_pattern_entities(
824 pack_rules,
825 src.name(),
826 None,
827 tgt.name(),
828 None,
829 )
830 .contains(&relation));
831 if allowed {
832 pairs.push((src.name(), tgt.name()));
833 }
834 }
835 }
836 pairs
837}
838
839fn static_impossible_edge_pattern_warnings(
854 language: khive_query::QueryLanguage,
855 pattern: &khive_query::ast::MatchPattern,
856 pack_rules: &[EdgeEndpointRule],
857) -> Vec<String> {
858 use khive_query::ast::{EdgeDirection, PatternElement};
859
860 if language != khive_query::QueryLanguage::Gql {
861 return Vec::new();
862 }
863
864 let elements = &pattern.elements;
865 let mut warnings = Vec::new();
866
867 for (i, el) in elements.iter().enumerate() {
868 let PatternElement::Edge(edge) = el else {
869 continue;
870 };
871 if edge.relations.len() != 1 || edge.min_hops != 1 || edge.max_hops != 1 {
872 continue;
873 }
874 let (left, right) = match (elements.get(i.wrapping_sub(1)), elements.get(i + 1)) {
875 (Some(PatternElement::Node(l)), Some(PatternElement::Node(r))) => (l, r),
876 _ => continue,
877 };
878 let (src_node, tgt_node) = match edge.direction {
879 EdgeDirection::Out => (left, right),
880 EdgeDirection::In => (right, left),
881 EdgeDirection::Both => continue,
882 };
883 let (Some(src_raw), Some(tgt_raw)) = (src_node.kind.as_deref(), tgt_node.kind.as_deref())
884 else {
885 continue;
886 };
887 let (Ok(src_kind), Ok(tgt_kind)) = (
888 src_raw.parse::<khive_types::EntityKind>(),
889 tgt_raw.parse::<khive_types::EntityKind>(),
890 ) else {
891 continue;
892 };
893 let Ok(relation) = edge.relations[0].parse::<EdgeRelation>() else {
894 continue;
895 };
896
897 let possible = base_entity_rule_allows(src_kind.name(), relation, tgt_kind.name())
898 || (!crate::pack::is_special_relation(relation)
899 && accepted_pack_relations_for_pattern_entities(
900 pack_rules,
901 src_kind.name(),
902 src_node.entity_type.as_deref(),
903 tgt_kind.name(),
904 tgt_node.entity_type.as_deref(),
905 )
906 .contains(&relation));
907 if possible {
908 continue;
909 }
910
911 let accepted = accepted_entity_kind_pairs_for_relation(pack_rules, relation);
912 let accepted_str = if accepted.is_empty() {
913 "none".to_string()
914 } else {
915 accepted
916 .iter()
917 .map(|(s, t)| format!("{s}->{t}"))
918 .collect::<Vec<_>>()
919 .join(", ")
920 };
921 warnings.push(format!(
922 "pattern ({src})-[:{relation}]->({tgt}) can never match: '{relation}' does not accept \
923 {src}->{tgt} endpoints; accepted source->target kinds for '{relation}': {accepted_str}",
924 src = src_kind.name(),
925 tgt = tgt_kind.name(),
926 ));
927 }
928
929 warnings
930}
931
932fn pack_rule_allows(
935 rules: &[EdgeEndpointRule],
936 relation: EdgeRelation,
937 src: Option<&Resolved>,
938 tgt: Option<&Resolved>,
939) -> bool {
940 let Some((src_sub, src_kind, src_type)) = resolved_pair(src) else {
941 return false;
942 };
943 let Some((tgt_sub, tgt_kind, tgt_type)) = resolved_pair(tgt) else {
944 return false;
945 };
946 rules.iter().any(|r| {
947 r.relation == relation
948 && endpoint_matches(&r.source, src_sub, src_kind, src_type)
949 && endpoint_matches(&r.target, tgt_sub, tgt_kind, tgt_type)
950 })
951}
952
953pub const BASE_ENTITY_ENDPOINT_RULES: &[(&str, EdgeRelation, &str)] = &[
963 ("concept", EdgeRelation::Contains, "concept"),
965 ("project", EdgeRelation::Contains, "project"),
966 ("project", EdgeRelation::Contains, "artifact"),
967 ("org", EdgeRelation::Contains, "project"),
968 ("org", EdgeRelation::Contains, "service"),
969 ("concept", EdgeRelation::PartOf, "concept"),
970 ("project", EdgeRelation::PartOf, "project"),
971 ("project", EdgeRelation::PartOf, "org"),
972 ("*", EdgeRelation::InstanceOf, "concept"),
973 ("service", EdgeRelation::InstanceOf, "project"),
974 ("document", EdgeRelation::LinksTo, "document"),
979 ("concept", EdgeRelation::Extends, "concept"),
981 ("concept", EdgeRelation::VariantOf, "concept"),
982 ("artifact", EdgeRelation::VariantOf, "artifact"),
983 ("concept", EdgeRelation::IntroducedBy, "document"),
984 ("concept", EdgeRelation::IntroducedBy, "person"),
985 ("artifact", EdgeRelation::IntroducedBy, "document"),
986 ("project", EdgeRelation::IntroducedBy, "document"),
987 ("service", EdgeRelation::IntroducedBy, "document"),
990 ("document", EdgeRelation::IntroducedBy, "person"),
991 ("document", EdgeRelation::IntroducedBy, "org"),
992 ("concept", EdgeRelation::IntroducedBy, "org"),
993 ("artifact", EdgeRelation::DerivedFrom, "dataset"),
995 ("artifact", EdgeRelation::DerivedFrom, "document"),
996 ("artifact", EdgeRelation::DerivedFrom, "project"),
997 ("artifact", EdgeRelation::DerivedFrom, "artifact"),
998 ("document", EdgeRelation::DerivedFrom, "document"),
1001 ("document", EdgeRelation::Precedes, "document"),
1003 ("dataset", EdgeRelation::Precedes, "dataset"),
1004 ("artifact", EdgeRelation::Precedes, "artifact"),
1005 ("service", EdgeRelation::Precedes, "service"),
1006 ("project", EdgeRelation::Precedes, "project"),
1007 ("project", EdgeRelation::DependsOn, "project"),
1009 ("service", EdgeRelation::DependsOn, "project"),
1010 ("service", EdgeRelation::DependsOn, "service"),
1011 ("service", EdgeRelation::DependsOn, "artifact"),
1012 ("service", EdgeRelation::DependsOn, "dataset"),
1013 ("artifact", EdgeRelation::DependsOn, "project"),
1014 ("artifact", EdgeRelation::DependsOn, "service"),
1015 ("document", EdgeRelation::DependsOn, "document"),
1016 ("concept", EdgeRelation::Enables, "concept"),
1017 ("service", EdgeRelation::Enables, "concept"),
1018 ("dataset", EdgeRelation::Enables, "concept"),
1019 ("project", EdgeRelation::Implements, "concept"),
1021 ("service", EdgeRelation::Implements, "concept"),
1022 ("concept", EdgeRelation::CompetesWith, "concept"),
1024 ("project", EdgeRelation::CompetesWith, "project"),
1025 ("service", EdgeRelation::CompetesWith, "service"),
1026 ("concept", EdgeRelation::ComposedWith, "concept"),
1027 ("project", EdgeRelation::ComposedWith, "project"),
1028 ("concept", EdgeRelation::Supersedes, "concept"),
1030 ("document", EdgeRelation::Supersedes, "document"),
1031 ("artifact", EdgeRelation::Supersedes, "artifact"),
1032 ("service", EdgeRelation::Supersedes, "service"),
1033 ("dataset", EdgeRelation::Supersedes, "dataset"),
1034 ("concept", EdgeRelation::Supports, "concept"),
1036 ("document", EdgeRelation::Supports, "concept"),
1037 ("dataset", EdgeRelation::Supports, "concept"),
1038 ("artifact", EdgeRelation::Supports, "concept"),
1039 ("concept", EdgeRelation::Refutes, "concept"),
1040 ("document", EdgeRelation::Refutes, "concept"),
1041 ("dataset", EdgeRelation::Refutes, "concept"),
1042 ("artifact", EdgeRelation::Refutes, "concept"),
1043];
1044
1045pub fn base_entity_endpoint_rules() -> &'static [(&'static str, EdgeRelation, &'static str)] {
1051 BASE_ENTITY_ENDPOINT_RULES
1052}
1053
1054pub fn base_entity_rule_allows(src_kind: &str, relation: EdgeRelation, tgt_kind: &str) -> bool {
1060 BASE_ENTITY_ENDPOINT_RULES.iter().any(|(src, rel, tgt)| {
1061 *rel == relation && (*src == "*" || *src == src_kind) && *tgt == tgt_kind
1062 })
1063}
1064
1065pub(crate) fn canonical_edge_endpoints(
1071 relation: EdgeRelation,
1072 source_id: Uuid,
1073 target_id: Uuid,
1074) -> (Uuid, Uuid) {
1075 if relation.is_symmetric() && target_id < source_id {
1076 (target_id, source_id)
1077 } else {
1078 (source_id, target_id)
1079 }
1080}
1081
1082pub(crate) fn infer_dependency_kind(src_kind: &str, tgt_kind: &str) -> Option<&'static str> {
1088 match (src_kind, tgt_kind) {
1089 ("project", "project") => Some("build"),
1090 ("service", "service") => Some("runtime"),
1091 ("service", "dataset") => Some("data"),
1092 ("service", "artifact") => Some("artifact"),
1093 ("artifact", "project") | ("artifact", "service") => Some("tooling"),
1094 ("document", "document") => Some("normative"),
1095 _ => None,
1096 }
1097}
1098
1099pub(crate) fn merge_dependency_kind(
1109 src_kind: &str,
1110 tgt_kind: &str,
1111 metadata: Option<serde_json::Value>,
1112) -> Option<serde_json::Value> {
1113 if let Some(ref m) = metadata {
1114 if m.get("dependency_kind").is_some() {
1115 return metadata;
1116 }
1117 }
1118 let Some(inferred) = infer_dependency_kind(src_kind, tgt_kind) else {
1119 return metadata;
1120 };
1121 let mut obj = metadata.unwrap_or_else(|| serde_json::json!({}));
1122 if let Some(o) = obj.as_object_mut() {
1123 o.insert("dependency_kind".to_string(), serde_json::json!(inferred));
1124 }
1125 Some(obj)
1126}
1127
1128pub fn merge_entry_metadata(
1142 metadata: Option<serde_json::Value>,
1143 dependency_kind: Option<String>,
1144) -> RuntimeResult<Option<serde_json::Value>> {
1145 let Some(dk) = dependency_kind else {
1146 return Ok(metadata);
1147 };
1148 let mut obj = metadata.unwrap_or_else(|| serde_json::json!({}));
1149 let map = obj
1150 .as_object_mut()
1151 .ok_or_else(|| RuntimeError::InvalidInput("metadata must be a JSON object".into()))?;
1152 map.entry("dependency_kind".to_string())
1153 .or_insert_with(|| serde_json::json!(dk));
1154 Ok(Some(obj))
1155}
1156
1157const VALID_DEPENDENCY_KINDS: &[&str] = &[
1159 "build",
1160 "runtime",
1161 "data",
1162 "artifact",
1163 "tooling",
1164 "normative",
1165];
1166
1167pub(crate) fn validate_edge_weight(weight: f64) -> RuntimeResult<()> {
1173 if !weight.is_finite() || !(0.0..=1.0).contains(&weight) {
1174 return Err(RuntimeError::InvalidInput(format!(
1175 "edge weight must be finite and in [0.0, 1.0], got {weight}"
1176 )));
1177 }
1178 Ok(())
1179}
1180
1181pub(crate) fn validate_edge_metadata(
1187 relation: EdgeRelation,
1188 metadata: Option<&serde_json::Value>,
1189) -> RuntimeResult<()> {
1190 let Some(meta) = metadata else {
1191 return Ok(());
1192 };
1193 if let Some(dk) = meta.get("dependency_kind") {
1194 if relation != EdgeRelation::DependsOn {
1195 return Err(RuntimeError::InvalidInput(format!(
1196 "dependency_kind is only valid on depends_on edges (got {})",
1197 relation.as_str()
1198 )));
1199 }
1200 let dk_str = dk
1201 .as_str()
1202 .ok_or_else(|| RuntimeError::InvalidInput("dependency_kind must be a string".into()))?;
1203 if !VALID_DEPENDENCY_KINDS.contains(&dk_str) {
1204 return Err(RuntimeError::InvalidInput(format!(
1205 "unknown dependency_kind {dk_str:?}; valid: {}",
1206 VALID_DEPENDENCY_KINDS.join(" | ")
1207 )));
1208 }
1209 }
1210 Ok(())
1211}
1212
1213fn note_props_match(note_props: Option<&serde_json::Value>, filter: &serde_json::Value) -> bool {
1218 let required = match filter.as_object() {
1219 Some(obj) if !obj.is_empty() => obj,
1220 _ => return true,
1221 };
1222 let actual = match note_props.and_then(serde_json::Value::as_object) {
1223 Some(obj) => obj,
1224 None => return false,
1225 };
1226 required
1227 .iter()
1228 .all(|(k, v)| actual.get(k).is_some_and(|av| av == v))
1229}
1230
1231fn note_graph_name(note: &Note) -> String {
1232 note.name
1233 .as_deref()
1234 .filter(|name| !name.trim().is_empty())
1235 .map(str::to_owned)
1236 .unwrap_or_else(|| format!("[{}]", note.kind))
1237}
1238
1239fn merge_traversal_paths_by_root(paths: Vec<GraphPath>, limit: Option<u32>) -> Vec<GraphPath> {
1244 let mut order: Vec<Uuid> = Vec::new();
1245 let mut merged: HashMap<Uuid, GraphPath> = HashMap::new();
1246 let mut node_index: HashMap<Uuid, HashMap<Uuid, usize>> = HashMap::new();
1250
1251 for path in paths {
1252 let existing = merged.entry(path.root_id).or_insert_with(|| {
1253 order.push(path.root_id);
1254 GraphPath {
1255 root_id: path.root_id,
1256 nodes: Vec::new(),
1257 total_weight: 0.0,
1258 }
1259 });
1260 let index = node_index.entry(path.root_id).or_default();
1261 for node in path.nodes {
1262 match index.get(&node.node_id) {
1263 Some(&i) => {
1264 if node.depth < existing.nodes[i].depth {
1265 existing.nodes[i] = node;
1266 }
1267 }
1268 None => {
1269 index.insert(node.node_id, existing.nodes.len());
1270 existing.nodes.push(node);
1271 }
1272 }
1273 }
1274 }
1275
1276 order
1277 .into_iter()
1278 .filter_map(|root_id| merged.remove(&root_id))
1279 .map(|mut path| {
1280 path.nodes.sort_by_key(|n| n.depth);
1282 if let Some(lim) = limit {
1283 let lim = lim as usize;
1284 let mut non_root_kept = 0usize;
1285 path.nodes.retain(|n| {
1286 if n.depth == 0 {
1287 return true;
1288 }
1289 if non_root_kept < lim {
1290 non_root_kept += 1;
1291 true
1292 } else {
1293 false
1294 }
1295 });
1296 }
1297 recompute_total_weight(&mut path);
1298 path
1299 })
1300 .collect()
1301}
1302
1303fn recompute_total_weight(path: &mut GraphPath) {
1312 path.total_weight = path.nodes.iter().map(|n| n.weight).fold(0.0_f64, f64::max);
1313}
1314
1315async fn drain_embed_join_set<T: Send + 'static>(
1329 mut join_set: tokio::task::JoinSet<(usize, RuntimeResult<T>)>,
1330 model_count: usize,
1331) -> RuntimeResult<Vec<T>> {
1332 let mut vectors: Vec<Option<T>> = (0..model_count).map(|_| None).collect();
1333
1334 while let Some(joined) = join_set.join_next().await {
1335 match joined {
1336 Ok((idx, Ok(vector))) => vectors[idx] = Some(vector),
1337 Ok((_idx, Err(e))) => {
1338 join_set.abort_all();
1339 return Err(e);
1340 }
1341 Err(join_err) => {
1342 join_set.abort_all();
1343 return Err(RuntimeError::Internal(format!(
1344 "embed task panicked: {join_err}"
1345 )));
1346 }
1347 }
1348 }
1349
1350 Ok(vectors
1351 .into_iter()
1352 .map(|v| v.expect("every model index observed exactly once by join_set drain"))
1353 .collect())
1354}
1355
1356impl KhiveRuntime {
1357 async fn compensate_entity_create(
1360 &self,
1361 token: &NamespaceToken,
1362 entity_id: Uuid,
1363 namespace: &str,
1364 vector_models: &[String],
1365 ) -> Vec<String> {
1366 let mut cleanup_errors = Vec::new();
1367
1368 #[cfg(any(test, feature = "fault-injection"))]
1369 let entity_delete_injected = consume_fault(&ENTITY_COMPENSATION_FAIL_NS, namespace);
1370 #[cfg(not(any(test, feature = "fault-injection")))]
1371 let entity_delete_injected = false;
1372
1373 if entity_delete_injected {
1374 cleanup_errors.push("entity row delete: injected compensation failure".to_string());
1375 } else {
1376 match self.entities(token) {
1377 Ok(store) => {
1378 if let Err(error) = store.delete_entity(entity_id, DeleteMode::Hard).await {
1379 cleanup_errors.push(format!("entity row delete: {error}"));
1380 }
1381 }
1382 Err(error) => cleanup_errors.push(format!("entity store access: {error}")),
1383 }
1384 }
1385
1386 match self.text(token) {
1387 Ok(fts) => {
1388 if let Err(error) = fts.delete_document(namespace, entity_id).await {
1389 cleanup_errors.push(format!("FTS document delete: {error}"));
1390 }
1391 }
1392 Err(error) => cleanup_errors.push(format!("FTS store access: {error}")),
1393 }
1394
1395 for model_name in vector_models {
1396 match self.vectors_for_model(token, model_name) {
1397 Ok(vectors) => {
1398 if let Err(error) = vectors.delete(entity_id).await {
1399 cleanup_errors
1400 .push(format!("vector delete for model {model_name}: {error}"));
1401 }
1402 }
1403 Err(error) => cleanup_errors.push(format!(
1404 "vector store access for model {model_name}: {error}"
1405 )),
1406 }
1407 }
1408
1409 cleanup_errors
1410 }
1411
1412 fn entity_create_failure(
1413 entity_id: Uuid,
1414 primary: RuntimeError,
1415 cleanup_errors: Vec<String>,
1416 ) -> RuntimeError {
1417 if cleanup_errors.is_empty() {
1418 primary
1419 } else {
1420 RuntimeError::Khive(KhiveError::internal(format!(
1421 "create_entity indexing failed for record {entity_id}; primary failure: \
1422 {primary}; compensation failure(s): {}; partial persistence is possible; \
1423 inspect and reconcile this record before retrying",
1424 cleanup_errors.join("; ")
1425 )))
1426 }
1427 }
1428
1429 #[allow(clippy::too_many_arguments)]
1440 pub async fn create_entity(
1441 &self,
1442 token: &NamespaceToken,
1443 kind: &str,
1444 entity_type: Option<&str>,
1445 name: &str,
1446 description: Option<&str>,
1447 properties: Option<serde_json::Value>,
1448 tags: Vec<String>,
1449 ) -> RuntimeResult<Entity> {
1450 Ok(self
1451 .create_entity_with_embedding_report_inner(
1452 token,
1453 kind,
1454 entity_type,
1455 name,
1456 description,
1457 properties,
1458 tags,
1459 Vec::new(),
1460 )
1461 .await?
1462 .0)
1463 }
1464
1465 #[allow(clippy::too_many_arguments)]
1475 pub async fn create_entity_with_attachments(
1476 &self,
1477 token: &NamespaceToken,
1478 kind: &str,
1479 entity_type: Option<&str>,
1480 name: &str,
1481 description: Option<&str>,
1482 properties: Option<serde_json::Value>,
1483 tags: Vec<String>,
1484 attachments: Vec<NewAttachment>,
1485 ) -> RuntimeResult<Entity> {
1486 drop(self.attachments()?);
1490 let blob_store = self.blob_store().ok_or_else(|| {
1491 RuntimeError::Unconfigured(
1492 "create_entity_with_attachments requires an installed BlobStore".to_string(),
1493 )
1494 })?;
1495 let mut roles = std::collections::HashSet::with_capacity(attachments.len());
1496 for attachment in &attachments {
1497 attachment.validate()?;
1498 if !roles.insert(attachment.role.as_str()) {
1499 return Err(RuntimeError::InvalidInput(format!(
1500 "duplicate attachment role {:?}",
1501 attachment.role
1502 )));
1503 }
1504 }
1505 for attachment in &attachments {
1506 if !blob_store.exists(&attachment.content_ref).await? {
1507 return Err(RuntimeError::InvalidInput(format!(
1508 "create_entity_with_attachments requires a published blob; no object exists for {}",
1509 attachment.content_ref
1510 )));
1511 }
1512 }
1513 let validated_type = self.validate_entity_type_for_kind(kind, entity_type)?;
1514 Ok(self
1515 .create_entity_with_embedding_report_inner(
1516 token,
1517 kind,
1518 validated_type.as_deref(),
1519 name,
1520 description,
1521 properties,
1522 tags,
1523 attachments,
1524 )
1525 .await?
1526 .0)
1527 }
1528
1529 #[allow(clippy::too_many_arguments)]
1530 pub async fn create_entity_with_embedding_report(
1531 &self,
1532 token: &NamespaceToken,
1533 kind: &str,
1534 entity_type: Option<&str>,
1535 name: &str,
1536 description: Option<&str>,
1537 properties: Option<serde_json::Value>,
1538 tags: Vec<String>,
1539 ) -> RuntimeResult<(Entity, crate::retrieval::EmbeddingTruncationReport)> {
1540 self.create_entity_with_embedding_report_inner(
1541 token,
1542 kind,
1543 entity_type,
1544 name,
1545 description,
1546 properties,
1547 tags,
1548 Vec::new(),
1549 )
1550 .await
1551 }
1552
1553 #[allow(clippy::too_many_arguments)]
1554 async fn create_entity_with_embedding_report_inner(
1555 &self,
1556 token: &NamespaceToken,
1557 kind: &str,
1558 entity_type: Option<&str>,
1559 name: &str,
1560 description: Option<&str>,
1561 properties: Option<serde_json::Value>,
1562 tags: Vec<String>,
1563 attachments: Vec<NewAttachment>,
1564 ) -> RuntimeResult<(Entity, crate::retrieval::EmbeddingTruncationReport)> {
1565 self.validate_entity_kind(kind)?;
1566 crate::secret_gate::reject_reserved_secret_gate_property(properties.as_ref())?;
1567 crate::secret_gate::check_at(name, "entity", "name")?;
1569 if let Some(d) = description {
1570 crate::secret_gate::check_at(d, "entity", "description")?;
1571 }
1572 if let Some(ref p) = properties {
1573 crate::secret_gate::check_json_at(p, "entity", "properties")?;
1574 }
1575 crate::secret_gate::check_tags_at(&tags, "entity", "tags")?;
1576 let ns = token.namespace().as_str();
1577 let mut entity = Entity::new(ns, kind, name).with_entity_type(entity_type);
1578 if let Some(d) = description {
1579 entity = entity.with_description(d);
1580 }
1581 if let Some(p) = properties {
1582 entity = entity.with_properties(p);
1583 }
1584 if !tags.is_empty() {
1585 entity = entity.with_tags(tags);
1586 }
1587 let projected_content_ref = attachments
1588 .iter()
1589 .find(|attachment| attachment.role == "content")
1590 .map(|attachment| attachment.content_ref.to_string());
1591 let attachment_rows = attachments
1592 .into_iter()
1593 .map(|attachment| {
1594 Attachment::from_new(
1595 entity.id,
1596 AttachmentSubstrate::Entity,
1597 attachment,
1598 entity.created_at,
1599 )
1600 })
1601 .collect();
1602 self.entities(token)?
1603 .upsert_entity_with_attachments(entity.clone(), attachment_rows)
1604 .await?;
1605 entity.content_ref = projected_content_ref;
1606
1607 let doc = entity_fts_document(&entity);
1608 let embed_body = doc.body.clone();
1609
1610 {
1612 #[cfg(any(test, feature = "fault-injection"))]
1613 let fts_inject = consume_fault(&FTS_FAIL_NS, ns);
1614 #[cfg(not(any(test, feature = "fault-injection")))]
1615 let fts_inject = false;
1616 let fts_result: RuntimeResult<()> = if fts_inject {
1617 Err(RuntimeError::Internal("injected FTS failure".to_string()))
1618 } else {
1619 match self.text(token) {
1620 Ok(fts) => fts.upsert_document(doc).await.map_err(RuntimeError::from),
1621 Err(e) => Err(e),
1622 }
1623 };
1624 if let Err(e) = fts_result {
1625 let cleanup_errors = self
1626 .compensate_entity_create(token, entity.id, ns, &[])
1627 .await;
1628 return Err(Self::entity_create_failure(entity.id, e, cleanup_errors));
1629 }
1630 }
1631
1632 let embed_model_names = {
1635 let names = self.registered_embedding_model_names();
1636 if names.is_empty() {
1637 vec![]
1638 } else {
1639 names
1640 }
1641 };
1642
1643 let mut embedding_report = crate::retrieval::EmbeddingTruncationReport::default();
1644 if embed_model_names.len() == 1 {
1645 let model_name = &embed_model_names[0];
1646 let vec_result = self
1647 .embed_document_with_model_outcome_for_token(token, model_name, &embed_body)
1648 .await;
1649
1650 #[cfg(any(test, feature = "fault-injection"))]
1651 let vec_inject = consume_fault(&VECTOR_FAIL_NS, ns);
1652 #[cfg(not(any(test, feature = "fault-injection")))]
1653 let vec_inject = false;
1654 let vec_result: RuntimeResult<crate::retrieval::DocumentEmbeddingOutcome> =
1655 if vec_inject {
1656 Err(RuntimeError::Internal(
1657 "injected vector failure".to_string(),
1658 ))
1659 } else {
1660 vec_result
1661 };
1662
1663 let single_result: RuntimeResult<()> = match vec_result {
1664 Ok(outcome) => {
1665 embedding_report.observe(&outcome);
1666 match self.vectors_for_model(token, model_name) {
1667 Ok(vs) => vs
1668 .insert(
1669 entity.id,
1670 SubstrateKind::Entity,
1671 ns,
1672 "entity.body",
1673 vec![outcome.vector],
1674 )
1675 .await
1676 .map_err(RuntimeError::from),
1677 Err(e) => Err(e),
1678 }
1679 }
1680 Err(e) => Err(e),
1681 };
1682 if let Err(e) = single_result {
1683 let cleanup_errors = self
1684 .compensate_entity_create(
1685 token,
1686 entity.id,
1687 ns,
1688 std::slice::from_ref(model_name),
1689 )
1690 .await;
1691 return Err(Self::entity_create_failure(entity.id, e, cleanup_errors));
1692 }
1693 } else if !embed_model_names.is_empty() {
1694 let rt_clone = self.clone();
1697 let body_owned = embed_body.clone();
1698 let usage_ctx = crate::usage::current();
1699 let mut join_set = tokio::task::JoinSet::new();
1700 for (idx, model_name) in embed_model_names.iter().enumerate() {
1701 let rt = rt_clone.clone();
1702 let text = body_owned.clone();
1703 let name = model_name.clone();
1704 let ctx = usage_ctx.clone();
1705 let token = (*token).clone();
1706 join_set.spawn(crate::runtime::inherit_request_embedder_scope(async move {
1707 let fut = rt.embed_document_with_model_outcome_for_token(&token, &name, &text);
1708 let result = match ctx {
1709 Some(ctx) => crate::usage::scope(ctx, fut).await,
1710 None => fut.await,
1711 };
1712 (idx, result)
1713 }));
1714 }
1715 let outcomes = match drain_embed_join_set(join_set, embed_model_names.len()).await {
1719 Ok(outcomes) => outcomes,
1720 Err(e) => {
1721 let cleanup_errors = self
1722 .compensate_entity_create(token, entity.id, ns, &[])
1723 .await;
1724 return Err(Self::entity_create_failure(entity.id, e, cleanup_errors));
1725 }
1726 };
1727 let mut inserted_models: Vec<String> = Vec::with_capacity(embed_model_names.len());
1729 for (model_name, outcome) in embed_model_names.iter().zip(outcomes) {
1730 embedding_report.observe(&outcome);
1731 #[cfg(any(test, feature = "fault-injection"))]
1733 let count_inject = VECTOR_FAIL_AFTER.with(|cell| match cell.get() {
1734 Some(0) => {
1735 cell.set(None);
1736 true
1737 }
1738 Some(n) => {
1739 cell.set(Some(n - 1));
1740 false
1741 }
1742 None => false,
1743 });
1744 #[cfg(not(any(test, feature = "fault-injection")))]
1745 let count_inject = false;
1746
1747 let insert_result = if count_inject {
1748 Err(RuntimeError::Internal(
1749 "injected vector insert failure".to_string(),
1750 ))
1751 } else {
1752 match self.vectors_for_model(token, model_name) {
1753 Ok(vs) => vs
1754 .insert(
1755 entity.id,
1756 SubstrateKind::Entity,
1757 ns,
1758 "entity.body",
1759 vec![outcome.vector],
1760 )
1761 .await
1762 .map_err(RuntimeError::from),
1763 Err(e) => Err(e),
1764 }
1765 };
1766 if let Err(e) = insert_result {
1767 let mut cleanup_models = inserted_models.clone();
1770 cleanup_models.push(model_name.clone());
1771 let cleanup_errors = self
1772 .compensate_entity_create(token, entity.id, ns, &cleanup_models)
1773 .await;
1774 return Err(Self::entity_create_failure(entity.id, e, cleanup_errors));
1775 }
1776 inserted_models.push(model_name.clone());
1777 }
1778 }
1779
1780 let event_store = self.events(token)?;
1787 let created_event = khive_storage::event::Event::new(
1788 entity.namespace.clone(),
1789 "create",
1790 EventKind::EntityCreated,
1791 SubstrateKind::Entity,
1792 "",
1793 )
1794 .with_target(entity.id)
1795 .with_payload(serde_json::json!({
1796 "id": entity.id,
1797 "namespace": entity.namespace,
1798 "kind": entity.kind,
1799 }));
1800 event_store.append_event(created_event).await.map_err(|e| {
1801 RuntimeError::Internal(format!("create_entity: event store write failed: {e}"))
1802 })?;
1803
1804 Ok((entity, embedding_report))
1805 }
1806
1807 pub async fn get_entity(&self, token: &NamespaceToken, id: Uuid) -> RuntimeResult<Entity> {
1820 let store = self.entities(token)?;
1821 if let Some(entity) = store.get_entity(id).await? {
1822 return Ok(entity);
1823 }
1824 if let Some(tombstone) = store.get_entity_including_deleted(id).await? {
1825 if let Some(kept_id) = tombstone.merged_into {
1826 return Err(RuntimeError::NotFound(format!(
1827 "{id} was merged into {kept_id}; query the kept id"
1828 )));
1829 }
1830 }
1831 Err(RuntimeError::NotFound(format!("entity {id}")))
1832 }
1833
1834 pub async fn get_entity_including_deleted(
1838 &self,
1839 token: &NamespaceToken,
1840 id: Uuid,
1841 ) -> RuntimeResult<Option<Entity>> {
1842 self.entities(token)?
1843 .get_entity_including_deleted(id)
1844 .await
1845 .map_err(Into::into)
1846 }
1847
1848 pub async fn get_note_including_deleted(
1852 &self,
1853 token: &NamespaceToken,
1854 id: Uuid,
1855 ) -> RuntimeResult<Option<khive_storage::note::Note>> {
1856 self.notes(token)?
1857 .get_note_including_deleted(id)
1858 .await
1859 .map_err(Into::into)
1860 }
1861
1862 pub async fn get_entities_by_ids(
1866 &self,
1867 token: &NamespaceToken,
1868 ids: &[Uuid],
1869 ) -> RuntimeResult<Vec<Entity>> {
1870 if ids.is_empty() {
1871 return Ok(vec![]);
1872 }
1873 let filter = EntityFilter {
1874 ids: ids.to_vec(),
1875 ..Default::default()
1876 };
1877 let page = self
1878 .entities(token)?
1879 .query_entities(
1880 token.namespace().as_str(),
1881 filter,
1882 PageRequest {
1883 offset: 0,
1884 limit: ids.len() as u32,
1885 },
1886 )
1887 .await?;
1888 Ok(page.items)
1889 }
1890
1891 async fn get_entities_by_ids_visible(
1900 &self,
1901 token: &NamespaceToken,
1902 ids: &[Uuid],
1903 ) -> RuntimeResult<Vec<Entity>> {
1904 if ids.is_empty() {
1905 return Ok(vec![]);
1906 }
1907 let namespaces: Vec<String> = token
1908 .visible_namespaces()
1909 .iter()
1910 .map(|ns| ns.as_str().to_owned())
1911 .collect();
1912 let filter = EntityFilter {
1913 ids: ids.to_vec(),
1914 namespaces,
1915 ..Default::default()
1916 };
1917 let page = self
1918 .entities(token)?
1919 .query_entities(
1920 token.namespace().as_str(),
1921 filter,
1922 PageRequest {
1923 offset: 0,
1924 limit: ids.len() as u32,
1925 },
1926 )
1927 .await?;
1928 Ok(page.items)
1929 }
1930
1931 pub(crate) fn ensure_namespace(record_ns: &str, caller_primary_ns: &str) -> RuntimeResult<()> {
1940 if record_ns == caller_primary_ns {
1941 return Ok(());
1942 }
1943 Err(RuntimeError::NotFound("not found in this namespace".into()))
1944 }
1945
1946 pub(crate) fn ensure_namespace_visible(
1952 record_ns: &str,
1953 token: &NamespaceToken,
1954 ) -> RuntimeResult<()> {
1955 for ns in token.visible_namespaces() {
1956 if record_ns == ns.as_str() {
1957 return Ok(());
1958 }
1959 }
1960 Err(RuntimeError::NotFound("not found in this namespace".into()))
1961 }
1962
1963 pub async fn list_entities(
1970 &self,
1971 token: &NamespaceToken,
1972 kind: Option<&str>,
1973 entity_type: Option<&str>,
1974 limit: u32,
1975 offset: u32,
1976 ) -> RuntimeResult<Vec<Entity>> {
1977 let filter = EntityFilter {
1978 kinds: kind
1979 .map(|value| vec![value.to_string()])
1980 .unwrap_or_default(),
1981 entity_types: entity_type
1982 .map(|value| vec![value.to_string()])
1983 .unwrap_or_default(),
1984 legacy_entity_type_fallback: true,
1985 ..Default::default()
1986 };
1987 self.list_entities_filtered(token, filter, limit, offset)
1988 .await
1989 }
1990
1991 pub async fn list_entities_filtered(
1994 &self,
1995 token: &NamespaceToken,
1996 mut filter: EntityFilter,
1997 limit: u32,
1998 offset: u32,
1999 ) -> RuntimeResult<Vec<Entity>> {
2000 filter.namespaces = token
2001 .visible_namespaces()
2002 .iter()
2003 .map(|namespace| namespace.as_str().to_owned())
2004 .collect();
2005 let page = self
2006 .entities(token)?
2007 .query_entities(
2008 token.namespace().as_str(),
2009 filter,
2010 PageRequest {
2011 offset: offset.into(),
2012 limit,
2013 },
2014 )
2015 .await?;
2016 Ok(page.items)
2017 }
2018
2019 pub async fn list_entities_after(
2026 &self,
2027 token: &NamespaceToken,
2028 kind: Option<&str>,
2029 entity_type: Option<&str>,
2030 tags_any: &[String],
2031 after: Option<Uuid>,
2032 limit: u32,
2033 ) -> RuntimeResult<(Vec<Entity>, Option<Uuid>)> {
2034 let filter = EntityFilter {
2035 kinds: kind
2036 .map(|value| vec![value.to_string()])
2037 .unwrap_or_default(),
2038 entity_types: entity_type
2039 .map(|value| vec![value.to_string()])
2040 .unwrap_or_default(),
2041 legacy_entity_type_fallback: true,
2042 tags_any: tags_any.to_vec(),
2043 ..Default::default()
2044 };
2045 self.list_entities_after_filtered(token, filter, after, limit)
2046 .await
2047 }
2048
2049 pub async fn list_entities_after_filtered(
2052 &self,
2053 token: &NamespaceToken,
2054 mut filter: EntityFilter,
2055 after: Option<Uuid>,
2056 limit: u32,
2057 ) -> RuntimeResult<(Vec<Entity>, Option<Uuid>)> {
2058 let store = self.entities(token)?;
2059 let after = match after {
2060 Some(id) => {
2061 let entity = self
2062 .get_entity_including_deleted(token, id)
2063 .await?
2064 .ok_or_else(|| RuntimeError::NotFound(format!("entity cursor {id}")))?;
2065 Self::ensure_namespace_visible(&entity.namespace, token)?;
2066 let sequence = store.entity_sequence(id).await?.ok_or_else(|| {
2067 RuntimeError::Internal(format!(
2068 "entity cursor {id} has no insertion-sequence ledger row"
2069 ))
2070 })?;
2071 Some(SeekCursor { sequence, id })
2072 }
2073 None => None,
2074 };
2075 filter.namespaces = token
2076 .visible_namespaces()
2077 .iter()
2078 .map(|namespace| namespace.as_str().to_owned())
2079 .collect();
2080 let page = store
2081 .query_entities_after(token.namespace().as_str(), filter, after, limit)
2082 .await?;
2083 Ok((page.items, page.next_after.map(|cursor| cursor.id)))
2084 }
2085
2086 pub async fn list_entities_tagged(
2093 &self,
2094 token: &NamespaceToken,
2095 kind: Option<&str>,
2096 domain_tag: Option<&str>,
2097 limit: u32,
2098 offset: u32,
2099 ) -> RuntimeResult<Vec<Entity>> {
2100 let ns_strs: Vec<String> = token
2101 .visible_namespaces()
2102 .iter()
2103 .map(|ns| ns.as_str().to_owned())
2104 .collect();
2105 let filter = EntityFilter {
2106 kinds: match kind {
2107 Some(k) => vec![k.to_string()],
2108 None => vec![],
2109 },
2110 tags_any: match domain_tag {
2111 Some(t) if !t.is_empty() => vec![t.to_string()],
2112 _ => vec![],
2113 },
2114 namespaces: ns_strs,
2115 ..Default::default()
2116 };
2117 let page = self
2118 .entities(token)?
2119 .query_entities(
2120 token.namespace().as_str(),
2121 filter,
2122 PageRequest {
2123 offset: offset.into(),
2124 limit,
2125 },
2126 )
2127 .await?;
2128 Ok(page.items)
2129 }
2130
2131 pub async fn count_entities_tagged(
2136 &self,
2137 token: &NamespaceToken,
2138 kind: Option<&str>,
2139 domain_tag: Option<&str>,
2140 ) -> RuntimeResult<u64> {
2141 let ns_strs: Vec<String> = token
2142 .visible_namespaces()
2143 .iter()
2144 .map(|ns| ns.as_str().to_owned())
2145 .collect();
2146 let filter = EntityFilter {
2147 kinds: match kind {
2148 Some(k) => vec![k.to_string()],
2149 None => vec![],
2150 },
2151 tags_any: match domain_tag {
2152 Some(t) if !t.is_empty() => vec![t.to_string()],
2153 _ => vec![],
2154 },
2155 namespaces: ns_strs,
2156 ..Default::default()
2157 };
2158 Ok(self
2159 .entities(token)?
2160 .count_entities(token.namespace().as_str(), filter)
2161 .await?)
2162 }
2163
2164 pub async fn list_events(
2166 &self,
2167 token: &NamespaceToken,
2168 filter: EventFilter,
2169 page: PageRequest,
2170 ) -> RuntimeResult<Page<Event>> {
2171 self.events(token)?
2172 .query_events(filter, page)
2173 .await
2174 .map_err(Into::into)
2175 }
2176
2177 pub(crate) async fn validate_edge_relation_endpoints(
2195 &self,
2196 token: &NamespaceToken,
2197 source_id: Uuid,
2198 target_id: Uuid,
2199 relation: EdgeRelation,
2200 ) -> RuntimeResult<()> {
2201 if source_id == target_id {
2202 return Err(RuntimeError::InvalidInput(
2203 "self-loop edges are not allowed: source_id and target_id must be different".into(),
2204 ));
2205 }
2206 if relation == EdgeRelation::Annotates {
2207 match self.resolve_edge_endpoint(token, source_id).await? {
2211 Some(Resolved::Note(_)) => {}
2212 Some(_) => {
2213 return Err(RuntimeError::InvalidInput(format!(
2214 "annotates source {source_id} must be a note"
2215 )));
2216 }
2217 None => {
2218 if self.get_edge(token, source_id).await?.is_some() {
2220 return Err(RuntimeError::InvalidInput(format!(
2221 "annotates source {source_id} must be a note"
2222 )));
2223 }
2224 return Err(RuntimeError::NotFound(format!(
2225 "link source {source_id} not found"
2226 )));
2227 }
2228 }
2229 if !self.substrate_exists_by_id(token, target_id).await? {
2231 return Err(RuntimeError::NotFound(format!(
2232 "link target {target_id} not found"
2233 )));
2234 }
2235 } else if crate::pack::is_special_relation(relation) {
2236 let rel_name = relation.as_str();
2240 let src = match self.resolve_edge_endpoint(token, source_id).await? {
2241 Some(r) => r,
2242 None => {
2243 if self.get_edge(token, source_id).await?.is_some() {
2244 return Err(RuntimeError::InvalidInput(format!(
2245 "{rel_name} source {source_id} must be a note or entity (got edge)"
2246 )));
2247 }
2248 return Err(RuntimeError::NotFound(format!(
2249 "link source {source_id} not found"
2250 )));
2251 }
2252 };
2253 let tgt = match self.resolve_edge_endpoint(token, target_id).await? {
2254 Some(r) => r,
2255 None => {
2256 if self.get_edge(token, target_id).await?.is_some() {
2257 return Err(RuntimeError::InvalidInput(format!(
2258 "{rel_name} target {target_id} must be a note or entity (got edge)"
2259 )));
2260 }
2261 return Err(RuntimeError::NotFound(format!(
2262 "link target {target_id} not found"
2263 )));
2264 }
2265 };
2266 match (&src, &tgt) {
2267 (Resolved::Entity(src_e), Resolved::Entity(tgt_e)) => {
2268 if !base_entity_rule_allows(&src_e.kind, relation, &tgt_e.kind) {
2269 let legal_relations = accepted_entity_relations_description(
2270 &self.pack_edge_rules(),
2271 &src_e.kind,
2272 src_e.entity_type.as_deref(),
2273 &tgt_e.kind,
2274 tgt_e.entity_type.as_deref(),
2275 );
2276 let rule_hint = match relation {
2277 EdgeRelation::Supports | EdgeRelation::Refutes => {
2278 "requires concept|document|dataset|artifact -> concept \
2279 (or same-substrate note -> note)"
2280 }
2281 _ => "requires same-kind entity endpoints",
2282 };
2283 return Err(RuntimeError::InvalidInput(format!(
2284 "({}) -[{rel_name}]-> ({}) is not in the base endpoint \
2285 allowlist; {rel_name} {rule_hint}; currently legal relations for \
2286 {} -> {} under the loaded endpoint rules: {legal_relations}",
2287 src_e.kind, tgt_e.kind, src_e.kind, tgt_e.kind
2288 )));
2289 }
2290 }
2291 (Resolved::Note(_), Resolved::Note(_)) => {}
2292 (Resolved::Event(_), _) => {
2293 return Err(RuntimeError::InvalidInput(format!(
2294 "{rel_name} does not apply to events; source {source_id} is an event"
2295 )));
2296 }
2297 (_, Resolved::Event(_)) => {
2298 return Err(RuntimeError::InvalidInput(format!(
2299 "{rel_name} does not apply to events; target {target_id} is an event"
2300 )));
2301 }
2302 (Resolved::Entity(_), Resolved::Note(_)) => {
2303 return Err(RuntimeError::InvalidInput(format!(
2304 "{rel_name} endpoints must be the same substrate (note→note or entity→entity); \
2305 got source={source_id} (entity) target={target_id} (note)"
2306 )));
2307 }
2308 (Resolved::Note(_), Resolved::Entity(_)) => {
2309 return Err(RuntimeError::InvalidInput(format!(
2310 "{rel_name} endpoints must be the same substrate (note→note or entity→entity); \
2311 got source={source_id} (note) target={target_id} (entity)"
2312 )));
2313 }
2314 (Resolved::PackRecord { .. }, _) | (_, Resolved::PackRecord { .. }) => {
2315 return Err(RuntimeError::InvalidInput(format!(
2316 "pack-private record is not a valid edge endpoint for {rel_name}"
2317 )));
2318 }
2319 }
2320 } else {
2321 let src_res = self.resolve_edge_endpoint(token, source_id).await?;
2328 let tgt_res = self.resolve_edge_endpoint(token, target_id).await?;
2329 let pack_rules = self.pack_edge_rules();
2330
2331 if pack_rule_allows(&pack_rules, relation, src_res.as_ref(), tgt_res.as_ref()) {
2332 return Ok(());
2333 }
2334
2335 let (src_kind, src_entity_type) = match src_res.as_ref() {
2337 Some(Resolved::Entity(e)) => (e.kind.as_str(), e.entity_type.as_deref()),
2338 Some(_) => {
2339 return Err(RuntimeError::InvalidInput(format!(
2340 "link source {source_id} must be an entity for relation {relation:?} \
2341 (only `annotates` crosses substrates)"
2342 )));
2343 }
2344 None => {
2345 if self.get_edge(token, source_id).await?.is_some() {
2346 return Err(RuntimeError::InvalidInput(format!(
2347 "link source {source_id} must be an entity for relation {relation:?} \
2348 (only `annotates` crosses substrates)"
2349 )));
2350 }
2351 return Err(RuntimeError::NotFound(format!(
2352 "link source {source_id} not found"
2353 )));
2354 }
2355 };
2356 let (tgt_kind, tgt_entity_type) = match tgt_res.as_ref() {
2357 Some(Resolved::Entity(e)) => (e.kind.as_str(), e.entity_type.as_deref()),
2358 Some(_) => {
2359 return Err(RuntimeError::InvalidInput(format!(
2360 "link target {target_id} must be an entity for relation {relation:?} \
2361 (only `annotates` crosses substrates)"
2362 )));
2363 }
2364 None => {
2365 if self.get_edge(token, target_id).await?.is_some() {
2366 return Err(RuntimeError::InvalidInput(format!(
2367 "link target {target_id} must be an entity for relation {relation:?} \
2368 (only `annotates` crosses substrates)"
2369 )));
2370 }
2371 return Err(RuntimeError::NotFound(format!(
2372 "link target {target_id} not found"
2373 )));
2374 }
2375 };
2376 if !base_entity_rule_allows(src_kind, relation, tgt_kind) {
2377 let legal_relations = accepted_entity_relations_description(
2378 &pack_rules,
2379 src_kind,
2380 src_entity_type,
2381 tgt_kind,
2382 tgt_entity_type,
2383 );
2384 return Err(RuntimeError::InvalidInput(format!(
2385 "({src_kind}) -[{}]-> ({tgt_kind}) is not in the base endpoint \
2386 allowlist; use pack EDGE_RULES to extend the allowlist; currently legal \
2387 relations for {src_kind} -> {tgt_kind} under the loaded endpoint rules: \
2388 {legal_relations}",
2389 relation.as_str()
2390 )));
2391 }
2392 }
2393 Ok(())
2394 }
2395
2396 pub async fn validate_link_endpoints(
2401 &self,
2402 token: &NamespaceToken,
2403 source_id: Uuid,
2404 target_id: Uuid,
2405 relation: EdgeRelation,
2406 ) -> RuntimeResult<()> {
2407 self.validate_edge_relation_endpoints(token, source_id, target_id, relation)
2408 .await
2409 }
2410
2411 pub fn validate_link_endpoints_by_resolved(
2422 &self,
2423 source_id: Uuid,
2424 target_id: Uuid,
2425 relation: EdgeRelation,
2426 src: Option<&Resolved>,
2427 tgt: Option<&Resolved>,
2428 ) -> RuntimeResult<()> {
2429 if source_id == target_id {
2430 return Err(RuntimeError::InvalidInput(
2431 "self-loop edges are not allowed: source_id and target_id must be different".into(),
2432 ));
2433 }
2434
2435 if relation == EdgeRelation::Annotates {
2436 match src {
2437 Some(Resolved::Note(_)) => {}
2438 Some(_) => {
2439 return Err(RuntimeError::InvalidInput(format!(
2440 "annotates source {source_id} must be a note"
2441 )));
2442 }
2443 None => {
2444 return Err(RuntimeError::NotFound(format!(
2445 "link source {source_id} not found"
2446 )));
2447 }
2448 }
2449 if tgt.is_none() {
2450 return Err(RuntimeError::NotFound(format!(
2451 "link target {target_id} not found"
2452 )));
2453 }
2454 return Ok(());
2455 }
2456
2457 if crate::pack::is_special_relation(relation) {
2458 let rel_name = relation.as_str();
2459 let src = src.ok_or_else(|| {
2460 RuntimeError::NotFound(format!("link source {source_id} not found"))
2461 })?;
2462 let tgt = tgt.ok_or_else(|| {
2463 RuntimeError::NotFound(format!("link target {target_id} not found"))
2464 })?;
2465 match (src, tgt) {
2466 (Resolved::Entity(src_e), Resolved::Entity(tgt_e)) => {
2467 if !base_entity_rule_allows(&src_e.kind, relation, &tgt_e.kind) {
2468 let legal_relations = accepted_entity_relations_description(
2469 &self.pack_edge_rules(),
2470 &src_e.kind,
2471 src_e.entity_type.as_deref(),
2472 &tgt_e.kind,
2473 tgt_e.entity_type.as_deref(),
2474 );
2475 let rule_hint = match relation {
2476 EdgeRelation::Supports | EdgeRelation::Refutes => {
2477 "requires concept|document|dataset|artifact -> concept \
2478 (or same-substrate note -> note)"
2479 }
2480 _ => "requires same-kind entity endpoints",
2481 };
2482 return Err(RuntimeError::InvalidInput(format!(
2483 "({}) -[{rel_name}]-> ({}) is not in the base endpoint \
2484 allowlist; {rel_name} {rule_hint}; currently legal relations for \
2485 {} -> {} under the loaded endpoint rules: {legal_relations}",
2486 src_e.kind, tgt_e.kind, src_e.kind, tgt_e.kind
2487 )));
2488 }
2489 }
2490 (Resolved::Note(_), Resolved::Note(_)) => {}
2491 (Resolved::Entity(_), Resolved::Note(_)) => {
2492 return Err(RuntimeError::InvalidInput(format!(
2493 "{rel_name} endpoints must be the same substrate \
2494 (note→note or entity→entity); got source={source_id} (entity) \
2495 target={target_id} (note)"
2496 )));
2497 }
2498 (Resolved::Note(_), Resolved::Entity(_)) => {
2499 return Err(RuntimeError::InvalidInput(format!(
2500 "{rel_name} endpoints must be the same substrate \
2501 (note→note or entity→entity); got source={source_id} (note) \
2502 target={target_id} (entity)"
2503 )));
2504 }
2505 (Resolved::PackRecord { .. }, _) | (_, Resolved::PackRecord { .. }) => {
2506 return Err(RuntimeError::InvalidInput(format!(
2507 "pack-private record is not a valid edge endpoint for {rel_name}"
2508 )));
2509 }
2510 _ => {
2511 return Err(RuntimeError::InvalidInput(format!(
2512 "{rel_name} endpoints must be notes or entities (not events)"
2513 )));
2514 }
2515 }
2516 return Ok(());
2517 }
2518
2519 let pack_rules = self.pack_edge_rules();
2522 if pack_rule_allows(&pack_rules, relation, src, tgt) {
2523 return Ok(());
2524 }
2525
2526 let (src_kind, src_entity_type) = match src {
2527 Some(Resolved::Entity(e)) => (e.kind.as_str(), e.entity_type.as_deref()),
2528 Some(_) => {
2529 return Err(RuntimeError::InvalidInput(format!(
2530 "link source {source_id} must be an entity for relation {relation:?} \
2531 (only `annotates` crosses substrates)"
2532 )));
2533 }
2534 None => {
2535 return Err(RuntimeError::NotFound(format!(
2536 "link source {source_id} not found"
2537 )));
2538 }
2539 };
2540 let (tgt_kind, tgt_entity_type) = match tgt {
2541 Some(Resolved::Entity(e)) => (e.kind.as_str(), e.entity_type.as_deref()),
2542 Some(_) => {
2543 return Err(RuntimeError::InvalidInput(format!(
2544 "link target {target_id} must be an entity for relation {relation:?} \
2545 (only `annotates` crosses substrates)"
2546 )));
2547 }
2548 None => {
2549 return Err(RuntimeError::NotFound(format!(
2550 "link target {target_id} not found"
2551 )));
2552 }
2553 };
2554
2555 if !base_entity_rule_allows(src_kind, relation, tgt_kind) {
2556 let legal_relations = accepted_entity_relations_description(
2557 &pack_rules,
2558 src_kind,
2559 src_entity_type,
2560 tgt_kind,
2561 tgt_entity_type,
2562 );
2563 return Err(RuntimeError::InvalidInput(format!(
2564 "({src_kind}) -[{}]-> ({tgt_kind}) is not in the base endpoint \
2565 allowlist; use pack EDGE_RULES to extend the allowlist; currently legal relations \
2566 for {src_kind} -> {tgt_kind} under the loaded endpoint rules: {legal_relations}",
2567 relation.as_str()
2568 )));
2569 }
2570
2571 Ok(())
2572 }
2573
2574 pub fn validate_annotates_endpoint_kinds(
2586 &self,
2587 source_id: Uuid,
2588 target_id: Uuid,
2589 source: Option<EdgeEndpointKind>,
2590 target: Option<EdgeEndpointKind>,
2591 ) -> RuntimeResult<()> {
2592 if source_id == target_id {
2593 return Err(RuntimeError::InvalidInput(
2594 "self-loop edges are not allowed: source_id and target_id must be different".into(),
2595 ));
2596 }
2597 match source {
2598 Some(EdgeEndpointKind::Note) => {}
2599 Some(_) => {
2600 return Err(RuntimeError::InvalidInput(format!(
2601 "annotates source {source_id} must be a note"
2602 )));
2603 }
2604 None => {
2605 return Err(RuntimeError::NotFound(format!(
2606 "link source {source_id} not found"
2607 )));
2608 }
2609 }
2610 if target.is_none() {
2611 return Err(RuntimeError::NotFound(format!(
2612 "link target {target_id} not found"
2613 )));
2614 }
2615 Ok(())
2616 }
2617
2618 pub async fn link(
2638 &self,
2639 token: &NamespaceToken,
2640 source_id: Uuid,
2641 target_id: Uuid,
2642 relation: EdgeRelation,
2643 weight: f64,
2644 metadata: Option<serde_json::Value>,
2645 ) -> RuntimeResult<Edge> {
2646 self.link_observed(
2647 token, source_id, target_id, relation, weight, metadata, false,
2648 )
2649 .await
2650 .map(|result| result.edge)
2651 }
2652
2653 #[allow(clippy::too_many_arguments)]
2658 pub async fn link_observed(
2659 &self,
2660 token: &NamespaceToken,
2661 source_id: Uuid,
2662 target_id: Uuid,
2663 relation: EdgeRelation,
2664 weight: f64,
2665 metadata: Option<serde_json::Value>,
2666 resurrect: bool,
2667 ) -> RuntimeResult<EdgeUpsertResult> {
2668 validate_edge_weight(weight)?;
2669 self.validate_edge_relation_endpoints(token, source_id, target_id, relation)
2670 .await?;
2671 let (source_id, target_id) = canonical_edge_endpoints(relation, source_id, target_id);
2672 let metadata = if relation == EdgeRelation::DependsOn {
2673 match (
2678 self.resolve_edge_endpoint(token, source_id).await?,
2679 self.resolve_edge_endpoint(token, target_id).await?,
2680 ) {
2681 (Some(Resolved::Entity(src_e)), Some(Resolved::Entity(tgt_e))) => {
2682 merge_dependency_kind(&src_e.kind, &tgt_e.kind, metadata)
2683 }
2684 _ => metadata,
2685 }
2686 } else {
2687 metadata
2688 };
2689 validate_edge_metadata(relation, metadata.as_ref())?;
2690 let now = chrono::Utc::now();
2691 let ns = token.namespace().as_str();
2692 let edge = Edge {
2693 id: LinkId::from(Uuid::new_v4()),
2694 namespace: ns.to_string(),
2695 source_id,
2696 target_id,
2697 relation,
2698 weight,
2699 created_at: now,
2700 updated_at: now,
2701 deleted_at: None,
2702 metadata,
2703 target_backend: None,
2704 };
2705 let result = match self
2715 .graph(token)?
2716 .upsert_edge_guarded_observed(EdgeUpsertRequest { edge, resurrect })
2717 .await?
2718 {
2719 GuardedEdgeUpsertOutcome::Written(result) => result,
2720 GuardedEdgeUpsertOutcome::Refused(EdgeUpsertRefusal::MissingEndpoints(missing)) => {
2721 return Err(RuntimeError::GuardedWriteFailed(GuardedWriteFailure {
2722 entry_index: None,
2723 missing_source: missing.source.then_some(source_id),
2724 missing_target: missing.target.then_some(target_id),
2725 }));
2726 }
2727 GuardedEdgeUpsertOutcome::Refused(EdgeUpsertRefusal::ResurrectionRequired { edge }) => {
2728 return Err(RuntimeError::InvalidInput(format!(
2729 "edge {} is soft-deleted; pass resurrect=true to link explicitly",
2730 edge.id
2731 )))
2732 }
2733 };
2734 self.append_link_mutation_event(token, &result).await?;
2735 Ok(result)
2736 }
2737
2738 #[allow(clippy::too_many_arguments)]
2745 pub async fn link_with_target_backend(
2746 &self,
2747 token: &NamespaceToken,
2748 source_id: Uuid,
2749 target_id: Uuid,
2750 relation: EdgeRelation,
2751 weight: f64,
2752 metadata: Option<serde_json::Value>,
2753 target_backend: Option<String>,
2754 ) -> RuntimeResult<Edge> {
2755 self.link_with_target_backend_observed(
2756 token,
2757 source_id,
2758 target_id,
2759 relation,
2760 weight,
2761 metadata,
2762 target_backend,
2763 false,
2764 )
2765 .await
2766 .map(|result| result.edge)
2767 }
2768
2769 #[allow(clippy::too_many_arguments)]
2773 pub async fn link_with_target_backend_observed(
2774 &self,
2775 token: &NamespaceToken,
2776 source_id: Uuid,
2777 target_id: Uuid,
2778 relation: EdgeRelation,
2779 weight: f64,
2780 metadata: Option<serde_json::Value>,
2781 target_backend: Option<String>,
2782 resurrect: bool,
2783 ) -> RuntimeResult<EdgeUpsertResult> {
2784 validate_edge_weight(weight)?;
2785 let (source_id, target_id) = canonical_edge_endpoints(relation, source_id, target_id);
2786 validate_edge_metadata(relation, metadata.as_ref())?;
2787 let now = chrono::Utc::now();
2788 let ns = token.namespace().as_str();
2789 let edge = Edge {
2790 id: LinkId::from(Uuid::new_v4()),
2791 namespace: ns.to_string(),
2792 source_id,
2793 target_id,
2794 relation,
2795 weight,
2796 created_at: now,
2797 updated_at: now,
2798 deleted_at: None,
2799 metadata,
2800 target_backend,
2801 };
2802 let result = self
2803 .graph(token)?
2804 .upsert_edge_observed(EdgeUpsertRequest { edge, resurrect })
2805 .await
2806 .map_err(|error| {
2807 if matches!(error, khive_storage::StorageError::Conflict { .. }) {
2808 RuntimeError::InvalidInput(format!(
2809 "edge natural key is soft-deleted; pass resurrect=true to link explicitly: {error}"
2810 ))
2811 } else {
2812 error.into()
2813 }
2814 })?;
2815 self.append_link_mutation_event(token, &result).await?;
2816 Ok(result)
2817 }
2818
2819 async fn append_link_mutation_event(
2820 &self,
2821 token: &NamespaceToken,
2822 result: &EdgeUpsertResult,
2823 ) -> RuntimeResult<()> {
2824 let kind = match result.disposition {
2825 EdgeUpsertDisposition::Created => EventKind::LinkCreated,
2826 EdgeUpsertDisposition::Updated | EdgeUpsertDisposition::Resurrected => {
2827 EventKind::EdgeUpdated
2828 }
2829 };
2830 let edge_id = Uuid::from(result.edge.id);
2831 let actor = format!("{}:{}", token.actor().kind, token.actor().id);
2832 let event = khive_storage::event::Event::new(
2833 result.edge.namespace.clone(),
2834 "link",
2835 kind,
2836 SubstrateKind::Entity,
2837 actor,
2838 )
2839 .with_target(edge_id)
2840 .with_payload(serde_json::json!({
2841 "id": edge_id,
2842 "namespace": result.edge.namespace,
2843 "mutation": result.disposition.name(),
2844 "source_id": result.edge.source_id,
2845 "target_id": result.edge.target_id,
2846 "relation": result.edge.relation,
2847 "weight": result.edge.weight,
2848 "metadata": result.edge.metadata,
2849 "previous": result.previous,
2850 }));
2851 self.events(token)?
2852 .append_event(event)
2853 .await
2854 .map_err(|error| {
2855 RuntimeError::Internal(format!("link: lifecycle event write failed: {error}"))
2856 })
2857 }
2858
2859 pub(crate) async fn substrate_exists_in_ns(
2866 &self,
2867 token: &NamespaceToken,
2868 id: Uuid,
2869 ) -> RuntimeResult<bool> {
2870 if self.resolve(token, id).await?.is_some() {
2871 return Ok(true);
2872 }
2873 match self.get_edge_visible(token, id).await {
2874 Ok(Some(_)) => Ok(true),
2875 Ok(None) | Err(RuntimeError::NotFound(_)) => Ok(false),
2876 Err(err) => Err(err),
2877 }
2878 }
2879
2880 pub(crate) async fn substrate_exists_by_id(
2887 &self,
2888 token: &NamespaceToken,
2889 id: Uuid,
2890 ) -> RuntimeResult<bool> {
2891 if self.resolve_edge_endpoint(token, id).await?.is_some() {
2892 return Ok(true);
2893 }
2894 match self.get_edge(token, id).await {
2895 Ok(Some(_)) => Ok(true),
2896 Ok(None) | Err(RuntimeError::NotFound(_)) => Ok(false),
2897 Err(err) => Err(err),
2898 }
2899 }
2900
2901 pub async fn latest_annotating_note(
2906 &self,
2907 token: &NamespaceToken,
2908 node_id: Uuid,
2909 kind: &str,
2910 tag: &str,
2911 ) -> RuntimeResult<Option<Uuid>> {
2912 if !self.substrate_exists_in_ns(token, node_id).await? {
2913 return Ok(None);
2914 }
2915 let mut latest: Option<(Uuid, i64)> = None;
2916 for namespace in token.visible_namespaces() {
2917 let scoped = NamespaceToken::for_namespace(namespace.clone());
2918 if let Some(candidate) = self
2919 .graph(&scoped)?
2920 .latest_annotating_note(node_id, kind, tag)
2921 .await?
2922 {
2923 if latest.is_none_or(|(id, created_at)| {
2924 candidate.1 > created_at || (candidate.1 == created_at && candidate.0 < id)
2925 }) {
2926 latest = Some(candidate);
2927 }
2928 }
2929 }
2930 Ok(latest.map(|(id, _)| id))
2931 }
2932
2933 pub async fn neighbors(
2942 &self,
2943 token: &NamespaceToken,
2944 node_id: Uuid,
2945 direction: Direction,
2946 limit: Option<u32>,
2947 relations: Option<Vec<EdgeRelation>>,
2948 ) -> RuntimeResult<Vec<NeighborHit>> {
2949 self.neighbors_with_query(
2950 token,
2951 node_id,
2952 NeighborQuery {
2953 direction,
2954 relations,
2955 limit,
2956 min_weight: None,
2957 },
2958 )
2959 .await
2960 }
2961
2962 pub async fn neighbors_with_query(
2972 &self,
2973 token: &NamespaceToken,
2974 node_id: Uuid,
2975 query: NeighborQuery,
2976 ) -> RuntimeResult<Vec<NeighborHit>> {
2977 self.neighbors_with_query_page(token, node_id, query, None, None, true)
2978 .await
2979 }
2980
2981 pub async fn neighbors_with_query_page(
2985 &self,
2986 token: &NamespaceToken,
2987 node_id: Uuid,
2988 mut query: NeighborQuery,
2989 after: Option<NeighborCursor>,
2990 neighbor_kinds: Option<Vec<String>>,
2991 enrich: bool,
2992 ) -> RuntimeResult<Vec<NeighborHit>> {
2993 if !self.substrate_exists_by_id(token, node_id).await? {
2996 return Err(RuntimeError::NotFound(format!(
2997 "neighbor anchor {node_id} not found"
2998 )));
2999 }
3000
3001 query.direction =
3002 normalize_symmetric_direction(query.direction, query.relations.as_deref());
3003 let mut hits = Vec::new();
3004 for ns in token.visible_namespaces() {
3005 let temp = NamespaceToken::for_namespace(ns.clone());
3006 let mut ns_hits = self
3007 .graph(&temp)?
3008 .neighbors_page(node_id, query.clone(), after, neighbor_kinds.clone())
3009 .await?;
3010 hits.append(&mut ns_hits);
3011 }
3012 hits.sort_by_key(|h| (h.node_id, h.edge_id));
3013 hits.dedup_by_key(|h| (h.node_id, h.edge_id));
3014 if enrich {
3015 self.enrich_neighbor_hits(token, &mut hits).await;
3016 }
3017 let candidate_ids: Vec<Uuid> = hits.iter().map(|h| h.node_id).collect();
3019 let deleted = self.deleted_entity_ids(candidate_ids).await?;
3020 if !deleted.is_empty() {
3021 hits.retain(|h| !deleted.contains(&h.node_id));
3022 }
3023 hits.sort_by(|a, b| {
3030 b.weight
3031 .partial_cmp(&a.weight)
3032 .unwrap_or(std::cmp::Ordering::Equal)
3033 .then(a.node_id.cmp(&b.node_id))
3034 .then(a.edge_id.cmp(&b.edge_id))
3035 });
3036 Ok(hits)
3037 }
3038
3039 pub async fn annotation_neighbors_by_target_id(
3047 &self,
3048 target_id: Uuid,
3049 ) -> RuntimeResult<Vec<NeighborHit>> {
3050 let mut reader = self.sql().reader().await?;
3051 let rows = reader
3052 .query_all(SqlStatement {
3053 sql: "SELECT source_id, id, weight FROM graph_edges \
3054 WHERE target_id = ?1 AND relation = ?2 AND deleted_at IS NULL \
3055 ORDER BY weight DESC, source_id ASC"
3056 .to_string(),
3057 params: vec![
3058 SqlValue::Text(target_id.to_string()),
3059 SqlValue::Text(EdgeRelation::Annotates.to_string()),
3060 ],
3061 label: Some("annotations.by_target_id_unfiltered".into()),
3062 })
3063 .await?;
3064
3065 rows.into_iter()
3066 .map(|row| {
3067 let parse_uuid = |name: &str| match row.get(name) {
3068 Some(SqlValue::Text(value)) => Uuid::from_str(value).map_err(|error| {
3069 RuntimeError::Internal(format!("graph_edges.{name} is not a UUID: {error}"))
3070 }),
3071 Some(value) => Err(RuntimeError::Internal(format!(
3072 "graph_edges.{name} has unexpected SQL value {value:?}"
3073 ))),
3074 None => Err(RuntimeError::Internal(format!(
3075 "graph_edges row missing {name}"
3076 ))),
3077 };
3078 let weight = match row.get("weight") {
3079 Some(SqlValue::Float(value)) => Ok(*value),
3080 Some(value) => Err(RuntimeError::Internal(format!(
3081 "graph_edges.weight has unexpected SQL value {value:?}"
3082 ))),
3083 None => Err(RuntimeError::Internal(
3084 "graph_edges row missing weight".into(),
3085 )),
3086 }?;
3087
3088 Ok(NeighborHit {
3089 node_id: parse_uuid("source_id")?,
3090 edge_id: parse_uuid("id")?,
3091 relation: EdgeRelation::Annotates,
3092 weight,
3093 name: None,
3094 kind: None,
3095 entity_type: None,
3096 })
3097 })
3098 .collect()
3099 }
3100
3101 pub async fn neighbors_with_query_directed(
3110 &self,
3111 token: &NamespaceToken,
3112 node_id: Uuid,
3113 query: NeighborQuery,
3114 ) -> RuntimeResult<Vec<(NeighborHit, Direction)>> {
3115 if !self.substrate_exists_by_id(token, node_id).await? {
3116 return Err(RuntimeError::NotFound(format!(
3117 "neighbor anchor {node_id} not found"
3118 )));
3119 }
3120
3121 let mut hits: Vec<DirectedNeighborHit> = Vec::new();
3122 for ns in token.visible_namespaces() {
3123 let temp = NamespaceToken::for_namespace(ns.clone());
3124 let mut ns_hits = self
3125 .graph(&temp)?
3126 .neighbors_both_directions(node_id, query.clone())
3127 .await?;
3128 hits.append(&mut ns_hits);
3129 }
3130 hits.sort_by_key(|h| {
3134 (
3135 h.hit.node_id,
3136 h.hit.edge_id,
3137 direction_sort_rank(&h.direction),
3138 )
3139 });
3140 hits.dedup_by_key(|h| {
3141 (
3142 h.hit.node_id,
3143 h.hit.edge_id,
3144 direction_sort_rank(&h.direction),
3145 )
3146 });
3147
3148 let mut plain_hits: Vec<NeighborHit> = hits.iter().map(|h| h.hit.clone()).collect();
3149 self.enrich_neighbor_hits(token, &mut plain_hits).await;
3150 for (dh, enriched) in hits.iter_mut().zip(plain_hits) {
3151 dh.hit = enriched;
3152 }
3153
3154 let candidate_ids: Vec<Uuid> = hits.iter().map(|h| h.hit.node_id).collect();
3156 let deleted = self.deleted_entity_ids(candidate_ids).await?;
3157 if !deleted.is_empty() {
3158 hits.retain(|h| !deleted.contains(&h.hit.node_id));
3159 }
3160 hits.sort_by(|a, b| {
3164 b.hit
3165 .weight
3166 .partial_cmp(&a.hit.weight)
3167 .unwrap_or(std::cmp::Ordering::Equal)
3168 .then(a.hit.node_id.cmp(&b.hit.node_id))
3169 .then(a.hit.edge_id.cmp(&b.hit.edge_id))
3170 });
3171 Ok(hits.into_iter().map(|h| (h.hit, h.direction)).collect())
3172 }
3173
3174 pub async fn traverse(
3180 &self,
3181 token: &NamespaceToken,
3182 request: TraversalRequest,
3183 ) -> RuntimeResult<Vec<GraphPath>> {
3184 let mut request = request;
3185 request.validate().map_err(RuntimeError::InvalidInput)?;
3186 let mut roots = Vec::with_capacity(request.roots.len());
3187 let mut seen_roots = std::collections::HashSet::with_capacity(request.roots.len());
3188 for root in request.roots.drain(..) {
3189 if seen_roots.insert(root) {
3190 if !self.substrate_exists_by_id(token, root).await? {
3191 return Err(RuntimeError::NotFound(format!(
3192 "traverse root {root} not found"
3193 )));
3194 }
3195 roots.push(root);
3196 }
3197 }
3198 request.roots = roots;
3199 if request.roots.is_empty() {
3200 return Ok(Vec::new());
3201 }
3202
3203 let mut paths = Vec::new();
3204 for ns in token.visible_namespaces() {
3205 let temp = NamespaceToken::for_namespace(ns.clone());
3206 let mut ns_paths = self.graph(&temp)?.traverse(request.clone()).await?;
3207 paths.append(&mut ns_paths);
3208 }
3209 let mut paths =
3213 merge_traversal_paths_by_root(paths, Some(request.options.effective_limit()));
3214 self.enrich_path_nodes(token, &mut paths, request.include_properties)
3215 .await;
3216 let all_node_ids: Vec<Uuid> = paths
3218 .iter()
3219 .flat_map(|p| p.nodes.iter().map(|n| n.node_id))
3220 .collect();
3221 let deleted = self.deleted_entity_ids(all_node_ids).await?;
3222 if !deleted.is_empty() {
3223 for path in paths.iter_mut() {
3224 path.nodes.retain(|n| !deleted.contains(&n.node_id));
3225 recompute_total_weight(path);
3226 }
3227 paths.retain(|p| !p.nodes.is_empty());
3228 }
3229 Ok(paths)
3230 }
3231
3232 async fn deleted_entity_ids(
3252 &self,
3253 ids: Vec<Uuid>,
3254 ) -> RuntimeResult<std::collections::HashSet<Uuid>> {
3255 if ids.is_empty() {
3256 return Ok(std::collections::HashSet::new());
3257 }
3258 let id_strs: Vec<String> = ids.iter().map(|u| u.to_string()).collect();
3259 let n = id_strs.len();
3260 let entities_placeholders = (0..n)
3267 .map(|i| format!("?{}", i + 1))
3268 .collect::<Vec<_>>()
3269 .join(",");
3270 let notes_placeholders = (0..n)
3271 .map(|i| format!("?{}", n + i + 1))
3272 .collect::<Vec<_>>()
3273 .join(",");
3274 let sql_str = format!(
3275 "SELECT id FROM entities WHERE id IN ({entities_placeholders}) AND deleted_at IS NOT NULL \
3276 UNION \
3277 SELECT id FROM notes WHERE id IN ({notes_placeholders}) AND deleted_at IS NOT NULL"
3278 );
3279 let params: Vec<SqlValue> = id_strs
3281 .iter()
3282 .chain(id_strs.iter())
3283 .cloned()
3284 .map(SqlValue::Text)
3285 .collect();
3286 let stmt = SqlStatement {
3287 sql: sql_str,
3288 params,
3289 label: Some("deleted_entity_ids".into()),
3290 };
3291 let mut out = std::collections::HashSet::new();
3292 let sql = self.sql();
3293 let mut reader = sql.reader().await?;
3294 let rows = reader.query_all(stmt).await?;
3295 for row in rows {
3296 if let Some(col) = row.columns.first() {
3297 if let SqlValue::Text(s) = &col.value {
3298 if let Ok(u) = s.parse::<Uuid>() {
3299 out.insert(u);
3300 }
3301 }
3302 }
3303 }
3304 Ok(out)
3305 }
3306
3307 async fn enrich_neighbor_hits(&self, token: &NamespaceToken, hits: &mut [NeighborHit]) {
3316 if hits.is_empty() {
3317 return;
3318 }
3319
3320 let unique_ids: Vec<Uuid> = {
3322 let mut seen = std::collections::HashSet::new();
3323 hits.iter()
3324 .filter_map(|h| {
3325 if seen.insert(h.node_id) {
3326 Some(h.node_id)
3327 } else {
3328 None
3329 }
3330 })
3331 .collect()
3332 };
3333
3334 let entity_map: HashMap<Uuid, Entity> = self
3335 .get_entities_by_ids_visible(token, &unique_ids)
3336 .await
3337 .unwrap_or_default()
3338 .into_iter()
3339 .map(|e| (e.id, e))
3340 .collect();
3341
3342 let residual_ids: Vec<Uuid> = unique_ids
3344 .iter()
3345 .filter(|id| !entity_map.contains_key(id))
3346 .copied()
3347 .collect();
3348
3349 let note_map: HashMap<Uuid, Note> = if !residual_ids.is_empty() {
3350 if let Ok(store) = self.notes(token) {
3351 store
3352 .get_notes_batch(&residual_ids)
3353 .await
3354 .unwrap_or_default()
3355 .into_iter()
3356 .map(|n| (n.id, n))
3357 .collect()
3358 } else {
3359 HashMap::new()
3360 }
3361 } else {
3362 HashMap::new()
3363 };
3364
3365 for hit in hits.iter_mut() {
3366 if let Some(entity) = entity_map.get(&hit.node_id) {
3367 hit.name = Some(entity.name.clone());
3368 hit.kind = Some(entity.kind.clone());
3369 hit.entity_type = entity.entity_type.clone();
3370 } else if let Some(note) = note_map.get(&hit.node_id) {
3371 hit.name = Some(note_graph_name(note));
3372 hit.kind = Some(note.kind.clone());
3373 }
3374 }
3375 }
3376
3377 async fn enrich_path_nodes(
3388 &self,
3389 token: &NamespaceToken,
3390 paths: &mut [GraphPath],
3391 include_properties: bool,
3392 ) {
3393 if paths.is_empty() {
3394 return;
3395 }
3396
3397 let unique_ids: Vec<Uuid> = {
3399 let mut seen = std::collections::HashSet::new();
3400 paths
3401 .iter()
3402 .flat_map(|p| p.nodes.iter())
3403 .filter_map(|n| {
3404 if seen.insert(n.node_id) {
3405 Some(n.node_id)
3406 } else {
3407 None
3408 }
3409 })
3410 .collect()
3411 };
3412
3413 let entity_map: HashMap<Uuid, Entity> = self
3414 .get_entities_by_ids_visible(token, &unique_ids)
3415 .await
3416 .unwrap_or_default()
3417 .into_iter()
3418 .map(|e| (e.id, e))
3419 .collect();
3420
3421 let residual_ids: Vec<Uuid> = unique_ids
3422 .iter()
3423 .filter(|id| !entity_map.contains_key(id))
3424 .copied()
3425 .collect();
3426
3427 let note_map: HashMap<Uuid, Note> = if !residual_ids.is_empty() {
3428 if let Ok(store) = self.notes(token) {
3429 store
3430 .get_notes_batch(&residual_ids)
3431 .await
3432 .unwrap_or_default()
3433 .into_iter()
3434 .map(|n| (n.id, n))
3435 .collect()
3436 } else {
3437 HashMap::new()
3438 }
3439 } else {
3440 HashMap::new()
3441 };
3442
3443 for path in paths.iter_mut() {
3444 for node in path.nodes.iter_mut() {
3445 if let Some(entity) = entity_map.get(&node.node_id) {
3446 node.name = Some(entity.name.clone());
3447 node.kind = Some(entity.kind.clone());
3448 if include_properties {
3449 node.properties = entity.properties.clone();
3450 }
3451 } else if let Some(note) = note_map.get(&node.node_id) {
3452 node.name = Some(note_graph_name(note));
3453 node.kind = Some(note.kind.clone());
3454 }
3455 }
3456 }
3457 }
3458
3459 #[allow(clippy::too_many_arguments)]
3473 pub async fn create_note(
3474 &self,
3475 token: &NamespaceToken,
3476 kind: &str,
3477 name: Option<&str>,
3478 content: &str,
3479 salience: Option<f64>,
3480 properties: Option<serde_json::Value>,
3481 annotates: Vec<Uuid>,
3482 ) -> RuntimeResult<Note> {
3483 Ok(self
3484 .create_note_inner(
3485 token, kind, name, content, None, salience, None, properties, annotates, None,
3486 )
3487 .await?
3488 .0)
3489 }
3490
3491 #[allow(clippy::too_many_arguments)]
3502 pub async fn create_note_with_embedding_content(
3503 &self,
3504 token: &NamespaceToken,
3505 kind: &str,
3506 name: Option<&str>,
3507 content: &str,
3508 embedding_content: Option<&str>,
3509 salience: Option<f64>,
3510 properties: Option<serde_json::Value>,
3511 annotates: Vec<Uuid>,
3512 ) -> RuntimeResult<Note> {
3513 Ok(self
3514 .create_note_inner(
3515 token,
3516 kind,
3517 name,
3518 content,
3519 embedding_content,
3520 salience,
3521 None,
3522 properties,
3523 annotates,
3524 None,
3525 )
3526 .await?
3527 .0)
3528 }
3529
3530 #[allow(clippy::too_many_arguments)]
3531 pub async fn create_note_with_embedding_content_and_report(
3532 &self,
3533 token: &NamespaceToken,
3534 kind: &str,
3535 name: Option<&str>,
3536 content: &str,
3537 embedding_content: Option<&str>,
3538 salience: Option<f64>,
3539 properties: Option<serde_json::Value>,
3540 annotates: Vec<Uuid>,
3541 ) -> RuntimeResult<(Note, crate::retrieval::EmbeddingTruncationReport)> {
3542 self.create_note_inner(
3543 token,
3544 kind,
3545 name,
3546 content,
3547 embedding_content,
3548 salience,
3549 None,
3550 properties,
3551 annotates,
3552 None,
3553 )
3554 .await
3555 }
3556
3557 #[allow(clippy::too_many_arguments)]
3561 pub async fn create_note_with_decay(
3562 &self,
3563 token: &NamespaceToken,
3564 kind: &str,
3565 name: Option<&str>,
3566 content: &str,
3567 salience: Option<f64>,
3568 decay_factor: f64,
3569 properties: Option<serde_json::Value>,
3570 annotates: Vec<Uuid>,
3571 ) -> RuntimeResult<Note> {
3572 self.create_note_with_decay_for_embedding_model(
3573 token,
3574 kind,
3575 name,
3576 content,
3577 salience,
3578 decay_factor,
3579 properties,
3580 annotates,
3581 None,
3582 )
3583 .await
3584 }
3585
3586 #[allow(clippy::too_many_arguments)]
3591 pub async fn create_note_with_decay_for_embedding_model(
3592 &self,
3593 token: &NamespaceToken,
3594 kind: &str,
3595 name: Option<&str>,
3596 content: &str,
3597 salience: Option<f64>,
3598 decay_factor: f64,
3599 properties: Option<serde_json::Value>,
3600 annotates: Vec<Uuid>,
3601 embedding_model: Option<&str>,
3602 ) -> RuntimeResult<Note> {
3603 Ok(self
3604 .create_note_inner(
3605 token,
3606 kind,
3607 name,
3608 content,
3609 None,
3610 salience,
3611 Some(decay_factor),
3612 properties,
3613 annotates,
3614 embedding_model,
3615 )
3616 .await?
3617 .0)
3618 }
3619
3620 pub async fn try_create_note(
3641 &self,
3642 token: &NamespaceToken,
3643 kind: &str,
3644 name: Option<&str>,
3645 content: &str,
3646 properties: Option<serde_json::Value>,
3647 ) -> RuntimeResult<Option<Note>> {
3648 self.try_create_note_impl(token, kind, name, content, properties, false)
3649 .await
3650 }
3651
3652 pub async fn try_create_note_as_trusted_ingest(
3668 &self,
3669 _capability: &crate::pack::ChannelIngestCapability,
3670 token: &NamespaceToken,
3671 kind: &str,
3672 name: Option<&str>,
3673 content: &str,
3674 properties: Option<serde_json::Value>,
3675 ) -> RuntimeResult<Option<Note>> {
3676 self.try_create_note_impl(token, kind, name, content, properties, true)
3677 .await
3678 }
3679
3680 #[allow(clippy::too_many_arguments)]
3681 async fn try_create_note_impl(
3682 &self,
3683 token: &NamespaceToken,
3684 kind: &str,
3685 name: Option<&str>,
3686 content: &str,
3687 properties: Option<serde_json::Value>,
3688 allow_transport_owned_message_properties: bool,
3689 ) -> RuntimeResult<Option<Note>> {
3690 self.validate_note_kind(kind)?;
3691 crate::secret_gate::reject_reserved_secret_gate_property(properties.as_ref())?;
3692 crate::secret_gate::check_at(content, "note", "content")?;
3693 if let Some(n) = name {
3694 crate::secret_gate::check_at(n, "note", "name")?;
3695 }
3696 if let Some(ref p) = properties {
3697 crate::secret_gate::check_json_at(p, "note", "properties")?;
3698 }
3699 if !allow_transport_owned_message_properties && kind == "message" {
3700 if let Some(key) = properties
3701 .as_ref()
3702 .and_then(serde_json::Value::as_object)
3703 .and_then(transport_owned_message_property_named_in)
3704 {
3705 return Err(RuntimeError::InvalidInput(format!(
3706 "`{key}` is transport-owned on a `message` note and cannot be supplied \
3707 through `try_create_note`; only the trusted channel-ingest path may \
3708 establish quarantine disposition and channel provenance"
3709 )));
3710 }
3711 }
3712
3713 let ns = token.namespace().as_str();
3714 let mut note = Note::new(ns, kind, content);
3715 if let Some(n) = name {
3716 note = note.with_name(n);
3717 }
3718 if let Some(p) = properties {
3719 note = note.with_properties(p);
3720 }
3721
3722 let inserted = self.raw_notes(token)?.try_insert_note(note.clone()).await?;
3729 if !inserted {
3730 return Ok(None);
3731 }
3732
3733 if let Ok(fts) = self.text_for_notes(token) {
3735 if let Err(e) = fts.upsert_document(note_fts_document(¬e)).await {
3736 tracing::warn!(
3737 note_id = %note.id,
3738 error = %e,
3739 "try_create_note: FTS indexing failed (non-fatal)"
3740 );
3741 }
3742 }
3743
3744 let embed_model_names = self.registered_embedding_model_names();
3746 for model_name in &embed_model_names {
3747 match self
3748 .embed_document_with_model_outcome_for_token(
3749 token,
3750 model_name,
3751 note_embedding_text_ref(¬e),
3752 )
3753 .await
3754 {
3755 Ok(outcome) => {
3756 if outcome.truncated {
3757 tracing::warn!(
3758 note_id = %note.id,
3759 model = %outcome.model_name,
3760 source_bytes = outcome.source_bytes,
3761 embedded_bytes = outcome.embedded_bytes,
3762 "try_create_note: embedding input truncated; full content stored unchanged"
3763 );
3764 }
3765 if let Ok(vs) = self.vectors_for_model(token, model_name) {
3766 if let Err(e) = vs
3767 .insert(
3768 note.id,
3769 SubstrateKind::Note,
3770 ns,
3771 "note.content",
3772 vec![outcome.vector],
3773 )
3774 .await
3775 {
3776 tracing::warn!(
3777 note_id = %note.id,
3778 model = %model_name,
3779 error = %e,
3780 "try_create_note: vector insert failed (non-fatal)"
3781 );
3782 }
3783 }
3784 }
3785 Err(e) => {
3786 tracing::warn!(
3787 note_id = %note.id,
3788 model = %model_name,
3789 error = %e,
3790 "try_create_note: embedding failed (non-fatal)"
3791 );
3792 }
3793 }
3794 }
3795
3796 Ok(Some(note))
3797 }
3798
3799 #[allow(clippy::too_many_arguments)]
3803 async fn create_note_inner(
3804 &self,
3805 token: &NamespaceToken,
3806 kind: &str,
3807 name: Option<&str>,
3808 content: &str,
3809 embedding_content: Option<&str>,
3810 salience: Option<f64>,
3811 decay_factor: Option<f64>,
3812 properties: Option<serde_json::Value>,
3813 annotates: Vec<Uuid>,
3814 embedding_model: Option<&str>,
3815 ) -> RuntimeResult<(Note, crate::retrieval::EmbeddingTruncationReport)> {
3816 self.validate_note_kind(kind)?;
3817 let properties = self.derive_note_write_properties(kind, token, properties)?;
3823 crate::secret_gate::reject_reserved_secret_gate_property(properties.as_ref())?;
3824 crate::secret_gate::check_at(content, "note", "content")?;
3826 if let Some(n) = name {
3827 crate::secret_gate::check_at(n, "note", "name")?;
3828 }
3829 if let Some(ref p) = properties {
3830 crate::secret_gate::check_json_at(p, "note", "properties")?;
3831 }
3832 if let Some(ec) = embedding_content {
3838 if ec.is_empty() {
3839 return Err(RuntimeError::InvalidInput(
3840 "embedding_content must not be empty".into(),
3841 ));
3842 }
3843 if ec.len() >= content.len() || !content.starts_with(ec) {
3844 return Err(RuntimeError::InvalidInput(
3845 "embedding_content must be a proper prefix of content".into(),
3846 ));
3847 }
3848 crate::secret_gate::check_at(ec, "note", "embedding_content")?;
3849 }
3850 let ns = token.namespace().as_str();
3851
3852 for &target_id in &annotates {
3855 if !self.substrate_exists_by_id(token, target_id).await? {
3856 return Err(RuntimeError::NotFound(format!(
3857 "create_note annotates target {target_id} not found"
3858 )));
3859 }
3860 }
3861
3862 if let Some(s) = salience {
3865 if !s.is_finite() || !(0.0..=1.0).contains(&s) {
3866 return Err(RuntimeError::InvalidInput(format!(
3867 "salience must be a finite value in [0.0, 1.0]; got {s}"
3868 )));
3869 }
3870 }
3871 if let Some(d) = decay_factor {
3872 if !d.is_finite() || d < 0.0 {
3873 return Err(RuntimeError::InvalidInput(format!(
3874 "decay_factor must be a finite value >= 0.0; got {d}"
3875 )));
3876 }
3877 }
3878
3879 if let Some(model_name) = embedding_model {
3883 self.resolve_embedding_model(Some(model_name))?;
3884 }
3885
3886 let mut note = Note::new(ns, kind, content);
3887 if let Some(s) = salience {
3888 note = note.with_salience(s);
3889 }
3890 if let Some(df) = decay_factor {
3891 note = note.with_decay(df);
3892 }
3893 if let Some(n) = name {
3894 note = note.with_name(n);
3895 }
3896 if let Some(p) = properties {
3897 note = note.with_properties(p);
3898 }
3899 self.notes(token)?.upsert_note(note.clone()).await?;
3900
3901 let embed_model_names: Vec<String> = if let Some(m) = embedding_model {
3907 vec![m.to_string()]
3908 } else {
3909 let names = self.registered_embedding_model_names();
3915 if names.is_empty() {
3916 vec![]
3918 } else {
3919 names
3920 }
3921 };
3922
3923 {
3925 #[cfg(any(test, feature = "fault-injection"))]
3931 let fts_inject = consume_fault(&FTS_FAIL_NS, ns);
3932 #[cfg(not(any(test, feature = "fault-injection")))]
3933 let fts_inject = false;
3934 let fts_result: RuntimeResult<()> = if fts_inject {
3935 Err(RuntimeError::Internal("injected FTS failure".to_string()))
3936 } else {
3937 let statements =
3938 khive_db::stores::text::delete_document_statements("fts_notes", ns, note.id)
3939 .into_iter()
3940 .chain(khive_db::stores::text::insert_document_statements(
3941 "fts_notes",
3942 ¬e_fts_document(¬e),
3943 ))
3944 .collect();
3945 self.apply_note_index_revision(¬e, statements)
3946 .await
3947 .map(|_| ())
3948 };
3949
3950 if let Err(e) = fts_result {
3951 self.compensate_note_creation(¬e).await;
3952 return Err(e);
3953 }
3954 }
3955
3956 let canonical_embed_text = note_embedding_text_ref(¬e);
3966 let embed_text = embedding_content.unwrap_or(canonical_embed_text);
3967
3968 let mut embedding_report = crate::retrieval::EmbeddingTruncationReport::default();
3969 if embed_model_names.len() == 1 {
3970 let model_name = &embed_model_names[0];
3972 let vec_result = self
3973 .embed_document_with_model_outcome_for_token(token, model_name, embed_text)
3974 .await;
3975
3976 #[cfg(any(test, feature = "fault-injection"))]
3986 let vec_inject = {
3987 let ns_inject = consume_fault(&VECTOR_FAIL_NS, ns);
3988 let count_inject = VECTOR_FAIL_AFTER.with(|cell| match cell.get() {
3989 Some(0) => {
3990 cell.set(None);
3991 true
3992 }
3993 Some(n) => {
3994 cell.set(Some(n - 1));
3995 false
3996 }
3997 None => false,
3998 });
3999 ns_inject || count_inject
4000 };
4001 #[cfg(not(any(test, feature = "fault-injection")))]
4002 let vec_inject = false;
4003 let vec_result: RuntimeResult<crate::retrieval::DocumentEmbeddingOutcome> =
4004 if vec_inject {
4005 Err(RuntimeError::Internal(
4006 "injected vector failure".to_string(),
4007 ))
4008 } else {
4009 vec_result
4010 };
4011
4012 let single_model_result: RuntimeResult<()> = match vec_result {
4013 Ok(outcome) => {
4014 embedding_report.observe(&outcome);
4015 self.publish_note_vector_revision(token, ¬e, model_name, &outcome.vector)
4016 .await
4017 .map(|_| ())
4018 }
4019 Err(e) => Err(e),
4020 };
4021 if let Err(e) = single_model_result {
4022 self.compensate_note_creation(¬e).await;
4023 return Err(e);
4024 }
4025 } else if !embed_model_names.is_empty() {
4026 let rt_clone = self.clone();
4029 let content_owned: std::sync::Arc<str> = std::sync::Arc::from(embed_text);
4032 let usage_ctx = crate::usage::current();
4033 let mut join_set = tokio::task::JoinSet::new();
4034 for (idx, model_name) in embed_model_names.iter().enumerate() {
4035 let rt = rt_clone.clone();
4036 let text = std::sync::Arc::clone(&content_owned);
4037 let name = model_name.clone();
4038 let ctx = usage_ctx.clone();
4039 let token = (*token).clone();
4040 join_set.spawn(crate::runtime::inherit_request_embedder_scope(async move {
4041 let fut = rt.embed_document_with_model_outcome_for_token(
4042 &token,
4043 &name,
4044 text.as_ref(),
4045 );
4046 let result = match ctx {
4047 Some(ctx) => crate::usage::scope(ctx, fut).await,
4048 None => fut.await,
4049 };
4050 (idx, result)
4051 }));
4052 }
4053 let outcomes = match drain_embed_join_set(join_set, embed_model_names.len()).await {
4057 Ok(outcomes) => outcomes,
4058 Err(e) => {
4059 self.compensate_note_creation(¬e).await;
4060 return Err(e);
4061 }
4062 };
4063 for (model_name, outcome) in embed_model_names.iter().zip(outcomes) {
4065 embedding_report.observe(&outcome);
4066 let insert_result = self
4067 .publish_note_vector_revision(token, ¬e, model_name, &outcome.vector)
4068 .await;
4069 if let Err(e) = insert_result {
4070 self.compensate_note_creation(¬e).await;
4071 return Err(e);
4072 }
4073 }
4074 }
4075
4076 let mut created_edges: Vec<Uuid> = Vec::with_capacity(annotates.len());
4082
4083 #[cfg(test)]
4086 let annotates_iter: Vec<(usize, Uuid)> = annotates
4087 .iter()
4088 .enumerate()
4089 .map(|(i, &id)| (i, id))
4090 .collect();
4091 #[cfg(test)]
4092 macro_rules! next_target {
4093 ($pair:expr) => {
4094 $pair.1
4095 };
4096 }
4097 #[cfg(not(test))]
4098 let annotates_iter: Vec<Uuid> = annotates.to_vec();
4099 #[cfg(not(test))]
4100 macro_rules! next_target {
4101 ($pair:expr) => {
4102 $pair
4103 };
4104 }
4105
4106 for pair in annotates_iter {
4107 let target_id = next_target!(pair);
4108
4109 #[cfg(test)]
4111 let injected_err: Option<RuntimeError> = {
4112 let call_idx = pair.0;
4113 LINK_FAIL_AFTER.with(|cell| {
4114 let n = cell.get();
4115 if n > 0 && call_idx + 1 == n {
4116 cell.set(0); Some(RuntimeError::Internal("injected link failure".to_string()))
4118 } else {
4119 None
4120 }
4121 })
4122 };
4123 #[cfg(not(test))]
4124 let injected_err: Option<RuntimeError> = None;
4125
4126 let link_result = if let Some(e) = injected_err {
4127 Err(e)
4128 } else {
4129 self.link(
4130 token,
4131 note.id,
4132 target_id,
4133 EdgeRelation::Annotates,
4134 1.0,
4135 None,
4136 )
4137 .await
4138 };
4139
4140 match link_result {
4141 Ok(edge) => created_edges.push(edge.id.into()),
4142 Err(e) => {
4143 if self.compensate_note_creation(¬e).await {
4146 for edge_id in created_edges {
4147 let _ = self.delete_edge(token, edge_id, true).await;
4148 }
4149 }
4150 return Err(e);
4151 }
4152 }
4153 }
4154
4155 let event_store = self.events(token)?;
4160 let created_event = khive_storage::event::Event::new(
4161 note.namespace.clone(),
4162 "create",
4163 EventKind::NoteCreated,
4164 SubstrateKind::Note,
4165 "",
4166 )
4167 .with_target(note.id)
4168 .with_payload(serde_json::json!({
4169 "id": note.id,
4170 "namespace": note.namespace,
4171 "kind": note.kind,
4172 "salience": note.salience,
4173 }));
4174 event_store.append_event(created_event).await.map_err(|e| {
4175 RuntimeError::Internal(format!("create_note: event store write failed: {e}"))
4176 })?;
4177
4178 Ok((note, embedding_report))
4179 }
4180
4181 pub async fn list_notes(
4187 &self,
4188 token: &NamespaceToken,
4189 kind: Option<&str>,
4190 limit: u32,
4191 offset: u32,
4192 ) -> RuntimeResult<Vec<Note>> {
4193 let visible = token.visible_namespaces();
4194 if visible.len() == 1 {
4195 let page = self
4197 .notes(token)?
4198 .query_notes_count_free(
4199 token.namespace().as_str(),
4200 kind,
4201 PageRequest {
4202 offset: offset.into(),
4203 limit,
4204 },
4205 )
4206 .await?;
4207 return Ok(page.items);
4208 }
4209 use khive_storage::note::NoteFilter;
4211 let ns_strs: Vec<String> = visible.iter().map(|ns| ns.as_str().to_owned()).collect();
4212 let filter = NoteFilter {
4213 kind: kind.map(|k| k.to_string()),
4214 namespaces: ns_strs,
4215 ..Default::default()
4216 };
4217 let page = self
4218 .notes(token)?
4219 .query_notes_filtered_count_free(
4220 token.namespace().as_str(),
4221 &filter,
4222 PageRequest {
4223 offset: offset.into(),
4224 limit,
4225 },
4226 )
4227 .await?;
4228 Ok(page.items)
4229 }
4230
4231 pub async fn list_notes_after(
4237 &self,
4238 token: &NamespaceToken,
4239 kind: Option<&str>,
4240 after: Option<Uuid>,
4241 limit: u32,
4242 ) -> RuntimeResult<(Vec<Note>, Option<Uuid>)> {
4243 let store = self.notes(token)?;
4244 let after = match after {
4245 Some(id) => {
4246 let note = self
4247 .get_note_including_deleted(token, id)
4248 .await?
4249 .ok_or_else(|| RuntimeError::NotFound(format!("note cursor {id}")))?;
4250 Self::ensure_namespace_visible(¬e.namespace, token)?;
4251 let sequence = store.note_sequence(id).await?.ok_or_else(|| {
4252 RuntimeError::Internal(format!(
4253 "note cursor {id} has no insertion-sequence ledger row"
4254 ))
4255 })?;
4256 Some(SeekCursor { sequence, id })
4257 }
4258 None => None,
4259 };
4260 let filter = khive_storage::note::NoteFilter {
4261 kind: kind.map(str::to_string),
4262 namespaces: token
4263 .visible_namespaces()
4264 .iter()
4265 .map(|namespace| namespace.as_str().to_owned())
4266 .collect(),
4267 ..Default::default()
4268 };
4269 let page = store
4270 .query_notes_filtered_after(token.namespace().as_str(), &filter, after, limit)
4271 .await?;
4272 Ok((page.items, page.next_after.map(|cursor| cursor.id)))
4273 }
4274
4275 pub async fn count_notes(
4277 &self,
4278 token: &NamespaceToken,
4279 kind: Option<&str>,
4280 ) -> RuntimeResult<u64> {
4281 let namespaces: Vec<String> = token
4282 .visible_namespaces()
4283 .iter()
4284 .map(|namespace| namespace.as_str().to_owned())
4285 .collect();
4286 Ok(self
4287 .notes(token)?
4288 .count_notes_in_namespaces(&namespaces, kind)
4289 .await?)
4290 }
4291
4292 #[allow(clippy::too_many_arguments)]
4312 pub async fn search_notes(
4313 &self,
4314 token: &NamespaceToken,
4315 query_text: &str,
4316 query_vector: Option<Vec<f32>>,
4317 limit: u32,
4318 note_kind: Option<&str>,
4319 include_superseded: bool,
4320 tags_any: &[String],
4321 properties_filter: Option<&serde_json::Value>,
4322 ) -> RuntimeResult<Vec<NoteSearchHit>> {
4323 self.search_notes_with_text_mode(
4324 token,
4325 query_text,
4326 query_vector,
4327 limit,
4328 note_kind,
4329 include_superseded,
4330 tags_any,
4331 properties_filter,
4332 TextQueryMode::Plain,
4333 )
4334 .await
4335 }
4336
4337 #[allow(clippy::too_many_arguments)]
4339 pub async fn search_notes_with_text_mode(
4340 &self,
4341 token: &NamespaceToken,
4342 query_text: &str,
4343 query_vector: Option<Vec<f32>>,
4344 limit: u32,
4345 note_kind: Option<&str>,
4346 include_superseded: bool,
4347 tags_any: &[String],
4348 properties_filter: Option<&serde_json::Value>,
4349 text_mode: TextQueryMode,
4350 ) -> RuntimeResult<Vec<NoteSearchHit>> {
4351 let (hits, _vector_error) = self
4352 .search_notes_inner(
4353 token,
4354 query_text,
4355 query_vector,
4356 limit,
4357 note_kind,
4358 include_superseded,
4359 tags_any,
4360 properties_filter,
4361 text_mode,
4362 false,
4363 )
4364 .await?;
4365 Ok(hits)
4366 }
4367
4368 #[allow(clippy::too_many_arguments)]
4375 pub async fn search_notes_outcome(
4376 &self,
4377 token: &NamespaceToken,
4378 query_text: &str,
4379 limit: u32,
4380 note_kind: Option<&str>,
4381 include_superseded: bool,
4382 tags_any: &[String],
4383 properties_filter: Option<&serde_json::Value>,
4384 ) -> RuntimeResult<NoteSearchOutcome> {
4385 self.search_notes_outcome_with_text_mode(
4386 token,
4387 query_text,
4388 limit,
4389 note_kind,
4390 include_superseded,
4391 tags_any,
4392 properties_filter,
4393 TextQueryMode::Plain,
4394 )
4395 .await
4396 }
4397
4398 #[allow(clippy::too_many_arguments)]
4400 pub async fn search_notes_outcome_with_text_mode(
4401 &self,
4402 token: &NamespaceToken,
4403 query_text: &str,
4404 limit: u32,
4405 note_kind: Option<&str>,
4406 include_superseded: bool,
4407 tags_any: &[String],
4408 properties_filter: Option<&serde_json::Value>,
4409 text_mode: TextQueryMode,
4410 ) -> RuntimeResult<NoteSearchOutcome> {
4411 let (hits, vector_error) = self
4412 .search_notes_inner(
4413 token,
4414 query_text,
4415 None,
4416 limit,
4417 note_kind,
4418 include_superseded,
4419 tags_any,
4420 properties_filter,
4421 text_mode,
4422 true,
4423 )
4424 .await?;
4425 Ok(NoteSearchOutcome { hits, vector_error })
4426 }
4427
4428 #[allow(clippy::too_many_arguments)]
4429 async fn search_notes_inner(
4430 &self,
4431 token: &NamespaceToken,
4432 query_text: &str,
4433 query_vector: Option<Vec<f32>>,
4434 limit: u32,
4435 note_kind: Option<&str>,
4436 include_superseded: bool,
4437 tags_any: &[String],
4438 properties_filter: Option<&serde_json::Value>,
4439 text_mode: TextQueryMode,
4440 tolerate_vector_error: bool,
4441 ) -> RuntimeResult<(Vec<NoteSearchHit>, Option<String>)> {
4442 const RRF_K: usize = 60;
4443 let candidates = limit.saturating_mul(4).max(limit);
4444 let visible_ns: Vec<String> = token
4445 .visible_namespaces()
4446 .iter()
4447 .map(|ns| ns.as_str().to_owned())
4448 .collect();
4449
4450 #[cfg(any(test, feature = "fault-injection"))]
4463 let fts_search_inject = {
4464 let mut g = FTS_SEARCH_FAIL_NS.lock().unwrap();
4465 match g.as_deref() {
4466 Some(armed) if visible_ns.iter().any(|ns| ns == armed) => {
4467 *g = None;
4468 true
4469 }
4470 _ => false,
4471 }
4472 };
4473 #[cfg(not(any(test, feature = "fault-injection")))]
4474 let fts_search_inject = false;
4475
4476 let text_search_result = if fts_search_inject {
4477 Err(khive_storage::StorageError::Timeout {
4478 operation: "fts_search".into(),
4479 })
4480 } else {
4481 self.text_for_notes(token)?
4482 .search(TextSearchRequest {
4483 query: query_text.to_string(),
4484 mode: text_mode,
4485 filter: Some(TextFilter {
4486 namespaces: visible_ns.clone(),
4487 record_kinds: note_kind
4494 .map(|kind| vec![kind.to_string()])
4495 .unwrap_or_default(),
4496 ..TextFilter::default()
4497 }),
4498 top_k: candidates,
4499 snippet_chars: 200,
4500 })
4501 .await
4502 };
4503
4504 let text_hits = crate::error::fts_text_leg_or_err(
4510 text_search_result.map_err(RuntimeError::from),
4511 "search_notes",
4512 query_text,
4513 )?;
4514
4515 let mut vector_error: Option<String> = None;
4517 let vector_hits = if query_vector.is_some() || self.config().embedding_model.is_some() {
4518 match self
4519 .vector_search(
4520 token,
4521 query_vector,
4522 Some(query_text),
4523 candidates,
4524 Some(SubstrateKind::Note),
4525 )
4526 .await
4527 {
4528 Ok(hits) => hits,
4529 Err(e) if tolerate_vector_error => {
4530 vector_error = Some(e.to_string());
4531 Vec::new()
4532 }
4533 Err(e) => return Err(e),
4534 }
4535 } else {
4536 vec![]
4537 };
4538
4539 let fuse_k = text_hits.len() + vector_hits.len();
4545 let fused = crate::fusion::rrf_fuse_k(self, text_hits, vector_hits, RRF_K, fuse_k).await?;
4546
4547 let candidate_ids: Vec<Uuid> = fused.iter().map(|hit| hit.entity_id).collect();
4548 if candidate_ids.is_empty() {
4549 return Ok((vec![], vector_error));
4550 }
4551
4552 let note_store = self.notes(token)?;
4557 let mut alive_notes: HashMap<Uuid, Note> = HashMap::new();
4558 for id in &candidate_ids {
4559 if let Some(note) = note_store.get_note(*id).await? {
4560 if note.deleted_at.is_some() {
4561 continue;
4562 }
4563 if let Some(want_kind) = note_kind {
4564 if note.kind != want_kind {
4565 continue;
4566 }
4567 }
4568 if !tags_any.is_empty() {
4573 let note_tags: Vec<String> = note
4574 .properties
4575 .as_ref()
4576 .and_then(|p| p.get("tags"))
4577 .and_then(serde_json::Value::as_array)
4578 .map(|arr| {
4579 arr.iter()
4580 .filter_map(serde_json::Value::as_str)
4581 .map(str::to_owned)
4582 .collect()
4583 })
4584 .unwrap_or_default();
4585 if !note_tags
4586 .iter()
4587 .any(|t| tags_any.iter().any(|w| t.eq_ignore_ascii_case(w)))
4588 {
4589 continue;
4590 }
4591 }
4592 if let Some(pf) = properties_filter {
4594 if !note_props_match(note.properties.as_ref(), pf) {
4595 continue;
4596 }
4597 }
4598 alive_notes.insert(*id, note);
4599 }
4600 }
4601
4602 if !include_superseded && !alive_notes.is_empty() {
4605 let graph = self.graph(token)?;
4606 let note_ids: Vec<Uuid> = alive_notes.keys().copied().collect();
4607 let superseded: std::collections::HashSet<Uuid> = graph
4608 .batch_neighbors(
4609 ¬e_ids,
4610 NeighborQuery {
4611 direction: Direction::In,
4612 relations: Some(vec![EdgeRelation::Supersedes]),
4613 limit: Some(1),
4614 min_weight: None,
4615 },
4616 )
4617 .await?
4618 .into_iter()
4619 .map(|(note_id, _)| note_id)
4620 .collect();
4621 alive_notes.retain(|id, _| !superseded.contains(id));
4622 }
4623
4624 let mut hits: Vec<NoteSearchHit> = fused
4626 .into_iter()
4627 .filter_map(|hit| {
4628 let note = alive_notes.get(&hit.entity_id)?;
4629 let weighted = salience_weighted_rank(hit.score, note.salience);
4630 Some(NoteSearchHit {
4631 note_id: hit.entity_id,
4632 score: weighted,
4633 rank_score_kind: hit.rank_score_kind,
4634 signals: hit.signals,
4635 source: hit.source,
4636 title: hit.title.or_else(|| note_title(note)),
4637 snippet: hit.snippet.or_else(|| note_snippet(note)),
4638 })
4639 })
4640 .collect();
4641
4642 hits.sort_by(|a, b| b.score.cmp(&a.score).then(a.note_id.cmp(&b.note_id)));
4643 hits.truncate(limit as usize);
4644 Ok((hits, vector_error))
4645 }
4646
4647 pub async fn resolve_prefix(
4654 &self,
4655 token: &NamespaceToken,
4656 prefix: &str,
4657 ) -> RuntimeResult<Option<Uuid>> {
4658 let namespaces = [token.namespace().as_str().to_owned()];
4659 self.resolve_prefix_inner(Some(&namespaces), prefix, false, false)
4660 .await
4661 }
4662
4663 pub async fn resolve_prefix_including_deleted(
4664 &self,
4665 token: &NamespaceToken,
4666 prefix: &str,
4667 ) -> RuntimeResult<Option<Uuid>> {
4668 let namespaces = [token.namespace().as_str().to_owned()];
4669 self.resolve_prefix_inner(Some(&namespaces), prefix, true, false)
4670 .await
4671 }
4672
4673 pub async fn resolve_prefix_unfiltered(&self, prefix: &str) -> RuntimeResult<Option<Uuid>> {
4681 self.resolve_prefix_inner(None, prefix, false, false).await
4682 }
4683
4684 pub async fn resolve_prefix_unfiltered_including_deleted(
4687 &self,
4688 prefix: &str,
4689 ) -> RuntimeResult<Option<Uuid>> {
4690 self.resolve_prefix_inner(None, prefix, true, false).await
4691 }
4692
4693 pub(crate) async fn resolve_prefix_for_kg_read(
4696 &self,
4697 prefix: &str,
4698 include_deleted: bool,
4699 ) -> RuntimeResult<Option<Uuid>> {
4700 self.resolve_prefix_inner(None, prefix, include_deleted, true)
4701 .await
4702 }
4703
4704 async fn resolve_prefix_inner(
4716 &self,
4717 namespaces: Option<&[String]>,
4718 prefix: &str,
4719 include_deleted: bool,
4720 require_tables: bool,
4721 ) -> RuntimeResult<Option<Uuid>> {
4722 if !prefix.chars().all(|c| c.is_ascii_hexdigit() || c == '-') {
4729 return Ok(None);
4730 }
4731
4732 #[cfg(any(test, feature = "fault-injection"))]
4736 if consume_fault(&PREFIX_RESOLVE_FAIL_NS, prefix) {
4737 return Err(RuntimeError::Storage(
4738 khive_storage::StorageError::Timeout {
4739 operation: "resolve_prefix".into(),
4740 },
4741 ));
4742 }
4743
4744 let Some((lower, upper)) = uuid_prefix_bounds(prefix) else {
4745 return Ok(None);
4746 };
4747
4748 let tables = [
4749 ("entities", true),
4750 ("notes", true),
4751 ("events", false),
4752 ("graph_edges", false),
4753 ];
4754
4755 let mut matches: Vec<String> = Vec::new();
4764 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
4765 let mut reader = self.sql().reader().await.map_err(RuntimeError::Storage)?;
4766
4767 for (table, has_deleted_at) in tables {
4768 let sql = resolve_prefix_statement(
4769 table,
4770 has_deleted_at,
4771 include_deleted,
4772 namespaces,
4773 &lower,
4774 &upper,
4775 );
4776 match reader.query_all(sql).await {
4777 Ok(rows) => {
4778 for row in rows {
4779 if let Some(col) = row.columns.first() {
4780 if let SqlValue::Text(s) = &col.value {
4781 if seen.insert(s.clone()) {
4782 matches.push(s.clone());
4783 }
4784 }
4785 }
4786 }
4787 }
4788 Err(e) => {
4789 let msg = e.to_string();
4790 if !require_tables && msg.contains("no such table") {
4791 continue;
4792 }
4793 return Err(RuntimeError::Storage(e));
4794 }
4795 }
4796 if matches.len() > 1 {
4797 break;
4798 }
4799 }
4800
4801 if matches.len() <= 1 {
4806 if let Some(sidecar_sql) = self.events_sidecar_sql_read_only()? {
4807 let mut params = vec![SqlValue::Text(lower.clone()), SqlValue::Text(upper.clone())];
4808 if let Some(ns) = namespaces {
4809 params.extend(ns.iter().map(|n| SqlValue::Text(n.clone())));
4810 }
4811 let namespace_clause = namespaces.map(|namespaces| {
4812 let placeholders: Vec<String> = (0..namespaces.len())
4813 .map(|index| format!("?{}", index + 3))
4814 .collect();
4815 format!(" AND namespace IN ({})", placeholders.join(", "))
4816 });
4817 let sql = SqlStatement {
4818 sql: format!(
4819 "SELECT id FROM events \
4820 WHERE id >= ?1 AND id < ?2{namespace_clause} LIMIT 2",
4821 namespace_clause = namespace_clause.as_deref().unwrap_or("")
4822 ),
4823 params,
4824 label: Some("resolve_prefix.events_sidecar".into()),
4825 };
4826 let mut sidecar_reader =
4827 sidecar_sql.reader().await.map_err(RuntimeError::Storage)?;
4828 match sidecar_reader.query_all(sql).await {
4829 Ok(rows) => {
4830 for row in rows {
4831 if let Some(col) = row.columns.first() {
4832 if let SqlValue::Text(s) = &col.value {
4833 if seen.insert(s.clone()) {
4834 matches.push(s.clone());
4835 }
4836 }
4837 }
4838 }
4839 }
4840 Err(e) => {
4841 let msg = e.to_string();
4842 if require_tables || !msg.contains("no such table") {
4843 return Err(RuntimeError::Storage(e));
4844 }
4845 }
4846 }
4847 }
4848 }
4849
4850 match matches.len() {
4851 0 => Ok(None),
4852 1 => {
4853 let uuid = Uuid::from_str(&matches[0])
4854 .map_err(|e| RuntimeError::Internal(format!("stored UUID is invalid: {e}")))?;
4855 Ok(Some(uuid))
4856 }
4857 _ => {
4858 let uuids: Vec<uuid::Uuid> = matches
4859 .iter()
4860 .filter_map(|s| Uuid::from_str(s).ok())
4861 .collect();
4862 Err(RuntimeError::AmbiguousPrefix {
4863 prefix: prefix.to_string(),
4864 matches: uuids,
4865 })
4866 }
4867 }
4868 }
4869
4870 pub async fn resolve_by_id(
4881 &self,
4882 token: &NamespaceToken,
4883 id: Uuid,
4884 ) -> RuntimeResult<Option<Resolved>> {
4885 if let Some(entity) = self.entities(token)?.get_entity(id).await? {
4887 return Ok(Some(Resolved::Entity(entity)));
4888 }
4889
4890 if let Some(note) = self.notes(token)?.get_note(id).await? {
4892 return Ok(Some(Resolved::Note(note)));
4893 }
4894
4895 Ok(None)
4898 }
4899
4900 pub async fn resolve_by_id_including_deleted(
4906 &self,
4907 token: &NamespaceToken,
4908 id: Uuid,
4909 ) -> RuntimeResult<Option<Resolved>> {
4910 if let Some(entity) = self
4912 .entities(token)?
4913 .get_entity_including_deleted(id)
4914 .await?
4915 {
4916 return Ok(Some(Resolved::Entity(entity)));
4917 }
4918
4919 if let Some(note) = self.notes(token)?.get_note_including_deleted(id).await? {
4921 return Ok(Some(Resolved::Note(note)));
4922 }
4923
4924 Ok(None)
4927 }
4928
4929 pub async fn resolve(
4934 &self,
4935 token: &NamespaceToken,
4936 id: Uuid,
4937 ) -> RuntimeResult<Option<Resolved>> {
4938 match self.get_entity(token, id).await {
4940 Ok(entity) => return Ok(Some(Resolved::Entity(entity))),
4941 Err(RuntimeError::NotFound(_) | RuntimeError::NamespaceMismatch { .. }) => {}
4942 Err(e) => return Err(e),
4943 }
4944
4945 if let Some(note) = self.notes(token)?.get_note(id).await? {
4947 if Self::ensure_namespace_visible(¬e.namespace, token).is_ok() {
4948 return Ok(Some(Resolved::Note(note)));
4949 }
4950 }
4951
4952 if let Some(event) = self.events(token)?.get_event(id).await? {
4954 if Self::ensure_namespace_visible(&event.namespace, token).is_ok() {
4955 return Ok(Some(Resolved::Event(event)));
4956 }
4957 }
4958
4959 Ok(None)
4960 }
4961
4962 pub async fn resolve_edge_endpoint(
4973 &self,
4974 token: &NamespaceToken,
4975 id: Uuid,
4976 ) -> RuntimeResult<Option<Resolved>> {
4977 if let Some(resolved) = self.resolve_by_id(token, id).await? {
4978 return Ok(Some(resolved));
4979 }
4980 if let Some(event) = self.events(token)?.get_event(id).await? {
4981 return Ok(Some(Resolved::Event(event)));
4982 }
4983 Ok(None)
4984 }
4985
4986 pub async fn resolve_primary(
4991 &self,
4992 token: &NamespaceToken,
4993 id: Uuid,
4994 ) -> RuntimeResult<Option<Resolved>> {
4995 let ns = token.namespace().as_str();
4996
4997 if let Some(entity) = self.entities(token)?.get_entity(id).await? {
4999 if Self::ensure_namespace(&entity.namespace, ns).is_ok() {
5000 return Ok(Some(Resolved::Entity(entity)));
5001 }
5002 }
5003
5004 if let Some(note) = self.notes(token)?.get_note(id).await? {
5006 if Self::ensure_namespace(¬e.namespace, ns).is_ok() {
5007 return Ok(Some(Resolved::Note(note)));
5008 }
5009 }
5010
5011 if let Some(event) = self.events(token)?.get_event(id).await? {
5013 if Self::ensure_namespace(&event.namespace, ns).is_ok() {
5014 return Ok(Some(Resolved::Event(event)));
5015 }
5016 }
5017
5018 Ok(None)
5019 }
5020
5021 pub async fn resolve_including_deleted(
5026 &self,
5027 token: &NamespaceToken,
5028 id: Uuid,
5029 ) -> RuntimeResult<Option<Resolved>> {
5030 let ns = token.namespace().as_str();
5031
5032 if let Some(entity) = self
5033 .entities(token)?
5034 .get_entity_including_deleted(id)
5035 .await?
5036 {
5037 if Self::ensure_namespace(&entity.namespace, ns).is_ok() {
5038 return Ok(Some(Resolved::Entity(entity)));
5039 }
5040 }
5041
5042 if let Some(note) = self.notes(token)?.get_note_including_deleted(id).await? {
5043 if Self::ensure_namespace(¬e.namespace, ns).is_ok() {
5044 return Ok(Some(Resolved::Note(note)));
5045 }
5046 }
5047
5048 if let Some(event) = self.events(token)?.get_event(id).await? {
5049 if Self::ensure_namespace(&event.namespace, ns).is_ok() {
5050 return Ok(Some(Resolved::Event(event)));
5051 }
5052 }
5053
5054 Ok(None)
5055 }
5056
5057 async fn atomic_hard_delete_with_edge_purge(
5069 &self,
5070 row_statement: SqlStatement,
5071 node_id: Uuid,
5072 namespace: &str,
5073 actor: &str,
5074 substrate: SubstrateKind,
5075 ) -> RuntimeResult<bool> {
5076 let mut statements = vec![PlanStatement {
5077 statement: row_statement,
5078 guard: Some(AffectedRowGuard::exactly(1)),
5079 }];
5080 if substrate == SubstrateKind::Entity {
5081 statements.push(PlanStatement {
5082 statement: khive_db::stores::attachment::delete_record_attachments_statement(
5083 node_id,
5084 AttachmentSubstrate::Entity,
5085 ),
5086 guard: None,
5087 });
5088 }
5089 statements.extend(
5090 hard_delete_lineage_warning_statements(namespace, actor, node_id, substrate)
5091 .into_iter()
5092 .map(|statement| PlanStatement {
5093 statement,
5094 guard: None,
5095 }),
5096 );
5097 statements.push(PlanStatement {
5098 statement: purge_incident_edges_statement(node_id),
5099 guard: None,
5100 });
5101 let plan = AtomicOpPlan::Delete(DeletePlan {
5102 target_id: node_id,
5103 statements,
5104 post_commit: PostCommitEffect::None,
5105 });
5106 match run_atomic_unit(self.sql().as_ref(), vec![plan]).await {
5107 Ok(AtomicRunOutcome::Committed { .. }) => Ok(true),
5108 Ok(AtomicRunOutcome::RolledBack {
5109 failure: AtomicOpFailure::NoteConflict(conflict),
5110 ..
5111 }) => Err(conflict.into_error().into()),
5112 Ok(AtomicRunOutcome::RolledBack {
5113 failure: AtomicOpFailure::EntityConflict(conflict),
5114 ..
5115 }) => Err(conflict.into_error().into()),
5116 Ok(AtomicRunOutcome::RolledBack {
5117 failure: AtomicOpFailure::GuardFailed { .. },
5118 ..
5119 }) => Ok(false),
5120 Ok(AtomicRunOutcome::RolledBack {
5121 failure: AtomicOpFailure::SqlError { message, .. },
5122 ..
5123 }) => Err(RuntimeError::Internal(format!(
5124 "hard delete + edge purge for {node_id} failed: {message}"
5125 ))),
5126 Err(e) => Err(RuntimeError::Internal(format!(
5127 "hard delete + edge purge for {node_id}: atomic unit seam failure: {}",
5128 e.0
5129 ))),
5130 }
5131 }
5132
5133 pub async fn restore_entity(
5139 &self,
5140 token: &NamespaceToken,
5141 id: Uuid,
5142 ) -> RuntimeResult<Option<(Entity, bool)>> {
5143 let Some(entity) = self
5144 .entities(token)?
5145 .get_entity_including_deleted(id)
5146 .await?
5147 else {
5148 return Ok(None);
5149 };
5150 if entity.namespace != token.namespace().as_str() {
5151 return Ok(None);
5152 }
5153 if let Some(kept_id) = entity.merged_into {
5165 if entity.deleted_at.is_none() {
5166 return Err(live_merged_entity_refused(id, kept_id));
5167 }
5168 return Err(merge_tombstone_restore_refused(id, kept_id));
5169 }
5170 if entity.deleted_at.is_none() {
5171 return Ok(Some((entity, false)));
5172 }
5173 let updated_at =
5174 Utc::now()
5175 .timestamp_micros()
5176 .max(entity.updated_at.checked_add(1).ok_or_else(|| {
5177 RuntimeError::Internal(format!(
5178 "entity {id} updated_at is already at i64::MAX and cannot advance"
5179 ))
5180 })?);
5181 let mut restored = entity;
5182 restored.deleted_at = None;
5183 restored.updated_at = updated_at;
5184 restored.version = restored
5185 .version
5186 .checked_add(1)
5187 .ok_or_else(|| RuntimeError::InvalidInput("entity version overflow".into()))?;
5188 let mut statements = vec![PlanStatement {
5189 statement: SqlStatement {
5190 sql: "UPDATE entities SET deleted_at=NULL, updated_at=?1, version=version+1 \
5191 WHERE id=?2 AND namespace=?3 AND deleted_at IS NOT NULL AND version=?4"
5192 .into(),
5193 params: vec![
5194 SqlValue::Integer(updated_at),
5195 SqlValue::Text(id.to_string()),
5196 SqlValue::Text(token.namespace().as_str().to_owned()),
5197 SqlValue::Integer(restored.version - 1),
5198 ],
5199 label: Some("entity-restore".into()),
5200 },
5201 guard: Some(AffectedRowGuard::exactly(1)),
5202 }];
5203 for statement in khive_db::stores::text::delete_document_statements(
5208 "fts_entities",
5209 &restored.namespace,
5210 id,
5211 )
5212 .into_iter()
5213 .chain(insert_document_statements(
5214 "fts_entities",
5215 &entity_fts_document(&restored),
5216 )) {
5217 statements.push(PlanStatement {
5218 statement,
5219 guard: None,
5220 });
5221 }
5222 let plan = AtomicOpPlan::Update(Box::new(UpdatePlan {
5223 graph_effects: Vec::new(),
5224 target_id: id,
5225 statements,
5226 post_commit: PostCommitEffect::None,
5227 edge_natural_key: None,
5228 idempotent_noop: false,
5229 entity_guard: None,
5230 note_guard: None,
5231 note_vector_purge: None,
5232 note_embedding_inheritance: None,
5233 }));
5234 match run_atomic_unit(self.sql().as_ref(), vec![plan]).await {
5235 Ok(AtomicRunOutcome::Committed { .. }) => {
5236 #[cfg(any(test, feature = "fault-injection"))]
5239 if consume_fault(&FTS_FAIL_NS, &restored.namespace) {
5240 return Err(restore_reindex_failed(
5241 "entity",
5242 id,
5243 RuntimeError::Internal("injected FTS failure".to_string()),
5244 ));
5245 }
5246 self.reindex_entity(token, &restored)
5247 .await
5248 .map_err(|e| restore_reindex_failed("entity", id, e))?;
5249 Ok(Some((restored, true)))
5250 }
5251 Ok(AtomicRunOutcome::RolledBack { failure, .. }) => Err(RuntimeError::Internal(
5252 format!("entity restore rolled back: {failure:?}"),
5253 )),
5254 Err(error) => Err(RuntimeError::Storage(error.0)),
5255 }
5256 }
5257
5258 pub async fn restore_note(
5264 &self,
5265 token: &NamespaceToken,
5266 id: Uuid,
5267 ) -> RuntimeResult<Option<(Note, bool)>> {
5268 let Some(note) = self.notes(token)?.get_note_including_deleted(id).await? else {
5269 return Ok(None);
5270 };
5271 if note.namespace != token.namespace().as_str() {
5272 return Ok(None);
5273 }
5274 if note.deleted_at.is_none() {
5275 return Ok(Some((note, false)));
5276 }
5277 if let Some(key) = note.key.as_deref() {
5278 if let Some(holder) = self
5279 .notes(token)?
5280 .get_live_notes_by_key(¬e.namespace, key, Some(¬e.kind))
5281 .await?
5282 .into_iter()
5283 .find(|holder| holder.id != note.id)
5284 {
5285 return Err(restore_key_conflict(key, &holder));
5286 }
5287 }
5288 let updated_at =
5289 Utc::now()
5290 .timestamp_micros()
5291 .max(note.updated_at.checked_add(1).ok_or_else(|| {
5292 RuntimeError::Internal(format!(
5293 "note {id} updated_at is already at i64::MAX and cannot advance"
5294 ))
5295 })?);
5296 let mut params = vec![
5297 SqlValue::Text("active".into()),
5298 SqlValue::Integer(updated_at),
5299 SqlValue::Text(id.to_string()),
5300 SqlValue::Text(note.namespace.clone()),
5301 SqlValue::Text(note.kind.clone()),
5302 ];
5303 let key_clause = if let Some(key) = note.key.as_deref() {
5304 params.push(SqlValue::Text(key.to_owned()));
5305 format!(
5306 " AND (key IS NULL OR NOT EXISTS (SELECT 1 FROM notes live \
5307 WHERE live.namespace=?4 AND live.kind=?5 AND live.key=?{} \
5308 AND live.deleted_at IS NULL AND live.id != notes.id))",
5309 params.len()
5310 )
5311 } else {
5312 String::new()
5313 };
5314 let mut restored = note.clone();
5315 restored.status = "active".into();
5316 restored.deleted_at = None;
5317 restored.updated_at = updated_at;
5318 restored.version = restored
5319 .version
5320 .checked_add(1)
5321 .ok_or_else(|| RuntimeError::Internal(format!("note {id} version is exhausted")))?;
5322 let mut statements = vec![PlanStatement {
5323 statement: SqlStatement {
5324 sql: format!(
5325 "UPDATE notes SET status=?1, deleted_at=NULL, updated_at=?2 \
5326 WHERE id=?3 AND namespace=?4 AND kind=?5 AND deleted_at IS NOT NULL{key_clause}"
5327 ),
5328 params,
5329 label: Some("note-restore".into()),
5330 },
5331 guard: Some(AffectedRowGuard::exactly(1)),
5332 }];
5333 for statement in
5335 khive_db::stores::text::delete_document_statements("fts_notes", &restored.namespace, id)
5336 .into_iter()
5337 .chain(insert_document_statements(
5338 "fts_notes",
5339 ¬e_fts_document(&restored),
5340 ))
5341 {
5342 statements.push(PlanStatement {
5343 statement,
5344 guard: None,
5345 });
5346 }
5347 let plan = AtomicOpPlan::Update(Box::new(UpdatePlan {
5348 graph_effects: Vec::new(),
5349 target_id: id,
5350 statements,
5351 post_commit: PostCommitEffect::None,
5352 edge_natural_key: None,
5353 idempotent_noop: false,
5354 entity_guard: None,
5355 note_guard: None,
5356 note_vector_purge: None,
5357 note_embedding_inheritance: None,
5358 }));
5359 match run_atomic_unit(self.sql().as_ref(), vec![plan]).await {
5360 Ok(AtomicRunOutcome::Committed { .. }) => {
5361 #[cfg(any(test, feature = "fault-injection"))]
5362 if consume_fault(&FTS_FAIL_NS, &restored.namespace) {
5363 return Err(restore_reindex_failed(
5364 "note",
5365 id,
5366 RuntimeError::Internal("injected FTS failure".to_string()),
5367 ));
5368 }
5369 self.reindex_note(token, &restored)
5370 .await
5371 .map_err(|e| restore_reindex_failed("note", id, e))?;
5372 Ok(Some((restored, true)))
5373 }
5374 Ok(AtomicRunOutcome::RolledBack {
5375 failure: AtomicOpFailure::GuardFailed { .. },
5376 ..
5377 }) => {
5378 if let Some(key) = note.key.as_deref() {
5379 if let Some(holder) = self
5380 .notes(token)?
5381 .get_live_notes_by_key(¬e.namespace, key, Some(¬e.kind))
5382 .await?
5383 .into_iter()
5384 .find(|holder| holder.id != note.id)
5385 {
5386 return Err(restore_key_conflict(key, &holder));
5387 }
5388 }
5389 Err(RuntimeError::NotFound(format!(
5390 "note {id} is no longer a caller-owned tombstone"
5391 )))
5392 }
5393 Ok(AtomicRunOutcome::RolledBack { failure, .. }) => Err(RuntimeError::Internal(
5394 format!("note restore rolled back: {failure:?}"),
5395 )),
5396 Err(error) => Err(RuntimeError::Storage(error.0)),
5397 }
5398 }
5399
5400 pub async fn restore_edge(
5402 &self,
5403 token: &NamespaceToken,
5404 id: Uuid,
5405 ) -> RuntimeResult<Option<(Edge, bool)>> {
5406 let Some(edge) = self.get_edge_including_deleted(token, id).await? else {
5407 return Ok(None);
5408 };
5409 if edge.namespace != token.namespace().as_str() {
5410 return Ok(None);
5411 }
5412 if edge.deleted_at.is_none() {
5413 return Ok(Some((edge, false)));
5414 }
5415 let updated_at = Utc::now();
5416 let plan = AtomicOpPlan::Update(Box::new(UpdatePlan {
5417 graph_effects: Vec::new(),
5418 target_id: id,
5419 statements: vec![PlanStatement {
5420 statement: SqlStatement {
5421 sql: "UPDATE graph_edges SET deleted_at=NULL, updated_at=?1 \
5422 WHERE id=?2 AND namespace=?3 AND deleted_at IS NOT NULL"
5423 .into(),
5424 params: vec![
5425 SqlValue::Integer(updated_at.timestamp_micros()),
5426 SqlValue::Text(id.to_string()),
5427 SqlValue::Text(edge.namespace.clone()),
5428 ],
5429 label: Some("edge-restore".into()),
5430 },
5431 guard: Some(AffectedRowGuard::exactly(1)),
5432 }],
5433 post_commit: PostCommitEffect::None,
5434 edge_natural_key: None,
5435 idempotent_noop: false,
5436 entity_guard: None,
5437 note_guard: None,
5438 note_vector_purge: None,
5439 note_embedding_inheritance: None,
5440 }));
5441 match run_atomic_unit(self.sql().as_ref(), vec![plan]).await {
5442 Ok(AtomicRunOutcome::Committed { .. }) => {
5443 let mut restored = edge;
5444 restored.deleted_at = None;
5445 restored.updated_at = updated_at;
5446 Ok(Some((restored, true)))
5447 }
5448 Ok(AtomicRunOutcome::RolledBack { failure, .. }) => Err(RuntimeError::Internal(
5449 format!("edge restore rolled back: {failure:?}"),
5450 )),
5451 Err(error) => Err(RuntimeError::Storage(error.0)),
5452 }
5453 }
5454
5455 pub async fn delete_note(
5466 &self,
5467 token: &NamespaceToken,
5468 id: Uuid,
5469 hard: bool,
5470 ) -> RuntimeResult<bool> {
5471 let note_store = self.notes(token)?;
5472 let note = if hard {
5473 match note_store.get_note_including_deleted(id).await? {
5474 Some(n) => n,
5475 None => return Ok(false),
5476 }
5477 } else {
5478 match note_store.get_note(id).await? {
5479 Some(n) => n,
5480 None => return Ok(false),
5481 }
5482 };
5483 if let Some(error) = self.stream_member_error(¬e).await? {
5484 return Err(error);
5485 }
5486 let mode = if hard {
5487 DeleteMode::Hard
5488 } else {
5489 DeleteMode::Soft
5490 };
5491
5492 let record_tok = token.with_namespace(
5494 khive_types::Namespace::parse(¬e.namespace)
5495 .map_err(|e| RuntimeError::Internal(format!("note namespace invalid: {e}")))?,
5496 );
5497 let record_ns = note.namespace.clone();
5498 let actor = format!("{}:{}", token.actor().kind, token.actor().id);
5499
5500 let deleted = if hard {
5505 let deleted = self
5506 .atomic_hard_delete_with_edge_purge(
5507 note_hard_delete_statement(id),
5508 id,
5509 &record_ns,
5510 &actor,
5511 SubstrateKind::Note,
5512 )
5513 .await?;
5514 self.text_for_notes(&record_tok)?
5515 .delete_document(&record_ns, id)
5516 .await?;
5517 for model_name in self.registered_embedding_model_names() {
5520 self.vectors_for_model(&record_tok, &model_name)?
5521 .delete(id)
5522 .await?;
5523 }
5524 deleted
5525 } else {
5526 let deleted = note_store.delete_note(id, mode).await?;
5527 if deleted {
5528 self.text_for_notes(&record_tok)?
5529 .delete_document(&record_ns, id)
5530 .await?;
5531 for model_name in self.registered_embedding_model_names() {
5532 self.vectors_for_model(&record_tok, &model_name)?
5533 .delete(id)
5534 .await?;
5535 }
5536 }
5537 deleted
5538 };
5539 if deleted {
5540 let event_store = self.events(&record_tok)?;
5541 let event = khive_storage::event::Event::new(
5542 record_ns.clone(),
5543 "delete",
5544 EventKind::NoteDeleted,
5545 SubstrateKind::Note,
5546 "",
5547 )
5548 .with_target(id)
5549 .with_payload(serde_json::json!({"id": id, "namespace": record_ns, "hard": hard}));
5550 event_store.append_event(event).await.map_err(|e| {
5551 RuntimeError::Internal(format!("delete_note: event store write failed: {e}"))
5552 })?;
5553 self.fire_note_mutation_hook(¬e.kind, id).await;
5560 }
5561 Ok(deleted)
5562 }
5563
5564 pub async fn delete_note_row_first_for_compensation(
5583 &self,
5584 token: &NamespaceToken,
5585 id: Uuid,
5586 ) -> RuntimeResult<()> {
5587 let note_store = self.notes(token)?;
5588 let Some(note) = note_store.get_note_including_deleted(id).await? else {
5589 return Ok(());
5590 };
5591 let record_tok = NamespaceToken::for_namespace(
5592 khive_types::Namespace::parse(¬e.namespace)
5593 .map_err(|e| RuntimeError::Internal(format!("note namespace invalid: {e}")))?,
5594 );
5595 let record_ns = note.namespace.clone();
5596
5597 note_store.delete_note(id, DeleteMode::Hard).await?;
5599
5600 #[cfg(any(test, feature = "fault-injection"))]
5601 {
5602 let armed = ROLLBACK_CLEANUP_FAIL_NS.lock().unwrap().take();
5603 if armed.as_deref() == Some(record_ns.as_str()) {
5604 return Err(RuntimeError::Internal(
5605 "row removed but compensation cleanup failed: injected=true".to_string(),
5606 ));
5607 }
5608 }
5609
5610 let mut cleanup_errors = Vec::new();
5611 if let Err(e) = self.graph(&record_tok)?.purge_incident_edges(id).await {
5612 cleanup_errors.push(format!("graph={e}"));
5613 }
5614 if let Err(e) = self
5615 .text_for_notes(&record_tok)?
5616 .delete_document(&record_ns, id)
5617 .await
5618 {
5619 cleanup_errors.push(format!("fts={e}"));
5620 }
5621 for model_name in self.registered_embedding_model_names() {
5622 if let Err(e) = self
5623 .vectors_for_model(&record_tok, &model_name)?
5624 .delete(id)
5625 .await
5626 {
5627 cleanup_errors.push(format!("vector[{model_name}]={e}"));
5628 }
5629 }
5630 if cleanup_errors.is_empty() {
5631 Ok(())
5632 } else {
5633 Err(RuntimeError::Internal(format!(
5634 "row removed but compensation cleanup failed: {}",
5635 cleanup_errors.join("; ")
5636 )))
5637 }
5638 }
5639}
5640
5641#[derive(Clone, Debug, Serialize)]
5643pub struct QueryResult {
5644 pub rows: Vec<SqlRow>,
5645 #[serde(skip_serializing_if = "Vec::is_empty")]
5646 pub warnings: Vec<String>,
5647 pub offset: usize,
5649 pub page_size: usize,
5651 pub has_more: bool,
5653 #[serde(skip_serializing_if = "Option::is_none")]
5655 pub next_offset: Option<usize>,
5656 pub truncated: bool,
5658}
5659
5660#[derive(Debug)]
5662enum SymmetricEdgeUpdateOutcome {
5663 Absorbed(String),
5667 Updated,
5669 Stale,
5674}
5675
5676impl KhiveRuntime {
5677 pub async fn query(&self, token: &NamespaceToken, query: &str) -> RuntimeResult<Vec<SqlRow>> {
5685 Ok(self
5686 .query_with_metadata(token, query, khive_query::CompileOptions::default())
5687 .await?
5688 .rows)
5689 }
5690
5691 pub async fn query_with_metadata(
5693 &self,
5694 token: &NamespaceToken,
5695 query: &str,
5696 mut opts: khive_query::CompileOptions,
5697 ) -> RuntimeResult<QueryResult> {
5698 use khive_query::QueryValue;
5699 use khive_storage::types::SqlValue;
5700
5701 let (language, ast) = khive_query::language::parse_auto_with_language(query)?;
5702 if opts.max_limit == 0 {
5703 return Err(RuntimeError::InvalidInput(
5704 "query page size must be at least 1".into(),
5705 ));
5706 }
5707 let offset = ast.offset;
5708 let page_size = ast.limit.unwrap_or(opts.max_limit).min(opts.max_limit);
5709 opts.scopes = token
5710 .visible_namespaces()
5711 .iter()
5712 .map(|ns| ns.as_str().to_string())
5713 .collect();
5714 let compiled = khive_query::compile(&ast, &opts)?;
5715 let mut warnings = compiled.warnings;
5716 let truncation_check = compiled.truncation_check;
5717
5718 warnings.extend(self.with_pack_edge_rules(|pack_rules| {
5719 static_impossible_edge_pattern_warnings(language, &ast.pattern, pack_rules)
5720 }));
5721
5722 let params: Vec<SqlValue> = compiled
5725 .params
5726 .into_iter()
5727 .map(|qv| match qv {
5728 QueryValue::Null => SqlValue::Null,
5729 QueryValue::Integer(n) => SqlValue::Integer(n),
5730 QueryValue::Float(f) => SqlValue::Float(f),
5731 QueryValue::Text(s) => SqlValue::Text(s),
5732 QueryValue::Blob(b) => SqlValue::Blob(b),
5733 })
5734 .collect();
5735
5736 let mut reader = self.sql().reader().await?;
5737 let stmt = SqlStatement {
5738 sql: compiled.sql,
5739 params,
5740 label: None,
5741 };
5742 let mut rows = reader.query_all(stmt).await?;
5743
5744 let mut truncated = false;
5748 if let Some(check) = truncation_check {
5749 if rows.len() > check.max_limit {
5750 rows.truncate(check.max_limit);
5751 truncated = true;
5752 }
5753 }
5754
5755 let next_offset = if truncated && language == khive_query::QueryLanguage::Gql {
5756 let next = offset.checked_add(rows.len()).ok_or_else(|| {
5757 RuntimeError::InvalidInput("GQL next_offset exceeds usize::MAX".into())
5758 })?;
5759 if next == offset {
5760 return Err(RuntimeError::InvalidInput(
5761 "query page did not advance; page size must be at least 1".into(),
5762 ));
5763 }
5764 i64::try_from(next).map_err(|_| {
5765 RuntimeError::InvalidInput("GQL next_offset exceeds i64::MAX".into())
5766 })?;
5767 Some(next)
5768 } else {
5769 None
5770 };
5771
5772 if truncated {
5773 let Some(check) = truncation_check else {
5774 return Err(RuntimeError::Internal(
5775 "truncated query result is missing sentinel metadata".into(),
5776 ));
5777 };
5778 let bound = match check.requested_limit {
5779 Some(requested) => {
5780 format!("requested query LIMIT {requested} exceeds the effective page size")
5781 }
5782 None => "the query has no explicit LIMIT".to_string(),
5783 };
5784 let warning = match language {
5785 khive_query::QueryLanguage::Gql => {
5786 let Some(next) = next_offset else {
5787 return Err(RuntimeError::Internal(
5788 "truncated GQL result is missing its continuation offset".into(),
5789 ));
5790 };
5791 format!(
5792 "result page capped at {} rows because {bound}; more matches exist. \
5793 Continue the same GQL query with `SKIP {next}` (the machine-readable \
5794 `next_offset`) and keep the same page size.",
5795 check.max_limit
5796 )
5797 }
5798 khive_query::QueryLanguage::Sparql => format!(
5799 "result page capped at {} rows because {bound}; more matches exist. \
5800 SPARQL OFFSET paging is not part of the supported dialect.",
5801 check.max_limit
5802 ),
5803 };
5804 warnings.push(warning);
5805 }
5806
5807 Ok(QueryResult {
5808 rows,
5809 warnings,
5810 offset,
5811 page_size,
5812 has_more: truncated,
5813 next_offset,
5814 truncated,
5815 })
5816 }
5817
5818 pub async fn delete_entity(
5827 &self,
5828 token: &NamespaceToken,
5829 id: Uuid,
5830 hard: bool,
5831 ) -> RuntimeResult<bool> {
5832 let entity = if hard {
5833 match self
5834 .entities(token)?
5835 .get_entity_including_deleted(id)
5836 .await?
5837 {
5838 Some(e) => e,
5839 None => return Ok(false),
5840 }
5841 } else {
5842 match self.entities(token)?.get_entity(id).await? {
5843 Some(e) => e,
5844 None => return Ok(false),
5845 }
5846 };
5847 let mode = if hard {
5848 DeleteMode::Hard
5849 } else {
5850 DeleteMode::Soft
5851 };
5852
5853 let record_tok = token.with_namespace(
5855 khive_types::Namespace::parse(&entity.namespace)
5856 .map_err(|e| RuntimeError::Internal(format!("entity namespace invalid: {e}")))?,
5857 );
5858 let actor = format!("{}:{}", token.actor().kind, token.actor().id);
5859
5860 let deleted = if hard {
5865 let deleted = self
5866 .atomic_hard_delete_with_edge_purge(
5867 entity_hard_delete_statement(id),
5868 id,
5869 &entity.namespace,
5870 &actor,
5871 SubstrateKind::Entity,
5872 )
5873 .await?;
5874 self.remove_from_indexes(&record_tok, id).await?;
5876 deleted
5877 } else {
5878 let deleted = self.entities(token)?.delete_entity(id, mode).await?;
5879 if deleted {
5880 self.remove_from_indexes(&record_tok, id).await?;
5881 }
5882 deleted
5883 };
5884 if deleted {
5885 let event_store = self.events(&record_tok)?;
5886 let ns = entity.namespace.clone();
5887 let event = khive_storage::event::Event::new(
5888 ns.clone(),
5889 "delete",
5890 EventKind::EntityDeleted,
5891 SubstrateKind::Entity,
5892 "",
5893 )
5894 .with_target(id)
5895 .with_payload(serde_json::json!({"id": id, "namespace": ns, "hard": hard}));
5896 event_store.append_event(event).await.map_err(|e| {
5897 RuntimeError::Internal(format!("delete_entity: event store write failed: {e}"))
5898 })?;
5899 }
5900 Ok(deleted)
5901 }
5902
5903 pub(crate) async fn delete_entity_attachments_on_core(&self, id: Uuid) -> RuntimeResult<bool> {
5904 let core = self.core();
5905 drop(core.attachments()?);
5906 let statement = khive_db::stores::attachment::delete_record_attachments_statement(
5907 id,
5908 AttachmentSubstrate::Entity,
5909 );
5910 Ok(core.sql().writer().await?.execute(statement).await? > 0)
5911 }
5912
5913 pub async fn count_entities(
5915 &self,
5916 token: &NamespaceToken,
5917 kind: Option<&str>,
5918 ) -> RuntimeResult<u64> {
5919 let ns_strs: Vec<String> = token
5920 .visible_namespaces()
5921 .iter()
5922 .map(|ns| ns.as_str().to_owned())
5923 .collect();
5924 let filter = EntityFilter {
5925 kinds: match kind {
5926 Some(k) => vec![k.to_string()],
5927 None => vec![],
5928 },
5929 namespaces: ns_strs,
5930 ..Default::default()
5931 };
5932 Ok(self
5933 .entities(token)?
5934 .count_entities(token.namespace().as_str(), filter)
5935 .await?)
5936 }
5937
5938 pub async fn get_edge(
5945 &self,
5946 _token: &NamespaceToken,
5947 edge_id: Uuid,
5948 ) -> RuntimeResult<Option<Edge>> {
5949 let mut reader = self.sql().reader().await?;
5950 let record_ns = reader
5951 .query_scalar(SqlStatement {
5952 sql: "SELECT namespace FROM graph_edges \
5953 WHERE id = ?1 AND deleted_at IS NULL LIMIT 1"
5954 .into(),
5955 params: vec![SqlValue::Text(edge_id.to_string())],
5956 label: Some("get_edge_namespace".into()),
5957 })
5958 .await?;
5959
5960 let Some(SqlValue::Text(record_ns)) = record_ns else {
5961 return Ok(None);
5962 };
5963 let record_tok = NamespaceToken::for_namespace(
5966 khive_types::Namespace::parse(&record_ns)
5967 .map_err(|e| RuntimeError::Internal(format!("edge namespace invalid: {e}")))?,
5968 );
5969 Ok(self
5970 .graph(&record_tok)?
5971 .get_edge(LinkId::from(edge_id))
5972 .await?)
5973 }
5974
5975 pub async fn get_edge_visible(
5980 &self,
5981 token: &NamespaceToken,
5982 edge_id: Uuid,
5983 ) -> RuntimeResult<Option<Edge>> {
5984 self.get_edge(token, edge_id).await
5985 }
5986
5987 pub async fn get_edge_including_deleted(
5993 &self,
5994 _token: &NamespaceToken,
5995 edge_id: Uuid,
5996 ) -> RuntimeResult<Option<Edge>> {
5997 let mut reader = self.sql().reader().await?;
5998 let record_ns = reader
5999 .query_scalar(SqlStatement {
6000 sql: "SELECT namespace FROM graph_edges WHERE id = ?1 LIMIT 1".into(),
6001 params: vec![SqlValue::Text(edge_id.to_string())],
6002 label: Some("get_edge_including_deleted_namespace".into()),
6003 })
6004 .await?;
6005
6006 let Some(SqlValue::Text(record_ns)) = record_ns else {
6007 return Ok(None);
6008 };
6009 let record_tok = NamespaceToken::for_namespace(
6011 khive_types::Namespace::parse(&record_ns)
6012 .map_err(|e| RuntimeError::Internal(format!("edge namespace invalid: {e}")))?,
6013 );
6014 Ok(self
6015 .graph(&record_tok)?
6016 .get_edge_including_deleted(LinkId::from(edge_id))
6017 .await?)
6018 }
6019
6020 pub async fn get_edge_by_natural_key_including_deleted(
6034 &self,
6035 token: &NamespaceToken,
6036 namespace: &str,
6037 source_id: Uuid,
6038 target_id: Uuid,
6039 relation: EdgeRelation,
6040 ) -> RuntimeResult<Option<Edge>> {
6041 Ok(self
6042 .graph(token)?
6043 .get_edge_by_natural_key_including_deleted(namespace, source_id, target_id, relation)
6044 .await?)
6045 }
6046
6047 pub const EDGE_LIST_MAX_LIMIT: u32 = 1000;
6052
6053 pub async fn list_edges(
6058 &self,
6059 token: &NamespaceToken,
6060 filter: crate::curation::EdgeListFilter,
6061 limit: u32,
6062 offset: u32,
6063 ) -> RuntimeResult<Vec<Edge>> {
6064 let limit = limit.min(Self::EDGE_LIST_MAX_LIMIT);
6065 let visible = token.visible_namespaces();
6066
6067 if let [ns] = visible {
6070 let temp = NamespaceToken::for_namespace(ns.clone());
6071 let page = self
6072 .graph(&temp)?
6073 .query_edges(
6074 filter.into(),
6075 vec![SortOrder {
6076 field: EdgeSortField::CreatedAt,
6077 direction: khive_storage::types::SortDirection::Asc,
6078 }],
6079 PageRequest {
6080 offset: offset.into(),
6081 limit,
6082 },
6083 )
6084 .await?;
6085 return Ok(page.items);
6086 }
6087
6088 let ns_strs: Vec<String> = visible.iter().map(|ns| ns.as_str().to_owned()).collect();
6095 let sort = vec![SortOrder {
6096 field: EdgeSortField::CreatedAt,
6097 direction: khive_storage::types::SortDirection::Asc,
6098 }];
6099 let graph = self.graph(token)?;
6100 match graph
6101 .query_edges_in_namespaces(
6102 &ns_strs,
6103 filter.clone().into(),
6104 sort.clone(),
6105 PageRequest {
6106 offset: offset.into(),
6107 limit,
6108 },
6109 )
6110 .await
6111 {
6112 Ok(page) => Ok(page.items),
6113 Err(khive_storage::StorageError::Unsupported { operation, .. })
6114 if operation == "query_edges_in_namespaces" =>
6115 {
6116 let fetch_limit = offset.saturating_add(limit);
6129 let mut namespace_prefixes = Vec::new();
6130 for ns in visible {
6131 let temp = NamespaceToken::for_namespace(ns.clone());
6132 let page = self
6133 .graph(&temp)?
6134 .query_edges(
6135 filter.clone().into(),
6136 sort.clone(),
6137 PageRequest {
6138 offset: 0,
6139 limit: fetch_limit,
6140 },
6141 )
6142 .await?;
6143 namespace_prefixes.push(page.items);
6144 }
6145 Ok(Self::merge_paged_namespace_edges(
6146 namespace_prefixes,
6147 offset,
6148 limit,
6149 ))
6150 }
6151 Err(error) => Err(error.into()),
6152 }
6153 }
6154
6155 fn merge_paged_namespace_edges(
6170 namespace_prefixes: Vec<Vec<Edge>>,
6171 offset: u32,
6172 limit: u32,
6173 ) -> Vec<Edge> {
6174 let mut results: Vec<Edge> = namespace_prefixes.into_iter().flatten().collect();
6175 results.sort_by_key(|e| (e.created_at, Uuid::from(e.id)));
6176 let start = (offset as usize).min(results.len());
6177 let end = (start + limit as usize).min(results.len());
6178 results[start..end].to_vec()
6179 }
6180
6181 pub async fn list_edges_after(
6193 &self,
6194 token: &NamespaceToken,
6195 filter: crate::curation::EdgeListFilter,
6196 after: Option<Uuid>,
6197 limit: u32,
6198 ) -> RuntimeResult<(Vec<Edge>, Option<Uuid>)> {
6199 let limit = limit.clamp(1, Self::EDGE_LIST_MAX_LIMIT);
6200 let visible = token.visible_namespaces();
6201 let limit_usize = limit as usize;
6202 let cursor_store = self.graph(token)?;
6203 let after = match after {
6204 Some(id) => {
6205 let edge = self
6206 .get_edge_including_deleted(token, id)
6207 .await?
6208 .ok_or_else(|| RuntimeError::NotFound(format!("edge cursor {id}")))?;
6209 Self::ensure_namespace_visible(&edge.namespace, token)?;
6210 let sequence = cursor_store.edge_sequence(id).await?.ok_or_else(|| {
6211 RuntimeError::Internal(format!(
6212 "edge cursor {id} has no insertion-sequence ledger row"
6213 ))
6214 })?;
6215 Some(SeekCursor { sequence, id })
6216 }
6217 None => None,
6218 };
6219
6220 if let [ns] = visible {
6221 let temp = NamespaceToken::for_namespace(ns.clone());
6222 let page = self
6223 .graph(&temp)?
6224 .query_edges_sequence_after(filter.into(), after, limit)
6225 .await?;
6226 return Ok((page.items, page.next_after.map(|cursor| cursor.id)));
6227 }
6228
6229 let probe_limit = limit.saturating_add(1);
6233 let mut results = Vec::new();
6234 for ns in visible {
6235 let temp = NamespaceToken::for_namespace(ns.clone());
6236 let page = self
6237 .graph(&temp)?
6238 .query_edges_sequence_after(filter.clone().into(), after, probe_limit)
6239 .await?;
6240 results.extend(page.items);
6241 }
6242 let ids = results
6243 .iter()
6244 .map(|edge| Uuid::from(edge.id))
6245 .collect::<Vec<_>>();
6246 let sequences = cursor_store
6247 .edge_sequences(&ids)
6248 .await?
6249 .into_iter()
6250 .collect::<HashMap<_, _>>();
6251 if let Some(missing) = ids.iter().find(|id| !sequences.contains_key(id)) {
6252 return Err(RuntimeError::Internal(format!(
6253 "edge {missing} has no insertion-sequence ledger row"
6254 )));
6255 }
6256 results.sort_by_key(|edge| {
6257 let id = Uuid::from(edge.id);
6258 (sequences[&id], id)
6259 });
6260 results.dedup_by_key(|e| Uuid::from(e.id));
6261 let has_more = results.len() > limit_usize;
6262 if has_more {
6263 results.truncate(limit_usize);
6264 }
6265 let next_after = if has_more {
6266 results.last().map(|e| Uuid::from(e.id))
6267 } else {
6268 None
6269 };
6270 Ok((results, next_after))
6271 }
6272
6273 pub async fn count_edges_by_relation(
6277 &self,
6278 token: &NamespaceToken,
6279 ) -> RuntimeResult<std::collections::HashMap<String, u64>> {
6280 let namespaces: Vec<String> = token
6281 .visible_namespaces()
6282 .iter()
6283 .map(|namespace| namespace.as_str().to_owned())
6284 .collect();
6285 let graph = self.graph(token)?;
6286 let counts = match graph
6287 .count_edges_by_relation_in_namespaces(&namespaces)
6288 .await
6289 {
6290 Ok(counts) => counts,
6291 Err(khive_storage::StorageError::Unsupported { operation, .. })
6292 if operation == "count_edges_by_relation_in_namespaces" =>
6293 {
6294 let mut totals = HashMap::new();
6295 for namespace in token.visible_namespaces() {
6296 let scoped = NamespaceToken::for_namespace(namespace.clone());
6297 for (relation, count) in self.graph(&scoped)?.count_edges_by_relation().await? {
6298 *totals.entry(relation).or_insert(0) += count;
6299 }
6300 }
6301 return Ok(totals
6302 .into_iter()
6303 .map(|(relation, count)| (relation.to_string(), count))
6304 .collect());
6305 }
6306 Err(error) => return Err(error.into()),
6307 };
6308 Ok(counts
6309 .into_iter()
6310 .map(|(relation, count)| (relation.to_string(), count))
6311 .collect())
6312 }
6313
6314 pub async fn count_edges_by_endpoint_base(
6323 &self,
6324 token: &NamespaceToken,
6325 ) -> RuntimeResult<khive_storage::types::EdgeEndpointBaseCounts> {
6326 use khive_storage::types::EdgeEndpointBaseCounts;
6327
6328 let namespaces: Vec<String> = token
6329 .visible_namespaces()
6330 .iter()
6331 .map(|namespace| namespace.as_str().to_owned())
6332 .collect();
6333 let graph = self.graph(token)?;
6334 match graph
6335 .count_edges_by_endpoint_base_in_namespaces(&namespaces)
6336 .await
6337 {
6338 Ok(counts) => Ok(counts),
6339 Err(khive_storage::StorageError::Unsupported { operation, .. })
6340 if operation == "count_edges_by_endpoint_base_in_namespaces"
6341 || operation == "count_edges_by_endpoint_base" =>
6342 {
6343 let mut totals = EdgeEndpointBaseCounts::default();
6344 for namespace in token.visible_namespaces() {
6345 let scoped = NamespaceToken::for_namespace(namespace.clone());
6346 let counts = self.graph(&scoped)?.count_edges_by_endpoint_base().await?;
6347 totals.entity_entity =
6348 totals.entity_entity.saturating_add(counts.entity_entity);
6349 totals.entity_note = totals.entity_note.saturating_add(counts.entity_note);
6350 totals.note_entity = totals.note_entity.saturating_add(counts.note_entity);
6351 totals.note_note = totals.note_note.saturating_add(counts.note_note);
6352 totals.unresolved = totals.unresolved.saturating_add(counts.unresolved);
6353 }
6354 Ok(totals)
6355 }
6356 Err(error) => Err(error.into()),
6357 }
6358 }
6359
6360 #[allow(clippy::too_many_arguments)]
6372 fn update_edge_symmetric_dml(
6373 conn: &rusqlite::Connection,
6374 ns: &str,
6375 edge_id_str: &str,
6376 canon_src_str: &str,
6377 canon_tgt_str: &str,
6378 relation_str: &str,
6379 weight: f64,
6380 metadata: Option<String>,
6381 expected_updated_at_micros: i64,
6382 expected_deleted_at_micros: Option<i64>,
6383 ) -> Result<SymmetricEdgeUpdateOutcome, SqliteError> {
6384 let minimum_updated_at_micros =
6398 expected_updated_at_micros.checked_add(1).ok_or_else(|| {
6399 SqliteError::InvalidData(format!(
6400 "update_edge: edge {edge_id_str} updated_at is already at i64::MAX \
6401 and cannot advance"
6402 ))
6403 })?;
6404 let now_ts = chrono::Utc::now()
6405 .timestamp_micros()
6406 .max(minimum_updated_at_micros);
6407
6408 let conflict_id: Option<String> = conn
6411 .query_row(
6412 khive_db::stores::graph::EDGE_SYMMETRIC_CONFLICT_PROBE_SQL,
6413 rusqlite::params![
6414 &ns,
6415 &canon_src_str,
6416 &canon_tgt_str,
6417 &relation_str,
6418 &edge_id_str
6419 ],
6420 |row| row.get(0),
6421 )
6422 .optional()
6423 .map_err(SqliteError::Rusqlite)?;
6424
6425 if let Some(existing_id) = conflict_id {
6426 let affected = conn
6445 .execute(
6446 khive_db::stores::graph::EDGE_SYMMETRIC_DELETE_NONCANONICAL_GUARDED_SQL,
6447 rusqlite::params![
6448 &ns,
6449 &edge_id_str,
6450 expected_updated_at_micros,
6451 expected_deleted_at_micros,
6452 ],
6453 )
6454 .map_err(SqliteError::Rusqlite)?;
6455 if affected == 0 {
6456 return Ok(SymmetricEdgeUpdateOutcome::Stale);
6457 }
6458 Ok(SymmetricEdgeUpdateOutcome::Absorbed(existing_id))
6459 } else {
6460 let affected = conn
6466 .execute(
6467 khive_db::stores::graph::EDGE_SYMMETRIC_UPDATE_INPLACE_SQL,
6468 rusqlite::params![
6469 &canon_src_str,
6470 &canon_tgt_str,
6471 &relation_str,
6472 weight,
6473 now_ts,
6474 metadata,
6475 &ns,
6476 &edge_id_str,
6477 expected_updated_at_micros,
6478 expected_deleted_at_micros,
6479 ],
6480 )
6481 .map_err(SqliteError::Rusqlite)?;
6482 if affected == 0 {
6483 return Ok(SymmetricEdgeUpdateOutcome::Stale);
6484 }
6485 Ok(SymmetricEdgeUpdateOutcome::Updated)
6486 }
6487 }
6488
6489 pub async fn update_edge(
6503 &self,
6504 token: &NamespaceToken,
6505 edge_id: Uuid,
6506 patch: crate::curation::EdgePatch,
6507 ) -> RuntimeResult<Edge> {
6508 let graph_for_fetch = self.graph(token)?;
6511 let mut edge = graph_for_fetch
6512 .get_edge(LinkId::from(edge_id))
6513 .await?
6514 .ok_or_else(|| crate::RuntimeError::NotFound(format!("edge {edge_id}")))?;
6515 let expected_updated_at = edge.updated_at;
6516 let expected_deleted_at = edge.deleted_at;
6517 #[cfg(test)]
6518 crate::curation::race_seam::pause_after_read().await;
6519
6520 let record_ns: String = edge.namespace.clone();
6525 let record_tok = token.with_namespace(
6526 khive_types::Namespace::parse(&record_ns)
6527 .map_err(|e| RuntimeError::Internal(format!("edge namespace invalid: {e}")))?,
6528 );
6529 let graph = self.graph(&record_tok)?;
6530
6531 let mut changed_fields: Vec<&'static str> = Vec::new();
6532 if let Some(r) = patch.relation {
6533 self.validate_edge_relation_endpoints(&record_tok, edge.source_id, edge.target_id, r)
6536 .await?;
6537 edge.relation = r;
6538 changed_fields.push("relation");
6539 }
6540 if let Some(w) = patch.weight {
6541 if !w.is_finite() || !(0.0..=1.0).contains(&w) {
6544 return Err(RuntimeError::InvalidInput(format!(
6545 "edge weight must be a finite value in [0.0, 1.0]; got {w}"
6546 )));
6547 }
6548 edge.weight = w;
6549 changed_fields.push("weight");
6550 }
6551 if let Some(props) = patch.properties {
6552 crate::secret_gate::reject_reserved_secret_gate_property(Some(&props))?;
6553 edge.metadata = Some(props);
6554 }
6555
6556 let (canon_src, canon_tgt) =
6566 canonical_edge_endpoints(edge.relation, edge.source_id, edge.target_id);
6567
6568 if edge.relation.is_symmetric() {
6569 let ns = record_ns.clone();
6573 let edge_id_str = edge_id.to_string();
6574 let relation_str = edge.relation.to_string();
6575 let canon_src_str = canon_src.to_string();
6576 let canon_tgt_str = canon_tgt.to_string();
6577 let weight = edge.weight;
6578 let metadata = edge
6579 .metadata
6580 .as_ref()
6581 .map(|v| serde_json::to_string(v).unwrap_or_default());
6582
6583 let expected_updated_at_micros = expected_updated_at.timestamp_micros();
6584 let expected_deleted_at_micros = expected_deleted_at.map(|v| v.timestamp_micros());
6585
6586 let pool = self.backend().pool_arc();
6587 let writer_task = pool
6588 .writer_task_for_runtime_write(RuntimeWriteOperation::UpdateSymmetricEdge)
6589 .map_err(RuntimeError::Storage)?;
6590
6591 let outcome: SymmetricEdgeUpdateOutcome = if let Some(writer_task) = writer_task {
6592 writer_task
6593 .send(move |conn| {
6594 Self::update_edge_symmetric_dml(
6595 conn,
6596 &ns,
6597 &edge_id_str,
6598 &canon_src_str,
6599 &canon_tgt_str,
6600 &relation_str,
6601 weight,
6602 metadata,
6603 expected_updated_at_micros,
6604 expected_deleted_at_micros,
6605 )
6606 .map_err(|e| {
6607 khive_storage::StorageError::driver(
6608 khive_storage::StorageCapability::Graph,
6609 "update_edge",
6610 e,
6611 )
6612 })
6613 })
6614 .await
6615 .map_err(RuntimeError::Storage)?
6616 } else {
6617 tokio::task::spawn_blocking(move || {
6618 let guard = pool.writer()?;
6619 guard.transaction(|conn| {
6620 Self::update_edge_symmetric_dml(
6621 conn,
6622 &ns,
6623 &edge_id_str,
6624 &canon_src_str,
6625 &canon_tgt_str,
6626 &relation_str,
6627 weight,
6628 metadata,
6629 expected_updated_at_micros,
6630 expected_deleted_at_micros,
6631 )
6632 })
6633 })
6634 .await
6635 .map_err(|e| {
6636 RuntimeError::Internal(format!("update_edge: spawn_blocking join: {e}"))
6637 })?
6638 .map_err(RuntimeError::Sqlite)?
6639 };
6640
6641 match outcome {
6642 SymmetricEdgeUpdateOutcome::Absorbed(sid) => {
6643 let surviving_uuid = Uuid::parse_str(&sid).map_err(|e| {
6650 RuntimeError::Internal(format!(
6651 "update_edge: surviving id parse failed: {e}"
6652 ))
6653 })?;
6654 edge = self
6655 .get_edge_including_deleted(&record_tok, surviving_uuid)
6656 .await?
6657 .ok_or_else(|| {
6658 RuntimeError::Internal(format!(
6659 "update_edge: surviving canonical row {surviving_uuid} vanished after update"
6660 ))
6661 })?;
6662 }
6663 SymmetricEdgeUpdateOutcome::Updated => {
6664 edge.source_id = canon_src;
6666 edge.target_id = canon_tgt;
6667 }
6668 SymmetricEdgeUpdateOutcome::Stale => {
6669 return Err(crate::curation::stale_edge_snapshot_error(edge_id));
6670 }
6671 }
6672 } else {
6673 let minimum_updated_at_micros = expected_updated_at
6686 .timestamp_micros()
6687 .checked_add(1)
6688 .ok_or_else(|| {
6689 RuntimeError::Internal(format!(
6690 "edge {edge_id} updated_at is already at i64::MAX and cannot advance"
6691 ))
6692 })?;
6693 let now_micros = chrono::Utc::now()
6694 .timestamp_micros()
6695 .max(minimum_updated_at_micros);
6696 edge.updated_at =
6697 chrono::DateTime::from_timestamp_micros(now_micros).ok_or_else(|| {
6698 RuntimeError::Internal(format!(
6699 "edge {edge_id}: computed updated_at {now_micros} is not a valid timestamp"
6700 ))
6701 })?;
6702 let persisted = graph
6703 .replace_edge_if_unchanged(edge.clone(), expected_updated_at, expected_deleted_at)
6704 .await?;
6705 if !persisted {
6706 return Err(crate::curation::stale_edge_snapshot_error(edge_id));
6707 }
6708 }
6709
6710 let event_store = self.events(&record_tok)?;
6712 let event = khive_storage::event::Event::new(
6713 record_ns.clone(),
6714 "update",
6715 EventKind::EdgeUpdated,
6716 SubstrateKind::Entity,
6717 "",
6718 )
6719 .with_target(edge_id)
6720 .with_payload(
6721 serde_json::json!({"id": edge_id, "namespace": record_ns, "changed_fields": changed_fields}),
6722 );
6723 event_store.append_event(event).await.map_err(|e| {
6724 RuntimeError::Internal(format!("update_edge: event store write failed: {e}"))
6725 })?;
6726
6727 Ok(edge)
6728 }
6729
6730 pub async fn delete_edge(
6741 &self,
6742 token: &NamespaceToken,
6743 edge_id: Uuid,
6744 hard: bool,
6745 ) -> RuntimeResult<bool> {
6746 let mode = if hard {
6747 DeleteMode::Hard
6748 } else {
6749 DeleteMode::Soft
6750 };
6751
6752 let edge = if hard {
6758 self.get_edge_including_deleted(token, edge_id).await?
6759 } else {
6760 self.get_edge(token, edge_id).await?
6761 };
6762 let Some(edge) = edge else {
6763 return Ok(false);
6764 };
6765
6766 let record_ns: String = edge.namespace.clone();
6768 let record_tok = token.with_namespace(
6769 khive_types::Namespace::parse(&record_ns)
6770 .map_err(|e| RuntimeError::Internal(format!("edge namespace invalid: {e}")))?,
6771 );
6772 let graph = self.graph(&record_tok)?;
6773 let actor = format!("{}:{}", token.actor().kind, token.actor().id);
6774
6775 let deleted = if hard {
6782 self.atomic_hard_delete_with_edge_purge(
6783 edge_hard_delete_statement(edge_id),
6784 edge_id,
6785 &record_ns,
6786 &actor,
6787 SubstrateKind::Entity,
6788 )
6789 .await?
6790 } else {
6791 graph.delete_edge(LinkId::from(edge_id), mode).await?
6792 };
6793 if deleted {
6794 let event_store = self.events(&record_tok)?;
6796 let event = khive_storage::event::Event::new(
6797 record_ns.clone(),
6798 "delete",
6799 EventKind::EdgeDeleted,
6800 SubstrateKind::Entity,
6801 "",
6802 )
6803 .with_target(edge_id)
6804 .with_payload(serde_json::json!({"id": edge_id, "namespace": record_ns, "hard": hard}));
6805 event_store.append_event(event).await.map_err(|e| {
6806 RuntimeError::Internal(format!("delete_edge: event store write failed: {e}"))
6807 })?;
6808 }
6809 Ok(deleted)
6810 }
6811
6812 pub async fn count_edges(
6814 &self,
6815 token: &NamespaceToken,
6816 filter: crate::curation::EdgeListFilter,
6817 ) -> RuntimeResult<u64> {
6818 let namespaces: Vec<String> = token
6819 .visible_namespaces()
6820 .iter()
6821 .map(|namespace| namespace.as_str().to_owned())
6822 .collect();
6823 let graph = self.graph(token)?;
6824 match graph
6825 .count_edges_in_namespaces(&namespaces, filter.clone().into())
6826 .await
6827 {
6828 Ok(count) => Ok(count),
6829 Err(khive_storage::StorageError::Unsupported { operation, .. })
6830 if operation == "count_edges_in_namespaces" =>
6831 {
6832 let mut total = 0;
6833 for namespace in token.visible_namespaces() {
6834 let scoped = NamespaceToken::for_namespace(namespace.clone());
6835 total += self
6836 .graph(&scoped)?
6837 .count_edges(filter.clone().into())
6838 .await?;
6839 }
6840 Ok(total)
6841 }
6842 Err(error) => Err(error.into()),
6843 }
6844 }
6845
6846 pub async fn build_edge(&self, token: &NamespaceToken, spec: &LinkSpec) -> RuntimeResult<Edge> {
6857 let ns_str = match &spec.namespace {
6858 Some(s) => {
6859 let spec_ns = crate::Namespace::parse(s)
6860 .map_err(|e| RuntimeError::InvalidInput(format!("invalid namespace: {e}")))?;
6861 if &spec_ns != token.namespace() {
6862 return Err(RuntimeError::InvalidInput(
6863 "LinkSpec namespace does not match token namespace".into(),
6864 ));
6865 }
6866 s.as_str()
6867 }
6868 None => token.namespace().as_str(),
6869 };
6870 self.validate_edge_relation_endpoints(token, spec.source_id, spec.target_id, spec.relation)
6871 .await?;
6872 let (source_id, target_id) =
6873 canonical_edge_endpoints(spec.relation, spec.source_id, spec.target_id);
6874 let metadata = if spec.relation == EdgeRelation::DependsOn {
6875 match (
6880 self.resolve_edge_endpoint(token, source_id).await?,
6881 self.resolve_edge_endpoint(token, target_id).await?,
6882 ) {
6883 (Some(Resolved::Entity(src_e)), Some(Resolved::Entity(tgt_e))) => {
6884 merge_dependency_kind(&src_e.kind, &tgt_e.kind, spec.metadata.clone())
6885 }
6886 _ => spec.metadata.clone(),
6887 }
6888 } else {
6889 spec.metadata.clone()
6890 };
6891 validate_edge_metadata(spec.relation, metadata.as_ref())?;
6892 let now = chrono::Utc::now();
6893 Ok(Edge {
6894 id: LinkId::from(Uuid::new_v4()),
6895 namespace: ns_str.to_string(),
6896 source_id,
6897 target_id,
6898 relation: spec.relation,
6899 weight: spec.weight,
6900 created_at: now,
6901 updated_at: now,
6902 deleted_at: None,
6903 metadata,
6904 target_backend: None,
6905 })
6906 }
6907
6908 pub async fn link_many(
6925 &self,
6926 token: &NamespaceToken,
6927 specs: Vec<LinkSpec>,
6928 ) -> RuntimeResult<Vec<Edge>> {
6929 self.link_many_observed(token, specs)
6930 .await
6931 .map(|rows| rows.into_iter().map(|row| row.edge).collect())
6932 }
6933
6934 pub async fn link_many_observed(
6938 &self,
6939 token: &NamespaceToken,
6940 specs: Vec<LinkSpec>,
6941 ) -> RuntimeResult<Vec<EdgeUpsertResult>> {
6942 if specs.is_empty() {
6943 return Ok(vec![]);
6944 }
6945 let mut edges = Vec::with_capacity(specs.len());
6946 for spec in &specs {
6947 edges.push(self.build_edge(token, spec).await?);
6948 }
6949 let requests = edges
6958 .into_iter()
6959 .zip(specs.iter())
6960 .map(|(edge, spec)| EdgeUpsertRequest {
6961 edge,
6962 resurrect: spec.resurrect,
6963 })
6964 .collect();
6965 let outcome = self
6966 .graph(token)?
6967 .upsert_edges_guarded_observed(requests)
6968 .await?;
6969 if let Some(refusal) = outcome.refusal {
6970 return match refusal.reason {
6971 EdgeUpsertRefusal::MissingEndpoints(missing) => {
6972 Err(RuntimeError::GuardedWriteFailed(guarded_link_batch_failure(
6973 &specs[refusal.entry_index],
6974 refusal.entry_index,
6975 missing,
6976 )))
6977 }
6978 EdgeUpsertRefusal::ResurrectionRequired { edge } => {
6979 Err(RuntimeError::InvalidInput(format!(
6980 "batch entry {} targets soft-deleted edge {}; pass resurrect=true for that link",
6981 refusal.entry_index, edge.id
6982 )))
6983 }
6984 };
6985 }
6986 for row in &outcome.rows {
6987 self.append_link_mutation_event(token, row).await?;
6988 }
6989 Ok(outcome.rows)
6990 }
6991
6992 pub async fn create_many(
7003 &self,
7004 token: &NamespaceToken,
7005 specs: Vec<EntityCreateSpec>,
7006 ) -> RuntimeResult<Vec<Entity>> {
7007 if specs.is_empty() {
7008 return Ok(vec![]);
7009 }
7010 let ns = token.namespace().as_str();
7011
7012 let mut entities = Vec::with_capacity(specs.len());
7016 for (index, spec) in specs.iter().enumerate() {
7017 entities.push(self.validate_bulk_entity(ns, spec, &format!("entity[{index}]"))?);
7018 }
7019
7020 #[cfg(any(test, feature = "fault-injection"))]
7021 let fts_many_inject = consume_fault(&FTS_FAIL_MANY_NS, ns);
7022 #[cfg(not(any(test, feature = "fault-injection")))]
7023 let fts_many_inject = false;
7024
7025 #[cfg(any(test, feature = "fault-injection"))]
7026 let fts_many_inject_partial = consume_fault(&FTS_FAIL_MANY_PARTIAL_NS, ns);
7027 #[cfg(not(any(test, feature = "fault-injection")))]
7028 let fts_many_inject_partial = false;
7029
7030 let injected_failure_index = if fts_many_inject {
7031 Some(0)
7032 } else if fts_many_inject_partial {
7033 Some(usize::from(entities.len() > 1))
7034 } else {
7035 None
7036 };
7037
7038 let _ = self.entities(token)?;
7039 let _ = self.text(token)?;
7040
7041 let plans = entities
7042 .iter()
7043 .enumerate()
7044 .map(|(index, entity)| {
7045 let mut plan = bulk_entity_plan(entity);
7046 if injected_failure_index == Some(index) {
7047 plan.statements.truncate(1);
7049 plan.statements.push(PlanStatement {
7050 statement: SqlStatement {
7051 sql:
7052 "INSERT INTO __khive_create_many_injected_failure__ DEFAULT VALUES"
7053 .to_string(),
7054 params: vec![],
7055 label: Some("fts-insert-injected-failure".to_string()),
7056 },
7057 guard: None,
7058 });
7059 }
7060 AtomicOpPlan::AddEntity(plan)
7061 })
7062 .collect();
7063
7064 match run_atomic_unit(self.sql().as_ref(), plans).await {
7065 Ok(AtomicRunOutcome::Committed { .. }) => Ok(entities),
7066 Ok(AtomicRunOutcome::RolledBack {
7067 failed_op_index,
7068 failure,
7069 }) => Err(RuntimeError::Internal(format!(
7070 "create_many: atomic batch rolled back at entity index {failed_op_index}: \
7071 {failure:?}"
7072 ))),
7073 Err(e) => Err(RuntimeError::Internal(format!(
7074 "create_many: atomic batch failed: {}",
7075 e.0
7076 ))),
7077 }
7078 }
7079
7080 fn validate_bulk_entity(
7085 &self,
7086 ns: &str,
7087 spec: &EntityCreateSpec,
7088 record: &str,
7089 ) -> RuntimeResult<Entity> {
7090 self.validate_entity_kind(&spec.kind)?;
7091 let validated_type =
7096 self.validate_entity_type_for_kind(&spec.kind, spec.entity_type.as_deref())?;
7097 if spec.name.trim().is_empty() {
7098 return Err(RuntimeError::InvalidInput("name must not be empty".into()));
7099 }
7100 crate::secret_gate::reject_reserved_secret_gate_property(spec.properties.as_ref())?;
7101 crate::secret_gate::check_at(&spec.name, record, "name")?;
7102 if let Some(d) = &spec.description {
7103 crate::secret_gate::check_at(d, record, "description")?;
7104 }
7105 if let Some(ref p) = spec.properties {
7106 crate::secret_gate::check_json_at(p, record, "properties")?;
7107 }
7108 crate::secret_gate::check_tags_at(&spec.tags, record, "tags")?;
7109
7110 let mut entity =
7111 Entity::new(ns, &spec.kind, &spec.name).with_entity_type(validated_type.as_deref());
7112 if let Some(d) = &spec.description {
7113 entity = entity.with_description(d);
7114 }
7115 if let Some(p) = spec.properties.clone() {
7116 entity = entity.with_properties(p);
7117 }
7118 if !spec.tags.is_empty() {
7119 entity = entity.with_tags(spec.tags.clone());
7120 }
7121 Ok(entity)
7122 }
7123
7124 pub async fn prepare_bulk_entity_plan(
7131 &self,
7132 token: &NamespaceToken,
7133 spec: EntityCreateSpec,
7134 ) -> RuntimeResult<(Entity, AtomicOpPlan)> {
7135 let entity = self.validate_bulk_entity(token.namespace().as_str(), &spec, "entity")?;
7136 let _ = self.entities(token)?;
7137 let _ = self.text(token)?;
7138
7139 let plan = AtomicOpPlan::AddEntity(bulk_entity_plan(&entity));
7140 Ok((entity, plan))
7141 }
7142
7143 pub async fn prepare_bulk_note_plan(
7152 &self,
7153 token: &NamespaceToken,
7154 spec: NoteCreateSpec,
7155 ) -> RuntimeResult<(Note, AtomicOpPlan)> {
7156 let mut candidate = Note::new(token.namespace().as_str(), &spec.kind, &spec.content);
7157 candidate.name = spec.name.clone();
7158 candidate.properties = spec.properties.clone();
7159 crate::note_write::validate_head(&candidate)?;
7160 let mut prepared = crate::atomic_message::prepare_atomic_notes(
7161 self,
7162 vec![crate::atomic_message::AtomicNoteSpec {
7163 token,
7164 id: None,
7165 kind: &spec.kind,
7166 name: spec.name.as_deref(),
7167 content: &spec.content,
7168 properties: spec.properties,
7169 }],
7170 crate::atomic_message::AtomicNoteOptions {
7171 salience: spec.salience,
7172 embed: Some(false),
7173 ..Default::default()
7174 },
7175 )
7176 .await?;
7177 match (prepared.notes.pop(), prepared.plans.pop()) {
7178 (Some(note), Some(plan)) if prepared.notes.is_empty() && prepared.plans.is_empty() => {
7179 Ok((note, plan))
7180 }
7181 _ => Err(RuntimeError::Internal(
7182 "bulk note preparation must yield exactly one note and one plan".into(),
7183 )),
7184 }
7185 }
7186}
7187
7188#[derive(Clone, Debug)]
7193pub struct NoteCreateSpec {
7194 pub kind: String,
7195 pub name: Option<String>,
7196 pub content: String,
7197 pub salience: Option<f64>,
7198 pub properties: Option<serde_json::Value>,
7199}
7200
7201fn bulk_entity_plan(entity: &Entity) -> AddEntityPlan {
7202 let mut statements = vec![PlanStatement {
7203 statement: entity_upsert_statement(entity),
7204 guard: Some(AffectedRowGuard::exactly(1)),
7205 }];
7206 statements.extend(
7208 insert_document_statements("fts_entities", &entity_fts_document(entity))
7209 .into_iter()
7210 .map(|statement| PlanStatement {
7211 statement,
7212 guard: None,
7213 }),
7214 );
7215 AddEntityPlan {
7216 entity_id: entity.id,
7217 statements,
7218 post_commit: PostCommitEffect::None,
7219 }
7220}
7221
7222fn guarded_link_batch_failure(
7223 spec: &LinkSpec,
7224 entry_index: usize,
7225 missing: khive_storage::MissingEndpoints,
7226) -> GuardedWriteFailure {
7227 let (source_id, target_id) =
7230 canonical_edge_endpoints(spec.relation, spec.source_id, spec.target_id);
7231 GuardedWriteFailure {
7232 entry_index: Some(entry_index),
7233 missing_source: missing.source.then_some(source_id),
7234 missing_target: missing.target.then_some(target_id),
7235 }
7236}
7237
7238#[derive(Clone, Debug)]
7241pub struct LinkSpec {
7242 pub namespace: Option<String>,
7243 pub source_id: Uuid,
7244 pub target_id: Uuid,
7245 pub relation: EdgeRelation,
7246 pub weight: f64,
7247 pub metadata: Option<serde_json::Value>,
7248 pub resurrect: bool,
7249}
7250
7251#[derive(Clone, Debug)]
7259pub struct EntityCreateSpec {
7260 pub kind: String,
7261 pub entity_type: Option<String>,
7262 pub name: String,
7263 pub description: Option<String>,
7264 pub properties: Option<serde_json::Value>,
7265 pub tags: Vec<String>,
7266}
7267
7268#[cfg(test)]
7274mod tests {
7275 use super::*;
7276 use crate::curation::EdgeListFilter;
7277 use crate::embedder_registry::{BlockingEmbeddingService, EmbedderProvider};
7278 use crate::error::RuntimeError;
7279 use crate::runtime::{KhiveRuntime, NamespaceToken};
7280 use crate::{ActorRef, Namespace};
7281 use async_trait::async_trait;
7282 use khive_storage::types::{PathNode, SqlValue};
7283 use lattice_embed::{EmbedError, EmbeddingModel, EmbeddingService, MAX_TEXT_BYTES};
7284 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
7285 use std::sync::Arc;
7286
7287 fn rt() -> KhiveRuntime {
7288 KhiveRuntime::memory().unwrap()
7289 }
7290
7291 #[test]
7292 fn dependency_kind_inference_preserves_unmatched_metadata() {
7293 let metadata = serde_json::json!({"note": "caller supplied"});
7294 assert_eq!(
7295 merge_dependency_kind("concept", "concept", Some(metadata.clone())),
7296 Some(metadata)
7297 );
7298 assert_eq!(merge_dependency_kind("concept", "concept", None), None);
7299 }
7300
7301 #[test]
7302 fn salience_rank_uses_fixed_point_at_q32_boundaries() {
7303 for (input, expected) in [(6, 4), (-6, -4), (1, 0), (-1, 0)] {
7304 for salience in [Some(0.5), None] {
7305 assert_eq!(
7306 salience_weighted_rank(DeterministicScore::from_raw(input), salience).to_raw(),
7307 expected
7308 );
7309 }
7310 }
7311 let one = DeterministicScore::from_raw(1_i64 << 32);
7312 for (salience, expected) in [
7313 (1.0 / 4_294_967_296.0, 2_147_483_648),
7314 (3.0 / 4_294_967_296.0, 2_147_483_649),
7315 ] {
7316 assert_eq!(
7317 salience_weighted_rank(one, Some(salience)).to_raw(),
7318 expected
7319 );
7320 }
7321 }
7322
7323 #[test]
7324 fn salience_rank_preserves_identity_and_saturates() {
7325 let exact = DeterministicScore::from_raw((1_i64 << 53) + 1);
7326 assert_eq!(salience_weighted_rank(exact, Some(1.0)), exact);
7327 assert_eq!(
7328 salience_weighted_rank(DeterministicScore::from_raw(6), Some(0.0)).to_raw(),
7329 3
7330 );
7331 assert_eq!(
7332 salience_weighted_rank(DeterministicScore::MAX, Some(3.0)),
7333 DeterministicScore::MAX
7334 );
7335 assert_eq!(
7336 salience_weighted_rank(DeterministicScore::NEG_INF, Some(3.0)),
7337 DeterministicScore::NEG_INF
7338 );
7339 assert_eq!(
7340 salience_weighted_rank(DeterministicScore::ZERO, None),
7341 DeterministicScore::ZERO
7342 );
7343 }
7344
7345 #[tokio::test]
7346 async fn list_composed_type_filters_apply_alias_and_disjoint_sets_before_pagination() {
7347 let runtime = rt();
7348 let token = runtime.authorize(Namespace::local()).unwrap();
7349 let store = runtime.entities(&token).unwrap();
7350 let mut expected = Vec::new();
7351 for (kind, column, property, matches) in [
7352 ("document", Some("paper"), "ignored", true),
7353 ("document", None, "preprint", true),
7354 ("document", Some("preprint"), "ignored", true),
7355 ("document", Some("report"), "preprint", false),
7356 ("concept", None, "preprint", false),
7357 ] {
7358 let row = Entity::new("local", kind, "type predicate")
7359 .with_entity_type(column)
7360 .with_properties(serde_json::json!({"type": property}));
7361 if matches {
7362 expected.push(row.id);
7363 }
7364 store.upsert_entity(row).await.unwrap();
7365 }
7366 store
7367 .upsert_entity(
7368 Entity::new("foreign", "document", "foreign alias")
7369 .with_properties(serde_json::json!({"type":"preprint"})),
7370 )
7371 .await
7372 .unwrap();
7373 for (values, should_match) in [
7374 (vec!["paper".to_string(), "preprint".to_string()], true),
7375 (vec!["absent".to_string()], false),
7376 (Vec::new(), false),
7377 ] {
7378 let filter = EntityFilter {
7379 entity_types_by_kind: [("document".to_string(), values)].into_iter().collect(),
7380 legacy_entity_type_fallback: true,
7381 namespaces: vec!["foreign".to_string()], ..Default::default()
7383 };
7384 let mut offset_ids = Vec::new();
7385 for offset in 0..=expected.len() {
7386 let page = runtime
7387 .list_entities_filtered(&token, filter.clone(), 1, offset as u32)
7388 .await
7389 .unwrap();
7390 offset_ids.extend(page.into_iter().map(|row| row.id));
7391 }
7392 let mut cursor_ids = Vec::new();
7393 let mut after = None;
7394 for _ in 0..=expected.len() {
7395 let (page, next) = runtime
7396 .list_entities_after_filtered(&token, filter.clone(), after, 1)
7397 .await
7398 .unwrap();
7399 cursor_ids.extend(page.into_iter().map(|row| row.id));
7400 after = next;
7401 if after.is_none() {
7402 break;
7403 }
7404 }
7405 let mut wanted = if should_match {
7406 expected.clone()
7407 } else {
7408 Vec::new()
7409 };
7410 wanted.sort_unstable();
7411 offset_ids.sort_unstable();
7412 cursor_ids.sort_unstable();
7413 assert_eq!(offset_ids, wanted);
7414 assert_eq!(cursor_ids, wanted);
7415 }
7416 assert_eq!(
7418 runtime
7419 .list_entities(&token, None, Some("paper"), 20, 0)
7420 .await
7421 .unwrap()
7422 .len(),
7423 1
7424 );
7425 assert_eq!(
7426 runtime
7427 .list_entities_after(&token, None, Some("paper"), &[], None, 20)
7428 .await
7429 .unwrap()
7430 .0
7431 .len(),
7432 1
7433 );
7434 }
7435
7436 #[tokio::test]
7437 async fn resolve_prefix_query_plan_uses_primary_key_range_seeks() {
7438 let rt = rt();
7439 let mut reader = rt.sql().reader().await.expect("prefix plan reader");
7440 let mut plans = Vec::new();
7441 let (lower, upper) =
7442 super::uuid_prefix_bounds("0b6cf134").expect("valid compact UUID prefix");
7443
7444 for (table, has_deleted_at) in [
7445 ("entities", true),
7446 ("notes", true),
7447 ("events", false),
7448 ("graph_edges", false),
7449 ] {
7450 let rows = reader
7451 .explain(super::resolve_prefix_statement(
7452 table,
7453 has_deleted_at,
7454 false,
7455 None,
7456 &lower,
7457 &upper,
7458 ))
7459 .await
7460 .expect("explain prefix query");
7461 let details: Vec<String> = rows
7462 .iter()
7463 .filter_map(|row| match row.get("detail") {
7464 Some(SqlValue::Text(detail)) => Some(detail.clone()),
7465 _ => None,
7466 })
7467 .collect();
7468 plans.push((table, details));
7469 }
7470
7471 assert!(
7472 plans.iter().all(|(table, details)| {
7473 details.iter().any(|detail| {
7474 detail.contains(&format!("SEARCH {table}"))
7475 && detail.contains("id>?")
7476 && detail.contains("id<?")
7477 }) && !details
7478 .iter()
7479 .any(|detail| detail.contains(&format!("SCAN {table}")))
7480 }),
7481 "every short-id table must use a primary-key range seek: {plans:?}"
7482 );
7483 }
7484
7485 #[test]
7486 fn fts_fault_arm_disarms_namespace_when_scope_panics() {
7487 let ns = format!("fault-arm-drop-{}", uuid::Uuid::new_v4().as_simple());
7488
7489 let panic_result = std::panic::catch_unwind(|| {
7490 let _arm = arm_fts_fail_scoped(&ns);
7491 panic!("leave the armed scope before consumption");
7492 });
7493
7494 assert!(panic_result.is_err());
7495 assert!(
7496 !consume_fault(&FTS_FAIL_NS, &ns),
7497 "unwinding an armed scope must remove its namespace"
7498 );
7499 }
7500
7501 #[test]
7502 fn fault_arm_set_rejects_entries_over_capacity() {
7503 static BOUNDED_ARMS: std::sync::LazyLock<FaultArmSet> =
7504 std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
7505 let first_ns = format!("fault-arm-bound-a-{}", uuid::Uuid::new_v4().as_simple());
7506 let overflow_ns = format!("fault-arm-bound-b-{}", uuid::Uuid::new_v4().as_simple());
7507 let arm = arm_fault(&BOUNDED_ARMS, &first_ns, 1);
7508
7509 let overflow = std::panic::catch_unwind(|| arm_fault(&BOUNDED_ARMS, &overflow_ns, 1));
7510
7511 assert!(
7512 overflow.is_err(),
7513 "an arm set must reject entries over its bound"
7514 );
7515 drop(arm);
7516 assert!(BOUNDED_ARMS.lock().unwrap().is_empty());
7517 }
7518
7519 struct ConstVecService {
7527 dims: usize,
7528 }
7529
7530 #[async_trait]
7531 impl EmbeddingService for ConstVecService {
7532 async fn embed(
7533 &self,
7534 texts: &[String],
7535 _model: EmbeddingModel,
7536 ) -> std::result::Result<Vec<Vec<f32>>, EmbedError> {
7537 Ok(texts.iter().map(|_| vec![1.0_f32; self.dims]).collect())
7538 }
7539
7540 fn supports_model(&self, _model: EmbeddingModel) -> bool {
7541 true
7542 }
7543
7544 fn name(&self) -> &'static str {
7545 "const-vec"
7546 }
7547 }
7548
7549 struct ConstVecProvider {
7550 provider_name: String,
7551 dims: usize,
7552 pub build_count: Arc<AtomicUsize>,
7553 }
7554
7555 impl ConstVecProvider {
7556 fn new(name: &str, dims: usize) -> (Self, Arc<AtomicUsize>) {
7557 let counter = Arc::new(AtomicUsize::new(0));
7558 let provider = Self {
7559 provider_name: name.to_owned(),
7560 dims,
7561 build_count: Arc::clone(&counter),
7562 };
7563 (provider, counter)
7564 }
7565 }
7566
7567 #[async_trait]
7568 impl EmbedderProvider for ConstVecProvider {
7569 fn name(&self) -> &str {
7570 &self.provider_name
7571 }
7572
7573 fn dimensions(&self) -> usize {
7574 self.dims
7575 }
7576
7577 async fn build(&self) -> crate::error::RuntimeResult<Arc<dyn EmbeddingService>> {
7578 self.build_count.fetch_add(1, Ordering::SeqCst);
7579 Ok(Arc::new(ConstVecService { dims: self.dims }))
7580 }
7581 }
7582
7583 struct SlowVecService {
7588 dims: usize,
7589 }
7590
7591 #[async_trait]
7592 impl EmbeddingService for SlowVecService {
7593 async fn embed(
7594 &self,
7595 texts: &[String],
7596 _model: EmbeddingModel,
7597 ) -> std::result::Result<Vec<Vec<f32>>, EmbedError> {
7598 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7599 Ok(texts.iter().map(|_| vec![1.0_f32; self.dims]).collect())
7600 }
7601
7602 fn supports_model(&self, _model: EmbeddingModel) -> bool {
7603 true
7604 }
7605
7606 fn name(&self) -> &'static str {
7607 "slow-vec"
7608 }
7609 }
7610
7611 struct SlowVecProvider {
7612 provider_name: String,
7613 dims: usize,
7614 }
7615
7616 impl SlowVecProvider {
7617 fn new(name: &str, dims: usize) -> Self {
7618 Self {
7619 provider_name: name.to_owned(),
7620 dims,
7621 }
7622 }
7623 }
7624
7625 #[async_trait]
7626 impl EmbedderProvider for SlowVecProvider {
7627 fn name(&self) -> &str {
7628 &self.provider_name
7629 }
7630
7631 fn dimensions(&self) -> usize {
7632 self.dims
7633 }
7634
7635 async fn build(&self) -> crate::error::RuntimeResult<Arc<dyn EmbeddingService>> {
7636 Ok(Arc::new(SlowVecService { dims: self.dims }))
7637 }
7638 }
7639
7640 struct FailFastProvider {
7643 provider_name: String,
7644 wait_for: Option<Arc<AtomicBool>>,
7645 }
7646
7647 impl FailFastProvider {
7648 fn new(name: &str) -> Self {
7649 Self {
7650 provider_name: name.to_owned(),
7651 wait_for: None,
7652 }
7653 }
7654
7655 fn after_signal(name: &str, wait_for: Arc<AtomicBool>) -> Self {
7656 Self {
7657 provider_name: name.to_owned(),
7658 wait_for: Some(wait_for),
7659 }
7660 }
7661 }
7662
7663 #[async_trait]
7664 impl EmbedderProvider for FailFastProvider {
7665 fn name(&self) -> &str {
7666 &self.provider_name
7667 }
7668
7669 fn dimensions(&self) -> usize {
7670 4
7671 }
7672
7673 async fn build(&self) -> crate::error::RuntimeResult<Arc<dyn EmbeddingService>> {
7674 Ok(Arc::new(FailFastService {
7675 wait_for: self.wait_for.clone(),
7676 }))
7677 }
7678 }
7679
7680 struct FailFastService {
7681 wait_for: Option<Arc<AtomicBool>>,
7682 }
7683
7684 #[async_trait]
7685 impl EmbeddingService for FailFastService {
7686 async fn embed(
7687 &self,
7688 _texts: &[String],
7689 _model: EmbeddingModel,
7690 ) -> std::result::Result<Vec<Vec<f32>>, EmbedError> {
7691 if let Some(wait_for) = &self.wait_for {
7692 while !wait_for.load(Ordering::Acquire) {
7693 tokio::task::yield_now().await;
7694 }
7695 }
7696 Err(EmbedError::InferenceFailed(
7697 "injected embed failure".to_string(),
7698 ))
7699 }
7700
7701 fn supports_model(&self, _model: EmbeddingModel) -> bool {
7702 true
7703 }
7704
7705 fn name(&self) -> &'static str {
7706 "fail-fast"
7707 }
7708 }
7709
7710 struct BlockingVecProvider {
7714 provider_name: String,
7715 dims: usize,
7716 release: Arc<(std::sync::Mutex<bool>, std::sync::Condvar)>,
7717 entered: Arc<AtomicBool>,
7718 }
7719
7720 struct BlockingVecControls {
7721 release: Arc<(std::sync::Mutex<bool>, std::sync::Condvar)>,
7722 entered: Arc<AtomicBool>,
7723 }
7724
7725 impl BlockingVecProvider {
7726 fn new(name: &str, dims: usize) -> (Self, BlockingVecControls) {
7727 let release = Arc::new((std::sync::Mutex::new(false), std::sync::Condvar::new()));
7728 let entered = Arc::new(AtomicBool::new(false));
7729 (
7730 Self {
7731 provider_name: name.to_owned(),
7732 dims,
7733 release: Arc::clone(&release),
7734 entered: Arc::clone(&entered),
7735 },
7736 BlockingVecControls { release, entered },
7737 )
7738 }
7739 }
7740
7741 #[async_trait]
7742 impl EmbedderProvider for BlockingVecProvider {
7743 fn name(&self) -> &str {
7744 &self.provider_name
7745 }
7746
7747 fn dimensions(&self) -> usize {
7748 self.dims
7749 }
7750
7751 async fn build(&self) -> crate::error::RuntimeResult<Arc<dyn EmbeddingService>> {
7752 let service = Arc::new(BlockingVecService {
7753 dims: self.dims,
7754 release: Arc::clone(&self.release),
7755 entered: Arc::clone(&self.entered),
7756 });
7757 Ok(Arc::new(BlockingEmbeddingService::new(service)))
7758 }
7759 }
7760
7761 struct BlockingVecService {
7762 dims: usize,
7763 release: Arc<(std::sync::Mutex<bool>, std::sync::Condvar)>,
7764 entered: Arc<AtomicBool>,
7765 }
7766
7767 #[async_trait]
7768 impl EmbeddingService for BlockingVecService {
7769 async fn embed(
7770 &self,
7771 texts: &[String],
7772 _model: EmbeddingModel,
7773 ) -> std::result::Result<Vec<Vec<f32>>, EmbedError> {
7774 self.entered.store(true, Ordering::Release);
7775 let (released, wake) = &*self.release;
7776 let guard = released.lock().expect("release lock must not be poisoned");
7777 let _guard = wake
7778 .wait_while(guard, |released| !*released)
7779 .expect("release lock must not be poisoned");
7780 Ok(texts.iter().map(|_| vec![1.0_f32; self.dims]).collect())
7781 }
7782
7783 fn supports_model(&self, _model: EmbeddingModel) -> bool {
7784 true
7785 }
7786
7787 fn name(&self) -> &'static str {
7788 "blocking-vec"
7789 }
7790 }
7791
7792 struct ParkedVecProvider {
7797 provider_name: String,
7798 dims: usize,
7799 release: Arc<tokio::sync::Notify>,
7800 entered: Arc<tokio::sync::Notify>,
7801 }
7802
7803 impl ParkedVecProvider {
7804 fn new(
7805 name: &str,
7806 dims: usize,
7807 ) -> (Self, Arc<tokio::sync::Notify>, Arc<tokio::sync::Notify>) {
7808 let release = Arc::new(tokio::sync::Notify::new());
7809 let entered = Arc::new(tokio::sync::Notify::new());
7810 (
7811 Self {
7812 provider_name: name.to_owned(),
7813 dims,
7814 release: Arc::clone(&release),
7815 entered: Arc::clone(&entered),
7816 },
7817 release,
7818 entered,
7819 )
7820 }
7821 }
7822
7823 #[async_trait]
7824 impl EmbedderProvider for ParkedVecProvider {
7825 fn name(&self) -> &str {
7826 &self.provider_name
7827 }
7828
7829 fn dimensions(&self) -> usize {
7830 self.dims
7831 }
7832
7833 async fn build(&self) -> crate::error::RuntimeResult<Arc<dyn EmbeddingService>> {
7834 Ok(Arc::new(ParkedVecService {
7835 dims: self.dims,
7836 release: Arc::clone(&self.release),
7837 entered: Arc::clone(&self.entered),
7838 }))
7839 }
7840 }
7841
7842 struct ParkedVecService {
7843 dims: usize,
7844 release: Arc<tokio::sync::Notify>,
7845 entered: Arc<tokio::sync::Notify>,
7846 }
7847
7848 #[async_trait]
7849 impl EmbeddingService for ParkedVecService {
7850 async fn embed(
7851 &self,
7852 texts: &[String],
7853 _model: EmbeddingModel,
7854 ) -> std::result::Result<Vec<Vec<f32>>, EmbedError> {
7855 self.entered.notify_one();
7858 self.release.notified().await;
7859 Ok(texts.iter().map(|_| vec![1.0_f32; self.dims]).collect())
7860 }
7861
7862 fn supports_model(&self, _model: EmbeddingModel) -> bool {
7863 true
7864 }
7865
7866 fn name(&self) -> &'static str {
7867 "parked-vec"
7868 }
7869 }
7870
7871 #[tokio::test]
7876 async fn custom_embedder_only_runtime_fanout_stores_vector() {
7877 const MODEL_NAME: &str = "test-custom-encoder";
7878 const DIMS: usize = 8;
7879
7880 let rt = KhiveRuntime::memory().unwrap();
7882
7883 let (provider, _counter) = ConstVecProvider::new(MODEL_NAME, DIMS);
7885 rt.register_embedder(provider);
7886
7887 assert!(rt.config().embedding_model.is_none());
7889 assert_eq!(rt.registered_embedding_model_names(), vec![MODEL_NAME]);
7890
7891 let tok = NamespaceToken::local();
7892
7893 let note = rt
7895 .create_note(
7896 &tok,
7897 "memory",
7898 None,
7899 "custom embedder integration test content",
7900 Some(0.7),
7901 None,
7902 vec![],
7903 )
7904 .await
7905 .expect("create_note with custom-only embedder must succeed");
7906
7907 use khive_storage::types::VectorSearchRequest;
7909 let query_vec = vec![1.0_f32; DIMS];
7910 let hits = rt
7911 .vectors_for_model(&tok, MODEL_NAME)
7912 .expect("vector store for custom model must be accessible")
7913 .search(VectorSearchRequest {
7914 query_vectors: vec![query_vec],
7915 top_k: 5,
7916 namespace: Some(tok.namespace().as_str().to_string()),
7917 kind: Some(khive_types::SubstrateKind::Note),
7918 embedding_model: Some(MODEL_NAME.to_string()),
7919 filter: None,
7920 backend_hints: None,
7921 })
7922 .await
7923 .expect("vector search succeeds");
7924
7925 assert!(
7926 hits.iter().any(|h| h.subject_id == note.id),
7927 "custom embedder must have written a vector for note {}: hits={hits:?}",
7928 note.id
7929 );
7930 }
7931
7932 #[tokio::test]
7937 async fn embed_with_model_accepts_custom_provider_name() {
7938 const MODEL_NAME: &str = "my-custom-enc";
7939 const DIMS: usize = 4;
7940
7941 let rt = KhiveRuntime::memory().unwrap();
7942 let (provider, _counter) = ConstVecProvider::new(MODEL_NAME, DIMS);
7943 rt.register_embedder(provider);
7944
7945 let result = rt
7946 .embed_with_model(MODEL_NAME, "hello world")
7947 .await
7948 .expect("embed_with_model must accept custom provider names");
7949
7950 assert_eq!(
7951 result.len(),
7952 DIMS,
7953 "embedding dimension must match provider"
7954 );
7955 assert!(
7956 result.iter().all(|&v| (v - 1.0_f32).abs() < 1e-6),
7957 "ConstVecService must produce all-ones vector; got: {result:?}"
7958 );
7959 }
7960
7961 #[tokio::test]
7964 async fn embed_with_model_rejects_unregistered_name() {
7965 let rt = KhiveRuntime::memory().unwrap();
7966 let result = rt.embed_with_model("nonexistent-model", "hello").await;
7967 assert!(
7968 matches!(result.unwrap_err(), RuntimeError::UnknownModel(ref n) if n == "nonexistent-model"),
7969 "unregistered model name must return UnknownModel"
7970 );
7971 }
7972
7973 #[tokio::test]
7980 async fn no_embeddings_config_registers_zero_embedders() {
7981 let config = crate::config::RuntimeConfig {
7982 db_path: None,
7983 packs: vec!["kg".to_string()],
7984 ..crate::config::RuntimeConfig::no_embeddings()
7985 };
7986 let rt = KhiveRuntime::new(config).expect("runtime construction must succeed");
7987
7988 assert!(rt.config().embedding_model.is_none());
7989 assert!(
7990 rt.registered_embedding_model_names().is_empty(),
7991 "no_embeddings() runtime must register zero embedders"
7992 );
7993 }
7994
7995 #[tokio::test]
7996 async fn no_embeddings_runtime_create_note_succeeds_without_model_fanout() {
7997 let config = crate::config::RuntimeConfig {
7998 db_path: None,
7999 packs: vec!["kg".to_string()],
8000 ..crate::config::RuntimeConfig::no_embeddings()
8001 };
8002 let rt = KhiveRuntime::new(config).expect("runtime construction must succeed");
8003 let tok = NamespaceToken::local();
8004
8005 let note = rt
8009 .create_note(
8010 &tok,
8011 "memory",
8012 None,
8013 "issue-396 regression: model-less remember must succeed",
8014 Some(0.7),
8015 None,
8016 vec![],
8017 )
8018 .await
8019 .expect("create_note must succeed with zero registered embedders");
8020
8021 assert_eq!(
8022 note.content,
8023 "issue-396 regression: model-less remember must succeed"
8024 );
8025 }
8026
8027 #[tokio::test]
8028 async fn update_edge_changes_weight() {
8029 let rt = rt();
8030 let tok = NamespaceToken::local();
8031 let a = rt
8032 .create_entity(&tok, "concept", None, "A", None, None, vec![])
8033 .await
8034 .unwrap();
8035 let b = rt
8036 .create_entity(&tok, "concept", None, "B", None, None, vec![])
8037 .await
8038 .unwrap();
8039 let edge = rt
8040 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
8041 .await
8042 .unwrap();
8043 let edge_id: Uuid = edge.id.into();
8044
8045 let updated = rt
8046 .update_edge(
8047 &tok,
8048 edge_id,
8049 crate::curation::EdgePatch {
8050 weight: Some(0.5),
8051 ..Default::default()
8052 },
8053 )
8054 .await
8055 .unwrap();
8056 assert!((updated.weight - 0.5).abs() < 0.001);
8057 }
8058
8059 #[tokio::test]
8078 async fn concurrent_edge_patches_from_one_revision_only_one_survives() {
8079 let rt = rt();
8080 let tok = NamespaceToken::local();
8081 let a = rt
8082 .create_entity(&tok, "concept", None, "A", None, None, vec![])
8083 .await
8084 .unwrap();
8085 let b = rt
8086 .create_entity(&tok, "concept", None, "B", None, None, vec![])
8087 .await
8088 .unwrap();
8089 let edge = rt
8090 .link(&tok, a.id, b.id, EdgeRelation::Extends, 0.5, None)
8091 .await
8092 .unwrap();
8093 let edge_id: Uuid = edge.id.into();
8094
8095 let graph = rt.graph(&tok).expect("graph store");
8096 let snapshot_for_a = graph
8097 .get_edge(LinkId::from(edge_id))
8098 .await
8099 .unwrap()
8100 .expect("edge exists");
8101 let snapshot_for_b = graph
8102 .get_edge(LinkId::from(edge_id))
8103 .await
8104 .unwrap()
8105 .expect("edge exists");
8106 assert_eq!(
8107 snapshot_for_a.updated_at, snapshot_for_b.updated_at,
8108 "both readers must observe the same pre-write revision for this to be a real race"
8109 );
8110 let expected_updated_at = snapshot_for_a.updated_at;
8111 let expected_deleted_at = snapshot_for_a.deleted_at;
8112
8113 let mut edge_a = snapshot_for_a;
8114 edge_a.weight = 0.9;
8115 edge_a.updated_at = expected_updated_at + chrono::Duration::microseconds(1);
8116
8117 let mut edge_b = snapshot_for_b;
8118 edge_b.metadata = Some(serde_json::json!({"note": "from B"}));
8119 edge_b.updated_at = expected_updated_at + chrono::Duration::microseconds(1);
8120
8121 assert!(
8122 graph
8123 .replace_edge_if_unchanged(edge_a, expected_updated_at, expected_deleted_at)
8124 .await
8125 .expect("writer A CAS query"),
8126 "the first committer from a shared revision must win"
8127 );
8128 assert!(
8129 !graph
8130 .replace_edge_if_unchanged(edge_b, expected_updated_at, expected_deleted_at)
8131 .await
8132 .expect("writer B CAS query"),
8133 "the second committer from the SAME stale revision must be refused, not merged"
8134 );
8135
8136 let final_edge = graph
8137 .get_edge(LinkId::from(edge_id))
8138 .await
8139 .unwrap()
8140 .expect("edge still exists");
8141 assert!(
8142 (final_edge.weight - 0.9).abs() < 0.001,
8143 "writer A's weight change must survive: {final_edge:?}"
8144 );
8145 assert!(
8146 final_edge.metadata.is_none(),
8147 "writer B's metadata must not be silently merged into the persisted row: {final_edge:?}"
8148 );
8149 }
8150
8151 #[tokio::test]
8162 async fn production_update_edge_non_symmetric_refuses_concurrent_stale_writer() {
8163 let rt = std::sync::Arc::new(rt());
8164 let tok = NamespaceToken::local();
8165 let a = rt
8166 .create_entity(&tok, "concept", None, "A", None, None, vec![])
8167 .await
8168 .unwrap();
8169 let b = rt
8170 .create_entity(&tok, "concept", None, "B", None, None, vec![])
8171 .await
8172 .unwrap();
8173 let edge = rt
8174 .link(&tok, a.id, b.id, EdgeRelation::Extends, 0.5, None)
8175 .await
8176 .unwrap();
8177 let edge_id: Uuid = edge.id.into();
8178
8179 let barrier = std::sync::Arc::new(tokio::sync::Barrier::new(2));
8180
8181 let writer_a = {
8182 let rt = std::sync::Arc::clone(&rt);
8183 let tok = tok.clone();
8184 let barrier = std::sync::Arc::clone(&barrier);
8185 tokio::spawn(crate::curation::race_seam::AFTER_READ_BARRIER.scope(
8186 barrier,
8187 async move {
8188 rt.update_edge(
8189 &tok,
8190 edge_id,
8191 crate::curation::EdgePatch {
8192 weight: Some(0.9),
8193 ..Default::default()
8194 },
8195 )
8196 .await
8197 },
8198 ))
8199 };
8200 let writer_b = {
8201 let rt = std::sync::Arc::clone(&rt);
8202 let tok = tok.clone();
8203 let barrier = std::sync::Arc::clone(&barrier);
8204 tokio::spawn(crate::curation::race_seam::AFTER_READ_BARRIER.scope(
8205 barrier,
8206 async move {
8207 rt.update_edge(
8208 &tok,
8209 edge_id,
8210 crate::curation::EdgePatch {
8211 properties: Some(serde_json::json!({"note": "from B"})),
8212 ..Default::default()
8213 },
8214 )
8215 .await
8216 },
8217 ))
8218 };
8219
8220 let result_a = writer_a.await.expect("writer A task");
8221 let result_b = writer_b.await.expect("writer B task");
8222 let successes = [result_a.is_ok(), result_b.is_ok()]
8223 .into_iter()
8224 .filter(|ok| *ok)
8225 .count();
8226 assert_eq!(
8227 successes, 1,
8228 "exactly one production caller must win the race; the other must be refused: \
8229 a={result_a:?} b={result_b:?}"
8230 );
8231 let refused = if result_a.is_err() {
8232 result_a
8233 } else {
8234 result_b
8235 };
8236 match &refused {
8237 Err(RuntimeError::Khive(khive_error)) => {
8238 assert_eq!(
8239 khive_error.kind(),
8240 khive_types::ErrorKind::Conflict,
8241 "the losing production caller must surface a typed conflict, not \
8242 silently overwrite: {refused:?}"
8243 );
8244 }
8245 other => panic!("expected a typed conflict error, got {other:?}"),
8246 }
8247
8248 let final_edge = rt
8249 .graph(&tok)
8250 .expect("graph store")
8251 .get_edge(LinkId::from(edge_id))
8252 .await
8253 .unwrap()
8254 .expect("edge still exists");
8255 assert!(
8256 !((final_edge.weight - 0.9).abs() < 0.001 && final_edge.metadata.is_some()),
8257 "both racers' changes must never both land: that would mean the loser's stale \
8258 write silently succeeded: {final_edge:?}"
8259 );
8260 }
8261
8262 #[tokio::test]
8271 async fn production_update_edge_symmetric_refuses_concurrent_stale_writer() {
8272 let rt = std::sync::Arc::new(rt());
8273 let tok = NamespaceToken::local();
8274 let a = rt
8275 .create_entity(&tok, "concept", None, "A", None, None, vec![])
8276 .await
8277 .unwrap();
8278 let b = rt
8279 .create_entity(&tok, "concept", None, "B", None, None, vec![])
8280 .await
8281 .unwrap();
8282 let edge = rt
8283 .link(&tok, a.id, b.id, EdgeRelation::CompetesWith, 0.5, None)
8284 .await
8285 .unwrap();
8286 let edge_id: Uuid = edge.id.into();
8287
8288 let barrier = std::sync::Arc::new(tokio::sync::Barrier::new(2));
8289
8290 let writer_a = {
8291 let rt = std::sync::Arc::clone(&rt);
8292 let tok = tok.clone();
8293 let barrier = std::sync::Arc::clone(&barrier);
8294 tokio::spawn(crate::curation::race_seam::AFTER_READ_BARRIER.scope(
8295 barrier,
8296 async move {
8297 rt.update_edge(
8298 &tok,
8299 edge_id,
8300 crate::curation::EdgePatch {
8301 weight: Some(0.9),
8302 ..Default::default()
8303 },
8304 )
8305 .await
8306 },
8307 ))
8308 };
8309 let writer_b = {
8310 let rt = std::sync::Arc::clone(&rt);
8311 let tok = tok.clone();
8312 let barrier = std::sync::Arc::clone(&barrier);
8313 tokio::spawn(crate::curation::race_seam::AFTER_READ_BARRIER.scope(
8314 barrier,
8315 async move {
8316 rt.update_edge(
8317 &tok,
8318 edge_id,
8319 crate::curation::EdgePatch {
8320 weight: Some(0.1),
8321 ..Default::default()
8322 },
8323 )
8324 .await
8325 },
8326 ))
8327 };
8328
8329 let result_a = writer_a.await.expect("writer A task");
8330 let result_b = writer_b.await.expect("writer B task");
8331 let successes = [result_a.is_ok(), result_b.is_ok()]
8332 .into_iter()
8333 .filter(|ok| *ok)
8334 .count();
8335 assert_eq!(
8336 successes, 1,
8337 "exactly one production caller must win the symmetric-edge race; the other must \
8338 be refused: a={result_a:?} b={result_b:?}"
8339 );
8340 let refused = if result_a.is_err() {
8341 result_a
8342 } else {
8343 result_b
8344 };
8345 match &refused {
8346 Err(RuntimeError::Khive(khive_error)) => {
8347 assert_eq!(
8348 khive_error.kind(),
8349 khive_types::ErrorKind::Conflict,
8350 "the losing production caller must surface a typed conflict, not \
8351 silently overwrite: {refused:?}"
8352 );
8353 }
8354 other => panic!("expected a typed conflict error, got {other:?}"),
8355 }
8356 }
8357
8358 #[tokio::test]
8367 async fn update_edge_symmetric_absorption_refuses_stale_snapshot_when_survivor_exists() {
8368 let rt = rt();
8369 let tok = NamespaceToken::local();
8370 let a = rt
8371 .create_entity(&tok, "concept", None, "A", None, None, vec![])
8372 .await
8373 .unwrap();
8374 let b = rt
8375 .create_entity(&tok, "concept", None, "B", None, None, vec![])
8376 .await
8377 .unwrap();
8378
8379 let survivor = rt
8382 .link(&tok, a.id, b.id, EdgeRelation::CompetesWith, 0.6, None)
8383 .await
8384 .unwrap();
8385 let survivor_id: Uuid = survivor.id.into();
8386
8387 let e = rt
8390 .link(&tok, a.id, b.id, EdgeRelation::Extends, 0.2, None)
8391 .await
8392 .unwrap();
8393 let e_id: Uuid = e.id.into();
8394 let stale_updated_at_micros = e.updated_at.timestamp_micros();
8395 let stale_deleted_at_micros = e.deleted_at.map(|t| t.timestamp_micros());
8396
8397 let advanced = rt
8400 .update_edge(
8401 &tok,
8402 e_id,
8403 crate::curation::EdgePatch {
8404 weight: Some(0.77),
8405 ..Default::default()
8406 },
8407 )
8408 .await
8409 .expect("concurrent writer update");
8410 assert_ne!(
8411 advanced.updated_at.timestamp_micros(),
8412 stale_updated_at_micros,
8413 "test setup: the concurrent write must actually advance the revision"
8414 );
8415
8416 assert_eq!(
8421 stale_deleted_at_micros, None,
8422 "fixture premise: the stale snapshot must be of a LIVE edge, otherwise \
8423 `deleted_at IS ?14` would refuse and this stops being an isolating fixture"
8424 );
8425 let survivor_before = rt.get_edge(&tok, survivor_id).await.unwrap().expect(
8426 "fixture premise: the survivor must already own the canonical natural key \
8427 before the stale DML runs",
8428 );
8429 assert_eq!(
8430 survivor_before.relation,
8431 EdgeRelation::CompetesWith,
8432 "fixture premise: the survivor must occupy the canonical natural key this stale \
8433 writer is about to target, otherwise there is no conflict to absorb and the \
8434 refusal would be attributable to something else: {survivor_before:?}"
8435 );
8436 assert_ne!(
8437 survivor_id, e_id,
8438 "fixture premise: the survivor and the stale writer's edge must be distinct rows"
8439 );
8440
8441 let pool = rt.backend().pool_arc();
8444 let guard = pool.writer().expect("writer guard");
8445 let (canon_src, canon_tgt) =
8446 canonical_edge_endpoints(EdgeRelation::CompetesWith, a.id, b.id);
8447 let outcome = guard
8448 .transaction(|conn| {
8449 KhiveRuntime::update_edge_symmetric_dml(
8450 conn,
8451 "local",
8452 &e_id.to_string(),
8453 &canon_src.to_string(),
8454 &canon_tgt.to_string(),
8455 "competes_with",
8456 0.9,
8457 None,
8458 stale_updated_at_micros,
8459 stale_deleted_at_micros,
8460 )
8461 })
8462 .expect("dml call must not error");
8463 drop(guard);
8464 assert!(
8465 matches!(outcome, SymmetricEdgeUpdateOutcome::Stale),
8466 "a stale writer must be refused, not silently absorbed into the survivor: {outcome:?}"
8467 );
8468
8469 let e_after = rt
8470 .get_edge(&tok, e_id)
8471 .await
8472 .unwrap()
8473 .expect("E must still exist after the refused absorption");
8474 assert_eq!(
8475 e_after.relation,
8476 EdgeRelation::Extends,
8477 "E must be untouched by the refused absorption: {e_after:?}"
8478 );
8479 assert!(
8480 (e_after.weight - 0.77).abs() < 1e-9,
8481 "the concurrent writer's weight must survive the refused absorption: {e_after:?}"
8482 );
8483
8484 let s_after = rt
8485 .get_edge(&tok, survivor_id)
8486 .await
8487 .unwrap()
8488 .expect("S must still exist after the refused absorption");
8489 assert_eq!(
8490 s_after.weight, 0.6,
8491 "the survivor must be untouched by the refused absorption: {s_after:?}"
8492 );
8493 }
8494
8495 #[tokio::test]
8500 async fn update_edge_symmetric_relation_stores_microsecond_updated_at() {
8501 let rt = rt();
8502 let tok = NamespaceToken::local();
8503 let a = rt
8504 .create_entity(&tok, "concept", None, "A", None, None, vec![])
8505 .await
8506 .unwrap();
8507 let b = rt
8508 .create_entity(&tok, "concept", None, "B", None, None, vec![])
8509 .await
8510 .unwrap();
8511 let edge = rt
8512 .link(&tok, a.id, b.id, EdgeRelation::CompetesWith, 1.0, None)
8513 .await
8514 .unwrap();
8515 let edge_id: Uuid = edge.id.into();
8516
8517 let before = chrono::Utc::now();
8518 let updated = rt
8519 .update_edge(
8520 &tok,
8521 edge_id,
8522 crate::curation::EdgePatch {
8523 weight: Some(0.5),
8524 ..Default::default()
8525 },
8526 )
8527 .await
8528 .unwrap();
8529
8530 let drift = (updated.updated_at - before).num_seconds().abs();
8531 assert!(
8532 drift < 60,
8533 "updated_at must round-trip as a recent timestamp (micros, not \
8534 seconds); got {:?}, expected within 60s of {:?}",
8535 updated.updated_at,
8536 before
8537 );
8538 }
8539
8540 #[tokio::test]
8541 async fn update_edge_changes_relation() {
8542 let rt = rt();
8543 let tok = NamespaceToken::local();
8544 let a = rt
8545 .create_entity(&tok, "concept", None, "A", None, None, vec![])
8546 .await
8547 .unwrap();
8548 let b = rt
8549 .create_entity(&tok, "concept", None, "B", None, None, vec![])
8550 .await
8551 .unwrap();
8552 let edge = rt
8553 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
8554 .await
8555 .unwrap();
8556 let edge_id: Uuid = edge.id.into();
8557
8558 let updated = rt
8559 .update_edge(
8560 &tok,
8561 edge_id,
8562 crate::curation::EdgePatch {
8563 relation: Some(EdgeRelation::VariantOf),
8564 ..Default::default()
8565 },
8566 )
8567 .await
8568 .unwrap();
8569 assert_eq!(updated.relation, EdgeRelation::VariantOf);
8570 }
8571
8572 #[tokio::test]
8578 async fn update_edge_symmetric_conflict_keeps_survivor_attributes() {
8579 let rt = rt();
8580 let tok = NamespaceToken::local();
8581 let a = rt
8582 .create_entity(&tok, "concept", None, "A", None, None, vec![])
8583 .await
8584 .unwrap();
8585 let b = rt
8586 .create_entity(&tok, "concept", None, "B", None, None, vec![])
8587 .await
8588 .unwrap();
8589
8590 let requested = rt
8591 .link(&tok, a.id, b.id, EdgeRelation::Extends, 0.2, None)
8592 .await
8593 .unwrap();
8594 let requested_id: Uuid = requested.id.into();
8595
8596 let canonical = rt
8597 .link(&tok, a.id, b.id, EdgeRelation::CompetesWith, 0.6, None)
8598 .await
8599 .unwrap();
8600 let canonical_id: Uuid = canonical.id.into();
8601
8602 let updated = rt
8603 .update_edge(
8604 &tok,
8605 requested_id,
8606 crate::curation::EdgePatch {
8607 relation: Some(EdgeRelation::CompetesWith),
8608 weight: Some(0.9),
8609 ..Default::default()
8610 },
8611 )
8612 .await
8613 .unwrap();
8614
8615 assert_eq!(Uuid::from(updated.id), canonical_id);
8617 assert_eq!(
8618 updated.weight, 0.6,
8619 "survivor weight must not be overwritten by the discarded edge's patch"
8620 );
8621
8622 let requested_after = rt
8623 .get_edge_including_deleted(&tok, requested_id)
8624 .await
8625 .unwrap();
8626 assert!(
8627 requested_after.is_none(),
8628 "the non-canonical requested row must be deleted, not just tombstoned"
8629 );
8630 }
8631
8632 #[tokio::test]
8635 async fn update_edge_symmetric_conflict_does_not_resurrect_tombstoned_survivor() {
8636 let rt = rt();
8637 let tok = NamespaceToken::local();
8638 let a = rt
8639 .create_entity(&tok, "concept", None, "A", None, None, vec![])
8640 .await
8641 .unwrap();
8642 let b = rt
8643 .create_entity(&tok, "concept", None, "B", None, None, vec![])
8644 .await
8645 .unwrap();
8646
8647 let requested = rt
8648 .link(&tok, a.id, b.id, EdgeRelation::Extends, 0.2, None)
8649 .await
8650 .unwrap();
8651 let requested_id: Uuid = requested.id.into();
8652
8653 let canonical = rt
8654 .link(&tok, a.id, b.id, EdgeRelation::CompetesWith, 0.6, None)
8655 .await
8656 .unwrap();
8657 let canonical_id: Uuid = canonical.id.into();
8658 rt.delete_edge(&tok, canonical_id, false).await.unwrap();
8659
8660 rt.update_edge(
8661 &tok,
8662 requested_id,
8663 crate::curation::EdgePatch {
8664 relation: Some(EdgeRelation::CompetesWith),
8665 weight: Some(0.9),
8666 ..Default::default()
8667 },
8668 )
8669 .await
8670 .unwrap();
8671
8672 let requested_after = rt
8673 .get_edge_including_deleted(&tok, requested_id)
8674 .await
8675 .unwrap();
8676 assert!(
8677 requested_after.is_none(),
8678 "the non-canonical requested row must be deleted, not just tombstoned"
8679 );
8680
8681 let canonical_after = rt.get_edge(&tok, canonical_id).await.unwrap();
8682 assert!(
8683 canonical_after.is_none(),
8684 "a tombstoned survivor must not be resurrected by a conflicting update"
8685 );
8686 }
8687
8688 #[tokio::test]
8693 async fn update_edge_annotates_note_to_entity_set_supersedes_returns_invalid_input() {
8694 let rt = rt();
8695 let tok = NamespaceToken::local();
8696 let note = rt
8697 .create_note(&tok, "observation", None, "a note", Some(0.5), None, vec![])
8698 .await
8699 .unwrap();
8700 let entity = rt
8701 .create_entity(&tok, "concept", None, "E", None, None, vec![])
8702 .await
8703 .unwrap();
8704 let edge = rt
8706 .link(&tok, note.id, entity.id, EdgeRelation::Annotates, 1.0, None)
8707 .await
8708 .unwrap();
8709 let edge_id: Uuid = edge.id.into();
8710
8711 let result = rt
8713 .update_edge(
8714 &tok,
8715 edge_id,
8716 crate::curation::EdgePatch {
8717 relation: Some(EdgeRelation::Supersedes),
8718 ..Default::default()
8719 },
8720 )
8721 .await;
8722 assert!(
8723 matches!(result, Err(RuntimeError::InvalidInput(_))),
8724 "update to Supersedes on note→entity edge must return InvalidInput, got {result:?}"
8725 );
8726
8727 let fetched = rt.get_edge(&tok, edge_id).await.unwrap().unwrap();
8729 assert_eq!(
8730 fetched.relation,
8731 EdgeRelation::Annotates,
8732 "edge relation must be unchanged after failed update"
8733 );
8734 }
8735
8736 #[tokio::test]
8739 async fn update_edge_entity_to_entity_set_annotates_returns_invalid_input() {
8740 let rt = rt();
8741 let tok = NamespaceToken::local();
8742 let a = rt
8743 .create_entity(&tok, "concept", None, "A", None, None, vec![])
8744 .await
8745 .unwrap();
8746 let b = rt
8747 .create_entity(&tok, "concept", None, "B", None, None, vec![])
8748 .await
8749 .unwrap();
8750 let edge = rt
8751 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
8752 .await
8753 .unwrap();
8754 let edge_id: Uuid = edge.id.into();
8755
8756 let result = rt
8757 .update_edge(
8758 &tok,
8759 edge_id,
8760 crate::curation::EdgePatch {
8761 relation: Some(EdgeRelation::Annotates),
8762 ..Default::default()
8763 },
8764 )
8765 .await;
8766 assert!(
8767 matches!(result, Err(RuntimeError::InvalidInput(_))),
8768 "update to Annotates on entity→entity edge must return InvalidInput, got {result:?}"
8769 );
8770 }
8771
8772 #[tokio::test]
8775 async fn update_edge_entity_to_entity_set_supersedes_succeeds() {
8776 let rt = rt();
8777 let tok = NamespaceToken::local();
8778 let a = rt
8779 .create_entity(&tok, "concept", None, "A", None, None, vec![])
8780 .await
8781 .unwrap();
8782 let b = rt
8783 .create_entity(&tok, "concept", None, "B", None, None, vec![])
8784 .await
8785 .unwrap();
8786 let edge = rt
8787 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
8788 .await
8789 .unwrap();
8790 let edge_id: Uuid = edge.id.into();
8791
8792 let updated = rt
8793 .update_edge(
8794 &tok,
8795 edge_id,
8796 crate::curation::EdgePatch {
8797 relation: Some(EdgeRelation::Supersedes),
8798 ..Default::default()
8799 },
8800 )
8801 .await
8802 .unwrap();
8803 assert_eq!(updated.relation, EdgeRelation::Supersedes);
8804
8805 let fetched = rt.get_edge(&tok, edge_id).await.unwrap().unwrap();
8807 assert_eq!(fetched.relation, EdgeRelation::Supersedes);
8808 }
8809
8810 #[tokio::test]
8812 async fn update_edge_weight_only_skips_validation() {
8813 let rt = rt();
8814 let tok = NamespaceToken::local();
8815 let a = rt
8816 .create_entity(&tok, "concept", None, "A", None, None, vec![])
8817 .await
8818 .unwrap();
8819 let b = rt
8820 .create_entity(&tok, "concept", None, "B", None, None, vec![])
8821 .await
8822 .unwrap();
8823 let edge = rt
8824 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
8825 .await
8826 .unwrap();
8827 let edge_id: Uuid = edge.id.into();
8828
8829 let updated = rt
8830 .update_edge(
8831 &tok,
8832 edge_id,
8833 crate::curation::EdgePatch {
8834 weight: Some(0.3),
8835 ..Default::default()
8836 },
8837 )
8838 .await
8839 .unwrap();
8840 assert_eq!(updated.relation, EdgeRelation::Extends);
8841 assert!((updated.weight - 0.3).abs() < 0.001);
8842 }
8843
8844 #[tokio::test]
8846 async fn update_edge_same_class_relation_change_succeeds() {
8847 let rt = rt();
8848 let tok = NamespaceToken::local();
8849 let a = rt
8850 .create_entity(&tok, "concept", None, "A", None, None, vec![])
8851 .await
8852 .unwrap();
8853 let b = rt
8854 .create_entity(&tok, "concept", None, "B", None, None, vec![])
8855 .await
8856 .unwrap();
8857 let edge = rt
8858 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
8859 .await
8860 .unwrap();
8861 let edge_id: Uuid = edge.id.into();
8862
8863 let updated = rt
8864 .update_edge(
8865 &tok,
8866 edge_id,
8867 crate::curation::EdgePatch {
8868 relation: Some(EdgeRelation::VariantOf),
8869 ..Default::default()
8870 },
8871 )
8872 .await
8873 .unwrap();
8874 assert_eq!(updated.relation, EdgeRelation::VariantOf);
8875 }
8876
8877 #[tokio::test]
8895 async fn list_edges_multi_namespace_offset_paging_enumerates_exactly() {
8896 let rt = rt();
8897 let ns_a = Namespace::parse("ns-a").unwrap();
8898 let ns_b = Namespace::parse("ns-b").unwrap();
8899 let tok_a = NamespaceToken::for_namespace(ns_a.clone());
8900 let tok_b = NamespaceToken::for_namespace(ns_b.clone());
8901
8902 let source = rt
8903 .create_entity(&tok_a, "concept", None, "Source", None, None, vec![])
8904 .await
8905 .unwrap();
8906 let mut targets = Vec::new();
8907 for index in 0..6 {
8908 targets.push(
8909 rt.create_entity(
8910 &tok_a,
8911 "concept",
8912 None,
8913 &format!("Target{index}"),
8914 None,
8915 None,
8916 vec![],
8917 )
8918 .await
8919 .unwrap(),
8920 );
8921 }
8922
8923 let a_uuids = [
8926 Uuid::parse_str("ffffffff-ffff-4fff-8fff-ffffffffff01").unwrap(),
8927 Uuid::parse_str("ffffffff-ffff-4fff-8fff-ffffffffff02").unwrap(),
8928 Uuid::parse_str("ffffffff-ffff-4fff-8fff-ffffffffff03").unwrap(),
8929 ];
8930 let b_uuids = [
8931 Uuid::parse_str("00000000-0000-4000-8000-000000000001").unwrap(),
8932 Uuid::parse_str("00000000-0000-4000-8000-000000000002").unwrap(),
8933 Uuid::parse_str("00000000-0000-4000-8000-000000000003").unwrap(),
8934 ];
8935
8936 let graph_a = rt.graph(&tok_a).unwrap();
8937 let graph_b = rt.graph(&tok_b).unwrap();
8938 let mut expected_order: Vec<Uuid> = Vec::new();
8939 for i in 0..3usize {
8940 let a_created_at = chrono::DateTime::<chrono::Utc>::from_timestamp_micros(
8941 1_000_000 + (2 * i as i64) * 1_000_000,
8942 )
8943 .unwrap();
8944 graph_a
8945 .upsert_edge(Edge {
8946 id: a_uuids[i].into(),
8947 namespace: "ns-a".into(),
8948 source_id: source.id,
8949 target_id: targets[2 * i].id,
8950 relation: EdgeRelation::Extends,
8951 weight: 0.5,
8952 created_at: a_created_at,
8953 updated_at: a_created_at,
8954 deleted_at: None,
8955 metadata: None,
8956 target_backend: None,
8957 })
8958 .await
8959 .unwrap();
8960 expected_order.push(a_uuids[i]);
8961
8962 let b_created_at = chrono::DateTime::<chrono::Utc>::from_timestamp_micros(
8963 2_000_000 + (2 * i as i64) * 1_000_000,
8964 )
8965 .unwrap();
8966 graph_b
8967 .upsert_edge(Edge {
8968 id: b_uuids[i].into(),
8969 namespace: "ns-b".into(),
8970 source_id: source.id,
8971 target_id: targets[2 * i + 1].id,
8972 relation: EdgeRelation::Extends,
8973 weight: 0.5,
8974 created_at: b_created_at,
8975 updated_at: b_created_at,
8976 deleted_at: None,
8977 metadata: None,
8978 target_backend: None,
8979 })
8980 .await
8981 .unwrap();
8982 expected_order.push(b_uuids[i]);
8983 }
8984
8985 let multi = NamespaceToken::mint_with_visibility(ns_a, vec![ns_b], ActorRef::anonymous());
8986 let mut seen: Vec<Uuid> = Vec::new();
8987 let mut offset = 0u32;
8988 loop {
8989 let page = rt
8990 .list_edges(&multi, EdgeListFilter::default(), 2, offset)
8991 .await
8992 .unwrap();
8993 if page.is_empty() {
8994 break;
8995 }
8996 seen.extend(page.iter().map(|e| Uuid::from(e.id)));
8997 offset += 2;
8998 }
8999
9000 assert_eq!(
9001 seen, expected_order,
9002 "must enumerate in exact created_at order, not UUID order"
9003 );
9004 let distinct: std::collections::HashSet<Uuid> = seen.iter().copied().collect();
9005 assert_eq!(distinct.len(), 6, "no duplicates across pages");
9006 let expected_set: std::collections::HashSet<Uuid> =
9007 expected_order.iter().copied().collect();
9008 assert_eq!(
9009 distinct, expected_set,
9010 "enumerated set must equal the seeded set"
9011 );
9012 }
9013
9014 #[test]
9028 fn merge_paged_namespace_edges_orders_by_created_at_not_uuid() {
9029 fn edge(id_hex_suffix: &str, created_at_micros: i64) -> Edge {
9030 let created_at =
9031 chrono::DateTime::<chrono::Utc>::from_timestamp_micros(created_at_micros).unwrap();
9032 Edge {
9033 id: Uuid::parse_str(&format!("00000000-0000-4000-8000-{id_hex_suffix}"))
9034 .unwrap()
9035 .into(),
9036 namespace: "irrelevant".into(),
9037 source_id: Uuid::nil(),
9038 target_id: Uuid::nil(),
9039 relation: EdgeRelation::Extends,
9040 weight: 0.5,
9041 created_at,
9042 updated_at: created_at,
9043 deleted_at: None,
9044 metadata: None,
9045 target_backend: None,
9046 }
9047 }
9048
9049 let a0 = edge("ffffffffffff", 1_000_000);
9053 let b0 = edge("000000000001", 2_000_000);
9054 let a1 = edge("fffffffffffe", 3_000_000);
9055 let b1 = edge("000000000002", 4_000_000);
9056
9057 let namespace_prefixes = vec![vec![a0.clone(), a1.clone()], vec![b0.clone(), b1.clone()]];
9058
9059 let full_page = KhiveRuntime::merge_paged_namespace_edges(namespace_prefixes.clone(), 0, 4);
9060 let ids: Vec<Uuid> = full_page.iter().map(|e| Uuid::from(e.id)).collect();
9061 assert_eq!(
9062 ids,
9063 vec![
9064 Uuid::from(a0.id),
9065 Uuid::from(b0.id),
9066 Uuid::from(a1.id),
9067 Uuid::from(b1.id)
9068 ],
9069 "must order the merge by created_at, not by UUID magnitude"
9070 );
9071
9072 let middle_page = KhiveRuntime::merge_paged_namespace_edges(namespace_prefixes, 1, 2);
9075 let middle_ids: Vec<Uuid> = middle_page.iter().map(|e| Uuid::from(e.id)).collect();
9076 assert_eq!(middle_ids, vec![Uuid::from(b0.id), Uuid::from(a1.id)]);
9077 }
9078
9079 #[tokio::test]
9080 async fn list_edges_filters_by_relation() {
9081 let rt = rt();
9082 let tok = NamespaceToken::local();
9083 let a = rt
9084 .create_entity(&tok, "concept", None, "A", None, None, vec![])
9085 .await
9086 .unwrap();
9087 let b = rt
9088 .create_entity(&tok, "concept", None, "B", None, None, vec![])
9089 .await
9090 .unwrap();
9091 let c = rt
9092 .create_entity(&tok, "concept", None, "C", None, None, vec![])
9093 .await
9094 .unwrap();
9095
9096 rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
9097 .await
9098 .unwrap();
9099 rt.link(&tok, a.id, c.id, EdgeRelation::Enables, 1.0, None)
9100 .await
9101 .unwrap();
9102
9103 let filter = EdgeListFilter {
9104 relations: vec![EdgeRelation::Extends],
9105 ..Default::default()
9106 };
9107 let edges = rt.list_edges(&tok, filter, 100, 0).await.unwrap();
9108 assert_eq!(edges.len(), 1);
9109 assert_eq!(edges[0].relation, EdgeRelation::Extends);
9110 }
9111
9112 #[tokio::test]
9113 async fn list_edges_filters_by_source() {
9114 let rt = rt();
9115 let tok = NamespaceToken::local();
9116 let a = rt
9117 .create_entity(&tok, "concept", None, "A", None, None, vec![])
9118 .await
9119 .unwrap();
9120 let b = rt
9121 .create_entity(&tok, "concept", None, "B", None, None, vec![])
9122 .await
9123 .unwrap();
9124 let c = rt
9125 .create_entity(&tok, "concept", None, "C", None, None, vec![])
9126 .await
9127 .unwrap();
9128 let d = rt
9129 .create_entity(&tok, "concept", None, "D", None, None, vec![])
9130 .await
9131 .unwrap();
9132
9133 rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
9134 .await
9135 .unwrap();
9136 rt.link(&tok, c.id, d.id, EdgeRelation::Extends, 1.0, None)
9137 .await
9138 .unwrap();
9139
9140 let filter = EdgeListFilter {
9141 source_id: Some(a.id),
9142 ..Default::default()
9143 };
9144 let edges = rt.list_edges(&tok, filter, 100, 0).await.unwrap();
9145 assert_eq!(edges.len(), 1);
9146 let src: Uuid = edges[0].source_id;
9147 assert_eq!(src, a.id);
9148 }
9149
9150 #[tokio::test]
9155 async fn list_edges_offset_pages_through_full_set() {
9156 let rt = rt();
9157 let tok = NamespaceToken::local();
9158 let a = rt
9159 .create_entity(&tok, "concept", None, "A", None, None, vec![])
9160 .await
9161 .unwrap();
9162 for i in 0..5 {
9163 let t = rt
9164 .create_entity(&tok, "concept", None, &format!("T{i}"), None, None, vec![])
9165 .await
9166 .unwrap();
9167 rt.link(&tok, a.id, t.id, EdgeRelation::Extends, 1.0, None)
9168 .await
9169 .unwrap();
9170 }
9171
9172 let filter = EdgeListFilter {
9173 source_id: Some(a.id),
9174 relations: vec![EdgeRelation::Extends],
9175 ..Default::default()
9176 };
9177
9178 let page0 = rt.list_edges(&tok, filter.clone(), 2, 0).await.unwrap();
9179 let page1 = rt.list_edges(&tok, filter.clone(), 2, 2).await.unwrap();
9180 let page2 = rt.list_edges(&tok, filter.clone(), 2, 4).await.unwrap();
9181 assert_eq!(page0.len(), 2);
9182 assert_eq!(page1.len(), 2);
9183 assert_eq!(page2.len(), 1);
9184
9185 let ids = |p: &[Edge]| p.iter().map(|e| Uuid::from(e.id)).collect::<Vec<_>>();
9186 assert_ne!(ids(&page0), ids(&page1), "page 2 must differ from page 1");
9187
9188 let mut all_ids: Vec<Uuid> = ids(&page0)
9189 .into_iter()
9190 .chain(ids(&page1))
9191 .chain(ids(&page2))
9192 .collect();
9193 all_ids.sort();
9194 all_ids.dedup();
9195 assert_eq!(all_ids.len(), 5, "pages must tile the full edge set");
9196
9197 let empty = rt.list_edges(&tok, filter.clone(), 2, 100).await.unwrap();
9198 assert!(
9199 empty.is_empty(),
9200 "offset past the end must return empty, not page 1"
9201 );
9202 }
9203
9204 #[tokio::test]
9207 async fn list_edges_after_keyset_tiles_full_set() {
9208 let rt = rt();
9209 let tok = NamespaceToken::local();
9210 let a = rt
9211 .create_entity(&tok, "concept", None, "A", None, None, vec![])
9212 .await
9213 .unwrap();
9214 for i in 0..5 {
9215 let t = rt
9216 .create_entity(&tok, "concept", None, &format!("K{i}"), None, None, vec![])
9217 .await
9218 .unwrap();
9219 rt.link(&tok, a.id, t.id, EdgeRelation::Extends, 1.0, None)
9220 .await
9221 .unwrap();
9222 }
9223
9224 let filter = EdgeListFilter {
9225 source_id: Some(a.id),
9226 relations: vec![EdgeRelation::Extends],
9227 ..Default::default()
9228 };
9229
9230 let mut seen = Vec::new();
9231 let mut cursor: Option<Uuid> = None;
9232 for _ in 0..20 {
9233 let (page, next) = rt
9234 .list_edges_after(&tok, filter.clone(), cursor, 2)
9235 .await
9236 .unwrap();
9237 if page.is_empty() {
9238 break;
9239 }
9240 seen.extend(page.iter().map(|e| Uuid::from(e.id)));
9241 if next.is_none() {
9242 break;
9243 }
9244 cursor = next;
9245 }
9246 seen.sort();
9247 seen.dedup();
9248 assert_eq!(seen.len(), 5, "keyset walk must tile the full edge set");
9249
9250 let (first_a, next_a) = rt
9253 .list_edges_after(&tok, filter.clone(), None, 2)
9254 .await
9255 .unwrap();
9256 let (first_b, next_b) = rt
9257 .list_edges_after(&tok, filter.clone(), None, 2)
9258 .await
9259 .unwrap();
9260 assert_eq!(
9261 first_a.iter().map(|e| e.id.0).collect::<Vec<_>>(),
9262 first_b.iter().map(|e| e.id.0).collect::<Vec<_>>(),
9263 );
9264 assert_eq!(next_a, next_b);
9265 }
9266
9267 #[tokio::test]
9268 async fn list_entities_after_same_timestamp_lower_uuid_insert_is_not_skipped() {
9269 let rt = rt();
9270 let tok = NamespaceToken::local();
9271 let ids = [
9272 Uuid::parse_str("f0000000-0000-4000-8000-000000000011").unwrap(),
9273 Uuid::parse_str("f0000000-0000-4000-8000-000000000012").unwrap(),
9274 Uuid::parse_str("f0000000-0000-4000-8000-000000000013").unwrap(),
9275 ];
9276 let store = rt.entities(&tok).unwrap();
9277 for (index, id) in ids.into_iter().enumerate() {
9278 let mut entity = Entity::new("local", "concept", format!("Entity{index}"));
9279 entity.id = id;
9280 entity.created_at = 1_000_000;
9281 entity.updated_at = 1_000_000;
9282 store.upsert_entity(entity).await.unwrap();
9283 }
9284
9285 let (first, next) = rt
9286 .list_entities_after(&tok, None, None, &[], None, 2)
9287 .await
9288 .unwrap();
9289 assert_eq!(
9290 first.iter().map(|entity| entity.id).collect::<Vec<_>>(),
9291 ids[..2]
9292 );
9293 let cursor = next.expect("one original entity remains after page one");
9294
9295 let low_id = Uuid::parse_str("00000000-0000-4000-8000-000000000011").unwrap();
9296 assert!(low_id < cursor);
9297 let mut inserted = Entity::new("local", "concept", "InsertedEntity");
9298 inserted.id = low_id;
9299 inserted.created_at = 1_000_000;
9300 inserted.updated_at = 1_000_000;
9301 store.upsert_entity(inserted).await.unwrap();
9302
9303 let (second, final_cursor) = rt
9304 .list_entities_after(&tok, None, None, &[], Some(cursor), 2)
9305 .await
9306 .unwrap();
9307 assert_eq!(
9308 second.iter().map(|entity| entity.id).collect::<Vec<_>>(),
9309 vec![ids[2], low_id]
9310 );
9311 assert_eq!(final_cursor, None);
9312 }
9313
9314 #[tokio::test]
9315 async fn list_notes_after_same_timestamp_lower_uuid_insert_is_not_skipped() {
9316 let rt = rt();
9317 let tok = NamespaceToken::local();
9318 let ids = [
9319 Uuid::parse_str("f0000000-0000-4000-8000-000000000021").unwrap(),
9320 Uuid::parse_str("f0000000-0000-4000-8000-000000000022").unwrap(),
9321 Uuid::parse_str("f0000000-0000-4000-8000-000000000023").unwrap(),
9322 ];
9323 let store = rt.notes(&tok).unwrap();
9324 for (index, id) in ids.into_iter().enumerate() {
9325 let mut note = Note::new("local", "observation", format!("Note {index}"));
9326 note.id = id;
9327 note.created_at = 1_000_000;
9328 note.updated_at = 1_000_000;
9329 store.upsert_note(note).await.unwrap();
9330 }
9331
9332 let (first, next) = rt.list_notes_after(&tok, None, None, 2).await.unwrap();
9333 assert_eq!(
9334 first.iter().map(|note| note.id).collect::<Vec<_>>(),
9335 ids[..2]
9336 );
9337 let cursor = next.expect("one original note remains after page one");
9338
9339 let low_id = Uuid::parse_str("00000000-0000-4000-8000-000000000021").unwrap();
9340 assert!(low_id < cursor);
9341 let mut inserted = Note::new("local", "observation", "Inserted note");
9342 inserted.id = low_id;
9343 inserted.created_at = 1_000_000;
9344 inserted.updated_at = 1_000_000;
9345 store.upsert_note(inserted).await.unwrap();
9346
9347 let (second, final_cursor) = rt
9348 .list_notes_after(&tok, None, Some(cursor), 2)
9349 .await
9350 .unwrap();
9351 assert_eq!(
9352 second.iter().map(|note| note.id).collect::<Vec<_>>(),
9353 vec![ids[2], low_id]
9354 );
9355 assert_eq!(final_cursor, None);
9356 }
9357
9358 #[tokio::test]
9359 async fn list_edges_after_same_timestamp_lower_uuid_insert_is_not_skipped() {
9360 let rt = rt();
9361 let tok = NamespaceToken::local();
9362 let source = rt
9363 .create_entity(&tok, "concept", None, "CursorSource", None, None, vec![])
9364 .await
9365 .unwrap();
9366 let mut targets = Vec::new();
9367 for index in 0..4 {
9368 targets.push(
9369 rt.create_entity(
9370 &tok,
9371 "concept",
9372 None,
9373 &format!("CursorTarget{index}"),
9374 None,
9375 None,
9376 vec![],
9377 )
9378 .await
9379 .unwrap(),
9380 );
9381 }
9382
9383 let ids = [
9384 Uuid::parse_str("f0000000-0000-4000-8000-000000000001").unwrap(),
9385 Uuid::parse_str("f0000000-0000-4000-8000-000000000002").unwrap(),
9386 Uuid::parse_str("f0000000-0000-4000-8000-000000000003").unwrap(),
9387 ];
9388 let graph = rt.graph(&tok).unwrap();
9389 let created_at = chrono::DateTime::<chrono::Utc>::from_timestamp_micros(1_000_000).unwrap();
9390 for (index, id) in ids.into_iter().enumerate() {
9391 graph
9392 .upsert_edge(Edge {
9393 id: id.into(),
9394 namespace: "local".into(),
9395 source_id: source.id,
9396 target_id: targets[index].id,
9397 relation: EdgeRelation::Extends,
9398 weight: 1.0,
9399 created_at,
9400 updated_at: created_at,
9401 deleted_at: None,
9402 metadata: None,
9403 target_backend: None,
9404 })
9405 .await
9406 .unwrap();
9407 }
9408
9409 let filter = EdgeListFilter {
9410 source_id: Some(source.id),
9411 relations: vec![EdgeRelation::Extends],
9412 ..Default::default()
9413 };
9414 let (first, next) = rt
9415 .list_edges_after(&tok, filter.clone(), None, 2)
9416 .await
9417 .unwrap();
9418 assert_eq!(
9419 first.iter().map(|edge| edge.id.0).collect::<Vec<_>>(),
9420 ids[..2]
9421 );
9422 let cursor = next.expect("one original edge remains after page one");
9423
9424 let low_id = Uuid::parse_str("00000000-0000-4000-8000-000000000001").unwrap();
9428 assert!(low_id < cursor);
9429 graph
9430 .upsert_edge(Edge {
9431 id: low_id.into(),
9432 namespace: "local".into(),
9433 source_id: source.id,
9434 target_id: targets[3].id,
9435 relation: EdgeRelation::Extends,
9436 weight: 1.0,
9437 created_at,
9438 updated_at: created_at,
9439 deleted_at: None,
9440 metadata: None,
9441 target_backend: None,
9442 })
9443 .await
9444 .unwrap();
9445
9446 let (second, final_cursor) = rt
9447 .list_edges_after(&tok, filter, Some(cursor), 2)
9448 .await
9449 .unwrap();
9450 assert_eq!(
9451 second.iter().map(|edge| edge.id.0).collect::<Vec<_>>(),
9452 vec![ids[2], low_id]
9453 );
9454 assert_eq!(final_cursor, None);
9455 }
9456
9457 #[tokio::test]
9458 async fn list_entity_cursor_survives_soft_delete_and_rejects_hard_delete_or_hidden_scope() {
9459 let rt = rt();
9460 let local = NamespaceToken::local();
9461 for index in 0..3 {
9462 rt.create_entity(
9463 &local,
9464 "concept",
9465 None,
9466 &format!("CursorLifecycle{index}"),
9467 None,
9468 None,
9469 vec![],
9470 )
9471 .await
9472 .unwrap();
9473 }
9474
9475 let (_, next) = rt
9476 .list_entities_after(&local, None, None, &[], None, 2)
9477 .await
9478 .unwrap();
9479 let cursor = next.expect("three entities require a second page");
9480 let store = rt.entities(&local).unwrap();
9481 assert!(store.delete_entity(cursor, DeleteMode::Soft).await.unwrap());
9482
9483 let (remaining, next) = rt
9484 .list_entities_after(&local, None, None, &[], Some(cursor), 2)
9485 .await
9486 .expect("a soft-deleted cursor must retain its sequence boundary");
9487 assert_eq!(remaining.len(), 1);
9488 assert_eq!(next, None);
9489
9490 assert!(store.delete_entity(cursor, DeleteMode::Hard).await.unwrap());
9491 let hard_deleted = rt
9492 .list_entities_after(&local, None, None, &[], Some(cursor), 2)
9493 .await;
9494 assert!(
9495 matches!(hard_deleted, Err(RuntimeError::NotFound(_))),
9496 "a hard-deleted cursor must fail explicitly: {hard_deleted:?}"
9497 );
9498
9499 let hidden_ns = Namespace::parse("cursor-hidden").unwrap();
9500 let hidden_token = NamespaceToken::for_namespace(hidden_ns);
9501 let hidden = rt
9502 .create_entity(
9503 &hidden_token,
9504 "concept",
9505 None,
9506 "HiddenCursor",
9507 None,
9508 None,
9509 vec![],
9510 )
9511 .await
9512 .unwrap();
9513 let out_of_scope = rt
9514 .list_entities_after(&local, None, None, &[], Some(hidden.id), 2)
9515 .await;
9516 assert!(
9517 matches!(out_of_scope, Err(RuntimeError::NotFound(_))),
9518 "an out-of-scope cursor must not reveal or resume from the hidden row: {out_of_scope:?}"
9519 );
9520 }
9521
9522 #[tokio::test]
9523 async fn list_edges_after_single_namespace_exact_final_page_has_no_next_after() {
9524 let rt = rt();
9525 let tok = NamespaceToken::local();
9526 let a = rt
9527 .create_entity(&tok, "concept", None, "SingleCursorA", None, None, vec![])
9528 .await
9529 .unwrap();
9530 for i in 0..4 {
9531 let t = rt
9532 .create_entity(
9533 &tok,
9534 "concept",
9535 None,
9536 &format!("SingleCursorT{i}"),
9537 None,
9538 None,
9539 vec![],
9540 )
9541 .await
9542 .unwrap();
9543 rt.link(&tok, a.id, t.id, EdgeRelation::Extends, 1.0, None)
9544 .await
9545 .unwrap();
9546 }
9547
9548 let filter = EdgeListFilter {
9549 source_id: Some(a.id),
9550 relations: vec![EdgeRelation::Extends],
9551 ..Default::default()
9552 };
9553
9554 let (page1, next1) = rt
9555 .list_edges_after(&tok, filter.clone(), None, 2)
9556 .await
9557 .unwrap();
9558 assert_eq!(page1.len(), 2);
9559 let cursor = next1.expect("first page must report a cursor when two rows remain");
9560
9561 let (page2, next2) = rt
9562 .list_edges_after(&tok, filter, Some(cursor), 2)
9563 .await
9564 .unwrap();
9565 assert_eq!(page2.len(), 2);
9566 assert_eq!(
9567 next2, None,
9568 "an exact-size final single-namespace page must not report a cursor"
9569 );
9570 }
9571
9572 #[tokio::test]
9573 async fn list_edges_after_multi_namespace_exact_final_page_has_no_next_after() {
9574 let rt = rt();
9575 let ns_a = Namespace::parse("cursor-ns-a").unwrap();
9576 let ns_b = Namespace::parse("cursor-ns-b").unwrap();
9577 let tok_a = NamespaceToken::for_namespace(ns_a.clone());
9578 let tok_b = NamespaceToken::for_namespace(ns_b.clone());
9579 let visible = NamespaceToken::mint_with_visibility(ns_a, vec![ns_b], ActorRef::anonymous());
9580
9581 for (tok, prefix) in [(&tok_a, "A"), (&tok_b, "B")] {
9582 let source = rt
9583 .create_entity(
9584 tok,
9585 "concept",
9586 None,
9587 &format!("MultiCursor{prefix}Source"),
9588 None,
9589 None,
9590 vec![],
9591 )
9592 .await
9593 .unwrap();
9594 for i in 0..2 {
9595 let target = rt
9596 .create_entity(
9597 tok,
9598 "concept",
9599 None,
9600 &format!("MultiCursor{prefix}Target{i}"),
9601 None,
9602 None,
9603 vec![],
9604 )
9605 .await
9606 .unwrap();
9607 rt.link(tok, source.id, target.id, EdgeRelation::Extends, 1.0, None)
9608 .await
9609 .unwrap();
9610 }
9611 }
9612
9613 let filter = EdgeListFilter {
9614 relations: vec![EdgeRelation::Extends],
9615 ..Default::default()
9616 };
9617 let (page1, next1) = rt
9618 .list_edges_after(&visible, filter.clone(), None, 2)
9619 .await
9620 .unwrap();
9621 assert_eq!(page1.len(), 2);
9622 let cursor = next1.expect("first merged page must report a cursor when rows remain");
9623
9624 let (page2, next2) = rt
9625 .list_edges_after(&visible, filter, Some(cursor), 2)
9626 .await
9627 .unwrap();
9628 assert_eq!(page2.len(), 2);
9629 assert_eq!(
9630 next2, None,
9631 "an exact-size final multi-namespace page must not report a cursor"
9632 );
9633 }
9634
9635 #[tokio::test]
9638 async fn count_edges_by_relation_matches_fixtures() {
9639 let rt = rt();
9640 let tok = NamespaceToken::local();
9641 let a = rt
9642 .create_entity(&tok, "concept", None, "A", None, None, vec![])
9643 .await
9644 .unwrap();
9645 let b = rt
9646 .create_entity(&tok, "concept", None, "B", None, None, vec![])
9647 .await
9648 .unwrap();
9649 let c = rt
9650 .create_entity(&tok, "concept", None, "C", None, None, vec![])
9651 .await
9652 .unwrap();
9653
9654 rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
9655 .await
9656 .unwrap();
9657 rt.link(&tok, a.id, c.id, EdgeRelation::Extends, 1.0, None)
9658 .await
9659 .unwrap();
9660 rt.link(&tok, b.id, c.id, EdgeRelation::Enables, 1.0, None)
9661 .await
9662 .unwrap();
9663
9664 let counts = rt.count_edges_by_relation(&tok).await.unwrap();
9665 assert_eq!(counts.get("extends").copied(), Some(2));
9666 assert_eq!(counts.get("enables").copied(), Some(1));
9667 }
9668
9669 #[tokio::test]
9670 async fn delete_edge_removes_from_storage() {
9671 let rt = rt();
9672 let tok = NamespaceToken::local();
9673 let a = rt
9674 .create_entity(&tok, "concept", None, "A", None, None, vec![])
9675 .await
9676 .unwrap();
9677 let b = rt
9678 .create_entity(&tok, "concept", None, "B", None, None, vec![])
9679 .await
9680 .unwrap();
9681 let edge = rt
9682 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
9683 .await
9684 .unwrap();
9685 let edge_id: Uuid = edge.id.into();
9686
9687 let deleted = rt.delete_edge(&tok, edge_id, true).await.unwrap();
9688 assert!(deleted);
9689
9690 let fetched = rt.get_edge(&tok, edge_id).await.unwrap();
9691 assert!(fetched.is_none(), "edge should be gone after delete");
9692 }
9693
9694 #[tokio::test]
9695 async fn count_edges_matches_filter() {
9696 let rt = rt();
9697 let tok = NamespaceToken::local();
9698 let a = rt
9699 .create_entity(&tok, "concept", None, "A", None, None, vec![])
9700 .await
9701 .unwrap();
9702 let b = rt
9703 .create_entity(&tok, "concept", None, "B", None, None, vec![])
9704 .await
9705 .unwrap();
9706 let c = rt
9707 .create_entity(&tok, "concept", None, "C", None, None, vec![])
9708 .await
9709 .unwrap();
9710
9711 rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
9712 .await
9713 .unwrap();
9714 rt.link(&tok, a.id, c.id, EdgeRelation::Enables, 1.0, None)
9715 .await
9716 .unwrap();
9717
9718 let all = rt
9719 .count_edges(&tok, EdgeListFilter::default())
9720 .await
9721 .unwrap();
9722 assert_eq!(all, 2);
9723
9724 let just_extends = rt
9725 .count_edges(
9726 &tok,
9727 EdgeListFilter {
9728 relations: vec![EdgeRelation::Extends],
9729 ..Default::default()
9730 },
9731 )
9732 .await
9733 .unwrap();
9734 assert_eq!(just_extends, 1);
9735 }
9736
9737 #[tokio::test]
9743 async fn edge_in_visible_namespace_reachable_as_graph_root() {
9744 let rt = rt();
9745 let ns_a = Namespace::parse("vis-edge-a").unwrap();
9746 let ns_b = Namespace::parse("vis-edge-b").unwrap();
9747
9748 let tok_b = NamespaceToken::for_namespace(ns_b.clone());
9750 let src = rt
9751 .create_entity(&tok_b, "concept", None, "SrcB", None, None, vec![])
9752 .await
9753 .unwrap();
9754 let tgt = rt
9755 .create_entity(&tok_b, "concept", None, "TgtB", None, None, vec![])
9756 .await
9757 .unwrap();
9758 let edge = rt
9759 .link(&tok_b, src.id, tgt.id, EdgeRelation::Extends, 1.0, None)
9760 .await
9761 .unwrap();
9762
9763 let tok_a_vis = rt
9766 .authorize_with_visibility(ns_a.clone(), vec![ns_b.clone()])
9767 .unwrap();
9768
9769 let got = rt.get_edge_visible(&tok_a_vis, edge.id.0).await.unwrap();
9771 assert!(
9772 got.is_some(),
9773 "edge in visible namespace must be retrievable via get_edge_visible"
9774 );
9775
9776 let neighbors = rt
9779 .neighbors(&tok_a_vis, src.id, Direction::Out, Some(16), None)
9780 .await
9781 .unwrap();
9782 assert!(
9783 neighbors.iter().any(|h| h.node_id == tgt.id),
9784 "neighbors of visible-ns node must include its visible-ns neighbor; got: {neighbors:?}"
9785 );
9786 }
9787
9788 #[tokio::test]
9789 async fn neighbors_accepts_foreign_full_uuid_anchor_but_scopes_returned_edges() {
9790 let rt = rt();
9791 let ns_a = Namespace::parse("neighbor-owner").unwrap();
9792 let ns_b = Namespace::parse("neighbor-caller").unwrap();
9793 let tok_a = NamespaceToken::for_namespace(ns_a.clone());
9794 let tok_b = NamespaceToken::for_namespace(ns_b.clone());
9795
9796 let src = rt
9797 .create_entity(&tok_a, "concept", None, "Source", None, None, vec![])
9798 .await
9799 .unwrap();
9800 let tgt = rt
9801 .create_entity(&tok_a, "concept", None, "Target", None, None, vec![])
9802 .await
9803 .unwrap();
9804 let isolated = rt
9805 .create_entity(&tok_a, "concept", None, "Isolated", None, None, vec![])
9806 .await
9807 .unwrap();
9808 let note = rt
9809 .create_note(&tok_a, "observation", None, "Note", None, None, vec![])
9810 .await
9811 .unwrap();
9812 let caller_target = rt
9813 .create_entity(&tok_b, "concept", None, "Caller target", None, None, vec![])
9814 .await
9815 .unwrap();
9816 let edge = rt
9817 .link(&tok_a, src.id, tgt.id, EdgeRelation::Extends, 1.0, None)
9818 .await
9819 .unwrap();
9820 rt.link(
9821 &tok_b,
9822 src.id,
9823 caller_target.id,
9824 EdgeRelation::Extends,
9825 1.0,
9826 None,
9827 )
9828 .await
9829 .unwrap();
9830
9831 let own_hits = rt
9832 .neighbors(&tok_a, src.id, Direction::Out, None, None)
9833 .await
9834 .unwrap();
9835 assert_eq!(own_hits.len(), 1);
9836 assert_eq!(own_hits[0].node_id, tgt.id);
9837
9838 assert!(rt.get_entity(&tok_b, src.id).await.is_ok());
9841 assert!(rt.get_edge(&tok_b, edge.id.0).await.unwrap().is_some());
9842 let foreign_hits = rt
9843 .neighbors(&tok_b, src.id, Direction::Out, None, None)
9844 .await
9845 .unwrap();
9846 assert_eq!(foreign_hits.len(), 1);
9847 assert_eq!(foreign_hits[0].node_id, caller_target.id);
9848 for anchor in [note.id, edge.id.0, isolated.id] {
9849 assert!(
9850 rt.neighbors(&tok_b, anchor, Direction::Out, None, None)
9851 .await
9852 .unwrap()
9853 .is_empty(),
9854 "live foreign anchor without a caller-visible edge must be empty"
9855 );
9856 }
9857 let missing = Uuid::new_v4();
9858 assert!(matches!(
9859 rt.neighbors(&tok_b, missing, Direction::Out, None, None)
9860 .await,
9861 Err(RuntimeError::NotFound(message)) if message.contains(&missing.to_string())
9862 ));
9863
9864 let page = rt
9865 .neighbors_with_query_page(
9866 &tok_b,
9867 src.id,
9868 NeighborQuery {
9869 direction: Direction::Out,
9870 relations: None,
9871 limit: Some(1),
9872 min_weight: None,
9873 },
9874 None,
9875 None,
9876 true,
9877 )
9878 .await;
9879 let page = page.unwrap();
9880 assert_eq!(page.len(), 1);
9881 assert_eq!(page[0].node_id, caller_target.id);
9882
9883 let visible_from_b = rt.authorize_with_visibility(ns_b, vec![ns_a]).unwrap();
9884 let shared_hits = rt
9885 .neighbors(&visible_from_b, src.id, Direction::Out, None, None)
9886 .await
9887 .unwrap();
9888 assert_eq!(shared_hits.len(), 2);
9889 assert!(shared_hits.iter().any(|hit| hit.node_id == tgt.id));
9890 assert!(shared_hits
9891 .iter()
9892 .any(|hit| hit.node_id == caller_target.id));
9893
9894 let isolated_hits = rt
9895 .neighbors(&tok_a, isolated.id, Direction::Out, None, None)
9896 .await
9897 .unwrap();
9898 assert!(isolated_hits.is_empty());
9899 }
9900
9901 #[tokio::test]
9905 async fn get_entity_cross_namespace_no_longer_denied() {
9906 let rt = rt();
9907 let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
9908 let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
9909 let entity = rt
9910 .create_entity(&ns_a, "concept", None, "Alpha", None, None, vec![])
9911 .await
9912 .unwrap();
9913
9914 let found = rt.get_entity(&ns_a, entity.id).await;
9916 assert!(found.is_ok(), "same-namespace get must succeed");
9917
9918 let cross = rt.get_entity(&ns_b, entity.id).await;
9920 assert!(
9921 cross.is_ok(),
9922 "cross-namespace get must succeed in shared-brain OSS (ADR-007 rule 2)"
9923 );
9924 assert_eq!(cross.unwrap().id, entity.id);
9925 }
9926
9927 #[tokio::test]
9928 async fn delete_entity_cross_namespace_no_longer_denied() {
9929 let rt = rt();
9930 let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
9931 let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
9932 let entity = rt
9933 .create_entity(&ns_a, "concept", None, "Beta", None, None, vec![])
9934 .await
9935 .unwrap();
9936
9937 let cross_ns_result = rt.delete_entity(&ns_b, entity.id, true).await;
9939 assert!(
9940 cross_ns_result.is_ok(),
9941 "cross-namespace delete must succeed in shared-brain OSS; got {:?}",
9942 cross_ns_result
9943 );
9944 assert!(cross_ns_result.unwrap(), "delete must return true");
9945
9946 let gone = rt.get_entity(&ns_a, entity.id).await;
9948 assert!(gone.is_err(), "entity must be gone after delete");
9949 }
9950
9951 #[tokio::test]
9954 async fn create_note_indexes_into_fts5() {
9955 let rt = rt();
9956 let tok = NamespaceToken::local();
9957 let note = rt
9958 .create_note(
9959 &tok,
9960 "observation",
9961 None,
9962 "FlashAttention reduces memory by using tiling",
9963 Some(0.8),
9964 None,
9965 vec![],
9966 )
9967 .await
9968 .unwrap();
9969
9970 let ns = tok.namespace().as_str().to_string();
9972 let hits = rt
9973 .text_for_notes(&tok)
9974 .unwrap()
9975 .search(khive_storage::types::TextSearchRequest {
9976 query: "FlashAttention".to_string(),
9977 mode: khive_storage::types::TextQueryMode::Plain,
9978 filter: Some(khive_storage::types::TextFilter {
9979 namespaces: vec![ns],
9980 ..Default::default()
9981 }),
9982 top_k: 10,
9983 snippet_chars: 100,
9984 })
9985 .await
9986 .unwrap();
9987
9988 assert!(
9989 hits.iter().any(|h| h.subject_id == note.id),
9990 "note should be indexed in FTS5 after create"
9991 );
9992 }
9993
9994 #[tokio::test]
10001 async fn search_notes_with_residual_fts5_char_now_sanitized() {
10002 let rt = rt();
10003 let tok = NamespaceToken::local();
10004 rt.create_note(
10005 &tok,
10006 "observation",
10007 None,
10008 "use foo@bar to chain calls",
10009 Some(0.5),
10010 None,
10011 vec![],
10012 )
10013 .await
10014 .unwrap();
10015
10016 let result = rt
10017 .search_notes(&tok, "foo@bar", None, 10, None, false, &[], None)
10018 .await;
10019
10020 let hits = result.unwrap_or_else(|e| {
10021 panic!("#916 search_notes must not fail on an '@'-bearing query, got: {e:?}")
10022 });
10023 assert!(
10024 !hits.is_empty(),
10025 "#916 '@'-bearing query must still find the seeded 'foo@bar' content via the \
10026 quoted-phrase alternative"
10027 );
10028 }
10029
10030 #[tokio::test]
10039 async fn search_notes_propagates_non_parser_fts_error() {
10040 let rt = rt();
10041 let tok = NamespaceToken::local();
10042 rt.create_note(
10043 &tok,
10044 "observation",
10045 None,
10046 "FlashAttention reduces memory by using tiling",
10047 Some(0.8),
10048 None,
10049 vec![],
10050 )
10051 .await
10052 .unwrap();
10053
10054 let ns = tok.namespace().as_str().to_string();
10055 arm_fts_search_fail(&ns);
10056
10057 let result = rt
10058 .search_notes(&tok, "FlashAttention", None, 10, None, false, &[], None)
10059 .await;
10060
10061 assert!(
10062 result.is_err(),
10063 "search_notes must propagate a non-parser FTS StorageError (Timeout) \
10064 as Err, not silently degrade it to an empty result, got: {:?}",
10065 result.ok()
10066 );
10067 assert!(
10068 matches!(
10069 result.unwrap_err(),
10070 RuntimeError::Storage(khive_storage::StorageError::Timeout { .. })
10071 ),
10072 "propagated error must be the injected StorageError::Timeout, unwrapped \
10073 through RuntimeError::Storage"
10074 );
10075 }
10076
10077 #[tokio::test]
10078 async fn create_note_with_properties() {
10079 let rt = rt();
10080 let tok = NamespaceToken::local();
10081 let props = serde_json::json!({"source": "arxiv:2205.14135"});
10082 let note = rt
10083 .create_note(
10084 &tok,
10085 "insight",
10086 None,
10087 "FlashAttention is IO-aware",
10088 Some(0.9),
10089 Some(props.clone()),
10090 vec![],
10091 )
10092 .await
10093 .unwrap();
10094
10095 assert_eq!(note.properties.as_ref().unwrap(), &props);
10096 }
10097
10098 #[tokio::test]
10099 async fn create_note_creates_annotates_edges() {
10100 let rt = rt();
10101 let tok = NamespaceToken::local();
10102 let entity = rt
10103 .create_entity(&tok, "concept", None, "FlashAttention", None, None, vec![])
10104 .await
10105 .unwrap();
10106
10107 let note = rt
10108 .create_note(
10109 &tok,
10110 "observation",
10111 None,
10112 "FlashAttention uses SRAM tiling for memory efficiency",
10113 Some(0.9),
10114 None,
10115 vec![entity.id],
10116 )
10117 .await
10118 .unwrap();
10119
10120 let out_neighbors = rt
10122 .neighbors(
10123 &tok,
10124 note.id,
10125 Direction::Out,
10126 None,
10127 Some(vec![EdgeRelation::Annotates]),
10128 )
10129 .await
10130 .unwrap();
10131 assert_eq!(out_neighbors.len(), 1);
10132 assert_eq!(out_neighbors[0].node_id, entity.id);
10133 assert_eq!(out_neighbors[0].relation, EdgeRelation::Annotates);
10134
10135 let in_neighbors = rt
10137 .neighbors(
10138 &tok,
10139 entity.id,
10140 Direction::In,
10141 None,
10142 Some(vec![EdgeRelation::Annotates]),
10143 )
10144 .await
10145 .unwrap();
10146 assert_eq!(in_neighbors.len(), 1);
10147 assert_eq!(in_neighbors[0].node_id, note.id);
10148 }
10149
10150 #[tokio::test]
10151 async fn neighbors_without_relation_filter_returns_all() {
10152 let rt = rt();
10153 let tok = NamespaceToken::local();
10154 let a = rt
10155 .create_entity(&tok, "concept", None, "A", None, None, vec![])
10156 .await
10157 .unwrap();
10158 let b = rt
10159 .create_entity(&tok, "concept", None, "B", None, None, vec![])
10160 .await
10161 .unwrap();
10162 let c = rt
10163 .create_entity(&tok, "concept", None, "C", None, None, vec![])
10164 .await
10165 .unwrap();
10166
10167 rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
10168 .await
10169 .unwrap();
10170 rt.link(&tok, a.id, c.id, EdgeRelation::Enables, 1.0, None)
10171 .await
10172 .unwrap();
10173
10174 let all = rt
10175 .neighbors(&tok, a.id, Direction::Out, None, None)
10176 .await
10177 .unwrap();
10178 assert_eq!(all.len(), 2);
10179 }
10180
10181 #[tokio::test]
10182 async fn neighbors_with_relation_filter_returns_subset() {
10183 let rt = rt();
10184 let tok = NamespaceToken::local();
10185 let a = rt
10186 .create_entity(&tok, "concept", None, "A", None, None, vec![])
10187 .await
10188 .unwrap();
10189 let b = rt
10190 .create_entity(&tok, "concept", None, "B", None, None, vec![])
10191 .await
10192 .unwrap();
10193 let c = rt
10194 .create_entity(&tok, "concept", None, "C", None, None, vec![])
10195 .await
10196 .unwrap();
10197
10198 rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
10199 .await
10200 .unwrap();
10201 rt.link(&tok, a.id, c.id, EdgeRelation::Enables, 1.0, None)
10202 .await
10203 .unwrap();
10204
10205 let filtered = rt
10206 .neighbors(
10207 &tok,
10208 a.id,
10209 Direction::Out,
10210 None,
10211 Some(vec![EdgeRelation::Extends]),
10212 )
10213 .await
10214 .unwrap();
10215 assert_eq!(filtered.len(), 1);
10216 assert_eq!(filtered[0].node_id, b.id);
10217 assert_eq!(filtered[0].relation, EdgeRelation::Extends);
10218 }
10219
10220 #[tokio::test]
10228 async fn neighbors_with_query_directed_preserves_self_loop_direction_parity() {
10229 let rt = rt();
10230 let tok = NamespaceToken::local();
10231 let centre = rt
10232 .create_entity(&tok, "concept", None, "Centre", None, None, vec![])
10233 .await
10234 .unwrap();
10235
10236 let now = chrono::Utc::now();
10237 rt.graph(&tok)
10238 .unwrap()
10239 .upsert_edge(Edge {
10240 id: LinkId::from(Uuid::new_v4()),
10241 namespace: "local".to_string(),
10242 source_id: centre.id,
10243 target_id: centre.id,
10244 relation: EdgeRelation::Extends,
10245 weight: 0.7,
10246 created_at: now,
10247 updated_at: now,
10248 deleted_at: None,
10249 metadata: None,
10250 target_backend: None,
10251 })
10252 .await
10253 .unwrap();
10254
10255 let directed = rt
10256 .neighbors_with_query_directed(
10257 &tok,
10258 centre.id,
10259 NeighborQuery {
10260 direction: Direction::Both,
10261 relations: None,
10262 limit: None,
10263 min_weight: None,
10264 },
10265 )
10266 .await
10267 .unwrap();
10268
10269 assert_eq!(
10270 directed.len(),
10271 2,
10272 "a self-loop edge must produce both an Out hit and an In hit, not one collapsed hit"
10273 );
10274 let directions: Vec<Direction> = directed.iter().map(|(_, d)| d.clone()).collect();
10275 assert!(
10276 directions.contains(&Direction::Out),
10277 "self-loop must retain its Out-tagged hit"
10278 );
10279 assert!(
10280 directions.contains(&Direction::In),
10281 "self-loop must retain its In-tagged hit"
10282 );
10283 }
10284
10285 #[tokio::test]
10286 async fn search_notes_returns_relevant_note() {
10287 let rt = rt();
10288 let tok = NamespaceToken::local();
10289 rt.create_note(
10290 &tok,
10291 "observation",
10292 None,
10293 "GQA reduces KV cache memory for large models",
10294 Some(0.8),
10295 None,
10296 vec![],
10297 )
10298 .await
10299 .unwrap();
10300
10301 let results = rt
10302 .search_notes(&tok, "GQA KV cache", None, 10, None, false, &[], None)
10303 .await
10304 .unwrap();
10305
10306 assert!(!results.is_empty(), "search should return the indexed note");
10307 let hit = &results[0];
10308 assert!(
10309 hit.title.is_some(),
10310 "note hit title should be populated (falls back to content)"
10311 );
10312 assert!(
10313 hit.snippet.is_some(),
10314 "note hit snippet should be populated"
10315 );
10316 }
10317
10318 #[tokio::test]
10319 async fn search_notes_excludes_soft_deleted() {
10320 let rt = rt();
10321 let tok = NamespaceToken::local();
10322 let note = rt
10323 .create_note(
10324 &tok,
10325 "observation",
10326 None,
10327 "RoPE positional encoding rotary embeddings",
10328 Some(0.7),
10329 None,
10330 vec![],
10331 )
10332 .await
10333 .unwrap();
10334
10335 rt.notes(&tok)
10337 .unwrap()
10338 .delete_note(note.id, DeleteMode::Soft)
10339 .await
10340 .unwrap();
10341
10342 let results = rt
10343 .search_notes(
10344 &tok,
10345 "RoPE rotary positional",
10346 None,
10347 10,
10348 None,
10349 false,
10350 &[],
10351 None,
10352 )
10353 .await
10354 .unwrap();
10355
10356 assert!(
10357 results.iter().all(|h| h.note_id != note.id),
10358 "soft-deleted note should be excluded from search"
10359 );
10360 }
10361
10362 #[tokio::test]
10376 async fn search_notes_tag_filter_pushed_before_truncation() {
10377 let rt = rt();
10378 let tok = NamespaceToken::local();
10379
10380 rt.create_note(
10382 &tok,
10383 "observation",
10384 None,
10385 "kappa lambda mu note decoy kappa lambda mu note decoy kappa lambda mu",
10386 Some(0.5),
10387 Some(serde_json::json!({"tags": ["other-note-tag"]})),
10388 vec![],
10389 )
10390 .await
10391 .unwrap();
10392
10393 let target = rt
10395 .create_note(
10396 &tok,
10397 "observation",
10398 None,
10399 "kappa lambda mu note target",
10400 Some(0.5),
10401 Some(serde_json::json!({"tags": ["note-target-tag"]})),
10402 vec![],
10403 )
10404 .await
10405 .unwrap();
10406
10407 let hits = rt
10410 .search_notes(
10411 &tok,
10412 "kappa lambda mu note",
10413 None,
10414 1,
10415 None,
10416 false,
10417 &["note-target-tag".to_string()],
10418 None,
10419 )
10420 .await
10421 .unwrap();
10422
10423 assert_eq!(
10424 hits.len(),
10425 1,
10426 "exactly one hit expected (tag-matching note)"
10427 );
10428 assert_eq!(
10429 hits[0].note_id, target.id,
10430 "tag-filtered note must be returned even when ranked below limit in raw fusion"
10431 );
10432 }
10433
10434 #[tokio::test]
10444 async fn search_notes_kind_filter_pushed_into_text_arm() {
10445 let rt = rt();
10446 let tok = NamespaceToken::local();
10447
10448 for i in 0..40 {
10449 rt.create_note(
10450 &tok,
10451 "observation",
10452 None,
10453 &format!("ref needle-93 decoy {i}"),
10454 Some(0.5),
10455 None,
10456 vec![],
10457 )
10458 .await
10459 .unwrap();
10460 }
10461
10462 let mut targets = Vec::new();
10463 for i in 0..6 {
10464 let target = rt
10465 .create_note(
10466 &tok,
10467 "task",
10468 Some(&format!("task {i} tracking the review")),
10469 &format!(
10470 "Long description number {i}: the work item needle-93 waits on the \
10471 build, the test report, the release, and the follow-up sweep of every \
10472 row that cited it; none of that shortens the row."
10473 ),
10474 Some(0.5),
10475 None,
10476 vec![],
10477 )
10478 .await
10479 .unwrap();
10480 targets.push(target.id);
10481 }
10482
10483 let hits = rt
10484 .search_notes(&tok, "needle-93", None, 8, Some("task"), false, &[], None)
10485 .await
10486 .unwrap();
10487
10488 let mut got: Vec<Uuid> = hits.iter().map(|h| h.note_id).collect();
10489 got.sort();
10490 targets.sort();
10491 assert_eq!(
10492 got,
10493 targets,
10494 "every task carrying the literal must be returned when the caller asks for tasks; \
10495 got {} hit(s)",
10496 hits.len()
10497 );
10498 }
10499
10500 #[tokio::test]
10507 async fn search_notes_props_filter_pushed_before_truncation() {
10508 let rt = rt();
10509 let tok = NamespaceToken::local();
10510
10511 rt.create_note(
10512 &tok,
10513 "observation",
10514 None,
10515 "nu xi omicron note decoy nu xi omicron note decoy nu xi omicron",
10516 Some(0.5),
10517 Some(serde_json::json!({"source": "other"})),
10518 vec![],
10519 )
10520 .await
10521 .unwrap();
10522
10523 let target = rt
10524 .create_note(
10525 &tok,
10526 "observation",
10527 None,
10528 "nu xi omicron note target",
10529 Some(0.5),
10530 Some(serde_json::json!({"source": "target"})),
10531 vec![],
10532 )
10533 .await
10534 .unwrap();
10535
10536 let filter = serde_json::json!({"source": "target"});
10537 let hits = rt
10538 .search_notes(
10539 &tok,
10540 "nu xi omicron note",
10541 None,
10542 1,
10543 None,
10544 false,
10545 &[],
10546 Some(&filter),
10547 )
10548 .await
10549 .unwrap();
10550
10551 assert_eq!(
10552 hits.len(),
10553 1,
10554 "exactly one hit expected (properties-matching note)"
10555 );
10556 assert_eq!(
10557 hits[0].note_id, target.id,
10558 "properties-filtered note must be returned even when ranked below limit"
10559 );
10560 }
10561
10562 #[tokio::test]
10563 async fn resolve_returns_entity() {
10564 let rt = rt();
10565 let tok = NamespaceToken::local();
10566 let entity = rt
10567 .create_entity(&tok, "concept", None, "LoRA", None, None, vec![])
10568 .await
10569 .unwrap();
10570
10571 let resolved = rt.resolve(&tok, entity.id).await.unwrap();
10572 match resolved {
10573 Some(Resolved::Entity(e)) => assert_eq!(e.id, entity.id),
10574 other => panic!("expected Resolved::Entity, got {:?}", other),
10575 }
10576 }
10577
10578 #[tokio::test]
10579 async fn resolve_returns_note() {
10580 let rt = rt();
10581 let tok = NamespaceToken::local();
10582 let note = rt
10583 .create_note(
10584 &tok,
10585 "observation",
10586 None,
10587 "LoRA fine-tunes LLMs with low-rank adapters",
10588 Some(0.85),
10589 None,
10590 vec![],
10591 )
10592 .await
10593 .unwrap();
10594
10595 let resolved = rt.resolve(&tok, note.id).await.unwrap();
10596 match resolved {
10597 Some(Resolved::Note(n)) => assert_eq!(n.id, note.id),
10598 other => panic!("expected Resolved::Note, got {:?}", other),
10599 }
10600 }
10601
10602 #[tokio::test]
10603 async fn resolve_returns_none_for_unknown_uuid() {
10604 let rt = rt();
10605 let tok = NamespaceToken::local();
10606 let unknown = Uuid::new_v4();
10607 let resolved = rt.resolve(&tok, unknown).await.unwrap();
10608 assert!(resolved.is_none(), "unknown UUID should resolve to None");
10609 }
10610
10611 #[tokio::test]
10612 async fn resolve_prefix_finds_entity_in_own_namespace() {
10613 let rt = rt();
10614 let tok = NamespaceToken::local();
10615 let entity = rt
10616 .create_entity(&tok, "concept", None, "PrefixTest", None, None, vec![])
10617 .await
10618 .unwrap();
10619 let prefix = &entity.id.to_string()[..8];
10620
10621 let resolved = rt.resolve_prefix(&tok, prefix).await.unwrap();
10622 assert_eq!(resolved, Some(entity.id));
10623 }
10624
10625 #[test]
10626 fn hex_prefix_to_uuid_pattern_inserts_hyphens_at_canonical_boundaries() {
10627 let full = "aabbccdd112240008000000000000ab1";
10628 let cases: &[(usize, &str)] = &[
10629 (1, "a"),
10630 (7, "aabbccd"),
10631 (8, "aabbccdd"),
10632 (9, "aabbccdd-1"),
10633 (12, "aabbccdd-1122"),
10634 (13, "aabbccdd-1122-4"),
10635 (16, "aabbccdd-1122-4000"),
10636 (18, "aabbccdd-1122-4000-80"),
10637 (20, "aabbccdd-1122-4000-8000"),
10638 (23, "aabbccdd-1122-4000-8000-000"),
10639 (24, "aabbccdd-1122-4000-8000-0000"),
10640 (28, "aabbccdd-1122-4000-8000-00000000"),
10641 (31, "aabbccdd-1122-4000-8000-000000000ab"),
10642 ];
10643 for (len, expected) in cases {
10644 let input = &full[..*len];
10645 assert_eq!(
10646 hex_prefix_to_uuid_pattern(input),
10647 *expected,
10648 "len={len} input={input:?}"
10649 );
10650 }
10651 }
10652
10653 #[test]
10654 fn hex_prefix_to_uuid_pattern_full_32_char_matches_canonical_uuid() {
10655 let compact = "aabbccdd112240008000000000000ab1";
10656 let compact32 = &compact[..32];
10657 assert_eq!(
10658 hex_prefix_to_uuid_pattern(compact32),
10659 "aabbccdd-1122-4000-8000-000000000ab1"
10660 );
10661 }
10662
10663 #[test]
10669 fn hex_prefix_to_uuid_pattern_overlong_input_is_not_truncated() {
10670 let compact32 = "aabbccdd112240008000000000000ab1";
10671 let overlong = format!("{compact32}extrahex");
10672 let pattern = hex_prefix_to_uuid_pattern(&overlong);
10673 assert_eq!(
10674 pattern, "aabbccdd-1122-4000-8000-000000000ab1extrahex",
10675 "overlong input must keep its extra chars, not truncate to the valid UUID"
10676 );
10677 assert_ne!(
10678 pattern, "aabbccdd-1122-4000-8000-000000000ab1",
10679 "overlong pattern must not collapse to the canonical 36-char UUID form"
10680 );
10681 }
10682
10683 #[test]
10684 fn hex_prefix_to_uuid_pattern_passes_through_hyphenated_input() {
10685 let hyphenated = "aabbccdd-1122-4000-8000-000000000ab1";
10686 assert_eq!(hex_prefix_to_uuid_pattern(hyphenated), hyphenated);
10687
10688 let partial = "aabbccdd-11";
10689 assert_eq!(hex_prefix_to_uuid_pattern(partial), partial);
10690 }
10691
10692 #[test]
10693 fn uuid_prefix_bounds_are_canonical_lowercase_half_open_ranges() {
10694 assert_eq!(
10695 super::uuid_prefix_bounds("0ABCDEFF"),
10696 Some(("0abcdeff".into(), "0abcdf".into()))
10697 );
10698 assert_eq!(
10699 super::uuid_prefix_bounds("aabbccdd1"),
10700 Some(("aabbccdd-1".into(), "aabbccdd-2".into()))
10701 );
10702 assert_eq!(
10703 super::uuid_prefix_bounds("AABBCCDD-19FF"),
10704 Some(("aabbccdd-19ff".into(), "aabbccdd-1a".into()))
10705 );
10706 assert_eq!(
10707 super::uuid_prefix_bounds("ffffffff"),
10708 Some(("ffffffff".into(), "g".into()))
10709 );
10710 }
10711
10712 #[test]
10713 fn uuid_prefix_bounds_reject_malformed_or_overlong_input() {
10714 for invalid in [
10715 "",
10716 "aabbccdd_",
10717 "aabbccdd1-",
10718 "aabbccdd-11111",
10719 "aabbccdd--",
10720 "aabbccdd112240008000000000000ab1f",
10721 ] {
10722 assert_eq!(
10723 super::uuid_prefix_bounds(invalid),
10724 None,
10725 "invalid prefix {invalid:?} must fail closed"
10726 );
10727 }
10728 }
10729
10730 #[tokio::test]
10731 async fn resolve_prefix_compact_9_to_31_char_matches() {
10732 let rt = rt();
10733 let tok = NamespaceToken::local();
10734 let entity = rt
10735 .create_entity(
10736 &tok,
10737 "concept",
10738 None,
10739 "CompactPrefixTest",
10740 None,
10741 None,
10742 vec![],
10743 )
10744 .await
10745 .unwrap();
10746 let compact = entity.id.simple().to_string();
10747
10748 for len in [9, 12, 16, 20, 24, 28, 31] {
10749 let prefix = &compact[..len];
10750 let resolved = rt.resolve_prefix(&tok, prefix).await.unwrap();
10751 assert_eq!(
10752 resolved,
10753 Some(entity.id),
10754 "compact prefix of len {len} should resolve"
10755 );
10756 }
10757 }
10758
10759 #[tokio::test]
10760 async fn resolve_prefix_compact_full_32_char_matches() {
10761 let rt = rt();
10762 let tok = NamespaceToken::local();
10763 let entity = rt
10764 .create_entity(&tok, "concept", None, "Full32Test", None, None, vec![])
10765 .await
10766 .unwrap();
10767 let compact = entity.id.simple().to_string();
10768 assert_eq!(compact.len(), 32);
10769
10770 let resolved = rt.resolve_prefix(&tok, &compact).await.unwrap();
10771 assert_eq!(resolved, Some(entity.id));
10772 }
10773
10774 #[tokio::test]
10777 async fn resolve_prefix_rejects_overlong_all_hex_input() {
10778 let rt = rt();
10779 let tok = NamespaceToken::local();
10780 let entity = rt
10781 .create_entity(&tok, "concept", None, "OverlongTest", None, None, vec![])
10782 .await
10783 .unwrap();
10784 let compact = entity.id.simple().to_string();
10785 assert_eq!(compact.len(), 32);
10786
10787 let overlong = format!("{compact}ab");
10788 let resolved = rt.resolve_prefix(&tok, &overlong).await.unwrap();
10789 assert_eq!(
10790 resolved, None,
10791 "a 32-char id plus extra hex chars must not resolve to the valid entity"
10792 );
10793 }
10794
10795 #[tokio::test]
10801 async fn resolve_prefix_rejects_like_wildcard_input() {
10802 let rt = rt();
10803 let tok = NamespaceToken::local();
10804 let entity = rt
10805 .create_entity(&tok, "concept", None, "WildcardTest", None, None, vec![])
10806 .await
10807 .unwrap();
10808 let compact = entity.id.simple().to_string();
10809 let wildcard_prefix = format!("{}%", &compact[..8]);
10813
10814 let resolved = rt.resolve_prefix(&tok, &wildcard_prefix).await.unwrap();
10815 assert_eq!(
10816 resolved, None,
10817 "prefix containing a LIKE wildcard must be rejected, not resolved"
10818 );
10819 }
10820
10821 #[tokio::test]
10822 async fn resolve_prefix_boundary_at_hyphen_positions() {
10823 let rt = rt();
10824 let tok = NamespaceToken::local();
10825 let entity = rt
10826 .create_entity(&tok, "concept", None, "BoundaryTest", None, None, vec![])
10827 .await
10828 .unwrap();
10829 let compact = entity.id.simple().to_string();
10830
10831 for len in [8, 12, 16, 20, 24] {
10832 let prefix = &compact[..len];
10833 let resolved = rt.resolve_prefix(&tok, prefix).await.unwrap();
10834 assert_eq!(
10835 resolved,
10836 Some(entity.id),
10837 "boundary prefix of len {len} should resolve"
10838 );
10839 }
10840 }
10841
10842 #[tokio::test]
10843 async fn resolve_prefix_range_preserves_boundaries_ambiguity_and_not_found() {
10844 use khive_storage::entity::Entity;
10845
10846 let rt = rt();
10847 let tok = NamespaceToken::local();
10848 let ids = [
10849 "0abcdefe-ffff-4fff-8fff-ffffffffffff",
10850 "0abcdeff-0000-4000-8000-000000000001",
10851 "0abcdeff-1000-4000-8000-000000000002",
10852 "0abcdf00-0000-4000-8000-000000000003",
10853 ];
10854 let store = rt.entities(&tok).unwrap();
10855 for (index, id) in ids.into_iter().enumerate() {
10856 let mut entity = Entity::new("local", "concept", format!("RangeControl{index}"));
10857 entity.id = Uuid::parse_str(id).unwrap();
10858 store.upsert_entity(entity).await.unwrap();
10859 }
10860
10861 let unique = rt.resolve_prefix(&tok, "0abcdeff0").await.unwrap();
10862 assert_eq!(unique, Some(Uuid::parse_str(ids[1]).unwrap()));
10863
10864 let ambiguous = rt.resolve_prefix(&tok, "0abcdeff").await.unwrap_err();
10865 assert!(
10866 matches!(
10867 ambiguous,
10868 RuntimeError::AmbiguousPrefix { ref matches, .. } if matches.len() == 2
10869 ),
10870 "the two in-range UUIDs must remain ambiguous: {ambiguous:?}"
10871 );
10872
10873 let missing = rt.resolve_prefix(&tok, "0abcdeff2").await.unwrap();
10874 assert_eq!(missing, None, "adjacent UUIDs must not become prefix hits");
10875 }
10876
10877 #[tokio::test]
10878 async fn resolve_prefix_ambiguous_still_detected_after_normalization() {
10879 use khive_storage::entity::Entity;
10880
10881 let rt = rt();
10882 let tok = NamespaceToken::local();
10883 let id_a = Uuid::parse_str("aabbccdd-1111-4000-8000-000000000001").unwrap();
10884 let id_b = Uuid::parse_str("aabbccdd-1111-4000-8000-000000000002").unwrap();
10885
10886 let mut entity_a = Entity::new("local", "concept", "AmbigCompactA");
10887 entity_a.id = id_a;
10888 let mut entity_b = Entity::new("local", "concept", "AmbigCompactB");
10889 entity_b.id = id_b;
10890
10891 let store = rt.entities(&tok).unwrap();
10892 store.upsert_entity(entity_a).await.unwrap();
10893 store.upsert_entity(entity_b).await.unwrap();
10894
10895 let shared_compact = &id_a.simple().to_string()[..20];
10897 let err = rt.resolve_prefix(&tok, shared_compact).await.unwrap_err();
10898 assert!(
10899 matches!(
10900 err,
10901 RuntimeError::AmbiguousPrefix { ref matches, .. } if matches.len() == 2
10902 ),
10903 "shared compact prefix must still return AmbiguousPrefix; got {err:?}"
10904 );
10905 }
10906
10907 #[tokio::test]
10908 async fn resolve_prefix_invisible_across_namespaces() {
10909 let rt = rt();
10910 let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
10911 let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
10912 let entity = rt
10913 .create_entity(&ns_a, "concept", None, "Invisible", None, None, vec![])
10914 .await
10915 .unwrap();
10916 let prefix = &entity.id.to_string()[..8];
10917
10918 let resolved = rt.resolve_prefix(&ns_b, prefix).await.unwrap();
10920 assert_eq!(resolved, None);
10921 }
10922
10923 #[tokio::test]
10924 async fn resolve_prefix_ambiguous_same_namespace() {
10925 use khive_storage::entity::Entity;
10926
10927 let rt = rt();
10928 let tok = NamespaceToken::local();
10929 let id_a = Uuid::parse_str("aabbccdd-1111-4000-8000-000000000001").unwrap();
10931 let id_b = Uuid::parse_str("aabbccdd-2222-4000-8000-000000000002").unwrap();
10932
10933 let mut entity_a = Entity::new("local", "concept", "AmbigA");
10934 entity_a.id = id_a;
10935 let mut entity_b = Entity::new("local", "concept", "AmbigB");
10936 entity_b.id = id_b;
10937
10938 let store = rt.entities(&tok).unwrap();
10939 store.upsert_entity(entity_a).await.unwrap();
10940 store.upsert_entity(entity_b).await.unwrap();
10941
10942 let err = rt.resolve_prefix(&tok, "aabbccdd").await.unwrap_err();
10943 assert!(
10944 matches!(
10945 err,
10946 RuntimeError::AmbiguousPrefix { ref prefix, ref matches }
10947 if prefix == "aabbccdd" && matches.len() == 2
10948 ),
10949 "shared 8-char prefix must return AmbiguousPrefix; got {err:?}"
10950 );
10951 }
10952
10953 #[tokio::test]
10958 async fn resolve_prefix_cross_table_duplicate_uuid_resolves_cleanly() {
10959 use khive_storage::entity::Entity;
10960
10961 let rt = rt();
10962 let tok = NamespaceToken::local();
10963 let shared_id = Uuid::parse_str("ccddeeff-1111-4000-8000-000000000001").unwrap();
10964
10965 let mut entity = Entity::new("local", "concept", "Nvk749Entity");
10966 entity.id = shared_id;
10967 rt.entities(&tok)
10968 .unwrap()
10969 .upsert_entity(entity)
10970 .await
10971 .unwrap();
10972
10973 let mut note = Note::new("local", "observation", "nvk749 note with the same id");
10974 note.id = shared_id;
10975 rt.notes(&tok).unwrap().upsert_note(note).await.unwrap();
10976
10977 let resolved = rt
10978 .resolve_prefix(&tok, "ccddeeff")
10979 .await
10980 .expect("#749: a UUID present in two tables must not be reported as ambiguous");
10981 assert_eq!(
10982 resolved,
10983 Some(shared_id),
10984 "#749: cross-table duplicate must resolve to the single shared UUID"
10985 );
10986 }
10987
10988 #[tokio::test]
10996 async fn resolve_prefix_early_exit_uses_deduped_match_count() {
10997 use khive_storage::entity::Entity;
10998
10999 let rt = rt();
11000 let tok = NamespaceToken::local();
11001 let shared_id = Uuid::parse_str("ddeeff11-2222-4000-8000-000000000002").unwrap();
11002
11003 let mut entity = Entity::new("local", "concept", "Nvk749bEntity");
11008 entity.id = shared_id;
11009 rt.entities(&tok)
11010 .unwrap()
11011 .upsert_entity(entity)
11012 .await
11013 .unwrap();
11014
11015 let mut note = Note::new("local", "observation", "nvk749b note with the same id");
11016 note.id = shared_id;
11017 rt.notes(&tok).unwrap().upsert_note(note).await.unwrap();
11018
11019 let resolved = rt
11020 .resolve_prefix(&tok, "ddeeff11")
11021 .await
11022 .expect("#749: deduped early-exit must not falsely report ambiguity");
11023 assert_eq!(resolved, Some(shared_id));
11024 }
11025
11026 #[tokio::test]
11033 async fn resolve_finds_event_by_full_uuid() {
11034 use khive_storage::Event;
11035 use khive_types::{EventKind, SubstrateKind};
11036
11037 let rt = rt();
11038 let tok = NamespaceToken::local();
11039 let ns = tok.namespace().as_str();
11040 let event = Event::new(
11041 ns,
11042 "test_verb",
11043 EventKind::Audit,
11044 SubstrateKind::Entity,
11045 "actor",
11046 );
11047 let event_id = event.id;
11048 rt.events(&tok).unwrap().append_event(event).await.unwrap();
11049
11050 let resolved = rt.resolve(&tok, event_id).await.unwrap();
11051 assert!(
11052 matches!(resolved, Some(Resolved::Event(_))),
11053 "event UUID must resolve to Resolved::Event, got {resolved:?}"
11054 );
11055 }
11056
11057 #[tokio::test]
11058 async fn resolve_prefix_finds_event() {
11059 use khive_storage::Event;
11060 use khive_types::{EventKind, SubstrateKind};
11061
11062 let rt = rt();
11063 let tok = NamespaceToken::local();
11064 let ns = tok.namespace().as_str();
11065 let event = Event::new(
11066 ns,
11067 "test_verb",
11068 EventKind::Audit,
11069 SubstrateKind::Entity,
11070 "actor",
11071 );
11072 let event_id = event.id;
11073 rt.events(&tok).unwrap().append_event(event).await.unwrap();
11074
11075 let prefix = &event_id.to_string()[..8];
11076 let resolved = rt.resolve_prefix(&tok, prefix).await.unwrap();
11077 assert_eq!(
11078 resolved,
11079 Some(event_id),
11080 "resolve_prefix must return event UUID for 8-char prefix"
11081 );
11082 }
11083
11084 #[tokio::test]
11087 async fn link_phantom_source_returns_not_found() {
11088 let rt = rt();
11089 let tok = NamespaceToken::local();
11090 let b = rt
11091 .create_entity(&tok, "concept", None, "B", None, None, vec![])
11092 .await
11093 .unwrap();
11094 let phantom = Uuid::new_v4();
11095
11096 let result = rt
11097 .link(&tok, phantom, b.id, EdgeRelation::Extends, 1.0, None)
11098 .await;
11099 match result {
11100 Err(RuntimeError::NotFound(msg)) => {
11101 assert!(
11102 msg.contains("source"),
11103 "error message must name 'source': {msg}"
11104 );
11105 }
11106 other => panic!("expected NotFound for phantom source, got {other:?}"),
11107 }
11108 }
11109
11110 #[tokio::test]
11111 async fn link_phantom_target_returns_not_found() {
11112 let rt = rt();
11113 let tok = NamespaceToken::local();
11114 let a = rt
11115 .create_entity(&tok, "concept", None, "A", None, None, vec![])
11116 .await
11117 .unwrap();
11118 let phantom = Uuid::new_v4();
11119
11120 let result = rt
11121 .link(&tok, a.id, phantom, EdgeRelation::Extends, 1.0, None)
11122 .await;
11123 match result {
11124 Err(RuntimeError::NotFound(msg)) => {
11125 assert!(
11126 msg.contains("target"),
11127 "error message must name 'target': {msg}"
11128 );
11129 }
11130 other => panic!("expected NotFound for phantom target, got {other:?}"),
11131 }
11132 }
11133
11134 #[tokio::test]
11135 async fn link_real_entities_succeeds() {
11136 let rt = rt();
11137 let tok = NamespaceToken::local();
11138 let a = rt
11139 .create_entity(&tok, "concept", None, "A", None, None, vec![])
11140 .await
11141 .unwrap();
11142 let b = rt
11143 .create_entity(&tok, "concept", None, "B", None, None, vec![])
11144 .await
11145 .unwrap();
11146
11147 let edge = rt
11148 .link(&tok, a.id, b.id, EdgeRelation::Extends, 0.8, None)
11149 .await
11150 .unwrap();
11151 assert_eq!(edge.source_id, a.id);
11152 assert_eq!(edge.target_id, b.id);
11153 assert_eq!(edge.relation, EdgeRelation::Extends);
11154 }
11155
11156 #[tokio::test]
11163 async fn link_write_time_guard_blocks_dangling_edge_after_target_vanishes() {
11164 let rt = rt();
11165 let tok = NamespaceToken::local();
11166 let a = rt
11167 .create_entity(&tok, "concept", None, "A", None, None, vec![])
11168 .await
11169 .unwrap();
11170 let x = rt
11171 .create_entity(&tok, "concept", None, "X", None, None, vec![])
11172 .await
11173 .unwrap();
11174
11175 rt.validate_edge_relation_endpoints(&tok, a.id, x.id, EdgeRelation::Extends)
11176 .await
11177 .expect("prepare-time validation must pass while X is live");
11178
11179 assert!(rt.delete_entity(&tok, x.id, true).await.unwrap());
11180
11181 let now = chrono::Utc::now();
11182 let edge = Edge {
11183 id: LinkId::from(Uuid::new_v4()),
11184 namespace: tok.namespace().as_str().to_string(),
11185 source_id: a.id,
11186 target_id: x.id,
11187 relation: EdgeRelation::Extends,
11188 weight: 1.0,
11189 created_at: now,
11190 updated_at: now,
11191 deleted_at: None,
11192 metadata: None,
11193 target_backend: None,
11194 };
11195 let outcome = rt
11196 .graph(&tok)
11197 .unwrap()
11198 .upsert_edge_guarded(edge)
11199 .await
11200 .unwrap();
11201 match outcome {
11202 khive_storage::GuardedWriteOutcome::Refused(missing) => {
11203 assert!(missing.target, "target must be reported missing");
11204 }
11205 other => panic!(
11206 "guarded write must refuse an edge whose target vanished before commit, got {other:?}"
11207 ),
11208 }
11209
11210 let edges = rt
11211 .list_edges(
11212 &tok,
11213 crate::curation::EdgeListFilter {
11214 source_id: Some(a.id),
11215 target_id: Some(x.id),
11216 relations: vec![EdgeRelation::Extends],
11217 ..Default::default()
11218 },
11219 10,
11220 0,
11221 )
11222 .await
11223 .unwrap();
11224 assert!(
11225 edges.is_empty(),
11226 "no dangling edge may be persisted after the guarded write refused it"
11227 );
11228 }
11229
11230 #[tokio::test]
11231 async fn link_many_writes_nothing_when_one_target_vanishes_before_write() {
11232 let rt = rt();
11233 let tok = NamespaceToken::local();
11234 let a = rt
11235 .create_entity(&tok, "concept", None, "A", None, None, vec![])
11236 .await
11237 .unwrap();
11238 let b = rt
11239 .create_entity(&tok, "concept", None, "B", None, None, vec![])
11240 .await
11241 .unwrap();
11242 let x = rt
11243 .create_entity(&tok, "concept", None, "X", None, None, vec![])
11244 .await
11245 .unwrap();
11246
11247 let specs = vec![
11248 LinkSpec {
11249 namespace: None,
11250 source_id: a.id,
11251 target_id: x.id,
11252 relation: EdgeRelation::Extends,
11253 weight: 1.0,
11254 metadata: None,
11255 resurrect: false,
11256 },
11257 LinkSpec {
11258 namespace: None,
11259 source_id: a.id,
11260 target_id: b.id,
11261 relation: EdgeRelation::Extends,
11262 weight: 1.0,
11263 metadata: None,
11264 resurrect: false,
11265 },
11266 ];
11267
11268 let mut edges = Vec::with_capacity(specs.len());
11270 for spec in &specs {
11271 edges.push(rt.build_edge(&tok, spec).await.unwrap());
11272 }
11273
11274 assert!(rt.delete_entity(&tok, x.id, true).await.unwrap());
11278
11279 let outcome = rt
11280 .graph(&tok)
11281 .unwrap()
11282 .upsert_edges_guarded(edges)
11283 .await
11284 .unwrap();
11285 assert_eq!(
11286 outcome.summary.affected, 0,
11287 "no edge from the batch may be persisted when any endpoint vanished"
11288 );
11289 assert!(
11290 outcome.refused.is_some(),
11291 "refused batch entry must be reported"
11292 );
11293
11294 let edges = rt
11295 .list_edges(
11296 &tok,
11297 crate::curation::EdgeListFilter {
11298 source_id: Some(a.id),
11299 relations: vec![EdgeRelation::Extends],
11300 ..Default::default()
11301 },
11302 10,
11303 0,
11304 )
11305 .await
11306 .unwrap();
11307 assert!(
11308 edges.is_empty(),
11309 "link_many's guarded batch must be all-or-nothing: the live A-B edge \
11310 must not have been persisted alongside the doomed A-X edge"
11311 );
11312 }
11313
11314 #[tokio::test]
11315 async fn link_many_reverse_symmetric_refusal_reports_canonical_missing_endpoint() {
11316 for relation in [EdgeRelation::CompetesWith, EdgeRelation::ComposedWith] {
11317 for delete_source in [true, false] {
11318 let rt = rt();
11319 let tok = NamespaceToken::local();
11320 let a = rt
11321 .create_entity(&tok, "concept", None, "A", None, None, vec![])
11322 .await
11323 .unwrap();
11324 let b = rt
11325 .create_entity(&tok, "concept", None, "B", None, None, vec![])
11326 .await
11327 .unwrap();
11328 let (source_id, target_id) = (a.id.min(b.id), a.id.max(b.id));
11329 let spec = LinkSpec {
11330 namespace: None,
11331 source_id: target_id,
11332 target_id: source_id,
11333 relation,
11334 weight: 1.0,
11335 metadata: None,
11336 resurrect: false,
11337 };
11338 assert!(spec.source_id > spec.target_id);
11339 let edge = rt.build_edge(&tok, &spec).await.unwrap();
11340 assert_eq!((edge.source_id, edge.target_id), (source_id, target_id));
11341
11342 let deleted_id = if delete_source { source_id } else { target_id };
11343 assert!(rt.delete_entity(&tok, deleted_id, true).await.unwrap());
11344 let outcome = rt
11345 .graph(&tok)
11346 .unwrap()
11347 .upsert_edges_guarded_observed(vec![EdgeUpsertRequest {
11348 edge,
11349 resurrect: false,
11350 }])
11351 .await
11352 .unwrap();
11353 assert!(outcome.rows.is_empty());
11354 let refusal = outcome.refusal.expect("the deleted endpoint must refuse");
11355 let EdgeUpsertRefusal::MissingEndpoints(missing) = refusal.reason else {
11356 panic!("expected a missing-endpoint refusal");
11357 };
11358 assert_eq!(missing.source, delete_source);
11359 assert_eq!(missing.target, !delete_source);
11360
11361 let failure = guarded_link_batch_failure(&spec, refusal.entry_index, missing);
11362 assert_eq!(failure.entry_index, Some(0));
11363 assert_eq!(failure.missing_source, delete_source.then_some(deleted_id));
11364 assert_eq!(
11365 failure.missing_target,
11366 (!delete_source).then_some(deleted_id)
11367 );
11368 assert!(rt
11369 .list_edges(&tok, EdgeListFilter::default(), 10, 0)
11370 .await
11371 .unwrap()
11372 .is_empty());
11373 }
11374 }
11375 }
11376
11377 fn raw_edge(source_id: Uuid, target_id: Uuid, ns: &str) -> Edge {
11388 let now = chrono::Utc::now();
11389 Edge {
11390 id: LinkId::from(Uuid::new_v4()),
11391 namespace: ns.to_string(),
11392 source_id,
11393 target_id,
11394 relation: EdgeRelation::Extends,
11395 weight: 1.0,
11396 created_at: now,
11397 updated_at: now,
11398 deleted_at: None,
11399 metadata: None,
11400 target_backend: None,
11401 }
11402 }
11403
11404 async fn assert_no_edges_touch(rt: &KhiveRuntime, tok: &NamespaceToken, node_id: Uuid) {
11405 let as_source = rt
11406 .list_edges(
11407 tok,
11408 crate::curation::EdgeListFilter {
11409 source_id: Some(node_id),
11410 ..Default::default()
11411 },
11412 10,
11413 0,
11414 )
11415 .await
11416 .unwrap();
11417 let as_target = rt
11418 .list_edges(
11419 tok,
11420 crate::curation::EdgeListFilter {
11421 target_id: Some(node_id),
11422 ..Default::default()
11423 },
11424 10,
11425 0,
11426 )
11427 .await
11428 .unwrap();
11429 assert!(
11430 as_source.is_empty() && as_target.is_empty(),
11431 "no edge may reference hard-deleted node {node_id}: source-side={as_source:?} \
11432 target-side={as_target:?}"
11433 );
11434 }
11435
11436 #[tokio::test]
11437 async fn hard_delete_entity_purges_edge_written_before_delete() {
11438 let rt = rt();
11439 let tok = NamespaceToken::local();
11440 let a = rt
11441 .create_entity(&tok, "concept", None, "A", None, None, vec![])
11442 .await
11443 .unwrap();
11444 let x = rt
11445 .create_entity(&tok, "concept", None, "X", None, None, vec![])
11446 .await
11447 .unwrap();
11448
11449 let edge = raw_edge(a.id, x.id, tok.namespace().as_str());
11450 assert_eq!(
11451 rt.graph(&tok)
11452 .unwrap()
11453 .upsert_edge_guarded(edge)
11454 .await
11455 .unwrap(),
11456 khive_storage::GuardedWriteOutcome::Written
11457 );
11458
11459 assert!(rt.delete_entity(&tok, x.id, true).await.unwrap());
11460 assert_no_edges_touch(&rt, &tok, x.id).await;
11461 }
11462
11463 #[tokio::test]
11464 async fn hard_delete_entity_concurrent_with_guarded_write_never_leaves_dangling_edge() {
11465 let rt = std::sync::Arc::new(rt());
11466 let tok = NamespaceToken::local();
11467 let a = rt
11468 .create_entity(&tok, "concept", None, "A", None, None, vec![])
11469 .await
11470 .unwrap();
11471 let x = rt
11472 .create_entity(&tok, "concept", None, "X", None, None, vec![])
11473 .await
11474 .unwrap();
11475
11476 let delete_rt = std::sync::Arc::clone(&rt);
11477 let delete_tok = tok.clone();
11478 let delete_task =
11479 tokio::spawn(async move { delete_rt.delete_entity(&delete_tok, x.id, true).await });
11480
11481 let write_rt = std::sync::Arc::clone(&rt);
11482 let write_tok = tok.clone();
11483 let ns = tok.namespace().as_str().to_string();
11484 let write_task = tokio::spawn(async move {
11485 let edge = raw_edge(a.id, x.id, &ns);
11486 write_rt
11487 .graph(&write_tok)
11488 .unwrap()
11489 .upsert_edge_guarded(edge)
11490 .await
11491 });
11492
11493 let (deleted, _written) = tokio::join!(delete_task, write_task);
11494 deleted.unwrap().unwrap();
11495 assert_no_edges_touch(&rt, &tok, x.id).await;
11496 }
11497
11498 #[tokio::test]
11499 async fn hard_delete_note_purges_edge_written_before_delete() {
11500 let rt = rt();
11501 let tok = NamespaceToken::local();
11502 let a = rt
11503 .create_entity(&tok, "concept", None, "A", None, None, vec![])
11504 .await
11505 .unwrap();
11506 let n = rt
11507 .create_note(
11508 &tok,
11509 "observation",
11510 None,
11511 "note content",
11512 None,
11513 None,
11514 vec![],
11515 )
11516 .await
11517 .unwrap();
11518
11519 let edge = raw_edge(a.id, n.id, tok.namespace().as_str());
11520 assert_eq!(
11521 rt.graph(&tok)
11522 .unwrap()
11523 .upsert_edge_guarded(edge)
11524 .await
11525 .unwrap(),
11526 khive_storage::GuardedWriteOutcome::Written
11527 );
11528
11529 assert!(rt.delete_note(&tok, n.id, true).await.unwrap());
11530 assert_no_edges_touch(&rt, &tok, n.id).await;
11531 }
11532
11533 #[tokio::test]
11534 async fn hard_delete_note_concurrent_with_guarded_write_never_leaves_dangling_edge() {
11535 let rt = std::sync::Arc::new(rt());
11536 let tok = NamespaceToken::local();
11537 let a = rt
11538 .create_entity(&tok, "concept", None, "A", None, None, vec![])
11539 .await
11540 .unwrap();
11541 let n = rt
11542 .create_note(
11543 &tok,
11544 "observation",
11545 None,
11546 "note content",
11547 None,
11548 None,
11549 vec![],
11550 )
11551 .await
11552 .unwrap();
11553
11554 let delete_rt = std::sync::Arc::clone(&rt);
11555 let delete_tok = tok.clone();
11556 let note_id = n.id;
11557 let delete_task =
11558 tokio::spawn(async move { delete_rt.delete_note(&delete_tok, note_id, true).await });
11559
11560 let write_rt = std::sync::Arc::clone(&rt);
11561 let write_tok = tok.clone();
11562 let ns = tok.namespace().as_str().to_string();
11563 let write_task = tokio::spawn(async move {
11564 let edge = raw_edge(a.id, note_id, &ns);
11565 write_rt
11566 .graph(&write_tok)
11567 .unwrap()
11568 .upsert_edge_guarded(edge)
11569 .await
11570 });
11571
11572 let (deleted, _written) = tokio::join!(delete_task, write_task);
11573 deleted.unwrap().unwrap();
11574 assert_no_edges_touch(&rt, &tok, note_id).await;
11575 }
11576
11577 #[tokio::test]
11578 async fn hard_delete_edge_endpoint_purges_annotating_edge_written_before_delete() {
11579 let rt = rt();
11580 let tok = NamespaceToken::local();
11581 let a = rt
11582 .create_entity(&tok, "concept", None, "A", None, None, vec![])
11583 .await
11584 .unwrap();
11585 let b = rt
11586 .create_entity(&tok, "concept", None, "B", None, None, vec![])
11587 .await
11588 .unwrap();
11589 let n = rt
11590 .create_note(
11591 &tok,
11592 "observation",
11593 None,
11594 "note content",
11595 None,
11596 None,
11597 vec![],
11598 )
11599 .await
11600 .unwrap();
11601 let base_edge = rt
11602 .link(&tok, a.id, b.id, EdgeRelation::Extends, 0.8, None)
11603 .await
11604 .unwrap();
11605 let base_edge_id = Uuid::from(base_edge.id);
11606
11607 let annotating = raw_edge(n.id, base_edge_id, tok.namespace().as_str());
11610 assert_eq!(
11611 rt.graph(&tok)
11612 .unwrap()
11613 .upsert_edge_guarded(annotating)
11614 .await
11615 .unwrap(),
11616 khive_storage::GuardedWriteOutcome::Written
11617 );
11618
11619 assert!(rt.delete_edge(&tok, base_edge_id, true).await.unwrap());
11620 assert_no_edges_touch(&rt, &tok, base_edge_id).await;
11621 }
11622
11623 #[tokio::test]
11624 async fn hard_delete_edge_endpoint_concurrent_with_guarded_write_never_leaves_dangling_edge() {
11625 let rt = std::sync::Arc::new(rt());
11626 let tok = NamespaceToken::local();
11627 let a = rt
11628 .create_entity(&tok, "concept", None, "A", None, None, vec![])
11629 .await
11630 .unwrap();
11631 let b = rt
11632 .create_entity(&tok, "concept", None, "B", None, None, vec![])
11633 .await
11634 .unwrap();
11635 let n = rt
11636 .create_note(
11637 &tok,
11638 "observation",
11639 None,
11640 "note content",
11641 None,
11642 None,
11643 vec![],
11644 )
11645 .await
11646 .unwrap();
11647 let base_edge = rt
11648 .link(&tok, a.id, b.id, EdgeRelation::Extends, 0.8, None)
11649 .await
11650 .unwrap();
11651 let base_edge_id = Uuid::from(base_edge.id);
11652
11653 let delete_rt = std::sync::Arc::clone(&rt);
11654 let delete_tok = tok.clone();
11655 let delete_task =
11656 tokio::spawn(
11657 async move { delete_rt.delete_edge(&delete_tok, base_edge_id, true).await },
11658 );
11659
11660 let write_rt = std::sync::Arc::clone(&rt);
11661 let write_tok = tok.clone();
11662 let ns = tok.namespace().as_str().to_string();
11663 let write_task = tokio::spawn(async move {
11664 let edge = raw_edge(n.id, base_edge_id, &ns);
11665 write_rt
11666 .graph(&write_tok)
11667 .unwrap()
11668 .upsert_edge_guarded(edge)
11669 .await
11670 });
11671
11672 let (deleted, _written) = tokio::join!(delete_task, write_task);
11673 deleted.unwrap().unwrap();
11674 assert_no_edges_touch(&rt, &tok, base_edge_id).await;
11675 }
11676
11677 fn file_backed_runtime(
11686 dir: &tempfile::TempDir,
11687 name: &str,
11688 write_queue_enabled: bool,
11689 ) -> KhiveRuntime {
11690 let path = dir.path().join(name);
11691 if write_queue_enabled {
11692 std::env::set_var("KHIVE_WRITE_QUEUE", "1");
11693 } else {
11694 std::env::remove_var("KHIVE_WRITE_QUEUE");
11695 }
11696 let rt = KhiveRuntime::new_for_test(crate::config::RuntimeConfig {
11697 db_path: Some(path),
11698 packs: vec!["kg".to_string()],
11699 brain_profile: None,
11700 actor_id: None,
11701 ..crate::config::RuntimeConfig::no_embeddings()
11702 })
11703 .unwrap();
11704 std::env::remove_var("KHIVE_WRITE_QUEUE");
11705 rt
11706 }
11707
11708 async fn assert_guarded_write_committed_before_delete_is_swept(rt: &KhiveRuntime) {
11709 let tok = NamespaceToken::local();
11710 let a = rt
11711 .create_entity(&tok, "concept", None, "A", None, None, vec![])
11712 .await
11713 .unwrap();
11714 let x = rt
11715 .create_entity(&tok, "concept", None, "X", None, None, vec![])
11716 .await
11717 .unwrap();
11718
11719 let edge = raw_edge(a.id, x.id, tok.namespace().as_str());
11722 assert_eq!(
11723 rt.graph(&tok)
11724 .unwrap()
11725 .upsert_edge_guarded(edge)
11726 .await
11727 .unwrap(),
11728 khive_storage::GuardedWriteOutcome::Written,
11729 "write must succeed while both endpoints are still live"
11730 );
11731
11732 assert!(rt.delete_entity(&tok, x.id, true).await.unwrap());
11733 assert_no_edges_touch(rt, &tok, x.id).await;
11734 }
11735
11736 async fn assert_guarded_write_attempted_after_delete_is_refused(rt: &KhiveRuntime) {
11737 let tok = NamespaceToken::local();
11738 let a = rt
11739 .create_entity(&tok, "concept", None, "A", None, None, vec![])
11740 .await
11741 .unwrap();
11742 let x = rt
11743 .create_entity(&tok, "concept", None, "X", None, None, vec![])
11744 .await
11745 .unwrap();
11746
11747 assert!(rt.delete_entity(&tok, x.id, true).await.unwrap());
11750
11751 let edge = raw_edge(a.id, x.id, tok.namespace().as_str());
11752 let outcome = rt
11753 .graph(&tok)
11754 .unwrap()
11755 .upsert_edge_guarded(edge)
11756 .await
11757 .unwrap();
11758 match outcome {
11759 khive_storage::GuardedWriteOutcome::Refused(missing) => {
11760 assert!(
11761 missing.target,
11762 "target must be reported missing once the delete has committed"
11763 );
11764 assert!(!missing.source, "source was never deleted");
11765 }
11766 other => panic!(
11767 "guarded write attempted after the delete committed must be refused, got {other:?}"
11768 ),
11769 }
11770 assert_no_edges_touch(rt, &tok, x.id).await;
11771 }
11772
11773 #[tokio::test]
11774 #[serial_test::serial(khive_write_queue_env)]
11775 async fn guarded_write_before_delete_swept_file_backed_write_queue_off() {
11776 let dir = tempfile::tempdir().unwrap();
11777 let rt = file_backed_runtime(&dir, "guard_before_off.db", false);
11778 assert_guarded_write_committed_before_delete_is_swept(&rt).await;
11779 }
11780
11781 #[tokio::test]
11782 #[serial_test::serial(khive_write_queue_env)]
11783 async fn guarded_write_after_delete_refused_file_backed_write_queue_off() {
11784 let dir = tempfile::tempdir().unwrap();
11785 let rt = file_backed_runtime(&dir, "guard_after_off.db", false);
11786 assert_guarded_write_attempted_after_delete_is_refused(&rt).await;
11787 }
11788
11789 #[tokio::test]
11790 #[serial_test::serial(khive_write_queue_env)]
11791 async fn guarded_write_before_delete_swept_file_backed_write_queue_on() {
11792 let dir = tempfile::tempdir().unwrap();
11793 let rt = file_backed_runtime(&dir, "guard_before_on.db", true);
11794 assert_guarded_write_committed_before_delete_is_swept(&rt).await;
11795 }
11796
11797 #[tokio::test]
11798 #[serial_test::serial(khive_write_queue_env)]
11799 async fn guarded_write_after_delete_refused_file_backed_write_queue_on() {
11800 let dir = tempfile::tempdir().unwrap();
11801 let rt = file_backed_runtime(&dir, "guard_after_on.db", true);
11802 assert_guarded_write_attempted_after_delete_is_refused(&rt).await;
11803 }
11804
11805 #[tokio::test]
11806 async fn create_note_annotates_phantom_returns_not_found() {
11807 let rt = rt();
11808 let tok = NamespaceToken::local();
11809 let phantom = Uuid::new_v4();
11810
11811 let result = rt
11812 .create_note(
11813 &tok,
11814 "observation",
11815 None,
11816 "some content",
11817 Some(0.5),
11818 None,
11819 vec![phantom],
11820 )
11821 .await;
11822 assert!(
11823 matches!(result, Err(RuntimeError::NotFound(_))),
11824 "annotates with phantom uuid must return NotFound, got {result:?}"
11825 );
11826 }
11827
11828 #[tokio::test]
11829 async fn create_note_annotates_real_entity_succeeds() {
11830 let rt = rt();
11831 let tok = NamespaceToken::local();
11832 let entity = rt
11833 .create_entity(&tok, "concept", None, "RealTarget", None, None, vec![])
11834 .await
11835 .unwrap();
11836
11837 let note = rt
11838 .create_note(
11839 &tok,
11840 "observation",
11841 None,
11842 "content",
11843 Some(0.5),
11844 None,
11845 vec![entity.id],
11846 )
11847 .await
11848 .unwrap();
11849
11850 let neighbors = rt
11851 .neighbors(
11852 &tok,
11853 note.id,
11854 Direction::Out,
11855 None,
11856 Some(vec![EdgeRelation::Annotates]),
11857 )
11858 .await
11859 .unwrap();
11860 assert_eq!(neighbors.len(), 1);
11861 assert_eq!(neighbors[0].node_id, entity.id);
11862 }
11863
11864 #[tokio::test]
11866 async fn create_note_multi_annotates_creates_all_edges() {
11867 let rt = rt();
11868 let tok = NamespaceToken::local();
11869 let t1 = rt
11870 .create_entity(&tok, "concept", None, "Target1", None, None, vec![])
11871 .await
11872 .unwrap();
11873 let t2 = rt
11874 .create_entity(&tok, "concept", None, "Target2", None, None, vec![])
11875 .await
11876 .unwrap();
11877
11878 let note = rt
11879 .create_note(
11880 &tok,
11881 "observation",
11882 None,
11883 "content",
11884 Some(0.5),
11885 None,
11886 vec![t1.id, t2.id],
11887 )
11888 .await
11889 .unwrap();
11890
11891 let neighbors = rt
11892 .neighbors(
11893 &tok,
11894 note.id,
11895 Direction::Out,
11896 None,
11897 Some(vec![EdgeRelation::Annotates]),
11898 )
11899 .await
11900 .unwrap();
11901 assert_eq!(
11902 neighbors.len(),
11903 2,
11904 "multi-annotates note must have exactly 2 outbound annotates edges"
11905 );
11906 let target_ids: Vec<Uuid> = neighbors.iter().map(|n| n.node_id).collect();
11907 assert!(target_ids.contains(&t1.id));
11908 assert!(target_ids.contains(&t2.id));
11909 }
11910
11911 #[tokio::test]
11922 async fn get_edge_by_natural_key_including_deleted_honors_explicit_namespace_not_token() {
11923 let rt = rt();
11924 let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
11925 let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
11926
11927 let a = rt
11928 .create_entity(&ns_b, "concept", None, "A", None, None, vec![])
11929 .await
11930 .unwrap();
11931 let b = rt
11932 .create_entity(&ns_b, "concept", None, "B", None, None, vec![])
11933 .await
11934 .unwrap();
11935 let edge = rt
11936 .link(&ns_b, a.id, b.id, EdgeRelation::CompetesWith, 1.0, None)
11937 .await
11938 .unwrap();
11939 let (canon_src, canon_tgt) =
11940 canonical_edge_endpoints(EdgeRelation::CompetesWith, a.id, b.id);
11941
11942 let found = rt
11945 .get_edge_by_natural_key_including_deleted(
11946 &ns_a,
11947 "ns-b",
11948 canon_src,
11949 canon_tgt,
11950 EdgeRelation::CompetesWith,
11951 )
11952 .await
11953 .unwrap();
11954 assert_eq!(
11955 found.map(|e| Uuid::from(e.id)),
11956 Some(Uuid::from(edge.id)),
11957 "must find the edge by its own namespace regardless of the caller's token"
11958 );
11959
11960 let not_found = rt
11964 .get_edge_by_natural_key_including_deleted(
11965 &ns_a,
11966 "ns-a",
11967 canon_src,
11968 canon_tgt,
11969 EdgeRelation::CompetesWith,
11970 )
11971 .await
11972 .unwrap();
11973 assert!(
11974 not_found.is_none(),
11975 "must not find an edge recorded in a different namespace than the one queried"
11976 );
11977 }
11978
11979 #[tokio::test]
11983 async fn link_target_in_different_namespace_succeeds() {
11984 let rt = rt();
11985 let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
11986 let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
11987 let a = rt
11988 .create_entity(&ns_a, "concept", None, "A", None, None, vec![])
11989 .await
11990 .unwrap();
11991 let b = rt
11992 .create_entity(&ns_b, "concept", None, "B", None, None, vec![])
11993 .await
11994 .unwrap();
11995
11996 let result = rt
11998 .link(&ns_a, a.id, b.id, EdgeRelation::Extends, 1.0, None)
11999 .await;
12000 assert!(
12001 result.is_ok(),
12002 "target in a different namespace than the caller must resolve (#631), got {result:?}"
12003 );
12004 }
12005
12006 #[tokio::test]
12007 async fn link_phantom_self_loop_returns_invalid_input() {
12008 let rt = rt();
12009 let tok = NamespaceToken::local();
12010 let phantom = Uuid::new_v4();
12011
12012 let result = rt
12013 .link(&tok, phantom, phantom, EdgeRelation::Extends, 1.0, None)
12014 .await;
12015 match result {
12016 Err(RuntimeError::InvalidInput(msg)) => {
12017 assert!(
12018 msg.contains("self-loop"),
12019 "self-loop must be rejected with self-loop message: {msg}"
12020 );
12021 }
12022 other => panic!("expected InvalidInput for self-loop, got {other:?}"),
12023 }
12024 }
12025
12026 #[tokio::test]
12029 async fn link_note_to_edge_annotates_succeeds() {
12030 let rt = rt();
12031 let tok = NamespaceToken::local();
12032 let a = rt
12033 .create_entity(&tok, "concept", None, "A", None, None, vec![])
12034 .await
12035 .unwrap();
12036 let b = rt
12037 .create_entity(&tok, "concept", None, "B", None, None, vec![])
12038 .await
12039 .unwrap();
12040 let edge = rt
12042 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
12043 .await
12044 .unwrap();
12045 let edge_uuid: Uuid = edge.id.into();
12046
12047 let note = rt
12049 .create_note(
12050 &tok,
12051 "observation",
12052 None,
12053 "edge note",
12054 Some(0.5),
12055 None,
12056 vec![],
12057 )
12058 .await
12059 .unwrap();
12060
12061 let result = rt
12062 .link(&tok, note.id, edge_uuid, EdgeRelation::Annotates, 1.0, None)
12063 .await;
12064 assert!(
12065 result.is_ok(),
12066 "note→edge Annotates must succeed, got {result:?}"
12067 );
12068 }
12069 #[tokio::test]
12076 async fn neighbors_edge_id_finds_annotating_note() {
12077 let rt = rt();
12078 let tok = NamespaceToken::local();
12079 let a = rt
12080 .create_entity(&tok, "concept", None, "A", None, None, vec![])
12081 .await
12082 .unwrap();
12083 let b = rt
12084 .create_entity(&tok, "concept", None, "B", None, None, vec![])
12085 .await
12086 .unwrap();
12087 let edge = rt
12088 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
12089 .await
12090 .unwrap();
12091 let edge_uuid: Uuid = edge.id.into();
12092
12093 let note = rt
12094 .create_note(
12095 &tok,
12096 "observation",
12097 None,
12098 "edge note",
12099 Some(0.5),
12100 None,
12101 vec![edge_uuid],
12102 )
12103 .await
12104 .unwrap();
12105
12106 let neighbors = rt
12107 .neighbors(
12108 &tok,
12109 edge_uuid,
12110 Direction::In,
12111 None,
12112 Some(vec![EdgeRelation::Annotates]),
12113 )
12114 .await
12115 .unwrap();
12116 assert_eq!(neighbors.len(), 1, "expected annotating note to show up");
12117 assert_eq!(neighbors[0].node_id, note.id);
12118 }
12119
12120 #[tokio::test]
12121 async fn create_note_annotates_real_edge_succeeds() {
12122 let rt = rt();
12123 let tok = NamespaceToken::local();
12124 let a = rt
12125 .create_entity(&tok, "concept", None, "A", None, None, vec![])
12126 .await
12127 .unwrap();
12128 let b = rt
12129 .create_entity(&tok, "concept", None, "B", None, None, vec![])
12130 .await
12131 .unwrap();
12132 let edge = rt
12133 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
12134 .await
12135 .unwrap();
12136 let edge_uuid: Uuid = edge.id.into();
12137
12138 let note = rt
12139 .create_note(
12140 &tok,
12141 "observation",
12142 None,
12143 "annotating an edge",
12144 Some(0.5),
12145 None,
12146 vec![edge_uuid],
12147 )
12148 .await
12149 .unwrap();
12150
12151 let neighbors = rt
12152 .neighbors(
12153 &tok,
12154 note.id,
12155 Direction::Out,
12156 None,
12157 Some(vec![EdgeRelation::Annotates]),
12158 )
12159 .await
12160 .unwrap();
12161 assert_eq!(neighbors.len(), 1);
12162 assert_eq!(neighbors[0].node_id, edge_uuid);
12163 }
12164
12165 #[tokio::test]
12166 async fn create_note_annotates_phantom_is_atomic_no_note_persisted() {
12167 let rt = rt();
12168 let tok = NamespaceToken::local();
12169 let phantom = Uuid::new_v4();
12170
12171 let before_count = rt.list_notes(&tok, None, 1000, 0).await.unwrap().len();
12172
12173 let result = rt
12174 .create_note(
12175 &tok,
12176 "observation",
12177 None,
12178 "should not persist",
12179 Some(0.5),
12180 None,
12181 vec![phantom],
12182 )
12183 .await;
12184 assert!(
12185 matches!(result, Err(RuntimeError::NotFound(_))),
12186 "phantom annotates target must return NotFound, got {result:?}"
12187 );
12188
12189 let after_count = rt.list_notes(&tok, None, 1000, 0).await.unwrap().len();
12191 assert_eq!(
12192 before_count, after_count,
12193 "failed create_note must not persist any note row (atomicity)"
12194 );
12195
12196 let search_hits = rt
12198 .search_notes(&tok, "should not persist", None, 10, None, false, &[], None)
12199 .await
12200 .unwrap();
12201 assert!(
12202 search_hits.is_empty(),
12203 "failed create_note must not index into FTS (atomicity)"
12204 );
12205 }
12208
12209 #[tokio::test]
12213 async fn link_entity_to_edge_uuid_non_annotates_returns_invalid_input() {
12214 let rt = rt();
12215 let tok = NamespaceToken::local();
12216 let a = rt
12217 .create_entity(&tok, "concept", None, "A", None, None, vec![])
12218 .await
12219 .unwrap();
12220 let b = rt
12221 .create_entity(&tok, "concept", None, "B", None, None, vec![])
12222 .await
12223 .unwrap();
12224 let edge = rt
12226 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
12227 .await
12228 .unwrap();
12229 let edge_uuid: Uuid = edge.id.into();
12230
12231 let result = rt
12232 .link(&tok, a.id, edge_uuid, EdgeRelation::Extends, 1.0, None)
12233 .await;
12234 match result {
12235 Err(RuntimeError::InvalidInput(msg)) => {
12236 assert!(
12237 msg.contains("target"),
12238 "error message must name 'target': {msg}"
12239 );
12240 }
12241 other => {
12242 panic!("expected InvalidInput for edge-uuid target with Extends, got {other:?}")
12243 }
12244 }
12245 }
12246
12247 #[tokio::test]
12249 async fn link_note_as_source_non_annotates_returns_invalid_input() {
12250 let rt = rt();
12251 let tok = NamespaceToken::local();
12252 let note = rt
12253 .create_note(&tok, "observation", None, "a note", Some(0.5), None, vec![])
12254 .await
12255 .unwrap();
12256 let entity = rt
12257 .create_entity(&tok, "concept", None, "E", None, None, vec![])
12258 .await
12259 .unwrap();
12260
12261 let result = rt
12262 .link(&tok, note.id, entity.id, EdgeRelation::DependsOn, 1.0, None)
12263 .await;
12264 match result {
12265 Err(RuntimeError::InvalidInput(msg)) => {
12266 assert!(
12267 msg.contains("source"),
12268 "error message must name 'source': {msg}"
12269 );
12270 }
12271 other => panic!("expected InvalidInput for note source with DependsOn, got {other:?}"),
12272 }
12273 }
12274
12275 #[tokio::test]
12277 async fn link_entity_as_annotates_source_returns_invalid_input() {
12278 let rt = rt();
12279 let tok = NamespaceToken::local();
12280 let a = rt
12281 .create_entity(&tok, "concept", None, "A", None, None, vec![])
12282 .await
12283 .unwrap();
12284 let b = rt
12285 .create_entity(&tok, "concept", None, "B", None, None, vec![])
12286 .await
12287 .unwrap();
12288
12289 let result = rt
12290 .link(&tok, a.id, b.id, EdgeRelation::Annotates, 1.0, None)
12291 .await;
12292 match result {
12293 Err(RuntimeError::InvalidInput(msg)) => {
12294 assert!(
12295 msg.contains("source") && msg.contains("note"),
12296 "error must say source must be a note: {msg}"
12297 );
12298 }
12299 other => {
12300 panic!("expected InvalidInput for entity source with Annotates, got {other:?}")
12301 }
12302 }
12303 }
12304
12305 #[tokio::test]
12306 async fn link_edge_as_annotates_source_returns_invalid_input() {
12307 let rt = rt();
12308 let tok = NamespaceToken::local();
12309 let a = rt
12310 .create_entity(&tok, "concept", None, "A", None, None, vec![])
12311 .await
12312 .unwrap();
12313 let b = rt
12314 .create_entity(&tok, "concept", None, "B", None, None, vec![])
12315 .await
12316 .unwrap();
12317 let edge = rt
12318 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
12319 .await
12320 .unwrap();
12321 let edge_uuid: Uuid = edge.id.into();
12322
12323 let result = rt
12325 .link(&tok, edge_uuid, a.id, EdgeRelation::Annotates, 1.0, None)
12326 .await;
12327 match result {
12328 Err(RuntimeError::InvalidInput(msg)) => {
12329 assert!(
12330 msg.contains("source") && msg.contains("note"),
12331 "edge-as-annotates-source must report wrong kind, not NotFound: {msg}"
12332 );
12333 }
12334 other => panic!("expected InvalidInput for edge source with Annotates, got {other:?}"),
12335 }
12336 }
12337
12338 #[tokio::test]
12340 async fn link_note_to_event_annotates_succeeds() {
12341 use khive_storage::Event;
12342 use khive_types::{EventKind, SubstrateKind};
12343
12344 let rt = rt();
12345 let tok = NamespaceToken::local();
12346 let note = rt
12347 .create_note(
12348 &tok,
12349 "observation",
12350 None,
12351 "observing an event",
12352 Some(0.6),
12353 None,
12354 vec![],
12355 )
12356 .await
12357 .unwrap();
12358
12359 let ns = tok.namespace().as_str();
12361 let event = Event::new(
12362 ns,
12363 "test_verb",
12364 EventKind::Audit,
12365 SubstrateKind::Entity,
12366 "test_actor",
12367 );
12368 let event_id = event.id;
12369 rt.events(&tok).unwrap().append_event(event).await.unwrap();
12370
12371 let result = rt
12372 .link(&tok, note.id, event_id, EdgeRelation::Annotates, 1.0, None)
12373 .await;
12374 assert!(
12375 result.is_ok(),
12376 "note→event Annotates must succeed, got {result:?}"
12377 );
12378 }
12379
12380 #[tokio::test]
12382 async fn create_note_annotates_event_succeeds() {
12383 use khive_storage::Event;
12384 use khive_types::{EventKind, SubstrateKind};
12385
12386 let rt = rt();
12387 let tok = NamespaceToken::local();
12388 let ns = tok.namespace().as_str();
12389 let event = Event::new(
12390 ns,
12391 "test_verb",
12392 EventKind::Audit,
12393 SubstrateKind::Entity,
12394 "test_actor",
12395 );
12396 let event_id = event.id;
12397 rt.events(&tok).unwrap().append_event(event).await.unwrap();
12398
12399 let result = rt
12400 .create_note(
12401 &tok,
12402 "observation",
12403 None,
12404 "note annotating an event",
12405 Some(0.5),
12406 None,
12407 vec![event_id],
12408 )
12409 .await;
12410 assert!(
12411 result.is_ok(),
12412 "create_note with event annotates target must succeed, got {result:?}"
12413 );
12414 let note = result.unwrap();
12416 let neighbors = rt
12417 .neighbors(
12418 &tok,
12419 note.id,
12420 Direction::Out,
12421 None,
12422 Some(vec![EdgeRelation::Annotates]),
12423 )
12424 .await
12425 .unwrap();
12426 assert_eq!(neighbors.len(), 1);
12427 assert_eq!(neighbors[0].node_id, event_id);
12428 }
12429
12430 #[tokio::test]
12433 async fn link_supersedes_note_to_note_succeeds() {
12434 let rt = rt();
12435 let tok = NamespaceToken::local();
12436 let old_note = rt
12437 .create_note(
12438 &tok,
12439 "observation",
12440 None,
12441 "old observation",
12442 Some(0.7),
12443 None,
12444 vec![],
12445 )
12446 .await
12447 .unwrap();
12448 let new_note = rt
12449 .create_note(
12450 &tok,
12451 "observation",
12452 None,
12453 "revised observation superseding the old one",
12454 Some(0.9),
12455 None,
12456 vec![],
12457 )
12458 .await
12459 .unwrap();
12460
12461 let result = rt
12462 .link(
12463 &tok,
12464 new_note.id,
12465 old_note.id,
12466 EdgeRelation::Supersedes,
12467 1.0,
12468 None,
12469 )
12470 .await;
12471 assert!(
12472 result.is_ok(),
12473 "note→note Supersedes must succeed (note supersession), got {result:?}"
12474 );
12475 }
12476
12477 #[tokio::test]
12478 async fn link_supersedes_entity_to_entity_succeeds() {
12479 let rt = rt();
12480 let tok = NamespaceToken::local();
12481 let old_entity = rt
12482 .create_entity(&tok, "concept", None, "OldConcept", None, None, vec![])
12483 .await
12484 .unwrap();
12485 let new_entity = rt
12486 .create_entity(&tok, "concept", None, "NewConcept", None, None, vec![])
12487 .await
12488 .unwrap();
12489
12490 let result = rt
12491 .link(
12492 &tok,
12493 new_entity.id,
12494 old_entity.id,
12495 EdgeRelation::Supersedes,
12496 1.0,
12497 None,
12498 )
12499 .await;
12500 assert!(
12501 result.is_ok(),
12502 "entity→entity Supersedes must succeed, got {result:?}"
12503 );
12504 }
12505
12506 #[tokio::test]
12507 async fn link_supersedes_note_to_entity_returns_invalid_input() {
12508 let rt = rt();
12509 let tok = NamespaceToken::local();
12510 let note = rt
12511 .create_note(&tok, "observation", None, "a note", Some(0.5), None, vec![])
12512 .await
12513 .unwrap();
12514 let entity = rt
12515 .create_entity(&tok, "concept", None, "SomeEntity", None, None, vec![])
12516 .await
12517 .unwrap();
12518
12519 let result = rt
12520 .link(
12521 &tok,
12522 note.id,
12523 entity.id,
12524 EdgeRelation::Supersedes,
12525 1.0,
12526 None,
12527 )
12528 .await;
12529 match result {
12530 Err(RuntimeError::InvalidInput(msg)) => {
12531 assert!(
12532 msg.contains("same substrate") || msg.contains("same-substrate"),
12533 "error must name the same-substrate rule: {msg}"
12534 );
12535 }
12536 other => panic!(
12537 "expected InvalidInput for note→entity Supersedes (cross-substrate), got {other:?}"
12538 ),
12539 }
12540 }
12541
12542 #[tokio::test]
12543 async fn link_supersedes_entity_to_note_returns_invalid_input() {
12544 let rt = rt();
12545 let tok = NamespaceToken::local();
12546 let entity = rt
12547 .create_entity(&tok, "concept", None, "SomeEntity", None, None, vec![])
12548 .await
12549 .unwrap();
12550 let note = rt
12551 .create_note(&tok, "observation", None, "a note", Some(0.5), None, vec![])
12552 .await
12553 .unwrap();
12554
12555 let result = rt
12556 .link(
12557 &tok,
12558 entity.id,
12559 note.id,
12560 EdgeRelation::Supersedes,
12561 1.0,
12562 None,
12563 )
12564 .await;
12565 match result {
12566 Err(RuntimeError::InvalidInput(msg)) => {
12567 assert!(
12568 msg.contains("same substrate") || msg.contains("same-substrate"),
12569 "error must name the same-substrate rule: {msg}"
12570 );
12571 }
12572 other => panic!(
12573 "expected InvalidInput for entity→note Supersedes (cross-substrate), got {other:?}"
12574 ),
12575 }
12576 }
12577
12578 #[tokio::test]
12579 async fn link_supersedes_event_source_returns_invalid_input() {
12580 use khive_storage::Event;
12581 use khive_types::{EventKind, SubstrateKind};
12582
12583 let rt = rt();
12584 let tok = NamespaceToken::local();
12585 let ns = tok.namespace().as_str();
12586 let event = Event::new(
12587 ns,
12588 "test_verb",
12589 EventKind::Audit,
12590 SubstrateKind::Entity,
12591 "test_actor",
12592 );
12593 let event_id = event.id;
12594 rt.events(&tok).unwrap().append_event(event).await.unwrap();
12595
12596 let entity = rt
12597 .create_entity(&tok, "concept", None, "SomeEntity", None, None, vec![])
12598 .await
12599 .unwrap();
12600
12601 let result = rt
12602 .link(
12603 &tok,
12604 event_id,
12605 entity.id,
12606 EdgeRelation::Supersedes,
12607 1.0,
12608 None,
12609 )
12610 .await;
12611 match result {
12612 Err(RuntimeError::InvalidInput(msg)) => {
12613 assert!(msg.contains("event"), "error must mention 'event': {msg}");
12614 }
12615 other => {
12616 panic!("expected InvalidInput for event source with Supersedes, got {other:?}")
12617 }
12618 }
12619 }
12620
12621 #[tokio::test]
12622 async fn link_supersedes_event_target_returns_invalid_input() {
12623 use khive_storage::Event;
12624 use khive_types::{EventKind, SubstrateKind};
12625
12626 let rt = rt();
12627 let tok = NamespaceToken::local();
12628 let ns = tok.namespace().as_str();
12629 let event = Event::new(
12630 ns,
12631 "test_verb",
12632 EventKind::Audit,
12633 SubstrateKind::Entity,
12634 "test_actor",
12635 );
12636 let event_id = event.id;
12637 rt.events(&tok).unwrap().append_event(event).await.unwrap();
12638
12639 let entity = rt
12640 .create_entity(&tok, "concept", None, "SomeEntity", None, None, vec![])
12641 .await
12642 .unwrap();
12643
12644 let result = rt
12645 .link(
12646 &tok,
12647 entity.id,
12648 event_id,
12649 EdgeRelation::Supersedes,
12650 1.0,
12651 None,
12652 )
12653 .await;
12654 match result {
12655 Err(RuntimeError::InvalidInput(msg)) => {
12656 assert!(msg.contains("event"), "error must mention 'event': {msg}");
12657 }
12658 other => {
12659 panic!("expected InvalidInput for event target with Supersedes, got {other:?}")
12660 }
12661 }
12662 }
12663
12664 #[tokio::test]
12665 async fn link_supersedes_edge_source_returns_invalid_input() {
12666 let rt = rt();
12667 let tok = NamespaceToken::local();
12668 let a = rt
12669 .create_entity(&tok, "concept", None, "A", None, None, vec![])
12670 .await
12671 .unwrap();
12672 let b = rt
12673 .create_entity(&tok, "concept", None, "B", None, None, vec![])
12674 .await
12675 .unwrap();
12676 let edge = rt
12677 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
12678 .await
12679 .unwrap();
12680 let edge_uuid: Uuid = edge.id.into();
12681
12682 let result = rt
12683 .link(&tok, edge_uuid, a.id, EdgeRelation::Supersedes, 1.0, None)
12684 .await;
12685 match result {
12686 Err(RuntimeError::InvalidInput(msg)) => {
12687 assert!(msg.contains("source"), "error must name 'source': {msg}");
12688 }
12689 other => {
12690 panic!("expected InvalidInput for edge-uuid source with Supersedes, got {other:?}")
12691 }
12692 }
12693 }
12694
12695 #[tokio::test]
12696 async fn link_supersedes_edge_target_returns_invalid_input() {
12697 let rt = rt();
12698 let tok = NamespaceToken::local();
12699 let a = rt
12700 .create_entity(&tok, "concept", None, "A", None, None, vec![])
12701 .await
12702 .unwrap();
12703 let b = rt
12704 .create_entity(&tok, "concept", None, "B", None, None, vec![])
12705 .await
12706 .unwrap();
12707 let edge = rt
12708 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
12709 .await
12710 .unwrap();
12711 let edge_uuid: Uuid = edge.id.into();
12712
12713 let result = rt
12714 .link(&tok, a.id, edge_uuid, EdgeRelation::Supersedes, 1.0, None)
12715 .await;
12716 match result {
12717 Err(RuntimeError::InvalidInput(msg)) => {
12718 assert!(msg.contains("target"), "error must name 'target': {msg}");
12719 }
12720 other => {
12721 panic!("expected InvalidInput for edge-uuid target with Supersedes, got {other:?}")
12722 }
12723 }
12724 }
12725
12726 #[tokio::test]
12727 async fn link_supersedes_phantom_source_returns_not_found() {
12728 let rt = rt();
12729 let tok = NamespaceToken::local();
12730 let note = rt
12731 .create_note(
12732 &tok,
12733 "observation",
12734 None,
12735 "existing note",
12736 Some(0.5),
12737 None,
12738 vec![],
12739 )
12740 .await
12741 .unwrap();
12742 let phantom = Uuid::new_v4();
12743
12744 let result = rt
12745 .link(&tok, phantom, note.id, EdgeRelation::Supersedes, 1.0, None)
12746 .await;
12747 match result {
12748 Err(RuntimeError::NotFound(msg)) => {
12749 assert!(msg.contains("source"), "error must name 'source': {msg}");
12750 }
12751 other => panic!("expected NotFound for phantom source with Supersedes, got {other:?}"),
12752 }
12753 }
12754
12755 #[tokio::test]
12756 async fn link_supersedes_phantom_target_returns_not_found() {
12757 let rt = rt();
12758 let tok = NamespaceToken::local();
12759 let note = rt
12760 .create_note(
12761 &tok,
12762 "observation",
12763 None,
12764 "existing note",
12765 Some(0.5),
12766 None,
12767 vec![],
12768 )
12769 .await
12770 .unwrap();
12771 let phantom = Uuid::new_v4();
12772
12773 let result = rt
12774 .link(&tok, note.id, phantom, EdgeRelation::Supersedes, 1.0, None)
12775 .await;
12776 match result {
12777 Err(RuntimeError::NotFound(msg)) => {
12778 assert!(msg.contains("target"), "error must name 'target': {msg}");
12779 }
12780 other => panic!("expected NotFound for phantom target with Supersedes, got {other:?}"),
12781 }
12782 }
12783
12784 #[tokio::test]
12787 async fn link_supersedes_cross_namespace_source_succeeds() {
12788 let rt = rt();
12789 let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
12790 let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
12791 let note_a = rt
12792 .create_note(
12793 &ns_a,
12794 "observation",
12795 None,
12796 "note in ns-a",
12797 Some(0.5),
12798 None,
12799 vec![],
12800 )
12801 .await
12802 .unwrap();
12803 let note_b = rt
12804 .create_note(
12805 &ns_b,
12806 "observation",
12807 None,
12808 "note in ns-b",
12809 Some(0.5),
12810 None,
12811 vec![],
12812 )
12813 .await
12814 .unwrap();
12815
12816 let result = rt
12819 .link(
12820 &ns_a,
12821 note_b.id,
12822 note_a.id,
12823 EdgeRelation::Supersedes,
12824 1.0,
12825 None,
12826 )
12827 .await;
12828 assert!(
12829 result.is_ok(),
12830 "cross-namespace supersedes source must resolve (#631), got {result:?}"
12831 );
12832 }
12833
12834 #[tokio::test]
12836 async fn link_extends_note_source_still_returns_invalid_input() {
12837 let rt = rt();
12838 let tok = NamespaceToken::local();
12839 let note = rt
12840 .create_note(
12841 &tok,
12842 "observation",
12843 None,
12844 "a note that cannot be an extends source",
12845 Some(0.5),
12846 None,
12847 vec![],
12848 )
12849 .await
12850 .unwrap();
12851 let entity = rt
12852 .create_entity(&tok, "concept", None, "E", None, None, vec![])
12853 .await
12854 .unwrap();
12855
12856 let result = rt
12857 .link(&tok, note.id, entity.id, EdgeRelation::Extends, 1.0, None)
12858 .await;
12859 assert!(
12860 matches!(result, Err(RuntimeError::InvalidInput(_))),
12861 "note source with Extends must still return InvalidInput after this fix, got {result:?}"
12862 );
12863 }
12864
12865 #[tokio::test]
12866 async fn link_annotates_note_to_edge_still_succeeds_after_fix() {
12867 let rt = rt();
12868 let tok = NamespaceToken::local();
12869 let a = rt
12870 .create_entity(&tok, "concept", None, "A", None, None, vec![])
12871 .await
12872 .unwrap();
12873 let b = rt
12874 .create_entity(&tok, "concept", None, "B", None, None, vec![])
12875 .await
12876 .unwrap();
12877 let edge = rt
12878 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
12879 .await
12880 .unwrap();
12881 let edge_uuid: Uuid = edge.id.into();
12882
12883 let note = rt
12884 .create_note(
12885 &tok,
12886 "observation",
12887 None,
12888 "annotating an edge",
12889 Some(0.5),
12890 None,
12891 vec![],
12892 )
12893 .await
12894 .unwrap();
12895
12896 let result = rt
12897 .link(&tok, note.id, edge_uuid, EdgeRelation::Annotates, 1.0, None)
12898 .await;
12899 assert!(
12900 result.is_ok(),
12901 "note→edge Annotates must still succeed after supersedes fix, got {result:?}"
12902 );
12903 }
12904
12905 #[tokio::test]
12919 async fn create_note_multi_annotates_compensation_cleanup_restores_pristine_state() {
12920 let rt = rt();
12921 let tok = NamespaceToken::local();
12922 let t1 = rt
12923 .create_entity(&tok, "concept", None, "T1", None, None, vec![])
12924 .await
12925 .unwrap();
12926
12927 let note = rt
12930 .create_note(
12931 &tok,
12932 "observation",
12933 None,
12934 "partial note",
12935 Some(0.5),
12936 None,
12937 vec![t1.id],
12938 )
12939 .await
12940 .unwrap();
12941
12942 let before_notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
12944 assert_eq!(before_notes.len(), 1, "note must be present before cleanup");
12945 let before_edges = rt
12946 .neighbors(
12947 &tok,
12948 note.id,
12949 Direction::Out,
12950 None,
12951 Some(vec![EdgeRelation::Annotates]),
12952 )
12953 .await
12954 .unwrap();
12955 assert_eq!(
12956 before_edges.len(),
12957 1,
12958 "one annotates edge must exist before cleanup"
12959 );
12960 let edge_id: Uuid = before_edges[0].edge_id;
12961
12962 rt.delete_edge(&tok, edge_id, true).await.unwrap();
12964 rt.delete_note(&tok, note.id, true )
12965 .await
12966 .unwrap();
12967
12968 let after_notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
12970 assert!(
12971 after_notes.is_empty(),
12972 "compensation must remove the note row; got {after_notes:?}"
12973 );
12974 let search_hits = rt
12975 .search_notes(&tok, "partial note", None, 10, None, false, &[], None)
12976 .await
12977 .unwrap();
12978 assert!(
12979 search_hits.is_empty(),
12980 "compensation must clean the FTS index; got {search_hits:?}"
12981 );
12982 let after_edges = rt
12985 .neighbors(
12986 &tok,
12987 t1.id,
12988 Direction::In,
12989 None,
12990 Some(vec![EdgeRelation::Annotates]),
12991 )
12992 .await
12993 .unwrap();
12994 assert!(
12995 after_edges.is_empty(),
12996 "compensation must remove all partial edges; got {after_edges:?}"
12997 );
12998 assert!(matches!(
12999 rt.neighbors(&tok, note.id, Direction::Out, None, None)
13000 .await,
13001 Err(RuntimeError::NotFound(_))
13002 ));
13003 }
13004
13005 #[tokio::test]
13013 async fn annotated_entity_hard_delete_cascades_annotate_edge() {
13014 let rt = rt();
13015 let tok = NamespaceToken::local();
13016 let entity = rt
13017 .create_entity(&tok, "concept", None, "E", None, None, vec![])
13018 .await
13019 .unwrap();
13020 let note = rt
13021 .create_note(
13022 &tok,
13023 "observation",
13024 None,
13025 "note about entity",
13026 Some(0.5),
13027 None,
13028 vec![entity.id],
13029 )
13030 .await
13031 .unwrap();
13032
13033 let before = rt
13035 .neighbors(
13036 &tok,
13037 note.id,
13038 Direction::Out,
13039 None,
13040 Some(vec![EdgeRelation::Annotates]),
13041 )
13042 .await
13043 .unwrap();
13044 assert_eq!(
13045 before.len(),
13046 1,
13047 "annotates edge must exist before entity delete"
13048 );
13049
13050 let deleted = rt.delete_entity(&tok, entity.id, true).await.unwrap();
13052 assert!(deleted, "entity hard delete must return true");
13053
13054 let after = rt
13056 .neighbors(
13057 &tok,
13058 note.id,
13059 Direction::Out,
13060 None,
13061 Some(vec![EdgeRelation::Annotates]),
13062 )
13063 .await
13064 .unwrap();
13065 assert!(
13066 after.is_empty(),
13067 "annotates edge must be cascaded on entity hard delete; got {after:?}"
13068 );
13069 }
13070
13071 #[tokio::test]
13072 async fn annotated_note_hard_delete_cascades_annotate_edge() {
13073 let rt = rt();
13074 let tok = NamespaceToken::local();
13075 let note_target = rt
13077 .create_note(
13078 &tok,
13079 "observation",
13080 None,
13081 "target note",
13082 Some(0.5),
13083 None,
13084 vec![],
13085 )
13086 .await
13087 .unwrap();
13088 let note_source = rt
13090 .create_note(
13091 &tok,
13092 "insight",
13093 None,
13094 "annotation",
13095 Some(0.5),
13096 None,
13097 vec![note_target.id],
13098 )
13099 .await
13100 .unwrap();
13101
13102 let before = rt
13103 .neighbors(
13104 &tok,
13105 note_source.id,
13106 Direction::Out,
13107 None,
13108 Some(vec![EdgeRelation::Annotates]),
13109 )
13110 .await
13111 .unwrap();
13112 assert_eq!(
13113 before.len(),
13114 1,
13115 "annotates edge must exist before note delete"
13116 );
13117
13118 let deleted = rt.delete_note(&tok, note_target.id, true).await.unwrap();
13120 assert!(deleted, "note hard delete must return true");
13121
13122 let after = rt
13124 .neighbors(
13125 &tok,
13126 note_source.id,
13127 Direction::Out,
13128 None,
13129 Some(vec![EdgeRelation::Annotates]),
13130 )
13131 .await
13132 .unwrap();
13133 assert!(
13134 after.is_empty(),
13135 "annotates edge must be cascaded on note-target hard delete; got {after:?}"
13136 );
13137 }
13138
13139 #[tokio::test]
13140 async fn annotated_edge_delete_cascades_annotate_edge() {
13141 let rt = rt();
13142 let tok = NamespaceToken::local();
13143 let a = rt
13144 .create_entity(&tok, "concept", None, "A", None, None, vec![])
13145 .await
13146 .unwrap();
13147 let b = rt
13148 .create_entity(&tok, "concept", None, "B", None, None, vec![])
13149 .await
13150 .unwrap();
13151 let base_edge = rt
13153 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
13154 .await
13155 .unwrap();
13156 let base_edge_uuid: Uuid = base_edge.id.into();
13157
13158 let note = rt
13160 .create_note(
13161 &tok,
13162 "observation",
13163 None,
13164 "note about edge",
13165 Some(0.5),
13166 None,
13167 vec![base_edge_uuid],
13168 )
13169 .await
13170 .unwrap();
13171
13172 let before = rt
13173 .neighbors(
13174 &tok,
13175 note.id,
13176 Direction::Out,
13177 None,
13178 Some(vec![EdgeRelation::Annotates]),
13179 )
13180 .await
13181 .unwrap();
13182 assert_eq!(
13183 before.len(),
13184 1,
13185 "annotates edge must exist before base edge delete"
13186 );
13187
13188 let deleted = rt.delete_edge(&tok, base_edge_uuid, true).await.unwrap();
13190 assert!(deleted, "edge delete must return true");
13191
13192 let after = rt
13194 .neighbors(
13195 &tok,
13196 note.id,
13197 Direction::Out,
13198 None,
13199 Some(vec![EdgeRelation::Annotates]),
13200 )
13201 .await
13202 .unwrap();
13203 assert!(
13204 after.is_empty(),
13205 "annotates edge must be cascaded on base edge delete; got {after:?}"
13206 );
13207 }
13208
13209 #[tokio::test]
13210 async fn mixed_multi_annotates_partial_target_hard_delete_leaves_remaining_edges() {
13211 let rt = rt();
13212 let tok = NamespaceToken::local();
13213 let t1 = rt
13214 .create_entity(&tok, "concept", None, "T1", None, None, vec![])
13215 .await
13216 .unwrap();
13217 let t2 = rt
13218 .create_entity(&tok, "concept", None, "T2", None, None, vec![])
13219 .await
13220 .unwrap();
13221
13222 let note = rt
13224 .create_note(
13225 &tok,
13226 "observation",
13227 None,
13228 "multi-target note",
13229 Some(0.5),
13230 None,
13231 vec![t1.id, t2.id],
13232 )
13233 .await
13234 .unwrap();
13235
13236 let before = rt
13237 .neighbors(
13238 &tok,
13239 note.id,
13240 Direction::Out,
13241 None,
13242 Some(vec![EdgeRelation::Annotates]),
13243 )
13244 .await
13245 .unwrap();
13246 assert_eq!(
13247 before.len(),
13248 2,
13249 "must have 2 annotates edges before any delete"
13250 );
13251
13252 rt.delete_entity(&tok, t1.id, true).await.unwrap();
13254
13255 let after = rt
13257 .neighbors(
13258 &tok,
13259 note.id,
13260 Direction::Out,
13261 None,
13262 Some(vec![EdgeRelation::Annotates]),
13263 )
13264 .await
13265 .unwrap();
13266 assert_eq!(
13267 after.len(),
13268 1,
13269 "only the edge to t1 must be cascaded; t2 edge must remain"
13270 );
13271 assert_eq!(
13272 after[0].node_id, t2.id,
13273 "remaining annotates edge must point to t2"
13274 );
13275 }
13276
13277 #[tokio::test]
13278 async fn annotated_note_soft_delete_preserves_annotate_edge() {
13279 let rt = rt();
13280 let tok = NamespaceToken::local();
13281 let note_target = rt
13282 .create_note(&tok, "observation", None, "target", Some(0.5), None, vec![])
13283 .await
13284 .unwrap();
13285 let note_source = rt
13286 .create_note(
13287 &tok,
13288 "insight",
13289 None,
13290 "annotation",
13291 Some(0.5),
13292 None,
13293 vec![note_target.id],
13294 )
13295 .await
13296 .unwrap();
13297
13298 let before = rt
13299 .neighbors(
13300 &tok,
13301 note_source.id,
13302 Direction::Out,
13303 None,
13304 Some(vec![EdgeRelation::Annotates]),
13305 )
13306 .await
13307 .unwrap();
13308 assert_eq!(before.len(), 1);
13309 let edge_id = before[0].edge_id;
13310
13311 let deleted = rt.delete_note(&tok, note_target.id, false).await.unwrap();
13313 assert!(deleted, "soft delete must return true");
13314
13315 let edge_after = rt.get_edge(&tok, edge_id).await.unwrap();
13323 assert!(
13324 edge_after.is_some(),
13325 "soft delete must NOT cascade edges; get_edge returned None"
13326 );
13327
13328 let after = rt
13329 .neighbors(
13330 &tok,
13331 note_source.id,
13332 Direction::Out,
13333 None,
13334 Some(vec![EdgeRelation::Annotates]),
13335 )
13336 .await
13337 .unwrap();
13338 assert_eq!(
13339 after.len(),
13340 0,
13341 "#748: neighbors() must screen out the soft-deleted note target; got {after:?}"
13342 );
13343 }
13344
13345 #[tokio::test]
13352 async fn delete_edge_non_edge_uuid_has_no_side_effects() {
13353 let rt = rt();
13354 let tok = NamespaceToken::local();
13355
13356 let entity = rt
13358 .create_entity(&tok, "concept", None, "Target", None, None, vec![])
13359 .await
13360 .unwrap();
13361 let note = rt
13362 .create_note(
13363 &tok,
13364 "observation",
13365 None,
13366 "annotates the entity",
13367 Some(0.5),
13368 None,
13369 vec![entity.id],
13370 )
13371 .await
13372 .unwrap();
13373
13374 let before = rt
13376 .neighbors(
13377 &tok,
13378 note.id,
13379 Direction::Out,
13380 None,
13381 Some(vec![EdgeRelation::Annotates]),
13382 )
13383 .await
13384 .unwrap();
13385 assert_eq!(before.len(), 1, "annotates edge must exist before test");
13386 let annotates_edge_id: Uuid = before[0].edge_id;
13387
13388 let result = rt.delete_edge(&tok, entity.id, true).await;
13390 assert!(
13391 result.is_ok(),
13392 "delete_edge must not error on a non-edge UUID"
13393 );
13394 assert!(
13395 !result.unwrap(),
13396 "delete_edge must return false for a non-edge UUID"
13397 );
13398
13399 let after = rt
13401 .neighbors(
13402 &tok,
13403 note.id,
13404 Direction::Out,
13405 None,
13406 Some(vec![EdgeRelation::Annotates]),
13407 )
13408 .await
13409 .unwrap();
13410 assert_eq!(
13411 after.len(),
13412 1,
13413 "delete_edge with a non-edge UUID must not touch inbound annotates edges"
13414 );
13415 assert_eq!(
13416 after[0].edge_id, annotates_edge_id,
13417 "the original annotates edge must be unchanged"
13418 );
13419 }
13420
13421 #[tokio::test]
13432 async fn create_note_multi_annotates_second_link_failure_rolls_back_partial_write() {
13433 let rt = rt();
13434 let tok = NamespaceToken::local();
13435 let t1 = rt
13436 .create_entity(&tok, "concept", None, "T1", None, None, vec![])
13437 .await
13438 .unwrap();
13439 let t2 = rt
13440 .create_entity(&tok, "concept", None, "T2", None, None, vec![])
13441 .await
13442 .unwrap();
13443
13444 LINK_FAIL_AFTER.with(|cell| cell.set(2));
13446
13447 let result = rt
13448 .create_note(
13449 &tok,
13450 "observation",
13451 None,
13452 "rollback target",
13453 Some(0.5),
13454 None,
13455 vec![t1.id, t2.id],
13456 )
13457 .await;
13458
13459 assert!(
13461 result.is_err(),
13462 "create_note must propagate the injected link failure"
13463 );
13464 let err_msg = result.unwrap_err().to_string();
13465 assert!(
13466 err_msg.contains("injected link failure"),
13467 "error must carry injection message; got: {err_msg}"
13468 );
13469
13470 let notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
13472 assert!(
13473 notes.is_empty(),
13474 "compensation must remove the note row; got {notes:?}"
13475 );
13476
13477 let hits = rt
13479 .search_notes(&tok, "rollback target", None, 10, None, false, &[], None)
13480 .await
13481 .unwrap();
13482 assert!(
13483 hits.is_empty(),
13484 "compensation must clean FTS index; got {hits:?}"
13485 );
13486
13487 let edges_from_t1 = rt
13489 .neighbors(
13490 &tok,
13491 t1.id,
13492 Direction::In,
13493 None,
13494 Some(vec![EdgeRelation::Annotates]),
13495 )
13496 .await
13497 .unwrap();
13498 let edges_from_t2 = rt
13499 .neighbors(
13500 &tok,
13501 t2.id,
13502 Direction::In,
13503 None,
13504 Some(vec![EdgeRelation::Annotates]),
13505 )
13506 .await
13507 .unwrap();
13508 assert!(
13509 edges_from_t1.is_empty(),
13510 "compensation must delete the first annotates edge; got {edges_from_t1:?}"
13511 );
13512 assert!(
13513 edges_from_t2.is_empty(),
13514 "no second annotates edge must exist; got {edges_from_t2:?}"
13515 );
13516 }
13517
13518 #[tokio::test]
13522 async fn create_note_fts_failure_rolls_back_note_row() {
13523 let rt = rt();
13524 let ns = Namespace::parse("fault-fts-rollback").unwrap();
13529 let tok = NamespaceToken::for_namespace(ns.clone());
13530
13531 let _arm = arm_fts_fail_scoped(ns.as_str());
13532
13533 let result = rt
13534 .create_note(
13535 &tok,
13536 "observation",
13537 None,
13538 "fts-fail rollback target",
13539 None,
13540 None,
13541 vec![],
13542 )
13543 .await;
13544
13545 assert!(
13546 result.is_err(),
13547 "create_note must propagate the injected FTS failure"
13548 );
13549 let err_msg = result.unwrap_err().to_string();
13550 assert!(
13551 err_msg.contains("injected FTS failure"),
13552 "error must carry injection message; got: {err_msg}"
13553 );
13554
13555 let notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
13557 assert!(
13558 notes.is_empty(),
13559 "compensation must remove the note row after FTS failure; got {notes:?}"
13560 );
13561 }
13562
13563 #[tokio::test]
13572 async fn create_note_fts_failure_fires_across_os_threads() {
13573 let rt = std::sync::Arc::new(rt());
13574 let ns = Namespace::parse("fault-fts-rollback-cross-thread").unwrap();
13575 let tok = NamespaceToken::for_namespace(ns.clone());
13576
13577 let _arm = arm_fts_fail_scoped(ns.as_str());
13578
13579 let thread_rt = std::sync::Arc::clone(&rt);
13580 let thread_tok = tok.clone();
13581 let result = std::thread::spawn(move || {
13582 let worker = tokio::runtime::Builder::new_current_thread()
13583 .enable_all()
13584 .build()
13585 .expect("worker runtime must build");
13586 worker.block_on(thread_rt.create_note(
13587 &thread_tok,
13588 "observation",
13589 None,
13590 "fts-fail rollback target (cross-thread)",
13591 None,
13592 None,
13593 vec![],
13594 ))
13595 })
13596 .join()
13597 .expect("worker thread must not panic");
13598
13599 assert!(
13600 result.is_err(),
13601 "create_note on a different OS thread must still observe the injected FTS failure"
13602 );
13603 let err_msg = result.unwrap_err().to_string();
13604 assert!(
13605 err_msg.contains("injected FTS failure"),
13606 "error must carry injection message; got: {err_msg}"
13607 );
13608
13609 let notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
13611 assert!(
13612 notes.is_empty(),
13613 "compensation must remove the note row after FTS failure; got {notes:?}"
13614 );
13615 }
13616
13617 #[tokio::test]
13625 async fn create_note_vector_failure_rolls_back_note_row_and_fts() {
13626 const MODEL: &str = "test-vec-inject";
13627 const DIMS: usize = 4;
13628
13629 let rt = KhiveRuntime::memory().unwrap();
13630 let (provider, _counter) = ConstVecProvider::new(MODEL, DIMS);
13631 rt.register_embedder(provider);
13632
13633 let ns = Namespace::parse("fault-vec-rollback").unwrap();
13634 let tok = NamespaceToken::for_namespace(ns.clone());
13635
13636 let _arm = arm_vector_fail_scoped(ns.as_str());
13637
13638 let result = rt
13639 .create_note(
13640 &tok,
13641 "observation",
13642 None,
13643 "vec-fail rollback target",
13644 None,
13645 None,
13646 vec![],
13647 )
13648 .await;
13649
13650 assert!(
13651 result.is_err(),
13652 "create_note must propagate the injected vector failure"
13653 );
13654 let err_msg = result.unwrap_err().to_string();
13655 assert!(
13656 err_msg.contains("injected vector failure"),
13657 "error must carry injection message; got: {err_msg}"
13658 );
13659
13660 let notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
13662 assert!(
13663 notes.is_empty(),
13664 "compensation must remove note row after vector failure; got {notes:?}"
13665 );
13666 }
13667
13668 #[tokio::test]
13669 async fn vector_failure_injections_for_distinct_namespaces_do_not_overwrite_each_other() {
13670 const MODEL: &str = "test-vec-inject-distinct-namespaces";
13671 const DIMS: usize = 4;
13672
13673 let rt_a = KhiveRuntime::memory().unwrap();
13674 let (provider_a, _counter_a) = ConstVecProvider::new(MODEL, DIMS);
13675 rt_a.register_embedder(provider_a);
13676 let ns_a = Namespace::parse("fault-vec-distinct-a").unwrap();
13677 let tok_a = NamespaceToken::for_namespace(ns_a.clone());
13678
13679 let rt_b = KhiveRuntime::memory().unwrap();
13680 let (provider_b, _counter_b) = ConstVecProvider::new(MODEL, DIMS);
13681 rt_b.register_embedder(provider_b);
13682 let ns_b = Namespace::parse("fault-vec-distinct-b").unwrap();
13683 let tok_b = NamespaceToken::for_namespace(ns_b.clone());
13684
13685 let _arm_a = arm_vector_fail_scoped(ns_a.as_str());
13686 let _arm_b = arm_vector_fail_scoped(ns_b.as_str());
13687
13688 let (result_a, result_b) = tokio::join!(
13689 rt_a.create_note(
13690 &tok_a,
13691 "observation",
13692 None,
13693 "vector failure target A",
13694 None,
13695 None,
13696 vec![],
13697 ),
13698 rt_b.create_note(
13699 &tok_b,
13700 "observation",
13701 None,
13702 "vector failure target B",
13703 None,
13704 None,
13705 vec![],
13706 ),
13707 );
13708
13709 assert!(
13710 result_a.is_err(),
13711 "namespace A must retain its pending vector failure injection"
13712 );
13713 assert!(
13714 result_b.is_err(),
13715 "namespace B must retain its pending vector failure injection"
13716 );
13717 }
13718
13719 #[tokio::test]
13725 async fn create_note_with_embedding_content_fts_failure_rolls_back_note_row() {
13726 let rt = rt();
13727 let ns = Namespace::parse("fault-fts-rollback-embedding-content").unwrap();
13728 let tok = NamespaceToken::for_namespace(ns.clone());
13729
13730 let _arm = arm_fts_fail_scoped(ns.as_str());
13731
13732 let full = "fts-fail rollback target with an embedding-content override";
13733 let head = &full[.."fts-fail rollback target".len()];
13734 let result = rt
13735 .create_note_with_embedding_content(
13736 &tok,
13737 "observation",
13738 None,
13739 full,
13740 Some(head),
13741 None,
13742 None,
13743 vec![],
13744 )
13745 .await;
13746
13747 assert!(
13748 result.is_err(),
13749 "create_note_with_embedding_content must propagate the injected FTS failure"
13750 );
13751 let err_msg = result.unwrap_err().to_string();
13752 assert!(
13753 err_msg.contains("injected FTS failure"),
13754 "error must carry injection message; got: {err_msg}"
13755 );
13756
13757 let notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
13761 assert!(
13762 notes.is_empty(),
13763 "compensation must remove the note row after FTS failure; got {notes:?}"
13764 );
13765 }
13766
13767 #[tokio::test]
13768 async fn create_note_with_embedding_content_vector_failure_rolls_back_note_row_and_fts() {
13769 const MODEL: &str = "test-vec-inject-embedding-content";
13770 const DIMS: usize = 4;
13771
13772 let rt = KhiveRuntime::memory().unwrap();
13773 let (provider, _counter) = ConstVecProvider::new(MODEL, DIMS);
13774 rt.register_embedder(provider);
13775
13776 let ns = Namespace::parse("fault-vec-rollback-embedding-content").unwrap();
13777 let tok = NamespaceToken::for_namespace(ns.clone());
13778
13779 let _arm = arm_vector_fail_scoped(ns.as_str());
13780
13781 let full = "vec-fail rollback target with an embedding-content override";
13782 let head = &full[.."vec-fail rollback target".len()];
13783 let result = rt
13784 .create_note_with_embedding_content(
13785 &tok,
13786 "observation",
13787 None,
13788 full,
13789 Some(head),
13790 None,
13791 None,
13792 vec![],
13793 )
13794 .await;
13795
13796 assert!(
13797 result.is_err(),
13798 "create_note_with_embedding_content must propagate the injected vector failure"
13799 );
13800 let err_msg = result.unwrap_err().to_string();
13801 assert!(
13802 err_msg.contains("injected vector failure"),
13803 "error must carry injection message; got: {err_msg}"
13804 );
13805
13806 let notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
13811 assert!(
13812 notes.is_empty(),
13813 "compensation must remove note row after vector failure; got {notes:?}"
13814 );
13815 }
13816
13817 #[tokio::test]
13820 async fn soft_delete_entity_removes_indexes() {
13821 let rt = rt();
13822 let tok = NamespaceToken::local();
13823 let entity = rt
13824 .create_entity(
13825 &tok,
13826 "concept",
13827 None,
13828 "QuantumEntanglement",
13829 Some("unique FTS term xzqjwv for soft delete test"),
13830 None,
13831 vec![],
13832 )
13833 .await
13834 .unwrap();
13835
13836 let ns = tok.namespace().as_str().to_string();
13837
13838 let before = rt
13839 .text(&tok)
13840 .unwrap()
13841 .search(TextSearchRequest {
13842 query: "xzqjwv".to_string(),
13843 mode: TextQueryMode::Plain,
13844 filter: Some(TextFilter {
13845 namespaces: vec![ns.clone()],
13846 ..Default::default()
13847 }),
13848 top_k: 10,
13849 snippet_chars: 100,
13850 })
13851 .await
13852 .unwrap();
13853 assert!(
13854 before.iter().any(|h| h.subject_id == entity.id),
13855 "entity must be in FTS before soft-delete"
13856 );
13857
13858 let deleted = rt.delete_entity(&tok, entity.id, false).await.unwrap();
13859 assert!(deleted, "soft delete must return true");
13860
13861 let after = rt
13862 .text(&tok)
13863 .unwrap()
13864 .search(TextSearchRequest {
13865 query: "xzqjwv".to_string(),
13866 mode: TextQueryMode::Plain,
13867 filter: Some(TextFilter {
13868 namespaces: vec![ns],
13869 ..Default::default()
13870 }),
13871 top_k: 10,
13872 snippet_chars: 100,
13873 })
13874 .await
13875 .unwrap();
13876 assert!(
13877 after.iter().all(|h| h.subject_id != entity.id),
13878 "soft-deleted entity must be removed from FTS index"
13879 );
13880 }
13881
13882 #[tokio::test]
13883 async fn soft_delete_note_removes_indexes() {
13884 let rt = rt();
13885 let tok = NamespaceToken::local();
13886 let note = rt
13887 .create_note(
13888 &tok,
13889 "observation",
13890 None,
13891 "SpectralDecomposition unique term yvwkqz for soft delete test",
13892 Some(0.7),
13893 None,
13894 vec![],
13895 )
13896 .await
13897 .unwrap();
13898
13899 let before = rt
13900 .search_notes(&tok, "yvwkqz", None, 10, None, false, &[], None)
13901 .await
13902 .unwrap();
13903 assert!(
13904 before.iter().any(|h| h.note_id == note.id),
13905 "note must be in FTS before soft-delete"
13906 );
13907
13908 let deleted = rt.delete_note(&tok, note.id, false).await.unwrap();
13909 assert!(deleted, "soft delete must return true");
13910
13911 let after = rt
13912 .search_notes(&tok, "yvwkqz", None, 10, None, false, &[], None)
13913 .await
13914 .unwrap();
13915 assert!(
13916 after.iter().all(|h| h.note_id != note.id),
13917 "soft-deleted note must be removed from FTS index"
13918 );
13919 }
13920
13921 #[tokio::test]
13924 async fn link_extends_document_to_document_returns_invalid_input() {
13925 let rt = rt();
13926 let tok = NamespaceToken::local();
13927 let d1 = rt
13928 .create_entity(&tok, "document", None, "DocA", None, None, vec![])
13929 .await
13930 .unwrap();
13931 let d2 = rt
13932 .create_entity(&tok, "document", None, "DocB", None, None, vec![])
13933 .await
13934 .unwrap();
13935 let result = rt
13936 .link(&tok, d1.id, d2.id, EdgeRelation::Extends, 1.0, None)
13937 .await;
13938 assert!(
13939 result.is_err(),
13940 "F010: document->document Extends must be rejected by the base allowlist; \
13941 current generic entity fallthrough incorrectly accepts it"
13942 );
13943 }
13944
13945 #[tokio::test]
13946 async fn link_illegal_entity_pair_names_loaded_legal_relations() {
13947 let rt = rt();
13948 let tok = NamespaceToken::local();
13949 rt.install_edge_rules(vec![EdgeEndpointRule {
13950 relation: EdgeRelation::DependsOn,
13951 source: EndpointKind::EntityOfKind("concept"),
13952 target: EndpointKind::EntityOfKind("project"),
13953 }]);
13954 let concept = rt
13955 .create_entity(&tok, "concept", None, "Concept", None, None, vec![])
13956 .await
13957 .unwrap();
13958 let project = rt
13959 .create_entity(&tok, "project", None, "Project", None, None, vec![])
13960 .await
13961 .unwrap();
13962
13963 let error = rt
13964 .link(
13965 &tok,
13966 concept.id,
13967 project.id,
13968 EdgeRelation::CompetesWith,
13969 1.0,
13970 None,
13971 )
13972 .await
13973 .expect_err("concept competes_with project must be rejected");
13974 let message = error.to_string();
13975 assert!(
13976 message.contains(
13977 "currently legal relations for concept -> project under the loaded endpoint rules: depends_on"
13978 ),
13979 "rejection must expose the exact loaded legal set; got: {message}"
13980 );
13981 }
13982
13983 #[test]
13984 fn cross_backend_legal_set_ignores_unenforced_annotates_pack_rule() {
13985 let rt = rt();
13986 rt.install_edge_rules(vec![EdgeEndpointRule {
13987 relation: EdgeRelation::Annotates,
13988 source: EndpointKind::EntityOfKind("concept"),
13989 target: EndpointKind::EntityOfKind("project"),
13990 }]);
13991 let source_id = Uuid::new_v4();
13992 let target_id = Uuid::new_v4();
13993 let source = Resolved::Entity(Entity::new("local", "concept", "Concept"));
13994 let target = Resolved::Entity(Entity::new("local", "project", "Project"));
13995
13996 rt.validate_link_endpoints_by_resolved(
13997 source_id,
13998 target_id,
13999 EdgeRelation::Annotates,
14000 Some(&source),
14001 Some(&target),
14002 )
14003 .expect_err(
14004 "an annotates pack rule cannot override the dedicated note-source validator branch",
14005 );
14006
14007 let error = rt
14008 .validate_link_endpoints_by_resolved(
14009 source_id,
14010 target_id,
14011 EdgeRelation::CompetesWith,
14012 Some(&source),
14013 Some(&target),
14014 )
14015 .expect_err("concept competes_with project must be rejected");
14016 let message = error.to_string();
14017 assert!(
14018 message.contains(
14019 "currently legal relations for concept -> project under the loaded endpoint rules: none"
14020 ),
14021 "an unenforced entity-source annotates pack rule must not be advertised; got: {message}"
14022 );
14023 assert!(
14024 !message.contains("endpoint rules: annotates"),
14025 "the rejection must not call annotates legal when the live validator rejects it; got: {message}"
14026 );
14027 }
14028
14029 #[tokio::test]
14031 async fn link_extends_concept_to_concept_succeeds() {
14032 let rt = rt();
14033 let tok = NamespaceToken::local();
14034 let a = rt
14035 .create_entity(&tok, "concept", None, "CA", None, None, vec![])
14036 .await
14037 .unwrap();
14038 let b = rt
14039 .create_entity(&tok, "concept", None, "CB", None, None, vec![])
14040 .await
14041 .unwrap();
14042 let result = rt
14043 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
14044 .await;
14045 assert!(
14046 result.is_ok(),
14047 "F010: concept->concept Extends must be allowed (base allowlist)"
14048 );
14049 }
14050
14051 #[tokio::test]
14053 async fn link_symmetric_relation_canonicalizes_endpoint_order() {
14054 use khive_storage::EdgeFilter;
14055 let rt = rt();
14056 let tok = NamespaceToken::local();
14057 let a = rt
14058 .create_entity(&tok, "concept", None, "ConceptP", None, None, vec![])
14059 .await
14060 .unwrap();
14061 let b = rt
14062 .create_entity(&tok, "concept", None, "ConceptQ", None, None, vec![])
14063 .await
14064 .unwrap();
14065 rt.link(&tok, a.id, b.id, EdgeRelation::CompetesWith, 1.0, None)
14067 .await
14068 .unwrap();
14069 rt.link(&tok, b.id, a.id, EdgeRelation::CompetesWith, 1.0, None)
14070 .await
14071 .unwrap();
14072 let count = rt
14073 .graph(&tok)
14074 .unwrap()
14075 .count_edges(EdgeFilter::default())
14076 .await
14077 .unwrap();
14078 assert_eq!(
14079 count,
14080 1,
14081 "F012: CompetesWith is symmetric; A->B and B->A must deduplicate to one canonical row; \
14082 found {count} rows (canonicalization not yet implemented)"
14083 );
14084 }
14085
14086 #[tokio::test]
14088 async fn f010_supersedes_document_to_document_allowed() {
14089 let rt = rt();
14090 let tok = NamespaceToken::local();
14091 let a = rt
14092 .create_entity(&tok, "document", None, "DocA", None, None, vec![])
14093 .await
14094 .unwrap();
14095 let b = rt
14096 .create_entity(&tok, "document", None, "DocB", None, None, vec![])
14097 .await
14098 .unwrap();
14099 let result = rt
14100 .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
14101 .await;
14102 assert!(
14103 result.is_ok(),
14104 "document->document Supersedes must be allowed (allowlist), got {result:?}"
14105 );
14106 }
14107
14108 #[tokio::test]
14109 async fn f010_supersedes_artifact_to_artifact_allowed() {
14110 let rt = rt();
14111 let tok = NamespaceToken::local();
14112 let a = rt
14113 .create_entity(&tok, "artifact", None, "ArtA", None, None, vec![])
14114 .await
14115 .unwrap();
14116 let b = rt
14117 .create_entity(&tok, "artifact", None, "ArtB", None, None, vec![])
14118 .await
14119 .unwrap();
14120 let result = rt
14121 .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
14122 .await;
14123 assert!(
14124 result.is_ok(),
14125 "artifact->artifact Supersedes must be allowed (allowlist), got {result:?}"
14126 );
14127 }
14128
14129 #[tokio::test]
14130 async fn f010_supersedes_service_to_service_allowed() {
14131 let rt = rt();
14132 let tok = NamespaceToken::local();
14133 let a = rt
14134 .create_entity(&tok, "service", None, "SvcA", None, None, vec![])
14135 .await
14136 .unwrap();
14137 let b = rt
14138 .create_entity(&tok, "service", None, "SvcB", None, None, vec![])
14139 .await
14140 .unwrap();
14141 let result = rt
14142 .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
14143 .await;
14144 assert!(
14145 result.is_ok(),
14146 "service->service Supersedes must be allowed (allowlist), got {result:?}"
14147 );
14148 }
14149
14150 #[tokio::test]
14151 async fn f010_supersedes_dataset_to_dataset_allowed() {
14152 let rt = rt();
14153 let tok = NamespaceToken::local();
14154 let a = rt
14155 .create_entity(&tok, "dataset", None, "DataA", None, None, vec![])
14156 .await
14157 .unwrap();
14158 let b = rt
14159 .create_entity(&tok, "dataset", None, "DataB", None, None, vec![])
14160 .await
14161 .unwrap();
14162 let result = rt
14163 .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
14164 .await;
14165 assert!(
14166 result.is_ok(),
14167 "dataset->dataset Supersedes must be allowed (allowlist), got {result:?}"
14168 );
14169 }
14170
14171 #[tokio::test]
14173 async fn f010_supersedes_project_to_project_rejected() {
14174 let rt = rt();
14175 let tok = NamespaceToken::local();
14176 let a = rt
14177 .create_entity(&tok, "project", None, "ProjA", None, None, vec![])
14178 .await
14179 .unwrap();
14180 let b = rt
14181 .create_entity(&tok, "project", None, "ProjB", None, None, vec![])
14182 .await
14183 .unwrap();
14184 let result = rt
14185 .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
14186 .await;
14187 assert!(
14188 matches!(result, Err(RuntimeError::InvalidInput(_))),
14189 "project->project Supersedes must be rejected (not in allowlist), got {result:?}"
14190 );
14191 }
14192
14193 #[tokio::test]
14194 async fn f010_supersedes_person_to_person_rejected() {
14195 let rt = rt();
14196 let tok = NamespaceToken::local();
14197 let a = rt
14198 .create_entity(&tok, "person", None, "Alice", None, None, vec![])
14199 .await
14200 .unwrap();
14201 let b = rt
14202 .create_entity(&tok, "person", None, "Bob", None, None, vec![])
14203 .await
14204 .unwrap();
14205 let result = rt
14206 .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
14207 .await;
14208 assert!(
14209 matches!(result, Err(RuntimeError::InvalidInput(_))),
14210 "person->person Supersedes must be rejected (not in allowlist), got {result:?}"
14211 );
14212 }
14213
14214 #[tokio::test]
14215 async fn f010_supersedes_org_to_org_rejected() {
14216 let rt = rt();
14217 let tok = NamespaceToken::local();
14218 let a = rt
14219 .create_entity(&tok, "org", None, "OrgA", None, None, vec![])
14220 .await
14221 .unwrap();
14222 let b = rt
14223 .create_entity(&tok, "org", None, "OrgB", None, None, vec![])
14224 .await
14225 .unwrap();
14226 let result = rt
14227 .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
14228 .await;
14229 assert!(
14230 matches!(result, Err(RuntimeError::InvalidInput(_))),
14231 "org->org Supersedes must be rejected (not in allowlist), got {result:?}"
14232 );
14233 }
14234
14235 #[tokio::test]
14237 async fn f010_supersedes_same_kind_entity_allowed() {
14238 let rt = rt();
14239 let tok = NamespaceToken::local();
14240 let a = rt
14241 .create_entity(&tok, "concept", None, "OldV", None, None, vec![])
14242 .await
14243 .unwrap();
14244 let b = rt
14245 .create_entity(&tok, "concept", None, "NewV", None, None, vec![])
14246 .await
14247 .unwrap();
14248 let result = rt
14249 .link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
14250 .await;
14251 assert!(
14252 result.is_ok(),
14253 "concept->concept Supersedes must be allowed by the base allowlist, got {result:?}"
14254 );
14255 }
14256
14257 #[tokio::test]
14261 async fn f161_link_always_writes_null_target_backend() {
14262 let rt = rt();
14263 let tok = NamespaceToken::local();
14264 let a = rt
14265 .create_entity(&tok, "concept", None, "A", None, None, vec![])
14266 .await
14267 .unwrap();
14268 let b = rt
14269 .create_entity(&tok, "concept", None, "B", None, None, vec![])
14270 .await
14271 .unwrap();
14272 let edge = rt
14273 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
14274 .await
14275 .unwrap();
14276 assert!(
14277 edge.target_backend.is_none(),
14278 "F161: target_backend must be None for locally-routed edges; got {:?}",
14279 edge.target_backend
14280 );
14281 }
14282
14283 #[tokio::test]
14285 async fn f161_link_many_always_writes_null_target_backend() {
14286 let rt = rt();
14287 let tok = NamespaceToken::local();
14288 let a = rt
14289 .create_entity(&tok, "concept", None, "A", None, None, vec![])
14290 .await
14291 .unwrap();
14292 let b = rt
14293 .create_entity(&tok, "concept", None, "B", None, None, vec![])
14294 .await
14295 .unwrap();
14296 let c = rt
14297 .create_entity(&tok, "concept", None, "C", None, None, vec![])
14298 .await
14299 .unwrap();
14300 let specs = vec![
14301 LinkSpec {
14302 namespace: None,
14303 source_id: a.id,
14304 target_id: b.id,
14305 relation: EdgeRelation::Extends,
14306 weight: 1.0,
14307 metadata: None,
14308 resurrect: false,
14309 },
14310 LinkSpec {
14311 namespace: None,
14312 source_id: a.id,
14313 target_id: c.id,
14314 relation: EdgeRelation::Enables,
14315 weight: 1.0,
14316 metadata: None,
14317 resurrect: false,
14318 },
14319 ];
14320 let edges = rt.link_many(&tok, specs).await.unwrap();
14321 for edge in &edges {
14322 assert!(
14323 edge.target_backend.is_none(),
14324 "F161: target_backend must be None for locally-routed edges in link_many; got {:?}",
14325 edge.target_backend
14326 );
14327 }
14328 }
14329
14330 #[tokio::test]
14333 async fn f012_symmetric_neighbors_visible_from_both_endpoints() {
14334 let rt = rt();
14335 let tok = NamespaceToken::local();
14336 let a = rt
14337 .create_entity(&tok, "concept", None, "A", None, None, vec![])
14338 .await
14339 .unwrap();
14340 let b = rt
14341 .create_entity(&tok, "concept", None, "B", None, None, vec![])
14342 .await
14343 .unwrap();
14344 rt.link(&tok, a.id, b.id, EdgeRelation::CompetesWith, 1.0, None)
14346 .await
14347 .unwrap();
14348 let from_a = rt
14350 .neighbors(
14351 &tok,
14352 a.id,
14353 Direction::Out,
14354 None,
14355 Some(vec![EdgeRelation::CompetesWith]),
14356 )
14357 .await
14358 .unwrap();
14359 let from_b = rt
14360 .neighbors(
14361 &tok,
14362 b.id,
14363 Direction::Out,
14364 None,
14365 Some(vec![EdgeRelation::CompetesWith]),
14366 )
14367 .await
14368 .unwrap();
14369 assert_eq!(
14370 from_a.len(),
14371 1,
14372 "node A must see competes_with neighbor from Direction::Out (F012); got {from_a:?}"
14373 );
14374 assert_eq!(
14375 from_b.len(),
14376 1,
14377 "node B must see competes_with neighbor from Direction::Out (F012); got {from_b:?}"
14378 );
14379 }
14380
14381 #[tokio::test]
14383 async fn f010_supersedes_cross_kind_entity_rejected() {
14384 let rt = rt();
14385 let tok = NamespaceToken::local();
14386 let concept = rt
14387 .create_entity(&tok, "concept", None, "MyConcept", None, None, vec![])
14388 .await
14389 .unwrap();
14390 let doc = rt
14391 .create_entity(&tok, "document", None, "MyDoc", None, None, vec![])
14392 .await
14393 .unwrap();
14394 let result = rt
14395 .link(
14396 &tok,
14397 concept.id,
14398 doc.id,
14399 EdgeRelation::Supersedes,
14400 1.0,
14401 None,
14402 )
14403 .await;
14404 assert!(
14405 matches!(result, Err(RuntimeError::InvalidInput(_))),
14406 "concept->document Supersedes must be rejected by the base allowlist, got {result:?}"
14407 );
14408 }
14409
14410 #[tokio::test]
14413 async fn delete_note_cross_namespace_succeeds() {
14414 let rt = rt();
14415 let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
14416 let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
14417 let note = rt
14418 .create_note(
14419 &ns_a,
14420 "observation",
14421 None,
14422 "note in ns-a",
14423 Some(0.8),
14424 None,
14425 vec![],
14426 )
14427 .await
14428 .unwrap();
14429
14430 let result = rt.delete_note(&ns_b, note.id, false).await;
14432 assert!(
14433 result.unwrap(),
14434 "cross-namespace delete_note (soft) must return Ok(true)"
14435 );
14436
14437 let note_store = rt.notes(&ns_a).unwrap();
14439 let gone = note_store.get_note(note.id).await.unwrap();
14440 assert!(
14441 gone.is_none(),
14442 "note must be soft-deleted in its home namespace after cross-ns delete"
14443 );
14444
14445 let note2 = rt
14447 .create_note(
14448 &ns_a,
14449 "observation",
14450 None,
14451 "note2 in ns-a",
14452 Some(0.5),
14453 None,
14454 vec![],
14455 )
14456 .await
14457 .unwrap();
14458 let hard_result = rt.delete_note(&ns_b, note2.id, true).await;
14459 assert!(
14460 hard_result.unwrap(),
14461 "cross-namespace hard delete_note must return Ok(true)"
14462 );
14463 let gone2 = rt
14464 .get_note_including_deleted(&ns_a, note2.id)
14465 .await
14466 .unwrap();
14467 assert!(
14468 gone2.is_none(),
14469 "hard-deleted note must not appear even in including_deleted query"
14470 );
14471 }
14472
14473 #[tokio::test]
14481 async fn link_many_overlapping_triple_returns_persisted_ids() {
14482 let rt = rt();
14483 let tok = NamespaceToken::local();
14484 let a = rt
14485 .create_entity(&tok, "concept", None, "A", None, None, vec![])
14486 .await
14487 .unwrap();
14488 let b = rt
14489 .create_entity(&tok, "concept", None, "B", None, None, vec![])
14490 .await
14491 .unwrap();
14492
14493 let spec = || LinkSpec {
14494 namespace: None,
14495 source_id: a.id,
14496 target_id: b.id,
14497 relation: EdgeRelation::Extends,
14498 weight: 1.0,
14499 metadata: None,
14500 resurrect: false,
14501 };
14502
14503 let first = rt.link_many(&tok, vec![spec()]).await.unwrap();
14505 assert_eq!(first.len(), 1);
14506 let persisted_id: Uuid = first[0].id.into();
14507
14508 let second = rt.link_many(&tok, vec![spec()]).await.unwrap();
14511 assert_eq!(second.len(), 1);
14512 let second_id: Uuid = second[0].id.into();
14513
14514 assert_eq!(
14515 persisted_id, second_id,
14516 "link_many with an existing triple must return the persisted row ID ({persisted_id}), \
14517 not a new phantom ID ({second_id})"
14518 );
14519
14520 let count = rt
14522 .count_edges(&tok, crate::curation::EdgeListFilter::default())
14523 .await
14524 .unwrap();
14525 assert_eq!(count, 1, "upsert must not duplicate the edge row");
14526 }
14527
14528 #[tokio::test]
14531 async fn create_many_empty_and_invalid_batches_preserve_pending_fts_failure() {
14532 async fn assert_empty(rt: &KhiveRuntime, tok: &NamespaceToken) {
14533 assert_eq!(rt.count_entities(tok, None).await.unwrap(), 0);
14534 assert_eq!(
14535 rt.text(tok)
14536 .unwrap()
14537 .count(TextFilter {
14538 namespaces: vec![tok.namespace().as_str().to_owned()],
14539 ..Default::default()
14540 })
14541 .await
14542 .unwrap(),
14543 0,
14544 "BULK_ADMISSION_NO_FTS"
14545 );
14546 }
14547
14548 let rt = rt();
14549 let ns = format!("bulk-admission-{}", Uuid::new_v4());
14550 let tok = NamespaceToken::for_namespace(Namespace::parse(&ns).unwrap());
14551 let clean = |name: &str| EntityCreateSpec {
14552 kind: "concept".into(),
14553 entity_type: None,
14554 name: name.into(),
14555 description: None,
14556 properties: None,
14557 tags: vec![],
14558 };
14559 let _arm = arm_fts_fail_many_scoped(&ns);
14560 assert!(rt.create_many(&tok, vec![]).await.unwrap().is_empty());
14561 assert_empty(&rt, &tok).await;
14562
14563 let error = rt
14564 .create_many(
14565 &tok,
14566 vec![
14567 clean("Clean first item"),
14568 clean("ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"),
14569 ],
14570 )
14571 .await
14572 .expect_err("BULK_ADMISSION_INVALID");
14573 let RuntimeError::SecretDetected(matched) = error else {
14574 panic!("expected indexed secret refusal, got {error:?}");
14575 };
14576 assert_eq!(matched.location.as_deref(), Some("entity[1].name"));
14577 assert_empty(&rt, &tok).await;
14578
14579 let error = rt
14580 .create_many(&tok, vec![clean("Valid after refusal")])
14581 .await
14582 .expect_err("BULK_ADMISSION_FAULT_RETAINED");
14583 assert!(
14584 error.to_string().contains("atomic batch rolled back"),
14585 "{error}"
14586 );
14587 assert_empty(&rt, &tok).await;
14588
14589 let entities = rt
14590 .create_many(&tok, vec![clean("Valid after consumed fault")])
14591 .await
14592 .unwrap();
14593 assert_eq!(entities.len(), 1, "BULK_ADMISSION_EFFECT_CONTROL");
14594 assert!(rt
14595 .text(&tok)
14596 .unwrap()
14597 .get_document(&ns, entities[0].id)
14598 .await
14599 .unwrap()
14600 .is_some());
14601 }
14602
14603 #[tokio::test]
14604 async fn bulk_entity_paths_preserve_rows_fts_order_and_defer_embeddings() {
14605 for batch in [true, false] {
14606 let rt = rt();
14607 let ns = format!("bulk-plan-{}", Uuid::new_v4());
14608 let tok = NamespaceToken::for_namespace(Namespace::parse(&ns).unwrap());
14609 let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
14610 rt.register_embedder(CapturingVecProvider {
14611 provider_name: "bulk-plan-test".into(),
14612 dims: 4,
14613 captured: Arc::clone(&captured),
14614 });
14615 rt.install_entity_type_validator(Arc::new(|kind, entity_type| {
14616 if kind == "concept" && entity_type == Some("algo") {
14617 Ok(Some("algorithm".into()))
14618 } else {
14619 Err(RuntimeError::InvalidInput("expected fixture alias".into()))
14620 }
14621 }));
14622 let specs: Vec<_> = ["Zulu concept", "Alpha concept"]
14623 .into_iter()
14624 .enumerate()
14625 .map(|(index, name)| EntityCreateSpec {
14626 kind: "concept".into(),
14627 entity_type: Some("algo".into()),
14628 name: name.into(),
14629 description: Some(format!("glimmerneedle{index}")),
14630 properties: Some(serde_json::json!({"ordinal":index,"enabled":true})),
14631 tags: vec![format!("tag-{index}")],
14632 })
14633 .collect();
14634 let entities = if batch {
14635 rt.create_many(&tok, specs.clone()).await.unwrap()
14636 } else {
14637 let mut entities = Vec::new();
14638 let mut plans = Vec::new();
14639 for spec in specs.clone() {
14640 let (entity, plan) = rt.prepare_bulk_entity_plan(&tok, spec).await.unwrap();
14641 entities.push(entity);
14642 plans.push(plan);
14643 }
14644 assert_eq!(rt.count_entities(&tok, None).await.unwrap(), 0);
14645 match run_atomic_unit(rt.sql().as_ref(), plans).await.unwrap() {
14646 AtomicRunOutcome::Committed { post_commit } => {
14647 assert!(post_commit.as_slice().is_empty(), "BULK_PLAN_NO_REINDEX");
14648 }
14649 outcome => panic!("expected committed entity plans, got {outcome:?}"),
14650 }
14651 entities
14652 };
14653 assert_eq!(entities.len(), specs.len());
14654 for (entity, spec) in entities.iter().zip(&specs) {
14655 let stored = rt.get_entity(&tok, entity.id).await.unwrap();
14656 assert_eq!(entity.name, spec.name, "BULK_PLAN_INPUT_ORDER");
14657 assert_eq!(stored.namespace, ns);
14658 assert_eq!(stored.kind, spec.kind);
14659 assert_eq!(stored.name, spec.name);
14660 assert_eq!(stored.description, spec.description);
14661 assert_eq!(stored.properties, spec.properties);
14662 assert_eq!(stored.tags, spec.tags);
14663 assert_eq!(stored.entity_type.as_deref(), Some("algorithm"));
14664 assert_eq!(stored.version, 1);
14665 let document = rt
14666 .text(&tok)
14667 .unwrap()
14668 .get_document(&ns, entity.id)
14669 .await
14670 .unwrap()
14671 .expect("BULK_PLAN_FTS_VISIBLE");
14672 assert_eq!(document.title.as_deref(), Some(spec.name.as_str()));
14673 assert_eq!(
14674 document.body,
14675 format!("{} {}", spec.name, spec.description.as_deref().unwrap())
14676 );
14677 assert_eq!(document.tags, spec.tags);
14678 assert_eq!(document.metadata, spec.properties);
14679 assert_eq!(document.record_kind.as_deref(), Some("concept"));
14680 assert_eq!(document.updated_at.timestamp_micros(), stored.updated_at);
14681 let hits = rt
14682 .text(&tok)
14683 .unwrap()
14684 .search(TextSearchRequest {
14685 query: spec.description.clone().unwrap(),
14686 mode: TextQueryMode::Plain,
14687 filter: Some(TextFilter {
14688 namespaces: vec![ns.clone()],
14689 ..Default::default()
14690 }),
14691 top_k: 10,
14692 snippet_chars: 100,
14693 })
14694 .await
14695 .unwrap();
14696 assert_eq!(
14697 hits.iter().map(|hit| hit.subject_id).collect::<Vec<_>>(),
14698 vec![entity.id],
14699 "BULK_PLAN_SEARCH_VISIBLE"
14700 );
14701 }
14702 assert!(
14703 captured.lock().unwrap().is_empty(),
14704 "BULK_PLAN_EMBEDDING_DEFERRED"
14705 );
14706 assert_eq!(
14707 rt.vectors_for_model(&tok, "bulk-plan-test")
14708 .unwrap()
14709 .info()
14710 .await
14711 .unwrap()
14712 .entry_count,
14713 0
14714 );
14715 rt.reindex_entity(&tok, &entities[0]).await.unwrap();
14716 assert_eq!(
14717 captured.lock().unwrap().len(),
14718 1,
14719 "BULK_PLAN_EMBEDDER_EFFECT_CONTROL"
14720 );
14721 assert_eq!(
14722 rt.vectors_for_model(&tok, "bulk-plan-test")
14723 .unwrap()
14724 .info()
14725 .await
14726 .unwrap()
14727 .entry_count,
14728 1
14729 );
14730 }
14731 }
14732
14733 #[tokio::test]
14734 async fn create_many_persists_all_entities() {
14735 let rt = rt();
14736 let tok = NamespaceToken::local();
14737
14738 let specs: Vec<EntityCreateSpec> = (0..5)
14739 .map(|i| EntityCreateSpec {
14740 kind: "concept".into(),
14741 entity_type: None,
14742 name: format!("BulkConcept-{i}"),
14743 description: Some(format!("desc {i}")),
14744 properties: None,
14745 tags: vec!["bulk-test".into()],
14746 })
14747 .collect();
14748
14749 let entities = rt.create_many(&tok, specs).await.unwrap();
14750 assert_eq!(entities.len(), 5, "all 5 entities must be returned");
14751
14752 for entity in &entities {
14754 let fetched = rt.get_entity(&tok, entity.id).await.unwrap();
14755 assert_eq!(fetched.id, entity.id);
14756 }
14757 }
14758
14759 #[tokio::test]
14760 async fn create_many_empty_name_rejects_atomically() {
14761 let rt = rt();
14762 let tok = NamespaceToken::local();
14763
14764 let specs = vec![
14765 EntityCreateSpec {
14766 kind: "concept".into(),
14767 entity_type: None,
14768 name: "ValidEntity".into(),
14769 description: None,
14770 properties: None,
14771 tags: vec![],
14772 },
14773 EntityCreateSpec {
14774 kind: "concept".into(),
14775 entity_type: None,
14776 name: "".into(), description: None,
14778 properties: None,
14779 tags: vec![],
14780 },
14781 ];
14782
14783 let result = rt.create_many(&tok, specs).await;
14784 assert!(
14785 matches!(result, Err(RuntimeError::InvalidInput(_))),
14786 "empty name must produce InvalidInput error"
14787 );
14788
14789 let rows = rt.list_entities(&tok, None, None, 100, 0).await.unwrap();
14791 assert_eq!(
14792 rows.len(),
14793 0,
14794 "atomic rejection must leave storage unchanged"
14795 );
14796 }
14797
14798 #[tokio::test]
14804 async fn create_many_secret_refusal_names_the_record_and_field() {
14805 let rt = rt();
14806 let tok = NamespaceToken::local();
14807 let token_span = "ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
14808 let clean = |name: &str| EntityCreateSpec {
14809 kind: "concept".into(),
14810 entity_type: None,
14811 name: name.into(),
14812 description: None,
14813 properties: None,
14814 tags: vec![],
14815 };
14816
14817 let err = rt
14818 .create_many(&tok, vec![clean("FirstIsClean"), clean(token_span)])
14819 .await
14820 .expect_err("a credential-shaped name must fail the secret gate");
14821 let RuntimeError::SecretDetected(matched) = err else {
14822 panic!("expected SecretDetected, got {err:?}");
14823 };
14824 assert_eq!(
14825 matched.location.as_deref(),
14826 Some("entity[1].name"),
14827 "the refusal must name the offending record and field"
14828 );
14829
14830 let mut second = clean("SecondIsClean");
14831 second.description = Some(format!("{token_span} sitting in a description"));
14832 let err = rt
14833 .create_many(&tok, vec![clean("FirstIsClean"), second])
14834 .await
14835 .expect_err("a credential-shaped description must fail the secret gate");
14836 let RuntimeError::SecretDetected(matched) = err else {
14837 panic!("expected SecretDetected, got {err:?}");
14838 };
14839 assert_eq!(
14840 matched.location.as_deref(),
14841 Some("entity[1].description"),
14842 "same record, different field: the field half of the location must move"
14843 );
14844
14845 let count = rt.count_entities(&tok, None).await.unwrap();
14846 assert_eq!(count, 0, "a rejected batch must leave no entity behind");
14847 }
14848
14849 #[tokio::test]
14853 async fn create_note_secret_refusal_names_the_field() {
14854 let rt = rt();
14855 let tok = NamespaceToken::local();
14856 let token_span = "ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
14857
14858 let err = rt
14859 .create_note_with_embedding_content(
14860 &tok,
14861 "observation",
14862 Some(token_span),
14863 "content with nothing credential-shaped in it",
14864 None,
14865 None,
14866 None,
14867 vec![],
14868 )
14869 .await
14870 .expect_err("a credential-shaped name must fail the secret gate");
14871 let RuntimeError::SecretDetected(matched) = err else {
14872 panic!("expected SecretDetected, got {err:?}");
14873 };
14874 assert_eq!(matched.location.as_deref(), Some("note.name"));
14875
14876 let err = rt
14877 .create_note_with_embedding_content(
14878 &tok,
14879 "observation",
14880 Some("a clean name"),
14881 &format!("{token_span} sitting in the content"),
14882 None,
14883 None,
14884 None,
14885 vec![],
14886 )
14887 .await
14888 .expect_err("a credential-shaped content must fail the secret gate");
14889 let RuntimeError::SecretDetected(matched) = err else {
14890 panic!("expected SecretDetected, got {err:?}");
14891 };
14892 assert_eq!(
14893 matched.location.as_deref(),
14894 Some("note.content"),
14895 "the location must follow the field that actually matched"
14896 );
14897 }
14898
14899 #[tokio::test]
14900 async fn create_many_rejects_unknown_entity_type_when_validator_installed() {
14901 let rt = rt();
14902 let tok = NamespaceToken::local();
14903
14904 rt.install_entity_type_validator(Arc::new(|kind, entity_type| {
14906 let Some(raw) = entity_type else {
14907 return Ok(None);
14908 };
14909 if kind == "concept" && raw == "algorithm" {
14910 return Ok(Some("algorithm".to_string()));
14911 }
14912 Err(RuntimeError::InvalidInput(format!(
14913 "unknown entity_type {raw:?} for {kind:?}; valid: algorithm"
14914 )))
14915 }));
14916
14917 let bad_spec = vec![EntityCreateSpec {
14918 kind: "concept".into(),
14919 entity_type: Some("not_a_registered_type".into()),
14920 name: "ShouldNotLand".into(),
14921 description: None,
14922 properties: None,
14923 tags: vec![],
14924 }];
14925
14926 let result = rt.create_many(&tok, bad_spec).await;
14927 assert!(
14928 matches!(result, Err(RuntimeError::InvalidInput(_))),
14929 "unknown entity_type must be rejected by the runtime-layer validator; got {result:?}"
14930 );
14931
14932 let rows = rt.list_entities(&tok, None, None, 100, 0).await.unwrap();
14934 assert_eq!(
14935 rows.len(),
14936 0,
14937 "validator rejection must leave storage empty"
14938 );
14939 }
14940
14941 #[tokio::test]
14943 async fn create_many_accepts_valid_entity_type_via_validator() {
14944 let rt = rt();
14945 let tok = NamespaceToken::local();
14946
14947 rt.install_entity_type_validator(Arc::new(|kind, entity_type| {
14948 let Some(raw) = entity_type else {
14949 return Ok(None);
14950 };
14951 if kind == "concept" && raw == "algorithm" {
14952 return Ok(Some("algorithm".to_string()));
14953 }
14954 Err(RuntimeError::InvalidInput(format!(
14955 "unknown entity_type {raw:?} for {kind:?}"
14956 )))
14957 }));
14958
14959 let specs = vec![EntityCreateSpec {
14960 kind: "concept".into(),
14961 entity_type: Some("algorithm".into()),
14962 name: "BubbleSort".into(),
14963 description: None,
14964 properties: None,
14965 tags: vec![],
14966 }];
14967
14968 let entities = rt.create_many(&tok, specs).await.unwrap();
14969 assert_eq!(entities.len(), 1, "valid entity_type must be accepted");
14970 assert_eq!(
14971 entities[0].entity_type.as_deref(),
14972 Some("algorithm"),
14973 "entity_type must be stored as returned by the validator"
14974 );
14975 }
14976
14977 #[tokio::test]
14982 async fn create_many_fts_failure_rolls_back_both_substrates() {
14983 let ns = format!("fts-fail-many-{}", uuid::Uuid::new_v4().as_simple());
14985 let rt = rt();
14986 let tok = NamespaceToken::for_namespace(Namespace::parse(&ns).unwrap());
14987
14988 let specs = vec![
14989 EntityCreateSpec {
14990 kind: "concept".into(),
14991 entity_type: None,
14992 name: "FtsRollbackA".into(),
14993 description: None,
14994 properties: None,
14995 tags: vec![],
14996 },
14997 EntityCreateSpec {
14998 kind: "concept".into(),
14999 entity_type: None,
15000 name: "FtsRollbackB".into(),
15001 description: None,
15002 properties: None,
15003 tags: vec![],
15004 },
15005 ];
15006
15007 let _arm = arm_fts_fail_many_scoped(&ns);
15008 let result = rt.create_many(&tok, specs).await;
15009
15010 assert!(
15011 result.is_err(),
15012 "create_many must return Err when FTS write fails"
15013 );
15014
15015 let entity_rows = rt.list_entities(&tok, None, None, 100, 0).await.unwrap();
15017 assert_eq!(
15018 entity_rows.len(),
15019 0,
15020 "entity rows must be rolled back on FTS failure; found {entity_rows:?}"
15021 );
15022
15023 let fts = rt.text(&tok).unwrap();
15025 let fts_count = fts
15026 .count(TextFilter {
15027 ids: vec![],
15028 kinds: vec![],
15029 record_kinds: vec![],
15030 namespaces: vec![ns.clone()],
15031 })
15032 .await
15033 .unwrap();
15034 assert_eq!(
15035 fts_count, 0,
15036 "fts_entities must be empty after FTS-failure rollback; found {fts_count}"
15037 );
15038 }
15039
15040 #[tokio::test]
15048 async fn restore_note_publishes_fts_in_the_same_unit_as_the_row() {
15049 let ns = format!("restore-fts-note-{}", uuid::Uuid::new_v4().as_simple());
15050 let rt = rt();
15051 let tok = NamespaceToken::for_namespace(Namespace::parse(&ns).unwrap());
15052 let note = rt
15053 .create_note(
15054 &tok,
15055 "observation",
15056 None,
15057 "quartz tombstone restore lookup marker",
15058 None,
15059 None,
15060 vec![],
15061 )
15062 .await
15063 .unwrap();
15064 let fts = rt.text_for_notes(&tok).unwrap();
15065 let filter = || TextFilter {
15066 ids: vec![note.id],
15067 kinds: vec![],
15068 record_kinds: vec![],
15069 namespaces: vec![ns.clone()],
15070 };
15071 assert_eq!(
15072 fts.count(filter()).await.unwrap(),
15073 1,
15074 "control: the filter must see the live note's FTS row before the delete"
15075 );
15076 assert!(rt.delete_note(&tok, note.id, false).await.unwrap());
15077 assert_eq!(
15078 fts.count(filter()).await.unwrap(),
15079 0,
15080 "soft delete must remove the FTS row"
15081 );
15082
15083 let _arm = arm_fts_fail_scoped(&ns);
15084 let err = rt
15085 .restore_note(&tok, note.id)
15086 .await
15087 .expect_err("the post-commit reindex failure must surface");
15088 assert!(
15089 err.to_string().contains("is restored and text-indexed"),
15090 "the error must name the live row: {err}"
15091 );
15092
15093 let live = rt
15094 .notes(&tok)
15095 .unwrap()
15096 .get_note(note.id)
15097 .await
15098 .unwrap()
15099 .expect("the row is live after the committed unit");
15100 assert!(live.deleted_at.is_none());
15101 assert_eq!(
15102 fts.count(filter()).await.unwrap(),
15103 1,
15104 "the FTS row must be published by the restore unit, not by the reindex"
15105 );
15106 let hits = rt
15107 .search_notes(&tok, "quartz tombstone", None, 5, None, false, &[], None)
15108 .await
15109 .unwrap();
15110 assert!(
15111 hits.iter().any(|h| h.note_id == note.id),
15112 "search must find the restored note: {hits:?}"
15113 );
15114 }
15115
15116 #[tokio::test]
15117 async fn restore_entity_publishes_fts_in_the_same_unit_as_the_row() {
15118 let ns = format!("restore-fts-entity-{}", uuid::Uuid::new_v4().as_simple());
15119 let rt = rt();
15120 let tok = NamespaceToken::for_namespace(Namespace::parse(&ns).unwrap());
15121 let entity = rt
15122 .create_entity(
15123 &tok,
15124 "concept",
15125 None,
15126 "QuartzRestoreMarker",
15127 Some("tombstone restore lookup marker"),
15128 None,
15129 vec![],
15130 )
15131 .await
15132 .unwrap();
15133 let fts = rt.text(&tok).unwrap();
15134 let filter = || TextFilter {
15135 ids: vec![entity.id],
15136 kinds: vec![],
15137 record_kinds: vec![],
15138 namespaces: vec![ns.clone()],
15139 };
15140 assert_eq!(
15141 fts.count(filter()).await.unwrap(),
15142 1,
15143 "control: the filter must see the live entity's FTS row before the delete"
15144 );
15145 assert!(rt.delete_entity(&tok, entity.id, false).await.unwrap());
15146 assert_eq!(fts.count(filter()).await.unwrap(), 0);
15147
15148 let _arm = arm_fts_fail_scoped(&ns);
15149 let err = rt
15150 .restore_entity(&tok, entity.id)
15151 .await
15152 .expect_err("the post-commit reindex failure must surface");
15153 assert!(
15154 err.to_string().contains("is restored and text-indexed"),
15155 "{err}"
15156 );
15157 let live = rt.get_entity(&tok, entity.id).await.unwrap();
15158 assert!(live.deleted_at.is_none());
15159 assert_eq!(
15160 fts.count(filter()).await.unwrap(),
15161 1,
15162 "the FTS row must be published by the restore unit, not by the reindex"
15163 );
15164 }
15165
15166 #[tokio::test]
15167 async fn create_many_fts_failure_injections_for_distinct_namespaces_do_not_overwrite_each_other(
15168 ) {
15169 let rt_a = rt();
15170 let ns_a = Namespace::parse("fts-fail-many-distinct-a").unwrap();
15171 let tok_a = NamespaceToken::for_namespace(ns_a.clone());
15172 let rt_b = rt();
15173 let ns_b = Namespace::parse("fts-fail-many-distinct-b").unwrap();
15174 let tok_b = NamespaceToken::for_namespace(ns_b.clone());
15175
15176 let _arm_a = arm_fts_fail_many_scoped(ns_a.as_str());
15177 let _arm_b = arm_fts_fail_many_scoped(ns_b.as_str());
15178
15179 let (result_a, result_b) = tokio::join!(
15180 rt_a.create_many(
15181 &tok_a,
15182 vec![EntityCreateSpec {
15183 kind: "concept".into(),
15184 entity_type: None,
15185 name: "FtsFailureTargetA".into(),
15186 description: None,
15187 properties: None,
15188 tags: vec![],
15189 }],
15190 ),
15191 rt_b.create_many(
15192 &tok_b,
15193 vec![EntityCreateSpec {
15194 kind: "concept".into(),
15195 entity_type: None,
15196 name: "FtsFailureTargetB".into(),
15197 description: None,
15198 properties: None,
15199 tags: vec![],
15200 }],
15201 ),
15202 );
15203
15204 assert!(
15205 result_a.is_err(),
15206 "namespace A must retain its pending create_many FTS failure injection"
15207 );
15208 assert!(
15209 result_b.is_err(),
15210 "namespace B must retain its pending create_many FTS failure injection"
15211 );
15212 }
15213
15214 #[tokio::test]
15218 async fn create_many_mid_batch_storage_failure_rolls_back_both_substrates() {
15219 let ns = format!("fts-fail-partial-{}", uuid::Uuid::new_v4().as_simple());
15220 let rt = rt();
15221 let tok = NamespaceToken::for_namespace(Namespace::parse(&ns).unwrap());
15222
15223 let specs = vec![
15224 EntityCreateSpec {
15225 kind: "concept".into(),
15226 entity_type: None,
15227 name: "PartialRollbackA".into(),
15228 description: None,
15229 properties: None,
15230 tags: vec![],
15231 },
15232 EntityCreateSpec {
15233 kind: "concept".into(),
15234 entity_type: None,
15235 name: "PartialRollbackB".into(),
15236 description: None,
15237 properties: None,
15238 tags: vec![],
15239 },
15240 ];
15241
15242 let _arm = arm_fts_fail_many_partial_scoped(&ns);
15243 let result = rt.create_many(&tok, specs).await;
15244
15245 assert!(
15246 result.is_err(),
15247 "create_many must return Err when an FTS write fails mid-batch"
15248 );
15249 let error = result.unwrap_err().to_string();
15250 assert!(
15251 error.contains("atomic batch rolled back at entity index 1"),
15252 "the failure must occur inside the atomic batch after one complete row; got: {error}"
15253 );
15254
15255 let entity_rows = rt.list_entities(&tok, None, None, 100, 0).await.unwrap();
15257 assert_eq!(
15258 entity_rows.len(),
15259 0,
15260 "entity rows must be empty after a mid-batch FTS failure; found {entity_rows:?}"
15261 );
15262
15263 let fts = rt.text(&tok).unwrap();
15265 let fts_count = fts
15266 .count(TextFilter {
15267 ids: vec![],
15268 kinds: vec![],
15269 record_kinds: vec![],
15270 namespaces: vec![ns.clone()],
15271 })
15272 .await
15273 .unwrap();
15274 assert_eq!(
15275 fts_count, 0,
15276 "fts_entities must be empty after a mid-batch FTS failure; found {fts_count}"
15277 );
15278 }
15279
15280 #[tokio::test]
15281 async fn create_many_fts_partial_failure_injections_for_distinct_namespaces_do_not_overwrite_each_other(
15282 ) {
15283 let rt_a = rt();
15284 let ns_a = Namespace::parse("fts-fail-many-partial-distinct-a").unwrap();
15285 let tok_a = NamespaceToken::for_namespace(ns_a.clone());
15286 let rt_b = rt();
15287 let ns_b = Namespace::parse("fts-fail-many-partial-distinct-b").unwrap();
15288 let tok_b = NamespaceToken::for_namespace(ns_b.clone());
15289
15290 let _arm_a = arm_fts_fail_many_partial_scoped(ns_a.as_str());
15291 let _arm_b = arm_fts_fail_many_partial_scoped(ns_b.as_str());
15292
15293 let (result_a, result_b) = tokio::join!(
15294 rt_a.create_many(
15295 &tok_a,
15296 vec![EntityCreateSpec {
15297 kind: "concept".into(),
15298 entity_type: None,
15299 name: "FtsPartialFailureTargetA".into(),
15300 description: None,
15301 properties: None,
15302 tags: vec![],
15303 }],
15304 ),
15305 rt_b.create_many(
15306 &tok_b,
15307 vec![EntityCreateSpec {
15308 kind: "concept".into(),
15309 entity_type: None,
15310 name: "FtsPartialFailureTargetB".into(),
15311 description: None,
15312 properties: None,
15313 tags: vec![],
15314 }],
15315 ),
15316 );
15317
15318 assert!(
15319 result_a.is_err(),
15320 "namespace A must retain its pending create_many partial FTS failure injection"
15321 );
15322 assert!(
15323 result_b.is_err(),
15324 "namespace B must retain its pending create_many partial FTS failure injection"
15325 );
15326 }
15327
15328 #[tokio::test]
15331 async fn get_edge_cross_namespace_succeeds() {
15332 let rt = rt();
15333 let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
15334 let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
15335
15336 let src = rt
15337 .create_entity(&ns_a, "concept", None, "Src", None, None, vec![])
15338 .await
15339 .unwrap();
15340 let tgt = rt
15341 .create_entity(&ns_a, "concept", None, "Tgt", None, None, vec![])
15342 .await
15343 .unwrap();
15344 let edge = rt
15345 .link(&ns_a, src.id, tgt.id, EdgeRelation::Extends, 1.0, None)
15346 .await
15347 .unwrap();
15348
15349 let own_ns = rt.get_edge(&ns_a, Uuid::from(edge.id)).await;
15351 assert!(
15352 own_ns.is_ok() && own_ns.unwrap().is_some(),
15353 "edge must be visible in its own namespace"
15354 );
15355
15356 let cross_ns = rt.get_edge(&ns_b, Uuid::from(edge.id)).await;
15358 assert!(
15359 matches!(cross_ns, Ok(Some(_))),
15360 "cross-namespace get_edge must return Ok(Some(_)) after PR-A1, got {cross_ns:?}"
15361 );
15362
15363 let absent = rt.get_edge(&ns_b, Uuid::new_v4()).await;
15365 assert!(
15366 matches!(absent, Ok(None)),
15367 "absent edge must return Ok(None), got {absent:?}"
15368 );
15369 }
15370
15371 #[tokio::test]
15387 async fn deleted_entity_ids_propagates_reader_pool_admission_timeout_instead_of_swallowing_it()
15388 {
15389 let dir = tempfile::tempdir().unwrap();
15390 let db_path = dir.path().join("deleted-entity-ids-saturation.db");
15391 let rt = KhiveRuntime::new_for_test(crate::config::RuntimeConfig {
15392 db_path: Some(db_path),
15393 ..crate::config::RuntimeConfig::no_embeddings()
15394 })
15395 .expect("file-backed runtime construction must succeed");
15396 let ns = NamespaceToken::for_namespace(Namespace::parse("saturation-ns").unwrap());
15397 let a = rt
15398 .create_entity(&ns, "concept", None, "A", None, None, vec![])
15399 .await
15400 .unwrap();
15401
15402 let pool = rt.backend().pool_arc();
15403 let capacity = pool.reader_acquisition_snapshot().reader_admission_capacity;
15404 let held: Vec<_> = (0..capacity)
15405 .map(|_| pool.reader().expect("hold every pooled reader"))
15406 .collect();
15407
15408 let result = rt.deleted_entity_ids(vec![a.id]).await;
15409 assert!(
15410 matches!(
15411 result,
15412 Err(RuntimeError::Storage(
15413 khive_storage::StorageError::AdmissionTimeout { .. }
15414 ))
15415 ),
15416 "a saturated reader pool must surface AdmissionTimeout, not an empty deleted \
15417 set; got {result:?}"
15418 );
15419
15420 drop(held);
15421 }
15422
15423 #[tokio::test]
15426 async fn traverse_foreign_full_uuid_root_uses_caller_edge_scope() {
15427 use khive_storage::types::TraversalOptions;
15428
15429 let rt = rt();
15430 let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
15431 let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
15432
15433 let a = rt
15434 .create_entity(&ns_a, "concept", None, "A", None, None, vec![])
15435 .await
15436 .unwrap();
15437 let owner_target = rt
15438 .create_entity(&ns_a, "concept", None, "B", None, None, vec![])
15439 .await
15440 .unwrap();
15441 let caller_target = rt
15442 .create_entity(&ns_b, "concept", None, "C", None, None, vec![])
15443 .await
15444 .unwrap();
15445 rt.link(
15446 &ns_a,
15447 a.id,
15448 owner_target.id,
15449 EdgeRelation::Extends,
15450 1.0,
15451 None,
15452 )
15453 .await
15454 .unwrap();
15455 rt.link(
15456 &ns_b,
15457 a.id,
15458 caller_target.id,
15459 EdgeRelation::Extends,
15460 1.0,
15461 None,
15462 )
15463 .await
15464 .unwrap();
15465
15466 let request = TraversalRequest {
15467 roots: vec![a.id],
15468 options: TraversalOptions {
15469 max_depth: 1,
15470 direction: Direction::Out,
15471 ..Default::default()
15472 },
15473 include_roots: false,
15474 include_properties: false,
15475 execution_budget: Default::default(),
15476 };
15477 let paths = rt
15478 .traverse(&ns_b, request.clone())
15479 .await
15480 .expect("foreign full-UUID root must be accepted");
15481 assert_eq!(paths.len(), 1);
15482 assert_eq!(paths[0].nodes.len(), 1);
15483 assert_eq!(paths[0].nodes[0].node_id, caller_target.id);
15484
15485 let missing = Uuid::new_v4();
15486 let error = rt
15487 .traverse(
15488 &ns_b,
15489 TraversalRequest {
15490 roots: vec![a.id, missing],
15491 ..request
15492 },
15493 )
15494 .await
15495 .unwrap_err();
15496 assert!(matches!(
15497 error,
15498 RuntimeError::NotFound(message) if message.contains(&missing.to_string())
15499 ));
15500 }
15501
15502 #[tokio::test]
15505 async fn traverse_single_root_across_visible_namespaces_yields_one_path() {
15506 use khive_storage::types::TraversalOptions;
15507
15508 let rt = rt();
15509 let owner = NamespaceToken::for_namespace(Namespace::parse("owner-ns").unwrap());
15510 let a = rt
15511 .create_entity(&owner, "concept", None, "A", None, None, vec![])
15512 .await
15513 .unwrap();
15514 let b = rt
15515 .create_entity(&owner, "concept", None, "B", None, None, vec![])
15516 .await
15517 .unwrap();
15518 rt.link(&owner, a.id, b.id, EdgeRelation::Extends, 1.0, None)
15519 .await
15520 .unwrap();
15521
15522 let caller = NamespaceToken::mint_with_visibility(
15527 Namespace::parse("caller-ns").unwrap(),
15528 vec![Namespace::parse("owner-ns").unwrap()],
15529 ActorRef::anonymous(),
15530 );
15531 assert_eq!(caller.visible_namespaces().len(), 2);
15532
15533 let result = rt
15534 .traverse(
15535 &caller,
15536 TraversalRequest {
15537 roots: vec![a.id],
15538 options: TraversalOptions {
15539 max_depth: 1,
15540 direction: Direction::Out,
15541 ..Default::default()
15542 },
15543 include_roots: true,
15544 include_properties: false,
15545 execution_budget: Default::default(),
15546 },
15547 )
15548 .await
15549 .unwrap();
15550
15551 assert_eq!(
15552 result.len(),
15553 1,
15554 "one root visible across 2 namespaces must yield exactly one \
15555 GraphPath, got {result:#?}"
15556 );
15557 assert_eq!(result[0].root_id, a.id);
15558 let node_ids: std::collections::HashSet<Uuid> =
15559 result[0].nodes.iter().map(|n| n.node_id).collect();
15560 assert!(node_ids.contains(&a.id));
15561 assert!(
15562 node_ids.contains(&b.id),
15563 "merged path must retain the neighbor discovered in the owning \
15564 namespace, got {result:#?}"
15565 );
15566 }
15567
15568 #[tokio::test]
15573 async fn traverse_visible_namespaces_share_one_work_budget() {
15574 use khive_storage::types::{TraversalExecutionBudget, TraversalOptions};
15575
15576 let rt = rt();
15577 let ns_a = Namespace::parse("traverse-budget-a").unwrap();
15578 let ns_b = Namespace::parse("traverse-budget-b").unwrap();
15579 let tok_a = NamespaceToken::for_namespace(ns_a.clone());
15580 let tok_b = NamespaceToken::for_namespace(ns_b.clone());
15581 let visible = NamespaceToken::mint_with_visibility(ns_a, vec![ns_b], ActorRef::anonymous());
15582
15583 let root = rt
15584 .create_entity(&tok_a, "concept", None, "BudgetRoot", None, None, vec![])
15585 .await
15586 .unwrap();
15587 let child_a = rt
15588 .create_entity(&tok_a, "concept", None, "BudgetChildA", None, None, vec![])
15589 .await
15590 .unwrap();
15591 let child_b = rt
15592 .create_entity(&tok_b, "concept", None, "BudgetChildB", None, None, vec![])
15593 .await
15594 .unwrap();
15595 rt.link(
15596 &tok_a,
15597 root.id,
15598 child_a.id,
15599 EdgeRelation::Extends,
15600 1.0,
15601 None,
15602 )
15603 .await
15604 .unwrap();
15605 rt.link(
15606 &tok_b,
15607 root.id,
15608 child_b.id,
15609 EdgeRelation::Extends,
15610 1.0,
15611 None,
15612 )
15613 .await
15614 .unwrap();
15615
15616 let result = rt
15617 .traverse(
15618 &visible,
15619 TraversalRequest {
15620 roots: vec![root.id],
15621 options: TraversalOptions {
15622 max_depth: 1,
15623 direction: Direction::Out,
15624 relations: None,
15625 min_weight: None,
15626 limit: Some(2),
15627 },
15628 include_roots: false,
15629 include_properties: false,
15630 execution_budget: TraversalExecutionBudget::new(
15631 1,
15632 std::time::Duration::from_secs(5),
15633 ),
15634 },
15635 )
15636 .await;
15637
15638 assert!(matches!(
15639 result,
15640 Err(RuntimeError::Storage(khive_storage::StorageError::InvalidInput {
15641 message,
15642 ..
15643 })) if message.contains("work budget exceeded after 1 adjacency rows")
15644 ));
15645 }
15646
15647 #[tokio::test]
15650 async fn traverse_multi_root_one_path_per_distinct_root() {
15651 use khive_storage::types::TraversalOptions;
15652
15653 let rt = rt();
15654 let owner = NamespaceToken::for_namespace(Namespace::parse("owner-ns2").unwrap());
15655 let a = rt
15656 .create_entity(&owner, "concept", None, "A", None, None, vec![])
15657 .await
15658 .unwrap();
15659 let c = rt
15660 .create_entity(&owner, "concept", None, "C", None, None, vec![])
15661 .await
15662 .unwrap();
15663
15664 let result = rt
15669 .traverse(
15670 &owner,
15671 TraversalRequest {
15672 roots: vec![a.id, a.id, c.id],
15673 options: TraversalOptions {
15674 max_depth: 1,
15675 direction: Direction::Out,
15676 ..Default::default()
15677 },
15678 include_roots: true,
15679 include_properties: false,
15680 execution_budget: Default::default(),
15681 },
15682 )
15683 .await
15684 .unwrap();
15685
15686 let root_ids: Vec<Uuid> = result.iter().map(|p| p.root_id).collect();
15687 assert_eq!(
15688 root_ids.len(),
15689 2,
15690 "duplicate root value must not produce a duplicate GraphPath, got {result:#?}"
15691 );
15692 assert!(root_ids.contains(&a.id));
15693 assert!(root_ids.contains(&c.id));
15694 }
15695
15696 #[tokio::test]
15699 async fn traverse_note_node_matches_neighbors_in_one_path() {
15700 use khive_storage::types::TraversalOptions;
15701
15702 let rt = rt();
15703 let owner = NamespaceToken::for_namespace(Namespace::parse("owner-ns3").unwrap());
15704 let a = rt
15705 .create_entity(&owner, "concept", None, "A", None, None, vec![])
15706 .await
15707 .unwrap();
15708 let note = rt
15709 .create_note(
15710 &owner,
15711 "observation",
15712 Some("TraversalNote"),
15713 "note body",
15714 None,
15715 None,
15716 vec![a.id],
15717 )
15718 .await
15719 .unwrap();
15720
15721 let caller = NamespaceToken::mint_with_visibility(
15722 Namespace::parse("caller-ns3").unwrap(),
15723 vec![Namespace::parse("owner-ns3").unwrap()],
15724 ActorRef::anonymous(),
15725 );
15726 let neighbors = rt
15727 .neighbors(
15728 &caller,
15729 a.id,
15730 Direction::In,
15731 None,
15732 Some(vec![EdgeRelation::Annotates]),
15733 )
15734 .await
15735 .unwrap();
15736 let note_hit = neighbors
15737 .iter()
15738 .find(|hit| hit.node_id == note.id)
15739 .unwrap_or_else(|| panic!("note must be present in neighbors, got {neighbors:#?}"));
15740
15741 let result = rt
15742 .traverse(
15743 &caller,
15744 TraversalRequest {
15745 roots: vec![a.id],
15746 options: TraversalOptions {
15747 max_depth: 1,
15748 direction: Direction::In,
15749 relations: Some(vec![EdgeRelation::Annotates]),
15750 ..Default::default()
15751 },
15752 include_roots: false,
15753 include_properties: false,
15754 execution_budget: Default::default(),
15755 },
15756 )
15757 .await
15758 .unwrap();
15759
15760 assert_eq!(
15761 result.len(),
15762 1,
15763 "one requested root must yield one path across the caller and owner namespaces"
15764 );
15765 let note_node = result[0]
15766 .nodes
15767 .iter()
15768 .find(|n| n.node_id == note.id)
15769 .unwrap_or_else(|| panic!("note must be present in traversal nodes, got {result:#?}"));
15770 assert_eq!(note_node.name.as_deref(), note_hit.name.as_deref());
15771 assert_eq!(note_node.kind.as_deref(), note_hit.kind.as_deref());
15772 assert_eq!(note_node.name.as_deref(), Some("TraversalNote"));
15773 assert_eq!(note_node.kind.as_deref(), Some("observation"));
15774 }
15775
15776 #[tokio::test]
15779 async fn traverse_nameless_note_falls_back_to_bracketed_kind() {
15780 use khive_storage::types::TraversalOptions;
15781
15782 let rt = rt();
15783 let owner = NamespaceToken::for_namespace(Namespace::parse("owner-ns4").unwrap());
15784 let a = rt
15785 .create_entity(&owner, "concept", None, "A", None, None, vec![])
15786 .await
15787 .unwrap();
15788 let note = rt
15789 .create_note(
15790 &owner,
15791 "observation",
15792 None,
15793 "note body",
15794 None,
15795 None,
15796 vec![a.id],
15797 )
15798 .await
15799 .unwrap();
15800
15801 let caller = NamespaceToken::mint_with_visibility(
15802 Namespace::parse("caller-ns4").unwrap(),
15803 vec![Namespace::parse("owner-ns4").unwrap()],
15804 ActorRef::anonymous(),
15805 );
15806 let neighbors = rt
15807 .neighbors(
15808 &caller,
15809 a.id,
15810 Direction::In,
15811 None,
15812 Some(vec![EdgeRelation::Annotates]),
15813 )
15814 .await
15815 .unwrap();
15816 let note_hit = neighbors
15817 .iter()
15818 .find(|hit| hit.node_id == note.id)
15819 .unwrap_or_else(|| panic!("note must be present in neighbors, got {neighbors:#?}"));
15820
15821 let result = rt
15822 .traverse(
15823 &caller,
15824 TraversalRequest {
15825 roots: vec![a.id],
15826 options: TraversalOptions {
15827 max_depth: 1,
15828 direction: Direction::In,
15829 relations: Some(vec![EdgeRelation::Annotates]),
15830 ..Default::default()
15831 },
15832 include_roots: false,
15833 include_properties: false,
15834 execution_budget: Default::default(),
15835 },
15836 )
15837 .await
15838 .unwrap();
15839
15840 let note_node = result[0]
15841 .nodes
15842 .iter()
15843 .find(|n| n.node_id == note.id)
15844 .unwrap_or_else(|| panic!("note must be present in traversal nodes, got {result:#?}"));
15845 assert_eq!(note_node.name.as_deref(), note_hit.name.as_deref());
15846 assert_eq!(note_node.kind.as_deref(), note_hit.kind.as_deref());
15847 assert_eq!(note_node.name.as_deref(), Some("[observation]"));
15848 assert_eq!(note_node.kind.as_deref(), Some("observation"));
15849 }
15850
15851 async fn count_all_incident_edges(rt: &KhiveRuntime, node_id: Uuid, ns: &str) -> u64 {
15860 let mut reader = rt.sql().reader().await.expect("sql reader must open");
15861 let row = reader
15862 .query_scalar(SqlStatement {
15863 sql: "SELECT COUNT(*) FROM graph_edges \
15864 WHERE namespace = ?1 AND (source_id = ?2 OR target_id = ?2)"
15865 .into(),
15866 params: vec![
15867 SqlValue::Text(ns.to_string()),
15868 SqlValue::Text(node_id.to_string()),
15869 ],
15870 label: Some("count_all_incident_edges".into()),
15871 })
15872 .await
15873 .expect("count query must succeed");
15874 match row {
15875 Some(SqlValue::Integer(n)) => n as u64,
15876 _ => panic!("count must return an integer"),
15877 }
15878 }
15879
15880 async fn lineage_warning_events(
15881 rt: &KhiveRuntime,
15882 tok: &NamespaceToken,
15883 target_id: Uuid,
15884 ) -> Vec<Event> {
15885 rt.events(tok)
15886 .expect("event store")
15887 .query_events(
15888 EventFilter {
15889 kinds: vec![EventKind::Audit],
15890 ..Default::default()
15891 },
15892 PageRequest::default(),
15893 )
15894 .await
15895 .expect("query warning events")
15896 .items
15897 .into_iter()
15898 .filter(|event| event.target_id == Some(target_id))
15899 .collect()
15900 }
15901
15902 #[tokio::test]
15903 async fn hard_delete_emits_relation_specific_lineage_warnings_only() {
15904 let rt = rt();
15905 let tok = NamespaceToken::local();
15906 let doomed = rt
15907 .create_entity(&tok, "document", None, "doomed", None, None, vec![])
15908 .await
15909 .unwrap();
15910
15911 let provenance_source = rt
15912 .create_entity(&tok, "artifact", None, "source", None, None, vec![])
15913 .await
15914 .unwrap();
15915 rt.link(
15916 &tok,
15917 provenance_source.id,
15918 doomed.id,
15919 EdgeRelation::DerivedFrom,
15920 1.0,
15921 None,
15922 )
15923 .await
15924 .unwrap();
15925
15926 for (kind, name, relation) in [
15927 ("document", "old", EdgeRelation::Supersedes),
15928 ("document", "next", EdgeRelation::Precedes),
15929 ("concept", "supported", EdgeRelation::Supports),
15930 ("concept", "refuted", EdgeRelation::Refutes),
15931 ] {
15932 let target = rt
15933 .create_entity(&tok, kind, None, name, None, None, vec![])
15934 .await
15935 .unwrap();
15936 rt.link(&tok, doomed.id, target.id, relation, 1.0, None)
15937 .await
15938 .unwrap();
15939 }
15940 let unrelated = rt
15941 .create_entity(&tok, "concept", None, "unrelated", None, None, vec![])
15942 .await
15943 .unwrap();
15944 rt.link(
15945 &tok,
15946 unrelated.id,
15947 doomed.id,
15948 EdgeRelation::IntroducedBy,
15949 1.0,
15950 None,
15951 )
15952 .await
15953 .unwrap();
15954
15955 assert!(rt.delete_entity(&tok, doomed.id, false).await.unwrap());
15956 assert!(
15957 lineage_warning_events(&rt, &tok, doomed.id)
15958 .await
15959 .is_empty(),
15960 "soft delete must not emit cascade-loss warnings"
15961 );
15962
15963 assert!(rt.delete_entity(&tok, doomed.id, true).await.unwrap());
15964 let warnings = lineage_warning_events(&rt, &tok, doomed.id).await;
15965 assert_eq!(warnings.len(), 5);
15966
15967 let mut actual = warnings
15968 .iter()
15969 .map(|event| {
15970 assert_eq!(event.substrate, SubstrateKind::Entity);
15971 assert_eq!(event.payload["severity"], "warning");
15972 assert_eq!(event.payload["deleted_id"], doomed.id.to_string());
15973 assert_eq!(event.payload["edge_count"], 1);
15974 assert_eq!(event.payload["edges"].as_array().unwrap().len(), 1);
15975 (
15976 event.payload["relation"].as_str().unwrap().to_string(),
15977 event.payload["warning"].as_str().unwrap().to_string(),
15978 )
15979 })
15980 .collect::<Vec<_>>();
15981 actual.sort();
15982 assert_eq!(
15983 actual,
15984 vec![
15985 ("derived_from".into(), "provenance_loss".into()),
15986 ("precedes".into(), "temporal_sequence_loss".into()),
15987 ("refutes".into(), "evidential_link_loss".into()),
15988 ("supersedes".into(), "replacement_lineage_loss".into()),
15989 ("supports".into(), "evidential_link_loss".into()),
15990 ]
15991 );
15992 assert!(warnings
15993 .iter()
15994 .all(|event| event.payload["relation"] != "introduced_by"));
15995 }
15996
15997 #[tokio::test]
15998 async fn hard_delete_lineage_warning_is_filterable_by_caller_actor() {
15999 let rt = rt();
16000 let tok = NamespaceToken::mint_authorized(
16001 Namespace::local(),
16002 ActorRef::new("agent", "lineage-deleter"),
16003 );
16004 let expected_actor = "agent:lineage-deleter";
16005 let doomed = rt
16006 .create_entity(&tok, "document", None, "actor-doomed", None, None, vec![])
16007 .await
16008 .unwrap();
16009 let source = rt
16010 .create_entity(&tok, "artifact", None, "actor-source", None, None, vec![])
16011 .await
16012 .unwrap();
16013 rt.link(
16014 &tok,
16015 source.id,
16016 doomed.id,
16017 EdgeRelation::DerivedFrom,
16018 1.0,
16019 None,
16020 )
16021 .await
16022 .unwrap();
16023
16024 assert!(rt.delete_entity(&tok, doomed.id, true).await.unwrap());
16025
16026 let warnings = rt
16027 .events(&tok)
16028 .expect("event store")
16029 .query_events(
16030 EventFilter {
16031 kinds: vec![EventKind::Audit],
16032 actors: vec![expected_actor.to_string()],
16033 ..Default::default()
16034 },
16035 PageRequest::default(),
16036 )
16037 .await
16038 .expect("query actor-filtered warning events")
16039 .items
16040 .into_iter()
16041 .filter(|event| event.target_id == Some(doomed.id))
16042 .collect::<Vec<_>>();
16043 assert_eq!(warnings.len(), 1);
16044 assert_eq!(warnings[0].actor, expected_actor);
16045 assert_eq!(warnings[0].payload["relation"], "derived_from");
16046 }
16047
16048 #[tokio::test]
16049 async fn hard_delete_warns_for_tombstoned_protected_incident_edge() {
16050 let rt = rt();
16051 let tok = NamespaceToken::local();
16052 let doomed = rt
16053 .create_entity(
16054 &tok,
16055 "document",
16056 None,
16057 "tombstoned-edge-doomed",
16058 None,
16059 None,
16060 vec![],
16061 )
16062 .await
16063 .unwrap();
16064 let source = rt
16065 .create_entity(
16066 &tok,
16067 "artifact",
16068 None,
16069 "tombstoned-edge-source",
16070 None,
16071 None,
16072 vec![],
16073 )
16074 .await
16075 .unwrap();
16076 let edge = rt
16077 .link(
16078 &tok,
16079 source.id,
16080 doomed.id,
16081 EdgeRelation::DerivedFrom,
16082 1.0,
16083 None,
16084 )
16085 .await
16086 .unwrap();
16087 let edge_id = Uuid::from(edge.id);
16088 assert!(rt.delete_edge(&tok, edge_id, false).await.unwrap());
16089
16090 assert!(rt.delete_entity(&tok, doomed.id, true).await.unwrap());
16091
16092 let warnings = lineage_warning_events(&rt, &tok, doomed.id).await;
16093 assert_eq!(warnings.len(), 1);
16094 assert_eq!(warnings[0].payload["edge_count"], 1);
16095 let warned_edge = &warnings[0].payload["edges"].as_array().unwrap()[0];
16096 assert_eq!(warned_edge["id"], edge_id.to_string());
16097 assert!(
16098 warned_edge["deleted_at"].is_number(),
16099 "warning must preserve the edge's prior tombstone timestamp"
16100 );
16101 assert!(
16102 rt.get_edge_including_deleted(&tok, edge_id)
16103 .await
16104 .unwrap()
16105 .is_none(),
16106 "the warned tombstoned edge must still be physically cascaded"
16107 );
16108 }
16109
16110 #[tokio::test]
16111 async fn hard_delete_note_emits_evidential_lineage_warning() {
16112 let rt = rt();
16113 let tok = NamespaceToken::local();
16114 let evidence = rt
16115 .create_note(&tok, "observation", None, "evidence", None, None, vec![])
16116 .await
16117 .unwrap();
16118 let claim = rt
16119 .create_note(&tok, "insight", None, "claim", None, None, vec![])
16120 .await
16121 .unwrap();
16122 rt.link(
16123 &tok,
16124 evidence.id,
16125 claim.id,
16126 EdgeRelation::Supports,
16127 1.0,
16128 None,
16129 )
16130 .await
16131 .unwrap();
16132
16133 assert!(rt.delete_note(&tok, evidence.id, true).await.unwrap());
16134 let warnings = lineage_warning_events(&rt, &tok, evidence.id).await;
16135 assert_eq!(warnings.len(), 1);
16136 assert_eq!(warnings[0].substrate, SubstrateKind::Note);
16137 assert_eq!(warnings[0].payload["relation"], "supports");
16138 assert_eq!(warnings[0].payload["warning"], "evidential_link_loss");
16139 }
16140
16141 #[tokio::test]
16142 async fn lineage_warning_insert_failure_rolls_back_hard_delete_and_cascade() {
16143 let rt = rt();
16144 let tok = NamespaceToken::local();
16145 let doomed = rt
16146 .create_entity(&tok, "document", None, "doomed", None, None, vec![])
16147 .await
16148 .unwrap();
16149 let source = rt
16150 .create_entity(&tok, "artifact", None, "source", None, None, vec![])
16151 .await
16152 .unwrap();
16153 let edge = rt
16154 .link(
16155 &tok,
16156 source.id,
16157 doomed.id,
16158 EdgeRelation::DerivedFrom,
16159 1.0,
16160 None,
16161 )
16162 .await
16163 .unwrap();
16164
16165 let mut writer = rt.sql().writer().await.expect("sql writer");
16166 writer
16167 .execute_script(
16168 "CREATE TRIGGER reject_lineage_warning \
16169 BEFORE INSERT ON events \
16170 WHEN NEW.kind = 'audit' \
16171 AND json_extract(NEW.payload, '$.severity') = 'warning' \
16172 BEGIN \
16173 SELECT RAISE(ABORT, 'injected lineage warning failure'); \
16174 END;"
16175 .into(),
16176 )
16177 .await
16178 .expect("install warning failure trigger");
16179 drop(writer);
16180
16181 let error = rt.delete_entity(&tok, doomed.id, true).await.unwrap_err();
16182 assert!(error
16183 .to_string()
16184 .contains("injected lineage warning failure"));
16185 assert!(
16186 rt.entities(&tok)
16187 .unwrap()
16188 .get_entity(doomed.id)
16189 .await
16190 .unwrap()
16191 .is_some(),
16192 "the endpoint row must roll back with the failed warning insert"
16193 );
16194 assert!(
16195 rt.get_edge(&tok, Uuid::from(edge.id))
16196 .await
16197 .unwrap()
16198 .is_some(),
16199 "the incident edge must roll back with the failed warning insert"
16200 );
16201 }
16202
16203 #[tokio::test]
16204 async fn hard_delete_entity_purges_already_soft_deleted_incident_edge() {
16205 let rt = rt();
16206 let tok = NamespaceToken::local();
16207 let ns = tok.namespace().to_string();
16208
16209 let a = rt
16210 .create_entity(&tok, "concept", None, "SrcA", None, None, vec![])
16211 .await
16212 .unwrap();
16213 let b = rt
16214 .create_entity(&tok, "concept", None, "TgtB", None, None, vec![])
16215 .await
16216 .unwrap();
16217
16218 rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
16219 .await
16220 .unwrap();
16221
16222 let edge_hit = rt
16224 .neighbors(&tok, a.id, Direction::Out, None, None)
16225 .await
16226 .unwrap();
16227 assert_eq!(edge_hit.len(), 1, "edge must exist before soft-delete");
16228 let edge_uuid = edge_hit[0].edge_id;
16229 rt.delete_edge(&tok, edge_uuid, false).await.unwrap();
16230
16231 let visible = rt
16233 .neighbors(&tok, a.id, Direction::Out, None, None)
16234 .await
16235 .unwrap();
16236 assert!(visible.is_empty(), "soft-deleted edge must be invisible");
16237 let raw_before = count_all_incident_edges(&rt, a.id, &ns).await;
16238 assert_eq!(
16239 raw_before, 1,
16240 "soft-deleted edge must still be a physical row"
16241 );
16242
16243 rt.delete_entity(&tok, a.id, true).await.unwrap();
16245
16246 let raw_after = count_all_incident_edges(&rt, a.id, &ns).await;
16247 assert_eq!(
16248 raw_after, 0,
16249 "purge_incident_edges must physically remove soft-deleted edge rows (ADR-002)"
16250 );
16251 }
16252
16253 #[tokio::test]
16254 async fn hard_delete_note_purges_already_soft_deleted_incident_edge() {
16255 let rt = rt();
16256 let tok = NamespaceToken::local();
16257 let ns = tok.namespace().to_string();
16258
16259 let target = rt
16260 .create_note(
16261 &tok,
16262 "observation",
16263 None,
16264 "purge-cascade target note",
16265 Some(0.5),
16266 None,
16267 vec![],
16268 )
16269 .await
16270 .unwrap();
16271 let annotating = rt
16272 .create_note(
16273 &tok,
16274 "insight",
16275 None,
16276 "annotator note",
16277 Some(0.5),
16278 None,
16279 vec![target.id],
16280 )
16281 .await
16282 .unwrap();
16283
16284 let edge_hit = rt
16286 .neighbors(
16287 &tok,
16288 annotating.id,
16289 Direction::Out,
16290 None,
16291 Some(vec![EdgeRelation::Annotates]),
16292 )
16293 .await
16294 .unwrap();
16295 assert_eq!(edge_hit.len(), 1, "annotates edge must exist");
16296 let edge_uuid = edge_hit[0].edge_id;
16297 rt.delete_edge(&tok, edge_uuid, false).await.unwrap();
16298
16299 let raw_before = count_all_incident_edges(&rt, target.id, &ns).await;
16300 assert_eq!(
16301 raw_before, 1,
16302 "soft-deleted edge must still be a physical row before note purge"
16303 );
16304
16305 rt.delete_note(&tok, target.id, true).await.unwrap();
16307
16308 let raw_after = count_all_incident_edges(&rt, target.id, &ns).await;
16309 assert_eq!(
16310 raw_after, 0,
16311 "purge_incident_edges must physically remove soft-deleted edge rows on note purge (ADR-002)"
16312 );
16313 }
16314
16315 async fn count_all_incident_edges_global(rt: &KhiveRuntime, node_id: Uuid) -> u64 {
16323 let mut reader = rt.sql().reader().await.expect("sql reader must open");
16324 let row = reader
16325 .query_scalar(SqlStatement {
16326 sql: "SELECT COUNT(*) FROM graph_edges WHERE source_id = ?1 OR target_id = ?1"
16327 .into(),
16328 params: vec![SqlValue::Text(node_id.to_string())],
16329 label: Some("count_all_incident_edges_global".into()),
16330 })
16331 .await
16332 .expect("count query must succeed");
16333 match row {
16334 Some(SqlValue::Integer(n)) => n as u64,
16335 _ => panic!("count must return an integer"),
16336 }
16337 }
16338
16339 #[tokio::test]
16340 async fn cross_namespace_hard_delete_entity_purges_all_incident_edges() {
16341 let rt = rt();
16345 let ns_owner = NamespaceToken::for_namespace(Namespace::parse("ns-owner").unwrap());
16346 let ns_caller = NamespaceToken::for_namespace(Namespace::parse("ns-caller").unwrap());
16347
16348 let entity = rt
16349 .create_entity(
16350 &ns_owner,
16351 "concept",
16352 None,
16353 "ForeignEntity",
16354 None,
16355 None,
16356 vec![],
16357 )
16358 .await
16359 .unwrap();
16360 let peer = rt
16361 .create_entity(&ns_owner, "concept", None, "Peer", None, None, vec![])
16362 .await
16363 .unwrap();
16364 rt.link(
16366 &ns_owner,
16367 entity.id,
16368 peer.id,
16369 EdgeRelation::Extends,
16370 1.0,
16371 None,
16372 )
16373 .await
16374 .unwrap();
16375 rt.link(
16376 &ns_owner,
16377 peer.id,
16378 entity.id,
16379 EdgeRelation::Extends,
16380 1.0,
16381 None,
16382 )
16383 .await
16384 .unwrap();
16385
16386 let before = count_all_incident_edges_global(&rt, entity.id).await;
16387 assert_eq!(before, 2, "two incident edges must exist before delete");
16388
16389 let deleted = rt.delete_entity(&ns_caller, entity.id, true).await.unwrap();
16391 assert!(deleted, "cross-ns hard delete must return true");
16392
16393 let after = count_all_incident_edges_global(&rt, entity.id).await;
16395 assert_eq!(
16396 after, 0,
16397 "purge_incident_edges must remove all incident edges across namespaces (ADR-002, ADR-007)"
16398 );
16399 }
16400
16401 async fn count_edge_rows_by_id(rt: &KhiveRuntime, edge_id: Uuid, ns: &str) -> u64 {
16411 let mut reader = rt.sql().reader().await.expect("sql reader must open");
16412 let row = reader
16413 .query_scalar(SqlStatement {
16414 sql: "SELECT COUNT(*) FROM graph_edges WHERE namespace = ?1 AND id = ?2".into(),
16415 params: vec![
16416 SqlValue::Text(ns.to_string()),
16417 SqlValue::Text(edge_id.to_string()),
16418 ],
16419 label: Some("count_edge_rows_by_id".into()),
16420 })
16421 .await
16422 .expect("count query must succeed");
16423 match row {
16424 Some(SqlValue::Integer(n)) => n as u64,
16425 _ => panic!("count must return an integer"),
16426 }
16427 }
16428
16429 #[tokio::test]
16430 async fn hard_delete_edge_purges_already_soft_deleted_primary_edge() {
16431 let rt = rt();
16432 let tok = NamespaceToken::local();
16433 let ns = tok.namespace().to_string();
16434
16435 let a = rt
16436 .create_entity(&tok, "concept", None, "EA", None, None, vec![])
16437 .await
16438 .unwrap();
16439 let b = rt
16440 .create_entity(&tok, "concept", None, "EB", None, None, vec![])
16441 .await
16442 .unwrap();
16443
16444 let edge = rt
16445 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
16446 .await
16447 .unwrap();
16448 let edge_uuid: Uuid = edge.id.into();
16449
16450 let soft = rt.delete_edge(&tok, edge_uuid, false).await.unwrap();
16452 assert!(soft, "soft delete must succeed");
16453
16454 assert!(
16456 rt.get_edge(&tok, edge_uuid).await.unwrap().is_none(),
16457 "soft-deleted edge must be invisible to get_edge"
16458 );
16459 assert_eq!(
16460 count_edge_rows_by_id(&rt, edge_uuid, &ns).await,
16461 1,
16462 "soft-deleted edge must still be a physical row"
16463 );
16464
16465 let purged = rt.delete_edge(&tok, edge_uuid, true).await.unwrap();
16467 assert!(
16468 purged,
16469 "hard delete of a soft-deleted edge must return true"
16470 );
16471
16472 assert_eq!(
16473 count_edge_rows_by_id(&rt, edge_uuid, &ns).await,
16474 0,
16475 "hard-delete must physically remove the soft-deleted edge row (ADR-002)"
16476 );
16477 }
16478
16479 #[tokio::test]
16480 async fn hard_delete_base_edge_purges_already_soft_deleted_annotates_edge() {
16481 let rt = rt();
16482 let tok = NamespaceToken::local();
16483 let ns = tok.namespace().to_string();
16484
16485 let a = rt
16486 .create_entity(&tok, "concept", None, "CA", None, None, vec![])
16487 .await
16488 .unwrap();
16489 let b = rt
16490 .create_entity(&tok, "concept", None, "CB", None, None, vec![])
16491 .await
16492 .unwrap();
16493
16494 let base_edge = rt
16496 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
16497 .await
16498 .unwrap();
16499 let base_edge_uuid: Uuid = base_edge.id.into();
16500
16501 let note = rt
16503 .create_note(
16504 &tok,
16505 "observation",
16506 None,
16507 "note about base edge",
16508 Some(0.5),
16509 None,
16510 vec![base_edge_uuid],
16511 )
16512 .await
16513 .unwrap();
16514
16515 let ann_hits = rt
16517 .neighbors(
16518 &tok,
16519 note.id,
16520 Direction::Out,
16521 None,
16522 Some(vec![EdgeRelation::Annotates]),
16523 )
16524 .await
16525 .unwrap();
16526 assert_eq!(ann_hits.len(), 1, "annotates edge must exist");
16527 let ann_edge_uuid = ann_hits[0].edge_id;
16528
16529 rt.delete_edge(&tok, ann_edge_uuid, false).await.unwrap();
16531 assert_eq!(
16532 count_edge_rows_by_id(&rt, ann_edge_uuid, &ns).await,
16533 1,
16534 "soft-deleted annotates edge must still be a physical row"
16535 );
16536
16537 let purged = rt.delete_edge(&tok, base_edge_uuid, true).await.unwrap();
16539 assert!(purged, "hard delete of base edge must return true");
16540
16541 assert_eq!(
16542 count_edge_rows_by_id(&rt, ann_edge_uuid, &ns).await,
16543 0,
16544 "hard-delete of base edge must purge already-soft-deleted annotates edge row (ADR-002)"
16545 );
16546 assert_eq!(
16547 count_edge_rows_by_id(&rt, base_edge_uuid, &ns).await,
16548 0,
16549 "hard-delete must physically remove the base edge row"
16550 );
16551 }
16552
16553 #[tokio::test]
16560 async fn create_entity_fts_failure_rolls_back_entity_row() {
16561 let rt = KhiveRuntime::memory().unwrap();
16562 let ns = Namespace::parse("fault-entity-fts").unwrap();
16563 let tok = NamespaceToken::for_namespace(ns.clone());
16564
16565 let _arm = arm_fts_fail_scoped(ns.as_str());
16566
16567 let result = rt
16568 .create_entity(
16569 &tok,
16570 "concept",
16571 None,
16572 "fts-fail rollback target",
16573 None,
16574 None,
16575 vec![],
16576 )
16577 .await;
16578
16579 assert!(
16580 result.is_err(),
16581 "create_entity must propagate the injected FTS failure"
16582 );
16583 let err_msg = result.unwrap_err().to_string();
16584 assert!(
16585 err_msg.contains("injected FTS failure"),
16586 "error must carry injection message; got: {err_msg}"
16587 );
16588
16589 let entities = rt.list_entities(&tok, None, None, 1000, 0).await.unwrap();
16590 assert!(
16591 entities.is_empty(),
16592 "compensation must remove the entity row after FTS failure; got {entities:?}"
16593 );
16594 }
16595
16596 #[tokio::test]
16597 async fn create_entity_fts_and_compensation_failures_report_partial_persistence() {
16598 let rt = KhiveRuntime::memory().unwrap();
16599 let ns = Namespace::parse("partial-persistence-fts").unwrap();
16600 let tok = NamespaceToken::for_namespace(ns.clone());
16601 let _fts_arm = arm_fts_fail_scoped(ns.as_str());
16602 let _cleanup_arm = arm_entity_compensation_fail_scoped(ns.as_str());
16603
16604 let error = rt
16605 .create_entity(
16606 &tok,
16607 "concept",
16608 None,
16609 "FTS partial persistence",
16610 None,
16611 None,
16612 vec![],
16613 )
16614 .await
16615 .expect_err("FTS and compensation failures must fail create_entity");
16616 assert!(
16617 matches!(&error, RuntimeError::Khive(_)),
16618 "combined failures must use the structured wire error path"
16619 );
16620 let error_message = error.to_string();
16621 assert!(error_message.contains("injected FTS failure"));
16622 assert!(error_message.contains("injected compensation failure"));
16623 assert!(error_message.contains("partial persistence is possible"));
16624
16625 let entities = rt.list_entities(&tok, None, None, 10, 0).await.unwrap();
16626 assert_eq!(
16627 entities.len(),
16628 1,
16629 "the failed row cleanup must leave residue"
16630 );
16631 let record_id = entities[0].id;
16632 assert!(
16633 error_message.contains(&record_id.to_string()),
16634 "the error must identify the durable row that needs reconciliation"
16635 );
16636 assert!(
16637 rt.text(&tok)
16638 .unwrap()
16639 .get_document(ns.as_str(), record_id)
16640 .await
16641 .unwrap()
16642 .is_none(),
16643 "FTS cleanup must run even when the FTS upsert reports failure"
16644 );
16645 }
16646
16647 #[tokio::test]
16650 async fn create_entity_vector_failure_rolls_back_entity_row_and_fts() {
16651 const MODEL: &str = "test-entity-vec-inject";
16652 const DIMS: usize = 4;
16653
16654 let rt = KhiveRuntime::memory().unwrap();
16655 let (provider, _counter) = ConstVecProvider::new(MODEL, DIMS);
16656 rt.register_embedder(provider);
16657
16658 let ns = Namespace::parse("fault-entity-vec").unwrap();
16659 let tok = NamespaceToken::for_namespace(ns.clone());
16660
16661 let _arm = arm_vector_fail_scoped(ns.as_str());
16662
16663 let result = rt
16664 .create_entity(
16665 &tok,
16666 "concept",
16667 None,
16668 "vec-fail rollback target",
16669 Some("description so embed body is non-empty"),
16670 None,
16671 vec![],
16672 )
16673 .await;
16674
16675 assert!(
16676 result.is_err(),
16677 "create_entity must propagate the injected vector failure"
16678 );
16679 let err_msg = result.unwrap_err().to_string();
16680 assert!(
16681 err_msg.contains("injected vector failure"),
16682 "error must carry injection message; got: {err_msg}"
16683 );
16684
16685 let entities = rt.list_entities(&tok, None, None, 1000, 0).await.unwrap();
16686 assert!(
16687 entities.is_empty(),
16688 "compensation must remove entity row after vector failure; got {entities:?}"
16689 );
16690
16691 use khive_storage::types::{TextFilter, TextQueryMode, TextSearchRequest};
16693 let fts_hits = rt
16694 .text(&tok)
16695 .unwrap()
16696 .search(TextSearchRequest {
16697 query: "vec-fail rollback target".to_string(),
16698 mode: TextQueryMode::Plain,
16699 filter: Some(TextFilter {
16700 namespaces: vec![ns.as_str().to_string()],
16701 ..Default::default()
16702 }),
16703 top_k: 10,
16704 snippet_chars: 100,
16705 })
16706 .await
16707 .unwrap();
16708 assert!(
16709 fts_hits.is_empty(),
16710 "compensation must remove FTS document after vector failure; got {fts_hits:?}"
16711 );
16712 }
16713
16714 #[tokio::test]
16715 async fn create_entity_single_model_reports_primary_and_compensation_failures() {
16716 const MODEL: &str = "partial-persistence-single";
16717
16718 let rt = KhiveRuntime::memory().unwrap();
16719 let (provider, _counter) = ConstVecProvider::new(MODEL, 4);
16720 rt.register_embedder(provider);
16721
16722 let ns = Namespace::parse("partial-persistence-single").unwrap();
16723 let tok = NamespaceToken::for_namespace(ns.clone());
16724 let _vector_arm = arm_vector_fail_scoped(ns.as_str());
16725 let _cleanup_arm = arm_entity_compensation_fail_scoped(ns.as_str());
16726
16727 let error = rt
16728 .create_entity(
16729 &tok,
16730 "concept",
16731 None,
16732 "single-model partial persistence",
16733 Some("the FTS document lands before the injected vector failure"),
16734 None,
16735 vec![],
16736 )
16737 .await
16738 .expect_err("vector and compensation failures must fail create_entity");
16739
16740 let error_message = error.to_string();
16741 assert!(error_message.contains("injected vector failure"));
16742 assert!(error_message.contains("injected compensation failure"));
16743 assert!(error_message.contains("partial persistence is possible"));
16744
16745 let entities = rt.list_entities(&tok, None, None, 10, 0).await.unwrap();
16746 assert_eq!(
16747 entities.len(),
16748 1,
16749 "the failed row cleanup must leave residue"
16750 );
16751 let record_id = entities[0].id;
16752 assert!(
16753 error_message.contains(&record_id.to_string()),
16754 "the caller-visible error must carry the recovery record ID"
16755 );
16756 assert!(
16757 rt.text(&tok)
16758 .unwrap()
16759 .get_document(ns.as_str(), record_id)
16760 .await
16761 .unwrap()
16762 .is_none(),
16763 "independent FTS compensation must still complete"
16764 );
16765 assert_eq!(
16766 rt.vectors_for_model(&tok, MODEL)
16767 .unwrap()
16768 .count()
16769 .await
16770 .unwrap(),
16771 0,
16772 "the failed model must be cleaned even though INSERT returned an error"
16773 );
16774 }
16775
16776 #[tokio::test]
16783 async fn create_entity_multi_model_second_vector_failure_rolls_back_all() {
16784 const DIMS: usize = 4;
16785
16786 let rt = KhiveRuntime::memory().unwrap();
16787 let (provider_a, _ca) = ConstVecProvider::new("model-a", DIMS);
16788 let (provider_b, _cb) = ConstVecProvider::new("model-b", DIMS);
16789 rt.register_embedder(provider_a);
16790 rt.register_embedder(provider_b);
16791
16792 let ns = Namespace::parse("fault-entity-multi").unwrap();
16793 let tok = NamespaceToken::for_namespace(ns.clone());
16794
16795 arm_vector_fail_after(1);
16797
16798 let result = rt
16799 .create_entity(
16800 &tok,
16801 "concept",
16802 None,
16803 "multi-model rollback target",
16804 Some("description for embedding"),
16805 None,
16806 vec![],
16807 )
16808 .await;
16809
16810 assert!(
16811 result.is_err(),
16812 "create_entity must propagate the injected multi-model vector failure"
16813 );
16814
16815 let entities = rt.list_entities(&tok, None, None, 1000, 0).await.unwrap();
16816 assert!(
16817 entities.is_empty(),
16818 "compensation must remove entity row; got {entities:?}"
16819 );
16820
16821 use khive_storage::types::VectorSearchRequest;
16826 let query_vec = vec![1.0_f32; DIMS];
16827 let hits_a = rt
16828 .vectors_for_model(&tok, "model-a")
16829 .unwrap()
16830 .search(VectorSearchRequest {
16831 query_vectors: vec![query_vec.clone()],
16832 top_k: 100,
16833 namespace: Some(ns.as_str().to_string()),
16834 kind: Some(khive_types::SubstrateKind::Entity),
16835 embedding_model: Some("model-a".to_string()),
16836 filter: None,
16837 backend_hints: None,
16838 })
16839 .await
16840 .unwrap();
16841 assert!(
16842 hits_a.is_empty(),
16843 "model-a vector store must be empty after rollback; got {hits_a:?}"
16844 );
16845 let hits_b = rt
16846 .vectors_for_model(&tok, "model-b")
16847 .unwrap()
16848 .search(VectorSearchRequest {
16849 query_vectors: vec![query_vec],
16850 top_k: 100,
16851 namespace: Some(ns.as_str().to_string()),
16852 kind: Some(khive_types::SubstrateKind::Entity),
16853 embedding_model: Some("model-b".to_string()),
16854 filter: None,
16855 backend_hints: None,
16856 })
16857 .await
16858 .unwrap();
16859 assert!(
16860 hits_b.is_empty(),
16861 "model-b vector store must be empty after rollback; got {hits_b:?}"
16862 );
16863 }
16864
16865 #[tokio::test]
16866 async fn create_entity_multi_model_reports_cleanup_failure_and_removes_all_vectors() {
16867 let rt = KhiveRuntime::memory().unwrap();
16868 let (provider_a, _ca) = ConstVecProvider::new("partial-multi-a", 4);
16869 let (provider_b, _cb) = ConstVecProvider::new("partial-multi-b", 4);
16870 rt.register_embedder(provider_a);
16871 rt.register_embedder(provider_b);
16872
16873 let ns = Namespace::parse("partial-persistence-multi").unwrap();
16874 let tok = NamespaceToken::for_namespace(ns.clone());
16875 let _cleanup_arm = arm_entity_compensation_fail_scoped(ns.as_str());
16876 arm_vector_fail_after(1);
16877
16878 let error = rt
16879 .create_entity(
16880 &tok,
16881 "concept",
16882 None,
16883 "multi-model partial persistence",
16884 Some("the second model fails after the first vector lands"),
16885 None,
16886 vec![],
16887 )
16888 .await
16889 .expect_err("second vector and compensation failures must fail create_entity");
16890
16891 let error_message = error.to_string();
16892 assert!(
16893 error_message.contains("injected vector insert failure"),
16894 "primary cause must be retained: {error_message}"
16895 );
16896 assert!(error_message.contains("injected compensation failure"));
16897
16898 let entities = rt.list_entities(&tok, None, None, 10, 0).await.unwrap();
16899 assert_eq!(
16900 entities.len(),
16901 1,
16902 "the failed row cleanup must leave residue"
16903 );
16904 let record_id = entities[0].id;
16905 assert!(error_message.contains(&record_id.to_string()));
16906
16907 for model in ["partial-multi-a", "partial-multi-b"] {
16908 assert_eq!(
16909 rt.vectors_for_model(&tok, model)
16910 .unwrap()
16911 .count()
16912 .await
16913 .unwrap(),
16914 0,
16915 "compensation must clean both the successful and failed model ({model})"
16916 );
16917 }
16918 assert!(
16919 rt.text(&tok)
16920 .unwrap()
16921 .get_document(ns.as_str(), record_id)
16922 .await
16923 .unwrap()
16924 .is_none(),
16925 "FTS compensation must succeed even when row cleanup fails"
16926 );
16927 }
16928
16929 #[tokio::test]
16934 async fn create_entity_multi_model_counts_all_executed_embeds() {
16935 const DIMS: usize = 4;
16936
16937 let rt = KhiveRuntime::memory().unwrap();
16938 let (provider_a, _ca) = ConstVecProvider::new("usage-entity-model-a", DIMS);
16939 let (provider_b, _cb) = ConstVecProvider::new("usage-entity-model-b", DIMS);
16940 rt.register_embedder(provider_a);
16941 rt.register_embedder(provider_b);
16942
16943 let ns = Namespace::parse("usage-entity-multi").unwrap();
16944 let tok = NamespaceToken::for_namespace(ns.clone());
16945
16946 let ctx = crate::usage::UsageContext::new();
16947 crate::usage::scope(ctx.clone(), async {
16948 rt.create_entity(
16949 &tok,
16950 "concept",
16951 None,
16952 "usage-counted entity",
16953 Some("description so embed body is non-empty"),
16954 None,
16955 vec![],
16956 )
16957 .await
16958 })
16959 .await
16960 .expect("create_entity must succeed");
16961
16962 let snap = ctx.snapshot();
16963 assert_eq!(
16964 snap["embed_calls"], 2,
16965 "both configured models' executed embeds must be counted; got {snap:?}"
16966 );
16967 }
16968
16969 #[tokio::test]
16971 async fn create_note_multi_model_counts_all_executed_embeds() {
16972 const DIMS: usize = 4;
16973
16974 let rt = KhiveRuntime::memory().unwrap();
16975 let (provider_a, _ca) = ConstVecProvider::new("usage-note-model-a", DIMS);
16976 let (provider_b, _cb) = ConstVecProvider::new("usage-note-model-b", DIMS);
16977 rt.register_embedder(provider_a);
16978 rt.register_embedder(provider_b);
16979
16980 let ns = Namespace::parse("usage-note-multi").unwrap();
16981 let tok = NamespaceToken::for_namespace(ns.clone());
16982
16983 let ctx = crate::usage::UsageContext::new();
16984 crate::usage::scope(ctx.clone(), async {
16985 rt.create_note(
16986 &tok,
16987 "observation",
16988 None,
16989 "usage-counted note body",
16990 None,
16991 None,
16992 vec![],
16993 )
16994 .await
16995 })
16996 .await
16997 .expect("create_note must succeed");
16998
16999 let snap = ctx.snapshot();
17000 assert_eq!(
17001 snap["embed_calls"], 2,
17002 "both configured models' executed embeds must be counted; got {snap:?}"
17003 );
17004 }
17005
17006 #[tokio::test]
17009 async fn create_entity_multi_model_error_keeps_issued_usage_stable() {
17010 const DIMS: usize = 4;
17011
17012 let rt = KhiveRuntime::memory().unwrap();
17013 rt.register_embedder(FailFastProvider::new("usage-entity-fail-fast"));
17014 rt.register_embedder(SlowVecProvider::new("usage-entity-slow", DIMS));
17015
17016 let ns = Namespace::parse("usage-entity-error-drain").unwrap();
17017 let tok = NamespaceToken::for_namespace(ns.clone());
17018
17019 let ctx = crate::usage::UsageContext::new();
17020 let result = crate::usage::scope(ctx.clone(), async {
17021 rt.create_entity(
17022 &tok,
17023 "concept",
17024 None,
17025 "usage-drain entity",
17026 Some("description so embed body is non-empty"),
17027 None,
17028 vec![],
17029 )
17030 .await
17031 })
17032 .await;
17033
17034 assert!(
17035 result.is_err(),
17036 "one model failing must fail the whole create_entity call"
17037 );
17038
17039 let snap_immediately_after = ctx.snapshot();
17040 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
17041 let snap_after_delay = ctx.snapshot();
17042
17043 assert_eq!(
17044 snap_immediately_after, snap_after_delay,
17045 "embed completion after create_entity returns must not change issued \
17046 usage; got immediately_after={snap_immediately_after:?} \
17047 after_delay={snap_after_delay:?}"
17048 );
17049 }
17050
17051 #[tokio::test]
17053 async fn create_note_multi_model_error_keeps_issued_usage_stable() {
17054 const DIMS: usize = 4;
17055
17056 let rt = KhiveRuntime::memory().unwrap();
17057 rt.register_embedder(FailFastProvider::new("usage-note-fail-fast"));
17058 rt.register_embedder(SlowVecProvider::new("usage-note-slow", DIMS));
17059
17060 let ns = Namespace::parse("usage-note-error-drain").unwrap();
17061 let tok = NamespaceToken::for_namespace(ns.clone());
17062
17063 let ctx = crate::usage::UsageContext::new();
17064 let result = crate::usage::scope(ctx.clone(), async {
17065 rt.create_note(
17066 &tok,
17067 "observation",
17068 None,
17069 "usage-drain note body",
17070 None,
17071 None,
17072 vec![],
17073 )
17074 .await
17075 })
17076 .await;
17077
17078 assert!(
17079 result.is_err(),
17080 "one model failing must fail the whole create_note call"
17081 );
17082
17083 let snap_immediately_after = ctx.snapshot();
17084 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
17085 let snap_after_delay = ctx.snapshot();
17086
17087 assert_eq!(
17088 snap_immediately_after, snap_after_delay,
17089 "embed completion after create_note returns must not change issued \
17090 usage; got immediately_after={snap_immediately_after:?} \
17091 after_delay={snap_after_delay:?}"
17092 );
17093 }
17094
17095 #[tokio::test]
17101 async fn create_entity_fast_embed_failure_does_not_wait_for_hung_sibling() {
17102 const DIMS: usize = 4;
17103
17104 let rt = KhiveRuntime::memory().unwrap();
17105 rt.register_embedder(FailFastProvider::new("latency-fail-fast"));
17106 let (parked, _release, _entered) = ParkedVecProvider::new("latency-parked", DIMS);
17107 rt.register_embedder(parked);
17108
17109 let ns = Namespace::parse("usage-entity-latency").unwrap();
17110 let tok = NamespaceToken::for_namespace(ns.clone());
17111
17112 let started = std::time::Instant::now();
17113 let result = tokio::time::timeout(
17116 std::time::Duration::from_secs(60),
17117 rt.create_entity(
17118 &tok,
17119 "concept",
17120 None,
17121 "latency entity",
17122 Some("description so embed body is non-empty"),
17123 None,
17124 vec![],
17125 ),
17126 )
17127 .await
17128 .expect("create_entity must return within the timeout — a hang means the abort path regressed to await-to-completion");
17129 let elapsed = started.elapsed();
17130
17131 assert!(
17132 result.is_err(),
17133 "one model failing must fail the whole create_entity call"
17134 );
17135 assert!(
17136 elapsed < std::time::Duration::from_secs(5),
17137 "a fast embed failure must return without waiting on the hung \
17138 sibling (which parks forever); took {elapsed:?}"
17139 );
17140 }
17141
17142 #[test]
17143 fn create_entity_embed_failure_returns_under_single_worker_saturation() {
17144 let (blocking, controls) = BlockingVecProvider::new("latency-blocking", 4);
17145 let fail_after_entry = FailFastProvider::after_signal(
17146 "latency-fail-after-entry",
17147 Arc::clone(&controls.entered),
17148 );
17149 let (result_tx, result_rx) = std::sync::mpsc::sync_channel(1);
17150
17151 let runtime_thread = std::thread::spawn(move || {
17152 let runtime = tokio::runtime::Builder::new_multi_thread()
17153 .worker_threads(1)
17154 .enable_all()
17155 .build()
17156 .expect("single-worker runtime must build");
17157 let rt = KhiveRuntime::memory().unwrap();
17158 rt.register_embedder(blocking);
17159 rt.register_embedder(fail_after_entry);
17160 let tok =
17161 NamespaceToken::for_namespace(Namespace::parse("embed-failure-latency").unwrap());
17162 let result = runtime.block_on(rt.create_entity(
17163 &tok,
17164 "concept",
17165 None,
17166 "blocked sibling entity",
17167 None,
17168 None,
17169 vec![],
17170 ));
17171 result_tx
17172 .send(result.map_err(|error| error.to_string()))
17173 .expect("test receiver must remain connected");
17174 });
17175
17176 let result = result_rx.recv_timeout(std::time::Duration::from_secs(3));
17177
17178 let (released, wake) = &*controls.release;
17179 *released.lock().expect("release lock must not be poisoned") = true;
17180 wake.notify_all();
17181 runtime_thread
17182 .join()
17183 .expect("single-worker runtime thread must join after release");
17184
17185 let error = result
17186 .expect("embed failure must return while synchronous inference remains blocked")
17187 .expect_err("one failed model must fail entity creation");
17188 assert!(error.contains("injected embed failure"));
17189 }
17190
17191 #[tokio::test]
17196 async fn aborted_embed_task_still_counts_issued_embed_call() {
17197 const DIMS: usize = 4;
17198
17199 let rt = std::sync::Arc::new(KhiveRuntime::memory().unwrap());
17200 let (parked, _release, entered) = ParkedVecProvider::new("abort-count-parked", DIMS);
17201 rt.register_embedder(parked);
17202
17203 let ctx = crate::usage::UsageContext::new();
17204 let task = {
17205 let rt = std::sync::Arc::clone(&rt);
17206 let ctx = ctx.clone();
17207 tokio::spawn(crate::usage::scope(ctx, async move {
17208 rt.embed_document_with_model("abort-count-parked", "abort count body")
17209 .await
17210 }))
17211 };
17212
17213 entered.notified().await;
17216 task.abort();
17217 let joined = task.await;
17218 assert!(
17219 joined.is_err() && joined.unwrap_err().is_cancelled(),
17220 "task must end as cancelled by the abort"
17221 );
17222
17223 assert_eq!(
17224 ctx.snapshot()["embed_calls"],
17225 1,
17226 "an issued embed call must be counted even when the task is \
17227 aborted while parked on the provider await"
17228 );
17229 }
17230
17231 #[tokio::test]
17235 async fn search_notes_counts_one_fts_pass() {
17236 let rt = KhiveRuntime::memory().unwrap();
17237 let ns = Namespace::parse("usage-note-search").unwrap();
17238 let tok = NamespaceToken::for_namespace(ns.clone());
17239
17240 rt.create_note(
17241 &tok,
17242 "observation",
17243 None,
17244 "usage counted note search body",
17245 None,
17246 None,
17247 vec![],
17248 )
17249 .await
17250 .expect("create_note must succeed");
17251
17252 let ctx = crate::usage::UsageContext::new();
17253 crate::usage::scope(ctx.clone(), async {
17254 rt.search_notes(&tok, "usage counted", None, 10, None, false, &[], None)
17255 .await
17256 })
17257 .await
17258 .expect("search_notes must succeed");
17259
17260 let snap = ctx.snapshot();
17261 assert!(
17262 snap["fts_passes"].as_u64().unwrap_or(0) >= 1,
17263 "note search FTS execution must count fts_passes; got {snap:?}"
17264 );
17265 }
17266
17267 #[tokio::test]
17268 async fn search_notes_batches_supersedes_suppression_round_trip() {
17269 let rt = KhiveRuntime::memory().unwrap();
17270 let ns = Namespace::parse("usage-note-supersedes-batch").unwrap();
17271 let tok = NamespaceToken::for_namespace(ns);
17272
17273 let mut notes = Vec::new();
17274 for ordinal in 0..4 {
17275 notes.push(
17276 rt.create_note(
17277 &tok,
17278 "observation",
17279 None,
17280 &format!("batchsupersedesprobe live candidate {ordinal}"),
17281 None,
17282 None,
17283 vec![],
17284 )
17285 .await
17286 .expect("create_note must succeed"),
17287 );
17288 }
17289 rt.link(
17290 &tok,
17291 notes[3].id,
17292 notes[0].id,
17293 EdgeRelation::Supersedes,
17294 1.0,
17295 None,
17296 )
17297 .await
17298 .expect("supersedes link must succeed");
17299
17300 let ctx = crate::usage::UsageContext::new();
17301 let hits = crate::usage::scope(ctx.clone(), async {
17302 rt.search_notes(
17303 &tok,
17304 "batchsupersedesprobe",
17305 None,
17306 10,
17307 None,
17308 false,
17309 &[],
17310 None,
17311 )
17312 .await
17313 })
17314 .await
17315 .expect("search_notes must succeed");
17316
17317 let hit_ids: std::collections::HashSet<Uuid> = hits.iter().map(|hit| hit.note_id).collect();
17318 assert_eq!(
17319 hit_ids.len(),
17320 3,
17321 "all live, non-superseded notes must remain"
17322 );
17323 assert!(
17324 !hit_ids.contains(¬es[0].id),
17325 "the superseded note must be excluded"
17326 );
17327 assert!(
17328 notes[1..].iter().all(|note| hit_ids.contains(¬e.id)),
17329 "every live, non-superseded note must be returned"
17330 );
17331
17332 let snap = ctx.snapshot();
17333 assert_eq!(
17334 snap["db_round_trips"], 1,
17335 "supersedes suppression must use one batched adjacency query; got {snap:?}"
17336 );
17337 }
17338
17339 #[tokio::test]
17343 async fn update_entity_fans_out_to_all_registered_models() {
17344 const DIMS: usize = 4;
17345
17346 let rt = KhiveRuntime::memory().unwrap();
17347 let (provider_a, _ca) = ConstVecProvider::new("embed-a", DIMS);
17348 let (provider_b, _cb) = ConstVecProvider::new("embed-b", DIMS);
17349 rt.register_embedder(provider_a);
17350 rt.register_embedder(provider_b);
17351
17352 let ns = Namespace::parse("update-entity-fanout").unwrap();
17353 let tok = NamespaceToken::for_namespace(ns.clone());
17354
17355 let entity = rt
17356 .create_entity(
17357 &tok,
17358 "concept",
17359 None,
17360 "FanOutEntity",
17361 Some("initial description"),
17362 None,
17363 vec![],
17364 )
17365 .await
17366 .expect("create_entity must succeed");
17367
17368 use crate::curation::EntityPatch;
17369 let patch = EntityPatch {
17370 description: Some(Some("updated description after fan-out fix".to_string())),
17371 ..Default::default()
17372 };
17373 rt.update_entity(&tok, entity.id, patch)
17374 .await
17375 .expect("update_entity must succeed");
17376
17377 use khive_storage::types::VectorSearchRequest;
17378 let query_vec = vec![1.0_f32; DIMS];
17379
17380 let hits_a = rt
17381 .vectors_for_model(&tok, "embed-a")
17382 .unwrap()
17383 .search(VectorSearchRequest {
17384 query_vectors: vec![query_vec.clone()],
17385 top_k: 10,
17386 namespace: Some(ns.as_str().to_string()),
17387 kind: Some(khive_types::SubstrateKind::Entity),
17388 embedding_model: Some("embed-a".to_string()),
17389 filter: None,
17390 backend_hints: None,
17391 })
17392 .await
17393 .unwrap();
17394 assert!(
17395 hits_a.iter().any(|h| h.subject_id == entity.id),
17396 "embed-a must hold a vector for the entity after update; got {hits_a:?}"
17397 );
17398
17399 let hits_b = rt
17400 .vectors_for_model(&tok, "embed-b")
17401 .unwrap()
17402 .search(VectorSearchRequest {
17403 query_vectors: vec![query_vec],
17404 top_k: 10,
17405 namespace: Some(ns.as_str().to_string()),
17406 kind: Some(khive_types::SubstrateKind::Entity),
17407 embedding_model: Some("embed-b".to_string()),
17408 filter: None,
17409 backend_hints: None,
17410 })
17411 .await
17412 .unwrap();
17413 assert!(
17414 hits_b.iter().any(|h| h.subject_id == entity.id),
17415 "embed-b must hold a vector for the entity after update; got {hits_b:?}"
17416 );
17417 }
17418
17419 #[tokio::test]
17423 async fn update_note_fans_out_to_all_registered_models() {
17424 const DIMS: usize = 4;
17425
17426 let rt = KhiveRuntime::memory().unwrap();
17427 let (provider_a, _ca) = ConstVecProvider::new("embed-a", DIMS);
17428 let (provider_b, _cb) = ConstVecProvider::new("embed-b", DIMS);
17429 rt.register_embedder(provider_a);
17430 rt.register_embedder(provider_b);
17431
17432 let ns = Namespace::parse("update-note-fanout").unwrap();
17433 let tok = NamespaceToken::for_namespace(ns.clone());
17434
17435 let note = rt
17436 .create_note(
17437 &tok,
17438 "observation",
17439 None,
17440 "initial note content for fan-out test",
17441 None,
17442 None,
17443 vec![],
17444 )
17445 .await
17446 .expect("create_note must succeed");
17447
17448 use crate::curation::NotePatch;
17449 let patch = NotePatch {
17450 content: Some("updated content after fan-out fix".to_string()),
17451 ..Default::default()
17452 };
17453 rt.update_note(&tok, note.id, patch)
17454 .await
17455 .expect("update_note must succeed");
17456
17457 use khive_storage::types::VectorSearchRequest;
17458 let query_vec = vec![1.0_f32; DIMS];
17459
17460 let hits_a = rt
17461 .vectors_for_model(&tok, "embed-a")
17462 .unwrap()
17463 .search(VectorSearchRequest {
17464 query_vectors: vec![query_vec.clone()],
17465 top_k: 10,
17466 namespace: Some(ns.as_str().to_string()),
17467 kind: Some(khive_types::SubstrateKind::Note),
17468 embedding_model: Some("embed-a".to_string()),
17469 filter: None,
17470 backend_hints: None,
17471 })
17472 .await
17473 .unwrap();
17474 assert!(
17475 hits_a.iter().any(|h| h.subject_id == note.id),
17476 "embed-a must hold a vector for the note after update; got {hits_a:?}"
17477 );
17478
17479 let hits_b = rt
17480 .vectors_for_model(&tok, "embed-b")
17481 .unwrap()
17482 .search(VectorSearchRequest {
17483 query_vectors: vec![query_vec],
17484 top_k: 10,
17485 namespace: Some(ns.as_str().to_string()),
17486 kind: Some(khive_types::SubstrateKind::Note),
17487 embedding_model: Some("embed-b".to_string()),
17488 filter: None,
17489 backend_hints: None,
17490 })
17491 .await
17492 .unwrap();
17493 assert!(
17494 hits_b.iter().any(|h| h.subject_id == note.id),
17495 "embed-b must hold a vector for the note after update; got {hits_b:?}"
17496 );
17497 }
17498
17499 #[tokio::test]
17507 async fn get_entity_cross_namespace_succeeds() {
17508 let rt = rt();
17509 let leo_tok = NamespaceToken::for_namespace(Namespace::parse("lambda:leo").unwrap());
17511 let entity = rt
17512 .create_entity(&leo_tok, "concept", None, "Peer-Entity", None, None, vec![])
17513 .await
17514 .unwrap();
17515 assert_eq!(entity.namespace, "lambda:leo");
17516
17517 let local_tok = NamespaceToken::local();
17519 let fetched = rt.get_entity(&local_tok, entity.id).await;
17520 assert!(
17521 fetched.is_ok(),
17522 "get_entity from local token must find lambda:leo entity; got {:?}",
17523 fetched
17524 );
17525 assert_eq!(fetched.unwrap().id, entity.id);
17526 }
17527
17528 #[tokio::test]
17529 async fn update_entity_cross_namespace_succeeds() {
17530 let rt = rt();
17531 let leo_tok = NamespaceToken::for_namespace(Namespace::parse("lambda:leo").unwrap());
17532 let entity = rt
17533 .create_entity(
17534 &leo_tok,
17535 "concept",
17536 None,
17537 "Peer-Entity-Update",
17538 None,
17539 None,
17540 vec![],
17541 )
17542 .await
17543 .unwrap();
17544
17545 let local_tok = NamespaceToken::local();
17547 let patch = crate::curation::EntityPatch {
17548 name: Some("Peer-Entity-Updated".to_string()),
17549 ..Default::default()
17550 };
17551 let result = rt.update_entity(&local_tok, entity.id, patch).await;
17552 assert!(
17553 result.is_ok(),
17554 "update_entity from local token must succeed on lambda:leo entity; got {:?}",
17555 result
17556 );
17557 assert_eq!(result.unwrap().name, "Peer-Entity-Updated");
17558 }
17559
17560 #[tokio::test]
17561 async fn delete_entity_cross_namespace_succeeds() {
17562 let rt = rt();
17563 let leo_tok = NamespaceToken::for_namespace(Namespace::parse("lambda:leo").unwrap());
17564 let entity = rt
17565 .create_entity(
17566 &leo_tok,
17567 "concept",
17568 None,
17569 "Peer-Entity-Delete",
17570 None,
17571 None,
17572 vec![],
17573 )
17574 .await
17575 .unwrap();
17576
17577 let local_tok = NamespaceToken::local();
17579 let deleted = rt.delete_entity(&local_tok, entity.id, false).await;
17580 assert!(
17581 deleted.is_ok(),
17582 "delete_entity from local token must succeed on lambda:leo entity; got {:?}",
17583 deleted
17584 );
17585 assert!(
17586 deleted.unwrap(),
17587 "delete must return true when entity existed"
17588 );
17589 }
17590
17591 #[tokio::test]
17592 async fn namespace_preserved_on_entity_after_cross_namespace_get() {
17593 let rt = rt();
17594 let leo_tok = NamespaceToken::for_namespace(Namespace::parse("lambda:leo").unwrap());
17595 let entity = rt
17596 .create_entity(
17597 &leo_tok,
17598 "concept",
17599 None,
17600 "NS-Preserved",
17601 None,
17602 None,
17603 vec![],
17604 )
17605 .await
17606 .unwrap();
17607
17608 let local_tok = NamespaceToken::local();
17610 let fetched = rt.get_entity(&local_tok, entity.id).await.unwrap();
17611 assert_eq!(
17612 fetched.namespace, "lambda:leo",
17613 "namespace column must be preserved; not overwritten with caller's namespace"
17614 );
17615 }
17616
17617 use crate::pack::PackByIdResolver;
17620 use tokio::sync::Mutex as TokioMutex;
17621
17622 #[derive(Debug, Default)]
17623 struct MockResolverState {
17624 owned: Vec<Uuid>,
17625 deleted: Vec<Uuid>,
17626 delete_calls: Vec<(Uuid, bool)>,
17627 }
17628
17629 struct MockPackResolver(TokioMutex<MockResolverState>);
17630
17631 impl MockPackResolver {
17632 fn new() -> Self {
17633 Self(TokioMutex::new(MockResolverState::default()))
17634 }
17635 }
17636
17637 #[async_trait::async_trait]
17638 impl crate::pack::PackByIdResolver for MockPackResolver {
17639 async fn resolve_by_id(&self, id: Uuid) -> Result<Option<Resolved>, RuntimeError> {
17640 let state = self.0.lock().await;
17641 if state.owned.contains(&id) && !state.deleted.contains(&id) {
17642 Ok(Some(Resolved::PackRecord {
17643 pack: "mock".into(),
17644 kind: "widget".into(),
17645 data: serde_json::json!({ "id": id.to_string(), "name": "test-widget" }),
17646 }))
17647 } else {
17648 Ok(None)
17649 }
17650 }
17651
17652 async fn resolve_by_id_including_deleted(
17653 &self,
17654 id: Uuid,
17655 ) -> Result<Option<Resolved>, RuntimeError> {
17656 let state = self.0.lock().await;
17657 if state.owned.contains(&id) {
17658 Ok(Some(Resolved::PackRecord {
17659 pack: "mock".into(),
17660 kind: "widget".into(),
17661 data: serde_json::json!({ "id": id.to_string(), "name": "test-widget" }),
17662 }))
17663 } else {
17664 Ok(None)
17665 }
17666 }
17667
17668 async fn delete_by_id(
17669 &self,
17670 id: Uuid,
17671 hard: bool,
17672 ) -> Result<serde_json::Value, RuntimeError> {
17673 let mut state = self.0.lock().await;
17674 if !state.owned.contains(&id) {
17675 return Err(RuntimeError::NotFound(format!(
17676 "mock widget not found: {id}"
17677 )));
17678 }
17679 state.delete_calls.push((id, hard));
17680 if hard {
17681 state.owned.retain(|&x| x != id);
17682 state.deleted.retain(|&x| x != id);
17683 } else {
17684 state.deleted.push(id);
17685 }
17686 Ok(
17687 serde_json::json!({ "deleted": true, "id": id.to_string(), "kind": "widget", "hard": hard }),
17688 )
17689 }
17690 }
17691
17692 fn registry_with_mock_resolver(
17693 rt: KhiveRuntime,
17694 resolver: Box<dyn crate::pack::PackByIdResolver>,
17695 ) -> crate::VerbRegistry {
17696 use crate::pack::{PackRuntime, VerbRegistryBuilder};
17697 use khive_types::{HandlerDef, VerbCategory, Visibility};
17698
17699 static MINIMAL_HANDLERS: &[HandlerDef] = &[HandlerDef {
17700 name: "minimal.noop",
17701 description: "noop",
17702 visibility: Visibility::Verb,
17703 category: VerbCategory::Commissive,
17704 params: &[],
17705 }];
17706
17707 struct MinimalPack;
17708 impl khive_types::Pack for MinimalPack {
17709 const NAME: &'static str = "minimal";
17710 const NOTE_KINDS: &'static [&'static str] = &[];
17711 const ENTITY_KINDS: &'static [&'static str] = &[];
17712 const HANDLERS: &'static [HandlerDef] = MINIMAL_HANDLERS;
17713 }
17714 #[async_trait::async_trait]
17715 impl PackRuntime for MinimalPack {
17716 fn name(&self) -> &str {
17717 "minimal"
17718 }
17719 fn note_kinds(&self) -> &'static [&'static str] {
17720 &[]
17721 }
17722 fn entity_kinds(&self) -> &'static [&'static str] {
17723 &[]
17724 }
17725 fn handlers(&self) -> &'static [HandlerDef] {
17726 MINIMAL_HANDLERS
17727 }
17728 async fn dispatch(
17729 &self,
17730 _verb: &str,
17731 _params: serde_json::Value,
17732 _registry: &crate::VerbRegistry,
17733 _token: &NamespaceToken,
17734 ) -> Result<serde_json::Value, RuntimeError> {
17735 Err(RuntimeError::InvalidInput("stub".into()))
17736 }
17737 }
17738
17739 let _ = rt;
17740 let mut builder = VerbRegistryBuilder::new();
17741 builder.register(MinimalPack);
17742 builder.register_resolver("mock", resolver);
17743 builder.build().expect("registry build failed")
17744 }
17745
17746 #[tokio::test]
17747 async fn pack_record_resolved_pair_returns_none() {
17748 let pr = Resolved::PackRecord {
17749 pack: "knowledge".into(),
17750 kind: "atom".into(),
17751 data: serde_json::json!({}),
17752 };
17753 assert!(
17754 resolved_pair(Some(&pr)).is_none(),
17755 "PackRecord must not be a valid edge endpoint"
17756 );
17757 }
17758
17759 #[test]
17760 fn resolved_pair_surfaces_entity_type() {
17761 let e = Resolved::Entity(
17762 Entity::new("mathlib", "concept", "Nat.add_comm").with_entity_type(Some("theorem")),
17763 );
17764 assert_eq!(
17765 resolved_pair(Some(&e)),
17766 Some(("entity", "concept", Some("theorem"))),
17767 "entity_type subtype must be surfaced alongside base kind"
17768 );
17769 }
17770
17771 #[test]
17772 fn endpoint_of_type_matches_subtype_not_base_kind() {
17773 let kind = "concept";
17775 let et = Some("theorem");
17776
17777 assert!(endpoint_matches(
17779 &EndpointKind::EntityOfType {
17780 kind: "concept",
17781 entity_type: "theorem",
17782 },
17783 "entity",
17784 kind,
17785 et
17786 ));
17787 assert!(!endpoint_matches(
17788 &EndpointKind::EntityOfType {
17789 kind: "concept",
17790 entity_type: "definition",
17791 },
17792 "entity",
17793 kind,
17794 et
17795 ));
17796
17797 assert!(!endpoint_matches(
17800 &EndpointKind::EntityOfKind("theorem"),
17801 "entity",
17802 kind,
17803 et
17804 ));
17805 assert!(endpoint_matches(
17807 &EndpointKind::EntityOfKind("concept"),
17808 "entity",
17809 kind,
17810 et
17811 ));
17812
17813 assert!(!endpoint_matches(
17815 &EndpointKind::EntityOfType {
17816 kind: "concept",
17817 entity_type: "theorem",
17818 },
17819 "note",
17820 "task",
17821 None
17822 ));
17823 assert!(!endpoint_matches(
17824 &EndpointKind::EntityOfType {
17825 kind: "concept",
17826 entity_type: "theorem",
17827 },
17828 "entity",
17829 kind,
17830 None
17831 ));
17832 }
17833
17834 #[test]
17835 fn endpoint_of_type_requires_base_kind_match() {
17836 let wrong_base_kind = "project"; let et = Some("theorem");
17841
17842 assert!(
17846 !endpoint_matches(
17847 &EndpointKind::EntityOfType {
17848 kind: "concept",
17849 entity_type: "theorem",
17850 },
17851 "entity",
17852 wrong_base_kind,
17853 et
17854 ),
17855 "EntityOfType must reject an entity whose base kind != rule.kind \
17856 even when entity_type matches — the pre-fix bug admitted this"
17857 );
17858
17859 assert!(endpoint_matches(
17861 &EndpointKind::EntityOfType {
17862 kind: "concept",
17863 entity_type: "theorem",
17864 },
17865 "entity",
17866 "concept",
17867 et
17868 ));
17869 }
17870
17871 #[tokio::test]
17872 async fn registry_resolvers_accessor_returns_registered() {
17873 let resolver = Box::new(MockPackResolver::new());
17874 let registry = registry_with_mock_resolver(rt(), resolver);
17875 assert_eq!(registry.resolvers().len(), 1);
17876 assert_eq!(registry.resolvers()[0].0, "mock");
17877 }
17878
17879 #[tokio::test]
17880 async fn mock_resolver_resolve_by_id_returns_pack_record() {
17881 let id = Uuid::new_v4();
17882 let resolver: Box<dyn PackByIdResolver> = Box::new(MockPackResolver::new());
17883 let inner = MockPackResolver::new();
17885 inner.0.lock().await.owned.push(id);
17886 let result: Result<Option<Resolved>, RuntimeError> = inner.resolve_by_id(id).await;
17887 match result.unwrap() {
17888 Some(Resolved::PackRecord { pack, kind, data }) => {
17889 assert_eq!(pack, "mock");
17890 assert_eq!(kind, "widget");
17891 assert_eq!(data["id"].as_str().unwrap(), id.to_string());
17892 }
17893 other => panic!("expected PackRecord, got {:?}", other),
17894 }
17895 let _ = resolver;
17896 }
17897
17898 #[tokio::test]
17899 async fn mock_resolver_resolve_unknown_uuid_returns_none() {
17900 let inner = MockPackResolver::new();
17901 let id = Uuid::new_v4();
17902 let result: Result<Option<Resolved>, RuntimeError> = inner.resolve_by_id(id).await;
17903 assert!(result.unwrap().is_none());
17904 }
17905
17906 #[tokio::test]
17907 async fn mock_resolver_delete_soft_records_call() {
17908 let id = Uuid::new_v4();
17909 let inner = MockPackResolver::new();
17910 inner.0.lock().await.owned.push(id);
17911
17912 let result: Result<serde_json::Value, RuntimeError> = inner.delete_by_id(id, false).await;
17913 let result = result.unwrap();
17914 assert_eq!(result["deleted"], serde_json::json!(true));
17915 assert_eq!(result["hard"], serde_json::json!(false));
17916
17917 let live: Result<Option<Resolved>, RuntimeError> = inner.resolve_by_id(id).await;
17919 assert!(live.unwrap().is_none());
17920 let incl: Result<Option<Resolved>, RuntimeError> =
17921 inner.resolve_by_id_including_deleted(id).await;
17922 assert!(incl.unwrap().is_some());
17923 }
17924
17925 #[tokio::test]
17926 async fn mock_resolver_delete_hard_removes_record() {
17927 let id = Uuid::new_v4();
17928 let inner = MockPackResolver::new();
17929 inner.0.lock().await.owned.push(id);
17930
17931 let result: Result<serde_json::Value, RuntimeError> = inner.delete_by_id(id, true).await;
17932 assert_eq!(result.unwrap()["hard"], serde_json::json!(true));
17933
17934 let incl: Result<Option<Resolved>, RuntimeError> =
17936 inner.resolve_by_id_including_deleted(id).await;
17937 assert!(incl.unwrap().is_none());
17938 }
17939
17940 #[tokio::test]
17941 async fn pack_record_not_valid_context_entity() {
17942 let pr = Resolved::PackRecord {
17945 pack: "knowledge".into(),
17946 kind: "atom".into(),
17947 data: serde_json::json!({}),
17948 };
17949 assert!(matches!(pr, Resolved::PackRecord { .. }));
17952 }
17953
17954 fn neighbor_hit(node_id: Uuid) -> NeighborHit {
17957 NeighborHit {
17958 node_id,
17959 edge_id: Uuid::new_v4(),
17960 relation: EdgeRelation::Extends,
17961 weight: 1.0,
17962 name: None,
17963 kind: None,
17964 entity_type: None,
17965 }
17966 }
17967
17968 fn path_node(node_id: Uuid, depth: usize) -> PathNode {
17969 PathNode {
17970 node_id,
17971 via_edge: None,
17972 depth,
17973 name: None,
17974 kind: None,
17975 properties: None,
17976 weight: 0.0,
17977 }
17978 }
17979
17980 #[test]
17986 fn merge_traversal_paths_reenforces_limit_across_namespaces() {
17987 let root = Uuid::new_v4();
17988 let path_for = |n: usize| GraphPath {
17989 root_id: root,
17990 nodes: (0..n).map(|_| path_node(Uuid::new_v4(), 1)).collect(),
17991 total_weight: 1.0,
17992 };
17993
17994 let paths = vec![path_for(2), path_for(2), path_for(2)];
17995 let merged = merge_traversal_paths_by_root(paths, Some(2));
17996
17997 assert_eq!(merged.len(), 1);
17998 assert_eq!(
17999 merged[0].nodes.len(),
18000 2,
18001 "merge must re-enforce limit=2 on the unioned nodes, got {:?}",
18002 merged[0].nodes
18003 );
18004 }
18005
18006 #[test]
18012 fn merge_traversal_paths_keeps_shortest_depth_and_bfs_order() {
18013 let root = Uuid::new_v4();
18014 let shared = Uuid::new_v4();
18015 let far_node = Uuid::new_v4();
18016 let deep_edge = Uuid::new_v4();
18017 let shallow_edge = Uuid::new_v4();
18018
18019 let ns_first = GraphPath {
18022 root_id: root,
18023 nodes: vec![
18024 path_node(far_node, 1),
18025 PathNode {
18026 node_id: shared,
18027 via_edge: Some(deep_edge),
18028 depth: 4,
18029 name: None,
18030 kind: None,
18031 properties: None,
18032 weight: 0.0,
18033 },
18034 ],
18035 total_weight: 1.0,
18036 };
18037 let ns_second = GraphPath {
18039 root_id: root,
18040 nodes: vec![PathNode {
18041 node_id: shared,
18042 via_edge: Some(shallow_edge),
18043 depth: 2,
18044 name: None,
18045 kind: None,
18046 properties: None,
18047 weight: 0.0,
18048 }],
18049 total_weight: 1.0,
18050 };
18051
18052 let merged = merge_traversal_paths_by_root(vec![ns_first, ns_second], None);
18053
18054 assert_eq!(merged.len(), 1);
18055 let nodes = &merged[0].nodes;
18056 assert!(
18057 nodes.windows(2).all(|w| w[0].depth <= w[1].depth),
18058 "merged nodes must be in BFS (ascending depth) order, got {:?}",
18059 nodes
18060 .iter()
18061 .map(|n| (n.node_id, n.depth))
18062 .collect::<Vec<_>>()
18063 );
18064 let shared_node = nodes
18065 .iter()
18066 .find(|n| n.node_id == shared)
18067 .expect("shared node must survive the merge");
18068 assert_eq!(
18069 shared_node.depth, 2,
18070 "shared node must report its shortest depth across namespaces"
18071 );
18072 assert_eq!(
18073 shared_node.via_edge,
18074 Some(shallow_edge),
18075 "shared node must carry the via_edge that produced the shortest path"
18076 );
18077 }
18078
18079 #[test]
18085 fn merge_traversal_paths_total_weight_drops_with_the_node_it_described() {
18086 let root = Uuid::new_v4();
18087 let weighted = |depth: usize, weight: f64| PathNode {
18088 node_id: Uuid::new_v4(),
18089 via_edge: None,
18090 depth,
18091 name: None,
18092 kind: None,
18093 properties: None,
18094 weight,
18095 };
18096
18097 let path = GraphPath {
18098 root_id: root,
18099 nodes: vec![weighted(1, 0.5), weighted(1, 0.4), weighted(2, 9.0)],
18100 total_weight: 9.0,
18101 };
18102
18103 let merged = merge_traversal_paths_by_root(vec![path], Some(2));
18104
18105 assert_eq!(merged.len(), 1);
18106 assert_eq!(
18107 merged[0].nodes.len(),
18108 2,
18109 "limit=2 must drop the depth-2 node"
18110 );
18111 assert_eq!(
18112 merged[0].total_weight, 0.5,
18113 "total_weight must be the max over surviving nodes, not the 9.0 \
18114 carried by the node the limit removed"
18115 );
18116 }
18117
18118 #[tokio::test]
18121 async fn enrich_neighbor_hits_batch_entity_note_and_bogus() {
18122 let rt = rt();
18123 let tok = NamespaceToken::local();
18124
18125 let entity = rt
18127 .create_entity(&tok, "concept", None, "MyEntity", None, None, vec![])
18128 .await
18129 .unwrap();
18130
18131 let note = rt
18133 .create_note(&tok, "observation", None, "body", Some(0.5), None, vec![])
18134 .await
18135 .unwrap();
18136
18137 let bogus_id = Uuid::new_v4();
18138
18139 let mut hits = vec![
18140 neighbor_hit(entity.id),
18141 neighbor_hit(note.id),
18142 neighbor_hit(bogus_id),
18143 ];
18144
18145 rt.enrich_neighbor_hits(&tok, &mut hits).await;
18146
18147 assert_eq!(hits[0].name.as_deref(), Some("MyEntity"));
18148 assert_eq!(hits[0].kind.as_deref(), Some("concept"));
18149
18150 assert_eq!(hits[1].name.as_deref(), Some("[observation]"));
18151 assert_eq!(hits[1].kind.as_deref(), Some("observation"));
18152
18153 assert!(hits[2].name.is_none());
18154 assert!(hits[2].kind.is_none());
18155 }
18156
18157 #[tokio::test]
18159 async fn enrich_neighbor_hits_note_with_name_uses_name() {
18160 let rt = rt();
18161 let tok = NamespaceToken::local();
18162
18163 let note = rt
18164 .create_note(
18165 &tok,
18166 "insight",
18167 Some("NoteTitle"),
18168 "body",
18169 Some(0.5),
18170 None,
18171 vec![],
18172 )
18173 .await
18174 .unwrap();
18175
18176 let mut hits = vec![neighbor_hit(note.id)];
18177 rt.enrich_neighbor_hits(&tok, &mut hits).await;
18178
18179 assert_eq!(hits[0].name.as_deref(), Some("NoteTitle"));
18180 assert_eq!(hits[0].kind.as_deref(), Some("insight"));
18181 }
18182
18183 #[tokio::test]
18186 async fn enrich_path_nodes_batch_dedup_and_unresolved() {
18187 let rt = rt();
18188 let tok = NamespaceToken::local();
18189
18190 let ea = rt
18191 .create_entity(&tok, "concept", None, "Alpha", None, None, vec![])
18192 .await
18193 .unwrap();
18194 let eb = rt
18195 .create_entity(&tok, "document", None, "Beta", None, None, vec![])
18196 .await
18197 .unwrap();
18198 let bogus_id = Uuid::new_v4();
18199
18200 let mut paths = vec![
18202 GraphPath {
18203 root_id: ea.id,
18204 nodes: vec![
18205 path_node(ea.id, 0),
18206 path_node(eb.id, 1),
18207 path_node(bogus_id, 2),
18208 ],
18209 total_weight: 1.0,
18210 },
18211 GraphPath {
18212 root_id: eb.id,
18213 nodes: vec![path_node(eb.id, 0), path_node(ea.id, 1)],
18214 total_weight: 1.0,
18215 },
18216 ];
18217
18218 rt.enrich_path_nodes(&tok, &mut paths, false).await;
18219
18220 assert_eq!(paths[0].nodes[0].name.as_deref(), Some("Alpha"));
18221 assert_eq!(paths[0].nodes[0].kind.as_deref(), Some("concept"));
18222 assert_eq!(paths[0].nodes[1].name.as_deref(), Some("Beta"));
18223 assert_eq!(paths[0].nodes[1].kind.as_deref(), Some("document"));
18224 assert!(paths[0].nodes[2].name.is_none());
18225 assert!(paths[0].nodes[2].kind.is_none());
18226
18227 assert_eq!(paths[1].nodes[0].name.as_deref(), Some("Beta"));
18229 assert_eq!(paths[1].nodes[0].kind.as_deref(), Some("document"));
18230 assert_eq!(paths[1].nodes[1].name.as_deref(), Some("Alpha"));
18231 assert_eq!(paths[1].nodes[1].kind.as_deref(), Some("concept"));
18232 }
18233
18234 #[tokio::test]
18242 async fn enrich_resolves_entities_in_extra_visible_namespace() {
18243 let rt = KhiveRuntime::memory().unwrap();
18244
18245 let ns_a = Namespace::parse("enrich-ns-a").unwrap();
18246 let ns_b = Namespace::parse("enrich-ns-b").unwrap();
18247
18248 let tok_b = rt.authorize(ns_b.clone()).unwrap();
18249
18250 let entity_b = rt
18252 .create_entity(&tok_b, "concept", None, "EntityInB", None, None, vec![])
18253 .await
18254 .unwrap();
18255 assert_eq!(entity_b.namespace, "enrich-ns-b");
18256
18257 let vis_tok = rt
18259 .authorize_with_visibility(ns_a.clone(), vec![ns_b.clone()])
18260 .unwrap();
18261
18262 let mut hits = vec![neighbor_hit(entity_b.id)];
18264 rt.enrich_neighbor_hits(&vis_tok, &mut hits).await;
18265
18266 assert_eq!(
18267 hits[0].name.as_deref(),
18268 Some("EntityInB"),
18269 "entity in extra-visible ns must be enriched by enrich_neighbor_hits"
18270 );
18271 assert_eq!(hits[0].kind.as_deref(), Some("concept"));
18272
18273 let mut paths = vec![GraphPath {
18275 root_id: entity_b.id,
18276 nodes: vec![path_node(entity_b.id, 0)],
18277 total_weight: 1.0,
18278 }];
18279 rt.enrich_path_nodes(&vis_tok, &mut paths, false).await;
18280
18281 assert_eq!(
18282 paths[0].nodes[0].name.as_deref(),
18283 Some("EntityInB"),
18284 "entity in extra-visible ns must be enriched by enrich_path_nodes"
18285 );
18286 assert_eq!(paths[0].nodes[0].kind.as_deref(), Some("concept"));
18287 }
18288
18289 #[tokio::test]
18293 async fn enrich_neighbor_hits_populates_entity_type() {
18294 let rt = rt();
18295 let tok = NamespaceToken::local();
18296
18297 let props = serde_json::json!({"domain": "attention"});
18298 let entity = rt
18299 .create_entity(
18300 &tok,
18301 "concept",
18302 Some("algorithm"),
18303 "FlashAttn",
18304 None,
18305 Some(props),
18306 vec![],
18307 )
18308 .await
18309 .unwrap();
18310
18311 let entity_no_type = rt
18312 .create_entity(&tok, "concept", None, "PlainConcept", None, None, vec![])
18313 .await
18314 .unwrap();
18315
18316 let mut hits = vec![neighbor_hit(entity.id), neighbor_hit(entity_no_type.id)];
18317 rt.enrich_neighbor_hits(&tok, &mut hits).await;
18318
18319 assert_eq!(hits[0].entity_type.as_deref(), Some("algorithm"));
18320 assert!(
18321 hits[1].entity_type.is_none(),
18322 "entity without entity_type must leave the field as None"
18323 );
18324 }
18325
18326 #[tokio::test]
18330 async fn enrich_path_nodes_populates_properties() {
18331 let rt = rt();
18332 let tok = NamespaceToken::local();
18333
18334 let props = serde_json::json!({"year": 2024, "venue": "NeurIPS"});
18335 let entity_with_props = rt
18336 .create_entity(
18337 &tok,
18338 "document",
18339 None,
18340 "AttentionPaper",
18341 None,
18342 Some(props.clone()),
18343 vec![],
18344 )
18345 .await
18346 .unwrap();
18347
18348 let entity_no_props = rt
18349 .create_entity(&tok, "concept", None, "BareConceptNode", None, None, vec![])
18350 .await
18351 .unwrap();
18352
18353 let mut paths = vec![GraphPath {
18354 root_id: entity_with_props.id,
18355 nodes: vec![
18356 path_node(entity_with_props.id, 0),
18357 path_node(entity_no_props.id, 1),
18358 ],
18359 total_weight: 1.0,
18360 }];
18361
18362 rt.enrich_path_nodes(&tok, &mut paths, true).await;
18363
18364 assert_eq!(
18365 paths[0].nodes[0].properties.as_ref(),
18366 Some(&props),
18367 "properties must be filled when entity has a non-null properties blob"
18368 );
18369 assert!(
18370 paths[0].nodes[1].properties.is_none(),
18371 "entity without properties must leave the field as None"
18372 );
18373 }
18374
18375 #[tokio::test]
18378 async fn traverse_rejects_root_count_above_public_cap_before_lookup() {
18379 use khive_storage::types::{TraversalOptions, MAX_TRAVERSAL_ROOTS};
18380
18381 let rt = rt();
18382 let tok = NamespaceToken::local();
18383 let err = rt
18384 .traverse(
18385 &tok,
18386 TraversalRequest {
18387 roots: (0..=MAX_TRAVERSAL_ROOTS)
18388 .map(|_| uuid::Uuid::new_v4())
18389 .collect(),
18390 options: TraversalOptions {
18391 max_depth: 1,
18392 direction: Direction::Out,
18393 relations: None,
18394 min_weight: None,
18395 limit: None,
18396 },
18397 include_roots: false,
18398 include_properties: false,
18399 execution_budget: Default::default(),
18400 },
18401 )
18402 .await
18403 .unwrap_err();
18404
18405 assert!(matches!(
18406 err,
18407 RuntimeError::InvalidInput(message)
18408 if message.contains("roots must contain at most 100 entries")
18409 ));
18410 }
18411
18412 #[test]
18427 fn pack_entity_of_type_rules_do_not_shadow_base_entity_of_kind_rule() {
18428 let pack_rules: Vec<EdgeEndpointRule> = vec![
18431 EdgeEndpointRule {
18432 relation: EdgeRelation::VariantOf,
18433 source: EndpointKind::EntityOfType {
18434 kind: "concept",
18435 entity_type: "goal",
18436 },
18437 target: EndpointKind::EntityOfType {
18438 kind: "concept",
18439 entity_type: "theorem",
18440 },
18441 },
18442 EdgeEndpointRule {
18443 relation: EdgeRelation::VariantOf,
18444 source: EndpointKind::EntityOfType {
18445 kind: "concept",
18446 entity_type: "goal",
18447 },
18448 target: EndpointKind::EntityOfType {
18449 kind: "concept",
18450 entity_type: "definition",
18451 },
18452 },
18453 ];
18454
18455 let goal_a =
18456 Resolved::Entity(Entity::new("local", "concept", "G-a").with_entity_type(Some("goal")));
18457 let goal_b =
18458 Resolved::Entity(Entity::new("local", "concept", "G-b").with_entity_type(Some("goal")));
18459
18460 assert!(
18462 !pack_rule_allows(
18463 &pack_rules,
18464 EdgeRelation::VariantOf,
18465 Some(&goal_a),
18466 Some(&goal_b)
18467 ),
18468 "pack rules must not cover goal->goal variant_of (no such rule declared)"
18469 );
18470
18471 assert!(
18474 base_entity_rule_allows("concept", EdgeRelation::VariantOf, "concept"),
18475 "base contract must allow concept->concept variant_of regardless of pack EntityOfType rules"
18476 );
18477 }
18478
18479 #[tokio::test]
18484 async fn link_variant_of_goal_to_goal_allowed_when_pack_has_entity_of_type_rules() {
18485 let rt = rt();
18486 let tok = NamespaceToken::local();
18487
18488 rt.install_edge_rules(vec![
18491 EdgeEndpointRule {
18492 relation: EdgeRelation::VariantOf,
18493 source: EndpointKind::EntityOfType {
18494 kind: "concept",
18495 entity_type: "goal",
18496 },
18497 target: EndpointKind::EntityOfType {
18498 kind: "concept",
18499 entity_type: "theorem",
18500 },
18501 },
18502 EdgeEndpointRule {
18503 relation: EdgeRelation::VariantOf,
18504 source: EndpointKind::EntityOfType {
18505 kind: "concept",
18506 entity_type: "goal",
18507 },
18508 target: EndpointKind::EntityOfType {
18509 kind: "concept",
18510 entity_type: "definition",
18511 },
18512 },
18513 ]);
18514
18515 let a = rt
18516 .create_entity(
18517 &tok,
18518 "concept",
18519 Some("goal"),
18520 "Goal Alpha",
18521 None,
18522 None,
18523 vec![],
18524 )
18525 .await
18526 .unwrap();
18527 let b = rt
18528 .create_entity(
18529 &tok,
18530 "concept",
18531 Some("goal"),
18532 "Goal Beta",
18533 None,
18534 None,
18535 vec![],
18536 )
18537 .await
18538 .unwrap();
18539
18540 let result = rt
18543 .link(&tok, a.id, b.id, EdgeRelation::VariantOf, 1.0, None)
18544 .await;
18545 assert!(
18546 result.is_ok(),
18547 "goal->goal variant_of must be allowed via the base concept->concept rule \
18548 even when EntityOfType rules for variant_of are installed; got {result:?}"
18549 );
18550
18551 let p = rt
18556 .create_entity(&tok, "project", None, "Proj", None, None, vec![])
18557 .await
18558 .unwrap();
18559 let bad = rt
18560 .link(&tok, a.id, p.id, EdgeRelation::VariantOf, 1.0, None)
18561 .await;
18562 assert!(
18563 bad.is_err(),
18564 "additive pack rules must not make validation fail-open; \
18565 goal(concept)->project variant_of must be rejected (pack miss + base miss); \
18566 got {bad:?}"
18567 );
18568 }
18569
18570 #[tokio::test]
18575 async fn link_depends_on_theorem_to_definition_allowed_only_via_pack_rule() {
18576 let rt = rt();
18577 let tok = NamespaceToken::local();
18578
18579 assert!(
18582 !base_entity_rule_allows("concept", EdgeRelation::DependsOn, "concept"),
18583 "base contract must not allow concept->concept DependsOn; \
18584 test would be vacuous if this precondition fails"
18585 );
18586
18587 rt.install_edge_rules(vec![EdgeEndpointRule {
18591 relation: EdgeRelation::DependsOn,
18592 source: EndpointKind::EntityOfType {
18593 kind: "concept",
18594 entity_type: "theorem",
18595 },
18596 target: EndpointKind::EntityOfType {
18597 kind: "concept",
18598 entity_type: "definition",
18599 },
18600 }]);
18601
18602 let thm = rt
18603 .create_entity(&tok, "concept", Some("theorem"), "T1", None, None, vec![])
18604 .await
18605 .unwrap();
18606 let def = rt
18607 .create_entity(
18608 &tok,
18609 "concept",
18610 Some("definition"),
18611 "D1",
18612 None,
18613 None,
18614 vec![],
18615 )
18616 .await
18617 .unwrap();
18618
18619 let result = rt
18621 .link(&tok, thm.id, def.id, EdgeRelation::DependsOn, 1.0, None)
18622 .await;
18623 assert!(
18624 result.is_ok(),
18625 "theorem->definition DependsOn must be allowed by the installed pack rule; \
18626 the base contract has no concept->concept DependsOn row; got {result:?}"
18627 );
18628 }
18629
18630 #[tokio::test]
18637 async fn link_document_introduced_by_person_allowed() {
18638 let rt = rt();
18639 let tok = NamespaceToken::local();
18640
18641 let doc = rt
18642 .create_entity(&tok, "document", None, "Paper", None, None, vec![])
18643 .await
18644 .unwrap();
18645 let author = rt
18646 .create_entity(&tok, "person", None, "Author", None, None, vec![])
18647 .await
18648 .unwrap();
18649
18650 let result = rt
18651 .link(
18652 &tok,
18653 doc.id,
18654 author.id,
18655 EdgeRelation::IntroducedBy,
18656 1.0,
18657 None,
18658 )
18659 .await;
18660 assert!(
18661 result.is_ok(),
18662 "document->person introduced_by must be allowed by the ADR-002 \
18663 endpoint amendment; got {result:?}"
18664 );
18665 }
18666
18667 #[tokio::test]
18668 async fn link_document_introduced_by_org_allowed() {
18669 let rt = rt();
18670 let tok = NamespaceToken::local();
18671
18672 let doc = rt
18673 .create_entity(&tok, "document", None, "Whitepaper", None, None, vec![])
18674 .await
18675 .unwrap();
18676 let org = rt
18677 .create_entity(&tok, "org", None, "Publisher", None, None, vec![])
18678 .await
18679 .unwrap();
18680
18681 let result = rt
18682 .link(&tok, doc.id, org.id, EdgeRelation::IntroducedBy, 1.0, None)
18683 .await;
18684 assert!(
18685 result.is_ok(),
18686 "document->org introduced_by must be allowed by the ADR-002 \
18687 endpoint amendment; got {result:?}"
18688 );
18689 }
18690
18691 #[tokio::test]
18692 async fn link_concept_introduced_by_org_allowed() {
18693 let rt = rt();
18694 let tok = NamespaceToken::local();
18695
18696 let concept = rt
18697 .create_entity(&tok, "concept", None, "Architecture", None, None, vec![])
18698 .await
18699 .unwrap();
18700 let org = rt
18701 .create_entity(&tok, "org", None, "Originator", None, None, vec![])
18702 .await
18703 .unwrap();
18704
18705 let result = rt
18706 .link(
18707 &tok,
18708 concept.id,
18709 org.id,
18710 EdgeRelation::IntroducedBy,
18711 1.0,
18712 None,
18713 )
18714 .await;
18715 assert!(
18716 result.is_ok(),
18717 "concept->org introduced_by must be allowed by the ADR-002 \
18718 endpoint amendment; got {result:?}"
18719 );
18720 }
18721
18722 #[tokio::test]
18723 async fn link_document_depends_on_document_allowed() {
18724 let rt = rt();
18725 let tok = NamespaceToken::local();
18726
18727 let doc_a = rt
18728 .create_entity(&tok, "document", None, "Spec A", None, None, vec![])
18729 .await
18730 .unwrap();
18731 let doc_b = rt
18732 .create_entity(&tok, "document", None, "Spec B", None, None, vec![])
18733 .await
18734 .unwrap();
18735
18736 let result = rt
18737 .link(&tok, doc_a.id, doc_b.id, EdgeRelation::DependsOn, 1.0, None)
18738 .await;
18739 assert!(
18740 result.is_ok(),
18741 "document->document depends_on must be allowed by the ADR-002 \
18742 endpoint amendment; got {result:?}"
18743 );
18744 let edge = result.unwrap();
18745 let dk = edge
18746 .metadata
18747 .as_ref()
18748 .and_then(|m| m.get("dependency_kind"))
18749 .and_then(|v| v.as_str());
18750 assert_eq!(
18751 dk,
18752 Some("normative"),
18753 "document->document depends_on must infer dependency_kind=normative"
18754 );
18755 }
18756
18757 #[tokio::test]
18762 async fn link_document_links_to_document_allowed_service_and_concept_targets_rejected() {
18763 let rt = rt();
18764 let tok = NamespaceToken::local();
18765
18766 let page_a = rt
18767 .create_entity(&tok, "document", None, "Page A", None, None, vec![])
18768 .await
18769 .unwrap();
18770 let page_b = rt
18771 .create_entity(&tok, "document", None, "Page B", None, None, vec![])
18772 .await
18773 .unwrap();
18774
18775 let result = rt
18776 .link(&tok, page_a.id, page_b.id, EdgeRelation::LinksTo, 1.0, None)
18777 .await;
18778 assert!(
18779 result.is_ok(),
18780 "document->document links_to must be allowed by the ADR-191 \
18781 endpoint amendment; got {result:?}"
18782 );
18783 let edge = result.unwrap();
18784 assert!(
18785 edge.metadata.is_none(),
18786 "links_to carries no governed metadata and infers none, unlike \
18787 depends_on; got {:?}",
18788 edge.metadata
18789 );
18790
18791 let svc = rt
18792 .create_entity(&tok, "service", None, "Some Site", None, None, vec![])
18793 .await
18794 .unwrap();
18795 let concept = rt
18796 .create_entity(&tok, "concept", None, "Some Concept", None, None, vec![])
18797 .await
18798 .unwrap();
18799
18800 let doc_to_service = rt
18801 .link(&tok, page_a.id, svc.id, EdgeRelation::LinksTo, 1.0, None)
18802 .await
18803 .unwrap_err();
18804 assert!(
18805 doc_to_service
18806 .to_string()
18807 .contains("base endpoint allowlist"),
18808 "document->service links_to must be refused with the \
18809 endpoint-contract error; got {doc_to_service}"
18810 );
18811
18812 let concept_to_doc = rt
18813 .link(
18814 &tok,
18815 concept.id,
18816 page_a.id,
18817 EdgeRelation::LinksTo,
18818 1.0,
18819 None,
18820 )
18821 .await
18822 .unwrap_err();
18823 assert!(
18824 concept_to_doc
18825 .to_string()
18826 .contains("base endpoint allowlist"),
18827 "concept->document links_to must be refused with the \
18828 endpoint-contract error; got {concept_to_doc}"
18829 );
18830 }
18831
18832 #[tokio::test]
18833 async fn link_org_introduced_by_document_rejected_direction_matters() {
18834 let rt = rt();
18835 let tok = NamespaceToken::local();
18836
18837 let org = rt
18838 .create_entity(&tok, "org", None, "Publisher", None, None, vec![])
18839 .await
18840 .unwrap();
18841 let doc = rt
18842 .create_entity(&tok, "document", None, "Paper", None, None, vec![])
18843 .await
18844 .unwrap();
18845
18846 let result = rt
18849 .link(&tok, org.id, doc.id, EdgeRelation::IntroducedBy, 1.0, None)
18850 .await;
18851 assert!(
18852 result.is_err(),
18853 "org->document introduced_by must remain rejected; only \
18854 document->org is permitted, not the reverse; got {result:?}"
18855 );
18856 }
18857
18858 #[tokio::test]
18864 async fn link_service_introduced_by_document_allowed() {
18865 let rt = rt();
18866 let tok = NamespaceToken::local();
18867
18868 let svc = rt
18869 .create_entity(&tok, "service", None, "Search API", None, None, vec![])
18870 .await
18871 .unwrap();
18872 let doc = rt
18873 .create_entity(&tok, "document", None, "Design doc", None, None, vec![])
18874 .await
18875 .unwrap();
18876
18877 let result = rt
18878 .link(&tok, svc.id, doc.id, EdgeRelation::IntroducedBy, 1.0, None)
18879 .await;
18880 assert!(
18881 result.is_ok(),
18882 "service->document introduced_by must be allowed by the ADR-167 \
18883 endpoint amendment; got {result:?}"
18884 );
18885 }
18886
18887 #[tokio::test]
18888 async fn link_service_introduced_by_person_rejected() {
18889 let rt = rt();
18890 let tok = NamespaceToken::local();
18891
18892 let svc = rt
18893 .create_entity(&tok, "service", None, "Search API", None, None, vec![])
18894 .await
18895 .unwrap();
18896 let person = rt
18897 .create_entity(&tok, "person", None, "Operator", None, None, vec![])
18898 .await
18899 .unwrap();
18900
18901 let result = rt
18902 .link(
18903 &tok,
18904 svc.id,
18905 person.id,
18906 EdgeRelation::IntroducedBy,
18907 1.0,
18908 None,
18909 )
18910 .await;
18911 assert!(
18912 result.is_err(),
18913 "service->person introduced_by is not in the endpoint table and \
18914 must be rejected; got {result:?}"
18915 );
18916 }
18917
18918 #[tokio::test]
18919 async fn link_service_introduced_by_org_rejected() {
18920 let rt = rt();
18921 let tok = NamespaceToken::local();
18922
18923 let svc = rt
18924 .create_entity(&tok, "service", None, "Search API", None, None, vec![])
18925 .await
18926 .unwrap();
18927 let org = rt
18928 .create_entity(&tok, "org", None, "Vendor", None, None, vec![])
18929 .await
18930 .unwrap();
18931
18932 let result = rt
18933 .link(&tok, svc.id, org.id, EdgeRelation::IntroducedBy, 1.0, None)
18934 .await;
18935 assert!(
18936 result.is_err(),
18937 "service->org introduced_by is not in the endpoint table and \
18938 must be rejected; got {result:?}"
18939 );
18940 }
18941
18942 #[tokio::test]
18943 async fn link_document_introduced_by_service_rejected_direction_matters() {
18944 let rt = rt();
18945 let tok = NamespaceToken::local();
18946
18947 let doc = rt
18948 .create_entity(&tok, "document", None, "Design doc", None, None, vec![])
18949 .await
18950 .unwrap();
18951 let svc = rt
18952 .create_entity(&tok, "service", None, "Search API", None, None, vec![])
18953 .await
18954 .unwrap();
18955
18956 let result = rt
18957 .link(&tok, doc.id, svc.id, EdgeRelation::IntroducedBy, 1.0, None)
18958 .await;
18959 assert!(
18960 result.is_err(),
18961 "document->service introduced_by must remain rejected; only \
18962 service->document is permitted, not the reverse; got {result:?}"
18963 );
18964 }
18965
18966 #[tokio::test]
18967 async fn link_service_derived_from_document_rejected() {
18968 let rt = rt();
18969 let tok = NamespaceToken::local();
18970
18971 let svc = rt
18972 .create_entity(&tok, "service", None, "Search API", None, None, vec![])
18973 .await
18974 .unwrap();
18975 let doc = rt
18976 .create_entity(&tok, "document", None, "Design doc", None, None, vec![])
18977 .await
18978 .unwrap();
18979
18980 let result = rt
18981 .link(&tok, svc.id, doc.id, EdgeRelation::DerivedFrom, 1.0, None)
18982 .await;
18983 assert!(
18984 result.is_err(),
18985 "service->document is permitted only for introduced_by; the same \
18986 endpoints under derived_from must be rejected; got {result:?}"
18987 );
18988 }
18989
18990 struct CapturingVecService {
18997 dims: usize,
18998 captured: Arc<std::sync::Mutex<Vec<String>>>,
18999 }
19000
19001 #[async_trait]
19002 impl EmbeddingService for CapturingVecService {
19003 async fn embed(
19004 &self,
19005 texts: &[String],
19006 _model: EmbeddingModel,
19007 ) -> std::result::Result<Vec<Vec<f32>>, EmbedError> {
19008 for text in texts {
19009 if text.len() > MAX_TEXT_BYTES {
19010 return Err(EmbedError::TextTooLong {
19011 length: text.len(),
19012 max: MAX_TEXT_BYTES,
19013 });
19014 }
19015 }
19016 self.captured.lock().unwrap().extend(texts.iter().cloned());
19017 Ok(texts.iter().map(|_| vec![1.0_f32; self.dims]).collect())
19018 }
19019
19020 fn supports_model(&self, _model: EmbeddingModel) -> bool {
19021 true
19022 }
19023
19024 fn name(&self) -> &'static str {
19025 "capturing-vec"
19026 }
19027 }
19028
19029 struct CapturingVecProvider {
19030 provider_name: String,
19031 dims: usize,
19032 captured: Arc<std::sync::Mutex<Vec<String>>>,
19033 }
19034
19035 #[async_trait]
19036 impl EmbedderProvider for CapturingVecProvider {
19037 fn name(&self) -> &str {
19038 &self.provider_name
19039 }
19040
19041 fn dimensions(&self) -> usize {
19042 self.dims
19043 }
19044
19045 async fn build(&self) -> crate::error::RuntimeResult<Arc<dyn EmbeddingService>> {
19046 Ok(Arc::new(CapturingVecService {
19047 dims: self.dims,
19048 captured: Arc::clone(&self.captured),
19049 }))
19050 }
19051 }
19052
19053 #[tokio::test]
19054 async fn create_bounds_embedding_input_without_truncating_stored_content() {
19055 let rt = rt();
19056 let tok = NamespaceToken::local();
19057 let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
19058 rt.register_embedder(CapturingVecProvider {
19059 provider_name: "strict-length-test".into(),
19060 dims: 4,
19061 captured: Arc::clone(&captured),
19062 });
19063
19064 let content = format!("{}\u{1f980}tail", "a".repeat(MAX_TEXT_BYTES - 1));
19065 let note = rt
19066 .create_note(&tok, "observation", None, &content, None, None, vec![])
19067 .await
19068 .expect("over-length note create must succeed");
19069 let fetched = rt
19070 .notes(&tok)
19071 .unwrap()
19072 .get_note(note.id)
19073 .await
19074 .unwrap()
19075 .expect("created note must be retrievable");
19076 assert_eq!(fetched.content, content, "stored content must remain full");
19077
19078 let embedded = captured.lock().unwrap().clone();
19079 assert_eq!(embedded.len(), 1);
19080 assert_eq!(embedded[0].len(), MAX_TEXT_BYTES - 1);
19081 assert!(embedded[0].is_char_boundary(embedded[0].len()));
19082 assert!(!embedded[0].contains('\u{1f980}'));
19083
19084 let vector_info = rt
19085 .vectors_for_model(&tok, "strict-length-test")
19086 .expect("vector store")
19087 .info()
19088 .await
19089 .expect("vector info");
19090 assert_eq!(vector_info.dimensions, 4);
19091 assert_eq!(vector_info.entry_count, 1);
19092
19093 captured.lock().unwrap().clear();
19094 rt.reindex_note(&tok, &fetched)
19095 .await
19096 .expect("reindex must bound the same stored content");
19097 assert_eq!(captured.lock().unwrap()[0].len(), MAX_TEXT_BYTES - 1);
19098
19099 captured.lock().unwrap().clear();
19100 rt.embed_document_batch_with_model("strict-length-test", std::slice::from_ref(&content))
19101 .await
19102 .expect("batch reindex seam must bound stored content");
19103 assert_eq!(captured.lock().unwrap()[0].len(), MAX_TEXT_BYTES - 1);
19104
19105 let normal = "normal byte-identical embedding input";
19106 rt.create_note(&tok, "observation", None, normal, None, None, vec![])
19107 .await
19108 .expect("normal note create must succeed");
19109 assert_eq!(captured.lock().unwrap().last().unwrap(), normal);
19110
19111 let long_description = format!("{}\u{1f980}tail", "b".repeat(MAX_TEXT_BYTES));
19112 rt.create_entity(
19113 &tok,
19114 "concept",
19115 None,
19116 "entity",
19117 Some(&long_description),
19118 None,
19119 vec![],
19120 )
19121 .await
19122 .expect("over-length entity create must succeed");
19123 }
19124
19125 #[tokio::test]
19126 async fn create_note_with_embedding_content_none_matches_create_note() {
19127 let rt = rt();
19128 let tok = NamespaceToken::local();
19129 let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
19130 rt.register_embedder(CapturingVecProvider {
19131 provider_name: "capturing-vec".into(),
19132 dims: 4,
19133 captured: Arc::clone(&captured),
19134 });
19135
19136 let note = rt
19137 .create_note_with_embedding_content(
19138 &tok,
19139 "observation",
19140 None,
19141 "full content, no override",
19142 None,
19143 None,
19144 None,
19145 vec![],
19146 )
19147 .await
19148 .expect("create with None override must behave like create_note");
19149 assert_eq!(note.content, "full content, no override");
19150 let seen = captured.lock().unwrap().clone();
19151 assert_eq!(
19152 seen,
19153 vec!["full content, no override".to_string()],
19154 "with no override the embedder must see the full content"
19155 );
19156 }
19157
19158 #[tokio::test]
19159 async fn create_note_with_embedding_content_embeds_capped_override_and_stores_full_content() {
19160 let rt = rt();
19161 let tok = NamespaceToken::local();
19162 let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
19163 rt.register_embedder(CapturingVecProvider {
19164 provider_name: "capturing-vec".into(),
19165 dims: 4,
19166 captured: Arc::clone(&captured),
19167 });
19168
19169 let full = "head-term and then a very long tail-term that exceeds any cap";
19170 let head = &full[.."head-term and then a very long".len()];
19171
19172 let note = rt
19173 .create_note_with_embedding_content(
19174 &tok,
19175 "observation",
19176 None,
19177 full,
19178 Some(head),
19179 None,
19180 None,
19181 vec![],
19182 )
19183 .await
19184 .expect("proper-prefix override must be accepted");
19185 assert_eq!(note.content, full, "stored content must be the full text");
19186
19187 let seen = captured.lock().unwrap().clone();
19188 assert_eq!(
19189 seen,
19190 vec![head.to_string()],
19191 "embedder must see only the capped override"
19192 );
19193 }
19194
19195 #[tokio::test]
19196 async fn create_note_with_embedding_content_fans_out_identical_override_to_multiple_models() {
19197 let rt = rt();
19198 let tok = NamespaceToken::local();
19199 let captured_a = Arc::new(std::sync::Mutex::new(Vec::new()));
19200 let captured_b = Arc::new(std::sync::Mutex::new(Vec::new()));
19201 rt.register_embedder(CapturingVecProvider {
19202 provider_name: "capturing-vec-a".into(),
19203 dims: 4,
19204 captured: Arc::clone(&captured_a),
19205 });
19206 rt.register_embedder(CapturingVecProvider {
19207 provider_name: "capturing-vec-b".into(),
19208 dims: 4,
19209 captured: Arc::clone(&captured_b),
19210 });
19211
19212 let full = "head-only-embedded plus a long discarded tail";
19213 let head = &full[.."head-only-embedded".len()];
19214
19215 rt.create_note_with_embedding_content(
19216 &tok,
19217 "observation",
19218 None,
19219 full,
19220 Some(head),
19221 None,
19222 None,
19223 vec![],
19224 )
19225 .await
19226 .expect("create ok");
19227
19228 assert_eq!(
19229 captured_a.lock().unwrap().clone(),
19230 vec![head.to_string()],
19231 "model A must receive the identical capped override"
19232 );
19233 assert_eq!(
19234 captured_b.lock().unwrap().clone(),
19235 vec![head.to_string()],
19236 "model B must receive the identical capped override"
19237 );
19238
19239 let vs_a = rt
19242 .vectors_for_model(&tok, "capturing-vec-a")
19243 .expect("vector store for model A");
19244 assert_eq!(
19245 vs_a.count().await.expect("vector count A"),
19246 1,
19247 "model A must have exactly one vector row for the note"
19248 );
19249 let vs_b = rt
19250 .vectors_for_model(&tok, "capturing-vec-b")
19251 .expect("vector store for model B");
19252 assert_eq!(
19253 vs_b.count().await.expect("vector count B"),
19254 1,
19255 "model B must have exactly one vector row for the note"
19256 );
19257 }
19258
19259 #[tokio::test]
19260 async fn create_note_with_embedding_content_rejects_empty_override() {
19261 let rt = rt();
19262 let tok = NamespaceToken::local();
19263
19264 let err = rt
19265 .create_note_with_embedding_content(
19266 &tok,
19267 "observation",
19268 None,
19269 "some content",
19270 Some(""),
19271 None,
19272 None,
19273 vec![],
19274 )
19275 .await
19276 .expect_err("empty override must be rejected");
19277 assert!(matches!(err, RuntimeError::InvalidInput(_)));
19278 }
19279
19280 #[tokio::test]
19281 async fn create_note_with_embedding_content_rejects_non_prefix_override() {
19282 let rt = rt();
19283 let tok = NamespaceToken::local();
19284
19285 let err = rt
19286 .create_note_with_embedding_content(
19287 &tok,
19288 "observation",
19289 None,
19290 "the actual content",
19291 Some("an unrelated string"),
19292 None,
19293 None,
19294 vec![],
19295 )
19296 .await
19297 .expect_err("non-prefix override must be rejected");
19298 assert!(matches!(err, RuntimeError::InvalidInput(ref m) if m.contains("prefix")));
19299 }
19300
19301 #[tokio::test]
19302 async fn create_note_with_embedding_content_rejects_equal_length_override() {
19303 let rt = rt();
19304 let tok = NamespaceToken::local();
19305
19306 let err = rt
19308 .create_note_with_embedding_content(
19309 &tok,
19310 "observation",
19311 None,
19312 "identical text",
19313 Some("identical text"),
19314 None,
19315 None,
19316 vec![],
19317 )
19318 .await
19319 .expect_err("an equal-length override must be rejected as not a proper prefix");
19320 assert!(matches!(err, RuntimeError::InvalidInput(ref m) if m.contains("prefix")));
19321 }
19322
19323 #[tokio::test]
19324 async fn create_note_with_embedding_content_rejects_secret_bearing_override() {
19325 let rt = rt();
19326 let tok = NamespaceToken::local();
19327
19328 let token_span = "ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
19329 let content = format!("{token_span} plus extra trailing content beyond the override");
19330 let embedding_content = format!("{token_span} plus extra");
19331
19332 let err = rt
19333 .create_note_with_embedding_content(
19334 &tok,
19335 "observation",
19336 None,
19337 &content,
19338 Some(&embedding_content),
19339 None,
19340 None,
19341 vec![],
19342 )
19343 .await
19344 .expect_err("a credential-shaped override must fail the secret gate");
19345 assert!(
19346 matches!(err, RuntimeError::SecretDetected(_)),
19347 "expected SecretDetected, got {err:?}"
19348 );
19349
19350 let count = rt
19352 .notes(&tok)
19353 .unwrap()
19354 .count_notes(tok.namespace().as_str(), None)
19355 .await
19356 .unwrap();
19357 assert_eq!(count, 0, "a rejected create must leave no note behind");
19358 }
19359
19360 const ADR002_EXCLUDED_SUBSECTIONS: &[&str] = &["Annotation relation", "KG pack extensions"];
19376
19377 fn parse_adr002_base_entity_endpoint_matrix(
19380 ) -> std::collections::HashSet<(String, EdgeRelation, String)> {
19381 let adr_path = format!(
19382 "{}/../../docs/adr/ADR-002-edge-ontology.md",
19383 env!("CARGO_MANIFEST_DIR")
19384 );
19385 let text = std::fs::read_to_string(&adr_path)
19386 .unwrap_or_else(|e| panic!("failed to read {adr_path}: {e}"));
19387 let lines: Vec<&str> = text.lines().collect();
19388
19389 let start = lines
19390 .iter()
19391 .position(|l| l.trim() == "### Base endpoint contract")
19392 .expect("ADR-002 must contain a '### Base endpoint contract' heading");
19393 let end = lines[start + 1..]
19394 .iter()
19395 .position(|l| l.starts_with("## "))
19396 .map(|i| start + 1 + i)
19397 .unwrap_or(lines.len());
19398 let section = &lines[start..end];
19399
19400 let mut triples = std::collections::HashSet::new();
19401 let mut excluded = false;
19402
19403 for line in section {
19404 let trimmed = line.trim();
19405 if let Some(title) = trimmed.strip_prefix("#### ") {
19406 excluded = ADR002_EXCLUDED_SUBSECTIONS
19407 .iter()
19408 .any(|ex| title.starts_with(ex));
19409 continue;
19410 }
19411 if excluded || !trimmed.starts_with('|') {
19412 continue;
19413 }
19414
19415 let cells: Vec<&str> = trimmed
19416 .trim_matches('|')
19417 .split('|')
19418 .map(|c| c.trim())
19419 .collect();
19420 if cells.len() != 3 {
19421 continue;
19422 }
19423 let is_header = cells[0].eq_ignore_ascii_case("Source");
19424 let is_separator = cells
19425 .iter()
19426 .all(|c| !c.is_empty() && c.chars().all(|ch| ch == '-'));
19427 if is_header || is_separator {
19428 continue;
19429 }
19430
19431 let src_raw = cells[0].trim_matches('`');
19432 let rel_raw = cells[1].trim_matches('`');
19433 let tgt_raw = cells[2].trim_matches('`');
19434
19435 let relation: EdgeRelation = rel_raw.parse().unwrap_or_else(|_| {
19436 panic!("ADR-002 base endpoint contract row has unparseable relation: {trimmed:?}")
19437 });
19438
19439 let src = if src_raw.eq_ignore_ascii_case("any entity") {
19440 "*".to_string()
19441 } else {
19442 src_raw.to_ascii_lowercase()
19443 };
19444 let tgt = tgt_raw.to_ascii_lowercase();
19445
19446 triples.insert((src, relation, tgt));
19447 }
19448
19449 triples
19450 }
19451
19452 #[test]
19453 fn base_entity_endpoint_rules_match_adr002_base_endpoint_contract() {
19454 let workspace_manifest = format!("{}/../Cargo.toml", env!("CARGO_MANIFEST_DIR"));
19461 if !std::path::Path::new(&workspace_manifest).exists() {
19462 eprintln!(
19463 "skipping ADR-002 conformance: workspace manifest not present \
19464 (published-package context); the check runs in the repository"
19465 );
19466 return;
19467 }
19468 let adr_matrix = parse_adr002_base_entity_endpoint_matrix();
19469 assert!(
19470 !adr_matrix.is_empty(),
19471 "parsed zero rows from ADR-002's base endpoint contract — parser or heading drift"
19472 );
19473
19474 let runtime_rules: std::collections::HashSet<(String, EdgeRelation, String)> =
19475 base_entity_endpoint_rules()
19476 .iter()
19477 .map(|(src, rel, tgt)| (src.to_string(), *rel, tgt.to_string()))
19478 .collect();
19479
19480 let missing_from_runtime: Vec<_> = adr_matrix.difference(&runtime_rules).collect();
19481 let extra_in_runtime: Vec<_> = runtime_rules.difference(&adr_matrix).collect();
19482
19483 assert!(
19484 missing_from_runtime.is_empty() && extra_in_runtime.is_empty(),
19485 "BASE_ENTITY_ENDPOINT_RULES has drifted from ADR-002's base endpoint contract.\n\
19486 In the ADR but not enforced by the runtime: {missing_from_runtime:#?}\n\
19487 Enforced by the runtime but not in the ADR: {extra_in_runtime:#?}"
19488 );
19489 }
19490
19491 fn reserved_key_props() -> serde_json::Value {
19494 serde_json::json!({"khive:secret_gate": "exempted:content-sha256-manifest-v1"})
19495 }
19496
19497 #[tokio::test]
19498 async fn create_entity_rejects_reserved_secret_gate_key() {
19499 let rt = rt();
19500 let tok = NamespaceToken::local();
19501 let err = rt
19502 .create_entity(
19503 &tok,
19504 "concept",
19505 None,
19506 "reserved-key-entity",
19507 None,
19508 Some(reserved_key_props()),
19509 vec![],
19510 )
19511 .await
19512 .expect_err("caller-supplied reserved key must be rejected");
19513 assert!(
19514 matches!(err, RuntimeError::InvalidInput(ref msg) if msg.contains("khive:secret_gate")),
19515 "unexpected error: {err:?}"
19516 );
19517 let found = rt
19519 .list_entities(&tok, Some("concept"), None, 10, 0)
19520 .await
19521 .expect("list must succeed");
19522 assert!(
19523 found.iter().all(|e| e.name != "reserved-key-entity"),
19524 "rejected create must leave no row behind"
19525 );
19526 }
19527
19528 #[tokio::test]
19529 async fn create_note_rejects_reserved_secret_gate_key() {
19530 let rt = rt();
19531 let tok = NamespaceToken::local();
19532 let err = rt
19533 .create_note(
19534 &tok,
19535 "observation",
19536 None,
19537 "reserved-key note content",
19538 None,
19539 Some(reserved_key_props()),
19540 vec![],
19541 )
19542 .await
19543 .expect_err("caller-supplied reserved key must be rejected");
19544 assert!(
19545 matches!(err, RuntimeError::InvalidInput(ref msg) if msg.contains("khive:secret_gate")),
19546 "unexpected error: {err:?}"
19547 );
19548 }
19549
19550 #[tokio::test]
19551 async fn create_many_rejects_reserved_secret_gate_key_atomically() {
19552 let rt = rt();
19553 let tok = NamespaceToken::local();
19554 let specs = vec![
19555 EntityCreateSpec {
19556 kind: "concept".to_string(),
19557 entity_type: None,
19558 name: "clean-entity".to_string(),
19559 description: None,
19560 properties: None,
19561 tags: vec![],
19562 },
19563 EntityCreateSpec {
19564 kind: "concept".to_string(),
19565 entity_type: None,
19566 name: "reserved-key-entity".to_string(),
19567 description: None,
19568 properties: Some(reserved_key_props()),
19569 tags: vec![],
19570 },
19571 ];
19572 let err = rt
19573 .create_many(&tok, specs)
19574 .await
19575 .expect_err("a reserved key anywhere in the batch must reject the whole batch");
19576 assert!(
19577 matches!(err, RuntimeError::InvalidInput(ref msg) if msg.contains("khive:secret_gate")),
19578 "unexpected error: {err:?}"
19579 );
19580 let found = rt
19582 .list_entities(&tok, Some("concept"), None, 10, 0)
19583 .await
19584 .expect("list must succeed");
19585 assert!(
19586 found.is_empty(),
19587 "batch rejection must leave zero rows behind, found: {found:?}"
19588 );
19589 }
19590
19591 #[tokio::test]
19592 async fn create_entity_with_attachments_commits_roles_and_projects_content() {
19593 use khive_db::stores::blob::FsBlobStore;
19594 use khive_storage::BlobStore as _;
19595
19596 let runtime = rt();
19597 let token = NamespaceToken::local();
19598 let blob_dir = tempfile::tempdir().expect("blob tempdir");
19599 let blob_store =
19600 Arc::new(FsBlobStore::new(blob_dir.path().to_path_buf(), 0).expect("blob store"));
19601 let content_ref = blob_store
19602 .put(b"bundle".to_vec())
19603 .await
19604 .expect("publish bundle");
19605 let network_ref = blob_store
19606 .put(b"network".to_vec())
19607 .await
19608 .expect("publish network");
19609 runtime
19610 .install_blob_store(blob_store)
19611 .expect("install blob store");
19612
19613 let created = runtime
19614 .create_entity_with_attachments(
19615 &token,
19616 "artifact",
19617 None,
19618 "role-keyed artifact",
19619 None,
19620 None,
19621 vec![],
19622 vec![
19623 NewAttachment {
19624 role: "content".to_string(),
19625 content_ref: content_ref.clone(),
19626 media_type: Some("application/json".to_string()),
19627 size_bytes: Some(6),
19628 },
19629 NewAttachment {
19630 role: "fann-network".to_string(),
19631 content_ref: network_ref.clone(),
19632 media_type: Some("application/octet-stream".to_string()),
19633 size_bytes: Some(7),
19634 },
19635 ],
19636 )
19637 .await
19638 .expect("atomic record + attachments publication");
19639
19640 assert_eq!(created.content_ref.as_deref(), Some(content_ref.as_str()));
19641 let reloaded = runtime
19642 .get_entity(&token, created.id)
19643 .await
19644 .expect("reload entity");
19645 assert_eq!(reloaded.content_ref.as_deref(), Some(content_ref.as_str()));
19646 let attachments = runtime
19647 .attachments()
19648 .expect("main attachment store")
19649 .list_attachments(created.id)
19650 .await
19651 .expect("list attachments");
19652 assert_eq!(attachments.len(), 2);
19653 assert_eq!(attachments[0].role, "content");
19654 assert_eq!(attachments[0].content_ref, content_ref);
19655 assert_eq!(attachments[1].role, "fann-network");
19656 assert_eq!(attachments[1].content_ref, network_ref);
19657 }
19658}