use std::collections::HashMap;
use std::str::FromStr;
use serde::Serialize;
use uuid::Uuid;
use khive_score::{rrf_score, DeterministicScore};
use khive_storage::note::Note;
use khive_storage::types::{
DeleteMode, Direction, EdgeSortField, GraphPath, LinkId, NeighborHit, NeighborQuery, Page,
PageRequest, SortOrder, SqlRow, SqlStatement, TextDocument, TextFilter, TextQueryMode,
TextSearchRequest, TraversalRequest,
};
use khive_storage::{Edge, EdgeRelation, Entity, EntityFilter, Event, EventFilter};
use khive_types::{EdgeEndpointRule, EndpointKind, EventKind, SubstrateKind};
use crate::error::{RuntimeError, RuntimeResult};
use crate::runtime::{KhiveRuntime, NamespaceToken};
#[cfg(test)]
std::thread_local! {
static LINK_FAIL_AFTER: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
#[derive(Clone, Debug)]
pub struct NoteSearchHit {
pub note_id: Uuid,
pub score: DeterministicScore,
pub title: Option<String>,
pub snippet: Option<String>,
}
fn text_preview(text: &str, max_chars: usize) -> Option<String> {
let trimmed = text.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.chars().take(max_chars).collect())
}
}
fn normalize_symmetric_direction(
direction: Direction,
relations: Option<&[EdgeRelation]>,
) -> Direction {
let Some(rels) = relations else {
return direction;
};
if rels.is_empty() {
return direction;
}
let all_symmetric = rels
.iter()
.all(|r| matches!(r, EdgeRelation::CompetesWith | EdgeRelation::ComposedWith));
if all_symmetric {
Direction::Both
} else {
direction
}
}
fn note_title(note: &Note) -> Option<String> {
note.name
.clone()
.filter(|s| !s.trim().is_empty())
.or_else(|| text_preview(¬e.content, 80))
}
fn note_snippet(note: &Note) -> Option<String> {
text_preview(¬e.content, 200)
}
#[derive(Clone, Debug)]
pub enum Resolved {
Entity(Entity),
Note(Note),
Event(Event),
}
fn resolved_pair(r: Option<&Resolved>) -> Option<(&'static str, &str)> {
match r? {
Resolved::Entity(e) => Some(("entity", e.kind.as_str())),
Resolved::Note(n) => Some(("note", n.kind.as_str())),
Resolved::Event(_) => None,
}
}
fn endpoint_matches(spec: &EndpointKind, substrate: &str, kind: &str) -> bool {
match spec {
EndpointKind::EntityOfKind(k) => substrate == "entity" && *k == kind,
EndpointKind::NoteOfKind(k) => substrate == "note" && *k == kind,
}
}
fn pack_rule_allows(
rules: &[EdgeEndpointRule],
relation: EdgeRelation,
src: Option<&Resolved>,
tgt: Option<&Resolved>,
) -> bool {
let Some((src_sub, src_kind)) = resolved_pair(src) else {
return false;
};
let Some((tgt_sub, tgt_kind)) = resolved_pair(tgt) else {
return false;
};
rules.iter().any(|r| {
r.relation == relation
&& endpoint_matches(&r.source, src_sub, src_kind)
&& endpoint_matches(&r.target, tgt_sub, tgt_kind)
})
}
fn base_entity_rule_allows(src_kind: &str, relation: EdgeRelation, tgt_kind: &str) -> bool {
const RULES: &[(&str, EdgeRelation, &str)] = &[
("concept", EdgeRelation::Contains, "concept"),
("project", EdgeRelation::Contains, "project"),
("project", EdgeRelation::Contains, "artifact"),
("org", EdgeRelation::Contains, "project"),
("org", EdgeRelation::Contains, "service"),
("concept", EdgeRelation::PartOf, "concept"),
("project", EdgeRelation::PartOf, "project"),
("project", EdgeRelation::PartOf, "org"),
("*", EdgeRelation::InstanceOf, "concept"),
("service", EdgeRelation::InstanceOf, "project"),
("concept", EdgeRelation::Extends, "concept"),
("concept", EdgeRelation::VariantOf, "concept"),
("artifact", EdgeRelation::VariantOf, "artifact"),
("concept", EdgeRelation::IntroducedBy, "document"),
("concept", EdgeRelation::IntroducedBy, "person"),
("artifact", EdgeRelation::IntroducedBy, "document"),
("artifact", EdgeRelation::DerivedFrom, "dataset"),
("artifact", EdgeRelation::DerivedFrom, "document"),
("artifact", EdgeRelation::DerivedFrom, "project"),
("artifact", EdgeRelation::DerivedFrom, "artifact"),
("document", EdgeRelation::Precedes, "document"),
("dataset", EdgeRelation::Precedes, "dataset"),
("artifact", EdgeRelation::Precedes, "artifact"),
("service", EdgeRelation::Precedes, "service"),
("project", EdgeRelation::Precedes, "project"),
("project", EdgeRelation::DependsOn, "project"),
("service", EdgeRelation::DependsOn, "project"),
("service", EdgeRelation::DependsOn, "service"),
("service", EdgeRelation::DependsOn, "artifact"),
("service", EdgeRelation::DependsOn, "dataset"),
("artifact", EdgeRelation::DependsOn, "project"),
("artifact", EdgeRelation::DependsOn, "service"),
("concept", EdgeRelation::Enables, "concept"),
("service", EdgeRelation::Enables, "concept"),
("dataset", EdgeRelation::Enables, "concept"),
("project", EdgeRelation::Implements, "concept"),
("service", EdgeRelation::Implements, "concept"),
("concept", EdgeRelation::CompetesWith, "concept"),
("project", EdgeRelation::CompetesWith, "project"),
("service", EdgeRelation::CompetesWith, "service"),
("concept", EdgeRelation::ComposedWith, "concept"),
("project", EdgeRelation::ComposedWith, "project"),
("concept", EdgeRelation::Supersedes, "concept"),
("document", EdgeRelation::Supersedes, "document"),
("artifact", EdgeRelation::Supersedes, "artifact"),
("service", EdgeRelation::Supersedes, "service"),
("dataset", EdgeRelation::Supersedes, "dataset"),
];
RULES.iter().any(|(src, rel, tgt)| {
*rel == relation && (*src == "*" || *src == src_kind) && *tgt == tgt_kind
})
}
fn canonical_edge_endpoints(
relation: EdgeRelation,
source_id: Uuid,
target_id: Uuid,
) -> (Uuid, Uuid) {
if relation.is_symmetric() && target_id < source_id {
(target_id, source_id)
} else {
(source_id, target_id)
}
}
fn infer_dependency_kind(src_kind: &str, tgt_kind: &str) -> Option<&'static str> {
match (src_kind, tgt_kind) {
("project", "project") => Some("build"),
("service", "service") => Some("runtime"),
("service", "dataset") => Some("data"),
("service", "artifact") => Some("artifact"),
("artifact", "project") | ("artifact", "service") => Some("tooling"),
_ => None,
}
}
fn merge_dependency_kind(
src_kind: &str,
tgt_kind: &str,
metadata: Option<serde_json::Value>,
) -> Option<serde_json::Value> {
if let Some(ref m) = metadata {
if m.get("dependency_kind").is_some() {
return metadata;
}
}
let inferred = infer_dependency_kind(src_kind, tgt_kind)?;
let mut obj = metadata.unwrap_or_else(|| serde_json::json!({}));
if let Some(o) = obj.as_object_mut() {
o.insert("dependency_kind".to_string(), serde_json::json!(inferred));
}
Some(obj)
}
const VALID_DEPENDENCY_KINDS: &[&str] = &["build", "runtime", "data", "artifact", "tooling"];
fn validate_edge_metadata(
relation: EdgeRelation,
metadata: Option<&serde_json::Value>,
) -> RuntimeResult<()> {
let Some(meta) = metadata else {
return Ok(());
};
if let Some(dk) = meta.get("dependency_kind") {
if relation != EdgeRelation::DependsOn {
return Err(RuntimeError::InvalidInput(format!(
"dependency_kind is only valid on depends_on edges (got {})",
relation.as_str()
)));
}
let dk_str = dk
.as_str()
.ok_or_else(|| RuntimeError::InvalidInput("dependency_kind must be a string".into()))?;
if !VALID_DEPENDENCY_KINDS.contains(&dk_str) {
return Err(RuntimeError::InvalidInput(format!(
"unknown dependency_kind {dk_str:?}; valid: {}",
VALID_DEPENDENCY_KINDS.join(" | ")
)));
}
}
Ok(())
}
impl KhiveRuntime {
#[allow(clippy::too_many_arguments)]
pub async fn create_entity(
&self,
token: &NamespaceToken,
kind: &str,
entity_type: Option<&str>,
name: &str,
description: Option<&str>,
properties: Option<serde_json::Value>,
tags: Vec<String>,
) -> RuntimeResult<Entity> {
let ns = token.namespace().as_str();
let mut entity = Entity::new(ns, kind, name).with_entity_type(entity_type);
if let Some(d) = description {
entity = entity.with_description(d);
}
if let Some(p) = properties {
entity = entity.with_properties(p);
}
if !tags.is_empty() {
entity = entity.with_tags(tags);
}
self.entities(token)?.upsert_entity(entity.clone()).await?;
let body = match &entity.description {
Some(d) if !d.is_empty() => format!("{} {}", entity.name, d),
_ => entity.name.clone(),
};
self.text(token)?
.upsert_document(TextDocument {
subject_id: entity.id,
kind: SubstrateKind::Entity,
title: Some(entity.name.clone()),
body: body.clone(),
tags: entity.tags.clone(),
namespace: ns.to_string(),
metadata: entity.properties.clone(),
updated_at: chrono::Utc::now(),
})
.await?;
if self.config().embedding_model.is_some() {
let vector = self.embed(&body).await?;
self.vectors(token)?
.insert(
entity.id,
SubstrateKind::Entity,
ns,
"entity.body",
vec![vector],
)
.await?;
}
Ok(entity)
}
pub async fn get_entity(&self, token: &NamespaceToken, id: Uuid) -> RuntimeResult<Entity> {
let entity = self
.entities(token)?
.get_entity(id)
.await?
.ok_or_else(|| RuntimeError::NotFound("not found in this namespace".into()))?;
self.ensure_namespace(&entity.namespace, token, id)?;
Ok(entity)
}
pub(crate) fn ensure_namespace(
&self,
actual: &str,
token: &NamespaceToken,
id: Uuid,
) -> RuntimeResult<()> {
if actual == token.namespace().as_str() {
return Ok(());
}
Err(RuntimeError::NamespaceMismatch { id })
}
pub async fn list_entities(
&self,
token: &NamespaceToken,
kind: Option<&str>,
entity_type: Option<&str>,
limit: u32,
offset: u32,
) -> RuntimeResult<Vec<Entity>> {
let filter = EntityFilter {
kinds: match kind {
Some(k) => vec![k.to_string()],
None => vec![],
},
entity_types: match entity_type {
Some(t) => vec![t.to_string()],
None => vec![],
},
..Default::default()
};
let page = self
.entities(token)?
.query_entities(
token.namespace().as_str(),
filter,
PageRequest {
offset: offset.into(),
limit,
},
)
.await?;
Ok(page.items)
}
pub async fn list_events(
&self,
token: &NamespaceToken,
filter: EventFilter,
page: PageRequest,
) -> RuntimeResult<Page<Event>> {
self.events(token)?
.query_events(filter, page)
.await
.map_err(Into::into)
}
async fn validate_edge_relation_endpoints(
&self,
token: &NamespaceToken,
source_id: Uuid,
target_id: Uuid,
relation: EdgeRelation,
) -> RuntimeResult<()> {
if relation == EdgeRelation::Annotates {
match self.resolve(token, source_id).await? {
Some(Resolved::Note(_)) => {}
Some(_) => {
return Err(RuntimeError::InvalidInput(format!(
"annotates source {source_id} must be a note"
)));
}
None => {
if self.get_edge(token, source_id).await?.is_some() {
return Err(RuntimeError::InvalidInput(format!(
"annotates source {source_id} must be a note"
)));
}
return Err(RuntimeError::NotFound(format!(
"link source {source_id} not found in namespace"
)));
}
}
if !self.substrate_exists_in_ns(token, target_id).await? {
return Err(RuntimeError::NotFound(format!(
"link target {target_id} not found in namespace"
)));
}
} else if relation == EdgeRelation::Supersedes {
let src = match self.resolve(token, source_id).await? {
Some(r) => r,
None => {
if self.get_edge(token, source_id).await?.is_some() {
return Err(RuntimeError::InvalidInput(format!(
"supersedes source {source_id} must be a note or entity (got edge)"
)));
}
return Err(RuntimeError::NotFound(format!(
"link source {source_id} not found in namespace"
)));
}
};
let tgt = match self.resolve(token, target_id).await? {
Some(r) => r,
None => {
if self.get_edge(token, target_id).await?.is_some() {
return Err(RuntimeError::InvalidInput(format!(
"supersedes target {target_id} must be a note or entity (got edge)"
)));
}
return Err(RuntimeError::NotFound(format!(
"link target {target_id} not found in namespace"
)));
}
};
match (&src, &tgt) {
(Resolved::Entity(src_e), Resolved::Entity(tgt_e)) => {
if !base_entity_rule_allows(&src_e.kind, EdgeRelation::Supersedes, &tgt_e.kind)
{
return Err(RuntimeError::InvalidInput(format!(
"({}) -[supersedes]-> ({}) is not in the ADR-002 base endpoint \
allowlist; supersedes requires same-kind entity endpoints",
src_e.kind, tgt_e.kind
)));
}
}
(Resolved::Note(_), Resolved::Note(_)) => {}
(Resolved::Event(_), _) => {
return Err(RuntimeError::InvalidInput(format!(
"supersedes does not apply to events; source {source_id} is an event"
)));
}
(_, Resolved::Event(_)) => {
return Err(RuntimeError::InvalidInput(format!(
"supersedes does not apply to events; target {target_id} is an event"
)));
}
(Resolved::Entity(_), Resolved::Note(_)) => {
return Err(RuntimeError::InvalidInput(format!(
"supersedes endpoints must be the same substrate (note→note or entity→entity); \
got source={source_id} (entity) target={target_id} (note)"
)));
}
(Resolved::Note(_), Resolved::Entity(_)) => {
return Err(RuntimeError::InvalidInput(format!(
"supersedes endpoints must be the same substrate (note→note or entity→entity); \
got source={source_id} (note) target={target_id} (entity)"
)));
}
}
} else {
let src_res = self.resolve(token, source_id).await?;
let tgt_res = self.resolve(token, target_id).await?;
if pack_rule_allows(
&self.pack_edge_rules(),
relation,
src_res.as_ref(),
tgt_res.as_ref(),
) {
return Ok(());
}
let src_kind = match src_res {
Some(Resolved::Entity(e)) => e.kind,
Some(_) => {
return Err(RuntimeError::InvalidInput(format!(
"link source {source_id} must be an entity for relation {relation:?} \
(ADR-002: only `annotates` crosses substrates)"
)));
}
None => {
if self.get_edge(token, source_id).await?.is_some() {
return Err(RuntimeError::InvalidInput(format!(
"link source {source_id} must be an entity for relation {relation:?} \
(ADR-002: only `annotates` crosses substrates)"
)));
}
return Err(RuntimeError::NotFound(format!(
"link source {source_id} not found in namespace"
)));
}
};
let tgt_kind = match tgt_res {
Some(Resolved::Entity(e)) => e.kind,
Some(_) => {
return Err(RuntimeError::InvalidInput(format!(
"link target {target_id} must be an entity for relation {relation:?} \
(ADR-002: only `annotates` crosses substrates)"
)));
}
None => {
if self.get_edge(token, target_id).await?.is_some() {
return Err(RuntimeError::InvalidInput(format!(
"link target {target_id} must be an entity for relation {relation:?} \
(ADR-002: only `annotates` crosses substrates)"
)));
}
return Err(RuntimeError::NotFound(format!(
"link target {target_id} not found in namespace"
)));
}
};
if !base_entity_rule_allows(&src_kind, relation, &tgt_kind) {
return Err(RuntimeError::InvalidInput(format!(
"({src_kind}) -[{}]-> ({tgt_kind}) is not in the ADR-002 base endpoint \
allowlist; use pack EDGE_RULES to extend the allowlist",
relation.as_str()
)));
}
}
Ok(())
}
pub async fn link(
&self,
token: &NamespaceToken,
source_id: Uuid,
target_id: Uuid,
relation: EdgeRelation,
weight: f64,
metadata: Option<serde_json::Value>,
) -> RuntimeResult<Edge> {
self.validate_edge_relation_endpoints(token, source_id, target_id, relation)
.await?;
let (source_id, target_id) = canonical_edge_endpoints(relation, source_id, target_id);
let metadata = if relation == EdgeRelation::DependsOn {
match (
self.resolve(token, source_id).await?,
self.resolve(token, target_id).await?,
) {
(Some(Resolved::Entity(src_e)), Some(Resolved::Entity(tgt_e))) => {
merge_dependency_kind(&src_e.kind, &tgt_e.kind, metadata)
}
_ => metadata,
}
} else {
metadata
};
validate_edge_metadata(relation, metadata.as_ref())?;
let now = chrono::Utc::now();
let ns = token.namespace().as_str();
let edge = Edge {
id: LinkId::from(Uuid::new_v4()),
namespace: ns.to_string(),
source_id,
target_id,
relation,
weight,
created_at: now,
updated_at: now,
deleted_at: None,
metadata,
target_backend: None,
};
self.graph(token)?.upsert_edge(edge.clone()).await?;
Ok(edge)
}
async fn substrate_exists_in_ns(
&self,
token: &NamespaceToken,
id: Uuid,
) -> RuntimeResult<bool> {
if self.resolve(token, id).await?.is_some() {
return Ok(true);
}
Ok(self.get_edge(token, id).await?.is_some())
}
pub async fn neighbors(
&self,
token: &NamespaceToken,
node_id: Uuid,
direction: Direction,
limit: Option<u32>,
relations: Option<Vec<EdgeRelation>>,
) -> RuntimeResult<Vec<NeighborHit>> {
self.neighbors_with_query(
token,
node_id,
NeighborQuery {
direction,
relations,
limit,
min_weight: None,
},
)
.await
}
pub async fn neighbors_with_query(
&self,
token: &NamespaceToken,
node_id: Uuid,
mut query: NeighborQuery,
) -> RuntimeResult<Vec<NeighborHit>> {
query.direction =
normalize_symmetric_direction(query.direction, query.relations.as_deref());
let mut hits = self.graph(token)?.neighbors(node_id, query).await?;
self.enrich_neighbor_hits(token, &mut hits).await;
Ok(hits)
}
pub async fn traverse(
&self,
token: &NamespaceToken,
request: TraversalRequest,
) -> RuntimeResult<Vec<GraphPath>> {
let mut paths = self.graph(token)?.traverse(request).await?;
self.enrich_path_nodes(token, &mut paths).await;
Ok(paths)
}
async fn enrich_neighbor_hits(&self, token: &NamespaceToken, hits: &mut [NeighborHit]) {
if hits.is_empty() {
return;
}
let store = match self.entities(token) {
Ok(s) => s,
Err(_) => return, };
for hit in hits.iter_mut() {
if let Ok(Some(entity)) = store.get_entity(hit.node_id).await {
hit.name = Some(entity.name);
hit.kind = Some(entity.kind);
}
}
}
async fn enrich_path_nodes(&self, token: &NamespaceToken, paths: &mut [GraphPath]) {
if paths.is_empty() {
return;
}
let store = match self.entities(token) {
Ok(s) => s,
Err(_) => return,
};
for path in paths.iter_mut() {
for node in path.nodes.iter_mut() {
if let Ok(Some(entity)) = store.get_entity(node.node_id).await {
node.name = Some(entity.name);
node.kind = Some(entity.kind);
}
}
}
}
#[allow(clippy::too_many_arguments)]
pub async fn create_note(
&self,
token: &NamespaceToken,
kind: &str,
name: Option<&str>,
content: &str,
salience: Option<f64>,
properties: Option<serde_json::Value>,
annotates: Vec<Uuid>,
) -> RuntimeResult<Note> {
self.create_note_inner(
token, kind, name, content, salience, None, properties, annotates,
)
.await
}
#[allow(clippy::too_many_arguments)]
pub async fn create_note_with_decay(
&self,
token: &NamespaceToken,
kind: &str,
name: Option<&str>,
content: &str,
salience: Option<f64>,
decay_factor: f64,
properties: Option<serde_json::Value>,
annotates: Vec<Uuid>,
) -> RuntimeResult<Note> {
self.create_note_inner(
token,
kind,
name,
content,
salience,
Some(decay_factor),
properties,
annotates,
)
.await
}
#[allow(clippy::too_many_arguments)]
async fn create_note_inner(
&self,
token: &NamespaceToken,
kind: &str,
name: Option<&str>,
content: &str,
salience: Option<f64>,
decay_factor: Option<f64>,
properties: Option<serde_json::Value>,
annotates: Vec<Uuid>,
) -> RuntimeResult<Note> {
let ns = token.namespace().as_str();
for &target_id in &annotates {
if !self.substrate_exists_in_ns(token, target_id).await? {
return Err(RuntimeError::NotFound(format!(
"create_note annotates target {target_id} not found in namespace"
)));
}
}
let mut note = Note::new(ns, kind, content);
if let Some(s) = salience {
note = note.with_salience(s);
}
if let Some(df) = decay_factor {
note = note.with_decay(df);
}
if let Some(n) = name {
note = note.with_name(n);
}
if let Some(p) = properties {
note = note.with_properties(p);
}
self.notes(token)?.upsert_note(note.clone()).await?;
let body = match ¬e.name {
Some(n) => format!("{n} {}", note.content),
None => note.content.clone(),
};
self.text_for_notes(token)?
.upsert_document(TextDocument {
subject_id: note.id,
kind: SubstrateKind::Note,
title: note.name.clone(),
body,
tags: vec![],
namespace: ns.to_string(),
metadata: note.properties.clone(),
updated_at: chrono::Utc::now(),
})
.await?;
if self.config().embedding_model.is_some() {
let vector = self.embed(¬e.content).await?;
self.vectors(token)?
.insert(
note.id,
SubstrateKind::Note,
ns,
"note.content",
vec![vector],
)
.await?;
}
let mut created_edges: Vec<Uuid> = Vec::with_capacity(annotates.len());
#[cfg(test)]
let annotates_iter: Vec<(usize, Uuid)> = annotates
.iter()
.enumerate()
.map(|(i, &id)| (i, id))
.collect();
#[cfg(test)]
macro_rules! next_target {
($pair:expr) => {
$pair.1
};
}
#[cfg(not(test))]
let annotates_iter: Vec<Uuid> = annotates.to_vec();
#[cfg(not(test))]
macro_rules! next_target {
($pair:expr) => {
$pair
};
}
for pair in annotates_iter {
let target_id = next_target!(pair);
#[cfg(test)]
let injected_err: Option<RuntimeError> = {
let call_idx = pair.0;
LINK_FAIL_AFTER.with(|cell| {
let n = cell.get();
if n > 0 && call_idx + 1 == n {
cell.set(0); Some(RuntimeError::Internal("injected link failure".to_string()))
} else {
None
}
})
};
#[cfg(not(test))]
let injected_err: Option<RuntimeError> = None;
let link_result = if let Some(e) = injected_err {
Err(e)
} else {
self.link(
token,
note.id,
target_id,
EdgeRelation::Annotates,
1.0,
None,
)
.await
};
match link_result {
Ok(edge) => created_edges.push(edge.id.into()),
Err(e) => {
for edge_id in created_edges {
let _ = self.delete_edge(token, edge_id, true).await;
}
if let Ok(store) = self.notes(token) {
let _ = store.delete_note(note.id, DeleteMode::Hard).await;
}
if let Ok(fts) = self.text_for_notes(token) {
let _ = fts.delete_document(ns, note.id).await;
}
if self.config().embedding_model.is_some() {
if let Ok(vs) = self.vectors(token) {
let _ = vs.delete(note.id).await;
}
}
return Err(e);
}
}
}
Ok(note)
}
pub async fn list_notes(
&self,
token: &NamespaceToken,
kind: Option<&str>,
limit: u32,
offset: u32,
) -> RuntimeResult<Vec<Note>> {
let page = self
.notes(token)?
.query_notes(
token.namespace().as_str(),
kind,
PageRequest {
offset: offset.into(),
limit,
},
)
.await?;
Ok(page.items)
}
pub async fn search_notes(
&self,
token: &NamespaceToken,
query_text: &str,
query_vector: Option<Vec<f32>>,
limit: u32,
note_kind: Option<&str>,
include_superseded: bool,
) -> RuntimeResult<Vec<NoteSearchHit>> {
const RRF_K: usize = 60;
let candidates = limit.saturating_mul(4).max(limit);
let ns = token.namespace().as_str().to_owned();
let text_hits = self
.text_for_notes(token)?
.search(TextSearchRequest {
query: query_text.to_string(),
mode: TextQueryMode::Plain,
filter: Some(TextFilter {
namespaces: vec![ns.clone()],
..TextFilter::default()
}),
top_k: candidates,
snippet_chars: 200,
})
.await?;
let vector_hits = if query_vector.is_some() || self.config().embedding_model.is_some() {
self.vector_search(
token,
query_vector,
Some(query_text),
candidates,
Some(SubstrateKind::Note),
)
.await?
} else {
vec![]
};
#[derive(Default)]
struct Bucket {
score: DeterministicScore,
title: Option<String>,
snippet: Option<String>,
}
let mut buckets: HashMap<Uuid, Bucket> = HashMap::new();
for (i, hit) in text_hits.into_iter().enumerate() {
let rank = i + 1;
let entry = buckets.entry(hit.subject_id).or_default();
entry.score = entry.score + rrf_score(rank, RRF_K);
if entry.title.is_none() {
entry.title = hit.title;
}
if entry.snippet.is_none() {
entry.snippet = hit.snippet;
}
}
for (i, hit) in vector_hits.into_iter().enumerate() {
let rank = i + 1;
let entry = buckets.entry(hit.subject_id).or_default();
entry.score = entry.score + rrf_score(rank, RRF_K);
}
let candidate_ids: Vec<Uuid> = buckets.keys().copied().collect();
if candidate_ids.is_empty() {
return Ok(vec![]);
}
let note_store = self.notes(token)?;
let mut alive_notes: HashMap<Uuid, Note> = HashMap::new();
for id in &candidate_ids {
if let Some(note) = note_store.get_note(*id).await? {
if note.deleted_at.is_some() {
continue;
}
if let Some(want_kind) = note_kind {
if note.kind != want_kind {
continue;
}
}
alive_notes.insert(*id, note);
}
}
if !include_superseded && !alive_notes.is_empty() {
let graph = self.graph(token)?;
let mut superseded: std::collections::HashSet<Uuid> = std::collections::HashSet::new();
for ¬e_id in alive_notes.keys() {
let inbound = graph
.neighbors(
note_id,
NeighborQuery {
direction: Direction::In,
relations: Some(vec![EdgeRelation::Supersedes]),
limit: Some(1),
min_weight: None,
},
)
.await?;
if !inbound.is_empty() {
superseded.insert(note_id);
}
}
alive_notes.retain(|id, _| !superseded.contains(id));
}
let mut hits: Vec<NoteSearchHit> = buckets
.into_iter()
.filter_map(|(id, bucket)| {
let note = alive_notes.get(&id)?;
let salience = note.salience.unwrap_or(0.5);
let weight = 0.5 + 0.5 * salience;
let weighted = DeterministicScore::from_f64(bucket.score.to_f64() * weight);
Some(NoteSearchHit {
note_id: id,
score: weighted,
title: bucket.title.or_else(|| note_title(note)),
snippet: bucket.snippet.or_else(|| note_snippet(note)),
})
})
.collect();
hits.sort_by(|a, b| b.score.cmp(&a.score).then(a.note_id.cmp(&b.note_id)));
hits.truncate(limit as usize);
Ok(hits)
}
pub async fn resolve_prefix(
&self,
token: &NamespaceToken,
prefix: &str,
) -> RuntimeResult<Option<Uuid>> {
use khive_storage::types::{SqlStatement, SqlValue};
let ns = token.namespace().as_str().to_owned();
let pattern = format!("{}%", prefix);
let tables = [
("entities", true),
("notes", true),
("events", false),
("graph_edges", false),
];
let mut matches: Vec<String> = Vec::new();
let mut reader = self.sql().reader().await.map_err(RuntimeError::Storage)?;
for (table, has_deleted_at) in tables {
let deleted_filter = if has_deleted_at {
" AND deleted_at IS NULL"
} else {
""
};
let sql = SqlStatement {
sql: format!(
"SELECT id FROM {table} WHERE id LIKE ?1 AND namespace = ?2{deleted_filter} LIMIT 2"
),
params: vec![
SqlValue::Text(pattern.clone()),
SqlValue::Text(ns.clone()),
],
label: Some("resolve_prefix".into()),
};
match reader.query_all(sql).await {
Ok(rows) => {
for row in rows {
if let Some(col) = row.columns.first() {
if let SqlValue::Text(s) = &col.value {
matches.push(s.clone());
}
}
}
}
Err(e) => {
let msg = e.to_string();
if msg.contains("no such table") {
continue;
}
return Err(RuntimeError::Storage(e));
}
}
if matches.len() > 1 {
break;
}
}
match matches.len() {
0 => Ok(None),
1 => {
let uuid = Uuid::from_str(&matches[0])
.map_err(|e| RuntimeError::Internal(format!("stored UUID is invalid: {e}")))?;
Ok(Some(uuid))
}
_ => {
let uuids: Vec<uuid::Uuid> = matches
.iter()
.filter_map(|s| Uuid::from_str(s).ok())
.collect();
Err(RuntimeError::AmbiguousPrefix {
prefix: prefix.to_string(),
matches: uuids,
})
}
}
}
pub async fn resolve(
&self,
token: &NamespaceToken,
id: Uuid,
) -> RuntimeResult<Option<Resolved>> {
let ns = token.namespace().as_str();
match self.get_entity(token, id).await {
Ok(entity) => return Ok(Some(Resolved::Entity(entity))),
Err(RuntimeError::NotFound(_) | RuntimeError::NamespaceMismatch { .. }) => {}
Err(e) => return Err(e),
}
if let Some(note) = self.notes(token)?.get_note(id).await? {
if note.namespace == ns {
return Ok(Some(Resolved::Note(note)));
}
}
if let Some(event) = self.events(token)?.get_event(id).await? {
if event.namespace == ns {
return Ok(Some(Resolved::Event(event)));
}
}
Ok(None)
}
pub async fn delete_note(
&self,
token: &NamespaceToken,
id: Uuid,
hard: bool,
) -> RuntimeResult<bool> {
let ns = token.namespace().as_str();
let note_store = self.notes(token)?;
let note = match note_store.get_note(id).await? {
Some(n) => n,
None => return Ok(false),
};
if note.namespace != ns {
return Err(RuntimeError::NamespaceMismatch { id });
}
let mode = if hard {
DeleteMode::Hard
} else {
DeleteMode::Soft
};
if hard {
let graph = self.graph(token)?;
for direction in [Direction::Out, Direction::In] {
let hits = graph
.neighbors(
id,
NeighborQuery {
direction,
relations: None,
limit: None,
min_weight: None,
},
)
.await?;
for hit in hits {
graph
.delete_edge(LinkId::from(hit.edge_id), DeleteMode::Hard)
.await?;
}
}
let ns_str = ns.to_string();
self.text_for_notes(token)?
.delete_document(&ns_str, id)
.await?;
if self.config().embedding_model.is_some() {
self.vectors(token)?.delete(id).await?;
}
}
let deleted = note_store.delete_note(id, mode).await?;
if !hard && deleted {
let ns_str = ns.to_string();
self.text_for_notes(token)?
.delete_document(&ns_str, id)
.await?;
if self.config().embedding_model.is_some() {
self.vectors(token)?.delete(id).await?;
}
}
if deleted {
let event_store = self.events(token)?;
let ns_str = ns.to_string();
let event = khive_storage::event::Event::new(
ns_str.clone(),
"delete",
EventKind::NoteDeleted,
SubstrateKind::Note,
"",
)
.with_target(id)
.with_payload(serde_json::json!({"id": id, "namespace": ns_str, "hard": hard}));
event_store.append_event(event).await.map_err(|e| {
RuntimeError::Internal(format!("delete_note: event store write failed: {e}"))
})?;
}
Ok(deleted)
}
}
#[derive(Clone, Debug, Serialize)]
pub struct QueryResult {
pub rows: Vec<SqlRow>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<String>,
}
impl KhiveRuntime {
pub async fn query(&self, token: &NamespaceToken, query: &str) -> RuntimeResult<Vec<SqlRow>> {
Ok(self.query_with_metadata(token, query).await?.rows)
}
pub async fn query_with_metadata(
&self,
token: &NamespaceToken,
query: &str,
) -> RuntimeResult<QueryResult> {
use khive_query::QueryValue;
use khive_storage::types::SqlValue;
let ns = token.namespace().as_str();
let ast = khive_query::parse_auto(query)?;
let opts = khive_query::CompileOptions {
scopes: vec![ns.to_string()],
..Default::default()
};
let compiled = khive_query::compile(&ast, &opts)?;
let warnings = compiled.warnings;
let params: Vec<SqlValue> = compiled
.params
.into_iter()
.map(|qv| match qv {
QueryValue::Null => SqlValue::Null,
QueryValue::Integer(n) => SqlValue::Integer(n),
QueryValue::Float(f) => SqlValue::Float(f),
QueryValue::Text(s) => SqlValue::Text(s),
QueryValue::Blob(b) => SqlValue::Blob(b),
})
.collect();
let mut reader = self.sql().reader().await?;
let stmt = SqlStatement {
sql: compiled.sql,
params,
label: None,
};
let rows = reader.query_all(stmt).await?;
Ok(QueryResult { rows, warnings })
}
pub async fn delete_entity(
&self,
token: &NamespaceToken,
id: Uuid,
hard: bool,
) -> RuntimeResult<bool> {
let entity = match self.entities(token)?.get_entity(id).await? {
Some(e) => e,
None => return Ok(false),
};
self.ensure_namespace(&entity.namespace, token, id)?;
let mode = if hard {
DeleteMode::Hard
} else {
DeleteMode::Soft
};
if hard {
let graph = self.graph(token)?;
for direction in [Direction::Out, Direction::In] {
let hits = graph
.neighbors(
id,
NeighborQuery {
direction,
relations: None,
limit: None,
min_weight: None,
},
)
.await?;
for hit in hits {
graph
.delete_edge(LinkId::from(hit.edge_id), DeleteMode::Hard)
.await?;
}
}
self.remove_from_indexes(token, id).await?;
}
let deleted = self.entities(token)?.delete_entity(id, mode).await?;
if !hard && deleted {
self.remove_from_indexes(token, id).await?;
}
if deleted {
let event_store = self.events(token)?;
let ns = entity.namespace.clone();
let event = khive_storage::event::Event::new(
ns.clone(),
"delete",
EventKind::EntityDeleted,
SubstrateKind::Entity,
"",
)
.with_target(id)
.with_payload(serde_json::json!({"id": id, "namespace": ns, "hard": hard}));
event_store.append_event(event).await.map_err(|e| {
RuntimeError::Internal(format!("delete_entity: event store write failed: {e}"))
})?;
}
Ok(deleted)
}
pub async fn count_entities(
&self,
token: &NamespaceToken,
kind: Option<&str>,
) -> RuntimeResult<u64> {
let filter = EntityFilter {
kinds: match kind {
Some(k) => vec![k.to_string()],
None => vec![],
},
..Default::default()
};
Ok(self
.entities(token)?
.count_entities(token.namespace().as_str(), filter)
.await?)
}
pub async fn get_edge(
&self,
token: &NamespaceToken,
edge_id: Uuid,
) -> RuntimeResult<Option<Edge>> {
Ok(self.graph(token)?.get_edge(LinkId::from(edge_id)).await?)
}
pub async fn list_edges(
&self,
token: &NamespaceToken,
filter: crate::curation::EdgeListFilter,
limit: u32,
) -> RuntimeResult<Vec<Edge>> {
let limit = limit.clamp(1, 1000);
let page = self
.graph(token)?
.query_edges(
filter.into(),
vec![SortOrder {
field: EdgeSortField::CreatedAt,
direction: khive_storage::types::SortDirection::Asc,
}],
PageRequest { offset: 0, limit },
)
.await?;
Ok(page.items)
}
pub async fn update_edge(
&self,
token: &NamespaceToken,
edge_id: Uuid,
patch: crate::curation::EdgePatch,
) -> RuntimeResult<Edge> {
let graph = self.graph(token)?;
let mut edge = graph
.get_edge(LinkId::from(edge_id))
.await?
.ok_or_else(|| crate::RuntimeError::NotFound(format!("edge {edge_id}")))?;
let mut changed_fields: Vec<&'static str> = Vec::new();
if let Some(r) = patch.relation {
self.validate_edge_relation_endpoints(token, edge.source_id, edge.target_id, r)
.await?;
edge.relation = r;
changed_fields.push("relation");
}
if let Some(w) = patch.weight {
edge.weight = w.clamp(0.0, 1.0);
changed_fields.push("weight");
}
if let Some(props) = patch.properties {
edge.metadata = Some(props);
}
graph.upsert_edge(edge.clone()).await?;
let event_store = self.events(token)?;
let ns = token.namespace().as_str().to_string();
let event = khive_storage::event::Event::new(
ns.clone(),
"update",
EventKind::EdgeUpdated,
SubstrateKind::Entity,
"",
)
.with_target(edge_id)
.with_payload(
serde_json::json!({"id": edge_id, "namespace": ns, "changed_fields": changed_fields}),
);
event_store.append_event(event).await.map_err(|e| {
RuntimeError::Internal(format!("update_edge: event store write failed: {e}"))
})?;
Ok(edge)
}
pub async fn delete_edge(
&self,
token: &NamespaceToken,
edge_id: Uuid,
hard: bool,
) -> RuntimeResult<bool> {
let graph = self.graph(token)?;
let mode = if hard {
DeleteMode::Hard
} else {
DeleteMode::Soft
};
if graph.get_edge(LinkId::from(edge_id)).await?.is_none() {
return Ok(false);
}
let inbound = graph
.neighbors(
edge_id,
NeighborQuery {
direction: Direction::In,
relations: None,
limit: None,
min_weight: None,
},
)
.await?;
for hit in inbound {
graph
.delete_edge(LinkId::from(hit.edge_id), DeleteMode::Hard)
.await?;
}
let deleted = graph.delete_edge(LinkId::from(edge_id), mode).await?;
if deleted {
let event_store = self.events(token)?;
let ns = token.namespace().as_str().to_string();
let event = khive_storage::event::Event::new(
ns.clone(),
"delete",
EventKind::EdgeDeleted,
SubstrateKind::Entity,
"",
)
.with_target(edge_id)
.with_payload(serde_json::json!({"id": edge_id, "namespace": ns, "hard": hard}));
event_store.append_event(event).await.map_err(|e| {
RuntimeError::Internal(format!("delete_edge: event store write failed: {e}"))
})?;
}
Ok(deleted)
}
pub async fn count_edges(
&self,
token: &NamespaceToken,
filter: crate::curation::EdgeListFilter,
) -> RuntimeResult<u64> {
Ok(self.graph(token)?.count_edges(filter.into()).await?)
}
pub async fn build_edge(&self, token: &NamespaceToken, spec: &LinkSpec) -> RuntimeResult<Edge> {
let ns_str = match &spec.namespace {
Some(s) => {
let spec_ns = crate::Namespace::parse(s)
.map_err(|e| RuntimeError::InvalidInput(format!("invalid namespace: {e}")))?;
if &spec_ns != token.namespace() {
return Err(RuntimeError::InvalidInput(
"LinkSpec namespace does not match token namespace".into(),
));
}
s.as_str()
}
None => token.namespace().as_str(),
};
self.validate_edge_relation_endpoints(token, spec.source_id, spec.target_id, spec.relation)
.await?;
let (source_id, target_id) =
canonical_edge_endpoints(spec.relation, spec.source_id, spec.target_id);
let metadata = if spec.relation == EdgeRelation::DependsOn {
match (
self.resolve(token, source_id).await?,
self.resolve(token, target_id).await?,
) {
(Some(Resolved::Entity(src_e)), Some(Resolved::Entity(tgt_e))) => {
merge_dependency_kind(&src_e.kind, &tgt_e.kind, spec.metadata.clone())
}
_ => spec.metadata.clone(),
}
} else {
spec.metadata.clone()
};
validate_edge_metadata(spec.relation, metadata.as_ref())?;
let now = chrono::Utc::now();
Ok(Edge {
id: LinkId::from(Uuid::new_v4()),
namespace: ns_str.to_string(),
source_id,
target_id,
relation: spec.relation,
weight: spec.weight,
created_at: now,
updated_at: now,
deleted_at: None,
metadata,
target_backend: None,
})
}
pub async fn link_many(
&self,
token: &NamespaceToken,
specs: Vec<LinkSpec>,
) -> RuntimeResult<Vec<Edge>> {
if specs.is_empty() {
return Ok(vec![]);
}
let mut edges = Vec::with_capacity(specs.len());
for spec in &specs {
edges.push(self.build_edge(token, spec).await?);
}
self.graph(token)?.upsert_edges(edges.clone()).await?;
Ok(edges)
}
}
#[derive(Clone, Debug)]
pub struct LinkSpec {
pub namespace: Option<String>,
pub source_id: Uuid,
pub target_id: Uuid,
pub relation: EdgeRelation,
pub weight: f64,
pub metadata: Option<serde_json::Value>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::curation::EdgeListFilter;
use crate::runtime::{KhiveRuntime, NamespaceToken};
use crate::Namespace;
fn rt() -> KhiveRuntime {
KhiveRuntime::memory().unwrap()
}
#[tokio::test]
async fn update_edge_changes_weight() {
let rt = rt();
let tok = NamespaceToken::local();
let a = rt
.create_entity(&tok, "concept", None, "A", None, None, vec![])
.await
.unwrap();
let b = rt
.create_entity(&tok, "concept", None, "B", None, None, vec![])
.await
.unwrap();
let edge = rt
.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
.await
.unwrap();
let edge_id: Uuid = edge.id.into();
let updated = rt
.update_edge(
&tok,
edge_id,
crate::curation::EdgePatch {
weight: Some(0.5),
..Default::default()
},
)
.await
.unwrap();
assert!((updated.weight - 0.5).abs() < 0.001);
}
#[tokio::test]
async fn update_edge_changes_relation() {
let rt = rt();
let tok = NamespaceToken::local();
let a = rt
.create_entity(&tok, "concept", None, "A", None, None, vec![])
.await
.unwrap();
let b = rt
.create_entity(&tok, "concept", None, "B", None, None, vec![])
.await
.unwrap();
let edge = rt
.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
.await
.unwrap();
let edge_id: Uuid = edge.id.into();
let updated = rt
.update_edge(
&tok,
edge_id,
crate::curation::EdgePatch {
relation: Some(EdgeRelation::VariantOf),
..Default::default()
},
)
.await
.unwrap();
assert_eq!(updated.relation, EdgeRelation::VariantOf);
}
#[tokio::test]
async fn update_edge_annotates_note_to_entity_set_supersedes_returns_invalid_input() {
let rt = rt();
let tok = NamespaceToken::local();
let note = rt
.create_note(&tok, "observation", None, "a note", Some(0.5), None, vec![])
.await
.unwrap();
let entity = rt
.create_entity(&tok, "concept", None, "E", None, None, vec![])
.await
.unwrap();
let edge = rt
.link(&tok, note.id, entity.id, EdgeRelation::Annotates, 1.0, None)
.await
.unwrap();
let edge_id: Uuid = edge.id.into();
let result = rt
.update_edge(
&tok,
edge_id,
crate::curation::EdgePatch {
relation: Some(EdgeRelation::Supersedes),
..Default::default()
},
)
.await;
assert!(
matches!(result, Err(RuntimeError::InvalidInput(_))),
"update to Supersedes on note→entity edge must return InvalidInput, got {result:?}"
);
let fetched = rt.get_edge(&tok, edge_id).await.unwrap().unwrap();
assert_eq!(
fetched.relation,
EdgeRelation::Annotates,
"edge relation must be unchanged after failed update"
);
}
#[tokio::test]
async fn update_edge_entity_to_entity_set_annotates_returns_invalid_input() {
let rt = rt();
let tok = NamespaceToken::local();
let a = rt
.create_entity(&tok, "concept", None, "A", None, None, vec![])
.await
.unwrap();
let b = rt
.create_entity(&tok, "concept", None, "B", None, None, vec![])
.await
.unwrap();
let edge = rt
.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
.await
.unwrap();
let edge_id: Uuid = edge.id.into();
let result = rt
.update_edge(
&tok,
edge_id,
crate::curation::EdgePatch {
relation: Some(EdgeRelation::Annotates),
..Default::default()
},
)
.await;
assert!(
matches!(result, Err(RuntimeError::InvalidInput(_))),
"update to Annotates on entity→entity edge must return InvalidInput, got {result:?}"
);
}
#[tokio::test]
async fn update_edge_entity_to_entity_set_supersedes_succeeds() {
let rt = rt();
let tok = NamespaceToken::local();
let a = rt
.create_entity(&tok, "concept", None, "A", None, None, vec![])
.await
.unwrap();
let b = rt
.create_entity(&tok, "concept", None, "B", None, None, vec![])
.await
.unwrap();
let edge = rt
.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
.await
.unwrap();
let edge_id: Uuid = edge.id.into();
let updated = rt
.update_edge(
&tok,
edge_id,
crate::curation::EdgePatch {
relation: Some(EdgeRelation::Supersedes),
..Default::default()
},
)
.await
.unwrap();
assert_eq!(updated.relation, EdgeRelation::Supersedes);
let fetched = rt.get_edge(&tok, edge_id).await.unwrap().unwrap();
assert_eq!(fetched.relation, EdgeRelation::Supersedes);
}
#[tokio::test]
async fn update_edge_weight_only_skips_validation() {
let rt = rt();
let tok = NamespaceToken::local();
let a = rt
.create_entity(&tok, "concept", None, "A", None, None, vec![])
.await
.unwrap();
let b = rt
.create_entity(&tok, "concept", None, "B", None, None, vec![])
.await
.unwrap();
let edge = rt
.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
.await
.unwrap();
let edge_id: Uuid = edge.id.into();
let updated = rt
.update_edge(
&tok,
edge_id,
crate::curation::EdgePatch {
weight: Some(0.3),
..Default::default()
},
)
.await
.unwrap();
assert_eq!(updated.relation, EdgeRelation::Extends);
assert!((updated.weight - 0.3).abs() < 0.001);
}
#[tokio::test]
async fn update_edge_same_class_relation_change_succeeds() {
let rt = rt();
let tok = NamespaceToken::local();
let a = rt
.create_entity(&tok, "concept", None, "A", None, None, vec![])
.await
.unwrap();
let b = rt
.create_entity(&tok, "concept", None, "B", None, None, vec![])
.await
.unwrap();
let edge = rt
.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
.await
.unwrap();
let edge_id: Uuid = edge.id.into();
let updated = rt
.update_edge(
&tok,
edge_id,
crate::curation::EdgePatch {
relation: Some(EdgeRelation::VariantOf),
..Default::default()
},
)
.await
.unwrap();
assert_eq!(updated.relation, EdgeRelation::VariantOf);
}
#[tokio::test]
async fn list_edges_filters_by_relation() {
let rt = rt();
let tok = NamespaceToken::local();
let a = rt
.create_entity(&tok, "concept", None, "A", None, None, vec![])
.await
.unwrap();
let b = rt
.create_entity(&tok, "concept", None, "B", None, None, vec![])
.await
.unwrap();
let c = rt
.create_entity(&tok, "concept", None, "C", None, None, vec![])
.await
.unwrap();
rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
.await
.unwrap();
rt.link(&tok, a.id, c.id, EdgeRelation::Enables, 1.0, None)
.await
.unwrap();
let filter = EdgeListFilter {
relations: vec![EdgeRelation::Extends],
..Default::default()
};
let edges = rt.list_edges(&tok, filter, 100).await.unwrap();
assert_eq!(edges.len(), 1);
assert_eq!(edges[0].relation, EdgeRelation::Extends);
}
#[tokio::test]
async fn list_edges_filters_by_source() {
let rt = rt();
let tok = NamespaceToken::local();
let a = rt
.create_entity(&tok, "concept", None, "A", None, None, vec![])
.await
.unwrap();
let b = rt
.create_entity(&tok, "concept", None, "B", None, None, vec![])
.await
.unwrap();
let c = rt
.create_entity(&tok, "concept", None, "C", None, None, vec![])
.await
.unwrap();
let d = rt
.create_entity(&tok, "concept", None, "D", None, None, vec![])
.await
.unwrap();
rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
.await
.unwrap();
rt.link(&tok, c.id, d.id, EdgeRelation::Extends, 1.0, None)
.await
.unwrap();
let filter = EdgeListFilter {
source_id: Some(a.id),
..Default::default()
};
let edges = rt.list_edges(&tok, filter, 100).await.unwrap();
assert_eq!(edges.len(), 1);
let src: Uuid = edges[0].source_id;
assert_eq!(src, a.id);
}
#[tokio::test]
async fn delete_edge_removes_from_storage() {
let rt = rt();
let tok = NamespaceToken::local();
let a = rt
.create_entity(&tok, "concept", None, "A", None, None, vec![])
.await
.unwrap();
let b = rt
.create_entity(&tok, "concept", None, "B", None, None, vec![])
.await
.unwrap();
let edge = rt
.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
.await
.unwrap();
let edge_id: Uuid = edge.id.into();
let deleted = rt.delete_edge(&tok, edge_id, true).await.unwrap();
assert!(deleted);
let fetched = rt.get_edge(&tok, edge_id).await.unwrap();
assert!(fetched.is_none(), "edge should be gone after delete");
}
#[tokio::test]
async fn count_edges_matches_filter() {
let rt = rt();
let tok = NamespaceToken::local();
let a = rt
.create_entity(&tok, "concept", None, "A", None, None, vec![])
.await
.unwrap();
let b = rt
.create_entity(&tok, "concept", None, "B", None, None, vec![])
.await
.unwrap();
let c = rt
.create_entity(&tok, "concept", None, "C", None, None, vec![])
.await
.unwrap();
rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
.await
.unwrap();
rt.link(&tok, a.id, c.id, EdgeRelation::Enables, 1.0, None)
.await
.unwrap();
let all = rt
.count_edges(&tok, EdgeListFilter::default())
.await
.unwrap();
assert_eq!(all, 2);
let just_extends = rt
.count_edges(
&tok,
EdgeListFilter {
relations: vec![EdgeRelation::Extends],
..Default::default()
},
)
.await
.unwrap();
assert_eq!(just_extends, 1);
}
#[tokio::test]
async fn get_entity_namespace_isolation() {
let rt = rt();
let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
let entity = rt
.create_entity(&ns_a, "concept", None, "Alpha", None, None, vec![])
.await
.unwrap();
let found = rt.get_entity(&ns_a, entity.id).await;
assert!(found.is_ok(), "should be visible in its own namespace");
let not_found = rt.get_entity(&ns_b, entity.id).await;
assert!(
not_found.is_err(),
"should not be visible across namespaces"
);
assert!(
matches!(not_found.unwrap_err(), crate::RuntimeError::NamespaceMismatch { id } if id == entity.id),
"cross-namespace get must return NamespaceMismatch with the entity id"
);
}
#[tokio::test]
async fn namespace_mismatch_error_message_is_opaque() {
let rt = rt();
let ns_a = NamespaceToken::for_namespace(Namespace::parse("secret-ns").unwrap());
let ns_b = NamespaceToken::for_namespace(Namespace::parse("other-ns").unwrap());
let entity = rt
.create_entity(&ns_a, "concept", None, "Hidden", None, None, vec![])
.await
.unwrap();
let err = rt.get_entity(&ns_b, entity.id).await.unwrap_err();
let msg = err.to_string();
assert!(
!msg.contains("secret-ns"),
"error message must not leak the actual namespace; got: {msg}"
);
assert!(
!msg.contains("other-ns"),
"error message must not leak the requested namespace; got: {msg}"
);
}
#[tokio::test]
async fn delete_entity_namespace_isolation() {
let rt = rt();
let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
let entity = rt
.create_entity(&ns_a, "concept", None, "Beta", None, None, vec![])
.await
.unwrap();
let cross_ns_result = rt.delete_entity(&ns_b, entity.id, true).await;
assert!(
cross_ns_result.is_err(),
"cross-namespace delete must error"
);
assert!(
matches!(cross_ns_result.unwrap_err(), crate::RuntimeError::NamespaceMismatch { id } if id == entity.id),
"cross-namespace delete must return NamespaceMismatch, not a generic error"
);
let still_there = rt.get_entity(&ns_a, entity.id).await;
assert!(
still_there.is_ok(),
"entity must survive cross-ns delete attempt"
);
let deleted_ok = rt.delete_entity(&ns_a, entity.id, true).await.unwrap();
assert!(deleted_ok, "same-namespace delete must succeed");
}
#[tokio::test]
async fn create_note_indexes_into_fts5() {
let rt = rt();
let tok = NamespaceToken::local();
let note = rt
.create_note(
&tok,
"observation",
None,
"FlashAttention reduces memory by using tiling",
Some(0.8),
None,
vec![],
)
.await
.unwrap();
let ns = tok.namespace().as_str().to_string();
let hits = rt
.text_for_notes(&tok)
.unwrap()
.search(khive_storage::types::TextSearchRequest {
query: "FlashAttention".to_string(),
mode: khive_storage::types::TextQueryMode::Plain,
filter: Some(khive_storage::types::TextFilter {
namespaces: vec![ns],
..Default::default()
}),
top_k: 10,
snippet_chars: 100,
})
.await
.unwrap();
assert!(
hits.iter().any(|h| h.subject_id == note.id),
"note should be indexed in FTS5 after create"
);
}
#[tokio::test]
async fn create_note_with_properties() {
let rt = rt();
let tok = NamespaceToken::local();
let props = serde_json::json!({"source": "arxiv:2205.14135"});
let note = rt
.create_note(
&tok,
"insight",
None,
"FlashAttention is IO-aware",
Some(0.9),
Some(props.clone()),
vec![],
)
.await
.unwrap();
assert_eq!(note.properties.as_ref().unwrap(), &props);
}
#[tokio::test]
async fn create_note_creates_annotates_edges() {
let rt = rt();
let tok = NamespaceToken::local();
let entity = rt
.create_entity(&tok, "concept", None, "FlashAttention", None, None, vec![])
.await
.unwrap();
let note = rt
.create_note(
&tok,
"observation",
None,
"FlashAttention uses SRAM tiling for memory efficiency",
Some(0.9),
None,
vec![entity.id],
)
.await
.unwrap();
let out_neighbors = rt
.neighbors(
&tok,
note.id,
Direction::Out,
None,
Some(vec![EdgeRelation::Annotates]),
)
.await
.unwrap();
assert_eq!(out_neighbors.len(), 1);
assert_eq!(out_neighbors[0].node_id, entity.id);
assert_eq!(out_neighbors[0].relation, EdgeRelation::Annotates);
let in_neighbors = rt
.neighbors(
&tok,
entity.id,
Direction::In,
None,
Some(vec![EdgeRelation::Annotates]),
)
.await
.unwrap();
assert_eq!(in_neighbors.len(), 1);
assert_eq!(in_neighbors[0].node_id, note.id);
}
#[tokio::test]
async fn neighbors_without_relation_filter_returns_all() {
let rt = rt();
let tok = NamespaceToken::local();
let a = rt
.create_entity(&tok, "concept", None, "A", None, None, vec![])
.await
.unwrap();
let b = rt
.create_entity(&tok, "concept", None, "B", None, None, vec![])
.await
.unwrap();
let c = rt
.create_entity(&tok, "concept", None, "C", None, None, vec![])
.await
.unwrap();
rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
.await
.unwrap();
rt.link(&tok, a.id, c.id, EdgeRelation::Enables, 1.0, None)
.await
.unwrap();
let all = rt
.neighbors(&tok, a.id, Direction::Out, None, None)
.await
.unwrap();
assert_eq!(all.len(), 2);
}
#[tokio::test]
async fn neighbors_with_relation_filter_returns_subset() {
let rt = rt();
let tok = NamespaceToken::local();
let a = rt
.create_entity(&tok, "concept", None, "A", None, None, vec![])
.await
.unwrap();
let b = rt
.create_entity(&tok, "concept", None, "B", None, None, vec![])
.await
.unwrap();
let c = rt
.create_entity(&tok, "concept", None, "C", None, None, vec![])
.await
.unwrap();
rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
.await
.unwrap();
rt.link(&tok, a.id, c.id, EdgeRelation::Enables, 1.0, None)
.await
.unwrap();
let filtered = rt
.neighbors(
&tok,
a.id,
Direction::Out,
None,
Some(vec![EdgeRelation::Extends]),
)
.await
.unwrap();
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].node_id, b.id);
assert_eq!(filtered[0].relation, EdgeRelation::Extends);
}
#[tokio::test]
async fn search_notes_returns_relevant_note() {
let rt = rt();
let tok = NamespaceToken::local();
rt.create_note(
&tok,
"observation",
None,
"GQA reduces KV cache memory for large models",
Some(0.8),
None,
vec![],
)
.await
.unwrap();
let results = rt
.search_notes(&tok, "GQA KV cache", None, 10, None, false)
.await
.unwrap();
assert!(!results.is_empty(), "search should return the indexed note");
let hit = &results[0];
assert!(
hit.title.is_some(),
"note hit title should be populated (falls back to content)"
);
assert!(
hit.snippet.is_some(),
"note hit snippet should be populated"
);
}
#[tokio::test]
async fn search_notes_excludes_soft_deleted() {
let rt = rt();
let tok = NamespaceToken::local();
let note = rt
.create_note(
&tok,
"observation",
None,
"RoPE positional encoding rotary embeddings",
Some(0.7),
None,
vec![],
)
.await
.unwrap();
rt.notes(&tok)
.unwrap()
.delete_note(note.id, DeleteMode::Soft)
.await
.unwrap();
let results = rt
.search_notes(&tok, "RoPE rotary positional", None, 10, None, false)
.await
.unwrap();
assert!(
results.iter().all(|h| h.note_id != note.id),
"soft-deleted note should be excluded from search"
);
}
#[tokio::test]
async fn resolve_returns_entity() {
let rt = rt();
let tok = NamespaceToken::local();
let entity = rt
.create_entity(&tok, "concept", None, "LoRA", None, None, vec![])
.await
.unwrap();
let resolved = rt.resolve(&tok, entity.id).await.unwrap();
match resolved {
Some(Resolved::Entity(e)) => assert_eq!(e.id, entity.id),
other => panic!("expected Resolved::Entity, got {:?}", other),
}
}
#[tokio::test]
async fn resolve_returns_note() {
let rt = rt();
let tok = NamespaceToken::local();
let note = rt
.create_note(
&tok,
"observation",
None,
"LoRA fine-tunes LLMs with low-rank adapters",
Some(0.85),
None,
vec![],
)
.await
.unwrap();
let resolved = rt.resolve(&tok, note.id).await.unwrap();
match resolved {
Some(Resolved::Note(n)) => assert_eq!(n.id, note.id),
other => panic!("expected Resolved::Note, got {:?}", other),
}
}
#[tokio::test]
async fn resolve_returns_none_for_unknown_uuid() {
let rt = rt();
let tok = NamespaceToken::local();
let unknown = Uuid::new_v4();
let resolved = rt.resolve(&tok, unknown).await.unwrap();
assert!(resolved.is_none(), "unknown UUID should resolve to None");
}
#[tokio::test]
async fn resolve_prefix_finds_entity_in_own_namespace() {
let rt = rt();
let tok = NamespaceToken::local();
let entity = rt
.create_entity(&tok, "concept", None, "PrefixTest", None, None, vec![])
.await
.unwrap();
let prefix = &entity.id.to_string()[..8];
let resolved = rt.resolve_prefix(&tok, prefix).await.unwrap();
assert_eq!(resolved, Some(entity.id));
}
#[tokio::test]
async fn resolve_prefix_invisible_across_namespaces() {
let rt = rt();
let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
let entity = rt
.create_entity(&ns_a, "concept", None, "Invisible", None, None, vec![])
.await
.unwrap();
let prefix = &entity.id.to_string()[..8];
let resolved = rt.resolve_prefix(&ns_b, prefix).await.unwrap();
assert_eq!(resolved, None);
}
#[tokio::test]
async fn resolve_prefix_ambiguous_same_namespace() {
use khive_storage::entity::Entity;
let rt = rt();
let tok = NamespaceToken::local();
let id_a = Uuid::parse_str("aabbccdd-1111-4000-8000-000000000001").unwrap();
let id_b = Uuid::parse_str("aabbccdd-2222-4000-8000-000000000002").unwrap();
let mut entity_a = Entity::new("local", "concept", "AmbigA");
entity_a.id = id_a;
let mut entity_b = Entity::new("local", "concept", "AmbigB");
entity_b.id = id_b;
let store = rt.entities(&tok).unwrap();
store.upsert_entity(entity_a).await.unwrap();
store.upsert_entity(entity_b).await.unwrap();
let result = rt.resolve_prefix(&tok, "aabbccdd").await;
assert!(
result.is_err(),
"shared 8-char prefix must return Ambiguous error"
);
}
#[tokio::test]
async fn resolve_finds_event_by_full_uuid() {
use khive_storage::Event;
use khive_types::{EventKind, SubstrateKind};
let rt = rt();
let tok = NamespaceToken::local();
let ns = tok.namespace().as_str();
let event = Event::new(
ns,
"test_verb",
EventKind::Audit,
SubstrateKind::Entity,
"actor",
);
let event_id = event.id;
rt.events(&tok).unwrap().append_event(event).await.unwrap();
let resolved = rt.resolve(&tok, event_id).await.unwrap();
assert!(
matches!(resolved, Some(Resolved::Event(_))),
"event UUID must resolve to Resolved::Event, got {resolved:?}"
);
}
#[tokio::test]
async fn resolve_prefix_finds_event() {
use khive_storage::Event;
use khive_types::{EventKind, SubstrateKind};
let rt = rt();
let tok = NamespaceToken::local();
let ns = tok.namespace().as_str();
let event = Event::new(
ns,
"test_verb",
EventKind::Audit,
SubstrateKind::Entity,
"actor",
);
let event_id = event.id;
rt.events(&tok).unwrap().append_event(event).await.unwrap();
let prefix = &event_id.to_string()[..8];
let resolved = rt.resolve_prefix(&tok, prefix).await.unwrap();
assert_eq!(
resolved,
Some(event_id),
"resolve_prefix must return event UUID for 8-char prefix"
);
}
#[tokio::test]
async fn link_phantom_source_returns_not_found() {
let rt = rt();
let tok = NamespaceToken::local();
let b = rt
.create_entity(&tok, "concept", None, "B", None, None, vec![])
.await
.unwrap();
let phantom = Uuid::new_v4();
let result = rt
.link(&tok, phantom, b.id, EdgeRelation::Extends, 1.0, None)
.await;
match result {
Err(RuntimeError::NotFound(msg)) => {
assert!(
msg.contains("source"),
"error message must name 'source': {msg}"
);
}
other => panic!("expected NotFound for phantom source, got {other:?}"),
}
}
#[tokio::test]
async fn link_phantom_target_returns_not_found() {
let rt = rt();
let tok = NamespaceToken::local();
let a = rt
.create_entity(&tok, "concept", None, "A", None, None, vec![])
.await
.unwrap();
let phantom = Uuid::new_v4();
let result = rt
.link(&tok, a.id, phantom, EdgeRelation::Extends, 1.0, None)
.await;
match result {
Err(RuntimeError::NotFound(msg)) => {
assert!(
msg.contains("target"),
"error message must name 'target': {msg}"
);
}
other => panic!("expected NotFound for phantom target, got {other:?}"),
}
}
#[tokio::test]
async fn link_real_entities_succeeds() {
let rt = rt();
let tok = NamespaceToken::local();
let a = rt
.create_entity(&tok, "concept", None, "A", None, None, vec![])
.await
.unwrap();
let b = rt
.create_entity(&tok, "concept", None, "B", None, None, vec![])
.await
.unwrap();
let edge = rt
.link(&tok, a.id, b.id, EdgeRelation::Extends, 0.8, None)
.await
.unwrap();
assert_eq!(edge.source_id, a.id);
assert_eq!(edge.target_id, b.id);
assert_eq!(edge.relation, EdgeRelation::Extends);
}
#[tokio::test]
async fn create_note_annotates_phantom_returns_not_found() {
let rt = rt();
let tok = NamespaceToken::local();
let phantom = Uuid::new_v4();
let result = rt
.create_note(
&tok,
"observation",
None,
"some content",
Some(0.5),
None,
vec![phantom],
)
.await;
assert!(
matches!(result, Err(RuntimeError::NotFound(_))),
"annotates with phantom uuid must return NotFound, got {result:?}"
);
}
#[tokio::test]
async fn create_note_annotates_real_entity_succeeds() {
let rt = rt();
let tok = NamespaceToken::local();
let entity = rt
.create_entity(&tok, "concept", None, "RealTarget", None, None, vec![])
.await
.unwrap();
let note = rt
.create_note(
&tok,
"observation",
None,
"content",
Some(0.5),
None,
vec![entity.id],
)
.await
.unwrap();
let neighbors = rt
.neighbors(
&tok,
note.id,
Direction::Out,
None,
Some(vec![EdgeRelation::Annotates]),
)
.await
.unwrap();
assert_eq!(neighbors.len(), 1);
assert_eq!(neighbors[0].node_id, entity.id);
}
#[tokio::test]
async fn create_note_multi_annotates_creates_all_edges() {
let rt = rt();
let tok = NamespaceToken::local();
let t1 = rt
.create_entity(&tok, "concept", None, "Target1", None, None, vec![])
.await
.unwrap();
let t2 = rt
.create_entity(&tok, "concept", None, "Target2", None, None, vec![])
.await
.unwrap();
let note = rt
.create_note(
&tok,
"observation",
None,
"content",
Some(0.5),
None,
vec![t1.id, t2.id],
)
.await
.unwrap();
let neighbors = rt
.neighbors(
&tok,
note.id,
Direction::Out,
None,
Some(vec![EdgeRelation::Annotates]),
)
.await
.unwrap();
assert_eq!(
neighbors.len(),
2,
"multi-annotates note must have exactly 2 outbound annotates edges"
);
let target_ids: Vec<Uuid> = neighbors.iter().map(|n| n.node_id).collect();
assert!(target_ids.contains(&t1.id));
assert!(target_ids.contains(&t2.id));
}
#[tokio::test]
async fn link_target_in_different_namespace_returns_not_found() {
let rt = rt();
let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
let a = rt
.create_entity(&ns_a, "concept", None, "A", None, None, vec![])
.await
.unwrap();
let b = rt
.create_entity(&ns_b, "concept", None, "B", None, None, vec![])
.await
.unwrap();
let result = rt
.link(&ns_a, a.id, b.id, EdgeRelation::Extends, 1.0, None)
.await;
assert!(
matches!(result, Err(RuntimeError::NotFound(_))),
"target in different namespace must return NotFound (fail-closed), got {result:?}"
);
}
#[tokio::test]
async fn link_phantom_self_loop_returns_not_found() {
let rt = rt();
let tok = NamespaceToken::local();
let phantom = Uuid::new_v4();
let result = rt
.link(&tok, phantom, phantom, EdgeRelation::Extends, 1.0, None)
.await;
match result {
Err(RuntimeError::NotFound(msg)) => {
assert!(
msg.contains("source"),
"self-loop must fail on source first: {msg}"
);
}
other => panic!("expected NotFound for phantom self-loop, got {other:?}"),
}
}
#[tokio::test]
async fn link_note_to_edge_annotates_succeeds() {
let rt = rt();
let tok = NamespaceToken::local();
let a = rt
.create_entity(&tok, "concept", None, "A", None, None, vec![])
.await
.unwrap();
let b = rt
.create_entity(&tok, "concept", None, "B", None, None, vec![])
.await
.unwrap();
let edge = rt
.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
.await
.unwrap();
let edge_uuid: Uuid = edge.id.into();
let note = rt
.create_note(
&tok,
"observation",
None,
"edge note",
Some(0.5),
None,
vec![],
)
.await
.unwrap();
let result = rt
.link(&tok, note.id, edge_uuid, EdgeRelation::Annotates, 1.0, None)
.await;
assert!(
result.is_ok(),
"note→edge Annotates must succeed, got {result:?}"
);
}
#[tokio::test]
async fn create_note_annotates_real_edge_succeeds() {
let rt = rt();
let tok = NamespaceToken::local();
let a = rt
.create_entity(&tok, "concept", None, "A", None, None, vec![])
.await
.unwrap();
let b = rt
.create_entity(&tok, "concept", None, "B", None, None, vec![])
.await
.unwrap();
let edge = rt
.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
.await
.unwrap();
let edge_uuid: Uuid = edge.id.into();
let note = rt
.create_note(
&tok,
"observation",
None,
"annotating an edge",
Some(0.5),
None,
vec![edge_uuid],
)
.await
.unwrap();
let neighbors = rt
.neighbors(
&tok,
note.id,
Direction::Out,
None,
Some(vec![EdgeRelation::Annotates]),
)
.await
.unwrap();
assert_eq!(neighbors.len(), 1);
assert_eq!(neighbors[0].node_id, edge_uuid);
}
#[tokio::test]
async fn create_note_annotates_phantom_is_atomic_no_note_persisted() {
let rt = rt();
let tok = NamespaceToken::local();
let phantom = Uuid::new_v4();
let before_count = rt.list_notes(&tok, None, 1000, 0).await.unwrap().len();
let result = rt
.create_note(
&tok,
"observation",
None,
"should not persist",
Some(0.5),
None,
vec![phantom],
)
.await;
assert!(
matches!(result, Err(RuntimeError::NotFound(_))),
"phantom annotates target must return NotFound, got {result:?}"
);
let after_count = rt.list_notes(&tok, None, 1000, 0).await.unwrap().len();
assert_eq!(
before_count, after_count,
"failed create_note must not persist any note row (atomicity)"
);
let search_hits = rt
.search_notes(&tok, "should not persist", None, 10, None, false)
.await
.unwrap();
assert!(
search_hits.is_empty(),
"failed create_note must not index into FTS (atomicity)"
);
}
#[tokio::test]
async fn link_entity_to_edge_uuid_non_annotates_returns_invalid_input() {
let rt = rt();
let tok = NamespaceToken::local();
let a = rt
.create_entity(&tok, "concept", None, "A", None, None, vec![])
.await
.unwrap();
let b = rt
.create_entity(&tok, "concept", None, "B", None, None, vec![])
.await
.unwrap();
let edge = rt
.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
.await
.unwrap();
let edge_uuid: Uuid = edge.id.into();
let result = rt
.link(&tok, a.id, edge_uuid, EdgeRelation::Extends, 1.0, None)
.await;
match result {
Err(RuntimeError::InvalidInput(msg)) => {
assert!(
msg.contains("target"),
"error message must name 'target': {msg}"
);
}
other => {
panic!("expected InvalidInput for edge-uuid target with Extends, got {other:?}")
}
}
}
#[tokio::test]
async fn link_note_as_source_non_annotates_returns_invalid_input() {
let rt = rt();
let tok = NamespaceToken::local();
let note = rt
.create_note(&tok, "observation", None, "a note", Some(0.5), None, vec![])
.await
.unwrap();
let entity = rt
.create_entity(&tok, "concept", None, "E", None, None, vec![])
.await
.unwrap();
let result = rt
.link(&tok, note.id, entity.id, EdgeRelation::DependsOn, 1.0, None)
.await;
match result {
Err(RuntimeError::InvalidInput(msg)) => {
assert!(
msg.contains("source"),
"error message must name 'source': {msg}"
);
}
other => panic!("expected InvalidInput for note source with DependsOn, got {other:?}"),
}
}
#[tokio::test]
async fn link_entity_as_annotates_source_returns_invalid_input() {
let rt = rt();
let tok = NamespaceToken::local();
let a = rt
.create_entity(&tok, "concept", None, "A", None, None, vec![])
.await
.unwrap();
let b = rt
.create_entity(&tok, "concept", None, "B", None, None, vec![])
.await
.unwrap();
let result = rt
.link(&tok, a.id, b.id, EdgeRelation::Annotates, 1.0, None)
.await;
match result {
Err(RuntimeError::InvalidInput(msg)) => {
assert!(
msg.contains("source") && msg.contains("note"),
"error must say source must be a note: {msg}"
);
}
other => {
panic!("expected InvalidInput for entity source with Annotates, got {other:?}")
}
}
}
#[tokio::test]
async fn link_edge_as_annotates_source_returns_invalid_input() {
let rt = rt();
let tok = NamespaceToken::local();
let a = rt
.create_entity(&tok, "concept", None, "A", None, None, vec![])
.await
.unwrap();
let b = rt
.create_entity(&tok, "concept", None, "B", None, None, vec![])
.await
.unwrap();
let edge = rt
.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
.await
.unwrap();
let edge_uuid: Uuid = edge.id.into();
let result = rt
.link(&tok, edge_uuid, a.id, EdgeRelation::Annotates, 1.0, None)
.await;
match result {
Err(RuntimeError::InvalidInput(msg)) => {
assert!(
msg.contains("source") && msg.contains("note"),
"edge-as-annotates-source must report wrong kind, not NotFound: {msg}"
);
}
other => panic!("expected InvalidInput for edge source with Annotates, got {other:?}"),
}
}
#[tokio::test]
async fn link_note_to_event_annotates_succeeds() {
use khive_storage::Event;
use khive_types::{EventKind, SubstrateKind};
let rt = rt();
let tok = NamespaceToken::local();
let note = rt
.create_note(
&tok,
"observation",
None,
"observing an event",
Some(0.6),
None,
vec![],
)
.await
.unwrap();
let ns = tok.namespace().as_str();
let event = Event::new(
ns,
"test_verb",
EventKind::Audit,
SubstrateKind::Entity,
"test_actor",
);
let event_id = event.id;
rt.events(&tok).unwrap().append_event(event).await.unwrap();
let result = rt
.link(&tok, note.id, event_id, EdgeRelation::Annotates, 1.0, None)
.await;
assert!(
result.is_ok(),
"note→event Annotates must succeed, got {result:?}"
);
}
#[tokio::test]
async fn create_note_annotates_event_succeeds() {
use khive_storage::Event;
use khive_types::{EventKind, SubstrateKind};
let rt = rt();
let tok = NamespaceToken::local();
let ns = tok.namespace().as_str();
let event = Event::new(
ns,
"test_verb",
EventKind::Audit,
SubstrateKind::Entity,
"test_actor",
);
let event_id = event.id;
rt.events(&tok).unwrap().append_event(event).await.unwrap();
let result = rt
.create_note(
&tok,
"observation",
None,
"note annotating an event",
Some(0.5),
None,
vec![event_id],
)
.await;
assert!(
result.is_ok(),
"create_note with event annotates target must succeed, got {result:?}"
);
let note = result.unwrap();
let neighbors = rt
.neighbors(
&tok,
note.id,
Direction::Out,
None,
Some(vec![EdgeRelation::Annotates]),
)
.await
.unwrap();
assert_eq!(neighbors.len(), 1);
assert_eq!(neighbors[0].node_id, event_id);
}
#[tokio::test]
async fn link_supersedes_note_to_note_succeeds() {
let rt = rt();
let tok = NamespaceToken::local();
let old_note = rt
.create_note(
&tok,
"observation",
None,
"old observation",
Some(0.7),
None,
vec![],
)
.await
.unwrap();
let new_note = rt
.create_note(
&tok,
"observation",
None,
"revised observation superseding the old one",
Some(0.9),
None,
vec![],
)
.await
.unwrap();
let result = rt
.link(
&tok,
new_note.id,
old_note.id,
EdgeRelation::Supersedes,
1.0,
None,
)
.await;
assert!(
result.is_ok(),
"note→note Supersedes must succeed (ADR-019 note supersession), got {result:?}"
);
}
#[tokio::test]
async fn link_supersedes_entity_to_entity_succeeds() {
let rt = rt();
let tok = NamespaceToken::local();
let old_entity = rt
.create_entity(&tok, "concept", None, "OldConcept", None, None, vec![])
.await
.unwrap();
let new_entity = rt
.create_entity(&tok, "concept", None, "NewConcept", None, None, vec![])
.await
.unwrap();
let result = rt
.link(
&tok,
new_entity.id,
old_entity.id,
EdgeRelation::Supersedes,
1.0,
None,
)
.await;
assert!(
result.is_ok(),
"entity→entity Supersedes must succeed, got {result:?}"
);
}
#[tokio::test]
async fn link_supersedes_note_to_entity_returns_invalid_input() {
let rt = rt();
let tok = NamespaceToken::local();
let note = rt
.create_note(&tok, "observation", None, "a note", Some(0.5), None, vec![])
.await
.unwrap();
let entity = rt
.create_entity(&tok, "concept", None, "SomeEntity", None, None, vec![])
.await
.unwrap();
let result = rt
.link(
&tok,
note.id,
entity.id,
EdgeRelation::Supersedes,
1.0,
None,
)
.await;
match result {
Err(RuntimeError::InvalidInput(msg)) => {
assert!(
msg.contains("same substrate") || msg.contains("same-substrate"),
"error must name the same-substrate rule: {msg}"
);
}
other => panic!(
"expected InvalidInput for note→entity Supersedes (cross-substrate), got {other:?}"
),
}
}
#[tokio::test]
async fn link_supersedes_entity_to_note_returns_invalid_input() {
let rt = rt();
let tok = NamespaceToken::local();
let entity = rt
.create_entity(&tok, "concept", None, "SomeEntity", None, None, vec![])
.await
.unwrap();
let note = rt
.create_note(&tok, "observation", None, "a note", Some(0.5), None, vec![])
.await
.unwrap();
let result = rt
.link(
&tok,
entity.id,
note.id,
EdgeRelation::Supersedes,
1.0,
None,
)
.await;
match result {
Err(RuntimeError::InvalidInput(msg)) => {
assert!(
msg.contains("same substrate") || msg.contains("same-substrate"),
"error must name the same-substrate rule: {msg}"
);
}
other => panic!(
"expected InvalidInput for entity→note Supersedes (cross-substrate), got {other:?}"
),
}
}
#[tokio::test]
async fn link_supersedes_event_source_returns_invalid_input() {
use khive_storage::Event;
use khive_types::{EventKind, SubstrateKind};
let rt = rt();
let tok = NamespaceToken::local();
let ns = tok.namespace().as_str();
let event = Event::new(
ns,
"test_verb",
EventKind::Audit,
SubstrateKind::Entity,
"test_actor",
);
let event_id = event.id;
rt.events(&tok).unwrap().append_event(event).await.unwrap();
let entity = rt
.create_entity(&tok, "concept", None, "SomeEntity", None, None, vec![])
.await
.unwrap();
let result = rt
.link(
&tok,
event_id,
entity.id,
EdgeRelation::Supersedes,
1.0,
None,
)
.await;
match result {
Err(RuntimeError::InvalidInput(msg)) => {
assert!(msg.contains("event"), "error must mention 'event': {msg}");
}
other => {
panic!("expected InvalidInput for event source with Supersedes, got {other:?}")
}
}
}
#[tokio::test]
async fn link_supersedes_event_target_returns_invalid_input() {
use khive_storage::Event;
use khive_types::{EventKind, SubstrateKind};
let rt = rt();
let tok = NamespaceToken::local();
let ns = tok.namespace().as_str();
let event = Event::new(
ns,
"test_verb",
EventKind::Audit,
SubstrateKind::Entity,
"test_actor",
);
let event_id = event.id;
rt.events(&tok).unwrap().append_event(event).await.unwrap();
let entity = rt
.create_entity(&tok, "concept", None, "SomeEntity", None, None, vec![])
.await
.unwrap();
let result = rt
.link(
&tok,
entity.id,
event_id,
EdgeRelation::Supersedes,
1.0,
None,
)
.await;
match result {
Err(RuntimeError::InvalidInput(msg)) => {
assert!(msg.contains("event"), "error must mention 'event': {msg}");
}
other => {
panic!("expected InvalidInput for event target with Supersedes, got {other:?}")
}
}
}
#[tokio::test]
async fn link_supersedes_edge_source_returns_invalid_input() {
let rt = rt();
let tok = NamespaceToken::local();
let a = rt
.create_entity(&tok, "concept", None, "A", None, None, vec![])
.await
.unwrap();
let b = rt
.create_entity(&tok, "concept", None, "B", None, None, vec![])
.await
.unwrap();
let edge = rt
.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
.await
.unwrap();
let edge_uuid: Uuid = edge.id.into();
let result = rt
.link(&tok, edge_uuid, a.id, EdgeRelation::Supersedes, 1.0, None)
.await;
match result {
Err(RuntimeError::InvalidInput(msg)) => {
assert!(msg.contains("source"), "error must name 'source': {msg}");
}
other => {
panic!("expected InvalidInput for edge-uuid source with Supersedes, got {other:?}")
}
}
}
#[tokio::test]
async fn link_supersedes_edge_target_returns_invalid_input() {
let rt = rt();
let tok = NamespaceToken::local();
let a = rt
.create_entity(&tok, "concept", None, "A", None, None, vec![])
.await
.unwrap();
let b = rt
.create_entity(&tok, "concept", None, "B", None, None, vec![])
.await
.unwrap();
let edge = rt
.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
.await
.unwrap();
let edge_uuid: Uuid = edge.id.into();
let result = rt
.link(&tok, a.id, edge_uuid, EdgeRelation::Supersedes, 1.0, None)
.await;
match result {
Err(RuntimeError::InvalidInput(msg)) => {
assert!(msg.contains("target"), "error must name 'target': {msg}");
}
other => {
panic!("expected InvalidInput for edge-uuid target with Supersedes, got {other:?}")
}
}
}
#[tokio::test]
async fn link_supersedes_phantom_source_returns_not_found() {
let rt = rt();
let tok = NamespaceToken::local();
let note = rt
.create_note(
&tok,
"observation",
None,
"existing note",
Some(0.5),
None,
vec![],
)
.await
.unwrap();
let phantom = Uuid::new_v4();
let result = rt
.link(&tok, phantom, note.id, EdgeRelation::Supersedes, 1.0, None)
.await;
match result {
Err(RuntimeError::NotFound(msg)) => {
assert!(msg.contains("source"), "error must name 'source': {msg}");
}
other => panic!("expected NotFound for phantom source with Supersedes, got {other:?}"),
}
}
#[tokio::test]
async fn link_supersedes_phantom_target_returns_not_found() {
let rt = rt();
let tok = NamespaceToken::local();
let note = rt
.create_note(
&tok,
"observation",
None,
"existing note",
Some(0.5),
None,
vec![],
)
.await
.unwrap();
let phantom = Uuid::new_v4();
let result = rt
.link(&tok, note.id, phantom, EdgeRelation::Supersedes, 1.0, None)
.await;
match result {
Err(RuntimeError::NotFound(msg)) => {
assert!(msg.contains("target"), "error must name 'target': {msg}");
}
other => panic!("expected NotFound for phantom target with Supersedes, got {other:?}"),
}
}
#[tokio::test]
async fn link_supersedes_cross_namespace_source_returns_not_found() {
let rt = rt();
let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
let note_a = rt
.create_note(
&ns_a,
"observation",
None,
"note in ns-a",
Some(0.5),
None,
vec![],
)
.await
.unwrap();
let note_b = rt
.create_note(
&ns_b,
"observation",
None,
"note in ns-b",
Some(0.5),
None,
vec![],
)
.await
.unwrap();
let result = rt
.link(
&ns_a,
note_b.id,
note_a.id,
EdgeRelation::Supersedes,
1.0,
None,
)
.await;
assert!(
matches!(result, Err(RuntimeError::NotFound(_))),
"cross-namespace source with Supersedes must return NotFound (fail-closed), got {result:?}"
);
}
#[tokio::test]
async fn link_extends_note_source_still_returns_invalid_input() {
let rt = rt();
let tok = NamespaceToken::local();
let note = rt
.create_note(
&tok,
"observation",
None,
"a note that cannot be an extends source",
Some(0.5),
None,
vec![],
)
.await
.unwrap();
let entity = rt
.create_entity(&tok, "concept", None, "E", None, None, vec![])
.await
.unwrap();
let result = rt
.link(&tok, note.id, entity.id, EdgeRelation::Extends, 1.0, None)
.await;
assert!(
matches!(result, Err(RuntimeError::InvalidInput(_))),
"note source with Extends must still return InvalidInput after this fix, got {result:?}"
);
}
#[tokio::test]
async fn link_annotates_note_to_edge_still_succeeds_after_fix() {
let rt = rt();
let tok = NamespaceToken::local();
let a = rt
.create_entity(&tok, "concept", None, "A", None, None, vec![])
.await
.unwrap();
let b = rt
.create_entity(&tok, "concept", None, "B", None, None, vec![])
.await
.unwrap();
let edge = rt
.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
.await
.unwrap();
let edge_uuid: Uuid = edge.id.into();
let note = rt
.create_note(
&tok,
"observation",
None,
"annotating an edge",
Some(0.5),
None,
vec![],
)
.await
.unwrap();
let result = rt
.link(&tok, note.id, edge_uuid, EdgeRelation::Annotates, 1.0, None)
.await;
assert!(
result.is_ok(),
"note→edge Annotates must still succeed after supersedes fix, got {result:?}"
);
}
#[tokio::test]
async fn create_note_multi_annotates_compensation_cleanup_restores_pristine_state() {
let rt = rt();
let tok = NamespaceToken::local();
let t1 = rt
.create_entity(&tok, "concept", None, "T1", None, None, vec![])
.await
.unwrap();
let note = rt
.create_note(
&tok,
"observation",
None,
"partial note",
Some(0.5),
None,
vec![t1.id],
)
.await
.unwrap();
let before_notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
assert_eq!(before_notes.len(), 1, "note must be present before cleanup");
let before_edges = rt
.neighbors(
&tok,
note.id,
Direction::Out,
None,
Some(vec![EdgeRelation::Annotates]),
)
.await
.unwrap();
assert_eq!(
before_edges.len(),
1,
"one annotates edge must exist before cleanup"
);
let edge_id: Uuid = before_edges[0].edge_id;
rt.delete_edge(&tok, edge_id, true).await.unwrap();
rt.delete_note(&tok, note.id, true )
.await
.unwrap();
let after_notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
assert!(
after_notes.is_empty(),
"compensation must remove the note row; got {after_notes:?}"
);
let search_hits = rt
.search_notes(&tok, "partial note", None, 10, None, false)
.await
.unwrap();
assert!(
search_hits.is_empty(),
"compensation must clean the FTS index; got {search_hits:?}"
);
let after_edges = rt
.neighbors(&tok, note.id, Direction::Out, None, None)
.await
.unwrap();
assert!(
after_edges.is_empty(),
"compensation must remove all partial edges; got {after_edges:?}"
);
}
#[tokio::test]
async fn annotated_entity_hard_delete_cascades_annotate_edge() {
let rt = rt();
let tok = NamespaceToken::local();
let entity = rt
.create_entity(&tok, "concept", None, "E", None, None, vec![])
.await
.unwrap();
let note = rt
.create_note(
&tok,
"observation",
None,
"note about entity",
Some(0.5),
None,
vec![entity.id],
)
.await
.unwrap();
let before = rt
.neighbors(
&tok,
note.id,
Direction::Out,
None,
Some(vec![EdgeRelation::Annotates]),
)
.await
.unwrap();
assert_eq!(
before.len(),
1,
"annotates edge must exist before entity delete"
);
let deleted = rt.delete_entity(&tok, entity.id, true).await.unwrap();
assert!(deleted, "entity hard delete must return true");
let after = rt
.neighbors(
&tok,
note.id,
Direction::Out,
None,
Some(vec![EdgeRelation::Annotates]),
)
.await
.unwrap();
assert!(
after.is_empty(),
"annotates edge must be cascaded on entity hard delete; got {after:?}"
);
}
#[tokio::test]
async fn annotated_note_hard_delete_cascades_annotate_edge() {
let rt = rt();
let tok = NamespaceToken::local();
let note_target = rt
.create_note(
&tok,
"observation",
None,
"target note",
Some(0.5),
None,
vec![],
)
.await
.unwrap();
let note_source = rt
.create_note(
&tok,
"insight",
None,
"annotation",
Some(0.5),
None,
vec![note_target.id],
)
.await
.unwrap();
let before = rt
.neighbors(
&tok,
note_source.id,
Direction::Out,
None,
Some(vec![EdgeRelation::Annotates]),
)
.await
.unwrap();
assert_eq!(
before.len(),
1,
"annotates edge must exist before note delete"
);
let deleted = rt.delete_note(&tok, note_target.id, true).await.unwrap();
assert!(deleted, "note hard delete must return true");
let after = rt
.neighbors(
&tok,
note_source.id,
Direction::Out,
None,
Some(vec![EdgeRelation::Annotates]),
)
.await
.unwrap();
assert!(
after.is_empty(),
"annotates edge must be cascaded on note-target hard delete; got {after:?}"
);
}
#[tokio::test]
async fn annotated_edge_delete_cascades_annotate_edge() {
let rt = rt();
let tok = NamespaceToken::local();
let a = rt
.create_entity(&tok, "concept", None, "A", None, None, vec![])
.await
.unwrap();
let b = rt
.create_entity(&tok, "concept", None, "B", None, None, vec![])
.await
.unwrap();
let base_edge = rt
.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
.await
.unwrap();
let base_edge_uuid: Uuid = base_edge.id.into();
let note = rt
.create_note(
&tok,
"observation",
None,
"note about edge",
Some(0.5),
None,
vec![base_edge_uuid],
)
.await
.unwrap();
let before = rt
.neighbors(
&tok,
note.id,
Direction::Out,
None,
Some(vec![EdgeRelation::Annotates]),
)
.await
.unwrap();
assert_eq!(
before.len(),
1,
"annotates edge must exist before base edge delete"
);
let deleted = rt.delete_edge(&tok, base_edge_uuid, true).await.unwrap();
assert!(deleted, "edge delete must return true");
let after = rt
.neighbors(
&tok,
note.id,
Direction::Out,
None,
Some(vec![EdgeRelation::Annotates]),
)
.await
.unwrap();
assert!(
after.is_empty(),
"annotates edge must be cascaded on base edge delete; got {after:?}"
);
}
#[tokio::test]
async fn mixed_multi_annotates_partial_target_hard_delete_leaves_remaining_edges() {
let rt = rt();
let tok = NamespaceToken::local();
let t1 = rt
.create_entity(&tok, "concept", None, "T1", None, None, vec![])
.await
.unwrap();
let t2 = rt
.create_entity(&tok, "concept", None, "T2", None, None, vec![])
.await
.unwrap();
let note = rt
.create_note(
&tok,
"observation",
None,
"multi-target note",
Some(0.5),
None,
vec![t1.id, t2.id],
)
.await
.unwrap();
let before = rt
.neighbors(
&tok,
note.id,
Direction::Out,
None,
Some(vec![EdgeRelation::Annotates]),
)
.await
.unwrap();
assert_eq!(
before.len(),
2,
"must have 2 annotates edges before any delete"
);
rt.delete_entity(&tok, t1.id, true).await.unwrap();
let after = rt
.neighbors(
&tok,
note.id,
Direction::Out,
None,
Some(vec![EdgeRelation::Annotates]),
)
.await
.unwrap();
assert_eq!(
after.len(),
1,
"only the edge to t1 must be cascaded; t2 edge must remain"
);
assert_eq!(
after[0].node_id, t2.id,
"remaining annotates edge must point to t2"
);
}
#[tokio::test]
async fn annotated_note_soft_delete_preserves_annotate_edge() {
let rt = rt();
let tok = NamespaceToken::local();
let note_target = rt
.create_note(&tok, "observation", None, "target", Some(0.5), None, vec![])
.await
.unwrap();
let note_source = rt
.create_note(
&tok,
"insight",
None,
"annotation",
Some(0.5),
None,
vec![note_target.id],
)
.await
.unwrap();
let before = rt
.neighbors(
&tok,
note_source.id,
Direction::Out,
None,
Some(vec![EdgeRelation::Annotates]),
)
.await
.unwrap();
assert_eq!(before.len(), 1);
let deleted = rt.delete_note(&tok, note_target.id, false).await.unwrap();
assert!(deleted, "soft delete must return true");
let after = rt
.neighbors(
&tok,
note_source.id,
Direction::Out,
None,
Some(vec![EdgeRelation::Annotates]),
)
.await
.unwrap();
assert_eq!(
after.len(),
1,
"soft delete must NOT cascade edges; got {after:?}"
);
}
#[tokio::test]
async fn delete_edge_non_edge_uuid_has_no_side_effects() {
let rt = rt();
let tok = NamespaceToken::local();
let entity = rt
.create_entity(&tok, "concept", None, "Target", None, None, vec![])
.await
.unwrap();
let note = rt
.create_note(
&tok,
"observation",
None,
"annotates the entity",
Some(0.5),
None,
vec![entity.id],
)
.await
.unwrap();
let before = rt
.neighbors(
&tok,
note.id,
Direction::Out,
None,
Some(vec![EdgeRelation::Annotates]),
)
.await
.unwrap();
assert_eq!(before.len(), 1, "annotates edge must exist before test");
let annotates_edge_id: Uuid = before[0].edge_id;
let result = rt.delete_edge(&tok, entity.id, true).await;
assert!(
result.is_ok(),
"delete_edge must not error on a non-edge UUID"
);
assert!(
!result.unwrap(),
"delete_edge must return false for a non-edge UUID"
);
let after = rt
.neighbors(
&tok,
note.id,
Direction::Out,
None,
Some(vec![EdgeRelation::Annotates]),
)
.await
.unwrap();
assert_eq!(
after.len(),
1,
"delete_edge with a non-edge UUID must not touch inbound annotates edges"
);
assert_eq!(
after[0].edge_id, annotates_edge_id,
"the original annotates edge must be unchanged"
);
}
#[tokio::test]
async fn create_note_multi_annotates_second_link_failure_rolls_back_partial_write() {
let rt = rt();
let tok = NamespaceToken::local();
let t1 = rt
.create_entity(&tok, "concept", None, "T1", None, None, vec![])
.await
.unwrap();
let t2 = rt
.create_entity(&tok, "concept", None, "T2", None, None, vec![])
.await
.unwrap();
LINK_FAIL_AFTER.with(|cell| cell.set(2));
let result = rt
.create_note(
&tok,
"observation",
None,
"rollback target",
Some(0.5),
None,
vec![t1.id, t2.id],
)
.await;
assert!(
result.is_err(),
"create_note must propagate the injected link failure"
);
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("injected link failure"),
"error must carry injection message; got: {err_msg}"
);
let notes = rt.list_notes(&tok, None, 1000, 0).await.unwrap();
assert!(
notes.is_empty(),
"compensation must remove the note row; got {notes:?}"
);
let hits = rt
.search_notes(&tok, "rollback target", None, 10, None, false)
.await
.unwrap();
assert!(
hits.is_empty(),
"compensation must clean FTS index; got {hits:?}"
);
let edges_from_t1 = rt
.neighbors(
&tok,
t1.id,
Direction::In,
None,
Some(vec![EdgeRelation::Annotates]),
)
.await
.unwrap();
let edges_from_t2 = rt
.neighbors(
&tok,
t2.id,
Direction::In,
None,
Some(vec![EdgeRelation::Annotates]),
)
.await
.unwrap();
assert!(
edges_from_t1.is_empty(),
"compensation must delete the first annotates edge; got {edges_from_t1:?}"
);
assert!(
edges_from_t2.is_empty(),
"no second annotates edge must exist; got {edges_from_t2:?}"
);
}
#[tokio::test]
async fn soft_delete_entity_removes_indexes() {
let rt = rt();
let tok = NamespaceToken::local();
let entity = rt
.create_entity(
&tok,
"concept",
None,
"QuantumEntanglement",
Some("unique FTS term xzqjwv for soft delete test"),
None,
vec![],
)
.await
.unwrap();
let ns = tok.namespace().as_str().to_string();
let before = rt
.text(&tok)
.unwrap()
.search(TextSearchRequest {
query: "xzqjwv".to_string(),
mode: TextQueryMode::Plain,
filter: Some(TextFilter {
namespaces: vec![ns.clone()],
..Default::default()
}),
top_k: 10,
snippet_chars: 100,
})
.await
.unwrap();
assert!(
before.iter().any(|h| h.subject_id == entity.id),
"entity must be in FTS before soft-delete"
);
let deleted = rt.delete_entity(&tok, entity.id, false).await.unwrap();
assert!(deleted, "soft delete must return true");
let after = rt
.text(&tok)
.unwrap()
.search(TextSearchRequest {
query: "xzqjwv".to_string(),
mode: TextQueryMode::Plain,
filter: Some(TextFilter {
namespaces: vec![ns],
..Default::default()
}),
top_k: 10,
snippet_chars: 100,
})
.await
.unwrap();
assert!(
after.iter().all(|h| h.subject_id != entity.id),
"soft-deleted entity must be removed from FTS index"
);
}
#[tokio::test]
async fn soft_delete_note_removes_indexes() {
let rt = rt();
let tok = NamespaceToken::local();
let note = rt
.create_note(
&tok,
"observation",
None,
"SpectralDecomposition unique term yvwkqz for soft delete test",
Some(0.7),
None,
vec![],
)
.await
.unwrap();
let before = rt
.search_notes(&tok, "yvwkqz", None, 10, None, false)
.await
.unwrap();
assert!(
before.iter().any(|h| h.note_id == note.id),
"note must be in FTS before soft-delete"
);
let deleted = rt.delete_note(&tok, note.id, false).await.unwrap();
assert!(deleted, "soft delete must return true");
let after = rt
.search_notes(&tok, "yvwkqz", None, 10, None, false)
.await
.unwrap();
assert!(
after.iter().all(|h| h.note_id != note.id),
"soft-deleted note must be removed from FTS index"
);
}
#[tokio::test]
async fn link_extends_document_to_document_returns_invalid_input() {
let rt = rt();
let tok = NamespaceToken::local();
let d1 = rt
.create_entity(&tok, "document", None, "DocA", None, None, vec![])
.await
.unwrap();
let d2 = rt
.create_entity(&tok, "document", None, "DocB", None, None, vec![])
.await
.unwrap();
let result = rt
.link(&tok, d1.id, d2.id, EdgeRelation::Extends, 1.0, None)
.await;
assert!(
result.is_err(),
"F010: document->document Extends must be rejected by ADR-002 allowlist; \
current generic entity fallthrough incorrectly accepts it"
);
}
#[tokio::test]
async fn link_extends_concept_to_concept_succeeds() {
let rt = rt();
let tok = NamespaceToken::local();
let a = rt
.create_entity(&tok, "concept", None, "CA", None, None, vec![])
.await
.unwrap();
let b = rt
.create_entity(&tok, "concept", None, "CB", None, None, vec![])
.await
.unwrap();
let result = rt
.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
.await;
assert!(
result.is_ok(),
"F010: concept->concept Extends must be allowed (ADR-002 allowlist)"
);
}
#[tokio::test]
async fn link_symmetric_relation_canonicalizes_endpoint_order() {
use khive_storage::EdgeFilter;
let rt = rt();
let tok = NamespaceToken::local();
let a = rt
.create_entity(&tok, "concept", None, "ConceptP", None, None, vec![])
.await
.unwrap();
let b = rt
.create_entity(&tok, "concept", None, "ConceptQ", None, None, vec![])
.await
.unwrap();
rt.link(&tok, a.id, b.id, EdgeRelation::CompetesWith, 1.0, None)
.await
.unwrap();
rt.link(&tok, b.id, a.id, EdgeRelation::CompetesWith, 1.0, None)
.await
.unwrap();
let count = rt
.graph(&tok)
.unwrap()
.count_edges(EdgeFilter::default())
.await
.unwrap();
assert_eq!(
count,
1,
"F012: CompetesWith is symmetric; A->B and B->A must deduplicate to one canonical row; \
found {count} rows (canonicalization not yet implemented)"
);
}
#[tokio::test]
async fn f010_supersedes_document_to_document_allowed() {
let rt = rt();
let tok = NamespaceToken::local();
let a = rt
.create_entity(&tok, "document", None, "DocA", None, None, vec![])
.await
.unwrap();
let b = rt
.create_entity(&tok, "document", None, "DocB", None, None, vec![])
.await
.unwrap();
let result = rt
.link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
.await;
assert!(
result.is_ok(),
"document->document Supersedes must be allowed (ADR-002:191), got {result:?}"
);
}
#[tokio::test]
async fn f010_supersedes_artifact_to_artifact_allowed() {
let rt = rt();
let tok = NamespaceToken::local();
let a = rt
.create_entity(&tok, "artifact", None, "ArtA", None, None, vec![])
.await
.unwrap();
let b = rt
.create_entity(&tok, "artifact", None, "ArtB", None, None, vec![])
.await
.unwrap();
let result = rt
.link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
.await;
assert!(
result.is_ok(),
"artifact->artifact Supersedes must be allowed (ADR-002:192), got {result:?}"
);
}
#[tokio::test]
async fn f010_supersedes_service_to_service_allowed() {
let rt = rt();
let tok = NamespaceToken::local();
let a = rt
.create_entity(&tok, "service", None, "SvcA", None, None, vec![])
.await
.unwrap();
let b = rt
.create_entity(&tok, "service", None, "SvcB", None, None, vec![])
.await
.unwrap();
let result = rt
.link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
.await;
assert!(
result.is_ok(),
"service->service Supersedes must be allowed (ADR-002:193), got {result:?}"
);
}
#[tokio::test]
async fn f010_supersedes_dataset_to_dataset_allowed() {
let rt = rt();
let tok = NamespaceToken::local();
let a = rt
.create_entity(&tok, "dataset", None, "DataA", None, None, vec![])
.await
.unwrap();
let b = rt
.create_entity(&tok, "dataset", None, "DataB", None, None, vec![])
.await
.unwrap();
let result = rt
.link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
.await;
assert!(
result.is_ok(),
"dataset->dataset Supersedes must be allowed (ADR-002:194), got {result:?}"
);
}
#[tokio::test]
async fn f010_supersedes_project_to_project_rejected() {
let rt = rt();
let tok = NamespaceToken::local();
let a = rt
.create_entity(&tok, "project", None, "ProjA", None, None, vec![])
.await
.unwrap();
let b = rt
.create_entity(&tok, "project", None, "ProjB", None, None, vec![])
.await
.unwrap();
let result = rt
.link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
.await;
assert!(
matches!(result, Err(RuntimeError::InvalidInput(_))),
"project->project Supersedes must be rejected (not in ADR-002 allowlist), got {result:?}"
);
}
#[tokio::test]
async fn f010_supersedes_person_to_person_rejected() {
let rt = rt();
let tok = NamespaceToken::local();
let a = rt
.create_entity(&tok, "person", None, "Alice", None, None, vec![])
.await
.unwrap();
let b = rt
.create_entity(&tok, "person", None, "Bob", None, None, vec![])
.await
.unwrap();
let result = rt
.link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
.await;
assert!(
matches!(result, Err(RuntimeError::InvalidInput(_))),
"person->person Supersedes must be rejected (not in ADR-002 allowlist), got {result:?}"
);
}
#[tokio::test]
async fn f010_supersedes_org_to_org_rejected() {
let rt = rt();
let tok = NamespaceToken::local();
let a = rt
.create_entity(&tok, "org", None, "OrgA", None, None, vec![])
.await
.unwrap();
let b = rt
.create_entity(&tok, "org", None, "OrgB", None, None, vec![])
.await
.unwrap();
let result = rt
.link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
.await;
assert!(
matches!(result, Err(RuntimeError::InvalidInput(_))),
"org->org Supersedes must be rejected (not in ADR-002 allowlist), got {result:?}"
);
}
#[tokio::test]
async fn f010_supersedes_same_kind_entity_allowed() {
let rt = rt();
let tok = NamespaceToken::local();
let a = rt
.create_entity(&tok, "concept", None, "OldV", None, None, vec![])
.await
.unwrap();
let b = rt
.create_entity(&tok, "concept", None, "NewV", None, None, vec![])
.await
.unwrap();
let result = rt
.link(&tok, b.id, a.id, EdgeRelation::Supersedes, 1.0, None)
.await;
assert!(
result.is_ok(),
"concept->concept Supersedes must be allowed by ADR-002 allowlist, got {result:?}"
);
}
#[tokio::test]
async fn f161_link_always_writes_null_target_backend() {
let rt = rt();
let tok = NamespaceToken::local();
let a = rt
.create_entity(&tok, "concept", None, "A", None, None, vec![])
.await
.unwrap();
let b = rt
.create_entity(&tok, "concept", None, "B", None, None, vec![])
.await
.unwrap();
let edge = rt
.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
.await
.unwrap();
assert!(
edge.target_backend.is_none(),
"ADR-009: target_backend must be None for locally-routed edges (F161); got {:?}",
edge.target_backend
);
}
#[tokio::test]
async fn f161_link_many_always_writes_null_target_backend() {
let rt = rt();
let tok = NamespaceToken::local();
let a = rt
.create_entity(&tok, "concept", None, "A", None, None, vec![])
.await
.unwrap();
let b = rt
.create_entity(&tok, "concept", None, "B", None, None, vec![])
.await
.unwrap();
let c = rt
.create_entity(&tok, "concept", None, "C", None, None, vec![])
.await
.unwrap();
let specs = vec![
LinkSpec {
namespace: None,
source_id: a.id,
target_id: b.id,
relation: EdgeRelation::Extends,
weight: 1.0,
metadata: None,
},
LinkSpec {
namespace: None,
source_id: a.id,
target_id: c.id,
relation: EdgeRelation::Enables,
weight: 1.0,
metadata: None,
},
];
let edges = rt.link_many(&tok, specs).await.unwrap();
for edge in &edges {
assert!(
edge.target_backend.is_none(),
"ADR-009: target_backend must be None for locally-routed edges in link_many (F161); got {:?}",
edge.target_backend
);
}
}
#[tokio::test]
async fn f012_symmetric_neighbors_visible_from_both_endpoints() {
let rt = rt();
let tok = NamespaceToken::local();
let a = rt
.create_entity(&tok, "concept", None, "A", None, None, vec![])
.await
.unwrap();
let b = rt
.create_entity(&tok, "concept", None, "B", None, None, vec![])
.await
.unwrap();
rt.link(&tok, a.id, b.id, EdgeRelation::CompetesWith, 1.0, None)
.await
.unwrap();
let from_a = rt
.neighbors(
&tok,
a.id,
Direction::Out,
None,
Some(vec![EdgeRelation::CompetesWith]),
)
.await
.unwrap();
let from_b = rt
.neighbors(
&tok,
b.id,
Direction::Out,
None,
Some(vec![EdgeRelation::CompetesWith]),
)
.await
.unwrap();
assert_eq!(
from_a.len(),
1,
"node A must see competes_with neighbor from Direction::Out (F012); got {from_a:?}"
);
assert_eq!(
from_b.len(),
1,
"node B must see competes_with neighbor from Direction::Out (F012); got {from_b:?}"
);
}
#[tokio::test]
async fn f010_supersedes_cross_kind_entity_rejected() {
let rt = rt();
let tok = NamespaceToken::local();
let concept = rt
.create_entity(&tok, "concept", None, "MyConcept", None, None, vec![])
.await
.unwrap();
let doc = rt
.create_entity(&tok, "document", None, "MyDoc", None, None, vec![])
.await
.unwrap();
let result = rt
.link(
&tok,
concept.id,
doc.id,
EdgeRelation::Supersedes,
1.0,
None,
)
.await;
assert!(
matches!(result, Err(RuntimeError::InvalidInput(_))),
"concept->document Supersedes must be rejected by ADR-002 allowlist, got {result:?}"
);
}
#[tokio::test]
async fn delete_note_cross_namespace_returns_mismatch_error() {
let rt = rt();
let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
let note = rt
.create_note(
&ns_a,
"observation",
None,
"note in ns-a",
Some(0.8),
None,
vec![],
)
.await
.unwrap();
let result = rt.delete_note(&ns_b, note.id, true).await;
assert!(
matches!(result.unwrap_err(), crate::RuntimeError::NamespaceMismatch { id } if id == note.id),
"cross-namespace delete_note must return NamespaceMismatch with the note id"
);
let note_store = rt.notes(&ns_a).unwrap();
let still_there = note_store.get_note(note.id).await.unwrap();
assert!(
still_there.is_some(),
"note must survive cross-ns delete attempt"
);
}
}