use super::*;
use crate::curation::EdgeListFilter;
use crate::embedder_registry::{BlockingEmbeddingService, EmbedderProvider};
use crate::error::RuntimeError;
use crate::runtime::{KhiveRuntime, NamespaceToken};
use crate::{ActorRef, Namespace};
use async_trait::async_trait;
use khive_storage::types::{PathNode, SqlValue};
use lattice_embed::{EmbedError, EmbeddingModel, EmbeddingService, MAX_TEXT_BYTES};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::Arc;
#[path = "operations/tests/embed_failure_latency.rs"]
mod embed_failure_latency;
#[path = "operations/tests/entity_list_pages.rs"]
mod entity_list_pages;
#[path = "operations/tests/try_create_note.rs"]
mod try_create_note;
fn rt() -> KhiveRuntime {
KhiveRuntime::memory().unwrap()
}
#[test]
fn dependency_kind_inference_preserves_unmatched_metadata() {
let metadata = serde_json::json!({"note": "caller supplied"});
assert_eq!(
merge_dependency_kind("concept", "concept", Some(metadata.clone())),
Some(metadata)
);
assert_eq!(merge_dependency_kind("concept", "concept", None), None);
}
#[test]
fn edge_metadata_requires_object_and_boolean_optional() {
for invalid in [
serde_json::json!(false),
serde_json::json!("text"),
serde_json::json!([]),
] {
let error = validate_edge_metadata(EdgeRelation::DependsOn, Some(&invalid))
.expect_err("scalar or array metadata must be refused");
assert!(format!("{error}").contains("metadata must be a JSON object"));
assert!(merge_entry_metadata(Some(invalid), None).is_err());
}
let invalid_optional = serde_json::json!({"optional": "false"});
let error = validate_edge_metadata(EdgeRelation::DependsOn, Some(&invalid_optional))
.expect_err("optional must be boolean");
assert!(format!("{error}").contains("metadata.optional"));
validate_edge_metadata(
EdgeRelation::DependsOn,
Some(&serde_json::json!({"optional": false})),
)
.expect("boolean optional is valid");
assert_eq!(
merge_entry_metadata(Some(serde_json::Value::Null), None).unwrap(),
None
);
assert_eq!(
merge_dependency_kind("document", "document", Some(serde_json::Value::Null)),
Some(serde_json::json!({"dependency_kind": "normative"}))
);
}
#[test]
fn salience_rank_uses_fixed_point_at_q32_boundaries() {
for (input, expected) in [(6, 4), (-6, -4), (1, 0), (-1, 0)] {
for salience in [Some(0.5), None] {
assert_eq!(
salience_weighted_rank(DeterministicScore::from_raw(input), salience).to_raw(),
expected
);
}
}
let one = DeterministicScore::from_raw(1_i64 << 32);
for (salience, expected) in [
(1.0 / 4_294_967_296.0, 2_147_483_648),
(3.0 / 4_294_967_296.0, 2_147_483_649),
] {
assert_eq!(
salience_weighted_rank(one, Some(salience)).to_raw(),
expected
);
}
}
#[test]
fn salience_rank_preserves_identity_and_saturates() {
let exact = DeterministicScore::from_raw((1_i64 << 53) + 1);
assert_eq!(salience_weighted_rank(exact, Some(1.0)), exact);
assert_eq!(
salience_weighted_rank(DeterministicScore::from_raw(6), Some(0.0)).to_raw(),
3
);
assert_eq!(
salience_weighted_rank(DeterministicScore::MAX, Some(3.0)),
DeterministicScore::MAX
);
assert_eq!(
salience_weighted_rank(DeterministicScore::NEG_INF, Some(3.0)),
DeterministicScore::NEG_INF
);
assert_eq!(
salience_weighted_rank(DeterministicScore::ZERO, None),
DeterministicScore::ZERO
);
}
#[tokio::test]
async fn resolve_prefix_query_plan_uses_primary_key_range_seeks() {
let rt = rt();
let mut reader = rt.sql().reader().await.expect("prefix plan reader");
let mut plans = Vec::new();
let (lower, upper) = super::uuid_prefix_bounds("0b6cf134").expect("valid compact UUID prefix");
for (table, has_deleted_at) in [
("entities", true),
("notes", true),
("events", false),
("graph_edges", false),
] {
let rows = reader
.explain(super::resolve_prefix_statement(
table,
has_deleted_at,
false,
None,
&lower,
&upper,
))
.await
.expect("explain prefix query");
let details: Vec<String> = rows
.iter()
.filter_map(|row| match row.get("detail") {
Some(SqlValue::Text(detail)) => Some(detail.clone()),
_ => None,
})
.collect();
plans.push((table, details));
}
assert!(
plans.iter().all(|(table, details)| {
details.iter().any(|detail| {
detail.contains(&format!("SEARCH {table}"))
&& detail.contains("id>?")
&& detail.contains("id<?")
}) && !details
.iter()
.any(|detail| detail.contains(&format!("SCAN {table}")))
}),
"every short-id table must use a primary-key range seek: {plans:?}"
);
}
#[test]
fn fts_fault_arm_disarms_namespace_when_scope_panics() {
let ns = format!("fault-arm-drop-{}", uuid::Uuid::new_v4().as_simple());
let panic_result = std::panic::catch_unwind(|| {
let _arm = arm_fts_fail_scoped(&ns);
panic!("leave the armed scope before consumption");
});
assert!(panic_result.is_err());
assert!(
!consume_fault(&FTS_FAIL_NS, &ns),
"unwinding an armed scope must remove its namespace"
);
}
#[test]
fn fault_arm_set_rejects_entries_over_capacity() {
static BOUNDED_ARMS: std::sync::LazyLock<FaultArmSet> =
std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
let first_ns = format!("fault-arm-bound-a-{}", uuid::Uuid::new_v4().as_simple());
let overflow_ns = format!("fault-arm-bound-b-{}", uuid::Uuid::new_v4().as_simple());
let arm = arm_fault(&BOUNDED_ARMS, &first_ns, 1);
let overflow = std::panic::catch_unwind(|| arm_fault(&BOUNDED_ARMS, &overflow_ns, 1));
assert!(
overflow.is_err(),
"an arm set must reject entries over its bound"
);
drop(arm);
assert!(BOUNDED_ARMS.lock().unwrap().is_empty());
}
struct ConstVecService {
dims: usize,
}
#[async_trait]
impl EmbeddingService for ConstVecService {
async fn embed(
&self,
texts: &[String],
_model: EmbeddingModel,
) -> std::result::Result<Vec<Vec<f32>>, EmbedError> {
Ok(texts.iter().map(|_| vec![1.0_f32; self.dims]).collect())
}
fn supports_model(&self, _model: EmbeddingModel) -> bool {
true
}
fn name(&self) -> &'static str {
"const-vec"
}
}
struct ConstVecProvider {
provider_name: String,
dims: usize,
pub build_count: Arc<AtomicUsize>,
}
impl ConstVecProvider {
fn new(name: &str, dims: usize) -> (Self, Arc<AtomicUsize>) {
let counter = Arc::new(AtomicUsize::new(0));
let provider = Self {
provider_name: name.to_owned(),
dims,
build_count: Arc::clone(&counter),
};
(provider, counter)
}
}
#[async_trait]
impl EmbedderProvider for ConstVecProvider {
fn name(&self) -> &str {
&self.provider_name
}
fn dimensions(&self) -> usize {
self.dims
}
async fn build(&self) -> crate::error::RuntimeResult<Arc<dyn EmbeddingService>> {
self.build_count.fetch_add(1, Ordering::SeqCst);
Ok(Arc::new(ConstVecService { dims: self.dims }))
}
}
struct SlowVecService {
dims: usize,
}
#[async_trait]
impl EmbeddingService for SlowVecService {
async fn embed(
&self,
texts: &[String],
_model: EmbeddingModel,
) -> std::result::Result<Vec<Vec<f32>>, EmbedError> {
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
Ok(texts.iter().map(|_| vec![1.0_f32; self.dims]).collect())
}
fn supports_model(&self, _model: EmbeddingModel) -> bool {
true
}
fn name(&self) -> &'static str {
"slow-vec"
}
}
struct SlowVecProvider {
provider_name: String,
dims: usize,
}
impl SlowVecProvider {
fn new(name: &str, dims: usize) -> Self {
Self {
provider_name: name.to_owned(),
dims,
}
}
}
#[async_trait]
impl EmbedderProvider for SlowVecProvider {
fn name(&self) -> &str {
&self.provider_name
}
fn dimensions(&self) -> usize {
self.dims
}
async fn build(&self) -> crate::error::RuntimeResult<Arc<dyn EmbeddingService>> {
Ok(Arc::new(SlowVecService { dims: self.dims }))
}
}
struct FailFastProvider {
provider_name: String,
wait_for: Option<Arc<AtomicBool>>,
}
impl FailFastProvider {
fn new(name: &str) -> Self {
Self {
provider_name: name.to_owned(),
wait_for: None,
}
}
fn after_signal(name: &str, wait_for: Arc<AtomicBool>) -> Self {
Self {
provider_name: name.to_owned(),
wait_for: Some(wait_for),
}
}
}
#[async_trait]
impl EmbedderProvider for FailFastProvider {
fn name(&self) -> &str {
&self.provider_name
}
fn dimensions(&self) -> usize {
4
}
async fn build(&self) -> crate::error::RuntimeResult<Arc<dyn EmbeddingService>> {
Ok(Arc::new(FailFastService {
wait_for: self.wait_for.clone(),
}))
}
}
struct FailFastService {
wait_for: Option<Arc<AtomicBool>>,
}
#[async_trait]
impl EmbeddingService for FailFastService {
async fn embed(
&self,
_texts: &[String],
_model: EmbeddingModel,
) -> std::result::Result<Vec<Vec<f32>>, EmbedError> {
if let Some(wait_for) = &self.wait_for {
while !wait_for.load(Ordering::Acquire) {
tokio::task::yield_now().await;
}
}
Err(EmbedError::InferenceFailed(
"injected embed failure".to_string(),
))
}
fn supports_model(&self, _model: EmbeddingModel) -> bool {
true
}
fn name(&self) -> &'static str {
"fail-fast"
}
}
struct BlockingVecProvider {
provider_name: String,
dims: usize,
release: Arc<(std::sync::Mutex<bool>, std::sync::Condvar)>,
entered: Arc<AtomicBool>,
}
struct BlockingVecControls {
release: Arc<(std::sync::Mutex<bool>, std::sync::Condvar)>,
entered: Arc<AtomicBool>,
}
impl BlockingVecProvider {
fn new(name: &str, dims: usize) -> (Self, BlockingVecControls) {
let release = Arc::new((std::sync::Mutex::new(false), std::sync::Condvar::new()));
let entered = Arc::new(AtomicBool::new(false));
(
Self {
provider_name: name.to_owned(),
dims,
release: Arc::clone(&release),
entered: Arc::clone(&entered),
},
BlockingVecControls { release, entered },
)
}
}
#[async_trait]
impl EmbedderProvider for BlockingVecProvider {
fn name(&self) -> &str {
&self.provider_name
}
fn dimensions(&self) -> usize {
self.dims
}
async fn build(&self) -> crate::error::RuntimeResult<Arc<dyn EmbeddingService>> {
let service = Arc::new(BlockingVecService {
dims: self.dims,
release: Arc::clone(&self.release),
entered: Arc::clone(&self.entered),
});
Ok(Arc::new(BlockingEmbeddingService::new(service)))
}
}
struct BlockingVecService {
dims: usize,
release: Arc<(std::sync::Mutex<bool>, std::sync::Condvar)>,
entered: Arc<AtomicBool>,
}
#[async_trait]
impl EmbeddingService for BlockingVecService {
async fn embed(
&self,
texts: &[String],
_model: EmbeddingModel,
) -> std::result::Result<Vec<Vec<f32>>, EmbedError> {
self.entered.store(true, Ordering::Release);
let (released, wake) = &*self.release;
let guard = released.lock().expect("release lock must not be poisoned");
let _guard = wake
.wait_while(guard, |released| !*released)
.expect("release lock must not be poisoned");
Ok(texts.iter().map(|_| vec![1.0_f32; self.dims]).collect())
}
fn supports_model(&self, _model: EmbeddingModel) -> bool {
true
}
fn name(&self) -> &'static str {
"blocking-vec"
}
}
struct ParkedVecProvider {
provider_name: String,
dims: usize,
release: Arc<tokio::sync::Notify>,
entered: Arc<tokio::sync::Notify>,
}
impl ParkedVecProvider {
fn new(name: &str, dims: usize) -> (Self, Arc<tokio::sync::Notify>, Arc<tokio::sync::Notify>) {
let release = Arc::new(tokio::sync::Notify::new());
let entered = Arc::new(tokio::sync::Notify::new());
(
Self {
provider_name: name.to_owned(),
dims,
release: Arc::clone(&release),
entered: Arc::clone(&entered),
},
release,
entered,
)
}
}
#[async_trait]
impl EmbedderProvider for ParkedVecProvider {
fn name(&self) -> &str {
&self.provider_name
}
fn dimensions(&self) -> usize {
self.dims
}
async fn build(&self) -> crate::error::RuntimeResult<Arc<dyn EmbeddingService>> {
Ok(Arc::new(ParkedVecService {
dims: self.dims,
release: Arc::clone(&self.release),
entered: Arc::clone(&self.entered),
}))
}
}
struct ParkedVecService {
dims: usize,
release: Arc<tokio::sync::Notify>,
entered: Arc<tokio::sync::Notify>,
}
#[async_trait]
impl EmbeddingService for ParkedVecService {
async fn embed(
&self,
texts: &[String],
_model: EmbeddingModel,
) -> std::result::Result<Vec<Vec<f32>>, EmbedError> {
self.entered.notify_one();
self.release.notified().await;
Ok(texts.iter().map(|_| vec![1.0_f32; self.dims]).collect())
}
fn supports_model(&self, _model: EmbeddingModel) -> bool {
true
}
fn name(&self) -> &'static str {
"parked-vec"
}
}
#[tokio::test]
async fn custom_embedder_only_runtime_fanout_stores_vector() {
const MODEL_NAME: &str = "test-custom-encoder";
const DIMS: usize = 8;
let rt = KhiveRuntime::memory().unwrap();
let (provider, _counter) = ConstVecProvider::new(MODEL_NAME, DIMS);
rt.register_embedder(provider);
assert!(rt.config().embedding_model.is_none());
assert_eq!(rt.registered_embedding_model_names(), vec![MODEL_NAME]);
let tok = NamespaceToken::local();
let note = rt
.create_note(
&tok,
"memory",
None,
"custom embedder integration test content",
Some(0.7),
None,
vec![],
)
.await
.expect("create_note with custom-only embedder must succeed");
use khive_storage::types::VectorSearchRequest;
let query_vec = vec![1.0_f32; DIMS];
let hits = rt
.vectors_for_model(&tok, MODEL_NAME)
.expect("vector store for custom model must be accessible")
.search(VectorSearchRequest {
query_vectors: vec![query_vec],
top_k: 5,
namespace: Some(tok.namespace().as_str().to_string()),
kind: Some(khive_types::SubstrateKind::Note),
embedding_model: Some(MODEL_NAME.to_string()),
filter: None,
backend_hints: None,
})
.await
.expect("vector search succeeds");
assert!(
hits.iter().any(|h| h.subject_id == note.id),
"custom embedder must have written a vector for note {}: hits={hits:?}",
note.id
);
}
#[tokio::test]
async fn embed_with_model_accepts_custom_provider_name() {
const MODEL_NAME: &str = "my-custom-enc";
const DIMS: usize = 4;
let rt = KhiveRuntime::memory().unwrap();
let (provider, _counter) = ConstVecProvider::new(MODEL_NAME, DIMS);
rt.register_embedder(provider);
let result = rt
.embed_with_model(MODEL_NAME, "hello world")
.await
.expect("embed_with_model must accept custom provider names");
assert_eq!(
result.len(),
DIMS,
"embedding dimension must match provider"
);
assert!(
result.iter().all(|&v| (v - 1.0_f32).abs() < 1e-6),
"ConstVecService must produce all-ones vector; got: {result:?}"
);
}
#[tokio::test]
async fn embed_with_model_rejects_unregistered_name() {
let rt = KhiveRuntime::memory().unwrap();
let result = rt.embed_with_model("nonexistent-model", "hello").await;
assert!(
matches!(result.unwrap_err(), RuntimeError::UnknownModel(ref n) if n == "nonexistent-model"),
"unregistered model name must return UnknownModel"
);
}
#[tokio::test]
async fn no_embeddings_config_registers_zero_embedders() {
let config = crate::config::RuntimeConfig {
db_path: None,
packs: vec!["kg".to_string()],
..crate::config::RuntimeConfig::no_embeddings()
};
let rt = KhiveRuntime::new(config).expect("runtime construction must succeed");
assert!(rt.config().embedding_model.is_none());
assert!(
rt.registered_embedding_model_names().is_empty(),
"no_embeddings() runtime must register zero embedders"
);
}
#[tokio::test]
async fn no_embeddings_runtime_create_note_succeeds_without_model_fanout() {
let config = crate::config::RuntimeConfig {
db_path: None,
packs: vec!["kg".to_string()],
..crate::config::RuntimeConfig::no_embeddings()
};
let rt = KhiveRuntime::new(config).expect("runtime construction must succeed");
let tok = NamespaceToken::local();
let note = rt
.create_note(
&tok,
"memory",
None,
"issue-396 regression: model-less remember must succeed",
Some(0.7),
None,
vec![],
)
.await
.expect("create_note must succeed with zero registered embedders");
assert_eq!(
note.content,
"issue-396 regression: model-less remember must succeed"
);
}
#[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 concurrent_edge_patches_from_one_revision_only_one_survives() {
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.5, None)
.await
.unwrap();
let edge_id: Uuid = edge.id.into();
let graph = rt.graph(&tok).expect("graph store");
let snapshot_for_a = graph
.get_edge(LinkId::from(edge_id))
.await
.unwrap()
.expect("edge exists");
let snapshot_for_b = graph
.get_edge(LinkId::from(edge_id))
.await
.unwrap()
.expect("edge exists");
assert_eq!(
snapshot_for_a.updated_at, snapshot_for_b.updated_at,
"both readers must observe the same pre-write revision for this to be a real race"
);
let expected_updated_at = snapshot_for_a.updated_at;
let expected_deleted_at = snapshot_for_a.deleted_at;
let mut edge_a = snapshot_for_a;
edge_a.weight = 0.9;
edge_a.updated_at = expected_updated_at + chrono::Duration::microseconds(1);
let mut edge_b = snapshot_for_b;
edge_b.metadata = Some(serde_json::json!({"note": "from B"}));
edge_b.updated_at = expected_updated_at + chrono::Duration::microseconds(1);
assert!(
graph
.replace_edge_if_unchanged(edge_a, expected_updated_at, expected_deleted_at)
.await
.expect("writer A CAS query"),
"the first committer from a shared revision must win"
);
assert!(
!graph
.replace_edge_if_unchanged(edge_b, expected_updated_at, expected_deleted_at)
.await
.expect("writer B CAS query"),
"the second committer from the SAME stale revision must be refused, not merged"
);
let final_edge = graph
.get_edge(LinkId::from(edge_id))
.await
.unwrap()
.expect("edge still exists");
assert!(
(final_edge.weight - 0.9).abs() < 0.001,
"writer A's weight change must survive: {final_edge:?}"
);
assert!(
final_edge.metadata.is_none(),
"writer B's metadata must not be silently merged into the persisted row: {final_edge:?}"
);
}
#[tokio::test]
async fn production_update_edge_non_symmetric_refuses_concurrent_stale_writer() {
let rt = std::sync::Arc::new(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.5, None)
.await
.unwrap();
let edge_id: Uuid = edge.id.into();
let barrier = std::sync::Arc::new(tokio::sync::Barrier::new(2));
let writer_a = {
let rt = std::sync::Arc::clone(&rt);
let tok = tok.clone();
let barrier = std::sync::Arc::clone(&barrier);
tokio::spawn(
crate::curation::race_seam::AFTER_READ_BARRIER.scope(barrier, async move {
rt.update_edge(
&tok,
edge_id,
crate::curation::EdgePatch {
weight: Some(0.9),
..Default::default()
},
)
.await
}),
)
};
let writer_b = {
let rt = std::sync::Arc::clone(&rt);
let tok = tok.clone();
let barrier = std::sync::Arc::clone(&barrier);
tokio::spawn(
crate::curation::race_seam::AFTER_READ_BARRIER.scope(barrier, async move {
rt.update_edge(
&tok,
edge_id,
crate::curation::EdgePatch {
properties: Some(serde_json::json!({"note": "from B"})),
..Default::default()
},
)
.await
}),
)
};
let result_a = writer_a.await.expect("writer A task");
let result_b = writer_b.await.expect("writer B task");
let successes = [result_a.is_ok(), result_b.is_ok()]
.into_iter()
.filter(|ok| *ok)
.count();
assert_eq!(
successes, 1,
"exactly one production caller must win the race; the other must be refused: \
a={result_a:?} b={result_b:?}"
);
let refused = if result_a.is_err() {
result_a
} else {
result_b
};
match &refused {
Err(RuntimeError::Khive(khive_error)) => {
assert_eq!(
khive_error.kind(),
khive_types::ErrorKind::Conflict,
"the losing production caller must surface a typed conflict, not \
silently overwrite: {refused:?}"
);
}
other => panic!("expected a typed conflict error, got {other:?}"),
}
let final_edge = rt
.graph(&tok)
.expect("graph store")
.get_edge(LinkId::from(edge_id))
.await
.unwrap()
.expect("edge still exists");
assert!(
!((final_edge.weight - 0.9).abs() < 0.001 && final_edge.metadata.is_some()),
"both racers' changes must never both land: that would mean the loser's stale \
write silently succeeded: {final_edge:?}"
);
}
#[tokio::test]
async fn production_update_edge_symmetric_refuses_concurrent_stale_writer() {
let rt = std::sync::Arc::new(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::CompetesWith, 0.5, None)
.await
.unwrap();
let edge_id: Uuid = edge.id.into();
let barrier = std::sync::Arc::new(tokio::sync::Barrier::new(2));
let writer_a = {
let rt = std::sync::Arc::clone(&rt);
let tok = tok.clone();
let barrier = std::sync::Arc::clone(&barrier);
tokio::spawn(
crate::curation::race_seam::AFTER_READ_BARRIER.scope(barrier, async move {
rt.update_edge(
&tok,
edge_id,
crate::curation::EdgePatch {
weight: Some(0.9),
..Default::default()
},
)
.await
}),
)
};
let writer_b = {
let rt = std::sync::Arc::clone(&rt);
let tok = tok.clone();
let barrier = std::sync::Arc::clone(&barrier);
tokio::spawn(
crate::curation::race_seam::AFTER_READ_BARRIER.scope(barrier, async move {
rt.update_edge(
&tok,
edge_id,
crate::curation::EdgePatch {
weight: Some(0.1),
..Default::default()
},
)
.await
}),
)
};
let result_a = writer_a.await.expect("writer A task");
let result_b = writer_b.await.expect("writer B task");
let successes = [result_a.is_ok(), result_b.is_ok()]
.into_iter()
.filter(|ok| *ok)
.count();
assert_eq!(
successes, 1,
"exactly one production caller must win the symmetric-edge race; the other must \
be refused: a={result_a:?} b={result_b:?}"
);
let refused = if result_a.is_err() {
result_a
} else {
result_b
};
match &refused {
Err(RuntimeError::Khive(khive_error)) => {
assert_eq!(
khive_error.kind(),
khive_types::ErrorKind::Conflict,
"the losing production caller must surface a typed conflict, not \
silently overwrite: {refused:?}"
);
}
other => panic!("expected a typed conflict error, got {other:?}"),
}
}
#[tokio::test]
async fn update_edge_symmetric_absorption_refuses_stale_snapshot_when_survivor_exists() {
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 survivor = rt
.link(&tok, a.id, b.id, EdgeRelation::CompetesWith, 0.6, None)
.await
.unwrap();
let survivor_id: Uuid = survivor.id.into();
let e = rt
.link(&tok, a.id, b.id, EdgeRelation::Extends, 0.2, None)
.await
.unwrap();
let e_id: Uuid = e.id.into();
let stale_updated_at_micros = e.updated_at.timestamp_micros();
let stale_deleted_at_micros = e.deleted_at.map(|t| t.timestamp_micros());
let advanced = rt
.update_edge(
&tok,
e_id,
crate::curation::EdgePatch {
weight: Some(0.77),
..Default::default()
},
)
.await
.expect("concurrent writer update");
assert_ne!(
advanced.updated_at.timestamp_micros(),
stale_updated_at_micros,
"test setup: the concurrent write must actually advance the revision"
);
assert_eq!(
stale_deleted_at_micros, None,
"fixture premise: the stale snapshot must be of a LIVE edge, otherwise \
`deleted_at IS ?14` would refuse and this stops being an isolating fixture"
);
let survivor_before = rt.get_edge(&tok, survivor_id).await.unwrap().expect(
"fixture premise: the survivor must already own the canonical natural key \
before the stale DML runs",
);
assert_eq!(
survivor_before.relation,
EdgeRelation::CompetesWith,
"fixture premise: the survivor must occupy the canonical natural key this stale \
writer is about to target, otherwise there is no conflict to absorb and the \
refusal would be attributable to something else: {survivor_before:?}"
);
assert_ne!(
survivor_id, e_id,
"fixture premise: the survivor and the stale writer's edge must be distinct rows"
);
let pool = rt.backend().pool_arc();
let guard = pool.writer().expect("writer guard");
let (canon_src, canon_tgt) = canonical_edge_endpoints(EdgeRelation::CompetesWith, a.id, b.id);
let outcome = guard
.transaction(|conn| {
KhiveRuntime::update_edge_symmetric_dml(
conn,
"local",
&e_id.to_string(),
&canon_src.to_string(),
&canon_tgt.to_string(),
"competes_with",
0.9,
None,
stale_updated_at_micros,
stale_deleted_at_micros,
)
})
.expect("dml call must not error");
drop(guard);
assert!(
matches!(outcome, SymmetricEdgeUpdateOutcome::Stale),
"a stale writer must be refused, not silently absorbed into the survivor: {outcome:?}"
);
let e_after = rt
.get_edge(&tok, e_id)
.await
.unwrap()
.expect("E must still exist after the refused absorption");
assert_eq!(
e_after.relation,
EdgeRelation::Extends,
"E must be untouched by the refused absorption: {e_after:?}"
);
assert!(
(e_after.weight - 0.77).abs() < 1e-9,
"the concurrent writer's weight must survive the refused absorption: {e_after:?}"
);
let s_after = rt
.get_edge(&tok, survivor_id)
.await
.unwrap()
.expect("S must still exist after the refused absorption");
assert_eq!(
s_after.weight, 0.6,
"the survivor must be untouched by the refused absorption: {s_after:?}"
);
}
#[tokio::test]
async fn update_edge_symmetric_relation_stores_microsecond_updated_at() {
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::CompetesWith, 1.0, None)
.await
.unwrap();
let edge_id: Uuid = edge.id.into();
let before = chrono::Utc::now();
let updated = rt
.update_edge(
&tok,
edge_id,
crate::curation::EdgePatch {
weight: Some(0.5),
..Default::default()
},
)
.await
.unwrap();
let drift = (updated.updated_at - before).num_seconds().abs();
assert!(
drift < 60,
"updated_at must round-trip as a recent timestamp (micros, not \
seconds); got {:?}, expected within 60s of {:?}",
updated.updated_at,
before
);
}
#[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_symmetric_conflict_keeps_survivor_attributes() {
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 requested = rt
.link(&tok, a.id, b.id, EdgeRelation::Extends, 0.2, None)
.await
.unwrap();
let requested_id: Uuid = requested.id.into();
let canonical = rt
.link(&tok, a.id, b.id, EdgeRelation::CompetesWith, 0.6, None)
.await
.unwrap();
let canonical_id: Uuid = canonical.id.into();
let updated = rt
.update_edge(
&tok,
requested_id,
crate::curation::EdgePatch {
relation: Some(EdgeRelation::CompetesWith),
weight: Some(0.9),
..Default::default()
},
)
.await
.unwrap();
assert_eq!(Uuid::from(updated.id), canonical_id);
assert_eq!(
updated.weight, 0.6,
"survivor weight must not be overwritten by the discarded edge's patch"
);
let requested_after = rt
.get_edge_including_deleted(&tok, requested_id)
.await
.unwrap();
assert!(
requested_after.is_none(),
"the non-canonical requested row must be deleted, not just tombstoned"
);
}
#[tokio::test]
async fn update_edge_symmetric_conflict_does_not_resurrect_tombstoned_survivor() {
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 requested = rt
.link(&tok, a.id, b.id, EdgeRelation::Extends, 0.2, None)
.await
.unwrap();
let requested_id: Uuid = requested.id.into();
let canonical = rt
.link(&tok, a.id, b.id, EdgeRelation::CompetesWith, 0.6, None)
.await
.unwrap();
let canonical_id: Uuid = canonical.id.into();
rt.delete_edge(&tok, canonical_id, false).await.unwrap();
rt.update_edge(
&tok,
requested_id,
crate::curation::EdgePatch {
relation: Some(EdgeRelation::CompetesWith),
weight: Some(0.9),
..Default::default()
},
)
.await
.unwrap();
let requested_after = rt
.get_edge_including_deleted(&tok, requested_id)
.await
.unwrap();
assert!(
requested_after.is_none(),
"the non-canonical requested row must be deleted, not just tombstoned"
);
let canonical_after = rt.get_edge(&tok, canonical_id).await.unwrap();
assert!(
canonical_after.is_none(),
"a tombstoned survivor must not be resurrected by a conflicting update"
);
}
#[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_multi_namespace_offset_paging_enumerates_exactly() {
let rt = rt();
let ns_a = Namespace::parse("ns-a").unwrap();
let ns_b = Namespace::parse("ns-b").unwrap();
let tok_a = NamespaceToken::for_namespace(ns_a.clone());
let tok_b = NamespaceToken::for_namespace(ns_b.clone());
let source = rt
.create_entity(&tok_a, "concept", None, "Source", None, None, vec![])
.await
.unwrap();
let mut targets = Vec::new();
for index in 0..6 {
targets.push(
rt.create_entity(
&tok_a,
"concept",
None,
&format!("Target{index}"),
None,
None,
vec![],
)
.await
.unwrap(),
);
}
let a_uuids = [
Uuid::parse_str("ffffffff-ffff-4fff-8fff-ffffffffff01").unwrap(),
Uuid::parse_str("ffffffff-ffff-4fff-8fff-ffffffffff02").unwrap(),
Uuid::parse_str("ffffffff-ffff-4fff-8fff-ffffffffff03").unwrap(),
];
let b_uuids = [
Uuid::parse_str("00000000-0000-4000-8000-000000000001").unwrap(),
Uuid::parse_str("00000000-0000-4000-8000-000000000002").unwrap(),
Uuid::parse_str("00000000-0000-4000-8000-000000000003").unwrap(),
];
let graph_a = rt.graph(&tok_a).unwrap();
let graph_b = rt.graph(&tok_b).unwrap();
let mut expected_order: Vec<Uuid> = Vec::new();
for i in 0..3usize {
let a_created_at = chrono::DateTime::<chrono::Utc>::from_timestamp_micros(
1_000_000 + (2 * i as i64) * 1_000_000,
)
.unwrap();
graph_a
.upsert_edge(Edge {
id: a_uuids[i].into(),
namespace: "ns-a".into(),
source_id: source.id,
target_id: targets[2 * i].id,
relation: EdgeRelation::Extends,
weight: 0.5,
created_at: a_created_at,
updated_at: a_created_at,
deleted_at: None,
metadata: None,
target_backend: None,
})
.await
.unwrap();
expected_order.push(a_uuids[i]);
let b_created_at = chrono::DateTime::<chrono::Utc>::from_timestamp_micros(
2_000_000 + (2 * i as i64) * 1_000_000,
)
.unwrap();
graph_b
.upsert_edge(Edge {
id: b_uuids[i].into(),
namespace: "ns-b".into(),
source_id: source.id,
target_id: targets[2 * i + 1].id,
relation: EdgeRelation::Extends,
weight: 0.5,
created_at: b_created_at,
updated_at: b_created_at,
deleted_at: None,
metadata: None,
target_backend: None,
})
.await
.unwrap();
expected_order.push(b_uuids[i]);
}
let multi = NamespaceToken::mint_with_visibility(ns_a, vec![ns_b], ActorRef::anonymous());
let mut seen: Vec<Uuid> = Vec::new();
let mut offset = 0u32;
loop {
let page = rt
.list_edges(&multi, EdgeListFilter::default(), 2, offset)
.await
.unwrap();
if page.is_empty() {
break;
}
seen.extend(page.iter().map(|e| Uuid::from(e.id)));
offset += 2;
}
assert_eq!(
seen, expected_order,
"must enumerate in exact created_at order, not UUID order"
);
let distinct: std::collections::HashSet<Uuid> = seen.iter().copied().collect();
assert_eq!(distinct.len(), 6, "no duplicates across pages");
let expected_set: std::collections::HashSet<Uuid> = expected_order.iter().copied().collect();
assert_eq!(
distinct, expected_set,
"enumerated set must equal the seeded set"
);
}
#[test]
fn merge_paged_namespace_edges_orders_by_created_at_not_uuid() {
fn edge(id_hex_suffix: &str, created_at_micros: i64) -> Edge {
let created_at =
chrono::DateTime::<chrono::Utc>::from_timestamp_micros(created_at_micros).unwrap();
Edge {
id: Uuid::parse_str(&format!("00000000-0000-4000-8000-{id_hex_suffix}"))
.unwrap()
.into(),
namespace: "irrelevant".into(),
source_id: Uuid::nil(),
target_id: Uuid::nil(),
relation: EdgeRelation::Extends,
weight: 0.5,
created_at,
updated_at: created_at,
deleted_at: None,
metadata: None,
target_backend: None,
}
}
let a0 = edge("ffffffffffff", 1_000_000);
let b0 = edge("000000000001", 2_000_000);
let a1 = edge("fffffffffffe", 3_000_000);
let b1 = edge("000000000002", 4_000_000);
let namespace_prefixes = vec![vec![a0.clone(), a1.clone()], vec![b0.clone(), b1.clone()]];
let full_page = KhiveRuntime::merge_paged_namespace_edges(namespace_prefixes.clone(), 0, 4);
let ids: Vec<Uuid> = full_page.iter().map(|e| Uuid::from(e.id)).collect();
assert_eq!(
ids,
vec![
Uuid::from(a0.id),
Uuid::from(b0.id),
Uuid::from(a1.id),
Uuid::from(b1.id)
],
"must order the merge by created_at, not by UUID magnitude"
);
let middle_page = KhiveRuntime::merge_paged_namespace_edges(namespace_prefixes, 1, 2);
let middle_ids: Vec<Uuid> = middle_page.iter().map(|e| Uuid::from(e.id)).collect();
assert_eq!(middle_ids, vec![Uuid::from(b0.id), Uuid::from(a1.id)]);
}
#[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, 0).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, 0).await.unwrap();
assert_eq!(edges.len(), 1);
let src: Uuid = edges[0].source_id;
assert_eq!(src, a.id);
}
#[tokio::test]
async fn list_edges_offset_pages_through_full_set() {
let rt = rt();
let tok = NamespaceToken::local();
let a = rt
.create_entity(&tok, "concept", None, "A", None, None, vec![])
.await
.unwrap();
for i in 0..5 {
let t = rt
.create_entity(&tok, "concept", None, &format!("T{i}"), None, None, vec![])
.await
.unwrap();
rt.link(&tok, a.id, t.id, EdgeRelation::Extends, 1.0, None)
.await
.unwrap();
}
let filter = EdgeListFilter {
source_id: Some(a.id),
relations: vec![EdgeRelation::Extends],
..Default::default()
};
let page0 = rt.list_edges(&tok, filter.clone(), 2, 0).await.unwrap();
let page1 = rt.list_edges(&tok, filter.clone(), 2, 2).await.unwrap();
let page2 = rt.list_edges(&tok, filter.clone(), 2, 4).await.unwrap();
assert_eq!(page0.len(), 2);
assert_eq!(page1.len(), 2);
assert_eq!(page2.len(), 1);
let ids = |p: &[Edge]| p.iter().map(|e| Uuid::from(e.id)).collect::<Vec<_>>();
assert_ne!(ids(&page0), ids(&page1), "page 2 must differ from page 1");
let mut all_ids: Vec<Uuid> = ids(&page0)
.into_iter()
.chain(ids(&page1))
.chain(ids(&page2))
.collect();
all_ids.sort();
all_ids.dedup();
assert_eq!(all_ids.len(), 5, "pages must tile the full edge set");
let empty = rt.list_edges(&tok, filter.clone(), 2, 100).await.unwrap();
assert!(
empty.is_empty(),
"offset past the end must return empty, not page 1"
);
}
#[tokio::test]
async fn list_edges_after_keyset_tiles_full_set() {
let rt = rt();
let tok = NamespaceToken::local();
let a = rt
.create_entity(&tok, "concept", None, "A", None, None, vec![])
.await
.unwrap();
for i in 0..5 {
let t = rt
.create_entity(&tok, "concept", None, &format!("K{i}"), None, None, vec![])
.await
.unwrap();
rt.link(&tok, a.id, t.id, EdgeRelation::Extends, 1.0, None)
.await
.unwrap();
}
let filter = EdgeListFilter {
source_id: Some(a.id),
relations: vec![EdgeRelation::Extends],
..Default::default()
};
let mut seen = Vec::new();
let mut cursor: Option<Uuid> = None;
for _ in 0..20 {
let (page, next) = rt
.list_edges_after(&tok, filter.clone(), cursor, 2)
.await
.unwrap();
if page.is_empty() {
break;
}
seen.extend(page.iter().map(|e| Uuid::from(e.id)));
if next.is_none() {
break;
}
cursor = next;
}
seen.sort();
seen.dedup();
assert_eq!(seen.len(), 5, "keyset walk must tile the full edge set");
let (first_a, next_a) = rt
.list_edges_after(&tok, filter.clone(), None, 2)
.await
.unwrap();
let (first_b, next_b) = rt
.list_edges_after(&tok, filter.clone(), None, 2)
.await
.unwrap();
assert_eq!(
first_a.iter().map(|e| e.id.0).collect::<Vec<_>>(),
first_b.iter().map(|e| e.id.0).collect::<Vec<_>>(),
);
assert_eq!(next_a, next_b);
}
#[tokio::test]
async fn list_entities_after_same_timestamp_lower_uuid_insert_is_not_skipped() {
let rt = rt();
let tok = NamespaceToken::local();
let ids = [
Uuid::parse_str("f0000000-0000-4000-8000-000000000011").unwrap(),
Uuid::parse_str("f0000000-0000-4000-8000-000000000012").unwrap(),
Uuid::parse_str("f0000000-0000-4000-8000-000000000013").unwrap(),
];
let store = rt.entities(&tok).unwrap();
for (index, id) in ids.into_iter().enumerate() {
let mut entity = Entity::new("local", "concept", format!("Entity{index}"));
entity.id = id;
entity.created_at = 1_000_000;
entity.updated_at = 1_000_000;
store.upsert_entity(entity).await.unwrap();
}
let (first, next) = rt
.list_entities_after(&tok, None, None, &[], None, 2)
.await
.unwrap();
assert_eq!(
first.iter().map(|entity| entity.id).collect::<Vec<_>>(),
ids[..2]
);
let cursor = next.expect("one original entity remains after page one");
let low_id = Uuid::parse_str("00000000-0000-4000-8000-000000000011").unwrap();
assert!(low_id < cursor);
let mut inserted = Entity::new("local", "concept", "InsertedEntity");
inserted.id = low_id;
inserted.created_at = 1_000_000;
inserted.updated_at = 1_000_000;
store.upsert_entity(inserted).await.unwrap();
let (second, final_cursor) = rt
.list_entities_after(&tok, None, None, &[], Some(cursor), 2)
.await
.unwrap();
assert_eq!(
second.iter().map(|entity| entity.id).collect::<Vec<_>>(),
vec![ids[2], low_id]
);
assert_eq!(final_cursor, None);
}
#[tokio::test]
async fn list_notes_after_same_timestamp_lower_uuid_insert_is_not_skipped() {
let rt = rt();
let tok = NamespaceToken::local();
let ids = [
Uuid::parse_str("f0000000-0000-4000-8000-000000000021").unwrap(),
Uuid::parse_str("f0000000-0000-4000-8000-000000000022").unwrap(),
Uuid::parse_str("f0000000-0000-4000-8000-000000000023").unwrap(),
];
let store = rt.notes(&tok).unwrap();
for (index, id) in ids.into_iter().enumerate() {
let mut note = Note::new("local", "observation", format!("Note {index}"));
note.id = id;
note.created_at = 1_000_000;
note.updated_at = 1_000_000;
store.upsert_note(note).await.unwrap();
}
let (first, next) = rt.list_notes_after(&tok, None, None, 2).await.unwrap();
assert_eq!(
first.iter().map(|note| note.id).collect::<Vec<_>>(),
ids[..2]
);
let cursor = next.expect("one original note remains after page one");
let low_id = Uuid::parse_str("00000000-0000-4000-8000-000000000021").unwrap();
assert!(low_id < cursor);
let mut inserted = Note::new("local", "observation", "Inserted note");
inserted.id = low_id;
inserted.created_at = 1_000_000;
inserted.updated_at = 1_000_000;
store.upsert_note(inserted).await.unwrap();
let (second, final_cursor) = rt
.list_notes_after(&tok, None, Some(cursor), 2)
.await
.unwrap();
assert_eq!(
second.iter().map(|note| note.id).collect::<Vec<_>>(),
vec![ids[2], low_id]
);
assert_eq!(final_cursor, None);
}
#[tokio::test]
async fn list_edges_after_same_timestamp_lower_uuid_insert_is_not_skipped() {
let rt = rt();
let tok = NamespaceToken::local();
let source = rt
.create_entity(&tok, "concept", None, "CursorSource", None, None, vec![])
.await
.unwrap();
let mut targets = Vec::new();
for index in 0..4 {
targets.push(
rt.create_entity(
&tok,
"concept",
None,
&format!("CursorTarget{index}"),
None,
None,
vec![],
)
.await
.unwrap(),
);
}
let ids = [
Uuid::parse_str("f0000000-0000-4000-8000-000000000001").unwrap(),
Uuid::parse_str("f0000000-0000-4000-8000-000000000002").unwrap(),
Uuid::parse_str("f0000000-0000-4000-8000-000000000003").unwrap(),
];
let graph = rt.graph(&tok).unwrap();
let created_at = chrono::DateTime::<chrono::Utc>::from_timestamp_micros(1_000_000).unwrap();
for (index, id) in ids.into_iter().enumerate() {
graph
.upsert_edge(Edge {
id: id.into(),
namespace: "local".into(),
source_id: source.id,
target_id: targets[index].id,
relation: EdgeRelation::Extends,
weight: 1.0,
created_at,
updated_at: created_at,
deleted_at: None,
metadata: None,
target_backend: None,
})
.await
.unwrap();
}
let filter = EdgeListFilter {
source_id: Some(source.id),
relations: vec![EdgeRelation::Extends],
..Default::default()
};
let (first, next) = rt
.list_edges_after(&tok, filter.clone(), None, 2)
.await
.unwrap();
assert_eq!(
first.iter().map(|edge| edge.id.0).collect::<Vec<_>>(),
ids[..2]
);
let cursor = next.expect("one original edge remains after page one");
let low_id = Uuid::parse_str("00000000-0000-4000-8000-000000000001").unwrap();
assert!(low_id < cursor);
graph
.upsert_edge(Edge {
id: low_id.into(),
namespace: "local".into(),
source_id: source.id,
target_id: targets[3].id,
relation: EdgeRelation::Extends,
weight: 1.0,
created_at,
updated_at: created_at,
deleted_at: None,
metadata: None,
target_backend: None,
})
.await
.unwrap();
let (second, final_cursor) = rt
.list_edges_after(&tok, filter, Some(cursor), 2)
.await
.unwrap();
assert_eq!(
second.iter().map(|edge| edge.id.0).collect::<Vec<_>>(),
vec![ids[2], low_id]
);
assert_eq!(final_cursor, None);
}
#[tokio::test]
async fn list_entity_cursor_survives_soft_delete_and_rejects_hard_delete_or_hidden_scope() {
let rt = rt();
let local = NamespaceToken::local();
for index in 0..3 {
rt.create_entity(
&local,
"concept",
None,
&format!("CursorLifecycle{index}"),
None,
None,
vec![],
)
.await
.unwrap();
}
let (_, next) = rt
.list_entities_after(&local, None, None, &[], None, 2)
.await
.unwrap();
let cursor = next.expect("three entities require a second page");
let store = rt.entities(&local).unwrap();
assert!(store.delete_entity(cursor, DeleteMode::Soft).await.unwrap());
let (remaining, next) = rt
.list_entities_after(&local, None, None, &[], Some(cursor), 2)
.await
.expect("a soft-deleted cursor must retain its sequence boundary");
assert_eq!(remaining.len(), 1);
assert_eq!(next, None);
assert!(store.delete_entity(cursor, DeleteMode::Hard).await.unwrap());
let hard_deleted = rt
.list_entities_after(&local, None, None, &[], Some(cursor), 2)
.await;
assert!(
matches!(hard_deleted, Err(RuntimeError::NotFound(_))),
"a hard-deleted cursor must fail explicitly: {hard_deleted:?}"
);
let hidden_ns = Namespace::parse("cursor-hidden").unwrap();
let hidden_token = NamespaceToken::for_namespace(hidden_ns);
let hidden = rt
.create_entity(
&hidden_token,
"concept",
None,
"HiddenCursor",
None,
None,
vec![],
)
.await
.unwrap();
let out_of_scope = rt
.list_entities_after(&local, None, None, &[], Some(hidden.id), 2)
.await;
assert!(
matches!(out_of_scope, Err(RuntimeError::NotFound(_))),
"an out-of-scope cursor must not reveal or resume from the hidden row: {out_of_scope:?}"
);
}
#[tokio::test]
async fn list_edges_after_single_namespace_exact_final_page_has_no_next_after() {
let rt = rt();
let tok = NamespaceToken::local();
let a = rt
.create_entity(&tok, "concept", None, "SingleCursorA", None, None, vec![])
.await
.unwrap();
for i in 0..4 {
let t = rt
.create_entity(
&tok,
"concept",
None,
&format!("SingleCursorT{i}"),
None,
None,
vec![],
)
.await
.unwrap();
rt.link(&tok, a.id, t.id, EdgeRelation::Extends, 1.0, None)
.await
.unwrap();
}
let filter = EdgeListFilter {
source_id: Some(a.id),
relations: vec![EdgeRelation::Extends],
..Default::default()
};
let (page1, next1) = rt
.list_edges_after(&tok, filter.clone(), None, 2)
.await
.unwrap();
assert_eq!(page1.len(), 2);
let cursor = next1.expect("first page must report a cursor when two rows remain");
let (page2, next2) = rt
.list_edges_after(&tok, filter, Some(cursor), 2)
.await
.unwrap();
assert_eq!(page2.len(), 2);
assert_eq!(
next2, None,
"an exact-size final single-namespace page must not report a cursor"
);
}
#[tokio::test]
async fn list_edges_after_multi_namespace_exact_final_page_has_no_next_after() {
let rt = rt();
let ns_a = Namespace::parse("cursor-ns-a").unwrap();
let ns_b = Namespace::parse("cursor-ns-b").unwrap();
let tok_a = NamespaceToken::for_namespace(ns_a.clone());
let tok_b = NamespaceToken::for_namespace(ns_b.clone());
let visible = NamespaceToken::mint_with_visibility(ns_a, vec![ns_b], ActorRef::anonymous());
for (tok, prefix) in [(&tok_a, "A"), (&tok_b, "B")] {
let source = rt
.create_entity(
tok,
"concept",
None,
&format!("MultiCursor{prefix}Source"),
None,
None,
vec![],
)
.await
.unwrap();
for i in 0..2 {
let target = rt
.create_entity(
tok,
"concept",
None,
&format!("MultiCursor{prefix}Target{i}"),
None,
None,
vec![],
)
.await
.unwrap();
rt.link(tok, source.id, target.id, EdgeRelation::Extends, 1.0, None)
.await
.unwrap();
}
}
let filter = EdgeListFilter {
relations: vec![EdgeRelation::Extends],
..Default::default()
};
let (page1, next1) = rt
.list_edges_after(&visible, filter.clone(), None, 2)
.await
.unwrap();
assert_eq!(page1.len(), 2);
let cursor = next1.expect("first merged page must report a cursor when rows remain");
let (page2, next2) = rt
.list_edges_after(&visible, filter, Some(cursor), 2)
.await
.unwrap();
assert_eq!(page2.len(), 2);
assert_eq!(
next2, None,
"an exact-size final multi-namespace page must not report a cursor"
);
}
#[tokio::test]
async fn count_edges_by_relation_matches_fixtures() {
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::Extends, 1.0, None)
.await
.unwrap();
rt.link(&tok, b.id, c.id, EdgeRelation::Enables, 1.0, None)
.await
.unwrap();
let counts = rt.count_edges_by_relation(&tok).await.unwrap();
assert_eq!(counts.get("extends").copied(), Some(2));
assert_eq!(counts.get("enables").copied(), Some(1));
}
#[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 edge_in_visible_namespace_reachable_as_graph_root() {
let rt = rt();
let ns_a = Namespace::parse("vis-edge-a").unwrap();
let ns_b = Namespace::parse("vis-edge-b").unwrap();
let tok_b = NamespaceToken::for_namespace(ns_b.clone());
let src = rt
.create_entity(&tok_b, "concept", None, "SrcB", None, None, vec![])
.await
.unwrap();
let tgt = rt
.create_entity(&tok_b, "concept", None, "TgtB", None, None, vec![])
.await
.unwrap();
let edge = rt
.link(&tok_b, src.id, tgt.id, EdgeRelation::Extends, 1.0, None)
.await
.unwrap();
let tok_a_vis = rt
.authorize_with_visibility(ns_a.clone(), vec![ns_b.clone()])
.unwrap();
let got = rt.get_edge_visible(&tok_a_vis, edge.id.0).await.unwrap();
assert!(
got.is_some(),
"edge in visible namespace must be retrievable via get_edge_visible"
);
let neighbors = rt
.neighbors(&tok_a_vis, src.id, Direction::Out, Some(16), None)
.await
.unwrap();
assert!(
neighbors.iter().any(|h| h.node_id == tgt.id),
"neighbors of visible-ns node must include its visible-ns neighbor; got: {neighbors:?}"
);
}
#[tokio::test]
async fn neighbors_accepts_foreign_full_uuid_anchor_but_scopes_returned_edges() {
let rt = rt();
let ns_a = Namespace::parse("neighbor-owner").unwrap();
let ns_b = Namespace::parse("neighbor-caller").unwrap();
let tok_a = NamespaceToken::for_namespace(ns_a.clone());
let tok_b = NamespaceToken::for_namespace(ns_b.clone());
let src = rt
.create_entity(&tok_a, "concept", None, "Source", None, None, vec![])
.await
.unwrap();
let tgt = rt
.create_entity(&tok_a, "concept", None, "Target", None, None, vec![])
.await
.unwrap();
let isolated = rt
.create_entity(&tok_a, "concept", None, "Isolated", None, None, vec![])
.await
.unwrap();
let note = rt
.create_note(&tok_a, "observation", None, "Note", None, None, vec![])
.await
.unwrap();
let caller_target = rt
.create_entity(&tok_b, "concept", None, "Caller target", None, None, vec![])
.await
.unwrap();
let edge = rt
.link(&tok_a, src.id, tgt.id, EdgeRelation::Extends, 1.0, None)
.await
.unwrap();
rt.link(
&tok_b,
src.id,
caller_target.id,
EdgeRelation::Extends,
1.0,
None,
)
.await
.unwrap();
let own_hits = rt
.neighbors(&tok_a, src.id, Direction::Out, None, None)
.await
.unwrap();
assert_eq!(own_hits.len(), 1);
assert_eq!(own_hits[0].node_id, tgt.id);
assert!(rt.get_entity(&tok_b, src.id).await.is_ok());
assert!(rt.get_edge(&tok_b, edge.id.0).await.unwrap().is_some());
let foreign_hits = rt
.neighbors(&tok_b, src.id, Direction::Out, None, None)
.await
.unwrap();
assert_eq!(foreign_hits.len(), 1);
assert_eq!(foreign_hits[0].node_id, caller_target.id);
for anchor in [note.id, edge.id.0, isolated.id] {
assert!(
rt.neighbors(&tok_b, anchor, Direction::Out, None, None)
.await
.unwrap()
.is_empty(),
"live foreign anchor without a caller-visible edge must be empty"
);
}
let missing = Uuid::new_v4();
assert!(matches!(
rt.neighbors(&tok_b, missing, Direction::Out, None, None)
.await,
Err(RuntimeError::NotFound(message)) if message.contains(&missing.to_string())
));
let page = rt
.neighbors_with_query_page(
&tok_b,
src.id,
NeighborQuery {
direction: Direction::Out,
relations: None,
limit: Some(1),
min_weight: None,
},
None,
None,
true,
)
.await;
let page = page.unwrap();
assert_eq!(page.len(), 1);
assert_eq!(page[0].node_id, caller_target.id);
let visible_from_b = rt.authorize_with_visibility(ns_b, vec![ns_a]).unwrap();
let shared_hits = rt
.neighbors(&visible_from_b, src.id, Direction::Out, None, None)
.await
.unwrap();
assert_eq!(shared_hits.len(), 2);
assert!(shared_hits.iter().any(|hit| hit.node_id == tgt.id));
assert!(shared_hits
.iter()
.any(|hit| hit.node_id == caller_target.id));
let isolated_hits = rt
.neighbors(&tok_a, isolated.id, Direction::Out, None, None)
.await
.unwrap();
assert!(isolated_hits.is_empty());
}
#[tokio::test]
async fn get_entity_cross_namespace_no_longer_denied() {
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(), "same-namespace get must succeed");
let cross = rt.get_entity(&ns_b, entity.id).await;
assert!(
cross.is_ok(),
"cross-namespace get must succeed in shared-brain OSS (ADR-007 rule 2)"
);
assert_eq!(cross.unwrap().id, entity.id);
}
#[tokio::test]
async fn delete_entity_cross_namespace_no_longer_denied() {
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_ok(),
"cross-namespace delete must succeed in shared-brain OSS; got {:?}",
cross_ns_result
);
assert!(cross_ns_result.unwrap(), "delete must return true");
let gone = rt.get_entity(&ns_a, entity.id).await;
assert!(gone.is_err(), "entity must be gone after delete");
}
#[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 search_notes_with_residual_fts5_char_now_sanitized() {
let rt = rt();
let tok = NamespaceToken::local();
rt.create_note(
&tok,
"observation",
None,
"use foo@bar to chain calls",
Some(0.5),
None,
vec![],
)
.await
.unwrap();
let result = rt
.search_notes(&tok, "foo@bar", None, 10, None, false, &[], None)
.await;
let hits = result.unwrap_or_else(|e| {
panic!("#916 search_notes must not fail on an '@'-bearing query, got: {e:?}")
});
assert!(
!hits.is_empty(),
"#916 '@'-bearing query must still find the seeded 'foo@bar' content via the \
quoted-phrase alternative"
);
}
#[tokio::test]
async fn search_notes_propagates_non_parser_fts_error() {
let rt = rt();
let tok = NamespaceToken::local();
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();
arm_fts_search_fail(&ns);
let result = rt
.search_notes(&tok, "FlashAttention", None, 10, None, false, &[], None)
.await;
assert!(
result.is_err(),
"search_notes must propagate a non-parser FTS StorageError (Timeout) \
as Err, not silently degrade it to an empty result, got: {:?}",
result.ok()
);
assert!(
matches!(
result.unwrap_err(),
RuntimeError::Storage(khive_storage::StorageError::Timeout { .. })
),
"propagated error must be the injected StorageError::Timeout, unwrapped \
through RuntimeError::Storage"
);
}
#[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 neighbors_with_query_directed_preserves_self_loop_direction_parity() {
let rt = rt();
let tok = NamespaceToken::local();
let centre = rt
.create_entity(&tok, "concept", None, "Centre", None, None, vec![])
.await
.unwrap();
let now = chrono::Utc::now();
rt.graph(&tok)
.unwrap()
.upsert_edge(Edge {
id: LinkId::from(Uuid::new_v4()),
namespace: "local".to_string(),
source_id: centre.id,
target_id: centre.id,
relation: EdgeRelation::Extends,
weight: 0.7,
created_at: now,
updated_at: now,
deleted_at: None,
metadata: None,
target_backend: None,
})
.await
.unwrap();
let directed = rt
.neighbors_with_query_directed(
&tok,
centre.id,
NeighborQuery {
direction: Direction::Both,
relations: None,
limit: None,
min_weight: None,
},
)
.await
.unwrap();
assert_eq!(
directed.len(),
2,
"a self-loop edge must produce both an Out hit and an In hit, not one collapsed hit"
);
let directions: Vec<Direction> = directed.iter().map(|(_, d)| d.clone()).collect();
assert!(
directions.contains(&Direction::Out),
"self-loop must retain its Out-tagged hit"
);
assert!(
directions.contains(&Direction::In),
"self-loop must retain its In-tagged hit"
);
}
#[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, &[], None)
.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,
&[],
None,
)
.await
.unwrap();
assert!(
results.iter().all(|h| h.note_id != note.id),
"soft-deleted note should be excluded from search"
);
}
#[tokio::test]
async fn search_notes_tag_filter_pushed_before_truncation() {
let rt = rt();
let tok = NamespaceToken::local();
rt.create_note(
&tok,
"observation",
None,
"kappa lambda mu note decoy kappa lambda mu note decoy kappa lambda mu",
Some(0.5),
Some(serde_json::json!({"tags": ["other-note-tag"]})),
vec![],
)
.await
.unwrap();
let target = rt
.create_note(
&tok,
"observation",
None,
"kappa lambda mu note target",
Some(0.5),
Some(serde_json::json!({"tags": ["note-target-tag"]})),
vec![],
)
.await
.unwrap();
let hits = rt
.search_notes(
&tok,
"kappa lambda mu note",
None,
1,
None,
false,
&["note-target-tag".to_string()],
None,
)
.await
.unwrap();
assert_eq!(
hits.len(),
1,
"exactly one hit expected (tag-matching note)"
);
assert_eq!(
hits[0].note_id, target.id,
"tag-filtered note must be returned even when ranked below limit in raw fusion"
);
}
#[tokio::test]
async fn search_notes_kind_filter_pushed_into_text_arm() {
let rt = rt();
let tok = NamespaceToken::local();
for i in 0..40 {
rt.create_note(
&tok,
"observation",
None,
&format!("ref needle-93 decoy {i}"),
Some(0.5),
None,
vec![],
)
.await
.unwrap();
}
let mut targets = Vec::new();
for i in 0..6 {
let target = rt
.create_note(
&tok,
"task",
Some(&format!("task {i} tracking the review")),
&format!(
"Long description number {i}: the work item needle-93 waits on the \
build, the test report, the release, and the follow-up sweep of every \
row that cited it; none of that shortens the row."
),
Some(0.5),
None,
vec![],
)
.await
.unwrap();
targets.push(target.id);
}
let hits = rt
.search_notes(&tok, "needle-93", None, 8, Some("task"), false, &[], None)
.await
.unwrap();
let mut got: Vec<Uuid> = hits.iter().map(|h| h.note_id).collect();
got.sort();
targets.sort();
assert_eq!(
got,
targets,
"every task carrying the literal must be returned when the caller asks for tasks; \
got {} hit(s)",
hits.len()
);
}
#[tokio::test]
async fn search_notes_props_filter_pushed_before_truncation() {
let rt = rt();
let tok = NamespaceToken::local();
rt.create_note(
&tok,
"observation",
None,
"nu xi omicron note decoy nu xi omicron note decoy nu xi omicron",
Some(0.5),
Some(serde_json::json!({"source": "other"})),
vec![],
)
.await
.unwrap();
let target = rt
.create_note(
&tok,
"observation",
None,
"nu xi omicron note target",
Some(0.5),
Some(serde_json::json!({"source": "target"})),
vec![],
)
.await
.unwrap();
let filter = serde_json::json!({"source": "target"});
let hits = rt
.search_notes(
&tok,
"nu xi omicron note",
None,
1,
None,
false,
&[],
Some(&filter),
)
.await
.unwrap();
assert_eq!(
hits.len(),
1,
"exactly one hit expected (properties-matching note)"
);
assert_eq!(
hits[0].note_id, target.id,
"properties-filtered note must be returned even when ranked below limit"
);
}
#[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));
}
#[test]
fn hex_prefix_to_uuid_pattern_inserts_hyphens_at_canonical_boundaries() {
let full = "aabbccdd112240008000000000000ab1";
let cases: &[(usize, &str)] = &[
(1, "a"),
(7, "aabbccd"),
(8, "aabbccdd"),
(9, "aabbccdd-1"),
(12, "aabbccdd-1122"),
(13, "aabbccdd-1122-4"),
(16, "aabbccdd-1122-4000"),
(18, "aabbccdd-1122-4000-80"),
(20, "aabbccdd-1122-4000-8000"),
(23, "aabbccdd-1122-4000-8000-000"),
(24, "aabbccdd-1122-4000-8000-0000"),
(28, "aabbccdd-1122-4000-8000-00000000"),
(31, "aabbccdd-1122-4000-8000-000000000ab"),
];
for (len, expected) in cases {
let input = &full[..*len];
assert_eq!(
hex_prefix_to_uuid_pattern(input),
*expected,
"len={len} input={input:?}"
);
}
}
#[test]
fn hex_prefix_to_uuid_pattern_full_32_char_matches_canonical_uuid() {
let compact = "aabbccdd112240008000000000000ab1";
let compact32 = &compact[..32];
assert_eq!(
hex_prefix_to_uuid_pattern(compact32),
"aabbccdd-1122-4000-8000-000000000ab1"
);
}
#[test]
fn hex_prefix_to_uuid_pattern_overlong_input_is_not_truncated() {
let compact32 = "aabbccdd112240008000000000000ab1";
let overlong = format!("{compact32}extrahex");
let pattern = hex_prefix_to_uuid_pattern(&overlong);
assert_eq!(
pattern, "aabbccdd-1122-4000-8000-000000000ab1extrahex",
"overlong input must keep its extra chars, not truncate to the valid UUID"
);
assert_ne!(
pattern, "aabbccdd-1122-4000-8000-000000000ab1",
"overlong pattern must not collapse to the canonical 36-char UUID form"
);
}
#[test]
fn hex_prefix_to_uuid_pattern_passes_through_hyphenated_input() {
let hyphenated = "aabbccdd-1122-4000-8000-000000000ab1";
assert_eq!(hex_prefix_to_uuid_pattern(hyphenated), hyphenated);
let partial = "aabbccdd-11";
assert_eq!(hex_prefix_to_uuid_pattern(partial), partial);
}
#[test]
fn uuid_prefix_bounds_are_canonical_lowercase_half_open_ranges() {
assert_eq!(
super::uuid_prefix_bounds("0ABCDEFF"),
Some(("0abcdeff".into(), "0abcdf".into()))
);
assert_eq!(
super::uuid_prefix_bounds("aabbccdd1"),
Some(("aabbccdd-1".into(), "aabbccdd-2".into()))
);
assert_eq!(
super::uuid_prefix_bounds("AABBCCDD-19FF"),
Some(("aabbccdd-19ff".into(), "aabbccdd-1a".into()))
);
assert_eq!(
super::uuid_prefix_bounds("ffffffff"),
Some(("ffffffff".into(), "g".into()))
);
}
#[test]
fn uuid_prefix_bounds_reject_malformed_or_overlong_input() {
for invalid in [
"",
"aabbccdd_",
"aabbccdd1-",
"aabbccdd-11111",
"aabbccdd--",
"aabbccdd112240008000000000000ab1f",
] {
assert_eq!(
super::uuid_prefix_bounds(invalid),
None,
"invalid prefix {invalid:?} must fail closed"
);
}
}
#[tokio::test]
async fn resolve_prefix_compact_9_to_31_char_matches() {
let rt = rt();
let tok = NamespaceToken::local();
let entity = rt
.create_entity(
&tok,
"concept",
None,
"CompactPrefixTest",
None,
None,
vec![],
)
.await
.unwrap();
let compact = entity.id.simple().to_string();
for len in [9, 12, 16, 20, 24, 28, 31] {
let prefix = &compact[..len];
let resolved = rt.resolve_prefix(&tok, prefix).await.unwrap();
assert_eq!(
resolved,
Some(entity.id),
"compact prefix of len {len} should resolve"
);
}
}
#[tokio::test]
async fn resolve_prefix_compact_full_32_char_matches() {
let rt = rt();
let tok = NamespaceToken::local();
let entity = rt
.create_entity(&tok, "concept", None, "Full32Test", None, None, vec![])
.await
.unwrap();
let compact = entity.id.simple().to_string();
assert_eq!(compact.len(), 32);
let resolved = rt.resolve_prefix(&tok, &compact).await.unwrap();
assert_eq!(resolved, Some(entity.id));
}
#[tokio::test]
async fn resolve_prefix_rejects_overlong_all_hex_input() {
let rt = rt();
let tok = NamespaceToken::local();
let entity = rt
.create_entity(&tok, "concept", None, "OverlongTest", None, None, vec![])
.await
.unwrap();
let compact = entity.id.simple().to_string();
assert_eq!(compact.len(), 32);
let overlong = format!("{compact}ab");
let resolved = rt.resolve_prefix(&tok, &overlong).await.unwrap();
assert_eq!(
resolved, None,
"a 32-char id plus extra hex chars must not resolve to the valid entity"
);
}
#[tokio::test]
async fn resolve_prefix_rejects_like_wildcard_input() {
let rt = rt();
let tok = NamespaceToken::local();
let entity = rt
.create_entity(&tok, "concept", None, "WildcardTest", None, None, vec![])
.await
.unwrap();
let compact = entity.id.simple().to_string();
let wildcard_prefix = format!("{}%", &compact[..8]);
let resolved = rt.resolve_prefix(&tok, &wildcard_prefix).await.unwrap();
assert_eq!(
resolved, None,
"prefix containing a LIKE wildcard must be rejected, not resolved"
);
}
#[tokio::test]
async fn resolve_prefix_boundary_at_hyphen_positions() {
let rt = rt();
let tok = NamespaceToken::local();
let entity = rt
.create_entity(&tok, "concept", None, "BoundaryTest", None, None, vec![])
.await
.unwrap();
let compact = entity.id.simple().to_string();
for len in [8, 12, 16, 20, 24] {
let prefix = &compact[..len];
let resolved = rt.resolve_prefix(&tok, prefix).await.unwrap();
assert_eq!(
resolved,
Some(entity.id),
"boundary prefix of len {len} should resolve"
);
}
}
#[tokio::test]
async fn resolve_prefix_range_preserves_boundaries_ambiguity_and_not_found() {
use khive_storage::entity::Entity;
let rt = rt();
let tok = NamespaceToken::local();
let ids = [
"0abcdefe-ffff-4fff-8fff-ffffffffffff",
"0abcdeff-0000-4000-8000-000000000001",
"0abcdeff-1000-4000-8000-000000000002",
"0abcdf00-0000-4000-8000-000000000003",
];
let store = rt.entities(&tok).unwrap();
for (index, id) in ids.into_iter().enumerate() {
let mut entity = Entity::new("local", "concept", format!("RangeControl{index}"));
entity.id = Uuid::parse_str(id).unwrap();
store.upsert_entity(entity).await.unwrap();
}
let unique = rt.resolve_prefix(&tok, "0abcdeff0").await.unwrap();
assert_eq!(unique, Some(Uuid::parse_str(ids[1]).unwrap()));
let ambiguous = rt.resolve_prefix(&tok, "0abcdeff").await.unwrap_err();
assert!(
matches!(
ambiguous,
RuntimeError::AmbiguousPrefix { ref matches, .. } if matches.len() == 2
),
"the two in-range UUIDs must remain ambiguous: {ambiguous:?}"
);
let missing = rt.resolve_prefix(&tok, "0abcdeff2").await.unwrap();
assert_eq!(missing, None, "adjacent UUIDs must not become prefix hits");
}
#[tokio::test]
async fn resolve_prefix_ambiguous_still_detected_after_normalization() {
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-1111-4000-8000-000000000002").unwrap();
let mut entity_a = Entity::new("local", "concept", "AmbigCompactA");
entity_a.id = id_a;
let mut entity_b = Entity::new("local", "concept", "AmbigCompactB");
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 shared_compact = &id_a.simple().to_string()[..20];
let err = rt.resolve_prefix(&tok, shared_compact).await.unwrap_err();
assert!(
matches!(
err,
RuntimeError::AmbiguousPrefix { ref matches, .. } if matches.len() == 2
),
"shared compact prefix must still return AmbiguousPrefix; got {err:?}"
);
}
#[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 err = rt.resolve_prefix(&tok, "aabbccdd").await.unwrap_err();
assert!(
matches!(
err,
RuntimeError::AmbiguousPrefix { ref prefix, ref matches }
if prefix == "aabbccdd" && matches.len() == 2
),
"shared 8-char prefix must return AmbiguousPrefix; got {err:?}"
);
}
#[tokio::test]
async fn resolve_prefix_cross_table_duplicate_uuid_resolves_cleanly() {
use khive_storage::entity::Entity;
let rt = rt();
let tok = NamespaceToken::local();
let shared_id = Uuid::parse_str("ccddeeff-1111-4000-8000-000000000001").unwrap();
let mut entity = Entity::new("local", "concept", "Nvk749Entity");
entity.id = shared_id;
rt.entities(&tok)
.unwrap()
.upsert_entity(entity)
.await
.unwrap();
let mut note = Note::new("local", "observation", "nvk749 note with the same id");
note.id = shared_id;
rt.notes(&tok).unwrap().upsert_note(note).await.unwrap();
let resolved = rt
.resolve_prefix(&tok, "ccddeeff")
.await
.expect("#749: a UUID present in two tables must not be reported as ambiguous");
assert_eq!(
resolved,
Some(shared_id),
"#749: cross-table duplicate must resolve to the single shared UUID"
);
}
#[tokio::test]
async fn resolve_prefix_early_exit_uses_deduped_match_count() {
use khive_storage::entity::Entity;
let rt = rt();
let tok = NamespaceToken::local();
let shared_id = Uuid::parse_str("ddeeff11-2222-4000-8000-000000000002").unwrap();
let mut entity = Entity::new("local", "concept", "Nvk749bEntity");
entity.id = shared_id;
rt.entities(&tok)
.unwrap()
.upsert_entity(entity)
.await
.unwrap();
let mut note = Note::new("local", "observation", "nvk749b note with the same id");
note.id = shared_id;
rt.notes(&tok).unwrap().upsert_note(note).await.unwrap();
let resolved = rt
.resolve_prefix(&tok, "ddeeff11")
.await
.expect("#749: deduped early-exit must not falsely report ambiguity");
assert_eq!(resolved, Some(shared_id));
}
#[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 link_write_time_guard_blocks_dangling_edge_after_target_vanishes() {
let rt = rt();
let tok = NamespaceToken::local();
let a = rt
.create_entity(&tok, "concept", None, "A", None, None, vec![])
.await
.unwrap();
let x = rt
.create_entity(&tok, "concept", None, "X", None, None, vec![])
.await
.unwrap();
rt.validate_edge_relation_endpoints(&tok, a.id, x.id, EdgeRelation::Extends)
.await
.expect("prepare-time validation must pass while X is live");
assert!(rt.delete_entity(&tok, x.id, true).await.unwrap());
let now = chrono::Utc::now();
let edge = Edge {
id: LinkId::from(Uuid::new_v4()),
namespace: tok.namespace().as_str().to_string(),
source_id: a.id,
target_id: x.id,
relation: EdgeRelation::Extends,
weight: 1.0,
created_at: now,
updated_at: now,
deleted_at: None,
metadata: None,
target_backend: None,
};
let outcome = rt
.graph(&tok)
.unwrap()
.upsert_edge_guarded(edge)
.await
.unwrap();
match outcome {
khive_storage::GuardedWriteOutcome::Refused(missing) => {
assert!(missing.target, "target must be reported missing");
}
other => panic!(
"guarded write must refuse an edge whose target vanished before commit, got {other:?}"
),
}
let edges = rt
.list_edges(
&tok,
crate::curation::EdgeListFilter {
source_id: Some(a.id),
target_id: Some(x.id),
relations: vec![EdgeRelation::Extends],
..Default::default()
},
10,
0,
)
.await
.unwrap();
assert!(
edges.is_empty(),
"no dangling edge may be persisted after the guarded write refused it"
);
}
#[tokio::test]
async fn link_many_writes_nothing_when_one_target_vanishes_before_write() {
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 x = rt
.create_entity(&tok, "concept", None, "X", None, None, vec![])
.await
.unwrap();
let specs = vec![
LinkSpec {
namespace: None,
source_id: a.id,
target_id: x.id,
relation: EdgeRelation::Extends,
weight: 1.0,
metadata: None,
resurrect: false,
},
LinkSpec {
namespace: None,
source_id: a.id,
target_id: b.id,
relation: EdgeRelation::Extends,
weight: 1.0,
metadata: None,
resurrect: false,
},
];
let mut edges = Vec::with_capacity(specs.len());
for spec in &specs {
edges.push(rt.build_edge(&tok, spec).await.unwrap());
}
assert!(rt.delete_entity(&tok, x.id, true).await.unwrap());
let outcome = rt
.graph(&tok)
.unwrap()
.upsert_edges_guarded(edges)
.await
.unwrap();
assert_eq!(
outcome.summary.affected, 0,
"no edge from the batch may be persisted when any endpoint vanished"
);
assert!(
outcome.refused.is_some(),
"refused batch entry must be reported"
);
let edges = rt
.list_edges(
&tok,
crate::curation::EdgeListFilter {
source_id: Some(a.id),
relations: vec![EdgeRelation::Extends],
..Default::default()
},
10,
0,
)
.await
.unwrap();
assert!(
edges.is_empty(),
"link_many's guarded batch must be all-or-nothing: the live A-B edge \
must not have been persisted alongside the doomed A-X edge"
);
}
#[tokio::test]
async fn link_many_reverse_symmetric_refusal_reports_canonical_missing_endpoint() {
for relation in [EdgeRelation::CompetesWith, EdgeRelation::ComposedWith] {
for delete_source in [true, false] {
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 (source_id, target_id) = (a.id.min(b.id), a.id.max(b.id));
let spec = LinkSpec {
namespace: None,
source_id: target_id,
target_id: source_id,
relation,
weight: 1.0,
metadata: None,
resurrect: false,
};
assert!(spec.source_id > spec.target_id);
let edge = rt.build_edge(&tok, &spec).await.unwrap();
assert_eq!((edge.source_id, edge.target_id), (source_id, target_id));
let deleted_id = if delete_source { source_id } else { target_id };
assert!(rt.delete_entity(&tok, deleted_id, true).await.unwrap());
let outcome = rt
.graph(&tok)
.unwrap()
.upsert_edges_guarded_observed(vec![EdgeUpsertRequest {
edge,
resurrect: false,
}])
.await
.unwrap();
assert!(outcome.rows.is_empty());
let refusal = outcome.refusal.expect("the deleted endpoint must refuse");
let EdgeUpsertRefusal::MissingEndpoints(missing) = refusal.reason else {
panic!("expected a missing-endpoint refusal");
};
assert_eq!(missing.source, delete_source);
assert_eq!(missing.target, !delete_source);
let failure = guarded_link_batch_failure(&spec, refusal.entry_index, missing);
assert_eq!(failure.entry_index, Some(0));
assert_eq!(failure.missing_source, delete_source.then_some(deleted_id));
assert_eq!(
failure.missing_target,
(!delete_source).then_some(deleted_id)
);
assert!(rt
.list_edges(&tok, EdgeListFilter::default(), 10, 0)
.await
.unwrap()
.is_empty());
}
}
}
fn raw_edge(source_id: Uuid, target_id: Uuid, ns: &str) -> Edge {
let now = chrono::Utc::now();
Edge {
id: LinkId::from(Uuid::new_v4()),
namespace: ns.to_string(),
source_id,
target_id,
relation: EdgeRelation::Extends,
weight: 1.0,
created_at: now,
updated_at: now,
deleted_at: None,
metadata: None,
target_backend: None,
}
}
async fn assert_no_edges_touch(rt: &KhiveRuntime, tok: &NamespaceToken, node_id: Uuid) {
let as_source = rt
.list_edges(
tok,
crate::curation::EdgeListFilter {
source_id: Some(node_id),
..Default::default()
},
10,
0,
)
.await
.unwrap();
let as_target = rt
.list_edges(
tok,
crate::curation::EdgeListFilter {
target_id: Some(node_id),
..Default::default()
},
10,
0,
)
.await
.unwrap();
assert!(
as_source.is_empty() && as_target.is_empty(),
"no edge may reference hard-deleted node {node_id}: source-side={as_source:?} \
target-side={as_target:?}"
);
}
#[tokio::test]
async fn hard_delete_entity_purges_edge_written_before_delete() {
let rt = rt();
let tok = NamespaceToken::local();
let a = rt
.create_entity(&tok, "concept", None, "A", None, None, vec![])
.await
.unwrap();
let x = rt
.create_entity(&tok, "concept", None, "X", None, None, vec![])
.await
.unwrap();
let edge = raw_edge(a.id, x.id, tok.namespace().as_str());
assert_eq!(
rt.graph(&tok)
.unwrap()
.upsert_edge_guarded(edge)
.await
.unwrap(),
khive_storage::GuardedWriteOutcome::Written
);
assert!(rt.delete_entity(&tok, x.id, true).await.unwrap());
assert_no_edges_touch(&rt, &tok, x.id).await;
}
#[tokio::test]
async fn hard_delete_entity_concurrent_with_guarded_write_never_leaves_dangling_edge() {
let rt = std::sync::Arc::new(rt());
let tok = NamespaceToken::local();
let a = rt
.create_entity(&tok, "concept", None, "A", None, None, vec![])
.await
.unwrap();
let x = rt
.create_entity(&tok, "concept", None, "X", None, None, vec![])
.await
.unwrap();
let delete_rt = std::sync::Arc::clone(&rt);
let delete_tok = tok.clone();
let delete_task =
tokio::spawn(async move { delete_rt.delete_entity(&delete_tok, x.id, true).await });
let write_rt = std::sync::Arc::clone(&rt);
let write_tok = tok.clone();
let ns = tok.namespace().as_str().to_string();
let write_task = tokio::spawn(async move {
let edge = raw_edge(a.id, x.id, &ns);
write_rt
.graph(&write_tok)
.unwrap()
.upsert_edge_guarded(edge)
.await
});
let (deleted, _written) = tokio::join!(delete_task, write_task);
deleted.unwrap().unwrap();
assert_no_edges_touch(&rt, &tok, x.id).await;
}
#[tokio::test]
async fn hard_delete_note_purges_edge_written_before_delete() {
let rt = rt();
let tok = NamespaceToken::local();
let a = rt
.create_entity(&tok, "concept", None, "A", None, None, vec![])
.await
.unwrap();
let n = rt
.create_note(
&tok,
"observation",
None,
"note content",
None,
None,
vec![],
)
.await
.unwrap();
let edge = raw_edge(a.id, n.id, tok.namespace().as_str());
assert_eq!(
rt.graph(&tok)
.unwrap()
.upsert_edge_guarded(edge)
.await
.unwrap(),
khive_storage::GuardedWriteOutcome::Written
);
assert!(rt.delete_note(&tok, n.id, true).await.unwrap());
assert_no_edges_touch(&rt, &tok, n.id).await;
}
#[tokio::test]
async fn hard_delete_note_concurrent_with_guarded_write_never_leaves_dangling_edge() {
let rt = std::sync::Arc::new(rt());
let tok = NamespaceToken::local();
let a = rt
.create_entity(&tok, "concept", None, "A", None, None, vec![])
.await
.unwrap();
let n = rt
.create_note(
&tok,
"observation",
None,
"note content",
None,
None,
vec![],
)
.await
.unwrap();
let delete_rt = std::sync::Arc::clone(&rt);
let delete_tok = tok.clone();
let note_id = n.id;
let delete_task =
tokio::spawn(async move { delete_rt.delete_note(&delete_tok, note_id, true).await });
let write_rt = std::sync::Arc::clone(&rt);
let write_tok = tok.clone();
let ns = tok.namespace().as_str().to_string();
let write_task = tokio::spawn(async move {
let edge = raw_edge(a.id, note_id, &ns);
write_rt
.graph(&write_tok)
.unwrap()
.upsert_edge_guarded(edge)
.await
});
let (deleted, _written) = tokio::join!(delete_task, write_task);
deleted.unwrap().unwrap();
assert_no_edges_touch(&rt, &tok, note_id).await;
}
#[tokio::test]
async fn hard_delete_edge_endpoint_purges_annotating_edge_written_before_delete() {
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 n = rt
.create_note(
&tok,
"observation",
None,
"note content",
None,
None,
vec![],
)
.await
.unwrap();
let base_edge = rt
.link(&tok, a.id, b.id, EdgeRelation::Extends, 0.8, None)
.await
.unwrap();
let base_edge_id = Uuid::from(base_edge.id);
let annotating = raw_edge(n.id, base_edge_id, tok.namespace().as_str());
assert_eq!(
rt.graph(&tok)
.unwrap()
.upsert_edge_guarded(annotating)
.await
.unwrap(),
khive_storage::GuardedWriteOutcome::Written
);
assert!(rt.delete_edge(&tok, base_edge_id, true).await.unwrap());
assert_no_edges_touch(&rt, &tok, base_edge_id).await;
}
#[tokio::test]
async fn hard_delete_edge_endpoint_concurrent_with_guarded_write_never_leaves_dangling_edge() {
let rt = std::sync::Arc::new(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 n = rt
.create_note(
&tok,
"observation",
None,
"note content",
None,
None,
vec![],
)
.await
.unwrap();
let base_edge = rt
.link(&tok, a.id, b.id, EdgeRelation::Extends, 0.8, None)
.await
.unwrap();
let base_edge_id = Uuid::from(base_edge.id);
let delete_rt = std::sync::Arc::clone(&rt);
let delete_tok = tok.clone();
let delete_task =
tokio::spawn(async move { delete_rt.delete_edge(&delete_tok, base_edge_id, true).await });
let write_rt = std::sync::Arc::clone(&rt);
let write_tok = tok.clone();
let ns = tok.namespace().as_str().to_string();
let write_task = tokio::spawn(async move {
let edge = raw_edge(n.id, base_edge_id, &ns);
write_rt
.graph(&write_tok)
.unwrap()
.upsert_edge_guarded(edge)
.await
});
let (deleted, _written) = tokio::join!(delete_task, write_task);
deleted.unwrap().unwrap();
assert_no_edges_touch(&rt, &tok, base_edge_id).await;
}
fn file_backed_runtime(
dir: &tempfile::TempDir,
name: &str,
write_queue_enabled: bool,
) -> KhiveRuntime {
let path = dir.path().join(name);
if write_queue_enabled {
std::env::set_var("KHIVE_WRITE_QUEUE", "1");
} else {
std::env::remove_var("KHIVE_WRITE_QUEUE");
}
let rt = KhiveRuntime::new_for_test(crate::config::RuntimeConfig {
db_path: Some(path),
packs: vec!["kg".to_string()],
brain_profile: None,
actor_id: None,
..crate::config::RuntimeConfig::no_embeddings()
})
.unwrap();
std::env::remove_var("KHIVE_WRITE_QUEUE");
rt
}
async fn assert_guarded_write_committed_before_delete_is_swept(rt: &KhiveRuntime) {
let tok = NamespaceToken::local();
let a = rt
.create_entity(&tok, "concept", None, "A", None, None, vec![])
.await
.unwrap();
let x = rt
.create_entity(&tok, "concept", None, "X", None, None, vec![])
.await
.unwrap();
let edge = raw_edge(a.id, x.id, tok.namespace().as_str());
assert_eq!(
rt.graph(&tok)
.unwrap()
.upsert_edge_guarded(edge)
.await
.unwrap(),
khive_storage::GuardedWriteOutcome::Written,
"write must succeed while both endpoints are still live"
);
assert!(rt.delete_entity(&tok, x.id, true).await.unwrap());
assert_no_edges_touch(rt, &tok, x.id).await;
}
async fn assert_guarded_write_attempted_after_delete_is_refused(rt: &KhiveRuntime) {
let tok = NamespaceToken::local();
let a = rt
.create_entity(&tok, "concept", None, "A", None, None, vec![])
.await
.unwrap();
let x = rt
.create_entity(&tok, "concept", None, "X", None, None, vec![])
.await
.unwrap();
assert!(rt.delete_entity(&tok, x.id, true).await.unwrap());
let edge = raw_edge(a.id, x.id, tok.namespace().as_str());
let outcome = rt
.graph(&tok)
.unwrap()
.upsert_edge_guarded(edge)
.await
.unwrap();
match outcome {
khive_storage::GuardedWriteOutcome::Refused(missing) => {
assert!(
missing.target,
"target must be reported missing once the delete has committed"
);
assert!(!missing.source, "source was never deleted");
}
other => panic!(
"guarded write attempted after the delete committed must be refused, got {other:?}"
),
}
assert_no_edges_touch(rt, &tok, x.id).await;
}
#[tokio::test]
#[serial_test::serial(khive_write_queue_env)]
async fn guarded_write_before_delete_swept_file_backed_write_queue_off() {
let dir = tempfile::tempdir().unwrap();
let rt = file_backed_runtime(&dir, "guard_before_off.db", false);
assert_guarded_write_committed_before_delete_is_swept(&rt).await;
}
#[tokio::test]
#[serial_test::serial(khive_write_queue_env)]
async fn guarded_write_after_delete_refused_file_backed_write_queue_off() {
let dir = tempfile::tempdir().unwrap();
let rt = file_backed_runtime(&dir, "guard_after_off.db", false);
assert_guarded_write_attempted_after_delete_is_refused(&rt).await;
}
#[tokio::test]
#[serial_test::serial(khive_write_queue_env)]
async fn guarded_write_before_delete_swept_file_backed_write_queue_on() {
let dir = tempfile::tempdir().unwrap();
let rt = file_backed_runtime(&dir, "guard_before_on.db", true);
assert_guarded_write_committed_before_delete_is_swept(&rt).await;
}
#[tokio::test]
#[serial_test::serial(khive_write_queue_env)]
async fn guarded_write_after_delete_refused_file_backed_write_queue_on() {
let dir = tempfile::tempdir().unwrap();
let rt = file_backed_runtime(&dir, "guard_after_on.db", true);
assert_guarded_write_attempted_after_delete_is_refused(&rt).await;
}
#[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 get_edge_by_natural_key_including_deleted_honors_explicit_namespace_not_token() {
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_b, "concept", None, "A", None, None, vec![])
.await
.unwrap();
let b = rt
.create_entity(&ns_b, "concept", None, "B", None, None, vec![])
.await
.unwrap();
let edge = rt
.link(&ns_b, a.id, b.id, EdgeRelation::CompetesWith, 1.0, None)
.await
.unwrap();
let (canon_src, canon_tgt) = canonical_edge_endpoints(EdgeRelation::CompetesWith, a.id, b.id);
let found = rt
.get_edge_by_natural_key_including_deleted(
&ns_a,
"ns-b",
canon_src,
canon_tgt,
EdgeRelation::CompetesWith,
)
.await
.unwrap();
assert_eq!(
found.map(|e| Uuid::from(e.id)),
Some(Uuid::from(edge.id)),
"must find the edge by its own namespace regardless of the caller's token"
);
let not_found = rt
.get_edge_by_natural_key_including_deleted(
&ns_a,
"ns-a",
canon_src,
canon_tgt,
EdgeRelation::CompetesWith,
)
.await
.unwrap();
assert!(
not_found.is_none(),
"must not find an edge recorded in a different namespace than the one queried"
);
}
#[tokio::test]
async fn link_target_in_different_namespace_succeeds() {
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!(
result.is_ok(),
"target in a different namespace than the caller must resolve (#631), got {result:?}"
);
}
#[tokio::test]
async fn link_phantom_self_loop_returns_invalid_input() {
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::InvalidInput(msg)) => {
assert!(
msg.contains("self-loop"),
"self-loop must be rejected with self-loop message: {msg}"
);
}
other => panic!("expected InvalidInput for 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 neighbors_edge_id_finds_annotating_note() {
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![edge_uuid],
)
.await
.unwrap();
let neighbors = rt
.neighbors(
&tok,
edge_uuid,
Direction::In,
None,
Some(vec![EdgeRelation::Annotates]),
)
.await
.unwrap();
assert_eq!(neighbors.len(), 1, "expected annotating note to show up");
assert_eq!(neighbors[0].node_id, note.id);
}
#[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, &[], None)
.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 (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_succeeds() {
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!(
result.is_ok(),
"cross-namespace supersedes source must resolve (#631), 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, &[], None)
.await
.unwrap();
assert!(
search_hits.is_empty(),
"compensation must clean the FTS index; got {search_hits:?}"
);
let after_edges = rt
.neighbors(
&tok,
t1.id,
Direction::In,
None,
Some(vec![EdgeRelation::Annotates]),
)
.await
.unwrap();
assert!(
after_edges.is_empty(),
"compensation must remove all partial edges; got {after_edges:?}"
);
assert!(matches!(
rt.neighbors(&tok, note.id, Direction::Out, None, None)
.await,
Err(RuntimeError::NotFound(_))
));
}
#[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 edge_id = before[0].edge_id;
let deleted = rt.delete_note(&tok, note_target.id, false).await.unwrap();
assert!(deleted, "soft delete must return true");
let edge_after = rt.get_edge(&tok, edge_id).await.unwrap();
assert!(
edge_after.is_some(),
"soft delete must NOT cascade edges; get_edge returned None"
);
let after = rt
.neighbors(
&tok,
note_source.id,
Direction::Out,
None,
Some(vec![EdgeRelation::Annotates]),
)
.await
.unwrap();
assert_eq!(
after.len(),
0,
"#748: neighbors() must screen out the soft-deleted note target; 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, &[], None)
.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 failed_annotates_edge_cleanup_keeps_the_source_note_and_names_the_edge() {
let rt = rt();
let tok = NamespaceToken::local();
let t1 = rt
.create_entity(&tok, "concept", None, "cleanup T1", None, None, vec![])
.await
.unwrap();
let t2 = rt
.create_entity(&tok, "concept", None, "cleanup T2", None, None, vec![])
.await
.unwrap();
let mut writer = rt.sql().writer().await.unwrap();
writer
.execute_script(
"CREATE TRIGGER reject_annotates_compensation \
BEFORE DELETE ON graph_edges \
WHEN OLD.relation = 'annotates' \
BEGIN SELECT RAISE(ABORT, 'injected edge cleanup failure'); END;"
.into(),
)
.await
.unwrap();
drop(writer);
LINK_FAIL_AFTER.with(|cell| cell.set(2));
let error = rt
.create_note(
&tok,
"observation",
None,
"preserve source on failed cleanup",
None,
None,
vec![t1.id, t2.id],
)
.await
.expect_err("second link and first-edge cleanup must fail");
let notes = rt.list_notes(&tok, None, 10, 0).await.unwrap();
assert_eq!(notes.len(), 1, "failed cleanup keeps the edge source live");
let edges = rt
.neighbors(
&tok,
notes[0].id,
Direction::Out,
None,
Some(vec![EdgeRelation::Annotates]),
)
.await
.unwrap();
assert_eq!(edges.len(), 1);
let text = error.to_string();
assert!(text.contains("injected edge cleanup failure"), "{text}");
assert!(text.contains(¬es[0].id.to_string()), "{text}");
assert!(text.contains(&edges[0].edge_id.to_string()), "{text}");
}
#[tokio::test]
async fn failed_created_event_reports_committed_entity_and_note_ids() {
let rt = rt();
let tok = NamespaceToken::local();
let (provider, _) = ConstVecProvider::new("created-event-test-model", 4);
rt.register_embedder(provider);
let mut writer = rt.sql().writer().await.unwrap();
writer
.execute_script(
"CREATE TRIGGER reject_created_events BEFORE INSERT ON events \
WHEN NEW.kind IN ('entity_created', 'note_created') \
BEGIN SELECT RAISE(ABORT, 'injected created-event failure'); END;"
.into(),
)
.await
.unwrap();
drop(writer);
let (entity, _, entity_degradations) = rt
.create_entity_with_post_commit_report(
&tok,
"concept",
None,
"committed entity",
None,
None,
vec![],
)
.await
.expect("event telemetry failure must not conceal a committed entity");
assert_eq!(rt.get_entity(&tok, entity.id).await.unwrap().id, entity.id);
assert_eq!(entity_degradations.len(), 1);
assert_eq!(entity_degradations[0].stage, "event_append");
assert!(entity_degradations[0]
.error
.contains("injected created-event failure"));
let (note, _, note_degradations) = rt
.create_note_with_embedding_content_and_post_commit_report(
&tok,
"observation",
None,
"committed note",
None,
None,
None,
vec![],
)
.await
.expect("event telemetry failure must not conceal a committed note");
assert_eq!(
rt.notes(&tok)
.unwrap()
.get_note(note.id)
.await
.unwrap()
.unwrap()
.id,
note.id
);
assert_eq!(note_degradations.len(), 1);
assert_eq!(note_degradations[0].stage, "event_append");
assert!(note_degradations[0]
.error
.contains("injected created-event failure"));
}
fn assert_legacy_post_commit_error(
error: RuntimeError,
operation: &str,
expected_stages: &[&str],
) -> Uuid {
let RuntimeError::Khive(domain) = error.refusal_source() else {
panic!("legacy caller lost its typed post-commit signal: {error:?}");
};
assert_eq!(domain.kind(), khive_types::ErrorKind::Internal);
let details = domain.details().expect("post-commit details");
assert_eq!(details.get("reason"), Some("post_commit_degraded"));
assert_eq!(details.get("operation"), Some(operation));
assert_eq!(details.get("committed"), Some("true"));
assert_eq!(details.get("retryable"), Some("false"));
let id = details
.get("record_id")
.expect("committed id")
.parse::<Uuid>()
.expect("canonical committed id");
let failures: serde_json::Value = serde_json::from_str(
details
.get("post_commit_degradations")
.expect("complete degradation list"),
)
.expect("degradations are JSON");
let stages: Vec<&str> = failures
.as_array()
.expect("degradation array")
.iter()
.map(|failure| {
assert!(
failure["error"]
.as_str()
.is_some_and(|error| error.contains("legacy")),
"the original injected failure must remain in the report: {failure:?}"
);
failure["stage"].as_str().expect("named failure stage")
})
.collect();
assert_eq!(stages, expected_stages);
let projected =
crate::error_projection::runtime_error_value(error, crate::DomainDisposition::Unknown);
assert_eq!(projected["domain_disposition"], "committed");
id
}
#[tokio::test]
async fn legacy_create_callers_receive_committed_id_and_failed_event_detail() {
let rt = rt();
let tok = NamespaceToken::local();
let mut writer = rt.sql().writer().await.unwrap();
writer
.execute_script(
"CREATE TRIGGER reject_legacy_created_events BEFORE INSERT ON events \
WHEN NEW.kind IN ('entity_created', 'note_created') \
BEGIN SELECT RAISE(ABORT, 'legacy created-event failure'); END;"
.into(),
)
.await
.unwrap();
drop(writer);
let entity_error = rt
.create_entity(&tok, "concept", None, "legacy entity", None, None, vec![])
.await
.expect_err("legacy create_entity must report the committed degradation");
let entity_id =
assert_legacy_post_commit_error(entity_error, "create_entity", &["event_append"]);
assert_eq!(rt.get_entity(&tok, entity_id).await.unwrap().id, entity_id);
let report_error = rt
.create_entity_with_embedding_report(
&tok,
"concept",
None,
"legacy report entity",
None,
None,
vec![],
)
.await
.expect_err("legacy embedding-report wrapper must retain degradation");
let report_id = assert_legacy_post_commit_error(
report_error,
"create_entity_with_embedding_report",
&["event_append"],
);
assert_eq!(rt.get_entity(&tok, report_id).await.unwrap().id, report_id);
let note_error = rt
.create_note(&tok, "observation", None, "legacy note", None, None, vec![])
.await
.expect_err("legacy create_note must report the committed degradation");
let note_id = assert_legacy_post_commit_error(note_error, "create_note", &["event_append"]);
assert!(rt
.notes(&tok)
.unwrap()
.get_note(note_id)
.await
.unwrap()
.is_some());
let decay_error = rt
.create_note_with_decay_for_embedding_model(
&tok,
"observation",
None,
"legacy decay note",
None,
0.01,
None,
vec![],
None,
)
.await
.expect_err("legacy decay wrapper must retain degradation");
let decay_id = assert_legacy_post_commit_error(
decay_error,
"create_note_with_decay_for_embedding_model",
&["event_append"],
);
assert!(rt
.notes(&tok)
.unwrap()
.get_note(decay_id)
.await
.unwrap()
.is_some());
}
#[tokio::test]
async fn legacy_delete_callers_receive_committed_id_and_all_failed_cleanup_stages() {
let rt = rt();
let tok = NamespaceToken::local();
let entity = rt
.create_entity(
&tok,
"concept",
None,
"delete legacy entity",
None,
None,
vec![],
)
.await
.unwrap();
let note = rt
.create_note(
&tok,
"observation",
None,
"delete legacy note",
None,
None,
vec![],
)
.await
.unwrap();
let mut writer = rt.sql().writer().await.unwrap();
writer
.execute_script(
"CREATE TRIGGER reject_legacy_deleted_events BEFORE INSERT ON events \
WHEN NEW.kind IN ('entity_deleted', 'note_deleted') \
BEGIN SELECT RAISE(ABORT, 'legacy deleted-event failure'); END; \
CREATE TRIGGER reject_legacy_note_fts_cleanup BEFORE DELETE ON fts_notes_rowids \
BEGIN SELECT RAISE(ABORT, 'legacy note FTS cleanup failure'); END;"
.into(),
)
.await
.unwrap();
drop(writer);
let note_error = rt
.delete_note(&tok, note.id, true)
.await
.expect_err("legacy delete_note must report committed cleanup failures");
let note_id = assert_legacy_post_commit_error(
note_error,
"delete_note",
&["fts_cleanup", "event_append"],
);
assert_eq!(note_id, note.id);
assert!(rt
.notes(&tok)
.unwrap()
.get_note(note.id)
.await
.unwrap()
.is_none());
let entity_error = rt
.delete_entity(&tok, entity.id, true)
.await
.expect_err("legacy delete_entity must report committed event failure");
let entity_id =
assert_legacy_post_commit_error(entity_error, "delete_entity", &["event_append"]);
assert_eq!(entity_id, entity.id);
assert!(matches!(
rt.get_entity(&tok, entity.id).await,
Err(RuntimeError::NotFound(_))
));
}
#[tokio::test]
async fn delete_note_fts_failure_still_emits_event_and_invalidates_cache() {
let rt = rt();
let tok = NamespaceToken::local();
let note = rt
.create_note(
&tok,
"observation",
None,
"delete with failed FTS cleanup",
None,
None,
vec![],
)
.await
.unwrap();
let invalidations = Arc::new(AtomicUsize::new(0));
let observed = Arc::clone(&invalidations);
rt.install_note_mutation_hook(Arc::new(move |_kind, _id| {
let observed = Arc::clone(&observed);
Box::pin(async move {
observed.fetch_add(1, Ordering::SeqCst);
})
}));
let mut writer = rt.sql().writer().await.unwrap();
writer
.execute_script(
"CREATE TRIGGER reject_note_fts_cleanup BEFORE DELETE ON fts_notes_rowids \
BEGIN SELECT RAISE(ABORT, 'injected fts_notes cleanup failure'); END;"
.into(),
)
.await
.unwrap();
drop(writer);
let (deleted, degradations) = rt
.delete_note_with_post_commit_report(&tok, note.id, true)
.await
.expect("a failed index cleanup must not conceal a committed delete");
assert!(deleted);
assert_eq!(invalidations.load(Ordering::SeqCst), 1);
assert_eq!(degradations.len(), 1);
assert_eq!(degradations[0].stage, "fts_cleanup");
assert!(degradations[0].error.contains("fts_notes"));
assert!(rt
.notes(&tok)
.unwrap()
.get_note(note.id)
.await
.unwrap()
.is_none());
let events = rt
.list_events(
&tok,
EventFilter {
target_id: Some(note.id),
kinds: vec![EventKind::NoteDeleted],
..Default::default()
},
PageRequest::default(),
)
.await
.unwrap();
assert_eq!(events.items.len(), 1);
}
#[tokio::test]
async fn create_note_fts_failure_rolls_back_note_row() {
let rt = rt();
let ns = Namespace::parse("fault-fts-rollback").unwrap();
let tok = NamespaceToken::for_namespace(ns.clone());
let _arm = arm_fts_fail_scoped(ns.as_str());
let result = rt
.create_note(
&tok,
"observation",
None,
"fts-fail rollback target",
None,
None,
vec![],
)
.await;
assert!(
result.is_err(),
"create_note must propagate the injected FTS failure"
);
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("injected FTS 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 after FTS failure; got {notes:?}"
);
}
#[tokio::test]
async fn create_note_fts_failure_fires_across_os_threads() {
let rt = std::sync::Arc::new(rt());
let ns = Namespace::parse("fault-fts-rollback-cross-thread").unwrap();
let tok = NamespaceToken::for_namespace(ns.clone());
let _arm = arm_fts_fail_scoped(ns.as_str());
let thread_rt = std::sync::Arc::clone(&rt);
let thread_tok = tok.clone();
let result = std::thread::spawn(move || {
let worker = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("worker runtime must build");
worker.block_on(thread_rt.create_note(
&thread_tok,
"observation",
None,
"fts-fail rollback target (cross-thread)",
None,
None,
vec![],
))
})
.join()
.expect("worker thread must not panic");
assert!(
result.is_err(),
"create_note on a different OS thread must still observe the injected FTS failure"
);
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("injected FTS 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 after FTS failure; got {notes:?}"
);
}
#[tokio::test]
async fn create_note_vector_failure_rolls_back_note_row_and_fts() {
const MODEL: &str = "test-vec-inject";
const DIMS: usize = 4;
let rt = KhiveRuntime::memory().unwrap();
let (provider, _counter) = ConstVecProvider::new(MODEL, DIMS);
rt.register_embedder(provider);
let ns = Namespace::parse("fault-vec-rollback").unwrap();
let tok = NamespaceToken::for_namespace(ns.clone());
let _arm = arm_vector_fail_scoped(ns.as_str());
let result = rt
.create_note(
&tok,
"observation",
None,
"vec-fail rollback target",
None,
None,
vec![],
)
.await;
assert!(
result.is_err(),
"create_note must propagate the injected vector failure"
);
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("injected vector 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 note row after vector failure; got {notes:?}"
);
}
#[tokio::test]
async fn vector_failure_injections_for_distinct_namespaces_do_not_overwrite_each_other() {
const MODEL: &str = "test-vec-inject-distinct-namespaces";
const DIMS: usize = 4;
let rt_a = KhiveRuntime::memory().unwrap();
let (provider_a, _counter_a) = ConstVecProvider::new(MODEL, DIMS);
rt_a.register_embedder(provider_a);
let ns_a = Namespace::parse("fault-vec-distinct-a").unwrap();
let tok_a = NamespaceToken::for_namespace(ns_a.clone());
let rt_b = KhiveRuntime::memory().unwrap();
let (provider_b, _counter_b) = ConstVecProvider::new(MODEL, DIMS);
rt_b.register_embedder(provider_b);
let ns_b = Namespace::parse("fault-vec-distinct-b").unwrap();
let tok_b = NamespaceToken::for_namespace(ns_b.clone());
let _arm_a = arm_vector_fail_scoped(ns_a.as_str());
let _arm_b = arm_vector_fail_scoped(ns_b.as_str());
let (result_a, result_b) = tokio::join!(
rt_a.create_note(
&tok_a,
"observation",
None,
"vector failure target A",
None,
None,
vec![],
),
rt_b.create_note(
&tok_b,
"observation",
None,
"vector failure target B",
None,
None,
vec![],
),
);
assert!(
result_a.is_err(),
"namespace A must retain its pending vector failure injection"
);
assert!(
result_b.is_err(),
"namespace B must retain its pending vector failure injection"
);
}
#[tokio::test]
async fn create_note_with_embedding_content_fts_failure_rolls_back_note_row() {
let rt = rt();
let ns = Namespace::parse("fault-fts-rollback-embedding-content").unwrap();
let tok = NamespaceToken::for_namespace(ns.clone());
let _arm = arm_fts_fail_scoped(ns.as_str());
let full = "fts-fail rollback target with an embedding-content override";
let head = &full[.."fts-fail rollback target".len()];
let result = rt
.create_note_with_embedding_content(
&tok,
"observation",
None,
full,
Some(head),
None,
None,
vec![],
)
.await;
assert!(
result.is_err(),
"create_note_with_embedding_content must propagate the injected FTS failure"
);
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("injected FTS 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 after FTS failure; got {notes:?}"
);
}
#[tokio::test]
async fn create_note_with_embedding_content_vector_failure_rolls_back_note_row_and_fts() {
const MODEL: &str = "test-vec-inject-embedding-content";
const DIMS: usize = 4;
let rt = KhiveRuntime::memory().unwrap();
let (provider, _counter) = ConstVecProvider::new(MODEL, DIMS);
rt.register_embedder(provider);
let ns = Namespace::parse("fault-vec-rollback-embedding-content").unwrap();
let tok = NamespaceToken::for_namespace(ns.clone());
let _arm = arm_vector_fail_scoped(ns.as_str());
let full = "vec-fail rollback target with an embedding-content override";
let head = &full[.."vec-fail rollback target".len()];
let result = rt
.create_note_with_embedding_content(
&tok,
"observation",
None,
full,
Some(head),
None,
None,
vec![],
)
.await;
assert!(
result.is_err(),
"create_note_with_embedding_content must propagate the injected vector failure"
);
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("injected vector 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 note row after vector failure; got {notes:?}"
);
}
#[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, &[], None)
.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, &[], None)
.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 the base allowlist; \
current generic entity fallthrough incorrectly accepts it"
);
}
#[tokio::test]
async fn link_illegal_entity_pair_names_loaded_legal_relations() {
let rt = rt();
let tok = NamespaceToken::local();
rt.install_edge_rules(vec![EdgeEndpointRule {
relation: EdgeRelation::DependsOn,
source: EndpointKind::EntityOfKind("concept"),
target: EndpointKind::EntityOfKind("project"),
}]);
let concept = rt
.create_entity(&tok, "concept", None, "Concept", None, None, vec![])
.await
.unwrap();
let project = rt
.create_entity(&tok, "project", None, "Project", None, None, vec![])
.await
.unwrap();
let error = rt
.link(
&tok,
concept.id,
project.id,
EdgeRelation::CompetesWith,
1.0,
None,
)
.await
.expect_err("concept competes_with project must be rejected");
let message = error.to_string();
assert!(
message.contains(
"currently legal relations for concept -> project under the loaded endpoint rules: depends_on"
),
"rejection must expose the exact loaded legal set; got: {message}"
);
}
#[test]
fn cross_backend_legal_set_ignores_unenforced_annotates_pack_rule() {
let rt = rt();
rt.install_edge_rules(vec![EdgeEndpointRule {
relation: EdgeRelation::Annotates,
source: EndpointKind::EntityOfKind("concept"),
target: EndpointKind::EntityOfKind("project"),
}]);
let source_id = Uuid::new_v4();
let target_id = Uuid::new_v4();
let source = Resolved::Entity(Entity::new("local", "concept", "Concept"));
let target = Resolved::Entity(Entity::new("local", "project", "Project"));
rt.validate_link_endpoints_by_resolved(
source_id,
target_id,
EdgeRelation::Annotates,
Some(&source),
Some(&target),
)
.expect_err(
"an annotates pack rule cannot override the dedicated note-source validator branch",
);
let error = rt
.validate_link_endpoints_by_resolved(
source_id,
target_id,
EdgeRelation::CompetesWith,
Some(&source),
Some(&target),
)
.expect_err("concept competes_with project must be rejected");
let message = error.to_string();
assert!(
message.contains(
"currently legal relations for concept -> project under the loaded endpoint rules: none"
),
"an unenforced entity-source annotates pack rule must not be advertised; got: {message}"
);
assert!(
!message.contains("endpoint rules: annotates"),
"the rejection must not call annotates legal when the live validator rejects it; got: {message}"
);
}
#[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 (base 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 (allowlist), 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 (allowlist), 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 (allowlist), 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 (allowlist), 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 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 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 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 the base 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(),
"F161: target_backend must be None for locally-routed edges; 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,
resurrect: false,
},
LinkSpec {
namespace: None,
source_id: a.id,
target_id: c.id,
relation: EdgeRelation::Enables,
weight: 1.0,
metadata: None,
resurrect: false,
},
];
let edges = rt.link_many(&tok, specs).await.unwrap();
for edge in &edges {
assert!(
edge.target_backend.is_none(),
"F161: target_backend must be None for locally-routed edges in link_many; 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 the base allowlist, got {result:?}"
);
}
#[tokio::test]
async fn delete_note_cross_namespace_succeeds() {
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, false).await;
assert!(
result.unwrap(),
"cross-namespace delete_note (soft) must return Ok(true)"
);
let note_store = rt.notes(&ns_a).unwrap();
let gone = note_store.get_note(note.id).await.unwrap();
assert!(
gone.is_none(),
"note must be soft-deleted in its home namespace after cross-ns delete"
);
let note2 = rt
.create_note(
&ns_a,
"observation",
None,
"note2 in ns-a",
Some(0.5),
None,
vec![],
)
.await
.unwrap();
let hard_result = rt.delete_note(&ns_b, note2.id, true).await;
assert!(
hard_result.unwrap(),
"cross-namespace hard delete_note must return Ok(true)"
);
let gone2 = rt
.get_note_including_deleted(&ns_a, note2.id)
.await
.unwrap();
assert!(
gone2.is_none(),
"hard-deleted note must not appear even in including_deleted query"
);
}
#[tokio::test]
async fn link_many_overlapping_triple_returns_persisted_ids() {
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 spec = || LinkSpec {
namespace: None,
source_id: a.id,
target_id: b.id,
relation: EdgeRelation::Extends,
weight: 1.0,
metadata: None,
resurrect: false,
};
let first = rt.link_many(&tok, vec![spec()]).await.unwrap();
assert_eq!(first.len(), 1);
let persisted_id: Uuid = first[0].id.into();
let second = rt.link_many(&tok, vec![spec()]).await.unwrap();
assert_eq!(second.len(), 1);
let second_id: Uuid = second[0].id.into();
assert_eq!(
persisted_id, second_id,
"link_many with an existing triple must return the persisted row ID ({persisted_id}), \
not a new phantom ID ({second_id})"
);
let count = rt
.count_edges(&tok, crate::curation::EdgeListFilter::default())
.await
.unwrap();
assert_eq!(count, 1, "upsert must not duplicate the edge row");
}
#[tokio::test]
async fn create_many_empty_and_invalid_batches_preserve_pending_fts_failure() {
async fn assert_empty(rt: &KhiveRuntime, tok: &NamespaceToken) {
assert_eq!(rt.count_entities(tok, None).await.unwrap(), 0);
assert_eq!(
rt.text(tok)
.unwrap()
.count(TextFilter {
namespaces: vec![tok.namespace().as_str().to_owned()],
..Default::default()
})
.await
.unwrap(),
0,
"BULK_ADMISSION_NO_FTS"
);
}
let rt = rt();
let ns = format!("bulk-admission-{}", Uuid::new_v4());
let tok = NamespaceToken::for_namespace(Namespace::parse(&ns).unwrap());
let clean = |name: &str| EntityCreateSpec {
kind: "concept".into(),
entity_type: None,
name: name.into(),
description: None,
properties: None,
tags: vec![],
};
let _arm = arm_fts_fail_many_scoped(&ns);
assert!(rt.create_many(&tok, vec![]).await.unwrap().is_empty());
assert_empty(&rt, &tok).await;
let error = rt
.create_many(
&tok,
vec![
clean("Clean first item"),
clean("ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"),
],
)
.await
.expect_err("BULK_ADMISSION_INVALID");
let RuntimeError::SecretDetected(matched) = error else {
panic!("expected indexed secret refusal, got {error:?}");
};
assert_eq!(matched.location.as_deref(), Some("entity[1].name"));
assert_empty(&rt, &tok).await;
let error = rt
.create_many(&tok, vec![clean("Valid after refusal")])
.await
.expect_err("BULK_ADMISSION_FAULT_RETAINED");
assert!(
error.to_string().contains("atomic batch rolled back"),
"{error}"
);
assert_empty(&rt, &tok).await;
let entities = rt
.create_many(&tok, vec![clean("Valid after consumed fault")])
.await
.unwrap();
assert_eq!(entities.len(), 1, "BULK_ADMISSION_EFFECT_CONTROL");
assert!(rt
.text(&tok)
.unwrap()
.get_document(&ns, entities[0].id)
.await
.unwrap()
.is_some());
}
#[tokio::test]
async fn bulk_entity_paths_preserve_rows_fts_order_and_defer_embeddings() {
for batch in [true, false] {
let rt = rt();
let ns = format!("bulk-plan-{}", Uuid::new_v4());
let tok = NamespaceToken::for_namespace(Namespace::parse(&ns).unwrap());
let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
rt.register_embedder(CapturingVecProvider {
provider_name: "bulk-plan-test".into(),
dims: 4,
captured: Arc::clone(&captured),
});
rt.install_entity_type_validator(Arc::new(|kind, entity_type| {
if kind == "concept" && entity_type == Some("algo") {
Ok(Some("algorithm".into()))
} else {
Err(RuntimeError::InvalidInput("expected fixture alias".into()))
}
}));
let specs: Vec<_> = ["Zulu concept", "Alpha concept"]
.into_iter()
.enumerate()
.map(|(index, name)| EntityCreateSpec {
kind: "concept".into(),
entity_type: Some("algo".into()),
name: name.into(),
description: Some(format!("glimmerneedle{index}")),
properties: Some(serde_json::json!({"ordinal":index,"enabled":true})),
tags: vec![format!("tag-{index}")],
})
.collect();
let entities = if batch {
rt.create_many(&tok, specs.clone()).await.unwrap()
} else {
let mut entities = Vec::new();
let mut plans = Vec::new();
for spec in specs.clone() {
let (entity, plan) = rt.prepare_bulk_entity_plan(&tok, spec).await.unwrap();
entities.push(entity);
plans.push(plan);
}
assert_eq!(rt.count_entities(&tok, None).await.unwrap(), 0);
match run_atomic_unit(rt.sql().as_ref(), plans).await.unwrap() {
AtomicRunOutcome::Committed { post_commit } => {
assert!(post_commit.as_slice().is_empty(), "BULK_PLAN_NO_REINDEX");
}
outcome => panic!("expected committed entity plans, got {outcome:?}"),
}
entities
};
assert_eq!(entities.len(), specs.len());
for (entity, spec) in entities.iter().zip(&specs) {
let stored = rt.get_entity(&tok, entity.id).await.unwrap();
assert_eq!(entity.name, spec.name, "BULK_PLAN_INPUT_ORDER");
assert_eq!(stored.namespace, ns);
assert_eq!(stored.kind, spec.kind);
assert_eq!(stored.name, spec.name);
assert_eq!(stored.description, spec.description);
assert_eq!(stored.properties, spec.properties);
assert_eq!(stored.tags, spec.tags);
assert_eq!(stored.entity_type.as_deref(), Some("algorithm"));
assert_eq!(stored.version, 1);
let document = rt
.text(&tok)
.unwrap()
.get_document(&ns, entity.id)
.await
.unwrap()
.expect("BULK_PLAN_FTS_VISIBLE");
assert_eq!(document.title.as_deref(), Some(spec.name.as_str()));
assert_eq!(
document.body,
format!("{} {}", spec.name, spec.description.as_deref().unwrap())
);
assert_eq!(document.tags, spec.tags);
assert_eq!(document.metadata, spec.properties);
assert_eq!(document.record_kind.as_deref(), Some("concept"));
assert_eq!(document.updated_at.timestamp_micros(), stored.updated_at);
let hits = rt
.text(&tok)
.unwrap()
.search(TextSearchRequest {
query: spec.description.clone().unwrap(),
mode: TextQueryMode::Plain,
filter: Some(TextFilter {
namespaces: vec![ns.clone()],
..Default::default()
}),
top_k: 10,
snippet_chars: 100,
})
.await
.unwrap();
assert_eq!(
hits.iter().map(|hit| hit.subject_id).collect::<Vec<_>>(),
vec![entity.id],
"BULK_PLAN_SEARCH_VISIBLE"
);
}
assert!(
captured.lock().unwrap().is_empty(),
"BULK_PLAN_EMBEDDING_DEFERRED"
);
assert_eq!(
rt.vectors_for_model(&tok, "bulk-plan-test")
.unwrap()
.info()
.await
.unwrap()
.entry_count,
0
);
rt.reindex_entity(&tok, &entities[0]).await.unwrap();
assert_eq!(
captured.lock().unwrap().len(),
1,
"BULK_PLAN_EMBEDDER_EFFECT_CONTROL"
);
assert_eq!(
rt.vectors_for_model(&tok, "bulk-plan-test")
.unwrap()
.info()
.await
.unwrap()
.entry_count,
1
);
}
}
#[tokio::test]
async fn create_many_persists_all_entities() {
let rt = rt();
let tok = NamespaceToken::local();
let specs: Vec<EntityCreateSpec> = (0..5)
.map(|i| EntityCreateSpec {
kind: "concept".into(),
entity_type: None,
name: format!("BulkConcept-{i}"),
description: Some(format!("desc {i}")),
properties: None,
tags: vec!["bulk-test".into()],
})
.collect();
let entities = rt.create_many(&tok, specs).await.unwrap();
assert_eq!(entities.len(), 5, "all 5 entities must be returned");
for entity in &entities {
let fetched = rt.get_entity(&tok, entity.id).await.unwrap();
assert_eq!(fetched.id, entity.id);
}
}
#[tokio::test]
async fn create_many_empty_name_rejects_atomically() {
let rt = rt();
let tok = NamespaceToken::local();
let specs = vec![
EntityCreateSpec {
kind: "concept".into(),
entity_type: None,
name: "ValidEntity".into(),
description: None,
properties: None,
tags: vec![],
},
EntityCreateSpec {
kind: "concept".into(),
entity_type: None,
name: "".into(), description: None,
properties: None,
tags: vec![],
},
];
let result = rt.create_many(&tok, specs).await;
assert!(
matches!(result, Err(RuntimeError::InvalidInput(_))),
"empty name must produce InvalidInput error"
);
let rows = rt.list_entities(&tok, None, None, 100, 0).await.unwrap();
assert_eq!(
rows.len(),
0,
"atomic rejection must leave storage unchanged"
);
}
#[tokio::test]
async fn create_many_secret_refusal_names_the_record_and_field() {
let rt = rt();
let tok = NamespaceToken::local();
let token_span = "ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
let clean = |name: &str| EntityCreateSpec {
kind: "concept".into(),
entity_type: None,
name: name.into(),
description: None,
properties: None,
tags: vec![],
};
let err = rt
.create_many(&tok, vec![clean("FirstIsClean"), clean(token_span)])
.await
.expect_err("a credential-shaped name must fail the secret gate");
let RuntimeError::SecretDetected(matched) = err else {
panic!("expected SecretDetected, got {err:?}");
};
assert_eq!(
matched.location.as_deref(),
Some("entity[1].name"),
"the refusal must name the offending record and field"
);
let mut second = clean("SecondIsClean");
second.description = Some(format!("{token_span} sitting in a description"));
let err = rt
.create_many(&tok, vec![clean("FirstIsClean"), second])
.await
.expect_err("a credential-shaped description must fail the secret gate");
let RuntimeError::SecretDetected(matched) = err else {
panic!("expected SecretDetected, got {err:?}");
};
assert_eq!(
matched.location.as_deref(),
Some("entity[1].description"),
"same record, different field: the field half of the location must move"
);
let count = rt.count_entities(&tok, None).await.unwrap();
assert_eq!(count, 0, "a rejected batch must leave no entity behind");
}
#[tokio::test]
async fn create_note_secret_refusal_names_the_field() {
let rt = rt();
let tok = NamespaceToken::local();
let token_span = "ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
let err = rt
.create_note_with_embedding_content(
&tok,
"observation",
Some(token_span),
"content with nothing credential-shaped in it",
None,
None,
None,
vec![],
)
.await
.expect_err("a credential-shaped name must fail the secret gate");
let RuntimeError::SecretDetected(matched) = err else {
panic!("expected SecretDetected, got {err:?}");
};
assert_eq!(matched.location.as_deref(), Some("note.name"));
let err = rt
.create_note_with_embedding_content(
&tok,
"observation",
Some("a clean name"),
&format!("{token_span} sitting in the content"),
None,
None,
None,
vec![],
)
.await
.expect_err("a credential-shaped content must fail the secret gate");
let RuntimeError::SecretDetected(matched) = err else {
panic!("expected SecretDetected, got {err:?}");
};
assert_eq!(
matched.location.as_deref(),
Some("note.content"),
"the location must follow the field that actually matched"
);
}
#[tokio::test]
async fn create_many_rejects_unknown_entity_type_when_validator_installed() {
let rt = rt();
let tok = NamespaceToken::local();
rt.install_entity_type_validator(Arc::new(|kind, entity_type| {
let Some(raw) = entity_type else {
return Ok(None);
};
if kind == "concept" && raw == "algorithm" {
return Ok(Some("algorithm".to_string()));
}
Err(RuntimeError::InvalidInput(format!(
"unknown entity_type {raw:?} for {kind:?}; valid: algorithm"
)))
}));
let bad_spec = vec![EntityCreateSpec {
kind: "concept".into(),
entity_type: Some("not_a_registered_type".into()),
name: "ShouldNotLand".into(),
description: None,
properties: None,
tags: vec![],
}];
let result = rt.create_many(&tok, bad_spec).await;
assert!(
matches!(result, Err(RuntimeError::InvalidInput(_))),
"unknown entity_type must be rejected by the runtime-layer validator; got {result:?}"
);
let rows = rt.list_entities(&tok, None, None, 100, 0).await.unwrap();
assert_eq!(
rows.len(),
0,
"validator rejection must leave storage empty"
);
}
#[tokio::test]
async fn create_many_accepts_valid_entity_type_via_validator() {
let rt = rt();
let tok = NamespaceToken::local();
rt.install_entity_type_validator(Arc::new(|kind, entity_type| {
let Some(raw) = entity_type else {
return Ok(None);
};
if kind == "concept" && raw == "algorithm" {
return Ok(Some("algorithm".to_string()));
}
Err(RuntimeError::InvalidInput(format!(
"unknown entity_type {raw:?} for {kind:?}"
)))
}));
let specs = vec![EntityCreateSpec {
kind: "concept".into(),
entity_type: Some("algorithm".into()),
name: "BubbleSort".into(),
description: None,
properties: None,
tags: vec![],
}];
let entities = rt.create_many(&tok, specs).await.unwrap();
assert_eq!(entities.len(), 1, "valid entity_type must be accepted");
assert_eq!(
entities[0].entity_type.as_deref(),
Some("algorithm"),
"entity_type must be stored as returned by the validator"
);
}
#[tokio::test]
async fn create_many_fts_failure_rolls_back_both_substrates() {
let ns = format!("fts-fail-many-{}", uuid::Uuid::new_v4().as_simple());
let rt = rt();
let tok = NamespaceToken::for_namespace(Namespace::parse(&ns).unwrap());
let specs = vec![
EntityCreateSpec {
kind: "concept".into(),
entity_type: None,
name: "FtsRollbackA".into(),
description: None,
properties: None,
tags: vec![],
},
EntityCreateSpec {
kind: "concept".into(),
entity_type: None,
name: "FtsRollbackB".into(),
description: None,
properties: None,
tags: vec![],
},
];
let _arm = arm_fts_fail_many_scoped(&ns);
let result = rt.create_many(&tok, specs).await;
assert!(
result.is_err(),
"create_many must return Err when FTS write fails"
);
let entity_rows = rt.list_entities(&tok, None, None, 100, 0).await.unwrap();
assert_eq!(
entity_rows.len(),
0,
"entity rows must be rolled back on FTS failure; found {entity_rows:?}"
);
let fts = rt.text(&tok).unwrap();
let fts_count = fts
.count(TextFilter {
ids: vec![],
kinds: vec![],
record_kinds: vec![],
namespaces: vec![ns.clone()],
})
.await
.unwrap();
assert_eq!(
fts_count, 0,
"fts_entities must be empty after FTS-failure rollback; found {fts_count}"
);
}
#[tokio::test]
async fn restore_note_publishes_fts_in_the_same_unit_as_the_row() {
let ns = format!("restore-fts-note-{}", uuid::Uuid::new_v4().as_simple());
let rt = rt();
let tok = NamespaceToken::for_namespace(Namespace::parse(&ns).unwrap());
let note = rt
.create_note(
&tok,
"observation",
None,
"quartz tombstone restore lookup marker",
None,
None,
vec![],
)
.await
.unwrap();
let fts = rt.text_for_notes(&tok).unwrap();
let filter = || TextFilter {
ids: vec![note.id],
kinds: vec![],
record_kinds: vec![],
namespaces: vec![ns.clone()],
};
assert_eq!(
fts.count(filter()).await.unwrap(),
1,
"control: the filter must see the live note's FTS row before the delete"
);
assert!(rt.delete_note(&tok, note.id, false).await.unwrap());
assert_eq!(
fts.count(filter()).await.unwrap(),
0,
"soft delete must remove the FTS row"
);
let _arm = arm_fts_fail_scoped(&ns);
let err = rt
.restore_note(&tok, note.id)
.await
.expect_err("the post-commit reindex failure must surface");
assert!(
err.to_string().contains("is restored and text-indexed"),
"the error must name the live row: {err}"
);
let live = rt
.notes(&tok)
.unwrap()
.get_note(note.id)
.await
.unwrap()
.expect("the row is live after the committed unit");
assert!(live.deleted_at.is_none());
assert_eq!(
fts.count(filter()).await.unwrap(),
1,
"the FTS row must be published by the restore unit, not by the reindex"
);
let hits = rt
.search_notes(&tok, "quartz tombstone", None, 5, None, false, &[], None)
.await
.unwrap();
assert!(
hits.iter().any(|h| h.note_id == note.id),
"search must find the restored note: {hits:?}"
);
}
#[tokio::test]
async fn restore_entity_publishes_fts_in_the_same_unit_as_the_row() {
let ns = format!("restore-fts-entity-{}", uuid::Uuid::new_v4().as_simple());
let rt = rt();
let tok = NamespaceToken::for_namespace(Namespace::parse(&ns).unwrap());
let entity = rt
.create_entity(
&tok,
"concept",
None,
"QuartzRestoreMarker",
Some("tombstone restore lookup marker"),
None,
vec![],
)
.await
.unwrap();
let fts = rt.text(&tok).unwrap();
let filter = || TextFilter {
ids: vec![entity.id],
kinds: vec![],
record_kinds: vec![],
namespaces: vec![ns.clone()],
};
assert_eq!(
fts.count(filter()).await.unwrap(),
1,
"control: the filter must see the live entity's FTS row before the delete"
);
assert!(rt.delete_entity(&tok, entity.id, false).await.unwrap());
assert_eq!(fts.count(filter()).await.unwrap(), 0);
let _arm = arm_fts_fail_scoped(&ns);
let err = rt
.restore_entity(&tok, entity.id)
.await
.expect_err("the post-commit reindex failure must surface");
assert!(
err.to_string().contains("is restored and text-indexed"),
"{err}"
);
let live = rt.get_entity(&tok, entity.id).await.unwrap();
assert!(live.deleted_at.is_none());
assert_eq!(
fts.count(filter()).await.unwrap(),
1,
"the FTS row must be published by the restore unit, not by the reindex"
);
}
#[tokio::test]
async fn create_many_fts_failure_injections_for_distinct_namespaces_do_not_overwrite_each_other() {
let rt_a = rt();
let ns_a = Namespace::parse("fts-fail-many-distinct-a").unwrap();
let tok_a = NamespaceToken::for_namespace(ns_a.clone());
let rt_b = rt();
let ns_b = Namespace::parse("fts-fail-many-distinct-b").unwrap();
let tok_b = NamespaceToken::for_namespace(ns_b.clone());
let _arm_a = arm_fts_fail_many_scoped(ns_a.as_str());
let _arm_b = arm_fts_fail_many_scoped(ns_b.as_str());
let (result_a, result_b) = tokio::join!(
rt_a.create_many(
&tok_a,
vec![EntityCreateSpec {
kind: "concept".into(),
entity_type: None,
name: "FtsFailureTargetA".into(),
description: None,
properties: None,
tags: vec![],
}],
),
rt_b.create_many(
&tok_b,
vec![EntityCreateSpec {
kind: "concept".into(),
entity_type: None,
name: "FtsFailureTargetB".into(),
description: None,
properties: None,
tags: vec![],
}],
),
);
assert!(
result_a.is_err(),
"namespace A must retain its pending create_many FTS failure injection"
);
assert!(
result_b.is_err(),
"namespace B must retain its pending create_many FTS failure injection"
);
}
#[tokio::test]
async fn create_many_mid_batch_storage_failure_rolls_back_both_substrates() {
let ns = format!("fts-fail-partial-{}", uuid::Uuid::new_v4().as_simple());
let rt = rt();
let tok = NamespaceToken::for_namespace(Namespace::parse(&ns).unwrap());
let specs = vec![
EntityCreateSpec {
kind: "concept".into(),
entity_type: None,
name: "PartialRollbackA".into(),
description: None,
properties: None,
tags: vec![],
},
EntityCreateSpec {
kind: "concept".into(),
entity_type: None,
name: "PartialRollbackB".into(),
description: None,
properties: None,
tags: vec![],
},
];
let _arm = arm_fts_fail_many_partial_scoped(&ns);
let result = rt.create_many(&tok, specs).await;
assert!(
result.is_err(),
"create_many must return Err when an FTS write fails mid-batch"
);
let error = result.unwrap_err().to_string();
assert!(
error.contains("atomic batch rolled back at entity index 1"),
"the failure must occur inside the atomic batch after one complete row; got: {error}"
);
let entity_rows = rt.list_entities(&tok, None, None, 100, 0).await.unwrap();
assert_eq!(
entity_rows.len(),
0,
"entity rows must be empty after a mid-batch FTS failure; found {entity_rows:?}"
);
let fts = rt.text(&tok).unwrap();
let fts_count = fts
.count(TextFilter {
ids: vec![],
kinds: vec![],
record_kinds: vec![],
namespaces: vec![ns.clone()],
})
.await
.unwrap();
assert_eq!(
fts_count, 0,
"fts_entities must be empty after a mid-batch FTS failure; found {fts_count}"
);
}
#[tokio::test]
async fn create_many_fts_partial_failure_injections_for_distinct_namespaces_do_not_overwrite_each_other(
) {
let rt_a = rt();
let ns_a = Namespace::parse("fts-fail-many-partial-distinct-a").unwrap();
let tok_a = NamespaceToken::for_namespace(ns_a.clone());
let rt_b = rt();
let ns_b = Namespace::parse("fts-fail-many-partial-distinct-b").unwrap();
let tok_b = NamespaceToken::for_namespace(ns_b.clone());
let _arm_a = arm_fts_fail_many_partial_scoped(ns_a.as_str());
let _arm_b = arm_fts_fail_many_partial_scoped(ns_b.as_str());
let (result_a, result_b) = tokio::join!(
rt_a.create_many(
&tok_a,
vec![EntityCreateSpec {
kind: "concept".into(),
entity_type: None,
name: "FtsPartialFailureTargetA".into(),
description: None,
properties: None,
tags: vec![],
}],
),
rt_b.create_many(
&tok_b,
vec![EntityCreateSpec {
kind: "concept".into(),
entity_type: None,
name: "FtsPartialFailureTargetB".into(),
description: None,
properties: None,
tags: vec![],
}],
),
);
assert!(
result_a.is_err(),
"namespace A must retain its pending create_many partial FTS failure injection"
);
assert!(
result_b.is_err(),
"namespace B must retain its pending create_many partial FTS failure injection"
);
}
#[tokio::test]
async fn get_edge_cross_namespace_succeeds() {
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 src = rt
.create_entity(&ns_a, "concept", None, "Src", None, None, vec![])
.await
.unwrap();
let tgt = rt
.create_entity(&ns_a, "concept", None, "Tgt", None, None, vec![])
.await
.unwrap();
let edge = rt
.link(&ns_a, src.id, tgt.id, EdgeRelation::Extends, 1.0, None)
.await
.unwrap();
let own_ns = rt.get_edge(&ns_a, Uuid::from(edge.id)).await;
assert!(
own_ns.is_ok() && own_ns.unwrap().is_some(),
"edge must be visible in its own namespace"
);
let cross_ns = rt.get_edge(&ns_b, Uuid::from(edge.id)).await;
assert!(
matches!(cross_ns, Ok(Some(_))),
"cross-namespace get_edge must return Ok(Some(_)) after PR-A1, got {cross_ns:?}"
);
let absent = rt.get_edge(&ns_b, Uuid::new_v4()).await;
assert!(
matches!(absent, Ok(None)),
"absent edge must return Ok(None), got {absent:?}"
);
}
#[tokio::test]
async fn deleted_entity_ids_propagates_reader_pool_admission_timeout_instead_of_swallowing_it() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("deleted-entity-ids-saturation.db");
let rt = KhiveRuntime::new_for_test(crate::config::RuntimeConfig {
db_path: Some(db_path),
..crate::config::RuntimeConfig::no_embeddings()
})
.expect("file-backed runtime construction must succeed");
let ns = NamespaceToken::for_namespace(Namespace::parse("saturation-ns").unwrap());
let a = rt
.create_entity(&ns, "concept", None, "A", None, None, vec![])
.await
.unwrap();
let pool = rt.backend().pool_arc();
let capacity = pool.reader_acquisition_snapshot().reader_admission_capacity;
let held: Vec<_> = (0..capacity)
.map(|_| pool.reader().expect("hold every pooled reader"))
.collect();
let result = rt.deleted_entity_ids(vec![a.id]).await;
assert!(
matches!(
result,
Err(RuntimeError::Storage(
khive_storage::StorageError::AdmissionTimeout { .. }
))
),
"a saturated reader pool must surface AdmissionTimeout, not an empty deleted \
set; got {result:?}"
);
drop(held);
}
#[tokio::test]
async fn traverse_foreign_full_uuid_root_uses_caller_edge_scope() {
use khive_storage::types::TraversalOptions;
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 owner_target = rt
.create_entity(&ns_a, "concept", None, "B", None, None, vec![])
.await
.unwrap();
let caller_target = rt
.create_entity(&ns_b, "concept", None, "C", None, None, vec![])
.await
.unwrap();
rt.link(
&ns_a,
a.id,
owner_target.id,
EdgeRelation::Extends,
1.0,
None,
)
.await
.unwrap();
rt.link(
&ns_b,
a.id,
caller_target.id,
EdgeRelation::Extends,
1.0,
None,
)
.await
.unwrap();
let request = TraversalRequest {
roots: vec![a.id],
options: TraversalOptions {
max_depth: 1,
direction: Direction::Out,
..Default::default()
},
include_roots: false,
include_properties: false,
execution_budget: Default::default(),
};
let paths = rt
.traverse(&ns_b, request.clone())
.await
.expect("foreign full-UUID root must be accepted");
assert_eq!(paths.len(), 1);
assert_eq!(paths[0].nodes.len(), 1);
assert_eq!(paths[0].nodes[0].node_id, caller_target.id);
let missing = Uuid::new_v4();
let error = rt
.traverse(
&ns_b,
TraversalRequest {
roots: vec![a.id, missing],
..request
},
)
.await
.unwrap_err();
assert!(matches!(
error,
RuntimeError::NotFound(message) if message.contains(&missing.to_string())
));
}
#[tokio::test]
async fn traverse_single_root_across_visible_namespaces_yields_one_path() {
use khive_storage::types::TraversalOptions;
let rt = rt();
let owner = NamespaceToken::for_namespace(Namespace::parse("owner-ns").unwrap());
let a = rt
.create_entity(&owner, "concept", None, "A", None, None, vec![])
.await
.unwrap();
let b = rt
.create_entity(&owner, "concept", None, "B", None, None, vec![])
.await
.unwrap();
rt.link(&owner, a.id, b.id, EdgeRelation::Extends, 1.0, None)
.await
.unwrap();
let caller = NamespaceToken::mint_with_visibility(
Namespace::parse("caller-ns").unwrap(),
vec![Namespace::parse("owner-ns").unwrap()],
ActorRef::anonymous(),
);
assert_eq!(caller.visible_namespaces().len(), 2);
let result = rt
.traverse(
&caller,
TraversalRequest {
roots: vec![a.id],
options: TraversalOptions {
max_depth: 1,
direction: Direction::Out,
..Default::default()
},
include_roots: true,
include_properties: false,
execution_budget: Default::default(),
},
)
.await
.unwrap();
assert_eq!(
result.len(),
1,
"one root visible across 2 namespaces must yield exactly one \
GraphPath, got {result:#?}"
);
assert_eq!(result[0].root_id, a.id);
let node_ids: std::collections::HashSet<Uuid> =
result[0].nodes.iter().map(|n| n.node_id).collect();
assert!(node_ids.contains(&a.id));
assert!(
node_ids.contains(&b.id),
"merged path must retain the neighbor discovered in the owning \
namespace, got {result:#?}"
);
}
#[tokio::test]
async fn traverse_visible_namespaces_share_one_work_budget() {
use khive_storage::types::{TraversalExecutionBudget, TraversalOptions};
let rt = rt();
let ns_a = Namespace::parse("traverse-budget-a").unwrap();
let ns_b = Namespace::parse("traverse-budget-b").unwrap();
let tok_a = NamespaceToken::for_namespace(ns_a.clone());
let tok_b = NamespaceToken::for_namespace(ns_b.clone());
let visible = NamespaceToken::mint_with_visibility(ns_a, vec![ns_b], ActorRef::anonymous());
let root = rt
.create_entity(&tok_a, "concept", None, "BudgetRoot", None, None, vec![])
.await
.unwrap();
let child_a = rt
.create_entity(&tok_a, "concept", None, "BudgetChildA", None, None, vec![])
.await
.unwrap();
let child_b = rt
.create_entity(&tok_b, "concept", None, "BudgetChildB", None, None, vec![])
.await
.unwrap();
rt.link(
&tok_a,
root.id,
child_a.id,
EdgeRelation::Extends,
1.0,
None,
)
.await
.unwrap();
rt.link(
&tok_b,
root.id,
child_b.id,
EdgeRelation::Extends,
1.0,
None,
)
.await
.unwrap();
let result = rt
.traverse(
&visible,
TraversalRequest {
roots: vec![root.id],
options: TraversalOptions {
max_depth: 1,
direction: Direction::Out,
relations: None,
min_weight: None,
limit: Some(2),
},
include_roots: false,
include_properties: false,
execution_budget: TraversalExecutionBudget::new(
1,
std::time::Duration::from_secs(5),
),
},
)
.await;
assert!(matches!(
result,
Err(RuntimeError::Storage(khive_storage::StorageError::InvalidInput {
message,
..
})) if message.contains("work budget exceeded after 1 adjacency rows")
));
}
#[tokio::test]
async fn traverse_multi_root_one_path_per_distinct_root() {
use khive_storage::types::TraversalOptions;
let rt = rt();
let owner = NamespaceToken::for_namespace(Namespace::parse("owner-ns2").unwrap());
let a = rt
.create_entity(&owner, "concept", None, "A", None, None, vec![])
.await
.unwrap();
let c = rt
.create_entity(&owner, "concept", None, "C", None, None, vec![])
.await
.unwrap();
let result = rt
.traverse(
&owner,
TraversalRequest {
roots: vec![a.id, a.id, c.id],
options: TraversalOptions {
max_depth: 1,
direction: Direction::Out,
..Default::default()
},
include_roots: true,
include_properties: false,
execution_budget: Default::default(),
},
)
.await
.unwrap();
let root_ids: Vec<Uuid> = result.iter().map(|p| p.root_id).collect();
assert_eq!(
root_ids.len(),
2,
"duplicate root value must not produce a duplicate GraphPath, got {result:#?}"
);
assert!(root_ids.contains(&a.id));
assert!(root_ids.contains(&c.id));
}
#[tokio::test]
async fn traverse_note_node_matches_neighbors_in_one_path() {
use khive_storage::types::TraversalOptions;
let rt = rt();
let owner = NamespaceToken::for_namespace(Namespace::parse("owner-ns3").unwrap());
let a = rt
.create_entity(&owner, "concept", None, "A", None, None, vec![])
.await
.unwrap();
let note = rt
.create_note(
&owner,
"observation",
Some("TraversalNote"),
"note body",
None,
None,
vec![a.id],
)
.await
.unwrap();
let caller = NamespaceToken::mint_with_visibility(
Namespace::parse("caller-ns3").unwrap(),
vec![Namespace::parse("owner-ns3").unwrap()],
ActorRef::anonymous(),
);
let neighbors = rt
.neighbors(
&caller,
a.id,
Direction::In,
None,
Some(vec![EdgeRelation::Annotates]),
)
.await
.unwrap();
let note_hit = neighbors
.iter()
.find(|hit| hit.node_id == note.id)
.unwrap_or_else(|| panic!("note must be present in neighbors, got {neighbors:#?}"));
let result = rt
.traverse(
&caller,
TraversalRequest {
roots: vec![a.id],
options: TraversalOptions {
max_depth: 1,
direction: Direction::In,
relations: Some(vec![EdgeRelation::Annotates]),
..Default::default()
},
include_roots: false,
include_properties: false,
execution_budget: Default::default(),
},
)
.await
.unwrap();
assert_eq!(
result.len(),
1,
"one requested root must yield one path across the caller and owner namespaces"
);
let note_node = result[0]
.nodes
.iter()
.find(|n| n.node_id == note.id)
.unwrap_or_else(|| panic!("note must be present in traversal nodes, got {result:#?}"));
assert_eq!(note_node.name.as_deref(), note_hit.name.as_deref());
assert_eq!(note_node.kind.as_deref(), note_hit.kind.as_deref());
assert_eq!(note_node.name.as_deref(), Some("TraversalNote"));
assert_eq!(note_node.kind.as_deref(), Some("observation"));
}
#[tokio::test]
async fn traverse_nameless_note_falls_back_to_bracketed_kind() {
use khive_storage::types::TraversalOptions;
let rt = rt();
let owner = NamespaceToken::for_namespace(Namespace::parse("owner-ns4").unwrap());
let a = rt
.create_entity(&owner, "concept", None, "A", None, None, vec![])
.await
.unwrap();
let note = rt
.create_note(
&owner,
"observation",
None,
"note body",
None,
None,
vec![a.id],
)
.await
.unwrap();
let caller = NamespaceToken::mint_with_visibility(
Namespace::parse("caller-ns4").unwrap(),
vec![Namespace::parse("owner-ns4").unwrap()],
ActorRef::anonymous(),
);
let neighbors = rt
.neighbors(
&caller,
a.id,
Direction::In,
None,
Some(vec![EdgeRelation::Annotates]),
)
.await
.unwrap();
let note_hit = neighbors
.iter()
.find(|hit| hit.node_id == note.id)
.unwrap_or_else(|| panic!("note must be present in neighbors, got {neighbors:#?}"));
let result = rt
.traverse(
&caller,
TraversalRequest {
roots: vec![a.id],
options: TraversalOptions {
max_depth: 1,
direction: Direction::In,
relations: Some(vec![EdgeRelation::Annotates]),
..Default::default()
},
include_roots: false,
include_properties: false,
execution_budget: Default::default(),
},
)
.await
.unwrap();
let note_node = result[0]
.nodes
.iter()
.find(|n| n.node_id == note.id)
.unwrap_or_else(|| panic!("note must be present in traversal nodes, got {result:#?}"));
assert_eq!(note_node.name.as_deref(), note_hit.name.as_deref());
assert_eq!(note_node.kind.as_deref(), note_hit.kind.as_deref());
assert_eq!(note_node.name.as_deref(), Some("[observation]"));
assert_eq!(note_node.kind.as_deref(), Some("observation"));
}
async fn count_all_incident_edges(rt: &KhiveRuntime, node_id: Uuid, ns: &str) -> u64 {
let mut reader = rt.sql().reader().await.expect("sql reader must open");
let row = reader
.query_scalar(SqlStatement {
sql: "SELECT COUNT(*) FROM graph_edges \
WHERE namespace = ?1 AND (source_id = ?2 OR target_id = ?2)"
.into(),
params: vec![
SqlValue::Text(ns.to_string()),
SqlValue::Text(node_id.to_string()),
],
label: Some("count_all_incident_edges".into()),
})
.await
.expect("count query must succeed");
match row {
Some(SqlValue::Integer(n)) => n as u64,
_ => panic!("count must return an integer"),
}
}
async fn lineage_warning_events(
rt: &KhiveRuntime,
tok: &NamespaceToken,
target_id: Uuid,
) -> Vec<Event> {
rt.events(tok)
.expect("event store")
.query_events(
EventFilter {
kinds: vec![EventKind::Audit],
..Default::default()
},
PageRequest::default(),
)
.await
.expect("query warning events")
.items
.into_iter()
.filter(|event| event.target_id == Some(target_id))
.collect()
}
#[tokio::test]
async fn hard_delete_emits_relation_specific_lineage_warnings_only() {
let rt = rt();
let tok = NamespaceToken::local();
let doomed = rt
.create_entity(&tok, "document", None, "doomed", None, None, vec![])
.await
.unwrap();
let provenance_source = rt
.create_entity(&tok, "artifact", None, "source", None, None, vec![])
.await
.unwrap();
rt.link(
&tok,
provenance_source.id,
doomed.id,
EdgeRelation::DerivedFrom,
1.0,
None,
)
.await
.unwrap();
for (kind, name, relation) in [
("document", "old", EdgeRelation::Supersedes),
("document", "next", EdgeRelation::Precedes),
("concept", "supported", EdgeRelation::Supports),
("concept", "refuted", EdgeRelation::Refutes),
] {
let target = rt
.create_entity(&tok, kind, None, name, None, None, vec![])
.await
.unwrap();
rt.link(&tok, doomed.id, target.id, relation, 1.0, None)
.await
.unwrap();
}
let unrelated = rt
.create_entity(&tok, "concept", None, "unrelated", None, None, vec![])
.await
.unwrap();
rt.link(
&tok,
unrelated.id,
doomed.id,
EdgeRelation::IntroducedBy,
1.0,
None,
)
.await
.unwrap();
assert!(rt.delete_entity(&tok, doomed.id, false).await.unwrap());
assert!(
lineage_warning_events(&rt, &tok, doomed.id)
.await
.is_empty(),
"soft delete must not emit cascade-loss warnings"
);
assert!(rt.delete_entity(&tok, doomed.id, true).await.unwrap());
let warnings = lineage_warning_events(&rt, &tok, doomed.id).await;
assert_eq!(warnings.len(), 5);
let mut actual = warnings
.iter()
.map(|event| {
assert_eq!(event.substrate, SubstrateKind::Entity);
assert_eq!(event.payload["severity"], "warning");
assert_eq!(event.payload["deleted_id"], doomed.id.to_string());
assert_eq!(event.payload["edge_count"], 1);
assert_eq!(event.payload["edges"].as_array().unwrap().len(), 1);
(
event.payload["relation"].as_str().unwrap().to_string(),
event.payload["warning"].as_str().unwrap().to_string(),
)
})
.collect::<Vec<_>>();
actual.sort();
assert_eq!(
actual,
vec![
("derived_from".into(), "provenance_loss".into()),
("precedes".into(), "temporal_sequence_loss".into()),
("refutes".into(), "evidential_link_loss".into()),
("supersedes".into(), "replacement_lineage_loss".into()),
("supports".into(), "evidential_link_loss".into()),
]
);
assert!(warnings
.iter()
.all(|event| event.payload["relation"] != "introduced_by"));
}
#[tokio::test]
async fn hard_delete_lineage_warning_is_filterable_by_caller_actor() {
let rt = rt();
let tok = NamespaceToken::mint_authorized(
Namespace::local(),
ActorRef::new("agent", "lineage-deleter"),
);
let expected_actor = "agent:lineage-deleter";
let doomed = rt
.create_entity(&tok, "document", None, "actor-doomed", None, None, vec![])
.await
.unwrap();
let source = rt
.create_entity(&tok, "artifact", None, "actor-source", None, None, vec![])
.await
.unwrap();
rt.link(
&tok,
source.id,
doomed.id,
EdgeRelation::DerivedFrom,
1.0,
None,
)
.await
.unwrap();
assert!(rt.delete_entity(&tok, doomed.id, true).await.unwrap());
let warnings = rt
.events(&tok)
.expect("event store")
.query_events(
EventFilter {
kinds: vec![EventKind::Audit],
actors: vec![expected_actor.to_string()],
..Default::default()
},
PageRequest::default(),
)
.await
.expect("query actor-filtered warning events")
.items
.into_iter()
.filter(|event| event.target_id == Some(doomed.id))
.collect::<Vec<_>>();
assert_eq!(warnings.len(), 1);
assert_eq!(warnings[0].actor, expected_actor);
assert_eq!(warnings[0].payload["relation"], "derived_from");
}
#[tokio::test]
async fn hard_delete_warns_for_tombstoned_protected_incident_edge() {
let rt = rt();
let tok = NamespaceToken::local();
let doomed = rt
.create_entity(
&tok,
"document",
None,
"tombstoned-edge-doomed",
None,
None,
vec![],
)
.await
.unwrap();
let source = rt
.create_entity(
&tok,
"artifact",
None,
"tombstoned-edge-source",
None,
None,
vec![],
)
.await
.unwrap();
let edge = rt
.link(
&tok,
source.id,
doomed.id,
EdgeRelation::DerivedFrom,
1.0,
None,
)
.await
.unwrap();
let edge_id = Uuid::from(edge.id);
assert!(rt.delete_edge(&tok, edge_id, false).await.unwrap());
assert!(rt.delete_entity(&tok, doomed.id, true).await.unwrap());
let warnings = lineage_warning_events(&rt, &tok, doomed.id).await;
assert_eq!(warnings.len(), 1);
assert_eq!(warnings[0].payload["edge_count"], 1);
let warned_edge = &warnings[0].payload["edges"].as_array().unwrap()[0];
assert_eq!(warned_edge["id"], edge_id.to_string());
assert!(
warned_edge["deleted_at"].is_number(),
"warning must preserve the edge's prior tombstone timestamp"
);
assert!(
rt.get_edge_including_deleted(&tok, edge_id)
.await
.unwrap()
.is_none(),
"the warned tombstoned edge must still be physically cascaded"
);
}
#[tokio::test]
async fn hard_delete_note_emits_evidential_lineage_warning() {
let rt = rt();
let tok = NamespaceToken::local();
let evidence = rt
.create_note(&tok, "observation", None, "evidence", None, None, vec![])
.await
.unwrap();
let claim = rt
.create_note(&tok, "insight", None, "claim", None, None, vec![])
.await
.unwrap();
rt.link(
&tok,
evidence.id,
claim.id,
EdgeRelation::Supports,
1.0,
None,
)
.await
.unwrap();
assert!(rt.delete_note(&tok, evidence.id, true).await.unwrap());
let warnings = lineage_warning_events(&rt, &tok, evidence.id).await;
assert_eq!(warnings.len(), 1);
assert_eq!(warnings[0].substrate, SubstrateKind::Note);
assert_eq!(warnings[0].payload["relation"], "supports");
assert_eq!(warnings[0].payload["warning"], "evidential_link_loss");
}
#[tokio::test]
async fn lineage_warning_insert_failure_rolls_back_hard_delete_and_cascade() {
let rt = rt();
let tok = NamespaceToken::local();
let doomed = rt
.create_entity(&tok, "document", None, "doomed", None, None, vec![])
.await
.unwrap();
let source = rt
.create_entity(&tok, "artifact", None, "source", None, None, vec![])
.await
.unwrap();
let edge = rt
.link(
&tok,
source.id,
doomed.id,
EdgeRelation::DerivedFrom,
1.0,
None,
)
.await
.unwrap();
let mut writer = rt.sql().writer().await.expect("sql writer");
writer
.execute_script(
"CREATE TRIGGER reject_lineage_warning \
BEFORE INSERT ON events \
WHEN NEW.kind = 'audit' \
AND json_extract(NEW.payload, '$.severity') = 'warning' \
BEGIN \
SELECT RAISE(ABORT, 'injected lineage warning failure'); \
END;"
.into(),
)
.await
.expect("install warning failure trigger");
drop(writer);
let error = rt.delete_entity(&tok, doomed.id, true).await.unwrap_err();
assert!(error
.to_string()
.contains("injected lineage warning failure"));
assert!(
rt.entities(&tok)
.unwrap()
.get_entity(doomed.id)
.await
.unwrap()
.is_some(),
"the endpoint row must roll back with the failed warning insert"
);
assert!(
rt.get_edge(&tok, Uuid::from(edge.id))
.await
.unwrap()
.is_some(),
"the incident edge must roll back with the failed warning insert"
);
}
#[tokio::test]
async fn hard_delete_entity_purges_already_soft_deleted_incident_edge() {
let rt = rt();
let tok = NamespaceToken::local();
let ns = tok.namespace().to_string();
let a = rt
.create_entity(&tok, "concept", None, "SrcA", None, None, vec![])
.await
.unwrap();
let b = rt
.create_entity(&tok, "concept", None, "TgtB", None, None, vec![])
.await
.unwrap();
rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
.await
.unwrap();
let edge_hit = rt
.neighbors(&tok, a.id, Direction::Out, None, None)
.await
.unwrap();
assert_eq!(edge_hit.len(), 1, "edge must exist before soft-delete");
let edge_uuid = edge_hit[0].edge_id;
rt.delete_edge(&tok, edge_uuid, false).await.unwrap();
let visible = rt
.neighbors(&tok, a.id, Direction::Out, None, None)
.await
.unwrap();
assert!(visible.is_empty(), "soft-deleted edge must be invisible");
let raw_before = count_all_incident_edges(&rt, a.id, &ns).await;
assert_eq!(
raw_before, 1,
"soft-deleted edge must still be a physical row"
);
rt.delete_entity(&tok, a.id, true).await.unwrap();
let raw_after = count_all_incident_edges(&rt, a.id, &ns).await;
assert_eq!(
raw_after, 0,
"purge_incident_edges must physically remove soft-deleted edge rows (ADR-002)"
);
}
#[tokio::test]
async fn hard_delete_note_purges_already_soft_deleted_incident_edge() {
let rt = rt();
let tok = NamespaceToken::local();
let ns = tok.namespace().to_string();
let target = rt
.create_note(
&tok,
"observation",
None,
"purge-cascade target note",
Some(0.5),
None,
vec![],
)
.await
.unwrap();
let annotating = rt
.create_note(
&tok,
"insight",
None,
"annotator note",
Some(0.5),
None,
vec![target.id],
)
.await
.unwrap();
let edge_hit = rt
.neighbors(
&tok,
annotating.id,
Direction::Out,
None,
Some(vec![EdgeRelation::Annotates]),
)
.await
.unwrap();
assert_eq!(edge_hit.len(), 1, "annotates edge must exist");
let edge_uuid = edge_hit[0].edge_id;
rt.delete_edge(&tok, edge_uuid, false).await.unwrap();
let raw_before = count_all_incident_edges(&rt, target.id, &ns).await;
assert_eq!(
raw_before, 1,
"soft-deleted edge must still be a physical row before note purge"
);
rt.delete_note(&tok, target.id, true).await.unwrap();
let raw_after = count_all_incident_edges(&rt, target.id, &ns).await;
assert_eq!(
raw_after, 0,
"purge_incident_edges must physically remove soft-deleted edge rows on note purge (ADR-002)"
);
}
async fn count_all_incident_edges_global(rt: &KhiveRuntime, node_id: Uuid) -> u64 {
let mut reader = rt.sql().reader().await.expect("sql reader must open");
let row = reader
.query_scalar(SqlStatement {
sql: "SELECT COUNT(*) FROM graph_edges WHERE source_id = ?1 OR target_id = ?1".into(),
params: vec![SqlValue::Text(node_id.to_string())],
label: Some("count_all_incident_edges_global".into()),
})
.await
.expect("count query must succeed");
match row {
Some(SqlValue::Integer(n)) => n as u64,
_ => panic!("count must return an integer"),
}
}
#[tokio::test]
async fn cross_namespace_hard_delete_entity_purges_all_incident_edges() {
let rt = rt();
let ns_owner = NamespaceToken::for_namespace(Namespace::parse("ns-owner").unwrap());
let ns_caller = NamespaceToken::for_namespace(Namespace::parse("ns-caller").unwrap());
let entity = rt
.create_entity(
&ns_owner,
"concept",
None,
"ForeignEntity",
None,
None,
vec![],
)
.await
.unwrap();
let peer = rt
.create_entity(&ns_owner, "concept", None, "Peer", None, None, vec![])
.await
.unwrap();
rt.link(
&ns_owner,
entity.id,
peer.id,
EdgeRelation::Extends,
1.0,
None,
)
.await
.unwrap();
rt.link(
&ns_owner,
peer.id,
entity.id,
EdgeRelation::Extends,
1.0,
None,
)
.await
.unwrap();
let before = count_all_incident_edges_global(&rt, entity.id).await;
assert_eq!(before, 2, "two incident edges must exist before delete");
let deleted = rt.delete_entity(&ns_caller, entity.id, true).await.unwrap();
assert!(deleted, "cross-ns hard delete must return true");
let after = count_all_incident_edges_global(&rt, entity.id).await;
assert_eq!(
after, 0,
"purge_incident_edges must remove all incident edges across namespaces (ADR-002, ADR-007)"
);
}
async fn count_edge_rows_by_id(rt: &KhiveRuntime, edge_id: Uuid, ns: &str) -> u64 {
let mut reader = rt.sql().reader().await.expect("sql reader must open");
let row = reader
.query_scalar(SqlStatement {
sql: "SELECT COUNT(*) FROM graph_edges WHERE namespace = ?1 AND id = ?2".into(),
params: vec![
SqlValue::Text(ns.to_string()),
SqlValue::Text(edge_id.to_string()),
],
label: Some("count_edge_rows_by_id".into()),
})
.await
.expect("count query must succeed");
match row {
Some(SqlValue::Integer(n)) => n as u64,
_ => panic!("count must return an integer"),
}
}
#[tokio::test]
async fn hard_delete_edge_purges_already_soft_deleted_primary_edge() {
let rt = rt();
let tok = NamespaceToken::local();
let ns = tok.namespace().to_string();
let a = rt
.create_entity(&tok, "concept", None, "EA", None, None, vec![])
.await
.unwrap();
let b = rt
.create_entity(&tok, "concept", None, "EB", 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 soft = rt.delete_edge(&tok, edge_uuid, false).await.unwrap();
assert!(soft, "soft delete must succeed");
assert!(
rt.get_edge(&tok, edge_uuid).await.unwrap().is_none(),
"soft-deleted edge must be invisible to get_edge"
);
assert_eq!(
count_edge_rows_by_id(&rt, edge_uuid, &ns).await,
1,
"soft-deleted edge must still be a physical row"
);
let purged = rt.delete_edge(&tok, edge_uuid, true).await.unwrap();
assert!(
purged,
"hard delete of a soft-deleted edge must return true"
);
assert_eq!(
count_edge_rows_by_id(&rt, edge_uuid, &ns).await,
0,
"hard-delete must physically remove the soft-deleted edge row (ADR-002)"
);
}
#[tokio::test]
async fn hard_delete_base_edge_purges_already_soft_deleted_annotates_edge() {
let rt = rt();
let tok = NamespaceToken::local();
let ns = tok.namespace().to_string();
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 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 base edge",
Some(0.5),
None,
vec![base_edge_uuid],
)
.await
.unwrap();
let ann_hits = rt
.neighbors(
&tok,
note.id,
Direction::Out,
None,
Some(vec![EdgeRelation::Annotates]),
)
.await
.unwrap();
assert_eq!(ann_hits.len(), 1, "annotates edge must exist");
let ann_edge_uuid = ann_hits[0].edge_id;
rt.delete_edge(&tok, ann_edge_uuid, false).await.unwrap();
assert_eq!(
count_edge_rows_by_id(&rt, ann_edge_uuid, &ns).await,
1,
"soft-deleted annotates edge must still be a physical row"
);
let purged = rt.delete_edge(&tok, base_edge_uuid, true).await.unwrap();
assert!(purged, "hard delete of base edge must return true");
assert_eq!(
count_edge_rows_by_id(&rt, ann_edge_uuid, &ns).await,
0,
"hard-delete of base edge must purge already-soft-deleted annotates edge row (ADR-002)"
);
assert_eq!(
count_edge_rows_by_id(&rt, base_edge_uuid, &ns).await,
0,
"hard-delete must physically remove the base edge row"
);
}
#[tokio::test]
async fn create_entity_fts_failure_rolls_back_entity_row() {
let rt = KhiveRuntime::memory().unwrap();
let ns = Namespace::parse("fault-entity-fts").unwrap();
let tok = NamespaceToken::for_namespace(ns.clone());
let _arm = arm_fts_fail_scoped(ns.as_str());
let result = rt
.create_entity(
&tok,
"concept",
None,
"fts-fail rollback target",
None,
None,
vec![],
)
.await;
assert!(
result.is_err(),
"create_entity must propagate the injected FTS failure"
);
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("injected FTS failure"),
"error must carry injection message; got: {err_msg}"
);
let entities = rt.list_entities(&tok, None, None, 1000, 0).await.unwrap();
assert!(
entities.is_empty(),
"compensation must remove the entity row after FTS failure; got {entities:?}"
);
}
#[tokio::test]
async fn create_entity_fts_and_compensation_failures_report_partial_persistence() {
let rt = KhiveRuntime::memory().unwrap();
let ns = Namespace::parse("partial-persistence-fts").unwrap();
let tok = NamespaceToken::for_namespace(ns.clone());
let _fts_arm = arm_fts_fail_scoped(ns.as_str());
let _cleanup_arm = arm_entity_compensation_fail_scoped(ns.as_str());
let error = rt
.create_entity(
&tok,
"concept",
None,
"FTS partial persistence",
None,
None,
vec![],
)
.await
.expect_err("FTS and compensation failures must fail create_entity");
assert!(
matches!(&error, RuntimeError::Khive(_)),
"combined failures must use the structured wire error path"
);
let error_message = error.to_string();
assert!(error_message.contains("injected FTS failure"));
assert!(error_message.contains("injected compensation failure"));
assert!(error_message.contains("partial persistence is possible"));
let entities = rt.list_entities(&tok, None, None, 10, 0).await.unwrap();
assert_eq!(
entities.len(),
1,
"the failed row cleanup must leave residue"
);
let record_id = entities[0].id;
assert!(
error_message.contains(&record_id.to_string()),
"the error must identify the durable row that needs reconciliation"
);
assert!(
rt.text(&tok)
.unwrap()
.get_document(ns.as_str(), record_id)
.await
.unwrap()
.is_none(),
"FTS cleanup must run even when the FTS upsert reports failure"
);
}
#[tokio::test]
async fn create_entity_vector_failure_rolls_back_entity_row_and_fts() {
const MODEL: &str = "test-entity-vec-inject";
const DIMS: usize = 4;
let rt = KhiveRuntime::memory().unwrap();
let (provider, _counter) = ConstVecProvider::new(MODEL, DIMS);
rt.register_embedder(provider);
let ns = Namespace::parse("fault-entity-vec").unwrap();
let tok = NamespaceToken::for_namespace(ns.clone());
let _arm = arm_vector_fail_scoped(ns.as_str());
let result = rt
.create_entity(
&tok,
"concept",
None,
"vec-fail rollback target",
Some("description so embed body is non-empty"),
None,
vec![],
)
.await;
assert!(
result.is_err(),
"create_entity must propagate the injected vector failure"
);
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("injected vector failure"),
"error must carry injection message; got: {err_msg}"
);
let entities = rt.list_entities(&tok, None, None, 1000, 0).await.unwrap();
assert!(
entities.is_empty(),
"compensation must remove entity row after vector failure; got {entities:?}"
);
use khive_storage::types::{TextFilter, TextQueryMode, TextSearchRequest};
let fts_hits = rt
.text(&tok)
.unwrap()
.search(TextSearchRequest {
query: "vec-fail rollback target".to_string(),
mode: TextQueryMode::Plain,
filter: Some(TextFilter {
namespaces: vec![ns.as_str().to_string()],
..Default::default()
}),
top_k: 10,
snippet_chars: 100,
})
.await
.unwrap();
assert!(
fts_hits.is_empty(),
"compensation must remove FTS document after vector failure; got {fts_hits:?}"
);
}
#[tokio::test]
async fn create_entity_single_model_reports_primary_and_compensation_failures() {
const MODEL: &str = "partial-persistence-single";
let rt = KhiveRuntime::memory().unwrap();
let (provider, _counter) = ConstVecProvider::new(MODEL, 4);
rt.register_embedder(provider);
let ns = Namespace::parse("partial-persistence-single").unwrap();
let tok = NamespaceToken::for_namespace(ns.clone());
let _vector_arm = arm_vector_fail_scoped(ns.as_str());
let _cleanup_arm = arm_entity_compensation_fail_scoped(ns.as_str());
let error = rt
.create_entity(
&tok,
"concept",
None,
"single-model partial persistence",
Some("the FTS document lands before the injected vector failure"),
None,
vec![],
)
.await
.expect_err("vector and compensation failures must fail create_entity");
let error_message = error.to_string();
assert!(error_message.contains("injected vector failure"));
assert!(error_message.contains("injected compensation failure"));
assert!(error_message.contains("partial persistence is possible"));
let entities = rt.list_entities(&tok, None, None, 10, 0).await.unwrap();
assert_eq!(
entities.len(),
1,
"the failed row cleanup must leave residue"
);
let record_id = entities[0].id;
assert!(
error_message.contains(&record_id.to_string()),
"the caller-visible error must carry the recovery record ID"
);
assert!(
rt.text(&tok)
.unwrap()
.get_document(ns.as_str(), record_id)
.await
.unwrap()
.is_none(),
"independent FTS compensation must still complete"
);
assert_eq!(
rt.vectors_for_model(&tok, MODEL)
.unwrap()
.count()
.await
.unwrap(),
0,
"the failed model must be cleaned even though INSERT returned an error"
);
}
#[tokio::test]
async fn create_entity_multi_model_second_vector_failure_rolls_back_all() {
const DIMS: usize = 4;
let rt = KhiveRuntime::memory().unwrap();
let (provider_a, _ca) = ConstVecProvider::new("model-a", DIMS);
let (provider_b, _cb) = ConstVecProvider::new("model-b", DIMS);
rt.register_embedder(provider_a);
rt.register_embedder(provider_b);
let ns = Namespace::parse("fault-entity-multi").unwrap();
let tok = NamespaceToken::for_namespace(ns.clone());
arm_vector_fail_after(1);
let result = rt
.create_entity(
&tok,
"concept",
None,
"multi-model rollback target",
Some("description for embedding"),
None,
vec![],
)
.await;
assert!(
result.is_err(),
"create_entity must propagate the injected multi-model vector failure"
);
let entities = rt.list_entities(&tok, None, None, 1000, 0).await.unwrap();
assert!(
entities.is_empty(),
"compensation must remove entity row; got {entities:?}"
);
use khive_storage::types::VectorSearchRequest;
let query_vec = vec![1.0_f32; DIMS];
let hits_a = rt
.vectors_for_model(&tok, "model-a")
.unwrap()
.search(VectorSearchRequest {
query_vectors: vec![query_vec.clone()],
top_k: 100,
namespace: Some(ns.as_str().to_string()),
kind: Some(khive_types::SubstrateKind::Entity),
embedding_model: Some("model-a".to_string()),
filter: None,
backend_hints: None,
})
.await
.unwrap();
assert!(
hits_a.is_empty(),
"model-a vector store must be empty after rollback; got {hits_a:?}"
);
let hits_b = rt
.vectors_for_model(&tok, "model-b")
.unwrap()
.search(VectorSearchRequest {
query_vectors: vec![query_vec],
top_k: 100,
namespace: Some(ns.as_str().to_string()),
kind: Some(khive_types::SubstrateKind::Entity),
embedding_model: Some("model-b".to_string()),
filter: None,
backend_hints: None,
})
.await
.unwrap();
assert!(
hits_b.is_empty(),
"model-b vector store must be empty after rollback; got {hits_b:?}"
);
}
#[tokio::test]
async fn create_entity_multi_model_reports_cleanup_failure_and_removes_all_vectors() {
let rt = KhiveRuntime::memory().unwrap();
let (provider_a, _ca) = ConstVecProvider::new("partial-multi-a", 4);
let (provider_b, _cb) = ConstVecProvider::new("partial-multi-b", 4);
rt.register_embedder(provider_a);
rt.register_embedder(provider_b);
let ns = Namespace::parse("partial-persistence-multi").unwrap();
let tok = NamespaceToken::for_namespace(ns.clone());
let _cleanup_arm = arm_entity_compensation_fail_scoped(ns.as_str());
arm_vector_fail_after(1);
let error = rt
.create_entity(
&tok,
"concept",
None,
"multi-model partial persistence",
Some("the second model fails after the first vector lands"),
None,
vec![],
)
.await
.expect_err("second vector and compensation failures must fail create_entity");
let error_message = error.to_string();
assert!(
error_message.contains("injected vector insert failure"),
"primary cause must be retained: {error_message}"
);
assert!(error_message.contains("injected compensation failure"));
let entities = rt.list_entities(&tok, None, None, 10, 0).await.unwrap();
assert_eq!(
entities.len(),
1,
"the failed row cleanup must leave residue"
);
let record_id = entities[0].id;
assert!(error_message.contains(&record_id.to_string()));
for model in ["partial-multi-a", "partial-multi-b"] {
assert_eq!(
rt.vectors_for_model(&tok, model)
.unwrap()
.count()
.await
.unwrap(),
0,
"compensation must clean both the successful and failed model ({model})"
);
}
assert!(
rt.text(&tok)
.unwrap()
.get_document(ns.as_str(), record_id)
.await
.unwrap()
.is_none(),
"FTS compensation must succeed even when row cleanup fails"
);
}
#[tokio::test]
async fn create_entity_multi_model_counts_all_executed_embeds() {
const DIMS: usize = 4;
let rt = KhiveRuntime::memory().unwrap();
let (provider_a, _ca) = ConstVecProvider::new("usage-entity-model-a", DIMS);
let (provider_b, _cb) = ConstVecProvider::new("usage-entity-model-b", DIMS);
rt.register_embedder(provider_a);
rt.register_embedder(provider_b);
let ns = Namespace::parse("usage-entity-multi").unwrap();
let tok = NamespaceToken::for_namespace(ns.clone());
let ctx = crate::usage::UsageContext::new();
crate::usage::scope(ctx.clone(), async {
rt.create_entity(
&tok,
"concept",
None,
"usage-counted entity",
Some("description so embed body is non-empty"),
None,
vec![],
)
.await
})
.await
.expect("create_entity must succeed");
let snap = ctx.snapshot();
assert_eq!(
snap["embed_calls"], 2,
"both configured models' executed embeds must be counted; got {snap:?}"
);
}
#[tokio::test]
async fn create_note_multi_model_counts_all_executed_embeds() {
const DIMS: usize = 4;
let rt = KhiveRuntime::memory().unwrap();
let (provider_a, _ca) = ConstVecProvider::new("usage-note-model-a", DIMS);
let (provider_b, _cb) = ConstVecProvider::new("usage-note-model-b", DIMS);
rt.register_embedder(provider_a);
rt.register_embedder(provider_b);
let ns = Namespace::parse("usage-note-multi").unwrap();
let tok = NamespaceToken::for_namespace(ns.clone());
let ctx = crate::usage::UsageContext::new();
crate::usage::scope(ctx.clone(), async {
rt.create_note(
&tok,
"observation",
None,
"usage-counted note body",
None,
None,
vec![],
)
.await
})
.await
.expect("create_note must succeed");
let snap = ctx.snapshot();
assert_eq!(
snap["embed_calls"], 2,
"both configured models' executed embeds must be counted; got {snap:?}"
);
}
#[tokio::test]
async fn create_entity_multi_model_error_keeps_issued_usage_stable() {
const DIMS: usize = 4;
let rt = KhiveRuntime::memory().unwrap();
rt.register_embedder(FailFastProvider::new("usage-entity-fail-fast"));
rt.register_embedder(SlowVecProvider::new("usage-entity-slow", DIMS));
let ns = Namespace::parse("usage-entity-error-drain").unwrap();
let tok = NamespaceToken::for_namespace(ns.clone());
let ctx = crate::usage::UsageContext::new();
let result = crate::usage::scope(ctx.clone(), async {
rt.create_entity(
&tok,
"concept",
None,
"usage-drain entity",
Some("description so embed body is non-empty"),
None,
vec![],
)
.await
})
.await;
assert!(
result.is_err(),
"one model failing must fail the whole create_entity call"
);
let snap_immediately_after = ctx.snapshot();
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
let snap_after_delay = ctx.snapshot();
assert_eq!(
snap_immediately_after, snap_after_delay,
"embed completion after create_entity returns must not change issued \
usage; got immediately_after={snap_immediately_after:?} \
after_delay={snap_after_delay:?}"
);
}
#[tokio::test]
async fn create_note_multi_model_error_keeps_issued_usage_stable() {
const DIMS: usize = 4;
let rt = KhiveRuntime::memory().unwrap();
rt.register_embedder(FailFastProvider::new("usage-note-fail-fast"));
rt.register_embedder(SlowVecProvider::new("usage-note-slow", DIMS));
let ns = Namespace::parse("usage-note-error-drain").unwrap();
let tok = NamespaceToken::for_namespace(ns.clone());
let ctx = crate::usage::UsageContext::new();
let result = crate::usage::scope(ctx.clone(), async {
rt.create_note(
&tok,
"observation",
None,
"usage-drain note body",
None,
None,
vec![],
)
.await
})
.await;
assert!(
result.is_err(),
"one model failing must fail the whole create_note call"
);
let snap_immediately_after = ctx.snapshot();
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
let snap_after_delay = ctx.snapshot();
assert_eq!(
snap_immediately_after, snap_after_delay,
"embed completion after create_note returns must not change issued \
usage; got immediately_after={snap_immediately_after:?} \
after_delay={snap_after_delay:?}"
);
}
#[tokio::test]
async fn create_entity_fast_embed_failure_does_not_wait_for_hung_sibling() {
const DIMS: usize = 4;
let rt = KhiveRuntime::memory().unwrap();
rt.register_embedder(FailFastProvider::new("latency-fail-fast"));
let (parked, _release, _entered) = ParkedVecProvider::new("latency-parked", DIMS);
rt.register_embedder(parked);
let ns = Namespace::parse("usage-entity-latency").unwrap();
let tok = NamespaceToken::for_namespace(ns.clone());
let started = std::time::Instant::now();
let result = tokio::time::timeout(
std::time::Duration::from_secs(60),
rt.create_entity(
&tok,
"concept",
None,
"latency entity",
Some("description so embed body is non-empty"),
None,
vec![],
),
)
.await
.expect("create_entity must return within the timeout — a hang means the abort path regressed to await-to-completion");
let elapsed = started.elapsed();
assert!(
result.is_err(),
"one model failing must fail the whole create_entity call"
);
assert!(
elapsed < std::time::Duration::from_secs(5),
"a fast embed failure must return without waiting on the hung \
sibling (which parks forever); took {elapsed:?}"
);
}
#[tokio::test]
async fn aborted_embed_task_still_counts_issued_embed_call() {
const DIMS: usize = 4;
let rt = std::sync::Arc::new(KhiveRuntime::memory().unwrap());
let (parked, _release, entered) = ParkedVecProvider::new("abort-count-parked", DIMS);
rt.register_embedder(parked);
let ctx = crate::usage::UsageContext::new();
let task = {
let rt = std::sync::Arc::clone(&rt);
let ctx = ctx.clone();
tokio::spawn(crate::usage::scope(ctx, async move {
rt.embed_document_with_model_outcome("abort-count-parked", "abort count body")
.await
}))
};
entered.notified().await;
task.abort();
let joined = task.await;
assert!(
joined.is_err() && joined.unwrap_err().is_cancelled(),
"task must end as cancelled by the abort"
);
assert_eq!(
ctx.snapshot()["embed_calls"],
1,
"an issued embed call must be counted even when the task is \
aborted while parked on the provider await"
);
}
#[tokio::test]
async fn search_notes_counts_one_fts_pass() {
let rt = KhiveRuntime::memory().unwrap();
let ns = Namespace::parse("usage-note-search").unwrap();
let tok = NamespaceToken::for_namespace(ns.clone());
rt.create_note(
&tok,
"observation",
None,
"usage counted note search body",
None,
None,
vec![],
)
.await
.expect("create_note must succeed");
let ctx = crate::usage::UsageContext::new();
crate::usage::scope(ctx.clone(), async {
rt.search_notes(&tok, "usage counted", None, 10, None, false, &[], None)
.await
})
.await
.expect("search_notes must succeed");
let snap = ctx.snapshot();
assert!(
snap["fts_passes"].as_u64().unwrap_or(0) >= 1,
"note search FTS execution must count fts_passes; got {snap:?}"
);
}
#[tokio::test]
async fn search_notes_batches_supersedes_suppression_round_trip() {
let rt = KhiveRuntime::memory().unwrap();
let ns = Namespace::parse("usage-note-supersedes-batch").unwrap();
let tok = NamespaceToken::for_namespace(ns);
let mut notes = Vec::new();
for ordinal in 0..4 {
notes.push(
rt.create_note(
&tok,
"observation",
None,
&format!("batchsupersedesprobe live candidate {ordinal}"),
None,
None,
vec![],
)
.await
.expect("create_note must succeed"),
);
}
rt.link(
&tok,
notes[3].id,
notes[0].id,
EdgeRelation::Supersedes,
1.0,
None,
)
.await
.expect("supersedes link must succeed");
let ctx = crate::usage::UsageContext::new();
let hits = crate::usage::scope(ctx.clone(), async {
rt.search_notes(
&tok,
"batchsupersedesprobe",
None,
10,
None,
false,
&[],
None,
)
.await
})
.await
.expect("search_notes must succeed");
let hit_ids: std::collections::HashSet<Uuid> = hits.iter().map(|hit| hit.note_id).collect();
assert_eq!(
hit_ids.len(),
3,
"all live, non-superseded notes must remain"
);
assert!(
!hit_ids.contains(¬es[0].id),
"the superseded note must be excluded"
);
assert!(
notes[1..].iter().all(|note| hit_ids.contains(¬e.id)),
"every live, non-superseded note must be returned"
);
let snap = ctx.snapshot();
assert_eq!(
snap["db_round_trips"], 1,
"supersedes suppression must use one batched adjacency query; got {snap:?}"
);
}
#[tokio::test]
async fn search_notes_hydrates_candidates_in_chunked_batch_reads() {
const LIVE: usize = 901;
const LIMIT: u32 = 226;
let rt = KhiveRuntime::memory().unwrap();
let tok = NamespaceToken::for_namespace(Namespace::parse("note-hydration").unwrap());
let mut live_ids = std::collections::HashSet::new();
for ordinal in 0..LIVE {
let note = rt
.create_note(
&tok,
"observation",
None,
&format!("hydrationbatchprobe candidate {ordinal}"),
None,
None,
vec![],
)
.await
.expect("create_note must succeed");
live_ids.insert(note.id);
}
let deleted = rt
.create_note(
&tok,
"observation",
None,
"hydrationbatchprobe candidate deleted",
None,
None,
vec![],
)
.await
.expect("create_note must succeed");
rt.notes(&tok)
.unwrap()
.delete_note(deleted.id, DeleteMode::Soft)
.await
.unwrap();
let pool = rt.backend().pool();
let before = pool.search_mechanism_snapshot();
let observation = pool
.observe_test_statement_starts(4096)
.expect("statement observation");
let hits = rt
.search_notes(
&tok,
"hydrationbatchprobe",
None,
LIMIT,
None,
false,
&[],
None,
)
.await
.expect("search_notes must succeed");
let statements = observation
.started_statements()
.expect("complete statement observation");
drop(observation);
let after = pool.search_mechanism_snapshot();
let point_reads = statements
.iter()
.filter(|s| {
s.sql.starts_with("SELECT id, namespace, kind, status,")
&& s.sql.contains("FROM notes WHERE id = ?1")
})
.count();
let batch_reads = statements
.iter()
.filter(|s| {
s.sql.starts_with("SELECT id, namespace, kind, status,")
&& s.sql.contains("FROM notes WHERE id IN (")
})
.count();
assert_eq!(
point_reads, 0,
"candidate hydration must not issue a point read per candidate"
);
assert_eq!(
batch_reads,
LIVE.div_ceil(900),
"candidate hydration must issue ceil(candidates / 900) batched reads"
);
assert_eq!(
after.note_candidate_hydration_rows - before.note_candidate_hydration_rows,
LIVE as u64,
"the hydration-row counter must equal the live candidate count"
);
assert_eq!(hits.len(), LIMIT as usize);
assert!(
hits.iter().all(|hit| live_ids.contains(&hit.note_id)),
"only live candidates may be returned"
);
assert!(
hits.iter().all(|hit| hit.note_id != deleted.id),
"the soft-deleted note must not be returned"
);
assert!(
hits.windows(2).all(|pair| {
pair[0].score > pair[1].score
|| (pair[0].score == pair[1].score && pair[0].note_id < pair[1].note_id)
}),
"hits must stay ordered by score descending, then note id ascending"
);
}
#[tokio::test]
async fn update_entity_fans_out_to_all_registered_models() {
const DIMS: usize = 4;
let rt = KhiveRuntime::memory().unwrap();
let (provider_a, _ca) = ConstVecProvider::new("embed-a", DIMS);
let (provider_b, _cb) = ConstVecProvider::new("embed-b", DIMS);
rt.register_embedder(provider_a);
rt.register_embedder(provider_b);
let ns = Namespace::parse("update-entity-fanout").unwrap();
let tok = NamespaceToken::for_namespace(ns.clone());
let entity = rt
.create_entity(
&tok,
"concept",
None,
"FanOutEntity",
Some("initial description"),
None,
vec![],
)
.await
.expect("create_entity must succeed");
use crate::curation::EntityPatch;
let patch = EntityPatch {
description: Some(Some("updated description after fan-out fix".to_string())),
..Default::default()
};
rt.update_entity(&tok, entity.id, patch)
.await
.expect("update_entity must succeed");
use khive_storage::types::VectorSearchRequest;
let query_vec = vec![1.0_f32; DIMS];
let hits_a = rt
.vectors_for_model(&tok, "embed-a")
.unwrap()
.search(VectorSearchRequest {
query_vectors: vec![query_vec.clone()],
top_k: 10,
namespace: Some(ns.as_str().to_string()),
kind: Some(khive_types::SubstrateKind::Entity),
embedding_model: Some("embed-a".to_string()),
filter: None,
backend_hints: None,
})
.await
.unwrap();
assert!(
hits_a.iter().any(|h| h.subject_id == entity.id),
"embed-a must hold a vector for the entity after update; got {hits_a:?}"
);
let hits_b = rt
.vectors_for_model(&tok, "embed-b")
.unwrap()
.search(VectorSearchRequest {
query_vectors: vec![query_vec],
top_k: 10,
namespace: Some(ns.as_str().to_string()),
kind: Some(khive_types::SubstrateKind::Entity),
embedding_model: Some("embed-b".to_string()),
filter: None,
backend_hints: None,
})
.await
.unwrap();
assert!(
hits_b.iter().any(|h| h.subject_id == entity.id),
"embed-b must hold a vector for the entity after update; got {hits_b:?}"
);
}
#[tokio::test]
async fn update_note_fans_out_to_all_registered_models() {
const DIMS: usize = 4;
let rt = KhiveRuntime::memory().unwrap();
let (provider_a, _ca) = ConstVecProvider::new("embed-a", DIMS);
let (provider_b, _cb) = ConstVecProvider::new("embed-b", DIMS);
rt.register_embedder(provider_a);
rt.register_embedder(provider_b);
let ns = Namespace::parse("update-note-fanout").unwrap();
let tok = NamespaceToken::for_namespace(ns.clone());
let note = rt
.create_note(
&tok,
"observation",
None,
"initial note content for fan-out test",
None,
None,
vec![],
)
.await
.expect("create_note must succeed");
use crate::curation::NotePatch;
let patch = NotePatch {
content: Some("updated content after fan-out fix".to_string()),
..Default::default()
};
rt.update_note(&tok, note.id, patch)
.await
.expect("update_note must succeed");
use khive_storage::types::VectorSearchRequest;
let query_vec = vec![1.0_f32; DIMS];
let hits_a = rt
.vectors_for_model(&tok, "embed-a")
.unwrap()
.search(VectorSearchRequest {
query_vectors: vec![query_vec.clone()],
top_k: 10,
namespace: Some(ns.as_str().to_string()),
kind: Some(khive_types::SubstrateKind::Note),
embedding_model: Some("embed-a".to_string()),
filter: None,
backend_hints: None,
})
.await
.unwrap();
assert!(
hits_a.iter().any(|h| h.subject_id == note.id),
"embed-a must hold a vector for the note after update; got {hits_a:?}"
);
let hits_b = rt
.vectors_for_model(&tok, "embed-b")
.unwrap()
.search(VectorSearchRequest {
query_vectors: vec![query_vec],
top_k: 10,
namespace: Some(ns.as_str().to_string()),
kind: Some(khive_types::SubstrateKind::Note),
embedding_model: Some("embed-b".to_string()),
filter: None,
backend_hints: None,
})
.await
.unwrap();
assert!(
hits_b.iter().any(|h| h.subject_id == note.id),
"embed-b must hold a vector for the note after update; got {hits_b:?}"
);
}
#[tokio::test]
async fn get_entity_cross_namespace_succeeds() {
let rt = rt();
let leo_tok = NamespaceToken::for_namespace(Namespace::parse("lambda:leo").unwrap());
let entity = rt
.create_entity(&leo_tok, "concept", None, "Peer-Entity", None, None, vec![])
.await
.unwrap();
assert_eq!(entity.namespace, "lambda:leo");
let local_tok = NamespaceToken::local();
let fetched = rt.get_entity(&local_tok, entity.id).await;
assert!(
fetched.is_ok(),
"get_entity from local token must find lambda:leo entity; got {:?}",
fetched
);
assert_eq!(fetched.unwrap().id, entity.id);
}
#[tokio::test]
async fn update_entity_cross_namespace_succeeds() {
let rt = rt();
let leo_tok = NamespaceToken::for_namespace(Namespace::parse("lambda:leo").unwrap());
let entity = rt
.create_entity(
&leo_tok,
"concept",
None,
"Peer-Entity-Update",
None,
None,
vec![],
)
.await
.unwrap();
let local_tok = NamespaceToken::local();
let patch = crate::curation::EntityPatch {
name: Some("Peer-Entity-Updated".to_string()),
..Default::default()
};
let result = rt.update_entity(&local_tok, entity.id, patch).await;
assert!(
result.is_ok(),
"update_entity from local token must succeed on lambda:leo entity; got {:?}",
result
);
assert_eq!(result.unwrap().name, "Peer-Entity-Updated");
}
#[tokio::test]
async fn delete_entity_cross_namespace_succeeds() {
let rt = rt();
let leo_tok = NamespaceToken::for_namespace(Namespace::parse("lambda:leo").unwrap());
let entity = rt
.create_entity(
&leo_tok,
"concept",
None,
"Peer-Entity-Delete",
None,
None,
vec![],
)
.await
.unwrap();
let local_tok = NamespaceToken::local();
let deleted = rt.delete_entity(&local_tok, entity.id, false).await;
assert!(
deleted.is_ok(),
"delete_entity from local token must succeed on lambda:leo entity; got {:?}",
deleted
);
assert!(
deleted.unwrap(),
"delete must return true when entity existed"
);
}
#[tokio::test]
async fn namespace_preserved_on_entity_after_cross_namespace_get() {
let rt = rt();
let leo_tok = NamespaceToken::for_namespace(Namespace::parse("lambda:leo").unwrap());
let entity = rt
.create_entity(
&leo_tok,
"concept",
None,
"NS-Preserved",
None,
None,
vec![],
)
.await
.unwrap();
let local_tok = NamespaceToken::local();
let fetched = rt.get_entity(&local_tok, entity.id).await.unwrap();
assert_eq!(
fetched.namespace, "lambda:leo",
"namespace column must be preserved; not overwritten with caller's namespace"
);
}
use crate::pack::PackByIdResolver;
use tokio::sync::Mutex as TokioMutex;
#[derive(Debug, Default)]
struct MockResolverState {
owned: Vec<Uuid>,
deleted: Vec<Uuid>,
delete_calls: Vec<(Uuid, bool)>,
}
struct MockPackResolver(TokioMutex<MockResolverState>);
impl MockPackResolver {
fn new() -> Self {
Self(TokioMutex::new(MockResolverState::default()))
}
}
#[async_trait::async_trait]
impl crate::pack::PackByIdResolver for MockPackResolver {
async fn resolve_by_id(&self, id: Uuid) -> Result<Option<Resolved>, RuntimeError> {
let state = self.0.lock().await;
if state.owned.contains(&id) && !state.deleted.contains(&id) {
Ok(Some(Resolved::PackRecord {
pack: "mock".into(),
kind: "widget".into(),
data: serde_json::json!({ "id": id.to_string(), "name": "test-widget" }),
}))
} else {
Ok(None)
}
}
async fn resolve_by_id_including_deleted(
&self,
id: Uuid,
) -> Result<Option<Resolved>, RuntimeError> {
let state = self.0.lock().await;
if state.owned.contains(&id) {
Ok(Some(Resolved::PackRecord {
pack: "mock".into(),
kind: "widget".into(),
data: serde_json::json!({ "id": id.to_string(), "name": "test-widget" }),
}))
} else {
Ok(None)
}
}
async fn delete_by_id(&self, id: Uuid, hard: bool) -> Result<serde_json::Value, RuntimeError> {
let mut state = self.0.lock().await;
if !state.owned.contains(&id) {
return Err(RuntimeError::NotFound(format!(
"mock widget not found: {id}"
)));
}
state.delete_calls.push((id, hard));
if hard {
state.owned.retain(|&x| x != id);
state.deleted.retain(|&x| x != id);
} else {
state.deleted.push(id);
}
Ok(
serde_json::json!({ "deleted": true, "id": id.to_string(), "kind": "widget", "hard": hard }),
)
}
}
fn registry_with_mock_resolver(
rt: KhiveRuntime,
resolver: Box<dyn crate::pack::PackByIdResolver>,
) -> crate::VerbRegistry {
use crate::pack::{PackRuntime, VerbRegistryBuilder};
use khive_types::{HandlerDef, VerbCategory, Visibility};
static MINIMAL_HANDLERS: &[HandlerDef] = &[HandlerDef {
name: "minimal.noop",
description: "noop",
visibility: Visibility::Verb,
category: VerbCategory::Commissive,
params: &[],
}];
struct MinimalPack;
impl khive_types::Pack for MinimalPack {
const NAME: &'static str = "minimal";
const NOTE_KINDS: &'static [&'static str] = &[];
const ENTITY_KINDS: &'static [&'static str] = &[];
const HANDLERS: &'static [HandlerDef] = MINIMAL_HANDLERS;
}
#[async_trait::async_trait]
impl PackRuntime for MinimalPack {
fn name(&self) -> &str {
"minimal"
}
fn note_kinds(&self) -> &'static [&'static str] {
&[]
}
fn entity_kinds(&self) -> &'static [&'static str] {
&[]
}
fn handlers(&self) -> &'static [HandlerDef] {
MINIMAL_HANDLERS
}
async fn dispatch(
&self,
_verb: &str,
_params: serde_json::Value,
_registry: &crate::VerbRegistry,
_token: &NamespaceToken,
) -> Result<serde_json::Value, RuntimeError> {
Err(RuntimeError::InvalidInput("stub".into()))
}
}
let _ = rt;
let mut builder = VerbRegistryBuilder::new();
builder.register(MinimalPack);
builder.register_resolver("mock", resolver);
builder.build().expect("registry build failed")
}
#[tokio::test]
async fn pack_record_resolved_pair_returns_none() {
let pr = Resolved::PackRecord {
pack: "knowledge".into(),
kind: "atom".into(),
data: serde_json::json!({}),
};
assert!(
resolved_pair(Some(&pr)).is_none(),
"PackRecord must not be a valid edge endpoint"
);
}
#[test]
fn resolved_pair_surfaces_entity_type() {
let e = Resolved::Entity(
Entity::new("mathlib", "concept", "Nat.add_comm").with_entity_type(Some("theorem")),
);
assert_eq!(
resolved_pair(Some(&e)),
Some(("entity", "concept", Some("theorem"))),
"entity_type subtype must be surfaced alongside base kind"
);
}
#[test]
fn endpoint_of_type_matches_subtype_not_base_kind() {
let kind = "concept";
let et = Some("theorem");
assert!(endpoint_matches(
&EndpointKind::EntityOfType {
kind: "concept",
entity_type: "theorem",
},
"entity",
kind,
et
));
assert!(!endpoint_matches(
&EndpointKind::EntityOfType {
kind: "concept",
entity_type: "definition",
},
"entity",
kind,
et
));
assert!(!endpoint_matches(
&EndpointKind::EntityOfKind("theorem"),
"entity",
kind,
et
));
assert!(endpoint_matches(
&EndpointKind::EntityOfKind("concept"),
"entity",
kind,
et
));
assert!(!endpoint_matches(
&EndpointKind::EntityOfType {
kind: "concept",
entity_type: "theorem",
},
"note",
"task",
None
));
assert!(!endpoint_matches(
&EndpointKind::EntityOfType {
kind: "concept",
entity_type: "theorem",
},
"entity",
kind,
None
));
}
#[test]
fn endpoint_of_type_requires_base_kind_match() {
let wrong_base_kind = "project"; let et = Some("theorem");
assert!(
!endpoint_matches(
&EndpointKind::EntityOfType {
kind: "concept",
entity_type: "theorem",
},
"entity",
wrong_base_kind,
et
),
"EntityOfType must reject an entity whose base kind != rule.kind \
even when entity_type matches — the pre-fix bug admitted this"
);
assert!(endpoint_matches(
&EndpointKind::EntityOfType {
kind: "concept",
entity_type: "theorem",
},
"entity",
"concept",
et
));
}
#[tokio::test]
async fn registry_resolvers_accessor_returns_registered() {
let resolver = Box::new(MockPackResolver::new());
let registry = registry_with_mock_resolver(rt(), resolver);
assert_eq!(registry.resolvers().len(), 1);
assert_eq!(registry.resolvers()[0].0, "mock");
}
#[tokio::test]
async fn mock_resolver_resolve_by_id_returns_pack_record() {
let id = Uuid::new_v4();
let resolver: Box<dyn PackByIdResolver> = Box::new(MockPackResolver::new());
let inner = MockPackResolver::new();
inner.0.lock().await.owned.push(id);
let result: Result<Option<Resolved>, RuntimeError> = inner.resolve_by_id(id).await;
match result.unwrap() {
Some(Resolved::PackRecord { pack, kind, data }) => {
assert_eq!(pack, "mock");
assert_eq!(kind, "widget");
assert_eq!(data["id"].as_str().unwrap(), id.to_string());
}
other => panic!("expected PackRecord, got {:?}", other),
}
let _ = resolver;
}
#[tokio::test]
async fn mock_resolver_resolve_unknown_uuid_returns_none() {
let inner = MockPackResolver::new();
let id = Uuid::new_v4();
let result: Result<Option<Resolved>, RuntimeError> = inner.resolve_by_id(id).await;
assert!(result.unwrap().is_none());
}
#[tokio::test]
async fn mock_resolver_delete_soft_records_call() {
let id = Uuid::new_v4();
let inner = MockPackResolver::new();
inner.0.lock().await.owned.push(id);
let result: Result<serde_json::Value, RuntimeError> = inner.delete_by_id(id, false).await;
let result = result.unwrap();
assert_eq!(result["deleted"], serde_json::json!(true));
assert_eq!(result["hard"], serde_json::json!(false));
let live: Result<Option<Resolved>, RuntimeError> = inner.resolve_by_id(id).await;
assert!(live.unwrap().is_none());
let incl: Result<Option<Resolved>, RuntimeError> =
inner.resolve_by_id_including_deleted(id).await;
assert!(incl.unwrap().is_some());
}
#[tokio::test]
async fn mock_resolver_delete_hard_removes_record() {
let id = Uuid::new_v4();
let inner = MockPackResolver::new();
inner.0.lock().await.owned.push(id);
let result: Result<serde_json::Value, RuntimeError> = inner.delete_by_id(id, true).await;
assert_eq!(result.unwrap()["hard"], serde_json::json!(true));
let incl: Result<Option<Resolved>, RuntimeError> =
inner.resolve_by_id_including_deleted(id).await;
assert!(incl.unwrap().is_none());
}
#[tokio::test]
async fn pack_record_not_valid_context_entity() {
let pr = Resolved::PackRecord {
pack: "knowledge".into(),
kind: "atom".into(),
data: serde_json::json!({}),
};
assert!(matches!(pr, Resolved::PackRecord { .. }));
}
fn neighbor_hit(node_id: Uuid) -> NeighborHit {
NeighborHit {
node_id,
edge_id: Uuid::new_v4(),
relation: EdgeRelation::Extends,
weight: 1.0,
name: None,
kind: None,
entity_type: None,
}
}
fn path_node(node_id: Uuid, depth: usize) -> PathNode {
PathNode {
node_id,
via_edge: None,
depth,
name: None,
kind: None,
properties: None,
weight: 0.0,
}
}
#[test]
fn merge_traversal_paths_reenforces_limit_across_namespaces() {
let root = Uuid::new_v4();
let path_for = |n: usize| GraphPath {
root_id: root,
nodes: (0..n).map(|_| path_node(Uuid::new_v4(), 1)).collect(),
total_weight: 1.0,
};
let paths = vec![path_for(2), path_for(2), path_for(2)];
let merged = merge_traversal_paths_by_root(paths, Some(2));
assert_eq!(merged.len(), 1);
assert_eq!(
merged[0].nodes.len(),
2,
"merge must re-enforce limit=2 on the unioned nodes, got {:?}",
merged[0].nodes
);
}
#[test]
fn merge_traversal_paths_keeps_shortest_depth_and_bfs_order() {
let root = Uuid::new_v4();
let shared = Uuid::new_v4();
let far_node = Uuid::new_v4();
let deep_edge = Uuid::new_v4();
let shallow_edge = Uuid::new_v4();
let ns_first = GraphPath {
root_id: root,
nodes: vec![
path_node(far_node, 1),
PathNode {
node_id: shared,
via_edge: Some(deep_edge),
depth: 4,
name: None,
kind: None,
properties: None,
weight: 0.0,
},
],
total_weight: 1.0,
};
let ns_second = GraphPath {
root_id: root,
nodes: vec![PathNode {
node_id: shared,
via_edge: Some(shallow_edge),
depth: 2,
name: None,
kind: None,
properties: None,
weight: 0.0,
}],
total_weight: 1.0,
};
let merged = merge_traversal_paths_by_root(vec![ns_first, ns_second], None);
assert_eq!(merged.len(), 1);
let nodes = &merged[0].nodes;
assert!(
nodes.windows(2).all(|w| w[0].depth <= w[1].depth),
"merged nodes must be in BFS (ascending depth) order, got {:?}",
nodes
.iter()
.map(|n| (n.node_id, n.depth))
.collect::<Vec<_>>()
);
let shared_node = nodes
.iter()
.find(|n| n.node_id == shared)
.expect("shared node must survive the merge");
assert_eq!(
shared_node.depth, 2,
"shared node must report its shortest depth across namespaces"
);
assert_eq!(
shared_node.via_edge,
Some(shallow_edge),
"shared node must carry the via_edge that produced the shortest path"
);
}
#[test]
fn merge_traversal_paths_total_weight_drops_with_the_node_it_described() {
let root = Uuid::new_v4();
let weighted = |depth: usize, weight: f64| PathNode {
node_id: Uuid::new_v4(),
via_edge: None,
depth,
name: None,
kind: None,
properties: None,
weight,
};
let path = GraphPath {
root_id: root,
nodes: vec![weighted(1, 0.5), weighted(1, 0.4), weighted(2, 9.0)],
total_weight: 9.0,
};
let merged = merge_traversal_paths_by_root(vec![path], Some(2));
assert_eq!(merged.len(), 1);
assert_eq!(
merged[0].nodes.len(),
2,
"limit=2 must drop the depth-2 node"
);
assert_eq!(
merged[0].total_weight, 0.5,
"total_weight must be the max over surviving nodes, not the 9.0 \
carried by the node the limit removed"
);
}
#[tokio::test]
async fn enrich_neighbor_hits_batch_entity_note_and_bogus() {
let rt = rt();
let tok = NamespaceToken::local();
let entity = rt
.create_entity(&tok, "concept", None, "MyEntity", None, None, vec![])
.await
.unwrap();
let note = rt
.create_note(&tok, "observation", None, "body", Some(0.5), None, vec![])
.await
.unwrap();
let bogus_id = Uuid::new_v4();
let mut hits = vec![
neighbor_hit(entity.id),
neighbor_hit(note.id),
neighbor_hit(bogus_id),
];
rt.enrich_neighbor_hits(&tok, &mut hits).await;
assert_eq!(hits[0].name.as_deref(), Some("MyEntity"));
assert_eq!(hits[0].kind.as_deref(), Some("concept"));
assert_eq!(hits[1].name.as_deref(), Some("[observation]"));
assert_eq!(hits[1].kind.as_deref(), Some("observation"));
assert!(hits[2].name.is_none());
assert!(hits[2].kind.is_none());
}
#[tokio::test]
async fn enrich_neighbor_hits_note_with_name_uses_name() {
let rt = rt();
let tok = NamespaceToken::local();
let note = rt
.create_note(
&tok,
"insight",
Some("NoteTitle"),
"body",
Some(0.5),
None,
vec![],
)
.await
.unwrap();
let mut hits = vec![neighbor_hit(note.id)];
rt.enrich_neighbor_hits(&tok, &mut hits).await;
assert_eq!(hits[0].name.as_deref(), Some("NoteTitle"));
assert_eq!(hits[0].kind.as_deref(), Some("insight"));
}
#[tokio::test]
async fn enrich_path_nodes_batch_dedup_and_unresolved() {
let rt = rt();
let tok = NamespaceToken::local();
let ea = rt
.create_entity(&tok, "concept", None, "Alpha", None, None, vec![])
.await
.unwrap();
let eb = rt
.create_entity(&tok, "document", None, "Beta", None, None, vec![])
.await
.unwrap();
let bogus_id = Uuid::new_v4();
let mut paths = vec![
GraphPath {
root_id: ea.id,
nodes: vec![
path_node(ea.id, 0),
path_node(eb.id, 1),
path_node(bogus_id, 2),
],
total_weight: 1.0,
},
GraphPath {
root_id: eb.id,
nodes: vec![path_node(eb.id, 0), path_node(ea.id, 1)],
total_weight: 1.0,
},
];
rt.enrich_path_nodes(&tok, &mut paths, false).await;
assert_eq!(paths[0].nodes[0].name.as_deref(), Some("Alpha"));
assert_eq!(paths[0].nodes[0].kind.as_deref(), Some("concept"));
assert_eq!(paths[0].nodes[1].name.as_deref(), Some("Beta"));
assert_eq!(paths[0].nodes[1].kind.as_deref(), Some("document"));
assert!(paths[0].nodes[2].name.is_none());
assert!(paths[0].nodes[2].kind.is_none());
assert_eq!(paths[1].nodes[0].name.as_deref(), Some("Beta"));
assert_eq!(paths[1].nodes[0].kind.as_deref(), Some("document"));
assert_eq!(paths[1].nodes[1].name.as_deref(), Some("Alpha"));
assert_eq!(paths[1].nodes[1].kind.as_deref(), Some("concept"));
}
#[tokio::test]
async fn enrich_resolves_entities_in_extra_visible_namespace() {
let rt = KhiveRuntime::memory().unwrap();
let ns_a = Namespace::parse("enrich-ns-a").unwrap();
let ns_b = Namespace::parse("enrich-ns-b").unwrap();
let tok_b = rt.authorize(ns_b.clone()).unwrap();
let entity_b = rt
.create_entity(&tok_b, "concept", None, "EntityInB", None, None, vec![])
.await
.unwrap();
assert_eq!(entity_b.namespace, "enrich-ns-b");
let vis_tok = rt
.authorize_with_visibility(ns_a.clone(), vec![ns_b.clone()])
.unwrap();
let mut hits = vec![neighbor_hit(entity_b.id)];
rt.enrich_neighbor_hits(&vis_tok, &mut hits).await;
assert_eq!(
hits[0].name.as_deref(),
Some("EntityInB"),
"entity in extra-visible ns must be enriched by enrich_neighbor_hits"
);
assert_eq!(hits[0].kind.as_deref(), Some("concept"));
let mut paths = vec![GraphPath {
root_id: entity_b.id,
nodes: vec![path_node(entity_b.id, 0)],
total_weight: 1.0,
}];
rt.enrich_path_nodes(&vis_tok, &mut paths, false).await;
assert_eq!(
paths[0].nodes[0].name.as_deref(),
Some("EntityInB"),
"entity in extra-visible ns must be enriched by enrich_path_nodes"
);
assert_eq!(paths[0].nodes[0].kind.as_deref(), Some("concept"));
}
#[tokio::test]
async fn enrich_neighbor_hits_populates_entity_type() {
let rt = rt();
let tok = NamespaceToken::local();
let props = serde_json::json!({"domain": "attention"});
let entity = rt
.create_entity(
&tok,
"concept",
Some("algorithm"),
"FlashAttn",
None,
Some(props),
vec![],
)
.await
.unwrap();
let entity_no_type = rt
.create_entity(&tok, "concept", None, "PlainConcept", None, None, vec![])
.await
.unwrap();
let mut hits = vec![neighbor_hit(entity.id), neighbor_hit(entity_no_type.id)];
rt.enrich_neighbor_hits(&tok, &mut hits).await;
assert_eq!(hits[0].entity_type.as_deref(), Some("algorithm"));
assert!(
hits[1].entity_type.is_none(),
"entity without entity_type must leave the field as None"
);
}
#[tokio::test]
async fn enrich_path_nodes_populates_properties() {
let rt = rt();
let tok = NamespaceToken::local();
let props = serde_json::json!({"year": 2024, "venue": "NeurIPS"});
let entity_with_props = rt
.create_entity(
&tok,
"document",
None,
"AttentionPaper",
None,
Some(props.clone()),
vec![],
)
.await
.unwrap();
let entity_no_props = rt
.create_entity(&tok, "concept", None, "BareConceptNode", None, None, vec![])
.await
.unwrap();
let mut paths = vec![GraphPath {
root_id: entity_with_props.id,
nodes: vec![
path_node(entity_with_props.id, 0),
path_node(entity_no_props.id, 1),
],
total_weight: 1.0,
}];
rt.enrich_path_nodes(&tok, &mut paths, true).await;
assert_eq!(
paths[0].nodes[0].properties.as_ref(),
Some(&props),
"properties must be filled when entity has a non-null properties blob"
);
assert!(
paths[0].nodes[1].properties.is_none(),
"entity without properties must leave the field as None"
);
}
#[tokio::test]
async fn traverse_rejects_root_count_above_public_cap_before_lookup() {
use khive_storage::types::{TraversalOptions, MAX_TRAVERSAL_ROOTS};
let rt = rt();
let tok = NamespaceToken::local();
let err = rt
.traverse(
&tok,
TraversalRequest {
roots: (0..=MAX_TRAVERSAL_ROOTS)
.map(|_| uuid::Uuid::new_v4())
.collect(),
options: TraversalOptions {
max_depth: 1,
direction: Direction::Out,
relations: None,
min_weight: None,
limit: None,
},
include_roots: false,
include_properties: false,
execution_budget: Default::default(),
},
)
.await
.unwrap_err();
assert!(matches!(
err,
RuntimeError::InvalidInput(message)
if message.contains("roots must contain at most 100 entries")
));
}
#[test]
fn pack_entity_of_type_rules_do_not_shadow_base_entity_of_kind_rule() {
let pack_rules: Vec<EdgeEndpointRule> = vec![
EdgeEndpointRule {
relation: EdgeRelation::VariantOf,
source: EndpointKind::EntityOfType {
kind: "concept",
entity_type: "goal",
},
target: EndpointKind::EntityOfType {
kind: "concept",
entity_type: "theorem",
},
},
EdgeEndpointRule {
relation: EdgeRelation::VariantOf,
source: EndpointKind::EntityOfType {
kind: "concept",
entity_type: "goal",
},
target: EndpointKind::EntityOfType {
kind: "concept",
entity_type: "definition",
},
},
];
let goal_a =
Resolved::Entity(Entity::new("local", "concept", "G-a").with_entity_type(Some("goal")));
let goal_b =
Resolved::Entity(Entity::new("local", "concept", "G-b").with_entity_type(Some("goal")));
assert!(
!pack_rule_allows(
&pack_rules,
EdgeRelation::VariantOf,
Some(&goal_a),
Some(&goal_b)
),
"pack rules must not cover goal->goal variant_of (no such rule declared)"
);
assert!(
base_entity_rule_allows("concept", EdgeRelation::VariantOf, "concept"),
"base contract must allow concept->concept variant_of regardless of pack EntityOfType rules"
);
}
#[tokio::test]
async fn link_variant_of_goal_to_goal_allowed_when_pack_has_entity_of_type_rules() {
let rt = rt();
let tok = NamespaceToken::local();
rt.install_edge_rules(vec![
EdgeEndpointRule {
relation: EdgeRelation::VariantOf,
source: EndpointKind::EntityOfType {
kind: "concept",
entity_type: "goal",
},
target: EndpointKind::EntityOfType {
kind: "concept",
entity_type: "theorem",
},
},
EdgeEndpointRule {
relation: EdgeRelation::VariantOf,
source: EndpointKind::EntityOfType {
kind: "concept",
entity_type: "goal",
},
target: EndpointKind::EntityOfType {
kind: "concept",
entity_type: "definition",
},
},
]);
let a = rt
.create_entity(
&tok,
"concept",
Some("goal"),
"Goal Alpha",
None,
None,
vec![],
)
.await
.unwrap();
let b = rt
.create_entity(
&tok,
"concept",
Some("goal"),
"Goal Beta",
None,
None,
vec![],
)
.await
.unwrap();
let result = rt
.link(&tok, a.id, b.id, EdgeRelation::VariantOf, 1.0, None)
.await;
assert!(
result.is_ok(),
"goal->goal variant_of must be allowed via the base concept->concept rule \
even when EntityOfType rules for variant_of are installed; got {result:?}"
);
let p = rt
.create_entity(&tok, "project", None, "Proj", None, None, vec![])
.await
.unwrap();
let bad = rt
.link(&tok, a.id, p.id, EdgeRelation::VariantOf, 1.0, None)
.await;
assert!(
bad.is_err(),
"additive pack rules must not make validation fail-open; \
goal(concept)->project variant_of must be rejected (pack miss + base miss); \
got {bad:?}"
);
}
#[tokio::test]
async fn link_depends_on_theorem_to_definition_allowed_only_via_pack_rule() {
let rt = rt();
let tok = NamespaceToken::local();
assert!(
!base_entity_rule_allows("concept", EdgeRelation::DependsOn, "concept"),
"base contract must not allow concept->concept DependsOn; \
test would be vacuous if this precondition fails"
);
rt.install_edge_rules(vec![EdgeEndpointRule {
relation: EdgeRelation::DependsOn,
source: EndpointKind::EntityOfType {
kind: "concept",
entity_type: "theorem",
},
target: EndpointKind::EntityOfType {
kind: "concept",
entity_type: "definition",
},
}]);
let thm = rt
.create_entity(&tok, "concept", Some("theorem"), "T1", None, None, vec![])
.await
.unwrap();
let def = rt
.create_entity(
&tok,
"concept",
Some("definition"),
"D1",
None,
None,
vec![],
)
.await
.unwrap();
let result = rt
.link(&tok, thm.id, def.id, EdgeRelation::DependsOn, 1.0, None)
.await;
assert!(
result.is_ok(),
"theorem->definition DependsOn must be allowed by the installed pack rule; \
the base contract has no concept->concept DependsOn row; got {result:?}"
);
}
#[tokio::test]
async fn link_document_introduced_by_person_allowed() {
let rt = rt();
let tok = NamespaceToken::local();
let doc = rt
.create_entity(&tok, "document", None, "Paper", None, None, vec![])
.await
.unwrap();
let author = rt
.create_entity(&tok, "person", None, "Author", None, None, vec![])
.await
.unwrap();
let result = rt
.link(
&tok,
doc.id,
author.id,
EdgeRelation::IntroducedBy,
1.0,
None,
)
.await;
assert!(
result.is_ok(),
"document->person introduced_by must be allowed by the ADR-002 \
endpoint amendment; got {result:?}"
);
}
#[tokio::test]
async fn link_document_introduced_by_org_allowed() {
let rt = rt();
let tok = NamespaceToken::local();
let doc = rt
.create_entity(&tok, "document", None, "Whitepaper", None, None, vec![])
.await
.unwrap();
let org = rt
.create_entity(&tok, "org", None, "Publisher", None, None, vec![])
.await
.unwrap();
let result = rt
.link(&tok, doc.id, org.id, EdgeRelation::IntroducedBy, 1.0, None)
.await;
assert!(
result.is_ok(),
"document->org introduced_by must be allowed by the ADR-002 \
endpoint amendment; got {result:?}"
);
}
#[tokio::test]
async fn link_concept_introduced_by_org_allowed() {
let rt = rt();
let tok = NamespaceToken::local();
let concept = rt
.create_entity(&tok, "concept", None, "Architecture", None, None, vec![])
.await
.unwrap();
let org = rt
.create_entity(&tok, "org", None, "Originator", None, None, vec![])
.await
.unwrap();
let result = rt
.link(
&tok,
concept.id,
org.id,
EdgeRelation::IntroducedBy,
1.0,
None,
)
.await;
assert!(
result.is_ok(),
"concept->org introduced_by must be allowed by the ADR-002 \
endpoint amendment; got {result:?}"
);
}
#[tokio::test]
async fn link_document_depends_on_document_allowed() {
let rt = rt();
let tok = NamespaceToken::local();
let doc_a = rt
.create_entity(&tok, "document", None, "Spec A", None, None, vec![])
.await
.unwrap();
let doc_b = rt
.create_entity(&tok, "document", None, "Spec B", None, None, vec![])
.await
.unwrap();
let result = rt
.link(&tok, doc_a.id, doc_b.id, EdgeRelation::DependsOn, 1.0, None)
.await;
assert!(
result.is_ok(),
"document->document depends_on must be allowed by the ADR-002 \
endpoint amendment; got {result:?}"
);
let edge = result.unwrap();
let dk = edge
.metadata
.as_ref()
.and_then(|m| m.get("dependency_kind"))
.and_then(|v| v.as_str());
assert_eq!(
dk,
Some("normative"),
"document->document depends_on must infer dependency_kind=normative"
);
}
#[tokio::test]
async fn link_document_links_to_document_allowed_service_and_concept_targets_rejected() {
let rt = rt();
let tok = NamespaceToken::local();
let page_a = rt
.create_entity(&tok, "document", None, "Page A", None, None, vec![])
.await
.unwrap();
let page_b = rt
.create_entity(&tok, "document", None, "Page B", None, None, vec![])
.await
.unwrap();
let result = rt
.link(&tok, page_a.id, page_b.id, EdgeRelation::LinksTo, 1.0, None)
.await;
assert!(
result.is_ok(),
"document->document links_to must be allowed by the ADR-191 \
endpoint amendment; got {result:?}"
);
let edge = result.unwrap();
assert!(
edge.metadata.is_none(),
"links_to carries no governed metadata and infers none, unlike \
depends_on; got {:?}",
edge.metadata
);
let svc = rt
.create_entity(&tok, "service", None, "Some Site", None, None, vec![])
.await
.unwrap();
let concept = rt
.create_entity(&tok, "concept", None, "Some Concept", None, None, vec![])
.await
.unwrap();
let doc_to_service = rt
.link(&tok, page_a.id, svc.id, EdgeRelation::LinksTo, 1.0, None)
.await
.unwrap_err();
assert!(
doc_to_service
.to_string()
.contains("base endpoint allowlist"),
"document->service links_to must be refused with the \
endpoint-contract error; got {doc_to_service}"
);
let concept_to_doc = rt
.link(
&tok,
concept.id,
page_a.id,
EdgeRelation::LinksTo,
1.0,
None,
)
.await
.unwrap_err();
assert!(
concept_to_doc
.to_string()
.contains("base endpoint allowlist"),
"concept->document links_to must be refused with the \
endpoint-contract error; got {concept_to_doc}"
);
}
#[path = "operations/tests/located_in_endpoints.rs"]
mod located_in_endpoints;
#[tokio::test]
async fn link_org_introduced_by_document_rejected_direction_matters() {
let rt = rt();
let tok = NamespaceToken::local();
let org = rt
.create_entity(&tok, "org", None, "Publisher", None, None, vec![])
.await
.unwrap();
let doc = rt
.create_entity(&tok, "document", None, "Paper", None, None, vec![])
.await
.unwrap();
let result = rt
.link(&tok, org.id, doc.id, EdgeRelation::IntroducedBy, 1.0, None)
.await;
assert!(
result.is_err(),
"org->document introduced_by must remain rejected; only \
document->org is permitted, not the reverse; got {result:?}"
);
}
#[tokio::test]
async fn link_service_introduced_by_document_allowed() {
let rt = rt();
let tok = NamespaceToken::local();
let svc = rt
.create_entity(&tok, "service", None, "Search API", None, None, vec![])
.await
.unwrap();
let doc = rt
.create_entity(&tok, "document", None, "Design doc", None, None, vec![])
.await
.unwrap();
let result = rt
.link(&tok, svc.id, doc.id, EdgeRelation::IntroducedBy, 1.0, None)
.await;
assert!(
result.is_ok(),
"service->document introduced_by must be allowed by the ADR-167 \
endpoint amendment; got {result:?}"
);
}
#[tokio::test]
async fn link_service_introduced_by_person_rejected() {
let rt = rt();
let tok = NamespaceToken::local();
let svc = rt
.create_entity(&tok, "service", None, "Search API", None, None, vec![])
.await
.unwrap();
let person = rt
.create_entity(&tok, "person", None, "Operator", None, None, vec![])
.await
.unwrap();
let result = rt
.link(
&tok,
svc.id,
person.id,
EdgeRelation::IntroducedBy,
1.0,
None,
)
.await;
assert!(
result.is_err(),
"service->person introduced_by is not in the endpoint table and \
must be rejected; got {result:?}"
);
}
#[tokio::test]
async fn link_service_introduced_by_org_rejected() {
let rt = rt();
let tok = NamespaceToken::local();
let svc = rt
.create_entity(&tok, "service", None, "Search API", None, None, vec![])
.await
.unwrap();
let org = rt
.create_entity(&tok, "org", None, "Vendor", None, None, vec![])
.await
.unwrap();
let result = rt
.link(&tok, svc.id, org.id, EdgeRelation::IntroducedBy, 1.0, None)
.await;
assert!(
result.is_err(),
"service->org introduced_by is not in the endpoint table and \
must be rejected; got {result:?}"
);
}
#[tokio::test]
async fn link_document_introduced_by_service_rejected_direction_matters() {
let rt = rt();
let tok = NamespaceToken::local();
let doc = rt
.create_entity(&tok, "document", None, "Design doc", None, None, vec![])
.await
.unwrap();
let svc = rt
.create_entity(&tok, "service", None, "Search API", None, None, vec![])
.await
.unwrap();
let result = rt
.link(&tok, doc.id, svc.id, EdgeRelation::IntroducedBy, 1.0, None)
.await;
assert!(
result.is_err(),
"document->service introduced_by must remain rejected; only \
service->document is permitted, not the reverse; got {result:?}"
);
}
#[tokio::test]
async fn link_service_derived_from_document_rejected() {
let rt = rt();
let tok = NamespaceToken::local();
let svc = rt
.create_entity(&tok, "service", None, "Search API", None, None, vec![])
.await
.unwrap();
let doc = rt
.create_entity(&tok, "document", None, "Design doc", None, None, vec![])
.await
.unwrap();
let result = rt
.link(&tok, svc.id, doc.id, EdgeRelation::DerivedFrom, 1.0, None)
.await;
assert!(
result.is_err(),
"service->document is permitted only for introduced_by; the same \
endpoints under derived_from must be rejected; got {result:?}"
);
}
struct CapturingVecService {
dims: usize,
captured: Arc<std::sync::Mutex<Vec<String>>>,
}
#[async_trait]
impl EmbeddingService for CapturingVecService {
async fn embed(
&self,
texts: &[String],
_model: EmbeddingModel,
) -> std::result::Result<Vec<Vec<f32>>, EmbedError> {
for text in texts {
if text.len() > MAX_TEXT_BYTES {
return Err(EmbedError::TextTooLong {
length: text.len(),
max: MAX_TEXT_BYTES,
});
}
}
self.captured.lock().unwrap().extend(texts.iter().cloned());
Ok(texts.iter().map(|_| vec![1.0_f32; self.dims]).collect())
}
fn supports_model(&self, _model: EmbeddingModel) -> bool {
true
}
fn name(&self) -> &'static str {
"capturing-vec"
}
}
struct CapturingVecProvider {
provider_name: String,
dims: usize,
captured: Arc<std::sync::Mutex<Vec<String>>>,
}
#[async_trait]
impl EmbedderProvider for CapturingVecProvider {
fn name(&self) -> &str {
&self.provider_name
}
fn dimensions(&self) -> usize {
self.dims
}
async fn build(&self) -> crate::error::RuntimeResult<Arc<dyn EmbeddingService>> {
Ok(Arc::new(CapturingVecService {
dims: self.dims,
captured: Arc::clone(&self.captured),
}))
}
}
fn assert_embedding_truncation_after_commit(
error: RuntimeError,
operation: &str,
discarded_bytes: u64,
) -> Uuid {
let RuntimeError::Khive(domain) = error.refusal_source() else {
panic!("legacy caller lost its typed truncation signal: {error:?}");
};
let details = domain.details().expect("truncation details");
assert_eq!(details.get("reason"), Some("embedding_input_truncated"));
assert_eq!(details.get("operation"), Some(operation));
assert_eq!(details.get("committed"), Some("true"));
assert_eq!(details.get("retryable"), Some("false"));
let report: crate::retrieval::EmbeddingTruncationReport = serde_json::from_str(
details
.get("embedding_truncation_report")
.expect("truncation report"),
)
.expect("valid report");
assert_eq!(report.truncated, 1);
assert_eq!(report.discarded_bytes, discarded_bytes);
details
.get("record_id")
.expect("committed record id")
.parse()
.expect("canonical committed id")
}
#[tokio::test]
async fn create_bounds_embedding_input_without_truncating_stored_content() {
let rt = rt();
let tok = NamespaceToken::local();
let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
rt.register_embedder(CapturingVecProvider {
provider_name: "strict-length-test".into(),
dims: 4,
captured: Arc::clone(&captured),
});
let content = format!("{}\u{1f980}tail", "a".repeat(MAX_TEXT_BYTES - 1));
let error = rt
.create_note(&tok, "observation", None, &content, None, None, vec![])
.await
.expect_err("legacy note create must disclose truncation");
let note_id = assert_embedding_truncation_after_commit(error, "create_note", 8);
let fetched = rt
.notes(&tok)
.unwrap()
.get_note(note_id)
.await
.unwrap()
.expect("created note must be retrievable");
assert_eq!(fetched.content, content, "stored content must remain full");
let embedded = captured.lock().unwrap().clone();
assert_eq!(embedded.len(), 1);
assert_eq!(embedded[0].len(), MAX_TEXT_BYTES - 1);
assert!(embedded[0].is_char_boundary(embedded[0].len()));
assert!(!embedded[0].contains('\u{1f980}'));
let vector_info = rt
.vectors_for_model(&tok, "strict-length-test")
.expect("vector store")
.info()
.await
.expect("vector info");
assert_eq!(vector_info.dimensions, 4);
assert_eq!(vector_info.entry_count, 1);
let (reported_note, report) = rt
.create_note_with_embedding_content_and_report(
&tok,
"observation",
None,
&content,
None,
None,
None,
vec![],
)
.await
.expect("report-aware note create succeeds");
assert_eq!(reported_note.content, content);
assert_eq!(report.truncated, 1);
assert_eq!(report.discarded_bytes, 8);
captured.lock().unwrap().clear();
rt.reindex_note(&tok, &fetched)
.await
.expect("reindex must bound the same stored content");
assert_eq!(captured.lock().unwrap()[0].len(), MAX_TEXT_BYTES - 1);
captured.lock().unwrap().clear();
rt.embed_document_batch_with_model_outcomes(
"strict-length-test",
std::slice::from_ref(&content),
)
.await
.expect("report-aware batch embedding must retain outcomes");
assert_eq!(captured.lock().unwrap()[0].len(), MAX_TEXT_BYTES - 1);
assert!(rt
.embed_document_batch_with_model("strict-length-test", std::slice::from_ref(&content))
.await
.expect_err("legacy batch embedding must disclose truncation")
.to_string()
.contains("embedding input truncated"));
let normal = "normal byte-identical embedding input";
rt.create_note(&tok, "observation", None, normal, None, None, vec![])
.await
.expect("normal note create must succeed");
assert_eq!(captured.lock().unwrap().last().unwrap(), normal);
let long_description = format!("{}\u{1f980}tail", "b".repeat(MAX_TEXT_BYTES));
rt.create_entity(
&tok,
"concept",
None,
"entity",
Some(&long_description),
None,
vec![],
)
.await
.expect("over-length entity create must succeed");
}
#[tokio::test]
async fn default_document_embedding_discloses_truncation() {
let model = EmbeddingModel::AllMiniLmL6V2;
let rt = KhiveRuntime::new(crate::RuntimeConfig {
db_path: None,
embedding_model: Some(model),
packs: vec!["kg".to_string()],
..crate::RuntimeConfig::no_embeddings()
})
.unwrap();
let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
rt.register_embedder(CapturingVecProvider {
provider_name: model.to_string(),
dims: 4,
captured: Arc::clone(&captured),
});
let content = "x".repeat(MAX_TEXT_BYTES + 1);
let outcome = rt.embed_document_outcome(&content).await.unwrap();
assert!(outcome.truncated);
assert_eq!(outcome.source_bytes, content.len());
assert_eq!(outcome.embedded_bytes, MAX_TEXT_BYTES);
assert_eq!(outcome.source_bytes - outcome.embedded_bytes, 1);
assert_eq!(
captured.lock().unwrap().as_slice(),
&["x".repeat(MAX_TEXT_BYTES)]
);
assert!(rt
.embed_document(&content)
.await
.expect_err("legacy document embedding must disclose truncation")
.to_string()
.contains("embedding input truncated"));
let outcomes = rt
.embed_document_batch_outcomes(std::slice::from_ref(&content))
.await
.unwrap();
assert_eq!(outcomes.len(), 1);
assert!(outcomes[0].truncated);
assert_eq!(outcomes[0].source_bytes, content.len());
assert_eq!(outcomes[0].embedded_bytes, MAX_TEXT_BYTES);
assert_eq!(outcomes[0].source_bytes - outcomes[0].embedded_bytes, 1);
assert!(rt
.embed_document_batch(&[content])
.await
.expect_err("default batch embedding must disclose truncation")
.to_string()
.contains("embedding input truncated"));
assert_eq!(
captured.lock().unwrap().as_slice(),
&vec!["x".repeat(MAX_TEXT_BYTES); 4]
);
}
#[tokio::test]
async fn create_note_with_embedding_content_none_matches_create_note() {
let rt = rt();
let tok = NamespaceToken::local();
let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
rt.register_embedder(CapturingVecProvider {
provider_name: "capturing-vec".into(),
dims: 4,
captured: Arc::clone(&captured),
});
let note = rt
.create_note_with_embedding_content(
&tok,
"observation",
None,
"full content, no override",
None,
None,
None,
vec![],
)
.await
.expect("create with None override must behave like create_note");
assert_eq!(note.content, "full content, no override");
let seen = captured.lock().unwrap().clone();
assert_eq!(
seen,
vec!["full content, no override".to_string()],
"with no override the embedder must see the full content"
);
}
#[tokio::test]
async fn note_create_variants_disclose_truncated_embedding() {
let rt = rt();
let tok = NamespaceToken::local();
rt.register_embedder(CapturingVecProvider {
provider_name: "strict-length-test".into(),
dims: 4,
captured: Arc::new(std::sync::Mutex::new(Vec::new())),
});
let content = "x".repeat(MAX_TEXT_BYTES + 1);
let error = rt
.create_note_with_embedding_content(
&tok,
"observation",
None,
&content,
None,
None,
None,
vec![],
)
.await
.expect_err("legacy override variant must disclose truncation");
assert_embedding_truncation_after_commit(error, "create_note_with_embedding_content", 1);
let error = rt
.create_note_with_decay(&tok, "observation", None, &content, None, 0.5, None, vec![])
.await
.expect_err("legacy decay variant must disclose truncation");
assert_embedding_truncation_after_commit(
error,
"create_note_with_decay_for_embedding_model",
1,
);
let (note, report) = rt
.create_note_with_decay_and_report(
&tok,
"observation",
None,
&content,
None,
0.5,
None,
vec![],
)
.await
.expect("report-aware decay variant succeeds");
assert_eq!(note.content, content);
assert_eq!(report.truncated, 1);
assert_eq!(report.discarded_bytes, 1);
}
#[tokio::test]
async fn guarded_entity_update_discloses_truncated_embedding() {
let rt = rt();
let tok = NamespaceToken::local();
rt.register_embedder(CapturingVecProvider {
provider_name: "strict-length-test".into(),
dims: 4,
captured: Arc::new(std::sync::Mutex::new(Vec::new())),
});
let description = "d".repeat(MAX_TEXT_BYTES + 1);
let legacy = rt
.create_entity_with_embedding_report(&tok, "concept", None, "legacy", None, None, vec![])
.await
.unwrap()
.0;
let error = rt
.update_entity_if_unchanged(
&tok,
&legacy,
crate::curation::EntityPatch {
description: Some(Some(description.clone())),
..Default::default()
},
&[],
)
.await
.expect_err("legacy guarded update must disclose truncation");
let committed_id =
assert_embedding_truncation_after_commit(error, "update_entity_if_unchanged", 8);
assert_eq!(committed_id, legacy.id);
assert_eq!(
rt.get_entity(&tok, committed_id)
.await
.unwrap()
.description
.as_deref(),
Some(description.as_str())
);
let reported = rt
.create_entity_with_embedding_report(&tok, "concept", None, "reported", None, None, vec![])
.await
.unwrap()
.0;
let (_, report) = rt
.update_entity_if_unchanged_with_embedding_report(
&tok,
&reported,
crate::curation::EntityPatch {
description: Some(Some(description)),
..Default::default()
},
&[],
)
.await
.expect("report-aware guarded update succeeds");
assert_eq!(report.truncated, 1);
assert_eq!(report.discarded_bytes, 10);
}
#[tokio::test]
async fn create_note_with_embedding_content_embeds_capped_override_and_stores_full_content() {
let rt = rt();
let tok = NamespaceToken::local();
let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
rt.register_embedder(CapturingVecProvider {
provider_name: "capturing-vec".into(),
dims: 4,
captured: Arc::clone(&captured),
});
let full = "head-term and then a very long tail-term that exceeds any cap";
let head = &full[.."head-term and then a very long".len()];
let note = rt
.create_note_with_embedding_content(
&tok,
"observation",
None,
full,
Some(head),
None,
None,
vec![],
)
.await
.expect("proper-prefix override must be accepted");
assert_eq!(note.content, full, "stored content must be the full text");
let seen = captured.lock().unwrap().clone();
assert_eq!(
seen,
vec![head.to_string()],
"embedder must see only the capped override"
);
}
#[tokio::test]
async fn create_note_with_embedding_content_fans_out_identical_override_to_multiple_models() {
let rt = rt();
let tok = NamespaceToken::local();
let captured_a = Arc::new(std::sync::Mutex::new(Vec::new()));
let captured_b = Arc::new(std::sync::Mutex::new(Vec::new()));
rt.register_embedder(CapturingVecProvider {
provider_name: "capturing-vec-a".into(),
dims: 4,
captured: Arc::clone(&captured_a),
});
rt.register_embedder(CapturingVecProvider {
provider_name: "capturing-vec-b".into(),
dims: 4,
captured: Arc::clone(&captured_b),
});
let full = "head-only-embedded plus a long discarded tail";
let head = &full[.."head-only-embedded".len()];
rt.create_note_with_embedding_content(
&tok,
"observation",
None,
full,
Some(head),
None,
None,
vec![],
)
.await
.expect("create ok");
assert_eq!(
captured_a.lock().unwrap().clone(),
vec![head.to_string()],
"model A must receive the identical capped override"
);
assert_eq!(
captured_b.lock().unwrap().clone(),
vec![head.to_string()],
"model B must receive the identical capped override"
);
let vs_a = rt
.vectors_for_model(&tok, "capturing-vec-a")
.expect("vector store for model A");
assert_eq!(
vs_a.count().await.expect("vector count A"),
1,
"model A must have exactly one vector row for the note"
);
let vs_b = rt
.vectors_for_model(&tok, "capturing-vec-b")
.expect("vector store for model B");
assert_eq!(
vs_b.count().await.expect("vector count B"),
1,
"model B must have exactly one vector row for the note"
);
}
#[tokio::test]
async fn create_note_with_embedding_content_rejects_empty_override() {
let rt = rt();
let tok = NamespaceToken::local();
let err = rt
.create_note_with_embedding_content(
&tok,
"observation",
None,
"some content",
Some(""),
None,
None,
vec![],
)
.await
.expect_err("empty override must be rejected");
assert!(matches!(err, RuntimeError::InvalidInput(_)));
}
#[tokio::test]
async fn create_note_with_embedding_content_rejects_non_prefix_override() {
let rt = rt();
let tok = NamespaceToken::local();
let err = rt
.create_note_with_embedding_content(
&tok,
"observation",
None,
"the actual content",
Some("an unrelated string"),
None,
None,
vec![],
)
.await
.expect_err("non-prefix override must be rejected");
assert!(matches!(err, RuntimeError::InvalidInput(ref m) if m.contains("prefix")));
}
#[tokio::test]
async fn create_note_with_embedding_content_rejects_equal_length_override() {
let rt = rt();
let tok = NamespaceToken::local();
let err = rt
.create_note_with_embedding_content(
&tok,
"observation",
None,
"identical text",
Some("identical text"),
None,
None,
vec![],
)
.await
.expect_err("an equal-length override must be rejected as not a proper prefix");
assert!(matches!(err, RuntimeError::InvalidInput(ref m) if m.contains("prefix")));
}
#[tokio::test]
async fn create_note_with_embedding_content_rejects_secret_bearing_override() {
let rt = rt();
let tok = NamespaceToken::local();
let token_span = "ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
let content = format!("{token_span} plus extra trailing content beyond the override");
let embedding_content = format!("{token_span} plus extra");
let err = rt
.create_note_with_embedding_content(
&tok,
"observation",
None,
&content,
Some(&embedding_content),
None,
None,
vec![],
)
.await
.expect_err("a credential-shaped override must fail the secret gate");
assert!(
matches!(err, RuntimeError::SecretDetected(_)),
"expected SecretDetected, got {err:?}"
);
let count = rt
.notes(&tok)
.unwrap()
.count_notes(tok.namespace().as_str(), None)
.await
.unwrap();
assert_eq!(count, 0, "a rejected create must leave no note behind");
}
const ADR002_EXCLUDED_SUBSECTIONS: &[&str] = &["Annotation relation", "KG pack extensions"];
fn parse_adr002_base_entity_endpoint_matrix(
) -> std::collections::HashSet<(String, EdgeRelation, String)> {
let adr_path = format!(
"{}/../../docs/adr/ADR-002-edge-ontology.md",
env!("CARGO_MANIFEST_DIR")
);
let text = std::fs::read_to_string(&adr_path)
.unwrap_or_else(|e| panic!("failed to read {adr_path}: {e}"));
let lines: Vec<&str> = text.lines().collect();
let start = lines
.iter()
.position(|l| l.trim() == "### Base endpoint contract")
.expect("ADR-002 must contain a '### Base endpoint contract' heading");
let end = lines[start + 1..]
.iter()
.position(|l| l.starts_with("## "))
.map(|i| start + 1 + i)
.unwrap_or(lines.len());
let section = &lines[start..end];
let mut triples = std::collections::HashSet::new();
let mut excluded = false;
for line in section {
let trimmed = line.trim();
if let Some(title) = trimmed.strip_prefix("#### ") {
excluded = ADR002_EXCLUDED_SUBSECTIONS
.iter()
.any(|ex| title.starts_with(ex));
continue;
}
if excluded || !trimmed.starts_with('|') {
continue;
}
let cells: Vec<&str> = trimmed
.trim_matches('|')
.split('|')
.map(|c| c.trim())
.collect();
if cells.len() != 3 {
continue;
}
let is_header = cells[0].eq_ignore_ascii_case("Source");
let is_separator = cells
.iter()
.all(|c| !c.is_empty() && c.chars().all(|ch| ch == '-'));
if is_header || is_separator {
continue;
}
let src_raw = cells[0].trim_matches('`');
let rel_raw = cells[1].trim_matches('`');
let tgt_raw = cells[2].trim_matches('`');
let relation: EdgeRelation = rel_raw.parse().unwrap_or_else(|_| {
panic!("ADR-002 base endpoint contract row has unparseable relation: {trimmed:?}")
});
let src = if src_raw.eq_ignore_ascii_case("any entity") {
"*".to_string()
} else {
src_raw.to_ascii_lowercase()
};
let tgt = tgt_raw.to_ascii_lowercase();
triples.insert((src, relation, tgt));
}
triples
}
#[test]
fn base_entity_endpoint_rules_match_adr002_base_endpoint_contract() {
let workspace_manifest = format!("{}/../Cargo.toml", env!("CARGO_MANIFEST_DIR"));
if !std::path::Path::new(&workspace_manifest).exists() {
eprintln!(
"skipping ADR-002 conformance: workspace manifest not present \
(published-package context); the check runs in the repository"
);
return;
}
let adr_matrix = parse_adr002_base_entity_endpoint_matrix();
assert!(
!adr_matrix.is_empty(),
"parsed zero rows from ADR-002's base endpoint contract — parser or heading drift"
);
let runtime_rules: std::collections::HashSet<(String, EdgeRelation, String)> =
base_entity_endpoint_rules()
.iter()
.map(|(src, rel, tgt)| (src.to_string(), *rel, tgt.to_string()))
.collect();
let missing_from_runtime: Vec<_> = adr_matrix.difference(&runtime_rules).collect();
let extra_in_runtime: Vec<_> = runtime_rules.difference(&adr_matrix).collect();
assert!(
missing_from_runtime.is_empty() && extra_in_runtime.is_empty(),
"BASE_ENTITY_ENDPOINT_RULES has drifted from ADR-002's base endpoint contract.\n\
In the ADR but not enforced by the runtime: {missing_from_runtime:#?}\n\
Enforced by the runtime but not in the ADR: {extra_in_runtime:#?}"
);
}
fn reserved_key_props() -> serde_json::Value {
serde_json::json!({"khive:secret_gate": "exempted:content-sha256-manifest-v1"})
}
#[tokio::test]
async fn create_entity_rejects_reserved_secret_gate_key() {
let rt = rt();
let tok = NamespaceToken::local();
let err = rt
.create_entity(
&tok,
"concept",
None,
"reserved-key-entity",
None,
Some(reserved_key_props()),
vec![],
)
.await
.expect_err("caller-supplied reserved key must be rejected");
assert!(
matches!(err, RuntimeError::InvalidInput(ref msg) if msg.contains("khive:secret_gate")),
"unexpected error: {err:?}"
);
let found = rt
.list_entities(&tok, Some("concept"), None, 10, 0)
.await
.expect("list must succeed");
assert!(
found.iter().all(|e| e.name != "reserved-key-entity"),
"rejected create must leave no row behind"
);
}
#[tokio::test]
async fn create_note_rejects_reserved_secret_gate_key() {
let rt = rt();
let tok = NamespaceToken::local();
let err = rt
.create_note(
&tok,
"observation",
None,
"reserved-key note content",
None,
Some(reserved_key_props()),
vec![],
)
.await
.expect_err("caller-supplied reserved key must be rejected");
assert!(
matches!(err, RuntimeError::InvalidInput(ref msg) if msg.contains("khive:secret_gate")),
"unexpected error: {err:?}"
);
}
#[tokio::test]
async fn only_the_web_receipt_writer_can_establish_provenance() {
let rt = rt();
let tok = NamespaceToken::local();
let forged = serde_json::json!({
"tags": ["web.receipt"],
"request": {"verb": "web.fetch"},
"khive:web_receipt": "v1",
});
let error = rt
.create_note(
&tok,
"observation",
None,
"forged",
None,
Some(forged),
vec![],
)
.await
.expect_err("generic create must reject receipt provenance");
assert!(
matches!(error, RuntimeError::InvalidInput(message) if message.contains("khive:web_receipt"))
);
let receipt = rt
.create_web_receipt_note(
&tok,
"web.fetch",
serde_json::json!({"verb": "web.fetch"}),
vec![],
)
.await
.expect("web writer must publish provenance with its receipt");
assert_eq!(
receipt.properties.as_ref().unwrap()["khive:web_receipt"],
"v1"
);
let error = rt
.update_note(
&tok,
receipt.id,
crate::curation::NotePatch {
properties: Some(serde_json::json!({"request": {"verb": "web.refresh"}})),
..Default::default()
},
)
.await
.expect_err("generic update must not rewrite a trusted receipt");
assert!(
matches!(error, RuntimeError::InvalidInput(message) if message.contains("web receipt"))
);
}
#[tokio::test]
async fn create_many_rejects_reserved_secret_gate_key_atomically() {
let rt = rt();
let tok = NamespaceToken::local();
let specs = vec![
EntityCreateSpec {
kind: "concept".to_string(),
entity_type: None,
name: "clean-entity".to_string(),
description: None,
properties: None,
tags: vec![],
},
EntityCreateSpec {
kind: "concept".to_string(),
entity_type: None,
name: "reserved-key-entity".to_string(),
description: None,
properties: Some(reserved_key_props()),
tags: vec![],
},
];
let err = rt
.create_many(&tok, specs)
.await
.expect_err("a reserved key anywhere in the batch must reject the whole batch");
assert!(
matches!(err, RuntimeError::InvalidInput(ref msg) if msg.contains("khive:secret_gate")),
"unexpected error: {err:?}"
);
let found = rt
.list_entities(&tok, Some("concept"), None, 10, 0)
.await
.expect("list must succeed");
assert!(
found.is_empty(),
"batch rejection must leave zero rows behind, found: {found:?}"
);
}
#[tokio::test]
async fn create_entity_with_attachments_commits_roles_and_projects_content() {
use khive_db::stores::blob::FsBlobStore;
use khive_storage::BlobStore as _;
let runtime = rt();
let token = NamespaceToken::local();
let blob_dir = tempfile::tempdir().expect("blob tempdir");
let blob_store =
Arc::new(FsBlobStore::new(blob_dir.path().to_path_buf(), 0).expect("blob store"));
let content_ref = blob_store
.put(b"bundle".to_vec())
.await
.expect("publish bundle");
let network_ref = blob_store
.put(b"network".to_vec())
.await
.expect("publish network");
runtime
.install_blob_store(blob_store)
.expect("install blob store");
let created = runtime
.create_entity_with_attachments(
&token,
"artifact",
None,
"role-keyed artifact",
None,
None,
vec![],
vec![
NewAttachment {
role: "content".to_string(),
content_ref: content_ref.clone(),
media_type: Some("application/json".to_string()),
size_bytes: Some(6),
},
NewAttachment {
role: "fann-network".to_string(),
content_ref: network_ref.clone(),
media_type: Some("application/octet-stream".to_string()),
size_bytes: Some(7),
},
],
)
.await
.expect("atomic record + attachments publication");
assert_eq!(created.content_ref.as_deref(), Some(content_ref.as_str()));
let reloaded = runtime
.get_entity(&token, created.id)
.await
.expect("reload entity");
assert_eq!(reloaded.content_ref.as_deref(), Some(content_ref.as_str()));
let attachments = runtime
.attachments()
.expect("main attachment store")
.list_attachments(created.id)
.await
.expect("list attachments");
assert_eq!(attachments.len(), 2);
assert_eq!(attachments[0].role, "content");
assert_eq!(attachments[0].content_ref, content_ref);
assert_eq!(attachments[1].role, "fann-network");
assert_eq!(attachments[1].content_ref, network_ref);
}
#[tokio::test]
async fn create_entity_with_attachments_discloses_truncated_embedding() {
use khive_db::stores::blob::FsBlobStore;
use khive_storage::BlobStore as _;
let runtime = rt();
let token = NamespaceToken::local();
runtime.register_embedder(CapturingVecProvider {
provider_name: "strict-length-test".into(),
dims: 4,
captured: Arc::new(std::sync::Mutex::new(Vec::new())),
});
let blob_dir = tempfile::tempdir().unwrap();
let blob_store = Arc::new(FsBlobStore::new(blob_dir.path().to_path_buf(), 0).unwrap());
let content_ref = blob_store.put(b"bundle".to_vec()).await.unwrap();
runtime.install_blob_store(blob_store).unwrap();
let attachment = NewAttachment {
role: "content".to_string(),
content_ref,
media_type: None,
size_bytes: Some(6),
};
let description = "d".repeat(MAX_TEXT_BYTES + 1);
let (reported, report) = runtime
.create_entity_with_attachments_and_report(
&token,
"artifact",
None,
"reported artifact",
Some(&description),
None,
vec![],
vec![attachment.clone()],
)
.await
.expect("report-aware attachment create succeeds");
assert_eq!(reported.description.as_deref(), Some(description.as_str()));
assert_eq!(report.truncated, 1);
assert_eq!(
report.discarded_bytes,
("reported artifact ".len() + 1) as u64
);
let error = runtime
.create_entity_with_attachments(
&token,
"artifact",
None,
"legacy artifact",
Some(&description),
None,
vec![],
vec![attachment],
)
.await
.expect_err("legacy attachment create must disclose truncation");
let id = assert_embedding_truncation_after_commit(
error,
"create_entity_with_attachments",
("legacy artifact ".len() + 1) as u64,
);
let stored = runtime.get_entity(&token, id).await.unwrap();
assert_eq!(stored.description.as_deref(), Some(description.as_str()));
}
#[tokio::test]
async fn create_entity_with_attachments_reports_failed_created_event_after_commit() {
use khive_db::stores::blob::FsBlobStore;
use khive_storage::BlobStore as _;
let runtime = rt();
let token = NamespaceToken::local();
let blob_dir = tempfile::tempdir().expect("blob tempdir");
let blob_store =
Arc::new(FsBlobStore::new(blob_dir.path().to_path_buf(), 0).expect("blob store"));
let content_ref = blob_store
.put(b"bundle".to_vec())
.await
.expect("publish bundle");
let network_ref = blob_store
.put(b"network".to_vec())
.await
.expect("publish network");
runtime
.install_blob_store(blob_store)
.expect("install blob store");
let mut writer = runtime.sql().writer().await.unwrap();
writer
.execute_script(
"CREATE TRIGGER reject_attachment_created_event BEFORE INSERT ON events \
WHEN NEW.kind = 'entity_created' \
BEGIN SELECT RAISE(ABORT, 'injected attachment created-event failure'); END;"
.into(),
)
.await
.unwrap();
drop(writer);
let error = runtime
.create_entity_with_attachments(
&token,
"artifact",
None,
"artifact with failed created event",
None,
None,
vec![],
vec![
NewAttachment {
role: "content".to_string(),
content_ref: content_ref.clone(),
media_type: Some("application/json".to_string()),
size_bytes: Some(6),
},
NewAttachment {
role: "fann-network".to_string(),
content_ref: network_ref.clone(),
media_type: Some("application/octet-stream".to_string()),
size_bytes: Some(7),
},
],
)
.await
.expect_err("the committed entity must report the failed event append");
let RuntimeError::Khive(domain) = error.refusal_source() else {
panic!("attachment create lost its typed post-commit error: {error:?}");
};
assert_eq!(domain.kind(), khive_types::ErrorKind::Internal);
let details = domain.details().expect("post-commit details");
assert_eq!(details.get("reason"), Some("post_commit_degraded"));
assert_eq!(
details.get("operation"),
Some("create_entity_with_attachments")
);
assert_eq!(details.get("committed"), Some("true"));
assert_eq!(details.get("retryable"), Some("false"));
let entity_id = details
.get("record_id")
.expect("committed entity id")
.parse::<Uuid>()
.expect("canonical entity id");
let degradations: serde_json::Value = serde_json::from_str(
details
.get("post_commit_degradations")
.expect("complete degradation list"),
)
.expect("degradations are JSON");
let failures = degradations.as_array().expect("degradation array");
assert_eq!(failures.len(), 1);
assert_eq!(failures[0]["stage"], "event_append");
assert!(failures[0]["error"]
.as_str()
.is_some_and(|message| message.contains("injected attachment created-event failure")));
let stored = runtime
.get_entity(&token, entity_id)
.await
.expect("entity row committed");
assert_eq!(stored.id, entity_id);
assert_eq!(stored.content_ref.as_deref(), Some(content_ref.as_str()));
let attachments = runtime
.attachments()
.expect("main attachment store")
.list_attachments(entity_id)
.await
.expect("attachment rows committed");
assert_eq!(attachments.len(), 2);
assert_eq!(attachments[0].role, "content");
assert_eq!(attachments[0].content_ref, content_ref);
assert_eq!(attachments[1].role, "fann-network");
assert_eq!(attachments[1].content_ref, network_ref);
let events = runtime
.list_events(
&token,
EventFilter {
target_id: Some(entity_id),
kinds: vec![EventKind::EntityCreated],
..Default::default()
},
PageRequest::default(),
)
.await
.expect("query created events");
assert!(events.items.is_empty());
}